@adhdev/daemon-core 0.9.82-rc.49 → 0.9.82-rc.491
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 +66 -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 +53556 -19074
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +53380 -19063
- 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 +1309 -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
|
@@ -1,30 +1,621 @@
|
|
|
1
|
-
import { existsSync, writeFileSync, readFileSync, openSync, closeSync, unlinkSync } from 'fs';
|
|
2
|
-
import { join } from 'path';
|
|
3
1
|
import { randomUUID } from 'crypto';
|
|
4
|
-
import { getLedgerDir } from './mesh-ledger.js';
|
|
5
2
|
import { requireMeshHostQueueOwner } from './mesh-host-ownership.js';
|
|
6
3
|
import type { RepoMeshDaemonRole } from '../repo-mesh-types.js';
|
|
4
|
+
import { MESH_CONVERGE_REFINE_TAG, resolveAutoConvergeCodeChange } from '../repo-mesh-types.js';
|
|
5
|
+
import { MeshRuntimeStore } from './mesh-runtime-store.js';
|
|
6
|
+
import { getMesh, getDifficultyBrains } from '../config/mesh-config.js';
|
|
7
|
+
import { LOG } from '../logging/logger.js';
|
|
8
|
+
import { appendLedgerEntry } from './mesh-ledger.js';
|
|
9
|
+
import type { MeshLedgerKind } from './mesh-ledger.js';
|
|
10
|
+
import { createSessionDelivery } from './mesh-delivery-policy.js';
|
|
11
|
+
import { isTaskDispatchInFlight, endTaskDispatchInFlight } from './mesh-task-inflight.js';
|
|
12
|
+
import { sessionIdsEquivalent, isMeshTaskDifficulty, type MeshTaskDifficulty } from '@adhdev/mesh-shared';
|
|
7
13
|
|
|
8
14
|
export type MeshTaskStatus = 'pending' | 'assigned' | 'completed' | 'failed' | 'cancelled';
|
|
9
15
|
export type MeshActiveTaskStatus = Extract<MeshTaskStatus, 'pending' | 'assigned'>;
|
|
10
16
|
export type MeshHistoricalTaskStatus = Extract<MeshTaskStatus, 'completed' | 'failed' | 'cancelled'>;
|
|
17
|
+
export type MeshTaskMode = 'code_change' | 'validation' | 'live_debug_readonly' | 'launch_app' | 'convergence';
|
|
18
|
+
|
|
19
|
+
/** G6: task-level scheduling priority. Ranks which task a node pulls first (created_at tie-break). */
|
|
20
|
+
export type MeshTaskPriority = 'low' | 'normal' | 'high';
|
|
11
21
|
|
|
12
22
|
export const ACTIVE_MESH_QUEUE_STATUSES: MeshActiveTaskStatus[] = ['pending', 'assigned'];
|
|
13
23
|
export const HISTORICAL_MESH_QUEUE_STATUSES: MeshHistoricalTaskStatus[] = ['completed', 'failed', 'cancelled'];
|
|
24
|
+
export const MESH_TASK_MODES: MeshTaskMode[] = ['code_change', 'validation', 'live_debug_readonly', 'launch_app', 'convergence'];
|
|
25
|
+
export const MESH_TASK_PRIORITIES: MeshTaskPriority[] = ['low', 'normal', 'high'];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* G6: numeric rank of a task priority (higher = pulled first). Absent/unknown → 'normal' (1).
|
|
29
|
+
* Shared by the claim-candidate ordering and any surface that must sort by task priority.
|
|
30
|
+
*/
|
|
31
|
+
export function meshTaskPriorityRank(priority: unknown): number {
|
|
32
|
+
switch (priority) {
|
|
33
|
+
case 'high': return 2;
|
|
34
|
+
case 'low': return 0;
|
|
35
|
+
default: return 1; // 'normal' and any absent/unknown value
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** G6: coerce an arbitrary input to a valid MeshTaskPriority, or undefined when not one of the three. */
|
|
40
|
+
export function normalizeMeshTaskPriority(value: unknown): MeshTaskPriority | undefined {
|
|
41
|
+
return value === 'low' || value === 'normal' || value === 'high' ? value : undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* G7: resolve a not_before input to a stored ISO string (or undefined when absent/invalid).
|
|
46
|
+
* Accepts an ISO/date string, an absolute epoch-ms number, or a small relative-ms offset from
|
|
47
|
+
* `nowMs`. Disambiguation for numbers: a value below {@link NOT_BEFORE_RELATIVE_THRESHOLD_MS}
|
|
48
|
+
* (~1 year in ms) is treated as a relative offset added to now; a larger value is an absolute
|
|
49
|
+
* epoch-ms timestamp. A past/negative result is normalized to now (immediately claimable).
|
|
50
|
+
*/
|
|
51
|
+
export const NOT_BEFORE_RELATIVE_THRESHOLD_MS = 365 * 24 * 60 * 60 * 1000;
|
|
52
|
+
export function resolveNotBefore(value: unknown, nowMs: number = Date.now()): string | undefined {
|
|
53
|
+
if (value === undefined || value === null) return undefined;
|
|
54
|
+
let absMs: number;
|
|
55
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
56
|
+
absMs = value < NOT_BEFORE_RELATIVE_THRESHOLD_MS ? nowMs + value : value;
|
|
57
|
+
} else if (typeof value === 'string' && value.trim()) {
|
|
58
|
+
const parsed = Date.parse(value.trim());
|
|
59
|
+
if (Number.isNaN(parsed)) return undefined;
|
|
60
|
+
absMs = parsed;
|
|
61
|
+
} else {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
if (absMs <= nowMs) return new Date(nowMs).toISOString();
|
|
65
|
+
return new Date(absMs).toISOString();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** G7: is a task claimable now, or is it still held back by its notBefore gate? */
|
|
69
|
+
export function meshTaskNotBeforeReady(
|
|
70
|
+
task: { notBefore?: string } | null | undefined,
|
|
71
|
+
nowMs: number = Date.now(),
|
|
72
|
+
): boolean {
|
|
73
|
+
const nb = task?.notBefore;
|
|
74
|
+
if (!nb) return true;
|
|
75
|
+
const parsed = Date.parse(nb);
|
|
76
|
+
if (Number.isNaN(parsed)) return true; // unparseable → do not block (fail-open)
|
|
77
|
+
return parsed <= nowMs;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* QUEUE-NODE-SERIALIZATION: single source of truth for "is this task read-only?".
|
|
82
|
+
*
|
|
83
|
+
* Read-only classification used to be inlined as `task.taskMode === 'live_debug_readonly'`
|
|
84
|
+
* at every enforcement site (node-conflict claim gate, auto-launch isolation, the
|
|
85
|
+
* write/readonly cap counters, the write guardrail). That spread-out comparison is the
|
|
86
|
+
* exact recurring-defect class — one site drifting from the others silently makes the same
|
|
87
|
+
* task read-only at some gates and write at others, i.e. partial serialization. All sites
|
|
88
|
+
* MUST call this predicate so the classification is decided in exactly one place.
|
|
89
|
+
*
|
|
90
|
+
* Two orthogonal inputs feed the same boolean axis (kept backward-compatible):
|
|
91
|
+
* • `readonly === true` — the explicit boolean axis (new API surface).
|
|
92
|
+
* • `taskMode === 'live_debug_readonly'` — the original enum value, preserved as an
|
|
93
|
+
* OR-fallback so existing live_debug_readonly tasks keep behaving identically.
|
|
94
|
+
*
|
|
95
|
+
* Accepts any task-like shape (full {@link MeshWorkQueueEntry} or a bare
|
|
96
|
+
* `{ readonly?, taskMode? }`) so the daemon-core and mcp-server boundaries can share it.
|
|
97
|
+
*/
|
|
98
|
+
export function isTaskReadonly(task: { readonly?: boolean; taskMode?: MeshTaskMode | string } | null | undefined): boolean {
|
|
99
|
+
if (!task) return false;
|
|
100
|
+
return task.readonly === true || task.taskMode === 'live_debug_readonly';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface MeshTaskModeValidationResult {
|
|
104
|
+
valid: boolean;
|
|
105
|
+
taskMode?: MeshTaskMode;
|
|
106
|
+
violations: string[];
|
|
107
|
+
allowedOperations?: string[];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const LIVE_DEBUG_READONLY_FORBIDDEN: Array<{ label: string; pattern: RegExp }> = [
|
|
111
|
+
{ label: 'source_edit', pattern: /\b(edit|modify|patch|apply\s+patch|write\s+(?:to\s+)?(?:file|source)|overwrite|delete\s+file|remove\s+file|create\s+file|touch\s+file)\b/i },
|
|
112
|
+
{ label: 'checkpoint', pattern: /\b(checkpoint|mesh_checkpoint)\b/i },
|
|
113
|
+
{ label: 'deploy_or_version_bump', pattern: /\b(deploy|wrangler\s+deploy|version[-\s]?bump|npm\s+version|release|npm\s+publish|yarn\s+publish|pnpm\s+publish)\b/i },
|
|
114
|
+
{ label: 'destructive_shell', pattern: /\b(rm\s+-rf|mv\s+\S+\s+\S+|truncate\s|tee\s+\S+|sed\s+-i|shred\b)\b/i },
|
|
115
|
+
{ label: 'package_install', pattern: /\b(npm\s+(?:install|i|add|link|uninstall|remove)|yarn\s+(?:add|remove|link)|pnpm\s+(?:add|remove|link)|pip\s+install|brew\s+install|apt\s+install|cargo\s+install)\b/i },
|
|
116
|
+
{ label: 'container_mutation', pattern: /\b(docker\s+(?:build|run|exec|push|tag|rmi|rm|create|start|stop|kill)|kubectl\s+(?:apply|delete|patch|replace|create|scale))\b/i },
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Negation cues that, when they appear shortly before a mutation keyword inside
|
|
121
|
+
* the same clause, mean the keyword is being *forbidden* or described rather
|
|
122
|
+
* than invoked (e.g. "do not git reset", "절대 push 하지 마세요"). Matched
|
|
123
|
+
* case-insensitively. The Korean cues intentionally include sub-string forms
|
|
124
|
+
* ("않", "금지", "말 것") so conjugated variants are caught.
|
|
125
|
+
*/
|
|
126
|
+
const NEGATION_CUES: string[] = [
|
|
127
|
+
"don't", 'do not', 'never', 'avoid', 'without', 'no longer', 'not', 'forbidden',
|
|
128
|
+
'하지 마', '하지 마세요', '말 것', '금지', '없음', '않',
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
/** How many whitespace-delimited tokens before a keyword we scan for negation. */
|
|
132
|
+
const NEGATION_WINDOW_TOKENS = 6;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Returns true if a negation cue appears within {@link NEGATION_WINDOW_TOKENS}
|
|
136
|
+
* tokens before `matchIndex`, staying inside the same clause — we stop at
|
|
137
|
+
* sentence/line/clause boundaries (newline, `.`/`!`/`?`, `;`) so a negation in a
|
|
138
|
+
* previous sentence does not suppress a real command in the next one.
|
|
139
|
+
*/
|
|
140
|
+
function hasNegationBefore(text: string, matchIndex: number): boolean {
|
|
141
|
+
const before = text.slice(0, matchIndex);
|
|
142
|
+
// Restrict to the current clause: cut at the last clause/sentence/line break.
|
|
143
|
+
const clauseStart = Math.max(
|
|
144
|
+
before.lastIndexOf('\n'),
|
|
145
|
+
before.lastIndexOf('. '),
|
|
146
|
+
before.lastIndexOf('! '),
|
|
147
|
+
before.lastIndexOf('? '),
|
|
148
|
+
before.lastIndexOf(';'),
|
|
149
|
+
);
|
|
150
|
+
const clause = before.slice(clauseStart + 1);
|
|
151
|
+
const lower = clause.toLowerCase();
|
|
152
|
+
// Whitespace tokens immediately preceding the keyword, within the window.
|
|
153
|
+
const tokens = clause.split(/\s+/).filter(Boolean);
|
|
154
|
+
const windowTokens = tokens.slice(Math.max(0, tokens.length - NEGATION_WINDOW_TOKENS));
|
|
155
|
+
const windowText = windowTokens.join(' ').toLowerCase();
|
|
156
|
+
for (const cue of NEGATION_CUES) {
|
|
157
|
+
const c = cue.toLowerCase();
|
|
158
|
+
// ASCII cues are word-ish phrases — match in the bounded window only.
|
|
159
|
+
// CJK cues have no spaces, so the window-join can miss them; for those
|
|
160
|
+
// fall back to scanning the whole clause (still clause-bounded).
|
|
161
|
+
if (/[^\x00-\x7f]/.test(c)) {
|
|
162
|
+
if (lower.includes(c)) return true;
|
|
163
|
+
} else if (windowText.includes(c)) {
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Korean (and other) negation often trails the verb it negates ("git reset 하지
|
|
172
|
+
* 마세요" = "do not git reset"). Returns true if a CJK negation cue appears
|
|
173
|
+
* shortly after `matchEnd`, within the same clause. Scoped to CJK cues only —
|
|
174
|
+
* trailing ASCII words rarely negate a preceding command and would over-match.
|
|
175
|
+
*/
|
|
176
|
+
function hasTrailingNegation(text: string, matchEnd: number): boolean {
|
|
177
|
+
const after = text.slice(matchEnd);
|
|
178
|
+
// Clause-bound the lookahead: stop at the next clause/line break.
|
|
179
|
+
const clauseEnd = (() => {
|
|
180
|
+
const stops = [after.indexOf('\n'), after.indexOf('. '), after.indexOf('; ')]
|
|
181
|
+
.filter(i => i >= 0);
|
|
182
|
+
return stops.length ? Math.min(...stops) : after.length;
|
|
183
|
+
})();
|
|
184
|
+
const clause = after.slice(0, clauseEnd).toLowerCase();
|
|
185
|
+
for (const cue of NEGATION_CUES) {
|
|
186
|
+
const c = cue.toLowerCase();
|
|
187
|
+
if (/[^\x00-\x7f]/.test(c) && clause.includes(c)) return true;
|
|
188
|
+
}
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Returns true when the keyword at [matchStart, matchEnd) looks like an actual
|
|
194
|
+
* command invocation rather than a plain-prose mention. Command context is:
|
|
195
|
+
* - inside a fenced code block (``` ... ```) or inline backticks (`...`)
|
|
196
|
+
* - on a shell-prompt line (starts with `$ ` or `> `)
|
|
197
|
+
* - at a command-call position: line start (leading whitespace allowed) or
|
|
198
|
+
* right after a shell connective (`&&`, `||`, `|`, `;`) or an imperative
|
|
199
|
+
* connective ("then"/"run"/"," ) that introduces a command.
|
|
200
|
+
* Plain mid-sentence prose mentions (no backticks, no command position) are not
|
|
201
|
+
* command context and are excluded from violations.
|
|
202
|
+
*/
|
|
203
|
+
function isMutationKeywordInCommandContext(text: string, matchStart: number, matchEnd: number): boolean {
|
|
204
|
+
if (isInsideBackticksOrFence(text, matchStart, matchEnd)) return true;
|
|
205
|
+
|
|
206
|
+
// The line containing the match, and the portion of it before the keyword.
|
|
207
|
+
const lineStart = text.lastIndexOf('\n', matchStart - 1) + 1;
|
|
208
|
+
const linePrefix = text.slice(lineStart, matchStart);
|
|
209
|
+
|
|
210
|
+
// Shell-prompt line: "$ ..." or "> ..." (leading whitespace allowed).
|
|
211
|
+
if (/^\s*[$>]\s/.test(text.slice(lineStart))) return true;
|
|
212
|
+
|
|
213
|
+
// Line start (only whitespace before the keyword on this line).
|
|
214
|
+
if (/^\s*$/.test(linePrefix)) return true;
|
|
215
|
+
|
|
216
|
+
// Executed script at command position: the keyword sits inside a run-prefix
|
|
217
|
+
// path token (`./scripts/version-bump.sh`, `~/bin/deploy.sh`) that is the
|
|
218
|
+
// leading token of the line or follows a shell connective — the script is
|
|
219
|
+
// being invoked, so the keyword is a real command. (Path *arguments* like
|
|
220
|
+
// `list build/Release` are excluded earlier by isInsidePathSegment.)
|
|
221
|
+
let tokStart = matchStart;
|
|
222
|
+
while (tokStart > lineStart && !/\s/.test(text[tokStart - 1])) tokStart--;
|
|
223
|
+
const beforeToken = text.slice(lineStart, tokStart);
|
|
224
|
+
const tokenLead = text.slice(tokStart, matchStart);
|
|
225
|
+
const atCmdPos = /^\s*$/.test(beforeToken) || /(?:&&|\|\||\||;)\s*$/.test(beforeToken);
|
|
226
|
+
if (atCmdPos && /^(?:\.\/|\.\.\/|~\/|\/)/.test(tokenLead)) return true;
|
|
227
|
+
|
|
228
|
+
// After a shell connective or imperative connective introducing a command.
|
|
229
|
+
// We look at what immediately precedes the keyword on the same line.
|
|
230
|
+
if (/(?:&&|\|\||\||;)\s*$/.test(linePrefix)) return true;
|
|
231
|
+
if (/(?:^|[\s,])(?:then|run|first)\s+$/i.test(linePrefix)) return true;
|
|
232
|
+
if (/,\s*$/.test(linePrefix)) return true;
|
|
233
|
+
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* True if [matchStart, matchEnd) lies inside an inline-backtick span or a fenced
|
|
239
|
+
* code block. Fenced blocks (```...```) take precedence; otherwise we count
|
|
240
|
+
* inline backticks before the match — an odd count means we are inside a span.
|
|
241
|
+
*/
|
|
242
|
+
function isInsideBackticksOrFence(text: string, matchStart: number, matchEnd: number): boolean {
|
|
243
|
+
// Fenced code blocks: count ``` fences before the match.
|
|
244
|
+
const fenceRe = /```/g;
|
|
245
|
+
let fenceCount = 0;
|
|
246
|
+
let m: RegExpExecArray | null;
|
|
247
|
+
while ((m = fenceRe.exec(text)) !== null) {
|
|
248
|
+
if (m.index >= matchStart) break;
|
|
249
|
+
fenceCount++;
|
|
250
|
+
}
|
|
251
|
+
if (fenceCount % 2 === 1) return true;
|
|
252
|
+
|
|
253
|
+
// Inline backticks: count single backticks before the match start, ignoring
|
|
254
|
+
// those that are part of a ``` fence (handled above). An odd count → inside.
|
|
255
|
+
let inlineCount = 0;
|
|
256
|
+
for (let i = 0; i < matchStart; i++) {
|
|
257
|
+
if (text[i] === '`') {
|
|
258
|
+
// Skip triple-fence backticks.
|
|
259
|
+
if (text[i + 1] === '`' && text[i + 2] === '`') {
|
|
260
|
+
i += 2;
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
inlineCount++;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return inlineCount % 2 === 1;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* True if the matched keyword is part of a filesystem-path-like token rather than
|
|
271
|
+
* a standalone word/command. A "release" inside `build/Release`, `dist/release/`,
|
|
272
|
+
* or `packages\release` is a directory/file name, not a deploy instruction. We
|
|
273
|
+
* look at the characters immediately adjacent to the match: if either side is a
|
|
274
|
+
* path separator (`/` or `\`) joining it to another path segment, the keyword is
|
|
275
|
+
* a path component.
|
|
276
|
+
*
|
|
277
|
+
* IMPORTANT: a path token that is itself being *executed* — i.e. the leading
|
|
278
|
+
* token of a command line such as `./scripts/version-bump.sh patch` — is NOT a
|
|
279
|
+
* suppressible path; that is a real command. We therefore exclude the case where
|
|
280
|
+
* the path token sits at command-invocation position (line start, optionally with
|
|
281
|
+
* a leading `./` / `/` / `~/`, or right after a shell connective), which means it
|
|
282
|
+
* is the program being run rather than an argument being inspected.
|
|
283
|
+
*/
|
|
284
|
+
function isInsidePathSegment(text: string, matchStart: number, matchEnd: number): boolean {
|
|
285
|
+
const prev = matchStart > 0 ? text[matchStart - 1] : '';
|
|
286
|
+
const next = matchEnd < text.length ? text[matchEnd] : '';
|
|
287
|
+
const isSep = (c: string) => c === '/' || c === '\\';
|
|
288
|
+
const segChar = (c: string) => /[A-Za-z0-9._~-]/.test(c);
|
|
289
|
+
const inPath =
|
|
290
|
+
(isSep(prev) && (next === '' || isSep(next) || segChar(next) || /\s/.test(next))) ||
|
|
291
|
+
(isSep(next) && (prev === '' || isSep(prev) || segChar(prev) || /\s/.test(prev)));
|
|
292
|
+
if (!inPath) return false;
|
|
293
|
+
|
|
294
|
+
// Find the whole whitespace-delimited path token containing the match and the
|
|
295
|
+
// text on its line before it. If the token is the first thing on the line
|
|
296
|
+
// (after an optional `./`, `/`, `~/`, or `../` prefix) or directly follows a
|
|
297
|
+
// shell connective, it is being executed → not a suppressible path.
|
|
298
|
+
const lineStart = text.lastIndexOf('\n', matchStart - 1) + 1;
|
|
299
|
+
let tokStart = matchStart;
|
|
300
|
+
while (tokStart > lineStart && !/\s/.test(text[tokStart - 1])) tokStart--;
|
|
301
|
+
const linePrefixBeforeToken = text.slice(lineStart, tokStart);
|
|
302
|
+
const tokenPrefix = text.slice(tokStart, matchStart);
|
|
303
|
+
// The path token is in command position when nothing but whitespace (or a
|
|
304
|
+
// shell connective) precedes it on the line, AND the token itself is a
|
|
305
|
+
// run-prefix path (`./`, `/`, `~/`, `../`). A bare `build/Release` argument
|
|
306
|
+
// after a verb ("list build/Release") is NOT command position.
|
|
307
|
+
const atLineStart = /^\s*$/.test(linePrefixBeforeToken);
|
|
308
|
+
const afterConnective = /(?:&&|\|\||\||;)\s*$/.test(linePrefixBeforeToken);
|
|
309
|
+
const isRunPrefixPath = /^(?:\.\/|\.\.\/|~\/|\/)/.test(tokenPrefix);
|
|
310
|
+
if ((atLineStart || afterConnective) && isRunPrefixPath) return false;
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* True if [matchStart, matchEnd) lies inside a quoted span — straight quotes
|
|
316
|
+
* (`"..."`, `'...'`), typographic quotes (`“...”`, `‘...’`), or CJK brackets
|
|
317
|
+
* (`「...」`, `『...』`, `《...》`). Commit-message citations and other quoted prose
|
|
318
|
+
* (e.g. quoting a `chore: ... version-bump ...` log line, or the Korean
|
|
319
|
+
* "포인터 bump" phrase) are descriptive, not command invocations, so a forbidden
|
|
320
|
+
* keyword inside such a quote must not trip the guardrail. Backticks are excluded
|
|
321
|
+
* here on purpose — they denote shell/code snippets and are handled as command
|
|
322
|
+
* context in {@link isInsideBackticksOrFence}.
|
|
323
|
+
*/
|
|
324
|
+
function isInsideQuotedSpan(text: string, matchStart: number, matchEnd: number): boolean {
|
|
325
|
+
// Paired quotes: an opening char before the match without its closer in
|
|
326
|
+
// between, and the matching closer after the match.
|
|
327
|
+
const pairs: Array<[string, string]> = [
|
|
328
|
+
['“', '”'], ['‘', '’'], ['「', '」'], ['『', '』'], ['《', '》'],
|
|
329
|
+
];
|
|
330
|
+
for (const [open, close] of pairs) {
|
|
331
|
+
const openIdx = text.lastIndexOf(open, matchStart - 1);
|
|
332
|
+
if (openIdx < 0) continue;
|
|
333
|
+
// No closer between the opener and the match → still open at the match.
|
|
334
|
+
if (text.indexOf(close, openIdx + open.length) >= matchEnd) return true;
|
|
335
|
+
}
|
|
336
|
+
// Symmetric quotes (" and '): odd count of the quote char before the match,
|
|
337
|
+
// within the same line (a quote does not span newlines), and a closing quote
|
|
338
|
+
// later on the line.
|
|
339
|
+
const lineStart = text.lastIndexOf('\n', matchStart - 1) + 1;
|
|
340
|
+
const lineEnd = (() => { const i = text.indexOf('\n', matchEnd); return i < 0 ? text.length : i; })();
|
|
341
|
+
for (const q of ['"', "'"]) {
|
|
342
|
+
let count = 0;
|
|
343
|
+
for (let i = lineStart; i < matchStart; i++) if (text[i] === q) count++;
|
|
344
|
+
if (count % 2 === 1 && text.indexOf(q, matchEnd) >= 0 && text.indexOf(q, matchEnd) < lineEnd) {
|
|
345
|
+
// For "'" guard against the apostrophe-in-prose case (e.g. "don't"):
|
|
346
|
+
// require the opening quote to be preceded by a boundary (start/space/
|
|
347
|
+
// open-paren) so a contraction apostrophe is not read as a quote opener.
|
|
348
|
+
if (q === '"') return true;
|
|
349
|
+
// find the opening quote (the one making the count odd)
|
|
350
|
+
let openPos = -1, c = 0;
|
|
351
|
+
for (let i = lineStart; i < matchStart; i++) { if (text[i] === q) { c++; if (c % 2 === 1) openPos = i; } }
|
|
352
|
+
const beforeOpen = openPos > lineStart ? text[openPos - 1] : ' ';
|
|
353
|
+
if (/[\s(\[{>]/.test(beforeOpen) || openPos === lineStart) return true;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* True if the keyword at [matchStart, matchEnd) is glued directly to a Unicode
|
|
361
|
+
* *letter* on either side with no separator — i.e. it is word-internal, part of a
|
|
362
|
+
* larger word rather than a standalone command token.
|
|
363
|
+
*
|
|
364
|
+
* The keyword regexes use `\b` boundaries, which already exclude an *ASCII-letter*
|
|
365
|
+
* suffix/prefix: `\bdeploy\b` does NOT match the "deploy" inside "deployed"
|
|
366
|
+
* (`y`→`e` is not a boundary). But `\b` fires a boundary between a Latin letter
|
|
367
|
+
* and a non-Latin letter, so `deploy된` ("deployed", Korean passive), `release한`,
|
|
368
|
+
* or `version-bump됨` DO match even though they are single conjugated/compound
|
|
369
|
+
* words in prose, not shell commands. That is the exact i18n asymmetry behind
|
|
370
|
+
* GUARDRAIL-I18N-FP: the English "deployed worker" passes (mid-prose, no command
|
|
371
|
+
* context) while the Korean "deploy된 워커" was flagged because the glued CJK
|
|
372
|
+
* suffix left the bare keyword sitting at line-start, which the command-context
|
|
373
|
+
* heuristic reads as a command invocation.
|
|
374
|
+
*
|
|
375
|
+
* We restore the symmetry `\b` provides for ASCII: a forbidden keyword fused to
|
|
376
|
+
* ANY Unicode letter (Hangul, Han, Kana, accented Latin, …) is a word-internal
|
|
377
|
+
* occurrence → descriptive prose, never a command. A genuine command keyword is
|
|
378
|
+
* always followed by a non-letter — whitespace, end-of-input, a path separator,
|
|
379
|
+
* a flag/redirect, or a shell metachar (`npm run deploy`, `wrangler deploy\n`,
|
|
380
|
+
* `version-bump.sh`, `npm publish && ...`).
|
|
381
|
+
*/
|
|
382
|
+
function isGluedToLetterSuffix(text: string, matchStart: number, matchEnd: number): boolean {
|
|
383
|
+
// \p{L} = any Unicode letter; \p{M} = combining mark (e.g. Jamo/diacritics
|
|
384
|
+
// that compose with the adjacent letter). Either side counts as "glued".
|
|
385
|
+
const letterOrMark = /[\p{L}\p{M}]/u;
|
|
386
|
+
const next = matchEnd < text.length ? text[matchEnd] : '';
|
|
387
|
+
const prev = matchStart > 0 ? text[matchStart - 1] : '';
|
|
388
|
+
return letterOrMark.test(next) || letterOrMark.test(prev);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Decides whether a forbidden keyword match at [matchStart, matchEnd) should
|
|
393
|
+
* count as a real violation. A match counts only when it is in command context
|
|
394
|
+
* and not negated, and is NOT merely a path segment or quoted (descriptive)
|
|
395
|
+
* citation. Negation always wins (even inside a code block), per the conservative
|
|
396
|
+
* rule: code-block + negation → exclude; other code-block → keep.
|
|
397
|
+
*/
|
|
398
|
+
function isRealMutationMatch(text: string, matchStart: number, matchEnd: number): boolean {
|
|
399
|
+
if (hasNegationBefore(text, matchStart)) return false;
|
|
400
|
+
if (hasTrailingNegation(text, matchEnd)) return false;
|
|
401
|
+
// A keyword fused to a non-ASCII letter ("deploy된", "release한") is a single
|
|
402
|
+
// conjugated/compound word in prose, not a command — `\b` only guards the
|
|
403
|
+
// ASCII-letter case, so restore the same symmetry for all Unicode letters.
|
|
404
|
+
if (isGluedToLetterSuffix(text, matchStart, matchEnd)) return false;
|
|
405
|
+
// A keyword that is part of a file path (build/Release, dist/release/) or
|
|
406
|
+
// sits inside quoted prose (a commit-message citation, "포인터 bump") is a
|
|
407
|
+
// description, not a command — suppress it even if it lands at line-start.
|
|
408
|
+
if (isInsidePathSegment(text, matchStart, matchEnd)) return false;
|
|
409
|
+
if (isInsideQuotedSpan(text, matchStart, matchEnd)) return false;
|
|
410
|
+
return isMutationKeywordInCommandContext(text, matchStart, matchEnd);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Runs a global regex over `text` and returns true if any match is a real
|
|
415
|
+
* mutation (command context, not negated).
|
|
416
|
+
*/
|
|
417
|
+
function patternHasRealMutation(pattern: RegExp, text: string): boolean {
|
|
418
|
+
const re = new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : pattern.flags + 'g');
|
|
419
|
+
let match: RegExpExecArray | null;
|
|
420
|
+
while ((match = re.exec(text)) !== null) {
|
|
421
|
+
if (isRealMutationMatch(text, match.index, match.index + match[0].length)) return true;
|
|
422
|
+
if (match.index === re.lastIndex) re.lastIndex++; // avoid zero-width loop
|
|
423
|
+
}
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Git subcommands that mutate the working tree, index, refs, or remote.
|
|
429
|
+
* `stash` and `checkout` are intentionally absent here: they have read-only
|
|
430
|
+
* variants (`git stash list`/`show`, `git checkout-index`) and are classified
|
|
431
|
+
* token-by-token in {@link detectGitMutation} rather than by bare keyword.
|
|
432
|
+
*/
|
|
433
|
+
const GIT_MUTATION_SUBCOMMANDS = new Set([
|
|
434
|
+
'add', 'commit', 'push', 'reset', 'rebase', 'clean', 'switch', 'merge',
|
|
435
|
+
'tag', 'restore', 'rm', 'mv', 'cherry-pick', 'revert', 'pull', 'fetch',
|
|
436
|
+
'am', 'apply', 'gc', 'prune',
|
|
437
|
+
]);
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Read-only `git stash` variants. Any other `git stash <x>` (pop/apply/drop/
|
|
441
|
+
* push/save/clear, or bare `git stash` which defaults to push) is a mutation.
|
|
442
|
+
*/
|
|
443
|
+
const GIT_STASH_READONLY_SUBCOMMANDS = new Set(['list', 'show']);
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Detects a true git mutation in free-text task message, token-aware so that
|
|
447
|
+
* read-only diagnostics (`git stash list`, `git stash show --stat`,
|
|
448
|
+
* `git checkout-index`, `git status`, `git diff`, `git log`, ...) are allowed.
|
|
449
|
+
* Returns true only when a genuine mutating git invocation is present.
|
|
450
|
+
*/
|
|
451
|
+
function detectGitMutation(message: string): boolean {
|
|
452
|
+
const re = /\bgit\s+([a-z][a-z0-9-]*)/gi;
|
|
453
|
+
let match: RegExpExecArray | null;
|
|
454
|
+
while ((match = re.exec(message)) !== null) {
|
|
455
|
+
const sub = match[1].toLowerCase();
|
|
456
|
+
// Only treat a mutating `git <sub>` as a real violation when it appears
|
|
457
|
+
// as an actual command (code/command context) and is not negated. Plain
|
|
458
|
+
// prose mentions ("don't git reset", "we won't push") are ignored.
|
|
459
|
+
const isReal = () => isRealMutationMatch(message, match!.index, match!.index + match![0].length);
|
|
460
|
+
if (GIT_MUTATION_SUBCOMMANDS.has(sub)) {
|
|
461
|
+
if (isReal()) return true;
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
if (sub === 'stash') {
|
|
465
|
+
// Token following `git stash`; read-only only for list/show.
|
|
466
|
+
const after = message.slice(re.lastIndex).match(/^\s+([a-z][a-z0-9-]*)/i);
|
|
467
|
+
const next = after ? after[1].toLowerCase() : '';
|
|
468
|
+
if (!GIT_STASH_READONLY_SUBCOMMANDS.has(next) && isReal()) return true; // bare stash = push, or pop/apply/drop/...
|
|
469
|
+
} else if (sub === 'checkout') {
|
|
470
|
+
// `git checkout <ref/path>` mutates; `git checkout-index` is matched
|
|
471
|
+
// as its own token by the regex (sub === 'checkout-index') and is read-only.
|
|
472
|
+
if (isReal()) return true;
|
|
473
|
+
} else if (sub === 'submodule') {
|
|
474
|
+
// `git submodule update` mutates; `git submodule status` is read-only.
|
|
475
|
+
const after = message.slice(re.lastIndex).match(/^\s+([a-z][a-z0-9-]*)/i);
|
|
476
|
+
const next = after ? after[1].toLowerCase() : '';
|
|
477
|
+
if ((next === 'update' || next === 'add' || next === 'sync' || next === 'deinit') && isReal()) return true;
|
|
478
|
+
} else if (sub === 'worktree') {
|
|
479
|
+
const after = message.slice(re.lastIndex).match(/^\s+([a-z][a-z0-9-]*)/i);
|
|
480
|
+
const next = after ? after[1].toLowerCase() : '';
|
|
481
|
+
if ((next === 'add' || next === 'remove' || next === 'move' || next === 'prune') && isReal()) return true;
|
|
482
|
+
}
|
|
483
|
+
// checkout-index, stash-with-no-next-already-handled, status/diff/log/show/
|
|
484
|
+
// rev-parse/branch/submodule status fall through as read-only.
|
|
485
|
+
}
|
|
486
|
+
return false;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
export function normalizeMeshTaskMode(value: unknown): MeshTaskMode | undefined {
|
|
490
|
+
if (typeof value !== 'string') return undefined;
|
|
491
|
+
const normalized = value.trim() as MeshTaskMode;
|
|
492
|
+
return (MESH_TASK_MODES as string[]).includes(normalized) ? normalized : undefined;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
export function validateMeshTaskModeRequest(mode: unknown, message: string, readonly?: boolean): MeshTaskModeValidationResult {
|
|
496
|
+
const taskMode = normalizeMeshTaskMode(mode);
|
|
497
|
+
// QUEUE-NODE-SERIALIZATION: the write guardrail (reject deploy/push/edit commands on a
|
|
498
|
+
// read-only task) is driven by the unified read-only axis, not by the enum alone — so a
|
|
499
|
+
// task flagged read-only via the explicit `readonly:true` boolean is guarded identically
|
|
500
|
+
// to a legacy live_debug_readonly task. isTaskReadonly is the single classifier.
|
|
501
|
+
const isReadonly = isTaskReadonly({ readonly, taskMode });
|
|
502
|
+
if (!isReadonly) {
|
|
503
|
+
return taskMode ? { valid: true, taskMode, violations: [] } : { valid: true, violations: [] };
|
|
504
|
+
}
|
|
505
|
+
const text = message || '';
|
|
506
|
+
// Only flag keywords that look like real commands (code/command context) and
|
|
507
|
+
// are not negated — descriptive/prohibitive prose ("don't run `npm publish`",
|
|
508
|
+
// "read-only, no deploy") must not trip the guardrail.
|
|
509
|
+
const violations = LIVE_DEBUG_READONLY_FORBIDDEN
|
|
510
|
+
.filter(rule => patternHasRealMutation(rule.pattern, text))
|
|
511
|
+
.map(rule => rule.label);
|
|
512
|
+
if (detectGitMutation(text)) {
|
|
513
|
+
violations.push('git_mutation');
|
|
514
|
+
}
|
|
515
|
+
return {
|
|
516
|
+
valid: violations.length === 0,
|
|
517
|
+
taskMode,
|
|
518
|
+
violations,
|
|
519
|
+
allowedOperations: [
|
|
520
|
+
'process/log/window/port/session inspection',
|
|
521
|
+
'read-only filesystem listing/reading',
|
|
522
|
+
'status probes and keep-running handle reporting',
|
|
523
|
+
'diagnostic summaries without source edits, commits, checkpoints, pushes, deploys, resets, rebases, or destructive cleanups',
|
|
524
|
+
],
|
|
525
|
+
};
|
|
526
|
+
}
|
|
14
527
|
|
|
15
528
|
export interface MeshWorkQueueEntry {
|
|
16
529
|
id: string;
|
|
17
530
|
meshId: string;
|
|
18
531
|
message: string;
|
|
19
532
|
status: MeshTaskStatus;
|
|
533
|
+
taskMode?: MeshTaskMode;
|
|
534
|
+
/**
|
|
535
|
+
* QUEUE-NODE-SERIALIZATION: explicit read-only axis, orthogonal to taskMode. When
|
|
536
|
+
* true the task is treated as read-only by every scheduling gate (no node-busy
|
|
537
|
+
* isolation, counted under the read-only cap, write commands rejected) regardless of
|
|
538
|
+
* its taskMode. Decided exclusively through {@link isTaskReadonly}; `taskMode ===
|
|
539
|
+
* 'live_debug_readonly'` remains an OR-fallback so legacy rows behave unchanged.
|
|
540
|
+
*/
|
|
541
|
+
readonly?: boolean;
|
|
20
542
|
/** If specified, only this node can claim the task (used by legacy mesh_send_task) */
|
|
21
543
|
targetNodeId?: string;
|
|
22
544
|
/** If specified, only this runtime session can claim the task */
|
|
23
545
|
targetSessionId?: string;
|
|
546
|
+
/** If specified, a node must expose all tags before it can claim the task. */
|
|
547
|
+
requiredTags?: string[];
|
|
548
|
+
/**
|
|
549
|
+
* G6 (task-level scheduling priority): 'low' | 'normal' | 'high'. Orders the
|
|
550
|
+
* claim candidate list so a high-priority task is pulled ahead of an older
|
|
551
|
+
* normal/low task within the same claim tier (created_at is the tie-break).
|
|
552
|
+
* Absent → treated as 'normal'. This is the TASK-level priority, distinct from
|
|
553
|
+
* the NODE-level schedulingPriority (resolveNodeSchedulingPriority), which ranks
|
|
554
|
+
* which node a task goes to, not which task a node pulls first.
|
|
555
|
+
*/
|
|
556
|
+
priority?: MeshTaskPriority;
|
|
557
|
+
/**
|
|
558
|
+
* G7 (delayed execution): ISO timestamp before which the task is NOT claimable.
|
|
559
|
+
* The claim gate holds the task pending while now < notBefore; once the wall
|
|
560
|
+
* clock passes it the task becomes a normal claim candidate. A pure time gate —
|
|
561
|
+
* cron/webhook triggers are out of scope. Absent → immediately claimable.
|
|
562
|
+
*/
|
|
563
|
+
notBefore?: string;
|
|
564
|
+
/**
|
|
565
|
+
* M1: ids of tasks that must reach 'completed' before this task is claimable.
|
|
566
|
+
* Forward references (ids not yet enqueued) are allowed for batch flows and
|
|
567
|
+
* simply keep the task waiting until the referenced task exists and completes.
|
|
568
|
+
*/
|
|
569
|
+
dependsOn?: string[];
|
|
570
|
+
/** M1/M3: mission this task belongs to (joins mesh_missions). */
|
|
571
|
+
missionId?: string;
|
|
572
|
+
/**
|
|
573
|
+
* MAGI: consensus group id shared by every replica of one mesh_magi_review
|
|
574
|
+
* fan-out. Marks the task as part of an INTENTIONAL same-prompt quorum so the
|
|
575
|
+
* completion-event dedup (mesh-events-pending) never collapses grouped
|
|
576
|
+
* replicas. Absent on ordinary tasks. Rides in the payload JSON (no column).
|
|
577
|
+
*/
|
|
578
|
+
consensusGroupId?: string;
|
|
579
|
+
/**
|
|
580
|
+
* MAGI-KIND-PANEL (model axis): model override for the session that executes this
|
|
581
|
+
* task. When the task auto-launches a session, this is passed to launch_cli as
|
|
582
|
+
* `initialModel` (ACP → setConfigOption; CLI → modelLaunchArgs template). Absent on
|
|
583
|
+
* ordinary tasks. Rides in the payload JSON (no column). Best-effort — a provider
|
|
584
|
+
* that cannot honor the model still runs the task (never a fatal launch error).
|
|
585
|
+
*/
|
|
586
|
+
model?: string;
|
|
587
|
+
/**
|
|
588
|
+
* BRAIN-ROUTING (thinking axis): standard reasoning level ('low'|'medium'|'high')
|
|
589
|
+
* for the session that executes this task. When the task auto-launches, this is
|
|
590
|
+
* passed to launch_cli as `initialThinkingLevel` (CLI → thinkingLaunchArgs; ACP →
|
|
591
|
+
* setConfigOption('thought_level')). Rides in payload JSON. Best-effort like model.
|
|
592
|
+
*/
|
|
593
|
+
thinkingLevel?: string;
|
|
594
|
+
/**
|
|
595
|
+
* SLOT-ROUTING (ORCHESTRATION_NODE_SLOTS.md): the coordinator's difficulty
|
|
596
|
+
* classification for this task ('easy'|'medium'|'difficult'|'freeform'),
|
|
597
|
+
* PERSISTED on the entry so the scheduler can match it against node capability
|
|
598
|
+
* slots at assignment time. Previously an enqueue-only option consumed to
|
|
599
|
+
* resolve model/thinkingLevel and then discarded; keeping it lets task→node
|
|
600
|
+
* fitness matching run. Absent on tasks enqueued without a difficulty.
|
|
601
|
+
*/
|
|
602
|
+
difficulty?: string;
|
|
603
|
+
/**
|
|
604
|
+
* M1: why this task is held back (e.g. "dependency_failed:<taskId>").
|
|
605
|
+
* Only set by the system on dependency failure under the 'block' policy;
|
|
606
|
+
* waiting-on-dependency state is computed at view time, not stored.
|
|
607
|
+
*/
|
|
608
|
+
blockedReason?: string;
|
|
24
609
|
/** The node that actually claimed and is executing the task */
|
|
25
610
|
assignedNodeId?: string;
|
|
26
611
|
/** The session currently executing the task */
|
|
27
612
|
assignedSessionId?: string;
|
|
613
|
+
/**
|
|
614
|
+
* Provider type of the session that claimed the task. Recorded so the queue
|
|
615
|
+
* can enforce per-(node, provider) maxParallel caps (RepoMeshNodePolicy
|
|
616
|
+
* providerRoles) by counting active assignments grouped by node + provider.
|
|
617
|
+
*/
|
|
618
|
+
assignedProviderType?: string;
|
|
28
619
|
/** Human/operator reason for terminal cancellation. */
|
|
29
620
|
cancelReason?: string;
|
|
30
621
|
cancelledAt?: string;
|
|
@@ -32,6 +623,16 @@ export interface MeshWorkQueueEntry {
|
|
|
32
623
|
requeueReason?: string;
|
|
33
624
|
requeuedAt?: string;
|
|
34
625
|
requeueCount?: number;
|
|
626
|
+
/** Max automatic requeue attempts. When requeueCount reaches this, task is auto-failed. */
|
|
627
|
+
maxRetries?: number;
|
|
628
|
+
/**
|
|
629
|
+
* Bug B: number of times the reconcile assigned-stranded watchdog has reclaimed this
|
|
630
|
+
* row from 'assigned' back to 'pending' because its dispatch was never confirmed
|
|
631
|
+
* delivered. Separate from requeueCount (operator/execution retries) and bounded by
|
|
632
|
+
* MAX_STRANDED_RECLAIMS so a permanently-undeliverable target auto-fails rather than
|
|
633
|
+
* cycling reclaim→re-dispatch→strand forever.
|
|
634
|
+
*/
|
|
635
|
+
strandedReclaimCount?: number;
|
|
35
636
|
/** Last automatic queue session spin-up attempt, for mesh_view_queue/debug visibility. */
|
|
36
637
|
autoLaunch?: {
|
|
37
638
|
status: 'skipped' | 'started' | 'failed' | 'completed';
|
|
@@ -43,6 +644,25 @@ export interface MeshWorkQueueEntry {
|
|
|
43
644
|
};
|
|
44
645
|
/** ISO timestamp when the task was dispatched (assigned) to a node/session. Used for precise matching on completion. */
|
|
45
646
|
dispatchTimestamp?: string;
|
|
647
|
+
/**
|
|
648
|
+
* REDRIVE-DUP: monotonic per-task dispatch nonce. Bumped on every (re)dispatch of
|
|
649
|
+
* this task (assignQueueTask) AND on every reclaim (reclaimStrandedAssignedTask), and
|
|
650
|
+
* carried to the worker in meshContext.dispatchNonce. The worker echoes it back on
|
|
651
|
+
* agent:generating_started (metadataEvent.dispatchNonce). When a delivered-not-consumed
|
|
652
|
+
* task is reclaimed and re-dispatched to a different node, the ORIGINAL inject to the
|
|
653
|
+
* first node still carries the now-stale nonce; the coordinator rejects that node's
|
|
654
|
+
* generating_started ack (and stops it) so the SAME taskId is never executed twice.
|
|
655
|
+
* Absent on legacy rows → the coordinator skips the stale-nonce guard (backward safe).
|
|
656
|
+
*/
|
|
657
|
+
dispatchNonce?: number;
|
|
658
|
+
/**
|
|
659
|
+
* (3) The ORIGINATING coordinator session that enqueued this task. Stamped onto the
|
|
660
|
+
* worker at dispatch (meshCoordinatorSessionId) so the task's completion routes back to
|
|
661
|
+
* the exact coordinator session — even when several coordinator sessions share one
|
|
662
|
+
* daemon. Rides in the queue payload JSON (no column migration); absent on legacy rows
|
|
663
|
+
* → daemon-level routing fallback (backward + version-skew safe).
|
|
664
|
+
*/
|
|
665
|
+
sourceCoordinatorSessionId?: string;
|
|
46
666
|
createdAt: string;
|
|
47
667
|
updatedAt: string;
|
|
48
668
|
}
|
|
@@ -51,50 +671,195 @@ export interface MeshQueueMutationOptions {
|
|
|
51
671
|
ownerRole?: RepoMeshDaemonRole;
|
|
52
672
|
}
|
|
53
673
|
|
|
54
|
-
function
|
|
55
|
-
|
|
56
|
-
|
|
674
|
+
export function normalizeMeshCapabilityTags(value: unknown): string[] {
|
|
675
|
+
if (!Array.isArray(value)) return [];
|
|
676
|
+
const seen = new Set<string>();
|
|
677
|
+
return value
|
|
678
|
+
.map(tag => typeof tag === 'string' ? tag.trim() : '')
|
|
679
|
+
.filter(Boolean)
|
|
680
|
+
.filter(tag => {
|
|
681
|
+
if (seen.has(tag)) return false;
|
|
682
|
+
seen.add(tag);
|
|
683
|
+
return true;
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function firstProviderPriority(policy: unknown): string | undefined {
|
|
688
|
+
const raw = policy && typeof policy === 'object' && !Array.isArray(policy)
|
|
689
|
+
? (policy as Record<string, unknown>).providerPriority
|
|
690
|
+
: undefined;
|
|
691
|
+
if (!Array.isArray(raw)) return undefined;
|
|
692
|
+
return raw.find(type => typeof type === 'string' && type.trim())?.trim();
|
|
57
693
|
}
|
|
58
694
|
|
|
59
|
-
function
|
|
60
|
-
const
|
|
61
|
-
|
|
695
|
+
function readNodeOverride(node: { userOverrides?: unknown } | undefined, key: 'platform' | 'arch'): string | null {
|
|
696
|
+
const overrides = node?.userOverrides;
|
|
697
|
+
if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) return null;
|
|
698
|
+
const value = (overrides as Record<string, unknown>)[key];
|
|
699
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
62
700
|
}
|
|
63
701
|
|
|
64
702
|
/**
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
703
|
+
* Live, self-reported platform/arch the owning daemon stamped onto the node from
|
|
704
|
+
* its own process.platform/process.arch via the git_status envelope. Kept on a
|
|
705
|
+
* field DISTINCT from userOverrides so capability-tag derivation can prefer an
|
|
706
|
+
* explicit operator override while still self-healing auto-detected nodes — and
|
|
707
|
+
* so the value reflects the node's real OS rather than the coordinator's.
|
|
68
708
|
*/
|
|
69
|
-
function
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
for (let i = 0; i < 10; i++) {
|
|
73
|
-
try { fd = openSync(lockPath, 'wx'); break; } catch {
|
|
74
|
-
const deadline = Date.now() + 30;
|
|
75
|
-
while (Date.now() < deadline) { /* spin */ }
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
try { return fn(); } finally {
|
|
79
|
-
if (fd !== -1) try { closeSync(fd); } catch { /* noop */ }
|
|
80
|
-
try { unlinkSync(lockPath); } catch { /* already removed */ }
|
|
81
|
-
}
|
|
709
|
+
function readNodeReporter(node: { reportedPlatform?: unknown; reportedArch?: unknown } | undefined, key: 'platform' | 'arch'): string | null {
|
|
710
|
+
const value = key === 'platform' ? node?.reportedPlatform : node?.reportedArch;
|
|
711
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
82
712
|
}
|
|
83
713
|
|
|
84
|
-
function
|
|
85
|
-
|
|
86
|
-
|
|
714
|
+
export function buildMeshNodeCapabilityTags(
|
|
715
|
+
node: { capabilities?: unknown; policy?: unknown; isLocalWorktree?: unknown; worktreeBranch?: unknown; userOverrides?: unknown; reportedPlatform?: unknown; reportedArch?: unknown } | undefined,
|
|
716
|
+
providerType?: string,
|
|
717
|
+
): string[] {
|
|
718
|
+
const provider = typeof providerType === 'string' && providerType.trim()
|
|
719
|
+
? providerType.trim()
|
|
720
|
+
: firstProviderPriority(node?.policy);
|
|
721
|
+
const worktreeBranch = typeof node?.worktreeBranch === 'string' && node.worktreeBranch.trim()
|
|
722
|
+
? node.worktreeBranch.trim()
|
|
723
|
+
: null;
|
|
724
|
+
// Per-node platform/arch precedence (highest → lowest):
|
|
725
|
+
// 1. userOverrides.platform/arch — an EXPLICIT operator override always wins.
|
|
726
|
+
// 2. reportedPlatform/reportedArch — the live OS the owning daemon
|
|
727
|
+
// self-reported (its own process.platform/process.arch via the git_status
|
|
728
|
+
// envelope), persisted to the node record on each direct probe. This is
|
|
729
|
+
// why a Windows member advertises os=win32 even though the COORDINATOR
|
|
730
|
+
// computing these tags runs on darwin — without it the consumer reads the
|
|
731
|
+
// persistent node (operator userOverrides empty) and would fall straight
|
|
732
|
+
// through to the coordinator's own process.platform, mislabeling every
|
|
733
|
+
// node os=darwin. We prefer this LIVE value over any stale auto-stamp.
|
|
734
|
+
// 3. process.platform/process.arch — last-resort fallback, correct only for
|
|
735
|
+
// the local coordinator node / local worktree nodes that have not yet
|
|
736
|
+
// been probed (their workspace lives on THIS machine anyway).
|
|
737
|
+
// Vocabulary is raw process.platform/process.arch ("darwin"/"win32"/"linux",
|
|
738
|
+
// "arm64"/"x64") on both the advertiser and the required_tags matcher, which
|
|
739
|
+
// compares with plain string equality (nodeSatisfiesRequiredTags) — so this
|
|
740
|
+
// keeps the win32/darwin/linux vocabulary the matcher already expects.
|
|
741
|
+
const os = readNodeOverride(node, 'platform') ?? readNodeReporter(node, 'platform') ?? process.platform;
|
|
742
|
+
const arch = readNodeOverride(node, 'arch') ?? readNodeReporter(node, 'arch') ?? process.arch;
|
|
743
|
+
return normalizeMeshCapabilityTags([
|
|
744
|
+
...(Array.isArray(node?.capabilities) ? node.capabilities : []),
|
|
745
|
+
`os=${os}`,
|
|
746
|
+
`arch=${arch}`,
|
|
747
|
+
...(provider ? [`provider=${provider}`] : []),
|
|
748
|
+
// Worktree nodes automatically expose a "worktree=<branch>" tag so that
|
|
749
|
+
// mesh_enqueue_task with required_tags: ["worktree=<branch>"] routes
|
|
750
|
+
// only to the matching worktree node.
|
|
751
|
+
...(node?.isLocalWorktree === true && worktreeBranch ? [`worktree=${worktreeBranch}`] : []),
|
|
752
|
+
// Convergence routing: advertise how this node can land its work onto base.
|
|
753
|
+
// - converge=refine: local worktree nodes (on ANY machine — refine_mesh_node
|
|
754
|
+
// now forwards to the owning daemon) can run the Refinery merge → push →
|
|
755
|
+
// cleanup against their own checkout, so they accept code_change tasks.
|
|
756
|
+
// - converge=fast_forward: non-worktree nodes (the machine itself) can only
|
|
757
|
+
// ff/push an already-converged branch; they are NOT a destination for
|
|
758
|
+
// code_change work (a worktree is created first, and that worktree node
|
|
759
|
+
// receives the task instead). Reuses the ordinary required-tags filter —
|
|
760
|
+
// the load-balancing scheduler auto-injects converge=refine for code_change
|
|
761
|
+
// so such work is hard-filtered onto refine-capable nodes.
|
|
762
|
+
...(node?.isLocalWorktree === true ? ['converge=refine'] : ['converge=fast_forward']),
|
|
763
|
+
]);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
export function nodeSatisfiesRequiredTags(requiredTags: unknown, capabilityTags: unknown): boolean {
|
|
767
|
+
const required = normalizeMeshCapabilityTags(requiredTags);
|
|
768
|
+
if (required.length === 0) return true;
|
|
769
|
+
const available = new Set(normalizeMeshCapabilityTags(capabilityTags));
|
|
770
|
+
return required.every(tag => available.has(tag));
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* Convergence-aware required-tags resolution (load-balancing scheduler, opt-in).
|
|
775
|
+
*
|
|
776
|
+
* When the mesh enables policy.autoConvergeCodeChange, a `converge=refine` required
|
|
777
|
+
* tag is merged into a code_change task's required tags at enqueue time, so the
|
|
778
|
+
* scheduler hard-filters the task onto refine-capable worktree nodes only (on any
|
|
779
|
+
* machine — refine_mesh_node forwards to the owning daemon). Because the tag is
|
|
780
|
+
* persisted on the queue entry, BOTH the eligibility scan (maybeAutoLaunchOneQueueSession)
|
|
781
|
+
* and the claim transaction (claimNextQueueTask → nodeSatisfiesRequiredTags) enforce
|
|
782
|
+
* it consistently.
|
|
783
|
+
*
|
|
784
|
+
* Strict backward compatibility — the injection is skipped (returns the explicit tags
|
|
785
|
+
* unchanged) when ANY of:
|
|
786
|
+
* - the mesh does not opt in (autoConvergeCodeChange !== true), or
|
|
787
|
+
* - the task is not code_change (validation / live_debug_readonly / launch_app /
|
|
788
|
+
* convergence carry no merge cost and may run anywhere), or
|
|
789
|
+
* - the task is explicitly targeted (targetNodeId): the operator chose the node, so
|
|
790
|
+
* we do not second-guess it by filtering on convergence capability.
|
|
791
|
+
* Idempotent: normalizeMeshCapabilityTags dedupes, so re-injection is a no-op.
|
|
792
|
+
*/
|
|
793
|
+
export function resolveConvergeRequiredTags(
|
|
794
|
+
meshId: string,
|
|
795
|
+
taskMode: MeshTaskMode | undefined,
|
|
796
|
+
explicitRequiredTags: string[],
|
|
797
|
+
opts?: { targetNodeId?: string },
|
|
798
|
+
): string[] {
|
|
799
|
+
if (taskMode !== 'code_change') return explicitRequiredTags;
|
|
800
|
+
if (typeof opts?.targetNodeId === 'string' && opts.targetNodeId.trim()) return explicitRequiredTags;
|
|
801
|
+
let optedIn = false;
|
|
87
802
|
try {
|
|
88
|
-
|
|
89
|
-
return JSON.parse(content) as MeshWorkQueueEntry[];
|
|
803
|
+
optedIn = resolveAutoConvergeCodeChange(getMesh(meshId)?.policy as any);
|
|
90
804
|
} catch {
|
|
91
|
-
|
|
805
|
+
optedIn = false;
|
|
92
806
|
}
|
|
807
|
+
if (!optedIn) return explicitRequiredTags;
|
|
808
|
+
return normalizeMeshCapabilityTags([...explicitRequiredTags, MESH_CONVERGE_REFINE_TAG]);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function withQueueLock<T>(_meshId: string, fn: () => T): T {
|
|
812
|
+
return MeshRuntimeStore.getInstance().transaction(fn);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function readQueue(meshId: string): MeshWorkQueueEntry[] {
|
|
816
|
+
return MeshRuntimeStore.getInstance().getQueueEntries(meshId);
|
|
93
817
|
}
|
|
94
818
|
|
|
95
819
|
function writeQueue(meshId: string, queue: MeshWorkQueueEntry[]): void {
|
|
96
|
-
|
|
97
|
-
|
|
820
|
+
MeshRuntimeStore.getInstance().replaceQueue(meshId, queue);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function normalizeDependsOn(value: unknown): string[] {
|
|
824
|
+
if (!Array.isArray(value)) return [];
|
|
825
|
+
const seen = new Set<string>();
|
|
826
|
+
return value
|
|
827
|
+
.map(id => typeof id === 'string' ? id.trim() : '')
|
|
828
|
+
.filter(Boolean)
|
|
829
|
+
.filter(id => {
|
|
830
|
+
if (seen.has(id)) return false;
|
|
831
|
+
seen.add(id);
|
|
832
|
+
return true;
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* M1: detect dependency cycles before enqueue. Walks the dependency graph of
|
|
838
|
+
* existing queue entries plus the new task's edges. Fail-closed: a cycle
|
|
839
|
+
* rejects the enqueue entirely. Synchronous and bounded by queue size.
|
|
840
|
+
*/
|
|
841
|
+
export function assertNoDependencyCycle(meshId: string, newTaskId: string, dependsOn: string[]): void {
|
|
842
|
+
if (dependsOn.length === 0) return;
|
|
843
|
+
if (dependsOn.includes(newTaskId)) {
|
|
844
|
+
throw new Error(`dependency_cycle_detected: task '${newTaskId}' cannot depend on itself`);
|
|
845
|
+
}
|
|
846
|
+
const adjacency = new Map<string, string[]>();
|
|
847
|
+
for (const entry of readQueue(meshId)) {
|
|
848
|
+
adjacency.set(entry.id, normalizeDependsOn(entry.dependsOn));
|
|
849
|
+
}
|
|
850
|
+
adjacency.set(newTaskId, dependsOn);
|
|
851
|
+
// DFS from the new task: if we can reach newTaskId again, the edges form a cycle.
|
|
852
|
+
const stack = [...dependsOn];
|
|
853
|
+
const visited = new Set<string>();
|
|
854
|
+
while (stack.length > 0) {
|
|
855
|
+
const current = stack.pop()!;
|
|
856
|
+
if (current === newTaskId) {
|
|
857
|
+
throw new Error(`dependency_cycle_detected: task '${newTaskId}' is part of a dependency cycle via '${dependsOn.join(', ')}'`);
|
|
858
|
+
}
|
|
859
|
+
if (visited.has(current)) continue;
|
|
860
|
+
visited.add(current);
|
|
861
|
+
stack.push(...(adjacency.get(current) ?? []));
|
|
862
|
+
}
|
|
98
863
|
}
|
|
99
864
|
|
|
100
865
|
/**
|
|
@@ -103,23 +868,213 @@ function writeQueue(meshId: string, queue: MeshWorkQueueEntry[]): void {
|
|
|
103
868
|
export function enqueueTask(
|
|
104
869
|
meshId: string,
|
|
105
870
|
message: string,
|
|
106
|
-
opts?: {
|
|
871
|
+
opts?: {
|
|
872
|
+
targetNodeId?: string;
|
|
873
|
+
targetSessionId?: string;
|
|
874
|
+
taskMode?: MeshTaskMode | string;
|
|
875
|
+
/** QUEUE-NODE-SERIALIZATION: explicit read-only axis (orthogonal to taskMode). */
|
|
876
|
+
readonly?: boolean;
|
|
877
|
+
requiredTags?: string[];
|
|
878
|
+
/** M1: tasks that must complete before this one is claimable. */
|
|
879
|
+
dependsOn?: string[];
|
|
880
|
+
/** G6: task-level scheduling priority ('low' | 'normal' | 'high'). Absent → 'normal'. */
|
|
881
|
+
priority?: MeshTaskPriority | string;
|
|
882
|
+
/** G7: hold the task pending until this time. ISO string, absolute epoch-ms, or relative-ms offset from now. */
|
|
883
|
+
notBefore?: string | number;
|
|
884
|
+
/** P3: max automatic requeue attempts before the task auto-fails. Absent → policy default (1). */
|
|
885
|
+
maxRetries?: number;
|
|
886
|
+
/** M1/M3: mission this task belongs to. */
|
|
887
|
+
missionId?: string;
|
|
888
|
+
/** MAGI: consensus group id shared by every replica of a mesh_magi_review fan-out. */
|
|
889
|
+
consensusGroupId?: string;
|
|
890
|
+
/** MAGI-KIND-PANEL: model override forwarded to the executing session's launch (initialModel). */
|
|
891
|
+
model?: string;
|
|
892
|
+
/** BRAIN-ROUTING: standard thinking level forwarded to launch (initialThinkingLevel). */
|
|
893
|
+
thinkingLevel?: string;
|
|
894
|
+
/**
|
|
895
|
+
* BRAIN-ROUTING: task execution difficulty ('easy'|'medium'|'difficult'|
|
|
896
|
+
* 'freeform'). When set, the mesh's difficulty→brain preset fills in model /
|
|
897
|
+
* thinkingLevel that were not passed explicitly (an explicit model/thinkingLevel
|
|
898
|
+
* wins). Purely a convenience resolver — the stored task still carries the
|
|
899
|
+
* resolved model/thinkingLevel, so downstream launch is unchanged.
|
|
900
|
+
*/
|
|
901
|
+
difficulty?: string;
|
|
902
|
+
/** Explicit task id for batch/template flows (M5). Random UUID when omitted. */
|
|
903
|
+
id?: string;
|
|
904
|
+
/** (3) Originating coordinator session id (for session-anchored completion routing). */
|
|
905
|
+
sourceCoordinatorSessionId?: string;
|
|
906
|
+
} & MeshQueueMutationOptions,
|
|
107
907
|
): MeshWorkQueueEntry {
|
|
108
908
|
requireMeshHostQueueOwner(opts);
|
|
109
|
-
|
|
110
|
-
|
|
909
|
+
const readonly = opts?.readonly === true;
|
|
910
|
+
const modeValidation = validateMeshTaskModeRequest(opts?.taskMode, message, readonly);
|
|
911
|
+
if (!modeValidation.valid) {
|
|
912
|
+
throw new Error(`live_debug_readonly_guardrail_violation: forbidden operations (${modeValidation.violations.join(', ')})`);
|
|
913
|
+
}
|
|
914
|
+
const id = typeof opts?.id === 'string' && opts.id.trim() ? opts.id.trim() : randomUUID();
|
|
915
|
+
const dependsOn = normalizeDependsOn(opts?.dependsOn);
|
|
916
|
+
const priority = normalizeMeshTaskPriority(opts?.priority);
|
|
917
|
+
const notBefore = resolveNotBefore(opts?.notBefore);
|
|
918
|
+
const maxRetries = typeof opts?.maxRetries === 'number' && Number.isFinite(opts.maxRetries) && opts.maxRetries >= 0
|
|
919
|
+
? Math.floor(opts.maxRetries)
|
|
920
|
+
: undefined;
|
|
921
|
+
// BRAIN-ROUTING: resolve the difficulty preset into effective model / thinking
|
|
922
|
+
// level. An explicit opts.model / opts.thinkingLevel always wins; the preset only
|
|
923
|
+
// fills what the caller left blank. Best-effort — a missing/invalid difficulty or
|
|
924
|
+
// an unconfigured preset just leaves the explicit values (or none) in place.
|
|
925
|
+
let effectiveModel = typeof opts?.model === 'string' && opts.model.trim() ? opts.model.trim() : undefined;
|
|
926
|
+
let effectiveThinkingLevel = typeof opts?.thinkingLevel === 'string' && opts.thinkingLevel.trim() ? opts.thinkingLevel.trim() : undefined;
|
|
927
|
+
// SLOT-ROUTING: persist the difficulty class on the entry so the scheduler can
|
|
928
|
+
// match it against node capability slots at assignment time (not just resolve
|
|
929
|
+
// model/thinking here). Absent/invalid → undefined (task carries no difficulty).
|
|
930
|
+
const taskDifficulty = isMeshTaskDifficulty(opts?.difficulty) ? (opts!.difficulty as MeshTaskDifficulty) : undefined;
|
|
931
|
+
if (isMeshTaskDifficulty(opts?.difficulty)) {
|
|
932
|
+
try {
|
|
933
|
+
const preset = getDifficultyBrains()[opts!.difficulty as MeshTaskDifficulty];
|
|
934
|
+
if (preset) {
|
|
935
|
+
if (!effectiveModel && preset.model) effectiveModel = preset.model;
|
|
936
|
+
if (!effectiveThinkingLevel && preset.thinkingLevel) effectiveThinkingLevel = preset.thinkingLevel;
|
|
937
|
+
}
|
|
938
|
+
} catch { /* preset read is best-effort — never block enqueue */ }
|
|
939
|
+
}
|
|
940
|
+
const result = withQueueLock(meshId, () => {
|
|
941
|
+
if (MeshRuntimeStore.getInstance().findQueueEntryById(meshId, id)) {
|
|
942
|
+
throw new Error(`duplicate_task_id: task '${id}' already exists in mesh '${meshId}'`);
|
|
943
|
+
}
|
|
944
|
+
assertNoDependencyCycle(meshId, id, dependsOn);
|
|
945
|
+
const callerTags = normalizeMeshCapabilityTags(opts?.requiredTags);
|
|
946
|
+
// Convergence routing (opt-in): auto-inject converge=refine for code_change
|
|
947
|
+
// tasks so they hard-filter onto refine-capable worktree nodes. No-op unless
|
|
948
|
+
// the mesh opts in; explicit target_node_id / required_tags are preserved.
|
|
949
|
+
// Routing is otherwise governed solely by the caller's required_tags (hard
|
|
950
|
+
// filter through nodeSatisfiesRequiredTags) — no role/taskMode auto-routing.
|
|
951
|
+
const resolvedRequiredTags = resolveConvergeRequiredTags(
|
|
952
|
+
meshId,
|
|
953
|
+
modeValidation.taskMode,
|
|
954
|
+
callerTags,
|
|
955
|
+
{ targetNodeId: opts?.targetNodeId },
|
|
956
|
+
);
|
|
111
957
|
const entry: MeshWorkQueueEntry = {
|
|
112
|
-
id
|
|
958
|
+
id,
|
|
113
959
|
meshId,
|
|
114
960
|
message,
|
|
115
961
|
status: 'pending',
|
|
962
|
+
taskMode: modeValidation.taskMode,
|
|
963
|
+
...(readonly ? { readonly: true } : {}),
|
|
116
964
|
targetNodeId: opts?.targetNodeId,
|
|
117
965
|
targetSessionId: opts?.targetSessionId,
|
|
966
|
+
requiredTags: resolvedRequiredTags,
|
|
967
|
+
...(dependsOn.length > 0 ? { dependsOn } : {}),
|
|
968
|
+
// G6: only persist a non-default priority so legacy/normal rows stay minimal.
|
|
969
|
+
...(priority && priority !== 'normal' ? { priority } : {}),
|
|
970
|
+
// G7: hold-until gate (stored ISO). Omitted when absent/immediate.
|
|
971
|
+
...(notBefore ? { notBefore } : {}),
|
|
972
|
+
// P3: explicit retry cap. Omitted → requeue path falls back to policy default.
|
|
973
|
+
...(maxRetries !== undefined ? { maxRetries } : {}),
|
|
974
|
+
...(typeof opts?.missionId === 'string' && opts.missionId.trim() ? { missionId: opts.missionId.trim() } : {}),
|
|
975
|
+
...(typeof opts?.consensusGroupId === 'string' && opts.consensusGroupId.trim() ? { consensusGroupId: opts.consensusGroupId.trim() } : {}),
|
|
976
|
+
...(effectiveModel ? { model: effectiveModel } : {}),
|
|
977
|
+
...(effectiveThinkingLevel ? { thinkingLevel: effectiveThinkingLevel } : {}),
|
|
978
|
+
...(taskDifficulty ? { difficulty: taskDifficulty } : {}),
|
|
979
|
+
...(typeof opts?.sourceCoordinatorSessionId === 'string' && opts.sourceCoordinatorSessionId.trim()
|
|
980
|
+
? { sourceCoordinatorSessionId: opts.sourceCoordinatorSessionId.trim() }
|
|
981
|
+
: {}),
|
|
118
982
|
createdAt: new Date().toISOString(),
|
|
119
983
|
updatedAt: new Date().toISOString(),
|
|
120
984
|
};
|
|
121
|
-
|
|
122
|
-
|
|
985
|
+
MeshRuntimeStore.getInstance().insertQueueEntry(entry);
|
|
986
|
+
return entry;
|
|
987
|
+
});
|
|
988
|
+
// A fresh pending task returns its mission to a non-terminal state — reset any
|
|
989
|
+
// stale close-candidate marker so a later re-completion can nudge again.
|
|
990
|
+
scheduleMissionCloseCandidateCheck(meshId, [result]);
|
|
991
|
+
return result;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* Record a direct-dispatch task (mesh_send_task) as an already-assigned queue
|
|
996
|
+
* entry so it is attributable to a mission.
|
|
997
|
+
*
|
|
998
|
+
* Direct dispatch normally bypasses the queue entirely — the task lives only in
|
|
999
|
+
* the ledger + mesh_direct_dispatches table, neither of which carries a
|
|
1000
|
+
* missionId, so {@link summarizeMissionTasks}/{@link computeMeshTaskStats}
|
|
1001
|
+
* (which both scan the queue for `task.missionId`) count it as 0. When a
|
|
1002
|
+
* mission is attached, we materialise the same queue entry shape an enqueued
|
|
1003
|
+
* task would have, but pre-assigned to the dispatched node/session and stamped
|
|
1004
|
+
* with the dispatch timestamp. The terminal event path (updateSessionTaskStatus
|
|
1005
|
+
* → findAssignedBySession) then flips it to completed/failed exactly like a
|
|
1006
|
+
* pulled task, so mission total + completed aggregates work with no extra wiring.
|
|
1007
|
+
*
|
|
1008
|
+
* Intentionally separate from {@link enqueueTask}: enqueue creates `pending`
|
|
1009
|
+
* work for the queue to assign, whereas this records work already dispatched
|
|
1010
|
+
* out-of-band. They share the missionId stamping rule and mode validation.
|
|
1011
|
+
*/
|
|
1012
|
+
export function recordDirectDispatchTask(
|
|
1013
|
+
meshId: string,
|
|
1014
|
+
message: string,
|
|
1015
|
+
opts: {
|
|
1016
|
+
id: string;
|
|
1017
|
+
missionId: string;
|
|
1018
|
+
assignedNodeId?: string;
|
|
1019
|
+
assignedSessionId?: string;
|
|
1020
|
+
taskMode?: MeshTaskMode | string;
|
|
1021
|
+
/** QUEUE-NODE-SERIALIZATION: explicit read-only axis (orthogonal to taskMode). */
|
|
1022
|
+
readonly?: boolean;
|
|
1023
|
+
dispatchedAt?: string;
|
|
1024
|
+
},
|
|
1025
|
+
): MeshWorkQueueEntry | null {
|
|
1026
|
+
const missionId = typeof opts.missionId === 'string' ? opts.missionId.trim() : '';
|
|
1027
|
+
if (!missionId) return null;
|
|
1028
|
+
const taskId = typeof opts.id === 'string' ? opts.id.trim() : '';
|
|
1029
|
+
if (!taskId) return null;
|
|
1030
|
+
const readonly = opts.readonly === true;
|
|
1031
|
+
const modeValidation = validateMeshTaskModeRequest(opts.taskMode, message, readonly);
|
|
1032
|
+
if (!modeValidation.valid) {
|
|
1033
|
+
throw new Error(`live_debug_readonly_guardrail_violation: forbidden operations (${modeValidation.violations.join(', ')})`);
|
|
1034
|
+
}
|
|
1035
|
+
const now = opts.dispatchedAt && opts.dispatchedAt.trim() ? opts.dispatchedAt : new Date().toISOString();
|
|
1036
|
+
return withQueueLock(meshId, () => {
|
|
1037
|
+
if (MeshRuntimeStore.getInstance().findQueueEntryById(meshId, taskId)) {
|
|
1038
|
+
// Already materialised (e.g. retry of the same dispatch) — leave it untouched.
|
|
1039
|
+
return null;
|
|
1040
|
+
}
|
|
1041
|
+
const entry: MeshWorkQueueEntry = {
|
|
1042
|
+
id: taskId,
|
|
1043
|
+
meshId,
|
|
1044
|
+
message,
|
|
1045
|
+
status: 'assigned',
|
|
1046
|
+
...(modeValidation.taskMode ? { taskMode: modeValidation.taskMode } : {}),
|
|
1047
|
+
...(readonly ? { readonly: true } : {}),
|
|
1048
|
+
missionId,
|
|
1049
|
+
...(opts.assignedNodeId ? { targetNodeId: opts.assignedNodeId, assignedNodeId: opts.assignedNodeId } : {}),
|
|
1050
|
+
...(opts.assignedSessionId ? { targetSessionId: opts.assignedSessionId, assignedSessionId: opts.assignedSessionId } : {}),
|
|
1051
|
+
dispatchTimestamp: now,
|
|
1052
|
+
createdAt: now,
|
|
1053
|
+
updatedAt: now,
|
|
1054
|
+
};
|
|
1055
|
+
MeshRuntimeStore.getInstance().insertQueueEntry(entry);
|
|
1056
|
+
// R2 / NOTIF-DROP: a mission-attributed DIRECT dispatch (mesh_send_task) has
|
|
1057
|
+
// already been handed to the transport by the time we materialise this assigned
|
|
1058
|
+
// row — unlike a queue claim, there is no later delivery-confirmation write for
|
|
1059
|
+
// it. Without a confirmed delivery record keyed by this taskId, the assigned-
|
|
1060
|
+
// stranded watchdog (recoverStrandedAssignedDispatches → taskHasConfirmedDelivery)
|
|
1061
|
+
// sees the row as never-confirmed after ASSIGNED_STRANDED_DEADLINE_MS and reclaims
|
|
1062
|
+
// a task the worker already COMPLETED, dropping its agent:generating_completed
|
|
1063
|
+
// (live PROBE-B repro: "never confirmed delivered → pending"). Record a confirmed
|
|
1064
|
+
// delivery here so taskHasConfirmedDelivery() is true and the watchdog leaves the
|
|
1065
|
+
// row to PHASE 4 completion reconcile. This point is only reached after the direct
|
|
1066
|
+
// dispatch's result.success, so 'delivered' is the accurate state.
|
|
1067
|
+
try {
|
|
1068
|
+
createSessionDelivery({
|
|
1069
|
+
meshId,
|
|
1070
|
+
...(opts.assignedNodeId ? { nodeId: opts.assignedNodeId } : {}),
|
|
1071
|
+
...(opts.assignedSessionId ? { sessionId: opts.assignedSessionId } : {}),
|
|
1072
|
+
taskId,
|
|
1073
|
+
kind: 'task',
|
|
1074
|
+
message,
|
|
1075
|
+
status: 'delivered',
|
|
1076
|
+
});
|
|
1077
|
+
} catch { /* best-effort — the assigned row is already recorded */ }
|
|
123
1078
|
return entry;
|
|
124
1079
|
});
|
|
125
1080
|
}
|
|
@@ -128,41 +1083,119 @@ export function enqueueTask(
|
|
|
128
1083
|
* Get all tasks in the queue, optionally filtered by status.
|
|
129
1084
|
*/
|
|
130
1085
|
export function getQueue(meshId: string, opts?: { status?: MeshTaskStatus[] }): MeshWorkQueueEntry[] {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
return queue;
|
|
1086
|
+
return MeshRuntimeStore.getInstance().getQueueEntries(meshId, opts?.status?.length ? opts.status : undefined);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
export function getMeshQueueRevision(meshId: string): string {
|
|
1090
|
+
return MeshRuntimeStore.getInstance().getQueueRevision(meshId);
|
|
137
1091
|
}
|
|
138
1092
|
|
|
139
1093
|
/**
|
|
140
1094
|
* Find the next pending task that this node is allowed to claim, and mark it as assigned.
|
|
1095
|
+
*
|
|
1096
|
+
* `opts.providerType` is stamped onto the claimed entry (assignedProviderType) so
|
|
1097
|
+
* per-(node, provider) caps can be counted. `opts.providerMaxParallel`, when set,
|
|
1098
|
+
* is the enforced per-(node, provider) cap from RepoMeshNodePolicy.providerRoles:
|
|
1099
|
+
* a task is not assigned to this (node, provider) once it already has that many
|
|
1100
|
+
* active assignments. This composes with the global/taskMode caps (stricter wins).
|
|
141
1101
|
*/
|
|
142
|
-
export function claimNextTask(
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
1102
|
+
export function claimNextTask(
|
|
1103
|
+
meshId: string,
|
|
1104
|
+
nodeId: string,
|
|
1105
|
+
sessionId: string,
|
|
1106
|
+
capabilityTags?: string[],
|
|
1107
|
+
opts?: { providerType?: string; providerMaxParallel?: number; nodeIsWorktree?: boolean },
|
|
1108
|
+
): MeshWorkQueueEntry | null {
|
|
1109
|
+
return MeshRuntimeStore.getInstance().claimNextQueueTask(meshId, nodeId, sessionId, capabilityTags, opts);
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// ─── M1: Dependency Failure Propagation ─────────
|
|
1113
|
+
|
|
1114
|
+
export type DependencyFailurePolicy = 'block' | 'cancel';
|
|
1115
|
+
|
|
1116
|
+
function resolveDependencyFailurePolicy(meshId: string): DependencyFailurePolicy {
|
|
1117
|
+
try {
|
|
1118
|
+
const policy = (getMesh(meshId)?.policy ?? {}) as Record<string, unknown>;
|
|
1119
|
+
return policy.onDependencyFailure === 'cancel' ? 'cancel' : 'block';
|
|
1120
|
+
} catch {
|
|
1121
|
+
return 'block';
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
/**
|
|
1126
|
+
* Apply the mesh's onDependencyFailure policy to pending dependents of a task
|
|
1127
|
+
* that just reached a failed/cancelled terminal state.
|
|
1128
|
+
*
|
|
1129
|
+
* - 'block' (default): dependents stay pending with blockedReason
|
|
1130
|
+
* "dependency_failed:<taskId>" so an operator can requeue/cancel them.
|
|
1131
|
+
* - 'cancel': dependents are cancelled (cascading to their own dependents).
|
|
1132
|
+
*
|
|
1133
|
+
* Must be called inside the queue lock of the triggering transition.
|
|
1134
|
+
*/
|
|
1135
|
+
/**
|
|
1136
|
+
* Cascade a dependency failure. Returns the dependents whose status was flipped to
|
|
1137
|
+
* `cancelled` (the 'cancel' policy) so the caller can trigger mission_close_candidate
|
|
1138
|
+
* detection for their missions too — a cascade can be the very transition that leaves
|
|
1139
|
+
* a *different* mission all-terminal. Under the 'block' policy nothing goes terminal
|
|
1140
|
+
* (dependents are only marked blocked), so the returned list is empty.
|
|
1141
|
+
*/
|
|
1142
|
+
function propagateDependencyFailure(meshId: string, failedTaskId: string): MeshWorkQueueEntry[] {
|
|
1143
|
+
const policy = resolveDependencyFailurePolicy(meshId);
|
|
1144
|
+
const store = MeshRuntimeStore.getInstance();
|
|
1145
|
+
const cancelled: MeshWorkQueueEntry[] = [];
|
|
1146
|
+
const frontier = [failedTaskId];
|
|
1147
|
+
const seen = new Set<string>(frontier);
|
|
1148
|
+
while (frontier.length > 0) {
|
|
1149
|
+
const currentId = frontier.pop()!;
|
|
1150
|
+
const dependents = store.getQueueEntries(meshId, ['pending'])
|
|
1151
|
+
.filter(entry => Array.isArray(entry.dependsOn) && entry.dependsOn.includes(currentId));
|
|
1152
|
+
for (const dependent of dependents) {
|
|
1153
|
+
if (seen.has(dependent.id)) continue;
|
|
1154
|
+
seen.add(dependent.id);
|
|
1155
|
+
if (policy === 'cancel') {
|
|
1156
|
+
dependent.status = 'cancelled';
|
|
1157
|
+
dependent.cancelledAt = new Date().toISOString();
|
|
1158
|
+
dependent.cancelReason = `dependency_failed:${currentId}`;
|
|
1159
|
+
store.updateQueueEntry(dependent);
|
|
1160
|
+
cancelled.push(dependent);
|
|
1161
|
+
frontier.push(dependent.id); // cascade to transitive dependents
|
|
1162
|
+
} else {
|
|
1163
|
+
dependent.blockedReason = `dependency_failed:${currentId}`;
|
|
1164
|
+
store.updateQueueEntry(dependent);
|
|
1165
|
+
}
|
|
155
1166
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
1167
|
+
}
|
|
1168
|
+
return cancelled;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
const DEPENDENCY_FAILURE_TERMINALS = new Set<MeshTaskStatus>(['failed', 'cancelled']);
|
|
1172
|
+
|
|
1173
|
+
/**
|
|
1174
|
+
* G3 (step ①) — fire-and-forget mission_close_candidate detection for the missions of
|
|
1175
|
+
* the given task ids. Called after any task-status mutation (completion / failure /
|
|
1176
|
+
* cancel / dependency-cascade / new-task enqueue) so a mission whose tasks all just
|
|
1177
|
+
* became terminal gets a one-shot "consider closing" nudge, and a mission that just
|
|
1178
|
+
* gained a non-terminal task has its idempotency marker reset.
|
|
1179
|
+
*
|
|
1180
|
+
* Loaded via a lazy dynamic import to break the static queue↔missions import cycle
|
|
1181
|
+
* (mesh-missions statically imports getQueue from here): the resolve happens off the
|
|
1182
|
+
* mutation's critical path, and any failure is swallowed — this is a best-effort hint,
|
|
1183
|
+
* never allowed to affect the task write that triggered it.
|
|
1184
|
+
*/
|
|
1185
|
+
function scheduleMissionCloseCandidateCheck(meshId: string, entries: Array<MeshWorkQueueEntry | null | undefined>): void {
|
|
1186
|
+
const missionIds = new Set<string>();
|
|
1187
|
+
for (const entry of entries) {
|
|
1188
|
+
const missionId = entry?.missionId;
|
|
1189
|
+
if (typeof missionId === 'string' && missionId.trim()) missionIds.add(missionId.trim());
|
|
1190
|
+
}
|
|
1191
|
+
if (missionIds.size === 0) return;
|
|
1192
|
+
void import('./mesh-missions.js')
|
|
1193
|
+
.then(({ maybeEmitMissionCloseCandidate }) => {
|
|
1194
|
+
for (const missionId of missionIds) {
|
|
1195
|
+
try { maybeEmitMissionCloseCandidate(meshId, missionId); } catch { /* best-effort per mission */ }
|
|
1196
|
+
}
|
|
1197
|
+
})
|
|
1198
|
+
.catch(() => { /* best-effort: never break a task mutation on the hint path */ });
|
|
166
1199
|
}
|
|
167
1200
|
|
|
168
1201
|
/**
|
|
@@ -176,15 +1209,19 @@ export function updateTaskStatus(
|
|
|
176
1209
|
opts?: MeshQueueMutationOptions,
|
|
177
1210
|
): MeshWorkQueueEntry | null {
|
|
178
1211
|
requireMeshHostQueueOwner(opts);
|
|
179
|
-
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
1212
|
+
const result = withQueueLock(meshId, () => {
|
|
1213
|
+
const entry = MeshRuntimeStore.getInstance().findQueueEntryById(meshId, taskId);
|
|
1214
|
+
if (!entry) return null;
|
|
1215
|
+
entry.status = status;
|
|
1216
|
+
MeshRuntimeStore.getInstance().updateQueueEntry(entry);
|
|
1217
|
+
// Any transition OFF `assigned` ends the single-flight dispatch window (terminal
|
|
1218
|
+
// completion/failure, or the dispatch-failure requeue to `pending`).
|
|
1219
|
+
if (status !== 'assigned') endTaskDispatchInFlight(meshId, taskId);
|
|
1220
|
+
const cascaded = DEPENDENCY_FAILURE_TERMINALS.has(status) ? propagateDependencyFailure(meshId, taskId) : [];
|
|
1221
|
+
return { entry, cascaded };
|
|
187
1222
|
});
|
|
1223
|
+
if (result) scheduleMissionCloseCandidateCheck(meshId, [result.entry, ...result.cascaded]);
|
|
1224
|
+
return result ? result.entry : null;
|
|
188
1225
|
}
|
|
189
1226
|
|
|
190
1227
|
export function recordTaskAutoLaunch(
|
|
@@ -193,14 +1230,12 @@ export function recordTaskAutoLaunch(
|
|
|
193
1230
|
autoLaunch: Omit<NonNullable<MeshWorkQueueEntry['autoLaunch']>, 'updatedAt'>,
|
|
194
1231
|
): MeshWorkQueueEntry | null {
|
|
195
1232
|
return withQueueLock(meshId, () => {
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
if (idx === -1) return null;
|
|
1233
|
+
const entry = MeshRuntimeStore.getInstance().findQueueEntryById(meshId, taskId);
|
|
1234
|
+
if (!entry) return null;
|
|
199
1235
|
const now = new Date().toISOString();
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
return queue[idx];
|
|
1236
|
+
entry.autoLaunch = { ...autoLaunch, updatedAt: now };
|
|
1237
|
+
MeshRuntimeStore.getInstance().updateQueueEntry(entry);
|
|
1238
|
+
return entry;
|
|
204
1239
|
});
|
|
205
1240
|
}
|
|
206
1241
|
|
|
@@ -213,24 +1248,31 @@ export function cancelTask(
|
|
|
213
1248
|
opts?: { reason?: string } & MeshQueueMutationOptions,
|
|
214
1249
|
): MeshWorkQueueEntry | null {
|
|
215
1250
|
requireMeshHostQueueOwner(opts);
|
|
216
|
-
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
if (idx === -1) return null;
|
|
1251
|
+
const result = withQueueLock(meshId, () => {
|
|
1252
|
+
const entry = MeshRuntimeStore.getInstance().findQueueEntryById(meshId, taskId);
|
|
1253
|
+
if (!entry) return null;
|
|
220
1254
|
const now = new Date().toISOString();
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
1255
|
+
entry.status = 'cancelled';
|
|
1256
|
+
entry.cancelledAt = now;
|
|
1257
|
+
if (opts?.reason) entry.cancelReason = opts.reason;
|
|
1258
|
+
MeshRuntimeStore.getInstance().updateQueueEntry(entry);
|
|
1259
|
+
endTaskDispatchInFlight(meshId, taskId);
|
|
1260
|
+
const cascaded = propagateDependencyFailure(meshId, taskId);
|
|
1261
|
+
return { entry, cascaded };
|
|
227
1262
|
});
|
|
1263
|
+
if (result) scheduleMissionCloseCandidateCheck(meshId, [result.entry, ...result.cascaded]);
|
|
1264
|
+
return result ? result.entry : null;
|
|
228
1265
|
}
|
|
229
1266
|
|
|
230
1267
|
/**
|
|
231
1268
|
* Return a queue task to pending for retry. By default, dead session targeting
|
|
232
1269
|
* and assigned ownership are cleared so stale assignments do not strand again.
|
|
233
1270
|
*/
|
|
1271
|
+
export type RequeueResult =
|
|
1272
|
+
| { status: 'requeued'; entry: MeshWorkQueueEntry }
|
|
1273
|
+
| { status: 'failed_max_retries'; entry: MeshWorkQueueEntry; maxRetries: number; requeueCount: number }
|
|
1274
|
+
| { status: 'not_found' };
|
|
1275
|
+
|
|
234
1276
|
export function requeueTask(
|
|
235
1277
|
meshId: string,
|
|
236
1278
|
taskId: string,
|
|
@@ -240,16 +1282,53 @@ export function requeueTask(
|
|
|
240
1282
|
targetSessionId?: string;
|
|
241
1283
|
clearTargetNode?: boolean;
|
|
242
1284
|
clearTargetSession?: boolean;
|
|
1285
|
+
/**
|
|
1286
|
+
* Override the retry cap for this call. Use only for explicit operator actions.
|
|
1287
|
+
* If true, the task is requeued even when requeueCount >= maxRetries.
|
|
1288
|
+
*/
|
|
1289
|
+
force?: boolean;
|
|
1290
|
+
/** Per-task retry cap override. Falls back to mesh policy maxTaskRetries (default 1). */
|
|
1291
|
+
maxRetries?: number;
|
|
243
1292
|
} & MeshQueueMutationOptions,
|
|
244
1293
|
): MeshWorkQueueEntry | null {
|
|
245
1294
|
requireMeshHostQueueOwner(opts);
|
|
246
|
-
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
1295
|
+
const result = withQueueLock(meshId, () => {
|
|
1296
|
+
const entry = MeshRuntimeStore.getInstance().findQueueEntryById(meshId, taskId);
|
|
1297
|
+
if (!entry) return null;
|
|
1298
|
+
// CANON-IDENTITY single-flight: refuse (no-op) to reopen a task whose dispatch
|
|
1299
|
+
// is still in-flight — the worker is actively generating on it. Requeueing it
|
|
1300
|
+
// here would flip the row back to `pending` and let a SECOND session claim the
|
|
1301
|
+
// SAME task (the live `ade8586d` requeue-while-generating double-dispatch). A
|
|
1302
|
+
// STALE assigned row (dead session, dispatch never confirmed) is NOT in-flight
|
|
1303
|
+
// — its mark was cleared on the dispatch failure — so it still requeues as
|
|
1304
|
+
// before. An explicit operator override (`force`) bypasses this guard.
|
|
1305
|
+
// MAGI-NOTE: the future consensus group fan-out (separate mission) intentionally
|
|
1306
|
+
// re-dispatches a group-tagged task into multiple sessions and must be exempted
|
|
1307
|
+
// from this single-flight guard; the exemption hook (group-id check) belongs here.
|
|
1308
|
+
if (!opts?.force && isTaskDispatchInFlight(meshId, taskId)) {
|
|
1309
|
+
LOG.warn('MeshQueue', `Refusing to requeue task ${taskId} on mesh ${meshId}: it is actively dispatched/generating (single-flight in-flight). Requeueing now would open a duplicate second dispatch into another session. Pass force to override.`);
|
|
1310
|
+
// No status change → no mission aggregate change; nothing to re-check.
|
|
1311
|
+
return { entry, cascaded: [] as MeshWorkQueueEntry[], missionAffected: false };
|
|
1312
|
+
}
|
|
1313
|
+
// Proceeding to requeue (or force-override): the prior dispatch is being abandoned,
|
|
1314
|
+
// so end the single-flight window for this task id.
|
|
1315
|
+
endTaskDispatchInFlight(meshId, taskId);
|
|
1316
|
+
const currentCount = entry.requeueCount || 0;
|
|
1317
|
+
const maxRetries = opts?.maxRetries ?? entry.maxRetries ?? 1;
|
|
1318
|
+
if (!opts?.force && currentCount >= maxRetries) {
|
|
1319
|
+
// Auto-fail: cap exceeded without explicit force override.
|
|
1320
|
+
entry.status = 'failed';
|
|
1321
|
+
entry.cancelReason = `max_retries_exceeded: requeued ${currentCount} time(s), limit is ${maxRetries}`;
|
|
1322
|
+
entry.updatedAt = new Date().toISOString();
|
|
1323
|
+
MeshRuntimeStore.getInstance().updateQueueEntry(entry);
|
|
1324
|
+
const cascaded = propagateDependencyFailure(meshId, taskId);
|
|
1325
|
+
// Terminal (failed) → mission may now be all-terminal.
|
|
1326
|
+
return { entry, cascaded, missionAffected: true };
|
|
1327
|
+
}
|
|
252
1328
|
entry.status = 'pending';
|
|
1329
|
+
// Operator requeue clears a dependency-failure block — the operator is
|
|
1330
|
+
// explicitly overriding the held-back state.
|
|
1331
|
+
delete entry.blockedReason;
|
|
253
1332
|
delete entry.assignedNodeId;
|
|
254
1333
|
delete entry.assignedSessionId;
|
|
255
1334
|
delete entry.cancelledAt;
|
|
@@ -258,13 +1337,111 @@ export function requeueTask(
|
|
|
258
1337
|
if (typeof opts?.targetNodeId === 'string') entry.targetNodeId = opts.targetNodeId;
|
|
259
1338
|
if (opts?.clearTargetSession !== false) delete entry.targetSessionId;
|
|
260
1339
|
if (typeof opts?.targetSessionId === 'string') entry.targetSessionId = opts.targetSessionId;
|
|
261
|
-
entry.
|
|
262
|
-
entry.
|
|
263
|
-
entry.requeueCount = (entry.requeueCount || 0) + 1;
|
|
1340
|
+
entry.requeuedAt = new Date().toISOString();
|
|
1341
|
+
entry.requeueCount = currentCount + 1;
|
|
264
1342
|
if (opts?.reason) entry.requeueReason = opts.reason;
|
|
265
|
-
|
|
266
|
-
|
|
1343
|
+
MeshRuntimeStore.getInstance().updateQueueEntry(entry);
|
|
1344
|
+
// Non-terminal (back to pending) → mission left the all-terminal state; the
|
|
1345
|
+
// close-candidate check resets any stale idempotency marker so a later
|
|
1346
|
+
// re-completion can nudge again.
|
|
1347
|
+
return { entry, cascaded: [] as MeshWorkQueueEntry[], missionAffected: true };
|
|
267
1348
|
});
|
|
1349
|
+
if (result?.missionAffected) scheduleMissionCloseCandidateCheck(meshId, [result.entry, ...result.cascaded]);
|
|
1350
|
+
return result ? result.entry : null;
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
/**
|
|
1354
|
+
* Max times the assigned-stranded watchdog will reclaim a single task before giving
|
|
1355
|
+
* up and failing it. Bounds the reclaim→re-dispatch→strand cycle so a permanently
|
|
1356
|
+
* undeliverable target (e.g. a node whose transport is wedged) eventually fails and
|
|
1357
|
+
* unblocks its dependents instead of looping every reconcile tick.
|
|
1358
|
+
*/
|
|
1359
|
+
const MAX_STRANDED_RECLAIMS = 3;
|
|
1360
|
+
|
|
1361
|
+
/**
|
|
1362
|
+
* Bug B: reclaim a task stuck in 'assigned' because its dispatch was never confirmed.
|
|
1363
|
+
*
|
|
1364
|
+
* claimNextTask atomically marks a row 'assigned' BEFORE the fire-and-forget dispatch
|
|
1365
|
+
* runs. If that dispatch neither rejects (→ no .catch requeue) nor is confirmed
|
|
1366
|
+
* delivered — a relay that hangs without acking, or a confirm timer lost across a
|
|
1367
|
+
* daemon restart — the row stays 'assigned' forever, contributing 0 pending so PHASE 3
|
|
1368
|
+
* reconcile never re-examines it. This returns such a row to 'pending' and clears its
|
|
1369
|
+
* dead assignment ownership (node / session / provider / dispatchTimestamp) — the same
|
|
1370
|
+
* ownership-clear requeueTask applies — so PHASE 3 can re-dispatch it onto a fresh idle
|
|
1371
|
+
* session.
|
|
1372
|
+
*
|
|
1373
|
+
* Guarded to 'assigned' rows only (a completion/cancel that already moved the row off
|
|
1374
|
+
* 'assigned' must never be resurrected) and bounded by MAX_STRANDED_RECLAIMS (beyond
|
|
1375
|
+
* which the task is failed so dependents unblock).
|
|
1376
|
+
*/
|
|
1377
|
+
export function reclaimStrandedAssignedTask(
|
|
1378
|
+
meshId: string,
|
|
1379
|
+
taskId: string,
|
|
1380
|
+
opts?: { reason?: string; ageMs?: number } & MeshQueueMutationOptions,
|
|
1381
|
+
): MeshWorkQueueEntry | null {
|
|
1382
|
+
requireMeshHostQueueOwner(opts);
|
|
1383
|
+
const result = withQueueLock(meshId, () => {
|
|
1384
|
+
const entry = MeshRuntimeStore.getInstance().findQueueEntryById(meshId, taskId);
|
|
1385
|
+
if (!entry) return null;
|
|
1386
|
+
// Only a still-assigned row is stranded. If a completion/cancel already moved it
|
|
1387
|
+
// off 'assigned', there is nothing to reclaim — never resurrect a terminal row.
|
|
1388
|
+
if (entry.status !== 'assigned') return null;
|
|
1389
|
+
const now = new Date().toISOString();
|
|
1390
|
+
const reason = opts?.reason || 'assigned_stranded_dispatch_unconfirmed';
|
|
1391
|
+
const reclaims = (entry.strandedReclaimCount || 0) + 1;
|
|
1392
|
+
const prevNode = entry.assignedNodeId;
|
|
1393
|
+
const prevSession = entry.assignedSessionId;
|
|
1394
|
+
// Always clear the dead assignment ownership so a re-claim starts clean and the
|
|
1395
|
+
// assigned-counters (which filter status==='assigned') stop counting this row.
|
|
1396
|
+
delete entry.assignedNodeId;
|
|
1397
|
+
delete entry.assignedSessionId;
|
|
1398
|
+
delete entry.assignedProviderType;
|
|
1399
|
+
delete entry.dispatchTimestamp;
|
|
1400
|
+
// REDRIVE-DUP: bump the dispatch nonce so the ORIGINAL inject to prevNode/prevSession
|
|
1401
|
+
// (which is delivered-but-unconsumed and about to be re-dispatched elsewhere) now
|
|
1402
|
+
// carries a stale nonce. When that stranded inject finally fires and the worker emits
|
|
1403
|
+
// agent:generating_started echoing the old nonce, the coordinator's stale-nonce guard
|
|
1404
|
+
// rejects the ack and stops that worker — so the reclaimed+re-dispatched task is never
|
|
1405
|
+
// executed by the originally-assigned session (no duplicate execution).
|
|
1406
|
+
entry.dispatchNonce = (entry.dispatchNonce || 0) + 1;
|
|
1407
|
+
entry.strandedReclaimCount = reclaims;
|
|
1408
|
+
entry.updatedAt = now;
|
|
1409
|
+
// The stranded assignment is being torn down (→ pending or failed); end its
|
|
1410
|
+
// single-flight window so a re-claim/requeue is not blocked.
|
|
1411
|
+
endTaskDispatchInFlight(meshId, taskId);
|
|
1412
|
+
let cascaded: MeshWorkQueueEntry[] = [];
|
|
1413
|
+
if (reclaims > MAX_STRANDED_RECLAIMS) {
|
|
1414
|
+
// Repeatedly undeliverable — stop cycling and fail it so dependents unblock.
|
|
1415
|
+
entry.status = 'failed';
|
|
1416
|
+
entry.cancelReason = `stranded_dispatch_unrecovered: reclaimed ${reclaims - 1} time(s) without a confirmed dispatch`;
|
|
1417
|
+
MeshRuntimeStore.getInstance().updateQueueEntry(entry);
|
|
1418
|
+
cascaded = propagateDependencyFailure(meshId, taskId);
|
|
1419
|
+
} else {
|
|
1420
|
+
entry.status = 'pending';
|
|
1421
|
+
entry.requeuedAt = now;
|
|
1422
|
+
entry.requeueReason = reason;
|
|
1423
|
+
MeshRuntimeStore.getInstance().updateQueueEntry(entry);
|
|
1424
|
+
}
|
|
1425
|
+
try {
|
|
1426
|
+
appendLedgerEntry(meshId, {
|
|
1427
|
+
kind: 'task_reclaimed' as MeshLedgerKind,
|
|
1428
|
+
nodeId: prevNode,
|
|
1429
|
+
sessionId: prevSession,
|
|
1430
|
+
payload: {
|
|
1431
|
+
taskId,
|
|
1432
|
+
reason,
|
|
1433
|
+
...(typeof opts?.ageMs === 'number' ? { ageMs: opts.ageMs } : {}),
|
|
1434
|
+
reclaimCount: reclaims,
|
|
1435
|
+
outcome: entry.status,
|
|
1436
|
+
},
|
|
1437
|
+
});
|
|
1438
|
+
} catch { /* ledger write is best-effort */ }
|
|
1439
|
+
return { entry, cascaded };
|
|
1440
|
+
});
|
|
1441
|
+
// Reclaim toggles the mission aggregate either way: → failed may make it all-terminal;
|
|
1442
|
+
// → pending resets any stale close-candidate marker. Re-check in both outcomes.
|
|
1443
|
+
if (result) scheduleMissionCloseCandidateCheck(meshId, [result.entry, ...result.cascaded]);
|
|
1444
|
+
return result ? result.entry : null;
|
|
268
1445
|
}
|
|
269
1446
|
|
|
270
1447
|
/**
|
|
@@ -274,26 +1451,87 @@ export function updateSessionTaskStatus(
|
|
|
274
1451
|
meshId: string,
|
|
275
1452
|
sessionId: string,
|
|
276
1453
|
status: MeshTaskStatus,
|
|
277
|
-
opts?: { occurredAt?: string },
|
|
1454
|
+
opts?: { occurredAt?: string; taskId?: string },
|
|
278
1455
|
): MeshWorkQueueEntry | null {
|
|
279
|
-
|
|
280
|
-
const
|
|
281
|
-
const
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
if (
|
|
1456
|
+
const result = withQueueLock(meshId, () => {
|
|
1457
|
+
const store = MeshRuntimeStore.getInstance();
|
|
1458
|
+
const occurredAtIso = opts?.occurredAt ? new Date(opts.occurredAt).toISOString() : undefined;
|
|
1459
|
+
const entry = store.findAssignedBySession(meshId, sessionId, occurredAtIso, opts?.taskId);
|
|
1460
|
+
if (!entry) {
|
|
1461
|
+
// C2: the silent null here is exactly what stranded a finished task as
|
|
1462
|
+
// `assigned` for 19 minutes. If the session still has an assigned row we
|
|
1463
|
+
// failed to resolve, surface it loudly instead of dropping the completion.
|
|
1464
|
+
const assignedRows = store.getActiveAssignmentDetails(meshId)
|
|
1465
|
+
.filter(r => sessionIdsEquivalent(r.sessionId, sessionId));
|
|
1466
|
+
if (assignedRows.length > 0) {
|
|
1467
|
+
LOG.warn('MeshQueue', `No assigned queue row matched completion for mesh ${meshId} session ${sessionId} `
|
|
1468
|
+
+ `(taskId=${opts?.taskId ?? 'none'}, occurredAt=${occurredAtIso ?? 'none'}); `
|
|
1469
|
+
+ `${assignedRows.length} assigned row(s) exist: ${assignedRows.map(r => r.id).join(',')}`);
|
|
1470
|
+
}
|
|
1471
|
+
return null;
|
|
290
1472
|
}
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
1473
|
+
entry.status = status;
|
|
1474
|
+
store.updateQueueEntry(entry);
|
|
1475
|
+
// The worker reported a terminal/non-assigned outcome — the dispatch is over;
|
|
1476
|
+
// release the single-flight mark so the task id can be re-dispatched later.
|
|
1477
|
+
if (status !== 'assigned') endTaskDispatchInFlight(meshId, entry.id);
|
|
1478
|
+
const cascaded = DEPENDENCY_FAILURE_TERMINALS.has(status) ? propagateDependencyFailure(meshId, entry.id) : [];
|
|
1479
|
+
return { entry, cascaded };
|
|
296
1480
|
});
|
|
1481
|
+
if (result) scheduleMissionCloseCandidateCheck(meshId, [result.entry, ...result.cascaded]);
|
|
1482
|
+
return result ? result.entry : null;
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
/**
|
|
1486
|
+
* M1-3: true when at least one pending task is waiting on the given task.
|
|
1487
|
+
* Used by the completion event path to decide whether to wake the queue.
|
|
1488
|
+
*/
|
|
1489
|
+
export function hasPendingDependents(meshId: string, taskId: string): boolean {
|
|
1490
|
+
return MeshRuntimeStore.getInstance().getQueueEntries(meshId, ['pending'])
|
|
1491
|
+
.some(entry => Array.isArray(entry.dependsOn) && entry.dependsOn.includes(taskId));
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
/**
|
|
1495
|
+
* M1: THE single dependency-gate predicate. A task is claimable from a
|
|
1496
|
+
* dependency standpoint iff it carries no system block (`blockedReason`) AND
|
|
1497
|
+
* every id in `dependsOn` has reached 'completed'.
|
|
1498
|
+
*
|
|
1499
|
+
* DEPENDSON-GATE-SYMMETRY: every scheduler surface that decides whether a
|
|
1500
|
+
* pending task may run MUST route through this one predicate — the queue claim
|
|
1501
|
+
* (claimNextQueueTask), the auto-launch candidate filter
|
|
1502
|
+
* (maybeAutoLaunchOneQueueSession), and the cloud eager P2P push
|
|
1503
|
+
* (enqueue-and-push). If any surface computes dependency readiness on its own,
|
|
1504
|
+
* the gate goes asymmetric and a task blocked from the pull path can still be
|
|
1505
|
+
* eager-pushed straight to an idle session, silently bypassing its
|
|
1506
|
+
* prerequisites. The semantics here (all deps completed && !blocked) are the
|
|
1507
|
+
* invariant — do not fork them.
|
|
1508
|
+
*/
|
|
1509
|
+
export function taskDependenciesSatisfied(
|
|
1510
|
+
entry: Pick<MeshWorkQueueEntry, 'dependsOn' | 'blockedReason'>,
|
|
1511
|
+
statusById: Map<string, MeshTaskStatus | string>,
|
|
1512
|
+
): boolean {
|
|
1513
|
+
if (entry.blockedReason) return false;
|
|
1514
|
+
const deps = Array.isArray(entry.dependsOn) ? entry.dependsOn : [];
|
|
1515
|
+
return deps.every(depId => statusById.get(depId) === 'completed');
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
/**
|
|
1519
|
+
* M1-4: view-time dependency state for a task — unmet dependency ids and
|
|
1520
|
+
* whether the task is currently claimable from a dependency standpoint.
|
|
1521
|
+
* Not stored (truth stays in task statuses). The `dependenciesSatisfied` field
|
|
1522
|
+
* is derived from {@link taskDependenciesSatisfied} so the view and the
|
|
1523
|
+
* scheduler gates can never disagree.
|
|
1524
|
+
*/
|
|
1525
|
+
export function describeTaskDependencyState(
|
|
1526
|
+
entry: Pick<MeshWorkQueueEntry, 'dependsOn' | 'blockedReason'>,
|
|
1527
|
+
statusById: Map<string, MeshTaskStatus | string>,
|
|
1528
|
+
): { waitingOn: string[]; dependenciesSatisfied: boolean } {
|
|
1529
|
+
const deps = Array.isArray(entry.dependsOn) ? entry.dependsOn : [];
|
|
1530
|
+
const waitingOn = deps.filter(depId => statusById.get(depId) !== 'completed');
|
|
1531
|
+
return {
|
|
1532
|
+
waitingOn,
|
|
1533
|
+
dependenciesSatisfied: taskDependenciesSatisfied(entry, statusById),
|
|
1534
|
+
};
|
|
297
1535
|
}
|
|
298
1536
|
|
|
299
1537
|
export interface MeshWorkQueueStats {
|
|
@@ -321,14 +1559,16 @@ export interface MeshWorkQueueStats {
|
|
|
321
1559
|
* Return aggregate queue statistics for the given mesh.
|
|
322
1560
|
*/
|
|
323
1561
|
export function getMeshQueueStats(meshId: string): MeshWorkQueueStats {
|
|
324
|
-
const
|
|
325
|
-
const
|
|
326
|
-
const
|
|
327
|
-
const
|
|
328
|
-
const
|
|
329
|
-
const
|
|
1562
|
+
const rows = MeshRuntimeStore.getInstance().getQueueStatsByStatus(meshId);
|
|
1563
|
+
const counts: Record<string, number> = {};
|
|
1564
|
+
for (const r of rows) counts[r.status] = r.count;
|
|
1565
|
+
const pending = counts['pending'] ?? 0;
|
|
1566
|
+
const assigned = counts['assigned'] ?? 0;
|
|
1567
|
+
const completed = counts['completed'] ?? 0;
|
|
1568
|
+
const failed = counts['failed'] ?? 0;
|
|
1569
|
+
const cancelled = counts['cancelled'] ?? 0;
|
|
330
1570
|
return {
|
|
331
|
-
total:
|
|
1571
|
+
total: pending + assigned + completed + failed + cancelled,
|
|
332
1572
|
active: pending + assigned,
|
|
333
1573
|
historical: completed + failed + cancelled,
|
|
334
1574
|
pending,
|
|
@@ -336,22 +1576,126 @@ export function getMeshQueueStats(meshId: string): MeshWorkQueueStats {
|
|
|
336
1576
|
completed,
|
|
337
1577
|
failed,
|
|
338
1578
|
cancelled,
|
|
339
|
-
activeCounts: {
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
},
|
|
343
|
-
historicalCounts: {
|
|
344
|
-
completed,
|
|
345
|
-
failed,
|
|
346
|
-
cancelled,
|
|
347
|
-
},
|
|
348
|
-
activeAssignments: queue
|
|
349
|
-
.filter(q => q.status === 'assigned')
|
|
350
|
-
.map(q => ({
|
|
351
|
-
id: q.id,
|
|
352
|
-
nodeId: q.assignedNodeId,
|
|
353
|
-
sessionId: q.assignedSessionId,
|
|
354
|
-
message: q.message,
|
|
355
|
-
})),
|
|
1579
|
+
activeCounts: { pending, assigned },
|
|
1580
|
+
historicalCounts: { completed, failed, cancelled },
|
|
1581
|
+
activeAssignments: MeshRuntimeStore.getInstance().getActiveAssignmentDetails(meshId),
|
|
356
1582
|
};
|
|
357
1583
|
}
|
|
1584
|
+
|
|
1585
|
+
export function __replaceMeshQueueForTests(meshId: string, queue: MeshWorkQueueEntry[]): void {
|
|
1586
|
+
MeshRuntimeStore.getInstance().transaction(() => {
|
|
1587
|
+
MeshRuntimeStore.getInstance().replaceQueue(meshId, queue);
|
|
1588
|
+
});
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
export function __clearMeshQueueForTests(meshId: string): void {
|
|
1592
|
+
MeshRuntimeStore.getInstance().deleteQueue(meshId);
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
export function __clearDirectDispatchesForTests(meshId: string): void {
|
|
1596
|
+
MeshRuntimeStore.getInstance().deleteDirectDispatches(meshId);
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
export function __resetMeshRuntimeStoreForTests(): void {
|
|
1600
|
+
MeshRuntimeStore.resetForTests();
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
// ── Direct Dispatch Tracking ─────────────────────────────────────────────────
|
|
1604
|
+
// Persists direct (non-queue) task dispatches so buildMeshActiveWork can read
|
|
1605
|
+
// active work from MeshRuntimeStore instead of scanning ledger JSONL entries.
|
|
1606
|
+
|
|
1607
|
+
export type DirectDispatchRecord = ReturnType<MeshRuntimeStore['getActiveDirectDispatches']>[number];
|
|
1608
|
+
|
|
1609
|
+
export function insertDirectDispatch(
|
|
1610
|
+
meshId: string,
|
|
1611
|
+
data: {
|
|
1612
|
+
taskId: string;
|
|
1613
|
+
nodeId?: string;
|
|
1614
|
+
sessionId?: string;
|
|
1615
|
+
providerType?: string;
|
|
1616
|
+
message: string;
|
|
1617
|
+
taskMode?: string;
|
|
1618
|
+
via: string;
|
|
1619
|
+
dispatchedToIdleSession?: boolean;
|
|
1620
|
+
dispatchedAt: string;
|
|
1621
|
+
},
|
|
1622
|
+
): void {
|
|
1623
|
+
try {
|
|
1624
|
+
MeshRuntimeStore.getInstance().insertDirectDispatch({ ...data, meshId });
|
|
1625
|
+
} catch (e: any) {
|
|
1626
|
+
process.stderr.write(`[adhdev-mesh] insertDirectDispatch failed for task ${data.taskId}: ${e?.message || e}\n`);
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
export function getActiveDirectDispatches(meshId: string): DirectDispatchRecord[] {
|
|
1631
|
+
try {
|
|
1632
|
+
return MeshRuntimeStore.getInstance().getActiveDirectDispatches(meshId);
|
|
1633
|
+
} catch {
|
|
1634
|
+
return [];
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
export function updateDirectDispatchStatus(
|
|
1639
|
+
meshId: string,
|
|
1640
|
+
sessionId: string,
|
|
1641
|
+
status: 'acked' | 'completed' | 'failed' | 'stale',
|
|
1642
|
+
taskId?: string,
|
|
1643
|
+
): void {
|
|
1644
|
+
try {
|
|
1645
|
+
// CANON-B: prefer the exact task_id row; fall back to the session_id match only when
|
|
1646
|
+
// the firing event carried no taskId (a legacy/relayed event). Warn on the fallback so
|
|
1647
|
+
// the residual PK-substitute path is observable when it strands a sibling dispatch.
|
|
1648
|
+
if (!taskId) {
|
|
1649
|
+
LOG.warn('MeshQueue', `updateDirectDispatchStatus(${status}) for mesh ${meshId} session ${sessionId} has no taskId — falling back to session_id match (may flip a sibling dispatch row)`);
|
|
1650
|
+
}
|
|
1651
|
+
MeshRuntimeStore.getInstance().updateDirectDispatchStatus(meshId, sessionId, status, taskId);
|
|
1652
|
+
} catch { /* best-effort */ }
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
export function cleanupTerminalDirectDispatches(olderThanMs = 7 * 24 * 60 * 60_000): void {
|
|
1656
|
+
try {
|
|
1657
|
+
MeshRuntimeStore.getInstance().cleanupTerminalDirectDispatches(olderThanMs);
|
|
1658
|
+
} catch { /* best-effort */ }
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
export function markStaleDirectDispatches(meshId: string, olderThanMs = 60 * 60_000): void {
|
|
1662
|
+
try {
|
|
1663
|
+
MeshRuntimeStore.getInstance().markStaleDirectDispatches(meshId, olderThanMs);
|
|
1664
|
+
} catch { /* best-effort */ }
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
/**
|
|
1668
|
+
* Delete specific direct dispatch rows by taskId. Returns the number of rows deleted.
|
|
1669
|
+
* Used by the staleDirect prune path to evict orphaned/terminal dispatch records from the
|
|
1670
|
+
* active staleDirect surface while leaving the append-only mesh ledger (audit history) intact.
|
|
1671
|
+
*/
|
|
1672
|
+
export function deleteDirectDispatchesByTaskId(meshId: string, taskIds: string[]): number {
|
|
1673
|
+
try {
|
|
1674
|
+
return MeshRuntimeStore.getInstance().deleteDirectDispatchesByTaskId(meshId, taskIds);
|
|
1675
|
+
} catch {
|
|
1676
|
+
return 0;
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
export type MeshToolCallRateResult = { rateLimitExceeded: boolean; callsInWindow: number; advisory: string | null };
|
|
1681
|
+
|
|
1682
|
+
/**
|
|
1683
|
+
* Record a coordinator tool call and return a rate-limit advisory when the
|
|
1684
|
+
* call rate for that tool exceeds the allowed threshold.
|
|
1685
|
+
*
|
|
1686
|
+
* Defaults: 10-second sliding window, max 5 calls before advisory is raised.
|
|
1687
|
+
* Returns { rateLimitExceeded: false } on any store error so callers are not blocked.
|
|
1688
|
+
*/
|
|
1689
|
+
export function recordMeshToolCall(opts: {
|
|
1690
|
+
meshId: string;
|
|
1691
|
+
tool: string;
|
|
1692
|
+
sessionId?: string | null;
|
|
1693
|
+
windowMs?: number;
|
|
1694
|
+
maxCalls?: number;
|
|
1695
|
+
}): MeshToolCallRateResult {
|
|
1696
|
+
try {
|
|
1697
|
+
return MeshRuntimeStore.getInstance().recordMeshToolCall(opts);
|
|
1698
|
+
} catch {
|
|
1699
|
+
return { rateLimitExceeded: false, callsInWindow: 0, advisory: null };
|
|
1700
|
+
}
|
|
1701
|
+
}
|