create-walle 0.10.2 → 0.10.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/lib/release-schema.json +2 -2
- package/lib/runtime-closure-policy.json +8 -7
- package/package.json +1 -1
- package/template/coding-task-manager/api-prompts.js +45 -7
- package/template/coding-task-manager/bin/claude-hook.js +55 -1
- package/template/coding-task-manager/bin/ctm-relay-client.js +35 -12
- package/template/coding-task-manager/bin/land.js +2069 -0
- package/template/coding-task-manager/bin/memory-census.js +4 -2
- package/template/coding-task-manager/bin/memory-sync-verify.js +151 -0
- package/template/coding-task-manager/bin/probe-refusal-scope.js +530 -0
- package/template/coding-task-manager/db.js +156 -32
- package/template/coding-task-manager/lib/acp/acp-client.js +383 -0
- package/template/coding-task-manager/lib/acp/dsh-context-truth.js +176 -0
- package/template/coding-task-manager/lib/acp/dsh-launch-model.js +302 -0
- package/template/coding-task-manager/lib/acp/dsh-mcp-servers.js +194 -0
- package/template/coding-task-manager/lib/acp/dsh-prompt-lane.js +616 -0
- package/template/coding-task-manager/lib/acp/dsh-route-patch.js +308 -0
- package/template/coding-task-manager/lib/acp/dsh-session-runtime.js +522 -0
- package/template/coding-task-manager/lib/acp/dsh-transcript.js +395 -0
- package/template/coding-task-manager/lib/acp/dsh-usage-log.js +162 -0
- package/template/coding-task-manager/lib/acp/ndjson-stdio-transport.js +225 -0
- package/template/coding-task-manager/lib/adopted-host-redial.js +47 -11
- package/template/coding-task-manager/lib/adopted-launch-environment.js +123 -0
- package/template/coding-task-manager/lib/agent-auto-update-runtime.js +0 -3
- package/template/coding-task-manager/lib/agent-auto-update.js +26 -10
- package/template/coding-task-manager/lib/agent-bundle/build.js +657 -0
- package/template/coding-task-manager/lib/agent-bundle/codex-install.js +558 -0
- package/template/coding-task-manager/lib/agent-bundle/drift.js +120 -0
- package/template/coding-task-manager/lib/agent-bundle/gc.js +522 -0
- package/template/coding-task-manager/lib/agent-bundle/inventory.js +346 -0
- package/template/coding-task-manager/lib/agent-bundle/push.js +717 -0
- package/template/coding-task-manager/lib/agent-bundle/skill-transfer.js +724 -0
- package/template/coding-task-manager/lib/agent-bundle/spawn.js +119 -0
- package/template/coding-task-manager/lib/agent-bundle/store.js +568 -0
- package/template/coding-task-manager/lib/agent-capabilities.js +142 -12
- package/template/coding-task-manager/lib/agent-cli-cache.js +30 -5
- package/template/coding-task-manager/lib/agent-definitions.js +19 -0
- package/template/coding-task-manager/lib/agent-hooks-codex-toml.js +28 -16
- package/template/coding-task-manager/lib/agent-hooks-codex-workspace.js +9 -1
- package/template/coding-task-manager/lib/agent-hooks-installer.js +23 -8
- package/template/coding-task-manager/lib/agent-hooks-prune.js +37 -11
- package/template/coding-task-manager/lib/agent-hooks-shim.js +105 -0
- package/template/coding-task-manager/lib/agent-presets.js +84 -1
- package/template/coding-task-manager/lib/agent-upgrade-jobs.js +12 -1
- package/template/coding-task-manager/lib/agent-version-service.js +74 -11
- package/template/coding-task-manager/lib/all-sessions-query.js +6 -2
- package/template/coding-task-manager/lib/api/agent-bundles.js +436 -0
- package/template/coding-task-manager/lib/api/backup-retention.js +83 -0
- package/template/coding-task-manager/lib/api/conversation-sync-routes.js +124 -0
- package/template/coding-task-manager/lib/api/diagnostics.js +549 -69
- package/template/coding-task-manager/lib/api/fleet.js +118 -0
- package/template/coding-task-manager/lib/api/hub-bridge.js +243 -0
- package/template/coding-task-manager/lib/api/ingress.js +9 -1
- package/template/coding-task-manager/lib/api/landing.js +403 -0
- package/template/coding-task-manager/lib/api/machines.js +691 -2
- package/template/coding-task-manager/lib/api/memory-sync-routes.js +335 -0
- package/template/coding-task-manager/lib/api/misc-ops.js +8 -3
- package/template/coding-task-manager/lib/api/remote-sessions.js +1326 -0
- package/template/coding-task-manager/lib/api/session-attach.js +623 -0
- package/template/coding-task-manager/lib/api/session-messages.js +1384 -0
- package/template/coding-task-manager/lib/api/sessions.js +77 -51
- package/template/coding-task-manager/lib/api/setup-network.js +544 -16
- package/template/coding-task-manager/lib/api/setup-wizard-handlers.js +884 -0
- package/template/coding-task-manager/lib/api/setup.js +829 -10
- package/template/coding-task-manager/lib/api/walle-bridge.js +9 -1
- package/template/coding-task-manager/lib/attachment-reconciler-wiring.js +5 -2
- package/template/coding-task-manager/lib/auth-rate-limit.js +23 -0
- package/template/coding-task-manager/lib/auth-rules.js +292 -21
- package/template/coding-task-manager/lib/background-stop.js +369 -0
- package/template/coding-task-manager/lib/best-of-attempts.js +4 -1
- package/template/coding-task-manager/lib/best-of-launch-plan.js +52 -0
- package/template/coding-task-manager/lib/box-keep-awake.js +89 -0
- package/template/coding-task-manager/lib/broadcast-reason-stats.js +152 -0
- package/template/coding-task-manager/lib/broadcast-serialize.js +5 -0
- package/template/coding-task-manager/lib/build-info.js +4 -2
- package/template/coding-task-manager/lib/claude-model-truth.js +13 -2
- package/template/coding-task-manager/lib/claude-transcript-terminal-renderer.js +17 -13
- package/template/coding-task-manager/lib/client-diag-handler.js +64 -2
- package/template/coding-task-manager/lib/codex-app-server-client.js +14 -16
- package/template/coding-task-manager/lib/codex-configuration-history.js +55 -0
- package/template/coding-task-manager/lib/codex-harness-qualification.js +43 -20
- package/template/coding-task-manager/lib/codex-rollout-history.js +743 -0
- package/template/coding-task-manager/lib/coding-agent-models.js +27 -12
- package/template/coding-task-manager/lib/coding-cli-skill-installer.js +44 -14
- package/template/coding-task-manager/lib/coding-session-model-adapters.js +43 -2
- package/template/coding-task-manager/lib/coding-session-model-owner.js +1 -1
- package/template/coding-task-manager/lib/coding-session-model-public.js +7 -1
- package/template/coding-task-manager/lib/coding-session-model-runtime.js +23 -4
- package/template/coding-task-manager/lib/coding-session-model-scan.js +31 -0
- package/template/coding-task-manager/lib/coding-session-model-state.js +7 -0
- package/template/coding-task-manager/lib/conversation-freshness-diagnostics.js +280 -0
- package/template/coding-task-manager/lib/conversation-item-clock.js +261 -0
- package/template/coding-task-manager/lib/conversation-source-registry.js +5 -0
- package/template/coding-task-manager/lib/conversation-store-v2.js +44 -0
- package/template/coding-task-manager/lib/conversation-sync.js +355 -0
- package/template/coding-task-manager/lib/create/resolve-launch-params.js +96 -12
- package/template/coding-task-manager/lib/create/worker-role-profile.js +4 -4
- package/template/coding-task-manager/lib/create-intent-lease.js +51 -0
- package/template/coding-task-manager/lib/ctm-session-context-api.js +97 -6
- package/template/coding-task-manager/lib/db-domain-tables.js +1 -0
- package/template/coding-task-manager/lib/db-domains.js +1 -0
- package/template/coding-task-manager/lib/db-owner-dispatch-probe.js +32 -0
- package/template/coding-task-manager/lib/db-owner-request-counts.js +178 -0
- package/template/coding-task-manager/lib/db-owner-worker-client.js +68 -14
- package/template/coding-task-manager/lib/db-schema-source-files.js +5 -0
- package/template/coding-task-manager/lib/deferred-restore-attempt.js +6 -2
- package/template/coding-task-manager/lib/feature-manifest.js +4 -0
- package/template/coding-task-manager/lib/fleet-conversation-route.js +97 -0
- package/template/coding-task-manager/lib/fleet-fanout.js +78 -0
- package/template/coding-task-manager/lib/fleet-hub.js +762 -0
- package/template/coding-task-manager/lib/fleet-origin-follower.js +252 -0
- package/template/coding-task-manager/lib/fleet-resolver-effects.js +147 -0
- package/template/coding-task-manager/lib/fleet-resolver-tick.js +148 -0
- package/template/coding-task-manager/lib/fleet-resolver.js +454 -0
- package/template/coding-task-manager/lib/fleet-session-list-routes.js +114 -0
- package/template/coding-task-manager/lib/fleet-session-lists.js +672 -0
- package/template/coding-task-manager/lib/fleet-update.js +798 -0
- package/template/coding-task-manager/lib/fs-watcher-root-watch.js +141 -0
- package/template/coding-task-manager/lib/fs-watcher.js +58 -42
- package/template/coding-task-manager/lib/harness-history-transition.js +50 -9
- package/template/coding-task-manager/lib/headless-term-service.js +84 -8
- package/template/coding-task-manager/lib/hermetic-agent-launch-guard.js +10 -0
- package/template/coding-task-manager/lib/history-transition-read-op.js +175 -8
- package/template/coding-task-manager/lib/host-cpu-categories.js +310 -0
- package/template/coding-task-manager/lib/host-fseventsd-sampler.js +399 -0
- package/template/coding-task-manager/lib/host-handoff.js +16 -19
- package/template/coding-task-manager/lib/host-stats.js +150 -3
- package/template/coding-task-manager/lib/hosted-sync-worker.js +43 -320
- package/template/coding-task-manager/lib/hub-bridge-access.js +798 -0
- package/template/coding-task-manager/lib/hub-bridge-box.js +409 -0
- package/template/coding-task-manager/lib/hub-bridge-mcp.js +380 -0
- package/template/coding-task-manager/lib/hub-bridge-mesh.js +313 -0
- package/template/coding-task-manager/lib/hub-bridge-ops.js +721 -0
- package/template/coding-task-manager/lib/hub-bridge.js +398 -0
- package/template/coding-task-manager/lib/idle-notify.js +491 -0
- package/template/coding-task-manager/lib/instance-id-ownership-manifest.js +8 -0
- package/template/coding-task-manager/lib/instance-manifest.js +5 -1
- package/template/coding-task-manager/lib/instance-sync-status.js +18 -8
- package/template/coding-task-manager/lib/landing/agent-rule.txt +1 -0
- package/template/coding-task-manager/lib/landing/batch-runner.js +366 -0
- package/template/coding-task-manager/lib/landing/config.js +536 -0
- package/template/coding-task-manager/lib/landing/ctm-view.js +256 -0
- package/template/coding-task-manager/lib/landing/gate.js +360 -0
- package/template/coding-task-manager/lib/landing/git.js +344 -0
- package/template/coding-task-manager/lib/landing/lease.js +247 -0
- package/template/coding-task-manager/lib/landing/merge-delta.js +380 -0
- package/template/coding-task-manager/lib/landing/proof.js +223 -0
- package/template/coding-task-manager/lib/landing/queue.js +998 -0
- package/template/coding-task-manager/lib/landing/radar.js +489 -0
- package/template/coding-task-manager/lib/landing/resolve-cli.js +424 -0
- package/template/coding-task-manager/lib/landing/state.js +263 -0
- package/template/coding-task-manager/lib/landing/stop.js +49 -0
- package/template/coding-task-manager/lib/landing/sync-cli.js +475 -0
- package/template/coding-task-manager/lib/landing/sync.js +456 -0
- package/template/coding-task-manager/lib/landing/train.js +885 -0
- package/template/coding-task-manager/lib/landing/window.js +57 -0
- package/template/coding-task-manager/lib/launch-environment.js +121 -0
- package/template/coding-task-manager/lib/launch-model-override.js +36 -10
- package/template/coding-task-manager/lib/launch-plan-pool.js +2 -1
- package/template/coding-task-manager/lib/launch-plan-warm-cache.js +6 -0
- package/template/coding-task-manager/lib/lead-model-observer.js +57 -1
- package/template/coding-task-manager/lib/lead-model-options.js +160 -3
- package/template/coding-task-manager/lib/lead-model-restore-reconciliation.js +106 -24
- package/template/coding-task-manager/lib/lead-model-switch-runtime.js +352 -68
- package/template/coding-task-manager/lib/lead-model-switch.js +114 -10
- package/template/coding-task-manager/lib/lead-model-transition-runtime.js +14 -2
- package/template/coding-task-manager/lib/lead-model-transition-store.js +24 -6
- package/template/coding-task-manager/lib/lead-route-preflight.js +179 -0
- package/template/coding-task-manager/lib/lead-switch-history-mode.js +110 -0
- package/template/coding-task-manager/lib/lead-wire-agent.js +7 -0
- package/template/coding-task-manager/lib/lifecycle-wake-experiment.js +9 -2
- package/template/coding-task-manager/lib/live-defer-announce.js +45 -0
- package/template/coding-task-manager/lib/machine-registry.js +750 -5
- package/template/coding-task-manager/lib/macos-capabilities.js +32 -15
- package/template/coding-task-manager/lib/main-thread-lint-exemptions.json +433 -53
- package/template/coding-task-manager/lib/mainloop-observability.js +14 -9
- package/template/coding-task-manager/lib/media-draft-owner.js +16 -3
- package/template/coding-task-manager/lib/memory-join.js +337 -0
- package/template/coding-task-manager/lib/memory-sync-verify.js +393 -0
- package/template/coding-task-manager/lib/memory-sync.js +565 -0
- package/template/coding-task-manager/lib/migrations/v57-delegate-pref-mirror-opencode.js +68 -0
- package/template/coding-task-manager/lib/migrations/v58-subagent-launch-native-subagents.js +42 -0
- package/template/coding-task-manager/lib/migrations/v59-coding-session-model-state-dsh.js +73 -0
- package/template/coding-task-manager/lib/migrations/v60-delegate-pref-mirror-dsh.js +78 -0
- package/template/coding-task-manager/lib/migrations/v61-conversation-item-clock.js +71 -0
- package/template/coding-task-manager/lib/model-availability.js +727 -0
- package/template/coding-task-manager/lib/model-execution-recipe-metadata.js +4 -6
- package/template/coding-task-manager/lib/model-host-receipt.js +6 -3
- package/template/coding-task-manager/lib/model-id-rule.js +19 -0
- package/template/coding-task-manager/lib/model-proxy/anthropic-face.js +4 -0
- package/template/coding-task-manager/lib/model-proxy/auto-mode-review.js +110 -23
- package/template/coding-task-manager/lib/model-proxy/chat-response-translators.js +3 -0
- package/template/coding-task-manager/lib/model-proxy/generation-probe.js +60 -17
- package/template/coding-task-manager/lib/model-proxy/historical-tool-declarations.js +37 -0
- package/template/coding-task-manager/lib/model-proxy/lead-request-validation.js +1 -0
- package/template/coding-task-manager/lib/model-proxy/lead-token-admin.js +130 -0
- package/template/coding-task-manager/lib/model-proxy/refusal-scope.js +151 -0
- package/template/coding-task-manager/lib/model-proxy/request-budget-store.js +11 -2
- package/template/coding-task-manager/lib/model-proxy/tokens.js +69 -5
- package/template/coding-task-manager/lib/model-proxy/upstream-outcome.js +9 -0
- package/template/coding-task-manager/lib/native-agent-model-args.js +25 -22
- package/template/coding-task-manager/lib/native-history-control.js +6 -4
- package/template/coding-task-manager/lib/native-lead-settings-client.js +44 -4
- package/template/coding-task-manager/lib/oauth-proxy-supervisor.js +173 -36
- package/template/coding-task-manager/lib/online-backup-worker-client.js +2 -0
- package/template/coding-task-manager/lib/opencode-cli.js +480 -0
- package/template/coding-task-manager/lib/opencode-launch-model.js +471 -0
- package/template/coding-task-manager/lib/opencode-v1-plugin/model-carrier.mjs +35 -0
- package/template/coding-task-manager/lib/opencode-v2-plugin/server.js +118 -0
- package/template/coding-task-manager/lib/owner-action-manifest-data.json +1 -1
- package/template/coding-task-manager/lib/park-composer-draft.js +54 -0
- package/template/coding-task-manager/lib/park-fresh-relaunch.js +41 -10
- package/template/coding-task-manager/lib/perf-phase-trace.js +72 -0
- package/template/coding-task-manager/lib/perf-summary.js +7 -0
- package/template/coding-task-manager/lib/perf-tracker.js +63 -2
- package/template/coding-task-manager/lib/project-config.js +7 -0
- package/template/coding-task-manager/lib/prompt-purge-v2.js +52 -1
- package/template/coding-task-manager/lib/provider-login-broker.js +72 -15
- package/template/coding-task-manager/lib/provider-login-tab.js +10 -2
- package/template/coding-task-manager/lib/read-pool-client.js +89 -2
- package/template/coding-task-manager/lib/read-pool-worker-handle.js +29 -4
- package/template/coding-task-manager/lib/recent-catalog-query.js +15 -0
- package/template/coding-task-manager/lib/recent-session-catalog-response.js +67 -10
- package/template/coding-task-manager/lib/recovery-evidence.js +26 -11
- package/template/coding-task-manager/lib/relay-client.js +57 -3
- package/template/coding-task-manager/lib/relink-audit.js +34 -5
- package/template/coding-task-manager/lib/remote-machine-catalog.js +321 -26
- package/template/coding-task-manager/lib/remote-machine-create.js +576 -36
- package/template/coding-task-manager/lib/remote-machine-link.js +546 -59
- package/template/coding-task-manager/lib/remote-machine-proxy.js +143 -9
- package/template/coding-task-manager/lib/remote-session-forward.js +65 -7
- package/template/coding-task-manager/lib/remote-terminal-target.js +9 -2
- package/template/coding-task-manager/lib/restart-guard.js +18 -6
- package/template/coding-task-manager/lib/restore-seed-modes.js +87 -0
- package/template/coding-task-manager/lib/restored-scrollback-hydration-runtime.js +22 -2
- package/template/coding-task-manager/lib/resume-admission.js +17 -3
- package/template/coding-task-manager/lib/review-repo-registry.js +58 -19
- package/template/coding-task-manager/lib/root-delivery-runtime.js +2 -2
- package/template/coding-task-manager/lib/root-prompt-flow.js +1 -1
- package/template/coding-task-manager/lib/runtime-contract.js +3 -1
- package/template/coding-task-manager/lib/schema-indexes.js +1 -0
- package/template/coding-task-manager/lib/session-context-locator.js +742 -0
- package/template/coding-task-manager/lib/session-create/attachment.js +2 -2
- package/template/coding-task-manager/lib/session-create/create-phases.js +118 -0
- package/template/coding-task-manager/lib/session-create/delegate-worker.js +86 -16
- package/template/coding-task-manager/lib/session-create/dsh-session.js +930 -0
- package/template/coding-task-manager/lib/session-create/lead-route-launch.js +186 -13
- package/template/coding-task-manager/lib/session-create/native-lead-lifetime.js +55 -3
- package/template/coding-task-manager/lib/session-create/opencode-lead-provider.js +50 -0
- package/template/coding-task-manager/lib/session-create/prepared.js +162 -38
- package/template/coding-task-manager/lib/session-create/provider-process.js +160 -28
- package/template/coding-task-manager/lib/session-create/response.js +7 -7
- package/template/coding-task-manager/lib/session-create/structured-session.js +27 -0
- package/template/coding-task-manager/lib/session-create/subagent-pick-table.js +3 -1
- package/template/coding-task-manager/lib/session-create/subagent-pick.js +458 -9
- package/template/coding-task-manager/lib/session-create/transcript.js +1 -1
- package/template/coding-task-manager/lib/session-create/worker-budget-setup.js +21 -2
- package/template/coding-task-manager/lib/session-create/worker-continuation.js +7 -1
- package/template/coding-task-manager/lib/session-create/worker-launch-sandbox.js +39 -10
- package/template/coding-task-manager/lib/session-create/worker-observability.js +2 -2
- package/template/coding-task-manager/lib/session-empty.js +13 -10
- package/template/coding-task-manager/lib/session-host-manager.js +60 -0
- package/template/coding-task-manager/lib/session-jobs.js +3 -1
- package/template/coding-task-manager/lib/session-lifecycle.js +10 -4
- package/template/coding-task-manager/lib/session-model-action-preconditions.js +6 -2
- package/template/coding-task-manager/lib/session-model-intent-contract.js +11 -8
- package/template/coding-task-manager/lib/session-park.js +738 -0
- package/template/coding-task-manager/lib/session-path-grant.js +13 -4
- package/template/coding-task-manager/lib/session-status-ledger.js +106 -4
- package/template/coding-task-manager/lib/session-transport.js +17 -0
- package/template/coding-task-manager/lib/session-workspace-projection.js +54 -5
- package/template/coding-task-manager/lib/session-workspace-tracking-runtime.js +213 -1
- package/template/coding-task-manager/lib/skill-autocomplete-query.js +120 -3
- package/template/coding-task-manager/lib/skill-autocomplete.js +15 -1
- package/template/coding-task-manager/lib/sqlite-file-set.js +39 -0
- package/template/coding-task-manager/lib/standup-search-adapter.js +16 -2
- package/template/coding-task-manager/lib/state-sync/client-ledger.js +12 -0
- package/template/coding-task-manager/lib/state-sync/frame-emitter.js +319 -100
- package/template/coding-task-manager/lib/storage/coding-session-model-store.js +4 -1
- package/template/coding-task-manager/lib/storage/delegate-pref-mirror-store.js +32 -9
- package/template/coding-task-manager/lib/structured-session-boot-sweep.js +235 -0
- package/template/coding-task-manager/lib/submission-ack.js +90 -2
- package/template/coding-task-manager/lib/systemd-scope.js +115 -0
- package/template/coding-task-manager/lib/telemetry-receiver.js +39 -0
- package/template/coding-task-manager/lib/terminal-attach-geometry.js +985 -0
- package/template/coding-task-manager/lib/terminal-bootstrap.js +30 -0
- package/template/coding-task-manager/lib/terminal-draft-operation.js +357 -0
- package/template/coding-task-manager/lib/terminal-input-admission.js +17 -1
- package/template/coding-task-manager/lib/terminal-input-prepared.js +563 -0
- package/template/coding-task-manager/lib/transcript-cache-temps.js +171 -0
- package/template/coding-task-manager/lib/transcript-frame-cache.js +8 -3
- package/template/coding-task-manager/lib/transcript-source.js +36 -4
- package/template/coding-task-manager/lib/transcript-store.js +9 -0
- package/template/coding-task-manager/lib/typing-interaction-trace.js +106 -3
- package/template/coding-task-manager/lib/usage-ledger/backfill.js +40 -5
- package/template/coding-task-manager/lib/usage-ledger/client.js +4 -1
- package/template/coding-task-manager/lib/usage-ledger/dsh-parse.js +152 -0
- package/template/coding-task-manager/lib/usage-ledger/dsh-read.js +73 -0
- package/template/coding-task-manager/lib/usage-ledger/ingest.js +34 -6
- package/template/coding-task-manager/lib/usage-ledger/live.js +35 -8
- package/template/coding-task-manager/lib/usage-ledger/read.js +29 -20
- package/template/coding-task-manager/lib/usage-ledger/schema.js +61 -1
- package/template/coding-task-manager/lib/usage-ledger/task-coordinator.js +4 -2
- package/template/coding-task-manager/lib/wake-skip-reasons.js +1 -0
- package/template/coding-task-manager/lib/walle/turn.js +1 -1
- package/template/coding-task-manager/lib/walle-history-serve.js +838 -0
- package/template/coding-task-manager/lib/walle-kill-authority.js +2 -2
- package/template/coding-task-manager/lib/walle-log-rotation.js +164 -1
- package/template/coding-task-manager/lib/walle-mcp-auto-config.js +56 -2
- package/template/coding-task-manager/lib/walle-row-history.js +495 -0
- package/template/coding-task-manager/lib/walle-supervisor.js +33 -14
- package/template/coding-task-manager/lib/watch-worker-client.js +203 -0
- package/template/coding-task-manager/lib/worker-terminal-output.js +5 -2
- package/template/coding-task-manager/lib/ws/hub-bridge.js +35 -0
- package/template/coding-task-manager/lib/ws/lifecycle.js +6 -11
- package/template/coding-task-manager/lib/ws/misc.js +1 -1
- package/template/coding-task-manager/lib/ws/state-sync.js +106 -4
- package/template/coding-task-manager/lib/ws/terminal-attach.js +91 -0
- package/template/coding-task-manager/oauth-proxy.js +112 -115
- package/template/coding-task-manager/package-lock.json +10 -94
- package/template/coding-task-manager/package.json +4 -3
- package/template/coding-task-manager/protocol/message-table.json +10 -0
- package/template/coding-task-manager/protocol/messages.js +10 -0
- package/template/coding-task-manager/protocol/ws-schema.json +20 -0
- package/template/coding-task-manager/protocol/ws-schema.md +2 -0
- package/template/coding-task-manager/public/css/app.css +170 -0
- package/template/coding-task-manager/public/css/walle-session.css +4 -0
- package/template/coding-task-manager/public/index.html +473 -243
- package/template/coding-task-manager/public/js/agent-command.js +86 -0
- package/template/coding-task-manager/public/js/app/backups-panel.js +111 -5
- package/template/coding-task-manager/public/js/app/coding-agent-upgrade.js +103 -4
- package/template/coding-task-manager/public/js/app/machines-page.js +1328 -8
- package/template/coding-task-manager/public/js/app/models-tab.js +113 -38
- package/template/coding-task-manager/public/js/app/restart-banner.js +53 -0
- package/template/coding-task-manager/public/js/app/worktrees-panel.js +184 -2
- package/template/coding-task-manager/public/js/background-work.js +196 -27
- package/template/coding-task-manager/public/js/coding-session-model-presentation.js +138 -10
- package/template/coding-task-manager/public/js/core/active-session-groups.js +179 -0
- package/template/coding-task-manager/public/js/core/pending-create.js +238 -0
- package/template/coding-task-manager/public/js/core/recent-machine-source.js +228 -0
- package/template/coding-task-manager/public/js/core/session-commands.js +180 -14
- package/template/coding-task-manager/public/js/dashboard-client-function-order.json +8 -0
- package/template/coding-task-manager/public/js/document-review-links.js +9 -1
- package/template/coding-task-manager/public/js/file-resolution-client.js +52 -1
- package/template/coding-task-manager/public/js/history-app-seam.js +22 -2
- package/template/coding-task-manager/public/js/machine-pool-shared.js +558 -3
- package/template/coding-task-manager/public/js/message-renderer.js +4 -3
- package/template/coding-task-manager/public/js/model-picker.js +3 -1
- package/template/coding-task-manager/public/js/native-subagent-review.js +15 -2
- package/template/coding-task-manager/public/js/new-session-agent-grid.js +346 -0
- package/template/coding-task-manager/public/js/pasted-content.js +236 -0
- package/template/coding-task-manager/public/js/pe-private-content-lifetime.js +39 -7
- package/template/coding-task-manager/public/js/private-model-lifetime.js +6 -2
- package/template/coding-task-manager/public/js/prompts.js +11 -10
- package/template/coding-task-manager/public/js/protocol.js +10 -0
- package/template/coding-task-manager/public/js/restart-banner-policy.js +29 -1
- package/template/coding-task-manager/public/js/session-model-picker.js +447 -57
- package/template/coding-task-manager/public/js/session-search-utils.js +18 -2
- package/template/coding-task-manager/public/js/session-usage-view.js +2 -1
- package/template/coding-task-manager/public/js/session-workspace-tracking.js +51 -1
- package/template/coding-task-manager/public/js/state-sync-client.js +10 -1
- package/template/coding-task-manager/public/js/stream-view.js +6 -5
- package/template/coding-task-manager/public/js/terminal/conv-only-session.js +287 -0
- package/template/coding-task-manager/public/js/terminal/runtime-09.js +2 -0
- package/template/coding-task-manager/public/js/terminal/runtime-10.js +5 -5
- package/template/coding-task-manager/public/js/terminal/runtime-12.js +8 -1
- package/template/coding-task-manager/public/js/terminal/runtime-16.js +4 -0
- package/template/coding-task-manager/public/js/terminal/runtime-20.js +1 -1
- package/template/coding-task-manager/public/js/terminal-attachment-draft.js +67 -13
- package/template/coding-task-manager/public/js/terminal-image-orchestration.js +52 -6
- package/template/coding-task-manager/public/js/terminal-wheel.js +23 -3
- package/template/coding-task-manager/public/js/walle/chat-conversation.js +4 -4
- package/template/coding-task-manager/public/js/walle.js +6 -0
- package/template/coding-task-manager/public/js/ws/live-dispatch.js +10 -0
- package/template/coding-task-manager/public/m/app.css +140 -0
- package/template/coding-task-manager/public/m/app.js +507 -68
- package/template/coding-task-manager/public/m/index.html +17 -0
- package/template/coding-task-manager/public/m/machines.js +838 -4
- package/template/coding-task-manager/public/m/private-api.js +31 -1
- package/template/coding-task-manager/public/usage.html +1 -0
- package/template/coding-task-manager/scripts/dev-route-fixture/synthetic-route-upstream.js +104 -0
- package/template/coding-task-manager/scripts/dev-route-fixture.js +339 -0
- package/template/coding-task-manager/scripts/opencode-lead-route-probe.js +195 -0
- package/template/coding-task-manager/scripts/opencode-v2-contract-probe/fake-anthropic.js +168 -0
- package/template/coding-task-manager/scripts/opencode-v2-contract-probe/probe-plugin/server.js +95 -0
- package/template/coding-task-manager/scripts/opencode-v2-contract-probe.js +673 -0
- package/template/coding-task-manager/server.js +1950 -11679
- package/template/coding-task-manager/session-integrity.js +64 -4
- package/template/coding-task-manager/workers/db-owner-worker.js +19 -2
- package/template/coding-task-manager/workers/online-backup-worker.js +1 -0
- package/template/coding-task-manager/workers/read-pool-model-collector.js +51 -3
- package/template/coding-task-manager/workers/read-pool-worker.js +76 -6
- package/template/coding-task-manager/workers/session-host-pool-process.js +6 -6
- package/template/coding-task-manager/workers/session-host-process.js +5 -5
- package/template/coding-task-manager/workers/watch-worker.js +105 -0
- package/template/coding-task-manager/workers/worktree-worker.js +168 -33
- package/template/ctm-relay/lib/protocol.js +14 -1
- package/template/package-lock.json +2 -2
- package/template/package.json +1 -1
- package/template/shared/backup-retention.js +82 -0
- package/template/shared/conversation-admission.js +2 -0
- package/template/shared/conversation-provenance.js +3 -7
- package/template/shared/conversation-storage-v2-contract.js +5 -0
- package/template/shared/linked-worktree.js +51 -0
- package/template/shared/macos-fda-target.js +57 -0
- package/template/shared/orphan-parents.js +55 -0
- package/template/shared/release-schema.json +2 -2
- package/template/shared/storage/contract.js +3 -0
- package/template/wall-e/adapters/imessage.js +12 -0
- package/template/wall-e/agent.js +71 -4
- package/template/wall-e/api-walle.js +21 -1
- package/template/wall-e/bin/train-gemma-e4b-tooluse.js +2 -2
- package/template/wall-e/bin/walle-mcp-stdio.js +5 -5
- package/template/wall-e/brain/brain-join.js +859 -0
- package/template/wall-e/brain/brain-sync-membership.js +20 -0
- package/template/wall-e/brain/brain-sync-store.js +131 -77
- package/template/wall-e/brain/brain-sync-v2-schema.js +203 -0
- package/template/wall-e/brain/brain-sync-v2.js +578 -0
- package/template/wall-e/brain/delegate-model-prefs-store.js +13 -4
- package/template/wall-e/brain/deterministic-id.js +51 -0
- package/template/wall-e/brain/memories.js +2 -2
- package/template/wall-e/brain/migrations.js +40 -6
- package/template/wall-e/brain/model-identity-migration.js +1 -1
- package/template/wall-e/brain/owner-call-whitelist.js +23 -7
- package/template/wall-e/brain/schema.js +45 -85
- package/template/wall-e/brain.js +171 -35
- package/template/wall-e/chat.js +3 -3
- package/template/wall-e/coding/agent-shell-env.js +7 -2
- package/template/wall-e/coding/prompt-bundle.js +66 -21
- package/template/wall-e/config/main-loop-exemptions.json +2 -2
- package/template/wall-e/http/admin-api.js +37 -0
- package/template/wall-e/http/gws-api.js +4 -0
- package/template/wall-e/http/helper-rules-api.js +7 -1
- package/template/wall-e/http/model-admin.js +8 -1
- package/template/wall-e/http/model-delegate-pref-admin.js +3 -3
- package/template/wall-e/http/model-proxy-providers.js +174 -1
- package/template/wall-e/http/model-route-health.js +157 -0
- package/template/wall-e/http/sync-api.js +278 -464
- package/template/wall-e/jobs/job-runner.js +28 -1
- package/template/wall-e/lib/brain-owner-op-priority.js +9 -1
- package/template/wall-e/lib/ctm-data-dir.js +26 -0
- package/template/wall-e/lib/derivation-home.js +147 -0
- package/template/wall-e/lib/helper-rules/evaluate.js +50 -9
- package/template/wall-e/lib/helper-rules/grade.js +51 -8
- package/template/wall-e/lib/helper-rules/lead-call-hook.js +38 -5
- package/template/wall-e/lib/helper-rules/store.js +4 -2
- package/template/wall-e/lib/landing-mcp-tools.js +170 -0
- package/template/wall-e/lib/mcp-client-config.js +133 -14
- package/template/wall-e/lib/mcp-gateway-migrate.js +28 -64
- package/template/wall-e/lib/mcp-integration.js +58 -13
- package/template/wall-e/lib/parent-death.js +7 -1
- package/template/wall-e/lib/scheduler-worker-jobs.js +6 -4
- package/template/wall-e/llm/delegate-lead-prefs.js +16 -1
- package/template/wall-e/llm/delegate-vendor-default.js +12 -5
- package/template/wall-e/llm/model-roles.js +1 -1
- package/template/wall-e/llm/native-delegate-selection.js +56 -8
- package/template/wall-e/llm/worker-delegate-args.js +72 -7
- package/template/wall-e/llm/worker-mcp-routing.js +66 -17
- package/template/wall-e/loops/brain-custom-writes.js +19 -0
- package/template/wall-e/loops/ingest.js +17 -1
- package/template/wall-e/loops/think.js +19 -4
- package/template/wall-e/mcp-contract.js +222 -6
- package/template/wall-e/mcp-protocol-contract.js +40 -5
- package/template/wall-e/mcp-server.js +128 -29
- package/template/wall-e/mcp-worker-schemas.js +5 -5
- package/template/wall-e/memory/ctm-context-client.js +85 -0
- package/template/wall-e/memory/ctm-session-context.js +9 -3
- package/template/wall-e/memory/hub-bridge-client.js +388 -0
- package/template/wall-e/memory/replica-origin.js +56 -0
- package/template/wall-e/memory/session-pool.js +135 -0
- package/template/wall-e/runtime/live-capabilities.js +8 -3
- package/template/wall-e/server.js +4 -2
- package/template/wall-e/shared/release-schema.json +2 -2
- package/template/wall-e/shared/sqlite-file-set.js +39 -0
- package/template/wall-e/shared/storage/contract.js +3 -0
- package/template/wall-e/skills/_templates/delegate-worker/VERSION +1 -1
- package/template/wall-e/skills/_templates/delegate-worker/released-sha256.json +7 -1
- package/template/wall-e/skills/_templates/delegate-worker/templates/claude/SKILL.md +4 -4
- package/template/wall-e/skills/_templates/delegate-worker/templates/codex/SKILL.md +6 -6
- package/template/wall-e/skills/_templates/fleet-sync/VERSION +1 -0
- package/template/wall-e/skills/_templates/fleet-sync/released-sha256.json +16 -0
- package/template/wall-e/skills/_templates/fleet-sync/templates/claude/SKILL.md +64 -0
- package/template/wall-e/skills/_templates/fleet-sync/templates/codex/SKILL.md +64 -0
- package/template/wall-e/tools/chrome-automation.js +6 -0
- package/template/wall-e/training/setup-cuda-train-env.sh +2 -2
- package/template/wall-e/workers/brain-owner-worker.js +18 -4
- package/template/wall-e/workers/mcp-gateway-process.js +5 -5
|
@@ -0,0 +1,762 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// WP-2 (docs/fleet-auto-update-pooled-machines-2026-10-26.html §5.1 D-1/D-7/D-10, §6, §8 WP-2,
|
|
4
|
+
// §9 S-8): the HUB reconciler — decide which box to update and send it the bundle.
|
|
5
|
+
//
|
|
6
|
+
// The box side (WP-0/WP-1, lib/fleet-update.js + bin/fleet-apply.js) already answers two questions:
|
|
7
|
+
// `GET /api/fleet/status` ("what am I") and `POST /api/fleet/bundle?to=<sha>` ("apply this"). This
|
|
8
|
+
// module is the only place that decides WHERE those bundles go, and it is deliberately split in two:
|
|
9
|
+
//
|
|
10
|
+
// * `planFleetAction` — PURE. No I/O, no clock. Given the machine rows, the box statuses, the
|
|
11
|
+
// ancestry answers, the target and the hub's own persisted state, it returns one reason per box
|
|
12
|
+
// (first match wins) and at most one box to send to. A whole decision table is therefore testable
|
|
13
|
+
// with plain objects, and the ordering below is the specification, not an accident of evaluation.
|
|
14
|
+
// * `reconcileFleetOnce` — the one pass that does the I/O: read the target, ask every box, settle a
|
|
15
|
+
// previous send, compute ancestry, plan, and (when the plan says so) build a `git bundle` and POST
|
|
16
|
+
// it. Re-entrant by construction: a second call while one is running joins the first.
|
|
17
|
+
//
|
|
18
|
+
// Lead decisions that override the doc (2026-10-26):
|
|
19
|
+
// * There is NO `autoUpdate` setting (doc S-5 / AC-2d are dropped). A box opts out by not being a
|
|
20
|
+
// clean `main` checkout; the reasons `not_main`/`dirty` are the whole opt-out story.
|
|
21
|
+
// * OQ-3 cooldown: at most one bundle per box every 3 h, measured on the HUB clock from the hub's
|
|
22
|
+
// own `lastSentAt` for that box (never the box clock) — reason `cooldown`.
|
|
23
|
+
// * One attempt per (box, target): once the hub has sent target T to box B it never sends T to B
|
|
24
|
+
// again, whatever the verdict. A new hub `main` commit is a new target.
|
|
25
|
+
// * Serial: at most one box in flight across the whole pool (D-7).
|
|
26
|
+
//
|
|
27
|
+
// Lead review round 2 (2026-10-27) tightened four things, each with its own test:
|
|
28
|
+
// * the serial rule is not broken by a timing-out box — a box that still ANSWERS stays in flight
|
|
29
|
+
// however long it takes (`bin/fleet-apply.js` waits up to 24 h in `waiting_idle`); see step 4.
|
|
30
|
+
// * only the hub leads — `isFleetLeader`, checked before any fetch; see step 1b.
|
|
31
|
+
// * `current` means RUNNING the target, not just having it on disk — `restart_pending` otherwise.
|
|
32
|
+
// * the memoized target is re-read before a send is recorded, so a moved `main` cannot burn the
|
|
33
|
+
// one attempt for a target with a bundle the box would refuse; see step 7a.
|
|
34
|
+
//
|
|
35
|
+
// Every git call goes through `execFile`/`spawn` with an argv ARRAY (never a shell string), and every
|
|
36
|
+
// network call carries an `AbortSignal.timeout` budget — the codebase's sanctioned non-timer deadline,
|
|
37
|
+
// so this module owns no timer of its own.
|
|
38
|
+
|
|
39
|
+
const { execFile, spawn } = require('node:child_process');
|
|
40
|
+
const fsp = require('node:fs/promises');
|
|
41
|
+
const path = require('node:path');
|
|
42
|
+
|
|
43
|
+
const { HEALTH_STATES } = require('./machine-registry');
|
|
44
|
+
const { FLEET_BUNDLE_MAX_BYTES, isFullSha, readFleetState, readHubTarget } = require('./fleet-update');
|
|
45
|
+
const { isDevInstanceTag } = require('./dev-fault-injection');
|
|
46
|
+
|
|
47
|
+
// The real launchers under a second name: both `buildFleetBundle` and `_execFileGit` keep the literal
|
|
48
|
+
// call names (`spawn(...)`/`execFile(...)`) so bin/main-thread-lint.js keeps SEEING their launch sites
|
|
49
|
+
// — a call written through an `*Impl` alias would be invisible to it, and an invisible process launch
|
|
50
|
+
// is exactly what that ledger exists to prevent (same trick as lib/fleet-update.js).
|
|
51
|
+
const defaultExecFile = execFile;
|
|
52
|
+
const defaultSpawn = spawn;
|
|
53
|
+
|
|
54
|
+
/** OQ-3/D-10 (user decision): at most one bundle per box every 3 h, on the hub clock. */
|
|
55
|
+
const FLEET_COOLDOWN_MS = 3 * 60 * 60 * 1000;
|
|
56
|
+
/**
|
|
57
|
+
* A send whose box went SILENT (no status answer at all) or whose helper died WITHOUT a verdict is
|
|
58
|
+
* abandoned after this and recorded as `timeout` — otherwise one stuck box would hold the serial lock
|
|
59
|
+
* forever and freeze the whole pool (D-7 has no other escape hatch).
|
|
60
|
+
*
|
|
61
|
+
* A box that still ANSWERS is never timed out, however long it takes: `bin/fleet-apply.js` waits up to
|
|
62
|
+
* its own `idleMaxMs` (24 h) in `waiting_idle` for real work to drain, so a hub-side 30 min timeout
|
|
63
|
+
* would abandon a perfectly healthy apply and, worse, advance the pool while the first box is still
|
|
64
|
+
* mid-apply. Only silence, or an `idle` box with no matching `last.to` (the helper never wrote its
|
|
65
|
+
* verdict), counts as a stall.
|
|
66
|
+
*/
|
|
67
|
+
const FLEET_IN_FLIGHT_TIMEOUT_MS = 30 * 60 * 1000;
|
|
68
|
+
/** The status read's budget: a box that cannot answer `/api/fleet/status` in 5 s reads `unreachable`. */
|
|
69
|
+
const FLEET_STATUS_TIMEOUT_MS = 5000;
|
|
70
|
+
/** The bundle POST's budget (the worst live gap measured 2.3 MB / 0.49 s, §1.3). */
|
|
71
|
+
const FLEET_SEND_TIMEOUT_MS = 120000;
|
|
72
|
+
|
|
73
|
+
/** `git merge-base --is-ancestor` is a graph walk, not a transfer; 5 s is the §6 status budget. */
|
|
74
|
+
const GIT_TIMEOUT_MS = 5000;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The one health value a box must carry to be dialled (lib/machine-registry.js `HEALTH_STATES`).
|
|
78
|
+
* `unhealthy` is a box that answered but is degraded, and `unknown` is one this hub has never read:
|
|
79
|
+
* neither is a box we may write over (D-2). Read from the registry's own list so the two modules
|
|
80
|
+
* cannot drift apart silently.
|
|
81
|
+
*/
|
|
82
|
+
const REACHABLE_HEALTH = HEALTH_STATES.find((state) => state === 'reachable') || 'reachable';
|
|
83
|
+
|
|
84
|
+
/** The repo root holding `.git` — `lib/` is two levels below it (`coding-task-manager/lib`). */
|
|
85
|
+
const DEFAULT_REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
86
|
+
|
|
87
|
+
/** `<dataDir>/fleet-hub.json` — the hub's own memory of what it has sent (doc §8 WP-2). */
|
|
88
|
+
const HUB_STATE_FILE = 'fleet-hub.json';
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The planner's closed reason set, in decision order (§8 WP-2 AC-2a) plus `sending` for the one box a
|
|
92
|
+
* pass picks. Kept LOCAL to this module on purpose: `lib/fleet-update.js`'s `FLEET_REASONS` is the BOX
|
|
93
|
+
* side's vocabulary (its status route never says `updating`/`sending`), and a completeness test over
|
|
94
|
+
* this list must not force the box module to grow two words it does not use.
|
|
95
|
+
*/
|
|
96
|
+
const FLEET_HUB_REASONS = Object.freeze([
|
|
97
|
+
'unreachable',
|
|
98
|
+
'not_git',
|
|
99
|
+
'not_main',
|
|
100
|
+
// WP-3 (docs/fleet-origin-main-sync-skill-2026-10-31.html §5 R-1, rev-2 OQ-3): the box's target
|
|
101
|
+
// tree carries `landing.publish`, so the box syncs itself through origin and this hub never hands
|
|
102
|
+
// it a bundle — keeping both channels would re-open incident I-1 (two boxes publishing the same
|
|
103
|
+
// content). Decided before `current`/`behind`, because it is a fact about the BOX, not about how
|
|
104
|
+
// far its sha is from the hub's.
|
|
105
|
+
'origin_sync',
|
|
106
|
+
'current',
|
|
107
|
+
'restart_pending',
|
|
108
|
+
'dirty',
|
|
109
|
+
'diverged',
|
|
110
|
+
'updating',
|
|
111
|
+
'already_attempted',
|
|
112
|
+
'cooldown',
|
|
113
|
+
'another_box_updating',
|
|
114
|
+
'busy',
|
|
115
|
+
'behind',
|
|
116
|
+
'sending',
|
|
117
|
+
]);
|
|
118
|
+
|
|
119
|
+
// ── state helpers ────────────────────────────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
/** The honest "nothing sent, nothing known" state — also what a missing/corrupt file reads as. */
|
|
122
|
+
function _emptyHubState() { return { inFlight: null, boxes: {} }; }
|
|
123
|
+
|
|
124
|
+
function hubStatePath(dataDir) { return path.join(String(dataDir || ''), HUB_STATE_FILE); }
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Coerce whatever came off disk (or a test) into the shape the planner relies on, so a torn file can
|
|
128
|
+
* never make `box.attempts[target]` throw. Unknown keys are dropped: this file is a cache of one
|
|
129
|
+
* decision, not a store of anything else.
|
|
130
|
+
*/
|
|
131
|
+
function _normalizeHubState(parsed) {
|
|
132
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return _emptyHubState();
|
|
133
|
+
const rawBoxes = parsed.boxes && typeof parsed.boxes === 'object' && !Array.isArray(parsed.boxes) ? parsed.boxes : {};
|
|
134
|
+
const boxes = {};
|
|
135
|
+
for (const [id, box] of Object.entries(rawBoxes)) {
|
|
136
|
+
if (!id || !box || typeof box !== 'object' || Array.isArray(box)) continue;
|
|
137
|
+
const attempts = {};
|
|
138
|
+
const rawAttempts = box.attempts && typeof box.attempts === 'object' && !Array.isArray(box.attempts) ? box.attempts : {};
|
|
139
|
+
for (const [attemptTarget, attempt] of Object.entries(rawAttempts)) {
|
|
140
|
+
if (!attemptTarget || !attempt || typeof attempt !== 'object') continue;
|
|
141
|
+
attempts[attemptTarget] = {
|
|
142
|
+
sentAt: Number(attempt.sentAt) || 0,
|
|
143
|
+
verdict: String(attempt.verdict == null ? '' : attempt.verdict),
|
|
144
|
+
reason: String(attempt.reason == null ? '' : attempt.reason),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
boxes[id] = { lastSentAt: Number(box.lastSentAt) || 0, attempts };
|
|
148
|
+
}
|
|
149
|
+
const inFlight = parsed.inFlight && typeof parsed.inFlight === 'object'
|
|
150
|
+
&& typeof parsed.inFlight.instanceId === 'string' && parsed.inFlight.instanceId
|
|
151
|
+
? {
|
|
152
|
+
instanceId: parsed.inFlight.instanceId,
|
|
153
|
+
target: String(parsed.inFlight.target == null ? '' : parsed.inFlight.target),
|
|
154
|
+
sentAt: Number(parsed.inFlight.sentAt) || 0,
|
|
155
|
+
}
|
|
156
|
+
: null;
|
|
157
|
+
return { inFlight, boxes };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Warn ONCE per data dir per process: a corrupt file is replaced on the next write anyway. */
|
|
161
|
+
const _corruptWarned = new Set();
|
|
162
|
+
function _warnCorruptHubState(dataDir) {
|
|
163
|
+
const key = String(dataDir || '');
|
|
164
|
+
if (_corruptWarned.has(key)) return;
|
|
165
|
+
_corruptWarned.add(key);
|
|
166
|
+
// REPORT: the hub's send history is unreadable, so this pass starts from empty. The next write
|
|
167
|
+
// replaces the file; until then the only cost is that a pre-existing attempt may be re-attempted.
|
|
168
|
+
console.warn(`[fleet-hub] hub state at ${hubStatePath(dataDir)} is unreadable; starting from an empty state`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* `<dataDir>/fleet-hub.json`. Missing or corrupt ⇒ the empty state (never a throw into a sweep); a
|
|
173
|
+
* corrupt file is REPORTED once and replaced by the next `writeHubState`.
|
|
174
|
+
*/
|
|
175
|
+
async function readHubState(dataDir) {
|
|
176
|
+
if (!dataDir) return _emptyHubState();
|
|
177
|
+
let raw = '';
|
|
178
|
+
try {
|
|
179
|
+
raw = await fsp.readFile(hubStatePath(dataDir), 'utf8');
|
|
180
|
+
} catch {
|
|
181
|
+
// HANDLE: no hub state yet (first sweep on this hub), or an unreadable path — "nothing sent" is
|
|
182
|
+
// the truthful answer and is what makes the very next pass a normal send.
|
|
183
|
+
return _emptyHubState();
|
|
184
|
+
}
|
|
185
|
+
let parsed = null;
|
|
186
|
+
try {
|
|
187
|
+
parsed = JSON.parse(raw);
|
|
188
|
+
} catch {
|
|
189
|
+
// HANDLE: torn/garbage file — report once, start empty (see _warnCorruptHubState).
|
|
190
|
+
_warnCorruptHubState(dataDir);
|
|
191
|
+
return _emptyHubState();
|
|
192
|
+
}
|
|
193
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
194
|
+
_warnCorruptHubState(dataDir);
|
|
195
|
+
return _emptyHubState();
|
|
196
|
+
}
|
|
197
|
+
return _normalizeHubState(parsed);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Atomic 0600 write: temp file in the same directory, then `rename` (the readFleetState pattern). */
|
|
201
|
+
async function writeHubState(dataDir, state) {
|
|
202
|
+
const dir = String(dataDir || '');
|
|
203
|
+
await fsp.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
204
|
+
const target = hubStatePath(dir);
|
|
205
|
+
const tmp = path.join(dir, `.fleet-hub.${process.pid}.${Date.now()}.tmp`);
|
|
206
|
+
await fsp.writeFile(tmp, JSON.stringify(_normalizeHubState(state)), { mode: 0o600 });
|
|
207
|
+
await fsp.rename(tmp, target);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── the pure planner (WP-2 §8 AC-2a) ─────────────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* One reason for one box, first match wins, in the order §8 WP-2 fixes (see FLEET_HUB_REASONS).
|
|
214
|
+
* `status` is the box's own `/api/fleet/status` body, or null when the GET failed.
|
|
215
|
+
*/
|
|
216
|
+
function _reasonFor({ machine, status, ancestry, target, state, id, clock }) {
|
|
217
|
+
// 1. unreachable — a health this hub cannot dial, or no status answer at all.
|
|
218
|
+
if (machine.health !== REACHABLE_HEALTH || !status) return 'unreachable';
|
|
219
|
+
// 2. not_git — an npm/release checkout has no bundle to fast-forward.
|
|
220
|
+
if (status.channel !== 'git') return 'not_git';
|
|
221
|
+
// 3. not_main — the box is on a branch; the fleet only ever advances `main`.
|
|
222
|
+
if (status.branch !== 'main') return 'not_main';
|
|
223
|
+
// 4. origin_sync — the box's target tree carries `landing.publish` (WP-3, R-1): its main is fed
|
|
224
|
+
// from origin by its own CTM follower (`land sync --pull-only`), so it neither needs nor may
|
|
225
|
+
// receive this hub's bundle (rev-2 OQ-3 retires the hub→box channel for exactly these boxes).
|
|
226
|
+
// Checked before `current`: a self-syncing box is never reported as current/behind AT ALL, and
|
|
227
|
+
// the hub's own view of its sha is no longer the deciding fact.
|
|
228
|
+
if (status.originSync && status.originSync.configured === true) return 'origin_sync';
|
|
229
|
+
// 5. current — at the target sha AND actually running it. A box whose `head` already moved but whose
|
|
230
|
+
// `runningSha` did not has the new code on disk only (fleet-apply's busy_timeout path leaves
|
|
231
|
+
// exactly that): it is NOT current, and it needs no bundle either — just its own restart.
|
|
232
|
+
if (status.head === target) return status.runningSha === target ? 'current' : 'restart_pending';
|
|
233
|
+
// 6. dirty — uncommitted tracked changes (untracked files never make a box ineligible).
|
|
234
|
+
if (status.clean !== true) return 'dirty';
|
|
235
|
+
// 7. diverged — the hub repo does not contain the box's head: `from..main` would not apply.
|
|
236
|
+
if (ancestry.get(id) !== true) return 'diverged';
|
|
237
|
+
// 8. updating — the box is mid-apply, or this hub already has a send out to it.
|
|
238
|
+
if (status.state !== 'idle') return 'updating';
|
|
239
|
+
if (state.inFlight && state.inFlight.instanceId === id) return 'updating';
|
|
240
|
+
const box = state.boxes ? state.boxes[id] : null;
|
|
241
|
+
// 9. already_attempted — one attempt per (box, target), whatever the verdict (lead decision).
|
|
242
|
+
if (box && box.attempts && box.attempts[target]) return 'already_attempted';
|
|
243
|
+
// 10. cooldown — at most one bundle per box every 3 h, on the hub clock (D-10/OQ-3).
|
|
244
|
+
const lastSentAt = box ? Number(box.lastSentAt) || 0 : 0;
|
|
245
|
+
if (lastSentAt > 0 && clock - lastSentAt < FLEET_COOLDOWN_MS) return 'cooldown';
|
|
246
|
+
// 11. another_box_updating — serial across the pool (D-7): the first box is the canary.
|
|
247
|
+
if (state.inFlight) return 'another_box_updating';
|
|
248
|
+
// 12. busy — the box's own restart guard is blocking (D-5). The box also waits on its own gate;
|
|
249
|
+
// this check only avoids handing a bundle to a box that would immediately park it.
|
|
250
|
+
if (Number(status.guard && status.guard.blockingCount) > 0) return 'busy';
|
|
251
|
+
// 13. behind — eligible.
|
|
252
|
+
return 'behind';
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The pure decision for ONE pass (no I/O, no `Date.now`). `machines` are the registry rows (only
|
|
257
|
+
* `enabled` rows are considered; the hub's own row never appears there), `statuses` maps instanceId
|
|
258
|
+
* to the box's `/api/fleet/status` body (null = the GET failed), `ancestry` maps instanceId to
|
|
259
|
+
* "the box's head is an ancestor of target in the hub repo", and `hubState` is the persisted state.
|
|
260
|
+
*
|
|
261
|
+
* Returns `{ send, decisions }`: `send` is the one instanceId to bundle (or null), and `decisions`
|
|
262
|
+
* maps EVERY enabled box to `{ reason, behind }` — `behind` is the pure "this box is not RUNNING the
|
|
263
|
+
* target" fact, independent of eligibility. A box that landed the target on disk without it running
|
|
264
|
+
* (`restart_pending`) is therefore `behind`, even though no bundle may be sent to it.
|
|
265
|
+
*/
|
|
266
|
+
function planFleetAction({ machines = [], statuses = new Map(), ancestry = new Map(), target = '', hubState = null, now = 0 } = {}) {
|
|
267
|
+
const decisions = new Map();
|
|
268
|
+
const state = hubState && typeof hubState === 'object' ? hubState : _emptyHubState();
|
|
269
|
+
const clock = Number(now) || 0;
|
|
270
|
+
const list = (Array.isArray(machines) ? machines : []).filter((machine) => machine && machine.enabled);
|
|
271
|
+
for (const machine of list) {
|
|
272
|
+
const id = String(machine.instanceId || '');
|
|
273
|
+
const status = statuses.get(id) || null;
|
|
274
|
+
const behind = !!(status && !(status.head === target && status.runningSha === target));
|
|
275
|
+
decisions.set(id, { reason: _reasonFor({ machine, status, ancestry, target, state, id, clock }), behind });
|
|
276
|
+
}
|
|
277
|
+
// send = the FIRST eligible box by ascending instanceId. Every other eligible box stays `behind`
|
|
278
|
+
// (not `another_box_updating`): nothing is in flight yet, so the next pass — with this one recorded
|
|
279
|
+
// — is what reports the serial wait.
|
|
280
|
+
const send = list
|
|
281
|
+
.map((machine) => String(machine.instanceId || ''))
|
|
282
|
+
.filter((id) => { const decision = decisions.get(id); return !!decision && decision.reason === 'behind'; })
|
|
283
|
+
.sort()[0] || null;
|
|
284
|
+
if (send) decisions.get(send).reason = 'sending';
|
|
285
|
+
return { send, decisions };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ── git: the bundle build and the ancestry probe ─────────────────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
/** The production git runner: `execFile` with an argv ARRAY, an explicit timeout, no shell. */
|
|
291
|
+
function _execFileGit(args, { cwd, timeout = GIT_TIMEOUT_MS } = {}) {
|
|
292
|
+
return new Promise((resolve, reject) => {
|
|
293
|
+
execFile('git', args, { cwd, timeout }, (error, stdout) => {
|
|
294
|
+
if (error) { reject(error); return; }
|
|
295
|
+
resolve(String(stdout == null ? '' : stdout));
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* One git call reduced to `{ code, stdout, stderr }` — never a throw. `runGit` has two legitimate
|
|
302
|
+
* shapes: the production runner above (rejects on a non-zero exit, with `error.code` = the exit code)
|
|
303
|
+
* and a test double that returns `{ code, stdout, stderr }`. Both read here, so a caller that must
|
|
304
|
+
* look at an EXIT CODE (`git merge-base --is-ancestor`) does not care which it got.
|
|
305
|
+
*/
|
|
306
|
+
async function _runGitCode(runGit, args, cwd, timeout = GIT_TIMEOUT_MS) {
|
|
307
|
+
try {
|
|
308
|
+
const out = await runGit(args, { cwd, timeout });
|
|
309
|
+
if (out && typeof out === 'object' && Number.isInteger(out.code)) {
|
|
310
|
+
return { code: out.code, stdout: String(out.stdout == null ? '' : out.stdout), stderr: String(out.stderr == null ? '' : out.stderr) };
|
|
311
|
+
}
|
|
312
|
+
return { code: 0, stdout: String(out == null ? '' : out), stderr: '' };
|
|
313
|
+
} catch (error) {
|
|
314
|
+
const code = error && Number.isInteger(error.code) ? error.code : 1;
|
|
315
|
+
const stderr = error && (error.stderr != null ? String(error.stderr) : String((error && error.message) || ''));
|
|
316
|
+
return { code, stdout: String((error && error.stdout) || ''), stderr };
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* D-3: `git bundle create - <from>..main`, the whole gap in one stream. The right-hand side MUST be
|
|
322
|
+
* the branch NAME `main` — git refuses to record a raw sha as the bundle's own ref, and a bundle with
|
|
323
|
+
* no ref is not one `git bundle verify`/`git fetch` can consume. stdout is collected into a Buffer and
|
|
324
|
+
* capped at `maxBytes`: past the cap the child is killed and the promise rejects with code `too_large`,
|
|
325
|
+
* so a pathological gap becomes a refusal on the hub, never an unbounded allocation.
|
|
326
|
+
*/
|
|
327
|
+
function buildFleetBundle({ repoRoot, from, maxBytes = FLEET_BUNDLE_MAX_BYTES, spawnImpl: spawn = defaultSpawn } = {}) {
|
|
328
|
+
return new Promise((resolve, reject) => {
|
|
329
|
+
let child;
|
|
330
|
+
try {
|
|
331
|
+
child = spawn('git', ['-C', repoRoot, 'bundle', 'create', '-', `${from}..main`], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
332
|
+
} catch (error) {
|
|
333
|
+
reject(error);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
const chunks = [];
|
|
337
|
+
const stderrChunks = [];
|
|
338
|
+
let total = 0;
|
|
339
|
+
let settled = false;
|
|
340
|
+
const fail = (error) => {
|
|
341
|
+
if (settled) return;
|
|
342
|
+
settled = true;
|
|
343
|
+
try { child.kill('SIGKILL'); } catch { /* expected: the child already exited (or never started) */ }
|
|
344
|
+
reject(error);
|
|
345
|
+
};
|
|
346
|
+
child.stdout.on('data', (chunk) => {
|
|
347
|
+
if (settled) return;
|
|
348
|
+
total += chunk.length;
|
|
349
|
+
if (total > maxBytes) {
|
|
350
|
+
const error = new Error(`fleet bundle for ${from} exceeds ${maxBytes} bytes`);
|
|
351
|
+
error.code = 'too_large';
|
|
352
|
+
fail(error);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
chunks.push(chunk);
|
|
356
|
+
});
|
|
357
|
+
child.stderr.on('data', (chunk) => { if (stderrChunks.length < 64) stderrChunks.push(chunk); });
|
|
358
|
+
child.on('error', fail);
|
|
359
|
+
child.on('close', (code) => {
|
|
360
|
+
if (settled) return;
|
|
361
|
+
settled = true;
|
|
362
|
+
if (code !== 0) {
|
|
363
|
+
const tail = Buffer.concat(stderrChunks).toString('utf8').trim().slice(-500);
|
|
364
|
+
const error = new Error(`git bundle create exited ${code}: ${tail}`);
|
|
365
|
+
error.code = 'bundle_failed';
|
|
366
|
+
reject(error);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
resolve(Buffer.concat(chunks));
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ── the reconciler ───────────────────────────────────────────────────────────────────────────────
|
|
375
|
+
|
|
376
|
+
/** Re-entrancy: a call while one pass is running joins that pass (the sweep may fire twice). */
|
|
377
|
+
let _reconciling = null;
|
|
378
|
+
/** The last plan, for `fleetRowFor` — the persisted state is cached beside it so that call does no I/O. */
|
|
379
|
+
let _lastPlan = null;
|
|
380
|
+
let _hubStateCache = { dataDir: '', state: _emptyHubState() };
|
|
381
|
+
|
|
382
|
+
function _joinUrl(baseUrl, pathname) {
|
|
383
|
+
const base = String(baseUrl == null ? '' : baseUrl).trim().replace(/\/+$/, '');
|
|
384
|
+
if (!base) return '';
|
|
385
|
+
try {
|
|
386
|
+
return new URL(pathname, base).toString();
|
|
387
|
+
} catch {
|
|
388
|
+
// expected: an unparseable machine url is a dial failure, never a throw out of a sweep
|
|
389
|
+
return '';
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** The codebase's sanctioned non-timer deadline (machines.js:157 and friends). */
|
|
394
|
+
function _timeoutSignal(ms) {
|
|
395
|
+
return (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function')
|
|
396
|
+
? AbortSignal.timeout(ms)
|
|
397
|
+
: undefined;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function _ensureBox(state, id) {
|
|
401
|
+
if (!state.boxes || typeof state.boxes !== 'object') state.boxes = {};
|
|
402
|
+
const existing = state.boxes[id];
|
|
403
|
+
if (existing && typeof existing === 'object') {
|
|
404
|
+
if (!existing.attempts || typeof existing.attempts !== 'object') existing.attempts = {};
|
|
405
|
+
if (!Number.isFinite(Number(existing.lastSentAt))) existing.lastSentAt = 0;
|
|
406
|
+
return existing;
|
|
407
|
+
}
|
|
408
|
+
const created = { lastSentAt: 0, attempts: {} };
|
|
409
|
+
state.boxes[id] = created;
|
|
410
|
+
return created;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function _attemptFor(state, id, target) {
|
|
414
|
+
const box = state && state.boxes ? state.boxes[id] : null;
|
|
415
|
+
return (box && box.attempts && box.attempts[target]) || null;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function _recordAttempt(state, id, target, attempt) {
|
|
419
|
+
const box = _ensureBox(state, id);
|
|
420
|
+
box.attempts[target] = {
|
|
421
|
+
sentAt: Number(attempt.sentAt) || 0,
|
|
422
|
+
verdict: String(attempt.verdict == null ? '' : attempt.verdict),
|
|
423
|
+
reason: String(attempt.reason == null ? '' : attempt.reason),
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async function _persist(dataDir, hubState) {
|
|
428
|
+
_hubStateCache = { dataDir: String(dataDir || ''), state: hubState };
|
|
429
|
+
if (!dataDir) return;
|
|
430
|
+
await writeHubState(dataDir, hubState);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Only the HUB leads (lead correction 2026-10-27). Pool members pull each other's rosters (the
|
|
435
|
+
* registry's P-5), so a BOX — the laptop, the Spark — also carries enabled machine rows for its peers,
|
|
436
|
+
* and would otherwise "reconcile" them with ITS OWN `main`: a box that is itself behind, or a /ctm-dev
|
|
437
|
+
* instance whose DB is a `--refresh` copy of the primary's, sending bundles to real boxes.
|
|
438
|
+
*
|
|
439
|
+
* Two refusals, both read-only:
|
|
440
|
+
* * a dev instance tag (`/ctm-dev`) never leads — `dev-fault-injection.isDevInstanceTag` is the
|
|
441
|
+
* codebase's one "is this the primary?" predicate (an unset/blank tag counts as primary, so the
|
|
442
|
+
* real hub is never accidentally locked out);
|
|
443
|
+
* * a box that has itself taken a fleet update (its own `<dataDir>/fleet/state.json` carries a
|
|
444
|
+
* `last` verdict) follows a hub, so it is a follower wherever it runs.
|
|
445
|
+
*/
|
|
446
|
+
async function isFleetLeader({ dataDir = '', env = process.env } = {}) {
|
|
447
|
+
if (isDevInstanceTag(env && env.CTM_INSTANCE_TAG)) return false;
|
|
448
|
+
const boxState = await readFleetState(dataDir);
|
|
449
|
+
if (boxState && boxState.last) return false;
|
|
450
|
+
return true;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* ONE status read. Any failure — no url, a transport error, a non-2xx, a body that is not JSON — is
|
|
455
|
+
* `null`, which the planner reports as `unreachable` (HANDLE at this boundary: a box that cannot
|
|
456
|
+
* answer is a box we must not write over, and never an exception out of the sweep).
|
|
457
|
+
*/
|
|
458
|
+
async function _readBoxStatus(machine, { fetchImpl, peerHeadersFor }) {
|
|
459
|
+
const url = _joinUrl(machine && machine.url, '/api/fleet/status');
|
|
460
|
+
if (!url || typeof fetchImpl !== 'function') return null;
|
|
461
|
+
const headers = typeof peerHeadersFor === 'function' ? (peerHeadersFor(machine.instanceId) || {}) : {};
|
|
462
|
+
let response = null;
|
|
463
|
+
try {
|
|
464
|
+
response = await fetchImpl(url, {
|
|
465
|
+
method: 'GET',
|
|
466
|
+
headers: { Accept: 'application/json', ...headers },
|
|
467
|
+
signal: _timeoutSignal(FLEET_STATUS_TIMEOUT_MS),
|
|
468
|
+
});
|
|
469
|
+
} catch {
|
|
470
|
+
// HANDLE: the box did not answer — the planner's `unreachable` reason is the whole verdict.
|
|
471
|
+
return null;
|
|
472
|
+
}
|
|
473
|
+
if (!response || !response.ok) return null;
|
|
474
|
+
try {
|
|
475
|
+
const body = await response.json();
|
|
476
|
+
return body && typeof body === 'object' ? body : null;
|
|
477
|
+
} catch {
|
|
478
|
+
// expected: a non-JSON 200 body is "no status", not a transport fault worth a different reason
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** The refusal's reason string: the box's own `reason`/`error` when it named one, else the status. */
|
|
484
|
+
async function _refusalReason(response) {
|
|
485
|
+
if (!response) return 'no_response';
|
|
486
|
+
let body = null;
|
|
487
|
+
try {
|
|
488
|
+
body = typeof response.json === 'function' ? await response.json() : null;
|
|
489
|
+
} catch {
|
|
490
|
+
// expected: a non-JSON refusal is reported by its HTTP status, never as a parse failure of ours
|
|
491
|
+
body = null;
|
|
492
|
+
}
|
|
493
|
+
const named = body && typeof body === 'object' ? (body.reason || body.error) : null;
|
|
494
|
+
if (named) return String(named).slice(0, 120);
|
|
495
|
+
return `http_${Number(response.status) || 0}`;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Step 7: build the bundle and POST it. Called with the send ALREADY recorded (in-flight + attempt +
|
|
500
|
+
* `lastSentAt` persisted), so a crash mid-POST still counts as the one attempt for that target and
|
|
501
|
+
* still starts the cooldown. `202` leaves the send in flight for a later pass to settle from the box's
|
|
502
|
+
* own status; anything else is a refusal, recorded with the box's reason (or the HTTP status, or the
|
|
503
|
+
* error's code) — still the attempt, still the cooldown.
|
|
504
|
+
*/
|
|
505
|
+
async function _sendBundle({ machine, status, target, fetchImpl, peerHeadersFor, repoRoot, buildBundle, hubState, dataDir }) {
|
|
506
|
+
const id = String(machine.instanceId || '');
|
|
507
|
+
const sentAt = Number((_attemptFor(hubState, id, target) || {}).sentAt) || 0;
|
|
508
|
+
const refuse = async (reason) => {
|
|
509
|
+
_recordAttempt(hubState, id, target, { sentAt, verdict: 'refused', reason });
|
|
510
|
+
hubState.inFlight = null;
|
|
511
|
+
await _persist(dataDir, hubState);
|
|
512
|
+
};
|
|
513
|
+
const from = String((status && status.head) || '');
|
|
514
|
+
if (!isFullSha(from)) {
|
|
515
|
+
// REPORT: a send without a full box head is a planner/status contradiction — refuse it and say so.
|
|
516
|
+
console.warn(`[fleet-hub] no usable head for ${id}; refusing the send`);
|
|
517
|
+
await refuse('no_head');
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
let bundle = null;
|
|
521
|
+
try {
|
|
522
|
+
bundle = await buildBundle({ repoRoot, from, maxBytes: FLEET_BUNDLE_MAX_BYTES });
|
|
523
|
+
} catch (error) {
|
|
524
|
+
// REPORT: a build failure is a hub-local fault; the code (or `bundle_failed`) is the recorded reason.
|
|
525
|
+
console.warn(`[fleet-hub] bundle build for ${id} failed: ${(error && error.message) || error}`);
|
|
526
|
+
await refuse((error && error.code) || 'bundle_failed');
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
let response = null;
|
|
530
|
+
try {
|
|
531
|
+
response = await fetchImpl(_joinUrl(machine.url, `/api/fleet/bundle?to=${encodeURIComponent(target)}`), {
|
|
532
|
+
method: 'POST',
|
|
533
|
+
headers: {
|
|
534
|
+
...(typeof peerHeadersFor === 'function' ? (peerHeadersFor(id) || {}) : {}),
|
|
535
|
+
'content-type': 'application/octet-stream',
|
|
536
|
+
},
|
|
537
|
+
body: bundle,
|
|
538
|
+
signal: _timeoutSignal(FLEET_SEND_TIMEOUT_MS),
|
|
539
|
+
});
|
|
540
|
+
} catch (error) {
|
|
541
|
+
// REPORT: the transport failed — record the code and let the next sweep try the next box.
|
|
542
|
+
console.warn(`[fleet-hub] bundle POST to ${id} failed: ${(error && error.message) || error}`);
|
|
543
|
+
await refuse((error && error.code) || 'send_failed');
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
if (response && Number(response.status) === 202) return; // accepted: stays in flight until settled
|
|
547
|
+
await refuse(await _refusalReason(response));
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* One reconciler pass (WP-2 §8). Resolves `{ target, send, decisions }`; `send` is the instanceId whose
|
|
552
|
+
* bundle was POSTed this pass (the caller does NOT await that POST — see the sweep hook), or null.
|
|
553
|
+
*/
|
|
554
|
+
async function _reconcileOnce({
|
|
555
|
+
db,
|
|
556
|
+
fetchImpl = (typeof fetch === 'function' ? fetch : null),
|
|
557
|
+
peerHeadersFor = null,
|
|
558
|
+
repoRoot = DEFAULT_REPO_ROOT,
|
|
559
|
+
dataDir = '',
|
|
560
|
+
env = process.env,
|
|
561
|
+
now = Date.now,
|
|
562
|
+
runGit = _execFileGit,
|
|
563
|
+
buildBundle = buildFleetBundle,
|
|
564
|
+
listMachines = require('./machine-registry').listMachines,
|
|
565
|
+
} = {}) {
|
|
566
|
+
const clock = () => Number(now()) || 0;
|
|
567
|
+
|
|
568
|
+
// 1. the target — the hub's local `refs/heads/main` (D-1). No target, no work at all.
|
|
569
|
+
const target = String((await readHubTarget({ repoRoot })) || '');
|
|
570
|
+
if (!target) return { target: '', send: null, decisions: new Map() };
|
|
571
|
+
|
|
572
|
+
// 1b. only a leader leads. A follower (a box, or a /ctm-dev instance) returns before ANY fetch, so
|
|
573
|
+
// it can never hand a real box a bundle built from its own — possibly stale, possibly refreshed —
|
|
574
|
+
// `main`. Nothing is recorded and no plan is published, so its rows stay `fleet: null`.
|
|
575
|
+
if (!(await isFleetLeader({ dataDir, env }))) {
|
|
576
|
+
return { target, send: null, decisions: new Map(), follower: true };
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// 2. the boxes — zero enabled machines ⇒ zero network work (AC-2g).
|
|
580
|
+
const rows = (listMachines({ db }) || {}).machines || [];
|
|
581
|
+
const machines = rows.filter((machine) => machine && machine.enabled);
|
|
582
|
+
if (!machines.length) {
|
|
583
|
+
_lastPlan = { target, decisions: new Map(), statuses: new Map() };
|
|
584
|
+
return { target, send: null, decisions: _lastPlan.decisions };
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const hubState = await readHubState(dataDir);
|
|
588
|
+
_hubStateCache = { dataDir: String(dataDir || ''), state: hubState };
|
|
589
|
+
|
|
590
|
+
// 3. ask every box what it is. Sequential on purpose: the pool is small, and a deterministic order
|
|
591
|
+
// makes a sweep's request count and sequence trivial to reason about.
|
|
592
|
+
const statuses = new Map();
|
|
593
|
+
for (const machine of machines) {
|
|
594
|
+
statuses.set(String(machine.instanceId || ''), await _readBoxStatus(machine, { fetchImpl, peerHeadersFor }));
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// 4. settle a previous send from the box's own status before planning anything new.
|
|
598
|
+
//
|
|
599
|
+
// A box that still ANSWERS is never timed out (lead correction 2026-10-27): bin/fleet-apply.js
|
|
600
|
+
// waits up to its own idleMaxMs = 24 h in `waiting_idle` for real work to drain, so while the box
|
|
601
|
+
// reports anything other than `idle` the hub keeps the serial lock — however long that takes —
|
|
602
|
+
// instead of abandoning a healthy apply and letting the pool advance behind it. `timeout` is
|
|
603
|
+
// reserved for the two ways a send really dies: the box stops answering (status null), or its
|
|
604
|
+
// helper exited without writing a verdict and the box is back to `idle` with no matching `last.to`.
|
|
605
|
+
let changed = false;
|
|
606
|
+
if (hubState.inFlight) {
|
|
607
|
+
const inFlight = hubState.inFlight;
|
|
608
|
+
const status = statuses.get(inFlight.instanceId) || null;
|
|
609
|
+
const previous = _attemptFor(hubState, inFlight.instanceId, inFlight.target) || { sentAt: 0 };
|
|
610
|
+
let verdict = '';
|
|
611
|
+
let reason = '';
|
|
612
|
+
if (status && status.head === inFlight.target && status.runningSha === inFlight.target && status.state === 'idle') {
|
|
613
|
+
verdict = 'updated';
|
|
614
|
+
} else if (status && status.state === 'idle' && status.last && status.last.to === inFlight.target) {
|
|
615
|
+
verdict = String(status.last.verdict || 'updated');
|
|
616
|
+
reason = String(status.last.reason || '');
|
|
617
|
+
} else if (
|
|
618
|
+
clock() - (Number(inFlight.sentAt) || 0) > FLEET_IN_FLIGHT_TIMEOUT_MS
|
|
619
|
+
&& (!status || (status.state === 'idle' && !(status.last && status.last.to === inFlight.target)))
|
|
620
|
+
) {
|
|
621
|
+
verdict = 'timeout';
|
|
622
|
+
}
|
|
623
|
+
if (verdict) {
|
|
624
|
+
_recordAttempt(hubState, inFlight.instanceId, inFlight.target, { sentAt: previous.sentAt, verdict, reason });
|
|
625
|
+
hubState.inFlight = null;
|
|
626
|
+
changed = true;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// 5. ancestry — `git merge-base --is-ancestor <boxHead> <target>` in the HUB repo. Exit 0 is the
|
|
631
|
+
// only true; 1 (not an ancestor) and 128 (not a sha we hold) are both `diverged` (D-9).
|
|
632
|
+
const ancestry = new Map();
|
|
633
|
+
for (const [id, status] of statuses) {
|
|
634
|
+
if (!status || !isFullSha(status.head) || status.head === target) continue;
|
|
635
|
+
const result = await _runGitCode(runGit, ['merge-base', '--is-ancestor', String(status.head), target], repoRoot);
|
|
636
|
+
ancestry.set(id, result.code === 0);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// 6. plan.
|
|
640
|
+
const plan = planFleetAction({ machines, statuses, ancestry, target, hubState, now: clock() });
|
|
641
|
+
// `statuses` is kept beside the decisions so `fleetRowFor` can serve the box's own `head`,
|
|
642
|
+
// `runningSha` and `originSync` to the Machines card (WP-3 AC-3d) without a second network read.
|
|
643
|
+
_lastPlan = { target, decisions: plan.decisions, statuses };
|
|
644
|
+
|
|
645
|
+
// 7. send. The send is recorded (in-flight, attempt, cooldown stamp) and persisted BEFORE any
|
|
646
|
+
// network call, so a crash mid-POST cannot produce a second attempt for the same target.
|
|
647
|
+
if (!plan.send) {
|
|
648
|
+
if (changed) await _persist(dataDir, hubState);
|
|
649
|
+
return { target, send: null, decisions: plan.decisions };
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// 7a. The bundle-ref race (lead correction 2026-10-27). `readHubTarget` memoizes for 15 s, so `main`
|
|
653
|
+
// can move between step 1 and the build below; `git bundle create - <from>..main` would then
|
|
654
|
+
// produce a bundle whose tip is no longer `to`, the box refuses it (tip !== to) and the ONE
|
|
655
|
+
// attempt allowed for that target is burned for nothing. Re-read the ref through `runGit` and
|
|
656
|
+
// stand down for this pass — nothing is recorded, so the next pass plans against the new target.
|
|
657
|
+
if (plan.send) {
|
|
658
|
+
const ref = await _runGitCode(runGit, ['rev-parse', '--verify', 'refs/heads/main'], repoRoot);
|
|
659
|
+
const currentMain = String(ref.stdout || '').trim();
|
|
660
|
+
if (!isFullSha(currentMain) || currentMain !== target) {
|
|
661
|
+
// REPORT: the hub moved under this pass. The box is still `behind` — it is just not this pass's
|
|
662
|
+
// job to update it, and no attempt should be spent on a bundle the box would refuse.
|
|
663
|
+
console.warn(`[fleet-hub] hub main is ${currentMain || '(unknown)'}, not ${target}; standing down for this pass`);
|
|
664
|
+
plan.decisions.get(plan.send).reason = 'behind';
|
|
665
|
+
if (changed) await _persist(dataDir, hubState);
|
|
666
|
+
return { target, send: null, decisions: plan.decisions };
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const machine = machines.find((row) => String(row.instanceId || '') === plan.send) || null;
|
|
671
|
+
const status = statuses.get(plan.send) || null;
|
|
672
|
+
const sentAt = clock();
|
|
673
|
+
const box = _ensureBox(hubState, plan.send);
|
|
674
|
+
box.lastSentAt = sentAt;
|
|
675
|
+
box.attempts[target] = { sentAt, verdict: 'sent', reason: '' };
|
|
676
|
+
hubState.inFlight = { instanceId: plan.send, target, sentAt };
|
|
677
|
+
await _persist(dataDir, hubState);
|
|
678
|
+
await _sendBundle({ machine, status, target, fetchImpl, peerHeadersFor, repoRoot, buildBundle, hubState, dataDir });
|
|
679
|
+
return { target, send: plan.send, decisions: plan.decisions };
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Re-entrant entry point: a call while a pass is running returns that pass's promise (§8 WP-2, S-8 —
|
|
684
|
+
* the machines-only loop and the sync tick can both fire). Rejections propagate to the caller (the
|
|
685
|
+
* sweep hook REPORTs them); the in-flight slot is always cleared.
|
|
686
|
+
*/
|
|
687
|
+
function reconcileFleetOnce(options = {}) {
|
|
688
|
+
if (_reconciling) return _reconciling;
|
|
689
|
+
_reconciling = _reconcileOnce(options).finally(() => { _reconciling = null; });
|
|
690
|
+
return _reconciling;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/** The newest recorded attempt, whatever the target — the row's "last verdict" (AC-3a's card line). */
|
|
694
|
+
function _lastVerdictFor(box) {
|
|
695
|
+
if (!box || !box.attempts || typeof box.attempts !== 'object') return null;
|
|
696
|
+
let best = null;
|
|
697
|
+
for (const [attemptTarget, attempt] of Object.entries(box.attempts)) {
|
|
698
|
+
if (!attempt || typeof attempt !== 'object') continue;
|
|
699
|
+
const entry = {
|
|
700
|
+
target: attemptTarget,
|
|
701
|
+
verdict: String(attempt.verdict == null ? '' : attempt.verdict),
|
|
702
|
+
reason: String(attempt.reason == null ? '' : attempt.reason),
|
|
703
|
+
sentAt: Number(attempt.sentAt) || 0,
|
|
704
|
+
};
|
|
705
|
+
if (!best || entry.sentAt >= best.sentAt) best = entry;
|
|
706
|
+
}
|
|
707
|
+
return best;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* The per-row fleet field for `GET /api/machines` (S-3). Reads the last plan + the cached persisted
|
|
712
|
+
* state — NO I/O and NO git, because this is called once per row per list. `null` before the first
|
|
713
|
+
* pass (never a fabricated "current"), and `null` for a box the last plan did not consider.
|
|
714
|
+
*
|
|
715
|
+
* WP-3 (AC-3d): the row also carries the box's own `head`/`runningSha` and its `originSync` block
|
|
716
|
+
* (the box's `/api/fleet/status` answer, reused verbatim) — the Machines card's `origin_sync` line
|
|
717
|
+
* needs all three, and this is the one place the box's status reaches the row. A box that did not
|
|
718
|
+
* answer (or a pass that ran before this field existed) reads `null`, never a fabricated sha.
|
|
719
|
+
*/
|
|
720
|
+
function fleetRowFor(instanceId) {
|
|
721
|
+
const id = String(instanceId || '');
|
|
722
|
+
if (!id || !_lastPlan) return null;
|
|
723
|
+
const decision = _lastPlan.decisions.get(id);
|
|
724
|
+
if (!decision) return null;
|
|
725
|
+
const state = (_hubStateCache && _hubStateCache.state) || _emptyHubState();
|
|
726
|
+
const box = state.boxes ? state.boxes[id] : null;
|
|
727
|
+
const status = (_lastPlan.statuses && _lastPlan.statuses.get(id)) || null;
|
|
728
|
+
return {
|
|
729
|
+
target: _lastPlan.target,
|
|
730
|
+
reason: decision.reason,
|
|
731
|
+
behind: !!decision.behind,
|
|
732
|
+
inFlight: !!(state.inFlight && state.inFlight.instanceId === id),
|
|
733
|
+
lastVerdict: _lastVerdictFor(box),
|
|
734
|
+
head: status ? String(status.head == null ? '' : status.head) : null,
|
|
735
|
+
runningSha: status ? String(status.runningSha == null ? '' : status.runningSha) : null,
|
|
736
|
+
originSync: (status && status.originSync) || null,
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function _resetFleetHubStateForTests() {
|
|
741
|
+
_reconciling = null;
|
|
742
|
+
_lastPlan = null;
|
|
743
|
+
_hubStateCache = { dataDir: '', state: _emptyHubState() };
|
|
744
|
+
_corruptWarned.clear();
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
module.exports = {
|
|
748
|
+
_resetFleetHubStateForTests,
|
|
749
|
+
FLEET_COOLDOWN_MS,
|
|
750
|
+
FLEET_HUB_REASONS,
|
|
751
|
+
FLEET_IN_FLIGHT_TIMEOUT_MS,
|
|
752
|
+
FLEET_SEND_TIMEOUT_MS,
|
|
753
|
+
FLEET_STATUS_TIMEOUT_MS,
|
|
754
|
+
buildFleetBundle,
|
|
755
|
+
fleetRowFor,
|
|
756
|
+
hubStatePath,
|
|
757
|
+
isFleetLeader,
|
|
758
|
+
planFleetAction,
|
|
759
|
+
readHubState,
|
|
760
|
+
reconcileFleetOnce,
|
|
761
|
+
writeHubState,
|
|
762
|
+
};
|