@zhushanwen/subagent-core 0.2.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -2
- package/agents/analyst.md +60 -0
- package/agents/coder.md +69 -0
- package/agents/debugger.md +66 -0
- package/agents/doc-reviewer.md +49 -0
- package/agents/explorer.md +63 -0
- package/agents/general-purpose.md +32 -0
- package/agents/orchestrator.md +61 -0
- package/agents/planner.md +53 -0
- package/agents/researcher.md +67 -0
- package/agents/reviewer.md +73 -0
- package/dist/chunk-43ONBFZX.js +240 -0
- package/dist/chunk-APZY4IME.js +27 -0
- package/dist/chunk-V3VHZ2VX.js +59 -0
- package/dist/execution/engine/engines/zcode/constants.cjs +62 -9
- package/dist/execution/engine/engines/zcode/constants.d.cts +116 -12
- package/dist/execution/engine/engines/zcode/constants.d.ts +116 -12
- package/dist/execution/engine/engines/zcode/constants.js +39 -7
- package/dist/execution/engine/engines/zcode/reader.d.cts +2 -1
- package/dist/execution/engine/engines/zcode/reader.d.ts +2 -1
- package/dist/execution/engine/engines/zcode/reader.js +4 -234
- package/dist/execution/engine/paths.js +7 -19
- package/dist/index.cjs +18234 -1277
- package/dist/index.d.cts +5646 -673
- package/dist/index.d.ts +5646 -673
- package/dist/index.js +17828 -1277
- package/dist/{types-BxyAidGf.d.cts → types-Dv4QhSJ_.d.cts} +678 -112
- package/dist/{types-BxyAidGf.d.ts → types-Dv4QhSJ_.d.ts} +678 -112
- package/package.json +7 -4
- package/src/__tests__/agent-opts-resolver-schema-prompt.test.ts +0 -1
- package/src/__tests__/append-system-prompt-assembly.test.ts +8 -7
- package/src/__tests__/fr4-get-state-handshake.test.ts +2 -2
- package/src/__tests__/m2-append-content-probe.test.ts +0 -1
- package/src/__tests__/manifest-store.test.ts +10 -9
- package/src/__tests__/record-store-cache.test.ts +0 -2
- package/src/__tests__/record-store-index.test.ts +1 -2
- package/src/__tests__/review-fix-loop-script.test.ts +93 -3
- package/src/__tests__/review-fix-loop-utils.test.ts +31 -10
- package/src/__tests__/robustness-low-batch1.test.ts +6 -5
- package/src/__tests__/robustness-medium-batch1.test.ts +2 -2
- package/src/__tests__/robustness-medium-batch2.test.ts +17 -6
- package/src/__tests__/robustness-medium-batch4.test.ts +2 -2
- package/src/__tests__/session-runner.test.ts +1 -1
- package/src/__tests__/smoke.test.ts +9 -2
- package/src/core/__tests__/host-services.test.ts +2 -2
- package/src/core/__tests__/logger.test.ts +1 -1
- package/src/core/error-message.ts +9 -0
- package/src/core/host-services.ts +21 -5
- package/src/core/notify-ports.ts +18 -5
- package/src/execution/__tests__/agent-profile.test.ts +232 -0
- package/src/execution/__tests__/agent-registry.test.ts +370 -0
- package/src/execution/__tests__/agent-result-mapper.test.ts +18 -2
- package/src/execution/__tests__/agents-assembly.test.ts +219 -0
- package/src/execution/__tests__/ask-user-transit-e2e.test.ts +6 -6
- package/src/execution/__tests__/chat-engine-routing.test.ts +136 -30
- package/src/execution/__tests__/chatmode-first-round-closure-spawn.test.ts +5 -4
- package/src/execution/__tests__/cold-resurrect.test.ts +283 -0
- package/src/execution/__tests__/create-concurrency-pool.test.ts +194 -0
- package/src/execution/__tests__/delivery-methods.test.ts +77 -62
- package/src/execution/__tests__/descendant-sweep-guards.test.ts +220 -0
- package/src/execution/__tests__/descendant-sweep.test.ts +269 -0
- package/src/execution/__tests__/dialog-queue.test.ts +199 -1
- package/src/execution/__tests__/engine-model-validation.test.ts +741 -0
- package/src/execution/__tests__/epipe-fallback.test.ts +31 -24
- package/src/execution/__tests__/execute-and-await-worktree.test.ts +21 -91
- package/src/execution/__tests__/execute-nesting.test.ts +34 -188
- package/src/execution/__tests__/execution-record.test.ts +217 -60
- package/src/execution/__tests__/execution-runtime-face.test.ts +272 -0
- package/src/execution/__tests__/explicit-agent-ref-guard.test.ts +3 -3
- package/src/execution/__tests__/finalize-record.test.ts +278 -16
- package/src/execution/__tests__/gc-timer.test.ts +57 -2
- package/src/execution/__tests__/get-record-for-action-restart.test.ts +14 -12
- package/src/execution/__tests__/get-state-handshake.test.ts +127 -0
- package/src/execution/__tests__/helpers/mock-extension-api.ts +1 -1
- package/src/execution/__tests__/helpers/session-runner-mocks.ts +113 -0
- package/src/execution/__tests__/helpers/spawn-mock.ts +150 -3
- package/src/execution/__tests__/helpers/subagent-service-mocks.ts +137 -0
- package/src/execution/__tests__/keep-alive-no-progress.test.ts +280 -0
- package/src/execution/__tests__/kill-all-escalation.test.ts +196 -0
- package/src/execution/__tests__/lifecycle-manager-idle-timer-identity.test.ts +117 -0
- package/src/execution/__tests__/lifecycle-manager.test.ts +39 -0
- package/src/execution/__tests__/lifecycle-predicates.test.ts +1 -2
- package/src/execution/__tests__/manifest-parentid.test.ts +4 -4
- package/src/execution/__tests__/manifest-store-tmp-recovery.test.ts +99 -0
- package/src/execution/__tests__/max-turns-to-watchdog-ms.test.ts +113 -0
- package/src/execution/__tests__/model-resolver.test.ts +2 -2
- package/src/execution/__tests__/nested-visibility-env-propagation.test.ts +23 -121
- package/src/execution/__tests__/nested-visibility.test.ts +2 -0
- package/src/execution/__tests__/notify-ledger.test.ts +1249 -0
- package/src/execution/__tests__/output-collector.test.ts +117 -6
- package/src/execution/__tests__/pi-invocation.test.ts +1 -1
- package/src/execution/__tests__/record-store-orphan-revive.test.ts +159 -0
- package/src/execution/__tests__/record-store.test.ts +89 -5
- package/src/execution/__tests__/records-cwd-isolation.test.ts +2 -2
- package/src/execution/__tests__/recursive-visibility-baseline.test.ts +29 -132
- package/src/execution/__tests__/recursive-visibility-env.test.ts +55 -179
- package/src/execution/__tests__/rpc-mode.test.ts +2 -2
- package/src/execution/__tests__/run-and-finalize-anchoring.test.ts +387 -0
- package/src/execution/__tests__/run-and-finalize-chatmode.test.ts +19 -9
- package/src/execution/__tests__/run-spawn-chatmode-settled.test.ts +5 -4
- package/src/execution/__tests__/run-spawn-edges.test.ts +418 -26
- package/src/execution/__tests__/run-spawn-integration.test.ts +3 -3
- package/src/execution/__tests__/run-spawn-resume.test.ts +2 -2
- package/src/execution/__tests__/run-spawn-rpc-mode.test.ts +2 -2
- package/src/execution/__tests__/run-spawn-stdout-callback-throw.test.ts +16 -8
- package/src/execution/__tests__/service-kill-escalation.test.ts +91 -0
- package/src/execution/__tests__/session-context-resolver.test.ts +0 -2
- package/src/execution/__tests__/session-file-gc.test.ts +35 -0
- package/src/execution/__tests__/session-pending.test.ts +471 -0
- package/src/execution/__tests__/session-reconstructor.test.ts +366 -15
- package/src/execution/__tests__/session-runner-branch-cache-lru.test.ts +110 -0
- package/src/execution/__tests__/session-runner-close-prune.test.ts +181 -0
- package/src/execution/__tests__/session-runner-dispatch.test.ts +489 -0
- package/src/execution/__tests__/session-runner-epipe.test.ts +4 -3
- package/src/execution/__tests__/session-runner-heartbeat-idle-fallback.test.ts +227 -0
- package/src/execution/__tests__/session-runner-lifecycle-helpers.test.ts +290 -0
- package/src/execution/__tests__/session-runner-schema-env.test.ts +7 -5
- package/src/execution/__tests__/settled-watchdog.test.ts +524 -0
- package/src/execution/__tests__/spawn-args.test.ts +11 -10
- package/src/execution/__tests__/spawn-event-adapter-rpc.test.ts +26 -23
- package/src/execution/__tests__/spawn-event-adapter.test.ts +1 -1
- package/src/execution/__tests__/spawn-worktree-guidance.test.ts +5 -4
- package/src/execution/__tests__/spawned-children.test.ts +155 -11
- package/src/execution/__tests__/start-sync-model-guard.test.ts +4 -4
- package/src/execution/__tests__/stdin-writer.test.ts +6 -4
- package/src/execution/__tests__/stream-sink-retirement.test.ts +6 -6
- package/src/execution/__tests__/subagent-actions-core.test.ts +1017 -0
- package/src/execution/__tests__/subagent-service-message-close.test.ts +99 -35
- package/src/execution/__tests__/subagent-service-multiproc-guard.test.ts +174 -0
- package/src/execution/__tests__/subagent-service-notify-gate.test.ts +271 -0
- package/src/execution/__tests__/subagent-service-parent-guard.test.ts +33 -29
- package/src/execution/__tests__/subagent-service-recovery-bounds.test.ts +341 -0
- package/src/execution/__tests__/subagent-service.test.ts +37 -30
- package/src/execution/__tests__/subprocess-agent-runner-routing.test.ts +88 -12
- package/src/execution/__tests__/subprocess-agent-runner-timeout.test.ts +75 -0
- package/src/execution/__tests__/subprocess-agent-runner.test.ts +22 -20
- package/src/execution/__tests__/temp-prompt.test.ts +1 -1
- package/src/execution/__tests__/timeout-integration.test.ts +13 -12
- package/src/execution/__tests__/turn-limiter-semantics.test.ts +1 -1
- package/src/execution/__tests__/turn-limiter.test.ts +1 -1
- package/src/execution/__tests__/ui-request-handler-factory.test.ts +107 -7
- package/src/execution/__tests__/ui-request-handler.test.ts +4 -3
- package/src/execution/__tests__/ui-request-queue.test.ts +167 -2
- package/src/execution/__tests__/worktree-git-ops.test.ts +571 -0
- package/src/execution/__tests__/worktree-manager.test.ts +14 -5
- package/src/execution/__tests__/worktree-pid-registration.integration.test.ts +4 -3
- package/src/execution/__tests__/worktree-reconcile-aging.test.ts +204 -0
- package/src/execution/agent-registry.ts +298 -30
- package/src/execution/agent-result-mapper.ts +3 -0
- package/src/execution/agents-assembly.ts +88 -0
- package/src/execution/cold-resurrect.ts +199 -0
- package/src/execution/concurrency-pool.ts +71 -9
- package/src/execution/dialog-queue.ts +101 -3
- package/src/execution/engine/__tests__/common/capability-gate.test.ts +91 -0
- package/src/execution/engine/__tests__/common/data-dir.test.ts +1 -1
- package/src/execution/engine/__tests__/common/journal-wiring.test.ts +84 -0
- package/src/execution/engine/__tests__/common/kill-chain.test.ts +34 -7
- package/src/execution/engine/__tests__/common/persona-router.test.ts +4 -5
- package/src/execution/engine/__tests__/common/pool-manager.test.ts +211 -51
- package/src/execution/engine/__tests__/common/session-view-service-zcode-dbpath.test.ts +149 -0
- package/src/execution/engine/__tests__/common/session-view-service.test.ts +499 -0
- package/src/execution/engine/__tests__/conformance/contract.abort.test.ts +56 -99
- package/src/execution/engine/__tests__/conformance/contract.agent-events.test.ts +88 -15
- package/src/execution/engine/__tests__/conformance/engine-conformance.live.test.ts +33 -7
- package/src/execution/engine/__tests__/conformance/golden-replay.pi.test.ts +1 -1
- package/src/execution/engine/__tests__/conformance/golden-replay.zcode.test.ts +75 -54
- package/src/execution/engine/__tests__/conformance/zcode-appserver-harness.ts +128 -0
- package/src/execution/engine/__tests__/engine-discovery.test.ts +15 -3
- package/src/execution/engine/__tests__/model-prompt.test.ts +15 -3
- package/src/execution/engine/__tests__/registry.test.ts +94 -3
- package/src/execution/engine/__tests__/routing.test.ts +115 -6
- package/src/execution/engine/common/capability-gate.ts +92 -0
- package/src/execution/engine/common/journal-wiring.ts +97 -0
- package/src/execution/engine/common/kill-chain.ts +42 -10
- package/src/execution/engine/common/nesting-guard.ts +66 -0
- package/src/execution/engine/common/persona-router.ts +18 -8
- package/src/execution/engine/common/pool-manager.ts +332 -78
- package/src/execution/engine/common/session-view-service.ts +506 -0
- package/src/execution/engine/common/session-view-types.ts +125 -0
- package/src/execution/engine/engine-discovery.ts +12 -16
- package/src/execution/engine/engines/pi/__tests__/pi-engine.test.ts +174 -28
- package/src/execution/engine/engines/pi/__tests__/spawn-opts-direct.test.ts +257 -0
- package/src/execution/{argv-mirror.ts → engine/engines/pi/argv-mirror.ts} +43 -21
- package/src/execution/{get-state-handshake.ts → engine/engines/pi/get-state-handshake.ts} +86 -11
- package/src/execution/{output-collector.ts → engine/engines/pi/output-collector.ts} +108 -19
- package/src/execution/engine/engines/pi/pi-engine.ts +434 -38
- package/src/execution/{pi-invocation.ts → engine/engines/pi/pi-invocation.ts} +2 -2
- package/src/execution/engine/engines/pi/reader.ts +11 -0
- package/src/execution/engine/engines/pi/registration.ts +4 -2
- package/src/execution/engine/engines/pi/session-runner.ts +2840 -0
- package/src/execution/{spawn-event-adapter.ts → engine/engines/pi/spawn-event-adapter.ts} +75 -34
- package/src/execution/{stdin-writer.ts → engine/engines/pi/stdin-writer.ts} +10 -9
- package/src/execution/{temp-prompt.ts → engine/engines/pi/temp-prompt.ts} +2 -2
- package/src/execution/{turn-limiter.ts → engine/engines/pi/turn-limiter.ts} +1 -1
- package/src/execution/engine/engines/zcode/__tests__/__fixtures__/fake-appserver.mjs +301 -0
- package/src/execution/engine/engines/zcode/__tests__/__fixtures__/zcode-golden-appserver.json +36 -0
- package/src/execution/engine/engines/zcode/__tests__/appserver-launcher.test.ts +321 -0
- package/src/execution/engine/engines/zcode/__tests__/connection.test.ts +473 -0
- package/src/execution/engine/engines/zcode/__tests__/parser.test.ts +7 -210
- package/src/execution/engine/engines/zcode/__tests__/preparer.test.ts +6 -110
- package/src/execution/engine/engines/zcode/__tests__/session-channel-dispose-harvest.test.ts +247 -0
- package/src/execution/engine/engines/zcode/__tests__/session-channel-turn-timers.test.ts +451 -0
- package/src/execution/engine/engines/zcode/__tests__/session-channel.test.ts +792 -0
- package/src/execution/engine/engines/zcode/__tests__/zcode-engine-appserver.test.ts +782 -0
- package/src/execution/engine/engines/zcode/__tests__/zcode-engine-degrade.test.ts +168 -0
- package/src/execution/engine/engines/zcode/__tests__/zcode-engine-dispose.test.ts +210 -0
- package/src/execution/engine/engines/zcode/__tests__/zcode-engine-retry.test.ts +353 -0
- package/src/execution/engine/engines/zcode/__tests__/zcode-engine-status.test.ts +282 -0
- package/src/execution/engine/engines/zcode/__tests__/zcode-engine-timeout.test.ts +372 -0
- package/src/execution/engine/engines/zcode/__tests__/zcode-engine.live.test.ts +65 -47
- package/src/execution/engine/engines/zcode/appserver-launcher.ts +167 -0
- package/src/execution/engine/engines/zcode/connection.ts +598 -0
- package/src/execution/engine/engines/zcode/constants.ts +147 -14
- package/src/execution/engine/engines/zcode/golden-sample.ts +42 -34
- package/src/execution/engine/engines/zcode/parser.ts +12 -346
- package/src/execution/engine/engines/zcode/preparer.ts +22 -150
- package/src/execution/engine/engines/zcode/session-channel.ts +968 -0
- package/src/execution/engine/engines/zcode/zcode-engine.ts +1023 -250
- package/src/execution/engine/host-task-spec.ts +32 -36
- package/src/execution/engine/model-validation.ts +177 -0
- package/src/execution/engine/port.ts +65 -7
- package/src/execution/engine/registry.ts +53 -0
- package/src/execution/engine/routing.ts +75 -1
- package/src/execution/engine/types.ts +30 -103
- package/src/execution/execution-record.ts +222 -144
- package/src/execution/finalize-record.ts +181 -85
- package/src/execution/idle-gc.ts +28 -0
- package/src/execution/lifecycle-manager.ts +27 -3
- package/src/execution/lifecycle-predicates.ts +1 -1
- package/src/execution/manifest-store.ts +53 -62
- package/src/execution/notifier.ts +10 -10
- package/src/execution/notify-host.ts +212 -0
- package/src/execution/notify-ledger.ts +117 -16
- package/src/execution/record-entry.ts +8 -2
- package/src/execution/record-store.ts +148 -88
- package/src/execution/round-settlement.ts +93 -0
- package/src/execution/session-file-gc.ts +96 -62
- package/src/execution/session-pending.ts +213 -62
- package/src/execution/session-reconstructor.ts +256 -143
- package/src/execution/sessions-index.ts +127 -102
- package/src/execution/settled-watchdog.ts +311 -0
- package/src/execution/subagent-actions-core.ts +686 -0
- package/src/execution/subagent-service.ts +1106 -907
- package/src/execution/subprocess-agent-runner.ts +144 -97
- package/src/execution/types.ts +9 -0
- package/src/execution/ui-request-handler-factory.ts +126 -75
- package/src/execution/ui-request-queue.ts +50 -19
- package/src/execution/worktree-git-ops.ts +397 -0
- package/src/execution/worktree-manager.ts +72 -10
- package/src/execution/worktree-registry.ts +6 -12
- package/src/index.ts +542 -9
- package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +42 -3
- package/src/orchestration/__tests__/agent-call-catch-fallback.test.ts +3 -5
- package/src/orchestration/__tests__/agent-call-stream.test.ts +2 -2
- package/src/orchestration/__tests__/args-meta.test.ts +358 -0
- package/src/orchestration/__tests__/args-validator.test.ts +14 -0
- package/src/orchestration/__tests__/error-recovery-rebuild-failure.test.ts +312 -0
- package/src/orchestration/__tests__/error-recovery-terminal-hardening.test.ts +384 -0
- package/src/orchestration/__tests__/execute-agent-call.test.ts +151 -11
- package/src/orchestration/__tests__/file-run-store-prune.test.ts +168 -0
- package/src/orchestration/__tests__/file-run-store-throttle.test.ts +168 -0
- package/src/orchestration/__tests__/file-run-store.test.ts +390 -0
- package/src/orchestration/__tests__/helpers/flush-microtasks.ts +13 -0
- package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +34 -0
- package/src/orchestration/__tests__/lifecycle-abort-broadcast-signal.test.ts +357 -0
- package/src/orchestration/__tests__/lifecycle-recover-crashed.test.ts +289 -0
- package/src/orchestration/__tests__/lifecycle-runid-injection.test.ts +3 -3
- package/src/orchestration/__tests__/lifecycle.test.ts +84 -5
- package/src/orchestration/__tests__/non-cloneable-return-e2e.test.ts +1 -1
- package/src/orchestration/__tests__/run-snapshot.test.ts +356 -0
- package/src/orchestration/__tests__/script-generate.test.ts +314 -0
- package/src/orchestration/__tests__/script-lint.test.ts +17 -0
- package/src/orchestration/__tests__/test-mocks.ts +2 -0
- package/src/orchestration/__tests__/worker-exit-without-result.test.ts +7 -6
- package/src/orchestration/__tests__/worker-message-pump-finalize-run.test.ts +229 -0
- package/src/orchestration/__tests__/{error-recovery-handlers.test.ts → worker-message-pump-handlers.test.ts} +28 -27
- package/src/orchestration/__tests__/{error-recovery-postmessage-defense.test.ts → worker-message-pump-postmessage-defense.test.ts} +8 -8
- package/src/orchestration/__tests__/{error-recovery-serialize-failed-result.test.ts → worker-message-pump-serialize-failed-result.test.ts} +2 -2
- package/src/orchestration/__tests__/{error-recovery-workflow-call.test.ts → worker-message-pump-workflow-call.test.ts} +1 -1
- package/src/orchestration/__tests__/worker-pending-timeout-abort.test.ts +275 -0
- package/src/orchestration/__tests__/worker-script-builder-runtime.test.ts +78 -1
- package/src/orchestration/__tests__/worker-script-builder.test.ts +1 -1
- package/src/orchestration/__tests__/workflow-files.test.ts +186 -0
- package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +1 -1
- package/src/orchestration/__tests__/workflow-run-summary.test.ts +119 -0
- package/src/orchestration/__tests__/workflow-script-registry-impl.test.ts +124 -0
- package/src/orchestration/agent-opts-resolver.ts +8 -8
- package/src/orchestration/args-meta.ts +198 -0
- package/src/orchestration/args-validator.ts +53 -31
- package/src/orchestration/execute-agent-call.ts +25 -79
- package/src/orchestration/file-run-store.ts +327 -0
- package/src/orchestration/launcher.ts +160 -104
- package/src/orchestration/lifecycle.ts +371 -97
- package/src/orchestration/models/agent-call.ts +7 -7
- package/src/orchestration/models/budget.ts +5 -5
- package/src/orchestration/models/ports.ts +4 -4
- package/src/orchestration/models/run-runtime.ts +15 -15
- package/src/orchestration/models/trace.ts +9 -9
- package/src/orchestration/models/types.ts +111 -22
- package/src/orchestration/models/workflow-run.ts +28 -28
- package/src/orchestration/models/workflow-script.ts +4 -4
- package/src/orchestration/run-snapshot.ts +266 -0
- package/src/orchestration/script-generate.ts +154 -0
- package/src/orchestration/script-lint.ts +123 -90
- package/src/orchestration/worker-handle.ts +10 -10
- package/src/orchestration/worker-host.ts +10 -10
- package/src/orchestration/{error-recovery.ts → worker-message-pump.ts} +498 -221
- package/src/orchestration/worker-script-builder.ts +401 -346
- package/src/orchestration/workflow-files.ts +37 -11
- package/src/orchestration/workflow-run-summary.ts +69 -0
- package/src/orchestration/workflow-script-registry-impl.ts +31 -9
- package/src/shared/__tests__/agent-ref.test.ts +209 -2
- package/src/shared/__tests__/atomic-write.test.ts +267 -0
- package/src/shared/__tests__/bounded-serialize.test.ts +236 -0
- package/src/shared/__tests__/injection-render.test.ts +518 -0
- package/src/shared/__tests__/meta-parser.test.ts +151 -0
- package/src/shared/__tests__/resource-discovery-host-roots.test.ts +474 -0
- package/src/shared/__tests__/resource-discovery.test.ts +261 -217
- package/src/shared/agent-ref.ts +143 -2
- package/src/shared/atomic-write.ts +323 -0
- package/src/shared/bounded-serialize.ts +154 -0
- package/src/shared/injection-render.ts +279 -0
- package/src/shared/meta-parser.ts +182 -68
- package/src/shared/model-ref.ts +15 -2
- package/src/shared/resource-discovery.ts +97 -204
- package/src/shared/resource-meta.ts +14 -0
- package/src/shared/schema-jsonify.ts +1 -1
- package/src/shared/xml-injection.ts +9 -9
- package/workflows/README.md +9 -9
- package/workflows/chain.js +4 -2
- package/workflows/map-reduce.js +5 -3
- package/workflows/parallel.js +5 -3
- package/workflows/review-fix-loop-utils.cjs +165 -92
- package/workflows/review-fix-loop-utils.d.cts +287 -0
- package/workflows/review-fix-loop.js +18 -7
- package/workflows/scatter-gather.js +4 -2
- package/dist/chunk-3VOERJPJ.js +0 -22
- package/src/execution/__tests__/execute-options-mapper.test.ts +0 -200
- package/src/execution/engine/engines/pi/__tests__/task-spec-mapper.test.ts +0 -164
- package/src/execution/engine/engines/pi/task-spec-mapper.ts +0 -100
- package/src/execution/engine/engines/zcode/__tests__/__fixtures__/zcode-golden-spawn.json +0 -39
- package/src/execution/engine/engines/zcode/__tests__/launcher.test.ts +0 -150
- package/src/execution/engine/engines/zcode/__tests__/zcode-engine.test.ts +0 -580
- package/src/execution/engine/engines/zcode/launcher.ts +0 -161
- package/src/execution/execute-options-mapper.ts +0 -115
- package/src/execution/session-runner.ts +0 -1789
- package/src/shared/__tests__/resource-discovery-manifest-cache.test.ts +0 -288
|
@@ -0,0 +1,1249 @@
|
|
|
1
|
+
// src/execution/__tests__/notify-ledger.test.ts
|
|
2
|
+
//
|
|
3
|
+
// U2 B-ledger 单测族:通知账本与 courier 的四步生命周期
|
|
4
|
+
// (设计 docs/design/subagent-dispatch-reliability.md §3.3 D4/D5)。
|
|
5
|
+
// u-5c 迁自壳套件 src/__tests__/notify-ledger.test.ts(被测 module 是 core 件,
|
|
6
|
+
// 唯一测试覆盖原落壳——设计 §2.2 C6 / §1 目标 6)。
|
|
7
|
+
//
|
|
8
|
+
// 覆盖验收面:
|
|
9
|
+
// - 写账先于投递(顺序断言:appendEntry ledger entry 先于 sendMessage)
|
|
10
|
+
// - settled 边沿投递 + isIdle 二次复查命中 busy 时挂回 pending
|
|
11
|
+
// - 回执销账(ack entry 追加)后两列 entry 差集为空
|
|
12
|
+
// - 重启恢复(新实例扫账本)重放未销账号、已销账零重发
|
|
13
|
+
// - notifyId 幂等(重复 record / 重复投递条目可识别)
|
|
14
|
+
// - 120s 看门狗触发(fake timers;主 session 长期无 settled 兜底 + 超时重投)
|
|
15
|
+
// - 合并投递(同一边沿多条 pending 单条送达,content join + batch details)
|
|
16
|
+
// - compaction 降级(ledger/ack entry 被清除后按内存态补写)
|
|
17
|
+
// - session_start 恢复钩子(bindNotifyLedgerHost + recoverFromSession:
|
|
18
|
+
// mock session entries 含 ledger/ack entry → 重放差集)
|
|
19
|
+
// - notifier 四步接线(createNotifier + bindNotifyLedgerHost:notify → 写账 →
|
|
20
|
+
// 边沿投递 → 回执销账全链路)
|
|
21
|
+
//
|
|
22
|
+
// u-5c 迁移改写(core 依赖闭包禁 pi 系包,验收「零跨包 specifier」):
|
|
23
|
+
// - 投递内核由真实 @xyz-agent/session-delivery createDelivery 改为下方内联
|
|
24
|
+
// 内核等价桩(notifier 实际消费的内核行为切片:payload fail-fast / dedupe /
|
|
25
|
+
// busy gate / 空闲立即投 / 同步 send 失败 warn + 退避重试 / 批量 join 形态),
|
|
26
|
+
// 内核自身全量语义(合批窗口 / checked / watchdog 等)由 session-delivery
|
|
27
|
+
// 包自有测试守卫,此处锚定的是 notifier 对内核契约的消费面。
|
|
28
|
+
//
|
|
29
|
+
// 合并终态(feat 容器 + dev T4③ 增量):
|
|
30
|
+
// - 追加 T4③ 重投止损(PS-6)族:回执确认不可达时 attempts 上限 + abandoned
|
|
31
|
+
// 放弃终态(docs/design/subagent-core-unbounded-wait-audit.md §7.2 T4③ /
|
|
32
|
+
// §8.2 S-E);差集 helper 相应升级三列(ledger − ack − abandoned)。
|
|
33
|
+
// - logger 基建统一 dev 侧 configureCore sink(core logger facade 经
|
|
34
|
+
// host-services 动态解析宿主实现,见 agents-assembly.test.ts 同款)——
|
|
35
|
+
// 移除 u-5c 期的 vi.mock(core/logger),日志断言用例自行注入 logCalls sink。
|
|
36
|
+
|
|
37
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
38
|
+
|
|
39
|
+
import { configureNotifyDomain, resetNotifyDomainForTests } from "../../core/notify-ports.ts";
|
|
40
|
+
import type {
|
|
41
|
+
DeliveryConfig,
|
|
42
|
+
DeliveryHandle,
|
|
43
|
+
DeliveryMessage,
|
|
44
|
+
DeliveryPort,
|
|
45
|
+
} from "../../core/notify-ports.ts";
|
|
46
|
+
import { configureCore, resetCoreForTests } from "../../core/host-services.ts";
|
|
47
|
+
import {
|
|
48
|
+
bindNotifyLedgerHost,
|
|
49
|
+
createNotifyLedger,
|
|
50
|
+
getBoundNotifyLedger,
|
|
51
|
+
NOTIFY_ABANDONED_CUSTOM_TYPE,
|
|
52
|
+
NOTIFY_ACK_CUSTOM_TYPE,
|
|
53
|
+
NOTIFY_CUSTOM_TYPE,
|
|
54
|
+
NOTIFY_LEDGER_CUSTOM_TYPE,
|
|
55
|
+
NOTIFY_REDELIVERY_MAX_ATTEMPTS,
|
|
56
|
+
NOTIFY_WATCHDOG_MS,
|
|
57
|
+
_resetNotifyLedgerForTest,
|
|
58
|
+
type NotifyLedgerHost,
|
|
59
|
+
} from "../notify-ledger.ts";
|
|
60
|
+
import { createNotifier, type BgNotifyRecord, type NotifierHost } from "../notifier.ts";
|
|
61
|
+
|
|
62
|
+
// ─── 投递内核等价桩(u-5c:替代真实 session-delivery createDelivery) ──────
|
|
63
|
+
//
|
|
64
|
+
// 忠实复刻 notifier 消费面触及的内核行为(对照 packages/session-delivery
|
|
65
|
+
// src/delivery.ts 同名实现):
|
|
66
|
+
// - send():payload 能力 fail-fast → dedupe → 入队 → 无合批依赖时立即
|
|
67
|
+
// scheduleFlush(0)(mergeHoldActive()=false 路径,notifier 单条通知即时投)
|
|
68
|
+
// - busy gate:isIdle() + hasPendingMessages() 双条件,busy 时退避轮询
|
|
69
|
+
// (无 subscribeSettled 装配的形态——notifier 测试 host 均不注入 onAgentSettled)
|
|
70
|
+
// - attemptSend():同步 port.send(notifier port 契约返回 void = 受理成功);
|
|
71
|
+
// 同步抛错 → onSendFail → warn("port.send failed, retrying with backoff")
|
|
72
|
+
// + 退避重试,达上限终态 warn
|
|
73
|
+
// - buildBatchPayload():多条合批 content join "\n\n---\n\n" + batch details
|
|
74
|
+
// (与 ledger 侧合批形态同构,钉住跨件一致的两处 join 语义)
|
|
75
|
+
function createDelivery(port: DeliveryPort, options?: DeliveryConfig): DeliveryHandle {
|
|
76
|
+
const cfg = {
|
|
77
|
+
intent: options?.intent ?? ("interrupt-at-turn-boundary" as const),
|
|
78
|
+
mergeWindowMs: options?.mergeWindowMs ?? 0,
|
|
79
|
+
mergeHoldActive: options?.mergeHoldActive,
|
|
80
|
+
busyPolicy: options?.busyPolicy ?? ("retry-force" as const),
|
|
81
|
+
backoff: options?.backoff ?? { ms: 100, max: 50 },
|
|
82
|
+
warn: options?.warn ?? ((msg: string, err?: unknown) => { console.warn(`[session-delivery] ${msg}`, err ?? ""); }),
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const queue: DeliveryMessage[] = [];
|
|
86
|
+
let inflightBatch: DeliveryMessage[] = [];
|
|
87
|
+
let inFlight = false;
|
|
88
|
+
let sendAttempts = 0;
|
|
89
|
+
let backoffTimer: ReturnType<typeof setTimeout> | undefined;
|
|
90
|
+
let mergeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
91
|
+
let disposed = false;
|
|
92
|
+
const dedupSet = options?.dedupe ? new Set<string>() : null;
|
|
93
|
+
|
|
94
|
+
function buildBatchPayload(batch: DeliveryMessage[]): DeliveryMessage {
|
|
95
|
+
if (batch.length === 1) return batch[0]!;
|
|
96
|
+
const first = batch[0]!;
|
|
97
|
+
const contents = batch.map((m) => m.payload.content);
|
|
98
|
+
const content = contents.join("\n\n---\n\n");
|
|
99
|
+
if (first.payload.kind !== "custom") {
|
|
100
|
+
return { ...first, payload: { kind: "text", content } };
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
...first,
|
|
104
|
+
payload: {
|
|
105
|
+
kind: "custom",
|
|
106
|
+
customType: first.payload.customType,
|
|
107
|
+
content,
|
|
108
|
+
display: first.payload.display,
|
|
109
|
+
details: {
|
|
110
|
+
batch: true,
|
|
111
|
+
items: batch.map((m) =>
|
|
112
|
+
m.payload.kind === "custom" && m.payload.details !== undefined
|
|
113
|
+
? m.payload.details
|
|
114
|
+
: m.payload,
|
|
115
|
+
),
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function isBusy(): boolean {
|
|
122
|
+
try {
|
|
123
|
+
if (!port.isIdle()) return true;
|
|
124
|
+
return port.hasPendingMessages();
|
|
125
|
+
} catch {
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function attemptSend(): void {
|
|
131
|
+
const composed = buildBatchPayload(inflightBatch);
|
|
132
|
+
try {
|
|
133
|
+
port.send(composed, composed.intent ?? cfg.intent);
|
|
134
|
+
onSendOk();
|
|
135
|
+
} catch (err) {
|
|
136
|
+
onSendFail(composed, err);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function onSendOk(): void {
|
|
141
|
+
if (disposed) return;
|
|
142
|
+
inFlight = false;
|
|
143
|
+
inflightBatch = [];
|
|
144
|
+
sendAttempts = 0;
|
|
145
|
+
if (queue.length > 0) scheduleFlush(0);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function onSendFail(composed: DeliveryMessage, err: unknown): void {
|
|
149
|
+
if (disposed) return;
|
|
150
|
+
void composed;
|
|
151
|
+
sendAttempts++;
|
|
152
|
+
if (sendAttempts > cfg.backoff.max) {
|
|
153
|
+
inFlight = false;
|
|
154
|
+
inflightBatch = [];
|
|
155
|
+
sendAttempts = 0;
|
|
156
|
+
cfg.warn("port.send failed after max retries", err);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (sendAttempts === 1) cfg.warn("port.send failed, retrying with backoff", err);
|
|
160
|
+
backoffTimer = setTimeout(() => {
|
|
161
|
+
backoffTimer = undefined;
|
|
162
|
+
if (disposed || !inFlight) return;
|
|
163
|
+
attemptSend();
|
|
164
|
+
}, cfg.backoff.ms);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function doSend(): void {
|
|
168
|
+
if (disposed || queue.length === 0 || inFlight) return;
|
|
169
|
+
inFlight = true;
|
|
170
|
+
inflightBatch = queue.splice(0);
|
|
171
|
+
sendAttempts = 0;
|
|
172
|
+
attemptSend();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function scheduleFlush(attempt: number): void {
|
|
176
|
+
if (disposed || queue.length === 0 || inFlight) return;
|
|
177
|
+
if (cfg.busyPolicy === "park" && attempt > 0) return;
|
|
178
|
+
if (backoffTimer !== undefined) {
|
|
179
|
+
clearTimeout(backoffTimer);
|
|
180
|
+
backoffTimer = undefined;
|
|
181
|
+
}
|
|
182
|
+
if (isBusy() && attempt < cfg.backoff.max) {
|
|
183
|
+
backoffTimer = setTimeout(() => {
|
|
184
|
+
backoffTimer = undefined;
|
|
185
|
+
scheduleFlush(attempt + 1);
|
|
186
|
+
}, cfg.backoff.ms);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
doSend();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
send(msg, opts) {
|
|
194
|
+
if (disposed) return;
|
|
195
|
+
if (!port.supportedPayloads.includes(msg.payload.kind)) {
|
|
196
|
+
cfg.warn(`unsupported payload kind: ${msg.payload.kind}`);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (dedupSet) {
|
|
200
|
+
if (msg.dedupeKey !== undefined) {
|
|
201
|
+
if (dedupSet.has(msg.dedupeKey)) return;
|
|
202
|
+
dedupSet.add(msg.dedupeKey);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
queue.push(msg);
|
|
206
|
+
const useMerge =
|
|
207
|
+
opts?.merge ??
|
|
208
|
+
(cfg.mergeWindowMs > 0 && cfg.mergeHoldActive != null && cfg.mergeHoldActive());
|
|
209
|
+
if (useMerge) {
|
|
210
|
+
if (mergeTimer !== undefined) clearTimeout(mergeTimer);
|
|
211
|
+
mergeTimer = setTimeout(() => {
|
|
212
|
+
mergeTimer = undefined;
|
|
213
|
+
scheduleFlush(0);
|
|
214
|
+
}, cfg.mergeWindowMs);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (mergeTimer !== undefined) {
|
|
218
|
+
clearTimeout(mergeTimer);
|
|
219
|
+
mergeTimer = undefined;
|
|
220
|
+
}
|
|
221
|
+
scheduleFlush(0);
|
|
222
|
+
},
|
|
223
|
+
flush() {
|
|
224
|
+
if (disposed) return;
|
|
225
|
+
if (mergeTimer !== undefined) {
|
|
226
|
+
clearTimeout(mergeTimer);
|
|
227
|
+
mergeTimer = undefined;
|
|
228
|
+
}
|
|
229
|
+
scheduleFlush(0);
|
|
230
|
+
},
|
|
231
|
+
dispose() {
|
|
232
|
+
disposed = true;
|
|
233
|
+
queue.length = 0;
|
|
234
|
+
inflightBatch = [];
|
|
235
|
+
inFlight = false;
|
|
236
|
+
if (backoffTimer !== undefined) clearTimeout(backoffTimer);
|
|
237
|
+
if (mergeTimer !== undefined) clearTimeout(mergeTimer);
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// 投递内核经通知域窄端口注入(notifier 不再直接 import session-delivery)——
|
|
243
|
+
// 「ledger 未 bind 退回内核路径」与「U4 warn 注入」用例依赖内核语义
|
|
244
|
+
// (gate / 合批 / onSendFail warn 出口),注入内核等价桩保住回归面;
|
|
245
|
+
// afterEach 重置防注入态泄漏。
|
|
246
|
+
//
|
|
247
|
+
// 同层注入 core host no-op sink:logger facade 经 host-services 动态解析,
|
|
248
|
+
// 缺省 NULL_HOST 的 warn/error 走 console(会刷屏且污染「console 零调用」类
|
|
249
|
+
// 断言);no-op sink 保持零噪音。日志断言用例(分桶 / U4 warn / T4③)在自身
|
|
250
|
+
// 用例或 beforeEach 内 configureCore 覆盖注入 logCalls sink——覆盖式写入、
|
|
251
|
+
// describe 内层后执行,天然生效。
|
|
252
|
+
const NOOP_HOST = {
|
|
253
|
+
dataRoot: () => "/fake-notify-ledger-data-root",
|
|
254
|
+
log: () => {},
|
|
255
|
+
};
|
|
256
|
+
beforeEach(() => {
|
|
257
|
+
configureNotifyDomain({ createDelivery });
|
|
258
|
+
configureCore(NOOP_HOST);
|
|
259
|
+
});
|
|
260
|
+
afterEach(() => {
|
|
261
|
+
resetNotifyDomainForTests();
|
|
262
|
+
resetCoreForTests();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// ─── mock host ─────────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
interface LedgerHostMock {
|
|
268
|
+
host: NotifyLedgerHost;
|
|
269
|
+
/** 追加的 plain custom entry(ledger/ack/abandoned 三列,含 customType)。与
|
|
270
|
+
* sessionEntries 共享同一数组——送达 custom_message entry 也会落入(无 data 字段)。 */
|
|
271
|
+
entries: { type: string; customType: string; data?: Record<string, unknown> }[];
|
|
272
|
+
/** session entries 视图(回执扫描 + 恢复扫描的输入;与 entries 共享同一数组)。 */
|
|
273
|
+
sessionEntries: unknown[];
|
|
274
|
+
/** 送达消息(sendDelivery 调用)。 */
|
|
275
|
+
sentMessages: { customType: string; content: string; display: boolean; details?: unknown }[];
|
|
276
|
+
/** settled 边沿回调(测试手动触发模拟 pi agent_settled 事件)。 */
|
|
277
|
+
settledHandlers: Array<() => void>;
|
|
278
|
+
setIdle(idle: boolean): void;
|
|
279
|
+
/** 送达是否同步落盘为 custom_message entry(模拟 pi:triggerTurn run 结束后落盘)。
|
|
280
|
+
* false = 模拟回执延迟/丢失(消息滞留内存)/ 回执不可匹配(PS-6 触发态)。 */
|
|
281
|
+
deliverPersists: { value: boolean };
|
|
282
|
+
/** U4:sendDelivery 是否抛异常(模拟受理被拒——下方 sendDelivery 首行 throw)。 */
|
|
283
|
+
deliveryThrows: { value: boolean };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function makeLedgerHost(): LedgerHostMock {
|
|
287
|
+
const entries: LedgerHostMock["entries"] = [];
|
|
288
|
+
const sessionEntries: unknown[] = entries;
|
|
289
|
+
const sentMessages: LedgerHostMock["sentMessages"] = [];
|
|
290
|
+
const settledHandlers: Array<() => void> = [];
|
|
291
|
+
const idle = { value: true };
|
|
292
|
+
const deliverPersists = { value: true };
|
|
293
|
+
const deliveryThrows = { value: false };
|
|
294
|
+
const host: NotifyLedgerHost = {
|
|
295
|
+
appendLedgerEntry: (customType, data) => {
|
|
296
|
+
entries.push({ type: "custom", customType, data: data as Record<string, unknown> | undefined });
|
|
297
|
+
},
|
|
298
|
+
readSessionEntries: () => sessionEntries,
|
|
299
|
+
isIdle: () => idle.value,
|
|
300
|
+
onAgentSettled: (handler) => {
|
|
301
|
+
settledHandlers.push(handler);
|
|
302
|
+
},
|
|
303
|
+
sendDelivery: (message) => {
|
|
304
|
+
if (deliveryThrows.value) {
|
|
305
|
+
throw new Error("delivery rejected (mock)");
|
|
306
|
+
}
|
|
307
|
+
sentMessages.push(message);
|
|
308
|
+
if (deliverPersists.value) {
|
|
309
|
+
sessionEntries.push({
|
|
310
|
+
type: "custom_message",
|
|
311
|
+
customType: message.customType,
|
|
312
|
+
content: message.content,
|
|
313
|
+
display: message.display,
|
|
314
|
+
details: message.details,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
return {
|
|
320
|
+
host,
|
|
321
|
+
entries,
|
|
322
|
+
sessionEntries,
|
|
323
|
+
sentMessages,
|
|
324
|
+
settledHandlers,
|
|
325
|
+
setIdle: (v) => {
|
|
326
|
+
idle.value = v;
|
|
327
|
+
},
|
|
328
|
+
deliverPersists,
|
|
329
|
+
deliveryThrows,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** 触发一次 settled 边沿(模拟 pi agent_settled 事件)。 */
|
|
334
|
+
function fireSettled(mock: LedgerHostMock): void {
|
|
335
|
+
for (const handler of mock.settledHandlers) handler();
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** 未终态号差集:ledger entry 的 notifyId −(ack ∪ abandoned)的 notifyId
|
|
339
|
+
* (T4③ 后终态有两列:已销账 + 已放弃;两列均不算「未投完」)。 */
|
|
340
|
+
function unsettledDiff(mock: LedgerHostMock): Set<string> {
|
|
341
|
+
const ledgerIds = new Set<string>();
|
|
342
|
+
const settled = new Set<string>();
|
|
343
|
+
for (const e of mock.entries) {
|
|
344
|
+
if (e.customType !== NOTIFY_LEDGER_CUSTOM_TYPE && e.customType !== NOTIFY_ACK_CUSTOM_TYPE && e.customType !== NOTIFY_ABANDONED_CUSTOM_TYPE) {
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
const notifyId = e.data?.["notifyId"];
|
|
348
|
+
if (typeof notifyId !== "string") continue;
|
|
349
|
+
if (e.customType === NOTIFY_LEDGER_CUSTOM_TYPE) ledgerIds.add(notifyId);
|
|
350
|
+
else settled.add(notifyId);
|
|
351
|
+
}
|
|
352
|
+
for (const id of settled) ledgerIds.delete(id);
|
|
353
|
+
return ledgerIds;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function abandonedEntries(mock: LedgerHostMock): { data?: Record<string, unknown> }[] {
|
|
357
|
+
return mock.entries.filter((e) => e.customType === NOTIFY_ABANDONED_CUSTOM_TYPE);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** 从日志 sink 中取放弃 warn(区别于分桶 warn「notify delivery bucket [...]」)。 */
|
|
361
|
+
function abandonedWarns(
|
|
362
|
+
logs: Array<{ level: string; component?: string; message: string; data?: unknown }>,
|
|
363
|
+
): Array<{
|
|
364
|
+
level: string;
|
|
365
|
+
component?: string;
|
|
366
|
+
message: string;
|
|
367
|
+
data?: unknown;
|
|
368
|
+
}> {
|
|
369
|
+
return logs.filter((l) => l.level === "warn" && l.message.includes("abandoned"));
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ─── 账本四步生命周期 ─────────────────────────────────────
|
|
373
|
+
|
|
374
|
+
describe("NotifyLedger — 四步生命周期(D4/D5)", () => {
|
|
375
|
+
beforeEach(() => {
|
|
376
|
+
_resetNotifyLedgerForTest();
|
|
377
|
+
});
|
|
378
|
+
afterEach(() => {
|
|
379
|
+
_resetNotifyLedgerForTest();
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it("①→② 写账先于投递:appendEntry(ledger) 先于 sendDelivery(顺序断言)", () => {
|
|
383
|
+
const mock = makeLedgerHost();
|
|
384
|
+
const ledger = createNotifyLedger(mock.host);
|
|
385
|
+
|
|
386
|
+
const recorded = ledger.record("sa-1", "content-1", { notifyId: "sa-1", id: "sa-1" });
|
|
387
|
+
expect(recorded).toBe(true);
|
|
388
|
+
// idle → notify 后立即投递(record 内不自投,attemptDeliver 由调用方驱动;
|
|
389
|
+
// 此处显式调用验证顺序)
|
|
390
|
+
ledger.attemptDeliver();
|
|
391
|
+
|
|
392
|
+
expect(mock.sentMessages).toHaveLength(1);
|
|
393
|
+
// 顺序断言:账本 entry(entries[0])先于送达消息(sentMessages[0])
|
|
394
|
+
expect(mock.entries[0]?.customType).toBe(NOTIFY_LEDGER_CUSTOM_TYPE);
|
|
395
|
+
expect(mock.sentMessages[0]?.customType).toBe(NOTIFY_CUSTOM_TYPE);
|
|
396
|
+
// 单条投递形态:details = record 本体(notifyId 携带在 details,不在文案)
|
|
397
|
+
expect(mock.sentMessages[0]?.details).toMatchObject({ notifyId: "sa-1" });
|
|
398
|
+
|
|
399
|
+
ledger.dispose();
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it("② settled 边沿投递:busy 挂回 pending,isIdle 复查通过后边沿送达", () => {
|
|
403
|
+
const mock = makeLedgerHost();
|
|
404
|
+
const ledger = createNotifyLedger(mock.host);
|
|
405
|
+
|
|
406
|
+
// busy:notify 后 attemptDeliver 放弃本次,消息挂回 pending
|
|
407
|
+
mock.setIdle(false);
|
|
408
|
+
expect(ledger.record("sa-busy", "content", { notifyId: "sa-busy" })).toBe(true);
|
|
409
|
+
ledger.attemptDeliver();
|
|
410
|
+
expect(mock.sentMessages).toHaveLength(0);
|
|
411
|
+
expect(ledger.pendingCount()).toBe(1);
|
|
412
|
+
|
|
413
|
+
// settled 边沿(边沿回调内 isIdle 复查):仍 busy → 继续挂 pending
|
|
414
|
+
fireSettled(mock);
|
|
415
|
+
expect(mock.sentMessages).toHaveLength(0);
|
|
416
|
+
expect(ledger.pendingCount()).toBe(1);
|
|
417
|
+
|
|
418
|
+
// 主 agent 空闲后的下一 settled 边沿 → 送达
|
|
419
|
+
mock.setIdle(true);
|
|
420
|
+
fireSettled(mock);
|
|
421
|
+
expect(mock.sentMessages).toHaveLength(1);
|
|
422
|
+
expect(ledger.pendingCount()).toBe(0);
|
|
423
|
+
expect(ledger.waitingReceiptCount()).toBe(1);
|
|
424
|
+
|
|
425
|
+
ledger.dispose();
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it("②→③ 回执销账:送达 entry 出现后下一 settled 边沿追加 ack entry,差集为空", () => {
|
|
429
|
+
const mock = makeLedgerHost();
|
|
430
|
+
const ledger = createNotifyLedger(mock.host);
|
|
431
|
+
|
|
432
|
+
ledger.record("sa-ack", "content", { notifyId: "sa-ack" });
|
|
433
|
+
ledger.attemptDeliver();
|
|
434
|
+
expect(mock.sentMessages).toHaveLength(1);
|
|
435
|
+
expect(unsettledDiff(mock)).toEqual(new Set(["sa-ack"])); // 已送达未销账
|
|
436
|
+
|
|
437
|
+
// 下一 settled 边沿:checkReceipts 扫到 custom_message entry(sendDelivery 已
|
|
438
|
+
// 模拟落盘)→ 追加 ack entry → 两列差集为空
|
|
439
|
+
fireSettled(mock);
|
|
440
|
+
expect(unsettledDiff(mock)).toEqual(new Set());
|
|
441
|
+
expect(ledger.waitingReceiptCount()).toBe(0);
|
|
442
|
+
expect(mock.entries.some((e) => e.customType === NOTIFY_ACK_CUSTOM_TYPE)).toBe(true);
|
|
443
|
+
|
|
444
|
+
ledger.dispose();
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it("③ 内存不承担销账职责:回执缺失(消息滞留内存)时不销账,差集保留", () => {
|
|
448
|
+
const mock = makeLedgerHost();
|
|
449
|
+
const ledger = createNotifyLedger(mock.host);
|
|
450
|
+
|
|
451
|
+
// 模拟消息滞留内存(如竞态窗口掉进 steer 队列):sendDelivery 不落盘
|
|
452
|
+
mock.deliverPersists.value = false;
|
|
453
|
+
ledger.record("sa-lost", "content", { notifyId: "sa-lost" });
|
|
454
|
+
ledger.attemptDeliver();
|
|
455
|
+
expect(mock.sentMessages).toHaveLength(1);
|
|
456
|
+
|
|
457
|
+
fireSettled(mock);
|
|
458
|
+
// 无 custom_message entry 回执 → 不销账(内存不销账,等看门狗重投)
|
|
459
|
+
expect(unsettledDiff(mock)).toEqual(new Set(["sa-lost"]));
|
|
460
|
+
expect(ledger.waitingReceiptCount()).toBe(1);
|
|
461
|
+
|
|
462
|
+
ledger.dispose();
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it("④ 重启恢复:新实例扫两列差集,未销账号重放、已销账零重发", () => {
|
|
466
|
+
// 旧实例(模拟崩溃前的进程):sa-r1/sa-r2 送达销账,sa-r3 入账未投
|
|
467
|
+
const oldMock = makeLedgerHost();
|
|
468
|
+
const oldLedger = createNotifyLedger(oldMock.host);
|
|
469
|
+
for (const id of ["sa-r1", "sa-r2"]) {
|
|
470
|
+
oldLedger.record(id, `content-${id}`, { notifyId: id });
|
|
471
|
+
}
|
|
472
|
+
oldLedger.attemptDeliver();
|
|
473
|
+
fireSettled(oldMock);
|
|
474
|
+
oldLedger.record("sa-r3", "content-sa-r3", { notifyId: "sa-r3" }); // 崩溃前未投
|
|
475
|
+
expect(unsettledDiff(oldMock)).toEqual(new Set(["sa-r3"]));
|
|
476
|
+
oldLedger.dispose();
|
|
477
|
+
|
|
478
|
+
// 重启:新实例扫同一 session 文件(sessionEntries 视图保留旧 entry 列)
|
|
479
|
+
const newMock = makeLedgerHost();
|
|
480
|
+
newMock.sessionEntries.push(...oldMock.sessionEntries);
|
|
481
|
+
const newLedger = createNotifyLedger(newMock.host);
|
|
482
|
+
const replayed = newLedger.recoverFromSession();
|
|
483
|
+
|
|
484
|
+
expect(replayed).toBe(1); // 仅未销账的 sa-r3 重放
|
|
485
|
+
expect(newMock.sentMessages).toHaveLength(1);
|
|
486
|
+
expect(newMock.sentMessages[0]?.details).toMatchObject({ notifyId: "sa-r3" });
|
|
487
|
+
// 已销账号零重发
|
|
488
|
+
const sentIds = newMock.sentMessages.map((m) => (m.details as { notifyId?: string }).notifyId);
|
|
489
|
+
expect(sentIds).not.toContain("sa-r1");
|
|
490
|
+
expect(sentIds).not.toContain("sa-r2");
|
|
491
|
+
|
|
492
|
+
newLedger.dispose();
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it("④ notifyId 幂等:重复 record 同 notifyId 返回 false,不重复投递", () => {
|
|
496
|
+
const mock = makeLedgerHost();
|
|
497
|
+
const ledger = createNotifyLedger(mock.host);
|
|
498
|
+
|
|
499
|
+
expect(ledger.record("sa-dup", "content", { notifyId: "sa-dup" })).toBe(true);
|
|
500
|
+
expect(ledger.record("sa-dup", "content", { notifyId: "sa-dup" })).toBe(false);
|
|
501
|
+
|
|
502
|
+
ledger.attemptDeliver();
|
|
503
|
+
ledger.attemptDeliver();
|
|
504
|
+
expect(mock.sentMessages).toHaveLength(1); // 至多一次
|
|
505
|
+
|
|
506
|
+
// 销账后同 notifyId 再来(重复调用方)→ 仍幂等拒绝(已销账号绝不重发)
|
|
507
|
+
fireSettled(mock);
|
|
508
|
+
expect(ledger.record("sa-dup", "content", { notifyId: "sa-dup" })).toBe(false);
|
|
509
|
+
|
|
510
|
+
ledger.dispose();
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it("④ 重复投递条目可识别:批量送达 details.items 每项携带 notifyId(回执匹配)", () => {
|
|
514
|
+
const mock = makeLedgerHost();
|
|
515
|
+
const ledger = createNotifyLedger(mock.host);
|
|
516
|
+
|
|
517
|
+
ledger.record("sa-m1", "c1", { notifyId: "sa-m1" });
|
|
518
|
+
ledger.record("sa-m2", "c2", { notifyId: "sa-m2" });
|
|
519
|
+
ledger.attemptDeliver();
|
|
520
|
+
|
|
521
|
+
expect(mock.sentMessages).toHaveLength(1); // 合并为一条
|
|
522
|
+
const msg = mock.sentMessages[0]!;
|
|
523
|
+
expect(msg.content).toBe("c1\n\n---\n\nc2");
|
|
524
|
+
const details = msg.details as { batch: boolean; items: Array<{ notifyId?: string }> };
|
|
525
|
+
expect(details.batch).toBe(true);
|
|
526
|
+
expect(details.items.map((i) => i.notifyId)).toEqual(["sa-m1", "sa-m2"]);
|
|
527
|
+
|
|
528
|
+
// 批量回执匹配:items[].notifyId 命中 → 两条全部销账
|
|
529
|
+
fireSettled(mock);
|
|
530
|
+
expect(unsettledDiff(mock)).toEqual(new Set());
|
|
531
|
+
|
|
532
|
+
ledger.dispose();
|
|
533
|
+
});
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
// ─── 120s 看门狗(fake timers) ───────────────────────────
|
|
537
|
+
|
|
538
|
+
describe("NotifyLedger — 120s 看门狗(D5 ②兜底触发面)", () => {
|
|
539
|
+
beforeEach(() => {
|
|
540
|
+
vi.useFakeTimers();
|
|
541
|
+
_resetNotifyLedgerForTest();
|
|
542
|
+
});
|
|
543
|
+
afterEach(() => {
|
|
544
|
+
_resetNotifyLedgerForTest();
|
|
545
|
+
vi.useRealTimers();
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it("主 session 长期无 settled:busy 挂起的 pending 由看门狗在空闲后补投", () => {
|
|
549
|
+
const mock = makeLedgerHost();
|
|
550
|
+
const ledger = createNotifyLedger(mock.host);
|
|
551
|
+
|
|
552
|
+
mock.setIdle(false);
|
|
553
|
+
ledger.record("sa-wd1", "content", { notifyId: "sa-wd1" });
|
|
554
|
+
ledger.attemptDeliver();
|
|
555
|
+
expect(mock.sentMessages).toHaveLength(0);
|
|
556
|
+
|
|
557
|
+
// 第一个周期仍 busy → 不投
|
|
558
|
+
vi.advanceTimersByTime(NOTIFY_WATCHDOG_MS);
|
|
559
|
+
expect(mock.sentMessages).toHaveLength(0);
|
|
560
|
+
|
|
561
|
+
// 主 agent 空闲(长期无 settled 事件)→ 下一周期看门狗补投
|
|
562
|
+
mock.setIdle(true);
|
|
563
|
+
vi.advanceTimersByTime(NOTIFY_WATCHDOG_MS);
|
|
564
|
+
expect(mock.sentMessages).toHaveLength(1);
|
|
565
|
+
|
|
566
|
+
ledger.dispose();
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
it("超时重投:sent 后一个周期无回执(消息滞留内存丢失)→ 看门狗重投", () => {
|
|
570
|
+
const mock = makeLedgerHost();
|
|
571
|
+
const ledger = createNotifyLedger(mock.host);
|
|
572
|
+
|
|
573
|
+
// 投递但不落盘(模拟 steer 滞留后丢失——回执永不到)
|
|
574
|
+
mock.deliverPersists.value = false;
|
|
575
|
+
ledger.record("sa-wd2", "content", { notifyId: "sa-wd2" });
|
|
576
|
+
ledger.attemptDeliver();
|
|
577
|
+
expect(mock.sentMessages).toHaveLength(1);
|
|
578
|
+
|
|
579
|
+
// 一个周期后:无回执 → sentAt 超时转回 pending → 本 tick 重投(at-least-once)
|
|
580
|
+
vi.advanceTimersByTime(NOTIFY_WATCHDOG_MS);
|
|
581
|
+
expect(mock.sentMessages).toHaveLength(2);
|
|
582
|
+
expect(mock.sentMessages[1]?.details).toMatchObject({ notifyId: "sa-wd2" }); // 同 notifyId 可识别
|
|
583
|
+
|
|
584
|
+
ledger.dispose();
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
it("回执正常时序不重投:送达落盘 + settled 销账后,看门狗空转零动作", () => {
|
|
588
|
+
const mock = makeLedgerHost();
|
|
589
|
+
const ledger = createNotifyLedger(mock.host);
|
|
590
|
+
|
|
591
|
+
ledger.record("sa-wd3", "content", { notifyId: "sa-wd3" });
|
|
592
|
+
ledger.attemptDeliver();
|
|
593
|
+
fireSettled(mock); // 销账
|
|
594
|
+
expect(unsettledDiff(mock)).toEqual(new Set());
|
|
595
|
+
|
|
596
|
+
vi.advanceTimersByTime(NOTIFY_WATCHDOG_MS * 3);
|
|
597
|
+
expect(mock.sentMessages).toHaveLength(1); // 无重投
|
|
598
|
+
expect(unsettledDiff(mock)).toEqual(new Set()); // 差集保持为空
|
|
599
|
+
|
|
600
|
+
ledger.dispose();
|
|
601
|
+
});
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
// ─── U4 投递计数分桶(设计 §5 U4 × §2.2 三条丢失路径) ─────
|
|
605
|
+
|
|
606
|
+
describe("NotifyLedger — U4 投递计数分桶", () => {
|
|
607
|
+
beforeEach(() => {
|
|
608
|
+
_resetNotifyLedgerForTest();
|
|
609
|
+
});
|
|
610
|
+
afterEach(() => {
|
|
611
|
+
_resetNotifyLedgerForTest();
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
it("初始快照:三桶全 0(无投递异常时零计数)", () => {
|
|
615
|
+
const mock = makeLedgerHost();
|
|
616
|
+
const ledger = createNotifyLedger(mock.host);
|
|
617
|
+
expect(ledger.deliveryMetrics()).toEqual({ settleRejected: 0, watchdogReplays: 0, recoveryReplays: 0 });
|
|
618
|
+
ledger.dispose();
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
it("②settleRejected 桶:sendDelivery 受理被拒 → +1 且消息挂回 pending;恢复后不再累计", () => {
|
|
622
|
+
const mock = makeLedgerHost();
|
|
623
|
+
const ledger = createNotifyLedger(mock.host);
|
|
624
|
+
|
|
625
|
+
mock.deliveryThrows.value = true;
|
|
626
|
+
ledger.record("sa-rj", "content", { notifyId: "sa-rj" });
|
|
627
|
+
ledger.attemptDeliver();
|
|
628
|
+
|
|
629
|
+
expect(ledger.deliveryMetrics().settleRejected).toBe(1);
|
|
630
|
+
expect(mock.sentMessages).toHaveLength(0);
|
|
631
|
+
expect(ledger.pendingCount()).toBe(1); // 挂回 pending,账未丢
|
|
632
|
+
|
|
633
|
+
// 下一边沿受理恢复:投递成功,rejection 桶不再增长
|
|
634
|
+
mock.deliveryThrows.value = false;
|
|
635
|
+
ledger.attemptDeliver();
|
|
636
|
+
expect(mock.sentMessages).toHaveLength(1);
|
|
637
|
+
expect(ledger.deliveryMetrics().settleRejected).toBe(1);
|
|
638
|
+
|
|
639
|
+
ledger.dispose();
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
it("①watchdogReplays 桶:销账超时重投按条数累计(同消息反复超时继续累计)", () => {
|
|
643
|
+
vi.useFakeTimers();
|
|
644
|
+
try {
|
|
645
|
+
const mock = makeLedgerHost();
|
|
646
|
+
const ledger = createNotifyLedger(mock.host);
|
|
647
|
+
|
|
648
|
+
// 投递但回执永不到(消息滞留内存丢失)
|
|
649
|
+
mock.deliverPersists.value = false;
|
|
650
|
+
ledger.record("sa-wd", "content", { notifyId: "sa-wd" });
|
|
651
|
+
ledger.attemptDeliver();
|
|
652
|
+
expect(ledger.deliveryMetrics().watchdogReplays).toBe(0);
|
|
653
|
+
|
|
654
|
+
// 第一个周期:销账超时 → 转回 pending 重投,桶 +1
|
|
655
|
+
vi.advanceTimersByTime(NOTIFY_WATCHDOG_MS);
|
|
656
|
+
expect(mock.sentMessages).toHaveLength(2);
|
|
657
|
+
expect(ledger.deliveryMetrics().watchdogReplays).toBe(1);
|
|
658
|
+
|
|
659
|
+
// 第二个周期仍无回执:同消息再重投,桶累计到 2(滞留深度可见)
|
|
660
|
+
vi.advanceTimersByTime(NOTIFY_WATCHDOG_MS);
|
|
661
|
+
expect(mock.sentMessages).toHaveLength(3);
|
|
662
|
+
expect(ledger.deliveryMetrics().watchdogReplays).toBe(2);
|
|
663
|
+
|
|
664
|
+
ledger.dispose();
|
|
665
|
+
} finally {
|
|
666
|
+
vi.useRealTimers();
|
|
667
|
+
}
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
it("③recoveryReplays 桶:session_start 恢复重放条数与返回值一致,已销账零计数", () => {
|
|
671
|
+
const mock = makeLedgerHost();
|
|
672
|
+
mock.sessionEntries.push(
|
|
673
|
+
{ type: "custom", customType: NOTIFY_LEDGER_CUSTOM_TYPE, data: { v: 1, notifyId: "s-a", content: "ca", record: { notifyId: "s-a" } } },
|
|
674
|
+
{ type: "custom", customType: NOTIFY_LEDGER_CUSTOM_TYPE, data: { v: 1, notifyId: "s-b", content: "cb", record: { notifyId: "s-b" } } },
|
|
675
|
+
{ type: "custom", customType: NOTIFY_ACK_CUSTOM_TYPE, data: { v: 1, notifyId: "s-a" } },
|
|
676
|
+
);
|
|
677
|
+
|
|
678
|
+
const ledger = createNotifyLedger(mock.host);
|
|
679
|
+
const replayed = ledger.recoverFromSession();
|
|
680
|
+
|
|
681
|
+
expect(replayed).toBe(1); // s-b 未销账重放;s-a 已销账零重发
|
|
682
|
+
expect(ledger.deliveryMetrics().recoveryReplays).toBe(1);
|
|
683
|
+
|
|
684
|
+
// 再次恢复(幂等):差集已入账,零重发零计数
|
|
685
|
+
expect(ledger.recoverFromSession()).toBe(0);
|
|
686
|
+
expect(ledger.deliveryMetrics().recoveryReplays).toBe(1);
|
|
687
|
+
|
|
688
|
+
ledger.dispose();
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
it("分桶增量经 core logger facade 落日志(warn,msg 固定 key + 动态值在 data)", () => {
|
|
692
|
+
// 用例级 logCalls sink(覆盖文件级 no-op sink)——从零开始收集,断言
|
|
693
|
+
// 「本次投递失败动作」恰产生一条分桶 warn(不受同 describe 前序用例影响)。
|
|
694
|
+
const logCalls: Array<{ level: string; component: string; message: string; data?: unknown }> = [];
|
|
695
|
+
configureCore({
|
|
696
|
+
dataRoot: () => "/fake-notify-ledger-data-root",
|
|
697
|
+
log: (level, component, message, data) => {
|
|
698
|
+
logCalls.push({ level, component, message, data });
|
|
699
|
+
},
|
|
700
|
+
});
|
|
701
|
+
const mock = makeLedgerHost();
|
|
702
|
+
const ledger = createNotifyLedger(mock.host);
|
|
703
|
+
|
|
704
|
+
mock.deliveryThrows.value = true;
|
|
705
|
+
ledger.record("sa-log", "content", { notifyId: "sa-log" });
|
|
706
|
+
ledger.attemptDeliver();
|
|
707
|
+
|
|
708
|
+
const warns = logCalls.filter((l) => l.level === "warn");
|
|
709
|
+
expect(warns).toHaveLength(1);
|
|
710
|
+
expect(warns[0]?.component).toBe("subagents");
|
|
711
|
+
expect(warns[0]?.message).toContain("notify delivery bucket [settleRejected]");
|
|
712
|
+
expect(warns[0]?.data).toEqual({ total: 1, pending: 1 });
|
|
713
|
+
|
|
714
|
+
ledger.dispose();
|
|
715
|
+
});
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
// ─── compaction 降级(P-B4) ───────────────────────────────
|
|
719
|
+
|
|
720
|
+
describe("NotifyLedger — compaction 降级(P-B4 未验证,条件补写)", () => {
|
|
721
|
+
beforeEach(() => {
|
|
722
|
+
_resetNotifyLedgerForTest();
|
|
723
|
+
});
|
|
724
|
+
afterEach(() => {
|
|
725
|
+
_resetNotifyLedgerForTest();
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
it("compaction 清除 ledger/ack entry 后按内存态补写(重建两列差集)", () => {
|
|
729
|
+
const mock = makeLedgerHost();
|
|
730
|
+
const ledger = createNotifyLedger(mock.host);
|
|
731
|
+
|
|
732
|
+
// sa-c1 送达销账、sa-c2 入账未投
|
|
733
|
+
ledger.record("sa-c1", "c1", { notifyId: "sa-c1" });
|
|
734
|
+
ledger.attemptDeliver();
|
|
735
|
+
fireSettled(mock);
|
|
736
|
+
ledger.record("sa-c2", "c2", { notifyId: "sa-c2" });
|
|
737
|
+
expect(unsettledDiff(mock)).toEqual(new Set(["sa-c2"]));
|
|
738
|
+
|
|
739
|
+
// 模拟 compaction 清掉全部 plain custom entry(ledger/ack 两列全丢,送达
|
|
740
|
+
// custom_message entry 假设保留——保守验证降级闭环)
|
|
741
|
+
mock.sessionEntries.length = 0;
|
|
742
|
+
|
|
743
|
+
const rewritten = ledger.compactionCheck();
|
|
744
|
+
expect(rewritten).toBe(2); // 补写 sa-c2 的 ledger + sa-c1 的 ack
|
|
745
|
+
// 补写后差集恢复:仍只有 sa-c2 未销账(sa-c1 销账事实随 ack 补写恢复)
|
|
746
|
+
expect(unsettledDiff(mock)).toEqual(new Set(["sa-c2"]));
|
|
747
|
+
|
|
748
|
+
ledger.dispose();
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
it("entry 存活时 compactionCheck no-op(零补写)", () => {
|
|
752
|
+
const mock = makeLedgerHost();
|
|
753
|
+
const ledger = createNotifyLedger(mock.host);
|
|
754
|
+
ledger.record("sa-nc", "c", { notifyId: "sa-nc" });
|
|
755
|
+
ledger.attemptDeliver();
|
|
756
|
+
fireSettled(mock);
|
|
757
|
+
|
|
758
|
+
expect(ledger.compactionCheck()).toBe(0);
|
|
759
|
+
ledger.dispose();
|
|
760
|
+
});
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
// ─── session_start 恢复钩子(bindNotifyLedgerHost) ────────
|
|
764
|
+
|
|
765
|
+
describe("bindNotifyLedgerHost — session_start 恢复钩子", () => {
|
|
766
|
+
beforeEach(() => {
|
|
767
|
+
_resetNotifyLedgerForTest();
|
|
768
|
+
});
|
|
769
|
+
afterEach(() => {
|
|
770
|
+
_resetNotifyLedgerForTest();
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
it("bind + recoverFromSession:mock session 含 ledger/ack entry → 重放差集", () => {
|
|
774
|
+
const mock = makeLedgerHost();
|
|
775
|
+
// 预置 session 文件内容(模拟重启前的落盘):2 条 ledger + 1 条 ack + 1 条已送达
|
|
776
|
+
mock.sessionEntries.push(
|
|
777
|
+
{ type: "custom", customType: NOTIFY_LEDGER_CUSTOM_TYPE, data: { v: 1, notifyId: "s-a", content: "ca", record: { notifyId: "s-a" } } },
|
|
778
|
+
{ type: "custom", customType: NOTIFY_LEDGER_CUSTOM_TYPE, data: { v: 1, notifyId: "s-b", content: "cb", record: { notifyId: "s-b" } } },
|
|
779
|
+
{ type: "custom", customType: NOTIFY_ACK_CUSTOM_TYPE, data: { v: 1, notifyId: "s-a" } },
|
|
780
|
+
{ type: "custom_message", customType: NOTIFY_CUSTOM_TYPE, content: "ca", display: true, details: { notifyId: "s-a" } },
|
|
781
|
+
);
|
|
782
|
+
|
|
783
|
+
const ledger = bindNotifyLedgerHost(mock.host);
|
|
784
|
+
const replayed = ledger.recoverFromSession();
|
|
785
|
+
|
|
786
|
+
expect(replayed).toBe(1); // s-b 未销账重放;s-a 已销账零重发
|
|
787
|
+
expect(mock.sentMessages).toHaveLength(1);
|
|
788
|
+
expect(mock.sentMessages[0]?.content).toBe("cb");
|
|
789
|
+
expect(mock.sentMessages[0]?.details).toMatchObject({ notifyId: "s-b" });
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
it("重复 bind 替换旧实例(/resume /fork /new 的 session_start)", () => {
|
|
793
|
+
const mock1 = makeLedgerHost();
|
|
794
|
+
bindNotifyLedgerHost(mock1.host);
|
|
795
|
+
expect(bindNotifyLedgerHost(makeLedgerHost().host)).toBeDefined();
|
|
796
|
+
// 旧实例已 dispose(settled 回调静默)
|
|
797
|
+
fireSettled(mock1);
|
|
798
|
+
expect(mock1.sentMessages).toHaveLength(0);
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
it("损坏 / 形态不符 entry 跳过不 throw(无 notifyId / data 非 object)", () => {
|
|
802
|
+
const mock = makeLedgerHost();
|
|
803
|
+
mock.sessionEntries.push(
|
|
804
|
+
{ type: "custom", customType: NOTIFY_LEDGER_CUSTOM_TYPE, data: { v: 1 } }, // 无 notifyId
|
|
805
|
+
{ type: "custom", customType: NOTIFY_LEDGER_CUSTOM_TYPE }, // 无 data
|
|
806
|
+
{ type: "custom", customType: "unrelated-type", data: { notifyId: "x" } },
|
|
807
|
+
null,
|
|
808
|
+
);
|
|
809
|
+
const ledger = bindNotifyLedgerHost(mock.host);
|
|
810
|
+
expect(ledger.recoverFromSession()).toBe(0);
|
|
811
|
+
});
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
// ─── notifier 四步接线(createNotifier × ledger) ─────────
|
|
815
|
+
|
|
816
|
+
describe("createNotifier — ledger 四步接线(U2)", () => {
|
|
817
|
+
beforeEach(() => {
|
|
818
|
+
_resetNotifyLedgerForTest();
|
|
819
|
+
});
|
|
820
|
+
afterEach(() => {
|
|
821
|
+
_resetNotifyLedgerForTest();
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
/** notifier 侧 mock host:sendMessage 是 ledger sendDelivery 的下游(index.ts
|
|
825
|
+
* 装配 sendDelivery = pi.sendMessage({triggerTurn:true}),此处直接捕获)。 */
|
|
826
|
+
function makeNotifierHost(): NotifierHost & {
|
|
827
|
+
sentOptions: unknown[];
|
|
828
|
+
sentMessages: { customType: string; content: string; display: boolean; details?: unknown }[];
|
|
829
|
+
} {
|
|
830
|
+
const sentOptions: unknown[] = [];
|
|
831
|
+
const sentMessages: { customType: string; content: string; display: boolean; details?: unknown }[] = [];
|
|
832
|
+
return {
|
|
833
|
+
sentOptions,
|
|
834
|
+
sentMessages,
|
|
835
|
+
sendMessage(message, options) {
|
|
836
|
+
sentOptions.push(options);
|
|
837
|
+
sentMessages.push(message);
|
|
838
|
+
},
|
|
839
|
+
hasRunningBackground: () => false,
|
|
840
|
+
isIdle: () => true,
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function oneShotRecord(id: string, result = "done"): BgNotifyRecord {
|
|
845
|
+
return { id, status: "closed", agent: "worker", result, startedAt: 1, endedAt: 2 };
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
it("notify → 写账 → 投递 → 回执销账全链路(notifyId = id,G4 文案不变)", () => {
|
|
849
|
+
const ledgerMock = makeLedgerHost();
|
|
850
|
+
// sendDelivery 桥接到 notifier host.sendMessage(模拟 index.ts 装配)
|
|
851
|
+
const notifierHost = makeNotifierHost();
|
|
852
|
+
ledgerMock.host.sendDelivery = (message) => {
|
|
853
|
+
ledgerMock.sentMessages.push(message);
|
|
854
|
+
if (ledgerMock.deliverPersists.value) {
|
|
855
|
+
ledgerMock.sessionEntries.push({
|
|
856
|
+
type: "custom_message",
|
|
857
|
+
customType: message.customType,
|
|
858
|
+
content: message.content,
|
|
859
|
+
display: message.display,
|
|
860
|
+
details: message.details,
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
notifierHost.sendMessage(message, { triggerTurn: true });
|
|
864
|
+
};
|
|
865
|
+
bindNotifyLedgerHost(ledgerMock.host);
|
|
866
|
+
|
|
867
|
+
const notifier = createNotifier(notifierHost);
|
|
868
|
+
notifier.notify(oneShotRecord("sa-n1"));
|
|
869
|
+
|
|
870
|
+
// ① 写账先于 ② 投递:ledger entry 落盘且消息已送达
|
|
871
|
+
expect(ledgerMock.entries[0]?.customType).toBe(NOTIFY_LEDGER_CUSTOM_TYPE);
|
|
872
|
+
expect(notifierHost.sentMessages).toHaveLength(1);
|
|
873
|
+
// 单通道断言:options 只有 triggerTurn,无 deliverAs
|
|
874
|
+
expect(notifierHost.sentOptions[0]).toEqual({ triggerTurn: true });
|
|
875
|
+
// G4 文案锚:one-shot 完成通知逐字节不变(notifyId 不进文案)
|
|
876
|
+
expect(notifierHost.sentMessages[0]?.content).toBe('Subagent "worker" (sa-n1) completed. Result:\ndone');
|
|
877
|
+
// details 携带 notifyId + outcome 物化(U3)
|
|
878
|
+
expect(notifierHost.sentMessages[0]?.details).toMatchObject({ notifyId: "sa-n1", outcome: "completed" });
|
|
879
|
+
|
|
880
|
+
// ③ 回执销账:settled 边沿 → ack entry + 差集为空
|
|
881
|
+
fireSettled(ledgerMock);
|
|
882
|
+
expect(unsettledDiff(ledgerMock)).toEqual(new Set());
|
|
883
|
+
|
|
884
|
+
notifier.dispose();
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
it("chatMode round dedupe key 平移:notifyId = `${id}:${round}`,规则不变", () => {
|
|
888
|
+
const ledgerMock = makeLedgerHost();
|
|
889
|
+
const notifierHost = makeNotifierHost();
|
|
890
|
+
// 桥接:ledger sendDelivery → notifier host.sendMessage(模拟 index.ts 装配)
|
|
891
|
+
ledgerMock.host.sendDelivery = (message) => {
|
|
892
|
+
notifierHost.sendMessage(message, { triggerTurn: true });
|
|
893
|
+
if (ledgerMock.deliverPersists.value) {
|
|
894
|
+
ledgerMock.sessionEntries.push({
|
|
895
|
+
type: "custom_message",
|
|
896
|
+
customType: message.customType,
|
|
897
|
+
content: message.content,
|
|
898
|
+
display: message.display,
|
|
899
|
+
details: message.details,
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
};
|
|
903
|
+
bindNotifyLedgerHost(ledgerMock.host);
|
|
904
|
+
|
|
905
|
+
const notifier = createNotifier(notifierHost);
|
|
906
|
+
notifier.notify({ id: "sa-chat", status: "running", agent: "w", round: 1, result: "r1", startedAt: 1, endedAt: 2 });
|
|
907
|
+
notifier.notify({ id: "sa-chat", status: "running", agent: "w", round: 2, result: "r2", startedAt: 3, endedAt: 4 });
|
|
908
|
+
// 同 id 同 round 第二次被 notifyId 幂等吞
|
|
909
|
+
notifier.notify({ id: "sa-chat", status: "running", agent: "w", round: 1, result: "r1-again", startedAt: 5, endedAt: 6 });
|
|
910
|
+
|
|
911
|
+
expect(notifierHost.sentMessages).toHaveLength(2);
|
|
912
|
+
expect(notifierHost.sentMessages[0]?.details).toMatchObject({ notifyId: "sa-chat:1" });
|
|
913
|
+
expect(notifierHost.sentMessages[1]?.details).toMatchObject({ notifyId: "sa-chat:2" });
|
|
914
|
+
// chatMode 轮次通知文案(running 分支)不变
|
|
915
|
+
expect(notifierHost.sentMessages[0]?.content).toBe('Subagent "w" (sa-chat) finished a round. Reply:\nr1');
|
|
916
|
+
|
|
917
|
+
notifier.dispose();
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
it("ledger 未 bind 时退回内核路径(向后兼容,options 仍单通道 triggerTurn)", () => {
|
|
921
|
+
const notifierHost = makeNotifierHost();
|
|
922
|
+
const notifier = createNotifier(notifierHost);
|
|
923
|
+
notifier.notify(oneShotRecord("sa-nb"));
|
|
924
|
+
|
|
925
|
+
expect(notifierHost.sentMessages).toHaveLength(1);
|
|
926
|
+
expect(notifierHost.sentOptions[0]).toEqual({ triggerTurn: true });
|
|
927
|
+
expect(notifierHost.sentMessages[0]?.content).toBe('Subagent "worker" (sa-nb) completed. Result:\ndone');
|
|
928
|
+
|
|
929
|
+
notifier.dispose();
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
it("U4 warn 注入:内核路径 port.send 失败 → 警告经 core logger 出口,console.warn 零调用", () => {
|
|
933
|
+
// logger facade 经 host-services 动态解析(无 vi.mock):用例级注入 logCalls
|
|
934
|
+
// sink 覆盖文件级 no-op sink——U4 意图断言保持:警告落可检索通道(注入出口)
|
|
935
|
+
// 而非 console.warn(NULL_HOST 缺省出口,被 spy 监听断言零调用)。
|
|
936
|
+
const logCalls: Array<{ level: string; message: string; data?: unknown }> = [];
|
|
937
|
+
configureCore({
|
|
938
|
+
dataRoot: () => "/fake-notify-ledger-data-root",
|
|
939
|
+
log: (level, _component, message, data) => {
|
|
940
|
+
logCalls.push({ level, message, data });
|
|
941
|
+
},
|
|
942
|
+
});
|
|
943
|
+
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
944
|
+
try {
|
|
945
|
+
const notifierHost = makeNotifierHost();
|
|
946
|
+
// sendMessage 抛异常 = port.send 同步失败 → 内核 onSendFail → warn(经注入出口)
|
|
947
|
+
notifierHost.sendMessage = () => {
|
|
948
|
+
throw new Error("channel closed (mock)");
|
|
949
|
+
};
|
|
950
|
+
const notifier = createNotifier(notifierHost);
|
|
951
|
+
notifier.notify(oneShotRecord("sa-warn"));
|
|
952
|
+
|
|
953
|
+
const sendFailLogged = logCalls.some(
|
|
954
|
+
(l) => l.level === "warn" && typeof l.message === "string" && l.message.includes("port.send failed"),
|
|
955
|
+
);
|
|
956
|
+
expect(sendFailLogged).toBe(true);
|
|
957
|
+
expect(consoleWarnSpy).not.toHaveBeenCalled();
|
|
958
|
+
|
|
959
|
+
notifier.dispose();
|
|
960
|
+
} finally {
|
|
961
|
+
consoleWarnSpy.mockRestore();
|
|
962
|
+
}
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
it("dispose 摘除模块级绑定:后续 notify 走内核路径,settled 边沿静默", () => {
|
|
966
|
+
const ledgerMock = makeLedgerHost();
|
|
967
|
+
bindNotifyLedgerHost(ledgerMock.host);
|
|
968
|
+
const notifierHost = makeNotifierHost();
|
|
969
|
+
const notifier = createNotifier(notifierHost);
|
|
970
|
+
notifier.dispose();
|
|
971
|
+
|
|
972
|
+
fireSettled(ledgerMock);
|
|
973
|
+
// revive(/resume /fork /new 后):notifier 复活,但 ledger 已随 dispose 摘除
|
|
974
|
+
//——notify 退回内核路径
|
|
975
|
+
notifier.revive();
|
|
976
|
+
notifier.notify(oneShotRecord("sa-post-dispose"));
|
|
977
|
+
expect(notifierHost.sentMessages).toHaveLength(1); // 内核路径立即投(无 ledger)
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
// ─── MF-5: settled 监听单例化 ──────────────────────────────
|
|
983
|
+
//
|
|
984
|
+
// 回归背景:index.ts:428-430 每次 session_start 注册新 pi.on("agent_settled")
|
|
985
|
+
// handler,旧实例 dispose 只置标志不移除物理监听(pi 0.84.1 on() 无 off)——
|
|
986
|
+
// 长会话 N 次切换累积 N 个死 handler。修复:bind 路径首次注册模块级单例
|
|
987
|
+
// handler(settledEdgeDispatch),后续 bind 只换 boundLedger 引用。
|
|
988
|
+
|
|
989
|
+
describe("MF-5: settled 监听单例化(多次 bind 物理监听数不增)", () => {
|
|
990
|
+
beforeEach(() => {
|
|
991
|
+
_resetNotifyLedgerForTest();
|
|
992
|
+
});
|
|
993
|
+
afterEach(() => {
|
|
994
|
+
_resetNotifyLedgerForTest();
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
it("连续 3 次 bind(/resume /fork /new 的 session_start)→ onAgentSettled 注册仅首次发生", () => {
|
|
998
|
+
const mocks = [makeLedgerHost(), makeLedgerHost(), makeLedgerHost()];
|
|
999
|
+
const registerSpies = mocks.map((mock) => {
|
|
1000
|
+
const spy = vi.fn();
|
|
1001
|
+
mock.host.onAgentSettled = spy;
|
|
1002
|
+
return spy;
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
bindNotifyLedgerHost(mocks[0]!.host);
|
|
1006
|
+
bindNotifyLedgerHost(mocks[1]!.host);
|
|
1007
|
+
bindNotifyLedgerHost(mocks[2]!.host);
|
|
1008
|
+
|
|
1009
|
+
// 首次 bind 注册一次(单例 handler);后续 bind 只换 boundLedger 引用
|
|
1010
|
+
expect(registerSpies[0]).toHaveBeenCalledTimes(1);
|
|
1011
|
+
expect(registerSpies[1]).not.toHaveBeenCalled();
|
|
1012
|
+
expect(registerSpies[2]).not.toHaveBeenCalled();
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
it("边沿分发到当前活跃实例:旧 mock 上的边沿事件驱动新 ledger 投递,旧实例零动作", () => {
|
|
1016
|
+
const mock1 = makeLedgerHost();
|
|
1017
|
+
bindNotifyLedgerHost(mock1.host);
|
|
1018
|
+
const mock2 = makeLedgerHost();
|
|
1019
|
+
bindNotifyLedgerHost(mock2.host);
|
|
1020
|
+
|
|
1021
|
+
// 活跃实例(mock2 的 ledger)入账 pending
|
|
1022
|
+
const ledger = getBoundNotifyLedger();
|
|
1023
|
+
expect(ledger).toBeDefined();
|
|
1024
|
+
ledger!.record("sa-active", "content", { notifyId: "sa-active" });
|
|
1025
|
+
|
|
1026
|
+
// 旧 mock(mock1)上触发边沿——单例 handler 分发到 boundLedger(mock2)
|
|
1027
|
+
fireSettled(mock1);
|
|
1028
|
+
expect(mock2.sentMessages).toHaveLength(1);
|
|
1029
|
+
expect(mock2.sentMessages[0]?.details).toMatchObject({ notifyId: "sa-active" });
|
|
1030
|
+
// 旧实例无动作(disposed 短路语义:不会成为分发目标)
|
|
1031
|
+
expect(mock1.sentMessages).toHaveLength(0);
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
it("活跃实例 dispose 后边沿静默(boundLedger 摘除 → 单例 handler 无分发目标)", () => {
|
|
1035
|
+
const mock1 = makeLedgerHost();
|
|
1036
|
+
bindNotifyLedgerHost(mock1.host);
|
|
1037
|
+
getBoundNotifyLedger()!.dispose();
|
|
1038
|
+
|
|
1039
|
+
fireSettled(mock1);
|
|
1040
|
+
expect(mock1.sentMessages).toHaveLength(0);
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
it("直调 createNotifyLedger 注册行为不变(默认注册 / 显式 registerSettledListener:false 跳过)", () => {
|
|
1044
|
+
const mockDefault = makeLedgerHost();
|
|
1045
|
+
const ledgerDefault = createNotifyLedger(mockDefault.host);
|
|
1046
|
+
expect(mockDefault.settledHandlers).toHaveLength(1);
|
|
1047
|
+
ledgerDefault.dispose();
|
|
1048
|
+
|
|
1049
|
+
const mockSkip = makeLedgerHost();
|
|
1050
|
+
const ledgerSkip = createNotifyLedger(mockSkip.host, { registerSettledListener: false });
|
|
1051
|
+
expect(mockSkip.settledHandlers).toHaveLength(0);
|
|
1052
|
+
ledgerSkip.dispose();
|
|
1053
|
+
});
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
// ─── 常量等值钉住(notify-ledger 与 notifier/渲染器共用字符串) ──
|
|
1057
|
+
|
|
1058
|
+
describe("notify-ledger 常量锚", () => {
|
|
1059
|
+
it("NOTIFY_CUSTOM_TYPE 与 notifier 送达 customType / ledger 回执扫描同源", async () => {
|
|
1060
|
+
const notifierModule = await import("../notifier.ts");
|
|
1061
|
+
// notifier 模块不再自带本地常量(单一常量源 = notify-ledger);用运行时行为钉住:
|
|
1062
|
+
// ledger 回执扫描匹配的 customType === notifier 送达消息的 customType。
|
|
1063
|
+
const mock = makeLedgerHost();
|
|
1064
|
+
const notifierHost: NotifierHost = {
|
|
1065
|
+
sendMessage: (message) => {
|
|
1066
|
+
mock.host.sendDelivery(message);
|
|
1067
|
+
},
|
|
1068
|
+
hasRunningBackground: () => false,
|
|
1069
|
+
isIdle: () => true,
|
|
1070
|
+
};
|
|
1071
|
+
const ledger = bindNotifyLedgerHost(mock.host);
|
|
1072
|
+
const notifier = createNotifier(notifierHost);
|
|
1073
|
+
notifier.notify({ id: "sa-cst", status: "closed", agent: "w", result: "r", startedAt: 1, endedAt: 2 });
|
|
1074
|
+
fireSettled(mock);
|
|
1075
|
+
// 全链路销账成功 === 三处 customType 同一字符串(否则回执扫描不命中)
|
|
1076
|
+
expect(unsettledDiff(mock)).toEqual(new Set());
|
|
1077
|
+
expect(typeof notifierModule.createNotifier).toBe("function");
|
|
1078
|
+
ledger.dispose();
|
|
1079
|
+
_resetNotifyLedgerForTest();
|
|
1080
|
+
});
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
// ─── T4③ 重投止损(PS-6) ─────────────────────────────────
|
|
1084
|
+
|
|
1085
|
+
describe("NotifyLedger — T4③ 重投止损(PS-6)", () => {
|
|
1086
|
+
let logCalls: Array<{ level: string; component: string; message: string; data?: unknown }>;
|
|
1087
|
+
|
|
1088
|
+
beforeEach(() => {
|
|
1089
|
+
vi.useFakeTimers();
|
|
1090
|
+
logCalls = [];
|
|
1091
|
+
configureCore({
|
|
1092
|
+
dataRoot: () => "/fake-notify-ledger-data-root",
|
|
1093
|
+
log: (level, component, message, data) => {
|
|
1094
|
+
logCalls.push({ level, component, message, data });
|
|
1095
|
+
},
|
|
1096
|
+
});
|
|
1097
|
+
_resetNotifyLedgerForTest();
|
|
1098
|
+
});
|
|
1099
|
+
afterEach(() => {
|
|
1100
|
+
_resetNotifyLedgerForTest();
|
|
1101
|
+
vi.useRealTimers();
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
it("① 回执不可匹配 → 重投至 attempts 上限后放弃:abandoned 终态 entry + warn 含恢复指引,此后零重投", () => {
|
|
1105
|
+
const mock = makeLedgerHost();
|
|
1106
|
+
// 回执永不可匹配(模拟 compaction 清除送达 entry —— PS-6 触发态)
|
|
1107
|
+
mock.deliverPersists.value = false;
|
|
1108
|
+
const ledger = createNotifyLedger(mock.host);
|
|
1109
|
+
|
|
1110
|
+
ledger.record("sa-cap", "content", { notifyId: "sa-cap", id: "sa-cap", agent: "worker" });
|
|
1111
|
+
ledger.attemptDeliver(); // 第 1 次投递(attempts=1)
|
|
1112
|
+
expect(mock.sentMessages).toHaveLength(1);
|
|
1113
|
+
|
|
1114
|
+
// 未达上限:每周期照常超时重投(共 4 次重投,attempts 累至上限)
|
|
1115
|
+
for (let i = 2; i <= NOTIFY_REDELIVERY_MAX_ATTEMPTS; i += 1) {
|
|
1116
|
+
vi.advanceTimersByTime(NOTIFY_WATCHDOG_MS);
|
|
1117
|
+
expect(mock.sentMessages).toHaveLength(i);
|
|
1118
|
+
}
|
|
1119
|
+
expect(abandonedEntries(mock)).toHaveLength(0);
|
|
1120
|
+
expect(ledger.waitingReceiptCount()).toBe(1);
|
|
1121
|
+
|
|
1122
|
+
// 第 5 个周期:attempts 已达上限仍无回执 → 放弃,不再重投
|
|
1123
|
+
vi.advanceTimersByTime(NOTIFY_WATCHDOG_MS);
|
|
1124
|
+
expect(mock.sentMessages).toHaveLength(NOTIFY_REDELIVERY_MAX_ATTEMPTS); // 零新增
|
|
1125
|
+
// 终态 entry 落盘(与 ack 同形:{ v:1, notifyId })
|
|
1126
|
+
const abandoned = abandonedEntries(mock);
|
|
1127
|
+
expect(abandoned).toHaveLength(1);
|
|
1128
|
+
expect(abandoned[0]?.data).toEqual({ v: 1, notifyId: "sa-cap" });
|
|
1129
|
+
// 账面摘除:pending / waiting 归零,差集为空(ledger − ack − abandoned)
|
|
1130
|
+
expect(ledger.pendingCount()).toBe(0);
|
|
1131
|
+
expect(ledger.waitingReceiptCount()).toBe(0);
|
|
1132
|
+
expect(unsettledDiff(mock)).toEqual(new Set());
|
|
1133
|
+
// 放弃不计入 watchdogReplays 桶(该桶口径 = 实际重投条数 = 上限 − 1)
|
|
1134
|
+
expect(ledger.deliveryMetrics().watchdogReplays).toBe(NOTIFY_REDELIVERY_MAX_ATTEMPTS - 1);
|
|
1135
|
+
|
|
1136
|
+
// warn:含 subagent 标识(agent 名)与 subagents action:"list" 恢复指引;
|
|
1137
|
+
// notifyId / attempts 动态值在 data(D4 约定,msg 近固定 key)
|
|
1138
|
+
const warns = abandonedWarns(logCalls);
|
|
1139
|
+
expect(warns).toHaveLength(1);
|
|
1140
|
+
expect(warns[0]?.component).toBe("subagents");
|
|
1141
|
+
expect(warns[0]?.message).toContain("worker");
|
|
1142
|
+
expect(warns[0]?.message).toContain('subagents action:"list"');
|
|
1143
|
+
expect(warns[0]?.data).toEqual({ notifyId: "sa-cap", attempts: NOTIFY_REDELIVERY_MAX_ATTEMPTS });
|
|
1144
|
+
|
|
1145
|
+
// 止损后看门狗空转:同一条通知不无限重复注入
|
|
1146
|
+
vi.advanceTimersByTime(NOTIFY_WATCHDOG_MS * 3);
|
|
1147
|
+
expect(mock.sentMessages).toHaveLength(NOTIFY_REDELIVERY_MAX_ATTEMPTS);
|
|
1148
|
+
|
|
1149
|
+
// 放弃是终态:同 notifyId 再 record 被幂等拒绝(绝不重发)
|
|
1150
|
+
expect(ledger.record("sa-cap", "content", { notifyId: "sa-cap" })).toBe(false);
|
|
1151
|
+
|
|
1152
|
+
ledger.dispose();
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
it("② 放弃条目重启恢复不复活:recoverFromSession 跳过 abandoned 差集,watchdog/settled 零投递", () => {
|
|
1156
|
+
const mock = makeLedgerHost();
|
|
1157
|
+
// 模拟重启前落盘:s-gone 已放弃(ledger + abandoned 两列),s-live 未销账未放弃
|
|
1158
|
+
mock.sessionEntries.push(
|
|
1159
|
+
{ type: "custom", customType: NOTIFY_LEDGER_CUSTOM_TYPE, data: { v: 1, notifyId: "s-gone", content: "cg", record: { notifyId: "s-gone", agent: "gone-agent" } } },
|
|
1160
|
+
{ type: "custom", customType: NOTIFY_ABANDONED_CUSTOM_TYPE, data: { v: 1, notifyId: "s-gone" } },
|
|
1161
|
+
{ type: "custom", customType: NOTIFY_LEDGER_CUSTOM_TYPE, data: { v: 1, notifyId: "s-live", content: "cl", record: { notifyId: "s-live" } } },
|
|
1162
|
+
);
|
|
1163
|
+
mock.setIdle(false); // busy:恢复路径的 attemptDeliver 挂 pending,隔离投递时机
|
|
1164
|
+
|
|
1165
|
+
const ledger = createNotifyLedger(mock.host);
|
|
1166
|
+
// 仅 s-live 重放;s-gone 被放弃终态挡住
|
|
1167
|
+
expect(ledger.recoverFromSession()).toBe(1);
|
|
1168
|
+
expect(mock.sentMessages).toHaveLength(0);
|
|
1169
|
+
expect(ledger.pendingCount()).toBe(1);
|
|
1170
|
+
|
|
1171
|
+
// 放弃号幂等拒绝(重启后 record 也不复活)
|
|
1172
|
+
expect(ledger.record("s-gone", "cg", { notifyId: "s-gone" })).toBe(false);
|
|
1173
|
+
|
|
1174
|
+
// 看门狗多周期 + settled 边沿:s-gone 零投递
|
|
1175
|
+
vi.advanceTimersByTime(NOTIFY_WATCHDOG_MS * 3);
|
|
1176
|
+
expect(mock.sentMessages).toHaveLength(0);
|
|
1177
|
+
fireSettled(mock);
|
|
1178
|
+
expect(mock.sentMessages).toHaveLength(0);
|
|
1179
|
+
|
|
1180
|
+
// 对照:idle 后 s-live 正常补投(证明差集重放本身未被破坏)
|
|
1181
|
+
mock.setIdle(true);
|
|
1182
|
+
fireSettled(mock);
|
|
1183
|
+
expect(mock.sentMessages).toHaveLength(1);
|
|
1184
|
+
expect(mock.sentMessages[0]?.details).toMatchObject({ notifyId: "s-live" });
|
|
1185
|
+
|
|
1186
|
+
ledger.dispose();
|
|
1187
|
+
});
|
|
1188
|
+
|
|
1189
|
+
it("③ 未达上限时正常重投行为不变:照常超时重投,回执到达后照常销账", () => {
|
|
1190
|
+
const mock = makeLedgerHost();
|
|
1191
|
+
// 前三轮回执不可匹配(重投路径)
|
|
1192
|
+
mock.deliverPersists.value = false;
|
|
1193
|
+
const ledger = createNotifyLedger(mock.host);
|
|
1194
|
+
|
|
1195
|
+
ledger.record("sa-ok", "content", { notifyId: "sa-ok", id: "sa-ok", agent: "worker" });
|
|
1196
|
+
ledger.attemptDeliver(); // 第 1 次投递
|
|
1197
|
+
expect(mock.sentMessages).toHaveLength(1);
|
|
1198
|
+
|
|
1199
|
+
// 两个周期无回执 → 两次照常重投(attempts=3 < 上限),无任何放弃痕迹
|
|
1200
|
+
vi.advanceTimersByTime(NOTIFY_WATCHDOG_MS * 2);
|
|
1201
|
+
expect(mock.sentMessages).toHaveLength(3);
|
|
1202
|
+
expect(abandonedEntries(mock)).toHaveLength(0);
|
|
1203
|
+
expect(abandonedWarns(logCalls)).toHaveLength(0);
|
|
1204
|
+
expect(ledger.deliveryMetrics().watchdogReplays).toBe(2);
|
|
1205
|
+
|
|
1206
|
+
// 回执恢复可达:本周期重投落盘 → settled 边沿销账(既有 at-least-once 闭环不变)
|
|
1207
|
+
mock.deliverPersists.value = true;
|
|
1208
|
+
vi.advanceTimersByTime(NOTIFY_WATCHDOG_MS); // 第 3 次重投(本次落盘)
|
|
1209
|
+
expect(mock.sentMessages).toHaveLength(4);
|
|
1210
|
+
fireSettled(mock);
|
|
1211
|
+
expect(unsettledDiff(mock)).toEqual(new Set());
|
|
1212
|
+
expect(ledger.waitingReceiptCount()).toBe(0);
|
|
1213
|
+
expect(abandonedEntries(mock)).toHaveLength(0);
|
|
1214
|
+
|
|
1215
|
+
ledger.dispose();
|
|
1216
|
+
});
|
|
1217
|
+
|
|
1218
|
+
it("放弃终态持久化:abandoned entry 被 compaction 清除后按内存态补写,重启仍不复活", () => {
|
|
1219
|
+
const mock = makeLedgerHost();
|
|
1220
|
+
mock.deliverPersists.value = false;
|
|
1221
|
+
const ledger = createNotifyLedger(mock.host);
|
|
1222
|
+
|
|
1223
|
+
ledger.record("sa-cp", "content", { notifyId: "sa-cp", id: "sa-cp", agent: "worker" });
|
|
1224
|
+
ledger.attemptDeliver();
|
|
1225
|
+
vi.advanceTimersByTime(NOTIFY_WATCHDOG_MS * NOTIFY_REDELIVERY_MAX_ATTEMPTS); // 达上限放弃
|
|
1226
|
+
expect(abandonedEntries(mock)).toHaveLength(1);
|
|
1227
|
+
|
|
1228
|
+
// 模拟 compaction 只清掉 abandoned entry(ledger entry 保留)
|
|
1229
|
+
const idx = mock.entries.findIndex((e) => e.customType === NOTIFY_ABANDONED_CUSTOM_TYPE);
|
|
1230
|
+
expect(idx).toBeGreaterThanOrEqual(0);
|
|
1231
|
+
mock.entries.splice(idx, 1);
|
|
1232
|
+
|
|
1233
|
+
// compaction 降级补写放弃终态(内存是权威)
|
|
1234
|
+
expect(ledger.compactionCheck()).toBe(1);
|
|
1235
|
+
expect(abandonedEntries(mock)).toHaveLength(1);
|
|
1236
|
+
ledger.dispose();
|
|
1237
|
+
|
|
1238
|
+
// 重启:abandoned 在场 → 不复活不重投
|
|
1239
|
+
const mock2 = makeLedgerHost();
|
|
1240
|
+
mock2.sessionEntries.push(...mock.entries);
|
|
1241
|
+
mock2.setIdle(false);
|
|
1242
|
+
const ledger2 = createNotifyLedger(mock2.host);
|
|
1243
|
+
expect(ledger2.recoverFromSession()).toBe(0);
|
|
1244
|
+
mock2.setIdle(true);
|
|
1245
|
+
fireSettled(mock2);
|
|
1246
|
+
expect(mock2.sentMessages).toHaveLength(0);
|
|
1247
|
+
ledger2.dispose();
|
|
1248
|
+
});
|
|
1249
|
+
});
|