looply-cli 0.0.0-stage → 0.2.0
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/LICENSE +21 -0
- package/README.md +137 -3
- package/dist/.src-hash +1 -0
- package/dist/acceptance-observe.d.ts +108 -0
- package/dist/acceptance-observe.d.ts.map +1 -0
- package/dist/acceptance-observe.js +152 -0
- package/dist/acceptance-observe.js.map +1 -0
- package/dist/agent-cmd.d.ts +131 -0
- package/dist/agent-cmd.d.ts.map +1 -0
- package/dist/agent-cmd.js +154 -0
- package/dist/agent-cmd.js.map +1 -0
- package/dist/agent-contained.d.ts +114 -0
- package/dist/agent-contained.d.ts.map +1 -0
- package/dist/agent-contained.js +292 -0
- package/dist/agent-contained.js.map +1 -0
- package/dist/agent-driver.d.ts +130 -0
- package/dist/agent-driver.d.ts.map +1 -0
- package/dist/agent-driver.js +170 -0
- package/dist/agent-driver.js.map +1 -0
- package/dist/agent-envelope.d.ts +94 -0
- package/dist/agent-envelope.d.ts.map +1 -0
- package/dist/agent-envelope.js +214 -0
- package/dist/agent-envelope.js.map +1 -0
- package/dist/agent-exec.d.ts +182 -0
- package/dist/agent-exec.d.ts.map +1 -0
- package/dist/agent-exec.js +125 -0
- package/dist/agent-exec.js.map +1 -0
- package/dist/agent-runtime.d.ts +23 -0
- package/dist/agent-runtime.d.ts.map +1 -0
- package/dist/agent-runtime.js +143 -0
- package/dist/agent-runtime.js.map +1 -0
- package/dist/approval-gate-read.d.ts +66 -0
- package/dist/approval-gate-read.d.ts.map +1 -0
- package/dist/approval-gate-read.js +138 -0
- package/dist/approval-gate-read.js.map +1 -0
- package/dist/approval-resolution.d.ts +87 -0
- package/dist/approval-resolution.d.ts.map +1 -0
- package/dist/approval-resolution.js +100 -0
- package/dist/approval-resolution.js.map +1 -0
- package/dist/approval-resumes.d.ts +102 -0
- package/dist/approval-resumes.d.ts.map +1 -0
- package/dist/approval-resumes.js +167 -0
- package/dist/approval-resumes.js.map +1 -0
- package/dist/ask-brain.d.ts +167 -0
- package/dist/ask-brain.d.ts.map +1 -0
- package/dist/ask-brain.js +355 -0
- package/dist/ask-brain.js.map +1 -0
- package/dist/autonomy-authority.d.ts +80 -0
- package/dist/autonomy-authority.d.ts.map +1 -0
- package/dist/autonomy-authority.js +82 -0
- package/dist/autonomy-authority.js.map +1 -0
- package/dist/autonomy-controller.d.ts +321 -0
- package/dist/autonomy-controller.d.ts.map +1 -0
- package/dist/autonomy-controller.js +517 -0
- package/dist/autonomy-controller.js.map +1 -0
- package/dist/autonomy-store.d.ts +327 -0
- package/dist/autonomy-store.d.ts.map +1 -0
- package/dist/autonomy-store.js +581 -0
- package/dist/autonomy-store.js.map +1 -0
- package/dist/autonomy-tick.d.ts +257 -0
- package/dist/autonomy-tick.d.ts.map +1 -0
- package/dist/autonomy-tick.js +616 -0
- package/dist/autonomy-tick.js.map +1 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +2719 -0
- package/dist/bin.js.map +1 -0
- package/dist/brain-advice.d.ts +21 -0
- package/dist/brain-advice.d.ts.map +1 -0
- package/dist/brain-advice.js +51 -0
- package/dist/brain-advice.js.map +1 -0
- package/dist/candidate-replay-runtime.d.ts +125 -0
- package/dist/candidate-replay-runtime.d.ts.map +1 -0
- package/dist/candidate-replay-runtime.js +188 -0
- package/dist/candidate-replay-runtime.js.map +1 -0
- package/dist/checkout-carry.d.ts +76 -0
- package/dist/checkout-carry.d.ts.map +1 -0
- package/dist/checkout-carry.js +125 -0
- package/dist/checkout-carry.js.map +1 -0
- package/dist/citation-readers.d.ts +26 -0
- package/dist/citation-readers.d.ts.map +1 -0
- package/dist/citation-readers.js +102 -0
- package/dist/citation-readers.js.map +1 -0
- package/dist/command-receipts.d.ts +79 -0
- package/dist/command-receipts.d.ts.map +1 -0
- package/dist/command-receipts.js +143 -0
- package/dist/command-receipts.js.map +1 -0
- package/dist/command-runner.d.ts +32 -0
- package/dist/command-runner.d.ts.map +1 -0
- package/dist/command-runner.js +108 -0
- package/dist/command-runner.js.map +1 -0
- package/dist/compatible-sets.d.ts +67 -0
- package/dist/compatible-sets.d.ts.map +1 -0
- package/dist/compatible-sets.js +81 -0
- package/dist/compatible-sets.js.map +1 -0
- package/dist/compile-check.d.ts +31 -0
- package/dist/compile-check.d.ts.map +1 -0
- package/dist/compile-check.js +34 -0
- package/dist/compile-check.js.map +1 -0
- package/dist/compile-store.d.ts +16 -0
- package/dist/compile-store.d.ts.map +1 -0
- package/dist/compile-store.js +21 -0
- package/dist/compile-store.js.map +1 -0
- package/dist/completion.d.ts +106 -0
- package/dist/completion.d.ts.map +1 -0
- package/dist/completion.js +131 -0
- package/dist/completion.js.map +1 -0
- package/dist/config.d.ts +549 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +763 -0
- package/dist/config.js.map +1 -0
- package/dist/control-file.d.ts +142 -0
- package/dist/control-file.d.ts.map +1 -0
- package/dist/control-file.js +144 -0
- package/dist/control-file.js.map +1 -0
- package/dist/coordinator-state.d.ts +131 -0
- package/dist/coordinator-state.d.ts.map +1 -0
- package/dist/coordinator-state.js +380 -0
- package/dist/coordinator-state.js.map +1 -0
- package/dist/coordinator.d.ts +95 -0
- package/dist/coordinator.d.ts.map +1 -0
- package/dist/coordinator.js +97 -0
- package/dist/coordinator.js.map +1 -0
- package/dist/cursor-store.d.ts +53 -0
- package/dist/cursor-store.d.ts.map +1 -0
- package/dist/cursor-store.js +98 -0
- package/dist/cursor-store.js.map +1 -0
- package/dist/daemon-cmd.d.ts +239 -0
- package/dist/daemon-cmd.d.ts.map +1 -0
- package/dist/daemon-cmd.js +1751 -0
- package/dist/daemon-cmd.js.map +1 -0
- package/dist/daemon-lock.d.ts +140 -0
- package/dist/daemon-lock.d.ts.map +1 -0
- package/dist/daemon-lock.js +352 -0
- package/dist/daemon-lock.js.map +1 -0
- package/dist/daemon-recovery.d.ts +78 -0
- package/dist/daemon-recovery.d.ts.map +1 -0
- package/dist/daemon-recovery.js +161 -0
- package/dist/daemon-recovery.js.map +1 -0
- package/dist/daemon.d.ts +368 -0
- package/dist/daemon.d.ts.map +1 -0
- package/dist/daemon.js +601 -0
- package/dist/daemon.js.map +1 -0
- package/dist/declared-response-shape.d.ts +93 -0
- package/dist/declared-response-shape.d.ts.map +1 -0
- package/dist/declared-response-shape.js +200 -0
- package/dist/declared-response-shape.js.map +1 -0
- package/dist/declared-surfaces.d.ts +16 -0
- package/dist/declared-surfaces.d.ts.map +1 -0
- package/dist/declared-surfaces.js +19 -0
- package/dist/declared-surfaces.js.map +1 -0
- package/dist/depends-review.d.ts +113 -0
- package/dist/depends-review.d.ts.map +1 -0
- package/dist/depends-review.js +110 -0
- package/dist/depends-review.js.map +1 -0
- package/dist/doctor.d.ts +91 -0
- package/dist/doctor.d.ts.map +1 -0
- package/dist/doctor.js +71 -0
- package/dist/doctor.js.map +1 -0
- package/dist/edge-approval.d.ts +105 -0
- package/dist/edge-approval.d.ts.map +1 -0
- package/dist/edge-approval.js +100 -0
- package/dist/edge-approval.js.map +1 -0
- package/dist/evidence-cache.d.ts +45 -0
- package/dist/evidence-cache.d.ts.map +1 -0
- package/dist/evidence-cache.js +90 -0
- package/dist/evidence-cache.js.map +1 -0
- package/dist/evidence-cmd.d.ts +133 -0
- package/dist/evidence-cmd.d.ts.map +1 -0
- package/dist/evidence-cmd.js +347 -0
- package/dist/evidence-cmd.js.map +1 -0
- package/dist/evidence-facts.d.ts +40 -0
- package/dist/evidence-facts.d.ts.map +1 -0
- package/dist/evidence-facts.js +114 -0
- package/dist/evidence-facts.js.map +1 -0
- package/dist/exclusive-lock.d.ts +52 -0
- package/dist/exclusive-lock.d.ts.map +1 -0
- package/dist/exclusive-lock.js +150 -0
- package/dist/exclusive-lock.js.map +1 -0
- package/dist/exec-domains.d.ts +140 -0
- package/dist/exec-domains.d.ts.map +1 -0
- package/dist/exec-domains.js +287 -0
- package/dist/exec-domains.js.map +1 -0
- package/dist/exemption-store.d.ts +197 -0
- package/dist/exemption-store.d.ts.map +1 -0
- package/dist/exemption-store.js +266 -0
- package/dist/exemption-store.js.map +1 -0
- package/dist/explicit-start-gate.d.ts +96 -0
- package/dist/explicit-start-gate.d.ts.map +1 -0
- package/dist/explicit-start-gate.js +103 -0
- package/dist/explicit-start-gate.js.map +1 -0
- package/dist/fs-trouble.d.ts +70 -0
- package/dist/fs-trouble.d.ts.map +1 -0
- package/dist/fs-trouble.js +98 -0
- package/dist/fs-trouble.js.map +1 -0
- package/dist/gate.d.ts +278 -0
- package/dist/gate.d.ts.map +1 -0
- package/dist/gate.js +278 -0
- package/dist/gate.js.map +1 -0
- package/dist/git-driver.d.ts +519 -0
- package/dist/git-driver.d.ts.map +1 -0
- package/dist/git-driver.js +900 -0
- package/dist/git-driver.js.map +1 -0
- package/dist/git-push-executor.d.ts +41 -0
- package/dist/git-push-executor.d.ts.map +1 -0
- package/dist/git-push-executor.js +142 -0
- package/dist/git-push-executor.js.map +1 -0
- package/dist/glob.d.ts +26 -0
- package/dist/glob.d.ts.map +1 -0
- package/dist/glob.js +93 -0
- package/dist/glob.js.map +1 -0
- package/dist/goal-adaptive.d.ts +295 -0
- package/dist/goal-adaptive.d.ts.map +1 -0
- package/dist/goal-adaptive.js +393 -0
- package/dist/goal-adaptive.js.map +1 -0
- package/dist/goal-compile.d.ts +342 -0
- package/dist/goal-compile.d.ts.map +1 -0
- package/dist/goal-compile.js +444 -0
- package/dist/goal-compile.js.map +1 -0
- package/dist/goal-depends-executor.d.ts +55 -0
- package/dist/goal-depends-executor.d.ts.map +1 -0
- package/dist/goal-depends-executor.js +202 -0
- package/dist/goal-depends-executor.js.map +1 -0
- package/dist/goal-execution-evidence.d.ts +135 -0
- package/dist/goal-execution-evidence.d.ts.map +1 -0
- package/dist/goal-execution-evidence.js +159 -0
- package/dist/goal-execution-evidence.js.map +1 -0
- package/dist/goal-gap.d.ts +140 -0
- package/dist/goal-gap.d.ts.map +1 -0
- package/dist/goal-gap.js +197 -0
- package/dist/goal-gap.js.map +1 -0
- package/dist/goal-met-target.d.ts +98 -0
- package/dist/goal-met-target.d.ts.map +1 -0
- package/dist/goal-met-target.js +92 -0
- package/dist/goal-met-target.js.map +1 -0
- package/dist/goal-ownership.d.ts +416 -0
- package/dist/goal-ownership.d.ts.map +1 -0
- package/dist/goal-ownership.js +576 -0
- package/dist/goal-ownership.js.map +1 -0
- package/dist/goal-runs.d.ts +82 -0
- package/dist/goal-runs.d.ts.map +1 -0
- package/dist/goal-runs.js +120 -0
- package/dist/goal-runs.js.map +1 -0
- package/dist/goal-store.d.ts +146 -0
- package/dist/goal-store.d.ts.map +1 -0
- package/dist/goal-store.js +294 -0
- package/dist/goal-store.js.map +1 -0
- package/dist/goal-view.d.ts +156 -0
- package/dist/goal-view.d.ts.map +1 -0
- package/dist/goal-view.js +499 -0
- package/dist/goal-view.js.map +1 -0
- package/dist/governed-approvals.d.ts +152 -0
- package/dist/governed-approvals.d.ts.map +1 -0
- package/dist/governed-approvals.js +246 -0
- package/dist/governed-approvals.js.map +1 -0
- package/dist/governed-broker.d.ts +169 -0
- package/dist/governed-broker.d.ts.map +1 -0
- package/dist/governed-broker.js +196 -0
- package/dist/governed-broker.js.map +1 -0
- package/dist/governed-run.d.ts +220 -0
- package/dist/governed-run.d.ts.map +1 -0
- package/dist/governed-run.js +406 -0
- package/dist/governed-run.js.map +1 -0
- package/dist/grant-reconcile.d.ts +63 -0
- package/dist/grant-reconcile.d.ts.map +1 -0
- package/dist/grant-reconcile.js +121 -0
- package/dist/grant-reconcile.js.map +1 -0
- package/dist/index-for-head.d.ts +56 -0
- package/dist/index-for-head.d.ts.map +1 -0
- package/dist/index-for-head.js +103 -0
- package/dist/index-for-head.js.map +1 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +55 -0
- package/dist/index.js.map +1 -0
- package/dist/init-detect.d.ts +39 -0
- package/dist/init-detect.d.ts.map +1 -0
- package/dist/init-detect.js +188 -0
- package/dist/init-detect.js.map +1 -0
- package/dist/integration-deps.d.ts +62 -0
- package/dist/integration-deps.d.ts.map +1 -0
- package/dist/integration-deps.js +251 -0
- package/dist/integration-deps.js.map +1 -0
- package/dist/integration-runtime.d.ts +458 -0
- package/dist/integration-runtime.d.ts.map +1 -0
- package/dist/integration-runtime.js +681 -0
- package/dist/integration-runtime.js.map +1 -0
- package/dist/integration.d.ts +45 -0
- package/dist/integration.d.ts.map +1 -0
- package/dist/integration.js +87 -0
- package/dist/integration.js.map +1 -0
- package/dist/integrator.d.ts +84 -0
- package/dist/integrator.d.ts.map +1 -0
- package/dist/integrator.js +193 -0
- package/dist/integrator.js.map +1 -0
- package/dist/lane-claims.d.ts +174 -0
- package/dist/lane-claims.d.ts.map +1 -0
- package/dist/lane-claims.js +218 -0
- package/dist/lane-claims.js.map +1 -0
- package/dist/lane-coverage.d.ts +87 -0
- package/dist/lane-coverage.d.ts.map +1 -0
- package/dist/lane-coverage.js +129 -0
- package/dist/lane-coverage.js.map +1 -0
- package/dist/ledger-replay.d.ts +115 -0
- package/dist/ledger-replay.d.ts.map +1 -0
- package/dist/ledger-replay.js +155 -0
- package/dist/ledger-replay.js.map +1 -0
- package/dist/ledger-store.d.ts +68 -0
- package/dist/ledger-store.d.ts.map +1 -0
- package/dist/ledger-store.js +111 -0
- package/dist/ledger-store.js.map +1 -0
- package/dist/loop-safety-observer.d.ts +132 -0
- package/dist/loop-safety-observer.d.ts.map +1 -0
- package/dist/loop-safety-observer.js +76 -0
- package/dist/loop-safety-observer.js.map +1 -0
- package/dist/loop-safety-store.d.ts +339 -0
- package/dist/loop-safety-store.d.ts.map +1 -0
- package/dist/loop-safety-store.js +800 -0
- package/dist/loop-safety-store.js.map +1 -0
- package/dist/machine-store.d.ts +54 -0
- package/dist/machine-store.d.ts.map +1 -0
- package/dist/machine-store.js +149 -0
- package/dist/machine-store.js.map +1 -0
- package/dist/mcp-main.d.ts +2 -0
- package/dist/mcp-main.d.ts.map +1 -0
- package/dist/mcp-main.js +116 -0
- package/dist/mcp-main.js.map +1 -0
- package/dist/mcp-server.d.ts +72 -0
- package/dist/mcp-server.d.ts.map +1 -0
- package/dist/mcp-server.js +259 -0
- package/dist/mcp-server.js.map +1 -0
- package/dist/model-routing.d.ts +75 -0
- package/dist/model-routing.d.ts.map +1 -0
- package/dist/model-routing.js +108 -0
- package/dist/model-routing.js.map +1 -0
- package/dist/model-transport.d.ts +188 -0
- package/dist/model-transport.d.ts.map +1 -0
- package/dist/model-transport.js +625 -0
- package/dist/model-transport.js.map +1 -0
- package/dist/notify-cadence.d.ts +55 -0
- package/dist/notify-cadence.d.ts.map +1 -0
- package/dist/notify-cadence.js +68 -0
- package/dist/notify-cadence.js.map +1 -0
- package/dist/notify-channel.d.ts +69 -0
- package/dist/notify-channel.d.ts.map +1 -0
- package/dist/notify-channel.js +151 -0
- package/dist/notify-channel.js.map +1 -0
- package/dist/notify.d.ts +71 -0
- package/dist/notify.d.ts.map +1 -0
- package/dist/notify.js +125 -0
- package/dist/notify.js.map +1 -0
- package/dist/observation-inventory.d.ts +65 -0
- package/dist/observation-inventory.d.ts.map +1 -0
- package/dist/observation-inventory.js +139 -0
- package/dist/observation-inventory.js.map +1 -0
- package/dist/owned-children.d.ts +45 -0
- package/dist/owned-children.d.ts.map +1 -0
- package/dist/owned-children.js +64 -0
- package/dist/owned-children.js.map +1 -0
- package/dist/packet-for-goal.d.ts +19 -0
- package/dist/packet-for-goal.d.ts.map +1 -0
- package/dist/packet-for-goal.js +32 -0
- package/dist/packet-for-goal.js.map +1 -0
- package/dist/pair-cmd.d.ts +85 -0
- package/dist/pair-cmd.d.ts.map +1 -0
- package/dist/pair-cmd.js +205 -0
- package/dist/pair-cmd.js.map +1 -0
- package/dist/partial-asker.d.ts +96 -0
- package/dist/partial-asker.d.ts.map +1 -0
- package/dist/partial-asker.js +117 -0
- package/dist/partial-asker.js.map +1 -0
- package/dist/pdf-text-lines.d.ts +17 -0
- package/dist/pdf-text-lines.d.ts.map +1 -0
- package/dist/pdf-text-lines.js +70 -0
- package/dist/pdf-text-lines.js.map +1 -0
- package/dist/pdf-text-worker.d.ts +2 -0
- package/dist/pdf-text-worker.d.ts.map +1 -0
- package/dist/pdf-text-worker.js +81 -0
- package/dist/pdf-text-worker.js.map +1 -0
- package/dist/pdf-text.d.ts +56 -0
- package/dist/pdf-text.d.ts.map +1 -0
- package/dist/pdf-text.js +64 -0
- package/dist/pdf-text.js.map +1 -0
- package/dist/pending-action.d.ts +154 -0
- package/dist/pending-action.d.ts.map +1 -0
- package/dist/pending-action.js +148 -0
- package/dist/pending-action.js.map +1 -0
- package/dist/preemptor.d.ts +40 -0
- package/dist/preemptor.d.ts.map +1 -0
- package/dist/preemptor.js +49 -0
- package/dist/preemptor.js.map +1 -0
- package/dist/programme-mode.d.ts +29 -0
- package/dist/programme-mode.d.ts.map +1 -0
- package/dist/programme-mode.js +50 -0
- package/dist/programme-mode.js.map +1 -0
- package/dist/progress-view.d.ts +14 -0
- package/dist/progress-view.d.ts.map +1 -0
- package/dist/progress-view.js +114 -0
- package/dist/progress-view.js.map +1 -0
- package/dist/project-graph-builder.d.ts +81 -0
- package/dist/project-graph-builder.d.ts.map +1 -0
- package/dist/project-graph-builder.js +246 -0
- package/dist/project-graph-builder.js.map +1 -0
- package/dist/project-graph-runtime.d.ts +86 -0
- package/dist/project-graph-runtime.d.ts.map +1 -0
- package/dist/project-graph-runtime.js +112 -0
- package/dist/project-graph-runtime.js.map +1 -0
- package/dist/project-index-builder.d.ts +96 -0
- package/dist/project-index-builder.d.ts.map +1 -0
- package/dist/project-index-builder.js +298 -0
- package/dist/project-index-builder.js.map +1 -0
- package/dist/project-index-facts.d.ts +51 -0
- package/dist/project-index-facts.d.ts.map +1 -0
- package/dist/project-index-facts.js +117 -0
- package/dist/project-index-facts.js.map +1 -0
- package/dist/project-index-store.d.ts +97 -0
- package/dist/project-index-store.d.ts.map +1 -0
- package/dist/project-index-store.js +140 -0
- package/dist/project-index-store.js.map +1 -0
- package/dist/project-memory-store.d.ts +127 -0
- package/dist/project-memory-store.d.ts.map +1 -0
- package/dist/project-memory-store.js +302 -0
- package/dist/project-memory-store.js.map +1 -0
- package/dist/proposal-store.d.ts +14 -0
- package/dist/proposal-store.d.ts.map +1 -0
- package/dist/proposal-store.js +19 -0
- package/dist/proposal-store.js.map +1 -0
- package/dist/prover.d.ts +80 -0
- package/dist/prover.d.ts.map +1 -0
- package/dist/prover.js +137 -0
- package/dist/prover.js.map +1 -0
- package/dist/provider-identity-store.d.ts +34 -0
- package/dist/provider-identity-store.d.ts.map +1 -0
- package/dist/provider-identity-store.js +72 -0
- package/dist/provider-identity-store.js.map +1 -0
- package/dist/provider-identity.d.ts +104 -0
- package/dist/provider-identity.d.ts.map +1 -0
- package/dist/provider-identity.js +167 -0
- package/dist/provider-identity.js.map +1 -0
- package/dist/provider-limit.d.ts +79 -0
- package/dist/provider-limit.d.ts.map +1 -0
- package/dist/provider-limit.js +148 -0
- package/dist/provider-limit.js.map +1 -0
- package/dist/provider-preflight.d.ts +171 -0
- package/dist/provider-preflight.d.ts.map +1 -0
- package/dist/provider-preflight.js +395 -0
- package/dist/provider-preflight.js.map +1 -0
- package/dist/provider-state.d.ts +120 -0
- package/dist/provider-state.d.ts.map +1 -0
- package/dist/provider-state.js +182 -0
- package/dist/provider-state.js.map +1 -0
- package/dist/providers.d.ts +132 -0
- package/dist/providers.d.ts.map +1 -0
- package/dist/providers.js +161 -0
- package/dist/providers.js.map +1 -0
- package/dist/publication-recorder.d.ts +117 -0
- package/dist/publication-recorder.d.ts.map +1 -0
- package/dist/publication-recorder.js +124 -0
- package/dist/publication-recorder.js.map +1 -0
- package/dist/pursuit-delivery.d.ts +24 -0
- package/dist/pursuit-delivery.d.ts.map +1 -0
- package/dist/pursuit-delivery.js +38 -0
- package/dist/pursuit-delivery.js.map +1 -0
- package/dist/queue-store.d.ts +132 -0
- package/dist/queue-store.d.ts.map +1 -0
- package/dist/queue-store.js +185 -0
- package/dist/queue-store.js.map +1 -0
- package/dist/queue-summary.d.ts +14 -0
- package/dist/queue-summary.d.ts.map +1 -0
- package/dist/queue-summary.js +82 -0
- package/dist/queue-summary.js.map +1 -0
- package/dist/queue-view.d.ts +20 -0
- package/dist/queue-view.d.ts.map +1 -0
- package/dist/queue-view.js +163 -0
- package/dist/queue-view.js.map +1 -0
- package/dist/queue-write-lease.d.ts +106 -0
- package/dist/queue-write-lease.d.ts.map +1 -0
- package/dist/queue-write-lease.js +282 -0
- package/dist/queue-write-lease.js.map +1 -0
- package/dist/rails-home.d.ts +162 -0
- package/dist/rails-home.d.ts.map +1 -0
- package/dist/rails-home.js +452 -0
- package/dist/rails-home.js.map +1 -0
- package/dist/rails-migrations.d.ts +75 -0
- package/dist/rails-migrations.d.ts.map +1 -0
- package/dist/rails-migrations.js +199 -0
- package/dist/rails-migrations.js.map +1 -0
- package/dist/rails-stores.d.ts +49 -0
- package/dist/rails-stores.d.ts.map +1 -0
- package/dist/rails-stores.js +100 -0
- package/dist/rails-stores.js.map +1 -0
- package/dist/reaper.d.ts +69 -0
- package/dist/reaper.d.ts.map +1 -0
- package/dist/reaper.js +152 -0
- package/dist/reaper.js.map +1 -0
- package/dist/record-store.d.ts +36 -0
- package/dist/record-store.d.ts.map +1 -0
- package/dist/record-store.js +78 -0
- package/dist/record-store.js.map +1 -0
- package/dist/recovery-io.d.ts +50 -0
- package/dist/recovery-io.d.ts.map +1 -0
- package/dist/recovery-io.js +122 -0
- package/dist/recovery-io.js.map +1 -0
- package/dist/recovery.d.ts +146 -0
- package/dist/recovery.d.ts.map +1 -0
- package/dist/recovery.js +110 -0
- package/dist/recovery.js.map +1 -0
- package/dist/redraw.d.ts +34 -0
- package/dist/redraw.d.ts.map +1 -0
- package/dist/redraw.js +62 -0
- package/dist/redraw.js.map +1 -0
- package/dist/rejected-evidence.d.ts +255 -0
- package/dist/rejected-evidence.d.ts.map +1 -0
- package/dist/rejected-evidence.js +324 -0
- package/dist/rejected-evidence.js.map +1 -0
- package/dist/report-cmd.d.ts +32 -0
- package/dist/report-cmd.d.ts.map +1 -0
- package/dist/report-cmd.js +201 -0
- package/dist/report-cmd.js.map +1 -0
- package/dist/repos-cmd.d.ts +80 -0
- package/dist/repos-cmd.d.ts.map +1 -0
- package/dist/repos-cmd.js +240 -0
- package/dist/repos-cmd.js.map +1 -0
- package/dist/request-body-shape.d.ts +93 -0
- package/dist/request-body-shape.d.ts.map +1 -0
- package/dist/request-body-shape.js +407 -0
- package/dist/request-body-shape.js.map +1 -0
- package/dist/requirement-claims.d.ts +89 -0
- package/dist/requirement-claims.d.ts.map +1 -0
- package/dist/requirement-claims.js +92 -0
- package/dist/requirement-claims.js.map +1 -0
- package/dist/retention-apply-runtime.d.ts +105 -0
- package/dist/retention-apply-runtime.d.ts.map +1 -0
- package/dist/retention-apply-runtime.js +269 -0
- package/dist/retention-apply-runtime.js.map +1 -0
- package/dist/reverify.d.ts +41 -0
- package/dist/reverify.d.ts.map +1 -0
- package/dist/reverify.js +69 -0
- package/dist/reverify.js.map +1 -0
- package/dist/round-records.d.ts +65 -0
- package/dist/round-records.d.ts.map +1 -0
- package/dist/round-records.js +61 -0
- package/dist/round-records.js.map +1 -0
- package/dist/round-runner.d.ts +924 -0
- package/dist/round-runner.d.ts.map +1 -0
- package/dist/round-runner.js +1693 -0
- package/dist/round-runner.js.map +1 -0
- package/dist/run-approval.d.ts +180 -0
- package/dist/run-approval.d.ts.map +1 -0
- package/dist/run-approval.js +199 -0
- package/dist/run-approval.js.map +1 -0
- package/dist/run-cmd.d.ts +359 -0
- package/dist/run-cmd.d.ts.map +1 -0
- package/dist/run-cmd.js +4418 -0
- package/dist/run-cmd.js.map +1 -0
- package/dist/run-delivery.d.ts +71 -0
- package/dist/run-delivery.d.ts.map +1 -0
- package/dist/run-delivery.js +104 -0
- package/dist/run-delivery.js.map +1 -0
- package/dist/run-ended.d.ts +75 -0
- package/dist/run-ended.d.ts.map +1 -0
- package/dist/run-ended.js +27 -0
- package/dist/run-ended.js.map +1 -0
- package/dist/run-ending-locator.d.ts +125 -0
- package/dist/run-ending-locator.d.ts.map +1 -0
- package/dist/run-ending-locator.js +133 -0
- package/dist/run-ending-locator.js.map +1 -0
- package/dist/run-launcher.d.ts +459 -0
- package/dist/run-launcher.d.ts.map +1 -0
- package/dist/run-launcher.js +652 -0
- package/dist/run-launcher.js.map +1 -0
- package/dist/run-lease.d.ts +208 -0
- package/dist/run-lease.d.ts.map +1 -0
- package/dist/run-lease.js +301 -0
- package/dist/run-lease.js.map +1 -0
- package/dist/run-refusal-marks.d.ts +189 -0
- package/dist/run-refusal-marks.d.ts.map +1 -0
- package/dist/run-refusal-marks.js +249 -0
- package/dist/run-refusal-marks.js.map +1 -0
- package/dist/run-start-marks.d.ts +153 -0
- package/dist/run-start-marks.d.ts.map +1 -0
- package/dist/run-start-marks.js +269 -0
- package/dist/run-start-marks.js.map +1 -0
- package/dist/scan-cmd.d.ts +14 -0
- package/dist/scan-cmd.d.ts.map +1 -0
- package/dist/scan-cmd.js +270 -0
- package/dist/scan-cmd.js.map +1 -0
- package/dist/scan-events.d.ts +50 -0
- package/dist/scan-events.d.ts.map +1 -0
- package/dist/scan-events.js +91 -0
- package/dist/scan-events.js.map +1 -0
- package/dist/scan.d.ts +191 -0
- package/dist/scan.d.ts.map +1 -0
- package/dist/scan.js +417 -0
- package/dist/scan.js.map +1 -0
- package/dist/scope-rule.d.ts +148 -0
- package/dist/scope-rule.d.ts.map +1 -0
- package/dist/scope-rule.js +121 -0
- package/dist/scope-rule.js.map +1 -0
- package/dist/self-repo-id.d.ts +18 -0
- package/dist/self-repo-id.d.ts.map +1 -0
- package/dist/self-repo-id.js +70 -0
- package/dist/self-repo-id.js.map +1 -0
- package/dist/server-client.d.ts +766 -0
- package/dist/server-client.d.ts.map +1 -0
- package/dist/server-client.js +475 -0
- package/dist/server-client.js.map +1 -0
- package/dist/session-adapter-runtime.d.ts +195 -0
- package/dist/session-adapter-runtime.d.ts.map +1 -0
- package/dist/session-adapter-runtime.js +373 -0
- package/dist/session-adapter-runtime.js.map +1 -0
- package/dist/session-checkpoint-runtime.d.ts +62 -0
- package/dist/session-checkpoint-runtime.d.ts.map +1 -0
- package/dist/session-checkpoint-runtime.js +268 -0
- package/dist/session-checkpoint-runtime.js.map +1 -0
- package/dist/session-control-transport.d.ts +106 -0
- package/dist/session-control-transport.d.ts.map +1 -0
- package/dist/session-control-transport.js +232 -0
- package/dist/session-control-transport.js.map +1 -0
- package/dist/session-observation-store.d.ts +98 -0
- package/dist/session-observation-store.d.ts.map +1 -0
- package/dist/session-observation-store.js +155 -0
- package/dist/session-observation-store.js.map +1 -0
- package/dist/session-observe-runtime.d.ts +123 -0
- package/dist/session-observe-runtime.d.ts.map +1 -0
- package/dist/session-observe-runtime.js +317 -0
- package/dist/session-observe-runtime.js.map +1 -0
- package/dist/session-store.d.ts +98 -0
- package/dist/session-store.d.ts.map +1 -0
- package/dist/session-store.js +116 -0
- package/dist/session-store.js.map +1 -0
- package/dist/sessions-cmd.d.ts +202 -0
- package/dist/sessions-cmd.d.ts.map +1 -0
- package/dist/sessions-cmd.js +609 -0
- package/dist/sessions-cmd.js.map +1 -0
- package/dist/settings-overlay.d.ts +53 -0
- package/dist/settings-overlay.d.ts.map +1 -0
- package/dist/settings-overlay.js +175 -0
- package/dist/settings-overlay.js.map +1 -0
- package/dist/setup-prompt.d.ts +34 -0
- package/dist/setup-prompt.d.ts.map +1 -0
- package/dist/setup-prompt.js +92 -0
- package/dist/setup-prompt.js.map +1 -0
- package/dist/setup.d.ts +103 -0
- package/dist/setup.d.ts.map +1 -0
- package/dist/setup.js +283 -0
- package/dist/setup.js.map +1 -0
- package/dist/shared-grants.d.ts +164 -0
- package/dist/shared-grants.d.ts.map +1 -0
- package/dist/shared-grants.js +242 -0
- package/dist/shared-grants.js.map +1 -0
- package/dist/signals.d.ts +55 -0
- package/dist/signals.d.ts.map +1 -0
- package/dist/signals.js +85 -0
- package/dist/signals.js.map +1 -0
- package/dist/source-cmd.d.ts +201 -0
- package/dist/source-cmd.d.ts.map +1 -0
- package/dist/source-cmd.js +684 -0
- package/dist/source-cmd.js.map +1 -0
- package/dist/source-verdict.d.ts +99 -0
- package/dist/source-verdict.d.ts.map +1 -0
- package/dist/source-verdict.js +182 -0
- package/dist/source-verdict.js.map +1 -0
- package/dist/spend-ledger.d.ts +350 -0
- package/dist/spend-ledger.d.ts.map +1 -0
- package/dist/spend-ledger.js +546 -0
- package/dist/spend-ledger.js.map +1 -0
- package/dist/stale-run-sweep.d.ts +79 -0
- package/dist/stale-run-sweep.d.ts.map +1 -0
- package/dist/stale-run-sweep.js +116 -0
- package/dist/stale-run-sweep.js.map +1 -0
- package/dist/start-attempts.d.ts +385 -0
- package/dist/start-attempts.d.ts.map +1 -0
- package/dist/start-attempts.js +613 -0
- package/dist/start-attempts.js.map +1 -0
- package/dist/status-cmd.d.ts +28 -0
- package/dist/status-cmd.d.ts.map +1 -0
- package/dist/status-cmd.js +116 -0
- package/dist/status-cmd.js.map +1 -0
- package/dist/stop-follow-up.d.ts +107 -0
- package/dist/stop-follow-up.d.ts.map +1 -0
- package/dist/stop-follow-up.js +172 -0
- package/dist/stop-follow-up.js.map +1 -0
- package/dist/surface-lease.d.ts +118 -0
- package/dist/surface-lease.d.ts.map +1 -0
- package/dist/surface-lease.js +485 -0
- package/dist/surface-lease.js.map +1 -0
- package/dist/sync.d.ts +54 -0
- package/dist/sync.d.ts.map +1 -0
- package/dist/sync.js +93 -0
- package/dist/sync.js.map +1 -0
- package/dist/syncing-ledger.d.ts +45 -0
- package/dist/syncing-ledger.d.ts.map +1 -0
- package/dist/syncing-ledger.js +53 -0
- package/dist/syncing-ledger.js.map +1 -0
- package/dist/temp-fixtures.d.ts +68 -0
- package/dist/temp-fixtures.d.ts.map +1 -0
- package/dist/temp-fixtures.js +38 -0
- package/dist/temp-fixtures.js.map +1 -0
- package/dist/token-window.d.ts +84 -0
- package/dist/token-window.d.ts.map +1 -0
- package/dist/token-window.js +143 -0
- package/dist/token-window.js.map +1 -0
- package/dist/tool-bridge.d.ts +177 -0
- package/dist/tool-bridge.d.ts.map +1 -0
- package/dist/tool-bridge.js +329 -0
- package/dist/tool-bridge.js.map +1 -0
- package/dist/tool-call-record.d.ts +197 -0
- package/dist/tool-call-record.d.ts.map +1 -0
- package/dist/tool-call-record.js +276 -0
- package/dist/tool-call-record.js.map +1 -0
- package/dist/tool-protocol.d.ts +135 -0
- package/dist/tool-protocol.d.ts.map +1 -0
- package/dist/tool-protocol.js +104 -0
- package/dist/tool-protocol.js.map +1 -0
- package/dist/tool-worker.d.ts +3 -0
- package/dist/tool-worker.d.ts.map +1 -0
- package/dist/tool-worker.js +478 -0
- package/dist/tool-worker.js.map +1 -0
- package/dist/toolchain-mirror.d.ts +86 -0
- package/dist/toolchain-mirror.d.ts.map +1 -0
- package/dist/toolchain-mirror.js +307 -0
- package/dist/toolchain-mirror.js.map +1 -0
- package/dist/trusted-git.d.ts +243 -0
- package/dist/trusted-git.d.ts.map +1 -0
- package/dist/trusted-git.js +920 -0
- package/dist/trusted-git.js.map +1 -0
- package/dist/ui.d.ts +76 -0
- package/dist/ui.d.ts.map +1 -0
- package/dist/ui.js +118 -0
- package/dist/ui.js.map +1 -0
- package/dist/untrusted-exec.d.ts +275 -0
- package/dist/untrusted-exec.d.ts.map +1 -0
- package/dist/untrusted-exec.js +479 -0
- package/dist/untrusted-exec.js.map +1 -0
- package/dist/untrusted-gate.d.ts +73 -0
- package/dist/untrusted-gate.d.ts.map +1 -0
- package/dist/untrusted-gate.js +226 -0
- package/dist/untrusted-gate.js.map +1 -0
- package/dist/uploader.d.ts +153 -0
- package/dist/uploader.d.ts.map +1 -0
- package/dist/uploader.js +204 -0
- package/dist/uploader.js.map +1 -0
- package/dist/verifier.d.ts +66 -0
- package/dist/verifier.d.ts.map +1 -0
- package/dist/verifier.js +170 -0
- package/dist/verifier.js.map +1 -0
- package/dist/verify-apply.d.ts +26 -0
- package/dist/verify-apply.d.ts.map +1 -0
- package/dist/verify-apply.js +36 -0
- package/dist/verify-apply.js.map +1 -0
- package/dist/verify-cmd.d.ts +44 -0
- package/dist/verify-cmd.d.ts.map +1 -0
- package/dist/verify-cmd.js +125 -0
- package/dist/verify-cmd.js.map +1 -0
- package/dist/verify-pass.d.ts +73 -0
- package/dist/verify-pass.d.ts.map +1 -0
- package/dist/verify-pass.js +103 -0
- package/dist/verify-pass.js.map +1 -0
- package/dist/welcome.d.ts +43 -0
- package/dist/welcome.d.ts.map +1 -0
- package/dist/welcome.js +116 -0
- package/dist/welcome.js.map +1 -0
- package/dist/workspace-provisioner.d.ts +65 -0
- package/dist/workspace-provisioner.d.ts.map +1 -0
- package/dist/workspace-provisioner.js +66 -0
- package/dist/workspace-provisioner.js.map +1 -0
- package/dist/workspace-quarantine.d.ts +64 -0
- package/dist/workspace-quarantine.d.ts.map +1 -0
- package/dist/workspace-quarantine.js +156 -0
- package/dist/workspace-quarantine.js.map +1 -0
- package/dist/workspace-residue-sweep.d.ts +113 -0
- package/dist/workspace-residue-sweep.d.ts.map +1 -0
- package/dist/workspace-residue-sweep.js +158 -0
- package/dist/workspace-residue-sweep.js.map +1 -0
- package/dist/workspace.d.ts +639 -0
- package/dist/workspace.d.ts.map +1 -0
- package/dist/workspace.js +2026 -0
- package/dist/workspace.js.map +1 -0
- package/package.json +54 -6
- package/win/CredProbe.cs +64 -0
- package/win/FakeClaude.cs +56 -0
- package/win/HandleProbe.cs +116 -0
- package/win/TokenProbe.cs +53 -0
- package/win/appcontainer.ps1 +500 -0
package/dist/run-cmd.js
ADDED
|
@@ -0,0 +1,4418 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.REPAIR_PROMPT_PATCH_MAX_CHARS = exports.REPAIR_PROMPT_OUTPUT_MAX_CHARS = exports.EXIT_ATTEMPT_CLAIMED = exports.EXIT_REFUSED = exports.RunRefused = void 0;
|
|
4
|
+
exports.writeEndingBeforeFirstRound = writeEndingBeforeFirstRound;
|
|
5
|
+
exports.describeRepair = describeRepair;
|
|
6
|
+
exports.buildImplementerPrompt = buildImplementerPrompt;
|
|
7
|
+
exports.runLoop = runLoop;
|
|
8
|
+
const node_crypto_1 = require("node:crypto");
|
|
9
|
+
const node_fs_1 = require("node:fs");
|
|
10
|
+
const node_os_1 = require("node:os");
|
|
11
|
+
const node_path_1 = require("node:path");
|
|
12
|
+
const workspace_1 = require("./workspace");
|
|
13
|
+
const trusted_git_1 = require("./trusted-git");
|
|
14
|
+
const core_1 = require("@looply/core");
|
|
15
|
+
const config_1 = require("./config");
|
|
16
|
+
const ledger_store_1 = require("./ledger-store");
|
|
17
|
+
const run_ended_1 = require("./run-ended");
|
|
18
|
+
const exemption_store_1 = require("./exemption-store");
|
|
19
|
+
const queue_store_1 = require("./queue-store");
|
|
20
|
+
const lane_claims_1 = require("./lane-claims");
|
|
21
|
+
const run_delivery_1 = require("./run-delivery");
|
|
22
|
+
const goal_met_target_1 = require("./goal-met-target");
|
|
23
|
+
const reaper_1 = require("./reaper");
|
|
24
|
+
const requirement_claims_1 = require("./requirement-claims");
|
|
25
|
+
const evidence_cache_1 = require("./evidence-cache");
|
|
26
|
+
const control_file_1 = require("./control-file");
|
|
27
|
+
const git_driver_1 = require("./git-driver");
|
|
28
|
+
const untrusted_gate_1 = require("./untrusted-gate");
|
|
29
|
+
const model_transport_1 = require("./model-transport");
|
|
30
|
+
const agent_envelope_1 = require("./agent-envelope");
|
|
31
|
+
const provider_limit_1 = require("./provider-limit");
|
|
32
|
+
const rejected_evidence_1 = require("./rejected-evidence");
|
|
33
|
+
const owned_children_1 = require("./owned-children");
|
|
34
|
+
const verify_cmd_1 = require("./verify-cmd");
|
|
35
|
+
const verify_pass_1 = require("./verify-pass");
|
|
36
|
+
const verify_cmd_2 = require("./verify-cmd");
|
|
37
|
+
const gate_1 = require("./gate");
|
|
38
|
+
const agent_driver_1 = require("./agent-driver");
|
|
39
|
+
const prover_1 = require("./prover");
|
|
40
|
+
const notify_channel_1 = require("./notify-channel");
|
|
41
|
+
const notify_cadence_1 = require("./notify-cadence");
|
|
42
|
+
const progress_view_1 = require("./progress-view");
|
|
43
|
+
const signals_1 = require("./signals");
|
|
44
|
+
const round_runner_1 = require("./round-runner");
|
|
45
|
+
const syncing_ledger_1 = require("./syncing-ledger");
|
|
46
|
+
const sync_1 = require("./sync");
|
|
47
|
+
const settings_overlay_1 = require("./settings-overlay");
|
|
48
|
+
const queue_summary_1 = require("./queue-summary");
|
|
49
|
+
const run_lease_1 = require("./run-lease");
|
|
50
|
+
const recovery_1 = require("./recovery");
|
|
51
|
+
const run_start_marks_1 = require("./run-start-marks");
|
|
52
|
+
const start_attempts_1 = require("./start-attempts");
|
|
53
|
+
const recovery_io_1 = require("./recovery-io");
|
|
54
|
+
const daemon_cmd_1 = require("./daemon-cmd");
|
|
55
|
+
const goal_store_1 = require("./goal-store");
|
|
56
|
+
const machine_store_1 = require("./machine-store");
|
|
57
|
+
const server_client_1 = require("./server-client");
|
|
58
|
+
const programme_mode_1 = require("./programme-mode");
|
|
59
|
+
const partial_asker_1 = require("./partial-asker");
|
|
60
|
+
const goal_ownership_1 = require("./goal-ownership");
|
|
61
|
+
const goal_compile_1 = require("./goal-compile");
|
|
62
|
+
const goal_execution_evidence_1 = require("./goal-execution-evidence");
|
|
63
|
+
const round_records_1 = require("./round-records");
|
|
64
|
+
const goal_gap_1 = require("./goal-gap");
|
|
65
|
+
const goal_adaptive_1 = require("./goal-adaptive");
|
|
66
|
+
const pending_action_1 = require("./pending-action");
|
|
67
|
+
const approval_resolution_1 = require("./approval-resolution");
|
|
68
|
+
const approval_gate_read_1 = require("./approval-gate-read");
|
|
69
|
+
const approval_resumes_1 = require("./approval-resumes");
|
|
70
|
+
const project_memory_store_1 = require("./project-memory-store");
|
|
71
|
+
const project_index_store_1 = require("./project-index-store");
|
|
72
|
+
const project_index_builder_1 = require("./project-index-builder");
|
|
73
|
+
const index_for_head_1 = require("./index-for-head");
|
|
74
|
+
const declared_surfaces_1 = require("./declared-surfaces");
|
|
75
|
+
const packet_for_goal_1 = require("./packet-for-goal");
|
|
76
|
+
const project_index_facts_1 = require("./project-index-facts");
|
|
77
|
+
const project_graph_runtime_1 = require("./project-graph-runtime");
|
|
78
|
+
const provider_identity_store_1 = require("./provider-identity-store");
|
|
79
|
+
const agent_contained_1 = require("./agent-contained");
|
|
80
|
+
const core_2 = require("@looply/core");
|
|
81
|
+
const core_3 = require("@looply/core");
|
|
82
|
+
const citation_readers_1 = require("./citation-readers");
|
|
83
|
+
const compile_check_1 = require("./compile-check");
|
|
84
|
+
const compile_store_1 = require("./compile-store");
|
|
85
|
+
const provider_identity_1 = require("./provider-identity");
|
|
86
|
+
const agent_exec_1 = require("./agent-exec");
|
|
87
|
+
const providers_1 = require("./providers");
|
|
88
|
+
const core_4 = require("@looply/core");
|
|
89
|
+
const queue_write_lease_1 = require("./queue-write-lease");
|
|
90
|
+
const surface_lease_1 = require("./surface-lease");
|
|
91
|
+
const spend_ledger_1 = require("./spend-ledger");
|
|
92
|
+
const integration_1 = require("./integration");
|
|
93
|
+
/**
|
|
94
|
+
* How long a surface lease is good for without being renewed.
|
|
95
|
+
*
|
|
96
|
+
* ⚠️ GENEROUS, BECAUSE EXPIRY HERE IS A BACKSTOP AND NOT A CONTROL. A run
|
|
97
|
+
* inside a forty-five-minute model call is legitimately silent; the thing
|
|
98
|
+
* that actually stops a stale holder is the FENCE, checked at the write. The
|
|
99
|
+
* TTL exists so a surface is not held for ever by a machine that was
|
|
100
|
+
* unplugged, and six hours is past any run this product will start.
|
|
101
|
+
*/
|
|
102
|
+
const SURFACE_TTL_MS = 6 * 60 * 60 * 1000;
|
|
103
|
+
const loop_safety_observer_1 = require("./loop-safety-observer");
|
|
104
|
+
const governed_approvals_1 = require("./governed-approvals");
|
|
105
|
+
const governed_broker_1 = require("./governed-broker");
|
|
106
|
+
const git_push_executor_1 = require("./git-push-executor");
|
|
107
|
+
const goal_depends_executor_1 = require("./goal-depends-executor");
|
|
108
|
+
const governed_run_1 = require("./governed-run");
|
|
109
|
+
const run_approval_1 = require("./run-approval");
|
|
110
|
+
const rails_stores_1 = require("./rails-stores");
|
|
111
|
+
/**
|
|
112
|
+
* A run that will not start, and that retrying cannot fix.
|
|
113
|
+
*
|
|
114
|
+
* Separate from every other failure because the daemon treats the two
|
|
115
|
+
* differently: an ordinary failure leaves the start-run queued to be offered
|
|
116
|
+
* again, and a command offered again every three seconds for an hour is noise
|
|
117
|
+
* rather than resilience. Carried out of the process as an exit code, since
|
|
118
|
+
* that is the only channel a detached child has.
|
|
119
|
+
*/
|
|
120
|
+
class RunRefused extends Error {
|
|
121
|
+
code;
|
|
122
|
+
branch;
|
|
123
|
+
delivery;
|
|
124
|
+
/**
|
|
125
|
+
* The same refusal, for a machine.
|
|
126
|
+
*
|
|
127
|
+
* Carried BESIDE the message, never derived from it. 6A3 gave a refusal a
|
|
128
|
+
* sentence a person reads; a second machine deciding whether to spend
|
|
129
|
+
* anything more needs to tell "the goal is met" from "a person has to decide
|
|
130
|
+
* something", and those two sentences deliberately share their opening words.
|
|
131
|
+
*
|
|
132
|
+
* Every site that throws names its own code, because the code that took the
|
|
133
|
+
* branch is the only thing that knows which branch it took.
|
|
134
|
+
*/
|
|
135
|
+
constructor(message, code = 'run_refused',
|
|
136
|
+
/**
|
|
137
|
+
* The run's branch, when the refusal came from a run that had one (M5-2).
|
|
138
|
+
* Carried so the mark can name whose branch the lane must publish before a
|
|
139
|
+
* met goal's grant closes; absent on every refusal made before a branch
|
|
140
|
+
* exists, and on every site that has nothing to say about one.
|
|
141
|
+
*/
|
|
142
|
+
branch,
|
|
143
|
+
/**
|
|
144
|
+
* What key 2 confirmed, on a `goal_satisfied` refusal only (L4b): the
|
|
145
|
+
* delivering run, the commit, and each required step's outcome. Carried to
|
|
146
|
+
* the mark so the daemon can settle the pursuit with facts, not a sentence.
|
|
147
|
+
*/
|
|
148
|
+
delivery) {
|
|
149
|
+
super(message);
|
|
150
|
+
this.code = code;
|
|
151
|
+
this.branch = branch;
|
|
152
|
+
this.delivery = delivery;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
exports.RunRefused = RunRefused;
|
|
156
|
+
/** What `looply run` exits with when it refuses for good. */
|
|
157
|
+
exports.EXIT_REFUSED = 3;
|
|
158
|
+
/**
|
|
159
|
+
* THE ENDING OF A RUN THAT REGISTERED AND NEVER STARTED, WITH ITS REASON (M6-16).
|
|
160
|
+
*
|
|
161
|
+
* `releaseHold` below writes a bare `run.ended` for a run that took its slot
|
|
162
|
+
* and never reached its first round — a compile that produced nothing, a plan
|
|
163
|
+
* that came back empty, a refusal to start. M6-12 made the runner say why it
|
|
164
|
+
* halts; these endings never reach the runner, and until M6-16 their reason
|
|
165
|
+
* lived only in this process's log, which nobody reads: found live in M6-15,
|
|
166
|
+
* two machine Starts on a goal with nothing pickable each spent a minute
|
|
167
|
+
* compiling, ended `aborted` at zero rounds, and the server's feed showed the
|
|
168
|
+
* ending alone. The reason now travels the way the runner's does — one
|
|
169
|
+
* `alert { code: 'run-halted' }` immediately before the ending, on the same
|
|
170
|
+
* sink, so the phone reads "why" beside "ended" whichever half of the run
|
|
171
|
+
* wrote it. No reason recorded → no alert, never an invented sentence. Both
|
|
172
|
+
* writes are best-effort, each named in the log when it fails, and neither
|
|
173
|
+
* stops the hold being given back.
|
|
174
|
+
*/
|
|
175
|
+
async function writeEndingBeforeFirstRound(sink, facts, log) {
|
|
176
|
+
const now = facts.now ?? (() => Date.now());
|
|
177
|
+
if (facts.because !== null) {
|
|
178
|
+
try {
|
|
179
|
+
await sink.append({
|
|
180
|
+
ts: new Date(now()).toISOString(),
|
|
181
|
+
runId: facts.runId,
|
|
182
|
+
projectId: facts.projectId,
|
|
183
|
+
lane: 'feature',
|
|
184
|
+
kind: 'alert',
|
|
185
|
+
actor: 'runner',
|
|
186
|
+
data: { code: round_runner_1.RUN_HALTED_CODE, severity: 'warn', text: facts.because },
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
catch (e) {
|
|
190
|
+
log(`run : could not record why this run ended before starting (${e.message})`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
await sink.append({
|
|
195
|
+
ts: new Date(now()).toISOString(),
|
|
196
|
+
runId: facts.runId,
|
|
197
|
+
projectId: facts.projectId,
|
|
198
|
+
lane: 'feature',
|
|
199
|
+
kind: 'run.ended',
|
|
200
|
+
actor: 'runner',
|
|
201
|
+
data: {
|
|
202
|
+
runId: facts.runId,
|
|
203
|
+
elapsedMs: now() - facts.startedAtMs,
|
|
204
|
+
stopReason: 'aborted',
|
|
205
|
+
totals: { rounds: 0, committed: 0, discarded: 0, abandoned: 0 },
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
catch (e) {
|
|
210
|
+
log(`run : could not record that this run ended before starting (${e.message})`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* The exit code for "somebody else is already executing this exact attempt".
|
|
215
|
+
*
|
|
216
|
+
* Distinct from {@link EXIT_REFUSED} on purpose, and the distinction is
|
|
217
|
+
* load-bearing. Exit 3 means the RUN decided something about the goal, and a
|
|
218
|
+
* controller may end a person's grant on it. This means nothing about the goal
|
|
219
|
+
* at all — another process got to the same attempt first — and a controller
|
|
220
|
+
* that read the two as one answer would report a goal finished because two
|
|
221
|
+
* daemons happened to tick at the same moment.
|
|
222
|
+
*/
|
|
223
|
+
exports.EXIT_ATTEMPT_CLAIMED = 4;
|
|
224
|
+
const AGENT_TIMEOUT_MS = 45 * 60 * 1000;
|
|
225
|
+
const PROVER_TIMEOUT_MS = 10 * 60 * 1000;
|
|
226
|
+
function evidenceLines(evidence) {
|
|
227
|
+
if (!evidence.length)
|
|
228
|
+
return '(no evidence recorded — find it yourself, and record what you find)';
|
|
229
|
+
return evidence
|
|
230
|
+
.map((e) => `- ${e.path}${e.line ? `:${e.line}` : ''}${e.quote ? ` — ${e.quote}` : ''}`)
|
|
231
|
+
.join('\n');
|
|
232
|
+
}
|
|
233
|
+
/** How much of a failing step's output the repair prompt carries inline. */
|
|
234
|
+
exports.REPAIR_PROMPT_OUTPUT_MAX_CHARS = 16 * 1024;
|
|
235
|
+
/** How much of the previous patch the repair prompt carries inline, when it was not restored. */
|
|
236
|
+
exports.REPAIR_PROMPT_PATCH_MAX_CHARS = 48 * 1024;
|
|
237
|
+
/**
|
|
238
|
+
* The previous attempt, as prose the worker can act on.
|
|
239
|
+
*
|
|
240
|
+
* Three things a repair needs and the one-line `lastFailure` never carried:
|
|
241
|
+
* WHAT refused the candidate and what it printed, WHAT the candidate changed,
|
|
242
|
+
* and WHETHER that change is in the tree now. The last is stated either way —
|
|
243
|
+
* "restored, these files" or "not restored, because …, you start from the
|
|
244
|
+
* base" — so the worker is never handed an unexplained clean tree.
|
|
245
|
+
*
|
|
246
|
+
* Bounded again here, tighter than the evidence on disk: the full logs live in
|
|
247
|
+
* the evidence directory, and a prompt is not the place for 256K of jest.
|
|
248
|
+
*/
|
|
249
|
+
function describeRepair(repair) {
|
|
250
|
+
const p = repair.previous;
|
|
251
|
+
const lines = [];
|
|
252
|
+
lines.push(`This is attempt ${repair.attempt} of ${repair.maxAttempts} on this task. ` +
|
|
253
|
+
`Attempt ${p.attempt} (round ${p.round}) was rejected: ${p.why}`);
|
|
254
|
+
if (p.recoveredFrom !== undefined) {
|
|
255
|
+
lines.push(`That attempt was made by an earlier run (${p.recoveredFrom.runId}), which has since ended. ` +
|
|
256
|
+
'What follows was recovered from its retained evidence and checked against this task and this ' +
|
|
257
|
+
'tree before it was used.');
|
|
258
|
+
}
|
|
259
|
+
if (p.failedStep !== null)
|
|
260
|
+
lines.push(`The check that refused it was \`${p.failedStep}\`.`);
|
|
261
|
+
if (repair.restored.ok) {
|
|
262
|
+
lines.push('', 'The previous candidate has been RESTORED into the working tree, so the files it changed are ' +
|
|
263
|
+
'exactly as that attempt left them. Read them; fix what the check refused; keep what was right. ' +
|
|
264
|
+
`Restored files: ${repair.restored.paths.join(', ') || '(none)'}`);
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
lines.push('', `The previous candidate is NOT in the working tree — ${repair.restored.because}. ` +
|
|
268
|
+
'The tree is at the base again. What follows is what that attempt changed, so you can ' +
|
|
269
|
+
'rebuild it rather than start from nothing.');
|
|
270
|
+
}
|
|
271
|
+
const ran = p.diagnostics.filter((d) => d.classification !== 'passed');
|
|
272
|
+
for (const d of ran) {
|
|
273
|
+
const exit = d.classification === 'timed_out'
|
|
274
|
+
? 'timed out — a resource outcome, not necessarily the code'
|
|
275
|
+
: d.classification === 'unavailable'
|
|
276
|
+
? 'could not run — the environment, not the code'
|
|
277
|
+
: `exited ${d.exitCode ?? '(unknown)'}`;
|
|
278
|
+
const out = (0, gate_1.boundText)(d.output, exports.REPAIR_PROMPT_OUTPUT_MAX_CHARS);
|
|
279
|
+
lines.push('', `### Output of \`${d.step}\` (\`${d.command}\`, ${exit})`, '', '```text', out.text.trimEnd(), '```');
|
|
280
|
+
}
|
|
281
|
+
const passed = p.diagnostics.filter((d) => d.classification === 'passed').map((d) => d.step);
|
|
282
|
+
if (passed.length > 0)
|
|
283
|
+
lines.push('', `Steps that passed on that candidate: ${passed.join(', ')}.`);
|
|
284
|
+
if (p.manifest.length > 0) {
|
|
285
|
+
lines.push('', 'Files that attempt changed:', ...p.manifest.map((m) => `- ${m.path} (${m.status.trim() || '??'})`));
|
|
286
|
+
}
|
|
287
|
+
if (!repair.restored.ok) {
|
|
288
|
+
if (p.patch !== null) {
|
|
289
|
+
const patch = (0, gate_1.boundText)(p.patch.text, exports.REPAIR_PROMPT_PATCH_MAX_CHARS);
|
|
290
|
+
const note = p.patch.truncated || patch.truncated ? ' (truncated — the full patch is in the evidence directory)' : '';
|
|
291
|
+
lines.push('', `The patch that attempt produced${note}:`, '', '```diff', patch.text.trimEnd(), '```');
|
|
292
|
+
}
|
|
293
|
+
else if (p.patchWithheld !== null) {
|
|
294
|
+
lines.push('', `Its patch is not shown: ${p.patchWithheld}.`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (p.evidencePath !== null) {
|
|
298
|
+
lines.push('', `Full evidence for that attempt is retained at ${p.evidencePath} (not readable from inside this task).`);
|
|
299
|
+
}
|
|
300
|
+
lines.push('', 'The acceptance criteria and the scope are unchanged from the previous attempt.');
|
|
301
|
+
return `\n## What happened to the previous attempt\n\n${lines.join('\n')}\n`;
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* The implementer prompt.
|
|
305
|
+
*
|
|
306
|
+
* Cached evidence goes in verbatim so the agent starts at the right file and
|
|
307
|
+
* line instead of re-searching for a defect somebody already located — that
|
|
308
|
+
* saving is most of what pays for the verification pass.
|
|
309
|
+
*
|
|
310
|
+
* A previous attempt's rejection reason goes in too: an item picked a second
|
|
311
|
+
* time with no memory of why the first attempt was thrown away will usually
|
|
312
|
+
* reproduce it.
|
|
313
|
+
*/
|
|
314
|
+
/**
|
|
315
|
+
* What this project has established, ready to embed in a prompt.
|
|
316
|
+
*
|
|
317
|
+
* 🔴 ONE PLACE DECIDES CURRENCY AND BUDGET. Two call sites need memory — the
|
|
318
|
+
* compiler and the implementer — and if each assembled its own, they would
|
|
319
|
+
* drift: one would read the tree and the other would not, and a fact stale in
|
|
320
|
+
* one prompt would be current in the next. The seam that produces the text is
|
|
321
|
+
* the seam that decides what is true enough to show.
|
|
322
|
+
*
|
|
323
|
+
* Reads the COMMITTED tree, because a fact was observed against a commit and
|
|
324
|
+
* `headTreeSha` is what a later run can compare it to. An unreadable tree
|
|
325
|
+
* yields null, which `currencyOf` treats as "cannot confirm" — so content facts
|
|
326
|
+
* are withheld rather than assumed, and decisions still come through.
|
|
327
|
+
*/
|
|
328
|
+
/**
|
|
329
|
+
* Record what a PERSON decided, from the one file only a person can write.
|
|
330
|
+
*
|
|
331
|
+
* 🔴 THE AUTHORITY COMES FROM THE CHANNEL, NOT FROM A FLAG. `.looply/config.yaml`
|
|
332
|
+
* is in `protectedPaths()`, so the container the model runs in cannot reach it.
|
|
333
|
+
* That is why these may be written as `source: 'human'` — the runtime knows the
|
|
334
|
+
* text came from a file the model has no path to, which is a stronger statement
|
|
335
|
+
* than any assertion carried on the record itself.
|
|
336
|
+
*
|
|
337
|
+
* Re-read every run, because the config is the truth and a decision removed
|
|
338
|
+
* from it should stop being asserted. Re-recording is free: the same claim
|
|
339
|
+
* collapses to one record by semantic identity, so a project that never edits
|
|
340
|
+
* its config appends nothing after the first run.
|
|
341
|
+
*/
|
|
342
|
+
async function rememberHumanDecisions(store, config, projectKey) {
|
|
343
|
+
if (store === null)
|
|
344
|
+
return;
|
|
345
|
+
for (const d of config.decisions) {
|
|
346
|
+
await store.remember({
|
|
347
|
+
kind: 'decision',
|
|
348
|
+
source: 'human',
|
|
349
|
+
subject: d.subject,
|
|
350
|
+
content: d.decision,
|
|
351
|
+
// The file IS the actor: it is tracked, diffable, and unreachable from
|
|
352
|
+
// the container. Naming a user we cannot verify would be worse.
|
|
353
|
+
provenance: { via: 'human-decision', refs: [], actor: 'config' },
|
|
354
|
+
scope: { projectKey },
|
|
355
|
+
}, false);
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* 🔴 AND WITHDRAW WHAT THE PERSON TOOK OUT. The comment above promised this
|
|
359
|
+
* and nothing implemented it: an adversarial review found that amending a
|
|
360
|
+
* decision left BOTH versions current for ever, so the model was handed two
|
|
361
|
+
* contradictory constraints with no way to tell which was live. Deleting one
|
|
362
|
+
* from the config did nothing at all.
|
|
363
|
+
*
|
|
364
|
+
* The config is the truth for decisions, so anything current that is no
|
|
365
|
+
* longer in it is withdrawn — by APPENDING a withdrawal, never by deleting.
|
|
366
|
+
* What a project used to hold is part of how it came to hold what it does.
|
|
367
|
+
*/
|
|
368
|
+
const live = new Set(config.decisions.map((d) => `${d.subject}\u0000${d.decision}`));
|
|
369
|
+
for (const r of store.current(null, { kind: 'decision' })) {
|
|
370
|
+
if (live.has(`${r.subject}\u0000${r.content}`))
|
|
371
|
+
continue;
|
|
372
|
+
await store.remember({
|
|
373
|
+
kind: 'decision',
|
|
374
|
+
source: 'human',
|
|
375
|
+
subject: r.subject,
|
|
376
|
+
content: 'withdrawn from the project configuration',
|
|
377
|
+
provenance: { via: 'human-decision', refs: [], actor: 'config' },
|
|
378
|
+
scope: { projectKey },
|
|
379
|
+
supersedes: r.id,
|
|
380
|
+
withdrawn: true,
|
|
381
|
+
}, false);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Record that an approach was tried and failed.
|
|
386
|
+
*
|
|
387
|
+
* P11.21, and it is the reason memory matters most after Phase 9: a model with
|
|
388
|
+
* no recollection proposes the same failed approach again, and the loop-safety
|
|
389
|
+
* brake stops the machine without anybody learning why. This is what turns a
|
|
390
|
+
* repeated failure into something the NEXT plan can read.
|
|
391
|
+
*
|
|
392
|
+
* Keyed on the run, so two failures of one approach stay two records — that a
|
|
393
|
+
* thing failed twice is the whole signal, and collapsing them would erase it.
|
|
394
|
+
*/
|
|
395
|
+
async function rememberFailedAttempt(store, projectKey, facts) {
|
|
396
|
+
if (store === null)
|
|
397
|
+
return;
|
|
398
|
+
await store.remember({
|
|
399
|
+
kind: 'attempt',
|
|
400
|
+
source: 'deterministic',
|
|
401
|
+
subject: facts.subject,
|
|
402
|
+
// The STEP, never the output. A digest identifies which failure it was
|
|
403
|
+
// without carrying a line of it — see `failureOutputDigest`.
|
|
404
|
+
content: `the ${facts.failureStep} gate refused this approach`,
|
|
405
|
+
provenance: { via: 'gate-result', refs: [facts.runId] },
|
|
406
|
+
scope: { projectKey, ...(facts.goalId === undefined ? {} : { goalId: facts.goalId }) },
|
|
407
|
+
distinct: facts.runId,
|
|
408
|
+
}, false);
|
|
409
|
+
}
|
|
410
|
+
async function memoryFor(store, git, scope,
|
|
411
|
+
/**
|
|
412
|
+
* The current project graph, when one could be established.
|
|
413
|
+
*
|
|
414
|
+
* 🔴 IT REORDERS, IT NEVER FILTERS — see `rankWithin`. Null means no graph was
|
|
415
|
+
* current, and the ranking is then exactly what it was before Phase 13, which
|
|
416
|
+
* is why this is a widening rather than a change of behaviour.
|
|
417
|
+
*/
|
|
418
|
+
graph) {
|
|
419
|
+
if (store === null)
|
|
420
|
+
return null;
|
|
421
|
+
const tree = await git.headTreeSha().catch(() => null);
|
|
422
|
+
const records = [
|
|
423
|
+
...store.current(tree, { kind: 'decision' }),
|
|
424
|
+
...store.current(tree, { kind: 'fact' }),
|
|
425
|
+
...store.current(tree, { kind: 'attempt', ...(scope.goalId === undefined ? {} : { goalId: scope.goalId }) }),
|
|
426
|
+
...store.current(tree, { kind: 'inference' }),
|
|
427
|
+
];
|
|
428
|
+
return (0, core_1.renderMemoryContext)((0, core_1.assembleMemoryContext)(records, undefined, relevantIds(graph, scope.goalId)));
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Which remembered records the graph says are about the work in hand.
|
|
432
|
+
*
|
|
433
|
+
* Empty whenever there is no graph or no bound goal — an empty set makes the
|
|
434
|
+
* relevance clause a no-op rather than a special case, so there is one code path
|
|
435
|
+
* here and not two.
|
|
436
|
+
*/
|
|
437
|
+
function relevantIds(graph, goalId) {
|
|
438
|
+
if (graph === null || graph === undefined || goalId === undefined)
|
|
439
|
+
return new Set();
|
|
440
|
+
const context = (0, core_1.contextForGoal)(graph, goalId);
|
|
441
|
+
if (!context.found)
|
|
442
|
+
return new Set();
|
|
443
|
+
/**
|
|
444
|
+
* All four kinds, because relevance is not the same question as standing. A
|
|
445
|
+
* hypothesis about the file being changed is more worth the model's attention
|
|
446
|
+
* than one about an unrelated module — it is still labelled a hypothesis, by
|
|
447
|
+
* the section it lands in, which this cannot touch.
|
|
448
|
+
*/
|
|
449
|
+
return new Set([
|
|
450
|
+
...context.decisions,
|
|
451
|
+
...context.facts,
|
|
452
|
+
...context.attempts,
|
|
453
|
+
...context.inferences,
|
|
454
|
+
]);
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* WHAT THE COMMITTED REPOSITORY SAYS ABOUT THIS GOAL'S WORK.
|
|
458
|
+
*
|
|
459
|
+
* 🔴 THIS IS THE PHASE'S PRODUCTION CONSUMER, AND IT IS ONE LINE FROM A PROMPT.
|
|
460
|
+
*
|
|
461
|
+
* Everything else in Phase 13 — the authority classes, the freshness guard, the
|
|
462
|
+
* bounded queries — exists so that this function can put structural claims in
|
|
463
|
+
* front of a model without any of them being mistakable for a guess. If this
|
|
464
|
+
* were not wired, the phase would be the thing it was written to avoid: correct,
|
|
465
|
+
* tested, and consumed by nothing.
|
|
466
|
+
*
|
|
467
|
+
* Null when no graph is current. A refusal renders nothing rather than an empty
|
|
468
|
+
* heading, for the reason `renderMemoryContext` returns null.
|
|
469
|
+
*/
|
|
470
|
+
function structureFor(graph, goalId) {
|
|
471
|
+
if (graph === null || goalId === undefined)
|
|
472
|
+
return null;
|
|
473
|
+
return (0, core_1.renderGraphContext)((0, core_1.assembleGraphContext)((0, core_1.contextForGoal)(graph, goalId)));
|
|
474
|
+
}
|
|
475
|
+
function buildImplementerPrompt(item, attempt, cfg,
|
|
476
|
+
/**
|
|
477
|
+
* What a person refused, when this run is a resume after one.
|
|
478
|
+
*
|
|
479
|
+
* Rendered as labelled prose rather than as data, and that is a safety
|
|
480
|
+
* decision rather than a stylistic one: the transcript is scanned for a
|
|
481
|
+
* structured request for authority, and the scan takes the FIRST one it
|
|
482
|
+
* finds. A prompt that illustrated the shape of such a request would be a
|
|
483
|
+
* decoy that an echoing model files for real.
|
|
484
|
+
*/
|
|
485
|
+
resolution,
|
|
486
|
+
/**
|
|
487
|
+
* What a person approved that has already been DONE, when this run did it.
|
|
488
|
+
*
|
|
489
|
+
* Prose for the same reason, and the reason bites harder here: this block
|
|
490
|
+
* describes a consequential act that SUCCEEDED, and a model that read it as
|
|
491
|
+
* a template and echoed the shape back would be asking for that act again.
|
|
492
|
+
*/
|
|
493
|
+
governed,
|
|
494
|
+
/**
|
|
495
|
+
* What this project has already established, as labelled prose.
|
|
496
|
+
*
|
|
497
|
+
* Prose for the same reason the two blocks above are, and the reasoning is
|
|
498
|
+
* Phase 7's: a declaration of authority is a SHAPE on a structured channel,
|
|
499
|
+
* never a phrase, so nothing written here can become a request. What it must
|
|
500
|
+
* not do is show a TEMPLATE of that shape, which an echoing model could fill
|
|
501
|
+
* in for real — so memory is rendered as bulleted claims, never as data.
|
|
502
|
+
*
|
|
503
|
+
* Already sectioned and budgeted by `assembleMemoryContext`; this builder
|
|
504
|
+
* neither ranks nor trims, because one that re-decided what fits would be a
|
|
505
|
+
* second place the budget lives.
|
|
506
|
+
*/
|
|
507
|
+
memory,
|
|
508
|
+
/**
|
|
509
|
+
* What the committed repository establishes about this work, from the graph.
|
|
510
|
+
*
|
|
511
|
+
* 🔴 A SEPARATE PARAMETER FROM `memory`, AND SEPARATE ALL THE WAY TO THE
|
|
512
|
+
* PROMPT. Concatenating them here would put "the repository says these files
|
|
513
|
+
* import you" under the same heading as "a person decided you must not
|
|
514
|
+
* force-push" — two different authorities, and the whole of Phase 13 is that
|
|
515
|
+
* a reader can always tell which one is speaking.
|
|
516
|
+
*/
|
|
517
|
+
structure,
|
|
518
|
+
/**
|
|
519
|
+
* What the previous attempt did and what refused it, when this run saw it.
|
|
520
|
+
*
|
|
521
|
+
* 🔴 THE RETRY USED TO GET ONE LINE AND A CLEAN TREE. "gate red: step
|
|
522
|
+
* typecheck failed" was the whole briefing, and the work from the round
|
|
523
|
+
* before had been reset away without a word about it. A worker cannot
|
|
524
|
+
* repair what it has not been shown; it starts over. This renders the
|
|
525
|
+
* retained evidence — the failing step's output, the manifest, the patch or
|
|
526
|
+
* the fact that it was restored into the tree — as labelled prose.
|
|
527
|
+
*
|
|
528
|
+
* Prose, and never a template, for the same reason every block above is:
|
|
529
|
+
* the step output and the patch are fenced as text the model is to READ,
|
|
530
|
+
* and nothing here shows the shape of a structured request for authority.
|
|
531
|
+
*/
|
|
532
|
+
repair,
|
|
533
|
+
/**
|
|
534
|
+
* WHAT THE PERSON ACTUALLY ASKED FOR, WHICH NEVER GOT THIS FAR.
|
|
535
|
+
*
|
|
536
|
+
* 🔴 THE IMPLEMENTER WAS HANDED AN ITEM TITLE. A person types a goal of up to
|
|
537
|
+
* two thousand characters; the brain compiles it into items; and this builder
|
|
538
|
+
* emitted `item.title` and nothing else — so everything they wrote about what
|
|
539
|
+
* they want, why, and what would count as done stopped at the compile step.
|
|
540
|
+
* The capability audit's E-01, and the distance between it and what the
|
|
541
|
+
* product promises is the whole reason M8 exists: eleven cycles of M6 made
|
|
542
|
+
* the AUTHORITY to pursue a goal safe, while the thing being pursued was not
|
|
543
|
+
* being carried to the model doing the work.
|
|
544
|
+
*
|
|
545
|
+
* ⚠️ IT WAS ALWAYS ONE PROPERTY AWAY. The call site reads
|
|
546
|
+
* `binding.goal.id` off a `Goal` whose `text` is, in core's words, "the
|
|
547
|
+
* user's own wording, preserved verbatim". Nothing had to be carried here;
|
|
548
|
+
* the id was taken and the text was left behind.
|
|
549
|
+
*
|
|
550
|
+
* ⚠️ PROSE, AND NEVER A TEMPLATE, for the reason every block above says it:
|
|
551
|
+
* a declaration of authority is a SHAPE on a structured channel, never a
|
|
552
|
+
* phrase, and a prompt that showed that shape would be a decoy an echoing
|
|
553
|
+
* model could fill in for real. A goal is exactly where a person might write
|
|
554
|
+
* a sentence that READS like an instruction — "and then push it" — so the
|
|
555
|
+
* block introduces the text as something THEY wrote, and carries no
|
|
556
|
+
* structured shape at all.
|
|
557
|
+
*
|
|
558
|
+
* ⚠️ NOT TRIMMED HERE. The server caps a goal at `MAX_GOAL_TEXT` where it is
|
|
559
|
+
* created, and a goal reaches this machine only through the server. A second
|
|
560
|
+
* trim here would be a second place the budget lives — the same reason
|
|
561
|
+
* `memory` is neither ranked nor trimmed by this function.
|
|
562
|
+
*/
|
|
563
|
+
goal) {
|
|
564
|
+
const retry = repair === undefined && attempt > 0 && item.lastFailure
|
|
565
|
+
? `\n## Why the last attempt was thrown away\n\n${item.lastFailure}\n\nDo not repeat it.\n`
|
|
566
|
+
: '';
|
|
567
|
+
const repairing = repair === undefined ? '' : describeRepair(repair);
|
|
568
|
+
const mustPass = cfg.gates.length === 0
|
|
569
|
+
? ''
|
|
570
|
+
: `\n## What must pass before the change is kept\n\n${cfg.gates
|
|
571
|
+
.map((g) => `- ${g.name}: \`${g.run}\`${g.required ? ' (required)' : ''}`)
|
|
572
|
+
.join('\n')}\n\nThe runner executes these after you stop. A change that does not pass them is discarded.\n`;
|
|
573
|
+
const answered = resolution === undefined
|
|
574
|
+
? ''
|
|
575
|
+
: `\n## What a person already decided\n\n${(0, approval_resolution_1.describeResolution)(resolution)}\n`;
|
|
576
|
+
const carriedOut = governed === undefined
|
|
577
|
+
? ''
|
|
578
|
+
: `\n## What has already been done for you\n\n${(0, governed_run_1.describeGovernedResult)(governed)}\n`;
|
|
579
|
+
// Null when nothing has been remembered yet — an empty heading would tell the
|
|
580
|
+
// model that Looply looked and found nothing, which is false on a new project.
|
|
581
|
+
const known = memory === undefined || memory === null
|
|
582
|
+
? ''
|
|
583
|
+
: `\n## What this project has already established\n\n${memory}\n`;
|
|
584
|
+
/**
|
|
585
|
+
* Null when no graph was current — which is the honest rendering of "Looply
|
|
586
|
+
* could not establish the structure this time", and is not the same claim as
|
|
587
|
+
* "this file is connected to nothing".
|
|
588
|
+
*/
|
|
589
|
+
const shape = structure === undefined || structure === null
|
|
590
|
+
? ''
|
|
591
|
+
: `\n## How this code is connected\n\n${structure}\n`;
|
|
592
|
+
/**
|
|
593
|
+
* Empty when this round is not working on a goal — a scan-seeded item, a
|
|
594
|
+
* manual run, a goal that lapsed — for the reason `memory` is empty when
|
|
595
|
+
* nothing is remembered: a heading with nothing under it would tell the model
|
|
596
|
+
* that Looply looked and found nothing, which is a different claim.
|
|
597
|
+
*
|
|
598
|
+
* The sentence around it names whose words these are. A model that cannot
|
|
599
|
+
* tell the person's wording from the runner's own instructions is one
|
|
600
|
+
* sentence away from treating the former as the latter.
|
|
601
|
+
*/
|
|
602
|
+
const why = goal === undefined || goal === null || goal.trim() === ''
|
|
603
|
+
? ''
|
|
604
|
+
: `\n## What this is for\n\nThe person who set this work going wrote this, and it is their wording, not Looply's:\n\n${goal}\n\nThe task above is one step towards it. Do that step; do not try to do the rest of it here.\n`;
|
|
605
|
+
return `You are one round of looply on the ${cfg.project.name} repository.
|
|
606
|
+
|
|
607
|
+
Do exactly this one task, then stop.
|
|
608
|
+
|
|
609
|
+
## The task
|
|
610
|
+
|
|
611
|
+
${item.title}
|
|
612
|
+
|
|
613
|
+
## Where it is
|
|
614
|
+
|
|
615
|
+
${evidenceLines(item.evidence)}
|
|
616
|
+
${why}${known}${shape}${retry}${repairing}${answered}${carriedOut}${mustPass}
|
|
617
|
+
## Scope
|
|
618
|
+
|
|
619
|
+
You may edit: ${cfg.scope.free.join(', ') || '(nothing declared)'}
|
|
620
|
+
Additive only (new files): ${cfg.scope.additive.join(', ') || '(none)'}
|
|
621
|
+
Never touch: ${cfg.rails.forbiddenPaths.join(', ') || '(no rails declared)'}
|
|
622
|
+
|
|
623
|
+
If the task cannot be done without touching a railed path, do not touch it —
|
|
624
|
+
report it as blocked with the path and the reason, and change nothing.
|
|
625
|
+
|
|
626
|
+
## Rules
|
|
627
|
+
|
|
628
|
+
- One concern. Keep the diff tight.
|
|
629
|
+
- Add or extend a unit test covering the new behaviour. Assert the requirement,
|
|
630
|
+
not the implementation.
|
|
631
|
+
- Never run git. The runner handles branching, committing and discarding.
|
|
632
|
+
- Do not add a dependency unless this task explicitly calls for one.
|
|
633
|
+
|
|
634
|
+
## When you are done
|
|
635
|
+
|
|
636
|
+
Print exactly one JSON object, on its own line, and nothing after it:
|
|
637
|
+
|
|
638
|
+
{"itemId":"${item.id}","outcome":"implemented","filesTouched":["..."],"testsAdded":0,"note":"one sentence","blocked":null}
|
|
639
|
+
|
|
640
|
+
If you were blocked by a rail, use:
|
|
641
|
+
|
|
642
|
+
{"itemId":"${item.id}","outcome":"blocked","filesTouched":[],"testsAdded":0,"note":"","blocked":{"path":"...","reason":"..."}}
|
|
643
|
+
`;
|
|
644
|
+
}
|
|
645
|
+
function openGoalServer(cwd, goals, log) {
|
|
646
|
+
let identity;
|
|
647
|
+
const store = new machine_store_1.MachineStore((0, machine_store_1.machineFilePath)());
|
|
648
|
+
try {
|
|
649
|
+
identity = store.load();
|
|
650
|
+
}
|
|
651
|
+
catch {
|
|
652
|
+
return null;
|
|
653
|
+
}
|
|
654
|
+
if (!identity)
|
|
655
|
+
return null;
|
|
656
|
+
const projectId = store.projectFor((0, machine_store_1.repoPathHash)(cwd, (t) => (0, node_crypto_1.createHash)('sha256').update(t, 'utf8').digest('hex')));
|
|
657
|
+
if (!projectId)
|
|
658
|
+
return null;
|
|
659
|
+
const client = new server_client_1.ServerClient({ baseUrl: identity.serverUrl }).withToken(identity.token);
|
|
660
|
+
return {
|
|
661
|
+
projectId,
|
|
662
|
+
approvals: { createApproval: (p, input) => client.createApproval(p, input) },
|
|
663
|
+
resolution: { projectApproval: (p, id) => client.projectApproval(p, id) },
|
|
664
|
+
gate: { projectApprovals: (p, goal, limit) => client.projectApprovals(p, goal, limit) },
|
|
665
|
+
// ...and the governed surface, assembled where the vocabulary belongs.
|
|
666
|
+
governed: (0, governed_run_1.governedServerOf)(client),
|
|
667
|
+
sources: { sourceDetail: (p, id) => client.sourceDetail(p, id) },
|
|
668
|
+
servings: { forGoal: async (p, goalId) => (await client.goalServings(p, goalId)).servings },
|
|
669
|
+
questions: {
|
|
670
|
+
ask: async (body) => ({ id: (await client.askQuestion(body)).id }),
|
|
671
|
+
read: async (p, id) => (await client.questionById(p, id)).question,
|
|
672
|
+
},
|
|
673
|
+
edges: {
|
|
674
|
+
read: async (p, goalId) => {
|
|
675
|
+
const found = (await client.projectGoals(p)).goals.find((g) => g.id === goalId);
|
|
676
|
+
return found === undefined ? null : { version: found.version, dependsOn: found.dependsOn ?? [] };
|
|
677
|
+
},
|
|
678
|
+
setDependsOn: async (p, goalId, version, dependsOn, executionId) => {
|
|
679
|
+
await client.applyGovernedGoalEdge(p, executionId, { goalId, version, dependsOn });
|
|
680
|
+
},
|
|
681
|
+
},
|
|
682
|
+
refresh: async () => {
|
|
683
|
+
try {
|
|
684
|
+
const r = await client.projectGoals(projectId);
|
|
685
|
+
// A 200 is not an answer. A captive portal or a proxy interstitial
|
|
686
|
+
// arrives here as a successful response, and writing its body over the
|
|
687
|
+
// mirror would erase the very state this is trying to read.
|
|
688
|
+
const body = r;
|
|
689
|
+
if (typeof body !== 'object' || body === null || !Array.isArray(body.goals)) {
|
|
690
|
+
log('goal : the server answered with something that is not a goal list');
|
|
691
|
+
return 'unreachable';
|
|
692
|
+
}
|
|
693
|
+
await goals.replace(body.goals);
|
|
694
|
+
return 'ok';
|
|
695
|
+
}
|
|
696
|
+
catch (e) {
|
|
697
|
+
log(`goal : could not re-read your goals (${e.message})`);
|
|
698
|
+
return 'unreachable';
|
|
699
|
+
}
|
|
700
|
+
},
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Assemble and run. Returns the summary the caller prints; every transition is
|
|
705
|
+
* already on disk in the ledger by the time this resolves.
|
|
706
|
+
*/
|
|
707
|
+
async function runLoop(opts) {
|
|
708
|
+
const { cwd, log } = opts;
|
|
709
|
+
// Reassigned once the phone's settings are read; see below.
|
|
710
|
+
let config = opts.config;
|
|
711
|
+
/**
|
|
712
|
+
* TWO DIRECTORIES, AND WHICH ONE A STORE GETS IS ITS INVARIANT'S ANSWER.
|
|
713
|
+
*
|
|
714
|
+
* `stateDir` is this EXECUTION's — one writer, one checkout. `projectDir` is
|
|
715
|
+
* the PROJECT's — the main worktree's, shared by every worktree of this
|
|
716
|
+
* repository. In an ordinary checkout they are the same path, so nothing about
|
|
717
|
+
* a normal `looply run` changes.
|
|
718
|
+
*/
|
|
719
|
+
const stateDir = (0, git_driver_1.executionStateDir)(cwd);
|
|
720
|
+
const projectStateDirPath = (0, git_driver_1.projectStateDir)(cwd);
|
|
721
|
+
const ledger = new ledger_store_1.LedgerStore(stateDir, ledger_store_1.nodeLedgerFs);
|
|
722
|
+
const queue = new queue_store_1.QueueStore(stateDir, ledger_store_1.nodeLedgerFs);
|
|
723
|
+
const evidence = new evidence_cache_1.EvidenceCache(stateDir, ledger_store_1.nodeLedgerFs);
|
|
724
|
+
// PROJECT-SCOPED: the one channel the daemon, the run and the controller share
|
|
725
|
+
// about whether a person said stop. Per-worktree, `looply stop` would reach
|
|
726
|
+
// one execution and silently miss the rest.
|
|
727
|
+
const projectControl = new control_file_1.ControlStore(projectStateDirPath, ledger_store_1.nodeLedgerFs);
|
|
728
|
+
// Which of a person's answers have already been handed to a run. The daemon
|
|
729
|
+
// writes these at the Start boundary; a run only ever reads them, to tell a
|
|
730
|
+
// refusal nobody has heard yet from one this very run was told about.
|
|
731
|
+
//
|
|
732
|
+
// 🔴 PROJECT-SCOPED, BECAUSE THE DAEMON THAT WRITES THEM IS. The daemon
|
|
733
|
+
// claims an answer under `projectStateDir`; this read it from the EXECUTION
|
|
734
|
+
// state, so a run placed in another checkout looked in a directory the daemon
|
|
735
|
+
// never wrote to, saw no claim, and treated an answer it had just been handed
|
|
736
|
+
// as one nobody had heard. Same split as the start marks, same fix.
|
|
737
|
+
const resumes = new approval_resumes_1.ApprovalResumes(projectStateDirPath, start_attempts_1.nodeAttemptFs);
|
|
738
|
+
/**
|
|
739
|
+
* The safety history, opened once here and READ ONCE at the fence below.
|
|
740
|
+
*
|
|
741
|
+
* Null when it could not be read, and the fence then lets the run through —
|
|
742
|
+
* which is the opposite of the daemon's rule for the same file, and
|
|
743
|
+
* deliberately so. The daemon is deciding whether to spend a person's night
|
|
744
|
+
* unattended and must fail closed; this is a person at a terminal who has
|
|
745
|
+
* typed a command, and refusing them because a JSON file is corrupt would
|
|
746
|
+
* strand them with no way to work. The daemon's brake is still on.
|
|
747
|
+
*/
|
|
748
|
+
const safety = await (async () => {
|
|
749
|
+
// MACHINE-SCOPED, keyed by the repository's identity. The history is
|
|
750
|
+
// bucketed on goalId alone, so the directory it sits in IS the other half
|
|
751
|
+
// of its key: a second worktree would be a fresh history, and since both
|
|
752
|
+
// detectors need three observations while a run contributes one,
|
|
753
|
+
// per-worktree histories mean they can never fire. Phase 1 fixed that by
|
|
754
|
+
// moving it to the project state directory; Phase 2 moved it out of the
|
|
755
|
+
// repository, where a re-clone or a `git clean -xfd` cannot take a live
|
|
756
|
+
// hold with it. See rails-home.ts.
|
|
757
|
+
const store = (0, rails_stores_1.railsSafetyStore)(cwd);
|
|
758
|
+
return (await store.open()) === 'ok' ? store : null;
|
|
759
|
+
})();
|
|
760
|
+
/**
|
|
761
|
+
* Project memory, opened the same way and for the same reason: a person at a
|
|
762
|
+
* keyboard is not refused because a JSON file is unreadable. Null simply
|
|
763
|
+
* means this run plans and works without what the project has learned — which
|
|
764
|
+
* is exactly how every run behaved before Phase 11.
|
|
765
|
+
*/
|
|
766
|
+
const memoryKey = (0, project_memory_store_1.projectKeyFor)(cwd, (t) => (0, node_crypto_1.createHash)('sha256').update(t, 'utf8').digest('hex'));
|
|
767
|
+
const memoryStore = await (async () => {
|
|
768
|
+
const store = new project_memory_store_1.ProjectMemoryStore(stateDir, memoryKey, ledger_store_1.nodeLedgerFs);
|
|
769
|
+
return (await store.open()) === 'ok' ? store : null;
|
|
770
|
+
})();
|
|
771
|
+
// The person's decisions, before anything plans or works. A decision read
|
|
772
|
+
// after the plan is a constraint applied too late.
|
|
773
|
+
await rememberHumanDecisions(memoryStore, config, memoryKey);
|
|
774
|
+
const git = new git_driver_1.GitDriver((0, git_driver_1.nodeGitExec)(cwd));
|
|
775
|
+
/**
|
|
776
|
+
* What the repository IS, established deterministically and remembered.
|
|
777
|
+
*
|
|
778
|
+
* 🔴 THIS IS THE HALF EACH OF THE LAST TWO PHASES WAS MISSING. Phase 11 froze
|
|
779
|
+
* with `fact` having no production producer; Phase 12 built an index nothing
|
|
780
|
+
* consumed. Each was the other's missing piece, and both were recorded as
|
|
781
|
+
* known limits rather than hidden — this is them being closed.
|
|
782
|
+
*
|
|
783
|
+
* Cached by tree, so the cost is paid once per revision rather than once per
|
|
784
|
+
* run. A failure here is not a reason to refuse a run: the index is a
|
|
785
|
+
* convenience for planning, not an authority over anything, so it fails
|
|
786
|
+
* silently and the run proceeds exactly as it did before Phase 12.
|
|
787
|
+
*/
|
|
788
|
+
await (async () => {
|
|
789
|
+
if (memoryStore === null)
|
|
790
|
+
return;
|
|
791
|
+
try {
|
|
792
|
+
const tree = await git.headTreeSha();
|
|
793
|
+
const indexStore = new project_index_store_1.ProjectIndexStore(stateDir, ledger_store_1.nodeLedgerFs);
|
|
794
|
+
/**
|
|
795
|
+
* ⚠️ THE PACKAGE SET IS PART OF THE KEY, NOT JUST OF THE BUILD. Passing
|
|
796
|
+
* it is what stops a config edit — which no tree sha covers — being
|
|
797
|
+
* served a snapshot built under the previous workspace list. See
|
|
798
|
+
* `ProjectIndexStore.load`.
|
|
799
|
+
*/
|
|
800
|
+
const packages = (0, index_for_head_1.workspacePackages)(config);
|
|
801
|
+
let snapshot = await indexStore.load(tree, packages.map((pkg) => pkg.name));
|
|
802
|
+
if (snapshot === null) {
|
|
803
|
+
snapshot = await (0, project_index_builder_1.buildProjectIndex)({ git, packages, treeSha: tree });
|
|
804
|
+
await indexStore.save(snapshot);
|
|
805
|
+
}
|
|
806
|
+
for (const draft of (0, project_index_facts_1.factsFromIndex)(snapshot, memoryKey)) {
|
|
807
|
+
await memoryStore.remember(draft, false);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
catch {
|
|
811
|
+
// Deliberately silent. Nothing downstream depends on the index existing,
|
|
812
|
+
// and a run that refused to start because a derived cache failed would be
|
|
813
|
+
// trading a real capability for a convenience.
|
|
814
|
+
}
|
|
815
|
+
})();
|
|
816
|
+
await ledger.open();
|
|
817
|
+
await queue.open();
|
|
818
|
+
await evidence.open();
|
|
819
|
+
// The goal mirror the daemon keeps. Read here rather than fetched, because
|
|
820
|
+
// the daemon has almost certainly already pulled the version the phone was
|
|
821
|
+
// looking at, and a run that phoned home before it could begin would make
|
|
822
|
+
// the network a dependency of doing any work at all.
|
|
823
|
+
// PROJECT-SCOPED, read-only here; the daemon is the sole writer.
|
|
824
|
+
const goals = new goal_store_1.GoalStore(projectStateDirPath, ledger_store_1.nodeLedgerFs);
|
|
825
|
+
await goals.load();
|
|
826
|
+
const goalMirror = { get: (id) => goals.get(id) };
|
|
827
|
+
/**
|
|
828
|
+
* HOW STRUCTURE, KNOWLEDGE AND WORK RELATE — asked fresh, every time.
|
|
829
|
+
*
|
|
830
|
+
* 🔴 CONSTRUCTED HERE BECAUSE IT NEEDS ALL FIVE STORES, AND CONSULTED LATER
|
|
831
|
+
* BECAUSE THE WORLD MOVES WHILE A RUN WORKS. `round-runner` commits inside
|
|
832
|
+
* the loop below, so the committed tree is not the tree this line measured.
|
|
833
|
+
* The runtime asks that question again at every consumption and rebuilds
|
|
834
|
+
* when the answer changed — which is the half Phase 12 could not do, and why
|
|
835
|
+
* its index-derived facts fall silent after a run’s first commit.
|
|
836
|
+
*
|
|
837
|
+
* Nothing downstream is allowed to depend on it: every consumer treats null
|
|
838
|
+
* as "render nothing", exactly as it treated a missing memory store before.
|
|
839
|
+
*/
|
|
840
|
+
const graphRuntime = new project_graph_runtime_1.ProjectGraphRuntime({
|
|
841
|
+
projectKey: memoryKey,
|
|
842
|
+
headTreeSha: () => git.headTreeSha(),
|
|
843
|
+
indexFor: async (tree) => {
|
|
844
|
+
const store = new project_index_store_1.ProjectIndexStore(stateDir, ledger_store_1.nodeLedgerFs);
|
|
845
|
+
const packages = (0, index_for_head_1.workspacePackages)(config);
|
|
846
|
+
// Same key as the block above, and for the same reason.
|
|
847
|
+
const cached = await store.load(tree, packages.map((pkg) => pkg.name));
|
|
848
|
+
if (cached !== null)
|
|
849
|
+
return cached;
|
|
850
|
+
const built = await (0, project_index_builder_1.buildProjectIndex)({
|
|
851
|
+
git,
|
|
852
|
+
packages,
|
|
853
|
+
treeSha: tree,
|
|
854
|
+
});
|
|
855
|
+
await store.save(built);
|
|
856
|
+
return built;
|
|
857
|
+
},
|
|
858
|
+
memory: () => (memoryStore === null ? [] : memoryStore.history()),
|
|
859
|
+
operational: () => ({
|
|
860
|
+
goals: goals.all().map((g) => ({ id: g.id, text: g.text, version: g.version })),
|
|
861
|
+
items: queue.list().map((i) => ({
|
|
862
|
+
id: i.id,
|
|
863
|
+
title: i.title,
|
|
864
|
+
goalId: i.goalId ?? null,
|
|
865
|
+
evidence: i.evidence.map((e) => ({ path: e.path })),
|
|
866
|
+
})),
|
|
867
|
+
}),
|
|
868
|
+
});
|
|
869
|
+
/**
|
|
870
|
+
* One question, asked once per consumption point, so a failure to establish
|
|
871
|
+
* the graph costs nothing and changes nothing. A run that cannot build one
|
|
872
|
+
* behaves exactly as every run behaved before this phase.
|
|
873
|
+
*/
|
|
874
|
+
const currentGraph = async () => graphRuntime.current().catch(() => null);
|
|
875
|
+
/**
|
|
876
|
+
* IS THIS STILL THE AUTHORITY? Asked of the FILE, not of what we remember.
|
|
877
|
+
*
|
|
878
|
+
* `goals.get` answers from an array this process loaded once, which is right
|
|
879
|
+
* for the cheap questions a run asks constantly and wrong for this one:
|
|
880
|
+
* comparing that frozen snapshot against a binding taken from the same
|
|
881
|
+
* snapshot compares a value to itself and can never fail.
|
|
882
|
+
*
|
|
883
|
+
* Both compilations need it, and for the same reason. Each hands a goal's
|
|
884
|
+
* wording to an agent for up to ten minutes, then writes durable work stamped
|
|
885
|
+
* with the version it started under — while the daemon rewrites goals.json on
|
|
886
|
+
* its own poll cadence, holding no lease, so a person amending or abandoning
|
|
887
|
+
* the goal in that window is invisible to a run that only remembers.
|
|
888
|
+
*
|
|
889
|
+
* ONE function, because this was fixed for the adaptive compile and left
|
|
890
|
+
* undone for the first one, which is the same mistake this phase has now made
|
|
891
|
+
* four times: correcting a path and not its twin.
|
|
892
|
+
*
|
|
893
|
+
* Still local-first, and no server call. A machine whose mirror has not yet
|
|
894
|
+
* learned of a newer goal may go on working the one it knows; what this
|
|
895
|
+
* refuses is going on after the newer authority HAS arrived here.
|
|
896
|
+
*/
|
|
897
|
+
const freshAuthority = async (goalId, boundVersion, boundText) => (0, goal_ownership_1.stillTheAuthority)(await goals.readFresh(), goalId, boundVersion, boundText);
|
|
898
|
+
// Built without starting a sync daemon: this needs one GET, not a flush loop,
|
|
899
|
+
// and the sync attaches much further down once the lease is held.
|
|
900
|
+
const goalServer = openGoalServer(cwd, goals, log);
|
|
901
|
+
// Resolved BEFORE the lease: this is read-only and may go to the network,
|
|
902
|
+
// and holding the working tree during a call that might time out would block
|
|
903
|
+
// every other run for no reason. A refusal here has taken nothing.
|
|
904
|
+
const binding = await (0, goal_ownership_1.resolveGoalBinding)({
|
|
905
|
+
...(opts.goalId === undefined ? {} : { goalId: opts.goalId }),
|
|
906
|
+
...(opts.goalVersion === undefined ? {} : { goalVersion: opts.goalVersion }),
|
|
907
|
+
// The SERVER's id for this repo, not the config's. They are different
|
|
908
|
+
// strings — the config's is a local name and every goal carries a `prj_`
|
|
909
|
+
// id — so checking against the wrong one would refuse every goal as
|
|
910
|
+
// belonging to somewhere else.
|
|
911
|
+
projectId: goalServer?.projectId ?? config.project.id ?? 'local',
|
|
912
|
+
mirror: goalMirror,
|
|
913
|
+
...(goalServer === null ? {} : { refresh: goalServer.refresh }),
|
|
914
|
+
});
|
|
915
|
+
/**
|
|
916
|
+
* What a person has attested about the requirements this goal names, read off
|
|
917
|
+
* the binding this run is bound to.
|
|
918
|
+
*
|
|
919
|
+
* A function rather than a value because both endings ask for it at the
|
|
920
|
+
* moment they render, and because a goal can be amended while a run is going:
|
|
921
|
+
* asking then means the sentence describes the goal the reader is being told
|
|
922
|
+
* about. Null for a run with no goal, and for a goal that names no
|
|
923
|
+
* requirements — and then both endings are byte-identical to before.
|
|
924
|
+
*/
|
|
925
|
+
const attestedNow = () => binding.kind === 'bound' ? (0, core_3.attestedStanding)(binding.goal) : null;
|
|
926
|
+
/**
|
|
927
|
+
* THE ITEM→REQUIREMENT CLAIMS THIS PLAN MAKES, validated before they are
|
|
928
|
+
* recorded.
|
|
929
|
+
*
|
|
930
|
+
* 🔴 THE MODEL'S PROPOSAL IS UNTRUSTED DATA ALL THE WAY TO ONE VALIDATOR.
|
|
931
|
+
* `normalizeStep` carries `serves` through without inspecting it;
|
|
932
|
+
* `produceClaims` filters step names against THIS project's gate — the one
|
|
933
|
+
* check core cannot make, because the gate configuration lives here — and
|
|
934
|
+
* then hands the remainder to `servedRequirementTrouble`, the same pure
|
|
935
|
+
* function the server's route calls. Nothing else decides.
|
|
936
|
+
*
|
|
937
|
+
* ⚠️ EVERY FAILURE PATH RECORDS NO CLAIMS, NEVER PARTIAL ONES. An unreadable
|
|
938
|
+
* source, an unbound goal or a server this run cannot reach all answer the
|
|
939
|
+
* empty list — because a claim silently missing is a requirement that blocks
|
|
940
|
+
* at the coverage gate, which is visible, while a claim wrongly recorded is
|
|
941
|
+
* evidence that is not.
|
|
942
|
+
*
|
|
943
|
+
* ⚠️ AND NO FALLBACK. A goal's requirements are NOT copied onto its items when
|
|
944
|
+
* the model named none: that would make every item claim everything and the
|
|
945
|
+
* per-requirement receipt would mean exactly what the whole-tree one already
|
|
946
|
+
* meant.
|
|
947
|
+
*/
|
|
948
|
+
const claimsFor = async (steps, items, goalLinks) => {
|
|
949
|
+
if (goalServer === null || goalLinks.length === 0)
|
|
950
|
+
return [];
|
|
951
|
+
if (!steps.some((s) => s.serves !== undefined))
|
|
952
|
+
return [];
|
|
953
|
+
const sources = await (0, requirement_claims_1.linkableSourcesForGoal)(goalLinks, async (sourceId) => goalServer.sources.sourceDetail(projectId, sourceId));
|
|
954
|
+
if (sources === null) {
|
|
955
|
+
log('compile : a linked source could not be read, so no requirement claims were recorded');
|
|
956
|
+
return [];
|
|
957
|
+
}
|
|
958
|
+
const produced = (0, requirement_claims_1.produceClaims)({
|
|
959
|
+
steps,
|
|
960
|
+
itemIds: items.map((i) => i.id),
|
|
961
|
+
goalLinks,
|
|
962
|
+
sources,
|
|
963
|
+
gateStepNames: new Set(config.gates.map((g) => g.name)),
|
|
964
|
+
});
|
|
965
|
+
// Named, never silent: a refused claim is a model proposal this project
|
|
966
|
+
// declined, and the person reading the log is the one who can fix it.
|
|
967
|
+
for (const d of produced.dropped)
|
|
968
|
+
log(`compile : claim refused — ${d.because}`);
|
|
969
|
+
if (produced.servings.length > 0) {
|
|
970
|
+
log(`compile : ${produced.servings.length} item(s) claim a requirement`);
|
|
971
|
+
}
|
|
972
|
+
return [...produced.servings];
|
|
973
|
+
};
|
|
974
|
+
/**
|
|
975
|
+
* WHAT THIS DELIVERY FINISHED, FOR THE LANE TO JUDGE LATER.
|
|
976
|
+
*
|
|
977
|
+
* The claims say what each item was FOR; this says which of them the delivery
|
|
978
|
+
* actually finished, and at which tip. The lane cannot re-derive either — no
|
|
979
|
+
* commit trailer records an item id, and the run registry is released on a
|
|
980
|
+
* clean exit — so it is written where KEY 2 has already answered both, rather
|
|
981
|
+
* than derived a second time somewhere that could disagree.
|
|
982
|
+
*
|
|
983
|
+
* ⚠️ IT IS PROVENANCE, NOT ACCEPTANCE. `coverageNow` is this worker's verdict
|
|
984
|
+
* about its own checkout; the lane discards that and recomputes from its own
|
|
985
|
+
* gate on the commit it publishes. What travels is the claim.
|
|
986
|
+
*
|
|
987
|
+
* ⚠️ AN ARROW, LIKE `coverageNow`, AND FOR THE SAME TYPE-LEVEL REASON.
|
|
988
|
+
* `key2Saw` is a `let` filled inside a callback, so in the straight-line body
|
|
989
|
+
* TypeScript's flow analysis still holds it at its `null` initializer and
|
|
990
|
+
* narrows `!== null` to `never`. Read across a closure boundary it has its
|
|
991
|
+
* declared type, which is the honest one — the alternative was a non-null
|
|
992
|
+
* assertion, and that would be a claim where this needs a check.
|
|
993
|
+
*/
|
|
994
|
+
/**
|
|
995
|
+
* 🔴 THE BRANCH THAT DELIVERED, NOT THIS START'S (F20). The goal-met question is
|
|
996
|
+
* asked by a Start whose plan had nothing left to pick, and that Start refuses
|
|
997
|
+
* before its runner ever creates `branch`. KEY 2's confirming round names the run
|
|
998
|
+
* that delivered; the lane-claims ledger names the branch that run cut
|
|
999
|
+
* (`deliveringBranchOf`). The checked-out branch is the fallback, `branch` the last.
|
|
1000
|
+
*
|
|
1001
|
+
* ⚠️ AN ARROW, for the same flow-analysis reason as `recordDelivery` below.
|
|
1002
|
+
*/
|
|
1003
|
+
const deliveringBranch = async () => {
|
|
1004
|
+
if (deliveringRunId === null)
|
|
1005
|
+
return branch;
|
|
1006
|
+
const cut = (0, run_delivery_1.deliveringBranchOf)((0, lane_claims_1.readLaneClaims)(cwd), deliveringRunId);
|
|
1007
|
+
if (cut !== null)
|
|
1008
|
+
return cut;
|
|
1009
|
+
try {
|
|
1010
|
+
const current = await git.currentBranch();
|
|
1011
|
+
// F33: after a goal-met Start detached HEAD there is no branch to name; this Start's own is the honest fallback.
|
|
1012
|
+
return current === '(detached)' || current === '(unknown)' ? branch : current;
|
|
1013
|
+
}
|
|
1014
|
+
catch {
|
|
1015
|
+
return branch;
|
|
1016
|
+
}
|
|
1017
|
+
};
|
|
1018
|
+
const recordDelivery = async (at, goalVersion) => {
|
|
1019
|
+
if (key2Saw === null)
|
|
1020
|
+
return;
|
|
1021
|
+
// F31 (soun-4): only work the confirmed head's history contains — the rounds of THIS goal's items.
|
|
1022
|
+
const confirmedAt = judged.kind === 'carried' ? judged.of : await git.headSha();
|
|
1023
|
+
const mineIds = new Set(queue.list().filter((i) => i.goalId === at.goalId).map((i) => i.id));
|
|
1024
|
+
const rounds = await (0, round_records_1.readRoundRecords)(stateDir, ledger_store_1.nodeLedgerFs);
|
|
1025
|
+
const ours = rounds.ok ? rounds.rounds.filter((r) => mineIds.has(r.itemId)) : [];
|
|
1026
|
+
const delivered = rounds.ok ? (0, run_delivery_1.onThisBranch)([...key2Saw.delivered], ours, await (0, run_delivery_1.reachableFrom)(git, confirmedAt, ours)) : [...key2Saw.delivered];
|
|
1027
|
+
(0, lane_claims_1.recordLaneClaim)(cwd, {
|
|
1028
|
+
schema: lane_claims_1.LANE_CLAIMS_SCHEMA,
|
|
1029
|
+
kind: 'delivery',
|
|
1030
|
+
at: new Date().toISOString(),
|
|
1031
|
+
branch: await deliveringBranch(),
|
|
1032
|
+
runId: deliveringRunId ?? runId,
|
|
1033
|
+
goalId: at.goalId,
|
|
1034
|
+
goalVersion,
|
|
1035
|
+
planRevision: at.planRevision ?? core_1.FIRST_PLAN_REVISION,
|
|
1036
|
+
// KEY 2's own sha — for a carried commit, the round the lane carried (F27).
|
|
1037
|
+
commitSha: judged.kind === 'carried' ? judged.of : await git.headSha(),
|
|
1038
|
+
...(judged.kind === 'carried' ? { judgedAt: judged.sha } : {}),
|
|
1039
|
+
delivered,
|
|
1040
|
+
acceptanceDigest: (0, core_3.acceptanceMapDigest)(config.gates),
|
|
1041
|
+
});
|
|
1042
|
+
};
|
|
1043
|
+
/**
|
|
1044
|
+
* D3 — the fourth key, computed at the moment the claim would be made.
|
|
1045
|
+
*
|
|
1046
|
+
* 🔴 THE DIFFERENCE BETWEEN D3 IMPLEMENTED AND D3 WIRED. `coverageGate` is a
|
|
1047
|
+
* pure fold; without a caller it decides nothing, and a rule nothing consults
|
|
1048
|
+
* is the shape this programme keeps writing tests about.
|
|
1049
|
+
*
|
|
1050
|
+
* ⚠️ `deliveredUnderReceipt: true` IS NOT AN ASSUMPTION. This is only ever
|
|
1051
|
+
* asked for a `satisfied` decision, and `satisfied` is reachable only when
|
|
1052
|
+
* KEY 2 turned — a decision whose KEY 2 failed is `unconfirmed`, a different
|
|
1053
|
+
* kind entirely. So the receipt is already established by the time this runs,
|
|
1054
|
+
* and re-deriving it here would be a second implementation of the same
|
|
1055
|
+
* question that could disagree with the first.
|
|
1056
|
+
*
|
|
1057
|
+
* ⚠️ UNDEFINED WHEN IT CANNOT BE ASKED, WHICH IS NOT THE SAME AS COVERED. No
|
|
1058
|
+
* server, no linked requirements, or a source the server could not answer for
|
|
1059
|
+
* all return undefined, and `describeAdaptive` then renders exactly what it
|
|
1060
|
+
* always did. What it must never do is return an empty gate, because an empty
|
|
1061
|
+
* gate says `ok` — granting coverage on the strength of a failed lookup.
|
|
1062
|
+
*/
|
|
1063
|
+
const coverageNow = async () => {
|
|
1064
|
+
if (binding.kind !== 'bound' || goalServer === null)
|
|
1065
|
+
return undefined;
|
|
1066
|
+
const links = binding.goal.requirements ?? [];
|
|
1067
|
+
if (links.length === 0)
|
|
1068
|
+
return undefined;
|
|
1069
|
+
const classifications = new Map();
|
|
1070
|
+
const dispositions = new Map();
|
|
1071
|
+
const attestations = new Map();
|
|
1072
|
+
let measuredTree = null;
|
|
1073
|
+
for (const sourceId of new Set(links.map((l) => l.sourceId))) {
|
|
1074
|
+
let detail;
|
|
1075
|
+
try {
|
|
1076
|
+
detail = await goalServer.sources.sourceDetail(projectId, sourceId);
|
|
1077
|
+
}
|
|
1078
|
+
catch {
|
|
1079
|
+
// A source that could not be read is a coverage question this run
|
|
1080
|
+
// cannot answer. Answering "covered" would be the lie; answering
|
|
1081
|
+
// "blocked" for every one of its links would invent a measurement.
|
|
1082
|
+
return undefined;
|
|
1083
|
+
}
|
|
1084
|
+
const key = (ordinal) => `${sourceId}#${ordinal}`;
|
|
1085
|
+
for (const c of detail.classifications)
|
|
1086
|
+
classifications.set(key(c.ordinal), c);
|
|
1087
|
+
for (const d of detail.dispositions)
|
|
1088
|
+
dispositions.set(key(d.ordinal), d);
|
|
1089
|
+
for (const a of detail.attestations)
|
|
1090
|
+
attestations.set(key(a.ordinal), a);
|
|
1091
|
+
if (detail.reconciledAt !== null)
|
|
1092
|
+
measuredTree = detail.reconciledAt.treeSha;
|
|
1093
|
+
}
|
|
1094
|
+
/**
|
|
1095
|
+
* 🔴 THE FOURTH KEY'S STRONGER HALF, AND THE FIRST TIME IT CAN BE EARNED.
|
|
1096
|
+
*
|
|
1097
|
+
* `receiptedRequirements` has had a place in the gate and no producer:
|
|
1098
|
+
* every measured grant came back `measured-under-whole-tree-receipt`
|
|
1099
|
+
* because nothing recorded what an item was FOR. The claims are on the
|
|
1100
|
+
* server; what the delivery did is what KEY 2 saw; the fold is the only
|
|
1101
|
+
* thing that decides which claims survive both.
|
|
1102
|
+
*
|
|
1103
|
+
* ⚠️ A SERVER THAT CANNOT ANSWER LEAVES THE SET EMPTY, WHICH GRANTS
|
|
1104
|
+
* NOTHING EXTRA. It is the weaker word again, never a stronger one on a
|
|
1105
|
+
* failed lookup — the same rule the whole function already follows.
|
|
1106
|
+
*/
|
|
1107
|
+
let receipted = new Set();
|
|
1108
|
+
if (key2Saw !== null) {
|
|
1109
|
+
try {
|
|
1110
|
+
const rows = await goalServer.servings.forGoal(projectId, binding.goal.id);
|
|
1111
|
+
const byItem = new Map();
|
|
1112
|
+
for (const r of rows) {
|
|
1113
|
+
const list = byItem.get(r.itemId) ?? [];
|
|
1114
|
+
list.push({
|
|
1115
|
+
sourceId: r.sourceId,
|
|
1116
|
+
ordinal: r.ordinal,
|
|
1117
|
+
...(r.row === undefined ? {} : { row: r.row }),
|
|
1118
|
+
provenBy: r.provenBy,
|
|
1119
|
+
});
|
|
1120
|
+
byItem.set(r.itemId, list);
|
|
1121
|
+
}
|
|
1122
|
+
const fold = (0, core_3.requirementReceipts)({
|
|
1123
|
+
servings: [...byItem].map(([itemId, serves]) => ({ itemId, serves })),
|
|
1124
|
+
deliveredItems: key2Saw.delivered,
|
|
1125
|
+
executedChecks: key2Saw.checks,
|
|
1126
|
+
});
|
|
1127
|
+
// A proven table row is receipted under its own address (F24); the gate never lets it speak for its statement.
|
|
1128
|
+
receipted = new Set([...fold.receipted, ...fold.receiptedRows]);
|
|
1129
|
+
}
|
|
1130
|
+
catch {
|
|
1131
|
+
// Unreadable claims prove nothing either way. An empty set is the
|
|
1132
|
+
// honest input: the gate then says `measured-under-whole-tree-receipt`
|
|
1133
|
+
// in simple mode and blocks in strict, which is what "we could not
|
|
1134
|
+
// check" should do in each.
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
return (0, core_3.coverageGate)({
|
|
1138
|
+
links,
|
|
1139
|
+
classifications,
|
|
1140
|
+
dispositions,
|
|
1141
|
+
attestations,
|
|
1142
|
+
measuredTree,
|
|
1143
|
+
deliveredUnderReceipt: true,
|
|
1144
|
+
receiptedRequirements: receipted,
|
|
1145
|
+
/**
|
|
1146
|
+
* 🔴 STRICT IS PROGRAMME MODE, AND SIMPLE IS EVERYTHING ELSE.
|
|
1147
|
+
*
|
|
1148
|
+
* A machine running as a programme decides its own completions with
|
|
1149
|
+
* nobody reading each one, so a green tree around an unclaimed
|
|
1150
|
+
* requirement must not settle it. Under a person's explicit Start the
|
|
1151
|
+
* answer is unchanged, byte for byte — which is the "preserve D3,
|
|
1152
|
+
* preserve Simple Mode" half of §6.B, and the reason this is a mode
|
|
1153
|
+
* rather than a new rule for everybody.
|
|
1154
|
+
*/
|
|
1155
|
+
mode: (0, programme_mode_1.programmeIsOn)(config) ? 'strict' : 'simple',
|
|
1156
|
+
});
|
|
1157
|
+
};
|
|
1158
|
+
if (binding.kind === 'refused') {
|
|
1159
|
+
// The binding says WHICH refusal it is; nothing here reads its sentence.
|
|
1160
|
+
throw new RunRefused(binding.reason, binding.lapse === 'abandoned' ? 'goal_abandoned' : 'goal_superseded');
|
|
1161
|
+
}
|
|
1162
|
+
if (binding.kind === 'retry')
|
|
1163
|
+
throw new Error(binding.reason);
|
|
1164
|
+
/**
|
|
1165
|
+
* The version AND THE REVISION are part of the scope.
|
|
1166
|
+
*
|
|
1167
|
+
* A run works the plan compiled for the goal AS IT IS NOW, plus anything a
|
|
1168
|
+
* person adopted by hand. A previous version's plan stays owned and durable
|
|
1169
|
+
* and is simply not this run's to work — and the same is now true one level
|
|
1170
|
+
* down, of a previous REVISION of the current version.
|
|
1171
|
+
*
|
|
1172
|
+
* This was revision-blind, and the asymmetry it created is why 6D0 blocked:
|
|
1173
|
+
* selection saw every revision as current while KEY 2 asked only about the
|
|
1174
|
+
* newest, so a run could deliver revision 1 work that the completion check
|
|
1175
|
+
* could not see. Under one explicit Start that cost a person one confusing
|
|
1176
|
+
* refusal. Under an autonomous controller it is a dead end — Starts spent
|
|
1177
|
+
* delivering work that can never satisfy the goal.
|
|
1178
|
+
*
|
|
1179
|
+
* `FIRST_PLAN_REVISION` when the goal owns nothing yet: this run is about to
|
|
1180
|
+
* compile revision 1, and its rows have to be selectable when it does. It is
|
|
1181
|
+
* the MAXIMUM present otherwise, which is what makes a "future" revision
|
|
1182
|
+
* unreachable by construction rather than by luck.
|
|
1183
|
+
*/
|
|
1184
|
+
let scope = binding.kind === 'bound'
|
|
1185
|
+
? {
|
|
1186
|
+
goalId: binding.goal.id,
|
|
1187
|
+
goalVersion: binding.goal.version,
|
|
1188
|
+
planRevision: Math.max((0, goal_ownership_1.currentPlanRevision)(queue.list(), binding.goal.id, binding.goal.version), core_1.FIRST_PLAN_REVISION),
|
|
1189
|
+
}
|
|
1190
|
+
: null;
|
|
1191
|
+
if (binding.kind === 'bound') {
|
|
1192
|
+
log(`goal : ${binding.goal.text}`);
|
|
1193
|
+
}
|
|
1194
|
+
// Scoped from here on. `list()` is filtered and nothing else is, so a run
|
|
1195
|
+
// still reads and writes any item it is holding — see `scopedQueue`.
|
|
1196
|
+
let runQueue = (0, goal_ownership_1.scopedQueue)(queue, scope);
|
|
1197
|
+
const all = runQueue.list();
|
|
1198
|
+
const ready = all.filter((i) => i.state === 'ready');
|
|
1199
|
+
const baseSha = await git.headSha();
|
|
1200
|
+
const runId = (0, core_1.newId)('run');
|
|
1201
|
+
/**
|
|
1202
|
+
* 🔴 AND AN ADDRESSED ONE, BECAUSE ONE SHARED FILE CANNOT BRAKE N RUNS.
|
|
1203
|
+
*
|
|
1204
|
+
* `ControlStore.consume()` writes `resume` into the file it read. With
|
|
1205
|
+
* several executions running, the FIRST to reach a round boundary therefore
|
|
1206
|
+
* cleared the instruction and the rest read `resume` and carried on — while
|
|
1207
|
+
* `looply stop` had already printed "N executions are running; the request
|
|
1208
|
+
* reaches all of them". It reached one.
|
|
1209
|
+
*
|
|
1210
|
+
* `looply stop` now also writes `control.<runId>.json` into each live
|
|
1211
|
+
* execution's own state directory, and this reads its own alongside the
|
|
1212
|
+
* shared one. An addressed instruction can only be consumed by the run it
|
|
1213
|
+
* names, so no run's stop is destroyed by another run obeying.
|
|
1214
|
+
*
|
|
1215
|
+
* ⚠️ THE SHARED FILE IS STILL READ, AND FIRST-EQUAL. It is what the daemon and
|
|
1216
|
+
* the phone write, what a `looply stop` typed while nothing was running
|
|
1217
|
+
* leaves behind for the next run, and what Simple Mode has always used. This
|
|
1218
|
+
* is additive: with one execution the two agree, and with none the addressed
|
|
1219
|
+
* file is never written.
|
|
1220
|
+
*
|
|
1221
|
+
* ⚠️ AND `consume()` CLEARS BOTH, in that order. Clearing only the addressed
|
|
1222
|
+
* one would leave a project-wide `stop` on disk for the next run to obey
|
|
1223
|
+
* without explanation — the permanent-stop failure the runner documents.
|
|
1224
|
+
*/
|
|
1225
|
+
const addressedControl = control_file_1.ControlStore.addressed(stateDir, runId, ledger_store_1.nodeLedgerFs);
|
|
1226
|
+
const control = {
|
|
1227
|
+
/**
|
|
1228
|
+
* 🔴 `resume` IS THE CLEARED STATE, NOT AN INSTRUCTION, and the first version
|
|
1229
|
+
* of this treated it as one. `??` falls through only on `null`, and every run
|
|
1230
|
+
* CONSUMES at start — which WRITES `resume` into the addressed file. From
|
|
1231
|
+
* that moment the addressed file always answered, the project's `control.json`
|
|
1232
|
+
* was never read again, and no `looply stop`, no phone Stop and no phone
|
|
1233
|
+
* Pause reached the run. Ever. Including in Simple Mode, where there is only
|
|
1234
|
+
* ever one execution and the whole addressed mechanism is beside the point.
|
|
1235
|
+
*
|
|
1236
|
+
* Measured before the fix: after one start-consume, a project-wide `stop`
|
|
1237
|
+
* read back as `resume` for ever.
|
|
1238
|
+
*
|
|
1239
|
+
* So an addressed file takes precedence only while it carries something to
|
|
1240
|
+
* act on.
|
|
1241
|
+
*/
|
|
1242
|
+
read: async () => {
|
|
1243
|
+
const mine = await addressedControl.read();
|
|
1244
|
+
if (mine !== null && mine.kind !== 'resume')
|
|
1245
|
+
return mine;
|
|
1246
|
+
return projectControl.read();
|
|
1247
|
+
},
|
|
1248
|
+
/**
|
|
1249
|
+
* ⚠️ AND THE ADDRESSED FILE IS ONLY WRITTEN WHEN THERE IS SOMETHING IN IT TO
|
|
1250
|
+
* CLEAR. Consuming unconditionally created a `control.<runId>.json` in every
|
|
1251
|
+
* ordinary run's state directory, one per run, for ever — a file Simple Mode
|
|
1252
|
+
* never had. Now only `looply stop` creates one, and only the run it names
|
|
1253
|
+
* clears it.
|
|
1254
|
+
*/
|
|
1255
|
+
consume: async () => {
|
|
1256
|
+
const mine = await addressedControl.read();
|
|
1257
|
+
if (mine !== null && mine.kind !== 'resume')
|
|
1258
|
+
await addressedControl.consume();
|
|
1259
|
+
await projectControl.consume();
|
|
1260
|
+
},
|
|
1261
|
+
};
|
|
1262
|
+
/**
|
|
1263
|
+
* 🔴 THE RUN ID IS IN THE BRANCH NAME, and it has to be.
|
|
1264
|
+
*
|
|
1265
|
+
* The stamp is second-resolution, so two runs starting in the same second
|
|
1266
|
+
* produced the IDENTICAL branch — measured: two calls in one process returned
|
|
1267
|
+
* `20260903222307` twice. `createBranch` treats an existing branch as a hard
|
|
1268
|
+
* refusal (git-driver.ts), which is right, so the second run simply died.
|
|
1269
|
+
*
|
|
1270
|
+
* Today that is nearly unreachable, because one lease means one run per
|
|
1271
|
+
* checkout. It stops being unreachable the moment two worktrees run at once:
|
|
1272
|
+
* linked worktrees SHARE the ref namespace, so isolating the state
|
|
1273
|
+
* directories does nothing for this. The stamp stays because it is what makes
|
|
1274
|
+
* a branch list readable by a human; the id is what makes it unique.
|
|
1275
|
+
*/
|
|
1276
|
+
const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14);
|
|
1277
|
+
const branch = `looply/run-${stamp}-${runId.slice(-8)}`;
|
|
1278
|
+
// Computed twice on purpose: the dry run reports the file, and the real run
|
|
1279
|
+
// recomputes below once the phone's settings have been laid over it.
|
|
1280
|
+
const fileMinHours = opts.hours ?? config.run.minHours;
|
|
1281
|
+
if (opts.dryRun) {
|
|
1282
|
+
log(`project : ${config.project.name}`);
|
|
1283
|
+
log(`branch : ${branch} (would be created from ${baseSha.slice(0, 7)})`);
|
|
1284
|
+
log(`budget : ${fileMinHours}h, up to ${config.run.maxRounds} rounds`);
|
|
1285
|
+
log(`queue : ${ready.length} ready`);
|
|
1286
|
+
// Said plainly rather than quietly wrong: the overlay needs the server, and
|
|
1287
|
+
// a dry run deliberately does not open one.
|
|
1288
|
+
log('dry run: settings from your phone are read when the run actually starts.');
|
|
1289
|
+
log(`proof : ${config.proof.enabled ? 'on' : 'OFF — work would be kept on a passing build alone'}`);
|
|
1290
|
+
log(`gates : ${config.gates.map((g) => g.name).join(', ')}`);
|
|
1291
|
+
log('');
|
|
1292
|
+
if (!ready.length) {
|
|
1293
|
+
// A dry run is precisely when an empty queue should be information rather
|
|
1294
|
+
// than an error — you asked what would happen, and this is what would.
|
|
1295
|
+
log(`dry run: nothing would run — ${(0, queue_summary_1.describeNoReady)(all)}`);
|
|
1296
|
+
}
|
|
1297
|
+
else {
|
|
1298
|
+
log(`dry run: would start with "${ready[0].title}".`);
|
|
1299
|
+
}
|
|
1300
|
+
log('dry run: no branch created, no agent called.');
|
|
1301
|
+
return { stopReason: 'command', totals: { rounds: 0, committed: 0, discarded: 0, abandoned: 0 }, elapsedMs: 0 };
|
|
1302
|
+
}
|
|
1303
|
+
/**
|
|
1304
|
+
* The provider preset, resolved BEFORE the lease is taken.
|
|
1305
|
+
*
|
|
1306
|
+
* 🔴 POSITION IS THE POINT. A misspelled provider name is a configuration
|
|
1307
|
+
* mistake, and the run must stop on it having done nothing — not after taking
|
|
1308
|
+
* the working tree's lease, cutting a branch and spending the first model
|
|
1309
|
+
* call. An earlier version of this sat beside the agent construction five
|
|
1310
|
+
* hundred lines below, with a comment claiming it stopped before the lease.
|
|
1311
|
+
* It did not; the lease is taken immediately after this line.
|
|
1312
|
+
*
|
|
1313
|
+
* ⚠️ THE WARNING IS PRINTED, NOT SWALLOWED. Two of the three presets have
|
|
1314
|
+
* never been run by this repository's tests, and somebody starting a night's
|
|
1315
|
+
* work on one should be told before it starts rather than after.
|
|
1316
|
+
*/
|
|
1317
|
+
const resolved = (0, providers_1.resolveProvider)({
|
|
1318
|
+
...(config.provider === undefined ? {} : { provider: config.provider }),
|
|
1319
|
+
spec: config.agent,
|
|
1320
|
+
});
|
|
1321
|
+
if (!('spec' in resolved)) {
|
|
1322
|
+
log(resolved.message);
|
|
1323
|
+
return {
|
|
1324
|
+
stopReason: 'command',
|
|
1325
|
+
totals: { rounds: 0, committed: 0, discarded: 0, abandoned: 0 },
|
|
1326
|
+
elapsedMs: 0,
|
|
1327
|
+
};
|
|
1328
|
+
}
|
|
1329
|
+
if (resolved.warning !== null)
|
|
1330
|
+
log(`provider: ${resolved.warning}`);
|
|
1331
|
+
// One working tree, one mutating runner — taken here, by the process that
|
|
1332
|
+
// will do the mutating, and not by the daemon that may have asked for it. The
|
|
1333
|
+
// daemon spawns a run detached and unrefs it, so a lease holding the daemon's
|
|
1334
|
+
// pid would name a process that is dead while the run it describes is alive,
|
|
1335
|
+
// and every reader of that lease would then act on "dead": start a second
|
|
1336
|
+
// runner, rewrite a live run's queue, tell the phone a healthy run crashed.
|
|
1337
|
+
// Taking it here also covers `looply run` typed by hand, which the daemon
|
|
1338
|
+
// never sees at all.
|
|
1339
|
+
const leases = new run_lease_1.RunLeaseStore(stateDir, run_lease_1.nodeLeaseFs);
|
|
1340
|
+
const baseBranch = await git.currentBranch();
|
|
1341
|
+
// What the last run left behind, judged before anything is taken or written.
|
|
1342
|
+
// Read-only: this decides, the block below acts.
|
|
1343
|
+
/**
|
|
1344
|
+
* 🔴 A QUEUE WRITE IN PROGRESS BLOCKS A RUN, WHICH MAKES THE LOCK TWO-SIDED.
|
|
1345
|
+
*
|
|
1346
|
+
* Scan and verify take a write lock so a run cannot revert their work. That
|
|
1347
|
+
* was only half the arrangement: a run started DURING a verify pass consulted
|
|
1348
|
+
* only the run lease — free, because the pass never takes it — acquired it,
|
|
1349
|
+
* re-opened the queue, and wrote its own snapshot back over every verdict the
|
|
1350
|
+
* pass produced. Measured. The pass reported N confirmed and the items were
|
|
1351
|
+
* all back to `draft`.
|
|
1352
|
+
*
|
|
1353
|
+
* ⚠️ REFUSED, NOT QUEUED. A run that waited would hold the lease while it
|
|
1354
|
+
* waited, which is the deadlock the other direction.
|
|
1355
|
+
*/
|
|
1356
|
+
const writing = (0, queue_write_lease_1.queueWriteInProgress)(cwd);
|
|
1357
|
+
if (writing.busy) {
|
|
1358
|
+
/**
|
|
1359
|
+
* ⚠️ A PLAIN THROW, NOT A `RunRefused`. Every `RunRefusalCode` is TERMINAL —
|
|
1360
|
+
* a controller that saw one would stop offering this goal — and "somebody is
|
|
1361
|
+
* writing the queue right now" is the most retryable condition there is. It
|
|
1362
|
+
* ends like a held lease does: exit 1, and the Start is redelivered.
|
|
1363
|
+
*
|
|
1364
|
+
* ⚠️ AND NOTHING IS RELEASED HERE, because nothing is held yet: this sits
|
|
1365
|
+
* before the lease is inspected, let alone acquired.
|
|
1366
|
+
*/
|
|
1367
|
+
throw new Error(`cannot start: ${writing.because}`);
|
|
1368
|
+
}
|
|
1369
|
+
const priorState = await leases.inspect();
|
|
1370
|
+
const recovery = (0, recovery_1.classifyRecovery)({
|
|
1371
|
+
lease: priorState.kind === 'dead' ? priorState.lease : null,
|
|
1372
|
+
// The RAW queue, deliberately. Recovery is about what the LAST run left
|
|
1373
|
+
// behind, and that run may have been bound to another goal or to none;
|
|
1374
|
+
// asking a scoped view would hide exactly the stranded work it exists to
|
|
1375
|
+
// find, and this run would then start on top of it.
|
|
1376
|
+
/**
|
|
1377
|
+
* 🔴 BOTH IN-FLIGHT STATES, NOT ONE. `round-runner` writes `verifying`
|
|
1378
|
+
* before it writes `in_progress`, so a run that died between them left an
|
|
1379
|
+
* item nothing could move and every later run in that checkout answered
|
|
1380
|
+
* "nothing to do". See `ItemRepair.unverify` for the measurement.
|
|
1381
|
+
*/
|
|
1382
|
+
stranded: queue.list().filter((i) => i.state === 'in_progress' || i.state === 'verifying'),
|
|
1383
|
+
changedPaths: await git.changedPaths(),
|
|
1384
|
+
currentBranch: baseBranch,
|
|
1385
|
+
headSubject: await git.messageOf('HEAD'),
|
|
1386
|
+
// F33: a goal-met Start that moved HEAD and died left it here on purpose.
|
|
1387
|
+
judging: (0, goal_met_target_1.readJudgingMarker)(stateDir),
|
|
1388
|
+
headSha: await git.headSha(),
|
|
1389
|
+
});
|
|
1390
|
+
if (recovery.kind === 'blocked') {
|
|
1391
|
+
// Nothing has been touched: not the queue, not the tree, not a ref. The
|
|
1392
|
+
// person is told what looply found and why it will not guess.
|
|
1393
|
+
log('');
|
|
1394
|
+
log(`recover : ${recovery.reason}`);
|
|
1395
|
+
throw new Error(`cannot start: ${recovery.reason}`);
|
|
1396
|
+
}
|
|
1397
|
+
const lease = await leases.acquire({
|
|
1398
|
+
runId,
|
|
1399
|
+
branch,
|
|
1400
|
+
baseBranch,
|
|
1401
|
+
cliVersion: daemon_cmd_1.CLI_VERSION,
|
|
1402
|
+
...(opts.commandId === undefined ? {} : { startedByCommandId: opts.commandId }),
|
|
1403
|
+
// Durable, and read by the daemon's launcher after a restart: it is how a
|
|
1404
|
+
// second Start naming a different goal is told apart from a redelivery of
|
|
1405
|
+
// this one.
|
|
1406
|
+
...(scope === null ? {} : { goalId: scope.goalId }),
|
|
1407
|
+
});
|
|
1408
|
+
/**
|
|
1409
|
+
* CAPACITY, AFTER THE LEASE AND BEFORE ANY WORK.
|
|
1410
|
+
*
|
|
1411
|
+
* The lease answers "is THIS checkout busy" and structurally cannot answer
|
|
1412
|
+
* anything else — leases live one per state directory and cannot see each
|
|
1413
|
+
* other. The registry answers "what else is this PROJECT running", which is a
|
|
1414
|
+
* question a second workspace makes askable for the first time.
|
|
1415
|
+
*
|
|
1416
|
+
* ⚠️ ORDERED AFTER THE LEASE. Taking the slot first would mean a run that then
|
|
1417
|
+
* loses the lease race has to hand a slot back, and a crash between the two
|
|
1418
|
+
* would leak one permanently.
|
|
1419
|
+
*
|
|
1420
|
+
* ⚠️ A PLAIN ERROR, NEVER `RunRefused`. Every `RunRefusalCode` is terminal —
|
|
1421
|
+
* `isContinuableRefusal` returns false for all seven — and a controller that
|
|
1422
|
+
* saw one would send the pursuit to `human_required`. "The machine is busy
|
|
1423
|
+
* right now" is the most retryable condition there is, and it is refused the
|
|
1424
|
+
* same way a held lease is refused: a sentence, and a non-zero exit.
|
|
1425
|
+
*/
|
|
1426
|
+
const registry = new workspace_1.RunRegistry(projectStateDirPath);
|
|
1427
|
+
const runRecord = {
|
|
1428
|
+
runId,
|
|
1429
|
+
workspace: cwd,
|
|
1430
|
+
stateDir,
|
|
1431
|
+
branch,
|
|
1432
|
+
baseSha,
|
|
1433
|
+
// The main checkout is not a workspace looply created, and the reaper must
|
|
1434
|
+
// never treat it as one.
|
|
1435
|
+
worktree: (0, node_path_1.resolve)(cwd) !== (0, node_path_1.resolve)((0, trusted_git_1.mainWorktreeRoot)(cwd) ?? cwd),
|
|
1436
|
+
pid: process.pid,
|
|
1437
|
+
host: (0, node_os_1.hostname)(),
|
|
1438
|
+
startedAt: new Date().toISOString(),
|
|
1439
|
+
// M4-2: who asked. `--autonomous` is the launcher's word for a machine start,
|
|
1440
|
+
// and only such a run may ever be asked to stop for a higher-priority goal.
|
|
1441
|
+
startedBy: opts.autonomous === true ? 'machine' : 'person',
|
|
1442
|
+
};
|
|
1443
|
+
/**
|
|
1444
|
+
* CLAIMED, NOT COUNTED. Counting was check-then-act: two runs starting
|
|
1445
|
+
* together both passed it and both proceeded. `claim` takes one of `limit`
|
|
1446
|
+
* exclusively-created slots, so the filesystem decides — see `RunRegistry`.
|
|
1447
|
+
*/
|
|
1448
|
+
/**
|
|
1449
|
+
* 🔴 GUARDED, BECAUSE `claim` CAN THROW. Its contract is
|
|
1450
|
+
* `{ ok: false, because }` and it keeps it for every decision — but a
|
|
1451
|
+
* filesystem that refuses a mkdir, a write with an errno outside the
|
|
1452
|
+
* lost-a-race set, or a full disk, all come out as exceptions. This sits forty
|
|
1453
|
+
* lines below `leases.acquire`, so an exception here left the run lease held
|
|
1454
|
+
* by a process that is about to exit, and the next start had to wait for the
|
|
1455
|
+
* pid to be judged dead before it could take over.
|
|
1456
|
+
*/
|
|
1457
|
+
let capacity;
|
|
1458
|
+
try {
|
|
1459
|
+
capacity = registry.claim(runRecord, config.run.maxConcurrentRuns ?? 1);
|
|
1460
|
+
}
|
|
1461
|
+
catch (e) {
|
|
1462
|
+
await leases.release(lease.token);
|
|
1463
|
+
throw e;
|
|
1464
|
+
}
|
|
1465
|
+
if (!capacity.ok) {
|
|
1466
|
+
// No `registry.release` here: nothing is registered yet. Releasing would be
|
|
1467
|
+
// harmless — it is idempotent — but it would read as if a slot had been
|
|
1468
|
+
// taken and given back, which is the opposite of what happened.
|
|
1469
|
+
await leases.release(lease.token);
|
|
1470
|
+
throw new Error(capacity.because);
|
|
1471
|
+
}
|
|
1472
|
+
/**
|
|
1473
|
+
* 🔴 GIVING THE HOLD BACK HAS ONE IMPLEMENTATION, AND EVERY EXIT USES IT.
|
|
1474
|
+
*
|
|
1475
|
+
* The first version released the lease, the registry entry and the slot as
|
|
1476
|
+
* three lines copied to four places — and there were ELEVEN other ways out of
|
|
1477
|
+
* the span between here and the try/finally far below, none of which released
|
|
1478
|
+
* anything. Each one left a registry file naming a pid that had already gone.
|
|
1479
|
+
*
|
|
1480
|
+
* That was survivable only by accident: the probe reads a dead pid as dead, so
|
|
1481
|
+
* the slot is reclaimed and the entry reads as an orphan. But it made
|
|
1482
|
+
* `looply runs` report executions that never started, and it put the whole
|
|
1483
|
+
* bound one recycled pid away from a machine that refuses to run at all —
|
|
1484
|
+
* because a leaked record whose pid a later process inherits reads as ALIVE,
|
|
1485
|
+
* forever, and nothing dead ever clears it.
|
|
1486
|
+
*
|
|
1487
|
+
* Idempotent on purpose: the teardown calls it too, and calling it twice must
|
|
1488
|
+
* be as ordinary as calling it once.
|
|
1489
|
+
*/
|
|
1490
|
+
let holdReleased = false;
|
|
1491
|
+
/**
|
|
1492
|
+
* The surfaces this run holds, readable from the teardown that is defined
|
|
1493
|
+
* ABOVE the acquisition which fills it.
|
|
1494
|
+
*
|
|
1495
|
+
* A ref rather than a `let`, so there is one place the teardown reads and no
|
|
1496
|
+
* chance of it closing over a stale value.
|
|
1497
|
+
*/
|
|
1498
|
+
const heldSurfacesRef = { current: [] };
|
|
1499
|
+
/**
|
|
1500
|
+
* SETTLE THIS RUN'S BUDGET RESERVATION, once the admission below has made one.
|
|
1501
|
+
*
|
|
1502
|
+
* A ref-shaped hook for the same reason `heldSurfacesRef` is: the teardown is
|
|
1503
|
+
* defined above the admission that fills it. Null until there is something to
|
|
1504
|
+
* settle, and `Admission.settle` is itself idempotent, so a teardown that runs
|
|
1505
|
+
* twice charges once.
|
|
1506
|
+
*/
|
|
1507
|
+
let settleAdmission = null;
|
|
1508
|
+
const startedAtMs = Date.now();
|
|
1509
|
+
/**
|
|
1510
|
+
* Whether this run has REGISTERED — taken a slot and written its start mark —
|
|
1511
|
+
* and so is a run the daemon may be waiting on. Set once, below, after
|
|
1512
|
+
* `registry.register` succeeds.
|
|
1513
|
+
*/
|
|
1514
|
+
let registered = false;
|
|
1515
|
+
/**
|
|
1516
|
+
* The sync loop `attachSync` starts below, once it has — stopped as the LAST
|
|
1517
|
+
* act of `releaseHold`, so every exit that gives the hold back also lets the
|
|
1518
|
+
* process end. See the end of `releaseHold` for why that is not the teardown's
|
|
1519
|
+
* job alone.
|
|
1520
|
+
*/
|
|
1521
|
+
let stopSync = null;
|
|
1522
|
+
/** Where the ending below is written: the synced ledger once it exists, the local one before. */
|
|
1523
|
+
let endingSink = ledger;
|
|
1524
|
+
let endingProjectId = config.project.id ?? 'local';
|
|
1525
|
+
/**
|
|
1526
|
+
* M6-16: WHY THIS RUN IS LEAVING BEFORE ITS FIRST ROUND, when it is. Set by
|
|
1527
|
+
* `leaving(...)` at each exit that ends the run between registration and
|
|
1528
|
+
* the runner's start, immediately before that exit's `releaseHold()` — so
|
|
1529
|
+
* the ending written there can say why. Null for a run that started (its
|
|
1530
|
+
* ending is the runner's) and for an exit nobody named.
|
|
1531
|
+
*/
|
|
1532
|
+
let endedBefore = null;
|
|
1533
|
+
/** What the compile pass concluded when it queued nothing — appended to the reason, if there is one. */
|
|
1534
|
+
let compileNote = null;
|
|
1535
|
+
const leaving = (because) => {
|
|
1536
|
+
endedBefore = compileNote === null ? because : `${because} (${compileNote})`;
|
|
1537
|
+
};
|
|
1538
|
+
const releaseHold = async () => {
|
|
1539
|
+
if (holdReleased)
|
|
1540
|
+
return;
|
|
1541
|
+
holdReleased = true;
|
|
1542
|
+
/**
|
|
1543
|
+
* 🔴 A RUN THAT REGISTERED AND NEVER STARTED LEAVES ITS OWN ENDING.
|
|
1544
|
+
*
|
|
1545
|
+
* The start mark is written at registration, and the daemon reads it as
|
|
1546
|
+
* "a Run exists" — from then on the controller closes this Start only on a
|
|
1547
|
+
* durable `run.ended` for this run id (`run-ended.ts`). But `run.started`
|
|
1548
|
+
* is emitted after the compile pass and both verify passes, and six exits
|
|
1549
|
+
* sit between registration and it: a superseded goal, an adoption that
|
|
1550
|
+
* fails, a work set with nothing ready (`nothing to do — …`), a decision
|
|
1551
|
+
* that cannot be picked up. The refusals among them write a refusal mark,
|
|
1552
|
+
* which outranks the receipt. The plain errors wrote NOTHING: the run gave
|
|
1553
|
+
* back its lease, its slot and its record and left, and the daemon waited
|
|
1554
|
+
* on a ledger that never mentions it for the life of the grant. MEASURED
|
|
1555
|
+
* end to end with the daemon's own reader in `scripts/coordinator-e2e.js`:
|
|
1556
|
+
* every placed run left by `nothing to do` and no ordinal ever closed.
|
|
1557
|
+
*
|
|
1558
|
+
* So a registered run that leaves without ever having started says so, as
|
|
1559
|
+
* its last act before the record is given back: `aborted`, because a living
|
|
1560
|
+
* process wrote it, with totals that are exactly zero because no round
|
|
1561
|
+
* began. A run that DID start is not touched here — its ending is the
|
|
1562
|
+
* runner's to write, and recovery's when the runner could not.
|
|
1563
|
+
*
|
|
1564
|
+
* ⚠️ NEVER FATAL. A run that could not write its ending still gives
|
|
1565
|
+
* everything back; the cost is the unclosed Start this exists to prevent,
|
|
1566
|
+
* and the line below says so.
|
|
1567
|
+
*/
|
|
1568
|
+
if (registered && ledger.currentStatus().runId !== runId) {
|
|
1569
|
+
await writeEndingBeforeFirstRound(endingSink, { runId, projectId: endingProjectId, startedAtMs, because: endedBefore }, log);
|
|
1570
|
+
}
|
|
1571
|
+
/**
|
|
1572
|
+
* 🔴 THE SLOT IS GIVEN BACK BY NAME, NOT BY NUMBER, and the difference is
|
|
1573
|
+
* the whole concurrency bound. `releaseSlot` used to unlink `slot-N.json`
|
|
1574
|
+
* unconditionally; because the lease was released first and `claim` treats
|
|
1575
|
+
* a slot whose holder's lease is gone as free, another run could
|
|
1576
|
+
* legitimately take slot N in between — and this teardown then deleted the
|
|
1577
|
+
* NEW holder's claim. The limit was exceeded, and stayed exceeded, because
|
|
1578
|
+
* the same window reopened at every teardown.
|
|
1579
|
+
*
|
|
1580
|
+
* ⚠️ AND EACH RELEASE IS INDEPENDENT. `holdReleased` is set FIRST so this
|
|
1581
|
+
* stays idempotent under re-entry, which means a throw in one release would
|
|
1582
|
+
* otherwise strand the other two for ever — the teardown's own call would
|
|
1583
|
+
* return at the guard. The lease is last because it is the one whose
|
|
1584
|
+
* absence lets a second run into THIS checkout.
|
|
1585
|
+
*/
|
|
1586
|
+
/**
|
|
1587
|
+
* 🔴 SURFACES GO BACK HERE TOO, BECAUSE THE ACQUIRE IS NOT INSIDE THE
|
|
1588
|
+
* `finally` THAT WAS GIVING THEM BACK.
|
|
1589
|
+
*
|
|
1590
|
+
* MEASURED by reading the structure: `acquireSurfaces` runs at ~1782, and
|
|
1591
|
+
* the teardown holding the only `releaseSurface` is the `finally` of a
|
|
1592
|
+
* `try` that does not open until ~2930. Every refusal between those two
|
|
1593
|
+
* points — a stale base, an unreadable safety history, a queue that will
|
|
1594
|
+
* not open, an approval that is outstanding, a compile that fails, a
|
|
1595
|
+
* `RunRefused` of any kind — returned or threw with the lease still held.
|
|
1596
|
+
* A surface leaked that way is held until its TTL lapses: six hours of a
|
|
1597
|
+
* project refusing to schedule itself, for a directory nobody is in.
|
|
1598
|
+
*
|
|
1599
|
+
* ⚠️ AND IT IS IDEMPOTENT, so the `finally` downstream may still call it.
|
|
1600
|
+
* `releaseSurface` marks rather than deletes and matches on runId, so a
|
|
1601
|
+
* second release is a no-op rather than a second writer.
|
|
1602
|
+
*
|
|
1603
|
+
* ⚠️ FIRST IN THE TEARDOWN, because a surface is the only thing here that
|
|
1604
|
+
* blocks OTHER PROJECTS' runs rather than only this checkout's.
|
|
1605
|
+
*/
|
|
1606
|
+
/**
|
|
1607
|
+
* 🔴 THE BUDGET IS SETTLED FIRST, because a reservation left open holds an
|
|
1608
|
+
* ESTIMATE against the window until its hold expires — and on a machine
|
|
1609
|
+
* running near its ceiling that is the difference between the next goal
|
|
1610
|
+
* starting and waiting five minutes for nothing.
|
|
1611
|
+
*/
|
|
1612
|
+
try {
|
|
1613
|
+
settleAdmission?.();
|
|
1614
|
+
}
|
|
1615
|
+
catch {
|
|
1616
|
+
/* the hold expires by itself; a settlement that will not write is not fatal */
|
|
1617
|
+
}
|
|
1618
|
+
for (const fence of heldSurfacesRef.current) {
|
|
1619
|
+
try {
|
|
1620
|
+
(0, surface_lease_1.releaseSurface)(cwd, fence, runId);
|
|
1621
|
+
}
|
|
1622
|
+
catch {
|
|
1623
|
+
/* a filesystem that said no. The TTL is the backstop. */
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
try {
|
|
1627
|
+
registry.releaseSlot(capacity.slot, runId);
|
|
1628
|
+
}
|
|
1629
|
+
catch {
|
|
1630
|
+
/* another run's slot, or a filesystem that said no. Never fatal here. */
|
|
1631
|
+
}
|
|
1632
|
+
try {
|
|
1633
|
+
registry.release(runId);
|
|
1634
|
+
}
|
|
1635
|
+
catch {
|
|
1636
|
+
/* the record is bookkeeping; the lease below is what matters. */
|
|
1637
|
+
}
|
|
1638
|
+
await leases.release(lease.token);
|
|
1639
|
+
// Named for a runId that will never exist again, so nothing can ever read
|
|
1640
|
+
// it. Left behind, `looply stop` would put one in an ordinary checkout's
|
|
1641
|
+
// state directory every time a person pressed stop, for ever.
|
|
1642
|
+
(0, workspace_1.clearAddressedControl)(stateDir, runId);
|
|
1643
|
+
/**
|
|
1644
|
+
* 🔴 AND THE SYNC LOOP STOPS, LAST — IT IS THE ONE THING HERE THAT KEEPS
|
|
1645
|
+
* THE PROCESS ALIVE.
|
|
1646
|
+
*
|
|
1647
|
+
* `attachSync` starts a loop that polls on a timer until `stop()`. The
|
|
1648
|
+
* teardown's `finally` and two early exits stopped it; the eight plain
|
|
1649
|
+
* refusals between `attachSync` and the teardown's `try` gave back the
|
|
1650
|
+
* lease, the slot, the record and the surfaces here, wrote their ending, and
|
|
1651
|
+
* threw — and the loop went on polling for ever, so the process never
|
|
1652
|
+
* exited. MEASURED live (LIVE-2): a run refused by the envelope wrote
|
|
1653
|
+
* `run.ended` and was still alive minutes later with one live timer,
|
|
1654
|
+
* `sleep ← Daemon.run`, and nothing else.
|
|
1655
|
+
*
|
|
1656
|
+
* ⚠️ AFTER the ending above, because stopping is what flushes it. `stop()`
|
|
1657
|
+
* never rethrows and is idempotent, so the teardown's own stop after this
|
|
1658
|
+
* one costs nothing.
|
|
1659
|
+
*/
|
|
1660
|
+
if (stopSync !== null)
|
|
1661
|
+
await stopSync();
|
|
1662
|
+
};
|
|
1663
|
+
/**
|
|
1664
|
+
* 🔴 ANYTHING THAT THROWS OUT OF A CALL MUST STILL GIVE BACK WHAT THIS RUN
|
|
1665
|
+
* IS HOLDING — AND SEVEN CALLS BETWEEN HERE AND THE ROUND LOOP DID NOT.
|
|
1666
|
+
*
|
|
1667
|
+
* Every `throw` STATEMENT in this stretch is preceded by `await releaseHold()`,
|
|
1668
|
+
* and `run-hold-release.spec.ts` pins that. What neither the discipline nor
|
|
1669
|
+
* the pin can see is an exception propagating out of a CALL: there is no
|
|
1670
|
+
* `throw` token at the call site, so the guard was never written and the pin
|
|
1671
|
+
* has nothing to match. Its own docblock says as much.
|
|
1672
|
+
*
|
|
1673
|
+
* What that costs, at the last of them (`exemptionStore.open()`): the run
|
|
1674
|
+
* lease, the capacity slot, the registry record, every surface this run holds,
|
|
1675
|
+
* AND AN OPEN BUDGET RESERVATION — which goes on charging its estimate against
|
|
1676
|
+
* the window until the hold expires — plus the creation marker, whose absence
|
|
1677
|
+
* leaves the checkout unreapable once the pid is recycled. At the earliest of
|
|
1678
|
+
* them the queue is simply corrupt, which `QueueStore.open` throws on BY
|
|
1679
|
+
* DESIGN, so this is not a hypothetical errno.
|
|
1680
|
+
*
|
|
1681
|
+
* ⚠️ IT RETHROWS, ALWAYS. This gives back a hold; it does not decide
|
|
1682
|
+
* anything, and swallowing here would turn a broken machine into a silent one.
|
|
1683
|
+
* `releaseHold` is idempotent, so a call that already released costs nothing.
|
|
1684
|
+
*/
|
|
1685
|
+
/**
|
|
1686
|
+
* Throws when this run was constructed to fail at `site`. See `RunFaultSite`.
|
|
1687
|
+
* Absent in every production caller, so this is a no-op everywhere but a test.
|
|
1688
|
+
*/
|
|
1689
|
+
const faulting = (site) => {
|
|
1690
|
+
if (opts.faultAt === site) {
|
|
1691
|
+
throw new Error(`injected fault at ${site} (test-only; see RunFaultSite)`);
|
|
1692
|
+
}
|
|
1693
|
+
};
|
|
1694
|
+
const holding = async (fn) => {
|
|
1695
|
+
try {
|
|
1696
|
+
return await fn();
|
|
1697
|
+
}
|
|
1698
|
+
catch (e) {
|
|
1699
|
+
await releaseHold();
|
|
1700
|
+
throw e;
|
|
1701
|
+
}
|
|
1702
|
+
};
|
|
1703
|
+
/**
|
|
1704
|
+
* The readable record of what holds the slot — what `looply runs` and the
|
|
1705
|
+
* reaper work from.
|
|
1706
|
+
*
|
|
1707
|
+
* 🔴 DEFINED AFTER `releaseHold`, AND GUARDED, because it can throw. It was
|
|
1708
|
+
* three lines ABOVE the definition, so an EEXIST on a repeated runId, a
|
|
1709
|
+
* filesystem refusal, or a runId this cannot address left the lease AND the
|
|
1710
|
+
* slot held with nothing able to give them back — and the source pin below
|
|
1711
|
+
* could not see it, because the pin looks for `throw` STATEMENTS and this is
|
|
1712
|
+
* an exception out of a call.
|
|
1713
|
+
*/
|
|
1714
|
+
/**
|
|
1715
|
+
* 🔴 PROJECT-SCOPED, BECAUSE THESE ARE THE DAEMON↔RUN WIRE — AND EXECUTION
|
|
1716
|
+
* SCOPING THEM ESCALATED EVERY COORDINATED GOAL TO A HUMAN.
|
|
1717
|
+
*
|
|
1718
|
+
* A LEASE is about a CHECKOUT: one run per working tree, so it belongs beside
|
|
1719
|
+
* the tree. A START MARK is about a REQUEST — durable proof that the Start the
|
|
1720
|
+
* daemon asked for produced a Run — and the daemon is its only reader.
|
|
1721
|
+
*
|
|
1722
|
+
* MEASURED end to end: a coordinator-placed run wrote `run-starts.json` into
|
|
1723
|
+
* its WORKSPACE, and the daemon read it from its own checkout. `provenStart`
|
|
1724
|
+
* therefore answered null for every placed run, the launcher never reported
|
|
1725
|
+
* `started`, and `autonomy-controller` concluded
|
|
1726
|
+
*
|
|
1727
|
+
* Start 1 was attempted 3 times and never began
|
|
1728
|
+
*
|
|
1729
|
+
* and escalated a perfectly healthy goal to `human_required` — three cadences
|
|
1730
|
+
* into every night, for every goal the coordinator placed.
|
|
1731
|
+
*
|
|
1732
|
+
* ⚠️ INVISIBLE IN A SINGLE CHECKOUT, which is why nothing caught it: there the
|
|
1733
|
+
* project and execution state directories ARE the same path, so writer and
|
|
1734
|
+
* reader agree by accident. It only appears once a run is placed somewhere
|
|
1735
|
+
* else, which is the entire point of a coordinator.
|
|
1736
|
+
*/
|
|
1737
|
+
const startMarks = new run_start_marks_1.RunStartMarks(projectStateDirPath, ledger_store_1.nodeLedgerFs, log);
|
|
1738
|
+
// READ BEFORE WRITING. Without this the store holds an empty list, and
|
|
1739
|
+
// recording this run rewrites the whole file with one entry — so the fifty
|
|
1740
|
+
// the cap allows for have never actually accumulated, and any question about
|
|
1741
|
+
// an earlier request answers "no run". Harmless while the only reader asked
|
|
1742
|
+
// about the newest request; not harmless once anything asks retrospectively.
|
|
1743
|
+
if (opts.commandId !== undefined)
|
|
1744
|
+
await holding(() => {
|
|
1745
|
+
faulting('startMarks.load');
|
|
1746
|
+
return startMarks.load();
|
|
1747
|
+
});
|
|
1748
|
+
try {
|
|
1749
|
+
registry.register(runRecord);
|
|
1750
|
+
}
|
|
1751
|
+
catch (e) {
|
|
1752
|
+
await releaseHold();
|
|
1753
|
+
throw e;
|
|
1754
|
+
}
|
|
1755
|
+
registered = true;
|
|
1756
|
+
/**
|
|
1757
|
+
* 🔴 THE START RECEIPT IS WRITTEN HERE, BECAUSE HERE IS WHERE IT BECOMES
|
|
1758
|
+
* TRUE — AND WRITING IT AT `run.started` ESCALATED PAID RUNS TO A HUMAN.
|
|
1759
|
+
*
|
|
1760
|
+
* `markingStartsOn` recorded the receipt when the ledger first appended
|
|
1761
|
+
* `run.started`, which `RoundRunner` emits after the compile pass, both
|
|
1762
|
+
* verify passes and the adaptive evaluation. So a Start could hold the lease,
|
|
1763
|
+
* take a capacity slot, reserve budget, spend a compile and two verify passes
|
|
1764
|
+
* — tens of minutes and real money — leave by any of the six refusals between
|
|
1765
|
+
* here and there, and leave NO EVIDENCE THAT IT EVER RAN. The launcher then
|
|
1766
|
+
* answers `failed` for that request for ever after, and three such Starts
|
|
1767
|
+
* produce
|
|
1768
|
+
*
|
|
1769
|
+
* Start 1 was attempted 3 times and never began
|
|
1770
|
+
*
|
|
1771
|
+
* on a goal that had begun three times and been charged for it.
|
|
1772
|
+
*
|
|
1773
|
+
* MEASURED on the real command, single checkout, idle machine, trivial queue:
|
|
1774
|
+
* the run appeared in the registry at 524 ms and the receipt appeared at
|
|
1775
|
+
* 769 ms. Anything that ends a run inside that window — a kill, a crash, a
|
|
1776
|
+
* machine going to sleep — produces the escalation above. With a real queue
|
|
1777
|
+
* the window is the whole compile-and-verify pass, which is minutes.
|
|
1778
|
+
*
|
|
1779
|
+
* Registration is the honest instant: the record is on disk, the lease and
|
|
1780
|
+
* the slot are held, and `registry.active()` already counts this run. Nothing
|
|
1781
|
+
* downstream can make it untrue that a Run existed.
|
|
1782
|
+
*
|
|
1783
|
+
* ⚠️ IT DOES NOT MEAN THE START SUCCEEDED, and the launcher no longer reads
|
|
1784
|
+
* it that way: a refusal mark for the same request outranks this receipt, so
|
|
1785
|
+
* the six terminal refusals downstream still speak for the goal.
|
|
1786
|
+
*
|
|
1787
|
+
* ⚠️ NEVER FATAL, and never for a hand-typed run. A run genuinely under way
|
|
1788
|
+
* must not be killed because the daemon's evidence file would not write; the
|
|
1789
|
+
* cost is an unacked command, which the redelivery window already bounds.
|
|
1790
|
+
*/
|
|
1791
|
+
if (opts.commandId !== undefined) {
|
|
1792
|
+
try {
|
|
1793
|
+
await startMarks.record({
|
|
1794
|
+
commandId: opts.commandId,
|
|
1795
|
+
runId,
|
|
1796
|
+
at: new Date().toISOString(),
|
|
1797
|
+
// The checkout this run is executing in. The daemon reads THIS to find
|
|
1798
|
+
// the run's ledger after it ends — the registry record is given back
|
|
1799
|
+
// on the way out, this is not.
|
|
1800
|
+
workspace: cwd,
|
|
1801
|
+
});
|
|
1802
|
+
}
|
|
1803
|
+
catch (e) {
|
|
1804
|
+
log(`start : could not record that this run began (${e.message}) — the phone may ask again`);
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
/**
|
|
1808
|
+
* ⚠️ THE CREATION MARKER IS NOT CLEARED HERE — it is cleared as the LAST thing
|
|
1809
|
+
* this run does, at the very end of the teardown below.
|
|
1810
|
+
*
|
|
1811
|
+
* 🔴 THIS DOCBLOCK USED TO SAY IT WAS NEVER CLEARED AT ALL, twenty lines from
|
|
1812
|
+
* the call that clears it. Both extremes were shipped in turn — cleared here,
|
|
1813
|
+
* which reopened the window it exists to close; then never cleared, which made
|
|
1814
|
+
* a finished checkout permanently unreapable once its pid was recycled — and
|
|
1815
|
+
* the comment was left describing the middle one.
|
|
1816
|
+
*
|
|
1817
|
+
* The marker names THIS PROCESS, so it covers the checkout for the process's
|
|
1818
|
+
* whole life, including the teardown window where `releaseHold` has already
|
|
1819
|
+
* given back the lease and the record.
|
|
1820
|
+
*/
|
|
1821
|
+
/**
|
|
1822
|
+
* READ THE QUEUE AGAIN, now that nothing else may write it.
|
|
1823
|
+
*
|
|
1824
|
+
* `queue.open()` ran a long way above this — before three git subprocesses
|
|
1825
|
+
* and before the wait for the lease — and the store answers `list()` from the
|
|
1826
|
+
* map it filled then. It never goes back to disk. So without this line every
|
|
1827
|
+
* decision below is taken against a snapshot of how things were BEFORE this
|
|
1828
|
+
* run had exclusive use of the tree: whether this goal version already has a
|
|
1829
|
+
* plan, what recovery should put back, what a round may pick.
|
|
1830
|
+
*
|
|
1831
|
+
* A stale snapshot here is not merely a stale opinion, which is what makes it
|
|
1832
|
+
* worth a second read rather than a comment. `insertAll` commits
|
|
1833
|
+
* `[...this.items.values(), ...inserted]` — the whole map, not a delta — so
|
|
1834
|
+
* whatever this process failed to see is not just missed, it is written back
|
|
1835
|
+
* over. A `release-goal-items` that took the lease first and let a goal's work
|
|
1836
|
+
* go would be silently undone by the next thing this run wrote.
|
|
1837
|
+
*
|
|
1838
|
+
* The lease is the only mutual exclusion in the system. Reading outside it and
|
|
1839
|
+
* writing inside it is the one arrangement that cannot work.
|
|
1840
|
+
*/
|
|
1841
|
+
await holding(() => {
|
|
1842
|
+
faulting('queue.open');
|
|
1843
|
+
return queue.open();
|
|
1844
|
+
});
|
|
1845
|
+
/**
|
|
1846
|
+
* BEFORE anything is written, refuse a queue that holds a LATER plan.
|
|
1847
|
+
*
|
|
1848
|
+
* This check used to stand further down, next to the compile decision — and
|
|
1849
|
+
* two things that write the queue stood in front of it: adopting the items a
|
|
1850
|
+
* Start named, and putting stranded work back. So the one case that is
|
|
1851
|
+
* supposed to touch nothing touched two things first, and a Start naming
|
|
1852
|
+
* items would take ownership of them on behalf of an authority it was about
|
|
1853
|
+
* to declare stale.
|
|
1854
|
+
*
|
|
1855
|
+
* Versions only go up, so a plan compiled for a later version is durable
|
|
1856
|
+
* evidence that this run's authority is out of date. The honest response to
|
|
1857
|
+
* "your instructions have been overtaken" is to do nothing at all, and doing
|
|
1858
|
+
* nothing has to include the repairs: recovery is skipped too, deliberately,
|
|
1859
|
+
* because a queue that has been rewritten underneath this process is not one
|
|
1860
|
+
* to tidy on the strength of a Start that should never have arrived. The next
|
|
1861
|
+
* run, under an authority that agrees with the queue, recovers it.
|
|
1862
|
+
*/
|
|
1863
|
+
if (scope !== null) {
|
|
1864
|
+
const contradicting = (0, goal_ownership_1.splitGoalWork)(queue.list(), scope).newerPlans;
|
|
1865
|
+
if (contradicting.length) {
|
|
1866
|
+
const versions = [...new Set(contradicting.map((i) => i.compiledForGoalVersion))].join(', ');
|
|
1867
|
+
const superseded = `this machine holds work compiled for a LATER version of this goal (${versions}, and this Start says ` +
|
|
1868
|
+
`${binding.kind === 'bound' ? binding.goal.version : '?'}) — refusing to run against evidence that ` +
|
|
1869
|
+
'the goal has already moved on';
|
|
1870
|
+
leaving(superseded);
|
|
1871
|
+
await releaseHold();
|
|
1872
|
+
throw new RunRefused(superseded,
|
|
1873
|
+
// Durable evidence that the goal is ahead of this Start. Not a guess:
|
|
1874
|
+
// a plan compiled for a later version cannot exist under a correct
|
|
1875
|
+
// history, so the goal has moved and this Start is behind it.
|
|
1876
|
+
'goal_superseded');
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
// Under the lease, because it writes the queue. One durable write for the
|
|
1880
|
+
// whole set: a crash partway through would otherwise leave the goal owning
|
|
1881
|
+
// some of what the phone named and nothing recording which.
|
|
1882
|
+
if (scope !== null && opts.itemIds?.length) {
|
|
1883
|
+
const goalId = scope.goalId;
|
|
1884
|
+
const itemIds = opts.itemIds;
|
|
1885
|
+
const adopted = await holding(() => {
|
|
1886
|
+
faulting('adoptGoalItems');
|
|
1887
|
+
return (0, goal_ownership_1.adoptGoalItems)(queue, goalId, itemIds);
|
|
1888
|
+
});
|
|
1889
|
+
if (!adopted.ok) {
|
|
1890
|
+
leaving(`cannot start this goal — ${adopted.problem}`);
|
|
1891
|
+
await releaseHold();
|
|
1892
|
+
// Terminal: the named items will not become adoptable by being asked for
|
|
1893
|
+
// again in three seconds.
|
|
1894
|
+
throw new RunRefused(`cannot start this goal — ${adopted.problem}`, 'run_refused');
|
|
1895
|
+
}
|
|
1896
|
+
log(`goal : ${adopted.adopted} item(s) taken on` +
|
|
1897
|
+
(adopted.alreadyOwned ? `, ${adopted.alreadyOwned} already this goal's` : ''));
|
|
1898
|
+
}
|
|
1899
|
+
const dispatcher = new notify_channel_1.ChannelDispatcher({
|
|
1900
|
+
exec: notify_channel_1.nodeNotifyExec,
|
|
1901
|
+
fileExists: (p) => (0, node_fs_1.existsSync)(p),
|
|
1902
|
+
log,
|
|
1903
|
+
homeDir: (0, node_os_1.homedir)(),
|
|
1904
|
+
}, config.notify.channel);
|
|
1905
|
+
dispatcher.preflight();
|
|
1906
|
+
// Two audiences, two rules. The cadence notifier decides whether to
|
|
1907
|
+
// interrupt somebody who is not here, and obeys notify.every_min. The
|
|
1908
|
+
// terminal one narrates to somebody who is, and has no rate limit: a run
|
|
1909
|
+
// that printed three header lines and then nothing for hours is
|
|
1910
|
+
// indistinguishable from a hung one, which is the failure mode this whole
|
|
1911
|
+
// product is written against.
|
|
1912
|
+
const terminal = new progress_view_1.TerminalNotifier(log);
|
|
1913
|
+
// implement() runs exactly once per round, so this is the round number.
|
|
1914
|
+
let roundNo = 0;
|
|
1915
|
+
const cadence = new notify_cadence_1.CadenceNotifier(config.notify.everyMin, { send: (text) => dispatcher.send(text, config.notify.to) }, () => Date.now());
|
|
1916
|
+
const notifier = (0, progress_view_1.fanOut)(terminal, cadence);
|
|
1917
|
+
// Every agent this run starts is registered here, so the run can stop them
|
|
1918
|
+
// on the way out rather than leaving one editing the repository with no
|
|
1919
|
+
// parent. Covers an ordinary exit, a throw, a Ctrl-C and a `looply stop`;
|
|
1920
|
+
// a SIGKILL of this process cannot be covered from inside it.
|
|
1921
|
+
const children = new owned_children_1.OwnedChildren();
|
|
1922
|
+
const signals = new signals_1.SignalWatcher({
|
|
1923
|
+
proc: (0, signals_1.nodeSignalSource)(),
|
|
1924
|
+
// First Ctrl-C: the runner finishes the round in flight, so the tree is
|
|
1925
|
+
// never left half-written. Said out loud because otherwise a run that
|
|
1926
|
+
// appears to ignore Ctrl-C invites a second, harder one.
|
|
1927
|
+
onGraceful: () => {
|
|
1928
|
+
log('');
|
|
1929
|
+
log('run : stopping after this round. Press Ctrl-C again to stop now.');
|
|
1930
|
+
},
|
|
1931
|
+
// Second: the round will not be finished, so kill what this run started
|
|
1932
|
+
// rather than leaving an agent editing the tree with no parent. Both of
|
|
1933
|
+
// these callbacks existed and neither was ever wired.
|
|
1934
|
+
onForce: () => {
|
|
1935
|
+
const killed = children.killAll();
|
|
1936
|
+
log(`run : stopping now — ${killed} agent process(es) stopped.`);
|
|
1937
|
+
},
|
|
1938
|
+
});
|
|
1939
|
+
signals.install();
|
|
1940
|
+
// If this machine is paired, every event the run writes also goes to the
|
|
1941
|
+
// server. Without it a run was correct, complete and invisible — the phone
|
|
1942
|
+
// showed a project that never moved. Unpaired is a normal state, not an
|
|
1943
|
+
// error: looply works on its own and the app is an addition.
|
|
1944
|
+
const sync = await (0, sync_1.attachSync)(cwd, config, log);
|
|
1945
|
+
stopSync = sync === null ? null : () => sync.stop();
|
|
1946
|
+
const synced = sync ? new syncing_ledger_1.SyncingLedger(ledger, sync.daemon, (0, syncing_ledger_1.logSinkErrorOnce)(log)) : ledger;
|
|
1947
|
+
endingSink = synced;
|
|
1948
|
+
/**
|
|
1949
|
+
* Say, durably, that the command which asked for this run got one.
|
|
1950
|
+
*
|
|
1951
|
+
* Wrapped around the ledger rather than hooked into RoundRunner, because the
|
|
1952
|
+
* moment that matters is precisely when `run.started` is appended — and the
|
|
1953
|
+
* runner already does that, for its own reasons, knowing nothing about
|
|
1954
|
+
* commands. This notices; the runner is unchanged.
|
|
1955
|
+
*
|
|
1956
|
+
* Only for a run the daemon asked for. A hand-typed `looply run` has no
|
|
1957
|
+
* command to prove anything to.
|
|
1958
|
+
*/
|
|
1959
|
+
/**
|
|
1960
|
+
* ⚠️ STILL HERE, AND NOW ALMOST ALWAYS A NO-OP. The receipt is written at
|
|
1961
|
+
* registration above; `RunStartMarks.record` returns early when the command
|
|
1962
|
+
* already has one, so this writes nothing on any path that registered.
|
|
1963
|
+
*
|
|
1964
|
+
* It is kept because it costs nothing and it is the only writer that would
|
|
1965
|
+
* still fire if a future path reached `run.started` without registering —
|
|
1966
|
+
* which is exactly the kind of silent gap this whole receipt exists to close.
|
|
1967
|
+
*/
|
|
1968
|
+
const onMarkError = (e) => log(`start : could not record that this run began (${e.message}) — the phone may ask again`);
|
|
1969
|
+
// `cwd` is carried so a mark written here names the checkout, exactly as the
|
|
1970
|
+
// one written at registration does.
|
|
1971
|
+
const ledgerForRun = opts.commandId === undefined ? synced : (0, run_start_marks_1.markingStartsOn)(synced, startMarks, opts.commandId, onMarkError, cwd);
|
|
1972
|
+
/**
|
|
1973
|
+
* What you set on your phone, laid over the file.
|
|
1974
|
+
*
|
|
1975
|
+
* Only settings somebody actually chose are applied — the server answers with
|
|
1976
|
+
* the whole registry, and its defaults are not this project's. Failure is not
|
|
1977
|
+
* fatal: a run that cannot reach the server still runs, on the file alone,
|
|
1978
|
+
* which is the same rule every other server call here follows.
|
|
1979
|
+
*/
|
|
1980
|
+
if (sync) {
|
|
1981
|
+
try {
|
|
1982
|
+
const { settings } = await sync.client.projectSettings(sync.projectId);
|
|
1983
|
+
const overlay = (0, settings_overlay_1.applySettings)(config, settings);
|
|
1984
|
+
config = overlay.config;
|
|
1985
|
+
for (const line of (0, settings_overlay_1.describeOverrides)(overlay))
|
|
1986
|
+
log(line);
|
|
1987
|
+
}
|
|
1988
|
+
catch (e) {
|
|
1989
|
+
log(`settings: could not read your phone's settings (${e.message}) — using ${'.looply/config.yaml'} alone`);
|
|
1990
|
+
/**
|
|
1991
|
+
* ...EXCEPT FOR PUBLISHING, WHICH NARROWS INSTEAD.
|
|
1992
|
+
*
|
|
1993
|
+
* Falling back to the file is right for every other setting: a run that
|
|
1994
|
+
* cannot reach the server should still run, and `max_rounds` from a file
|
|
1995
|
+
* somebody committed is a perfectly good answer.
|
|
1996
|
+
*
|
|
1997
|
+
* It is the wrong answer for exactly one key. The phone is where
|
|
1998
|
+
* publishing gets turned OFF, and "I could not ask" and "nobody has
|
|
1999
|
+
* turned it off" are the same silence and opposite facts — the argument
|
|
2000
|
+
* `approval-gate-read` already makes for `unknown`. A file saying
|
|
2001
|
+
* `on-approval` and a person who set `never` from the sofa an hour ago
|
|
2002
|
+
* would otherwise resolve, here, in favour of the file.
|
|
2003
|
+
*
|
|
2004
|
+
* A TOTAL OUTAGE COULD NOT REACH A PUSH ANYWAY: the governed path asks the
|
|
2005
|
+
* server to read the approval, to claim it and to mint an execution, and
|
|
2006
|
+
* refuses if any of them cannot be reached. What this closes is the
|
|
2007
|
+
* PARTIAL case — settings failing while the approval routes answer — which
|
|
2008
|
+
* is narrow, real, and the kind of gap only found by checking a shipped
|
|
2009
|
+
* thing against what was promised for it.
|
|
2010
|
+
*/
|
|
2011
|
+
if ((0, config_1.mayPublish)(config.publish.policy)) {
|
|
2012
|
+
log('settings: publishing is off for this run — your phone could not be asked');
|
|
2013
|
+
config = { ...config, publish: { policy: 'never' } };
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
// Now that the phone has had its say. `--hours` still wins: it is the most
|
|
2018
|
+
// deliberate thing in the chain, typed on the machine that is about to run.
|
|
2019
|
+
const minHours = opts.hours ?? config.run.minHours;
|
|
2020
|
+
/**
|
|
2021
|
+
* Built here, AFTER the sync, because only the sync knows the project id.
|
|
2022
|
+
*
|
|
2023
|
+
* This was constructed before `attachSync` ran and read `config.project.id`,
|
|
2024
|
+
* which no config carries — `looply init` writes `project.name` and nothing
|
|
2025
|
+
* else — so every event a run wrote was stamped `projectId: 'local'`. The
|
|
2026
|
+
* upload then worked perfectly: the daemon shipped the batch, the server
|
|
2027
|
+
* accepted the request, and ingest dropped every event on
|
|
2028
|
+
* `.filter(e => e.projectId === projectId)` because the id in the envelope
|
|
2029
|
+
* was not the id in the URL. Nothing failed, nothing was logged, and the
|
|
2030
|
+
* phone showed a project that never moved — the precise failure SyncingLedger
|
|
2031
|
+
* exists to prevent, reintroduced one line above it.
|
|
2032
|
+
*
|
|
2033
|
+
* `verifyDrafts` and the scan already resolved it this way. This was the odd
|
|
2034
|
+
* one out, which is what let it survive.
|
|
2035
|
+
*
|
|
2036
|
+
* Bound separately from the identity because recovery below is announced as
|
|
2037
|
+
* events too, and an event needs the same id.
|
|
2038
|
+
*/
|
|
2039
|
+
const projectId = sync?.projectId ?? config.project.id ?? 'local';
|
|
2040
|
+
endingProjectId = projectId;
|
|
2041
|
+
const identity = { runId, projectId, lane: 'feature', branch };
|
|
2042
|
+
// Applied here rather than where it was decided, because the repair is
|
|
2043
|
+
// announced as events and an event needs the project id the server
|
|
2044
|
+
// registered — the same reason the run identity is built here.
|
|
2045
|
+
//
|
|
2046
|
+
// Event-first on purpose: the machine does not reach into the server's tables
|
|
2047
|
+
// to fix them. It says what happened on the ledger it already owns, and every
|
|
2048
|
+
// projection converges from that. One execution history, many mirrors.
|
|
2049
|
+
if (recovery.kind === 'repairable' && (recovery.items.length || recovery.closeRun)) {
|
|
2050
|
+
await holding(() => {
|
|
2051
|
+
faulting('applyRecovery');
|
|
2052
|
+
return (0, recovery_io_1.applyRecovery)(recovery, {
|
|
2053
|
+
queue,
|
|
2054
|
+
ledger: ledgerForRun,
|
|
2055
|
+
// M5-5: the daemon's sweep may have closed this same dead run a moment
|
|
2056
|
+
// ago — measured: one second before, and the ending was written twice.
|
|
2057
|
+
// The ledger is read at write time; a second ending is never written.
|
|
2058
|
+
alreadyEnded: async (id) => {
|
|
2059
|
+
const ended = await (0, run_ended_1.readRunEnded)(stateDir, ledger_store_1.nodeLedgerFs, id);
|
|
2060
|
+
return ended.ok ? ended.ended : null;
|
|
2061
|
+
},
|
|
2062
|
+
projectId,
|
|
2063
|
+
now: () => Date.now(),
|
|
2064
|
+
log,
|
|
2065
|
+
});
|
|
2066
|
+
});
|
|
2067
|
+
}
|
|
2068
|
+
/**
|
|
2069
|
+
* WHAT A PERSON ALREADY DECIDED, WHEN THIS RUN IS PICKING IT UP.
|
|
2070
|
+
*
|
|
2071
|
+
* The daemon carried one id here. Everything the model will be told is read
|
|
2072
|
+
* now, from the row that person's decision was written to — the same place,
|
|
2073
|
+
* and the same wording, they saw before they answered.
|
|
2074
|
+
*
|
|
2075
|
+
* READ BEFORE THE FIRST ROUND, AND FATAL IF IT CANNOT BE. A run resumed on a
|
|
2076
|
+
* refusal it could not read is a run that plans as though nothing was
|
|
2077
|
+
* refused, and the likeliest thing it plans is asking for the same action
|
|
2078
|
+
* again — which stops it at the same boundary, having spent a Start.
|
|
2079
|
+
*
|
|
2080
|
+
* A PLAIN ERROR, DELIBERATELY NOT A REFUSAL. Every `RunRefusalCode` is
|
|
2081
|
+
* terminal: throwing one here would end a person's whole grant because a
|
|
2082
|
+
* single HTTP read failed. This exits retryable, and the next Start reads it
|
|
2083
|
+
* again.
|
|
2084
|
+
*/
|
|
2085
|
+
let resolution;
|
|
2086
|
+
if (opts.resumedFromApprovalId !== undefined) {
|
|
2087
|
+
if (binding.kind !== 'bound' || goalServer === null) {
|
|
2088
|
+
leaving('a run cannot pick up a decision without the goal it was made for');
|
|
2089
|
+
await releaseHold();
|
|
2090
|
+
throw new Error('a run cannot pick up a decision without the goal it was made for');
|
|
2091
|
+
}
|
|
2092
|
+
const got = await (0, approval_resolution_1.readApprovalResolution)(goalServer.resolution, opts.resumedFromApprovalId, {
|
|
2093
|
+
projectId,
|
|
2094
|
+
goalId: binding.goal.id,
|
|
2095
|
+
goalVersion: binding.goal.version,
|
|
2096
|
+
now: Date.now(),
|
|
2097
|
+
});
|
|
2098
|
+
if (!got.ok) {
|
|
2099
|
+
leaving(`could not pick up that decision — ${got.because}`);
|
|
2100
|
+
await releaseHold();
|
|
2101
|
+
throw new Error(`could not pick up that decision — ${got.because}`);
|
|
2102
|
+
}
|
|
2103
|
+
resolution = got.resolution;
|
|
2104
|
+
log(`action : picking up where you said ${resolution.outcome} — ${resolution.display}`);
|
|
2105
|
+
}
|
|
2106
|
+
/**
|
|
2107
|
+
* THE ONE THING A PERSON ALREADY SAID YES TO, DONE BEFORE ANYTHING IS ASKED.
|
|
2108
|
+
*
|
|
2109
|
+
* Before the agent exists, before a queue is compiled, before a single round.
|
|
2110
|
+
* If somebody approved publishing an artifact, opening this run by asking a
|
|
2111
|
+
* model what it would like to do would be the wrong first act — and worse, it
|
|
2112
|
+
* would let a model's next idea arrive before the thing already authorised.
|
|
2113
|
+
*
|
|
2114
|
+
* NOTHING HERE DECIDES ANYTHING. The whole step is one call into
|
|
2115
|
+
* `governed-run`, which owns every word about claims and execution ids
|
|
2116
|
+
* precisely so this file does not — a guard written at 7B4-B asserts that,
|
|
2117
|
+
* and it is still passing.
|
|
2118
|
+
*
|
|
2119
|
+
* A REFUSAL IS A PLAIN ERROR, not a `RunRefusalCode`. Every refusal code is
|
|
2120
|
+
* terminal, and ending a person's grant because a governed step could not
|
|
2121
|
+
* begin would answer a temporary problem with a permanent one.
|
|
2122
|
+
*/
|
|
2123
|
+
/**
|
|
2124
|
+
* WHY THIS LOOP WILL STOP, ARMED ONCE AND READ BY A RUNNER THAT CANNOT TELL WHY.
|
|
2125
|
+
*
|
|
2126
|
+
* Declared HERE rather than beside the approval machinery it used to sit
|
|
2127
|
+
* with, because the governed prehook below can arm it before a single round
|
|
2128
|
+
* exists. `RoundRunner` reads this at the top of its loop — before the pick,
|
|
2129
|
+
* not only after a model turn — and a comment there says that nothing arms it
|
|
2130
|
+
* that way today and the fence is for the day something does. This is that
|
|
2131
|
+
* day: an ambiguous governed result ends the run having invoked no model at
|
|
2132
|
+
* all, which is exactly what the fence was written for.
|
|
2133
|
+
*/
|
|
2134
|
+
let halt = null;
|
|
2135
|
+
let governed;
|
|
2136
|
+
if (opts.governedApprovalId !== undefined) {
|
|
2137
|
+
if (binding.kind !== 'bound' || goalServer === null) {
|
|
2138
|
+
leaving('a run cannot carry out an approved action without the goal it was for');
|
|
2139
|
+
await releaseHold();
|
|
2140
|
+
throw new Error('a run cannot carry out an approved action without the goal it was for');
|
|
2141
|
+
}
|
|
2142
|
+
const outcome = await (0, governed_run_1.runGovernedAction)({
|
|
2143
|
+
server: goalServer.governed,
|
|
2144
|
+
/**
|
|
2145
|
+
* EVERY EXECUTOR LOOPLY HAS, and the policy decides which of them can
|
|
2146
|
+
* ever be reached rather than which are registered here.
|
|
2147
|
+
*
|
|
2148
|
+
* Registering the push executor unconditionally is deliberate: a broker
|
|
2149
|
+
* assembled differently per policy would be a second place the policy
|
|
2150
|
+
* lives, and the two would eventually disagree. The gate is in one
|
|
2151
|
+
* place — before the claim — and this list is simply what Looply can
|
|
2152
|
+
* do.
|
|
2153
|
+
*/
|
|
2154
|
+
broker: (0, governed_broker_1.createGovernedBroker)([
|
|
2155
|
+
(0, governed_broker_1.fixturePublishExecutor)({ fs: start_attempts_1.nodeAttemptFs }),
|
|
2156
|
+
(0, git_push_executor_1.gitPushExecutor)(new git_driver_1.GitDriver((0, git_driver_1.nodeGitExec)(cwd))),
|
|
2157
|
+
/**
|
|
2158
|
+
* 🔴 ONE APPROVED ORDERING CONSTRAINT, CARRIED OUT. Registered
|
|
2159
|
+
* unconditionally beside the others, for the reason given above: a
|
|
2160
|
+
* broker assembled differently per policy would be a second place the
|
|
2161
|
+
* policy lives.
|
|
2162
|
+
*
|
|
2163
|
+
* ⚠️ THE CAPABILITY IS `goalServer.edges`, NOT THE CLIENT, and the
|
|
2164
|
+
* first draft of this reached for the client and would not compile
|
|
2165
|
+
* — `openGoalServer` keeps it inside itself on purpose, because
|
|
2166
|
+
* handing one out puts deciding, listing and claiming within reach of
|
|
2167
|
+
* the same call site.
|
|
2168
|
+
*/
|
|
2169
|
+
(0, goal_depends_executor_1.goalDependsExecutor)(projectId, goalServer.edges),
|
|
2170
|
+
]),
|
|
2171
|
+
// 🔴 PROJECT-SCOPED: the daemon RESERVES under `projectStateDir` at the
|
|
2172
|
+
// Start boundary, and this is the reader that re-derives the claim key
|
|
2173
|
+
// from that reservation. Read from the execution state, a placed run
|
|
2174
|
+
// found no reservation for the approval it was started to carry out.
|
|
2175
|
+
reservations: new governed_approvals_1.GovernedApprovals(projectStateDirPath, start_attempts_1.nodeAttemptFs),
|
|
2176
|
+
log: (line) => log(`action : ${line}`),
|
|
2177
|
+
}, {
|
|
2178
|
+
projectId,
|
|
2179
|
+
goalId: binding.goal.id,
|
|
2180
|
+
goalVersion: binding.goal.version,
|
|
2181
|
+
runId,
|
|
2182
|
+
// From the trusted daemon-to-child wire, never from anything a model
|
|
2183
|
+
// or a person typed alongside the approval id.
|
|
2184
|
+
...(opts.commandId === undefined ? { commandId: undefined } : { commandId: opts.commandId }),
|
|
2185
|
+
approvalId: opts.governedApprovalId,
|
|
2186
|
+
publishPolicy: config.publish.policy,
|
|
2187
|
+
});
|
|
2188
|
+
if (outcome.kind === 'refused') {
|
|
2189
|
+
leaving(`that action was not carried out — ${outcome.because}`);
|
|
2190
|
+
await releaseHold();
|
|
2191
|
+
throw new Error(`that action was not carried out — ${outcome.because}`);
|
|
2192
|
+
}
|
|
2193
|
+
if (outcome.kind === 'stop') {
|
|
2194
|
+
// The attempt was made and its result is not something to plan around.
|
|
2195
|
+
// The pursuit stays alive and held; a person decides what happens next.
|
|
2196
|
+
halt = { stopReason: 'governed-action-ambiguous', because: outcome.because };
|
|
2197
|
+
log(`action : ${outcome.because}`);
|
|
2198
|
+
}
|
|
2199
|
+
else if (outcome.kind === 'done') {
|
|
2200
|
+
governed = outcome.result;
|
|
2201
|
+
/**
|
|
2202
|
+
* F34 (group B walk #5): AN APPLIED EDGE MOVED THE GOAL TO A NEW VERSION, and everything this
|
|
2203
|
+
* run resolved — its binding, its plan, its grant — is about the old one. Working on meant a
|
|
2204
|
+
* wasted compile at best, and at worst work published under a claim the lane then calls stale.
|
|
2205
|
+
* Retryable, not terminal: the goal's next Start works the new version.
|
|
2206
|
+
*/
|
|
2207
|
+
if (governed.actionType === 'goal.depends' && governed.outcome === 'succeeded') {
|
|
2208
|
+
leaving(`the goal gained a dependency (${governed.display}) — its next Start works the new version`);
|
|
2209
|
+
await releaseHold();
|
|
2210
|
+
throw new Error(`the goal gained a dependency — ${governed.display}; its next Start works the new version`);
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2214
|
+
/**
|
|
2215
|
+
* A RUN THAT IS NOT THE ANSWER TO THE QUESTION DOES NOT GET TO REASON.
|
|
2216
|
+
*
|
|
2217
|
+
* The completion fence at the end of this file already refuses to call a goal
|
|
2218
|
+
* done while somebody is owed an answer, and that was enough while the worst
|
|
2219
|
+
* a run could do was waste itself. It is not enough now.
|
|
2220
|
+
*
|
|
2221
|
+
* Reached only when this run carries NEITHER a decision to pick up nor an
|
|
2222
|
+
* action to carry out. In that case any outstanding approval work belongs to
|
|
2223
|
+
* some other Start, and there is nothing this run can usefully do with a
|
|
2224
|
+
* model: it would reason for hours against a pursuit that is waiting on a
|
|
2225
|
+
* person, and the likeliest thing it proposes is the action already waiting.
|
|
2226
|
+
*
|
|
2227
|
+
* BEFORE THE AGENT EXISTS, which is the whole point of its position. Meeting
|
|
2228
|
+
* this at the fence instead would mean the model calls were already paid for.
|
|
2229
|
+
*
|
|
2230
|
+
* A PLAIN ERROR, like the fence: every `RunRefusalCode` is terminal, and
|
|
2231
|
+
* ending a person's grant because they have not answered yet would answer a
|
|
2232
|
+
* question they are still thinking about.
|
|
2233
|
+
*/
|
|
2234
|
+
if (opts.resumedFromApprovalId === undefined &&
|
|
2235
|
+
opts.governedApprovalId === undefined &&
|
|
2236
|
+
binding.kind === 'bound' &&
|
|
2237
|
+
goalServer !== null) {
|
|
2238
|
+
const outstanding = await (0, approval_gate_read_1.readApprovalGate)(goalServer.gate, {
|
|
2239
|
+
projectId,
|
|
2240
|
+
goalId: binding.goal.id,
|
|
2241
|
+
goalVersion: binding.goal.version,
|
|
2242
|
+
now: Date.now(),
|
|
2243
|
+
consumed: (ids) => resumes.consumed(ids),
|
|
2244
|
+
});
|
|
2245
|
+
const waiting = (0, core_1.approvalHold)(outstanding);
|
|
2246
|
+
if (waiting !== null) {
|
|
2247
|
+
log(`action : not starting work — ${waiting}`);
|
|
2248
|
+
leaving(`this goal is waiting on you — ${waiting}`);
|
|
2249
|
+
await releaseHold();
|
|
2250
|
+
throw new Error(`this goal is waiting on you — ${waiting}`);
|
|
2251
|
+
}
|
|
2252
|
+
}
|
|
2253
|
+
/**
|
|
2254
|
+
* ⛔ AND HAS THIS MACHINE ALREADY DECIDED THAT REPEATING IS USELESS?
|
|
2255
|
+
*
|
|
2256
|
+
* ───────────────────────────────────────────────────────────────────────────
|
|
2257
|
+
* THE BYPASS THIS CLOSES
|
|
2258
|
+
*
|
|
2259
|
+
* A hand-typed `looply run` never touches the autonomy controller. With no
|
|
2260
|
+
* `--command-id` and no `--request-attempt` the whole admission block in
|
|
2261
|
+
* `bin.ts` is skipped, so nothing consults a grant, a budget or a safety
|
|
2262
|
+
* hold — the daemon's brake is simply not on this path. Without this fence a
|
|
2263
|
+
* person could be told "I have stopped, nothing is working" and then re-enter
|
|
2264
|
+
* the identical feedback loop by typing eight characters.
|
|
2265
|
+
*
|
|
2266
|
+
* ───────────────────────────────────────────────────────────────────────────
|
|
2267
|
+
* WHY AN OVERRIDE NEEDS NOTHING SPECIAL HERE
|
|
2268
|
+
*
|
|
2269
|
+
* A person's explicit Start is the escape hatch, and it arrives through the
|
|
2270
|
+
* daemon: the Start gate spends the incident's one retry BEFORE spawning the
|
|
2271
|
+
* run, so by the time this reads the store the incident is no longer ACTIVE
|
|
2272
|
+
* and the authorised run walks through. A hand-typed run has spent nothing,
|
|
2273
|
+
* so it meets the hold — which is the whole distinction, and it needs no flag
|
|
2274
|
+
* on the command line to express. ARGV PROVES NOTHING HERE EITHER.
|
|
2275
|
+
*
|
|
2276
|
+
* ⚠️ IT IS A PLAIN ERROR, LIKE THE APPROVAL FENCE ABOVE, not a RunRefused:
|
|
2277
|
+
* every refusal code is terminal, and ending a person's grant because their
|
|
2278
|
+
* machine is stuck would answer a question nobody asked.
|
|
2279
|
+
*
|
|
2280
|
+
* ⚠️ HONEST LIMIT: this binds the BOUND path. An unbound hand-typed run has no
|
|
2281
|
+
* pursuit to be held for, and nothing on that path can clear or spend an
|
|
2282
|
+
* incident — asserted in the spec rather than assumed here.
|
|
2283
|
+
*/
|
|
2284
|
+
/**
|
|
2285
|
+
* THE RESOURCE SURFACES THIS RUN DECLARED, TAKEN IN A TOTAL ORDER.
|
|
2286
|
+
*
|
|
2287
|
+
* 🔴 ALL OR NOTHING, BEFORE ANY WORK. Holding two of three surfaces while
|
|
2288
|
+
* waiting for the third is precisely the state a total acquisition order
|
|
2289
|
+
* exists to make impossible — which is why deadlock here is unrepresentable
|
|
2290
|
+
* rather than detectable, and why there is no wait-for graph to write.
|
|
2291
|
+
*
|
|
2292
|
+
* ⚠️ AND `run.surfaces` OFF MEANS NO LEASE IS EVER TAKEN. Not "taken and
|
|
2293
|
+
* ignored": the acquire is not called at all, so a project that never asked
|
|
2294
|
+
* for surfaces has no new file, no new failure mode and no new refusal.
|
|
2295
|
+
*/
|
|
2296
|
+
const declaredSurfaces = (0, declared_surfaces_1.declaredSurfacesFor)(config, binding);
|
|
2297
|
+
let heldSurfaces = [];
|
|
2298
|
+
if (declaredSurfaces.length > 0) {
|
|
2299
|
+
const got = await holding(() => {
|
|
2300
|
+
faulting('acquireSurfaces');
|
|
2301
|
+
return (0, surface_lease_1.acquireSurfaces)(cwd, declaredSurfaces, { runId, ttlMs: SURFACE_TTL_MS });
|
|
2302
|
+
});
|
|
2303
|
+
/**
|
|
2304
|
+
* ⚠️ A PLAIN THROW, NOT A `RunRefused`. Every refusal code is TERMINAL — a
|
|
2305
|
+
* controller seeing one would stop offering this pursuit — and "somebody
|
|
2306
|
+
* else is holding a directory" is the most retryable condition there is.
|
|
2307
|
+
*
|
|
2308
|
+
* ⚠️ AND THE RELEASE IS ON THE LINE ABOVE THE THROW, not separated from it
|
|
2309
|
+
* by prose. A source pin reads a window around every `throw` in this
|
|
2310
|
+
* function looking for `releaseHold()`, because a throw that skips it leaks
|
|
2311
|
+
* the lease, the registry entry and the slot — and a comment between the
|
|
2312
|
+
* two is enough to make the pin miss it.
|
|
2313
|
+
*/
|
|
2314
|
+
if (!got.ok) {
|
|
2315
|
+
leaving(`cannot start: ${got.because}`);
|
|
2316
|
+
await releaseHold();
|
|
2317
|
+
throw new Error(`cannot start: ${got.because}`);
|
|
2318
|
+
}
|
|
2319
|
+
heldSurfaces = got.held;
|
|
2320
|
+
// Visible to `releaseHold`, which every refusal path already calls and
|
|
2321
|
+
// which is defined above this line. See its surface branch.
|
|
2322
|
+
heldSurfacesRef.current = got.held;
|
|
2323
|
+
log(`surface : holding ${declaredSurfaces.join(', ')}`);
|
|
2324
|
+
}
|
|
2325
|
+
/** The hold whose one retry this run has been told to spend, once work starts. */
|
|
2326
|
+
let retryToSpend = null;
|
|
2327
|
+
if (binding.kind === 'bound' && safety !== null) {
|
|
2328
|
+
const incident = safety.activeIncident(binding.goal.id, binding.goal.version);
|
|
2329
|
+
if (incident !== null) {
|
|
2330
|
+
/**
|
|
2331
|
+
* ⚠️ THE SPEND IS ASKED FOR, NEVER ASSUMED. Without the flag this is the
|
|
2332
|
+
* refusal it has always been — the brake does not release itself because
|
|
2333
|
+
* somebody ran the command again.
|
|
2334
|
+
*/
|
|
2335
|
+
if (opts.tryOnceMore !== true) {
|
|
2336
|
+
log(`action : not starting work — ${incident.because}`);
|
|
2337
|
+
leaving(`this machine has stopped working on that goal — ${incident.because}`);
|
|
2338
|
+
await releaseHold();
|
|
2339
|
+
throw new Error(`this machine has stopped working on that goal — ${incident.because}. ` +
|
|
2340
|
+
'Run it again with --try-once-more to spend this incident’s one retry.');
|
|
2341
|
+
}
|
|
2342
|
+
/**
|
|
2343
|
+
* 🔴 THE FENCE IS HERE; THE SPEND IS NOT. IT HAPPENS JUST BEFORE THE
|
|
2344
|
+
* FIRST ROUND, AND THE DISTANCE BETWEEN THE TWO IS WHY.
|
|
2345
|
+
*
|
|
2346
|
+
* Spending here burnt the retry about a thousand lines before any work
|
|
2347
|
+
* could begin, and several ordinary things refuse a run in between — a
|
|
2348
|
+
* dirty working tree ("commit or stash first", thrown as the first thing
|
|
2349
|
+
* `runner.run()` does), an empty work set, a superseded goal. So a person
|
|
2350
|
+
* with three uncommitted edits — which is how people at terminals live —
|
|
2351
|
+
* typed the command looply told them to, watched it print "one retry,
|
|
2352
|
+
* spent", and then watched it die without buying a single model turn. The
|
|
2353
|
+
* retry was gone and the hold was permanently discharged.
|
|
2354
|
+
*
|
|
2355
|
+
* The refusal has to be early, because the point is not to start. The
|
|
2356
|
+
* SPEND has to be late, because the point is to buy an attempt.
|
|
2357
|
+
*/
|
|
2358
|
+
retryToSpend = incident;
|
|
2359
|
+
log(`action : held by ${incident.kind} (${incident.id}) — its one retry will be spent when work starts`);
|
|
2360
|
+
}
|
|
2361
|
+
}
|
|
2362
|
+
// One agent runner for all three roles. The prompt travels on stdin, so a
|
|
2363
|
+
// multi-line prompt, a diff full of quotes, and a prompt past cmd.exe's 8191
|
|
2364
|
+
// character ceiling all stop being the runner's problem (agent-exec.ts).
|
|
2365
|
+
// MEDIATED. The model runs outside the container — the packaged provider
|
|
2366
|
+
// cannot start inside one — and gets no host tools at all. Every capability
|
|
2367
|
+
// it can call is a Looply MCP tool, and each of those executes contained.
|
|
2368
|
+
/**
|
|
2369
|
+
* WHICH PROVIDER BINARY IS ABOUT TO ANSWER.
|
|
2370
|
+
*
|
|
2371
|
+
* 🔴 Until now the only test applied to it was `existsSync`, and nothing
|
|
2372
|
+
* recorded what a run had used — so an update, a tamper, or a different Node
|
|
2373
|
+
* install shifting the derived path all looked identical to nothing having
|
|
2374
|
+
* happened. This says so once, and only when it CHANGED.
|
|
2375
|
+
*
|
|
2376
|
+
* Best-effort by construction: a diagnostic that could stop a run would be a
|
|
2377
|
+
* worse thing than the gap it reports.
|
|
2378
|
+
*/
|
|
2379
|
+
await (async () => {
|
|
2380
|
+
try {
|
|
2381
|
+
/**
|
|
2382
|
+
* 🔴 `resolved.spec.command`, NOT `config.agent.command` — AND THE
|
|
2383
|
+
* DIFFERENCE MADE THIS RECORD LIE.
|
|
2384
|
+
*
|
|
2385
|
+
* The transport launches `resolved.spec.command`; this asked the CONFIG.
|
|
2386
|
+
* A preset resolves one to the other, so a project configured for a
|
|
2387
|
+
* different provider could be recorded as the default — `provider: codex`
|
|
2388
|
+
* hashing `claude.exe` — which is precisely the "never silently swap
|
|
2389
|
+
* model or provider" invariant, broken in the one record kept to detect
|
|
2390
|
+
* it.
|
|
2391
|
+
*
|
|
2392
|
+
* The rule is that identity is a fact about what WILL RUN, so it is read
|
|
2393
|
+
* from the thing that will run it.
|
|
2394
|
+
*/
|
|
2395
|
+
const isDefault = resolved.spec.command === agent_exec_1.DEFAULT_AGENT.command;
|
|
2396
|
+
const pkg = isDefault ? (0, agent_contained_1.claudePackageDir)() : null;
|
|
2397
|
+
const note = await (0, provider_identity_store_1.noteProviderIdentity)(new provider_identity_store_1.ProviderIdentityStore(stateDir, ledger_store_1.nodeLedgerFs), isDefault
|
|
2398
|
+
? { kind: 'default', ...(pkg === null ? {} : { executable: (0, node_path_1.join)(pkg, 'bin', 'claude.exe') }) }
|
|
2399
|
+
: { kind: 'configured', command: resolved.spec.command });
|
|
2400
|
+
if (note !== null)
|
|
2401
|
+
log(`provider: ${note}`);
|
|
2402
|
+
}
|
|
2403
|
+
catch {
|
|
2404
|
+
/* a diagnostic must never be the reason a run does not start */
|
|
2405
|
+
}
|
|
2406
|
+
})();
|
|
2407
|
+
/**
|
|
2408
|
+
* ═══════════════════════════════════════════════════════════════════════
|
|
2409
|
+
* THE CANONICAL EXECUTION ADMISSION. EVERY CHARGEABLE RUN PASSES HERE.
|
|
2410
|
+
* ═══════════════════════════════════════════════════════════════════════
|
|
2411
|
+
*
|
|
2412
|
+
* 🔴 IT IS HERE, AND NOWHERE ELSE, BECAUSE `runLoop` IS THE ONE THING EVERY
|
|
2413
|
+
* RUN IS.
|
|
2414
|
+
*
|
|
2415
|
+
* A person typing `looply run`, a Start the daemon spawned, a goal the
|
|
2416
|
+
* coordinator placed in a workspace, and anything Autopilot decides all
|
|
2417
|
+
* converge on this function. Checking a budget in the Brain, the coordinator,
|
|
2418
|
+
* the CLI and the provider adapter would be four policies that agree until
|
|
2419
|
+
* the day one of them is edited — and the one that spends the money is
|
|
2420
|
+
* whichever runs last. So there is one gate, at the lowest point, and every
|
|
2421
|
+
* caller above it is an optimisation rather than an authority.
|
|
2422
|
+
*
|
|
2423
|
+
* ⚠️ BEFORE THE AGENT EXISTS. The line below constructs the transport; the
|
|
2424
|
+
* first model call follows within the round loop. Refusing here means no
|
|
2425
|
+
* provider process is started, no turn is spent, and — crucially — no
|
|
2426
|
+
* implementation ATTEMPT is charged against the item, because the round loop
|
|
2427
|
+
* has not begun.
|
|
2428
|
+
*
|
|
2429
|
+
* ⚠️ AND IT IS NOT A STATEMENT ABOUT THE GOAL. A full budget is the most
|
|
2430
|
+
* retryable condition there is: it becomes false as the window rolls. So it
|
|
2431
|
+
* leaves by the same door a held surface does — a plain Error, never a
|
|
2432
|
+
* `RunRefused`, whose every code is terminal and would send a controller's
|
|
2433
|
+
* pursuit to `human_required` for a wallet that refills in an hour.
|
|
2434
|
+
*
|
|
2435
|
+
* ⚠️ NO ENVELOPE, NO GATE. Absent is what every existing project has, and
|
|
2436
|
+
* Simple Mode gains no new refusal by upgrading.
|
|
2437
|
+
*/
|
|
2438
|
+
const envelope = config.machine?.envelope;
|
|
2439
|
+
let admission = null;
|
|
2440
|
+
if (envelope !== undefined) {
|
|
2441
|
+
const inFlight = (() => {
|
|
2442
|
+
try {
|
|
2443
|
+
/**
|
|
2444
|
+
* 🔴 THE CANDIDATE MUST NOT COUNT ITSELF, AND THIS COMMENT USED
|
|
2445
|
+
* TO ARGUE THAT IT SHOULD.
|
|
2446
|
+
*
|
|
2447
|
+
* `withinEnvelope` refuses when `runsInFlight >= maxConcurrentRuns`,
|
|
2448
|
+
* so what it is being asked is "may one MORE start" — the candidate
|
|
2449
|
+
* is the "more". This run registers before it asks, so passing the
|
|
2450
|
+
* registry's count included itself.
|
|
2451
|
+
*
|
|
2452
|
+
* MEASURED: `max_concurrent_runs` defaults to 1, so 1 >= 1 refused,
|
|
2453
|
+
* and ANY project that wrote a `machine.envelope:` block could no
|
|
2454
|
+
* longer run a single goal. A budget that bricks Simple Mode is not a
|
|
2455
|
+
* budget.
|
|
2456
|
+
*/
|
|
2457
|
+
return Math.max(0, registry.active().length - 1);
|
|
2458
|
+
}
|
|
2459
|
+
catch {
|
|
2460
|
+
return 0;
|
|
2461
|
+
}
|
|
2462
|
+
})();
|
|
2463
|
+
const admitted = (0, spend_ledger_1.admitSpend)(envelope, binding.kind === 'bound' ? `run:${binding.goal.id}` : 'run', envelope.estimateUsdPerStart, inFlight, () => Date.now(), undefined,
|
|
2464
|
+
// So the settlement below REPLACES this run's rows instead of being
|
|
2465
|
+
// added to the very rows it is computed from.
|
|
2466
|
+
runId);
|
|
2467
|
+
if (!admitted.ok) {
|
|
2468
|
+
log(`budget : ${admitted.because}`);
|
|
2469
|
+
leaving(`cannot start: ${admitted.because}`);
|
|
2470
|
+
await releaseHold();
|
|
2471
|
+
throw new Error(`cannot start: ${admitted.because}`);
|
|
2472
|
+
}
|
|
2473
|
+
admission = admitted.admission;
|
|
2474
|
+
settleAdmission = () => {
|
|
2475
|
+
/**
|
|
2476
|
+
* 🔴 SETTLED FROM THIS RUN'S OWN ROWS, EXACTLY ONCE. The reservation held
|
|
2477
|
+
* an ESTIMATE; the transport wrote what each invocation actually cost,
|
|
2478
|
+
* tagged with this runId. Settling with the sum turns the promise into a
|
|
2479
|
+
* receipt, and `Admission.settle` is idempotent so a teardown that runs
|
|
2480
|
+
* twice charges once.
|
|
2481
|
+
*
|
|
2482
|
+
* ⚠️ AN UNMEASURED RUN SETTLES AT `undefined`, not at zero. The round
|
|
2483
|
+
* path reports no cost at all, so a run that did rounds has spent an
|
|
2484
|
+
* amount nobody can state — and the window must stay unmeasurable rather
|
|
2485
|
+
* than record a night of work as free.
|
|
2486
|
+
*/
|
|
2487
|
+
const actual = (0, spend_ledger_1.spentByRun)(runId);
|
|
2488
|
+
admission?.settle(actual === null ? undefined : actual, Date.now() - startedAtMs);
|
|
2489
|
+
};
|
|
2490
|
+
log(`budget : admitted — the machine is inside its envelope` +
|
|
2491
|
+
(envelope.maxUsd === undefined ? '' : ` (ceiling $${envelope.maxUsd.toFixed(2)})`));
|
|
2492
|
+
}
|
|
2493
|
+
// `children` is this run's registry; the transport is the longest-lived
|
|
2494
|
+
// child it starts and was the one thing never adopted into it.
|
|
2495
|
+
//
|
|
2496
|
+
// ⚠️ `runId` TRAVELS WITH IT, so every invocation's cost is attributed to
|
|
2497
|
+
// this run and the admission above can be settled at what it really cost.
|
|
2498
|
+
/**
|
|
2499
|
+
* 🔴 THE JSON ENVELOPE, UNWRAPPED — SO THE ROUND'S COST IS RECORDED.
|
|
2500
|
+
*
|
|
2501
|
+
* The round asked for `--output-format text` and so recorded every
|
|
2502
|
+
* invocation with `usd` absent: honest, and useless — the most expensive
|
|
2503
|
+
* path in the system could never say what it spent, and a trial's report had
|
|
2504
|
+
* to write "no actual usage captured". The scanner already ran on the JSON
|
|
2505
|
+
* path; the round, the verifier and the planner now do too. The transport
|
|
2506
|
+
* records the provider's `total_cost_usd`, `num_turns`, `usage` and
|
|
2507
|
+
* `session_id` at the invocation, and `unwrapping` hands the round the same
|
|
2508
|
+
* final message it always read. Missing usage stays absent — never zero.
|
|
2509
|
+
*/
|
|
2510
|
+
/**
|
|
2511
|
+
* ⚠️ THE BUILT-IN PROVIDER ONLY. A configured provider's stdout is the
|
|
2512
|
+
* model's own stream: there is no CLI between the model and this process to
|
|
2513
|
+
* write an envelope the model cannot forge, so asking it for "json" and
|
|
2514
|
+
* unwrapping the answer would let a model-typed `total_cost_usd` become a
|
|
2515
|
+
* ledger row — the closure acceptance check proves exactly that, end to
|
|
2516
|
+
* end. A configured provider is driven exactly as before: text, unwrapped
|
|
2517
|
+
* by nothing, recorded with `usd` absent.
|
|
2518
|
+
*/
|
|
2519
|
+
const builtIn = (0, model_transport_1.isBuiltInProvider)(resolved.spec);
|
|
2520
|
+
const wireFormat = builtIn ? { format: 'json' } : {};
|
|
2521
|
+
/**
|
|
2522
|
+
* F35: and a usage-limit refusal is waited out inside this Start rather than returned as the
|
|
2523
|
+
* answer — measured live, it was read as "the compiler failed" and three Starts were bought in
|
|
2524
|
+
* ninety seconds. Inside `unwrapping`, because the refusal is read off the envelope it strips.
|
|
2525
|
+
* A Stop, a Pause or a signal ends the wait (see `provider-limit.ts`).
|
|
2526
|
+
*/
|
|
2527
|
+
const limitWait = {
|
|
2528
|
+
now: () => Date.now(),
|
|
2529
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
2530
|
+
interrupted: async () => {
|
|
2531
|
+
if (signals.requested() || halt !== null)
|
|
2532
|
+
return true;
|
|
2533
|
+
try {
|
|
2534
|
+
const req = await control.read();
|
|
2535
|
+
return req !== null && (req.kind === 'stop' || req.kind === 'pause');
|
|
2536
|
+
}
|
|
2537
|
+
catch {
|
|
2538
|
+
// A control file that cannot be read now says nothing; the next slice reads it again.
|
|
2539
|
+
return false;
|
|
2540
|
+
}
|
|
2541
|
+
},
|
|
2542
|
+
log,
|
|
2543
|
+
// A detached run's output is discarded: the ledger is what `looply goal` and the phone read.
|
|
2544
|
+
announce: async (text) => {
|
|
2545
|
+
try {
|
|
2546
|
+
await ledgerForRun.append({
|
|
2547
|
+
ts: new Date().toISOString(),
|
|
2548
|
+
runId,
|
|
2549
|
+
projectId,
|
|
2550
|
+
lane: 'feature',
|
|
2551
|
+
kind: 'alert',
|
|
2552
|
+
actor: 'system',
|
|
2553
|
+
data: { code: 'provider-limit', severity: 'warn', text },
|
|
2554
|
+
});
|
|
2555
|
+
}
|
|
2556
|
+
catch (e) {
|
|
2557
|
+
log(`provider: the wait could not be recorded (${e.message})`);
|
|
2558
|
+
}
|
|
2559
|
+
},
|
|
2560
|
+
};
|
|
2561
|
+
const withFinalMessage = (invoke) => builtIn ? (0, agent_envelope_1.unwrapping)((0, provider_limit_1.waitingOutProviderLimits)(invoke, limitWait)) : invoke;
|
|
2562
|
+
const agent = withFinalMessage((0, model_transport_1.makeMediatedAgentRunner)(cwd, 'implementer', resolved.spec, { runId, ...wireFormat }, children));
|
|
2563
|
+
/**
|
|
2564
|
+
* 🔴 THE VERIFIER GETS THE VERIFIER'S TOOLS — AND A RUN USED TO LEND IT
|
|
2565
|
+
* THE IMPLEMENTER'S.
|
|
2566
|
+
*
|
|
2567
|
+
* One transport was built here and handed to three passes: the round, the
|
|
2568
|
+
* compile step, and the VERIFY pass. `TOOL_POLICIES` distinguishes them —
|
|
2569
|
+
* implementer gets `read, glob, grep, edit, write`, verifier gets
|
|
2570
|
+
* `read, glob, grep` — and `verify-cmd` builds a verifier-roled transport for
|
|
2571
|
+
* the standalone command. Inside a run, that narrowing was simply not applied.
|
|
2572
|
+
*
|
|
2573
|
+
* MEASURED at the bridge: a verifier-roled bridge answers "the verifier may
|
|
2574
|
+
* not call write"; an implementer-roled one accepts the call. So a model asked
|
|
2575
|
+
* to CHECK an item against the code could change the code first, and its
|
|
2576
|
+
* verdict would then be about a tree it had just written — in one of the two
|
|
2577
|
+
* keys the whole design rests on.
|
|
2578
|
+
*
|
|
2579
|
+
* ⚠️ `runId` TRAVELS WITH IT TOO, so verify-pass invocations are still
|
|
2580
|
+
* attributed to this run and settled with everything else it spent.
|
|
2581
|
+
*/
|
|
2582
|
+
const verifyAgent = withFinalMessage((0, model_transport_1.makeMediatedAgentRunner)(cwd, 'verifier', resolved.spec, { runId, ...wireFormat }, children));
|
|
2583
|
+
/**
|
|
2584
|
+
* 🔴 PLANNING GETS THE PLANNER'S TOOLS. Compiling a goal and evaluating a
|
|
2585
|
+
* goal gap both READ the codebase and produce a plan; neither edits anything.
|
|
2586
|
+
* Both were lent the implementer's transport — `[read, glob, grep, edit,
|
|
2587
|
+
* write]` — for no reason beyond it being the one already in scope, which is
|
|
2588
|
+
* exactly how the verify pass came to carry write until it was narrowed.
|
|
2589
|
+
*
|
|
2590
|
+
* `brain` is `[read, glob, grep]`. A capability nothing uses is a capability
|
|
2591
|
+
* that only ever gets used by accident, and the narrowing costs nothing
|
|
2592
|
+
* because a compile that edits files is not a compile.
|
|
2593
|
+
*
|
|
2594
|
+
* `runId` travels with it, so planning invocations stay attributed to this
|
|
2595
|
+
* run and are settled with everything else it spent.
|
|
2596
|
+
*/
|
|
2597
|
+
const planAgent = withFinalMessage((0, model_transport_1.makeMediatedAgentRunner)(cwd, 'brain', resolved.spec, { runId, ...wireFormat }, children));
|
|
2598
|
+
/**
|
|
2599
|
+
* 🔴 THE PROVER GETS THE PROVER'S TOOLS — THE SAME NARROWING AS THE TWO
|
|
2600
|
+
* ABOVE, AND THE ONE THAT WAS MISSED.
|
|
2601
|
+
*
|
|
2602
|
+
* `prove` was handed `agent`, the implementer's transport, so the second
|
|
2603
|
+
* key ran with `edit` and `write` in the window between a GREEN gate and
|
|
2604
|
+
* the commit, where nothing re-gates what it changes. Measured live on
|
|
2605
|
+
* 2026-09-24: the bridge was launched as `implementer` for the proving turn,
|
|
2606
|
+
* and its spend was booked as implementer spend. See `TOOL_POLICIES.prover`.
|
|
2607
|
+
*/
|
|
2608
|
+
const proveAgent = withFinalMessage((0, model_transport_1.makeMediatedAgentRunner)(cwd, 'prover', resolved.spec, { runId, ...wireFormat }, children));
|
|
2609
|
+
/**
|
|
2610
|
+
* A goal with nothing to work on compiles itself some work.
|
|
2611
|
+
*
|
|
2612
|
+
* Here, and not in RoundRunner, because this is the composition root: the
|
|
2613
|
+
* goal is already resolved, the lease is already held, and the agent already
|
|
2614
|
+
* exists. The runner receives a queue and cannot tell why anything is in it,
|
|
2615
|
+
* which is the property the whole scoped-queue design exists to preserve.
|
|
2616
|
+
*
|
|
2617
|
+
* ONLY when the goal owns nothing. That is the whole idempotency story at
|
|
2618
|
+
* this level: derived ids make a materialisation retry converge, but they
|
|
2619
|
+
* cannot make two separate reasoning calls agree, so the second call must not
|
|
2620
|
+
* happen. A goal that already owns work — because a previous run compiled it,
|
|
2621
|
+
* or because this Start named items to adopt — is left alone. Re-planning a
|
|
2622
|
+
* goal whose work is under way is a later phase's decision, not a side effect
|
|
2623
|
+
* of starting it again.
|
|
2624
|
+
*
|
|
2625
|
+
* A failed compilation is not a failed run: the run carries on and reports
|
|
2626
|
+
* "nothing to do" if that is the truth, which leaves the start-run to be
|
|
2627
|
+
* offered again rather than terminally consumed.
|
|
2628
|
+
*/
|
|
2629
|
+
// Compile when there is no plan for THIS version, and nothing eligible that
|
|
2630
|
+
// could still move. Those are two different conditions and both matter:
|
|
2631
|
+
//
|
|
2632
|
+
// - no current plan, because a previous version's plan must not stop this
|
|
2633
|
+
// version ever getting one. That count was the whole defect.
|
|
2634
|
+
// - nothing eligible still working, because work a person adopted by hand
|
|
2635
|
+
// should be got on with rather than planned around.
|
|
2636
|
+
//
|
|
2637
|
+
// A finished current plan therefore does NOT trigger a second one. Deciding
|
|
2638
|
+
// whether a finished plan should be replaced is recompilation, and it is a
|
|
2639
|
+
// separate question with its own answer.
|
|
2640
|
+
const owned = queue.list();
|
|
2641
|
+
const eligible = runQueue.list();
|
|
2642
|
+
const split = (0, goal_ownership_1.splitGoalWork)(owned, scope);
|
|
2643
|
+
// The decision reads as the question it is asking. `split` below is for
|
|
2644
|
+
// saying what is being held back, which is a different job.
|
|
2645
|
+
const verdict = (0, goal_ownership_1.decideCompile)(split, eligible, scope);
|
|
2646
|
+
if (scope !== null && split.olderPlans.length) {
|
|
2647
|
+
// Said out loud rather than left to be inferred from a queue that looks
|
|
2648
|
+
// shorter than it is. These are durable and still owned; they are simply
|
|
2649
|
+
// not this version's work.
|
|
2650
|
+
log(`goal : ${split.olderPlans.length} item(s) from an earlier plan for this goal, kept but not worked`);
|
|
2651
|
+
}
|
|
2652
|
+
if (scope !== null && split.unknownPlans.length) {
|
|
2653
|
+
// Compiler output from before provenance was recorded. Durable, inert, and
|
|
2654
|
+
// NOT treated as hand-adopted work: it was compiled for some particular
|
|
2655
|
+
// version and nobody knows which, so it cannot be version-independent.
|
|
2656
|
+
log(`goal : ${split.unknownPlans.length} compiled item(s) here predate version tracking — ` +
|
|
2657
|
+
'kept, but not worked by any version');
|
|
2658
|
+
}
|
|
2659
|
+
if (scope !== null && split.forged.length) {
|
|
2660
|
+
log(`goal : ${split.forged.length} item(s) here carry a compiler version but did not come from the ` +
|
|
2661
|
+
'compiler — ignoring them');
|
|
2662
|
+
}
|
|
2663
|
+
if (scope !== null && split.newerPlans.length) {
|
|
2664
|
+
/**
|
|
2665
|
+
* REFUSE, rather than work around it.
|
|
2666
|
+
*
|
|
2667
|
+
* Goal versions only ever go up, so a plan compiled for a LATER version is
|
|
2668
|
+
* durable evidence against the authority this run is acting under — a
|
|
2669
|
+
* rolled-back mirror, a Start that should have been refused, a hand-edited
|
|
2670
|
+
* queue. It should be unreachable: a Start behind the mirror is terminally
|
|
2671
|
+
* refused long before selection.
|
|
2672
|
+
*
|
|
2673
|
+
* Merely hiding it and working something else is the unsafe part. The
|
|
2674
|
+
* machine would be executing under an authority it has proof is stale,
|
|
2675
|
+
* which is exactly what the version rules exist to prevent — so this stops,
|
|
2676
|
+
* terminally, and touches nothing. The offending items are left exactly as
|
|
2677
|
+
* they are: not deleted, not un-owned, not rewritten.
|
|
2678
|
+
*/
|
|
2679
|
+
if (sync)
|
|
2680
|
+
await sync.stop();
|
|
2681
|
+
signals.uninstall();
|
|
2682
|
+
const versions = [...new Set(split.newerPlans.map((i) => i.compiledForGoalVersion))].join(', ');
|
|
2683
|
+
const superseded = `this machine holds work compiled for a LATER version of this goal (${versions}, and this Start says ` +
|
|
2684
|
+
`${binding.kind === 'bound' ? binding.goal.version : '?'}) — refusing to run against evidence that ` +
|
|
2685
|
+
'the goal has already moved on';
|
|
2686
|
+
leaving(superseded);
|
|
2687
|
+
await releaseHold();
|
|
2688
|
+
throw new RunRefused(superseded, 'goal_superseded');
|
|
2689
|
+
}
|
|
2690
|
+
/**
|
|
2691
|
+
* Whether this Start has already paid for a compilation.
|
|
2692
|
+
*
|
|
2693
|
+
* The adaptive decision below is only for a Start that arrived with an
|
|
2694
|
+
* EXISTING exhausted plan. A Start that compiled its first plan has already
|
|
2695
|
+
* spent ten minutes of compiler and up to thirty of verify against a
|
|
2696
|
+
* sixty-minute window, and adding an evaluation and a second compilation to
|
|
2697
|
+
* that would run past it — the command would be marked expired while the run
|
|
2698
|
+
* carried on unacked, which is the failure Phase 4.1 exists to prevent.
|
|
2699
|
+
*/
|
|
2700
|
+
let compiledThisRun = false;
|
|
2701
|
+
/**
|
|
2702
|
+
* Verification is budgeted for the WHOLE process, not per pass.
|
|
2703
|
+
*
|
|
2704
|
+
* There are now two passes on the adaptive path — the one below, and one over
|
|
2705
|
+
* the plan the adaptive decision may produce. `VERIFY_MAX_PER_RUN` was
|
|
2706
|
+
* written when there could only be one, so two passes each taking six drafts
|
|
2707
|
+
* would put sixty minutes of verifier inside a sixty-minute window. They draw
|
|
2708
|
+
* from one budget instead.
|
|
2709
|
+
*/
|
|
2710
|
+
let verifyBudget = verify_cmd_2.VERIFY_MAX_PER_RUN;
|
|
2711
|
+
/**
|
|
2712
|
+
* EVERYTHING A START DOES BEFORE THE RUN, under one guard.
|
|
2713
|
+
*
|
|
2714
|
+
* The compile, the verify passes and the adaptive decision all happen above
|
|
2715
|
+
* the try/finally that protects the run itself, so anything thrown by any of
|
|
2716
|
+
* them — a rejected agent invocation being the likely one — would leave this
|
|
2717
|
+
* process holding the lease and skip the teardown entirely. Recoverable,
|
|
2718
|
+
* because the next process finds the pid dead, but it makes an ordinary agent
|
|
2719
|
+
* failure look like a crash and wedges the tree until something notices.
|
|
2720
|
+
*
|
|
2721
|
+
* The guard was first put around the adaptive seam alone, which left its two
|
|
2722
|
+
* older neighbours exposed for exactly the same reason.
|
|
2723
|
+
*
|
|
2724
|
+
* Nothing here throws. What it would have thrown is carried to the teardown,
|
|
2725
|
+
* which releases the lease and reports it as retryable.
|
|
2726
|
+
*/
|
|
2727
|
+
/** Hoisted: both are read by the teardown, which sits outside the guard. */
|
|
2728
|
+
/**
|
|
2729
|
+
* M3-2 — THE COMPILER'S ANSWER IS CHECKED, AND THE COMPILE IS A RECORD.
|
|
2730
|
+
*
|
|
2731
|
+
* The prompt defines a step's evidence as a claim about code that already
|
|
2732
|
+
* exists, so every entry is re-read at the committed tree before the step
|
|
2733
|
+
* becomes an item: a refused step is logged with the claim beside the fact
|
|
2734
|
+
* and never queued. Admitted or refused, the compile is appended to
|
|
2735
|
+
* `compiles.jsonl` — who was asked (the provider's identity, read from the
|
|
2736
|
+
* thing that runs, as the identity note above does), with what (a digest,
|
|
2737
|
+
* never the prompt or the transcript), at which tree, what came back and
|
|
2738
|
+
* what was refused — BEFORE any item is minted. A record that cannot be
|
|
2739
|
+
* written is a log line, never a refused run.
|
|
2740
|
+
*/
|
|
2741
|
+
const compileReaders = (0, citation_readers_1.nodeCitationReaders)(cwd, stateDir);
|
|
2742
|
+
const compileProvider = (0, provider_identity_1.providerRecordFor)(resolved.spec, resolved.spec.command === agent_exec_1.DEFAULT_AGENT.command ? (0, agent_contained_1.claudePackageDir)() : null);
|
|
2743
|
+
const compiles = new compile_store_1.CompileStore(stateDir, ledger_store_1.nodeLedgerFs);
|
|
2744
|
+
const recordCompile = async (forGoalId, forGoalVersion, treeSha, outcome, checked) => {
|
|
2745
|
+
try {
|
|
2746
|
+
if (!compiles.isOpen())
|
|
2747
|
+
await compiles.open();
|
|
2748
|
+
await compiles.append((0, core_2.compileRecordFrom)({
|
|
2749
|
+
goalId: forGoalId,
|
|
2750
|
+
goalVersion: forGoalVersion,
|
|
2751
|
+
treeSha,
|
|
2752
|
+
requestDigest: outcome.requestDigest,
|
|
2753
|
+
provider: compileProvider,
|
|
2754
|
+
askedAt: new Date().toISOString(),
|
|
2755
|
+
answer: outcome.ok ? 'plan' : outcome.failure === 'unreadable' ? 'malformed' : 'no-answer',
|
|
2756
|
+
...(outcome.ok ? {} : { because: outcome.reason }),
|
|
2757
|
+
admitted: checked?.admitted.length ?? 0,
|
|
2758
|
+
refused: (checked?.refused ?? []).map((r) => ({ title: r.step.title, because: r.verdict.ok ? 'admitted' : r.verdict.because })),
|
|
2759
|
+
malformed: outcome.ok ? outcome.malformed : 0,
|
|
2760
|
+
dropped: outcome.ok ? outcome.dropped : 0,
|
|
2761
|
+
}));
|
|
2762
|
+
}
|
|
2763
|
+
catch (e) {
|
|
2764
|
+
log(`compile : the record could not be written — ${e.message}`);
|
|
2765
|
+
}
|
|
2766
|
+
};
|
|
2767
|
+
let pickable = 0;
|
|
2768
|
+
let adaptive = null;
|
|
2769
|
+
/**
|
|
2770
|
+
* D3's answer, computed ONCE when a `satisfied` decision lands.
|
|
2771
|
+
*
|
|
2772
|
+
* Held rather than recomputed because one of the two endings that render it
|
|
2773
|
+
* (`nothingPickableBecause`) is synchronous, and because asking the server
|
|
2774
|
+
* twice could give two answers to a question whose whole purpose is to be
|
|
2775
|
+
* decided once. Undefined until a `satisfied` decision exists, and undefined
|
|
2776
|
+
* for ever if this run never reaches one.
|
|
2777
|
+
*/
|
|
2778
|
+
let coverage;
|
|
2779
|
+
/**
|
|
2780
|
+
* What KEY 2 actually observed, kept from the call that established it.
|
|
2781
|
+
*
|
|
2782
|
+
* Null until KEY 2 turns, and null for ever if it never does — in which case
|
|
2783
|
+
* the fourth key is never asked either, so there is nothing to be missing.
|
|
2784
|
+
*/
|
|
2785
|
+
let key2Saw = null;
|
|
2786
|
+
/** The run whose round KEY 2 confirmed — an EARLIER Start's, not this one's (see `deliveringBranch`). */
|
|
2787
|
+
let deliveringRunId = null;
|
|
2788
|
+
// L4b: the commit key 2 confirmed HEAD on, beside the run that delivered it.
|
|
2789
|
+
let deliveringSha = null;
|
|
2790
|
+
/** F27: the tree this Start's evaluator judged, when a programme moved HEAD to the tree the lane holds. */
|
|
2791
|
+
let judged = { kind: 'as-is', because: 'no goal-met check ran' };
|
|
2792
|
+
/** The first compile's authority answer, kept so its reason is the acted-on one. */
|
|
2793
|
+
let stillOurs = { ok: true };
|
|
2794
|
+
let seamFailure = null;
|
|
2795
|
+
try {
|
|
2796
|
+
if (verdict.compile && scope !== null && binding.kind === 'bound') {
|
|
2797
|
+
compiledThisRun = true;
|
|
2798
|
+
log(`compile : ${binding.goal.text}`);
|
|
2799
|
+
const compiled = await (0, goal_compile_1.compileGoal)({
|
|
2800
|
+
goalId: binding.goal.id,
|
|
2801
|
+
goalVersion: binding.goal.version,
|
|
2802
|
+
goalText: binding.goal.text,
|
|
2803
|
+
projectId,
|
|
2804
|
+
projectName: config.project.name,
|
|
2805
|
+
/**
|
|
2806
|
+
* 🔴 THE POINT OF THE WHOLE PHASE, AT THE ONE LINE THAT MAKES IT REAL.
|
|
2807
|
+
* Before this, the compiler received five fields and none of them was
|
|
2808
|
+
* history, so a plan was recompiled from the goal text every time and
|
|
2809
|
+
* could propose an approach that had already failed three times.
|
|
2810
|
+
*
|
|
2811
|
+
* Read at the COMMITTED tree, so a content fact observed at a tree
|
|
2812
|
+
* that has since moved is not offered as current — the reader decides
|
|
2813
|
+
* currency, and this is where it is given what it needs to.
|
|
2814
|
+
*/
|
|
2815
|
+
memory: await memoryFor(memoryStore, git, { goalId: binding.goal.id }, await currentGraph()),
|
|
2816
|
+
// The task packet (M2-3), after memory: the requirements a person linked
|
|
2817
|
+
// this goal to, quoted as data with their standing, one server read per
|
|
2818
|
+
// linked source; an unanswerable source is a `missing` line, not a refusal.
|
|
2819
|
+
...(goalServer === null
|
|
2820
|
+
? {}
|
|
2821
|
+
: {
|
|
2822
|
+
packet: (await (0, packet_for_goal_1.packetForGoal)(binding.goal, (id) => goalServer.sources.sourceDetail(projectId, id),
|
|
2823
|
+
// F19: in a programme, what the OTHER goals own is named so this plan does none of it.
|
|
2824
|
+
(0, programme_mode_1.programmeIsOn)(config) ? goals.all() : [])).packet,
|
|
2825
|
+
// The checks a claim may cite: the gate's step names ARE the
|
|
2826
|
+
// acceptance map `produceClaims` holds `provenBy` against.
|
|
2827
|
+
checks: config.gates.map((g) => g.name),
|
|
2828
|
+
}),
|
|
2829
|
+
}, { invoke: planAgent });
|
|
2830
|
+
// M3-2: checked at the committed tree before any step becomes an item; the compile recorded either way.
|
|
2831
|
+
const compiledAt = await git.headTreeSha().catch(() => null);
|
|
2832
|
+
const checked = compiled.ok ? await (0, compile_check_1.checkCompiledPlan)(compiled.steps, compileReaders, compiledAt) : null;
|
|
2833
|
+
for (const r of checked?.refused ?? [])
|
|
2834
|
+
log(`compile : ${(0, compile_check_1.describeRefusal)(r)}`);
|
|
2835
|
+
await recordCompile(binding.goal.id, binding.goal.version, compiledAt, compiled, checked);
|
|
2836
|
+
if (!compiled.ok) {
|
|
2837
|
+
// Retryable, and nothing durable: a compiler that failed did not produce
|
|
2838
|
+
// a plan, so this version still has none and the next Start tries again.
|
|
2839
|
+
log(`compile : ${compiled.reason} — this run has nothing to work on.`);
|
|
2840
|
+
compileNote = `compile: ${compiled.reason} — this run has nothing to work on`;
|
|
2841
|
+
}
|
|
2842
|
+
else if (!compiled.steps.length && compiled.malformed > 0) {
|
|
2843
|
+
/**
|
|
2844
|
+
* NOT an empty plan: an UNREADABLE one.
|
|
2845
|
+
*
|
|
2846
|
+
* `extractPlan` drops steps it cannot read and carries on, so a
|
|
2847
|
+
* transcript of malformed objects arrives here as `ok: true` with no
|
|
2848
|
+
* steps and a non-zero `dropped`. That is indistinguishable from a
|
|
2849
|
+
* legitimate empty plan by the step count alone — and the difference
|
|
2850
|
+
* matters, because a receipt would durably spend revision 1 on an answer
|
|
2851
|
+
* nobody could read.
|
|
2852
|
+
*
|
|
2853
|
+
* Nothing is written, so the next Start compiles the same revision again,
|
|
2854
|
+
* which is what a retryable failure should do.
|
|
2855
|
+
*/
|
|
2856
|
+
log(`compile : the plan could not be read — ${compiled.malformed} step(s) were malformed. ` +
|
|
2857
|
+
'Nothing queued, and this goal still has no plan.');
|
|
2858
|
+
compileNote = `compile: the plan could not be read — ${compiled.malformed} step(s) were malformed`;
|
|
2859
|
+
}
|
|
2860
|
+
else if (checked !== null && !checked.admitted.length && checked.refused.length > 0) {
|
|
2861
|
+
// Every step made a claim about the repository that does not hold. Like a
|
|
2862
|
+
// plan that could not be read: nothing queued, no plan for this version,
|
|
2863
|
+
// and the next Start tries again.
|
|
2864
|
+
log(`compile : every step was refused — ${checked.refused.length} step(s) cited evidence that does not hold at the tree. ` +
|
|
2865
|
+
'Nothing queued, and this goal still has no plan.');
|
|
2866
|
+
compileNote = `compile: every step was refused — ${checked.refused.length} step(s) cited evidence that does not hold at the tree`;
|
|
2867
|
+
}
|
|
2868
|
+
else if (!(stillOurs = await freshAuthority(binding.goal.id, binding.goal.version, binding.goal.text)).ok) {
|
|
2869
|
+
/**
|
|
2870
|
+
* The goal moved while its FIRST plan was being compiled.
|
|
2871
|
+
*
|
|
2872
|
+
* The same ten-minute window the adaptive path guards, on the path that
|
|
2873
|
+
* had no guard at all. `binding` is resolved once, before the lease is
|
|
2874
|
+
* even taken, and the daemon rewrites goals.json on its own cadence
|
|
2875
|
+
* holding no lease — so a person who amends or abandons the goal three
|
|
2876
|
+
* minutes into a compile is invisible to a run that only remembers.
|
|
2877
|
+
*
|
|
2878
|
+
* Nothing is written. The version stamped on those steps would be one
|
|
2879
|
+
* nobody is pursuing, and a run that materialised them would then work
|
|
2880
|
+
* them for hours: `scope` still says the old version, so they classify
|
|
2881
|
+
* as current-plan, the verify pass promotes them, and the release
|
|
2882
|
+
* command for an abandoned goal cannot even land — it needs the lease
|
|
2883
|
+
* this run is holding.
|
|
2884
|
+
*
|
|
2885
|
+
* Asked ONCE and its answer kept: two reads could disagree, and the
|
|
2886
|
+
* reason a person is given must be the reason the run acted on.
|
|
2887
|
+
*/
|
|
2888
|
+
log(`compile : this goal moved while its plan was being made — ${stillOurs.reason}`);
|
|
2889
|
+
compileNote = `compile: this goal moved while its plan was being made — ${stillOurs.reason}`;
|
|
2890
|
+
}
|
|
2891
|
+
else {
|
|
2892
|
+
const now = Date.now();
|
|
2893
|
+
/**
|
|
2894
|
+
* The receipt goes in whatever the plan contained, INCLUDING nothing.
|
|
2895
|
+
*
|
|
2896
|
+
* An empty plan is a legitimate answer — the compiler read the goal and
|
|
2897
|
+
* had no steps to propose — and it used to leave no trace at all. So the
|
|
2898
|
+
* next Start found a version that owned nothing, `decideCompile` said
|
|
2899
|
+
* "compile", and it asked the same question again. For ever, once per
|
|
2900
|
+
* explicit Start, at ten minutes a time.
|
|
2901
|
+
*
|
|
2902
|
+
* The receipt is what makes "revision 1 happened" true independently of
|
|
2903
|
+
* what revision 1 contained. It applies to the FIRST plan for the same
|
|
2904
|
+
* reason it applies to the adaptive ones; leaving R1 out was the only
|
|
2905
|
+
* thing that made an empty first plan different from an empty second one.
|
|
2906
|
+
*/
|
|
2907
|
+
const firstSteps = checked?.admitted ?? [];
|
|
2908
|
+
/**
|
|
2909
|
+
* HOISTED so the claims can be attributed to the items these steps
|
|
2910
|
+
* became. `planToItems` maps one step to one item IN ORDER, and
|
|
2911
|
+
* `produceClaims` relies on that alignment; computing it inline would
|
|
2912
|
+
* have left nothing to align against.
|
|
2913
|
+
*/
|
|
2914
|
+
const firstItems = (0, goal_compile_1.planToItems)(firstSteps, {
|
|
2915
|
+
goalId: scope.goalId,
|
|
2916
|
+
goalVersion: binding.goal.version,
|
|
2917
|
+
projectId,
|
|
2918
|
+
orderKeyAt: (i) => (0, core_1.newId)('item', now + i + 1),
|
|
2919
|
+
});
|
|
2920
|
+
const written = await (0, goal_ownership_1.materialiseGoalPlan)(queue, scope.goalId, [
|
|
2921
|
+
(0, goal_compile_1.planMarker)({
|
|
2922
|
+
goalId: scope.goalId,
|
|
2923
|
+
goalVersion: binding.goal.version,
|
|
2924
|
+
revision: core_1.FIRST_PLAN_REVISION,
|
|
2925
|
+
projectId,
|
|
2926
|
+
orderKey: (0, core_1.newId)('item', now),
|
|
2927
|
+
}),
|
|
2928
|
+
// Time-ordered keys, unlike the ids: the loop works top-down, so order
|
|
2929
|
+
// is priority, and a derived id sorts arbitrarily.
|
|
2930
|
+
//
|
|
2931
|
+
// NOTE the ids here are unchanged. `planToItems` at revision 1 writes
|
|
2932
|
+
// no `compiledForPlanRevision` and derives through `compiledItemId`, so
|
|
2933
|
+
// a plan compiled before revisions existed and one compiled now are
|
|
2934
|
+
// byte-identical. Only the marker is new.
|
|
2935
|
+
...firstItems,
|
|
2936
|
+
]);
|
|
2937
|
+
/**
|
|
2938
|
+
* 🔴 NAME THE BRANCH'S GOAL WHERE THE LANE CAN READ IT.
|
|
2939
|
+
*
|
|
2940
|
+
* Written at materialisation, before any work, and written even when
|
|
2941
|
+
* this plan claims nothing: `coordinator-state.ts:341-347` needs a
|
|
2942
|
+
* `goalId` on the published attempt, and until this record existed the
|
|
2943
|
+
* only writer was a person typing `looply integrate --goal`. See
|
|
2944
|
+
* `goalOfBranch` for the deadlock that caused.
|
|
2945
|
+
*/
|
|
2946
|
+
(0, lane_claims_1.recordLaneClaim)(cwd, {
|
|
2947
|
+
schema: lane_claims_1.LANE_CLAIMS_SCHEMA,
|
|
2948
|
+
kind: 'claims',
|
|
2949
|
+
at: new Date(now).toISOString(),
|
|
2950
|
+
branch,
|
|
2951
|
+
runId,
|
|
2952
|
+
goalId: scope.goalId,
|
|
2953
|
+
goalVersion: binding.goal.version,
|
|
2954
|
+
planRevision: core_1.FIRST_PLAN_REVISION,
|
|
2955
|
+
servings: await claimsFor(firstSteps, firstItems, binding.goal.requirements ?? []),
|
|
2956
|
+
// F28: the plan lives here, so the goal's later Starts are placed here.
|
|
2957
|
+
workspace: cwd,
|
|
2958
|
+
});
|
|
2959
|
+
if (!written.ok) {
|
|
2960
|
+
log(`compile : ${written.problem}`);
|
|
2961
|
+
compileNote = `compile: ${written.problem}`;
|
|
2962
|
+
}
|
|
2963
|
+
else if (!compiled.steps.length) {
|
|
2964
|
+
log('compile : the plan came back empty — nothing to do for this goal.');
|
|
2965
|
+
compileNote = 'compile: the plan came back empty';
|
|
2966
|
+
}
|
|
2967
|
+
else {
|
|
2968
|
+
log(
|
|
2969
|
+
// Less one for the receipt, which is not a step and must not be
|
|
2970
|
+
// counted to a person as though it were work queued for them.
|
|
2971
|
+
`compile : ${Math.max(0, written.created - 1)} step(s) queued` +
|
|
2972
|
+
(written.alreadyThere ? `, ${written.alreadyThere} already here` : '') +
|
|
2973
|
+
(compiled.dropped ? `, ${compiled.dropped} trimmed` : '') +
|
|
2974
|
+
(checked?.refused.length ? `, ${checked.refused.length} refused` : ''));
|
|
2975
|
+
}
|
|
2976
|
+
}
|
|
2977
|
+
}
|
|
2978
|
+
// Drafts are checked here rather than refused. A scan writes items in
|
|
2979
|
+
// `draft` because a finding is a claim; a run picks only `ready`. Nothing
|
|
2980
|
+
// joined the two, so a freshly scanned project could not be run at all — the
|
|
2981
|
+
// refusal pointed at the phone, which is no help unpaired and wrong paired,
|
|
2982
|
+
// since marking an item ready by hand skips the very check that makes it
|
|
2983
|
+
// pickable. Verification is the cheapest agent work looply does, so a run
|
|
2984
|
+
// does it rather than sending the user away.
|
|
2985
|
+
// Read fresh rather than from the snapshot taken before recovery: an item
|
|
2986
|
+
// this run just put back is pickable, and counting the stale figure made a
|
|
2987
|
+
// recovered run report "nothing to do" about a queue it had just repaired.
|
|
2988
|
+
// The SCOPED queue, not the raw one. A goal-bound run counting every ready
|
|
2989
|
+
// item in the project would decide it had work when none of it was its own —
|
|
2990
|
+
// skipping the verify pass over its own drafts, and then handing the runner a
|
|
2991
|
+
// scoped queue with nothing pickable in it.
|
|
2992
|
+
pickable = runQueue.list().filter((i) => i.state === 'ready').length;
|
|
2993
|
+
if (!pickable) {
|
|
2994
|
+
// Read fresh: `all` was taken before recovery and before compilation, so it
|
|
2995
|
+
// does not contain the plan this run may have just written.
|
|
2996
|
+
const drafts = runQueue.list().filter((i) => i.state === 'draft');
|
|
2997
|
+
if (drafts.length) {
|
|
2998
|
+
log(`verify : ${drafts.length} unchecked item(s) — confirming each against the code before picking`);
|
|
2999
|
+
const pass = await (0, verify_pass_1.verifyDrafts)({
|
|
3000
|
+
queue: runQueue,
|
|
3001
|
+
ledger: ledgerForRun,
|
|
3002
|
+
verify: (0, verify_cmd_1.makeItemVerifier)(verifyAgent, queue),
|
|
3003
|
+
cachePut: (id, head, ev) => evidence.put(id, head, ev),
|
|
3004
|
+
headSha: () => git.headSha(),
|
|
3005
|
+
projectId,
|
|
3006
|
+
now: () => Date.now(),
|
|
3007
|
+
onVerdict: (item, outcome) => {
|
|
3008
|
+
const mark = outcome.verdict === 'confirmed' || outcome.verdict === 'amended' ? 'ready' : outcome.verdict;
|
|
3009
|
+
log(`verify : ${mark.padEnd(9)} ${item.title.slice(0, 68)}`);
|
|
3010
|
+
},
|
|
3011
|
+
},
|
|
3012
|
+
// Bounded, because the whole pass happens BEFORE `run.started` — which
|
|
3013
|
+
// is the only thing that acks the start-run that asked for this run,
|
|
3014
|
+
// and that command expires an hour after it was issued. Sequential at
|
|
3015
|
+
// up to five minutes a draft, an uncapped pass over enough of them
|
|
3016
|
+
// spends the entire window and the command is marked expired while the
|
|
3017
|
+
// run carries on unacked. Drafts past the cap are not lost; the next
|
|
3018
|
+
// run checks them.
|
|
3019
|
+
{ max: verifyBudget });
|
|
3020
|
+
verifyBudget -= pass.checked;
|
|
3021
|
+
log(`verify : ${pass.ready} of ${pass.checked} are ready to work on`);
|
|
3022
|
+
pickable = pass.ready;
|
|
3023
|
+
}
|
|
3024
|
+
}
|
|
3025
|
+
/**
|
|
3026
|
+
* THE ADAPTIVE DECISION, and the only place it can live.
|
|
3027
|
+
*
|
|
3028
|
+
* Below this, the block that reports "nothing to do" has already stopped the
|
|
3029
|
+
* sync, uninstalled the signal handlers and RELEASED THE LEASE — it is
|
|
3030
|
+
* teardown, not a decision point. Anything that evaluates a goal or writes a
|
|
3031
|
+
* plan has to happen while the tree is still held, which is here: after the
|
|
3032
|
+
* verify pass has had its say about the drafts, and before any of that is
|
|
3033
|
+
* given back.
|
|
3034
|
+
*
|
|
3035
|
+
* `pickable` is re-tested rather than assumed. Line above closes the verify
|
|
3036
|
+
* block, and a pass that promoted a draft leaves work to do — this must not
|
|
3037
|
+
* fire on a run that has something to get on with.
|
|
3038
|
+
*
|
|
3039
|
+
* ONE START, AT MOST ONE RUN: this may produce one more plan for THIS run to
|
|
3040
|
+
* work, and it is never reached again afterwards. `runner.run()` returns to a
|
|
3041
|
+
* `finally` that does nothing but clean up.
|
|
3042
|
+
*/
|
|
3043
|
+
if (!pickable && scope !== null && binding.kind === 'bound' && !compiledThisRun) {
|
|
3044
|
+
const bound = binding.goal;
|
|
3045
|
+
// Captured, because `scope` moves when a new plan is materialised below and
|
|
3046
|
+
// the callbacks here must go on describing the goal they were built for.
|
|
3047
|
+
const goalId = scope.goalId;
|
|
3048
|
+
const owned = queue.list();
|
|
3049
|
+
const revision = (0, goal_ownership_1.currentPlanRevision)(owned, scope.goalId, bound.version);
|
|
3050
|
+
// Only for a version that already HAS a plan. With none, `decideCompile`
|
|
3051
|
+
// would have compiled one above, and there is nothing to have been
|
|
3052
|
+
// insufficient.
|
|
3053
|
+
if (revision > 0) {
|
|
3054
|
+
log(`goal : plan ${revision} has nothing left to work on — checking whether the goal is met`);
|
|
3055
|
+
/**
|
|
3056
|
+
* The scope KEY 2 is asked about, built HERE and used below.
|
|
3057
|
+
*
|
|
3058
|
+
* `scope` at this point still has no `planRevision` — it is only assigned
|
|
3059
|
+
* when a new plan is materialised, sixty lines down. Passing it to the
|
|
3060
|
+
* completion check made that check revision-BLIND: `classifyForScope`
|
|
3061
|
+
* treats a scope without a revision as "every plan for this version is
|
|
3062
|
+
* current", so revision 1's delivery could answer for revision 2. The
|
|
3063
|
+
* unit tests could not see it, because they supplied the revision the
|
|
3064
|
+
* composition root did not.
|
|
3065
|
+
*/
|
|
3066
|
+
const askedScope = { ...scope, planRevision: revision };
|
|
3067
|
+
/**
|
|
3068
|
+
* F27: IN A PROGRAMME WHOSE LANE INTEGRATES, JUDGE THE TREE THE LANE HOLDS for this
|
|
3069
|
+
* goal's delivered work — the delivered round, the lane's replay of it, or the moved
|
|
3070
|
+
* base it could not be carried onto (`goal-met-target.ts`). Only in a checkout looply
|
|
3071
|
+
* made: moving HEAD in a person's own checkout is not this run's to do.
|
|
3072
|
+
*/
|
|
3073
|
+
judged = { kind: 'as-is', because: 'not a programme workspace' };
|
|
3074
|
+
// F33: a checkout LOOPLY MADE (its provisioning marker), not merely any linked worktree — a person's
|
|
3075
|
+
// own `git worktree add` checkout inherits the tracked config and must never have its HEAD moved.
|
|
3076
|
+
if ((0, programme_mode_1.programmeIsOn)(config) && config.run.integration === true && (0, reaper_1.hasProvisionedMarker)(cwd)) {
|
|
3077
|
+
judged = await (0, goal_met_target_1.goalMetTarget)({ cwd, git, stateDir, scope: askedScope, items: owned });
|
|
3078
|
+
if (judged.kind !== 'as-is' && judged.sha !== (await git.headSha())) {
|
|
3079
|
+
if ((await git.changedPaths()).length > 0) {
|
|
3080
|
+
judged = { kind: 'as-is', because: 'the working tree has uncommitted changes' };
|
|
3081
|
+
}
|
|
3082
|
+
else {
|
|
3083
|
+
(0, goal_met_target_1.writeJudgingMarker)(stateDir, { runId, sha: judged.sha });
|
|
3084
|
+
await git.switchDetached(judged.sha);
|
|
3085
|
+
}
|
|
3086
|
+
}
|
|
3087
|
+
log(`goal : judging ${(0, goal_met_target_1.describeTarget)(judged)}`);
|
|
3088
|
+
}
|
|
3089
|
+
adaptive = await (0, goal_adaptive_1.decideAdaptive)({
|
|
3090
|
+
goalId: scope.goalId,
|
|
3091
|
+
goalVersion: bound.version,
|
|
3092
|
+
goalText: bound.text,
|
|
3093
|
+
projectId,
|
|
3094
|
+
projectName: config.project.name,
|
|
3095
|
+
currentRevision: revision,
|
|
3096
|
+
// The same packet and checks the first compile was given, so a
|
|
3097
|
+
// revision can claim what it delivers (see `AdaptiveInput.packet`).
|
|
3098
|
+
...(goalServer === null
|
|
3099
|
+
? {}
|
|
3100
|
+
: {
|
|
3101
|
+
packet: (await (0, packet_for_goal_1.packetForGoal)(bound, (id) => goalServer.sources.sourceDetail(projectId, id), (0, programme_mode_1.programmeIsOn)(config) ? goals.all() : [])).packet,
|
|
3102
|
+
checks: config.gates.map((g) => g.name),
|
|
3103
|
+
}),
|
|
3104
|
+
/**
|
|
3105
|
+
* Receipts filtered out — by DERIVED ID, not by tag.
|
|
3106
|
+
*
|
|
3107
|
+
* They are metadata about a plan having happened, not steps anybody
|
|
3108
|
+
* attempted, and listing "[closed] plan 2 for this goal" among the
|
|
3109
|
+
* work invites the model to reason about a row that means nothing.
|
|
3110
|
+
*
|
|
3111
|
+
* This was written as `tag !== 'goal' || state !== 'closed'` and
|
|
3112
|
+
* that was a real defect, the second of its kind: `'goal'` is the
|
|
3113
|
+
* compiler's default tag for an untagged step, and a step that
|
|
3114
|
+
* spends its last attempt becomes `closed`. So an untagged step
|
|
3115
|
+
* that FAILED three times was hidden from the evaluator, which then
|
|
3116
|
+
* had no reason not to say the goal was met — and seeing what the
|
|
3117
|
+
* plan actually did is the evaluator's whole job.
|
|
3118
|
+
*/
|
|
3119
|
+
planItems: (0, goal_ownership_1.scopedQueue)(queue, askedScope)
|
|
3120
|
+
.list()
|
|
3121
|
+
.filter((i) => !(0, goal_compile_1.isPlanMarker)(i)),
|
|
3122
|
+
}, {
|
|
3123
|
+
evaluate: (i) => (0, goal_gap_1.evaluateGoalGap)(i, { invoke: planAgent }),
|
|
3124
|
+
compile: (i, maxSteps) => (0, goal_compile_1.compileGoal)(i, { invoke: planAgent, maxSteps }),
|
|
3125
|
+
changedPaths: () => git.changedPaths(),
|
|
3126
|
+
headSha: () => git.headSha(),
|
|
3127
|
+
/**
|
|
3128
|
+
* KEY 2, against the CURRENT revision and the FINAL commit.
|
|
3129
|
+
*
|
|
3130
|
+
* The rounds are read from the ledger at the moment the question is
|
|
3131
|
+
* asked, not carried from earlier in the run: an answer about
|
|
3132
|
+
* "was this delivered" must be about what is durably written down.
|
|
3133
|
+
*/
|
|
3134
|
+
confirmSatisfied: async (head) => {
|
|
3135
|
+
const read = await (0, round_records_1.readRoundRecords)(stateDir, ledger_store_1.nodeLedgerFs);
|
|
3136
|
+
// A ledger that could not be read proves nothing EITHER WAY, and
|
|
3137
|
+
// since a KEY 2 refusal is terminal it must not be reported as
|
|
3138
|
+
// "no round delivered any of this plan". `unknown` keeps it
|
|
3139
|
+
// retryable and says what actually happened.
|
|
3140
|
+
if (!read.ok)
|
|
3141
|
+
return { ok: false, reason: read.because, unknown: true };
|
|
3142
|
+
const confirmed = (0, goal_execution_evidence_1.confirmGoalExecutionEvidence)({
|
|
3143
|
+
scope: askedScope,
|
|
3144
|
+
items: owned,
|
|
3145
|
+
rounds: read.rounds,
|
|
3146
|
+
// F27: a carried commit is confirmed as the round the lane carried.
|
|
3147
|
+
currentHead: judged.kind === 'carried' ? judged.of : head,
|
|
3148
|
+
requiredSteps: (0, gate_1.requiredSteps)(config.gates),
|
|
3149
|
+
});
|
|
3150
|
+
/**
|
|
3151
|
+
* 🔴 WHAT KEY 2 SAW, KEPT FOR THE FOURTH KEY.
|
|
3152
|
+
*
|
|
3153
|
+
* Per-requirement coverage asks two further questions — was the
|
|
3154
|
+
* item claiming a requirement in this delivery, and did the gate
|
|
3155
|
+
* step it names pass at this tree. Both are things this call has
|
|
3156
|
+
* already established, and re-deriving them below would be a
|
|
3157
|
+
* second answer that can disagree with the one the completion
|
|
3158
|
+
* claim rests on.
|
|
3159
|
+
*/
|
|
3160
|
+
if (confirmed.ok) {
|
|
3161
|
+
deliveringRunId = confirmed.round.runId;
|
|
3162
|
+
deliveringSha = confirmed.round.sha ?? null;
|
|
3163
|
+
key2Saw = {
|
|
3164
|
+
delivered: new Set(confirmed.delivered),
|
|
3165
|
+
checks: new Map((confirmed.round.gate?.steps ?? []).map((st) => [
|
|
3166
|
+
st.step,
|
|
3167
|
+
// A `when:` filter that matched nothing is SKIPPED, which
|
|
3168
|
+
// is a different answer from passing — and the whole
|
|
3169
|
+
// reason exit 0 cannot be taken for coverage.
|
|
3170
|
+
st.skipped === true ? 'skipped' : st.ok ? 'passed' : 'failed',
|
|
3171
|
+
])),
|
|
3172
|
+
};
|
|
3173
|
+
}
|
|
3174
|
+
return confirmed.ok ? { ok: true } : { ok: false, reason: confirmed.because };
|
|
3175
|
+
},
|
|
3176
|
+
stillAuthoritative: () => freshAuthority(goalId, bound.version, bound.text),
|
|
3177
|
+
});
|
|
3178
|
+
// M3-2: the adaptive plan's answer is checked and recorded the same way; a plan
|
|
3179
|
+
// with every step refused is no decision, not an empty revision.
|
|
3180
|
+
let adaptiveChecked = null;
|
|
3181
|
+
if (adaptive.kind === 'planned') {
|
|
3182
|
+
const adaptiveAt = await git.headTreeSha().catch(() => null);
|
|
3183
|
+
adaptiveChecked = adaptive.steps.ok ? await (0, compile_check_1.checkCompiledPlan)(adaptive.steps.steps, compileReaders, adaptiveAt) : null;
|
|
3184
|
+
for (const r of adaptiveChecked?.refused ?? [])
|
|
3185
|
+
log(`goal : ${(0, compile_check_1.describeRefusal)(r)}`);
|
|
3186
|
+
await recordCompile(scope.goalId, bound.version, adaptiveAt, adaptive.steps, adaptiveChecked);
|
|
3187
|
+
if (adaptiveChecked !== null && !adaptiveChecked.admitted.length && adaptiveChecked.refused.length > 0) {
|
|
3188
|
+
log(`goal : every step of plan ${adaptive.revision} was refused — ${adaptiveChecked.refused.length} step(s) cited evidence ` +
|
|
3189
|
+
'that does not hold at the tree; nothing queued');
|
|
3190
|
+
adaptive = { kind: 'no-decision', reason: 'every step of the adaptive plan was refused' };
|
|
3191
|
+
}
|
|
3192
|
+
}
|
|
3193
|
+
/**
|
|
3194
|
+
* D3 — asked HERE, where the decision is, and only for `satisfied`.
|
|
3195
|
+
*
|
|
3196
|
+
* Every other kind already refuses for its own reason, and asking the
|
|
3197
|
+
* server about coverage for a goal that is not claiming to be done
|
|
3198
|
+
* would be a round-trip nobody reads.
|
|
3199
|
+
*/
|
|
3200
|
+
if (adaptive.kind === 'satisfied') {
|
|
3201
|
+
/**
|
|
3202
|
+
* ⚠️ DEFERRED TO THE LANE WHEN THE LANE WILL JUDGE (F25). In programme mode with
|
|
3203
|
+
* integration, the daemon's third key judges strict coverage on the integrated tree,
|
|
3204
|
+
* from the lane's receipts — the acceptance authority. Judging it here, on this
|
|
3205
|
+
* checkout, read a receipt table nothing writes and blocked every goal. Anywhere
|
|
3206
|
+
* the lane does not judge, the worker still does.
|
|
3207
|
+
*/
|
|
3208
|
+
coverage =
|
|
3209
|
+
(0, programme_mode_1.programmeIsOn)(config) && config.run.integration === true && config.machine?.integrate === true
|
|
3210
|
+
? undefined
|
|
3211
|
+
: await coverageNow();
|
|
3212
|
+
/**
|
|
3213
|
+
* 🔴 WHAT THIS DELIVERY FINISHED, FOR THE LANE TO JUDGE LATER.
|
|
3214
|
+
*
|
|
3215
|
+
* The claims say what each item was FOR; this says which of them the
|
|
3216
|
+
* delivery actually finished, and at which tip. The lane cannot
|
|
3217
|
+
* re-derive either: no commit trailer records an item id, and the run
|
|
3218
|
+
* registry is released on a clean exit. It is written here because
|
|
3219
|
+
* this is where KEY 2 has already answered both, and a second
|
|
3220
|
+
* derivation could disagree with the one the completion claim rests on.
|
|
3221
|
+
*
|
|
3222
|
+
* ⚠️ IT IS PROVENANCE, NOT ACCEPTANCE. `coverage` above is this
|
|
3223
|
+
* worker's own verdict about its own checkout; the lane throws that
|
|
3224
|
+
* away and recomputes from its own gate. What travels is the claim.
|
|
3225
|
+
*/
|
|
3226
|
+
await recordDelivery(scope, bound.version);
|
|
3227
|
+
}
|
|
3228
|
+
if (adaptive.kind === 'planned') {
|
|
3229
|
+
const at = Date.now();
|
|
3230
|
+
const next = adaptive.revision;
|
|
3231
|
+
const nextSteps = adaptiveChecked?.admitted ?? [];
|
|
3232
|
+
// Hoisted for the same reason as the first plan's: the claims are
|
|
3233
|
+
// attributed by index to the items these steps became.
|
|
3234
|
+
const nextItems = (0, goal_compile_1.planToItems)(nextSteps, {
|
|
3235
|
+
goalId: scope.goalId,
|
|
3236
|
+
goalVersion: bound.version,
|
|
3237
|
+
projectId,
|
|
3238
|
+
orderKeyAt: (i) => (0, core_1.newId)('item', at + i + 1),
|
|
3239
|
+
planRevision: next,
|
|
3240
|
+
});
|
|
3241
|
+
const written = await (0, goal_ownership_1.materialiseGoalPlan)(queue, scope.goalId, [
|
|
3242
|
+
// The receipt first, so a revision that produced no steps is still
|
|
3243
|
+
// durable and still spends its place in the budget.
|
|
3244
|
+
(0, goal_compile_1.planMarker)({
|
|
3245
|
+
goalId: scope.goalId,
|
|
3246
|
+
goalVersion: bound.version,
|
|
3247
|
+
revision: next,
|
|
3248
|
+
projectId,
|
|
3249
|
+
orderKey: (0, core_1.newId)('item', at),
|
|
3250
|
+
}),
|
|
3251
|
+
...nextItems,
|
|
3252
|
+
]);
|
|
3253
|
+
// One record per plan revision, for the reason the first one is
|
|
3254
|
+
// written: the branch's goal must be nameable from the ledger
|
|
3255
|
+
// whatever this revision turns out to contain.
|
|
3256
|
+
(0, lane_claims_1.recordLaneClaim)(cwd, {
|
|
3257
|
+
schema: lane_claims_1.LANE_CLAIMS_SCHEMA,
|
|
3258
|
+
kind: 'claims',
|
|
3259
|
+
at: new Date(at).toISOString(),
|
|
3260
|
+
branch,
|
|
3261
|
+
runId,
|
|
3262
|
+
goalId: scope.goalId,
|
|
3263
|
+
goalVersion: bound.version,
|
|
3264
|
+
planRevision: next,
|
|
3265
|
+
servings: await claimsFor(nextSteps, nextItems, bound.requirements ?? []),
|
|
3266
|
+
workspace: cwd,
|
|
3267
|
+
});
|
|
3268
|
+
if (!written.ok) {
|
|
3269
|
+
log(`goal : ${written.problem}`);
|
|
3270
|
+
}
|
|
3271
|
+
else {
|
|
3272
|
+
log(`goal : plan ${next} — ${written.created - 1} step(s) queued`);
|
|
3273
|
+
// The run now works the NEW plan, so its scope moves with it;
|
|
3274
|
+
// otherwise its own fresh rows would read as a revision it has never
|
|
3275
|
+
// heard of, which is a contradiction it would refuse on.
|
|
3276
|
+
scope = { ...scope, planRevision: next };
|
|
3277
|
+
runQueue = (0, goal_ownership_1.scopedQueue)(queue, scope);
|
|
3278
|
+
const drafts = runQueue.list().filter((i) => i.state === 'draft');
|
|
3279
|
+
if (drafts.length && verifyBudget > 0) {
|
|
3280
|
+
const pass = await (0, verify_pass_1.verifyDrafts)({
|
|
3281
|
+
queue: runQueue,
|
|
3282
|
+
ledger: ledgerForRun,
|
|
3283
|
+
verify: (0, verify_cmd_1.makeItemVerifier)(verifyAgent, queue),
|
|
3284
|
+
cachePut: (id, head, ev) => evidence.put(id, head, ev),
|
|
3285
|
+
headSha: () => git.headSha(),
|
|
3286
|
+
projectId,
|
|
3287
|
+
now: () => Date.now(),
|
|
3288
|
+
onVerdict: (item, outcome) => {
|
|
3289
|
+
const mark = outcome.verdict === 'confirmed' || outcome.verdict === 'amended' ? 'ready' : outcome.verdict;
|
|
3290
|
+
log(`verify : ${mark.padEnd(9)} ${item.title.slice(0, 68)}`);
|
|
3291
|
+
},
|
|
3292
|
+
}, { max: verifyBudget });
|
|
3293
|
+
verifyBudget -= pass.checked;
|
|
3294
|
+
log(`verify : ${pass.ready} of ${pass.checked} are ready to work on`);
|
|
3295
|
+
pickable = pass.ready;
|
|
3296
|
+
}
|
|
3297
|
+
}
|
|
3298
|
+
}
|
|
3299
|
+
}
|
|
3300
|
+
}
|
|
3301
|
+
}
|
|
3302
|
+
catch (err) {
|
|
3303
|
+
seamFailure = err instanceof Error ? err.message : String(err);
|
|
3304
|
+
log(`goal : the goal check could not be completed — ${seamFailure}`);
|
|
3305
|
+
}
|
|
3306
|
+
/**
|
|
3307
|
+
* M6-16: WHY A RUN WITH NOTHING PICKABLE IS LEAVING — composed from what is
|
|
3308
|
+
* already decided (the seam, the adaptive verdict, the work set) in the same
|
|
3309
|
+
* order the exits below render it, because the hold is released at the top
|
|
3310
|
+
* of that block and the ending is written there, before any of them.
|
|
3311
|
+
*/
|
|
3312
|
+
const nothingPickableBecause = () => {
|
|
3313
|
+
if (seamFailure !== null)
|
|
3314
|
+
return `could not check this goal — ${seamFailure}`;
|
|
3315
|
+
const said = adaptive === null ? null : (0, goal_adaptive_1.describeAdaptive)(adaptive, attestedNow(), coverage);
|
|
3316
|
+
if (said !== null)
|
|
3317
|
+
return said.message;
|
|
3318
|
+
const left = scope === null ? null : (0, core_1.workLeft)(runQueue.list());
|
|
3319
|
+
if (left === 'finished')
|
|
3320
|
+
return `this goal's work is done — ${(0, queue_summary_1.describeNoReady)(runQueue.list())}`;
|
|
3321
|
+
if (left === 'stalled')
|
|
3322
|
+
return `nothing here can move without you — ${(0, queue_summary_1.describeNoReady)(runQueue.list())}`;
|
|
3323
|
+
return `nothing to do — ${(0, queue_summary_1.describeNoReady)(runQueue.list())}`;
|
|
3324
|
+
};
|
|
3325
|
+
if (!pickable) {
|
|
3326
|
+
if (sync)
|
|
3327
|
+
await sync.stop();
|
|
3328
|
+
signals.uninstall();
|
|
3329
|
+
// This path returns before the try/finally below, so the lease has to be
|
|
3330
|
+
// given back here too. Forgetting it would wedge the tree until the next
|
|
3331
|
+
// process noticed the pid was dead — recoverable, but for no reason.
|
|
3332
|
+
leaving(nothingPickableBecause());
|
|
3333
|
+
// F31 (corr-12): named BEFORE the slot is given back — the lookup's fallback reads this checkout's
|
|
3334
|
+
// branch, and once it is released another run may already be cutting its own here.
|
|
3335
|
+
const deliveredOn = await deliveringBranch();
|
|
3336
|
+
await releaseHold();
|
|
3337
|
+
/**
|
|
3338
|
+
* THE COMPLETION FENCE.
|
|
3339
|
+
*
|
|
3340
|
+
* Every terminal conclusion this pursuit can reach is below: the goal was
|
|
3341
|
+
* met, its work is finished, or nothing here can move without a person.
|
|
3342
|
+
* All three end the pursuit, and none of them may be reached while somebody
|
|
3343
|
+
* still owes an answer.
|
|
3344
|
+
*
|
|
3345
|
+
* WHY EVEN A DENIAL FENCES IT. A refusal that no run has been told about
|
|
3346
|
+
* yet is a fact the goal has not had a chance to plan around. Concluding
|
|
3347
|
+
* "met" or "finished" on the strength of work done BEFORE that answer
|
|
3348
|
+
* existed would close a pursuit on a question it never heard — and the
|
|
3349
|
+
* whole point of resuming is that the answer changes what is worth doing.
|
|
3350
|
+
* Once a resumed run has picked it up, it stops fencing anything.
|
|
3351
|
+
*
|
|
3352
|
+
* READ FRESH, HERE, and not carried from the top of the run. A run lasts
|
|
3353
|
+
* hours; the person may have answered during it, and a conclusion drawn
|
|
3354
|
+
* from a reading taken before the first round would be about a world that
|
|
3355
|
+
* has moved.
|
|
3356
|
+
*
|
|
3357
|
+
* A PLAIN ERROR, AND THAT IS THE LOAD-BEARING PART. Every `RunRefusalCode`
|
|
3358
|
+
* is terminal — `isContinuableRefusal` returns false for all seven — so
|
|
3359
|
+
* refusing here would settle the person's grant because they had not yet
|
|
3360
|
+
* answered a question. Retryable is the truth: the next Start, once they
|
|
3361
|
+
* have, may conclude anything it likes.
|
|
3362
|
+
*/
|
|
3363
|
+
if (binding.kind === 'bound' && goalServer !== null) {
|
|
3364
|
+
const fence = await (0, approval_gate_read_1.readApprovalGate)(goalServer.gate, {
|
|
3365
|
+
projectId,
|
|
3366
|
+
goalId: binding.goal.id,
|
|
3367
|
+
goalVersion: binding.goal.version,
|
|
3368
|
+
now: Date.now(),
|
|
3369
|
+
consumed: (ids) => resumes.consumed(ids),
|
|
3370
|
+
});
|
|
3371
|
+
const waiting = (0, core_1.approvalHold)(fence);
|
|
3372
|
+
if (waiting !== null) {
|
|
3373
|
+
log(`action : not calling this goal done — ${waiting}`);
|
|
3374
|
+
await releaseHold();
|
|
3375
|
+
throw new Error(`this goal is waiting on you — ${waiting}`);
|
|
3376
|
+
}
|
|
3377
|
+
}
|
|
3378
|
+
/**
|
|
3379
|
+
* "Nothing to do" was one message and one retryable failure, and it was
|
|
3380
|
+
* hiding three different situations.
|
|
3381
|
+
*
|
|
3382
|
+
* For a goal-bound run it now says which. A goal whose work is FINISHED, or
|
|
3383
|
+
* whose work can no longer move on this machine, will answer the same way
|
|
3384
|
+
* for ever — so a retryable failure buys a start-run offered again every
|
|
3385
|
+
* three seconds until it silently expires an hour later, and the person who
|
|
3386
|
+
* tapped Start is told nothing at all. Terminal is the truthful answer, and
|
|
3387
|
+
* it reaches the phone as a refusal with a reason rather than as silence.
|
|
3388
|
+
*
|
|
3389
|
+
* `working` and `none` stay retryable, because for those the next attempt
|
|
3390
|
+
* really might find something: a draft still to be verified, or a plan not
|
|
3391
|
+
* yet compiled.
|
|
3392
|
+
*
|
|
3393
|
+
* Deliberately NOT a goal state. Nothing is written anywhere — this is
|
|
3394
|
+
* derived from the goal's own slice of the queue, every time it is asked,
|
|
3395
|
+
* so it cannot go stale and there is no new column to keep true.
|
|
3396
|
+
*/
|
|
3397
|
+
/**
|
|
3398
|
+
* What the adaptive decision concluded, said in a way that reaches a person.
|
|
3399
|
+
*
|
|
3400
|
+
* These are TERMINAL and produce no run, which is the whole point: the
|
|
3401
|
+
* command is refused with a reason rather than acked `done`, because `done`
|
|
3402
|
+
* on a start-run means a run began and nothing else. The reason travels to
|
|
3403
|
+
* the phone through the transport Phase 6A3 built.
|
|
3404
|
+
*/
|
|
3405
|
+
// Retryable, and reported rather than swallowed.
|
|
3406
|
+
//
|
|
3407
|
+
// "Nothing durable was written" would be the tidy thing to say and is not
|
|
3408
|
+
// always true: a throw AFTER `materialiseGoalPlan` leaves the revision on
|
|
3409
|
+
// disk. That is safe — the write is one whole-file rename and the next
|
|
3410
|
+
// Start converges on the same rows — but the message should not claim more
|
|
3411
|
+
// than it knows, so it says what happened rather than what it hopes.
|
|
3412
|
+
if (seamFailure !== null) {
|
|
3413
|
+
await releaseHold();
|
|
3414
|
+
throw new Error(`could not check this goal — ${seamFailure}`);
|
|
3415
|
+
}
|
|
3416
|
+
if (adaptive !== null) {
|
|
3417
|
+
const said = (0, goal_adaptive_1.describeAdaptive)(adaptive, attestedNow(), coverage);
|
|
3418
|
+
// `null` is the compiled-a-plan case, which is not an outcome: the run
|
|
3419
|
+
// went on to work it, and only got here because the plan produced nothing
|
|
3420
|
+
// pickable. The work-set facts below say which nothing that is.
|
|
3421
|
+
if (said !== null) {
|
|
3422
|
+
// The decision's own code travels with it. Nothing here re-derives it.
|
|
3423
|
+
//
|
|
3424
|
+
// 🔴 AND IT RELEASED NOTHING. This exit was invisible to the source pin
|
|
3425
|
+
// that guards the other eleven, because that pin looked for `throw new`
|
|
3426
|
+
// and this is `throw <ternary>`. Widening the pin found it immediately —
|
|
3427
|
+
// which is the argument for structural rules over enumerated ones.
|
|
3428
|
+
// L4b: what key 2 saw, when this is the met-goal refusal and key 2 answered.
|
|
3429
|
+
// Computed BEFORE the release, so the throw stays beside `releaseHold()`.
|
|
3430
|
+
// An ARROW, for the reason `coverageNow` gives: these three are filled
|
|
3431
|
+
// inside a callback, so the straight-line body would narrow them to null.
|
|
3432
|
+
const deliveryNow = () => deliveringRunId !== null && deliveringSha !== null && key2Saw !== null
|
|
3433
|
+
? { runId: deliveringRunId, sha: deliveringSha, checks: Object.fromEntries(key2Saw.checks) }
|
|
3434
|
+
: undefined;
|
|
3435
|
+
const delivery = said.terminal && said.code === 'goal_satisfied' ? deliveryNow() : undefined;
|
|
3436
|
+
await releaseHold();
|
|
3437
|
+
// The branch the controller will ask the lane about: the one that delivered (F20).
|
|
3438
|
+
const refusedOn = deliveredOn;
|
|
3439
|
+
throw said.terminal ? new RunRefused(said.message, said.code, refusedOn, delivery) : new Error(said.message);
|
|
3440
|
+
}
|
|
3441
|
+
}
|
|
3442
|
+
const left = scope === null ? null : (0, core_1.workLeft)(runQueue.list());
|
|
3443
|
+
if (left === 'finished') {
|
|
3444
|
+
// A WORK-SET fact, not a goal verdict: every row reached a terminal state.
|
|
3445
|
+
// Deliberately NOT `goal_satisfied` — that requires both keys, and this
|
|
3446
|
+
// path never asked the second one.
|
|
3447
|
+
await releaseHold();
|
|
3448
|
+
throw new RunRefused(`this goal's work is done — ${(0, queue_summary_1.describeNoReady)(runQueue.list())}`, 'run_refused');
|
|
3449
|
+
}
|
|
3450
|
+
if (left === 'stalled') {
|
|
3451
|
+
await releaseHold();
|
|
3452
|
+
throw new RunRefused(`nothing here can move without you — ${(0, queue_summary_1.describeNoReady)(runQueue.list())}`, 'human_required');
|
|
3453
|
+
}
|
|
3454
|
+
await releaseHold();
|
|
3455
|
+
throw new Error(`nothing to do — ${(0, queue_summary_1.describeNoReady)(runQueue.list())}`);
|
|
3456
|
+
}
|
|
3457
|
+
/**
|
|
3458
|
+
* THE APPROVAL BOUNDARY, AND THE ONLY PLACE IT COULD LIVE.
|
|
3459
|
+
*
|
|
3460
|
+
* Everything a request for human authority needs is known here and nowhere
|
|
3461
|
+
* else: which project the server registered, which goal this run is for, at
|
|
3462
|
+
* which version, which run is asking, and — inside the round — which item it
|
|
3463
|
+
* was working. The runner has none of it, the tool bridge has none of it, and
|
|
3464
|
+
* the model must never have any of it.
|
|
3465
|
+
*
|
|
3466
|
+
* `halt` is the one bit of state the run keeps about this. It is armed once,
|
|
3467
|
+
* before the model turn returns to the round protocol, and read by the runner
|
|
3468
|
+
* through a dep that cannot tell an approval from any other reason to stop.
|
|
3469
|
+
*
|
|
3470
|
+
* ─────────────────────────────────────────────────────────────────────────
|
|
3471
|
+
* THE CONTROL PLANE THIS RUN MAY FILE A QUESTION WITH, OR NOTHING.
|
|
3472
|
+
*
|
|
3473
|
+
* Both halves are required and the second is the subtle one. `goalServer` is
|
|
3474
|
+
* built from the machine's own credentials and knows the server's id for this
|
|
3475
|
+
* repository; `projectId` is what this run STAMPS ON ITS EVENTS, and it comes
|
|
3476
|
+
* from the sync. When the sync could not be opened that falls back to
|
|
3477
|
+
* `'local'`, every event this run writes is dropped by ingest on exactly that
|
|
3478
|
+
* mismatch, and the server therefore never learns the run exists — so an
|
|
3479
|
+
* approval naming it could not be validated even though a perfectly good
|
|
3480
|
+
* client is sitting right there. Requiring the two to agree turns a confusing
|
|
3481
|
+
* 400 about an unknown run into a sentence about what is actually wrong.
|
|
3482
|
+
*/
|
|
3483
|
+
const approvalServer = goalServer !== null && goalServer.projectId === projectId ? goalServer.approvals : null;
|
|
3484
|
+
const blocking = new run_approval_1.BlockingApproval({
|
|
3485
|
+
server: approvalServer ?? {
|
|
3486
|
+
createApproval: () => Promise.reject(new Error('unreachable: guarded before use')),
|
|
3487
|
+
},
|
|
3488
|
+
// The house rule is that the next cadence is the retry and a tight catch
|
|
3489
|
+
// loop turns an outage into a spin. This is the one place that cannot wait
|
|
3490
|
+
// for a cadence — the run is holding a working tree and is about to end —
|
|
3491
|
+
// so it asks a bounded number of times and pauses between, rather than
|
|
3492
|
+
// firing three refused connections in the same millisecond.
|
|
3493
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
3494
|
+
});
|
|
3495
|
+
/**
|
|
3496
|
+
* Set when the approval could NOT be filed. Thrown after the run has settled,
|
|
3497
|
+
* never instead of settling it: the ledger is the only account of what
|
|
3498
|
+
* happened, and a process that threw before writing one would leave a run
|
|
3499
|
+
* that looks live for ever.
|
|
3500
|
+
*/
|
|
3501
|
+
let approvalFailure = null;
|
|
3502
|
+
/**
|
|
3503
|
+
* A model turn asked for something only a person may authorise.
|
|
3504
|
+
*
|
|
3505
|
+
* ORDERING IS THE WHOLE CONTRACT, and it runs downwards: validate what the
|
|
3506
|
+
* model said, build the exact request from trusted context, file it with the
|
|
3507
|
+
* server, and only then arm the halt that ends the run. Every failure above
|
|
3508
|
+
* the last step leaves `halt` unarmed, so a run can never come to rest saying
|
|
3509
|
+
* it is waiting for an approval that does not exist.
|
|
3510
|
+
*/
|
|
3511
|
+
const askForApproval = async (declaration, itemId) => {
|
|
3512
|
+
const said = (line) => log(`action : ${line}`);
|
|
3513
|
+
/** Nothing was filed, and the run stops saying so rather than carrying on. */
|
|
3514
|
+
const cannot = (why) => {
|
|
3515
|
+
approvalFailure = why;
|
|
3516
|
+
halt = { stopReason: 'aborted', because: why };
|
|
3517
|
+
said(why);
|
|
3518
|
+
};
|
|
3519
|
+
if (binding.kind !== 'bound') {
|
|
3520
|
+
// Nothing to hang the request on. The server requires a pursuit alongside
|
|
3521
|
+
// a run, and a later controller decides holds by reading approvals for a
|
|
3522
|
+
// goal — one filed without either would be invisible to the thing meant
|
|
3523
|
+
// to act on it, which is worse than not filing it.
|
|
3524
|
+
return cannot('a run with no goal cannot ask for approval');
|
|
3525
|
+
}
|
|
3526
|
+
if (approvalServer === null) {
|
|
3527
|
+
return cannot(goalServer === null
|
|
3528
|
+
? 'this machine is not paired, so there is nobody to ask'
|
|
3529
|
+
: 'this run is not being mirrored to the server, so nobody could be asked about it');
|
|
3530
|
+
}
|
|
3531
|
+
/**
|
|
3532
|
+
* The model chose the action and its fields. It chose nothing else.
|
|
3533
|
+
*
|
|
3534
|
+
* `runner` is the actor for the same reason every round event carries it:
|
|
3535
|
+
* this happened inside a round, at the machine's own initiative. It is
|
|
3536
|
+
* deliberately not a `human:` actor — a run that could sign as a person
|
|
3537
|
+
* would be a run that could authorise itself — and deliberately not derived
|
|
3538
|
+
* from `opts.autonomous`, because who asked and who said go are different
|
|
3539
|
+
* questions and only the first belongs on the request.
|
|
3540
|
+
*/
|
|
3541
|
+
// The project's publish policy travels WITH the request build, so an
|
|
3542
|
+
// action that may not leave this machine is refused before a person is
|
|
3543
|
+
// ever asked about it.
|
|
3544
|
+
const built = (0, pending_action_1.buildPendingActionSignal)(declaration, { projectId, requestedBy: 'runner' }, config.publish.policy);
|
|
3545
|
+
if (!built.ok)
|
|
3546
|
+
return cannot(`that action cannot be requested — ${built.because}`);
|
|
3547
|
+
/**
|
|
3548
|
+
* THE SERVER HAS TO HAVE HEARD OF THIS RUN FIRST.
|
|
3549
|
+
*
|
|
3550
|
+
* Creating the approval names the run it came from, and the server checks
|
|
3551
|
+
* that against a `Run` row it only has because this run's own `run.started`
|
|
3552
|
+
* was ingested. That event is written to the ledger the instant the runner
|
|
3553
|
+
* starts and shipped by the sync on its own cadence — which, for a first
|
|
3554
|
+
* round that finished quickly, may not have come round yet.
|
|
3555
|
+
*
|
|
3556
|
+
* Best-effort on purpose. A flush that fails changes nothing about what
|
|
3557
|
+
* happens next: the create is attempted anyway, and a server that still has
|
|
3558
|
+
* not heard of the run refuses it, which is the honest outcome rather than a
|
|
3559
|
+
* reason not to try.
|
|
3560
|
+
*/
|
|
3561
|
+
try {
|
|
3562
|
+
await sync?.daemon.flush();
|
|
3563
|
+
}
|
|
3564
|
+
catch {
|
|
3565
|
+
/* the create below is the real test of whether the server can be reached */
|
|
3566
|
+
}
|
|
3567
|
+
const outcome = await blocking.raise(built.signal, {
|
|
3568
|
+
projectId,
|
|
3569
|
+
goalId: binding.goal.id,
|
|
3570
|
+
goalVersion: binding.goal.version,
|
|
3571
|
+
originRunId: runId,
|
|
3572
|
+
// A REAL identity or none. The round is working this item, so there is
|
|
3573
|
+
// one; nothing here would invent a value to fill the field if there were
|
|
3574
|
+
// not.
|
|
3575
|
+
originItemId: itemId,
|
|
3576
|
+
});
|
|
3577
|
+
if (!outcome.ok)
|
|
3578
|
+
return cannot(`the approval could not be filed — ${outcome.because}`);
|
|
3579
|
+
// DURABLE FIRST, SETTLED SECOND. Past this line the server holds the
|
|
3580
|
+
// question, so a crash before the run finishes ending is recoverable from
|
|
3581
|
+
// the server's own row rather than from anything written here.
|
|
3582
|
+
said(`waiting for you to approve ${built.signal.request.type} — nothing has been done`);
|
|
3583
|
+
halt = {
|
|
3584
|
+
stopReason: 'approval-required',
|
|
3585
|
+
because: 'stopped to ask you to approve something',
|
|
3586
|
+
// Attribution, spread onto `run.ended` by a runner that does not read it.
|
|
3587
|
+
// The id and nothing else: what the approval SAYS is the server's answer
|
|
3588
|
+
// to give, every time it is asked.
|
|
3589
|
+
endedContext: { approvalId: outcome.approvalId },
|
|
3590
|
+
};
|
|
3591
|
+
};
|
|
3592
|
+
const deps = {
|
|
3593
|
+
ledger: ledgerForRun,
|
|
3594
|
+
queue: runQueue,
|
|
3595
|
+
git,
|
|
3596
|
+
// Passed straight through: an item's cross-repo needs are decided by
|
|
3597
|
+
// `pickableItems`, and the only thing this file contributes is the wire.
|
|
3598
|
+
...(opts.crossRepo === undefined ? {} : { crossRepo: opts.crossRepo }),
|
|
3599
|
+
// CONTAINED. A gate step is the project's own tests over code the agent
|
|
3600
|
+
// just wrote, so it runs inside the boundary with Looply's own runtime
|
|
3601
|
+
// rather than as the daemon with the machine's.
|
|
3602
|
+
gateRunner: (0, untrusted_gate_1.makeUntrustedGateRunner)(cwd),
|
|
3603
|
+
// ASKED EVERY ROUND, NEVER CACHED. ~0.6 s against a 45-minute agent
|
|
3604
|
+
// ceiling, and asking each time is what makes the three-round bound mean
|
|
3605
|
+
// anything — and what lets a machine that recovers mid-run carry on.
|
|
3606
|
+
environmentReady: (0, untrusted_gate_1.makeContainmentPreflight)(cwd),
|
|
3607
|
+
implement: async (item, attempt, repair) => {
|
|
3608
|
+
roundNo += 1;
|
|
3609
|
+
terminal.phase(roundNo, 'implementing', item.title);
|
|
3610
|
+
/**
|
|
3611
|
+
* 🔴 ONE SNAPSHOT FOR THIS ROUND, TAKEN HERE.
|
|
3612
|
+
*
|
|
3613
|
+
* Asked per round rather than once per run, because a previous round
|
|
3614
|
+
* may have committed — and asked ONCE per round rather than twice,
|
|
3615
|
+
* because the memory ranking and the structural block must describe the
|
|
3616
|
+
* same world. Two calls either side of a commit would rank memories
|
|
3617
|
+
* against one tree and print a neighbourhood from another, and nothing
|
|
3618
|
+
* downstream could tell.
|
|
3619
|
+
*/
|
|
3620
|
+
const graphNow = await currentGraph();
|
|
3621
|
+
const goalNow = binding.kind === 'bound' ? binding.goal.id : undefined;
|
|
3622
|
+
const turn = await (0, agent_driver_1.runImplementer)(agent, {
|
|
3623
|
+
itemId: item.id,
|
|
3624
|
+
// The decision travels to the model here and nowhere else. It is not on
|
|
3625
|
+
// the item, not in the queue, and not a runner dep — the round protocol
|
|
3626
|
+
// has no vocabulary for it and must not acquire one.
|
|
3627
|
+
prompt: buildImplementerPrompt(item, attempt, config, resolution, governed,
|
|
3628
|
+
// Scoped to the item's own subject as well as the pursuit: what failed
|
|
3629
|
+
// on THIS piece of work is what a round most needs not to repeat.
|
|
3630
|
+
await memoryFor(memoryStore, git, { goalId: goalNow }, graphNow),
|
|
3631
|
+
/**
|
|
3632
|
+
* 🔴 THE GRAPH REACHING A PROMPT. Same snapshot as the line above, so
|
|
3633
|
+
* the structure a model is shown and the memories it is ranked by
|
|
3634
|
+
* describe ONE world — two calls could straddle a commit and disagree.
|
|
3635
|
+
*/
|
|
3636
|
+
structureFor(graphNow, goalNow),
|
|
3637
|
+
// What the last attempt did and why it was refused, when this run
|
|
3638
|
+
// saw it refused. The runner built it from the retained evidence.
|
|
3639
|
+
repair,
|
|
3640
|
+
/**
|
|
3641
|
+
* 🔴 AND THE GOAL ITSELF, WHICH WAS ALWAYS ONE PROPERTY AWAY. The
|
|
3642
|
+
* line that computes `goalNow` reads `binding.goal.id` off this same
|
|
3643
|
+
* object; `binding.goal.text` is the person's own wording, preserved
|
|
3644
|
+
* verbatim, and it was being left behind on every round of every
|
|
3645
|
+
* pursuit (the audit's E-01). Read from the binding rather than
|
|
3646
|
+
* fetched, so the text a round is shown is the text the round is
|
|
3647
|
+
* bound to — a second read could straddle an amendment and show a
|
|
3648
|
+
* goal this work was never authorised against.
|
|
3649
|
+
*/
|
|
3650
|
+
binding.kind === 'bound' ? binding.goal.text : undefined),
|
|
3651
|
+
timeoutMs: AGENT_TIMEOUT_MS,
|
|
3652
|
+
});
|
|
3653
|
+
if (turn.kind !== 'pending-action')
|
|
3654
|
+
return turn;
|
|
3655
|
+
// NARROWED HERE, ONCE. A request for authority is not something a round
|
|
3656
|
+
// can be told about, so it never reaches the runner as one: the boundary
|
|
3657
|
+
// is dealt with, the halt is armed, and what goes back is a turn that
|
|
3658
|
+
// produced no implementable result — which is exactly what happened.
|
|
3659
|
+
await askForApproval(turn.declaration, item.id);
|
|
3660
|
+
const { transcript, ms, exitOk, timedOut } = turn;
|
|
3661
|
+
return {
|
|
3662
|
+
kind: 'abandoned',
|
|
3663
|
+
reason: halt?.because ?? 'the turn asked for an action instead of implementing',
|
|
3664
|
+
transcript,
|
|
3665
|
+
ms,
|
|
3666
|
+
exitOk,
|
|
3667
|
+
timedOut,
|
|
3668
|
+
};
|
|
3669
|
+
},
|
|
3670
|
+
...(config.proof.enabled
|
|
3671
|
+
? {
|
|
3672
|
+
prove: async ({ item }) => {
|
|
3673
|
+
terminal.phase(roundNo, 'proving', item.title);
|
|
3674
|
+
const diff = await git.diff();
|
|
3675
|
+
const run = await (0, prover_1.runProver)(proveAgent, {
|
|
3676
|
+
item: { title: item.title, evidence: item.evidence },
|
|
3677
|
+
diff,
|
|
3678
|
+
// Held only so the exclusion is enforced where it is testable;
|
|
3679
|
+
// the prompt builder never reads it. The prover grades the
|
|
3680
|
+
// code, not the implementer's account of it.
|
|
3681
|
+
implementer: {
|
|
3682
|
+
itemId: item.id,
|
|
3683
|
+
outcome: 'implemented',
|
|
3684
|
+
filesTouched: [],
|
|
3685
|
+
testsAdded: 0,
|
|
3686
|
+
note: '',
|
|
3687
|
+
blocked: null,
|
|
3688
|
+
},
|
|
3689
|
+
}, PROVER_TIMEOUT_MS);
|
|
3690
|
+
return (0, prover_1.toProofOutcome)(run);
|
|
3691
|
+
},
|
|
3692
|
+
/**
|
|
3693
|
+
* 🔴 §6.C, AND THE LINE `config.ts` HAS BEEN WARNING ABOUT SINCE
|
|
3694
|
+
* M6-20. `on_partial: ask` is the DEFAULT and nothing could ask, so a
|
|
3695
|
+
* partial proof was discarded with a log line while the server held
|
|
3696
|
+
* the whole mechanism and the phone rendered it.
|
|
3697
|
+
*
|
|
3698
|
+
* ⚠️ AN UNPAIRED MACHINE IS UNCHANGED. `goalServer` is null without a
|
|
3699
|
+
* paired machine and a registered project, and the asker says the
|
|
3700
|
+
* same two lines and discards, exactly as this did.
|
|
3701
|
+
*/
|
|
3702
|
+
resolvePartial: (0, partial_asker_1.makePartialAsker)({
|
|
3703
|
+
/**
|
|
3704
|
+
* 🔴 ONE FIELD, SO THERE IS NOTHING TO THROW ABOUT. Unpaired is the
|
|
3705
|
+
* ABSENCE of a server, not a null id beside calls that need one —
|
|
3706
|
+
* and a bare `throw` in this file is how a run leaks its lease and
|
|
3707
|
+
* its slot, which `run-hold-release.spec.ts` says out loud.
|
|
3708
|
+
*/
|
|
3709
|
+
server: goalServer === null
|
|
3710
|
+
? null
|
|
3711
|
+
: {
|
|
3712
|
+
projectId: goalServer.projectId,
|
|
3713
|
+
ask: (body) => goalServer.questions.ask(body),
|
|
3714
|
+
read: (p, id) => goalServer.questions.read(p, id),
|
|
3715
|
+
},
|
|
3716
|
+
runId,
|
|
3717
|
+
now: () => Date.now(),
|
|
3718
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
3719
|
+
windowMs: config.proof.askWindowMin * 60_000,
|
|
3720
|
+
graceMs: partial_asker_1.PARTIAL_ASK_GRACE_MS,
|
|
3721
|
+
pollMs: partial_asker_1.PARTIAL_ASK_POLL_MS,
|
|
3722
|
+
log,
|
|
3723
|
+
}),
|
|
3724
|
+
}
|
|
3725
|
+
: {}),
|
|
3726
|
+
evidence: { invalidate: (itemId) => evidence.invalidate(itemId) },
|
|
3727
|
+
// Where a rejected candidate goes before `git reset --hard` takes the tree:
|
|
3728
|
+
// under the state directory, which `discard()` spares by name.
|
|
3729
|
+
rejectedEvidence: (0, rejected_evidence_1.makeRejectedEvidenceStore)(cwd),
|
|
3730
|
+
reverify: {
|
|
3731
|
+
cache: {
|
|
3732
|
+
lookup: (itemId, head) => evidence.lookup(itemId, head),
|
|
3733
|
+
put: (itemId, head, ev) => evidence.put(itemId, head, ev),
|
|
3734
|
+
},
|
|
3735
|
+
countCommitsTouching: (from, to, paths) => git.countCommitsTouching(from, to, paths),
|
|
3736
|
+
// Same verifier the pre-run pass uses, so an item's verdict never
|
|
3737
|
+
// depends on which route asked for it.
|
|
3738
|
+
verify: (0, verify_cmd_1.makeItemVerifier)(verifyAgent, queue),
|
|
3739
|
+
},
|
|
3740
|
+
notifier,
|
|
3741
|
+
readEnvFile: (p) => {
|
|
3742
|
+
try {
|
|
3743
|
+
return (0, node_fs_1.readFileSync)((0, node_path_1.join)(cwd, p), 'utf8');
|
|
3744
|
+
}
|
|
3745
|
+
catch {
|
|
3746
|
+
return null;
|
|
3747
|
+
}
|
|
3748
|
+
},
|
|
3749
|
+
env: process.env,
|
|
3750
|
+
now: () => Date.now(),
|
|
3751
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
3752
|
+
stopRequested: () => signals.requested(),
|
|
3753
|
+
// The generic half of the boundary above. The runner asks whether the rest
|
|
3754
|
+
// of this run is still possible and is handed a reason; it never learns
|
|
3755
|
+
// that the reason is a person.
|
|
3756
|
+
haltRequested: () => halt,
|
|
3757
|
+
/**
|
|
3758
|
+
* ⚠️ ONLY WHEN THE PROJECT ASKED, and only for a run that has a base to be
|
|
3759
|
+
* stale. With `run.integration` off there is no `looply/integration` ref,
|
|
3760
|
+
* so this is not wired at all rather than wired and answering "current".
|
|
3761
|
+
*/
|
|
3762
|
+
...(config.run.integration === true
|
|
3763
|
+
? {
|
|
3764
|
+
/**
|
|
3765
|
+
* 🔴 `.catch(() => ({ kind: 'current' }))` WAS THE THIRD FAIL-OPEN IN
|
|
3766
|
+
* ONE CHECK, AND THE MOST DIRECT.
|
|
3767
|
+
*
|
|
3768
|
+
* `current` is this function's word for "the ground has not moved,
|
|
3769
|
+
* carry on". So a broken git, a missing base commit, a repository
|
|
3770
|
+
* mid-`rebase` with an index lock — every one of them told the run
|
|
3771
|
+
* that its base was fresh, which is the single answer this check
|
|
3772
|
+
* exists to be sure about. Combined with the two inside git-driver, a
|
|
3773
|
+
* staleness check failed open in three independent ways.
|
|
3774
|
+
*
|
|
3775
|
+
* A staleness check that cannot check has to HALT. That is the same
|
|
3776
|
+
* rule the loop-safety history follows ("an unreadable history must
|
|
3777
|
+
* hold, not pass") and the same one `withinEnvelope` follows for an
|
|
3778
|
+
* unmeasurable ceiling. Stopping costs a run; carrying on builds an
|
|
3779
|
+
* hour of work on ground that moved.
|
|
3780
|
+
*/
|
|
3781
|
+
/**
|
|
3782
|
+
* WHERE THIS RUN'S BRANCH IS CUT FROM — the other half of the phase.
|
|
3783
|
+
*
|
|
3784
|
+
* ⚠️ `cutFrom` ANSWERS null UNTIL INTEGRATION EXISTS, and the runner
|
|
3785
|
+
* then cuts from HEAD exactly as it always has. Once it exists, every
|
|
3786
|
+
* run starts from the same base instead of stacking on the last one.
|
|
3787
|
+
*/
|
|
3788
|
+
integrationBase: async () => {
|
|
3789
|
+
try {
|
|
3790
|
+
const at = await (0, integration_1.cutFrom)(git, true);
|
|
3791
|
+
return at === null ? null : at.ref;
|
|
3792
|
+
}
|
|
3793
|
+
catch {
|
|
3794
|
+
// A start point that cannot be read is not a reason to refuse the
|
|
3795
|
+
// run; it is a reason to start where runs have always started.
|
|
3796
|
+
return null;
|
|
3797
|
+
}
|
|
3798
|
+
},
|
|
3799
|
+
baseStale: async (cutAt) => {
|
|
3800
|
+
try {
|
|
3801
|
+
// F29: the commit the runner CUT from — never this checkout's HEAD before the cut, which in
|
|
3802
|
+
// a reused checkout is the LAST run's tip and read every moved lane as a stale base (corr-7).
|
|
3803
|
+
const out = await (0, integration_1.baseFreshness)(git, cutAt, true);
|
|
3804
|
+
/**
|
|
3805
|
+
* ⚠️ UNINITIALISED STOPS THE RUN TOO, and does not read as fresh.
|
|
3806
|
+
* `run.integration` is on, so this project asked for its runs to
|
|
3807
|
+
* converge; with no integration ref there is nothing to converge
|
|
3808
|
+
* on and nothing to compare against. Carrying on while reporting
|
|
3809
|
+
* freshness is the one answer this check exists to prevent.
|
|
3810
|
+
*/
|
|
3811
|
+
return out.kind === 'current' ? null : out.because;
|
|
3812
|
+
}
|
|
3813
|
+
catch (e) {
|
|
3814
|
+
return (`this run cannot tell whether its base is still current (${e.message}). ` +
|
|
3815
|
+
'Refusing to build on ground it cannot check');
|
|
3816
|
+
}
|
|
3817
|
+
},
|
|
3818
|
+
}
|
|
3819
|
+
: {}),
|
|
3820
|
+
// Attribution for this run's header event, passed as opaque values. The
|
|
3821
|
+
// runner spreads them and knows nothing about what they mean — that is the
|
|
3822
|
+
// whole point of the carrier, and why RoundRunner still contains no
|
|
3823
|
+
// occurrence of the word.
|
|
3824
|
+
...(binding.kind === 'bound'
|
|
3825
|
+
? {
|
|
3826
|
+
runContext: {
|
|
3827
|
+
goalId: binding.goal.id,
|
|
3828
|
+
goalVersion: binding.goal.version,
|
|
3829
|
+
// Which answer this run was started to pick up. Lineage, carried on
|
|
3830
|
+
// the same opaque channel the goal binding uses — the runner spreads
|
|
3831
|
+
// it onto the header and knows nothing about what it means.
|
|
3832
|
+
...(resolution === undefined ? {} : { resumedFromApprovalId: resolution.approvalId }),
|
|
3833
|
+
},
|
|
3834
|
+
}
|
|
3835
|
+
: {}),
|
|
3836
|
+
// The phone's pause and stop. The daemon writes this file from another
|
|
3837
|
+
// process; the runner reads it between rounds.
|
|
3838
|
+
control,
|
|
3839
|
+
};
|
|
3840
|
+
/**
|
|
3841
|
+
* THE GRANTS A PERSON HAS OPENED, AND THE RECORD OF WHAT THIS MACHINE SPENT.
|
|
3842
|
+
*
|
|
3843
|
+
* 🔴 THIS LINK DID NOT EXIST, AND EVERYTHING EITHER SIDE OF IT DID. The gate's
|
|
3844
|
+
* validity check, the core model, the server's grant and list routes and the
|
|
3845
|
+
* commit trailer line were all built and tested around a door nothing could
|
|
3846
|
+
* open: `looply run` wired neither a source of grants nor a sink for spent
|
|
3847
|
+
* ones, so no grant reached a gate from a run and a railed path stayed shut
|
|
3848
|
+
* whatever a person granted.
|
|
3849
|
+
*
|
|
3850
|
+
* ⚠️ BOTH OR NEITHER, AND THE RUNNER ENFORCES THAT. Its constructor refuses a
|
|
3851
|
+
* grant source with no spend sink — "one grant reopening the same rail every
|
|
3852
|
+
* round until it expires" — so this cannot be half-wired into a door that
|
|
3853
|
+
* never closes.
|
|
3854
|
+
*
|
|
3855
|
+
* ⚠️ NO SERVER, NO GRANTS. Without `sync` there is no trusted source, and the
|
|
3856
|
+
* runner then keeps every rail shut. That is the same deny-by-default the
|
|
3857
|
+
* whole design rests on, and it is why a failure to fetch below yields an
|
|
3858
|
+
* EMPTY list rather than an exception that would strand the run.
|
|
3859
|
+
*/
|
|
3860
|
+
// MACHINE-SCOPED. "Single use" held once only because there was one
|
|
3861
|
+
// exemptions ledger per checkout; one per worktree let the same granted row
|
|
3862
|
+
// pass every filter in each of them, and a single-use grant over a railed
|
|
3863
|
+
// path became spendable once per workspace. Phase 1 closed that by sharing
|
|
3864
|
+
// the project's ledger. Phase 2 moved it out of the repository, because a
|
|
3865
|
+
// ledger a person can delete by re-cloning is a ledger that forgets what the
|
|
3866
|
+
// machine has spent. See rails-home.ts.
|
|
3867
|
+
const exemptionStore = (0, rails_stores_1.railsExemptionStore)(cwd);
|
|
3868
|
+
let grants = [];
|
|
3869
|
+
if (sync) {
|
|
3870
|
+
await holding(() => {
|
|
3871
|
+
faulting('exemptionStore.open');
|
|
3872
|
+
return exemptionStore.open();
|
|
3873
|
+
});
|
|
3874
|
+
try {
|
|
3875
|
+
const { exemptions: rows } = await sync.client.projectExemptions(sync.projectId);
|
|
3876
|
+
// The three filters live in `grantsFromServer`, where they can be tested
|
|
3877
|
+
// and mutated one at a time: granted-not-refused, a real expiry, and not
|
|
3878
|
+
// already spent on this machine.
|
|
3879
|
+
grants = (0, exemption_store_1.grantsFromServer)(rows, (e) => exemptionStore.isSpent(e), (g) => (0, core_1.exemptionFromGrant)({ ...g, actor: g.actor }));
|
|
3880
|
+
if (grants.length > 0)
|
|
3881
|
+
log(`rails : ${grants.length} granted exemption(s) available`);
|
|
3882
|
+
}
|
|
3883
|
+
catch {
|
|
3884
|
+
// A grant source that cannot be reached is no grant source. Rails shut.
|
|
3885
|
+
grants = [];
|
|
3886
|
+
}
|
|
3887
|
+
}
|
|
3888
|
+
const runner = new round_runner_1.RoundRunner({ ...config, run: { ...config.run, minHours } }, {
|
|
3889
|
+
...deps,
|
|
3890
|
+
// ORIGIN TRANSLATED ONCE, HERE, into a policy the runner can act on
|
|
3891
|
+
// without knowing what a controller is. A person's tap consumes their
|
|
3892
|
+
// own control request; a Start this machine chose to make does not get
|
|
3893
|
+
// to overrule a person's pause with an act they never performed.
|
|
3894
|
+
consumeControlOnStart: opts.autonomous !== true,
|
|
3895
|
+
/**
|
|
3896
|
+
* ⚠️ ONLY WHEN A PURSUIT COULD BE OBSERVED AT ALL. Collecting per-round
|
|
3897
|
+
* facts for an unbound run costs a `headTreeSha` per round and produces
|
|
3898
|
+
* records nothing will ever read: the producer below is guarded on
|
|
3899
|
+
* `binding.kind === 'bound'`.
|
|
3900
|
+
*/
|
|
3901
|
+
perRoundSafety: config.run.perRoundSafety === true && binding.kind === 'bound',
|
|
3902
|
+
/**
|
|
3903
|
+
* ⚠️ ONLY WHEN THE PROJECT ASKED. With `run.surfaces` off this is empty,
|
|
3904
|
+
* and an empty declaration constrains nothing — which is the whole
|
|
3905
|
+
* compatibility guarantee, made by the same line that would enforce it.
|
|
3906
|
+
*/
|
|
3907
|
+
...(heldSurfaces.length === 0 ? {} : { declaredSurfaces: declaredSurfaces }),
|
|
3908
|
+
/**
|
|
3909
|
+
* 🔴 THE FENCE, WIRED. `checkFence` shipped with no production caller at
|
|
3910
|
+
* all — called only by its own unit test — so `surface-lease.ts`'s claim
|
|
3911
|
+
* that "EXPIRY EXISTS ONLY HERE, AND ONLY BECAUSE OF THE FENCE" was half
|
|
3912
|
+
* true: the expiry shipped and the comparison that makes expiry safe did
|
|
3913
|
+
* not. The runner asks this immediately before every commit.
|
|
3914
|
+
*
|
|
3915
|
+
* ⚠️ EVERY HELD SURFACE, AND THE FIRST REFUSAL WINS. A run holding three
|
|
3916
|
+
* surfaces that has been taken over on one of them is not the current
|
|
3917
|
+
* holder, and committing any part of that round would be the very
|
|
3918
|
+
* two-writer state the lease exists to prevent.
|
|
3919
|
+
*/
|
|
3920
|
+
...(heldSurfaces.length === 0
|
|
3921
|
+
? {}
|
|
3922
|
+
: {
|
|
3923
|
+
checkFence: () => {
|
|
3924
|
+
for (const fence of heldSurfaces) {
|
|
3925
|
+
const verdict = (0, surface_lease_1.checkFence)(cwd, fence);
|
|
3926
|
+
if (!verdict.ok)
|
|
3927
|
+
return verdict;
|
|
3928
|
+
}
|
|
3929
|
+
return { ok: true };
|
|
3930
|
+
},
|
|
3931
|
+
}),
|
|
3932
|
+
/**
|
|
3933
|
+
* 🔴 THE ONE RETRY IS SPENT WHEN A ROUND IS CERTAIN, NOT BEFORE.
|
|
3934
|
+
*
|
|
3935
|
+
* MEASURED, twice, on the version that spent it at the statement before
|
|
3936
|
+
* `runner.run()`: the dirty-tree check is `run()`'s FIRST statement, so a
|
|
3937
|
+
* person with uncommitted edits saw "one retry, spent" and "working tree
|
|
3938
|
+
* has 1 changed path(s) — refusing to start" in the same second, with no
|
|
3939
|
+
* ledger written and no round begun. The hold was discharged for ever and
|
|
3940
|
+
* the next unflagged `looply run` walked straight past the fence. The
|
|
3941
|
+
* clean-tree variant reached the same end through `queue-empty`.
|
|
3942
|
+
*
|
|
3943
|
+
* The runner calls this after its own preconditions and after an item is
|
|
3944
|
+
* picked, which is the first moment a round is certain.
|
|
3945
|
+
*/
|
|
3946
|
+
...(retryToSpend === null || safety === null
|
|
3947
|
+
? {}
|
|
3948
|
+
: {
|
|
3949
|
+
beforeFirstRound: async () => {
|
|
3950
|
+
const spent = await safety.override(retryToSpend.id, runId);
|
|
3951
|
+
if (!spent.ok) {
|
|
3952
|
+
return { ok: false, because: `${retryToSpend.because}; ${spent.because}. That retry is already gone.` };
|
|
3953
|
+
}
|
|
3954
|
+
log(`action : trying once more after ${retryToSpend.kind} (${retryToSpend.id}) — one retry, spent`);
|
|
3955
|
+
return { ok: true };
|
|
3956
|
+
},
|
|
3957
|
+
}),
|
|
3958
|
+
// Wired as a PAIR, because the runner refuses one without the other.
|
|
3959
|
+
// Without a server there is nothing to wire and every rail stays shut.
|
|
3960
|
+
...(sync
|
|
3961
|
+
? {
|
|
3962
|
+
// Item-scoped here; path, expiry and unused-ness are judged at the
|
|
3963
|
+
// interlock by `isExemptionValid`, which is the only place that
|
|
3964
|
+
// sees the path being opened.
|
|
3965
|
+
// 🔴 ASKS THE STORE EVERY ROUND, not just at fetch time. Filtering
|
|
3966
|
+
// the captured array alone kept offering a grant that an earlier
|
|
3967
|
+
// round of THIS run had already spent — "one grant reopening the
|
|
3968
|
+
// same rail every round until it expires", which is the exact
|
|
3969
|
+
// failure the constructor below refuses a half-wiring to prevent.
|
|
3970
|
+
// Wiring both halves satisfied that check and did it anyway.
|
|
3971
|
+
exemptionsFor: (itemId) => (0, exemption_store_1.offerableFor)(grants, itemId, (e) => exemptionStore.isSpent(e)),
|
|
3972
|
+
// Spent on COMMIT, not on check: a discarded round leaves the door
|
|
3973
|
+
// open for the retry. The runner calls this after the commit exists.
|
|
3974
|
+
spendExemption: async (spent) => {
|
|
3975
|
+
await exemptionStore.record(spent);
|
|
3976
|
+
},
|
|
3977
|
+
}
|
|
3978
|
+
: {}),
|
|
3979
|
+
}, identity);
|
|
3980
|
+
// The branch is NOT created here. RoundRunner.run() creates it, and
|
|
3981
|
+
// GitDriver.createBranch refuses a name that already exists (DESIGN section
|
|
3982
|
+
// 12 case 2 — reusing a branch would land this run's commits on top of
|
|
3983
|
+
// whatever an aborted run left there). Creating it in both places made that
|
|
3984
|
+
// refusal fire against the run's own branch, so every run exited 1 after
|
|
3985
|
+
// printing these three lines and no round was ever reached. The runner owns
|
|
3986
|
+
// git; this file only reports what the runner is about to do.
|
|
3987
|
+
log(`branch : ${branch}`);
|
|
3988
|
+
log(`base : ${baseSha.slice(0, 7)}`);
|
|
3989
|
+
log(`budget : ${minHours}h, up to ${config.run.maxRounds} rounds`);
|
|
3990
|
+
/**
|
|
3991
|
+
* THE RUN-END DELIVERY (F20), ON EVERY WAY OUT THAT COMMITTED (F31, corr-12). A run that
|
|
3992
|
+
* committed and then threw out of the runner left its branch with no claim, and the lane holds an
|
|
3993
|
+
* unclaimed branch to the strict rule — refused while any capability step is red, and not offered
|
|
3994
|
+
* again until the base moves. On a throw the summary is lost, so `onlyIfCommitted` asks this
|
|
3995
|
+
* run's own rounds instead.
|
|
3996
|
+
*/
|
|
3997
|
+
const writeRunEndDelivery = async (onlyIfCommitted) => {
|
|
3998
|
+
const at = scope;
|
|
3999
|
+
if (at === null)
|
|
4000
|
+
return;
|
|
4001
|
+
try {
|
|
4002
|
+
const read = await (0, round_records_1.readRoundRecords)(stateDir, ledger_store_1.nodeLedgerFs);
|
|
4003
|
+
if (onlyIfCommitted && (!read.ok || !read.rounds.some((r) => r.runId === runId && r.outcome === 'committed')))
|
|
4004
|
+
return;
|
|
4005
|
+
const head = await git.headSha();
|
|
4006
|
+
const mineIds = new Set(queue.list().filter((i) => i.goalId === at.goalId).map((i) => i.id));
|
|
4007
|
+
const ours = read.ok ? read.rounds.filter((r) => mineIds.has(r.itemId)) : [];
|
|
4008
|
+
const end = read.ok
|
|
4009
|
+
? (0, run_delivery_1.deliveryAtRunEnd)({
|
|
4010
|
+
scope: at,
|
|
4011
|
+
items: queue.list(),
|
|
4012
|
+
rounds: read.rounds,
|
|
4013
|
+
head,
|
|
4014
|
+
requiredSteps: (0, gate_1.requiredSteps)(config.gates),
|
|
4015
|
+
branch,
|
|
4016
|
+
runId,
|
|
4017
|
+
acceptanceDigest: (0, core_3.acceptanceMapDigest)(config.gates),
|
|
4018
|
+
at: new Date().toISOString(),
|
|
4019
|
+
// F31 (soun-4): only work this branch's history contains.
|
|
4020
|
+
reachable: await (0, run_delivery_1.reachableFrom)(git, head, ours),
|
|
4021
|
+
})
|
|
4022
|
+
: { kind: 'none', because: read.because };
|
|
4023
|
+
if (end.kind === 'record') {
|
|
4024
|
+
if (!(0, lane_claims_1.recordLaneClaim)(cwd, end.record))
|
|
4025
|
+
log('delivery: could not be written — the lane will hold this branch to the strict rule');
|
|
4026
|
+
}
|
|
4027
|
+
else {
|
|
4028
|
+
log(`delivery: none recorded for the lane — ${end.because}`);
|
|
4029
|
+
}
|
|
4030
|
+
}
|
|
4031
|
+
catch (e) {
|
|
4032
|
+
log(`delivery: none recorded for the lane — ${e.message}`);
|
|
4033
|
+
}
|
|
4034
|
+
};
|
|
4035
|
+
try {
|
|
4036
|
+
const summary = await runner.run().catch(async (e) => {
|
|
4037
|
+
await writeRunEndDelivery(true);
|
|
4038
|
+
throw e;
|
|
4039
|
+
});
|
|
4040
|
+
/**
|
|
4041
|
+
* 🔴 THE RUN DID NOTHING, AND SAYS SO RATHER THAN LOOKING LIKE A RUN.
|
|
4042
|
+
*
|
|
4043
|
+
* `beforeFirstRound` refuses when the one retry could not be taken. The
|
|
4044
|
+
* summary carries the reason so the ending is honest — this is not a run
|
|
4045
|
+
* that finished, it is a run that never started.
|
|
4046
|
+
*/
|
|
4047
|
+
/**
|
|
4048
|
+
* 🔴 A ROUND THAT WROTE OUTSIDE ITS LEASE FLAGS THE GOAL AND IS NOT RETRIED.
|
|
4049
|
+
*
|
|
4050
|
+
* The round was already discarded where the trespass was seen — before the
|
|
4051
|
+
* gate, before any commit. What is left is to say so, with the exact paths,
|
|
4052
|
+
* because the person's DECLARATION is what needs changing and they cannot
|
|
4053
|
+
* change it from "something went wrong".
|
|
4054
|
+
*/
|
|
4055
|
+
if (summary.surfaceViolation !== undefined) {
|
|
4056
|
+
const v = summary.surfaceViolation;
|
|
4057
|
+
log(`surface : wrote outside ${v.declared.join(', ')} — ${v.paths.slice(0, 5).join(', ')}`);
|
|
4058
|
+
log('surface : the round was discarded and this goal is flagged; the declaration is what is wrong');
|
|
4059
|
+
}
|
|
4060
|
+
if (summary.refusedBeforeFirstRound !== undefined) {
|
|
4061
|
+
log(`action : not starting work — ${summary.refusedBeforeFirstRound}`);
|
|
4062
|
+
await releaseHold();
|
|
4063
|
+
throw new Error(`this machine has stopped working on that goal — ${summary.refusedBeforeFirstRound}`);
|
|
4064
|
+
}
|
|
4065
|
+
/**
|
|
4066
|
+
* 🔴 THE DELIVERY IS WRITTEN BY THE RUN THAT DELIVERED IT, ON THE BRANCH IT CUT,
|
|
4067
|
+
* BEFORE ITS SLOT IS GIVEN BACK (F20). The daemon offers a finished branch to the
|
|
4068
|
+
* lane as soon as the slot is free, and under programme admission a branch the
|
|
4069
|
+
* lane finds no claim for gets the strict rule and is not offered again at that
|
|
4070
|
+
* tip. It used to be written only by a LATER Start, under that Start's own
|
|
4071
|
+
* never-created branch — so the lane never found it at all.
|
|
4072
|
+
*
|
|
4073
|
+
* ⚠️ KEY 2's OWN ANSWER (`deliveryAtRunEnd`), and best-effort: a delivery that
|
|
4074
|
+
* could not be written leaves the branch to the strict lane, never to a crash.
|
|
4075
|
+
*/
|
|
4076
|
+
if (scope !== null && summary.totals.committed > 0)
|
|
4077
|
+
await writeRunEndDelivery(false);
|
|
4078
|
+
/**
|
|
4079
|
+
* WHAT THIS RUN ACHIEVED, WRITTEN DOWN BEFORE ANYTHING ELSE CAN THROW.
|
|
4080
|
+
*
|
|
4081
|
+
* Recorded here rather than at the very end because the paths below throw,
|
|
4082
|
+
* and an attempt that is not written down is an attempt the detectors
|
|
4083
|
+
* cannot see — a run that keeps failing the same way would be invisible to
|
|
4084
|
+
* the one mechanism built to notice.
|
|
4085
|
+
*
|
|
4086
|
+
* ⚠️ THE EXCLUSION IS DERIVED FROM THE STOP REASON, NOT GUESSED. A run that
|
|
4087
|
+
* stopped because a person was asked something has not failed to progress;
|
|
4088
|
+
* it stopped ON PURPOSE, and counting it would turn somebody's ten minutes
|
|
4089
|
+
* over an approval into a safety incident. `approval-required` and the
|
|
4090
|
+
* ambiguous-action reason are the two the runner can end on that mean
|
|
4091
|
+
* "waiting for a person", and both are excluded.
|
|
4092
|
+
*
|
|
4093
|
+
* Bound pursuits only: an unbound run has no pursuit whose progress could
|
|
4094
|
+
* be measured, and inventing one would be inventing the very number this
|
|
4095
|
+
* phase refuses to invent.
|
|
4096
|
+
*/
|
|
4097
|
+
if (binding.kind === 'bound' && safety !== null && scope !== null) {
|
|
4098
|
+
/**
|
|
4099
|
+
* ⚠️ NOT DERIVABLE FROM `stopReason`, which is why the runner carries it
|
|
4100
|
+
* out separately: an environment-blocked run ends as `aborted`, and so
|
|
4101
|
+
* does an ordinary crash-out. Without this the run is recorded as
|
|
4102
|
+
* `gate_failed` — the outcome list's words for "a deterministic gate
|
|
4103
|
+
* refused it", which is exactly what did not happen — and its unchanged
|
|
4104
|
+
* tree fingerprint feeds `noProgress`, which needs no failure signature
|
|
4105
|
+
* and so survived the whole point of the `blocked` verdict.
|
|
4106
|
+
*
|
|
4107
|
+
* ⚠️ AND PAIRED WITH `committed === 0`, deliberately. Meeting a broken
|
|
4108
|
+
* container is not by itself a reason to record nothing: a run that hit
|
|
4109
|
+
* one, recovered and landed work made real progress, and hiding that
|
|
4110
|
+
* would be the opposite error — a pursuit that IS stuck looking healthy.
|
|
4111
|
+
*/
|
|
4112
|
+
const excluded = summary.stopReason === 'approval-required'
|
|
4113
|
+
? 'awaiting_approval'
|
|
4114
|
+
: summary.stopReason === 'governed-action-ambiguous'
|
|
4115
|
+
? 'awaiting_human_resolution'
|
|
4116
|
+
: summary.metUnavailableEnvironment === true && summary.totals.committed === 0
|
|
4117
|
+
? 'environment_unavailable'
|
|
4118
|
+
: null;
|
|
4119
|
+
/**
|
|
4120
|
+
* ONE OBSERVATION PER ROUND, OR — STILL, BY DEFAULT — ONE PER RUN.
|
|
4121
|
+
*
|
|
4122
|
+
* 🔴 THE DENOMINATOR WAS THE DEFECT. A four-hour run that discarded forty
|
|
4123
|
+
* rounds on the identical failure was ONE observation, so
|
|
4124
|
+
* `MAX_SAME_FAILURE_REPEATS = 3` needed three such RUNS — half a day —
|
|
4125
|
+
* before it said anything, while `MAX_ITEM_EXECUTION_ATTEMPTS = 3` was
|
|
4126
|
+
* being spent per round. The two constants read as if they agreed and
|
|
4127
|
+
* were measuring different things by a factor of the round count.
|
|
4128
|
+
*
|
|
4129
|
+
* ⚠️ THE OBSERVATIONS STILL ARRIVE AT THE END OF THE RUN, and that is a
|
|
4130
|
+
* deliberate narrowing of what the plan sketched. Recording them AS each
|
|
4131
|
+
* round ends would make a hold reachable inside a single Mode-1 run —
|
|
4132
|
+
* three consecutive rounds failing on one flaky test is an ordinary
|
|
4133
|
+
* evening, not a pathology — and a hold has no expiry. What this fixes is
|
|
4134
|
+
* what the NEXT run's brake is told, which is the defect that was
|
|
4135
|
+
* actually costing people money.
|
|
4136
|
+
*
|
|
4137
|
+
* ⚠️ AND `excluded` IS RE-DERIVED PER ROUND for the environment case,
|
|
4138
|
+
* because `metUnavailableEnvironment` latches true for the rest of the
|
|
4139
|
+
* run: reading it directly would exclude every round after the first bad
|
|
4140
|
+
* container, including the ones that worked.
|
|
4141
|
+
*/
|
|
4142
|
+
const perRound = summary.rounds ?? null;
|
|
4143
|
+
const records = perRound === null
|
|
4144
|
+
? [
|
|
4145
|
+
{
|
|
4146
|
+
worked: summary.totals.committed > 0,
|
|
4147
|
+
/**
|
|
4148
|
+
* 🔴 IT USED TO SAY `null` HERE, AND null MAPS TO `gate_failed`.
|
|
4149
|
+
*
|
|
4150
|
+
* The old comment argued that a run summarising many rounds
|
|
4151
|
+
* cannot say where it failed — which is true, and is not an
|
|
4152
|
+
* argument for naming the GATE. A prover refusal recorded as a
|
|
4153
|
+
* gate failure is a specific false claim, and it was the
|
|
4154
|
+
* claim made in the DEFAULT configuration: per-round facts
|
|
4155
|
+
* exist only when `run.per_round_safety` is on, and it is off
|
|
4156
|
+
* by default.
|
|
4157
|
+
*
|
|
4158
|
+
* The runner maintains `failedAtThisRound` on every round
|
|
4159
|
+
* whatever that flag says — only the facts ARRAY is gated — so
|
|
4160
|
+
* the last unsuccessful round's site is knowable here for free.
|
|
4161
|
+
* It is the LAST one, not a claim about all of them.
|
|
4162
|
+
*
|
|
4163
|
+
* ⚠️ AND UNDEFINED STILL MEANS `gate_failed`, unchanged: a run
|
|
4164
|
+
* that never set it never failed a round in a way the runner
|
|
4165
|
+
* classified.
|
|
4166
|
+
*/
|
|
4167
|
+
failedAt: summary.lastFailedAt ?? null,
|
|
4168
|
+
// See `failedAt` above: a run summarises many rounds and cannot
|
|
4169
|
+
// say which gates passed in which of them.
|
|
4170
|
+
passingGates: undefined,
|
|
4171
|
+
tree: await git.headTreeSha().catch(() => null),
|
|
4172
|
+
failure: summary.lastFailure ?? null,
|
|
4173
|
+
work: runQueue.list(),
|
|
4174
|
+
excludedFor: excluded,
|
|
4175
|
+
},
|
|
4176
|
+
]
|
|
4177
|
+
: perRound.map((r) => ({
|
|
4178
|
+
worked: r.worked,
|
|
4179
|
+
failedAt: r.failedAt,
|
|
4180
|
+
passingGates: r.passingGates,
|
|
4181
|
+
tree: r.treeFingerprint,
|
|
4182
|
+
failure: r.failure,
|
|
4183
|
+
/**
|
|
4184
|
+
* 🔴 THE ROUND'S OWN WORK LIST, NOT THE RUN'S FINAL ONE.
|
|
4185
|
+
*
|
|
4186
|
+
* Every record used to be built from ONE snapshot taken after the
|
|
4187
|
+
* run ended, so all N carried the same progress fingerprint — and
|
|
4188
|
+
* `noProgress` compares those for equality and never looks at the
|
|
4189
|
+
* outcome. Three rounds was therefore enough, unconditionally: a
|
|
4190
|
+
* run that picked three items, gated them green and committed all
|
|
4191
|
+
* three recorded that it had achieved nothing three times, and
|
|
4192
|
+
* held its own goal. Measured.
|
|
4193
|
+
*/
|
|
4194
|
+
work: r.work,
|
|
4195
|
+
excludedFor: r.metUnavailableEnvironment && !r.worked ? 'environment_unavailable' : excluded,
|
|
4196
|
+
}));
|
|
4197
|
+
/**
|
|
4198
|
+
* 🔴 THE HIGH-WATER MARK OF GATES THAT HAVE PASSED, ACROSS THIS RUN.
|
|
4199
|
+
*
|
|
4200
|
+
* `passingGates` feeds the progress fingerprint, and `noProgress` fires
|
|
4201
|
+
* only on three IDENTICAL fingerprints in a row. Fed the raw per-round
|
|
4202
|
+
* set, one flaky step — a timeout, a port in use, a slow container —
|
|
4203
|
+
* changes the set every round, so three identical fingerprints never
|
|
4204
|
+
* happen and ALL FOUR detectors are off for a pursuit that is stuck.
|
|
4205
|
+
*
|
|
4206
|
+
* A union only ever grows: a step passing for the first time is progress,
|
|
4207
|
+
* a step flapping is not. Seeded empty per run, which is the honest bound
|
|
4208
|
+
* — the observations are durable but their vectors are not, so this is a
|
|
4209
|
+
* within-run high-water mark and is described as one.
|
|
4210
|
+
*/
|
|
4211
|
+
const gatesEverPassed = new Set();
|
|
4212
|
+
for (const rec of records) {
|
|
4213
|
+
/**
|
|
4214
|
+
* ⚠️ AN EXCLUDED ATTEMPT MINTS NOTHING. `observationFor` returns null for
|
|
4215
|
+
* one, and the mint used to happen as an argument to it — so the claim
|
|
4216
|
+
* file was written and the observation thrown away. Those claims
|
|
4217
|
+
* accumulate above the last observation and are never reclaimed, which is
|
|
4218
|
+
* how the mint's candidate window came to be burnt through. Asking the
|
|
4219
|
+
* question first costs a comparison.
|
|
4220
|
+
*/
|
|
4221
|
+
if (!(0, core_4.countsTowardSafety)(rec.excludedFor))
|
|
4222
|
+
continue;
|
|
4223
|
+
const observation = (0, loop_safety_observer_1.observationFor)({
|
|
4224
|
+
goalId: binding.goal.id,
|
|
4225
|
+
goalVersion: binding.goal.version,
|
|
4226
|
+
// Not parsed out of a request id — this repository refuses to derive
|
|
4227
|
+
// anything from one. It counts on from the last attempt rather than
|
|
4228
|
+
// counting the history, which saturates once the history is trimmed.
|
|
4229
|
+
ordinal: await safety.mintOrdinal(binding.goal.id),
|
|
4230
|
+
runId,
|
|
4231
|
+
/**
|
|
4232
|
+
* 🔴 FOUR OUTCOMES, NOT TWO. This was
|
|
4233
|
+
* `committed > 0 ? 'worked' : 'gate_failed'`, so `verifier_failed` and
|
|
4234
|
+
* `action_rejected` had no production producer at all — a four-value
|
|
4235
|
+
* enum with two unreachable values, making every observation coarser
|
|
4236
|
+
* than it looked. A round the PROVER refused was recorded as "a
|
|
4237
|
+
* deterministic gate refused it", which is a different fact about a
|
|
4238
|
+
* different mechanism.
|
|
4239
|
+
*
|
|
4240
|
+
* ⚠️ AND AN UNCLASSIFIED FAILURE IS STILL `gate_failed`, which is what
|
|
4241
|
+
* the run-level path has always produced — so the default behaviour is
|
|
4242
|
+
* unchanged and only the newly-distinguishable cases move.
|
|
4243
|
+
*/
|
|
4244
|
+
outcome: rec.worked
|
|
4245
|
+
? 'worked'
|
|
4246
|
+
: rec.failedAt === 'verifier'
|
|
4247
|
+
? 'verifier_failed'
|
|
4248
|
+
: rec.failedAt === 'action'
|
|
4249
|
+
? 'action_rejected'
|
|
4250
|
+
: 'gate_failed',
|
|
4251
|
+
pursuit: {
|
|
4252
|
+
items: rec.work,
|
|
4253
|
+
planRevision: (0, goal_ownership_1.currentPlanRevision)(rec.work, binding.goal.id, binding.goal.version),
|
|
4254
|
+
},
|
|
4255
|
+
...(rec.passingGates === undefined ? {} : { passingGates: rec.passingGates }),
|
|
4256
|
+
// Everything that had already passed before this round. See the set's
|
|
4257
|
+
// declaration above: the union is what makes the contribution monotone.
|
|
4258
|
+
gatesEverPassed: [...gatesEverPassed],
|
|
4259
|
+
treeFingerprint: rec.tree,
|
|
4260
|
+
/**
|
|
4261
|
+
* 🔴 THIS WAS A LITERAL `null` AND SO `repeated_failure` COULD NEVER
|
|
4262
|
+
* FIRE — a whole detector, fully tested against hand-built inputs,
|
|
4263
|
+
* unreachable because the one producer never fed it. Found by an
|
|
4264
|
+
* adversarial review, not by any gate.
|
|
4265
|
+
*
|
|
4266
|
+
* Signed only when the run actually hit a deterministic failure.
|
|
4267
|
+
* Absent means "none", which is different from "one nobody could
|
|
4268
|
+
* identify" — the latter arrives as a real signature with a null digest.
|
|
4269
|
+
*/
|
|
4270
|
+
failureSignature: rec.failure === null ? null : (0, core_1.failureSignature)(rec.failure),
|
|
4271
|
+
actionFingerprint: null,
|
|
4272
|
+
excluded: rec.excludedFor,
|
|
4273
|
+
at: new Date().toISOString(),
|
|
4274
|
+
});
|
|
4275
|
+
if (observation !== null) {
|
|
4276
|
+
const verdict = await safety.observe(observation);
|
|
4277
|
+
if (verdict.kind === 'hold') {
|
|
4278
|
+
// Recorded, not enforced here: this run has already done its work.
|
|
4279
|
+
// The hold is what the NEXT tick and the next Start will meet.
|
|
4280
|
+
await safety.raise({
|
|
4281
|
+
goalId: binding.goal.id,
|
|
4282
|
+
goalVersion: binding.goal.version,
|
|
4283
|
+
kind: verdict.hold,
|
|
4284
|
+
because: verdict.because,
|
|
4285
|
+
evidence: verdict.evidence,
|
|
4286
|
+
});
|
|
4287
|
+
log(`safety : ${verdict.because}`);
|
|
4288
|
+
}
|
|
4289
|
+
}
|
|
4290
|
+
// AFTER the observation, so this round is measured against what had
|
|
4291
|
+
// already passed BEFORE it. Updating first would fold this round's own
|
|
4292
|
+
// gates into its own baseline and make every round look like no progress.
|
|
4293
|
+
for (const step of rec.passingGates ?? [])
|
|
4294
|
+
gatesEverPassed.add(step);
|
|
4295
|
+
}
|
|
4296
|
+
}
|
|
4297
|
+
/**
|
|
4298
|
+
* And what a person could read about it later.
|
|
4299
|
+
*
|
|
4300
|
+
* The safety brake above stops the machine; this is what stops the NEXT
|
|
4301
|
+
* plan proposing the same thing. They are deliberately separate: safety is
|
|
4302
|
+
* authority over whether to spend, memory is only evidence — and P11.22 is
|
|
4303
|
+
* explicit that memory must never become loop-safety truth.
|
|
4304
|
+
*/
|
|
4305
|
+
if (summary.lastFailure !== undefined && binding.kind === 'bound') {
|
|
4306
|
+
await rememberFailedAttempt(memoryStore, memoryKey, {
|
|
4307
|
+
runId,
|
|
4308
|
+
goalId: binding.goal.id,
|
|
4309
|
+
// The pursuit, not the item: an approach outlives the item that carried
|
|
4310
|
+
// it, and the next plan is written against the goal.
|
|
4311
|
+
subject: binding.goal.text,
|
|
4312
|
+
failureStep: summary.lastFailure.step,
|
|
4313
|
+
});
|
|
4314
|
+
}
|
|
4315
|
+
/**
|
|
4316
|
+
* SETTLED FIRST, THROWN SECOND, and never the other way round.
|
|
4317
|
+
*
|
|
4318
|
+
* The run has met a consequential action it could not file a question
|
|
4319
|
+
* about. That is a failure and must be reported as one — but the ledger is
|
|
4320
|
+
* the only account of what happened, and throwing before `run.ended` was
|
|
4321
|
+
* written would leave a run that looks live for ever to every reader on
|
|
4322
|
+
* both sides. So the ending is durable by the time this line runs, and it
|
|
4323
|
+
* says `aborted`: a failure this process could not continue past, which is
|
|
4324
|
+
* precisely what it is. What it must never say is that somebody is being
|
|
4325
|
+
* asked, because nobody is.
|
|
4326
|
+
*
|
|
4327
|
+
* A PLAIN ERROR, deliberately not a refusal. The likeliest cause is that
|
|
4328
|
+
* the server has not yet been told about this run, or could not be reached
|
|
4329
|
+
* at all — both of which the next attempt may well get past. A terminal
|
|
4330
|
+
* refusal would spend the pursuit permanently on a problem that fixes
|
|
4331
|
+
* itself.
|
|
4332
|
+
*/
|
|
4333
|
+
if (approvalFailure !== null) {
|
|
4334
|
+
await ledgerForRun.append({
|
|
4335
|
+
ts: new Date().toISOString(),
|
|
4336
|
+
runId,
|
|
4337
|
+
projectId,
|
|
4338
|
+
lane: 'feature',
|
|
4339
|
+
kind: 'alert',
|
|
4340
|
+
actor: 'system',
|
|
4341
|
+
// Critical for the same reason a dead prover is: nothing else reaches a
|
|
4342
|
+
// phone from a detached run, and a run that keeps meeting an action it
|
|
4343
|
+
// cannot ask about will do it again tonight and every night after.
|
|
4344
|
+
data: { code: 'approval-unfiled', severity: 'critical', text: approvalFailure },
|
|
4345
|
+
});
|
|
4346
|
+
throw new Error(approvalFailure);
|
|
4347
|
+
}
|
|
4348
|
+
return summary;
|
|
4349
|
+
}
|
|
4350
|
+
finally {
|
|
4351
|
+
// Before the lease is released, so nothing can start a new run while an
|
|
4352
|
+
// agent from this one is still writing to the tree.
|
|
4353
|
+
const killed = children.killAll();
|
|
4354
|
+
if (killed > 0)
|
|
4355
|
+
log(`run : stopped ${killed} agent process(es) this run had started.`);
|
|
4356
|
+
// Released whatever happened, including on the way out of a throw: a lease
|
|
4357
|
+
// left behind is recoverable (the next process finds the pid dead) but it
|
|
4358
|
+
// makes an ordinary failure look like a crash.
|
|
4359
|
+
await releaseHold();
|
|
4360
|
+
signals.uninstall();
|
|
4361
|
+
// Stopping the sync flushes what the last round produced. Without this the
|
|
4362
|
+
// final rounds of a run — including run.ended — could be lost on exit, and
|
|
4363
|
+
// the phone would show a run that appears to still be going forever.
|
|
4364
|
+
if (sync) {
|
|
4365
|
+
await sync.stop();
|
|
4366
|
+
// A stream that died an hour ago said so an hour ago, and that line has
|
|
4367
|
+
// scrolled away. The end of the run is where someone looks to find out
|
|
4368
|
+
// whether their phone saw any of it.
|
|
4369
|
+
const { buffered, dropped, unacked } = sync.daemon.status();
|
|
4370
|
+
if (!sync.alive()) {
|
|
4371
|
+
log(`sync : ${buffered} events never reached your phone. \`looply report\` still has the run.`);
|
|
4372
|
+
}
|
|
4373
|
+
// The cap sheds the *oldest* events, so an overflowed mirror reads as a
|
|
4374
|
+
// run that started late rather than one that is missing anything — the
|
|
4375
|
+
// only place that reads honestly is a line saying how many went.
|
|
4376
|
+
if (dropped > 0) {
|
|
4377
|
+
log(`sync : ${dropped} events were dropped while the server was away — your phone's copy has a gap.`);
|
|
4378
|
+
}
|
|
4379
|
+
// A different failure from the two above, and the quietest one: the
|
|
4380
|
+
// server answered every request successfully and kept fewer events than
|
|
4381
|
+
// it was handed. Nothing about the run looks wrong from here, so if this
|
|
4382
|
+
// is not said, the only symptom is a phone showing a project at rest
|
|
4383
|
+
// while a run is plainly happening in this terminal.
|
|
4384
|
+
if (unacked > 0) {
|
|
4385
|
+
log(`sync : ${unacked} events reached the server but were not stored. Your phone's copy is incomplete;`);
|
|
4386
|
+
log(' `looply report` still has the whole run.');
|
|
4387
|
+
}
|
|
4388
|
+
}
|
|
4389
|
+
/**
|
|
4390
|
+
* THE LAST THING THIS RUN DOES, and it has to be last.
|
|
4391
|
+
*
|
|
4392
|
+
* The creation marker names THIS PROCESS, and it is the checkout's only
|
|
4393
|
+
* cover once `releaseHold` has given back the lease and the registry
|
|
4394
|
+
* record — which happens at the TOP of this block, while there is still
|
|
4395
|
+
* work below it. Clearing the marker any earlier reopened a window where a
|
|
4396
|
+
* concurrent `--reap` saw no lease, no record and no marker.
|
|
4397
|
+
*
|
|
4398
|
+
* ⚠️ BUT IT MUST BE CLEARED. Left behind, it is a permanent pid-shaped
|
|
4399
|
+
* claim on a finished checkout: once that pid is recycled the workspace
|
|
4400
|
+
* reads as busy for ever, invisible to `looply runs --reap` with nothing a
|
|
4401
|
+
* person could point at. The lease has the same weakness and is cleared for
|
|
4402
|
+
* the same reason.
|
|
4403
|
+
*/
|
|
4404
|
+
/**
|
|
4405
|
+
* ⚠️ SURFACES GO BACK BEFORE THE START MARKER, and before anything else in
|
|
4406
|
+
* this teardown can throw. A surface held by a finished run blocks every
|
|
4407
|
+
* later run that declares it until the TTL lapses — six hours of a project
|
|
4408
|
+
* refusing to schedule itself, for a directory nobody is in.
|
|
4409
|
+
*
|
|
4410
|
+
* `releaseSurface` marks rather than deletes, so the ordinal still stands
|
|
4411
|
+
* and no later holder can be handed the same fence.
|
|
4412
|
+
*/
|
|
4413
|
+
for (const fence of heldSurfaces)
|
|
4414
|
+
(0, surface_lease_1.releaseSurface)(cwd, fence, runId);
|
|
4415
|
+
(0, workspace_1.clearCreationMarker)(stateDir);
|
|
4416
|
+
}
|
|
4417
|
+
}
|
|
4418
|
+
//# sourceMappingURL=run-cmd.js.map
|