create-walle 0.10.1 → 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 +7 -1
- package/lib/owner-write-safety.js +135 -0
- package/lib/public-runtime.js +13 -5
- package/lib/release-schema.json +3 -2
- package/lib/runtime-closure-policy.json +11 -8
- package/package.json +1 -1
- package/template/bin/deps-doctor.js +1 -1
- package/template/bin/node-bin.sh +11 -0
- package/template/coding-task-manager/api-prompts.js +55 -29
- package/template/coding-task-manager/bin/claude-hook.js +62 -4
- package/template/coding-task-manager/bin/claude-table-parity.js +307 -0
- package/template/coding-task-manager/bin/conversation-storage-auto-migration-lib.js +16 -1
- package/template/coding-task-manager/bin/conversation-storage-generation-evidence.js +9 -2
- package/template/coding-task-manager/bin/conversation-storage-service-entry-lib.js +16 -1
- package/template/coding-task-manager/bin/conversation-storage-walle-owner.js +247 -0
- package/template/coding-task-manager/bin/ctm-machine-pair.js +160 -0
- 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/lead-wait-census.js +1348 -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/bin/repair-foreign-clone.js +564 -0
- package/template/coding-task-manager/bin/repair-resume-echo-titles.js +248 -0
- package/template/coding-task-manager/bin/write-lock-bench.js +111 -0
- package/template/coding-task-manager/db.js +860 -317
- package/template/coding-task-manager/git-utils.js +44 -0
- 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 -7
- 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 +99 -1
- package/template/coding-task-manager/lib/agent-title-apply.js +88 -0
- package/template/coding-task-manager/lib/agent-title-backfill-read.js +92 -0
- 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/host-stats.js +98 -0
- package/template/coding-task-manager/lib/api/hub-bridge.js +243 -0
- package/template/coding-task-manager/lib/api/ingress.js +43 -12
- package/template/coding-task-manager/lib/api/landing.js +403 -0
- package/template/coding-task-manager/lib/api/machine-pairing.js +285 -0
- package/template/coding-task-manager/lib/api/machine-pool.js +307 -0
- package/template/coding-task-manager/lib/api/machines.js +1150 -0
- package/template/coding-task-manager/lib/api/memory-sync-routes.js +335 -0
- package/template/coding-task-manager/lib/api/misc-ops.js +25 -5
- package/template/coding-task-manager/lib/api/providers.js +53 -6
- package/template/coding-task-manager/lib/api/remote-sessions.js +1326 -0
- package/template/coding-task-manager/lib/api/resume-from-store.js +25 -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 +237 -59
- 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/transcript-history.js +17 -4
- package/template/coding-task-manager/lib/api/walle-bridge.js +9 -1
- package/template/coding-task-manager/lib/api/worktrees.js +38 -37
- package/template/coding-task-manager/lib/approval-drift.js +16 -1
- package/template/coding-task-manager/lib/approval-logical-frame.js +5 -1
- package/template/coding-task-manager/lib/approval-scope.js +68 -4
- package/template/coding-task-manager/lib/attachment-reconciler-wiring.js +5 -2
- package/template/coding-task-manager/lib/auth-context.js +32 -2
- package/template/coding-task-manager/lib/auth-rate-limit.js +64 -1
- package/template/coding-task-manager/lib/auth-rules.js +474 -62
- 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 +7 -1
- package/template/coding-task-manager/lib/browser-session-broker.js +9 -1
- package/template/coding-task-manager/lib/browser-use-adapter.js +33 -2
- package/template/coding-task-manager/lib/build-info.js +4 -2
- package/template/coding-task-manager/lib/claude-markdown-ansi.js +27 -85
- package/template/coding-task-manager/lib/claude-model-truth.js +13 -2
- package/template/coding-task-manager/lib/claude-session-title.js +6 -30
- package/template/coding-task-manager/lib/claude-table-layout.js +322 -0
- package/template/coding-task-manager/lib/claude-transcript-terminal-renderer.js +17 -13
- package/template/coding-task-manager/lib/client-diag-handler.js +69 -2
- package/template/coding-task-manager/lib/codex-app-server-client.js +14 -16
- package/template/coding-task-manager/lib/codex-config-guard.js +33 -3
- package/template/coding-task-manager/lib/codex-configuration-history.js +55 -0
- package/template/coding-task-manager/lib/codex-harness-qualification.js +55 -20
- package/template/coding-task-manager/lib/codex-launch-health.js +29 -2
- package/template/coding-task-manager/lib/codex-link-preparation.js +9 -8
- package/template/coding-task-manager/lib/codex-paths.js +2 -1
- package/template/coding-task-manager/lib/codex-rollout-history.js +743 -0
- package/template/coding-task-manager/lib/coding-agent-models.js +48 -42
- package/template/coding-task-manager/lib/coding-cli-skill-installer.js +44 -14
- package/template/coding-task-manager/lib/coding-session-model-adapters.js +70 -4
- package/template/coding-task-manager/lib/coding-session-model-owner.js +1 -1
- package/template/coding-task-manager/lib/coding-session-model-projection.js +5 -0
- package/template/coding-task-manager/lib/coding-session-model-public.js +7 -1
- package/template/coding-task-manager/lib/coding-session-model-runtime.js +24 -6
- package/template/coding-task-manager/lib/coding-session-model-scan.js +31 -0
- package/template/coding-task-manager/lib/coding-session-model-state.js +17 -0
- package/template/coding-task-manager/lib/coding-session-model-sync-state.js +0 -1
- package/template/coding-task-manager/lib/conversation-freshness-diagnostics.js +280 -0
- package/template/coding-task-manager/lib/conversation-import/cursor-guard-owner.js +10 -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 +104 -13
- 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-boot-lifecycle.js +17 -1
- package/template/coding-task-manager/lib/ctm-session-context-api.js +97 -6
- package/template/coding-task-manager/lib/db-domain-tables.js +8 -0
- package/template/coding-task-manager/lib/db-domains.js +22 -2
- package/template/coding-task-manager/lib/db-owner-dispatch-probe.js +32 -0
- package/template/coding-task-manager/lib/db-owner-op-registry.js +7 -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 +6 -0
- package/template/coding-task-manager/lib/deferred-restore-attempt.js +6 -2
- package/template/coding-task-manager/lib/feature-manifest.js +5 -1
- 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 +61 -44
- package/template/coding-task-manager/lib/harness-history-transition.js +52 -11
- package/template/coding-task-manager/lib/headless-inflight-ops.js +203 -0
- package/template/coding-task-manager/lib/headless-missing-reply.js +42 -0
- package/template/coding-task-manager/lib/headless-term-service.js +139 -25
- 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-control-dial.js +167 -0
- 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-orchestrator.js +16 -2
- package/template/coding-task-manager/lib/host-handoff.js +50 -15
- package/template/coding-task-manager/lib/host-health-ledger.js +164 -12
- package/template/coding-task-manager/lib/host-self-truth-watchdog.js +4 -0
- package/template/coding-task-manager/lib/host-stats.js +475 -0
- package/template/coding-task-manager/lib/host-threadpool-evacuation.js +160 -0
- package/template/coding-task-manager/lib/host-threadpool-monitor.js +14 -1
- package/template/coding-task-manager/lib/hosted-input-dispatch.js +203 -0
- package/template/coding-task-manager/lib/hosted-sync-worker.js +141 -310
- 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 +12 -0
- package/template/coding-task-manager/lib/instance-manifest-runtime.js +3 -0
- package/template/coding-task-manager/lib/instance-manifest.js +18 -2
- package/template/coding-task-manager/lib/instance-sync-status.js +33 -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 +193 -8
- 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/launch-plan-worker.js +59 -2
- 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 +409 -72
- package/template/coding-task-manager/lib/lead-model-switch.js +115 -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 +85 -9
- 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/legacy-draft-cancellation-recovery.js +111 -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/login-tripwire.js +1 -0
- package/template/coding-task-manager/lib/machine-pairing.js +238 -0
- package/template/coding-task-manager/lib/machine-pool.js +409 -0
- package/template/coding-task-manager/lib/machine-registry.js +1494 -0
- package/template/coding-task-manager/lib/machine-self.js +66 -0
- package/template/coding-task-manager/lib/macos-capabilities.js +32 -15
- package/template/coding-task-manager/lib/main-thread-lint-exemptions.json +485 -57
- 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/media-journal-control-reader.js +473 -0
- package/template/coding-task-manager/lib/media-journal.js +84 -27
- package/template/coding-task-manager/lib/media-store-options.js +43 -15
- 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/v56-host-samples-and-machine-pool.js +76 -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/mirror-feed-sanitize.js +12 -3
- 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 +8 -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 +6 -0
- package/template/coding-task-manager/lib/model-proxy/generation-probe.js +60 -17
- package/template/coding-task-manager/lib/model-proxy/handoff.js +494 -0
- 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/lead-worker-picks.js +25 -0
- package/template/coding-task-manager/lib/model-proxy/native-lead-listener.js +61 -7
- package/template/coding-task-manager/lib/model-proxy/proxy-cli.js +200 -0
- package/template/coding-task-manager/lib/model-proxy/proxy-instance-record.js +257 -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/stream-accounting.js +205 -0
- package/template/coding-task-manager/lib/model-proxy/tokens.js +147 -5
- package/template/coding-task-manager/lib/model-proxy/upstream-outcome.js +18 -0
- package/template/coding-task-manager/lib/native-agent-model-args.js +25 -22
- package/template/coding-task-manager/lib/native-history-control.js +7 -5
- package/template/coding-task-manager/lib/native-lead-settings-client.js +46 -5
- package/template/coding-task-manager/lib/native-lead-tls.js +18 -4
- package/template/coding-task-manager/lib/oauth-proxy-supervisor.js +776 -75
- 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 +15 -1
- package/template/coding-task-manager/lib/painted-row-recorder.js +9 -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/prompt-submission-contract.js +21 -1
- package/template/coding-task-manager/lib/provider-login-broker.js +264 -24
- package/template/coding-task-manager/lib/provider-login-capture.js +153 -0
- package/template/coding-task-manager/lib/provider-login-tab.js +10 -2
- package/template/coding-task-manager/lib/queue-draft-status-overlay.js +36 -0
- package/template/coding-task-manager/lib/raw-codex-history-window.js +5 -3
- package/template/coding-task-manager/lib/read-pool-client.js +94 -4
- package/template/coding-task-manager/lib/read-pool-op-registry.js +10 -1
- package/template/coding-task-manager/lib/read-pool-worker-handle.js +29 -4
- package/template/coding-task-manager/lib/recent-catalog-query.js +41 -0
- package/template/coding-task-manager/lib/recent-session-catalog-response.js +76 -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-command-context.js +12 -0
- package/template/coding-task-manager/lib/remote-command-runtime.js +13 -1
- package/template/coding-task-manager/lib/remote-machine-catalog.js +1357 -0
- package/template/coding-task-manager/lib/remote-machine-create.js +1233 -0
- package/template/coding-task-manager/lib/remote-machine-link.js +1426 -0
- package/template/coding-task-manager/lib/remote-machine-proxy.js +654 -0
- package/template/coding-task-manager/lib/remote-session-forward.js +364 -0
- package/template/coding-task-manager/lib/remote-submission-status.js +6 -0
- 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/restart-outcome-truth.js +20 -3
- package/template/coding-task-manager/lib/restart-preflight.js +9 -2
- package/template/coding-task-manager/lib/restore-backoff-gate.js +10 -2
- package/template/coding-task-manager/lib/restore-prepare-retry.js +12 -5
- package/template/coding-task-manager/lib/restore-resume-batch.js +22 -1
- 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/resume-blocked-wire.js +62 -1
- package/template/coding-task-manager/lib/resume-cwd.js +13 -0
- package/template/coding-task-manager/lib/resume-from-store.js +12 -1
- package/template/coding-task-manager/lib/resume-origin-facts.js +38 -1
- package/template/coding-task-manager/lib/resume-preflight.js +21 -16
- package/template/coding-task-manager/lib/review-repo-admission.js +39 -6
- package/template/coding-task-manager/lib/review-repo-registry.js +74 -18
- package/template/coding-task-manager/lib/root-delivery-runtime.js +74 -17
- package/template/coding-task-manager/lib/root-delivery-selection.js +37 -1
- package/template/coding-task-manager/lib/root-prompt-admission.js +33 -2
- package/template/coding-task-manager/lib/root-prompt-flow.js +168 -34
- 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/scrollback-archive-store.js +7 -4
- package/template/coding-task-manager/lib/session-analysis-memory.js +2 -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/codex-native-provider-gate.js +218 -0
- package/template/coding-task-manager/lib/session-create/codex-resume-exit-classify.js +52 -0
- package/template/coding-task-manager/lib/session-create/create-phases.js +118 -0
- package/template/coding-task-manager/lib/session-create/delegate-worker.js +179 -20
- package/template/coding-task-manager/lib/session-create/dsh-session.js +930 -0
- package/template/coding-task-manager/lib/session-create/identity.js +20 -1
- 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 +201 -9
- package/template/coding-task-manager/lib/session-create/provider-launch-context.js +77 -0
- package/template/coding-task-manager/lib/session-create/provider-process.js +193 -64
- package/template/coding-task-manager/lib/session-create/response.js +37 -19
- 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 +14 -5
- 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 +50 -2
- package/template/coding-task-manager/lib/session-create/worker-launch-sandbox.js +39 -10
- package/template/coding-task-manager/lib/session-create/worker-notices.js +94 -5
- package/template/coding-task-manager/lib/session-create/worker-observability.js +101 -9
- package/template/coding-task-manager/lib/session-create/worker-stop.js +11 -3
- package/template/coding-task-manager/lib/session-create/worker-wait-watcher.js +315 -0
- package/template/coding-task-manager/lib/session-empty.js +13 -10
- package/template/coding-task-manager/lib/session-geometry-authority-handler.js +10 -1
- package/template/coding-task-manager/lib/session-host-manager.js +212 -206
- package/template/coding-task-manager/lib/session-jobs.js +20 -10
- package/template/coding-task-manager/lib/session-lifecycle.js +28 -4
- package/template/coding-task-manager/lib/session-model-action-preconditions.js +15 -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-resource-sampler.js +19 -9
- package/template/coding-task-manager/lib/session-standup.js +67 -4
- package/template/coding-task-manager/lib/session-status-ledger.js +147 -6
- package/template/coding-task-manager/lib/session-status-runtime.js +45 -1
- package/template/coding-task-manager/lib/session-title-mirror.js +25 -57
- package/template/coding-task-manager/lib/session-transcript-trash.js +8 -1
- 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/startup-pointer-write.js +57 -0
- 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/state-sync/painted-frame-field.js +11 -0
- package/template/coding-task-manager/lib/storage/coding-session-model-store.js +25 -23
- 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/surviving-owner-bootstrap.js +7 -2
- package/template/coding-task-manager/lib/surviving-owner-recovery.js +2 -1
- 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-http-status.js +25 -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-archiver.js +229 -229
- package/template/coding-task-manager/lib/transcript-cache-temps.js +171 -0
- package/template/coding-task-manager/lib/transcript-frame-cache.js +20 -5
- package/template/coding-task-manager/lib/transcript-guard-job.js +55 -4
- package/template/coding-task-manager/lib/transcript-link.js +77 -0
- package/template/coding-task-manager/lib/transcript-manifest-store.js +8 -4
- package/template/coding-task-manager/lib/transcript-source.js +44 -7
- package/template/coding-task-manager/lib/transcript-store.js +49 -0
- package/template/coding-task-manager/lib/tunnel-lane-governor.js +6 -3
- package/template/coding-task-manager/lib/typing-interaction-trace.js +198 -3
- package/template/coding-task-manager/lib/typing-worker-stages.js +60 -0
- 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/visible-revision-watchdog.js +8 -12
- package/template/coding-task-manager/lib/wake-skip-reasons.js +47 -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 +130 -27
- package/template/coding-task-manager/lib/watch-worker-client.js +203 -0
- package/template/coding-task-manager/lib/worker-failure-classify.js +13 -1
- 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 +125 -6
- package/template/coding-task-manager/lib/ws/terminal-attach.js +123 -1
- package/template/coding-task-manager/lib/ws-router.js +68 -1
- package/template/coding-task-manager/lib/ws-send-admission.js +22 -4
- package/template/coding-task-manager/oauth-proxy.js +211 -170
- package/template/coding-task-manager/package-lock.json +34 -94
- package/template/coding-task-manager/package.json +7 -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/providers/codex-mcp-extract.js +121 -19
- package/template/coding-task-manager/providers/codex-mcp.js +14 -5
- package/template/coding-task-manager/public/css/app.css +449 -1
- package/template/coding-task-manager/public/css/reviews.css +4 -0
- package/template/coding-task-manager/public/css/walle-session.css +4 -0
- package/template/coding-task-manager/public/index.html +766 -465
- 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 +2572 -0
- package/template/coding-task-manager/public/js/app/models-tab.js +658 -73
- package/template/coding-task-manager/public/js/app/restart-banner.js +53 -0
- package/template/coding-task-manager/public/js/app/worktrees-panel.js +191 -7
- package/template/coding-task-manager/public/js/background-work.js +203 -28
- package/template/coding-task-manager/public/js/coding-session-model-presentation.js +181 -13
- 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 +847 -5
- package/template/coding-task-manager/public/js/core/session-selectors.js +5 -1
- 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 +48 -4
- package/template/coding-task-manager/public/js/history-painted-rows.js +17 -2
- package/template/coding-task-manager/public/js/history-reader-park.js +13 -1
- package/template/coding-task-manager/public/js/history-scroller.js +187 -9
- package/template/coding-task-manager/public/js/history-transcript-pages.js +5 -5
- package/template/coding-task-manager/public/js/machine-pool-shared.js +991 -0
- 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/private-state-scope.js +31 -3
- package/template/coding-task-manager/public/js/prompt-submission-client.js +173 -8
- package/template/coding-task-manager/public/js/prompt-submission-tray.js +281 -0
- 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/queue-panel-submission-reconcile.js +174 -1
- package/template/coding-task-manager/public/js/restart-banner-policy.js +29 -1
- package/template/coding-task-manager/public/js/reviews.js +7 -3
- package/template/coding-task-manager/public/js/root-draft-lifetime.js +139 -2
- package/template/coding-task-manager/public/js/root-queue-builder-lifetime.js +16 -0
- package/template/coding-task-manager/public/js/row-join.js +4 -2
- package/template/coding-task-manager/public/js/session-model-picker.js +447 -57
- package/template/coding-task-manager/public/js/session-search-utils.js +64 -21
- 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 +14 -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-02.js +16 -7
- package/template/coding-task-manager/public/js/terminal/runtime-03.js +41 -0
- package/template/coding-task-manager/public/js/terminal/runtime-04.js +41 -4
- 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 +60 -3
- package/template/coding-task-manager/public/js/terminal/runtime-16.js +16 -12
- package/template/coding-task-manager/public/js/terminal/runtime-18.js +14 -0
- package/template/coding-task-manager/public/js/terminal/runtime-19.js +8 -4
- package/template/coding-task-manager/public/js/terminal/runtime-20.js +211 -74
- package/template/coding-task-manager/public/js/terminal/runtime-21.js +19 -6
- package/template/coding-task-manager/public/js/terminal/runtime-22.js +86 -0
- package/template/coding-task-manager/public/js/terminal-attachment-draft.js +67 -13
- package/template/coding-task-manager/public/js/terminal-history-viewport.js +1 -1
- package/template/coding-task-manager/public/js/terminal-image-orchestration.js +52 -6
- package/template/coding-task-manager/public/js/terminal-wheel.js +62 -5
- package/template/coding-task-manager/public/js/walle/chat-conversation.js +15 -7
- package/template/coding-task-manager/public/js/walle-session.js +30 -7
- 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 +366 -8
- package/template/coding-task-manager/public/m/app.js +1006 -74
- package/template/coding-task-manager/public/m/index.html +55 -1
- package/template/coding-task-manager/public/m/machines.js +2147 -0
- package/template/coding-task-manager/public/m/pool-failover.js +391 -0
- package/template/coding-task-manager/public/m/private-api.js +31 -1
- package/template/coding-task-manager/public/m/private-queue-actions.js +26 -7
- package/template/coding-task-manager/public/m/sw.js +5 -0
- package/template/coding-task-manager/public/usage.html +1 -0
- package/template/coding-task-manager/queue-engine.js +13 -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/approvals.js +5 -6
- package/template/coding-task-manager/server/boot.js +136 -17
- package/template/coding-task-manager/server.js +2819 -11885
- package/template/coding-task-manager/session-integrity.js +71 -13
- package/template/coding-task-manager/workers/db-owner-worker.js +72 -9
- 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 +93 -12
- package/template/coding-task-manager/workers/session-host-pool-process.js +24 -10
- package/template/coding-task-manager/workers/session-host-process.js +19 -8
- package/template/coding-task-manager/workers/transcript-archive-worker.js +1 -1
- package/template/coding-task-manager/workers/watch-worker.js +105 -0
- package/template/coding-task-manager/workers/worktree-worker.js +202 -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/scripts/conversation-storage-v2-migration.js +78 -11
- 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 +47 -6
- package/template/shared/fixtures/worker-status-payloads.json +392 -0
- package/template/shared/git-remote-key.js +83 -0
- package/template/shared/host-identity.js +98 -2
- 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/owner-write-safety.js +135 -0
- package/template/shared/public-runtime.js +13 -5
- package/template/shared/release-schema.json +3 -2
- package/template/shared/report-citations.js +14 -0
- package/template/shared/sqlite-write-lock.js +361 -223
- package/template/shared/storage/contract.js +38 -3
- package/template/wall-e/adapters/imessage.js +12 -0
- package/template/wall-e/agent.js +92 -4
- package/template/wall-e/api-walle.js +26 -1
- package/template/wall-e/bin/brain-purge-legacy-ctm.js +3 -2
- package/template/wall-e/bin/train-gemma-e4b-tooluse.js +2 -2
- package/template/wall-e/bin/walle-mcp-stdio.js +14 -8
- 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/helper-rules-schema.js +129 -0
- package/template/wall-e/brain/memories.js +2 -2
- package/template/wall-e/brain/migrations.js +91 -28
- package/template/wall-e/brain/model-deployment-facts.js +31 -1
- package/template/wall-e/brain/model-identity-migration.js +1 -1
- package/template/wall-e/brain/owner-call-whitelist.js +39 -7
- package/template/wall-e/brain/schema-ensures.js +22 -0
- package/template/wall-e/brain/schema.js +45 -85
- package/template/wall-e/brain.js +172 -36
- package/template/wall-e/chat.js +3 -3
- package/template/wall-e/coding/agent-shell-env.js +7 -2
- package/template/wall-e/coding/journal-dispatcher.js +51 -0
- package/template/wall-e/coding/prompt-bundle.js +66 -21
- package/template/wall-e/coding/session-journal.js +28 -40
- package/template/wall-e/coding-orchestrator.js +23 -26
- 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/auth.js +18 -1
- package/template/wall-e/http/blog-views-api.js +318 -0
- package/template/wall-e/http/gws-api.js +4 -0
- package/template/wall-e/http/helper-rules-api.js +327 -0
- 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 +177 -2
- 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/http/telemetry-api.js +12 -0
- package/template/wall-e/jobs/job-runner.js +28 -1
- package/template/wall-e/lib/blog-view-policy.js +88 -0
- package/template/wall-e/lib/blog-view-store.js +169 -0
- package/template/wall-e/lib/brain-owner-op-priority.js +9 -1
- package/template/wall-e/lib/client-config-lock.js +2 -1
- package/template/wall-e/lib/coding-journal-writer-child-client.js +67 -18
- 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/error-kinds.js +10 -1
- package/template/wall-e/lib/feature-manifest.js +1 -1
- package/template/wall-e/lib/helper-rules/cell-state.js +123 -0
- package/template/wall-e/lib/helper-rules/evaluate.js +398 -0
- package/template/wall-e/lib/helper-rules/explore-rubric.md +12 -0
- package/template/wall-e/lib/helper-rules/grade.js +320 -0
- package/template/wall-e/lib/helper-rules/label.js +247 -0
- package/template/wall-e/lib/helper-rules/lead-call-hook.js +555 -0
- package/template/wall-e/lib/helper-rules/store.js +669 -0
- 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 +63 -19
- package/template/wall-e/lib/parent-death.js +7 -1
- package/template/wall-e/lib/runtime-worker-pool.js +1 -0
- package/template/wall-e/lib/scheduler-worker-jobs.js +12 -4
- package/template/wall-e/lib/telemetry-db-schema.js +37 -0
- package/template/wall-e/lib/telemetry-process-client.js +76 -1
- package/template/wall-e/llm/default-fallback.js +4 -0
- 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-catalog.js +13 -4
- package/template/wall-e/llm/model-roles.js +7 -1
- package/template/wall-e/llm/native-delegate-selection.js +56 -8
- package/template/wall-e/llm/portkey-http.js +150 -0
- package/template/wall-e/llm/portkey-sync.js +15 -145
- package/template/wall-e/llm/worker-delegate-args.js +72 -7
- package/template/wall-e/llm/worker-mcp-routing.js +117 -7
- package/template/wall-e/llm/worker-route.js +43 -1
- package/template/wall-e/loops/brain-custom-writes.js +64 -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 +77 -2
- package/template/wall-e/mcp-server.js +141 -31
- package/template/wall-e/mcp-worker-schemas.js +25 -7
- package/template/wall-e/memory/ctm-context-client.js +110 -5
- 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 +3 -2
- package/template/wall-e/shared/sqlite-file-set.js +39 -0
- package/template/wall-e/shared/sqlite-write-lock.js +361 -223
- package/template/wall-e/shared/storage/contract.js +38 -3
- package/template/wall-e/skills/_templates/delegate-worker/VERSION +1 -1
- package/template/wall-e/skills/_templates/delegate-worker/released-sha256.json +13 -1
- package/template/wall-e/skills/_templates/delegate-worker/templates/claude/SKILL.md +11 -6
- package/template/wall-e/skills/_templates/delegate-worker/templates/codex/SKILL.md +13 -8
- 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/tools/local-tools.js +6 -4
- package/template/wall-e/training/setup-cuda-train-env.sh +2 -2
- package/template/wall-e/workers/brain-owner-worker.js +27 -4
- package/template/wall-e/workers/coding-journal-writer-child.js +8 -1
- package/template/wall-e/workers/mcp-gateway-process.js +5 -5
- package/template/wall-e/workers/telemetry-worker.js +8 -0
|
@@ -0,0 +1,2069 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// WP-1 (docs/local-landing-queue-multi-session-integration-2026-10-26.html §6.2, §5 D-1..D-11,
|
|
4
|
+
// §8 WP-1): the `land` CLI.
|
|
5
|
+
//
|
|
6
|
+
// One queue per repository, no daemon (D-1/D-2). `land submit` writes a job, then tries to become
|
|
7
|
+
// the runner by claiming the single per-repository lease; a submitter that wins the lease drains the
|
|
8
|
+
// queue before exiting, a submitter that loses it prints `runner: 'other'` and exits in well under a
|
|
9
|
+
// second, spawning nothing. `land run [--once]` is the same loop without a submission, for CTM's
|
|
10
|
+
// button and for cron-less recovery.
|
|
11
|
+
//
|
|
12
|
+
// WP-1 (§7 "Header rule", docs/fleet-origin-main-sync-skill-2026-10-31.html): the queue runs
|
|
13
|
+
// `git push` only for a `publish` job, only to `landing.publish.remote/branch`, only fast-forward.
|
|
14
|
+
// Nothing else here pushes: every other git child is `--no-optional-locks` with the §4 GIT_*
|
|
15
|
+
// list scrubbed (`lib/landing/git.js`), never runs add/commit/checkout/stash outside its own train
|
|
16
|
+
// worktree (D-11), and never merges into a session's worktree.
|
|
17
|
+
//
|
|
18
|
+
// Exit codes (§6.2): 0 ok · 1 typed refusal/failure · 2 usage · 75 timeout.
|
|
19
|
+
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
const { spawn } = require('child_process');
|
|
23
|
+
|
|
24
|
+
const {
|
|
25
|
+
landingError,
|
|
26
|
+
childEnv,
|
|
27
|
+
toplevel,
|
|
28
|
+
commonDir,
|
|
29
|
+
revParse,
|
|
30
|
+
revParseOrNull,
|
|
31
|
+
runGitRaw,
|
|
32
|
+
mergeTree,
|
|
33
|
+
} = require('../lib/landing/git');
|
|
34
|
+
const { intFlag, strFlag } = require('../lib/env-flags');
|
|
35
|
+
const lease = require('../lib/landing/lease');
|
|
36
|
+
const queue = require('../lib/landing/queue');
|
|
37
|
+
const train = require('../lib/landing/train');
|
|
38
|
+
const gate = require('../lib/landing/gate');
|
|
39
|
+
const proof = require('../lib/landing/proof');
|
|
40
|
+
const mergeDelta = require('../lib/landing/merge-delta');
|
|
41
|
+
const batchRunner = require('../lib/landing/batch-runner');
|
|
42
|
+
const stop = require('../lib/landing/stop');
|
|
43
|
+
const radar = require('../lib/landing/radar');
|
|
44
|
+
const sync = require('../lib/landing/sync');
|
|
45
|
+
const syncCli = require('../lib/landing/sync-cli');
|
|
46
|
+
const resolveCli = require('../lib/landing/resolve-cli');
|
|
47
|
+
const { writeJsonAtomic } = require('../lib/landing/state');
|
|
48
|
+
const { loadLandingConfig, loadTargetLandingConfig, resolveMainBranch, gatesHash } = require('../lib/landing/config');
|
|
49
|
+
|
|
50
|
+
const CONTRACT = 1;
|
|
51
|
+
// `--wait` and `land wait` are bounded (the doc's 4 h); a queue that has not decided by then is a
|
|
52
|
+
// timeout (exit 75), never an endless poll.
|
|
53
|
+
const WAIT_TIMEOUT_MS = 4 * 60 * 60 * 1000;
|
|
54
|
+
const WAIT_TIMEOUT_S = WAIT_TIMEOUT_MS / 1000;
|
|
55
|
+
// Poll the job file at ≤ 1 s (§6.2), never a busy loop.
|
|
56
|
+
const POLL_MS = 200;
|
|
57
|
+
// §6.2 `land cancel --force` on a running job: how long the canceling process waits for the runner
|
|
58
|
+
// to observe the stop request and settle the job. The runner's heartbeat is 10 s and the SIGKILL
|
|
59
|
+
// grace is another 10 s, so 30 s covers the worst case with room to spare — and a runner that never
|
|
60
|
+
// settles is reported (exit 75), never waited on forever.
|
|
61
|
+
const CANCEL_WAIT_MS = 30000;
|
|
62
|
+
const CANCEL_POLL_MS = 250;
|
|
63
|
+
|
|
64
|
+
// D-10: the marked block written into the repository's agent file. Its content must be exactly
|
|
65
|
+
// `lib/landing/agent-rule.txt` with `<target>` substituted (WP-5's E-4 lint checks byte identity).
|
|
66
|
+
const MARK_BEGIN = '<!-- land:begin -->';
|
|
67
|
+
const MARK_END = '<!-- land:end -->';
|
|
68
|
+
const MARK_RE = /<!-- land:begin -->[\s\S]*?<!-- land:end -->/;
|
|
69
|
+
const AGENT_RULE_FILE = path.join(__dirname, '..', 'lib', 'landing', 'agent-rule.txt');
|
|
70
|
+
const AGENT_FILES = Object.freeze(['CLAUDE.md', 'AGENTS.md']);
|
|
71
|
+
|
|
72
|
+
const USAGE = [
|
|
73
|
+
'usage: land <command> [options]',
|
|
74
|
+
'',
|
|
75
|
+
' land init [--guard] [--agent-file CLAUDE.md|AGENTS.md]',
|
|
76
|
+
' land guard install',
|
|
77
|
+
' land submit [--branch <name>] [--title <text>] [--priority normal|high|urgent] [--reason <text>] [--queue-anyway] [--wait] [--json]',
|
|
78
|
+
' land run [--once]',
|
|
79
|
+
' land priority <jobId> normal|high|urgent [--reason <text>] [--json]',
|
|
80
|
+
' land status [--json] [--repo <path>] [--radar-for <branch>[,<branch>]]',
|
|
81
|
+
` land wait <jobId> [--timeout-s ${WAIT_TIMEOUT_S}]`,
|
|
82
|
+
' land inspect <jobId>',
|
|
83
|
+
' land cancel <jobId> [--force]',
|
|
84
|
+
' land conflicts [--with <branch>] [--json]',
|
|
85
|
+
' land claim <path>... [--ttl-h 4] [--session <id>] [--json]',
|
|
86
|
+
' land unclaim [--session <id>] [--json]',
|
|
87
|
+
' land record-proof --tree <oid> --plan-base <rev> --log <path> --seconds <n> [--selection <json-file>] [--commit <sha>] [--json]',
|
|
88
|
+
' land report merge-delta [--json]',
|
|
89
|
+
' land sync [--wait] [--pull-only] [--json]',
|
|
90
|
+
' land resolve <jobId> --worktree <dir> [--wait] [--json]',
|
|
91
|
+
' land resolve <jobId> --claim [--session <id>] [--json]',
|
|
92
|
+
' land doctor [--json]',
|
|
93
|
+
].join('\n');
|
|
94
|
+
|
|
95
|
+
const FLAG_SPECS = Object.freeze({
|
|
96
|
+
init: { bool: ['guard'], value: ['agent-file'] },
|
|
97
|
+
guard: { bool: [], value: [] },
|
|
98
|
+
submit: { bool: ['wait', 'json', 'queue-anyway'], value: ['branch', 'title', 'priority', 'reason'] },
|
|
99
|
+
run: { bool: ['once'], value: [] },
|
|
100
|
+
priority: { bool: ['json'], value: ['reason'] },
|
|
101
|
+
status: { bool: ['json'], value: ['repo', 'radar-for'] },
|
|
102
|
+
wait: { bool: [], value: ['timeout-s'] },
|
|
103
|
+
inspect: { bool: [], value: [] },
|
|
104
|
+
cancel: { bool: ['force'], value: [] },
|
|
105
|
+
conflicts: { bool: ['json'], value: ['with'] },
|
|
106
|
+
claim: { bool: ['json'], value: ['ttl-h', 'session'] },
|
|
107
|
+
unclaim: { bool: ['json'], value: ['session'] },
|
|
108
|
+
'record-proof': { bool: ['json'], value: ['tree', 'plan-base', 'log', 'seconds', 'selection', 'commit'] },
|
|
109
|
+
report: { bool: ['json'], value: [] },
|
|
110
|
+
sync: { bool: ['wait', 'json', 'pull-only'], value: [] },
|
|
111
|
+
resolve: { bool: ['wait', 'json', 'claim'], value: ['worktree', 'session'] },
|
|
112
|
+
doctor: { bool: ['json'], value: [] },
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// --- errors and output -------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
function usageError(message) {
|
|
118
|
+
const error = new Error(message);
|
|
119
|
+
error.code = 'usage';
|
|
120
|
+
return error;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function progress(line) {
|
|
124
|
+
process.stderr.write(`land: ${line}\n`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function emitJson(payload) {
|
|
128
|
+
process.stdout.write(`${JSON.stringify(payload)}\n`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function emitLine(line) {
|
|
132
|
+
process.stdout.write(`${line}\n`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// A real timer, never `state.sleepSync`: the runner's heartbeat is an interval, and blocking the
|
|
136
|
+
// event loop is exactly what it must not do.
|
|
137
|
+
function delay(ms) {
|
|
138
|
+
return new Promise((resolve) => { setTimeout(resolve, ms); });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function reportFailure(error, wantsJson) {
|
|
142
|
+
const code = typeof error.code === 'string' ? error.code : 'unexpected';
|
|
143
|
+
const message = error && error.message ? error.message : String(error);
|
|
144
|
+
if (code === 'usage') {
|
|
145
|
+
process.stderr.write(`land: ${message}\n\n${USAGE}\n`);
|
|
146
|
+
return 2;
|
|
147
|
+
}
|
|
148
|
+
// D-4 (§6.2): a submit refused because the branch conflicts with the target on a path the queue
|
|
149
|
+
// cannot regenerate is its OWN exit code (3), not the generic 1 — the caller has one action to
|
|
150
|
+
// take (`merge <target> into the branch`, or `--queue-anyway`) and a scripted agent must be able to
|
|
151
|
+
// tell that refusal apart from "the CLI broke". The paths ride in the payload so nothing has to
|
|
152
|
+
// re-run the merge to find out why.
|
|
153
|
+
if (code === 'conflict_at_submit') {
|
|
154
|
+
if (wantsJson) {
|
|
155
|
+
emitJson({
|
|
156
|
+
contract: CONTRACT,
|
|
157
|
+
ok: false,
|
|
158
|
+
error: code,
|
|
159
|
+
message,
|
|
160
|
+
paths: error.paths || [],
|
|
161
|
+
uncovered: error.uncovered || [],
|
|
162
|
+
covered: error.covered || [],
|
|
163
|
+
});
|
|
164
|
+
} else process.stderr.write(`land: error: ${code}: ${message}\n`);
|
|
165
|
+
return 3;
|
|
166
|
+
}
|
|
167
|
+
if (wantsJson) emitJson({ contract: CONTRACT, ok: false, error: { code, message } });
|
|
168
|
+
else process.stderr.write(`land: error: ${code}: ${message}\n`);
|
|
169
|
+
return 1;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// --- argv --------------------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
function parseArgs(argv, spec) {
|
|
175
|
+
const flags = new Set();
|
|
176
|
+
const values = {};
|
|
177
|
+
const positionals = [];
|
|
178
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
179
|
+
const arg = argv[index];
|
|
180
|
+
if (arg === '--') {
|
|
181
|
+
positionals.push(...argv.slice(index + 1));
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
if (!arg.startsWith('--')) {
|
|
185
|
+
positionals.push(arg);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
const equals = arg.indexOf('=');
|
|
189
|
+
const name = equals === -1 ? arg.slice(2) : arg.slice(2, equals);
|
|
190
|
+
const inline = equals === -1 ? null : arg.slice(equals + 1);
|
|
191
|
+
if (spec.bool.includes(name)) {
|
|
192
|
+
if (inline !== null) throw usageError(`--${name} takes no value`);
|
|
193
|
+
flags.add(name);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (spec.value.includes(name)) {
|
|
197
|
+
let value = inline;
|
|
198
|
+
if (value === null) {
|
|
199
|
+
value = argv[index + 1];
|
|
200
|
+
index += 1;
|
|
201
|
+
}
|
|
202
|
+
if (value === undefined || value.startsWith('--')) throw usageError(`--${name} requires a value`);
|
|
203
|
+
values[name] = value;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
throw usageError(`unknown option --${name}`);
|
|
207
|
+
}
|
|
208
|
+
return { flags, values, positionals };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// --- repository context ------------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
function context(cwd) {
|
|
214
|
+
const top = toplevel(cwd);
|
|
215
|
+
const common = commonDir(cwd);
|
|
216
|
+
return { cwd, top, common, root: path.join(common, 'landing') };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function branchRef(name) {
|
|
220
|
+
return `refs/heads/${name}`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function lines(stdout) {
|
|
224
|
+
return String(stdout || '').split('\n').map((line) => line.trim()).filter(Boolean);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function currentBranch(cwd) {
|
|
228
|
+
const res = runGitRaw(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
229
|
+
if (res.status !== 0) {
|
|
230
|
+
throw landingError('git_failed', `cannot resolve the current branch in ${cwd}`, { cwd, stderr: res.stderr });
|
|
231
|
+
}
|
|
232
|
+
const name = res.stdout.trim();
|
|
233
|
+
if (!name || name === 'HEAD') {
|
|
234
|
+
throw landingError('detached_head', `${cwd} is on a detached HEAD; check out a branch before submitting`, { cwd });
|
|
235
|
+
}
|
|
236
|
+
return name;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function unmergedPaths(cwd) {
|
|
240
|
+
return lines(runGitRaw(cwd, ['diff', '--name-only', '--diff-filter=U']).stdout);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function dirtyTrackedPaths(cwd) {
|
|
244
|
+
const unstaged = lines(runGitRaw(cwd, ['diff', '--name-only']).stdout);
|
|
245
|
+
const staged = lines(runGitRaw(cwd, ['diff', '--cached', '--name-only']).stdout);
|
|
246
|
+
return [...new Set([...unstaged, ...staged])];
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function commitSubject(cwd, rev) {
|
|
250
|
+
const res = runGitRaw(cwd, ['log', '-1', '--format=%s', rev]);
|
|
251
|
+
const subject = res.status === 0 ? res.stdout.trim() : '';
|
|
252
|
+
return subject || rev.slice(0, 12);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function submitterWorkdir(ctx, job) {
|
|
256
|
+
const dir = job && job.submittedBy ? job.submittedBy.cwd : null;
|
|
257
|
+
if (dir && fs.existsSync(dir) && fs.existsSync(path.join(dir, '.git'))) return dir;
|
|
258
|
+
return ctx.cwd;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// --- D-4/D-5: conflict previews (§9 S-7/S-8) -----------------------------------------------------
|
|
262
|
+
|
|
263
|
+
// The conflict paths the queue itself owns — a `landing.regenerate[]` or `landing.refresh[]` entry's
|
|
264
|
+
// glob covers them, so the runner resolves them without a human. Everything else is hand-written and
|
|
265
|
+
// is what D-4 refuses at submit. `train.globMatch` is the very matcher the runner uses to decide
|
|
266
|
+
// `matchingRegenerateEntries`, so the precheck and the actual resolve can never disagree.
|
|
267
|
+
function pathCoverage(config, conflicts) {
|
|
268
|
+
const entries = []
|
|
269
|
+
.concat(config && Array.isArray(config.regenerate) ? config.regenerate : [])
|
|
270
|
+
.concat(config && Array.isArray(config.refresh) ? config.refresh : []);
|
|
271
|
+
const owns = (file) => entries.some((entry) => Array.isArray(entry.paths) && entry.paths.some((pattern) => train.globMatch(pattern, file)));
|
|
272
|
+
return { covered: conflicts.filter(owns), uncovered: conflicts.filter((file) => !owns(file)) };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// D-5 (§9 S-8): after every landing the target has moved, so every WAITING job's train is rebuilt
|
|
276
|
+
// against the new tip — `merge-tree` only, no worktree and no commit object — and its
|
|
277
|
+
// `conflictPreview` records what it would now conflict on. The sweep is advisory by construction: it
|
|
278
|
+
// never cancels, requeues or reorders anything, and a single uncomputable merge-tree skips that job
|
|
279
|
+
// (which keeps the preview it already had) instead of failing the landing that triggered the sweep.
|
|
280
|
+
function sweepConflictPreviews(ctx, workdir, config) {
|
|
281
|
+
const tip = revParseOrNull(workdir, branchRef(config.target));
|
|
282
|
+
if (!tip) return 0;
|
|
283
|
+
let written = 0;
|
|
284
|
+
for (const id of queue.readQueue(ctx.root).order) {
|
|
285
|
+
const job = queue.readJob(ctx.root, id);
|
|
286
|
+
if (!job || job.state !== 'queued') continue;
|
|
287
|
+
let merged;
|
|
288
|
+
try {
|
|
289
|
+
merged = mergeTree(workdir, tip, job.tip);
|
|
290
|
+
} catch (error) {
|
|
291
|
+
/* REPORT: the sweep is advisory — `runJob` re-decides the real conflict when the job is taken,
|
|
292
|
+
so one job whose merge-tree cannot be computed is skipped and keeps its previous preview */
|
|
293
|
+
progress(`job ${id}: conflict preview skipped (${error && error.message ? error.message : String(error)})`);
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
const { uncovered } = pathCoverage(config, merged.conflicts);
|
|
297
|
+
const next = uncovered.length ? { base: tip, paths: merged.conflicts, checkedAt: queue.nowIso() } : null;
|
|
298
|
+
const before = job.conflictPreview === undefined ? null : job.conflictPreview;
|
|
299
|
+
if (previewSignature(before) === previewSignature(next)) continue;
|
|
300
|
+
queue.updateJob(ctx.root, id, { conflictPreview: next });
|
|
301
|
+
written += 1;
|
|
302
|
+
progress(next ? `job ${id}: will conflict on ${next.paths.join(', ')}` : `job ${id}: conflict cleared`);
|
|
303
|
+
}
|
|
304
|
+
return written;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// `checkedAt` moves on every sweep, so the change test compares the two facts a reader acts on — the
|
|
308
|
+
// base the preview was measured against and the paths it names — never the timestamp.
|
|
309
|
+
function previewSignature(preview) {
|
|
310
|
+
if (!preview) return '-';
|
|
311
|
+
const paths = Array.isArray(preview.paths) ? preview.paths.join(',') : '';
|
|
312
|
+
return `${preview.base || ''}:${paths}`;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// The start-up half of D-5: a runner that has just claimed the lease previews the queue it is about
|
|
316
|
+
// to drain, so a session waiting behind a target that moved under a PREVIOUS runner learns about it
|
|
317
|
+
// before the next `takeNextJob`. A policy this process cannot read yet is not a runner failure — the
|
|
318
|
+
// per-job path re-reads and enforces its own policy.
|
|
319
|
+
function sweepOnRunnerStart(ctx) {
|
|
320
|
+
let config;
|
|
321
|
+
try {
|
|
322
|
+
config = loadTargetLandingConfig(ctx.cwd);
|
|
323
|
+
} catch (error) {
|
|
324
|
+
/* REPORT: the start-up sweep is advisory; `runJobInner` loads (and enforces) the target policy
|
|
325
|
+
for the job it actually takes, so an unreadable policy here only means "no previews yet" */
|
|
326
|
+
progress(`start-up conflict sweep skipped (${error && error.code ? error.code : 'unexpected'})`);
|
|
327
|
+
return 0;
|
|
328
|
+
}
|
|
329
|
+
return sweepConflictPreviews(ctx, ctx.cwd, config);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// --- the runner (D-2) --------------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
// D-2: the runner's liveness record. The interval is what keeps `heartbeatAt` fresh, and it works
|
|
335
|
+
// only because gates run through an async `spawn` — the runner's event loop is free while a gate
|
|
336
|
+
// works, so a 40-minute gate is 40 minutes of heartbeats rather than one long silence. `unref()`
|
|
337
|
+
// because the lease, not the timer, decides when this process stops being the runner.
|
|
338
|
+
function startHeartbeat(root, token, current) {
|
|
339
|
+
return setInterval(() => {
|
|
340
|
+
try {
|
|
341
|
+
lease.heartbeat(root, token, { ...(current.jobId ? { jobId: current.jobId } : {}), jobIds: current.jobIds || null });
|
|
342
|
+
} catch (error) {
|
|
343
|
+
/* expected: the lease was reaped or replaced while this runner worked — the drain loop
|
|
344
|
+
re-checks the on-disk token before every job and stops rather than writing to a lease it
|
|
345
|
+
no longer holds */
|
|
346
|
+
}
|
|
347
|
+
// D-5: the same tick that keeps the lease fresh is the tick that reads the job's stop request.
|
|
348
|
+
// There is no new timer — the runner already polls every 10 s while a gate works — and the
|
|
349
|
+
// abort signal is what every `runShell` of this job (gates and regenerate entries) is watching.
|
|
350
|
+
if (!current.controller || current.controller.signal.aborted) return;
|
|
351
|
+
// D-2 (§9 S-1, WP-1): the predicate lives in lib/landing/stop.js. A `cancel` on ANY member voids
|
|
352
|
+
// the train's verdict (I-4); a preempt keeps the boundary rule, and a singleton satisfies "every".
|
|
353
|
+
const members = Array.isArray(current.jobIds) && current.jobIds.length
|
|
354
|
+
? current.jobIds
|
|
355
|
+
: (current.jobId ? [current.jobId] : []);
|
|
356
|
+
if (!members.length) return;
|
|
357
|
+
try {
|
|
358
|
+
if (stop.shouldAbortTrain(members.map((id) => queue.readJob(root, id)))) current.controller.abort();
|
|
359
|
+
} catch (error) {
|
|
360
|
+
/* expected: the job file can be mid-rename for an instant, or the directory is being torn
|
|
361
|
+
down under a test — a read error must never kill the interval that keeps this runner's
|
|
362
|
+
lease alive, and the next tick reads again */
|
|
363
|
+
}
|
|
364
|
+
}, lease.LAND_HEARTBEAT_MS);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// D-7 (WP-2): the CURRENT target tip, the thing a `base_broken` job waits for. It is the target the
|
|
368
|
+
// committed policy names (the same loader `runJobInner` gates every job with), resolved fresh each
|
|
369
|
+
// drain iteration so the hold is decided on the tip as it is now, not as it was when the job was
|
|
370
|
+
// parked. A policy this process cannot read holds nothing (`null`) — `runJobInner` refuses such a job
|
|
371
|
+
// with the same error a moment later, and parking work forever on a comparison that failed open would
|
|
372
|
+
// be worse than running it.
|
|
373
|
+
function targetTipOf(ctx) {
|
|
374
|
+
try {
|
|
375
|
+
return revParseOrNull(ctx.cwd, branchRef(loadTargetLandingConfig(ctx.cwd).target));
|
|
376
|
+
} catch (error) {
|
|
377
|
+
/* REPORT: unreadable policy/target — no hold this iteration; the job itself surfaces the error */
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// WP-1: the batched runner lives in lib/landing/batch-runner.js and runs on this file's helpers.
|
|
383
|
+
batchRunner.configure({ runJob, progress, submitterWorkdir, gateSelectionTelemetry, sweepConflictPreviews, branchRef, delay, afterTrainLanded: syncCli.afterTrainLanded });
|
|
384
|
+
|
|
385
|
+
async function drain(ctx, record, opts = {}) {
|
|
386
|
+
const token = record.token;
|
|
387
|
+
const current = { jobId: null, jobIds: null, controller: null };
|
|
388
|
+
const timer = startHeartbeat(ctx.root, token, current);
|
|
389
|
+
timer.unref();
|
|
390
|
+
let processed = 0;
|
|
391
|
+
let released = false;
|
|
392
|
+
try {
|
|
393
|
+
// D-5 (§9 S-8): preview the queue this runner inherited, before it takes the first job.
|
|
394
|
+
sweepOnRunnerStart(ctx);
|
|
395
|
+
for (;;) {
|
|
396
|
+
const held = lease.read(ctx.root);
|
|
397
|
+
if (!held || held.token !== token) {
|
|
398
|
+
progress('lease lost; stopping the runner');
|
|
399
|
+
break;
|
|
400
|
+
}
|
|
401
|
+
const targetTip = targetTipOf(ctx);
|
|
402
|
+
const window = await batchRunner.chooseWindow(ctx, targetTip);
|
|
403
|
+
const members = queue.takeNextBatch(ctx.root, {
|
|
404
|
+
targetTip,
|
|
405
|
+
window,
|
|
406
|
+
onEmpty: () => { released = lease.release(ctx.root, token); },
|
|
407
|
+
});
|
|
408
|
+
if (!members.length) break;
|
|
409
|
+
current.jobId = members[0].id;
|
|
410
|
+
current.jobIds = members.map((member) => member.id);
|
|
411
|
+
// D-5: one controller per train (stop.shouldAbortTrain); jobIds reach the lease NOW for `land cancel`.
|
|
412
|
+
try { lease.heartbeat(ctx.root, token, { jobId: current.jobId, jobIds: current.jobIds }); } catch (error) { /* expected: lease reaped/replaced — the drain loop re-checks the token before every job */ }
|
|
413
|
+
current.controller = new AbortController();
|
|
414
|
+
try {
|
|
415
|
+
await batchRunner.runTrain(ctx, members, token, current.controller.signal);
|
|
416
|
+
} finally {
|
|
417
|
+
current.controller = null;
|
|
418
|
+
current.jobId = null; current.jobIds = null;
|
|
419
|
+
try { lease.heartbeat(ctx.root, token, { jobId: null, jobIds: null }); } catch (error) { /* expected: lease reaped/replaced — nothing of this train's left to clear */ }
|
|
420
|
+
}
|
|
421
|
+
processed += members.length;
|
|
422
|
+
if (opts.once) break;
|
|
423
|
+
}
|
|
424
|
+
} finally {
|
|
425
|
+
clearInterval(timer);
|
|
426
|
+
if (!released) lease.release(ctx.root, token);
|
|
427
|
+
}
|
|
428
|
+
return processed;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// WP-0 (§5 D-8, §6.6, §8 WP-0): the shape `job.gate` and the terminal metrics row's `gate` share,
|
|
432
|
+
// built from the gate's own `--selection-out` record. A gate that wrote nothing (an older gate, or
|
|
433
|
+
// one that could not write) reads as `null` — "unknown", never a row of zeros. `fallbackReason` and
|
|
434
|
+
// `intersection` belong to WP-1/WP-3 and stay null until those exist.
|
|
435
|
+
function gateSelectionTelemetry(selection) {
|
|
436
|
+
if (!selection || typeof selection !== 'object') return null;
|
|
437
|
+
const finite = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : null);
|
|
438
|
+
return {
|
|
439
|
+
mode: typeof selection.mode === 'string' ? selection.mode : null,
|
|
440
|
+
planBase: typeof selection.planBase === 'string' ? selection.planBase : null,
|
|
441
|
+
selected: finite(selection.selected),
|
|
442
|
+
executed: finite(selection.executed),
|
|
443
|
+
cached: finite(selection.cached),
|
|
444
|
+
// §6.6 calls the cache-miss map `missReasons`; bin/test-gate.sh writes it as `uncached`.
|
|
445
|
+
missReasons: selection.uncached && typeof selection.uncached === 'object' ? selection.uncached : null,
|
|
446
|
+
fallbackReason: null,
|
|
447
|
+
intersection: null,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// The gate's own timeout, the bound the base rerun inherits: the longest `timeoutSeconds` among the
|
|
452
|
+
// configured gates (there is one in practice), in milliseconds; undefined when none is set, which
|
|
453
|
+
// `runShell` treats as unbounded exactly like a gate with no timeout.
|
|
454
|
+
function gateTimeoutMs(config) {
|
|
455
|
+
const seconds = (Array.isArray(config.gates) ? config.gates : [])
|
|
456
|
+
.map((gate) => Number(gate && gate.timeoutSeconds))
|
|
457
|
+
.filter((value) => Number.isFinite(value) && value > 0);
|
|
458
|
+
return seconds.length ? Math.max(...seconds) * 1000 : undefined;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// D-6/D-7 (WP-2, design §5): the base-rerun diagnosis. A FAILING gate names the test files that
|
|
462
|
+
// failed; before that is reported as THIS branch's failure, exactly those files are rerun on the
|
|
463
|
+
// BASE commit — Chromium's with-patch/without-patch rule: a failure that reproduces WITHOUT the patch
|
|
464
|
+
// is not the patch's failure, it is a broken main. Returns `{ triage, gateSeconds, requeued }`:
|
|
465
|
+
// `triage` is what the job record carries (null when the pass did not run), `gateSeconds` has the
|
|
466
|
+
// rerun added to the gate's own, and `requeued: true` means the job was parked `base_broken` via
|
|
467
|
+
// `queue.requeueBaseBroken` and the caller must NOT also finish it as failed (the record already
|
|
468
|
+
// holds the outcome, and the runner will pick the job up again once the target tip moves).
|
|
469
|
+
//
|
|
470
|
+
// The pass is SKIPPED — reason untouched, `job.triage = null`, one `triage skipped (<why>)` line —
|
|
471
|
+
// when the gate reason is not a test failure (`gate_timeout`/`gate_dirtied_tree`/`gate_stopped` name
|
|
472
|
+
// no files), when the verdict came from the cache (nothing ran, so there is no fresh log), when the
|
|
473
|
+
// repo turned it off (`landing.triage: false` → `maxFiles` 0), when the log names no test file at all
|
|
474
|
+
// (a lint-only failure — lint is deterministic on the train tree and is never rerun on base), and
|
|
475
|
+
// when it names more than `triage.maxFiles` files (a red main wide enough that a rerun would cost as
|
|
476
|
+
// much as the gate it is diagnosing). A rerun that cannot RUN is not a verdict about base either: the
|
|
477
|
+
// failure is reported exactly as the gate found it.
|
|
478
|
+
async function triageFailedJob(ctx, { config, job, cached, reason, jobVerdict, built, logFile, workdir, signal, gateSeconds }) {
|
|
479
|
+
const skip = (why) => {
|
|
480
|
+
progress(`job ${job.id}: triage skipped (${why})`);
|
|
481
|
+
queue.updateJob(ctx.root, job.id, { triage: null });
|
|
482
|
+
return { triage: null, gateSeconds, requeued: false };
|
|
483
|
+
};
|
|
484
|
+
if (cached || (reason !== 'train_only' && reason !== 'gate_failed') || !(config.triage.maxFiles > 0)) {
|
|
485
|
+
return { triage: null, gateSeconds, requeued: false };
|
|
486
|
+
}
|
|
487
|
+
let parsed;
|
|
488
|
+
try {
|
|
489
|
+
parsed = train.failedFilesFromLog(fs.readFileSync(logFile, 'utf8'));
|
|
490
|
+
} catch (error) {
|
|
491
|
+
/* REPORT: a diagnosis that could not be made leaves the failure exactly as the gate reported it,
|
|
492
|
+
with `triage: null` saying so — never a hold on a base this run never measured */
|
|
493
|
+
return skip(`gate log unreadable: ${error.message}`);
|
|
494
|
+
}
|
|
495
|
+
if (!parsed.tests.length) return skip(parsed.lint ? 'lint failures are not rerun on base' : 'the gate log named no test file');
|
|
496
|
+
if (parsed.tests.length > config.triage.maxFiles) {
|
|
497
|
+
return skip(`${parsed.tests.length} failing files > triage.maxFiles ${config.triage.maxFiles}`);
|
|
498
|
+
}
|
|
499
|
+
let rerun;
|
|
500
|
+
try {
|
|
501
|
+
rerun = await train.rerunOnBase(workdir, {
|
|
502
|
+
base: built.base, files: parsed.tests, root: ctx.root, jobId: job.id, signal, cachePaths: config.cachePaths,
|
|
503
|
+
// D-7: the rerun is bounded by the same timeout as the gate it diagnoses (per launcher).
|
|
504
|
+
timeoutMs: gateTimeoutMs(config),
|
|
505
|
+
});
|
|
506
|
+
} catch (error) {
|
|
507
|
+
/* REPORT: rerunning is diagnostic, and a diagnostic that failed must not invent an outcome */
|
|
508
|
+
return skip(`rerun on base failed: ${error.message}`);
|
|
509
|
+
}
|
|
510
|
+
const triage = { base: built.base, files: parsed.tests.slice(), failedOnBase: rerun.failedOnBase, seconds: rerun.seconds };
|
|
511
|
+
const totalGateSeconds = Number(((Number(gateSeconds) || 0) + rerun.seconds).toFixed(3));
|
|
512
|
+
// A stopped rerun is not a verdict (the runner is being stopped): keep the failure, record what ran.
|
|
513
|
+
if (rerun.aborted) return { triage, gateSeconds: totalGateSeconds, requeued: false };
|
|
514
|
+
const everyOnBase = rerun.failedOnBase.length > 0 && parsed.tests.every((file) => rerun.failedOnBase.includes(file));
|
|
515
|
+
if (!everyOnBase) return { triage, gateSeconds: totalGateSeconds, requeued: false };
|
|
516
|
+
progress(`job ${job.id}: base_broken — ${rerun.failedOnBase.length} file(s) fail on base too; left queued until ${config.target} moves`);
|
|
517
|
+
queue.requeueBaseBroken(ctx.root, job.id, {
|
|
518
|
+
reason: 'base_broken',
|
|
519
|
+
verdict: jobVerdict,
|
|
520
|
+
gateLog: logFile,
|
|
521
|
+
triage,
|
|
522
|
+
timestamps: { ...job.timestamps, gated: queue.nowIso() },
|
|
523
|
+
}, { seconds: null, gateSeconds: totalGateSeconds });
|
|
524
|
+
return { triage, gateSeconds: totalGateSeconds, requeued: true };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Anything unexpected still leaves the queue in a decidable state: the job is `failed` with the
|
|
528
|
+
// typed code recorded, never stuck in `in_progress` for a later runner to reap.
|
|
529
|
+
async function runJob(ctx, job, token, signal) {
|
|
530
|
+
try {
|
|
531
|
+
return await runJobInner(ctx, job, token, signal);
|
|
532
|
+
} catch (error) {
|
|
533
|
+
const code = typeof error.code === 'string' ? error.code : 'internal_error';
|
|
534
|
+
progress(`job ${job.id}: ${code}: ${error.message}`);
|
|
535
|
+
return queue.finishJob(ctx.root, job.id, {
|
|
536
|
+
state: 'failed',
|
|
537
|
+
reason: null,
|
|
538
|
+
error: { code, message: error.message },
|
|
539
|
+
timestamps: { ...job.timestamps, gated: queue.nowIso() },
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async function runJobInner(ctx, job, token, signal) {
|
|
545
|
+
const workdir = submitterWorkdir(ctx, job);
|
|
546
|
+
// D-1/I-5: the policy this job is gated by is the `.ctm.json` COMMITTED AT THE TARGET, never the
|
|
547
|
+
// submitter's working tree. Reading the branch's own file would let any branch weaken the gate it
|
|
548
|
+
// is landed under (`gates: [{run: "echo ok"}]`) — a policy change has to land before it counts.
|
|
549
|
+
let config = loadTargetLandingConfig(workdir);
|
|
550
|
+
// WP-6 §13.4 R-R4/R-R6 (§13.7): a resolution of a CONFLICT is re-gated by the FAST gate instead of
|
|
551
|
+
// `landing.gates`; a resolution of a GATE FAILURE keeps the full gate and falls through. The copy
|
|
552
|
+
// replaces the whole `gates` array for the entire job, so every key derived from it —
|
|
553
|
+
// `gatesHash(config.gates)` for the verdict cache and the proof record — is the fast gate's, and
|
|
554
|
+
// never the commit gate's.
|
|
555
|
+
if (job.kind === 'resolve' && job.gateLane === 'fast' && config.publish && config.publish.resolveFastGate) {
|
|
556
|
+
config = { ...config, gates: [config.publish.resolveFastGate] };
|
|
557
|
+
}
|
|
558
|
+
const trainsRoot = train.trainsRoot(workdir, ctx.common).dir;
|
|
559
|
+
const trainDir = train.trainDirFor(trainsRoot, job.id);
|
|
560
|
+
// `landing/logs/` is not in state.js's SUBDIRS, so it is created here — before the first thing that
|
|
561
|
+
// can append to it (the D-8 regenerate log, which happens before the gate ever runs).
|
|
562
|
+
const logFile = path.join(queue.ensureLogsDir(ctx.root), `${job.id}.log`);
|
|
563
|
+
const targetWorktree = train.targetWorktreeFor(workdir, config.target);
|
|
564
|
+
const sourceDir = targetWorktree ? targetWorktree.path : workdir;
|
|
565
|
+
const foreignCommits = [];
|
|
566
|
+
let gateSeconds = null;
|
|
567
|
+
// WP-0 (design doc §5 D-8, §6.6, §8 WP-0, §9 S-13): the gate's self-description, spread onto the
|
|
568
|
+
// job record and onto this run's terminal metrics row. `null` means "no gate ran for this
|
|
569
|
+
// transition" — an `unchanged` job is `mode: 'none'`, a verdict-cache hit is `mode: 'cached'`.
|
|
570
|
+
// Reset at the top of every round so a later round can never inherit an earlier round's numbers.
|
|
571
|
+
let gateTelemetry = null;
|
|
572
|
+
let lastBuilt = null;
|
|
573
|
+
// Where the enforced policy came from — recorded so `land inspect` can say why a job was gated by
|
|
574
|
+
// rules its own branch does not contain.
|
|
575
|
+
queue.updateJob(ctx.root, job.id, { policySource: config.policySource });
|
|
576
|
+
|
|
577
|
+
// D-5: re-read this job's stop request off disk. Either kind is a stop: `kind:'cancel'` never
|
|
578
|
+
// lands, and `kind:'preempt'` (written by `urgent`, WP-3) sends the job back to the queue — unless
|
|
579
|
+
// the work is already done, which is the R-3 window handled at checkpoint 3 and 4 below.
|
|
580
|
+
const pendingStop = () => {
|
|
581
|
+
const fresh = queue.readJob(ctx.root, job.id);
|
|
582
|
+
return (fresh && fresh.stopRequest) || null;
|
|
583
|
+
};
|
|
584
|
+
// D-5: the cancel transition. `finishJob` re-reads the job under the mutex and spreads the patch,
|
|
585
|
+
// so the base/tree/train already recorded for this run survive.
|
|
586
|
+
const finishCanceled = (request) => {
|
|
587
|
+
progress(`job ${job.id}: stopped before the target moved`);
|
|
588
|
+
return queue.finishJob(ctx.root, job.id, {
|
|
589
|
+
state: 'canceled',
|
|
590
|
+
reason: 'stopped',
|
|
591
|
+
canceledBy: request.by,
|
|
592
|
+
stopRequest: null,
|
|
593
|
+
timestamps: { ...job.timestamps, canceled: queue.nowIso() },
|
|
594
|
+
}, { gateSeconds, gate: gateTelemetry });
|
|
595
|
+
};
|
|
596
|
+
// D-5 for a PREEMPT: the job is not terminal — it goes back to the queue (D-4/D-3) and the runner's
|
|
597
|
+
// next `takeNextJob` takes the urgent job, which sorts at the head. A cancel keeps winning over
|
|
598
|
+
// everything, at every checkpoint.
|
|
599
|
+
const stopBeforeLanding = (request) => {
|
|
600
|
+
if (request.kind === 'cancel') return finishCanceled(request);
|
|
601
|
+
progress(`job ${job.id}: preempted by ${request.forJob}; back to the queue`);
|
|
602
|
+
return queue.requeuePreempted(ctx.root, job.id);
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
for (let round = 1; round <= train.MAX_ROUNDS; round += 1) {
|
|
606
|
+
gateTelemetry = null;
|
|
607
|
+
const fresh = queue.readJob(ctx.root, job.id);
|
|
608
|
+
if (!fresh) return null;
|
|
609
|
+
// Checkpoint 1 (D-5): a job a dead runner left `canceled` directly is honoured as before ...
|
|
610
|
+
if (fresh.state === 'canceled') {
|
|
611
|
+
progress(`job ${job.id}: canceled before round ${round}`);
|
|
612
|
+
return fresh;
|
|
613
|
+
}
|
|
614
|
+
// ... and the round start is also where a stop request that arrived between jobs is seen.
|
|
615
|
+
const requestAtRoundStart = pendingStop();
|
|
616
|
+
if (requestAtRoundStart) return stopBeforeLanding(requestAtRoundStart);
|
|
617
|
+
|
|
618
|
+
let built = train.buildTrain(workdir, config.target, job);
|
|
619
|
+
lastBuilt = built;
|
|
620
|
+
progress(`job ${job.id} round ${round}/${train.MAX_ROUNDS}: ${built.kind} on ${built.base.slice(0, 12)}`);
|
|
621
|
+
|
|
622
|
+
// Checkpoint 2 (D-5), first half: the train has been built — a cancel that arrived while it was
|
|
623
|
+
// built stops the job before it can land (`unchanged`) or be acted on (`conflict`).
|
|
624
|
+
const requestAfterBuild = pendingStop();
|
|
625
|
+
if (requestAfterBuild) return stopBeforeLanding(requestAfterBuild);
|
|
626
|
+
|
|
627
|
+
if (built.kind === 'unchanged') {
|
|
628
|
+
// WP-0: no gate ran at all — the train is empty. `mode: 'none'` says exactly that, so a
|
|
629
|
+
// metrics reader never has to infer "no gate" from a missing field.
|
|
630
|
+
const noneGate = {
|
|
631
|
+
mode: 'none', planBase: null, selected: null, executed: null, cached: null,
|
|
632
|
+
missReasons: null, fallbackReason: null, intersection: null,
|
|
633
|
+
};
|
|
634
|
+
const unchanged = queue.finishJob(ctx.root, job.id, {
|
|
635
|
+
state: 'landed',
|
|
636
|
+
reason: 'unchanged',
|
|
637
|
+
base: built.base,
|
|
638
|
+
tree: built.tree,
|
|
639
|
+
train: built.train,
|
|
640
|
+
verdict: null,
|
|
641
|
+
gate: noneGate,
|
|
642
|
+
timestamps: { ...job.timestamps, landed: queue.nowIso() },
|
|
643
|
+
}, { gateSeconds, gate: noneGate });
|
|
644
|
+
// D-5: the target did not move, but a job finishing is still a change to the queue's state —
|
|
645
|
+
// and the sweep is idempotent (a job whose preview did not change is not written at all).
|
|
646
|
+
sweepConflictPreviews(ctx, workdir, config);
|
|
647
|
+
return unchanged;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
if (built.kind === 'conflict') {
|
|
651
|
+
const entries = train.matchingRegenerateEntries(config, built.conflicts);
|
|
652
|
+
if (!entries) {
|
|
653
|
+
progress(`job ${job.id}: conflict in ${built.conflicts.join(', ')} — blocked`);
|
|
654
|
+
return queue.finishJob(ctx.root, job.id, {
|
|
655
|
+
state: 'blocked',
|
|
656
|
+
reason: 'conflict',
|
|
657
|
+
conflictPaths: built.conflicts,
|
|
658
|
+
base: built.base,
|
|
659
|
+
tree: built.tree,
|
|
660
|
+
train: null,
|
|
661
|
+
gate: null,
|
|
662
|
+
timestamps: { ...job.timestamps, gated: queue.nowIso() },
|
|
663
|
+
}, { gateSeconds, gate: gateTelemetry });
|
|
664
|
+
}
|
|
665
|
+
try {
|
|
666
|
+
built = await train.regenerateTrain({
|
|
667
|
+
cwd: workdir, dir: trainDir, job, base: built.base, conflicts: built.conflicts, entries, log: logFile, signal,
|
|
668
|
+
});
|
|
669
|
+
} catch (error) {
|
|
670
|
+
// Checkpoint 2 (D-5), second half: a `stopped` error from a regenerate entry is a STOP, not
|
|
671
|
+
// a failure — the entry was killed because this job's abort signal fired. Anything else (a
|
|
672
|
+
// real regenerate failure) is rethrown and becomes the job's failure.
|
|
673
|
+
if (error && error.code === 'stopped') {
|
|
674
|
+
const requestDuringRegenerate = pendingStop();
|
|
675
|
+
if (requestDuringRegenerate) return stopBeforeLanding(requestDuringRegenerate);
|
|
676
|
+
}
|
|
677
|
+
throw error;
|
|
678
|
+
}
|
|
679
|
+
lastBuilt = built;
|
|
680
|
+
progress(`job ${job.id}: regenerated ${built.regenerated.join(', ')}`);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// D-2/D-3 (WP-0): the refresh pass. Unlike regenerate this is not limited to a conflict — a
|
|
684
|
+
// tree-derived pin (the closure policy's `sourceBindings` digests) is stale the moment EITHER
|
|
685
|
+
// side touches a bound source, which is exactly the "one side changed server.js" case that never
|
|
686
|
+
// produces a textual conflict. The post-refresh tree is what the gate sees, what
|
|
687
|
+
// `readCachedVerdict` keys on, what `job.tree` records and what the CAS fast-forwards.
|
|
688
|
+
const refreshEntries = train.matchingRefreshEntries(config);
|
|
689
|
+
if (built.kind === 'train' && refreshEntries.length) {
|
|
690
|
+
try {
|
|
691
|
+
built = await train.refreshTrain({
|
|
692
|
+
cwd: workdir, dir: trainDir, job, built, entries: refreshEntries, log: logFile, signal,
|
|
693
|
+
});
|
|
694
|
+
} catch (error) {
|
|
695
|
+
// Same stop checkpoint as the regenerate block above: a `stopped` entry is a stop, never a
|
|
696
|
+
// failure; anything else (a real refresh failure) is rethrown and becomes the job's failure.
|
|
697
|
+
if (error && error.code === 'stopped') {
|
|
698
|
+
const requestDuringRefresh = pendingStop();
|
|
699
|
+
if (requestDuringRefresh) return stopBeforeLanding(requestDuringRefresh);
|
|
700
|
+
}
|
|
701
|
+
throw error;
|
|
702
|
+
}
|
|
703
|
+
lastBuilt = built;
|
|
704
|
+
if (built.refreshed.length) progress(`job ${job.id}: refreshed ${built.refreshed.join(', ')}`);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// WP-2 (§5 D-4, §9 S-7): before the legacy tree-keyed cache, ask the PROOF store. A fast-forward
|
|
708
|
+
// train (`built.train === job.tip`) is exactly what the author's own commit gate gated — the
|
|
709
|
+
// hook records `<tree>.<planBaseTree>` for it (§5 D-1, §9 S-5) — so a train whose whole chain is
|
|
710
|
+
// proven lands with no gate run at all. When the target moved, `built.train` is a merge commit
|
|
711
|
+
// the branch never proved, and the chain is not even consulted.
|
|
712
|
+
let proven = null;
|
|
713
|
+
if (built.train === job.tip) {
|
|
714
|
+
const chain = proof.proofChain(workdir, ctx.root, {
|
|
715
|
+
base: built.base, tip: job.tip, gatesHash: gatesHash(config.gates),
|
|
716
|
+
});
|
|
717
|
+
if (chain.ok) proven = chain;
|
|
718
|
+
}
|
|
719
|
+
// D-4: only a PASS is reusable (`readCachedVerdict` refuses to replay a FAIL, so a flake is
|
|
720
|
+
// retryable by resubmitting). A cached PASS skips the gate entirely. A PROVEN tree is stronger
|
|
721
|
+
// than a cached one, so the legacy store is not consulted for it.
|
|
722
|
+
const cached = proven ? null : gate.readCachedVerdict(ctx.root, built.tree, gatesHash(config.gates));
|
|
723
|
+
let verdict;
|
|
724
|
+
let reason = null;
|
|
725
|
+
if (proven) {
|
|
726
|
+
verdict = 'PASS';
|
|
727
|
+
reason = null;
|
|
728
|
+
gateSeconds = 0;
|
|
729
|
+
gateTelemetry = {
|
|
730
|
+
mode: 'proven', planBase: built.base, selected: null, executed: 0, cached: null,
|
|
731
|
+
missReasons: null, fallbackReason: null, intersection: null,
|
|
732
|
+
};
|
|
733
|
+
progress(`job ${job.id}: verdict PROVEN (proof chain of ${proven.checked} commits)`);
|
|
734
|
+
} else if (cached) {
|
|
735
|
+
verdict = cached.verdict;
|
|
736
|
+
reason = null;
|
|
737
|
+
gateSeconds = cached.seconds;
|
|
738
|
+
// WP-0: a verdict-cache hit ran no gate for this job, so it reports the mode that says so.
|
|
739
|
+
gateTelemetry = {
|
|
740
|
+
mode: 'cached', planBase: null, selected: null, executed: null, cached: null,
|
|
741
|
+
missReasons: null, fallbackReason: null, intersection: null,
|
|
742
|
+
};
|
|
743
|
+
progress(`job ${job.id}: verdict CACHED (${verdict})`);
|
|
744
|
+
// WP-0: a regenerate/refresh pass materialized the train worktree and only `gate.runGate`'s
|
|
745
|
+
// finally removes it; a cached PASS never runs the gate, so the worktree is dropped here.
|
|
746
|
+
if (fs.existsSync(trainDir)) progress(`job ${job.id}: train worktree ${JSON.stringify(train.removeTrainWorktree(workdir, trainDir))}`);
|
|
747
|
+
} else {
|
|
748
|
+
const result = await gate.runGate({
|
|
749
|
+
cwd: workdir, dir: trainDir, config, job, built, token, root: ctx.root, logFile, sourceDir, signal,
|
|
750
|
+
// WP-3a (§5 D-3, §6.5, §8 WP-3a): a MERGE train (`built.train !== job.tip`) is measured for S
|
|
751
|
+
// = sel(B→T) ∩ sel(P→T) while the gate still runs today's selection. A fast-forward train is
|
|
752
|
+
// already proved or gated as today, so it asks for no measurement (null, not a no-op closure).
|
|
753
|
+
onTrainReady: built.train === job.tip ? null : (dir) => mergeDelta.mergeDeltaSelection({
|
|
754
|
+
trainDir: dir, root: ctx.root, base: built.base, tip: job.tip, gatesHash: gatesHash(config.gates), env: process.env,
|
|
755
|
+
}),
|
|
756
|
+
});
|
|
757
|
+
verdict = result.verdict;
|
|
758
|
+
reason = result.reason;
|
|
759
|
+
gateSeconds = result.seconds;
|
|
760
|
+
gateTelemetry = gateSelectionTelemetry(result.selection);
|
|
761
|
+
// §6.6: the shadow record rides on `gate.intersection`, and the full S goes to
|
|
762
|
+
// `logs/<jobId>.merge-delta.json` (the job keeps its sha256 and the OQ-2 counters).
|
|
763
|
+
if (result.shadow) {
|
|
764
|
+
gateTelemetry = mergeDelta.recordIntersection(gateTelemetry, result.shadow, {
|
|
765
|
+
root: ctx.root, jobId: job.id, verdict, logFile: result.logPath,
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
progress(`job ${job.id}: gate ${verdict === null ? 'stopped' : verdict}${reason ? ` (${reason})` : ''} in ${result.seconds}s${result.shadow ? ` [merge-delta shadow ${result.shadow.mode}]` : ''}`);
|
|
769
|
+
// WP-2 (§5 D-1, §9 S-8): the train's own PASS is a proof too, so the NEXT train built on this
|
|
770
|
+
// tree (§5 D-3a's `proof(tree(B))`) — or a resubmit of it (§5 D-4) — needs no gate run. A
|
|
771
|
+
// refusal (a FLAKY rerun, a dependency change) or an unwritable store is reported and never
|
|
772
|
+
// changes this job's outcome: the gate already decided, and a proof is an optimization.
|
|
773
|
+
//
|
|
774
|
+
// The train's PASS goes through the SAME D-1 refusals the hook's PASS does — the runner is not
|
|
775
|
+
// a more trustworthy witness than the author's own commit gate, and a green run that contains a
|
|
776
|
+
// FLAKY rerun (or whose delta against the plan base touches a dependency manifest) is exactly
|
|
777
|
+
// what §5 D-1 says must never become a proof.
|
|
778
|
+
//
|
|
779
|
+
// ONE term is the hook's alone: `no_pass` asks whether the log carries the gate's own terminal
|
|
780
|
+
// line, and the runner does not need that witness — gate.js derives PASS from the gate's EXIT
|
|
781
|
+
// STATUS (`gate.js:234`, `const verdict = reason ? 'FAIL' : 'PASS'`), so a repository whose
|
|
782
|
+
// `gates[].run` is a plain script that prints no `[test-gate] VERDICT:` line is a legitimate
|
|
783
|
+
// configuration, and honouring `no_pass` here would make D-4 unreachable for every such repo
|
|
784
|
+
// (and for this repo's own queue fixtures, `tests-integration/fixtures/landing/gate-sentinel.sh`).
|
|
785
|
+
// The refusals the runner CAN witness from the same log — a FLAKY rerun, a dependency-changing
|
|
786
|
+
// delta — still apply.
|
|
787
|
+
//
|
|
788
|
+
// `result.logPath` is the job log, opened in APPEND mode and shared across the rounds of one
|
|
789
|
+
// job, so it can also carry an earlier round's lines; that can only make the refusal MORE
|
|
790
|
+
// conservative (a stale FLAKY line refuses a proof that would otherwise be recorded), never a
|
|
791
|
+
// false proof. An unreadable log is a skip, not an error — the job has already landed.
|
|
792
|
+
if (result.verdict === 'PASS') {
|
|
793
|
+
try {
|
|
794
|
+
const logText = fs.readFileSync(result.logPath, 'utf8');
|
|
795
|
+
const refused = proof.proofRefusal(workdir, {
|
|
796
|
+
planBase: built.base, tree: built.tree, logText,
|
|
797
|
+
});
|
|
798
|
+
const fatal = refused && refused !== 'no_pass' ? refused : null;
|
|
799
|
+
if (fatal) {
|
|
800
|
+
progress(`job ${job.id}: proof not recorded (${fatal})`);
|
|
801
|
+
} else {
|
|
802
|
+
proof.recordProof(ctx.root, {
|
|
803
|
+
tree: built.tree,
|
|
804
|
+
planBaseTree: revParse(workdir, `${built.base}^{tree}`),
|
|
805
|
+
gatesHash: gatesHash(config.gates),
|
|
806
|
+
verdict: 'PASS',
|
|
807
|
+
source: 'train',
|
|
808
|
+
commit: built.train,
|
|
809
|
+
seconds: result.seconds,
|
|
810
|
+
at: new Date().toISOString(),
|
|
811
|
+
logPath: result.logPath,
|
|
812
|
+
selection: result.selection,
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
} catch (error) {
|
|
816
|
+
progress(`job ${job.id}: proof not recorded (${error.code || 'error'}: ${error.message})`);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
const jobVerdict = proven ? 'PROVEN' : (cached ? 'CACHED' : verdict);
|
|
821
|
+
const jobGateLog = cached ? (cached.logPath || null) : logFile;
|
|
822
|
+
|
|
823
|
+
queue.updateJob(ctx.root, job.id, {
|
|
824
|
+
base: built.base,
|
|
825
|
+
tree: built.tree,
|
|
826
|
+
train: built.train,
|
|
827
|
+
verdict: jobVerdict,
|
|
828
|
+
gateLog: jobGateLog,
|
|
829
|
+
gate: gateTelemetry,
|
|
830
|
+
timestamps: { ...job.timestamps, gated: queue.nowIso() },
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
// Checkpoint 3 (D-5): the gate has returned and its result is on the job record. A cancel that
|
|
834
|
+
// arrived while it ran stops the job here — the target is never moved — and D-6's stopped gate
|
|
835
|
+
// (verdict null, reason gate_stopped) is not a verdict to act on. A PREEMPT here requeues the
|
|
836
|
+
// job unless the gate PASSed: the work is done, so the request is moot (R-3) and is cleared.
|
|
837
|
+
const requestAfterGate = pendingStop();
|
|
838
|
+
if (requestAfterGate) {
|
|
839
|
+
if (requestAfterGate.kind === 'cancel' || verdict !== 'PASS') return stopBeforeLanding(requestAfterGate);
|
|
840
|
+
queue.updateJob(ctx.root, job.id, { stopRequest: null });
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
if (verdict !== 'PASS') {
|
|
844
|
+
// D-6: a stopped gate with no cancel request behind it must still be decidable — it is a
|
|
845
|
+
// failure of this run (the job never landed), never a PASS.
|
|
846
|
+
if (reason === 'gate_stopped') {
|
|
847
|
+
progress(`job ${job.id}: gate stopped with no cancel request`);
|
|
848
|
+
return queue.finishJob(ctx.root, job.id, {
|
|
849
|
+
state: 'failed',
|
|
850
|
+
reason: 'gate_stopped',
|
|
851
|
+
verdict: jobVerdict,
|
|
852
|
+
base: built.base,
|
|
853
|
+
tree: built.tree,
|
|
854
|
+
train: built.train,
|
|
855
|
+
gateLog: jobGateLog,
|
|
856
|
+
timestamps: { ...job.timestamps, gated: queue.nowIso() },
|
|
857
|
+
}, { gateSeconds, gate: gateTelemetry });
|
|
858
|
+
}
|
|
859
|
+
// D-9 Tier A, computed per job and never by running a second gate.
|
|
860
|
+
const overlap = train.overlapWithTarget(workdir, built.base, job.tip);
|
|
861
|
+
const trainOnly = built.train !== job.tip && overlap.length > 0;
|
|
862
|
+
const failures = ['gate_timeout', 'gate_dirtied_tree'];
|
|
863
|
+
const finalReason = failures.includes(reason) ? reason : (trainOnly ? 'train_only' : 'gate_failed');
|
|
864
|
+
// D-6/D-7 (WP-2): diagnose on base before reporting — a `base_broken` job is left queued (it is
|
|
865
|
+
// main that is red, not this branch) and only every other outcome is a failure of this run.
|
|
866
|
+
const diagnosis = await triageFailedJob(ctx, {
|
|
867
|
+
config, job, cached, reason: finalReason, jobVerdict, built, logFile: jobGateLog, workdir, signal, gateSeconds,
|
|
868
|
+
});
|
|
869
|
+
if (diagnosis.requeued) return queue.readJob(ctx.root, job.id);
|
|
870
|
+
progress(`job ${job.id}: failed (${finalReason})`);
|
|
871
|
+
return queue.finishJob(ctx.root, job.id, {
|
|
872
|
+
state: 'failed',
|
|
873
|
+
reason: finalReason,
|
|
874
|
+
verdict: jobVerdict,
|
|
875
|
+
base: built.base,
|
|
876
|
+
tree: built.tree,
|
|
877
|
+
train: built.train,
|
|
878
|
+
overlapWithTarget: overlap,
|
|
879
|
+
gateLog: jobGateLog,
|
|
880
|
+
triage: diagnosis.triage,
|
|
881
|
+
timestamps: { ...job.timestamps, gated: queue.nowIso() },
|
|
882
|
+
}, { gateSeconds: diagnosis.gateSeconds, gate: gateTelemetry });
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// AC-3e/test hook: hold the runner here for LAND_TEST_PAUSE_BEFORE_LAND_MS so a stop request can
|
|
886
|
+
// be written inside the R-3 window (between checkpoint 3 and the land). Inert when unset.
|
|
887
|
+
const pauseBeforeLand = intFlag('LAND_TEST_PAUSE_BEFORE_LAND_MS', 0);
|
|
888
|
+
if (pauseBeforeLand > 0) await delay(pauseBeforeLand);
|
|
889
|
+
|
|
890
|
+
// Checkpoint 4 (D-5): the last read before the target moves. A cancel that arrived after
|
|
891
|
+
// checkpoint 3 stops the job here; the gate PASSed, but a cancel never lands. A preempt is moot
|
|
892
|
+
// for the same reason as at checkpoint 3 — the work is done — so it is cleared and the job lands.
|
|
893
|
+
const requestBeforeLand = pendingStop();
|
|
894
|
+
if (requestBeforeLand) {
|
|
895
|
+
if (requestBeforeLand.kind === 'cancel') return finishCanceled(requestBeforeLand);
|
|
896
|
+
queue.updateJob(ctx.root, job.id, { stopRequest: null });
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// D-5/D-11: land the train the gate APPROVED, comparing against `built.base` — the commit this
|
|
900
|
+
// train was built on — never against a freshly re-read tip. A re-read is a check-then-act gap:
|
|
901
|
+
// a commit landing in between would be silently dropped when the swap moved the target to a
|
|
902
|
+
// train that no longer contains it. `cas_lost` is that race, and it is handled exactly like a
|
|
903
|
+
// target that moved before the gate started: record the foreign commits and retrain, which
|
|
904
|
+
// counts toward MAX_ROUNDS.
|
|
905
|
+
let landed;
|
|
906
|
+
try {
|
|
907
|
+
landed = train.landTrain(workdir, config.target, built.train, built.base, { env: { LAND_TOKEN: token } });
|
|
908
|
+
} catch (error) {
|
|
909
|
+
if (error && error.code === 'target_dirty') {
|
|
910
|
+
progress(`job ${job.id}: target worktree has local changes overlapping the train (${error.paths.join(', ')})`);
|
|
911
|
+
return queue.finishJob(ctx.root, job.id, {
|
|
912
|
+
state: 'blocked',
|
|
913
|
+
reason: 'target_dirty',
|
|
914
|
+
paths: error.paths,
|
|
915
|
+
verdict: jobVerdict,
|
|
916
|
+
base: built.base,
|
|
917
|
+
tree: built.tree,
|
|
918
|
+
train: built.train,
|
|
919
|
+
gateLog: jobGateLog,
|
|
920
|
+
gate: gateTelemetry,
|
|
921
|
+
}, { gateSeconds, gate: gateTelemetry });
|
|
922
|
+
}
|
|
923
|
+
if (error && error.code === 'cas_lost') {
|
|
924
|
+
const moved = error.current || revParseOrNull(workdir, branchRef(config.target));
|
|
925
|
+
foreignCommits.push(...train.commitsBetween(workdir, built.base, moved));
|
|
926
|
+
// Persisted, not just held in memory: a retrain that then SUCCEEDS has to keep saying which
|
|
927
|
+
// commits landed under it, or the record of the race disappears the moment the job lands.
|
|
928
|
+
queue.updateJob(ctx.root, job.id, { foreignCommits: [...foreignCommits] });
|
|
929
|
+
progress(`job ${job.id}: target moved to ${String(moved).slice(0, 12)} (cas_lost); retraining`);
|
|
930
|
+
continue;
|
|
931
|
+
}
|
|
932
|
+
throw error;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
progress(`job ${job.id}: landed ${String(built.train).slice(0, 12)} via ${landed.via}`);
|
|
936
|
+
const landedJob = queue.finishJob(ctx.root, job.id, {
|
|
937
|
+
state: 'landed',
|
|
938
|
+
reason: null,
|
|
939
|
+
verdict: jobVerdict,
|
|
940
|
+
base: built.base,
|
|
941
|
+
tree: built.tree,
|
|
942
|
+
train: built.train,
|
|
943
|
+
landedVia: landed.via,
|
|
944
|
+
gateLog: jobGateLog,
|
|
945
|
+
gate: gateTelemetry,
|
|
946
|
+
timestamps: { ...job.timestamps, landed: queue.nowIso() },
|
|
947
|
+
}, { gateSeconds, gate: gateTelemetry });
|
|
948
|
+
// D-5 (§9 S-8): the target just moved, so every job still waiting is re-checked against the new
|
|
949
|
+
// tip BEFORE this runner takes the next one — the waiting session is told "merge main now" while
|
|
950
|
+
// it waits rather than discovering the conflict when its turn comes.
|
|
951
|
+
sweepConflictPreviews(ctx, workdir, config);
|
|
952
|
+
return landedJob;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
progress(`job ${job.id}: the target moved on every round — blocked target_moving`);
|
|
956
|
+
return queue.finishJob(ctx.root, job.id, {
|
|
957
|
+
state: 'blocked',
|
|
958
|
+
reason: 'target_moving',
|
|
959
|
+
foreignCommits,
|
|
960
|
+
base: lastBuilt ? lastBuilt.base : null,
|
|
961
|
+
tree: lastBuilt ? lastBuilt.tree : null,
|
|
962
|
+
train: lastBuilt ? lastBuilt.train : null,
|
|
963
|
+
gate: gateTelemetry,
|
|
964
|
+
}, { gateSeconds, gate: gateTelemetry });
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
async function waitForTerminal(root, id, timeoutMs) {
|
|
968
|
+
const deadline = Date.now() + timeoutMs;
|
|
969
|
+
for (;;) {
|
|
970
|
+
const job = queue.readJob(root, id);
|
|
971
|
+
if (job && queue.TERMINAL_STATES.includes(job.state)) return job;
|
|
972
|
+
const remaining = deadline - Date.now();
|
|
973
|
+
if (remaining <= 0) return null;
|
|
974
|
+
await delay(Math.min(POLL_MS, remaining));
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function exitCodeForState(jobState) {
|
|
979
|
+
if (jobState === 'landed') return 0;
|
|
980
|
+
if (jobState === 'failed' || jobState === 'blocked' || jobState === 'canceled') return 1;
|
|
981
|
+
return 0;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// --- verbs -------------------------------------------------------------------------------------
|
|
985
|
+
|
|
986
|
+
// D-1/D-7 (§6.2): the one place a priority level and its reason are validated, shared by
|
|
987
|
+
// `land submit` and `land priority`. The message names the FLAG, because the caller is a human or an
|
|
988
|
+
// agent reading a usage error (exit 2), and the level is the only thing the queue module refuses.
|
|
989
|
+
function parsePriorityLevel(rawPriority, rawReason) {
|
|
990
|
+
const level = rawPriority === undefined || rawPriority === null || rawPriority === '' ? 'normal' : String(rawPriority);
|
|
991
|
+
if (!queue.PRIORITIES.includes(level)) {
|
|
992
|
+
throw usageError('--priority must be one of normal, high, urgent');
|
|
993
|
+
}
|
|
994
|
+
const reason = rawReason === undefined || rawReason === null ? '' : String(rawReason);
|
|
995
|
+
// D-7: high and urgent are a request for someone else's attention, so they carry the reason for it.
|
|
996
|
+
if (level !== 'normal' && !reason.trim()) throw usageError('--reason is required for high and urgent');
|
|
997
|
+
if (reason.length > queue.PRIORITY_REASON_MAX) throw usageError(`--reason is at most ${queue.PRIORITY_REASON_MAX} characters`);
|
|
998
|
+
return { level, reason: reason || null };
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// D-7: who asked. A loopback CLI has no identity to check (there is no owner check, matching
|
|
1002
|
+
// `land cancel`), so the audit trail records the session, the pid and the directory it ran from.
|
|
1003
|
+
function priorityBy(cwd) {
|
|
1004
|
+
return { session: strFlag('CTM_SESSION_ID') || null, pid: process.pid, cwd };
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// D-11/§6.2: `[high]` / `[urgent]` after the state, and nothing for `normal` (the common case stays
|
|
1008
|
+
// as narrow as it was).
|
|
1009
|
+
function priorityTag(level) {
|
|
1010
|
+
return level && level !== 'normal' ? ` [${level}]` : '';
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
async function cmdSubmit(opts, cwd) {
|
|
1014
|
+
// Validated before anything touches the repository: a bad level or a missing reason is a usage
|
|
1015
|
+
// error whether or not the caller is even standing in a git worktree.
|
|
1016
|
+
const { level, reason } = parsePriorityLevel(opts.values.priority, opts.values.reason);
|
|
1017
|
+
const by = priorityBy(cwd);
|
|
1018
|
+
const ctx = context(cwd);
|
|
1019
|
+
// D-1/I-5: gates are resolved from the TARGET's committed policy, never the submitter's working
|
|
1020
|
+
// tree — a branch cannot weaken the gate that decides whether it lands.
|
|
1021
|
+
const config = loadTargetLandingConfig(cwd);
|
|
1022
|
+
const branch = opts.values.branch || currentBranch(cwd);
|
|
1023
|
+
const tip = revParse(cwd, branchRef(branch));
|
|
1024
|
+
|
|
1025
|
+
const unmerged = unmergedPaths(cwd);
|
|
1026
|
+
if (unmerged.length) {
|
|
1027
|
+
throw landingError('unmerged_paths', `${cwd} has unmerged paths: ${unmerged.join(', ')}`, { paths: unmerged });
|
|
1028
|
+
}
|
|
1029
|
+
const dirty = dirtyTrackedPaths(cwd);
|
|
1030
|
+
if (dirty.length) {
|
|
1031
|
+
progress(`warning: ${dirty.length} modified tracked file(s) in ${cwd}; the job lands the committed tip, not the working tree`);
|
|
1032
|
+
}
|
|
1033
|
+
queue.reapDeadRunner(ctx.root);
|
|
1034
|
+
|
|
1035
|
+
// D-11/I-5: reads the CTM session through `lib/env-flags.js` (the one flag reader) rather than
|
|
1036
|
+
// raw, so the flag-read ratchet needs no baseline entry for this file.
|
|
1037
|
+
const session = strFlag('CTM_SESSION_ID') || null;
|
|
1038
|
+
let job = queue.activeJobFor(ctx.root, branch, tip);
|
|
1039
|
+
// D-9: an already-active job for this branch@tip is re-prioritized rather than duplicated — the
|
|
1040
|
+
// same call an agent already makes is the bump verb. WITHOUT `--priority` the existing level is
|
|
1041
|
+
// kept, so a plain re-submit can never silently demote a bumped job.
|
|
1042
|
+
let preempt = null;
|
|
1043
|
+
if (job) {
|
|
1044
|
+
progress(`job ${job.id} already ${job.state} for ${branch}@${tip.slice(0, 12)}`);
|
|
1045
|
+
if (opts.values.priority !== undefined) {
|
|
1046
|
+
const updated = queue.setPriority(ctx.root, job.id, { priority: level, reason, by, via: 'submit' });
|
|
1047
|
+
job = updated.job;
|
|
1048
|
+
preempt = updated.preempt;
|
|
1049
|
+
}
|
|
1050
|
+
} else {
|
|
1051
|
+
if (!config.gates.length) {
|
|
1052
|
+
throw landingError(
|
|
1053
|
+
'config_gates_missing',
|
|
1054
|
+
`no landing.gates configured in ${config.configDir} and no test command detected; a queue that gates nothing is not a queue`,
|
|
1055
|
+
{ configDir: config.configDir },
|
|
1056
|
+
);
|
|
1057
|
+
}
|
|
1058
|
+
const duplicate = queue.duplicateSubmitter(ctx.root, session, process.pid, branch);
|
|
1059
|
+
if (duplicate) {
|
|
1060
|
+
// WP-0 D-8 (§9 S-13, AC-0a): a refusal is an EVENT. It is thrown before any job record exists,
|
|
1061
|
+
// so until now the queue could not see its own back-pressure at all (census §1).
|
|
1062
|
+
queue.recordRefusal(ctx.root, { reason: 'duplicate_submitter', session, branch });
|
|
1063
|
+
throw landingError(
|
|
1064
|
+
'duplicate_submitter',
|
|
1065
|
+
`session ${session} already has job ${duplicate.id} (pid ${duplicate.submittedBy.pid}) in ${duplicate.state}`,
|
|
1066
|
+
{ jobId: duplicate.id, pid: duplicate.submittedBy.pid },
|
|
1067
|
+
);
|
|
1068
|
+
}
|
|
1069
|
+
// D-4 (§9 S-7): decide the conflict HERE, not forty minutes later. `mergeTree` is the same oracle
|
|
1070
|
+
// the runner gates on and it writes no commit object — the tree it prints is garbage the moment
|
|
1071
|
+
// this process exits — so the precheck costs one `git merge-tree` and nothing else (§1.1: 24 jobs
|
|
1072
|
+
// waited 2,592 minutes to be blocked on a conflict that was already visible at submit).
|
|
1073
|
+
const targetTip = revParse(cwd, branchRef(config.target));
|
|
1074
|
+
const merged = mergeTree(cwd, targetTip, tip);
|
|
1075
|
+
let conflictPreview = null;
|
|
1076
|
+
if (merged.conflicts.length) {
|
|
1077
|
+
const { uncovered, covered } = pathCoverage(config, merged.conflicts);
|
|
1078
|
+
if (uncovered.length && !opts.flags.has('queue-anyway')) {
|
|
1079
|
+
// WP-0 D-8: the second refusal, recorded the same way (the file is only written when the
|
|
1080
|
+
// submit really is refused — `--queue-anyway` is an acceptance, not a refusal).
|
|
1081
|
+
queue.recordRefusal(ctx.root, { reason: 'conflict_at_submit', session, branch });
|
|
1082
|
+
throw landingError(
|
|
1083
|
+
'conflict_at_submit',
|
|
1084
|
+
`${branch}@${tip.slice(0, 12)} conflicts with ${config.target} on ${uncovered.join(', ')}; merge ${config.target} into the branch first, or pass --queue-anyway`,
|
|
1085
|
+
{ paths: merged.conflicts, uncovered, covered },
|
|
1086
|
+
);
|
|
1087
|
+
}
|
|
1088
|
+
// Either every path is owned by a regenerate/refresh entry (the runner resolves it) or the
|
|
1089
|
+
// submitter said `--queue-anyway` (it expects an earlier job to absorb the overlap): the job is
|
|
1090
|
+
// accepted, and only a conflict the QUEUE cannot resolve starts life with a preview.
|
|
1091
|
+
conflictPreview = uncovered.length
|
|
1092
|
+
? { base: targetTip, paths: merged.conflicts, checkedAt: queue.nowIso() }
|
|
1093
|
+
: null;
|
|
1094
|
+
}
|
|
1095
|
+
job = {
|
|
1096
|
+
contract: CONTRACT,
|
|
1097
|
+
id: queue.newJobId(),
|
|
1098
|
+
repo: ctx.common,
|
|
1099
|
+
branch,
|
|
1100
|
+
tip,
|
|
1101
|
+
title: opts.values.title || commitSubject(cwd, tip),
|
|
1102
|
+
submittedBy: { session, pid: process.pid, cwd },
|
|
1103
|
+
state: 'queued',
|
|
1104
|
+
reason: null,
|
|
1105
|
+
// D-1/D-7: the level is the flag or `normal`; the audit trail opens with the `from: 'normal'`
|
|
1106
|
+
// entry only when the job was not submitted normal (a normal job has nothing to record).
|
|
1107
|
+
priority: level,
|
|
1108
|
+
priorityHistory: level === 'normal' ? [] : [{ from: 'normal', to: level, reason, via: 'submit', at: queue.nowIso(), by }],
|
|
1109
|
+
preemptions: 0,
|
|
1110
|
+
base: null,
|
|
1111
|
+
train: null,
|
|
1112
|
+
tree: null,
|
|
1113
|
+
verdict: null,
|
|
1114
|
+
conflictPaths: [],
|
|
1115
|
+
// D-4/D-5: the preview the job starts life with — null unless the submitter took a conflict the
|
|
1116
|
+
// queue cannot resolve with `--queue-anyway` (a covered conflict IS resolved by the runner, so
|
|
1117
|
+
// there is nothing for the submitter to merge main for).
|
|
1118
|
+
conflictPreview,
|
|
1119
|
+
overlapWithTarget: [],
|
|
1120
|
+
policySource: config.policySource,
|
|
1121
|
+
runnerPid: null,
|
|
1122
|
+
timestamps: { queued: queue.nowIso() },
|
|
1123
|
+
gateLog: null,
|
|
1124
|
+
};
|
|
1125
|
+
queue.enqueue(ctx.root, job);
|
|
1126
|
+
// D-4/§6.2: a FRESH urgent job asks for the running lower-ranked job to be stopped, the same
|
|
1127
|
+
// decision `land priority` makes — one implementation, called after the enqueue so the queue
|
|
1128
|
+
// already holds the new job when the request is written.
|
|
1129
|
+
if (level === 'urgent') preempt = queue.requestPreemptFor(ctx.root, job.id, { by, reason });
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// D-2: a submit must return in well under a second even when the gate takes 40 minutes, so it
|
|
1133
|
+
// never drains in the foreground. If a runner is already live this process is just a producer
|
|
1134
|
+
// (`runner: 'other'`); otherwise it starts a DETACHED `land run` child — on demand, never an idle
|
|
1135
|
+
// daemon — which claims the lease, drains the queue and exits when the queue is empty.
|
|
1136
|
+
const holder = lease.read(ctx.root);
|
|
1137
|
+
const live = holder && !lease.isDead(holder) ? holder : null;
|
|
1138
|
+
let runner;
|
|
1139
|
+
let runnerPid;
|
|
1140
|
+
if (live) {
|
|
1141
|
+
runner = 'other';
|
|
1142
|
+
runnerPid = live.pid;
|
|
1143
|
+
} else {
|
|
1144
|
+
runner = 'self';
|
|
1145
|
+
const started = spawnRunner(ctx);
|
|
1146
|
+
runnerPid = started ? started.pid : null;
|
|
1147
|
+
}
|
|
1148
|
+
if (runnerPid !== null) queue.updateJob(ctx.root, job.id, { runnerPid });
|
|
1149
|
+
|
|
1150
|
+
const position = queue.positionOf(ctx.root, job.id);
|
|
1151
|
+
const payload = {
|
|
1152
|
+
contract: CONTRACT,
|
|
1153
|
+
jobId: job.id,
|
|
1154
|
+
position,
|
|
1155
|
+
queued: queue.readQueue(ctx.root).order.length,
|
|
1156
|
+
runner,
|
|
1157
|
+
eta_s: queue.etaSeconds(ctx.root, position),
|
|
1158
|
+
// §6.2: the level this job now HOLDS — read back off the job, never the parsed flag, so a plain
|
|
1159
|
+
// re-submit (no `--priority`) of an already-bumped job reports `high`, not the `normal` default
|
|
1160
|
+
// (§6.1 `priorityOf`). `preempt` is null unless this submit asked for `urgent`, and then it is
|
|
1161
|
+
// the D-4 outcome — `'requested'` having really written the running job's stop request (WP-3).
|
|
1162
|
+
priority: queue.priorityOf(job),
|
|
1163
|
+
preempt,
|
|
1164
|
+
};
|
|
1165
|
+
|
|
1166
|
+
if (opts.flags.has('wait')) {
|
|
1167
|
+
// `--wait` still waits for the job's terminal state; the work itself happens in the runner
|
|
1168
|
+
// child started above (or in whoever already held the lease).
|
|
1169
|
+
const final = await waitForTerminal(ctx.root, job.id, WAIT_TIMEOUT_MS);
|
|
1170
|
+
if (!final) {
|
|
1171
|
+
emitJsonOrLine(opts, { ...payload, state: 'queued', reason: null, verdict: null }, `job ${job.id} did not reach a terminal state within ${WAIT_TIMEOUT_S}s`);
|
|
1172
|
+
return 75;
|
|
1173
|
+
}
|
|
1174
|
+
emitJsonOrLine(
|
|
1175
|
+
opts,
|
|
1176
|
+
{ ...payload, position: null, state: final.state, reason: final.reason, verdict: final.verdict },
|
|
1177
|
+
`job ${job.id} ${final.state}${final.reason ? ` (${final.reason})` : ''}`,
|
|
1178
|
+
);
|
|
1179
|
+
return exitCodeForState(final.state);
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
emitJsonOrLine(
|
|
1183
|
+
opts,
|
|
1184
|
+
payload,
|
|
1185
|
+
`job ${job.id} queued at position ${position} of ${payload.queued}${priorityTag(payload.priority)} (runner: ${runner})`
|
|
1186
|
+
+ `${payload.eta_s === null ? '' : `, eta ~${payload.eta_s}s`}`
|
|
1187
|
+
+ preemptSuffix(preempt, preemptTargetOf(ctx.root, job.id)),
|
|
1188
|
+
);
|
|
1189
|
+
return 0;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
// §6.2: the human line says which job an urgent bump is stopping, so a caller never has to read the
|
|
1193
|
+
// job files to find out. It names the job the request was written on — the runner's `in_progress`
|
|
1194
|
+
// job, which is the queue head — and nothing at all when `preempt` is not `'requested'`.
|
|
1195
|
+
function preemptSuffix(preempt, targetId) {
|
|
1196
|
+
return preempt === 'requested' && targetId ? `; stopping job ${targetId}` : '';
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
// D-5 (§9 S-9): the D-4/D-5 preview as the `land status` row's suffix. A job with no preview (the
|
|
1200
|
+
// overwhelmingly common case) prints exactly the row it printed before WP-1.
|
|
1201
|
+
function conflictSuffix(preview) {
|
|
1202
|
+
const paths = preview && Array.isArray(preview.paths) ? preview.paths : [];
|
|
1203
|
+
return paths.length ? ` will conflict: ${paths.join(', ')}` : '';
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
function preemptTargetOf(root, urgentJobId) {
|
|
1207
|
+
for (const queuedId of queue.readQueue(root).order) {
|
|
1208
|
+
if (queuedId === urgentJobId) continue;
|
|
1209
|
+
const job = queue.readJob(root, queuedId);
|
|
1210
|
+
if (job && job.state === 'in_progress') return job.id;
|
|
1211
|
+
}
|
|
1212
|
+
return null;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
// §6.2 `land priority <jobId> normal|high|urgent [--reason <text>] [--json]`: bump (or drop) any
|
|
1216
|
+
// ACTIVE job. It never touches the job's state — only its rank and its audit trail — so a job that
|
|
1217
|
+
// is gating keeps gating and a queued job keeps its place in line at its new level.
|
|
1218
|
+
function cmdPriority(opts, cwd) {
|
|
1219
|
+
const ctx = context(cwd);
|
|
1220
|
+
const id = opts.positionals[0];
|
|
1221
|
+
const rawLevel = opts.positionals[1];
|
|
1222
|
+
if (!id || !rawLevel) throw usageError('land priority <jobId> normal|high|urgent [--reason <text>] [--json]');
|
|
1223
|
+
const { level, reason } = parsePriorityLevel(rawLevel, opts.values.reason);
|
|
1224
|
+
const result = queue.setPriority(ctx.root, id, { priority: level, reason, by: priorityBy(cwd), via: 'priority' });
|
|
1225
|
+
const total = queue.readQueue(ctx.root).order.length;
|
|
1226
|
+
if (opts.flags.has('json')) {
|
|
1227
|
+
emitJson({
|
|
1228
|
+
contract: CONTRACT,
|
|
1229
|
+
jobId: result.job.id,
|
|
1230
|
+
priority: level,
|
|
1231
|
+
changed: result.changed,
|
|
1232
|
+
position: result.position,
|
|
1233
|
+
preempt: result.preempt,
|
|
1234
|
+
});
|
|
1235
|
+
return 0;
|
|
1236
|
+
}
|
|
1237
|
+
if (!result.changed) {
|
|
1238
|
+
emitLine(`job ${id} already ${level}`);
|
|
1239
|
+
return 0;
|
|
1240
|
+
}
|
|
1241
|
+
const history = Array.isArray(result.job.priorityHistory) ? result.job.priorityHistory : [];
|
|
1242
|
+
const previous = history.length ? history[history.length - 1].from : 'normal';
|
|
1243
|
+
emitLine(
|
|
1244
|
+
`job ${id} priority ${level} (was ${previous}), position ${result.position} of ${total}`
|
|
1245
|
+
+ preemptSuffix(result.preempt, preemptTargetOf(ctx.root, id)),
|
|
1246
|
+
);
|
|
1247
|
+
return 0;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
// The detached runner: `node <this file> run`, in its own process group with its output in
|
|
1251
|
+
// `landing/logs/runner-<ts>.log`, unref'd so the submitter exits immediately. The child claims the
|
|
1252
|
+
// lease itself (a racing child simply finds one and exits), which is what keeps "exactly one runner"
|
|
1253
|
+
// true without the submitter holding a lock across a 40-minute gate.
|
|
1254
|
+
function spawnRunner(ctx) {
|
|
1255
|
+
const dir = queue.ensureLogsDir(ctx.root);
|
|
1256
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
1257
|
+
const logPath = path.join(dir, `runner-${stamp}-${process.pid}.log`);
|
|
1258
|
+
const fd = fs.openSync(logPath, 'a');
|
|
1259
|
+
try {
|
|
1260
|
+
const child = spawn(process.execPath, [__filename, 'run'], {
|
|
1261
|
+
cwd: ctx.cwd,
|
|
1262
|
+
env: childEnv(),
|
|
1263
|
+
detached: true,
|
|
1264
|
+
stdio: ['ignore', fd, fd],
|
|
1265
|
+
});
|
|
1266
|
+
child.on('error', (error) => {
|
|
1267
|
+
// Report, never throw: the job is already queued and the next `land submit` (or `land run`)
|
|
1268
|
+
// starts a runner again, so a failed spawn must not turn into a stack trace here.
|
|
1269
|
+
progress(`runner failed to start: ${error.message}`);
|
|
1270
|
+
});
|
|
1271
|
+
child.unref();
|
|
1272
|
+
progress(`runner ${child.pid} started (log: ${logPath})`);
|
|
1273
|
+
return { pid: child.pid, logPath };
|
|
1274
|
+
} finally {
|
|
1275
|
+
fs.closeSync(fd);
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
function emitJsonOrLine(opts, payload, human) {
|
|
1280
|
+
if (opts.flags.has('json')) emitJson(payload);
|
|
1281
|
+
else emitLine(human);
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
// Claim the lease, with one extra attempt when the first loss was a dead holder: `lease.claim`
|
|
1285
|
+
// reaps a dead holder internally, so a second call is the "the path is free now" case rather than
|
|
1286
|
+
// a spin.
|
|
1287
|
+
function claimRunner(ctx, jobId) {
|
|
1288
|
+
const first = lease.claim(ctx.root, { jobId });
|
|
1289
|
+
if (first) return first;
|
|
1290
|
+
const holder = lease.read(ctx.root);
|
|
1291
|
+
if (!holder || lease.isDead(holder)) return lease.claim(ctx.root, { jobId });
|
|
1292
|
+
return null;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
async function cmdRun(opts, cwd) {
|
|
1296
|
+
const ctx = context(cwd);
|
|
1297
|
+
queue.reapDeadRunner(ctx.root);
|
|
1298
|
+
const record = claimRunner(ctx, null);
|
|
1299
|
+
if (!record) {
|
|
1300
|
+
const holder = lease.read(ctx.root);
|
|
1301
|
+
progress(`another runner holds the lease${holder ? ` (pid ${holder.pid})` : ''}`);
|
|
1302
|
+
return 0;
|
|
1303
|
+
}
|
|
1304
|
+
const processed = await drain(ctx, record, { once: opts.flags.has('once') });
|
|
1305
|
+
progress(processed ? `drained ${processed} job(s)` : 'queue is empty');
|
|
1306
|
+
return 0;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
function cmdStatus(opts, cwd) {
|
|
1310
|
+
const dir = opts.values.repo ? path.resolve(opts.values.repo) : cwd;
|
|
1311
|
+
const ctx = context(dir);
|
|
1312
|
+
let config = null;
|
|
1313
|
+
let configError = null;
|
|
1314
|
+
try {
|
|
1315
|
+
config = loadLandingConfig(dir);
|
|
1316
|
+
} catch (error) {
|
|
1317
|
+
configError = { code: typeof error.code === 'string' ? error.code : 'unexpected', message: error.message };
|
|
1318
|
+
}
|
|
1319
|
+
const order = queue.readQueue(ctx.root).order;
|
|
1320
|
+
// OQ-3: one metrics read for the whole listing — the ETA is position × the median of the last 20
|
|
1321
|
+
// gate seconds, and stays `null` (never a guess) until 20 samples exist.
|
|
1322
|
+
const etaSamples = queue.recentGateSeconds(ctx.root, queue.ETA_SAMPLES);
|
|
1323
|
+
const etaFor = (position) => (position !== null && etaSamples.length >= queue.ETA_SAMPLES
|
|
1324
|
+
? Math.round(position * queue.median(etaSamples))
|
|
1325
|
+
: null);
|
|
1326
|
+
const jobs = queue.listJobs(ctx.root).slice(-50).map((job) => {
|
|
1327
|
+
const index = order.indexOf(job.id);
|
|
1328
|
+
const position = index === -1 ? null : index + 1;
|
|
1329
|
+
return { ...queue.jobSummary(job), position, eta_s: etaFor(position) };
|
|
1330
|
+
});
|
|
1331
|
+
const radarSummary = statusRadar(dir, configError, opts.values['radar-for']);
|
|
1332
|
+
// WP-1 (§6.2): the origin half of the status — ahead/behind against the LOCAL tracking ref (no
|
|
1333
|
+
// fetch: `land status` stays a read verb) plus the last sync verdict from `landing/sync-state.json`.
|
|
1334
|
+
const origin = syncCli.statusOrigin(dir, ctx.common, config);
|
|
1335
|
+
const payload = {
|
|
1336
|
+
contract: CONTRACT,
|
|
1337
|
+
repo: ctx.common,
|
|
1338
|
+
target: config ? config.target : null,
|
|
1339
|
+
origin,
|
|
1340
|
+
queue: { depth: order.length, order },
|
|
1341
|
+
runner: queue.runnerSummary(ctx.root),
|
|
1342
|
+
jobs,
|
|
1343
|
+
// WP-0 D-8: the day's refusal count (the `land status` line, and a field for the CTM probe).
|
|
1344
|
+
refusedToday: queue.refusalsToday(ctx.root),
|
|
1345
|
+
config: configError
|
|
1346
|
+
? { ok: false, error: configError }
|
|
1347
|
+
: { ok: true, gatesSource: config.gatesSource, gates: config.gates.map((entry) => entry.name) },
|
|
1348
|
+
// §8 WP-3: present only for `--radar-for` (the CTM probe). Plain `status` is the queue alone —
|
|
1349
|
+
// the repo-wide radar is `land conflicts`.
|
|
1350
|
+
radar: radarSummary ? radarSummary.payload : null,
|
|
1351
|
+
};
|
|
1352
|
+
if (opts.flags.has('json')) {
|
|
1353
|
+
emitJson(payload);
|
|
1354
|
+
return 0;
|
|
1355
|
+
}
|
|
1356
|
+
emitLine(`repo: ${payload.repo}`);
|
|
1357
|
+
emitLine(`target: ${payload.target === null ? '(unresolved)' : payload.target}`);
|
|
1358
|
+
emitLine(`queue: ${payload.queue.depth} job(s)`);
|
|
1359
|
+
for (const job of jobs) {
|
|
1360
|
+
// §6.2 / D-11: the level rides on the row for a bumped job; a normal row is byte-identical to
|
|
1361
|
+
// what it printed before this change.
|
|
1362
|
+
// WP-0 (§6.6/D-8): a job whose gate explained itself says so — `gate <mode> <executed>/<selected>`.
|
|
1363
|
+
// D-5 (§9 S-9): a non-empty preview is the queue saying "this job will not merge as it stands" —
|
|
1364
|
+
// the suffix is the whole action the reader has to take (merge the target into the branch).
|
|
1365
|
+
// D-7 (WP-2): a job parked on a broken base is `queued` on disk but is not waiting for its turn —
|
|
1366
|
+
// it is waiting for MAIN to move, and the state cell says so rather than leaving the reader to
|
|
1367
|
+
// wonder why a queued job never starts. Every other row is byte-identical to what it printed
|
|
1368
|
+
// before this change.
|
|
1369
|
+
const stateCell = job.retryAfterTargetMoves
|
|
1370
|
+
? 'base_broken (waiting for main to move)'
|
|
1371
|
+
: `${job.state}${job.reason ? ` (${job.reason})` : ''}`;
|
|
1372
|
+
emitLine(` ${job.position === null ? '-' : job.position} ${job.id} ${stateCell}${priorityTag(job.priority)} ${job.branch} ${job.title}${gateTag(job.gate)}${batchTag(job.batch)}${conflictSuffix(job.conflictPreview)}`);
|
|
1373
|
+
}
|
|
1374
|
+
emitLine(payload.runner ? `runner: pid ${payload.runner.pid}${payload.runner.jobId ? ` job ${payload.runner.jobId}` : ''}` : 'runner: none');
|
|
1375
|
+
// WP-0 D-8 (§8 WP-0): the back-pressure the queue could never see before this change. Always
|
|
1376
|
+
// printed (0 when none), so the line is a metric and not a hint.
|
|
1377
|
+
emitLine(`refused today: ${payload.refusedToday}`);
|
|
1378
|
+
// Only printed when the repository opted in — an unconfigured repo's status stays byte-identical
|
|
1379
|
+
// to what it printed before this change.
|
|
1380
|
+
const originLine = syncCli.originStatusLine(config, origin);
|
|
1381
|
+
if (originLine) emitLine(originLine);
|
|
1382
|
+
if (radarSummary) emitLine(radarSummary.line);
|
|
1383
|
+
return 0;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
// WP-0 (§6.6): `land status` prints `gate <mode> <executed>/<selected>` for a job that carries the
|
|
1387
|
+
// gate's self-description, and nothing at all for one that does not (every job written before this
|
|
1388
|
+
// change, and every gate that never ran). Null numbers print as `?`, never as 0.
|
|
1389
|
+
// WP-1 §6 (WP-1 step 10, AC-1f): the batch suffix on a member's row — ` [batch b-… 3/5]`. A job with
|
|
1390
|
+
// no `batch` (every job written before this change, and every singleton under a window of one) prints
|
|
1391
|
+
// nothing at all, so its row stays byte-identical to today's.
|
|
1392
|
+
function batchTag(batch) {
|
|
1393
|
+
if (!batch || typeof batch !== 'object' || typeof batch.id !== 'string') return '';
|
|
1394
|
+
const total = Array.isArray(batch.members) ? batch.members.length : null;
|
|
1395
|
+
const index = Number.isInteger(batch.index) ? batch.index + 1 : null;
|
|
1396
|
+
if (total === null || index === null) return ` [batch ${batch.id}]`;
|
|
1397
|
+
return ` [batch ${batch.id} ${index}/${total}]`;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
function gateTag(gate) {
|
|
1401
|
+
if (!gate || typeof gate !== 'object' || !gate.mode) return '';
|
|
1402
|
+
const value = (number) => (typeof number === 'number' && Number.isFinite(number) ? String(number) : '?');
|
|
1403
|
+
return ` gate ${gate.mode} ${value(gate.executed)}/${value(gate.selected)}`;
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
// `land status` without `--radar-for` has no radar (null): even a cache-only sweep has to list and
|
|
1407
|
+
// dirty-probe every worktree, which was ~51 s of a plain `land status` on a 413-worktree repo, to
|
|
1408
|
+
// print one line `land conflicts` already gives. A repository whose target is missing (or whose
|
|
1409
|
+
// policy is unreadable) has no radar either — the summary then says so instead of pretending L0.
|
|
1410
|
+
//
|
|
1411
|
+
// §8 WP-3: `--radar-for a,b` is the CTM probe's variant. It runs a FULL sweep
|
|
1412
|
+
// restricted to the listed branches, so each pair among them and each against the target is
|
|
1413
|
+
// actually computed, and the payload carries `pairs` (per-branch detail the view joins to sessions)
|
|
1414
|
+
// and `branches` (which worktrees were in scope) beside the usual summary. Un-listed worktrees are
|
|
1415
|
+
// filtered out before any dirty probe (radar.scopeBranches), so they are never snapshotted.
|
|
1416
|
+
function statusRadar(dir, configError, radarFor) {
|
|
1417
|
+
if (radarFor === undefined) return null;
|
|
1418
|
+
const onlyBranches = parseBranchList(radarFor);
|
|
1419
|
+
const unavailable = (code) => ({
|
|
1420
|
+
payload: { max: null, atLeastL2: null, unknown: null, pairs: [], branches: [] },
|
|
1421
|
+
line: `radar: unavailable (${code})`,
|
|
1422
|
+
});
|
|
1423
|
+
if (configError) return unavailable(configError.code);
|
|
1424
|
+
let config;
|
|
1425
|
+
try {
|
|
1426
|
+
config = loadTargetLandingConfig(dir);
|
|
1427
|
+
} catch (error) {
|
|
1428
|
+
return unavailable(typeof error.code === 'string' ? error.code : 'unexpected');
|
|
1429
|
+
}
|
|
1430
|
+
// The sweep is the only step here that can still refuse (a target branch that does not exist, a
|
|
1431
|
+
// worktree list git will not print). `land status` is the READ verb: the queue, runner and job
|
|
1432
|
+
// listing are all still true when the radar cannot be computed, so a radar refusal degrades the
|
|
1433
|
+
// radar field and never fails the verb. (It also keeps the CTM probe total: a session's chip must
|
|
1434
|
+
// not go dark because the repository's target branch has not been created yet.)
|
|
1435
|
+
let sweep;
|
|
1436
|
+
try {
|
|
1437
|
+
sweep = radar.sweep(dir, { config, onlyBranches });
|
|
1438
|
+
} catch (error) {
|
|
1439
|
+
return unavailable(typeof error.code === 'string' ? error.code : 'unexpected');
|
|
1440
|
+
}
|
|
1441
|
+
const summary = radar.summarize(sweep.pairs);
|
|
1442
|
+
return {
|
|
1443
|
+
payload: {
|
|
1444
|
+
...summary,
|
|
1445
|
+
pairs: sweep.pairs,
|
|
1446
|
+
branches: sweep.branches.map((entry) => ({ branch: entry.branch, tip: entry.tip, dirty: entry.dirty })),
|
|
1447
|
+
},
|
|
1448
|
+
line: `radar: max ${summary.max} · ${summary.atLeastL2} pairs ≥ L2 · ${summary.unknown} unknown (scope: ${onlyBranches.join(', ') || '(none)'})`,
|
|
1449
|
+
};
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
// `--radar-for a,b` / `--radar-for a`. An empty value is an empty restriction (see radar.sweep),
|
|
1453
|
+
// which is what a caller with no branches to name deserves: no pairs, no snapshots.
|
|
1454
|
+
function parseBranchList(raw) {
|
|
1455
|
+
const names = String(raw).split(',').map((name) => name.trim()).filter(Boolean);
|
|
1456
|
+
return [...new Set(names)];
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
async function cmdWait(opts, cwd) {
|
|
1460
|
+
const ctx = context(cwd);
|
|
1461
|
+
const id = opts.positionals[0];
|
|
1462
|
+
if (!id) throw usageError('land wait <jobId> [--timeout-s <seconds>]');
|
|
1463
|
+
let timeoutMs = WAIT_TIMEOUT_MS;
|
|
1464
|
+
if (opts.values['timeout-s'] !== undefined) {
|
|
1465
|
+
const seconds = Number(opts.values['timeout-s']);
|
|
1466
|
+
if (!Number.isFinite(seconds) || seconds <= 0) throw usageError('--timeout-s must be a positive number of seconds');
|
|
1467
|
+
timeoutMs = Math.round(seconds * 1000);
|
|
1468
|
+
}
|
|
1469
|
+
const existing = queue.jobOrThrow(ctx.root, id);
|
|
1470
|
+
const final = queue.TERMINAL_STATES.includes(existing.state)
|
|
1471
|
+
? existing
|
|
1472
|
+
: await waitForTerminal(ctx.root, id, timeoutMs);
|
|
1473
|
+
if (!final) {
|
|
1474
|
+
emitLine(`job ${id} still ${queue.readJob(ctx.root, id).state} after ${Math.round(timeoutMs / 1000)}s`);
|
|
1475
|
+
return 75;
|
|
1476
|
+
}
|
|
1477
|
+
emitLine(`job ${id} ${final.state}${final.reason ? ` (${final.reason})` : ''}`);
|
|
1478
|
+
return exitCodeForState(final.state);
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
function cmdInspect(opts, cwd) {
|
|
1482
|
+
const ctx = context(cwd);
|
|
1483
|
+
const id = opts.positionals[0];
|
|
1484
|
+
if (!id) throw usageError('land inspect <jobId>');
|
|
1485
|
+
const job = queue.jobOrThrow(ctx.root, id);
|
|
1486
|
+
emitJson({
|
|
1487
|
+
contract: CONTRACT,
|
|
1488
|
+
job,
|
|
1489
|
+
// WP-0 (§6.6/D-8): the same object the job carries, hoisted so a reader does not have to know
|
|
1490
|
+
// where the runner keeps it.
|
|
1491
|
+
gate: job.gate === undefined ? null : job.gate,
|
|
1492
|
+
gateLog: job.gateLog || null,
|
|
1493
|
+
gateLogExists: Boolean(job.gateLog && fs.existsSync(job.gateLog)),
|
|
1494
|
+
verdictFile: job.tree ? gate.verdictFile(ctx.root, job.tree) : null,
|
|
1495
|
+
trainDirHint: job.id,
|
|
1496
|
+
});
|
|
1497
|
+
return 0;
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
// §6.4: `land record-proof` — the one thing the pre-commit hook calls after a green gate (§9 S-5:
|
|
1501
|
+
// today the hook checks the PASS and throws it away). It resolves the policy exactly as the runner
|
|
1502
|
+
// does (`loadTargetLandingConfig`, the `.ctm.json` committed AT the target) and computes the gates
|
|
1503
|
+
// hash with the same call the gate itself was run under (`config.gatesHash(config.gates)`, the
|
|
1504
|
+
// identical expression `lib/landing/gate.js` `runGate` evaluates), so a proof the hook wrote is
|
|
1505
|
+
// readable by the runner verbatim — a mismatch on either half is a cache miss, never a wrong hit.
|
|
1506
|
+
//
|
|
1507
|
+
// Exit 3 and no write for every refusal: a log that is not a PASS, a log carrying a runner's FLAKY
|
|
1508
|
+
// evidence, a diff against the plan base that touches a dependency manifest, or a repository with no
|
|
1509
|
+
// landing config at the target. It never fails the commit — the hook ignores a non-zero exit and
|
|
1510
|
+
// prints one line (§6.4). Exit 0 prints the proof path.
|
|
1511
|
+
function cmdRecordProof(opts, cwd) {
|
|
1512
|
+
const refuse = (reason) => {
|
|
1513
|
+
if (opts.flags.has('json')) emitJson({ contract: CONTRACT, ok: false, error: { code: reason } });
|
|
1514
|
+
else process.stderr.write(`[land] record-proof: refused (${reason})\n`);
|
|
1515
|
+
return 3;
|
|
1516
|
+
};
|
|
1517
|
+
const { tree, log } = opts.values;
|
|
1518
|
+
const planBase = opts.values['plan-base'];
|
|
1519
|
+
if (!tree || !planBase || !log) {
|
|
1520
|
+
throw usageError('land record-proof --tree <oid> --plan-base <rev> --log <path> --seconds <n> [--selection <json-file>] [--commit <sha>] [--json]');
|
|
1521
|
+
}
|
|
1522
|
+
const seconds = opts.values.seconds === undefined ? 0 : Number(opts.values.seconds);
|
|
1523
|
+
if (!Number.isFinite(seconds)) throw usageError('--seconds must be a number');
|
|
1524
|
+
let config;
|
|
1525
|
+
try {
|
|
1526
|
+
config = loadTargetLandingConfig(cwd);
|
|
1527
|
+
} catch (error) {
|
|
1528
|
+
/* expected: no landing config at the target (or no repository at all) is a REFUSAL by design
|
|
1529
|
+
(§6.4) — the exit code and the one-line message ARE the disposition */
|
|
1530
|
+
return refuse(typeof error.code === 'string' ? error.code : 'config_unreadable');
|
|
1531
|
+
}
|
|
1532
|
+
let planBaseTree;
|
|
1533
|
+
try {
|
|
1534
|
+
planBaseTree = revParse(cwd, `${planBase}^{tree}`);
|
|
1535
|
+
} catch (error) {
|
|
1536
|
+
/* expected: an unresolvable --plan-base is a refusal, not a crash — nothing can be keyed
|
|
1537
|
+
without the parent's tree */
|
|
1538
|
+
return refuse('plan_base_unresolved');
|
|
1539
|
+
}
|
|
1540
|
+
let logText;
|
|
1541
|
+
try {
|
|
1542
|
+
logText = fs.readFileSync(log, 'utf8');
|
|
1543
|
+
} catch (error) {
|
|
1544
|
+
/* expected: an unreadable gate log cannot be shown to be a PASS, so it is refused rather than
|
|
1545
|
+
guessed at */
|
|
1546
|
+
return refuse('log_unreadable');
|
|
1547
|
+
}
|
|
1548
|
+
const refused = proof.proofRefusal(cwd, { planBase, tree, logText });
|
|
1549
|
+
if (refused) return refuse(refused);
|
|
1550
|
+
let selection = null;
|
|
1551
|
+
if (opts.values.selection !== undefined) {
|
|
1552
|
+
try {
|
|
1553
|
+
selection = JSON.parse(fs.readFileSync(opts.values.selection, 'utf8'));
|
|
1554
|
+
} catch (error) {
|
|
1555
|
+
/* expected: a gate that wrote no --selection-out file, or a torn one, reads as "unknown" —
|
|
1556
|
+
the proof still records the PASS rather than refusing over a missing extra */
|
|
1557
|
+
selection = null;
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
const file = proof.recordProof(context(cwd).root, {
|
|
1561
|
+
tree,
|
|
1562
|
+
planBaseTree,
|
|
1563
|
+
gatesHash: gatesHash(config.gates),
|
|
1564
|
+
verdict: 'PASS',
|
|
1565
|
+
source: 'commit',
|
|
1566
|
+
commit: opts.values.commit === undefined ? null : opts.values.commit,
|
|
1567
|
+
seconds,
|
|
1568
|
+
at: new Date().toISOString(),
|
|
1569
|
+
logPath: path.resolve(cwd, log),
|
|
1570
|
+
selection,
|
|
1571
|
+
});
|
|
1572
|
+
if (opts.flags.has('json')) emitJson({ contract: CONTRACT, ok: true, proof: file });
|
|
1573
|
+
else emitLine(file);
|
|
1574
|
+
return 0;
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
// §8 WP-3a/AC-3b: `land report merge-delta` is the OQ-2 instrument — it aggregates the shadow records
|
|
1578
|
+
// the runner already wrote onto every merge train's `gate.intersection` (n trains, n per mode, the
|
|
1579
|
+
// median |S|/|sel(B→T)| and the count of failed trains with a failing test outside S). It reads the
|
|
1580
|
+
// job records and nothing else, so it works on a repo whose runner is not running. The aggregation
|
|
1581
|
+
// itself lives in lib/landing/merge-delta.js, beside the measurement it reports on.
|
|
1582
|
+
function cmdReport(opts, cwd) {
|
|
1583
|
+
const topic = opts.positionals[0];
|
|
1584
|
+
if (topic !== 'merge-delta') throw usageError('land report merge-delta [--json]');
|
|
1585
|
+
const report = mergeDelta.mergeDeltaReport(context(cwd).root);
|
|
1586
|
+
if (opts.flags.has('json')) emitJson({ contract: CONTRACT, ...report });
|
|
1587
|
+
else for (const line of mergeDelta.reportLines(report)) emitLine(line);
|
|
1588
|
+
return 0;
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
// §6.2. A job that is not running is finished directly (only the runner writes in-progress
|
|
1592
|
+
// transitions, and there is no runner). For an `in_progress` job the runner is the only writer, so
|
|
1593
|
+
// this verb ASKS it to stop (D-5) and then waits for the runner to settle the job.
|
|
1594
|
+
//
|
|
1595
|
+
// D-10: a runner from an older `land.js` has no `stop` capability, so it would keep going and, on a
|
|
1596
|
+
// PASS, overwrite the `canceled` state with `landed`. Writing a state that runner will clobber is
|
|
1597
|
+
// worse than refusing: the refusal names the pid and says what to do about it.
|
|
1598
|
+
async function cmdCancel(opts, cwd) {
|
|
1599
|
+
const ctx = context(cwd);
|
|
1600
|
+
const id = opts.positionals[0];
|
|
1601
|
+
if (!id) throw usageError('land cancel <jobId> [--force]');
|
|
1602
|
+
const job = queue.jobOrThrow(ctx.root, id);
|
|
1603
|
+
if (queue.TERMINAL_STATES.includes(job.state)) {
|
|
1604
|
+
throw landingError('job_terminal', `job ${id} is already ${job.state}`, { jobId: id, state: job.state });
|
|
1605
|
+
}
|
|
1606
|
+
// D-6 (§9 S-2, WP-1): a member of a running train is a WITHDRAW — no `--force`; the refusal below
|
|
1607
|
+
// protects a caller's own singleton gate, which no one else is gated by.
|
|
1608
|
+
if (job.state === 'in_progress' && !opts.flags.has('force') && !stop.isBatchMemberInFlight(job, lease.read(ctx.root))) {
|
|
1609
|
+
throw landingError('job_in_progress', `job ${id} is in_progress; rerun with --force to cancel a running job`, { jobId: id });
|
|
1610
|
+
}
|
|
1611
|
+
const record = job.state === 'in_progress' ? lease.read(ctx.root) : null;
|
|
1612
|
+
// D-10: no lease or a dead lease means no runner is working this job — finish it directly, which
|
|
1613
|
+
// is exactly today's path and is now correct.
|
|
1614
|
+
if (job.state !== 'in_progress' || !record || lease.isDead(record)) {
|
|
1615
|
+
const next = queue.finishJob(ctx.root, id, {
|
|
1616
|
+
state: 'canceled',
|
|
1617
|
+
reason: null,
|
|
1618
|
+
canceledBy: { pid: process.pid, cwd: process.cwd(), at: queue.nowIso() },
|
|
1619
|
+
timestamps: { ...job.timestamps, canceled: queue.nowIso() },
|
|
1620
|
+
});
|
|
1621
|
+
emitLine(`job ${id} ${next.state}`);
|
|
1622
|
+
return 0;
|
|
1623
|
+
}
|
|
1624
|
+
if (!lease.hasCap(record, 'stop')) {
|
|
1625
|
+
throw landingError(
|
|
1626
|
+
'runner_cannot_stop',
|
|
1627
|
+
`runner pid ${record.pid} predates the stop primitive; it would land job ${id} anyway — wait for it, or stop that runner`,
|
|
1628
|
+
{ jobId: id, pid: record.pid },
|
|
1629
|
+
);
|
|
1630
|
+
}
|
|
1631
|
+
return await requestStopAndWait(ctx, id, record);
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
// D-5: write the stop request under the mutex, then poll the job until the runner settles it. The
|
|
1635
|
+
// runner's heartbeat (10 s) plus the SIGKILL grace (10 s) bound how long the request can take to
|
|
1636
|
+
// land; a runner that has not settled by `CANCEL_WAIT_MS` is reported (exit 75), never waited on.
|
|
1637
|
+
async function requestStopAndWait(ctx, id, record) {
|
|
1638
|
+
queue.requestStop(ctx.root, id, { kind: 'cancel', by: priorityBy(ctx.cwd), reason: null });
|
|
1639
|
+
const deadline = Date.now() + CANCEL_WAIT_MS;
|
|
1640
|
+
for (;;) {
|
|
1641
|
+
const job = queue.readJob(ctx.root, id);
|
|
1642
|
+
if (job && queue.TERMINAL_STATES.includes(job.state)) {
|
|
1643
|
+
if (job.state === 'canceled') {
|
|
1644
|
+
// D-6 (§9 S-3): a withdrawn member's line says what happened to the train's other members.
|
|
1645
|
+
const members = job.batch && Array.isArray(job.batch.members) ? job.batch.members : [];
|
|
1646
|
+
if (members.length >= 2) emitLine(`job ${id} withdrawn from batch ${job.batch.id}; ${members.length - 1} other member(s) requeued`);
|
|
1647
|
+
else emitLine(`job ${id} canceled`);
|
|
1648
|
+
return 0;
|
|
1649
|
+
}
|
|
1650
|
+
// The gate PASSed before the request was read: the job landed, and that is the truth.
|
|
1651
|
+
emitLine(`job ${id} ${job.state} (the cancel arrived after the gate)`);
|
|
1652
|
+
return 1;
|
|
1653
|
+
}
|
|
1654
|
+
const remaining = deadline - Date.now();
|
|
1655
|
+
if (remaining <= 0) break;
|
|
1656
|
+
await delay(Math.min(CANCEL_POLL_MS, remaining));
|
|
1657
|
+
}
|
|
1658
|
+
emitLine(`cancel requested; runner pid ${record.pid} will stop job ${id} within ~20 s`);
|
|
1659
|
+
return 75;
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
// --- the radar verbs (WP-2, §5 D-7, §6.2) --------------------------------------------------------
|
|
1663
|
+
|
|
1664
|
+
// §6.2 `land conflicts`: the advisory radar. Exit 0 whatever the levels — a conflict here is
|
|
1665
|
+
// information, not a refusal; `land submit` is what decides, and it decides on the gate.
|
|
1666
|
+
function cmdConflicts(opts, cwd) {
|
|
1667
|
+
const config = loadTargetLandingConfig(cwd);
|
|
1668
|
+
const result = radar.sweep(cwd, { config, withBranch: opts.values.with });
|
|
1669
|
+
if (opts.flags.has('json')) {
|
|
1670
|
+
emitJson(result);
|
|
1671
|
+
return 0;
|
|
1672
|
+
}
|
|
1673
|
+
const shown = result.pairs.filter((pair) => radar.levelRank(pair.level) >= radar.levelRank('L1'));
|
|
1674
|
+
shown.sort(comparePairs);
|
|
1675
|
+
for (const pair of shown) emitLine(formatPair(pair));
|
|
1676
|
+
emitLine(`radar: ${shown.length} pairs, max ${radar.summarize(result.pairs).max}`);
|
|
1677
|
+
return 0;
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
function comparePairs(left, right) {
|
|
1681
|
+
const byLevel = radar.levelRank(right.level) - radar.levelRank(left.level);
|
|
1682
|
+
if (byLevel !== 0) return byLevel;
|
|
1683
|
+
const byA = left.a.localeCompare(right.a);
|
|
1684
|
+
return byA !== 0 ? byA : left.b.localeCompare(right.b);
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
// `L3 feat/a × main conflicts: x.js (ledger)` — the level, the pair, then what was found.
|
|
1688
|
+
function formatPair(pair) {
|
|
1689
|
+
const head = `${pair.level} ${pair.a} × ${pair.b}`;
|
|
1690
|
+
if (pair.conflicts.length) return `${head} conflicts: ${pair.conflicts.join(', ')}${pair.ledger ? ' (ledger)' : ''}`;
|
|
1691
|
+
const tags = [];
|
|
1692
|
+
if (pair.registry) tags.push('registry');
|
|
1693
|
+
if (pair.claims.length) tags.push(`claims: ${pair.claims.map((claim) => claim.session).join(', ')}`);
|
|
1694
|
+
return `${head} files: ${pair.files.join(', ')}${tags.length ? ` (${tags.join('; ')})` : ''}`;
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
// The session is the claim's identity: a claim file is per session, so a session id that could name
|
|
1698
|
+
// a path is refused rather than sanitized (a rewritten id would be someone else's claim).
|
|
1699
|
+
function claimSession(opts) {
|
|
1700
|
+
const raw = opts.values.session || strFlag('CTM_SESSION_ID') || '';
|
|
1701
|
+
const session = String(raw).trim();
|
|
1702
|
+
if (!session) {
|
|
1703
|
+
throw landingError('session_required', 'no session: pass --session <id> or set CTM_SESSION_ID', {});
|
|
1704
|
+
}
|
|
1705
|
+
if (session === '.' || session === '..' || session.includes('/') || session.includes('\\')) {
|
|
1706
|
+
throw landingError('session_invalid', `session id ${JSON.stringify(session)} is not a usable claim name`, { session });
|
|
1707
|
+
}
|
|
1708
|
+
return session;
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
function claimFile(ctx, session) {
|
|
1712
|
+
return path.join(ctx.root, 'claims', `${session}.json`);
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
// Claim paths are recorded REPOSITORY-ROOT relative, because that is what the radar compares them
|
|
1716
|
+
// against (`git diff --name-only` is root-relative). A path typed from a subdirectory is prefixed with
|
|
1717
|
+
// that directory's `git rev-parse --show-prefix`, so `land claim lib/x.js` in `pkg/` stores
|
|
1718
|
+
// `pkg/lib/x.js` and matches. A path that climbs out of the repository (`..`) is refused rather than
|
|
1719
|
+
// stored as text the radar can never match. A leading `/` is treated as "from the repository root"
|
|
1720
|
+
// (the prefix is still applied), which is what a reader of the path expects.
|
|
1721
|
+
function claimPaths(cwd, rawPaths) {
|
|
1722
|
+
const res = runGitRaw(cwd, ['rev-parse', '--show-prefix']);
|
|
1723
|
+
if (res.status !== 0) {
|
|
1724
|
+
throw landingError('git_failed', `cannot resolve the repository prefix in ${cwd}`, { cwd, stderr: res.stderr });
|
|
1725
|
+
}
|
|
1726
|
+
const prefix = res.stdout.trim();
|
|
1727
|
+
return rawPaths.map((raw) => {
|
|
1728
|
+
// A leading `/` names a path from the repository root; anything else is relative to cwd.
|
|
1729
|
+
const text = String(raw);
|
|
1730
|
+
const rooted = text.startsWith('/');
|
|
1731
|
+
const normalized = path.posix.normalize(path.posix.join(rooted ? '' : prefix, text.replace(/^\/+/, '')));
|
|
1732
|
+
if (!normalized || normalized === '.' || normalized === '..' || normalized.startsWith('../')) {
|
|
1733
|
+
throw landingError('claim_path_invalid', `claim path ${JSON.stringify(raw)} is outside the repository`, { path: raw });
|
|
1734
|
+
}
|
|
1735
|
+
return normalized;
|
|
1736
|
+
});
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
// §6.2 `land claim <path>...`: a SOFT claim (D-7). It warns another session that its branch touches
|
|
1740
|
+
// the same paths; it never blocks, never locks, and expires on its own.
|
|
1741
|
+
function cmdClaim(opts, cwd) {
|
|
1742
|
+
const ctx = context(cwd);
|
|
1743
|
+
const session = claimSession(opts);
|
|
1744
|
+
const raw = opts.positionals.slice();
|
|
1745
|
+
if (!raw.length) throw usageError('land claim <path>... [--ttl-h 4] [--session <id>]');
|
|
1746
|
+
const paths = claimPaths(cwd, raw);
|
|
1747
|
+
const hours = opts.values['ttl-h'] === undefined ? radar.CLAIM_DEFAULT_TTL_HOURS : Number(opts.values['ttl-h']);
|
|
1748
|
+
if (!Number.isFinite(hours) || hours <= 0) throw usageError('--ttl-h must be a positive number of hours');
|
|
1749
|
+
const branch = currentBranch(cwd);
|
|
1750
|
+
const record = {
|
|
1751
|
+
contract: CONTRACT,
|
|
1752
|
+
session,
|
|
1753
|
+
branch,
|
|
1754
|
+
paths,
|
|
1755
|
+
until: new Date(Date.now() + hours * 3600 * 1000).toISOString(),
|
|
1756
|
+
};
|
|
1757
|
+
// One claim per session (the file is named for it): a second `land claim` replaces the first.
|
|
1758
|
+
writeJsonAtomic(claimFile(ctx, session), record);
|
|
1759
|
+
emitJsonOrLine(opts, record, `claim: ${session} on ${branch}: ${paths.join(', ')} until ${record.until}`);
|
|
1760
|
+
return 0;
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
// §6.2 `land unclaim`: removes only the caller's own file. Nothing to remove is not an error — the
|
|
1764
|
+
// caller asked for the claim to be gone, and it is.
|
|
1765
|
+
function cmdUnclaim(opts, cwd) {
|
|
1766
|
+
const ctx = context(cwd);
|
|
1767
|
+
const session = claimSession(opts);
|
|
1768
|
+
const file = claimFile(ctx, session);
|
|
1769
|
+
// A claim written by another contract is still THIS session's file; it is removed either way, since
|
|
1770
|
+
// leaving it behind would keep warning others with text this build cannot read.
|
|
1771
|
+
const existed = fs.existsSync(file);
|
|
1772
|
+
if (existed) fs.rmSync(file, { force: true });
|
|
1773
|
+
emitJsonOrLine(opts, { contract: CONTRACT, session, removed: existed }, existed ? `unclaimed: ${session}` : `no claim for ${session}`);
|
|
1774
|
+
return 0;
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
// --- WP-0/WP-1 (§6.1-§6.3, §7, §8 WP-0/WP-1): `land sync`, the sync-job half of the queue, and the
|
|
1778
|
+
// four origin doctor checks. The bodies live in lib/landing/sync-cli.js — moved verbatim to keep this
|
|
1779
|
+
// file under its frozen size ceiling. The module owns no process-level helper of its own: the ones
|
|
1780
|
+
// below are handed over once, here, right after this file's own definitions.
|
|
1781
|
+
syncCli.configure({
|
|
1782
|
+
progress, emitLine, emitJson, emitJsonOrLine, delay, context, branchRef, spawnRunner, readGuardState,
|
|
1783
|
+
CONTRACT, WAIT_TIMEOUT_MS, POLL_MS,
|
|
1784
|
+
});
|
|
1785
|
+
|
|
1786
|
+
// §13.5/§13.7 WP-6: `land resolve`, the resolver's validate-and-submit verb (lib/landing/resolve-cli.js).
|
|
1787
|
+
resolveCli.configure({ progress, emitJsonOrLine, context, branchRef, CONTRACT, WAIT_TIMEOUT_MS });
|
|
1788
|
+
|
|
1789
|
+
// (syncJobRecord / ensureRunner / finishSyncJob / afterTrainLanded moved to lib/landing/sync-cli.js.)
|
|
1790
|
+
|
|
1791
|
+
// --- WP-0 (§7, §8 WP-0, §9 S-3): `land doctor` and its four origin checks (hooks, guard, remote,
|
|
1792
|
+
// push-auth — the last a `--dry-run`, never a real push). Moved verbatim to lib/landing/sync-cli.js,
|
|
1793
|
+
// which is where the push-auth probe now lives; this file keeps the WP-6 target fence below.
|
|
1794
|
+
|
|
1795
|
+
// --- WP-6: the reference-transaction target fence (§7 E-1) ---------------------------------------
|
|
1796
|
+
|
|
1797
|
+
const GUARD_MARKER = '# landing-ref-guard (managed)';
|
|
1798
|
+
const GUARD_HOOK_NAME = 'reference-transaction';
|
|
1799
|
+
const GUARD_CHAINED_NAME = 'reference-transaction.chained';
|
|
1800
|
+
// The guard is a repo-root `bin/` script, resolved relative to this file — never a copy, and never
|
|
1801
|
+
// looked up through PATH (a repo whose PATH lacks the tools directory still installs the fence).
|
|
1802
|
+
const GUARD_SCRIPT = path.resolve(__dirname, '..', '..', 'bin', 'landing-ref-guard.sh');
|
|
1803
|
+
const GUARD_TARGET_LINE_RE = /^LANDING_TARGET_REF=.*$/m;
|
|
1804
|
+
|
|
1805
|
+
function guardHookPath(commonDirPath, name) {
|
|
1806
|
+
return path.join(commonDirPath, 'hooks', name);
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
// The guard script with its `LANDING_TARGET_REF=` line rewritten to `refs/heads/<target>`. Read on
|
|
1810
|
+
// every install so the installed copy can be compared byte-for-byte with the source of truth.
|
|
1811
|
+
function renderGuardScript(target) {
|
|
1812
|
+
let source;
|
|
1813
|
+
try {
|
|
1814
|
+
source = fs.readFileSync(GUARD_SCRIPT, 'utf8');
|
|
1815
|
+
} catch (error) {
|
|
1816
|
+
throw landingError('guard_script_missing', `cannot read the landing guard script at ${GUARD_SCRIPT}: ${error.message}`, {
|
|
1817
|
+
file: GUARD_SCRIPT, cause: error,
|
|
1818
|
+
});
|
|
1819
|
+
}
|
|
1820
|
+
if (!source.includes(GUARD_MARKER)) {
|
|
1821
|
+
throw landingError('guard_script_invalid', `${GUARD_SCRIPT} is missing its ${GUARD_MARKER} marker`, { file: GUARD_SCRIPT });
|
|
1822
|
+
}
|
|
1823
|
+
if (!GUARD_TARGET_LINE_RE.test(source)) {
|
|
1824
|
+
throw landingError('guard_script_invalid', `${GUARD_SCRIPT} has no LANDING_TARGET_REF= line to rewrite`, { file: GUARD_SCRIPT });
|
|
1825
|
+
}
|
|
1826
|
+
const line = `LANDING_TARGET_REF=refs/heads/${target}`;
|
|
1827
|
+
return source.replace(GUARD_TARGET_LINE_RE, () => line);
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
// Install (or refresh) the guard. Idempotent by construction: the installed bytes are a pure
|
|
1831
|
+
// function of the guard script and the target, so a second run writes nothing and — crucially —
|
|
1832
|
+
// does not move anything aside a second time. A foreign `reference-transaction` hook is moved to
|
|
1833
|
+
// `reference-transaction.chained` exactly once; if that name is already taken we refuse rather than
|
|
1834
|
+
// destroy either hook.
|
|
1835
|
+
function installGuard(commonDirPath, target) {
|
|
1836
|
+
const hooksDir = path.join(commonDirPath, 'hooks');
|
|
1837
|
+
const hook = guardHookPath(commonDirPath, GUARD_HOOK_NAME);
|
|
1838
|
+
const chained = guardHookPath(commonDirPath, GUARD_CHAINED_NAME);
|
|
1839
|
+
const content = renderGuardScript(target);
|
|
1840
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
1841
|
+
|
|
1842
|
+
let existing = null;
|
|
1843
|
+
try {
|
|
1844
|
+
existing = fs.readFileSync(hook, 'utf8');
|
|
1845
|
+
} catch (error) {
|
|
1846
|
+
/* expected: no reference-transaction hook in this repository yet — the common install case */
|
|
1847
|
+
existing = null;
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
if (existing !== null && !existing.includes(GUARD_MARKER)) {
|
|
1851
|
+
if (fs.existsSync(chained)) {
|
|
1852
|
+
throw landingError('guard_hook_conflict', `${chained} already exists; refusing to move the foreign ${hook} aside on top of it`, {
|
|
1853
|
+
hook, chained,
|
|
1854
|
+
});
|
|
1855
|
+
}
|
|
1856
|
+
fs.renameSync(hook, chained);
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
if (existing === content) {
|
|
1860
|
+
fs.chmodSync(hook, 0o755);
|
|
1861
|
+
return { hook, chained: fs.existsSync(chained) ? chained : null, action: 'unchanged' };
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
// Temp file + rename inside the hooks directory: a reader (git, mid-transaction) never sees a
|
|
1865
|
+
// half-written hook.
|
|
1866
|
+
const staging = path.join(hooksDir, `.${GUARD_HOOK_NAME}.guard.${process.pid}.${Date.now()}`);
|
|
1867
|
+
try {
|
|
1868
|
+
fs.writeFileSync(staging, content, { mode: 0o755 });
|
|
1869
|
+
fs.chmodSync(staging, 0o755);
|
|
1870
|
+
fs.renameSync(staging, hook);
|
|
1871
|
+
} catch (error) {
|
|
1872
|
+
try {
|
|
1873
|
+
fs.rmSync(staging, { force: true });
|
|
1874
|
+
} catch (cleanupError) {
|
|
1875
|
+
/* expected: the staging file is private to this call and lives in the hooks directory; a
|
|
1876
|
+
failed cleanup leaves a stray dotfile behind, never a half-installed hook */
|
|
1877
|
+
}
|
|
1878
|
+
throw landingError('guard_install_failed', `cannot install ${hook}: ${error.message}`, { file: hook, cause: error });
|
|
1879
|
+
}
|
|
1880
|
+
return {
|
|
1881
|
+
hook,
|
|
1882
|
+
chained: fs.existsSync(chained) ? chained : null,
|
|
1883
|
+
action: existing === null ? 'installed' : 'updated',
|
|
1884
|
+
};
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
// Doctor's three states. `present` requires BOTH our marker and the configured target, so a hook
|
|
1888
|
+
// left over from a repository whose target moved reads as `foreign` rather than as a fence that
|
|
1889
|
+
// silently guards the wrong branch.
|
|
1890
|
+
function readGuardState(commonDirPath, target) {
|
|
1891
|
+
const hook = guardHookPath(commonDirPath, GUARD_HOOK_NAME);
|
|
1892
|
+
let content;
|
|
1893
|
+
try {
|
|
1894
|
+
content = fs.readFileSync(hook, 'utf8');
|
|
1895
|
+
} catch (error) {
|
|
1896
|
+
/* expected: no reference-transaction hook in this repository — `guard: missing` */
|
|
1897
|
+
return 'missing';
|
|
1898
|
+
}
|
|
1899
|
+
if (!content.includes(GUARD_MARKER)) return 'foreign';
|
|
1900
|
+
const baked = GUARD_TARGET_LINE_RE.exec(content);
|
|
1901
|
+
if (!baked) return 'foreign';
|
|
1902
|
+
if (target !== null && baked[0] !== `LANDING_TARGET_REF=refs/heads/${target}`) return 'foreign';
|
|
1903
|
+
return 'present';
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
// `land guard install`: the narrow installer bin/install-hooks.sh reuses. It reads the landing
|
|
1907
|
+
// config (the target and nothing else) and touches only the hook — never `.ctm.json`, never the
|
|
1908
|
+
// agent file, which is why install-hooks must not call `land init`.
|
|
1909
|
+
function cmdGuard(opts, cwd) {
|
|
1910
|
+
const verb = opts.positionals[0];
|
|
1911
|
+
if (verb !== 'install') throw usageError('usage: land guard install');
|
|
1912
|
+
const ctx = context(cwd);
|
|
1913
|
+
const config = loadLandingConfig(cwd);
|
|
1914
|
+
const result = installGuard(ctx.common, config.target);
|
|
1915
|
+
emitLine(`guard: ${result.action} ${result.hook} (target refs/heads/${config.target})`);
|
|
1916
|
+
if (result.chained) emitLine(`guard: chained ${result.chained}`);
|
|
1917
|
+
return 0;
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
// --- init (D-10) --------------------------------------------------------------------------------
|
|
1921
|
+
|
|
1922
|
+
function renderAgentRule(target) {
|
|
1923
|
+
let raw;
|
|
1924
|
+
try {
|
|
1925
|
+
raw = fs.readFileSync(AGENT_RULE_FILE, 'utf8');
|
|
1926
|
+
} catch (error) {
|
|
1927
|
+
throw landingError('agent_rule_missing', `cannot read ${AGENT_RULE_FILE}: ${error.message}`, { file: AGENT_RULE_FILE });
|
|
1928
|
+
}
|
|
1929
|
+
return raw.replace(/\n+$/, '').replace(/<target>/g, target);
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
function resolveAgentFile(top, requested) {
|
|
1933
|
+
if (requested !== undefined) {
|
|
1934
|
+
if (!AGENT_FILES.includes(requested)) {
|
|
1935
|
+
throw usageError(`--agent-file must be one of ${AGENT_FILES.join(', ')}`);
|
|
1936
|
+
}
|
|
1937
|
+
return path.join(top, requested);
|
|
1938
|
+
}
|
|
1939
|
+
for (const name of AGENT_FILES) {
|
|
1940
|
+
if (fs.existsSync(path.join(top, name))) return path.join(top, name);
|
|
1941
|
+
}
|
|
1942
|
+
return path.join(top, 'AGENTS.md');
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
// The marked block: everything between the markers is exactly the rule text. Rewriting the block in
|
|
1946
|
+
// place is what makes a second `land init` byte-identical (AC-1h).
|
|
1947
|
+
function writeMarkedBlock(file, rule) {
|
|
1948
|
+
const block = `${MARK_BEGIN}\n${rule}\n${MARK_END}`;
|
|
1949
|
+
let existing = '';
|
|
1950
|
+
try {
|
|
1951
|
+
existing = fs.readFileSync(file, 'utf8');
|
|
1952
|
+
} catch (error) {
|
|
1953
|
+
/* expected: the agent file does not exist yet — it is created below with the block as its only
|
|
1954
|
+
content */
|
|
1955
|
+
existing = '';
|
|
1956
|
+
}
|
|
1957
|
+
let next;
|
|
1958
|
+
if (MARK_RE.test(existing)) next = existing.replace(MARK_RE, block);
|
|
1959
|
+
else if (existing === '') next = `${block}\n`;
|
|
1960
|
+
else next = `${existing.replace(/\n+$/, '')}\n\n${block}\n`;
|
|
1961
|
+
if (next === existing) return false;
|
|
1962
|
+
fs.writeFileSync(file, next);
|
|
1963
|
+
return true;
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
// `.ctm.json#landing` is written only when it is absent: an existing `landing` object belongs to the
|
|
1967
|
+
// repository, not to this command. Everything else in the file is preserved (2-space JSON).
|
|
1968
|
+
function writeLandingConfig(file, landing) {
|
|
1969
|
+
let raw = null;
|
|
1970
|
+
try {
|
|
1971
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
1972
|
+
} catch (error) {
|
|
1973
|
+
/* expected: no .ctm.json in this repository yet — one is created below */
|
|
1974
|
+
raw = null;
|
|
1975
|
+
}
|
|
1976
|
+
let data = {};
|
|
1977
|
+
if (raw !== null) {
|
|
1978
|
+
try {
|
|
1979
|
+
data = JSON.parse(raw);
|
|
1980
|
+
} catch (error) {
|
|
1981
|
+
throw landingError('config_invalid', `${file} is not valid JSON: ${error.message}`, { file });
|
|
1982
|
+
}
|
|
1983
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
1984
|
+
throw landingError('config_invalid', `${file} must contain a JSON object`, { file });
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
if (data.landing !== undefined) return { file, written: false, created: false };
|
|
1988
|
+
const next = `${JSON.stringify({ ...data, landing }, null, 2)}\n`;
|
|
1989
|
+
fs.writeFileSync(file, next);
|
|
1990
|
+
return { file, written: true, created: raw === null };
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
function cmdInit(opts, cwd) {
|
|
1994
|
+
const top = toplevel(cwd);
|
|
1995
|
+
const target = resolveMainBranch(top);
|
|
1996
|
+
const guard = opts.flags.has('guard');
|
|
1997
|
+
const config = writeLandingConfig(path.join(top, '.ctm.json'), { target, mode: 'serial', guard });
|
|
1998
|
+
const agentFile = resolveAgentFile(top, opts.values['agent-file']);
|
|
1999
|
+
const wroteBlock = writeMarkedBlock(agentFile, renderAgentRule(target));
|
|
2000
|
+
emitLine(`config: ${config.file}${config.written ? ' written' : ' already configured (left untouched)'}`);
|
|
2001
|
+
emitLine(`agent rule: ${agentFile}${wroteBlock ? ' written' : ' already up to date'}`);
|
|
2002
|
+
if (guard) {
|
|
2003
|
+
// WP-6 E-1: `--guard` installs the reference-transaction fence for the configured target. Same
|
|
2004
|
+
// installer as `land guard install`; the rest of init (config, agent file) is unchanged.
|
|
2005
|
+
const installed = installGuard(commonDir(top), target);
|
|
2006
|
+
emitLine(`guard: ${installed.action} ${installed.hook} (target refs/heads/${target})`);
|
|
2007
|
+
if (installed.chained) emitLine(`guard: chained ${installed.chained}`);
|
|
2008
|
+
} else {
|
|
2009
|
+
emitLine('guard: missing');
|
|
2010
|
+
}
|
|
2011
|
+
return 0;
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
// --- dispatch ------------------------------------------------------------------------------------
|
|
2015
|
+
|
|
2016
|
+
async function dispatch(argv) {
|
|
2017
|
+
const [verb, ...rest] = argv;
|
|
2018
|
+
if (!verb || verb === 'help' || verb === '--help' || verb === '-h') {
|
|
2019
|
+
process.stdout.write(`${USAGE}\n`);
|
|
2020
|
+
return verb ? 0 : 2;
|
|
2021
|
+
}
|
|
2022
|
+
const spec = FLAG_SPECS[verb];
|
|
2023
|
+
if (!spec) throw usageError(`unknown command ${JSON.stringify(verb)}`);
|
|
2024
|
+
const opts = parseArgs(rest, spec);
|
|
2025
|
+
const cwd = process.cwd();
|
|
2026
|
+
switch (verb) {
|
|
2027
|
+
case 'init': return cmdInit(opts, cwd);
|
|
2028
|
+
case 'guard': return cmdGuard(opts, cwd);
|
|
2029
|
+
case 'submit': return await cmdSubmit(opts, cwd);
|
|
2030
|
+
case 'run': return await cmdRun(opts, cwd);
|
|
2031
|
+
case 'priority': return cmdPriority(opts, cwd);
|
|
2032
|
+
case 'status': return cmdStatus(opts, cwd);
|
|
2033
|
+
case 'wait': return await cmdWait(opts, cwd);
|
|
2034
|
+
case 'inspect': return cmdInspect(opts, cwd);
|
|
2035
|
+
case 'cancel': return await cmdCancel(opts, cwd);
|
|
2036
|
+
case 'conflicts': return cmdConflicts(opts, cwd);
|
|
2037
|
+
case 'claim': return cmdClaim(opts, cwd);
|
|
2038
|
+
case 'unclaim': return cmdUnclaim(opts, cwd);
|
|
2039
|
+
case 'record-proof': return cmdRecordProof(opts, cwd);
|
|
2040
|
+
case 'report': return cmdReport(opts, cwd);
|
|
2041
|
+
case 'sync': return await syncCli.cmdSync(opts, cwd);
|
|
2042
|
+
case 'resolve': return await resolveCli.cmdResolve(opts, cwd);
|
|
2043
|
+
case 'doctor': return syncCli.cmdDoctor(opts, cwd);
|
|
2044
|
+
default: throw usageError(`unknown command ${JSON.stringify(verb)}`);
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
async function main(argv) {
|
|
2049
|
+
const wantsJson = argv.includes('--json');
|
|
2050
|
+
try {
|
|
2051
|
+
return await dispatch(argv);
|
|
2052
|
+
} catch (error) {
|
|
2053
|
+
return reportFailure(error, wantsJson);
|
|
2054
|
+
}
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
if (require.main === module) {
|
|
2058
|
+
// The CLI is asynchronous (gates are spawned, not blocked on), so the exit code is set once the
|
|
2059
|
+
// whole verb has settled; `process.exit` is never called early, or buffered stdout would be lost.
|
|
2060
|
+
main(process.argv.slice(2)).then(
|
|
2061
|
+
(code) => { process.exitCode = code; },
|
|
2062
|
+
(error) => {
|
|
2063
|
+
process.stderr.write(`land: unexpected: ${error && error.message ? error.message : String(error)}\n`);
|
|
2064
|
+
process.exitCode = 1;
|
|
2065
|
+
},
|
|
2066
|
+
);
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
module.exports = { main, parseArgs, FLAG_SPECS, USAGE };
|