@adhdev/daemon-core 0.9.82-rc.49 → 0.9.82-rc.490
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/dist/boot/daemon-lifecycle.d.ts +9 -0
- package/dist/boot/process-hardening.d.ts +50 -0
- package/dist/build-info.d.ts +37 -0
- package/dist/chat/source-machine.d.ts +166 -0
- package/dist/chat/source-resolver.d.ts +104 -0
- package/dist/chat/subscription-updates.d.ts +1 -0
- package/dist/cli-adapter-types.d.ts +68 -2
- package/dist/cli-adapters/cli-script-runner.d.ts +117 -0
- package/dist/cli-adapters/cli-state-engine.d.ts +220 -0
- package/dist/cli-adapters/provider-cli-adapter.d.ts +137 -64
- package/dist/cli-adapters/provider-cli-parse.d.ts +5 -0
- package/dist/cli-adapters/provider-cli-runtime.d.ts +34 -0
- package/dist/cli-adapters/provider-cli-shared.d.ts +74 -0
- package/dist/cli-adapters/pty-write-chunking.d.ts +34 -0
- package/dist/cli-adapters/raw-terminal-io.d.ts +37 -0
- package/dist/cli-adapters/resolve-executable.d.ts +69 -0
- package/dist/cli-adapters/terminal-backends/ghostty-vt-backend.d.ts +6 -3
- package/dist/cli-adapters/terminal-backends/types.d.ts +10 -2
- package/dist/cli-adapters/terminal-screen.d.ts +4 -11
- package/dist/commands/chat-commands-debug-bundle.d.ts +14 -0
- package/dist/commands/chat-commands-read.d.ts +23 -0
- package/dist/commands/chat-commands-scope.d.ts +39 -0
- package/dist/commands/chat-commands-shared.d.ts +33 -0
- package/dist/commands/chat-commands-write.d.ts +14 -0
- package/dist/commands/chat-commands.d.ts +10 -23
- package/dist/commands/cli-manager.d.ts +71 -1
- package/dist/commands/handler.d.ts +125 -0
- package/dist/commands/high-family/index.d.ts +3 -0
- package/dist/commands/high-family/mesh-coordinator-launch.d.ts +2 -0
- package/dist/commands/high-family/mesh-events.d.ts +2 -0
- package/dist/commands/high-family/mesh-status.d.ts +2 -0
- package/dist/commands/high-family/types.d.ts +64 -0
- package/dist/commands/low-family/coordinator-prompt.d.ts +9 -0
- package/dist/commands/low-family/daemon-lifecycle.d.ts +2 -0
- package/dist/commands/low-family/diagnostics.d.ts +2 -0
- package/dist/commands/low-family/index.d.ts +3 -0
- package/dist/commands/low-family/mesh-ledger.d.ts +10 -0
- package/dist/commands/low-family/mesh-node-logs.d.ts +2 -0
- package/dist/commands/low-family/notification.d.ts +2 -0
- package/dist/commands/low-family/refine-config.d.ts +2 -0
- package/dist/commands/low-family/session-host.d.ts +2 -0
- package/dist/commands/low-family/spec-providerdev.d.ts +11 -0
- package/dist/commands/low-family/status-meta.d.ts +2 -0
- package/dist/commands/low-family/types.d.ts +33 -0
- package/dist/commands/med-family/cli-agent.d.ts +2 -0
- package/dist/commands/med-family/fast-forward.d.ts +2 -0
- package/dist/commands/med-family/ide.d.ts +10 -0
- package/dist/commands/med-family/index.d.ts +3 -0
- package/dist/commands/med-family/mesh-crud.d.ts +48 -0
- package/dist/commands/med-family/mesh-host-pairing.d.ts +2 -0
- package/dist/commands/med-family/mesh-queue.d.ts +2 -0
- package/dist/commands/med-family/mesh-restart.d.ts +2 -0
- package/dist/commands/med-family/types.d.ts +152 -0
- package/dist/commands/mesh-coordinator.d.ts +85 -1
- package/dist/commands/router-aggregate-status.d.ts +24 -0
- package/dist/commands/router-mesh-session-owner.d.ts +59 -0
- package/dist/commands/router-refine.d.ts +151 -0
- package/dist/commands/router-worktree-cleanup.d.ts +133 -0
- package/dist/commands/router.d.ts +203 -15
- package/dist/commands/upgrade-helper.d.ts +41 -1
- package/dist/config/chat-history.d.ts +11 -0
- package/dist/config/config.d.ts +19 -0
- package/dist/config/mesh-config.d.ts +65 -0
- package/dist/config/mesh-json-config.d.ts +199 -0
- package/dist/config/registry-resolver.d.ts +54 -0
- package/dist/config/repo-settings.d.ts +77 -0
- package/dist/config/state-store.d.ts +30 -0
- package/dist/daemon/dev-server.d.ts +0 -2
- package/dist/detection/cli-detector.d.ts +17 -0
- package/dist/detection/ide-detector.d.ts +13 -0
- package/dist/detection/win32-ide-version.d.ts +37 -0
- package/dist/git/change-impact-config.d.ts +159 -0
- package/dist/git/git-commands.d.ts +26 -2
- package/dist/git/git-diff.d.ts +6 -0
- package/dist/git/git-executor.d.ts +11 -0
- package/dist/git/git-status.d.ts +80 -0
- package/dist/git/git-types.d.ts +2 -50
- package/dist/git/git-worktree.d.ts +71 -1
- package/dist/git/index.d.ts +3 -1
- package/dist/index.d.ts +70 -17
- package/dist/index.js +53532 -19073
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +53283 -18989
- package/dist/index.mjs.map +1 -1
- package/dist/installer.d.ts +1 -4
- package/dist/ipc/local-ipc-server.d.ts +91 -0
- package/dist/launch.d.ts +1 -1
- package/dist/logging/async-batch-writer.d.ts +10 -0
- package/dist/logging/debug-config.d.ts +16 -0
- package/dist/logging/log-redactor.d.ts +24 -0
- package/dist/logging/log-tail-reader.d.ts +81 -0
- package/dist/logging/logger.d.ts +1 -1
- package/dist/mesh/contracts.d.ts +256 -0
- package/dist/mesh/coordinator-prompt.d.ts +76 -0
- package/dist/mesh/coordinator-registry.d.ts +59 -0
- package/dist/mesh/mesh-active-work.d.ts +203 -0
- package/dist/mesh/mesh-clone-grace.d.ts +8 -0
- package/dist/mesh/mesh-completion-synthesis.d.ts +4 -0
- package/dist/mesh/mesh-coordinator-config.d.ts +21 -0
- package/dist/mesh/mesh-delivery-policy.d.ts +104 -0
- package/dist/mesh/mesh-event-classify.d.ts +7 -0
- package/dist/mesh/mesh-event-forwarding.d.ts +58 -0
- package/dist/mesh/mesh-event-trace.d.ts +21 -0
- package/dist/mesh/mesh-events-coordinator.d.ts +4 -0
- package/dist/mesh/mesh-events-pending.d.ts +258 -0
- package/dist/mesh/mesh-events-stale.d.ts +61 -0
- package/dist/mesh/mesh-events-utils.d.ts +75 -0
- package/dist/mesh/mesh-events.d.ts +6 -49
- package/dist/mesh/mesh-fast-forward.d.ts +81 -0
- package/dist/mesh/mesh-host-ownership.d.ts +21 -1
- package/dist/mesh/mesh-init.d.ts +128 -0
- package/dist/mesh/mesh-ledger-reconciliation.d.ts +23 -4
- package/dist/mesh/mesh-ledger.d.ts +139 -1
- package/dist/mesh/mesh-magi-status.d.ts +63 -0
- package/dist/mesh/mesh-missions.d.ts +281 -0
- package/dist/mesh/mesh-node-identity.d.ts +310 -0
- package/dist/mesh/mesh-queue-assignment.d.ts +162 -0
- package/dist/mesh/mesh-reconcile-acked-hold.d.ts +17 -0
- package/dist/mesh/mesh-reconcile-config.d.ts +6 -0
- package/dist/mesh/mesh-reconcile-identity.d.ts +6 -0
- package/dist/mesh/mesh-reconcile-loop.d.ts +84 -0
- package/dist/mesh/mesh-reconcile-v2-backstop.d.ts +16 -0
- package/dist/mesh/mesh-refine-batch.d.ts +68 -0
- package/dist/mesh/mesh-refine-gates.d.ts +457 -0
- package/dist/mesh/mesh-refine-status.d.ts +62 -0
- package/dist/mesh/mesh-remote-event-pull.d.ts +15 -0
- package/dist/mesh/mesh-review-inbox.d.ts +90 -0
- package/dist/mesh/mesh-routing.d.ts +70 -0
- package/dist/mesh/mesh-runtime-store.d.ts +567 -0
- package/dist/mesh/mesh-scheduling-runtime.d.ts +78 -0
- package/dist/mesh/mesh-task-inflight.d.ts +46 -0
- package/dist/mesh/mesh-task-stats.d.ts +49 -0
- package/dist/mesh/mesh-unresolved-forward-outbox.d.ts +34 -0
- package/dist/mesh/mesh-warmup-deadline.d.ts +68 -0
- package/dist/mesh/mesh-work-queue.d.ts +392 -1
- package/dist/mesh/preview-freshness.d.ts +28 -0
- package/dist/mesh/refine-config.d.ts +97 -0
- package/dist/mesh/worktree-bootstrap-config.d.ts +219 -0
- package/dist/providers/acp-provider-instance.d.ts +5 -0
- package/dist/providers/approval-utils.d.ts +46 -0
- package/dist/providers/chat-message-normalization.d.ts +58 -1
- package/dist/providers/cli-provider-effect-format.d.ts +30 -0
- package/dist/providers/cli-provider-history-dedup.d.ts +17 -0
- package/dist/providers/cli-provider-input-prompt.d.ts +12 -0
- package/dist/providers/cli-provider-instance-types.d.ts +45 -0
- package/dist/providers/cli-provider-instance.d.ts +426 -30
- package/dist/providers/cli-provider-status-helpers.d.ts +46 -0
- package/dist/providers/cli-provider-transcript-merge.d.ts +7 -0
- package/dist/providers/contracts.d.ts +196 -6
- package/dist/providers/external-sources.d.ts +71 -0
- package/dist/providers/manual-attendance.d.ts +79 -0
- package/dist/providers/native-history/antigravity-claim-registry.d.ts +39 -0
- package/dist/providers/native-history/antigravity-cli-transcript.d.ts +137 -0
- package/dist/providers/native-history/claude-cli-transcript.d.ts +70 -0
- package/dist/providers/native-history/codex-cli-transcript.d.ts +73 -0
- package/dist/providers/native-history/constants.d.ts +12 -0
- package/dist/providers/native-history/dispatcher.d.ts +44 -0
- package/dist/providers/native-history/hermes-cli-transcript.d.ts +30 -0
- package/dist/providers/native-history/index.d.ts +13 -0
- package/dist/providers/provider-instance-manager.d.ts +30 -0
- package/dist/providers/provider-instance.d.ts +31 -1
- package/dist/providers/provider-loader.d.ts +42 -5
- package/dist/providers/provider-trust.d.ts +31 -0
- package/dist/providers/read-chat-contract.d.ts +29 -0
- package/dist/providers/sdk/v1/builders/acp/detect-status.d.ts +68 -0
- package/dist/providers/sdk/v1/builders/cli/detect-status.d.ts +86 -0
- package/dist/providers/sdk/v1/builders/cli/parse-approval-squash.d.ts +59 -0
- package/dist/providers/sdk/v1/builders/cli/parse-approval.d.ts +65 -0
- package/dist/providers/sdk/v1/builders/cli/parse-session.d.ts +91 -0
- package/dist/providers/sdk/v1/builders/cli/visible-region.d.ts +42 -0
- package/dist/providers/sdk/v1/fixture-tooling/format.d.ts +126 -0
- package/dist/providers/sdk/v1/fixture-tooling/index.d.ts +8 -0
- package/dist/providers/sdk/v1/fixture-tooling/replay.d.ts +38 -0
- package/dist/providers/sdk/v1/index.d.ts +30 -0
- package/dist/providers/sdk/v1/sandbox/README-design.d.ts +193 -0
- package/dist/providers/sdk/v1/sandbox/require-whitelist.d.ts +74 -0
- package/dist/providers/sdk/v1/sandbox/script-runner.d.ts +98 -0
- package/dist/providers/sdk/v1/types/cli/index.d.ts +277 -0
- package/dist/providers/sdk/v1/types/common/index.d.ts +203 -0
- package/dist/providers/sdk/v1/validators/index.d.ts +5 -0
- package/dist/providers/sdk/v1/validators/manifest.d.ts +40 -0
- package/dist/providers/sdk/v1/validators/taint.d.ts +52 -0
- package/dist/providers/spec/adapter.d.ts +98 -0
- package/dist/providers/spec/cli-adapter.d.ts +223 -0
- package/dist/providers/spec/evaluator.d.ts +45 -0
- package/dist/providers/spec/fsm-driver.d.ts +449 -0
- package/dist/providers/spec/fsm-evaluator.d.ts +93 -0
- package/dist/providers/spec/fsm-loader.d.ts +14 -0
- package/dist/providers/spec/fsm-types.d.ts +202 -0
- package/dist/providers/spec/native-history-executor.d.ts +41 -0
- package/dist/providers/spec/pre-launch-trust.d.ts +16 -0
- package/dist/providers/spec/route.d.ts +4 -0
- package/dist/providers/spec/types.d.ts +244 -0
- package/dist/providers/status-monitor.d.ts +7 -7
- package/dist/providers/transcript-v2.d.ts +176 -0
- package/dist/providers/types/interactive-prompt.d.ts +84 -0
- package/dist/providers/working-dir.d.ts +17 -0
- package/dist/repo-mesh-types.d.ts +616 -12
- package/dist/runtime-defaults.d.ts +2 -0
- package/dist/session-host/managed-host.d.ts +64 -0
- package/dist/session-host/runtime-surface.d.ts +10 -16
- package/dist/sessions/registry.d.ts +26 -0
- package/dist/shared-types-extra.d.ts +2 -4
- package/dist/shared-types.d.ts +110 -55
- package/dist/status/chat-tail-hot-sessions.d.ts +40 -0
- package/dist/status/normalize.d.ts +1 -1
- package/dist/status/normalize.js +1 -0
- package/dist/status/normalize.js.map +1 -1
- package/dist/status/normalize.mjs +1 -0
- package/dist/status/normalize.mjs.map +1 -1
- package/dist/status/reporter.d.ts +2 -0
- package/dist/status/snapshot.d.ts +27 -0
- package/dist/system/hash.d.ts +8 -0
- package/dist/system/load-better-sqlite3.d.ts +21 -0
- package/dist/types.d.ts +20 -6
- package/package.json +11 -4
- package/src/agent-stream/poller.ts +2 -3
- package/src/agent-stream/provider-adapter.ts +1 -1
- package/src/boot/daemon-lifecycle.ts +80 -14
- package/src/boot/process-hardening.ts +89 -0
- package/src/build-info.ts +73 -0
- package/src/chat/source-machine.ts +534 -0
- package/src/chat/source-resolver.ts +0 -0
- package/src/chat/subscription-updates.ts +20 -1
- package/src/cli-adapter-types.d.ts +3 -1
- package/src/cli-adapter-types.ts +68 -2
- package/src/cli-adapters/cli-script-runner.ts +421 -0
- package/src/cli-adapters/cli-state-engine.ts +1282 -0
- package/src/cli-adapters/provider-cli-adapter.d.ts +1 -1
- package/src/cli-adapters/provider-cli-adapter.ts +1048 -1143
- package/src/cli-adapters/provider-cli-parse.d.ts +2 -0
- package/src/cli-adapters/provider-cli-parse.ts +19 -0
- package/src/cli-adapters/provider-cli-runtime.ts +56 -7
- package/src/cli-adapters/provider-cli-shared.d.ts +4 -0
- package/src/cli-adapters/provider-cli-shared.ts +121 -11
- package/src/cli-adapters/pty-transport.ts +2 -1
- package/src/cli-adapters/pty-write-chunking.ts +106 -0
- package/src/cli-adapters/raw-terminal-io.ts +252 -0
- package/src/cli-adapters/resolve-executable.ts +204 -0
- package/src/cli-adapters/session-host-transport.ts +2 -1
- package/src/cli-adapters/terminal-backends/ghostty-vt-backend.ts +63 -29
- package/src/cli-adapters/terminal-backends/types.ts +10 -3
- package/src/cli-adapters/terminal-screen.ts +16 -81
- package/src/commands/chat-commands-debug-bundle.ts +398 -0
- package/src/commands/chat-commands-read.ts +2788 -0
- package/src/commands/chat-commands-scope.ts +54 -0
- package/src/commands/chat-commands-shared.ts +114 -0
- package/src/commands/chat-commands-write.ts +891 -0
- package/src/commands/chat-commands.ts +19 -1841
- package/src/commands/cli-manager.d.ts +2 -0
- package/src/commands/cli-manager.ts +724 -27
- package/src/commands/handler.ts +865 -2
- package/src/commands/high-family/index.ts +28 -0
- package/src/commands/high-family/mesh-coordinator-launch.ts +696 -0
- package/src/commands/high-family/mesh-events.ts +102 -0
- package/src/commands/high-family/mesh-status.ts +857 -0
- package/src/commands/high-family/types.ts +80 -0
- package/src/commands/low-family/coordinator-prompt.ts +125 -0
- package/src/commands/low-family/daemon-lifecycle.ts +120 -0
- package/src/commands/low-family/diagnostics.ts +57 -0
- package/src/commands/low-family/index.ts +37 -0
- package/src/commands/low-family/mesh-ledger.ts +62 -0
- package/src/commands/low-family/mesh-node-logs.ts +87 -0
- package/src/commands/low-family/notification.ts +116 -0
- package/src/commands/low-family/refine-config.ts +106 -0
- package/src/commands/low-family/session-host.ts +282 -0
- package/src/commands/low-family/spec-providerdev.ts +217 -0
- package/src/commands/low-family/status-meta.ts +112 -0
- package/src/commands/low-family/types.ts +39 -0
- package/src/commands/med-family/cli-agent.ts +270 -0
- package/src/commands/med-family/fast-forward.ts +230 -0
- package/src/commands/med-family/ide.ts +163 -0
- package/src/commands/med-family/index.ts +37 -0
- package/src/commands/med-family/mesh-crud.ts +1268 -0
- package/src/commands/med-family/mesh-host-pairing.ts +234 -0
- package/src/commands/med-family/mesh-queue.ts +167 -0
- package/src/commands/med-family/mesh-restart.ts +92 -0
- package/src/commands/med-family/types.ts +153 -0
- package/src/commands/mesh-coordinator.ts +334 -124
- package/src/commands/router-aggregate-status.ts +209 -0
- package/src/commands/router-mesh-session-owner.ts +114 -0
- package/src/commands/router-refine.ts +1794 -0
- package/src/commands/router-worktree-cleanup.ts +870 -0
- package/src/commands/router.ts +567 -4149
- package/src/commands/stream-commands.ts +8 -0
- package/src/commands/upgrade-helper.ts +310 -45
- package/src/config/chat-history.ts +517 -24
- package/src/config/config.ts +30 -0
- package/src/config/mesh-config.ts +300 -24
- package/src/config/mesh-json-config.ts +376 -0
- package/src/config/recent-activity.ts +8 -2
- package/src/config/registry-resolver.ts +100 -0
- package/src/config/repo-settings.ts +111 -0
- package/src/config/state-store.ts +55 -0
- package/src/daemon/dev-auto-implement.ts +3 -2
- package/src/daemon/dev-cli-debug.ts +10 -1
- package/src/daemon/dev-server.ts +0 -541
- package/src/detection/cli-detector.ts +94 -9
- package/src/detection/ide-detector.ts +55 -16
- package/src/detection/win32-ide-version.ts +106 -0
- package/src/git/change-impact-config.ts +354 -0
- package/src/git/git-commands.ts +112 -16
- package/src/git/git-diff.ts +81 -11
- package/src/git/git-executor.ts +12 -0
- package/src/git/git-status.ts +822 -48
- package/src/git/git-types.ts +14 -62
- package/src/git/git-worktree.ts +261 -4
- package/src/git/index.ts +17 -0
- package/src/index.ts +190 -13
- package/src/installer.d.ts +1 -1
- package/src/installer.ts +8 -6
- package/src/ipc/local-ipc-server.ts +278 -0
- package/src/launch.d.ts +1 -1
- package/src/launch.ts +37 -28
- package/src/logging/async-batch-writer.ts +55 -0
- package/src/logging/command-log.ts +7 -5
- package/src/logging/debug-config.ts +25 -0
- package/src/logging/debug-trace.ts +7 -2
- package/src/logging/log-redactor.ts +100 -0
- package/src/logging/log-tail-reader.ts +341 -0
- package/src/logging/logger.ts +14 -7
- package/src/mesh/contracts.ts +510 -0
- package/src/mesh/coordinator-prompt.ts +627 -34
- package/src/mesh/coordinator-registry.ts +121 -0
- package/src/mesh/mesh-active-work.ts +706 -0
- package/src/mesh/mesh-clone-grace.ts +68 -0
- package/src/mesh/mesh-completion-synthesis.ts +416 -0
- package/src/mesh/mesh-coordinator-config.ts +97 -0
- package/src/mesh/mesh-delivery-policy.ts +284 -0
- package/src/mesh/mesh-event-classify.ts +78 -0
- package/src/mesh/mesh-event-forwarding.ts +2030 -0
- package/src/mesh/mesh-event-trace.ts +67 -0
- package/src/mesh/mesh-events-coordinator.ts +32 -0
- package/src/mesh/mesh-events-pending.ts +1548 -0
- package/src/mesh/mesh-events-stale.ts +452 -0
- package/src/mesh/mesh-events-utils.ts +443 -0
- package/src/mesh/mesh-events.ts +43 -1035
- package/src/mesh/mesh-fast-forward.ts +856 -0
- package/src/mesh/mesh-host-ownership.ts +41 -3
- package/src/mesh/mesh-init.ts +350 -0
- package/src/mesh/mesh-ledger-reconciliation.ts +12 -7
- package/src/mesh/mesh-ledger.ts +897 -102
- package/src/mesh/mesh-magi-status.ts +223 -0
- package/src/mesh/mesh-missions.ts +695 -0
- package/src/mesh/mesh-node-identity.ts +2065 -0
- package/src/mesh/mesh-queue-assignment.ts +2498 -0
- package/src/mesh/mesh-reconcile-acked-hold.ts +230 -0
- package/src/mesh/mesh-reconcile-config.ts +66 -0
- package/src/mesh/mesh-reconcile-identity.ts +103 -0
- package/src/mesh/mesh-reconcile-loop.ts +1696 -0
- package/src/mesh/mesh-reconcile-v2-backstop.ts +62 -0
- package/src/mesh/mesh-refine-batch.ts +205 -0
- package/src/mesh/mesh-refine-gates.ts +1792 -0
- package/src/mesh/mesh-refine-status.ts +231 -0
- package/src/mesh/mesh-remote-event-pull.ts +279 -0
- package/src/mesh/mesh-review-inbox.ts +308 -0
- package/src/mesh/mesh-routing.ts +291 -0
- package/src/mesh/mesh-runtime-store.ts +2292 -0
- package/src/mesh/mesh-scheduling-runtime.ts +198 -0
- package/src/mesh/mesh-task-inflight.ts +70 -0
- package/src/mesh/mesh-task-stats.ts +161 -0
- package/src/mesh/mesh-unresolved-forward-outbox.ts +215 -0
- package/src/mesh/mesh-warmup-deadline.ts +152 -0
- package/src/mesh/mesh-work-queue.ts +1489 -145
- package/src/mesh/preview-freshness.ts +163 -0
- package/src/mesh/refine-config.ts +129 -12
- package/src/mesh/worktree-bootstrap-config.ts +599 -0
- package/src/providers/acp-provider-instance.ts +43 -10
- package/src/providers/approval-utils.d.ts +5 -0
- package/src/providers/approval-utils.ts +100 -6
- package/src/providers/chat-message-normalization.ts +126 -4
- package/src/providers/cli-provider-effect-format.ts +53 -0
- package/src/providers/cli-provider-history-dedup.ts +75 -0
- package/src/providers/cli-provider-input-prompt.ts +133 -0
- package/src/providers/cli-provider-instance-types.ts +131 -0
- package/src/providers/cli-provider-instance.ts +2771 -499
- package/src/providers/cli-provider-status-helpers.ts +123 -0
- package/src/providers/cli-provider-transcript-merge.ts +114 -0
- package/src/providers/contracts.d.ts +55 -0
- package/src/providers/contracts.ts +207 -6
- package/src/providers/extension-provider-instance.ts +12 -7
- package/src/providers/external-sources.ts +218 -0
- package/src/providers/ide-provider-instance.ts +35 -12
- package/src/providers/manual-attendance.ts +105 -0
- package/src/providers/native-history/antigravity-claim-registry.ts +138 -0
- package/src/providers/native-history/antigravity-cli-transcript.ts +1275 -0
- package/src/providers/native-history/claude-cli-transcript.ts +396 -0
- package/src/providers/native-history/codex-cli-transcript.ts +479 -0
- package/src/providers/native-history/constants.ts +19 -0
- package/src/providers/native-history/dispatcher.ts +617 -0
- package/src/providers/native-history/hermes-cli-transcript.ts +312 -0
- package/src/providers/native-history/index.ts +30 -0
- package/src/providers/provider-instance-manager.ts +71 -0
- package/src/providers/provider-instance.ts +24 -1
- package/src/providers/provider-loader.ts +700 -55
- package/src/providers/provider-schema.ts +93 -14
- package/src/providers/provider-trust.ts +114 -0
- package/src/providers/read-chat-contract.ts +76 -16
- package/src/providers/sdk/README.md +49 -0
- package/src/providers/sdk/v1/builders/acp/detect-status.ts +144 -0
- package/src/providers/sdk/v1/builders/cli/detect-status.ts +313 -0
- package/src/providers/sdk/v1/builders/cli/parse-approval-squash.ts +158 -0
- package/src/providers/sdk/v1/builders/cli/parse-approval.ts +261 -0
- package/src/providers/sdk/v1/builders/cli/parse-session.ts +276 -0
- package/src/providers/sdk/v1/builders/cli/visible-region.ts +143 -0
- package/src/providers/sdk/v1/fixture-tooling/format.ts +130 -0
- package/src/providers/sdk/v1/fixture-tooling/index.ts +22 -0
- package/src/providers/sdk/v1/fixture-tooling/replay.ts +352 -0
- package/src/providers/sdk/v1/index.ts +152 -0
- package/src/providers/sdk/v1/sandbox/README-design.ts +195 -0
- package/src/providers/sdk/v1/sandbox/require-whitelist.ts +472 -0
- package/src/providers/sdk/v1/sandbox/script-runner.ts +150 -0
- package/src/providers/sdk/v1/schemas/cli/provider.schema.json +538 -0
- package/src/providers/sdk/v1/schemas/primitives/acp-session-protocol-v1.json +131 -0
- package/src/providers/sdk/v1/schemas/primitives/native-history-codex-rollout-v1.json +66 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-approval-squash-v1.json +91 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-assistant-block-v1.json +91 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-cue-ordering-v1.json +47 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-dispatch-order-v1.json +32 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-footer-chrome-v1.json +42 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-index-finder-v1.json +27 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-modal-v1.json +125 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-prompt-marker-v1.json +45 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-session-id-extraction-v1.json +46 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-settled-prompt-v1.json +71 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-spinner-v1.json +83 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-transcript-pty-v1.json +83 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-visible-region-v1.json +57 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-welcome-screen-v1.json +35 -0
- package/src/providers/sdk/v1/types/cli/index.ts +365 -0
- package/src/providers/sdk/v1/types/common/index.ts +229 -0
- package/src/providers/sdk/v1/validators/index.ts +19 -0
- package/src/providers/sdk/v1/validators/manifest.ts +110 -0
- package/src/providers/sdk/v1/validators/taint.ts +309 -0
- package/src/providers/spec/adapter.ts +246 -0
- package/src/providers/spec/cli-adapter.ts +1064 -0
- package/src/providers/spec/evaluator.ts +407 -0
- package/src/providers/spec/fsm-driver.ts +1528 -0
- package/src/providers/spec/fsm-evaluator.ts +290 -0
- package/src/providers/spec/fsm-loader.ts +128 -0
- package/src/providers/spec/fsm-types.ts +301 -0
- package/src/providers/spec/native-history-executor.ts +1241 -0
- package/src/providers/spec/pre-launch-trust.ts +104 -0
- package/src/providers/spec/route.ts +51 -0
- package/src/providers/spec/types.ts +284 -0
- package/src/providers/status-monitor.d.ts +7 -7
- package/src/providers/status-monitor.ts +37 -22
- package/src/providers/transcript-v2.ts +567 -0
- package/src/providers/types/interactive-prompt.ts +536 -0
- package/src/providers/version-archive.ts +64 -24
- package/src/providers/working-dir.ts +23 -0
- package/src/repo-mesh-types.ts +861 -14
- package/src/runtime-defaults.ts +39 -0
- package/src/session-host/managed-host.ts +218 -0
- package/src/session-host/runtime-surface.ts +20 -80
- package/src/sessions/registry.ts +44 -0
- package/src/shared-types-extra.ts +2 -4
- package/src/shared-types.d.ts +8 -0
- package/src/shared-types.ts +148 -55
- package/src/status/builders.ts +69 -6
- package/src/status/chat-tail-hot-sessions.ts +117 -2
- package/src/status/normalize.ts +2 -0
- package/src/status/reporter.ts +19 -1
- package/src/status/snapshot.ts +100 -27
- package/src/system/hash.ts +23 -0
- package/src/system/host-memory.ts +29 -12
- package/src/system/load-better-sqlite3.ts +68 -0
- package/src/types.ts +26 -6
- package/dist/cli-adapters/terminal-backends/xterm-backend.d.ts +0 -17
- package/dist/mesh/mesh-sync.d.ts +0 -53
- package/src/cli-adapters/terminal-backends/ghostty-vt-backend.d.ts +0 -16
- package/src/cli-adapters/terminal-backends/xterm-backend.ts +0 -97
- package/src/mesh/mesh-sync.ts +0 -111
|
@@ -0,0 +1,1548 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { randomUUID } from 'crypto';
|
|
4
|
+
import { LOG } from '../logging/logger.js';
|
|
5
|
+
import { loadConfig } from '../config/config.js';
|
|
6
|
+
import { getLedgerDir, readLedgerEntries, appendLedgerEntry } from './mesh-ledger.js';
|
|
7
|
+
import { MeshRuntimeStore } from './mesh-runtime-store.js';
|
|
8
|
+
import { buildMeshSystemMessage, readNonEmptyString, readRecord, resolveEventSessionId, readMeshCompletionSummary, isWeakCompletionMetadata } from './mesh-events-utils.js';
|
|
9
|
+
import { daemonIdsEquivalent, expandDaemonIdForms } from '@adhdev/mesh-shared';
|
|
10
|
+
import {
|
|
11
|
+
assertPendingMeshCoordinatorEventV2,
|
|
12
|
+
buildPendingEventEmitStamp,
|
|
13
|
+
coordinatorIdentityEquals,
|
|
14
|
+
coordinatorIdentityFromEmitFields,
|
|
15
|
+
coordinatorIdentityKey,
|
|
16
|
+
isMeshEventScope,
|
|
17
|
+
isTerminalTaskEvent,
|
|
18
|
+
MESH_PROTOCOL_VERSION_V2,
|
|
19
|
+
shouldDeliverPendingEventToCoordinator,
|
|
20
|
+
type CoordinatorIdentity,
|
|
21
|
+
type MeshEventScope,
|
|
22
|
+
type PendingMeshCoordinatorEventV2,
|
|
23
|
+
} from './contracts.js';
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// MCP coordinator pending-event queue — FILE-BASED PERSISTENCE
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// When a mesh event fires but no CLI coordinator session is registered (e.g.
|
|
29
|
+
// the coordinator is Claude Code running via MCP), we persist the event to a
|
|
30
|
+
// per-mesh JSONL file so it survives daemon restarts. The 50-entry hard cap
|
|
31
|
+
// is removed; the file is drained atomically on each get_pending_mesh_events
|
|
32
|
+
// call and limited to 100 KB to prevent runaway growth.
|
|
33
|
+
//
|
|
34
|
+
// File: <ledgerDir>/<meshId>.pending-events.jsonl
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
export interface PendingMeshCoordinatorEvent {
|
|
38
|
+
event: string;
|
|
39
|
+
meshId: string;
|
|
40
|
+
nodeLabel: string;
|
|
41
|
+
nodeId?: string;
|
|
42
|
+
workspace?: string;
|
|
43
|
+
metadataEvent: Record<string, unknown>;
|
|
44
|
+
coordinatorMessage?: string;
|
|
45
|
+
queuedAt: number;
|
|
46
|
+
/**
|
|
47
|
+
* When set, this event is intended for a specific coordinator daemon.
|
|
48
|
+
* Coordinators on other daemons should ignore it during drain.
|
|
49
|
+
* Absent on legacy events — treated as broadcast to any coordinator.
|
|
50
|
+
*/
|
|
51
|
+
targetCoordinatorDaemonId?: string;
|
|
52
|
+
/**
|
|
53
|
+
* When set, this event is intended for a specific coordinator SESSION on the
|
|
54
|
+
* target daemon (the session that originally dispatched the work). PHASE 2 inject
|
|
55
|
+
* strict-matches the live coordinator by this session id so a sibling coordinator
|
|
56
|
+
* session on the same daemon does not receive another coordinator's completion.
|
|
57
|
+
* Absent on legacy / version-skewed events → daemon-level broadcast (no regression).
|
|
58
|
+
* Rides inside the event payload, so it survives the SQLite payload round-trip and
|
|
59
|
+
* the JSONL file without a dedicated column; it is NOT a drain-scoping key.
|
|
60
|
+
*/
|
|
61
|
+
targetCoordinatorSessionId?: string;
|
|
62
|
+
|
|
63
|
+
// ─── v2 protocol envelope (B2a) — additive, populated at emit time ────────
|
|
64
|
+
// Stamped by queuePendingMeshCoordinatorEvent from the fields above plus an
|
|
65
|
+
// optional emit hint. A v1 reader that ignores these is unaffected (the v2
|
|
66
|
+
// shape is a strict superset). Absent → the event is a v1 event, treated as
|
|
67
|
+
// broadcast during rollout.
|
|
68
|
+
/** '2.0' once stamped. Absent on v1 events. */
|
|
69
|
+
protocolVersion?: typeof MESH_PROTOCOL_VERSION_V2;
|
|
70
|
+
/** Idempotency key (UUID). The receiver's authoritative dedup key (B3). */
|
|
71
|
+
eventId?: string;
|
|
72
|
+
/** Routing scope. Defaulted from the event name unless the emit hint overrides. */
|
|
73
|
+
scope?: MeshEventScope;
|
|
74
|
+
/** Identity of the coordinator that dispatched the work this event reports. */
|
|
75
|
+
dispatchedBy?: CoordinatorIdentity;
|
|
76
|
+
/** Present only for unicast scope: the coordinator this event is addressed to. */
|
|
77
|
+
intendedFor?: CoordinatorIdentity;
|
|
78
|
+
/**
|
|
79
|
+
* True when this event was stamped as a broadcast SOLELY because no owning
|
|
80
|
+
* coordinator identity was resolvable at emit time (self-fallback: dispatchedBy
|
|
81
|
+
* is THIS daemon's own machineId, not a real coordinator). Such a broadcast has
|
|
82
|
+
* no owner, so the MAGI-REPLICA-COMPLETION-EVENT-LEAK guard — which only exists
|
|
83
|
+
* to stop a NON-owner coordinator from consuming an OWNED terminal event — must
|
|
84
|
+
* not apply: an ownerless terminal broadcast is a genuine "deliver to any
|
|
85
|
+
* coordinator that drains on this machine" event and identity-matching its
|
|
86
|
+
* self-id dispatchedBy against the drainer would wrongly route it away.
|
|
87
|
+
* Absent (undefined/false) on a normally-owned event → the leak guard applies.
|
|
88
|
+
*/
|
|
89
|
+
dispatchedBySelfFallback?: boolean;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Optional emit-time hint passed to queuePendingMeshCoordinatorEvent so a call
|
|
94
|
+
* site can override the name-defaulted scope or supply richer coordinator
|
|
95
|
+
* identity (e.g. a coordinatorRunId the base event fields don't carry). Every
|
|
96
|
+
* field is optional; when omitted the stamp is derived entirely from the
|
|
97
|
+
* event's own targetCoordinatorDaemonId / targetCoordinatorSessionId. Kept
|
|
98
|
+
* separate from the event so existing single-arg callers are untouched.
|
|
99
|
+
*/
|
|
100
|
+
export interface PendingEventEmitHint {
|
|
101
|
+
scope?: MeshEventScope;
|
|
102
|
+
/** Overrides the coordinator identity derived from the event's target fields. */
|
|
103
|
+
dispatchedBy?: CoordinatorIdentity;
|
|
104
|
+
/** Overrides the unicast target derived from the event's target fields. */
|
|
105
|
+
intendedFor?: CoordinatorIdentity;
|
|
106
|
+
/** coordinatorRunId to fold into the derived identity when the event lacks one. */
|
|
107
|
+
coordinatorRunId?: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const REFINE_TERMINAL_EVENTS = new Set(['refine:completed', 'refine:failed']);
|
|
111
|
+
|
|
112
|
+
/** Normalise a coordinator-daemon-id argument (single id, list, or undefined) into a
|
|
113
|
+
* de-duplicated list of non-empty strings, EXPANDED to every equivalent daemon-id
|
|
114
|
+
* form (bare `mach_X` ≡ `daemon_mach_X` ≡ `standalone_mach_X`).
|
|
115
|
+
*
|
|
116
|
+
* A coordinator resolves its own id through one path (status instanceId, the config-
|
|
117
|
+
* form node daemonId, or the bare machineId) but a worker stamps a completion's
|
|
118
|
+
* `coordinator_daemon_id` through another, so the two are routinely in DIFFERENT
|
|
119
|
+
* forms of the SAME machine. The scope filter is an exact-string match, so without
|
|
120
|
+
* expansion a `daemon_mach_X`-scoped completion is silently skipped by a coordinator
|
|
121
|
+
* that only knows itself as bare `mach_X` (the base-node completion-surface bug).
|
|
122
|
+
* Expanding here fixes every drain/peek/surface caller uniformly. The first ORIGINAL
|
|
123
|
+
* id stays at [0] so per-daemon JSONL file naming keeps its primary; expansion stays
|
|
124
|
+
* within one machine core so a different coordinator's events are never claimed. */
|
|
125
|
+
function normalizeCoordinatorDaemonIds(
|
|
126
|
+
coordinatorDaemonId?: string | null | ReadonlyArray<string>,
|
|
127
|
+
): string[] {
|
|
128
|
+
return expandDaemonIdForms(coordinatorDaemonId);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ─── B3a: drain-side v2 routing (accept-and-warn) ────────────────────────────
|
|
132
|
+
//
|
|
133
|
+
// Stage-1 of the v2 receive path. The drain scope is still primarily gated by the
|
|
134
|
+
// SQLite/JSONL `coordinator_daemon_id` filter (v1 mechanism, untouched here), so
|
|
135
|
+
// this layer runs on top of an already daemon-scoped candidate set and adds:
|
|
136
|
+
//
|
|
137
|
+
// 1. v2 unicast routing — an event whose intendedFor addresses a DIFFERENT
|
|
138
|
+
// coordinator on THIS daemon (a sibling CLI/MCP session) is skipped, so a
|
|
139
|
+
// completion doesn't cross-surface. Broadcast/system-as-broadcast pass.
|
|
140
|
+
// 2. eventId idempotency — a v2 event whose eventId was already drained is
|
|
141
|
+
// skipped (durable, via MeshRuntimeStore.hasDrainedEventId) plus a per-drain
|
|
142
|
+
// batch guard against same-batch duplicates.
|
|
143
|
+
// 3. accept-and-warn — a v2 event that FAILS validation, or has NO version, is
|
|
144
|
+
// NOT dropped: it passes through (no v1 regression) with a one-shot WARN and
|
|
145
|
+
// a counter bump. Hard rejection is T6 (enforce mode), not here.
|
|
146
|
+
// 4. re-attribution fallback — a unicast event whose intendedFor does not match
|
|
147
|
+
// the drainer by identity is NOT dropped when its intendedFor.daemonId is the
|
|
148
|
+
// SAME machine as the drainer (a coordinatorRunId change from a restart
|
|
149
|
+
// orphaned it): it is delivered to the current coordinator on that daemon.
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* T6 (B3c) enforce switch. When ON, the drain path stops passing an unversioned
|
|
153
|
+
* (v1) or a validation-failing v2 event through to the coordinator — it QUARANTINES
|
|
154
|
+
* it instead (excluded from the delivered batch + WARN + counter), and unicast
|
|
155
|
+
* routing is the only delivery path (there is no v1 broadcast fallback). On by
|
|
156
|
+
* default; set MESH_PROTOCOL_V2_ENFORCE=0/false/off/no to disable and restore the
|
|
157
|
+
* accept-and-warn rollout behaviour exactly.
|
|
158
|
+
*
|
|
159
|
+
* Per the rollout plan (§1 decision 4): enforce is `MESH_PROTOCOL_V2_ENFORCE` (env).
|
|
160
|
+
* Now that every node emits v2 (§배포 게이트 1 / risk §4), the code default is ON —
|
|
161
|
+
* a manual env injection is no longer required to get enforce behaviour. Rollback to
|
|
162
|
+
* accept mode is a pure-env step: set `MESH_PROTOCOL_V2_ENFORCE=0` (or `false`/`off`/
|
|
163
|
+
* `no`) — no code change, no data migration (the schema is additive). Read at call
|
|
164
|
+
* time so a test / operator can toggle it without a restart.
|
|
165
|
+
*
|
|
166
|
+
* Quarantine (not drop) keeps the loss-free invariant. The DESTRUCTIVE drain has
|
|
167
|
+
* already consumed the event from its store by the time routing runs, so "held
|
|
168
|
+
* back" here means: excluded from the delivered batch AND mirrored into the mesh
|
|
169
|
+
* ledger as a recoverable `event_held` entry (the same recovery channel the
|
|
170
|
+
* pending-trim path uses). It is observable via the counters + the ledger, so an
|
|
171
|
+
* operator can requeue it after fixing the producer. The non-destructive PEEK path
|
|
172
|
+
* (countMetrics=false) merely omits the event from the returned list — it never
|
|
173
|
+
* consumed it and must not ledger-record on every status poll.
|
|
174
|
+
*/
|
|
175
|
+
export function isMeshProtocolV2EnforceEnabled(): boolean {
|
|
176
|
+
const raw = readNonEmptyString(process.env.MESH_PROTOCOL_V2_ENFORCE);
|
|
177
|
+
if (!raw) return true; // unset/blank = default ON
|
|
178
|
+
const v = raw.trim().toLowerCase();
|
|
179
|
+
return !(v === '0' || v === 'false' || v === 'off' || v === 'no'); // only explicit off = false
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Record a v2-enforce-quarantined event into the mesh ledger as recoverable, so a
|
|
184
|
+
* destructively-drained event held back by enforce is auditable and requeue-able
|
|
185
|
+
* (loss-free invariant). Mirrors the pending-trim `event_held` shape. Best-effort:
|
|
186
|
+
* a ledger write failure must not break the drain. Called ONLY on the destructive
|
|
187
|
+
* drain path (the peek path never consumed the event, so nothing to recover).
|
|
188
|
+
*/
|
|
189
|
+
function ledgerRecordQuarantinedEvent(event: PendingMeshCoordinatorEvent, reason: string): void {
|
|
190
|
+
try {
|
|
191
|
+
const finalSummary = readMeshCompletionSummary(event.metadataEvent || {});
|
|
192
|
+
appendLedgerEntry(event.meshId, {
|
|
193
|
+
kind: 'event_held',
|
|
194
|
+
...(event.nodeId ? { nodeId: event.nodeId } : {}),
|
|
195
|
+
payload: {
|
|
196
|
+
event: event.event,
|
|
197
|
+
reason,
|
|
198
|
+
recoverable: true,
|
|
199
|
+
nodeLabel: event.nodeLabel,
|
|
200
|
+
...(event.workspace ? { workspace: event.workspace } : {}),
|
|
201
|
+
targetCoordinatorDaemonId: event.targetCoordinatorDaemonId ?? null,
|
|
202
|
+
...(readNonEmptyString(event.eventId) ? { eventId: event.eventId } : {}),
|
|
203
|
+
queuedAt: event.queuedAt,
|
|
204
|
+
...(finalSummary ? { finalSummary } : {}),
|
|
205
|
+
// Full original event so mesh_requeue_held_events can restore it
|
|
206
|
+
// losslessly (event_held→pending). The summary/label fields above stay
|
|
207
|
+
// for human-readable audit; `heldEvent` is the machine recovery copy.
|
|
208
|
+
heldEvent: event,
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
} catch (e: any) {
|
|
212
|
+
LOG.warn('MeshEventsV2', `Failed to ledger-record v2-quarantined ${event.event} for mesh ${event.meshId}: ${e?.message || e}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Observability counters for the v2 drain path. Read by tests and surfaced in
|
|
217
|
+
* mesh_status (B4/T6). Process-lifetime totals — never reset in production. */
|
|
218
|
+
const meshV2DrainCounters = {
|
|
219
|
+
/** v2 events that passed validation and unicast/broadcast routing → delivered. */
|
|
220
|
+
v2Delivered: 0,
|
|
221
|
+
/** v2 unicast events skipped because intendedFor addressed another coordinator. */
|
|
222
|
+
v2RoutedAway: 0,
|
|
223
|
+
/** v2 events skipped because their eventId was already drained (idempotency). */
|
|
224
|
+
v2DedupSkipped: 0,
|
|
225
|
+
/** v2 events that failed assertPendingMeshCoordinatorEventV2 but were PASSED
|
|
226
|
+
* THROUGH (accept mode). Non-zero here is the rollout signal that a producer
|
|
227
|
+
* emits a malformed envelope. */
|
|
228
|
+
v2ValidationFailedAccepted: 0,
|
|
229
|
+
/** unicast events re-attributed to the drainer via daemon-core match (a
|
|
230
|
+
* coordinatorRunId change orphaned them). */
|
|
231
|
+
v2ReattributedToDrainer: 0,
|
|
232
|
+
/** v1 (unversioned) events passed through as broadcast (rollout baseline). */
|
|
233
|
+
v1BroadcastAccepted: 0,
|
|
234
|
+
/** T6 enforce: v2 events that FAILED validation and were QUARANTINED (held back
|
|
235
|
+
* from delivery, not dropped). Non-zero here means a producer is still emitting a
|
|
236
|
+
* malformed envelope after enforce was turned on. */
|
|
237
|
+
v2ValidationFailedQuarantined: 0,
|
|
238
|
+
/** T6 enforce: v1 (unversioned) events QUARANTINED because no v2 envelope could be
|
|
239
|
+
* derived at emit time. Non-zero here means a producer path still emits v1 after
|
|
240
|
+
* enforce — it should reach 0 once every node is on a v2-stamping build. */
|
|
241
|
+
v1UnversionedQuarantined: 0,
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
/** Test/observability accessor for the v2 drain counters (snapshot copy). */
|
|
245
|
+
export function getMeshV2DrainCounters(): Readonly<typeof meshV2DrainCounters> {
|
|
246
|
+
return { ...meshV2DrainCounters };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Test helper: zero the v2 drain counters so a test starts from a clean slate. */
|
|
250
|
+
export function __resetMeshV2DrainCountersForTests(): void {
|
|
251
|
+
for (const k of Object.keys(meshV2DrainCounters) as Array<keyof typeof meshV2DrainCounters>) {
|
|
252
|
+
meshV2DrainCounters[k] = 0;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// One-shot WARN dedup: an accept-mode warning is logged once per (meshId, eventId)
|
|
257
|
+
// so a re-polled malformed event doesn't spam the log every 4s reconcile tick.
|
|
258
|
+
const warnedV2Violations = new Set<string>();
|
|
259
|
+
function warnV2Once(key: string, message: string): void {
|
|
260
|
+
if (warnedV2Violations.has(key)) return;
|
|
261
|
+
warnedV2Violations.add(key);
|
|
262
|
+
// Bound the set so a long-lived daemon churning many distinct eventIds can't leak.
|
|
263
|
+
if (warnedV2Violations.size > 2000) {
|
|
264
|
+
const first = warnedV2Violations.values().next().value;
|
|
265
|
+
if (first !== undefined) warnedV2Violations.delete(first);
|
|
266
|
+
}
|
|
267
|
+
LOG.warn('MeshEventsV2', message);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Test helper: clear the one-shot WARN dedup set. */
|
|
271
|
+
export function __resetMeshV2WarnDedupForTests(): void {
|
|
272
|
+
warnedV2Violations.clear();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Resolve the drainer's CoordinatorIdentity for v2 routing from the daemon-id
|
|
277
|
+
* argument the (untouchable) reconcile-loop already passes. The daemon ids are the
|
|
278
|
+
* dual/expanded self-identity forms from resolveCoordinatorDaemonIds; the FIRST is
|
|
279
|
+
* the primary. coordinatorRunId is not threaded through the drain call yet, so it
|
|
280
|
+
* falls back to the daemonId exactly as the emit side does
|
|
281
|
+
* (coordinatorIdentityFromEmitFields) — this keeps drain-side identity CONSISTENT
|
|
282
|
+
* with how v1→v2 events were stamped, and unicast equality then reduces to the
|
|
283
|
+
* daemon-core match, which is the correct rollout-window granularity. A caller that
|
|
284
|
+
* knows the full identity (with a real coordinatorRunId) may pass it explicitly to
|
|
285
|
+
* override. Returns undefined when no daemon id is known (→ v2 routing is a no-op,
|
|
286
|
+
* everything passes as-is).
|
|
287
|
+
*/
|
|
288
|
+
function resolveDrainerIdentity(
|
|
289
|
+
daemonIds: ReadonlyArray<string>,
|
|
290
|
+
explicit?: CoordinatorIdentity,
|
|
291
|
+
): CoordinatorIdentity | undefined {
|
|
292
|
+
if (explicit) return explicit;
|
|
293
|
+
return coordinatorIdentityFromEmitFields({ daemonId: daemonIds[0] });
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** A v2 event carries a '2.0' protocolVersion. Everything else is a v1 event. */
|
|
297
|
+
function isV2Event(event: PendingMeshCoordinatorEvent): boolean {
|
|
298
|
+
return event.protocolVersion === MESH_PROTOCOL_VERSION_V2;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* True when an identity's coordinatorRunId is merely its own daemonId form (the
|
|
303
|
+
* B2a fallback in coordinatorIdentityFromEmitFields — no real coordinatorRunId was
|
|
304
|
+
* threaded through the emit/drain site yet). For such an identity the runId carries
|
|
305
|
+
* NO information beyond the daemon, so two different daemon-id FORMS of the same
|
|
306
|
+
* machine (mach_ vs daemon_mach_) must not be treated as different coordinators.
|
|
307
|
+
*/
|
|
308
|
+
function runIdIsDaemonFormFallback(identity: CoordinatorIdentity): boolean {
|
|
309
|
+
return daemonIdsEquivalent(identity.coordinatorRunId, identity.daemonId);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Delivery equality for the rollout window. When BOTH sides carry only a
|
|
314
|
+
* daemon-form-fallback runId (no real coordinatorRunId wired yet), a match reduces
|
|
315
|
+
* to same-machine — so a completion stamped `daemon_mach_X` is delivered to a
|
|
316
|
+
* coordinator that knows itself as bare `mach_X` (the canon-identity heterogeneous-
|
|
317
|
+
* form case). The session is compared ONLY when BOTH sides carry one: a session-less
|
|
318
|
+
* drainer is a daemon-level drain (what the reconcile loop passes) that accepts any
|
|
319
|
+
* session's events on that machine — targetCoordinatorSessionId is a PHASE-2 inject
|
|
320
|
+
* key, not a drain-scoping key (see the v1 field comment). When only the drainer AND
|
|
321
|
+
* the event both name a session do we require them to match, so a session-specific
|
|
322
|
+
* coordinator does not receive a sibling session's unicast event.
|
|
323
|
+
*
|
|
324
|
+
* When EITHER side has a real (non-daemon-form) runId, fall back to strict
|
|
325
|
+
* coordinatorIdentityEquals so two genuinely distinct coordinators on the same
|
|
326
|
+
* daemon (different real runIds) stay separated.
|
|
327
|
+
*/
|
|
328
|
+
function identityDeliversTo(intendedFor: CoordinatorIdentity, drainer: CoordinatorIdentity): boolean {
|
|
329
|
+
if (runIdIsDaemonFormFallback(intendedFor) && runIdIsDaemonFormFallback(drainer)) {
|
|
330
|
+
if (!daemonIdsEquivalent(intendedFor.daemonId, drainer.daemonId)) return false;
|
|
331
|
+
// Session filter applies only when the drainer itself is session-specific.
|
|
332
|
+
if (intendedFor.sessionId && drainer.sessionId) {
|
|
333
|
+
return intendedFor.sessionId === drainer.sessionId;
|
|
334
|
+
}
|
|
335
|
+
return true;
|
|
336
|
+
}
|
|
337
|
+
return coordinatorIdentityEquals(intendedFor, drainer);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Apply v2 receive-side routing + idempotency to a merged, already daemon-scoped
|
|
342
|
+
* candidate list (accept-and-warn — never drops on validation failure).
|
|
343
|
+
*
|
|
344
|
+
* - `drainer` undefined → routing is skipped, list returned unchanged (safety).
|
|
345
|
+
* - Marks each surviving v2 event's eventId in `batchSeen` so a same-batch dup is
|
|
346
|
+
* skipped; a caller that persists drains uses `alreadyDrained` for the durable
|
|
347
|
+
* check (getPending peek passes a no-op so a peek never dedups against itself).
|
|
348
|
+
*/
|
|
349
|
+
function routeV2EventsForDrainer(
|
|
350
|
+
events: PendingMeshCoordinatorEvent[],
|
|
351
|
+
drainer: CoordinatorIdentity | undefined,
|
|
352
|
+
ctx: {
|
|
353
|
+
alreadyDrained: (eventId: string) => boolean;
|
|
354
|
+
batchSeen: Set<string>;
|
|
355
|
+
/** false for a non-destructive peek so the frequent status-poll path does
|
|
356
|
+
* not inflate the delivery counters (only the real drain counts). */
|
|
357
|
+
countMetrics: boolean;
|
|
358
|
+
},
|
|
359
|
+
): PendingMeshCoordinatorEvent[] {
|
|
360
|
+
if (!drainer) return events;
|
|
361
|
+
// Read the enforce flag ONCE per drain so the whole batch is classified under a
|
|
362
|
+
// single, consistent policy (a mid-batch env flip cannot split one drain).
|
|
363
|
+
const enforce = isMeshProtocolV2EnforceEnabled();
|
|
364
|
+
const bump = (k: keyof typeof meshV2DrainCounters) => { if (ctx.countMetrics) meshV2DrainCounters[k]++; };
|
|
365
|
+
const kept: PendingMeshCoordinatorEvent[] = [];
|
|
366
|
+
for (const event of events) {
|
|
367
|
+
if (!isV2Event(event)) {
|
|
368
|
+
// v1 / unversioned event. ACCEPT MODE: broadcast during rollout (existing
|
|
369
|
+
// policy). ENFORCE MODE: quarantine — an unversioned event has no scope, so
|
|
370
|
+
// there is no safe unicast target; hold it back (not delivered) and mirror
|
|
371
|
+
// it to the ledger as recoverable, with a one-shot WARN + counter.
|
|
372
|
+
if (enforce) {
|
|
373
|
+
bump('v1UnversionedQuarantined');
|
|
374
|
+
if (ctx.countMetrics) ledgerRecordQuarantinedEvent(event, 'v2_enforce_unversioned_quarantined');
|
|
375
|
+
warnV2Once(
|
|
376
|
+
`${event.meshId}::${event.eventId ?? event.event}::v1-quarantined`,
|
|
377
|
+
`v2 ENFORCE: unversioned ${event.event} on mesh ${event.meshId} QUARANTINED (no v2 envelope — held back, not delivered; ledger-recorded recoverable). A producer path still emits v1.`,
|
|
378
|
+
);
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
bump('v1BroadcastAccepted');
|
|
382
|
+
kept.push(event);
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Validate the v2 envelope. ACCEPT MODE: a validation failure does NOT drop
|
|
387
|
+
// the event — it passes through with a one-shot WARN + counter. ENFORCE MODE:
|
|
388
|
+
// a validation failure is QUARANTINED (held back, not delivered) — the malformed
|
|
389
|
+
// envelope carries no trustworthy scope/target, so delivering it risks a
|
|
390
|
+
// cross-surface. It is ledger-recorded recoverable on the destructive path.
|
|
391
|
+
let validated: PendingMeshCoordinatorEventV2;
|
|
392
|
+
try {
|
|
393
|
+
validated = assertPendingMeshCoordinatorEventV2(event);
|
|
394
|
+
} catch (e: any) {
|
|
395
|
+
if (enforce) {
|
|
396
|
+
bump('v2ValidationFailedQuarantined');
|
|
397
|
+
if (ctx.countMetrics) ledgerRecordQuarantinedEvent(event, 'v2_enforce_validation_failed_quarantined');
|
|
398
|
+
warnV2Once(
|
|
399
|
+
`${event.meshId}::${event.eventId ?? event.event}::invalid-quarantined`,
|
|
400
|
+
`v2 ENFORCE: envelope validation failed for ${event.event} on mesh ${event.meshId} — QUARANTINED (held back, not delivered; ledger-recorded recoverable): ${e?.message || e}`,
|
|
401
|
+
);
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
bump('v2ValidationFailedAccepted');
|
|
405
|
+
warnV2Once(
|
|
406
|
+
`${event.meshId}::${event.eventId ?? event.event}::invalid`,
|
|
407
|
+
`v2 envelope validation failed for ${event.event} on mesh ${event.meshId} — PASSED THROUGH (accept mode): ${e?.message || e}`,
|
|
408
|
+
);
|
|
409
|
+
kept.push(event);
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// eventId idempotency: skip if already drained (durable) or already seen in
|
|
414
|
+
// this same batch (guards the SQLite+JSONL dual-store merge duplicate).
|
|
415
|
+
const eventId = validated.eventId;
|
|
416
|
+
if (ctx.batchSeen.has(eventId) || ctx.alreadyDrained(eventId)) {
|
|
417
|
+
bump('v2DedupSkipped');
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Broadcast → any coordinator; system → daemon handler only (never a
|
|
422
|
+
// coordinator). Delegates to the contract helper for those two scopes.
|
|
423
|
+
if (validated.scope !== 'unicast') {
|
|
424
|
+
// Defense-in-depth (MAGI-REPLICA-COMPLETION-EVENT-LEAK): a TERMINAL task
|
|
425
|
+
// event that reached the queue as broadcast is an ownership leak — a
|
|
426
|
+
// completion/stop belongs to the coordinator that dispatched the task, so
|
|
427
|
+
// a sibling coordinator that never dispatched it must NOT act on it. The
|
|
428
|
+
// emit-side stamp now narrows unaddressed terminal events to unicast, but a
|
|
429
|
+
// legacy/version-skewed/other-path broadcast can still arrive here; filter
|
|
430
|
+
// it by dispatchedBy vs the drainer using the SAME daemon-form/session
|
|
431
|
+
// matching semantics as unicast (identityDeliversTo), so the true owner —
|
|
432
|
+
// possibly addressed under a different daemon-id form — still receives it.
|
|
433
|
+
if (validated.scope === 'broadcast' && isTerminalTaskEvent(validated.event)) {
|
|
434
|
+
// An ownerless self-fallback broadcast (dispatchedBy is this daemon's
|
|
435
|
+
// own machineId because no coordinator identity existed at emit) has no
|
|
436
|
+
// coordinator owner — but it must still stay on ITS machine: a replica
|
|
437
|
+
// completion emitted on machine A must never fan out to a coordinator on
|
|
438
|
+
// machine B (the MAGI-REPLICA leak). So for a self-fallback event, match
|
|
439
|
+
// at the MACHINE (daemonId) level — deliver iff the drainer is on the
|
|
440
|
+
// same machine as the self-dispatcher — instead of the full
|
|
441
|
+
// identityDeliversTo (which also compares runId/session and would route
|
|
442
|
+
// the event away from a same-machine coordinator whose id form differs,
|
|
443
|
+
// the exact symptom for refine:* / agent:generating_completed reaching a
|
|
444
|
+
// stdio MCP coordinator). Non-self-fallback broadcasts keep the strict
|
|
445
|
+
// owner check.
|
|
446
|
+
const deliverSelfFallback = event.dispatchedBySelfFallback
|
|
447
|
+
&& daemonIdsEquivalent(validated.dispatchedBy.daemonId, drainer.daemonId);
|
|
448
|
+
if (deliverSelfFallback || identityDeliversTo(validated.dispatchedBy, drainer)) {
|
|
449
|
+
ctx.batchSeen.add(eventId);
|
|
450
|
+
bump('v2Delivered');
|
|
451
|
+
kept.push(event);
|
|
452
|
+
} else {
|
|
453
|
+
bump('v2RoutedAway');
|
|
454
|
+
}
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
if (shouldDeliverPendingEventToCoordinator(validated, drainer)) {
|
|
458
|
+
ctx.batchSeen.add(eventId);
|
|
459
|
+
bump('v2Delivered');
|
|
460
|
+
kept.push(event);
|
|
461
|
+
} else {
|
|
462
|
+
// system scope → not for any coordinator.
|
|
463
|
+
bump('v2RoutedAway');
|
|
464
|
+
}
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Unicast: deliver iff intendedFor addresses THIS drainer. identityDeliversTo
|
|
469
|
+
// treats a daemon-form-fallback runId (no real coordinatorRunId wired yet) as
|
|
470
|
+
// form-agnostic so a `daemon_mach_X`-addressed event reaches a bare-`mach_X`
|
|
471
|
+
// drainer (heterogeneous-form same coordinator), while keeping two REAL
|
|
472
|
+
// distinct runIds on one daemon separated.
|
|
473
|
+
if (validated.intendedFor && identityDeliversTo(validated.intendedFor, drainer)) {
|
|
474
|
+
ctx.batchSeen.add(eventId);
|
|
475
|
+
bump('v2Delivered');
|
|
476
|
+
kept.push(event);
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Not delivered by identity. Apply the re-attribution fallback (plan risk
|
|
481
|
+
// §4): if intendedFor addresses the SAME MACHINE as the drainer AND the
|
|
482
|
+
// mismatch is a genuine coordinatorRunId change (a restart minted a fresh
|
|
483
|
+
// runId), deliver it to the current coordinator rather than orphaning it.
|
|
484
|
+
//
|
|
485
|
+
// Guard: when BOTH sides carry only a daemon-form-fallback runId, a mismatch
|
|
486
|
+
// that survived identityDeliversTo is a SESSION mismatch (a sibling
|
|
487
|
+
// coordinator on the same daemon) — that is a legitimate route-away, NOT an
|
|
488
|
+
// orphaned event, so re-attribution must not fire. Re-attribution requires a
|
|
489
|
+
// REAL runId difference, which means at least one side carries a real runId.
|
|
490
|
+
const realRunIdMismatch = !runIdIsDaemonFormFallback(validated.intendedFor!)
|
|
491
|
+
|| !runIdIsDaemonFormFallback(drainer);
|
|
492
|
+
if (
|
|
493
|
+
validated.intendedFor
|
|
494
|
+
&& realRunIdMismatch
|
|
495
|
+
&& daemonIdsEquivalent(validated.intendedFor.daemonId, drainer.daemonId)
|
|
496
|
+
) {
|
|
497
|
+
ctx.batchSeen.add(eventId);
|
|
498
|
+
bump('v2ReattributedToDrainer');
|
|
499
|
+
warnV2Once(
|
|
500
|
+
`${event.meshId}::${eventId}::reattributed`,
|
|
501
|
+
`v2 unicast ${event.event} on mesh ${event.meshId} re-attributed to current coordinator ${coordinatorIdentityKey(drainer)} (originating coordinatorRunId no longer live)`,
|
|
502
|
+
);
|
|
503
|
+
kept.push(event);
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Addressed to a genuinely different coordinator (different machine, or
|
|
508
|
+
// system scope) → not for this drainer. Skipped (left for its own drainer).
|
|
509
|
+
bump('v2RoutedAway');
|
|
510
|
+
}
|
|
511
|
+
return kept;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export function readRefineJobId(event: { metadataEvent?: Record<string, unknown> } | Record<string, unknown>): string {
|
|
515
|
+
const metadata = readRecord((event as any).metadataEvent) || event as Record<string, unknown>;
|
|
516
|
+
const result = readRecord(metadata.result);
|
|
517
|
+
const refineJob = readRecord(result?.refineJob);
|
|
518
|
+
return readNonEmptyString(metadata.jobId) || readNonEmptyString(refineJob?.jobId);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function buildRefineTerminalEventFingerprint(meshId: string, eventName: string, metadataEvent: Record<string, unknown>): string {
|
|
522
|
+
const jobId = readRefineJobId({ metadataEvent });
|
|
523
|
+
return jobId && REFINE_TERMINAL_EVENTS.has(eventName) ? `${meshId}::${eventName}::${jobId}` : '';
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function hasPendingRefineTerminalEventDuplicate(event: PendingMeshCoordinatorEvent): boolean {
|
|
527
|
+
if (!REFINE_TERMINAL_EVENTS.has(event.event)) return false;
|
|
528
|
+
const jobId = readRefineJobId(event);
|
|
529
|
+
if (!jobId) return false;
|
|
530
|
+
return readPendingMeshCoordinatorEventsFromDisk(event.meshId).some((pending) =>
|
|
531
|
+
pending.event === event.event && readRefineJobId(pending) === jobId,
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// CANON-B / DUPNOTIF: terminal completion events that the coordinator surfaces as a
|
|
536
|
+
// notification. The native completion path (handleMeshCoordinatorEvent) and the transcript
|
|
537
|
+
// reconciliation fallback (reconcileDirectDispatchCompletionFromTranscript) BOTH queue one of
|
|
538
|
+
// these for the same finished task — with DIFFERENT timestamps — so a timestamp-bearing
|
|
539
|
+
// fingerprint lets both surface and the coordinator notifies twice. When the event carries a
|
|
540
|
+
// taskId we anchor the fingerprint on the taskId (dropping the timestamp), collapsing the two
|
|
541
|
+
// paths into a single surface. A weakness marker keeps a tentative false-idle completion
|
|
542
|
+
// distinct from the genuine completion that supersedes it, so the genuine one is never
|
|
543
|
+
// swallowed by the earlier weak one.
|
|
544
|
+
const TERMINAL_COMPLETION_EVENTS = new Set(['agent:generating_completed', 'agent:stopped']);
|
|
545
|
+
|
|
546
|
+
export function buildPendingEventFingerprint(event: PendingMeshCoordinatorEvent): string {
|
|
547
|
+
const metadata = readRecord(event.metadataEvent) || {};
|
|
548
|
+
// Bootstrap events are node-scoped: dedup by meshId+event+nodeId only.
|
|
549
|
+
// They carry no sessionId/taskId/timestamp — using those fields would produce
|
|
550
|
+
// an empty fingerprint that defeats dedup entirely.
|
|
551
|
+
if (event.event === 'worktree_bootstrap_complete' || event.event === 'worktree_bootstrap_failed') {
|
|
552
|
+
return [event.meshId, event.event, event.nodeId || ''].join('::');
|
|
553
|
+
}
|
|
554
|
+
// DUPNOTIF: a terminal completion carrying a taskId is deduped by taskId (+ weakness),
|
|
555
|
+
// NOT by timestamp — the native and transcript-reconciliation paths timestamp the same
|
|
556
|
+
// completion differently, and only taskId is stable across both.
|
|
557
|
+
if (TERMINAL_COMPLETION_EVENTS.has(event.event)) {
|
|
558
|
+
const terminalTaskId = readNonEmptyString(metadata.taskId) || readNonEmptyString(readRecord(metadata.payload)?.taskId);
|
|
559
|
+
if (terminalTaskId) {
|
|
560
|
+
return [
|
|
561
|
+
event.meshId,
|
|
562
|
+
event.event,
|
|
563
|
+
terminalTaskId,
|
|
564
|
+
isWeakCompletionMetadata(metadata) ? 'weak' : 'genuine',
|
|
565
|
+
].join('::');
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
// MAGI consensus-group exemption: a consensusGroupId marks an INTENTIONAL
|
|
569
|
+
// same-prompt fan-out across N replicas — the exact opposite of the accidental
|
|
570
|
+
// duplicates this dedup collapses. Anchor the fingerprint on the unique
|
|
571
|
+
// (taskId, consensusGroupId) so grouped replicas can NEVER be collapsed by any
|
|
572
|
+
// future prompt-content-based tightening of this builder. Mirrors the
|
|
573
|
+
// bootstrap-event exemption above and serves as the explicit fan-out marker.
|
|
574
|
+
// (Today this is belt-and-suspenders: each replica already gets a distinct
|
|
575
|
+
// taskId, so the generic key below would not collapse them either.)
|
|
576
|
+
const consensusGroupId = readNonEmptyString(metadata.consensusGroupId)
|
|
577
|
+
|| readNonEmptyString(readRecord(metadata.payload)?.consensusGroupId);
|
|
578
|
+
if (consensusGroupId) {
|
|
579
|
+
const groupTaskId = readNonEmptyString(metadata.taskId)
|
|
580
|
+
|| readNonEmptyString(readRecord(metadata.payload)?.taskId);
|
|
581
|
+
return [event.meshId, event.event, groupTaskId || '', consensusGroupId, 'group'].join('::');
|
|
582
|
+
}
|
|
583
|
+
const sessionId = resolveEventSessionId(metadata);
|
|
584
|
+
const providerSessionId = readNonEmptyString(metadata.providerSessionId);
|
|
585
|
+
const taskId = readNonEmptyString(metadata.taskId) || readNonEmptyString(readRecord(metadata.payload)?.taskId);
|
|
586
|
+
const jobId = readRefineJobId(event);
|
|
587
|
+
const timestamp = metadata.timestamp !== undefined && metadata.timestamp !== null ? String(metadata.timestamp) : '';
|
|
588
|
+
return [
|
|
589
|
+
event.meshId,
|
|
590
|
+
event.event,
|
|
591
|
+
event.nodeId || '',
|
|
592
|
+
sessionId || '',
|
|
593
|
+
providerSessionId || '',
|
|
594
|
+
taskId || '',
|
|
595
|
+
jobId || '',
|
|
596
|
+
timestamp || '',
|
|
597
|
+
].join('::');
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// NOTE: the former R3 "direct-delivered" marker (markMeshCoordinatorEventDirectDelivered /
|
|
601
|
+
// wasDirectDeliveredToCoordinator) was removed when spontaneous PTY direct-inject was retired.
|
|
602
|
+
// Delivery is now queue-drain-only: an event is consumed by exactly one drainer via the atomic
|
|
603
|
+
// SQLite drained=1 marking, so there is no PTY-vs-poll double-delivery left to dedup against.
|
|
604
|
+
// The dormant mesh_direct_delivered_events table that backed it was dropped in
|
|
605
|
+
// MeshRuntimeStore.migrateMeshIsolationColumns (DROP TABLE IF EXISTS, migration step 5).
|
|
606
|
+
|
|
607
|
+
export function hasPendingCoordinatorEventDuplicate(event: PendingMeshCoordinatorEvent): boolean {
|
|
608
|
+
const fingerprint = buildPendingEventFingerprint(event);
|
|
609
|
+
if (!fingerprint.trim()) return false;
|
|
610
|
+
// Check SQLite inbox first (G3 primary path)
|
|
611
|
+
try {
|
|
612
|
+
if (MeshRuntimeStore.getInstance().hasPendingEventFingerprint(event.meshId, fingerprint)) return true;
|
|
613
|
+
} catch { /* fall through to JSONL check */ }
|
|
614
|
+
return readPendingMeshCoordinatorEventsFromDisk(event.meshId).some((pending) => buildPendingEventFingerprint(pending) === fingerprint);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function getPendingEventsPath(meshId: string, coordinatorDaemonId?: string): string {
|
|
618
|
+
const safe = meshId.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
619
|
+
if (coordinatorDaemonId) {
|
|
620
|
+
const safeDaemon = coordinatorDaemonId.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
621
|
+
return join(getLedgerDir(), `${safe}-${safeDaemon}.pending-events.jsonl`);
|
|
622
|
+
}
|
|
623
|
+
return join(getLedgerDir(), `${safe}.pending-events.jsonl`);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function readPendingMeshCoordinatorEventsFromDisk(meshId?: string, coordinatorDaemonId?: string | ReadonlyArray<string>): PendingMeshCoordinatorEvent[] {
|
|
627
|
+
if (!meshId) return [];
|
|
628
|
+
const daemonIds = normalizeCoordinatorDaemonIds(coordinatorDaemonId);
|
|
629
|
+
const primaryDaemonId = daemonIds[0];
|
|
630
|
+
// Read coordinator-scoped file first; fall back to legacy shared file.
|
|
631
|
+
const paths = primaryDaemonId
|
|
632
|
+
? [getPendingEventsPath(meshId, primaryDaemonId), getPendingEventsPath(meshId)]
|
|
633
|
+
: [getPendingEventsPath(meshId)];
|
|
634
|
+
const events: PendingMeshCoordinatorEvent[] = [];
|
|
635
|
+
for (const path of paths) {
|
|
636
|
+
if (!existsSync(path)) continue;
|
|
637
|
+
try {
|
|
638
|
+
const raw = readFileSync(path, 'utf-8');
|
|
639
|
+
const parsed = raw.split('\n').filter(Boolean).flatMap(line => {
|
|
640
|
+
try { return [JSON.parse(line) as PendingMeshCoordinatorEvent]; } catch { return []; }
|
|
641
|
+
});
|
|
642
|
+
// If reading the shared file, filter to events that target this coordinator or are unscoped.
|
|
643
|
+
const filtered = (primaryDaemonId && path === getPendingEventsPath(meshId))
|
|
644
|
+
? parsed.filter(e => !e.targetCoordinatorDaemonId || daemonIds.includes(e.targetCoordinatorDaemonId))
|
|
645
|
+
: parsed;
|
|
646
|
+
events.push(...filtered);
|
|
647
|
+
} catch { /* skip unreadable files */ }
|
|
648
|
+
}
|
|
649
|
+
return events;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function refineTerminalEventFromLedger(meshId: string, pending: readonly PendingMeshCoordinatorEvent[]): PendingMeshCoordinatorEvent[] {
|
|
653
|
+
const acceptedJobIds = new Set(
|
|
654
|
+
pending
|
|
655
|
+
.filter(event => event.event === 'refine:accepted')
|
|
656
|
+
.map(event => readRefineJobId(event))
|
|
657
|
+
.filter(Boolean),
|
|
658
|
+
);
|
|
659
|
+
if (acceptedJobIds.size === 0) return [];
|
|
660
|
+
const existingTerminalJobIds = new Set(
|
|
661
|
+
pending
|
|
662
|
+
.filter(event => REFINE_TERMINAL_EVENTS.has(event.event))
|
|
663
|
+
.map(event => `${event.event}:${readRefineJobId(event)}`)
|
|
664
|
+
.filter(value => !value.endsWith(':')),
|
|
665
|
+
);
|
|
666
|
+
const backfilled: PendingMeshCoordinatorEvent[] = [];
|
|
667
|
+
const entries = readLedgerEntries(meshId, { tail: 200 });
|
|
668
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
669
|
+
const entry = entries[i];
|
|
670
|
+
if (entry.kind !== 'task_completed' && entry.kind !== 'task_failed') continue;
|
|
671
|
+
const payload = readRecord(entry.payload);
|
|
672
|
+
if (payload?.source !== 'refine_mesh_node_async_job') continue;
|
|
673
|
+
const refineJob = readRecord(payload.refineJob);
|
|
674
|
+
const jobId = readNonEmptyString(refineJob?.jobId);
|
|
675
|
+
if (!jobId || !acceptedJobIds.has(jobId)) continue;
|
|
676
|
+
const eventName = entry.kind === 'task_completed' ? 'refine:completed' : 'refine:failed';
|
|
677
|
+
if (existingTerminalJobIds.has(`${eventName}:${jobId}`)) continue;
|
|
678
|
+
existingTerminalJobIds.add(`${eventName}:${jobId}`);
|
|
679
|
+
const result = readRecord(payload.result);
|
|
680
|
+
const metadataEvent = {
|
|
681
|
+
source: 'refine_mesh_node_async_job',
|
|
682
|
+
jobId,
|
|
683
|
+
interactionId: readNonEmptyString(refineJob?.interactionId),
|
|
684
|
+
meshId,
|
|
685
|
+
nodeId: readNonEmptyString(refineJob?.nodeId) || entry.nodeId,
|
|
686
|
+
targetDaemonId: readNonEmptyString(refineJob?.targetDaemonId),
|
|
687
|
+
workspace: readNonEmptyString(refineJob?.workspace),
|
|
688
|
+
status: eventName === 'refine:completed' ? 'completed' : 'failed',
|
|
689
|
+
startedAt: readNonEmptyString(refineJob?.startedAt),
|
|
690
|
+
completedAt: readNonEmptyString(refineJob?.completedAt) || entry.timestamp,
|
|
691
|
+
retryOfJobId: readNonEmptyString(refineJob?.retryOfJobId) || readNonEmptyString(payload.retryOfJobId),
|
|
692
|
+
...(result ? { result } : {}),
|
|
693
|
+
};
|
|
694
|
+
const nodeLabel = readNonEmptyString(refineJob?.nodeId) || entry.nodeId || 'refine job';
|
|
695
|
+
backfilled.push({
|
|
696
|
+
event: eventName,
|
|
697
|
+
meshId,
|
|
698
|
+
nodeLabel,
|
|
699
|
+
nodeId: readNonEmptyString(refineJob?.nodeId) || entry.nodeId,
|
|
700
|
+
workspace: readNonEmptyString(refineJob?.workspace),
|
|
701
|
+
metadataEvent,
|
|
702
|
+
coordinatorMessage: buildMeshSystemMessage({ event: eventName, nodeLabel, metadataEvent }),
|
|
703
|
+
queuedAt: Date.now(),
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
return backfilled.reverse();
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function reconcilePendingMeshCoordinatorEvents(meshId: string, events: PendingMeshCoordinatorEvent[]): PendingMeshCoordinatorEvent[] {
|
|
710
|
+
const backfilled = refineTerminalEventFromLedger(meshId, events);
|
|
711
|
+
// A refine:accepted event is a provisional "job accepted, result to follow" signal.
|
|
712
|
+
// Once its terminal (completed/failed) counterpart for the same jobId exists — whether
|
|
713
|
+
// already direct-queued into the pending store OR backfilled from the ledger here — the
|
|
714
|
+
// accepted is superseded and is dropped so the coordinator isn't shown stale duplicate
|
|
715
|
+
// noise alongside the terminal outcome.
|
|
716
|
+
const terminalJobIds = new Set(
|
|
717
|
+
[...events.filter(event => REFINE_TERMINAL_EVENTS.has(event.event)), ...backfilled]
|
|
718
|
+
.map(event => readRefineJobId(event))
|
|
719
|
+
.filter(Boolean),
|
|
720
|
+
);
|
|
721
|
+
const reconciled = terminalJobIds.size === 0
|
|
722
|
+
? events
|
|
723
|
+
: events.filter(event => !(event.event === 'refine:accepted' && terminalJobIds.has(readRefineJobId(event))));
|
|
724
|
+
return backfilled.length === 0 ? reconciled : [...reconciled, ...backfilled];
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const MAX_PENDING_EVENTS_BYTES = 100 * 1024; // 100 KB — keep the pending file small
|
|
728
|
+
const MAX_PENDING_EVENTS_KEEP = 50; // keep the last 50 events when trimming
|
|
729
|
+
|
|
730
|
+
// ─── SQLite pending-event retention ─────────────────────────────────────────
|
|
731
|
+
// mesh_pending_events had no lifecycle GC: drained rows are retained forever (the
|
|
732
|
+
// durable v2-eventId dedup baseline drainedEventIdsForMesh reads them), and an
|
|
733
|
+
// undrained row for a coordinator identity that never returns stays queued forever.
|
|
734
|
+
// Both accumulate without bound. These windows bound that growth while preserving
|
|
735
|
+
// the two things the rows exist for — recent-re-delivery idempotency and delivery
|
|
736
|
+
// to a returning coordinator. A drained event older than the drained window cannot
|
|
737
|
+
// be re-delivered (its producer session is long gone), so keeping it buys nothing;
|
|
738
|
+
// an undrained event is kept far longer so a genuinely-offline coordinator's backlog
|
|
739
|
+
// survives, and only unrecoverable orphans are swept.
|
|
740
|
+
const PENDING_EVENTS_DRAINED_RETENTION_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
741
|
+
const PENDING_EVENTS_UNDRAINED_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* Retention sweep for the SQLite mesh_pending_events inbox. Deletes long-drained
|
|
745
|
+
* rows (past the idempotency-useful window) and long-orphaned undrained rows (a
|
|
746
|
+
* coordinator identity that never returned). Best-effort and idempotent: a store
|
|
747
|
+
* failure or an empty table is a cheap no-op. Called from the periodic mesh-event
|
|
748
|
+
* maintenance sweep. Returns the number of rows pruned (0 when nothing to do).
|
|
749
|
+
*/
|
|
750
|
+
export function prunePendingMeshCoordinatorEventsRetention(): number {
|
|
751
|
+
try {
|
|
752
|
+
const removed = MeshRuntimeStore.getInstance().prunePendingEvents({
|
|
753
|
+
drainedOlderThanMs: PENDING_EVENTS_DRAINED_RETENTION_MS,
|
|
754
|
+
undrainedOlderThanMs: PENDING_EVENTS_UNDRAINED_RETENTION_MS,
|
|
755
|
+
});
|
|
756
|
+
if (removed > 0) {
|
|
757
|
+
LOG.info('MeshEvents', `Pruned ${removed} stale pending-event row(s) (drained >7d / undrained >30d)`);
|
|
758
|
+
}
|
|
759
|
+
return removed;
|
|
760
|
+
} catch (e: any) {
|
|
761
|
+
LOG.warn('MeshEvents', `Pending-event retention prune failed: ${e?.message || e}`);
|
|
762
|
+
return 0;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function trimPendingEventsIfNeeded(path: string): void {
|
|
767
|
+
try {
|
|
768
|
+
if (!existsSync(path)) return;
|
|
769
|
+
if (statSync(path).size <= MAX_PENDING_EVENTS_BYTES) return;
|
|
770
|
+
const lines = readFileSync(path, 'utf-8').split('\n').filter(Boolean);
|
|
771
|
+
if (lines.length <= MAX_PENDING_EVENTS_KEEP) return;
|
|
772
|
+
// C1 (data safety): this trim discards the OLDEST queued lines to keep the file
|
|
773
|
+
// bounded. An undelivered terminal completion among them would otherwise lose its
|
|
774
|
+
// worker summary silently (the JSONL is the only copy when the SQLite dual-write
|
|
775
|
+
// failed). Before dropping, mirror any meaningful (coordinator-facing / summary-
|
|
776
|
+
// bearing) dropped event into the ledger so it stays auditable and recoverable,
|
|
777
|
+
// and LOG.warn so the drop is observable instead of silent.
|
|
778
|
+
const dropped = lines.slice(0, lines.length - MAX_PENDING_EVENTS_KEEP);
|
|
779
|
+
for (const line of dropped) {
|
|
780
|
+
let event: PendingMeshCoordinatorEvent | undefined;
|
|
781
|
+
try { event = JSON.parse(line) as PendingMeshCoordinatorEvent; } catch { continue; }
|
|
782
|
+
if (!event || !event.meshId) continue;
|
|
783
|
+
const finalSummary = readMeshCompletionSummary(event.metadataEvent || {});
|
|
784
|
+
// "Meaningful" = would have been delivered to a coordinator (carries a message)
|
|
785
|
+
// or carries worker output worth preserving. Silent lifecycle events are not
|
|
786
|
+
// logged — losing them on trim is harmless (they re-drive nothing once stale).
|
|
787
|
+
if (!readNonEmptyString(event.coordinatorMessage) && !finalSummary) continue;
|
|
788
|
+
try {
|
|
789
|
+
appendLedgerEntry(event.meshId, {
|
|
790
|
+
kind: 'event_held',
|
|
791
|
+
...(event.nodeId ? { nodeId: event.nodeId } : {}),
|
|
792
|
+
payload: {
|
|
793
|
+
event: event.event,
|
|
794
|
+
reason: 'pending_trim_dropped',
|
|
795
|
+
recoverable: true,
|
|
796
|
+
nodeLabel: event.nodeLabel,
|
|
797
|
+
...(event.workspace ? { workspace: event.workspace } : {}),
|
|
798
|
+
targetCoordinatorDaemonId: event.targetCoordinatorDaemonId ?? null,
|
|
799
|
+
...(readNonEmptyString(event.eventId) ? { eventId: event.eventId } : {}),
|
|
800
|
+
queuedAt: event.queuedAt,
|
|
801
|
+
...(finalSummary ? { finalSummary } : {}),
|
|
802
|
+
// Full original event for lossless mesh_requeue_held_events restore.
|
|
803
|
+
heldEvent: event,
|
|
804
|
+
},
|
|
805
|
+
});
|
|
806
|
+
LOG.warn('MeshEvents', `Pending-events trim dropping undelivered ${event.event} for mesh ${event.meshId} — recorded to ledger (recoverable)`);
|
|
807
|
+
} catch (e: any) {
|
|
808
|
+
LOG.warn('MeshEvents', `Failed to ledger-record trim-dropped ${event.event} for mesh ${event.meshId}: ${e?.message || e}`);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
writeFileSync(path, lines.slice(-MAX_PENDING_EVENTS_KEEP).join('\n') + '\n', 'utf-8');
|
|
812
|
+
} catch { /* best-effort; if trim fails, append still proceeds */ }
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* Stamp the v2 protocol envelope onto a pending event at emit time (B2a).
|
|
817
|
+
*
|
|
818
|
+
* Non-breaking: returns a NEW event object with protocolVersion/eventId/scope/
|
|
819
|
+
* dispatchedBy/intendedFor added when a coordinator identity can be derived,
|
|
820
|
+
* otherwise returns the input unchanged (a v1 event, broadcast-treated during
|
|
821
|
+
* rollout). Identity and the unicast target are derived from the event's own
|
|
822
|
+
* targetCoordinatorDaemonId / targetCoordinatorSessionId (already carried by
|
|
823
|
+
* every producer), so most call sites need no change; the optional `hint`
|
|
824
|
+
* overrides scope/identity where a site knows better.
|
|
825
|
+
*
|
|
826
|
+
* The eventId is generated here (randomUUID) exactly once, so re-queues that
|
|
827
|
+
* pass an already-stamped event keep their original eventId — the idempotency
|
|
828
|
+
* key is stable across re-delivery. An already-stamped event is returned as-is.
|
|
829
|
+
*/
|
|
830
|
+
export function stampPendingEventV2(
|
|
831
|
+
event: PendingMeshCoordinatorEvent,
|
|
832
|
+
hint?: PendingEventEmitHint,
|
|
833
|
+
): PendingMeshCoordinatorEvent {
|
|
834
|
+
// Preserve idempotency across re-queues: never re-stamp an event that already
|
|
835
|
+
// carries a v2 eventId (mesh-reconcile-loop / flushPendingForMeshIdleCoordinators
|
|
836
|
+
// re-queue built events verbatim).
|
|
837
|
+
if (event.protocolVersion === MESH_PROTOCOL_VERSION_V2 && readNonEmptyString(event.eventId)) {
|
|
838
|
+
return event;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
const coordinatorIdentity = hint?.dispatchedBy ?? coordinatorIdentityFromEmitFields({
|
|
842
|
+
daemonId: event.targetCoordinatorDaemonId,
|
|
843
|
+
coordinatorRunId: hint?.coordinatorRunId,
|
|
844
|
+
sessionId: event.targetCoordinatorSessionId,
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
// A/C ROOT FIX: an emit site with NO coordinator identity (direct-dispatch /
|
|
848
|
+
// refine notification / any path where the worker session never carried a
|
|
849
|
+
// meshCoordinatorDaemonId) used to leave the event UNVERSIONED (v1). Under v2
|
|
850
|
+
// enforce (default ON) routeV2EventsForDrainer QUARANTINES every unversioned
|
|
851
|
+
// event — so a summary-less completion (agent:generating_completed) and every
|
|
852
|
+
// refine terminal notification (refine:accepted/completed/failed) were held
|
|
853
|
+
// back and never reached the coordinator; only the backstop papered over it.
|
|
854
|
+
//
|
|
855
|
+
// Fall back to THIS daemon's own id as the dispatcher so a v2 envelope can
|
|
856
|
+
// still be minted. There is no addressable coordinator, so we intentionally
|
|
857
|
+
// leave intendedFor empty and let buildPendingEventEmitStamp downgrade the
|
|
858
|
+
// (unicast-defaulting) terminal event to a BROADCAST — deliverable to whatever
|
|
859
|
+
// coordinator drains on this machine, instead of an undeliverable v1 event.
|
|
860
|
+
// When a real coordinator identity DOES exist the unicast path below is
|
|
861
|
+
// unchanged (no regression). loadConfig().machineId is the same self-id source
|
|
862
|
+
// resolveCoordinatorDaemonIds / the local queue-assignment stamp use, so the
|
|
863
|
+
// broadcast dispatcher matches the drainer's own identity form.
|
|
864
|
+
const selfFallback = !coordinatorIdentity;
|
|
865
|
+
const dispatchedBy = coordinatorIdentity ?? coordinatorIdentityFromEmitFields({
|
|
866
|
+
daemonId: readNonEmptyString(loadConfig().machineId),
|
|
867
|
+
});
|
|
868
|
+
// The unicast target is, by default, the same coordinator the event is already
|
|
869
|
+
// routed to (its originating coordinator). A hint may override it. In the
|
|
870
|
+
// self-fallback case there is no originating coordinator to address, so leave
|
|
871
|
+
// it empty → broadcast (never a self-unicast that a sibling session's drainer
|
|
872
|
+
// would skip).
|
|
873
|
+
const intendedFor: CoordinatorIdentity | undefined = hint?.intendedFor
|
|
874
|
+
?? (selfFallback ? undefined : coordinatorIdentity);
|
|
875
|
+
|
|
876
|
+
const stamp = buildPendingEventEmitStamp({
|
|
877
|
+
eventName: event.event,
|
|
878
|
+
eventId: randomUUID(),
|
|
879
|
+
dispatchedBy,
|
|
880
|
+
intendedFor,
|
|
881
|
+
// Force broadcast for the self-fallback so a unicast-defaulting terminal
|
|
882
|
+
// event isn't addressed to this daemon alone; an explicit hint still wins.
|
|
883
|
+
scope: hint?.scope ?? (selfFallback ? 'broadcast' : undefined),
|
|
884
|
+
});
|
|
885
|
+
if (!stamp) return event; // no coordinator identity at all (no self id) → stays a v1 event
|
|
886
|
+
|
|
887
|
+
// Mark an ownerless (self-fallback) broadcast so the drain-side leak guard can
|
|
888
|
+
// tell it apart from a genuinely owned broadcast terminal event. selfFallback is
|
|
889
|
+
// true only when no coordinator identity existed and we minted the stamp under
|
|
890
|
+
// this daemon's own machineId; a broadcast that stays broadcast for that reason
|
|
891
|
+
// has no owner to leak from and must reach whatever coordinator drains here.
|
|
892
|
+
const dispatchedBySelfFallback = selfFallback && stamp.scope === 'broadcast';
|
|
893
|
+
|
|
894
|
+
return {
|
|
895
|
+
...event,
|
|
896
|
+
protocolVersion: stamp.protocolVersion,
|
|
897
|
+
eventId: stamp.eventId,
|
|
898
|
+
scope: stamp.scope,
|
|
899
|
+
dispatchedBy: stamp.dispatchedBy,
|
|
900
|
+
...(stamp.intendedFor ? { intendedFor: stamp.intendedFor } : {}),
|
|
901
|
+
...(dispatchedBySelfFallback ? { dispatchedBySelfFallback: true } : {}),
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// ─── v2 envelope: remote (P2P) boundary preservation (B3b/T4) ─────────────
|
|
906
|
+
//
|
|
907
|
+
// The remote pull round-trip (mesh-reconcile-loop pullRemoteNodeQueues →
|
|
908
|
+
// get_pending_mesh_events → buildForwardPayloadFromPending → handleMeshForwardEvent
|
|
909
|
+
// → queuePendingMeshCoordinatorEvent) flattens a queued PendingMeshCoordinatorEvent
|
|
910
|
+
// into a flat wire payload and rebuilds it on the receiving daemon. The v2 envelope
|
|
911
|
+
// fields (protocolVersion / eventId / scope / dispatchedBy / intendedFor) live at the
|
|
912
|
+
// TOP LEVEL of the event, not inside metadataEvent, so the flatten/rebuild whitelist
|
|
913
|
+
// dropped them: the re-queue then re-stamped a FRESH eventId, breaking cross-machine
|
|
914
|
+
// idempotency and downgrading the relayed completion to v1 (broadcast) routing.
|
|
915
|
+
//
|
|
916
|
+
// These two helpers are the single serialization/deserialization pair for that
|
|
917
|
+
// boundary. serializeV2EnvelopeToWire copies the present v2 fields onto the flat
|
|
918
|
+
// payload; readV2EnvelopeFromWire validates and restores them for the re-queue. The
|
|
919
|
+
// eventId is carried verbatim so stampPendingEventV2's already-stamped short-circuit
|
|
920
|
+
// preserves it (no new UUID). Kept pure + exported so the round-trip is unit-testable.
|
|
921
|
+
|
|
922
|
+
/** Read a CoordinatorIdentity off an untrusted wire object, or undefined if malformed. */
|
|
923
|
+
function readCoordinatorIdentityFromWire(raw: unknown): CoordinatorIdentity | undefined {
|
|
924
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
|
|
925
|
+
const obj = raw as Record<string, unknown>;
|
|
926
|
+
const daemonId = readNonEmptyString(obj.daemonId);
|
|
927
|
+
const coordinatorRunId = readNonEmptyString(obj.coordinatorRunId);
|
|
928
|
+
if (!daemonId || !coordinatorRunId) return undefined;
|
|
929
|
+
const sessionId = readNonEmptyString(obj.sessionId);
|
|
930
|
+
return { daemonId, coordinatorRunId, ...(sessionId ? { sessionId } : {}) };
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* Copy the v2 envelope fields that are present on `event` onto a flat wire
|
|
935
|
+
* payload. Only sets a field when it is present, so a v1 event contributes
|
|
936
|
+
* nothing (the payload stays v1-shaped and version-skew safe).
|
|
937
|
+
*/
|
|
938
|
+
export function serializeV2EnvelopeToWire(event: PendingMeshCoordinatorEvent): Record<string, unknown> {
|
|
939
|
+
const out: Record<string, unknown> = {};
|
|
940
|
+
if (event.protocolVersion) out.protocolVersion = event.protocolVersion;
|
|
941
|
+
if (readNonEmptyString(event.eventId)) out.eventId = event.eventId;
|
|
942
|
+
if (event.scope) out.scope = event.scope;
|
|
943
|
+
if (event.dispatchedBy) out.dispatchedBy = event.dispatchedBy;
|
|
944
|
+
if (event.intendedFor) out.intendedFor = event.intendedFor;
|
|
945
|
+
return out;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* Restore the v2 envelope fields from a flat wire payload for a re-queue. Only
|
|
950
|
+
* returns fields that survive validation; a payload missing/malforming a field
|
|
951
|
+
* yields a partial (or empty) object so the re-queue path stays v1-safe. The
|
|
952
|
+
* eventId is returned verbatim — its preservation is the idempotency guarantee.
|
|
953
|
+
*/
|
|
954
|
+
export function readV2EnvelopeFromWire(payload: Record<string, unknown>): Partial<Pick<
|
|
955
|
+
PendingMeshCoordinatorEvent,
|
|
956
|
+
'protocolVersion' | 'eventId' | 'scope' | 'dispatchedBy' | 'intendedFor'
|
|
957
|
+
>> {
|
|
958
|
+
const out: Partial<Pick<
|
|
959
|
+
PendingMeshCoordinatorEvent,
|
|
960
|
+
'protocolVersion' | 'eventId' | 'scope' | 'dispatchedBy' | 'intendedFor'
|
|
961
|
+
>> = {};
|
|
962
|
+
if (payload.protocolVersion === MESH_PROTOCOL_VERSION_V2) out.protocolVersion = MESH_PROTOCOL_VERSION_V2;
|
|
963
|
+
const eventId = readNonEmptyString(payload.eventId);
|
|
964
|
+
if (eventId) out.eventId = eventId;
|
|
965
|
+
if (isMeshEventScope(payload.scope)) out.scope = payload.scope;
|
|
966
|
+
const dispatchedBy = readCoordinatorIdentityFromWire(payload.dispatchedBy);
|
|
967
|
+
if (dispatchedBy) out.dispatchedBy = dispatchedBy;
|
|
968
|
+
const intendedFor = readCoordinatorIdentityFromWire(payload.intendedFor);
|
|
969
|
+
if (intendedFor) out.intendedFor = intendedFor;
|
|
970
|
+
return out;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
export function queuePendingMeshCoordinatorEvent(
|
|
974
|
+
rawEvent: PendingMeshCoordinatorEvent,
|
|
975
|
+
hint?: PendingEventEmitHint,
|
|
976
|
+
): boolean {
|
|
977
|
+
// B2a: stamp the v2 envelope before dedup/persist so the eventId/scope ride
|
|
978
|
+
// into both stores and the fingerprint/dedup logic sees the final shape.
|
|
979
|
+
const event = stampPendingEventV2(rawEvent, hint);
|
|
980
|
+
return persistPendingMeshCoordinatorEvent(event);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* Persist an ALREADY-STAMPED pending event to both stores (dedup + SQLite + JSONL),
|
|
985
|
+
* without re-running the emit stamp. queuePendingMeshCoordinatorEvent stamps then
|
|
986
|
+
* calls this; the only other caller is the test helper below, which needs to inject
|
|
987
|
+
* a genuinely-unversioned (v1) row to exercise the drain-side v1 handling now that
|
|
988
|
+
* the emit path never produces one (self-daemon fallback stamps every local emit).
|
|
989
|
+
*/
|
|
990
|
+
function persistPendingMeshCoordinatorEvent(event: PendingMeshCoordinatorEvent): boolean {
|
|
991
|
+
try {
|
|
992
|
+
if (hasPendingRefineTerminalEventDuplicate(event)) {
|
|
993
|
+
LOG.info('MeshEvents', `Suppressed duplicate pending ${event.event} for refine job ${readRefineJobId(event)}`);
|
|
994
|
+
return true;
|
|
995
|
+
}
|
|
996
|
+
if (hasPendingCoordinatorEventDuplicate(event)) {
|
|
997
|
+
LOG.info('MeshEvents', `Suppressed duplicate pending ${event.event} for mesh ${event.meshId}`);
|
|
998
|
+
return true;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
const fingerprint = buildPendingEventFingerprint(event);
|
|
1002
|
+
|
|
1003
|
+
// G3: Write to SQLite inbox (primary path going forward)
|
|
1004
|
+
let sqliteOk = false;
|
|
1005
|
+
try {
|
|
1006
|
+
MeshRuntimeStore.getInstance().insertPendingEvent({
|
|
1007
|
+
id: randomUUID(),
|
|
1008
|
+
meshId: event.meshId,
|
|
1009
|
+
coordinatorDaemonId: event.targetCoordinatorDaemonId ?? null,
|
|
1010
|
+
event: event.event,
|
|
1011
|
+
payload: event,
|
|
1012
|
+
fingerprint: fingerprint || null,
|
|
1013
|
+
queuedAt: event.queuedAt,
|
|
1014
|
+
// v2 envelope columns (B2a) — all nullable so v1 rows coexist. The
|
|
1015
|
+
// authoritative copy still rides inside `payload`; these columns exist
|
|
1016
|
+
// for queryable idempotency (event_id) and scope-based drain filtering
|
|
1017
|
+
// (scope / intended_for) without JSON-parsing every row.
|
|
1018
|
+
protocolVersion: event.protocolVersion ?? null,
|
|
1019
|
+
eventId: event.eventId ?? null,
|
|
1020
|
+
scope: event.scope ?? null,
|
|
1021
|
+
dispatchedBy: event.dispatchedBy ? JSON.stringify(event.dispatchedBy) : null,
|
|
1022
|
+
intendedFor: event.intendedFor ? JSON.stringify(event.intendedFor) : null,
|
|
1023
|
+
});
|
|
1024
|
+
sqliteOk = true;
|
|
1025
|
+
} catch {
|
|
1026
|
+
// SQLite write failure is non-fatal; JSONL fallback below still works.
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// Also write to JSONL (retained as legacy/export artifact). Best-effort once
|
|
1030
|
+
// SQLite (the primary store) has the event: a JSONL append failure (disk full,
|
|
1031
|
+
// permissions) must NOT report the whole persist as failed when SQLite holds it.
|
|
1032
|
+
try {
|
|
1033
|
+
const path = getPendingEventsPath(event.meshId, event.targetCoordinatorDaemonId);
|
|
1034
|
+
trimPendingEventsIfNeeded(path);
|
|
1035
|
+
appendFileSync(path, JSON.stringify(event) + '\n', 'utf-8');
|
|
1036
|
+
} catch (e: any) {
|
|
1037
|
+
if (!sqliteOk) throw e; // neither store has it — surface as a real failure
|
|
1038
|
+
LOG.warn('MeshEvents', `JSONL append failed for mesh ${event.meshId}; SQLite holds the event: ${e?.message || e}`);
|
|
1039
|
+
}
|
|
1040
|
+
return true;
|
|
1041
|
+
} catch (e: any) {
|
|
1042
|
+
LOG.warn('MeshEvents', `Failed to persist pending coordinator event: ${e?.message || e}`);
|
|
1043
|
+
return false;
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// Atomically rename the file before reading so concurrent drains can't both consume
|
|
1048
|
+
// the same events. renameSync is atomic on POSIX (same filesystem); only one caller
|
|
1049
|
+
// wins the rename — the other gets ENOENT and returns null, preventing duplicate delivery.
|
|
1050
|
+
function atomicDrainFile(path: string): string | null {
|
|
1051
|
+
const tmpPath = `${path}.draining`;
|
|
1052
|
+
try {
|
|
1053
|
+
renameSync(path, tmpPath);
|
|
1054
|
+
} catch {
|
|
1055
|
+
return null; // another drain already renamed it, or file doesn't exist
|
|
1056
|
+
}
|
|
1057
|
+
try {
|
|
1058
|
+
const content = readFileSync(tmpPath, 'utf-8');
|
|
1059
|
+
try { unlinkSync(tmpPath); } catch { /* already cleaned up */ }
|
|
1060
|
+
return content;
|
|
1061
|
+
} catch {
|
|
1062
|
+
try { unlinkSync(tmpPath); } catch { /* best-effort cleanup */ }
|
|
1063
|
+
return null;
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// Selectively drain a JSONL pending-events file: atomically claim it (rename), then
|
|
1068
|
+
// consume only the lines whose parsed event matches `predicate` and rewrite the
|
|
1069
|
+
// remaining (kept) lines back to the original path. Unparseable lines are kept
|
|
1070
|
+
// untouched. Returns the consumed events. The rename makes claiming exclusive —
|
|
1071
|
+
// only one concurrent caller wins, so there is no double-consume of the same lines.
|
|
1072
|
+
function selectiveDrainFile(
|
|
1073
|
+
path: string,
|
|
1074
|
+
predicate: (event: PendingMeshCoordinatorEvent) => boolean,
|
|
1075
|
+
): PendingMeshCoordinatorEvent[] {
|
|
1076
|
+
const tmpPath = `${path}.draining`;
|
|
1077
|
+
try {
|
|
1078
|
+
renameSync(path, tmpPath);
|
|
1079
|
+
} catch {
|
|
1080
|
+
return []; // another drain claimed it, or the file doesn't exist
|
|
1081
|
+
}
|
|
1082
|
+
let content: string;
|
|
1083
|
+
try {
|
|
1084
|
+
content = readFileSync(tmpPath, 'utf-8');
|
|
1085
|
+
} catch {
|
|
1086
|
+
try { unlinkSync(tmpPath); } catch { /* best-effort */ }
|
|
1087
|
+
return [];
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
const consumed: PendingMeshCoordinatorEvent[] = [];
|
|
1091
|
+
const keptLines: string[] = [];
|
|
1092
|
+
for (const line of content.split('\n')) {
|
|
1093
|
+
if (!line) continue;
|
|
1094
|
+
let parsed: PendingMeshCoordinatorEvent | undefined;
|
|
1095
|
+
try { parsed = JSON.parse(line) as PendingMeshCoordinatorEvent; } catch { parsed = undefined; }
|
|
1096
|
+
if (parsed && predicate(parsed)) {
|
|
1097
|
+
consumed.push(parsed);
|
|
1098
|
+
} else {
|
|
1099
|
+
keptLines.push(line); // non-matching or unparseable → leave queued
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
try {
|
|
1104
|
+
if (keptLines.length > 0) {
|
|
1105
|
+
writeFileSync(path, keptLines.join('\n') + '\n', 'utf-8');
|
|
1106
|
+
}
|
|
1107
|
+
unlinkSync(tmpPath);
|
|
1108
|
+
} catch {
|
|
1109
|
+
// If the rewrite/cleanup fails, restore the claimed file so no events are
|
|
1110
|
+
// lost — the next drain retries the whole file.
|
|
1111
|
+
try { if (existsSync(tmpPath) && !existsSync(path)) renameSync(tmpPath, path); } catch { /* best-effort */ }
|
|
1112
|
+
return [];
|
|
1113
|
+
}
|
|
1114
|
+
return consumed;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
/**
|
|
1118
|
+
* Drain and return pending coordinator events for meshId, removing the drained
|
|
1119
|
+
* ones from both the SQLite inbox and the JSONL legacy file.
|
|
1120
|
+
*
|
|
1121
|
+
* When `opts.onlyEvents` is supplied, ONLY events whose name is in that set are
|
|
1122
|
+
* drained; every other event stays queued (undrained in SQLite, rewritten back to
|
|
1123
|
+
* the JSONL file). The reconcile loop uses this to force-drain terminal/force-inject
|
|
1124
|
+
* events into a *generating* coordinator while leaving non-force progress events for
|
|
1125
|
+
* the coordinator's next idle transition. The atomic SQLite drained=1 marking and the
|
|
1126
|
+
* atomic JSONL rename keep force-drain and a concurrent full drain from double-consuming.
|
|
1127
|
+
*/
|
|
1128
|
+
export function drainPendingMeshCoordinatorEvents(
|
|
1129
|
+
meshId?: string,
|
|
1130
|
+
coordinatorDaemonId?: string | ReadonlyArray<string>,
|
|
1131
|
+
opts?: { onlyEvents?: ReadonlySet<string>; drainerIdentity?: CoordinatorIdentity },
|
|
1132
|
+
): PendingMeshCoordinatorEvent[] {
|
|
1133
|
+
if (!meshId) return [];
|
|
1134
|
+
|
|
1135
|
+
// A daemon may answer to more than one coordinator-id form (its canonical
|
|
1136
|
+
// status id like `standalone_<machineId>` AND the bare machineId). Normalise
|
|
1137
|
+
// to a list so both the SQLite IN-filter and the JSONL targeting predicate
|
|
1138
|
+
// accept any of them.
|
|
1139
|
+
const daemonIds = normalizeCoordinatorDaemonIds(coordinatorDaemonId);
|
|
1140
|
+
const primaryDaemonId = daemonIds[0];
|
|
1141
|
+
|
|
1142
|
+
// B3a: the drainer's v2 identity, for unicast routing + eventId dedup. Derived
|
|
1143
|
+
// from the daemon ids the (untouchable) reconcile-loop already passes, so no
|
|
1144
|
+
// caller change is required; a caller may still pass the full identity.
|
|
1145
|
+
const drainer = resolveDrainerIdentity(daemonIds, opts?.drainerIdentity);
|
|
1146
|
+
// Snapshot the ALREADY-drained v2 eventIds BEFORE the SQLite drain marks this
|
|
1147
|
+
// batch drained=1 — the re-delivery dedup baseline. Reading it after would
|
|
1148
|
+
// self-match the batch's own rows.
|
|
1149
|
+
let priorDrainedEventIds = new Set<string>();
|
|
1150
|
+
try {
|
|
1151
|
+
priorDrainedEventIds = MeshRuntimeStore.getInstance().drainedEventIdsForMesh(meshId);
|
|
1152
|
+
} catch { /* store unavailable — no durable baseline; batch guard still applies */ }
|
|
1153
|
+
|
|
1154
|
+
const onlyEvents = opts?.onlyEvents;
|
|
1155
|
+
const matchesFilter = (eventName: string): boolean => !onlyEvents || onlyEvents.has(eventName);
|
|
1156
|
+
|
|
1157
|
+
// Dual-write means SQLite and JSONL hold the same events. Both stores must be
|
|
1158
|
+
// emptied in one drain call — draining only one leaves the other to re-deliver
|
|
1159
|
+
// the same events on the next call. Merge with fingerprint dedup.
|
|
1160
|
+
const merged: PendingMeshCoordinatorEvent[] = [];
|
|
1161
|
+
const seenFingerprints = new Set<string>();
|
|
1162
|
+
const pushUnique = (event: PendingMeshCoordinatorEvent) => {
|
|
1163
|
+
const fingerprint = buildPendingEventFingerprint(event);
|
|
1164
|
+
if (fingerprint.trim()) {
|
|
1165
|
+
if (seenFingerprints.has(fingerprint)) return;
|
|
1166
|
+
seenFingerprints.add(fingerprint);
|
|
1167
|
+
}
|
|
1168
|
+
merged.push(event);
|
|
1169
|
+
};
|
|
1170
|
+
|
|
1171
|
+
// G3: SQLite inbox
|
|
1172
|
+
try {
|
|
1173
|
+
const store = MeshRuntimeStore.getInstance();
|
|
1174
|
+
if (store.pendingEventCount(meshId) > 0) {
|
|
1175
|
+
for (const row of store.drainPendingEvents(meshId, daemonIds.length > 0 ? daemonIds : undefined, onlyEvents ? { onlyEvents } : undefined)) {
|
|
1176
|
+
const event = row.payload as PendingMeshCoordinatorEvent;
|
|
1177
|
+
if (event) pushUnique(event);
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
} catch (e: any) {
|
|
1181
|
+
// SQLite drain failed — JSONL below still drains. Surface it: a silent
|
|
1182
|
+
// failure here means the JSONL copy is emptied while the SQLite rows
|
|
1183
|
+
// survive undrained, so the next drain re-delivers the same events to the
|
|
1184
|
+
// coordinator (duplicate refine:completed etc.) with no diagnostic trail.
|
|
1185
|
+
LOG.warn('MeshEvents', `SQLite pending-event drain failed for mesh ${meshId}; JSONL fallback only: ${e?.message || e}`);
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
// JSONL (legacy / migration path) — always drained alongside SQLite.
|
|
1189
|
+
// The scoped per-daemon file is keyed by a single id; use the primary. The
|
|
1190
|
+
// shared (unscoped) file's targeting predicate accepts ANY of this daemon's ids.
|
|
1191
|
+
const paths = primaryDaemonId
|
|
1192
|
+
? [getPendingEventsPath(meshId, primaryDaemonId), getPendingEventsPath(meshId)]
|
|
1193
|
+
: [getPendingEventsPath(meshId)];
|
|
1194
|
+
for (const path of paths) {
|
|
1195
|
+
const isSharedFile = !!primaryDaemonId && path === getPendingEventsPath(meshId);
|
|
1196
|
+
// Targeting predicate for the shared (unscoped) file: only this coordinator's
|
|
1197
|
+
// events (or legacy untargeted ones) are eligible.
|
|
1198
|
+
const targets = (e: PendingMeshCoordinatorEvent): boolean =>
|
|
1199
|
+
!isSharedFile || !e.targetCoordinatorDaemonId || daemonIds.includes(e.targetCoordinatorDaemonId);
|
|
1200
|
+
|
|
1201
|
+
if (onlyEvents) {
|
|
1202
|
+
// Selective JSONL drain: consume only matching events, rewrite the rest back.
|
|
1203
|
+
for (const event of selectiveDrainFile(path, e => targets(e) && matchesFilter(e.event))) {
|
|
1204
|
+
pushUnique(event);
|
|
1205
|
+
}
|
|
1206
|
+
continue;
|
|
1207
|
+
}
|
|
1208
|
+
const content = atomicDrainFile(path);
|
|
1209
|
+
if (!content) continue;
|
|
1210
|
+
const parsed = content.split('\n').filter(Boolean).flatMap(line => {
|
|
1211
|
+
try { return [JSON.parse(line) as PendingMeshCoordinatorEvent]; } catch { return []; }
|
|
1212
|
+
});
|
|
1213
|
+
// If reading the shared file, filter to events that target this coordinator or are unscoped.
|
|
1214
|
+
const filtered = isSharedFile ? parsed.filter(targets) : parsed;
|
|
1215
|
+
for (const event of filtered) pushUnique(event);
|
|
1216
|
+
}
|
|
1217
|
+
if (merged.length === 0) return [];
|
|
1218
|
+
// (Former R3 direct-delivered dedup removed.) Spontaneous PTY direct-inject no
|
|
1219
|
+
// longer exists — delivery is now queue-drain-only (reconcile loop or MCP pull),
|
|
1220
|
+
// so an event is consumed by exactly one drainer via the atomic SQLite drained=1
|
|
1221
|
+
// marking. There is no PTY-vs-poll double path left to dedup against.
|
|
1222
|
+
//
|
|
1223
|
+
// B3a: v2 receive-side routing (accept-and-warn) — unicast targeting, eventId
|
|
1224
|
+
// idempotency, malformed-envelope pass-through-with-warn. v1 events broadcast.
|
|
1225
|
+
// Runs AFTER the merge/reconcile so a single eventId dedup batch covers both
|
|
1226
|
+
// stores. Non-destructive to v1 behaviour when no drainer identity is known.
|
|
1227
|
+
const routed = routeV2EventsForDrainer(merged, drainer, {
|
|
1228
|
+
alreadyDrained: (eventId) => priorDrainedEventIds.has(eventId),
|
|
1229
|
+
batchSeen: new Set<string>(),
|
|
1230
|
+
countMetrics: true,
|
|
1231
|
+
});
|
|
1232
|
+
return reconcilePendingMeshCoordinatorEvents(meshId, routed);
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
/**
|
|
1236
|
+
* FALSE-BLOCKER-CLONE-QUEUE: retract any still-UNDELIVERED `mesh:dispatch_blocked`
|
|
1237
|
+
* actionable-skip event for a task whose blocker has since resolved (the task was
|
|
1238
|
+
* claimed, or its skip transitioned to a self-resolving transient reason). Without
|
|
1239
|
+
* this, a `target_node_id_unmatched` blocker paged during the brief clone/bootstrap
|
|
1240
|
+
* propagation window would linger in the coordinator's pending queue and surface as a
|
|
1241
|
+
* false "actionable blocker — will NOT clear on its own" even after the task dispatched.
|
|
1242
|
+
*
|
|
1243
|
+
* Only removes events that have NOT yet been drained/delivered to the coordinator — an
|
|
1244
|
+
* already-delivered message cannot be unsent, but de-dup re-arm (caller side) plus this
|
|
1245
|
+
* retraction guarantee no NEW stale blocker accumulates. Best-effort across both the
|
|
1246
|
+
* SQLite inbox and the JSONL legacy files (scoped + shared). Returns rows removed.
|
|
1247
|
+
*/
|
|
1248
|
+
export function retractPendingDispatchBlockedEvent(
|
|
1249
|
+
meshId: string | undefined,
|
|
1250
|
+
taskId: string | undefined,
|
|
1251
|
+
coordinatorDaemonId?: string,
|
|
1252
|
+
): number {
|
|
1253
|
+
if (!meshId || !taskId) return 0;
|
|
1254
|
+
let removed = 0;
|
|
1255
|
+
const matchesTask = (event: PendingMeshCoordinatorEvent | undefined): boolean => {
|
|
1256
|
+
if (!event || event.event !== 'mesh:dispatch_blocked') return false;
|
|
1257
|
+
const rowTaskId = readNonEmptyString((event.metadataEvent as Record<string, unknown> | undefined)?.taskId);
|
|
1258
|
+
return rowTaskId === taskId;
|
|
1259
|
+
};
|
|
1260
|
+
|
|
1261
|
+
// SQLite inbox: peek undrained rows for the mesh, hard-delete the matching ones by id.
|
|
1262
|
+
try {
|
|
1263
|
+
const store = MeshRuntimeStore.getInstance();
|
|
1264
|
+
const ids: string[] = [];
|
|
1265
|
+
for (const row of store.peekPendingEvents(meshId)) {
|
|
1266
|
+
if (row.event !== 'mesh:dispatch_blocked') continue;
|
|
1267
|
+
if (matchesTask(row.payload as PendingMeshCoordinatorEvent)) ids.push(row.id);
|
|
1268
|
+
}
|
|
1269
|
+
if (ids.length) removed += store.deletePendingEventsById(ids);
|
|
1270
|
+
} catch { /* best-effort — JSONL retraction below still runs */ }
|
|
1271
|
+
|
|
1272
|
+
// JSONL legacy files: selectively drop matching lines (rewrites the rest back).
|
|
1273
|
+
const daemonIds = normalizeCoordinatorDaemonIds(coordinatorDaemonId);
|
|
1274
|
+
const primaryDaemonId = daemonIds[0];
|
|
1275
|
+
const paths = primaryDaemonId
|
|
1276
|
+
? [getPendingEventsPath(meshId, primaryDaemonId), getPendingEventsPath(meshId)]
|
|
1277
|
+
: [getPendingEventsPath(meshId)];
|
|
1278
|
+
for (const path of paths) {
|
|
1279
|
+
try {
|
|
1280
|
+
removed += selectiveDrainFile(path, matchesTask).length;
|
|
1281
|
+
} catch { /* best-effort */ }
|
|
1282
|
+
}
|
|
1283
|
+
return removed;
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
/** Peek at pending coordinator events without draining (non-destructive). */
|
|
1287
|
+
export function getPendingMeshCoordinatorEvents(
|
|
1288
|
+
meshId?: string,
|
|
1289
|
+
coordinatorDaemonId?: string | ReadonlyArray<string>,
|
|
1290
|
+
opts?: { drainerIdentity?: CoordinatorIdentity },
|
|
1291
|
+
): readonly PendingMeshCoordinatorEvent[] {
|
|
1292
|
+
if (!meshId) return [];
|
|
1293
|
+
const daemonIds = normalizeCoordinatorDaemonIds(coordinatorDaemonId);
|
|
1294
|
+
// B3a: same v2 routing the destructive drain applies, so a peek (mesh_status
|
|
1295
|
+
// count, reconcile pre-check) sees the SAME set the drain would deliver — a
|
|
1296
|
+
// unicast event for another coordinator is not counted for this one.
|
|
1297
|
+
const drainer = resolveDrainerIdentity(daemonIds, opts?.drainerIdentity);
|
|
1298
|
+
let priorDrainedEventIds = new Set<string>();
|
|
1299
|
+
try {
|
|
1300
|
+
priorDrainedEventIds = MeshRuntimeStore.getInstance().drainedEventIdsForMesh(meshId);
|
|
1301
|
+
} catch { /* store unavailable — batch guard still applies */ }
|
|
1302
|
+
|
|
1303
|
+
// Merge SQLite (primary) + JSONL (legacy) with fingerprint dedup.
|
|
1304
|
+
const merged: PendingMeshCoordinatorEvent[] = [];
|
|
1305
|
+
const seenFingerprints = new Set<string>();
|
|
1306
|
+
const pushUnique = (event: PendingMeshCoordinatorEvent) => {
|
|
1307
|
+
const fingerprint = buildPendingEventFingerprint(event);
|
|
1308
|
+
if (fingerprint.trim()) {
|
|
1309
|
+
if (seenFingerprints.has(fingerprint)) return;
|
|
1310
|
+
seenFingerprints.add(fingerprint);
|
|
1311
|
+
}
|
|
1312
|
+
merged.push(event);
|
|
1313
|
+
};
|
|
1314
|
+
|
|
1315
|
+
// G3: SQLite inbox (non-destructive peek at undrained rows)
|
|
1316
|
+
try {
|
|
1317
|
+
const store = MeshRuntimeStore.getInstance();
|
|
1318
|
+
if (store.pendingEventCount(meshId) > 0) {
|
|
1319
|
+
for (const row of store.peekPendingEvents(meshId, daemonIds.length > 0 ? daemonIds : undefined)) {
|
|
1320
|
+
const event = row.payload as PendingMeshCoordinatorEvent;
|
|
1321
|
+
if (event) pushUnique(event);
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
} catch { /* SQLite unavailable — JSONL fallback below */ }
|
|
1325
|
+
|
|
1326
|
+
// JSONL (legacy)
|
|
1327
|
+
for (const event of readPendingMeshCoordinatorEventsFromDisk(meshId, daemonIds)) {
|
|
1328
|
+
pushUnique(event);
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
// (Former R3 direct-delivered filter removed — no PTY direct-inject path exists
|
|
1332
|
+
// anymore, so a peeked pending event has genuinely not yet been consumed.)
|
|
1333
|
+
// B3a: apply the SAME v2 routing the drain applies (non-destructive: no counter
|
|
1334
|
+
// inflation on the frequent status-poll path).
|
|
1335
|
+
const routed = routeV2EventsForDrainer(merged, drainer, {
|
|
1336
|
+
alreadyDrained: (eventId) => priorDrainedEventIds.has(eventId),
|
|
1337
|
+
batchSeen: new Set<string>(),
|
|
1338
|
+
countMetrics: false,
|
|
1339
|
+
});
|
|
1340
|
+
return reconcilePendingMeshCoordinatorEvents(meshId, routed);
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
/**
|
|
1344
|
+
* Test helper: purge all pending-event state for a mesh — SQLite rows
|
|
1345
|
+
* (including drained fingerprint history) and JSONL files.
|
|
1346
|
+
*/
|
|
1347
|
+
export function __clearMeshPendingEventsForTests(meshId: string): void {
|
|
1348
|
+
try {
|
|
1349
|
+
MeshRuntimeStore.getInstance().clearPendingEventsForMesh(meshId);
|
|
1350
|
+
} catch { /* store unavailable — nothing to clear */ }
|
|
1351
|
+
clearPendingMeshCoordinatorEvents(meshId);
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
/**
|
|
1355
|
+
* Test helper: persist a pending event VERBATIM, skipping the emit-time v2 stamp.
|
|
1356
|
+
* The local emit path (queuePendingMeshCoordinatorEvent → stampPendingEventV2) now
|
|
1357
|
+
* always mints a v2 envelope (self-daemon broadcast fallback when no coordinator
|
|
1358
|
+
* identity is present), so a genuinely-unversioned (v1) row can no longer be produced
|
|
1359
|
+
* through the normal queue. The drain-side v1 handling (accept-broadcast / enforce-
|
|
1360
|
+
* quarantine) still matters for durable v1 rows written by a pre-v2 daemon and for
|
|
1361
|
+
* version-skewed remote relays, so tests inject those rows directly through this.
|
|
1362
|
+
*/
|
|
1363
|
+
export function __persistUnstampedPendingEventForTests(event: PendingMeshCoordinatorEvent): boolean {
|
|
1364
|
+
return persistPendingMeshCoordinatorEvent(event);
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
// ---------------------------------------------------------------------------
|
|
1368
|
+
// event_held → pending requeue (T6 recovery path)
|
|
1369
|
+
// ---------------------------------------------------------------------------
|
|
1370
|
+
// T6 quarantine (v2 enforce) and the pending-events trim both mirror a
|
|
1371
|
+
// destructively-drained-but-undelivered event into the ledger as a recoverable
|
|
1372
|
+
// `event_held` entry. Until now that recovery channel was audit-only: the comment
|
|
1373
|
+
// said "an operator can requeue it" but no code path did. This restores those held
|
|
1374
|
+
// events to the pending queue so a coordinator drains them on its next poll.
|
|
1375
|
+
//
|
|
1376
|
+
// No-loss + no-double-requeue invariants:
|
|
1377
|
+
// • The full original event rides on the held payload as `heldEvent`, so the
|
|
1378
|
+
// restore is byte-for-byte (metadataEvent, coordinatorMessage, v2 envelope).
|
|
1379
|
+
// • queuePendingMeshCoordinatorEvent runs the normal dedup (fingerprint / eventId),
|
|
1380
|
+
// so an event still live in the queue is not duplicated.
|
|
1381
|
+
// • Every held entry that has ALREADY been requeued is marked with an
|
|
1382
|
+
// `event_held_requeued` ledger entry keyed by the source held-entry id; a later
|
|
1383
|
+
// pass skips those ids, so calling the tool twice does not requeue the same held
|
|
1384
|
+
// event twice.
|
|
1385
|
+
|
|
1386
|
+
/** Narrowing filter for {@link requeueHeldMeshCoordinatorEvents}, scoped within one mesh. */
|
|
1387
|
+
export interface MeshHeldEventRequeueFilter {
|
|
1388
|
+
/** Restore only held events whose worker task id matches (from the held event's metadata/taskId). */
|
|
1389
|
+
taskId?: string;
|
|
1390
|
+
/** Restore only held events originating from this node. */
|
|
1391
|
+
nodeId?: string;
|
|
1392
|
+
/** Restore only held events of this event name (e.g. 'session:completed'). */
|
|
1393
|
+
event?: string;
|
|
1394
|
+
/** Restore only held entries recorded at/after this ISO timestamp. */
|
|
1395
|
+
since?: string;
|
|
1396
|
+
/** Restore only held entries with this hold reason (e.g. 'pending_trim_dropped'). */
|
|
1397
|
+
reason?: string;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
export interface MeshHeldEventRequeueResult {
|
|
1401
|
+
meshId: string;
|
|
1402
|
+
/** event_held entries considered after the filter. */
|
|
1403
|
+
matched: number;
|
|
1404
|
+
/** entries skipped because a prior requeue already recovered them. */
|
|
1405
|
+
alreadyRequeued: number;
|
|
1406
|
+
/** entries skipped because they carried no restorable original event / were not recoverable. */
|
|
1407
|
+
unrecoverable: number;
|
|
1408
|
+
/** entries handed to the pending queue (some may have been dedup-suppressed downstream). */
|
|
1409
|
+
requeued: number;
|
|
1410
|
+
/** of `requeued`, how many the pending-queue dedup collapsed onto a live event. */
|
|
1411
|
+
dedupSuppressed: number;
|
|
1412
|
+
entries: Array<{
|
|
1413
|
+
heldEntryId: string;
|
|
1414
|
+
event: string;
|
|
1415
|
+
nodeId?: string;
|
|
1416
|
+
taskId?: string;
|
|
1417
|
+
reason?: string;
|
|
1418
|
+
outcome: 'requeued' | 'already_requeued' | 'unrecoverable';
|
|
1419
|
+
}>;
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
/** Read the taskId a held event carried, checking the restored event then the audit payload. */
|
|
1423
|
+
function readHeldTaskId(restored: PendingMeshCoordinatorEvent | undefined, payload: Record<string, unknown>): string {
|
|
1424
|
+
const fromMeta = restored?.metadataEvent && typeof restored.metadataEvent === 'object'
|
|
1425
|
+
? readNonEmptyString((restored.metadataEvent as Record<string, unknown>).taskId)
|
|
1426
|
+
: '';
|
|
1427
|
+
return fromMeta || readNonEmptyString(payload.taskId) || '';
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
/**
|
|
1431
|
+
* Restore recoverable `event_held` ledger entries back to the pending coordinator
|
|
1432
|
+
* queue for `meshId`. See the block comment above for the no-loss / no-double-requeue
|
|
1433
|
+
* invariants. Returns per-entry outcomes for the caller to surface.
|
|
1434
|
+
*/
|
|
1435
|
+
export function requeueHeldMeshCoordinatorEvents(
|
|
1436
|
+
meshId: string,
|
|
1437
|
+
filter?: MeshHeldEventRequeueFilter,
|
|
1438
|
+
): MeshHeldEventRequeueResult {
|
|
1439
|
+
const result: MeshHeldEventRequeueResult = {
|
|
1440
|
+
meshId,
|
|
1441
|
+
matched: 0,
|
|
1442
|
+
alreadyRequeued: 0,
|
|
1443
|
+
unrecoverable: 0,
|
|
1444
|
+
requeued: 0,
|
|
1445
|
+
dedupSuppressed: 0,
|
|
1446
|
+
entries: [],
|
|
1447
|
+
};
|
|
1448
|
+
|
|
1449
|
+
const all = readLedgerEntries(meshId);
|
|
1450
|
+
// held-entry ids already recovered by a prior requeue pass (dedup key = source id).
|
|
1451
|
+
const requeuedIds = new Set<string>();
|
|
1452
|
+
for (const entry of all) {
|
|
1453
|
+
if (entry.kind !== 'event_held_requeued') continue;
|
|
1454
|
+
const id = readNonEmptyString(entry.payload?.heldEntryId);
|
|
1455
|
+
if (id) requeuedIds.add(id);
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
const sinceMs = filter?.since ? new Date(filter.since).getTime() : NaN;
|
|
1459
|
+
const wantEvent = readNonEmptyString(filter?.event);
|
|
1460
|
+
const wantNode = readNonEmptyString(filter?.nodeId);
|
|
1461
|
+
const wantTask = readNonEmptyString(filter?.taskId);
|
|
1462
|
+
const wantReason = readNonEmptyString(filter?.reason);
|
|
1463
|
+
|
|
1464
|
+
for (const entry of all) {
|
|
1465
|
+
if (entry.kind !== 'event_held') continue;
|
|
1466
|
+
const payload = (entry.payload && typeof entry.payload === 'object') ? entry.payload : {};
|
|
1467
|
+
if (payload.recoverable !== true) continue;
|
|
1468
|
+
|
|
1469
|
+
// Reconstruct the original event: prefer the full `heldEvent` copy; fall back to
|
|
1470
|
+
// the flat audit fields for entries written before the copy was embedded.
|
|
1471
|
+
const restored: PendingMeshCoordinatorEvent | undefined =
|
|
1472
|
+
(payload.heldEvent && typeof payload.heldEvent === 'object')
|
|
1473
|
+
? { ...(payload.heldEvent as PendingMeshCoordinatorEvent) }
|
|
1474
|
+
: undefined;
|
|
1475
|
+
|
|
1476
|
+
const eventName = restored?.event || readNonEmptyString(payload.event);
|
|
1477
|
+
const nodeId = restored?.nodeId || entry.nodeId || readNonEmptyString((payload as any).nodeId) || undefined;
|
|
1478
|
+
const taskId = readHeldTaskId(restored, payload as Record<string, unknown>);
|
|
1479
|
+
const reason = readNonEmptyString(payload.reason) || undefined;
|
|
1480
|
+
|
|
1481
|
+
// Apply the caller filter within the mesh scope.
|
|
1482
|
+
if (wantEvent && eventName !== wantEvent) continue;
|
|
1483
|
+
if (wantNode && nodeId !== wantNode) continue;
|
|
1484
|
+
if (wantTask && taskId !== wantTask) continue;
|
|
1485
|
+
if (wantReason && reason !== wantReason) continue;
|
|
1486
|
+
if (filter?.since && !Number.isNaN(sinceMs) && new Date(entry.timestamp).getTime() < sinceMs) continue;
|
|
1487
|
+
|
|
1488
|
+
result.matched++;
|
|
1489
|
+
|
|
1490
|
+
if (requeuedIds.has(entry.id)) {
|
|
1491
|
+
result.alreadyRequeued++;
|
|
1492
|
+
result.entries.push({ heldEntryId: entry.id, event: eventName, ...(nodeId ? { nodeId } : {}), ...(taskId ? { taskId } : {}), ...(reason ? { reason } : {}), outcome: 'already_requeued' });
|
|
1493
|
+
continue;
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
if (!restored || !readNonEmptyString(restored.event) || !readNonEmptyString(restored.meshId)) {
|
|
1497
|
+
result.unrecoverable++;
|
|
1498
|
+
result.entries.push({ heldEntryId: entry.id, event: eventName, ...(nodeId ? { nodeId } : {}), ...(taskId ? { taskId } : {}), ...(reason ? { reason } : {}), outcome: 'unrecoverable' });
|
|
1499
|
+
continue;
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
// Restore to pending. queuePendingMeshCoordinatorEvent re-stamps/dedups; a live
|
|
1503
|
+
// duplicate is suppressed there (returns true) so we never double-deliver.
|
|
1504
|
+
const beforeDup = hasPendingCoordinatorEventDuplicate(restored);
|
|
1505
|
+
let ok = false;
|
|
1506
|
+
try {
|
|
1507
|
+
ok = queuePendingMeshCoordinatorEvent(restored);
|
|
1508
|
+
} catch (e: any) {
|
|
1509
|
+
LOG.warn('MeshEvents', `Requeue of held ${eventName} for mesh ${meshId} failed: ${e?.message || e}`);
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
// Mark the source held entry so a second pass skips it, regardless of whether the
|
|
1513
|
+
// queue dedup collapsed it (the recovery attempt is what we dedup on, not delivery).
|
|
1514
|
+
appendLedgerEntry(meshId, {
|
|
1515
|
+
kind: 'event_held_requeued',
|
|
1516
|
+
...(nodeId ? { nodeId } : {}),
|
|
1517
|
+
payload: {
|
|
1518
|
+
heldEntryId: entry.id,
|
|
1519
|
+
event: eventName,
|
|
1520
|
+
requeued: ok,
|
|
1521
|
+
...(taskId ? { taskId } : {}),
|
|
1522
|
+
...(reason ? { reason } : {}),
|
|
1523
|
+
...(beforeDup ? { dedupSuppressed: true } : {}),
|
|
1524
|
+
},
|
|
1525
|
+
});
|
|
1526
|
+
requeuedIds.add(entry.id);
|
|
1527
|
+
|
|
1528
|
+
result.requeued++;
|
|
1529
|
+
if (beforeDup) result.dedupSuppressed++;
|
|
1530
|
+
result.entries.push({ heldEntryId: entry.id, event: eventName, ...(nodeId ? { nodeId } : {}), ...(taskId ? { taskId } : {}), ...(reason ? { reason } : {}), outcome: 'requeued' });
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
return result;
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
/** Explicitly clear all pending coordinator events for a mesh (and coordinator if scoped). */
|
|
1537
|
+
export function clearPendingMeshCoordinatorEvents(meshId?: string, coordinatorDaemonId?: string): void {
|
|
1538
|
+
if (!meshId) return;
|
|
1539
|
+
// Clear SQLite rows
|
|
1540
|
+
try { MeshRuntimeStore.getInstance().clearPendingEventsForMesh(meshId); } catch { /* store unavailable */ }
|
|
1541
|
+
// Clear JSONL files
|
|
1542
|
+
const paths = coordinatorDaemonId
|
|
1543
|
+
? [getPendingEventsPath(meshId, coordinatorDaemonId), getPendingEventsPath(meshId)]
|
|
1544
|
+
: [getPendingEventsPath(meshId)];
|
|
1545
|
+
for (const path of paths) {
|
|
1546
|
+
if (existsSync(path)) try { unlinkSync(path); } catch { /* already removed */ }
|
|
1547
|
+
}
|
|
1548
|
+
}
|