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
|
@@ -0,0 +1,2026 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.RunRegistry = exports.WorkspaceError = exports.nodeProbe = exports.WORKSPACES_DIR = void 0;
|
|
4
|
+
exports.forgetCreationMarker = forgetCreationMarker;
|
|
5
|
+
exports.workspacesRoot = workspacesRoot;
|
|
6
|
+
exports.requestControlEverywhere = requestControlEverywhere;
|
|
7
|
+
exports.createWorkspace = createWorkspace;
|
|
8
|
+
exports.reapableCheckout = reapableCheckout;
|
|
9
|
+
exports.reapVerdict = reapVerdict;
|
|
10
|
+
exports.workspaceResidue = workspaceResidue;
|
|
11
|
+
exports.retainEvidence = retainEvidence;
|
|
12
|
+
exports.reapWorkspace = reapWorkspace;
|
|
13
|
+
exports.abandonedWorkspaces = abandonedWorkspaces;
|
|
14
|
+
exports.checkoutBusy = checkoutBusy;
|
|
15
|
+
exports.creationMarkerHolder = creationMarkerHolder;
|
|
16
|
+
exports.clearCreationMarker = clearCreationMarker;
|
|
17
|
+
exports.clearAddressedControl = clearAddressedControl;
|
|
18
|
+
exports.reapAbandoned = reapAbandoned;
|
|
19
|
+
const node_fs_1 = require("node:fs");
|
|
20
|
+
const checkout_carry_1 = require("./checkout-carry");
|
|
21
|
+
const node_crypto_1 = require("node:crypto");
|
|
22
|
+
const node_os_1 = require("node:os");
|
|
23
|
+
const node_path_1 = require("node:path");
|
|
24
|
+
const git_driver_1 = require("./git-driver");
|
|
25
|
+
const trusted_git_1 = require("./trusted-git");
|
|
26
|
+
const run_lease_1 = require("./run-lease");
|
|
27
|
+
const control_file_1 = require("./control-file");
|
|
28
|
+
const workspace_residue_sweep_1 = require("./workspace-residue-sweep");
|
|
29
|
+
const workspace_quarantine_1 = require("./workspace-quarantine");
|
|
30
|
+
/**
|
|
31
|
+
* WHERE A SECOND EXECUTION LIVES, AND WHO IS ALLOWED TO SAY SO.
|
|
32
|
+
*
|
|
33
|
+
* Phase 1 makes two executions safe by giving each its own checkout and its own
|
|
34
|
+
* execution state directory, rather than by making a dozen single-writer stores
|
|
35
|
+
* multi-writer. That is the cheaper half of a decision the recon argued at
|
|
36
|
+
* length: every store already takes `stateDir` as its first argument, so N state
|
|
37
|
+
* directories are already expressible — what was missing was a place to put
|
|
38
|
+
* them and something that knows they exist.
|
|
39
|
+
*
|
|
40
|
+
* TWO THINGS LIVE HERE AND THEY ARE DELIBERATELY SEPARATE:
|
|
41
|
+
*
|
|
42
|
+
* the WORKSPACE a linked git worktree plus its own `.looply/state`. Created
|
|
43
|
+
* through `GitDriver`, so it inherits every Phase 0/0.5
|
|
44
|
+
* defence rather than reimplementing a safe-enough subset.
|
|
45
|
+
* the REGISTRY one exclusively-created file per live execution, under the
|
|
46
|
+
* PROJECT's state directory. It is what makes capacity
|
|
47
|
+
* countable, `status` aggregable, and an orphan identifiable.
|
|
48
|
+
*
|
|
49
|
+
* ⚠️ THE REGISTRY IS NOT A LOCK. The run lease is still the mutual exclusion,
|
|
50
|
+
* still one per execution state directory, and still the thing that decides
|
|
51
|
+
* whether a checkout is busy. The registry answers a different question — "what
|
|
52
|
+
* else is running for this project" — which no lease can answer because leases
|
|
53
|
+
* cannot see each other.
|
|
54
|
+
*/
|
|
55
|
+
/** How a workspace's own directory is named under the workspaces root. */
|
|
56
|
+
exports.WORKSPACES_DIR = 'workspaces';
|
|
57
|
+
/**
|
|
58
|
+
* Clear a workspace's creation marker, by name.
|
|
59
|
+
*
|
|
60
|
+
* 🔴 A MARKER WHOSE PID IS RECYCLED PINS ITS WORKSPACE AS "in use" FOR EVER.
|
|
61
|
+
* `looply runs` shows it, `--reap` refuses it because something appears to be
|
|
62
|
+
* using it, and nothing else can ever say otherwise — the same permanent wedge
|
|
63
|
+
* a recycled pid makes of a slot, in a place with no `--release` to reach it.
|
|
64
|
+
*
|
|
65
|
+
* ⚠️ IT CLEARS THE MARKER, NOT THE CHECKOUT. Afterwards the workspace is
|
|
66
|
+
* ordinary: `--reap` judges it on residue and evidence like any other, and will
|
|
67
|
+
* still keep it if it holds a person's files.
|
|
68
|
+
*/
|
|
69
|
+
function forgetCreationMarker(cwd, name) {
|
|
70
|
+
/**
|
|
71
|
+
* ⚠️ A DIRECTORY BASENAME, NOT A RUN ID. This used `safeRunId`, which THREW on
|
|
72
|
+
* anything outside `[A-Za-z0-9_-]` — so a directory under the workspaces root
|
|
73
|
+
* with a dot in its name was both listed as busy and permanently unclearable,
|
|
74
|
+
* and typing its name crashed the command instead of answering it.
|
|
75
|
+
*
|
|
76
|
+
* The requirement is only that it name ONE directory directly under the
|
|
77
|
+
* workspaces root: no separators, nothing that could climb.
|
|
78
|
+
*/
|
|
79
|
+
if (name === '' || name === '.' || name === '..' || /[\/]/.test(name)) {
|
|
80
|
+
throw new WorkspaceError(`${JSON.stringify(name)} is not the name of a workspace`);
|
|
81
|
+
}
|
|
82
|
+
const at = (0, node_path_1.join)(workspacesRoot(cwd), name, git_driver_1.STATE_DIR, 'state', CREATING);
|
|
83
|
+
try {
|
|
84
|
+
(0, node_fs_1.unlinkSync)(at);
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
catch (e) {
|
|
88
|
+
if (e.code === 'ENOENT')
|
|
89
|
+
return false;
|
|
90
|
+
throw e;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Where every workspace for a project lives — OUTSIDE the repository.
|
|
95
|
+
*
|
|
96
|
+
* 🔴 IT WAS INSIDE, AND THAT WAS WRONG. The first version put workspaces under
|
|
97
|
+
* `<root>/.looply/workspaces`, and `.gitignore` covers `.looply/state/` and
|
|
98
|
+
* `.looply/runs/` but not that. Measured: creating one workspace turned the main
|
|
99
|
+
* checkout's `git status --porcelain` into `?? .looply/`. Looply's own dirty
|
|
100
|
+
* check would not have noticed — `changedPaths` filters `.looply/**` — but a
|
|
101
|
+
* PERSON would, and a person who then typed `git add -A` would commit an entire
|
|
102
|
+
* second checkout into their repository.
|
|
103
|
+
*
|
|
104
|
+
* `~/.looply` is where this codebase already puts state that must survive the
|
|
105
|
+
* repository, and for the same stated reason: `git clean -fd` would delete
|
|
106
|
+
* in-tree credentials. A workspace has exactly that property.
|
|
107
|
+
*
|
|
108
|
+
* ⚠️ KEYED BY A HASH OF THE MAIN WORKTREE PATH, not by the project's name. Two
|
|
109
|
+
* checkouts of the same repository in two places are two projects here, which is
|
|
110
|
+
* the same identity rule `containerName` and `providerSessionDir` already use.
|
|
111
|
+
*/
|
|
112
|
+
function workspacesRoot(cwd, home = (0, node_os_1.homedir)()) {
|
|
113
|
+
/**
|
|
114
|
+
* 🔴 THE SAME IDENTITY THE REGISTRY USES, and it briefly was not.
|
|
115
|
+
*
|
|
116
|
+
* `projectStateDir` learned to fall back to the common dir for a repository
|
|
117
|
+
* with no main worktree — `clone --bare` plus two `worktree add`s — so those
|
|
118
|
+
* checkouts share ONE registry. This still keyed on `mainWorktreeRoot ??
|
|
119
|
+
* cwd`, which is null there, so each checkout got its OWN workspaces root
|
|
120
|
+
* while sharing the records. A record written from checkout A then named a
|
|
121
|
+
* path under A's root, and a `--reap` run from B computed B's root and
|
|
122
|
+
* refused it as "not under this project's workspaces root" — for ever, with a
|
|
123
|
+
* reason that was true and useless.
|
|
124
|
+
*
|
|
125
|
+
* ⚠️ AN ORDINARY CHECKOUT AND A LINKED WORKTREE ARE UNCHANGED:
|
|
126
|
+
* `repoIdentityPath` answers the main worktree root for both, which is
|
|
127
|
+
* exactly what this hashed before.
|
|
128
|
+
*/
|
|
129
|
+
const root = (0, trusted_git_1.repoIdentityPath)(cwd);
|
|
130
|
+
const key = (0, node_crypto_1.createHash)('sha256').update(root.toLowerCase(), 'utf8').digest('hex').slice(0, 16);
|
|
131
|
+
return (0, node_path_1.join)(home, '.looply', exports.WORKSPACES_DIR, key);
|
|
132
|
+
}
|
|
133
|
+
exports.nodeProbe = run_lease_1.nodeProbe;
|
|
134
|
+
/**
|
|
135
|
+
* Errno values that mean "somebody else got there", not "something is broken".
|
|
136
|
+
*
|
|
137
|
+
* 🔴 `EEXIST` ALONE IS NOT ENOUGH ON WINDOWS, and this was measured rather than
|
|
138
|
+
* assumed. An exclusive create against a path another process is deleting at
|
|
139
|
+
* that instant fails with **EPERM**, because the file sits in a pending-delete
|
|
140
|
+
* state where it can be neither opened nor re-created. Under 240 concurrent
|
|
141
|
+
* claims it happened 4 times, and each one threw straight out of `claim` and
|
|
142
|
+
* killed the run with a raw filesystem error instead of refusing politely.
|
|
143
|
+
*
|
|
144
|
+
* Every one of these reads as losing a race, which is the conservative answer in
|
|
145
|
+
* all three places it is used: the slot is not taken, the reclaim token is not
|
|
146
|
+
* held, and the stale file is not removed.
|
|
147
|
+
*/
|
|
148
|
+
const LOST_THE_RACE = new Set(['EEXIST', 'EPERM', 'EACCES', 'EBUSY']);
|
|
149
|
+
/**
|
|
150
|
+
* A runId that is safe to use as a PATH SEGMENT.
|
|
151
|
+
*
|
|
152
|
+
* ⚠️ RUN IDS REACH THIS FILE FROM TWO UNTRUSTED DIRECTIONS, and both end in a
|
|
153
|
+
* `join` followed by an `unlink` or a recursive copy:
|
|
154
|
+
*
|
|
155
|
+
* from disk a registry record is a JSON file. Anything that can write the
|
|
156
|
+
* project's state directory can put `../../..` in a `runId`.
|
|
157
|
+
* from a person `looply runs --release <name>` passes what was typed straight
|
|
158
|
+
* through to `forget`.
|
|
159
|
+
*
|
|
160
|
+
* `newId('run')` only ever mints `[A-Za-z0-9_]`, so nothing legitimate is
|
|
161
|
+
* refused. This is not defence against the owner of the machine — it is defence
|
|
162
|
+
* against a path escaping the one directory these functions are allowed to
|
|
163
|
+
* touch, which is the same rule the reaper's realpath containment enforces for
|
|
164
|
+
* workspaces.
|
|
165
|
+
*/
|
|
166
|
+
const RUN_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
|
167
|
+
function safeRunId(runId) {
|
|
168
|
+
if (!RUN_ID.test(runId)) {
|
|
169
|
+
throw new WorkspaceError(`${JSON.stringify(runId)} is not a run id this can address`);
|
|
170
|
+
}
|
|
171
|
+
return runId;
|
|
172
|
+
}
|
|
173
|
+
class WorkspaceError extends Error {
|
|
174
|
+
}
|
|
175
|
+
exports.WorkspaceError = WorkspaceError;
|
|
176
|
+
/**
|
|
177
|
+
* The registry of live executions for one project.
|
|
178
|
+
*
|
|
179
|
+
* Entries are one file each, created with `wx`, exactly as the run lease is —
|
|
180
|
+
* the filesystem decides the winner, no read-then-write, and a crash leaves a
|
|
181
|
+
* file that can be judged rather than a lock nobody can clear.
|
|
182
|
+
*/
|
|
183
|
+
class RunRegistry {
|
|
184
|
+
projectState;
|
|
185
|
+
probe;
|
|
186
|
+
constructor(projectState, probe = exports.nodeProbe) {
|
|
187
|
+
this.projectState = projectState;
|
|
188
|
+
this.probe = probe;
|
|
189
|
+
}
|
|
190
|
+
get dir() {
|
|
191
|
+
return (0, node_path_1.join)(this.projectState, 'runs');
|
|
192
|
+
}
|
|
193
|
+
pathFor(runId) {
|
|
194
|
+
return (0, node_path_1.join)(this.dir, `${safeRunId(runId)}.json`);
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Claim a slot. Throws if this runId is already registered.
|
|
198
|
+
*
|
|
199
|
+
* ⚠️ EXCLUSIVE CREATE, not "check then write". Two executions starting in the
|
|
200
|
+
* same moment must not both believe they took the last slot, and only the
|
|
201
|
+
* filesystem can arbitrate that without a lock of our own.
|
|
202
|
+
*/
|
|
203
|
+
register(record) {
|
|
204
|
+
(0, node_fs_1.mkdirSync)(this.dir, { recursive: true });
|
|
205
|
+
try {
|
|
206
|
+
(0, node_fs_1.writeFileSync)(this.pathFor(record.runId), `${JSON.stringify(record, null, 2)}\n`, { flag: 'wx' });
|
|
207
|
+
}
|
|
208
|
+
catch (e) {
|
|
209
|
+
if (e.code === 'EEXIST') {
|
|
210
|
+
throw new WorkspaceError(`this run is already registered: ${record.runId}`);
|
|
211
|
+
}
|
|
212
|
+
throw e;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Take one of `limit` slots, or refuse. THIS is the bound.
|
|
217
|
+
*
|
|
218
|
+
* 🔴 COUNTING WAS CHECK-THEN-ACT AND CLAIMED NOT TO BE. `capacityVerdict`
|
|
219
|
+
* read `active()`, judged, and only then did anything get created — and the
|
|
220
|
+
* exclusive `wx` this class pointed at as "the arbitration" is keyed on
|
|
221
|
+
* `runId`, minted fresh per run. Two runs starting together could never
|
|
222
|
+
* collide on that name, so both passed the count and both registered. Nothing
|
|
223
|
+
* upstream helped: two workspaces hold two different leases, and a lease
|
|
224
|
+
* cannot see another checkout.
|
|
225
|
+
*
|
|
226
|
+
* The SLOT is the exclusive thing. There are at most `limit` slot names, so at
|
|
227
|
+
* most `limit` runs can hold one, and the filesystem decides — exactly as it
|
|
228
|
+
* does for the run lease and the override marker.
|
|
229
|
+
*
|
|
230
|
+
* ⚠️ A SLOT HELD BY A DEAD PROCESS IS RECLAIMED, not counted. Otherwise one
|
|
231
|
+
* crash would lower this machine's concurrency until somebody noticed, which
|
|
232
|
+
* is the capacity leak this exists to avoid.
|
|
233
|
+
*/
|
|
234
|
+
claim(record, limit) {
|
|
235
|
+
(0, node_fs_1.mkdirSync)(this.dir, { recursive: true });
|
|
236
|
+
/**
|
|
237
|
+
* 🔴 TAKING A SLOT AND DECIDING WHETHER TO KEEP IT MUST BE ONE STEP.
|
|
238
|
+
*
|
|
239
|
+
* `settle` counts the other holders after winning an exclusive create — the
|
|
240
|
+
* only place the answer cannot be raced by another `wx`. But two claimants
|
|
241
|
+
* BETWEEN their own take and their own count each see a world without the
|
|
242
|
+
* other, and both keep. The first version tried to break that tie by slot
|
|
243
|
+
* number, which works only when the lower-numbered claimant takes first; a
|
|
244
|
+
* holder that vanishes mid-claim reverses the order and both keep.
|
|
245
|
+
*
|
|
246
|
+
* A count cannot be made atomic, so the WINDOW is closed instead: one
|
|
247
|
+
* claimant at a time is between its take and its decision. Contention is
|
|
248
|
+
* brief — a few filesystem calls — and rare, because starting a run is not a
|
|
249
|
+
* hot path.
|
|
250
|
+
*
|
|
251
|
+
* ⚠️ AND THE LOCK IS NOT WHAT MAKES THE BOUND HOLD, which an earlier version
|
|
252
|
+
* of this comment implied. `settle` counts the other holders AFTER its own
|
|
253
|
+
* exclusive create, so a later claimant always sees an earlier one and the
|
|
254
|
+
* total can only ever climb to the limit — without any lock at all. What the
|
|
255
|
+
* lock prevents is the opposite failure: several claimants counting at the
|
|
256
|
+
* same instant, all seeing a full machine, and ALL backing out, leaving it
|
|
257
|
+
* idle below its own limit. That distinction decides what may break a stale
|
|
258
|
+
* lock, and the answer is: anything that cannot name a live owner.
|
|
259
|
+
*
|
|
260
|
+
* ⚠️ AND A LOCK LEFT BY A CRASH IS BROKEN ONLY IF ITS OWNER IS PROVEN DEAD,
|
|
261
|
+
* exactly as the reclaim token is. Otherwise one crash mid-claim would stop
|
|
262
|
+
* this project ever starting a run again.
|
|
263
|
+
*/
|
|
264
|
+
const lock = (0, node_path_1.join)(this.dir, 'claim.lock');
|
|
265
|
+
if (!this.waitForToken(lock)) {
|
|
266
|
+
return {
|
|
267
|
+
ok: false,
|
|
268
|
+
because: 'another start is deciding whether it can run, and did not finish. ' +
|
|
269
|
+
'Try again; if this repeats, `looply runs` will show what is holding on',
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
try {
|
|
273
|
+
return this.claimUnderLock(record, limit);
|
|
274
|
+
}
|
|
275
|
+
finally {
|
|
276
|
+
/**
|
|
277
|
+
* 🔴 BY NAME, NOT BY PATH. This unlinked whatever lock was there, so once
|
|
278
|
+
* ANY break had happened — and a measured run of the previous version
|
|
279
|
+
* broke 155 live owners' locks — a second claimant's exit deleted a third
|
|
280
|
+
* claimant's lock, and the loss cascaded: up to three processes were
|
|
281
|
+
* observed inside the critical section at once.
|
|
282
|
+
*
|
|
283
|
+
* Every other release in this class is keyed on identity. This one is now
|
|
284
|
+
* too, for the same reason and by the same rule.
|
|
285
|
+
*/
|
|
286
|
+
this.dropToken(lock);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
/** Wait a short while for the claim lock, breaking one a dead process left. */
|
|
290
|
+
waitForToken(lock) {
|
|
291
|
+
const wait = new Int32Array(new SharedArrayBuffer(4));
|
|
292
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
293
|
+
if (this.takeToken(lock))
|
|
294
|
+
return true;
|
|
295
|
+
// 20ms, synchronously. `claim` is sync by design — every one of its
|
|
296
|
+
// decisions is a filesystem fact — and this is the only place it waits.
|
|
297
|
+
Atomics.wait(wait, 0, 0, 20);
|
|
298
|
+
}
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
claimUnderLock(record, limit) {
|
|
302
|
+
const held = [];
|
|
303
|
+
/**
|
|
304
|
+
* ⚠️ THERE IS NO PRE-COUNT, AND THERE WAS ONE.
|
|
305
|
+
*
|
|
306
|
+
* It walked every slot file before the scan, so that slots ABOVE the limit —
|
|
307
|
+
* which the `[0, limit)` scan cannot see — still counted. But a count is not
|
|
308
|
+
* arbitration: several starts could all pass it and then all win different
|
|
309
|
+
* in-range slots, which is how five executions ran at a limit of three.
|
|
310
|
+
*
|
|
311
|
+
* `settle` replaced it, and does the same job on the other side of the
|
|
312
|
+
* exclusive create, where the answer cannot be raced. Keeping both would
|
|
313
|
+
* have left the weaker one able to refuse a start the stronger one would
|
|
314
|
+
* have allowed — a stray slot file turning every start into a refusal
|
|
315
|
+
* BEFORE anything had a chance to judge it.
|
|
316
|
+
*/
|
|
317
|
+
for (let slot = 0; slot < limit; slot += 1) {
|
|
318
|
+
const path = this.slotPath(slot);
|
|
319
|
+
if (!(0, node_fs_1.existsSync)(path)) {
|
|
320
|
+
if (this.take(path, record, slot))
|
|
321
|
+
return this.settle(slot, limit, record);
|
|
322
|
+
held.push(this.readSlot(path) ?? 'unreadable');
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
const taken = this.readSlot(path);
|
|
326
|
+
if (taken === null) {
|
|
327
|
+
/**
|
|
328
|
+
* 🔴 UNREADABLE FAILS CLOSED, AND IT USED TO FAIL OPEN.
|
|
329
|
+
*
|
|
330
|
+
* The old code read "cannot parse" as "nobody is using it", deleted the
|
|
331
|
+
* file and took the slot. But a slot file is written by another process
|
|
332
|
+
* and read without coordination, so the commonest way to see an
|
|
333
|
+
* unparseable one is to catch a LIVE claim mid-write — at which point
|
|
334
|
+
* this deletes a running execution's slot and starts a second run past
|
|
335
|
+
* the limit. The run lease, which this class says it mirrors, fails
|
|
336
|
+
* closed on exactly this and calls the state `ambiguous`.
|
|
337
|
+
*
|
|
338
|
+
* The cost is that an unreadable slot holds capacity until a person
|
|
339
|
+
* clears it. That is why `looply runs` lists slots as well as records,
|
|
340
|
+
* and why `--release slot-<n>` exists.
|
|
341
|
+
*/
|
|
342
|
+
held.push('unreadable');
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
if (this.probe(taken.pid) !== 'dead' && this.leaseAgrees(taken)) {
|
|
346
|
+
// ⚠️ BOTH WITNESSES MUST AGREE IT IS STILL RUNNING. A pid that is alive
|
|
347
|
+
// or unjudgeable is not enough on its own — a recycled pid looks exactly
|
|
348
|
+
// like that — and neither is a lease left behind by a crash. Either one
|
|
349
|
+
// saying 'gone' frees the slot; only both saying 'here' holds it.
|
|
350
|
+
held.push(taken);
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
if (!this.reclaim(path, taken)) {
|
|
354
|
+
held.push(this.readSlot(path) ?? 'unreadable');
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
if (this.take(path, record, slot))
|
|
358
|
+
return this.settle(slot, limit, record);
|
|
359
|
+
held.push(this.readSlot(path) ?? 'unreadable');
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* ⚠️ THE REFUSAL COUNTS EVERY HOLDER, not just the ones the scan walked.
|
|
363
|
+
* The scan sees `[0, limit)`; a person who lowered the limit has holders
|
|
364
|
+
* above it, and a message saying "2 executions and the limit is 2" while
|
|
365
|
+
* three were running would be true about the scan and false about the
|
|
366
|
+
* machine.
|
|
367
|
+
*/
|
|
368
|
+
return { ok: false, because: this.atCapacity(this.allHolders(), limit) };
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Having WON a slot, decide whether it may be kept.
|
|
372
|
+
*
|
|
373
|
+
* 🔴 THE OVER-LIMIT COUNT WAS CHECK-THEN-ACT, and it was the only thing that
|
|
374
|
+
* could see slots above the limit. The exclusive `wx` is the arbitration for
|
|
375
|
+
* `[0, limit)` and nothing else, so with two live runs on out-of-range slots
|
|
376
|
+
* and the limit lowered, several starts could all pass the count and then all
|
|
377
|
+
* win DIFFERENT in-range slots. Reproduced: five executions at a limit of
|
|
378
|
+
* three. The fix for one hole opened a smaller one beside it.
|
|
379
|
+
*
|
|
380
|
+
* A count cannot be made atomic, so the decision moves AFTER the exclusive
|
|
381
|
+
* create: take the slot, then look. At most `limit` in-range slots can exist,
|
|
382
|
+
* so what is left to judge is how many holders there are in total.
|
|
383
|
+
*
|
|
384
|
+
* ⚠️ THE TIE-BREAK IS THE SLOT NUMBER, so simultaneous winners do not all back
|
|
385
|
+
* out and leave the machine idle. A holder keeps its slot when the holders
|
|
386
|
+
* BELOW it, plus every out-of-range holder, still leave room; otherwise it
|
|
387
|
+
* gives the slot back and refuses. Deterministic, and it always leaves exactly
|
|
388
|
+
* as many runs as the limit allows.
|
|
389
|
+
*/
|
|
390
|
+
settle(slot, limit, record) {
|
|
391
|
+
const others = this.allHolders(slot);
|
|
392
|
+
if (others.length < limit)
|
|
393
|
+
return { ok: true, slot };
|
|
394
|
+
this.releaseSlot(slot, record.runId);
|
|
395
|
+
return { ok: false, because: this.atCapacity(others, limit) };
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* The claim lock, if one is being held, and by whom.
|
|
399
|
+
*
|
|
400
|
+
* 🔴 A WEDGED LOCK WAS INVISIBLE TO EVERY COMMAND. It is only breakable when
|
|
401
|
+
* its owner is PROVEN dead, so a lock left by a process that is alive but
|
|
402
|
+
* hung — or by one whose pid cannot be judged — denied every start of the
|
|
403
|
+
* project with a message about "another start deciding" and no way to see or
|
|
404
|
+
* clear it. That is the same wedge the slot files had before `looply runs`
|
|
405
|
+
* listed them.
|
|
406
|
+
*/
|
|
407
|
+
claimLock() {
|
|
408
|
+
const path = (0, node_path_1.join)(this.dir, 'claim.lock');
|
|
409
|
+
let text;
|
|
410
|
+
try {
|
|
411
|
+
text = (0, node_fs_1.readFileSync)(path, 'utf8').trim();
|
|
412
|
+
}
|
|
413
|
+
catch {
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
const pid = Number.parseInt(text, 10);
|
|
417
|
+
if (!Number.isInteger(pid))
|
|
418
|
+
return { pid: null, liveness: 'unknown' };
|
|
419
|
+
return { pid, liveness: this.probe(pid) };
|
|
420
|
+
}
|
|
421
|
+
/** Clear a held claim lock. A person's last resort, as `forgetSlot` is. */
|
|
422
|
+
forgetClaimLock() {
|
|
423
|
+
try {
|
|
424
|
+
(0, node_fs_1.unlinkSync)((0, node_path_1.join)(this.dir, 'claim.lock'));
|
|
425
|
+
return true;
|
|
426
|
+
}
|
|
427
|
+
catch (e) {
|
|
428
|
+
if (e.code === 'ENOENT')
|
|
429
|
+
return false;
|
|
430
|
+
throw e;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
/** Every slot that is still held, whatever its number. `except` is skipped. */
|
|
434
|
+
allHolders(except) {
|
|
435
|
+
const out = [];
|
|
436
|
+
for (const s of this.slots()) {
|
|
437
|
+
if (s.slot === except)
|
|
438
|
+
continue;
|
|
439
|
+
if (s.record === null)
|
|
440
|
+
out.push('unreadable');
|
|
441
|
+
else if (this.probe(s.record.pid) !== 'dead' && this.leaseAgrees(s.record))
|
|
442
|
+
out.push(s.record);
|
|
443
|
+
}
|
|
444
|
+
return out;
|
|
445
|
+
}
|
|
446
|
+
atCapacity(held, limit) {
|
|
447
|
+
/**
|
|
448
|
+
* 🔴 A REFUSAL THAT REPORTS FEWER HOLDERS THAN THE LIMIT IS NOT A REFUSAL A
|
|
449
|
+
* PERSON CAN ACT ON. The decision and this message are two observations of
|
|
450
|
+
* the world — the scan judged, then this walks again — and holders can
|
|
451
|
+
* finish in between. "already has 0 execution(s) holding a slot and the
|
|
452
|
+
* limit is 1" is what that looks like, and it reads as a bug in looply
|
|
453
|
+
* rather than as a race that will resolve itself.
|
|
454
|
+
*/
|
|
455
|
+
if (held.length < limit) {
|
|
456
|
+
return ('every slot was taken while this run was deciding, and they have since ' +
|
|
457
|
+
'been given back. Nothing is wrong — start it again');
|
|
458
|
+
}
|
|
459
|
+
const unreadable = held.filter((h) => h === 'unreadable').length;
|
|
460
|
+
const names = held
|
|
461
|
+
.filter((h) => h !== 'unreadable')
|
|
462
|
+
.map((r) => `${r.runId} in ${(0, node_path_1.basename)(r.workspace)}`)
|
|
463
|
+
.join(', ');
|
|
464
|
+
return (`this project already has ${held.length} execution(s) holding a slot and the limit is ${limit}` +
|
|
465
|
+
(names === '' ? '' : ` (${names})`) +
|
|
466
|
+
(unreadable === 0 ? '' : `, and ${unreadable} slot file(s) could not be read`) +
|
|
467
|
+
'. Raise run.max_concurrent_runs, or wait for one to finish. ' +
|
|
468
|
+
'If one of those is not really running, looply runs will say so, and ' +
|
|
469
|
+
'looply runs --release <runId|slot-N> clears it by name');
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Take a free slot. The ONLY way a slot file is ever created.
|
|
473
|
+
*
|
|
474
|
+
* ⚠️ `wx` AND NOTHING ELSE. No read first, no delete first: the filesystem is
|
|
475
|
+
* the arbitration, exactly as it is for the run lease and the override marker.
|
|
476
|
+
*/
|
|
477
|
+
take(path, record, slot) {
|
|
478
|
+
try {
|
|
479
|
+
(0, node_fs_1.writeFileSync)(path, `${JSON.stringify({ ...record, slot }, null, 2)}\n`, { flag: 'wx' });
|
|
480
|
+
return true;
|
|
481
|
+
}
|
|
482
|
+
catch (e) {
|
|
483
|
+
if (LOST_THE_RACE.has(e.code ?? ''))
|
|
484
|
+
return false;
|
|
485
|
+
throw e;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Remove a slot file whose holder is proven gone — or refuse to.
|
|
490
|
+
*
|
|
491
|
+
* 🔴 THE OLD RECLAIM WAS UNLINK-THEN-CREATE, WHICH IS THE CHECK-THEN-ACT THIS
|
|
492
|
+
* WHOLE CLASS EXISTS TO REMOVE. It judged a slot stale, unlinked it BY PATH,
|
|
493
|
+
* and only then tried its exclusive create. Two starters that both saw the
|
|
494
|
+
* same stale slot both unlinked — and the second unlink deleted the FIRST
|
|
495
|
+
* one's freshly created file, so both believed they held slot 0 and both ran.
|
|
496
|
+
* At limit 1. Reproduced with real concurrent processes.
|
|
497
|
+
*
|
|
498
|
+
* Two things fix it, and the second is the one that actually holds:
|
|
499
|
+
*
|
|
500
|
+
* THE TOKEN `slot-<n>.reclaiming`, created with `wx`. At most one
|
|
501
|
+
* process reclaims a given slot at a time, so the common case
|
|
502
|
+
* has no race to lose.
|
|
503
|
+
* THE RE-READ nothing is unlinked unless it is STILL the same record this
|
|
504
|
+
* judged, and still gone. A slot that has been re-taken names
|
|
505
|
+
* a different runId, and a different runId is never removed.
|
|
506
|
+
*
|
|
507
|
+
* The re-read is what makes this correct even if the token were somehow held
|
|
508
|
+
* twice — after a crash, say. The token only makes the correct path the quiet
|
|
509
|
+
* one.
|
|
510
|
+
*
|
|
511
|
+
* ⚠️ A TOKEN LEFT BY A CRASH IS BROKEN ONLY IF ITS OWN PID IS PROVEN DEAD, and
|
|
512
|
+
* then the re-read still decides. Otherwise a crash during reclaim would wedge
|
|
513
|
+
* one slot of this project for ever.
|
|
514
|
+
*/
|
|
515
|
+
reclaim(path, stale) {
|
|
516
|
+
const token = `${path}.reclaiming`;
|
|
517
|
+
if (!this.takeToken(token))
|
|
518
|
+
return false;
|
|
519
|
+
try {
|
|
520
|
+
if (!(0, node_fs_1.existsSync)(path))
|
|
521
|
+
return true;
|
|
522
|
+
const now = this.readSlot(path);
|
|
523
|
+
// Unreadable now: fail closed, exactly as the caller does.
|
|
524
|
+
if (now === null)
|
|
525
|
+
return false;
|
|
526
|
+
// Somebody else's slot now. Never ours to remove.
|
|
527
|
+
if (now.runId !== stale.runId)
|
|
528
|
+
return false;
|
|
529
|
+
if (this.probe(now.pid) !== 'dead' && this.leaseAgrees(now))
|
|
530
|
+
return false;
|
|
531
|
+
try {
|
|
532
|
+
(0, node_fs_1.unlinkSync)(path);
|
|
533
|
+
}
|
|
534
|
+
catch (e) {
|
|
535
|
+
// Gone, or held open by the process that is exiting. Either way this did
|
|
536
|
+
// not take the slot, and saying so is safer than throwing.
|
|
537
|
+
if (!LOST_THE_RACE.has(e.code ?? '') && e.code !== 'ENOENT') {
|
|
538
|
+
throw e;
|
|
539
|
+
}
|
|
540
|
+
if (e.code !== 'ENOENT')
|
|
541
|
+
return false;
|
|
542
|
+
}
|
|
543
|
+
return true;
|
|
544
|
+
}
|
|
545
|
+
finally {
|
|
546
|
+
// By name, as the claim lock is: a token this process does not hold is
|
|
547
|
+
// not this process's to remove.
|
|
548
|
+
this.dropToken(token);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
takeToken(token) {
|
|
552
|
+
const write = () => {
|
|
553
|
+
try {
|
|
554
|
+
(0, node_fs_1.writeFileSync)(token, `${process.pid}\n`, { flag: 'wx' });
|
|
555
|
+
return true;
|
|
556
|
+
}
|
|
557
|
+
catch (e) {
|
|
558
|
+
if (LOST_THE_RACE.has(e.code ?? ''))
|
|
559
|
+
return false;
|
|
560
|
+
throw e;
|
|
561
|
+
}
|
|
562
|
+
};
|
|
563
|
+
if (write())
|
|
564
|
+
return true;
|
|
565
|
+
/**
|
|
566
|
+
* Held. Break it only if it cannot be DEFENDED — its owner is proven dead,
|
|
567
|
+
* or it names no owner at all.
|
|
568
|
+
*
|
|
569
|
+
* 🔴 THE SECOND CASE WAS MISSING, AND IT WEDGED EVERYTHING. `writeFileSync`
|
|
570
|
+
* creates and then writes, so a crash between the two leaves a ZERO-BYTE
|
|
571
|
+
* lock. `parseInt('')` is NaN, NaN is not an integer, so the lock could
|
|
572
|
+
* never be broken — and every `looply run` in that project waited two
|
|
573
|
+
* seconds and refused, for ever, over an empty file. Measured.
|
|
574
|
+
*
|
|
575
|
+
* Breaking an un-owned token is safe in both places it is used. The reclaim
|
|
576
|
+
* token's correctness rests on the RE-READ that follows it, not on the
|
|
577
|
+
* token. And the claim lock is not what makes the bound hold — `settle`
|
|
578
|
+
* counts the other holders AFTER an exclusive create, so a second claimant
|
|
579
|
+
* always sees the first; the lock stops the machine sitting IDLE when
|
|
580
|
+
* several claimants back out together, which is a liveness property, not a
|
|
581
|
+
* safety one.
|
|
582
|
+
*/
|
|
583
|
+
let owner = Number.NaN;
|
|
584
|
+
let judged = '';
|
|
585
|
+
try {
|
|
586
|
+
judged = this.settledText(token);
|
|
587
|
+
const text = judged;
|
|
588
|
+
if (text === '') {
|
|
589
|
+
/**
|
|
590
|
+
* 🔴 AN EMPTY TOKEN IS NOT NECESSARILY ABANDONED, and the first version
|
|
591
|
+
* of this break assumed it was. `writeFileSync(path, data, {flag:'wx'})`
|
|
592
|
+
* opens exclusively and THEN writes, so a LIVE claimant's token is empty
|
|
593
|
+
* for the moment in between — and a second claimant that read it in that
|
|
594
|
+
* moment broke a lock somebody was holding.
|
|
595
|
+
*
|
|
596
|
+
* `settledText` re-reads over a short window first. A live writer fills
|
|
597
|
+
* it in microseconds; a crash between the two syscalls never will.
|
|
598
|
+
*/
|
|
599
|
+
this.breakToken(token, '');
|
|
600
|
+
return write();
|
|
601
|
+
}
|
|
602
|
+
owner = Number.parseInt(text, 10);
|
|
603
|
+
}
|
|
604
|
+
catch (e) {
|
|
605
|
+
// Gone between the failed create and this read: try again.
|
|
606
|
+
if (e.code === 'ENOENT')
|
|
607
|
+
return write();
|
|
608
|
+
return false;
|
|
609
|
+
}
|
|
610
|
+
if (!Number.isInteger(owner)) {
|
|
611
|
+
this.breakToken(token, judged);
|
|
612
|
+
return write();
|
|
613
|
+
}
|
|
614
|
+
if (this.probe(owner) !== 'dead')
|
|
615
|
+
return false;
|
|
616
|
+
this.breakToken(token, judged);
|
|
617
|
+
return write();
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* The token's contents, once they have stopped changing.
|
|
621
|
+
*
|
|
622
|
+
* An exclusive create is two syscalls — open, then write — so a token read in
|
|
623
|
+
* between is empty through no fault of its holder. This re-reads over a short
|
|
624
|
+
* window and only reports empty if it stays empty.
|
|
625
|
+
*/
|
|
626
|
+
settledText(token) {
|
|
627
|
+
const wait = new Int32Array(new SharedArrayBuffer(4));
|
|
628
|
+
for (let attempt = 0;; attempt += 1) {
|
|
629
|
+
const text = (0, node_fs_1.readFileSync)(token, 'utf8').trim();
|
|
630
|
+
if (text !== '' || attempt >= 5)
|
|
631
|
+
return text;
|
|
632
|
+
Atomics.wait(wait, 0, 0, 10);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
/** Give back a token THIS process holds. One held by anybody else is left. */
|
|
636
|
+
dropToken(token) {
|
|
637
|
+
let text;
|
|
638
|
+
try {
|
|
639
|
+
text = (0, node_fs_1.readFileSync)(token, 'utf8').trim();
|
|
640
|
+
}
|
|
641
|
+
catch {
|
|
642
|
+
// Gone, or unreadable. Either way not ours to remove on a guess.
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
if (Number.parseInt(text, 10) !== process.pid)
|
|
646
|
+
return;
|
|
647
|
+
// Pass what was read, so `breakToken` can confirm nothing changed under it.
|
|
648
|
+
this.breakToken(token, text);
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* Remove a token judged un-defendable — and ONLY if it is still the one that
|
|
652
|
+
* was judged.
|
|
653
|
+
*
|
|
654
|
+
* 🔴 THIS UNLINKED BY PATH. Two processes could both judge the same stale lock
|
|
655
|
+
* breakable; the first broke it and won the exclusive create, and the second —
|
|
656
|
+
* still inside its own break — then unlinked the FIRST one's fresh lock and
|
|
657
|
+
* created its own. Both were inside the critical section, and the loss
|
|
658
|
+
* cascaded from there. An adversarial pass measured up to four at once.
|
|
659
|
+
*
|
|
660
|
+
* `judged` is the content this decided about. If the file no longer says that,
|
|
661
|
+
* somebody else has already acted and this must not.
|
|
662
|
+
*/
|
|
663
|
+
breakToken(token, judged) {
|
|
664
|
+
try {
|
|
665
|
+
if ((0, node_fs_1.readFileSync)(token, 'utf8').trim() !== judged)
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
catch {
|
|
669
|
+
return; // gone already, or unreadable: not ours to remove on a guess
|
|
670
|
+
}
|
|
671
|
+
try {
|
|
672
|
+
(0, node_fs_1.unlinkSync)(token);
|
|
673
|
+
}
|
|
674
|
+
catch {
|
|
675
|
+
/* somebody broke it first */
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Give back the slot THIS run holds. A slot held by anybody else is left.
|
|
680
|
+
*
|
|
681
|
+
* 🔴 IT USED TO UNLINK BY INDEX, unconditionally, and that was the worst
|
|
682
|
+
* defect in this file. `releaseHold` gives the lease back BEFORE the slot, and
|
|
683
|
+
* `claim` treats a slot whose holder's lease is gone as free — so between
|
|
684
|
+
* those two lines another run could legitimately reclaim slot N, and this
|
|
685
|
+
* run's teardown would then delete the NEW holder's claim. The bound is
|
|
686
|
+
* exceeded, and it stays exceeded: the same window reopens at every teardown.
|
|
687
|
+
*
|
|
688
|
+
* Every sibling here is keyed on identity — `release(runId)`, `forget(runId)`.
|
|
689
|
+
* This one is now too. Absent is success, and so is a slot that has moved on.
|
|
690
|
+
*/
|
|
691
|
+
releaseSlot(slot, runId) {
|
|
692
|
+
const path = this.slotPath(slot);
|
|
693
|
+
const held = this.readSlot(path);
|
|
694
|
+
if (held === null) {
|
|
695
|
+
// Absent is success. Unreadable is NOT ours to delete: it may be a live
|
|
696
|
+
// claim caught mid-write, and this is the one place that could delete it.
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
if (held.runId !== runId)
|
|
700
|
+
return;
|
|
701
|
+
try {
|
|
702
|
+
(0, node_fs_1.unlinkSync)(path);
|
|
703
|
+
}
|
|
704
|
+
catch (e) {
|
|
705
|
+
if (e.code !== 'ENOENT')
|
|
706
|
+
throw e;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
/** Every slot, as a person needs to see it — including ones nothing can read. */
|
|
710
|
+
slots() {
|
|
711
|
+
let names;
|
|
712
|
+
try {
|
|
713
|
+
names = (0, node_fs_1.readdirSync)(this.dir).filter((n) => n.startsWith('slot-') && n.endsWith('.json'));
|
|
714
|
+
}
|
|
715
|
+
catch {
|
|
716
|
+
return [];
|
|
717
|
+
}
|
|
718
|
+
return (names
|
|
719
|
+
.map((n) => ({
|
|
720
|
+
slot: Number.parseInt(n.slice('slot-'.length), 10),
|
|
721
|
+
record: this.readSlot((0, node_path_1.join)(this.dir, n)),
|
|
722
|
+
path: (0, node_path_1.join)(this.dir, n),
|
|
723
|
+
}))
|
|
724
|
+
/**
|
|
725
|
+
* ⚠️ NUMBERED SLOTS ONLY. `slot-abc.json` parses to `NaN`, and NaN loses
|
|
726
|
+
* every comparison: `NaN >= limit` is false, so such a file would be
|
|
727
|
+
* invisible to the bound's over-limit count while still sitting in the
|
|
728
|
+
* directory. A slot nothing can name is not a slot — but it is still a
|
|
729
|
+
* file, so it is reported by `strayFiles()` below and printed by
|
|
730
|
+
* `looply runs`, rather than silently counted or silently ignored.
|
|
731
|
+
*/
|
|
732
|
+
/**
|
|
733
|
+
* ⚠️ AND THE NUMBER MUST ROUND-TRIP THROUGH THE FILENAME. `parseInt` is
|
|
734
|
+
* lenient: `slot-01.json` parses to 1, and `forgetSlot(1)` then deletes
|
|
735
|
+
* `slot-1.json` — a different file, or none. A slot a person is shown
|
|
736
|
+
* and cannot clear is worse than one that is never shown.
|
|
737
|
+
*/
|
|
738
|
+
.filter((s) => Number.isInteger(s.slot) && s.slot >= 0)
|
|
739
|
+
.filter((s) => (0, node_path_1.basename)(s.path) === `slot-${s.slot}.json`)
|
|
740
|
+
.sort((a, b) => a.slot - b.slot));
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* The files in the slots directory that `slots()` refuses to name.
|
|
744
|
+
*
|
|
745
|
+
* 🔴 THE DOCBLOCK ABOVE CLAIMED THESE WERE REPORTED, AND THEY WERE NOT.
|
|
746
|
+
*
|
|
747
|
+
* Filtering `slot-abc.json` and `slot-01.json` out of `slots()` is right —
|
|
748
|
+
* a slot nobody can name is not a slot, and showing a person a row that
|
|
749
|
+
* `--release slot-01` cannot clear is worse than showing nothing. But the
|
|
750
|
+
* consequence of "not a slot" was "not anywhere": no row, no count, no
|
|
751
|
+
* mention, while the file sat in the directory looking exactly like the
|
|
752
|
+
* others. That is the failure mode this whole command exists to prevent.
|
|
753
|
+
*
|
|
754
|
+
* ⚠️ IT IS INERT, NOT DANGEROUS. Nothing counts it toward the bound and
|
|
755
|
+
* nothing reads it. It is reported because a file a person can see on disk
|
|
756
|
+
* and cannot see in `looply runs` is the thing that makes people distrust
|
|
757
|
+
* the listing — and because `rm` is the fix, which they can only reach if
|
|
758
|
+
* they are told the path.
|
|
759
|
+
*/
|
|
760
|
+
strayFiles() {
|
|
761
|
+
let names;
|
|
762
|
+
try {
|
|
763
|
+
names = (0, node_fs_1.readdirSync)(this.dir).filter((n) => n.startsWith('slot-') && n.endsWith('.json'));
|
|
764
|
+
}
|
|
765
|
+
catch {
|
|
766
|
+
return [];
|
|
767
|
+
}
|
|
768
|
+
const named = new Set(this.slots().map((s) => (0, node_path_1.basename)(s.path)));
|
|
769
|
+
return names
|
|
770
|
+
.filter((n) => !named.has(n))
|
|
771
|
+
.sort()
|
|
772
|
+
.map((n) => (0, node_path_1.join)(this.dir, n));
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* Clear one slot by NUMBER, whatever it holds or fails to hold.
|
|
776
|
+
*
|
|
777
|
+
* The companion to `forget(runId)` for the case a runId cannot answer: a slot
|
|
778
|
+
* file that is corrupt, or truncated, or written by a version that is gone.
|
|
779
|
+
* Nothing automatic reaches this either — a person types `slot-<n>`.
|
|
780
|
+
*/
|
|
781
|
+
forgetSlot(slot) {
|
|
782
|
+
try {
|
|
783
|
+
(0, node_fs_1.unlinkSync)(this.slotPath(slot));
|
|
784
|
+
return true;
|
|
785
|
+
}
|
|
786
|
+
catch (e) {
|
|
787
|
+
if (e.code === 'ENOENT')
|
|
788
|
+
return false;
|
|
789
|
+
throw e;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* Whether the run this slot names is still holding its own lease.
|
|
794
|
+
*
|
|
795
|
+
* ⚠️ A SECOND WITNESS, BECAUSE A PID IS A WEAK ONE. Pids are recycled, and a
|
|
796
|
+
* record left behind by a hard crash whose number a later process inherits
|
|
797
|
+
* probes as ALIVE for ever — so the slot it holds is never reclaimed and the
|
|
798
|
+
* machine's concurrency drops by one permanently, with nothing to point at.
|
|
799
|
+
*
|
|
800
|
+
* A live run always holds the lease in its own execution state directory: it
|
|
801
|
+
* is taken before the slot is claimed and given back in the same breath as
|
|
802
|
+
* the slot. So a slot whose run has NO lease is not a run, whatever its pid
|
|
803
|
+
* says — which covers the leak this cannot otherwise see, including the
|
|
804
|
+
* narrow one where releasing the lease succeeded and clearing the entry did
|
|
805
|
+
* not.
|
|
806
|
+
*
|
|
807
|
+
* ⚠️ IT DOES NOT CLOSE THE PID-RECYCLING HOLE, and saying so is the point: a
|
|
808
|
+
* hard crash leaves BOTH the lease and the record behind, so both witnesses
|
|
809
|
+
* agree and both are wrong. That residual is inherited from the lease's own
|
|
810
|
+
* liveness model, which documents it and deliberately has no heartbeat. What
|
|
811
|
+
* bounds it is that a person can always say so by name — see
|
|
812
|
+
* `looply runs --release`.
|
|
813
|
+
*
|
|
814
|
+
* ⚠️ AND IT FAILS TOWARDS "HELD". An unreadable lease is not evidence of
|
|
815
|
+
* absence; only a lease that is gone, or one that names a DIFFERENT run,
|
|
816
|
+
* frees the slot.
|
|
817
|
+
*/
|
|
818
|
+
leaseAgrees(record) {
|
|
819
|
+
const path = (0, run_lease_1.leasePathIn)(record.stateDir);
|
|
820
|
+
let text;
|
|
821
|
+
try {
|
|
822
|
+
text = (0, node_fs_1.readFileSync)(path, 'utf8');
|
|
823
|
+
}
|
|
824
|
+
catch (e) {
|
|
825
|
+
// ENOENT is an answer; anything else is a failure to ask.
|
|
826
|
+
return e.code !== 'ENOENT';
|
|
827
|
+
}
|
|
828
|
+
try {
|
|
829
|
+
const lease = JSON.parse(text);
|
|
830
|
+
return typeof lease.runId !== 'string' || lease.runId === record.runId;
|
|
831
|
+
}
|
|
832
|
+
catch {
|
|
833
|
+
// Torn or half-written — a live run's lease, mid-rename, looks like this.
|
|
834
|
+
return true;
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
/**
|
|
838
|
+
* Forget a run entirely: its record and any slot it holds.
|
|
839
|
+
*
|
|
840
|
+
* ⚠️ THE ONLY THING THAT CAN CLEAR A SLOT WHOSE PID LOOKS ALIVE, and it exists
|
|
841
|
+
* for exactly one situation: a pid recycled onto a crashed run's number, where
|
|
842
|
+
* every automatic witness agrees and every one of them is wrong. It is not
|
|
843
|
+
* called by anything automatic — a person names the run.
|
|
844
|
+
*/
|
|
845
|
+
forget(runId) {
|
|
846
|
+
safeRunId(runId);
|
|
847
|
+
let slot = null;
|
|
848
|
+
let names = [];
|
|
849
|
+
try {
|
|
850
|
+
// `.json` as well as the prefix: a reclaim token is `slot-N.json.reclaiming`,
|
|
851
|
+
// and it is never a slot.
|
|
852
|
+
names = (0, node_fs_1.readdirSync)(this.dir).filter((n) => n.startsWith('slot-') && n.endsWith('.json'));
|
|
853
|
+
}
|
|
854
|
+
catch {
|
|
855
|
+
names = [];
|
|
856
|
+
}
|
|
857
|
+
for (const name of names) {
|
|
858
|
+
const held = this.readSlot((0, node_path_1.join)(this.dir, name));
|
|
859
|
+
if (held?.runId !== runId)
|
|
860
|
+
continue;
|
|
861
|
+
slot = Number.parseInt(name.slice('slot-'.length), 10);
|
|
862
|
+
try {
|
|
863
|
+
(0, node_fs_1.unlinkSync)((0, node_path_1.join)(this.dir, name));
|
|
864
|
+
}
|
|
865
|
+
catch {
|
|
866
|
+
/* already gone */
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
let forgot = true;
|
|
870
|
+
try {
|
|
871
|
+
(0, node_fs_1.unlinkSync)(this.pathFor(runId));
|
|
872
|
+
}
|
|
873
|
+
catch (e) {
|
|
874
|
+
if (e.code === 'ENOENT')
|
|
875
|
+
forgot = false;
|
|
876
|
+
else
|
|
877
|
+
throw e;
|
|
878
|
+
}
|
|
879
|
+
return { forgot, slot };
|
|
880
|
+
}
|
|
881
|
+
slotPath(slot) {
|
|
882
|
+
return (0, node_path_1.join)(this.dir, `slot-${slot}.json`);
|
|
883
|
+
}
|
|
884
|
+
readSlot(path) {
|
|
885
|
+
try {
|
|
886
|
+
const r = JSON.parse((0, node_fs_1.readFileSync)(path, 'utf8'));
|
|
887
|
+
if (typeof r.runId !== 'string' || typeof r.pid !== 'number')
|
|
888
|
+
return null;
|
|
889
|
+
/**
|
|
890
|
+
* 🔴 AND THE PATH FIELDS, for the same reason `list` checks them. This
|
|
891
|
+
* validated `runId` and `pid` only, so a slot record reached `atCapacity`,
|
|
892
|
+
* which calls `basename(r.workspace)` — and `claim`, whose whole contract
|
|
893
|
+
* is `{ ok: false, because }`, THREW a TypeError instead of refusing. A
|
|
894
|
+
* bound that crashes is not a bound.
|
|
895
|
+
*
|
|
896
|
+
* `leaseAgrees` reads `stateDir` too, so both are required to be strings
|
|
897
|
+
* before this record is handed to anything.
|
|
898
|
+
*/
|
|
899
|
+
if (typeof r.workspace !== 'string' || typeof r.stateDir !== 'string')
|
|
900
|
+
return null;
|
|
901
|
+
/**
|
|
902
|
+
* ⚠️ THE SAME DOORWAY RULE AS `list`. A slot file is written by another
|
|
903
|
+
* process and read without coordination, so its `runId` is untrusted for
|
|
904
|
+
* exactly the reason a record's is — and it flows on to `forget(runId)`,
|
|
905
|
+
* which builds a path from it and throws on one it cannot address. An
|
|
906
|
+
* unaddressable id makes the slot UNREADABLE, which every caller already
|
|
907
|
+
* handles by failing closed, rather than a value that reaches a path.
|
|
908
|
+
*/
|
|
909
|
+
if (!RUN_ID.test(r.runId))
|
|
910
|
+
return null;
|
|
911
|
+
return r;
|
|
912
|
+
}
|
|
913
|
+
catch {
|
|
914
|
+
return null;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
/** Give the slot back. Absent is success: releasing twice is not an error. */
|
|
918
|
+
release(runId) {
|
|
919
|
+
try {
|
|
920
|
+
(0, node_fs_1.unlinkSync)(this.pathFor(runId));
|
|
921
|
+
}
|
|
922
|
+
catch (e) {
|
|
923
|
+
if (e.code !== 'ENOENT')
|
|
924
|
+
throw e;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
/** Every entry, with its process probed. Never throws on a bad file. */
|
|
928
|
+
list() {
|
|
929
|
+
let names;
|
|
930
|
+
try {
|
|
931
|
+
// Slot files are the BOUND, not run records; counting them here would
|
|
932
|
+
// report every run twice.
|
|
933
|
+
names = (0, node_fs_1.readdirSync)(this.dir).filter((n) => n.endsWith('.json') && !n.startsWith('slot-'));
|
|
934
|
+
}
|
|
935
|
+
catch {
|
|
936
|
+
return [];
|
|
937
|
+
}
|
|
938
|
+
const out = [];
|
|
939
|
+
for (const name of names) {
|
|
940
|
+
const path = (0, node_path_1.join)(this.dir, name);
|
|
941
|
+
let record;
|
|
942
|
+
try {
|
|
943
|
+
record = JSON.parse((0, node_fs_1.readFileSync)(path, 'utf8'));
|
|
944
|
+
}
|
|
945
|
+
catch (e) {
|
|
946
|
+
out.push({ kind: 'unreadable', path, because: e.message });
|
|
947
|
+
continue;
|
|
948
|
+
}
|
|
949
|
+
if (typeof record.runId !== 'string' || typeof record.pid !== 'number') {
|
|
950
|
+
out.push({ kind: 'unreadable', path, because: 'not a run record' });
|
|
951
|
+
continue;
|
|
952
|
+
}
|
|
953
|
+
/**
|
|
954
|
+
* 🔴 THE runId IS VALIDATED HERE, WHERE IT ENTERS, and it was not.
|
|
955
|
+
*
|
|
956
|
+
* A registry record is a JSON file. `register` refuses a runId that is not
|
|
957
|
+
* a safe path segment — but `list` did not, so anything that could write
|
|
958
|
+
* the project's state directory could hand every consumer a runId with a
|
|
959
|
+
* separator in it. Measured, end to end, against the built CLI:
|
|
960
|
+
*
|
|
961
|
+
* a record naming `x/../queue`, with a live pid
|
|
962
|
+
* -> `looply stop` built `<state>/control.x/../queue.json`
|
|
963
|
+
* -> which resolves to `<state>/queue.json`
|
|
964
|
+
* -> and OVERWROTE THE PROJECT'S WORK QUEUE with a control request.
|
|
965
|
+
*
|
|
966
|
+
* Validating at each consumer would have been four places and a fifth one
|
|
967
|
+
* later. This is the doorway: a record whose id cannot be addressed is
|
|
968
|
+
* reported as unreadable, which every consumer already handles, and none
|
|
969
|
+
* of them ever sees it as a record again.
|
|
970
|
+
*/
|
|
971
|
+
if (!RUN_ID.test(record.runId)) {
|
|
972
|
+
out.push({
|
|
973
|
+
kind: 'unreadable',
|
|
974
|
+
path,
|
|
975
|
+
because: `its run id ${JSON.stringify(record.runId)} is not one this can address`,
|
|
976
|
+
});
|
|
977
|
+
continue;
|
|
978
|
+
}
|
|
979
|
+
/**
|
|
980
|
+
* 🔴 AND THE TWO PATH FIELDS, because validating the runId alone left the
|
|
981
|
+
* door open beside the one it closed.
|
|
982
|
+
*
|
|
983
|
+
* A record carries `workspace` and `stateDir`, both absolute paths, both
|
|
984
|
+
* read from a file. `requestControlEverywhere` WRITES into `stateDir`, so
|
|
985
|
+
* a record naming any directory on the machine turned `looply stop` into
|
|
986
|
+
* a write primitive pointed wherever the record said. `reapVerdict`
|
|
987
|
+
* contains `workspace` by realpath before removing anything, but nothing
|
|
988
|
+
* contained `stateDir` before writing to it.
|
|
989
|
+
*
|
|
990
|
+
* Every record looply writes has `stateDir === <workspace>/.looply/state`,
|
|
991
|
+
* because that is how `executionStateDir` builds it. A record that says
|
|
992
|
+
* otherwise did not come from looply, and is unreadable.
|
|
993
|
+
*/
|
|
994
|
+
if (typeof record.workspace !== 'string' || typeof record.stateDir !== 'string') {
|
|
995
|
+
out.push({ kind: 'unreadable', path, because: 'its workspace or state directory is missing' });
|
|
996
|
+
continue;
|
|
997
|
+
}
|
|
998
|
+
if ((0, node_path_1.resolve)(record.stateDir) !== (0, node_path_1.resolve)((0, node_path_1.join)(record.workspace, git_driver_1.STATE_DIR, 'state'))) {
|
|
999
|
+
out.push({
|
|
1000
|
+
kind: 'unreadable',
|
|
1001
|
+
path,
|
|
1002
|
+
because: 'its state directory is not the one its workspace would have',
|
|
1003
|
+
});
|
|
1004
|
+
continue;
|
|
1005
|
+
}
|
|
1006
|
+
const liveness = this.probe(record.pid);
|
|
1007
|
+
if (liveness === 'alive')
|
|
1008
|
+
out.push({ kind: 'active', record });
|
|
1009
|
+
else if (liveness === 'dead')
|
|
1010
|
+
out.push({ kind: 'orphaned', record });
|
|
1011
|
+
else
|
|
1012
|
+
out.push({ kind: 'unknown', record });
|
|
1013
|
+
}
|
|
1014
|
+
return out;
|
|
1015
|
+
}
|
|
1016
|
+
/**
|
|
1017
|
+
* Everything NOT PROVEN DEAD — what capacity counts and what `stop` signals.
|
|
1018
|
+
*
|
|
1019
|
+
* ⚠️ 'unknown' IS INCLUDED, DELIBERATELY. Both callers are safer over-counting
|
|
1020
|
+
* than under-counting: capacity would otherwise start an extra execution, and
|
|
1021
|
+
* `stop` would otherwise report a run as finished while it is still writing.
|
|
1022
|
+
*/
|
|
1023
|
+
active() {
|
|
1024
|
+
return this.list().flatMap((e) => (e.kind === 'active' || e.kind === 'unknown' ? [e.record] : []));
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
exports.RunRegistry = RunRegistry;
|
|
1028
|
+
/**
|
|
1029
|
+
* 🔴 `capacityVerdict` WAS DELETED, and its absence is the point.
|
|
1030
|
+
*
|
|
1031
|
+
* It counted live records and judged them against the limit, and it read like
|
|
1032
|
+
* the concurrency bound — but `claim` replaced it and nothing called it any
|
|
1033
|
+
* more. A function that looks like the bound, is exported, is tested, and
|
|
1034
|
+
* decides nothing is worse than no function at all: the next person to reach
|
|
1035
|
+
* for it gets check-then-act semantics back, with tests that pass.
|
|
1036
|
+
*
|
|
1037
|
+
* The bound is `RunRegistry.claim`. There is one.
|
|
1038
|
+
*/
|
|
1039
|
+
/**
|
|
1040
|
+
* Write a control request where EVERY live execution will read it.
|
|
1041
|
+
*
|
|
1042
|
+
* 🔴 ONE SHARED FILE PLUS N CONSUMERS IS A BRAKE THAT RELEASES ITSELF, and both
|
|
1043
|
+
* writers had the same hole. `ControlStore.consume()` writes `resume` into the
|
|
1044
|
+
* file it read, so the FIRST execution to reach a round boundary clears the
|
|
1045
|
+
* instruction and every other one reads `resume` and carries on.
|
|
1046
|
+
*
|
|
1047
|
+
* `looply stop` was taught to write an addressed request per execution; the
|
|
1048
|
+
* DAEMON — which is how the phone's Stop and Pause arrive — was not, and it is
|
|
1049
|
+
* the path that matters most, because nobody is standing at the terminal to
|
|
1050
|
+
* notice that two of three runs kept going.
|
|
1051
|
+
*
|
|
1052
|
+
* So there is one function, and both call it.
|
|
1053
|
+
*
|
|
1054
|
+
* ⚠️ THE SHARED FILE IS STILL WRITTEN, FIRST. It is what a run started AFTER
|
|
1055
|
+
* this request will read, what Simple Mode has always used, and what `resume`
|
|
1056
|
+
* has to clear. The addressed files are additive.
|
|
1057
|
+
*
|
|
1058
|
+
* ⚠️ AND A FAILURE TO REACH ONE EXECUTION IS REPORTED, not swallowed. A brake
|
|
1059
|
+
* that misses a run must say which.
|
|
1060
|
+
*/
|
|
1061
|
+
async function requestControlEverywhere(projectState, live, kind, actor, fs,
|
|
1062
|
+
/** Where this project's executions may legitimately keep state. See below. */
|
|
1063
|
+
cwd) {
|
|
1064
|
+
await new control_file_1.ControlStore(projectState, fs).request(kind, actor);
|
|
1065
|
+
/**
|
|
1066
|
+
* 🔴 AND IT WRITES ONLY WHERE A RUN'S STATE CAN LEGITIMATELY BE.
|
|
1067
|
+
*
|
|
1068
|
+
* `list` refuses a record whose `stateDir` is not its workspace's — but
|
|
1069
|
+
* `workspace` itself was unconstrained, so a record naming
|
|
1070
|
+
* `C:\anywhere` with `stateDir` `C:\anywhere\.looply\state` passed the
|
|
1071
|
+
* doorway, and `ControlStore.request` then MKDIR'd and WROTE there. `looply
|
|
1072
|
+
* stop` and the phone's Stop were still a write primitive, one level further
|
|
1073
|
+
* out than the runId hole they were supposed to have closed.
|
|
1074
|
+
*
|
|
1075
|
+
* There are exactly two places an execution's state can be: the project's own
|
|
1076
|
+
* state directory (an ordinary checkout, where project and execution scope are
|
|
1077
|
+
* the same path), or under this project's workspaces root. Anything else is
|
|
1078
|
+
* not this project's execution, whatever the record says.
|
|
1079
|
+
*/
|
|
1080
|
+
/**
|
|
1081
|
+
* 🔴 AND THE RULE IS "A CHECKOUT OF THIS REPOSITORY", NOT "ONE OF TWO PLACES".
|
|
1082
|
+
*
|
|
1083
|
+
* A first version allowed only the project's own state directory or something
|
|
1084
|
+
* under the workspaces root — and that broke the phase's own `--in` workflow,
|
|
1085
|
+
* where a person points a run at a worktree THEY made, which is neither.
|
|
1086
|
+
* Measured: `looply stop` reported a live `--in` execution as unreachable and
|
|
1087
|
+
* wrote nothing, which is the silent-brake failure this whole mechanism exists
|
|
1088
|
+
* to end.
|
|
1089
|
+
*
|
|
1090
|
+
* The honest question is not "where is this directory" but "is it a checkout
|
|
1091
|
+
* of the repository I am the registry for". `list` already guarantees the
|
|
1092
|
+
* state directory is its workspace's; this settles the workspace.
|
|
1093
|
+
*/
|
|
1094
|
+
const mine = cwd === undefined ? null : (0, trusted_git_1.repoIdentityPath)(cwd);
|
|
1095
|
+
const reached = [];
|
|
1096
|
+
const unreachable = [];
|
|
1097
|
+
for (const r of live) {
|
|
1098
|
+
/**
|
|
1099
|
+
* ⚠️ A CHECKOUT *ROOT* OF THIS REPOSITORY, not merely somewhere inside it.
|
|
1100
|
+
* `repoIdentityPath` walks up, so `<repo>/src` answers `<repo>` and matched
|
|
1101
|
+
* — and this would then have created `<repo>/src/.looply/state` and written
|
|
1102
|
+
* into it. Confined to the person's own repository rather than the whole
|
|
1103
|
+
* machine, but still a write nobody asked for, in a tree looply is supposed
|
|
1104
|
+
* to leave alone unless a round is committing.
|
|
1105
|
+
*/
|
|
1106
|
+
if (mine !== null && (!(0, trusted_git_1.isCheckoutRoot)(r.workspace) || (0, trusted_git_1.repoIdentityPath)(r.workspace) !== mine)) {
|
|
1107
|
+
unreachable.push(r.runId);
|
|
1108
|
+
continue;
|
|
1109
|
+
}
|
|
1110
|
+
try {
|
|
1111
|
+
await control_file_1.ControlStore.addressed(r.stateDir, r.runId, fs).request(kind, actor);
|
|
1112
|
+
reached.push(r.runId);
|
|
1113
|
+
}
|
|
1114
|
+
catch {
|
|
1115
|
+
// A workspace that has gone, or one this user cannot write.
|
|
1116
|
+
unreachable.push(r.runId);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
return { reached, unreachable };
|
|
1120
|
+
}
|
|
1121
|
+
/**
|
|
1122
|
+
* Create a workspace for an execution: a linked worktree on a fresh branch.
|
|
1123
|
+
*
|
|
1124
|
+
* ⚠️ THE GIT DRIVER DOES THE WORK, so the worktree is created by the same
|
|
1125
|
+
* substrate every other verb uses — the repository-config refusal, the
|
|
1126
|
+
* repository-root check, the rebuilt `GIT_` environment and the absolute git
|
|
1127
|
+
* binary all apply. A provisioner with its own runner would be a second path
|
|
1128
|
+
* around four defences at once.
|
|
1129
|
+
*/
|
|
1130
|
+
async function createWorkspace(git, cwd, runId, branch, baseSha,
|
|
1131
|
+
/** Whose home the workspaces root lives under; the user's unless a test says otherwise (M4-1). */
|
|
1132
|
+
home = (0, node_os_1.homedir)(),
|
|
1133
|
+
/**
|
|
1134
|
+
* `run.carry_into_checkouts` — directories this fresh checkout needs that git
|
|
1135
|
+
* does not carry, above all `node_modules`.
|
|
1136
|
+
*
|
|
1137
|
+
* 🔴 WITHOUT IT THE GATE CANNOT PASS HERE. A worktree holds what git tracks,
|
|
1138
|
+
* the gate runs with no network by design, and so a second worker's every
|
|
1139
|
+
* round went red on the same missing toolchain. Measured: four rounds, one
|
|
1140
|
+
* output digest.
|
|
1141
|
+
*
|
|
1142
|
+
* ⚠️ LAST AND DEFAULTED, so every existing caller and fixture is untouched and
|
|
1143
|
+
* an empty list is byte-identical to the old behaviour.
|
|
1144
|
+
*/
|
|
1145
|
+
carry = []) {
|
|
1146
|
+
const root = workspacesRoot(cwd, home);
|
|
1147
|
+
(0, node_fs_1.mkdirSync)(root, { recursive: true });
|
|
1148
|
+
const path = (0, node_path_1.join)(root, runId);
|
|
1149
|
+
if ((0, node_fs_1.existsSync)(path)) {
|
|
1150
|
+
throw new WorkspaceError(`a workspace already exists at ${path} — refusing to reuse it`);
|
|
1151
|
+
}
|
|
1152
|
+
await git.addWorktree(path, branch, baseSha);
|
|
1153
|
+
/**
|
|
1154
|
+
* Immediately after the checkout and BEFORE the claim below, so a workspace is
|
|
1155
|
+
* never offered to a run in a state where its gate is guaranteed to fail.
|
|
1156
|
+
*/
|
|
1157
|
+
if (carry.length > 0) {
|
|
1158
|
+
try {
|
|
1159
|
+
(0, checkout_carry_1.carryIntoCheckout)(cwd, path, carry);
|
|
1160
|
+
}
|
|
1161
|
+
catch (e) {
|
|
1162
|
+
throw new WorkspaceError(`the workspace could not be provisioned: ${e.message}`);
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
/**
|
|
1166
|
+
* ⚠️ CLAIMED THE INSTANT IT EXISTS. A run does not take its lease until after
|
|
1167
|
+
* the config is read and the git checks have run — seconds later — and until
|
|
1168
|
+
* then nothing in the checkout says anybody is coming. `abandonedWorkspaces`
|
|
1169
|
+
* would call it abandoned and a concurrent `--reap` would delete it.
|
|
1170
|
+
*/
|
|
1171
|
+
try {
|
|
1172
|
+
const stateDir = (0, node_path_1.join)(path, git_driver_1.STATE_DIR, 'state');
|
|
1173
|
+
(0, node_fs_1.mkdirSync)(stateDir, { recursive: true });
|
|
1174
|
+
/**
|
|
1175
|
+
* ⚠️ TEMP-THEN-RENAME, so a reader can never catch it half-written. That
|
|
1176
|
+
* matters because `checkoutBusy` has to decide what an unparseable marker
|
|
1177
|
+
* means: written atomically, a torn one cannot exist, so a corrupt one is
|
|
1178
|
+
* corruption rather than a race and can be read as stale.
|
|
1179
|
+
*/
|
|
1180
|
+
const tmp = (0, node_path_1.join)(stateDir, `${CREATING}.${process.pid}.tmp`);
|
|
1181
|
+
(0, node_fs_1.writeFileSync)(tmp, `${JSON.stringify({ pid: process.pid, at: new Date().toISOString() }, null, 2)}
|
|
1182
|
+
`);
|
|
1183
|
+
(0, node_fs_1.renameSync)(tmp, (0, node_path_1.join)(stateDir, CREATING));
|
|
1184
|
+
}
|
|
1185
|
+
catch (e) {
|
|
1186
|
+
/**
|
|
1187
|
+
* 🔴 THIS USED TO BE SWALLOWED, and swallowing it produced exactly the state
|
|
1188
|
+
* the marker exists to prevent.
|
|
1189
|
+
*
|
|
1190
|
+
* The marker is the ONLY thing covering the checkout between `worktree add`
|
|
1191
|
+
* returning and the run acquiring its lease — no registry record exists yet,
|
|
1192
|
+
* so `abandonedWorkspaces` has nothing else to go on. A `try {} catch {}`
|
|
1193
|
+
* around the write meant a refused mkdir, write or rename produced an
|
|
1194
|
+
* unprotected checkout with no marker and no diagnostic: a concurrent
|
|
1195
|
+
* `--reap` would remove it out from under a starting run, and nothing
|
|
1196
|
+
* anywhere would say why.
|
|
1197
|
+
*
|
|
1198
|
+
* So it fails closed: the worktree this just created is removed and the
|
|
1199
|
+
* caller is told. A workspace that cannot be protected is not a workspace.
|
|
1200
|
+
*/
|
|
1201
|
+
try {
|
|
1202
|
+
await git.removeWorktree(path);
|
|
1203
|
+
await git.pruneWorktrees();
|
|
1204
|
+
}
|
|
1205
|
+
catch {
|
|
1206
|
+
// Leave it rather than force it. It is empty and `looply runs` lists it.
|
|
1207
|
+
}
|
|
1208
|
+
throw new WorkspaceError(`the workspace at ${path} could not be claimed for this run (${e.message}), so it was not kept`);
|
|
1209
|
+
}
|
|
1210
|
+
return path;
|
|
1211
|
+
}
|
|
1212
|
+
/**
|
|
1213
|
+
* Whether a workspace may be reaped, and why not when it may not.
|
|
1214
|
+
*
|
|
1215
|
+
* 🔴 THE ORDER OF THESE CHECKS IS THE SAFETY. A reaper that deleted first and
|
|
1216
|
+
* asked afterwards is a reaper that deletes a running execution's checkout. So:
|
|
1217
|
+
* it must be registered as orphaned (its process gone), it must be a workspace
|
|
1218
|
+
* this machine created under the workspaces root, and it must not be the main
|
|
1219
|
+
* checkout. Anything else is left alone and reported.
|
|
1220
|
+
*/
|
|
1221
|
+
/**
|
|
1222
|
+
* The real path of `path`, resolving as much of it as actually exists.
|
|
1223
|
+
*
|
|
1224
|
+
* ⚠️ A PLAIN `realpathSync` IS NOT ENOUGH HERE, and the first version was one:
|
|
1225
|
+
* it throws ENOENT for anything absent, which is the ordinary case twice over —
|
|
1226
|
+
* the workspaces root before any workspace has been created, and a workspace
|
|
1227
|
+
* whose directory is already gone. Refusing those as "could not be resolved"
|
|
1228
|
+
* made the reaper unable to clear a registry entry for a directory that no
|
|
1229
|
+
* longer exists, which is precisely what it is for.
|
|
1230
|
+
*
|
|
1231
|
+
* So: resolve the longest EXISTING prefix, then re-append the rest. Links in the
|
|
1232
|
+
* part that exists are still collapsed — which is the whole point — and the
|
|
1233
|
+
* absent tail contributes no link to follow.
|
|
1234
|
+
*/
|
|
1235
|
+
function realpathDeep(path) {
|
|
1236
|
+
const full = (0, node_path_1.resolve)(path);
|
|
1237
|
+
const tail = [];
|
|
1238
|
+
let head = full;
|
|
1239
|
+
for (;;) {
|
|
1240
|
+
try {
|
|
1241
|
+
const real = node_fs_1.realpathSync.native(head);
|
|
1242
|
+
return tail.length === 0 ? real : (0, node_path_1.join)(real, ...tail);
|
|
1243
|
+
}
|
|
1244
|
+
catch {
|
|
1245
|
+
const parent = (0, node_path_1.dirname)(head);
|
|
1246
|
+
if (parent === head)
|
|
1247
|
+
return full; // reached the drive/root; nothing resolved
|
|
1248
|
+
tail.unshift((0, node_path_1.basename)(head));
|
|
1249
|
+
head = parent;
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
/**
|
|
1254
|
+
* Whether `child` is strictly inside `parent`.
|
|
1255
|
+
*
|
|
1256
|
+
* ⚠️ THE SEPARATOR IS PART OF THE TEST. A bare `startsWith` says
|
|
1257
|
+
* `C:\ws-evil` is inside `C:\ws`, and `relative()` alone says a sibling is
|
|
1258
|
+
* inside when it isn't. Equality is excluded too: the root itself is never a
|
|
1259
|
+
* workspace.
|
|
1260
|
+
*/
|
|
1261
|
+
function contains(parent, child) {
|
|
1262
|
+
const rel = (0, node_path_1.relative)(parent, child);
|
|
1263
|
+
return rel !== '' && !rel.startsWith('..') && !(0, node_path_1.isAbsolute)(rel);
|
|
1264
|
+
}
|
|
1265
|
+
/**
|
|
1266
|
+
* WHY THE REAPER IS KEEPING THIS CHECKOUT, IN WORDS THAT ARE TRUE.
|
|
1267
|
+
*
|
|
1268
|
+
* 🔴 THE OLD SENTENCE WAS WRONG IN THREE WAYS AT ONCE WHEN THE CAUSE WAS AN
|
|
1269
|
+
* UNINSPECTABLE CHECKOUT — and that is the one case that writes a durable
|
|
1270
|
+
* quarantine, so it was wrong exactly where it mattered most. Measured against
|
|
1271
|
+
* an empty, clean worktree during a transient git fault, it said:
|
|
1272
|
+
*
|
|
1273
|
+
* "it still holds 1 file(s) nothing else has a copy of (could not be
|
|
1274
|
+
* inspected (…)). Look at <path>, then remove it yourself"
|
|
1275
|
+
*
|
|
1276
|
+
* 1. It claimed a file count for a checkout holding nothing. The "1 file" was
|
|
1277
|
+
* the error string itself.
|
|
1278
|
+
* 2. It never said a quarantine had been written, and never named `--release`
|
|
1279
|
+
* — that guidance appeared only on the NEXT invocation.
|
|
1280
|
+
* 3. It told the person to hand-delete a worktree looply had just decided
|
|
1281
|
+
* must not be touched.
|
|
1282
|
+
*/
|
|
1283
|
+
function keptBecause(residue, path, quarantined) {
|
|
1284
|
+
const blind = residue.find((r) => r.startsWith('could not be inspected'));
|
|
1285
|
+
if (blind !== undefined) {
|
|
1286
|
+
return (`looply could not see inside it, so it cannot say whether anything there is yours — ${blind}. ` +
|
|
1287
|
+
(quarantined
|
|
1288
|
+
? `It is quarantined and will not be touched. Look at ${path}, then ` +
|
|
1289
|
+
`\`looply runs --release ${(0, node_path_1.basename)(path)}\` when you have`
|
|
1290
|
+
: `Look at ${path} yourself; nothing here will remove it`));
|
|
1291
|
+
}
|
|
1292
|
+
return (`it still holds ${residue.length} file(s) nothing else has a copy of ` +
|
|
1293
|
+
`(${residue.slice(0, 3).join(', ')}${residue.length > 3 ? ', …' : ''}). ` +
|
|
1294
|
+
`Look at ${path}, then remove it yourself`);
|
|
1295
|
+
}
|
|
1296
|
+
/**
|
|
1297
|
+
* 🔴 THE PART OF A REAP REFUSAL THAT NEVER CHANGES, ASKED ON ITS OWN.
|
|
1298
|
+
*
|
|
1299
|
+
* `looply runs` counted the orphans `--reap` could take by `record.worktree`,
|
|
1300
|
+
* which does not mean "a checkout looply made". It means "not the main
|
|
1301
|
+
* checkout" — and it is TRUE for a worktree a PERSON made and pointed `--in`
|
|
1302
|
+
* at. So a crashed `--in` run was announced as "in a checkout looply made",
|
|
1303
|
+
* the person ran `--reap`, and `--reap` refused it. Correctly, permanently,
|
|
1304
|
+
* and once a day for ever.
|
|
1305
|
+
*
|
|
1306
|
+
* ⚠️ LIVENESS IS DELIBERATELY NOT ASKED HERE. That answer changes between the
|
|
1307
|
+
* listing and the reap; this one does not. `reapVerdict` calls this, so the
|
|
1308
|
+
* count in the summary and the refusal that follows it cannot drift apart
|
|
1309
|
+
* again — there is one test, not two spellings of nearly the same one.
|
|
1310
|
+
*/
|
|
1311
|
+
function reapableCheckout(record, cwd,
|
|
1312
|
+
/** Whose home the workspaces root lives under; the user's unless a caller says otherwise (M4-4). */
|
|
1313
|
+
home = (0, node_os_1.homedir)()) {
|
|
1314
|
+
if (!record.worktree) {
|
|
1315
|
+
return { ok: false, because: 'this is the main checkout, which no execution created' };
|
|
1316
|
+
}
|
|
1317
|
+
/**
|
|
1318
|
+
* 🔴 CONTAINMENT IS CHECKED ON THE *REAL* PATH, because `resolve` does not
|
|
1319
|
+
* follow links and Windows junctions are trivial to make without privileges.
|
|
1320
|
+
*
|
|
1321
|
+
* The attack the first version allowed, in full: create
|
|
1322
|
+
* `~/.looply/workspaces/<key>/run_x` as a JUNCTION pointing at a live
|
|
1323
|
+
* sibling's checkout — or at the user's repository. `resolve` returns the
|
|
1324
|
+
* junction's own path, `startsWith(root)` says "inside the workspaces root",
|
|
1325
|
+
* and the reaper then runs `worktree remove` and `rm` through it, deleting
|
|
1326
|
+
* somebody else's work at the far end. Nothing about the record has to be
|
|
1327
|
+
* forged; only a directory has to be planted, and the record can be an
|
|
1328
|
+
* ordinary orphan of a genuinely dead run.
|
|
1329
|
+
*
|
|
1330
|
+
* `realpathSync` collapses junctions, symlinks and 8.3 short names, so both
|
|
1331
|
+
* sides are compared as the filesystem sees them. The root is realpathed too:
|
|
1332
|
+
* on macOS `/var` is a link to `/private/var`, and comparing a resolved child
|
|
1333
|
+
* against an unresolved parent would refuse every legitimate reap there.
|
|
1334
|
+
*/
|
|
1335
|
+
const rootReal = realpathDeep(workspacesRoot(cwd, home));
|
|
1336
|
+
const declared = (0, node_path_1.resolve)(record.workspace);
|
|
1337
|
+
const ws = realpathDeep(declared);
|
|
1338
|
+
if (!contains(rootReal, ws)) {
|
|
1339
|
+
return {
|
|
1340
|
+
ok: false,
|
|
1341
|
+
because: (ws === declared
|
|
1342
|
+
? `${declared} is not under this project's workspaces root`
|
|
1343
|
+
: `${declared} really lives at ${ws}, which is outside this project's workspaces root. ` +
|
|
1344
|
+
'A workspace that is a link to somewhere else is not a workspace this may remove'),
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
1347
|
+
return { ok: true };
|
|
1348
|
+
}
|
|
1349
|
+
function reapVerdict(record, cwd, probe = exports.nodeProbe, home = (0, node_os_1.homedir)()) {
|
|
1350
|
+
const liveness = probe(record.pid);
|
|
1351
|
+
if (liveness !== 'dead') {
|
|
1352
|
+
return {
|
|
1353
|
+
ok: false,
|
|
1354
|
+
because: liveness === 'alive'
|
|
1355
|
+
? `pid ${record.pid} is still alive`
|
|
1356
|
+
: `pid ${record.pid} could not be judged alive or dead, and a reaper does not guess`,
|
|
1357
|
+
};
|
|
1358
|
+
}
|
|
1359
|
+
const ours = reapableCheckout(record, cwd, home);
|
|
1360
|
+
if (!ours.ok)
|
|
1361
|
+
return ours;
|
|
1362
|
+
const declared = (0, node_path_1.resolve)(record.workspace);
|
|
1363
|
+
// Gone already: nothing to remove, but the registry entry is still ours to clear.
|
|
1364
|
+
if (!(0, node_fs_1.existsSync)(declared))
|
|
1365
|
+
return { ok: true };
|
|
1366
|
+
/**
|
|
1367
|
+
* 🔴 A DEAD RECORD DOES NOT MEAN AN IDLE CHECKOUT, and this only ever asked
|
|
1368
|
+
* about the record.
|
|
1369
|
+
*
|
|
1370
|
+
* A crashed run leaves its registry entry naming workspace W. Somebody then
|
|
1371
|
+
* starts a NEW run in W — `looply run --in W` — which is allowed, because the
|
|
1372
|
+
* crashed run's lease is judged dead and taken over. Now W holds a live
|
|
1373
|
+
* execution AND a stale record for a dead pid, and `--reap` would prove the
|
|
1374
|
+
* STALE pid dead and delete the live run's checkout out from under it, filing
|
|
1375
|
+
* its lease and ledger under the dead run's name.
|
|
1376
|
+
*
|
|
1377
|
+
* The workspace's own lease is the answer, exactly as it is for a slot: if
|
|
1378
|
+
* anything in there is holding one, this is not a dead workspace whatever the
|
|
1379
|
+
* record says.
|
|
1380
|
+
*/
|
|
1381
|
+
if (checkoutBusy((0, node_path_1.join)(declared, git_driver_1.STATE_DIR, 'state'), probe)) {
|
|
1382
|
+
return { ok: false, because: `something is still using that checkout (${busyBecause((0, node_path_1.join)(declared, git_driver_1.STATE_DIR, 'state'), probe)})` };
|
|
1383
|
+
}
|
|
1384
|
+
try {
|
|
1385
|
+
// `lstat`, not `stat`: a link TO a directory is not a directory this owns.
|
|
1386
|
+
if (!(0, node_fs_1.lstatSync)(declared).isDirectory()) {
|
|
1387
|
+
return { ok: false, because: `${declared} is not a directory` };
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
catch (e) {
|
|
1391
|
+
return { ok: false, because: `${declared} could not be read (${e.message})` };
|
|
1392
|
+
}
|
|
1393
|
+
return { ok: true };
|
|
1394
|
+
}
|
|
1395
|
+
/**
|
|
1396
|
+
* Remove a proven-dead workspace, worktree record and registry entry.
|
|
1397
|
+
*
|
|
1398
|
+
* ⚠️ `git worktree remove` WITHOUT `--force`, then nothing. If git refuses —
|
|
1399
|
+
* because the tree is dirty, or a file is held — the directory is LEFT and the
|
|
1400
|
+
* refusal is returned. `--force` deletes ignored files, which is how a reaper
|
|
1401
|
+
* would take somebody's `node_modules` or `.env` with it, and it fires exactly
|
|
1402
|
+
* when git has already judged there is something worth refusing over.
|
|
1403
|
+
*/
|
|
1404
|
+
/**
|
|
1405
|
+
* Everything in a workspace that is not a pristine checkout — INCLUDING ignored
|
|
1406
|
+
* files, which is the whole reason this exists.
|
|
1407
|
+
*
|
|
1408
|
+
* ⚠️ THE DRIVER MUST BE BUILT FOR THE WORKSPACE, not for the main checkout.
|
|
1409
|
+
* `trustedGitEnv` pins GIT_DIR and GIT_WORK_TREE from the cwd a driver was made
|
|
1410
|
+
* with, and a pinned work tree beats `-C` — so a main-checkout driver asked
|
|
1411
|
+
* about a workspace answers about the main checkout, confidently and wrongly.
|
|
1412
|
+
*
|
|
1413
|
+
* A failure to ask counts AS residue: a reaper that cannot see what is there
|
|
1414
|
+
* must not delete it.
|
|
1415
|
+
*/
|
|
1416
|
+
async function workspaceResidue(gitInWorkspace, workspace,
|
|
1417
|
+
/**
|
|
1418
|
+
* `run.carry_into_checkouts` — paths LOOPLY PUT HERE ITSELF, and therefore not
|
|
1419
|
+
* the person's.
|
|
1420
|
+
*
|
|
1421
|
+
* 🔴 WITHOUT THIS, PROVISIONING A WORKSPACE MAKES IT UNREAPABLE FOR EVER.
|
|
1422
|
+
* `--ignored` is the whole point of this check, and a carried `node_modules`
|
|
1423
|
+
* is ignored by definition — so the reaper counted the product's own copy as
|
|
1424
|
+
* "file(s) nothing else has a copy of" and refused. Measured immediately after
|
|
1425
|
+
* the carry landed: SIX workspaces, every one kept, each naming
|
|
1426
|
+
* `!! node_modules/` as the person's content.
|
|
1427
|
+
*
|
|
1428
|
+
* The reasoning is the same one that drops `.looply/state/` two lines below:
|
|
1429
|
+
* this is looply's own, it was copied from a source that still has it, and
|
|
1430
|
+
* deleting it loses nothing. It is a COPY — that is the whole contract of
|
|
1431
|
+
* `carryIntoCheckout` — so "nothing else has a copy of it" is exactly false.
|
|
1432
|
+
*
|
|
1433
|
+
* ⚠️ TOP-LEVEL NAMES ONLY, matching what `carryNameTrouble` permits. A path
|
|
1434
|
+
* deeper than one segment is not carriable, so it cannot be excused here.
|
|
1435
|
+
*/
|
|
1436
|
+
carried = []) {
|
|
1437
|
+
let lines;
|
|
1438
|
+
try {
|
|
1439
|
+
lines = (await gitInWorkspace.statusWithIgnored())
|
|
1440
|
+
.split('\n')
|
|
1441
|
+
.map((l) => l.trim())
|
|
1442
|
+
.filter((l) => l !== '');
|
|
1443
|
+
}
|
|
1444
|
+
catch (e) {
|
|
1445
|
+
return [`could not be inspected (${e.message})`];
|
|
1446
|
+
}
|
|
1447
|
+
const out = [];
|
|
1448
|
+
for (const line of lines) {
|
|
1449
|
+
/**
|
|
1450
|
+
* Porcelain v1 is `XY PATH` or `XY ORIG -> PATH`. Only the path field is
|
|
1451
|
+
* examined: a looser match would filter `R secrets.env -> .looply/x` — a
|
|
1452
|
+
* USER file, renamed — by the very rule meant to ignore looply's own state.
|
|
1453
|
+
*/
|
|
1454
|
+
const field = line.slice(3).trim();
|
|
1455
|
+
const shown = field.startsWith('"') ? field.slice(1) : field;
|
|
1456
|
+
// Looply's own execution state, which `retainEvidence` moves to safety.
|
|
1457
|
+
if (shown.startsWith('.looply/state/') || shown.startsWith('.looply\\state\\'))
|
|
1458
|
+
continue;
|
|
1459
|
+
/**
|
|
1460
|
+
* Looply's own provisioning. Git collapses an ignored directory to one line,
|
|
1461
|
+
* so a carried `node_modules` arrives as `!! node_modules/` — matched here
|
|
1462
|
+
* with and without the trailing separator, in both slash spellings.
|
|
1463
|
+
*/
|
|
1464
|
+
if (carried.some((name) => shown === name || shown === `${name}/` || shown === `${name}\\`))
|
|
1465
|
+
continue;
|
|
1466
|
+
/**
|
|
1467
|
+
* 🔴 GIT COLLAPSES AN IGNORED DIRECTORY TO ONE LINE, and a project that
|
|
1468
|
+
* ignores `.looply/` wholesale — which many do — gets `!! .looply/` and
|
|
1469
|
+
* nothing else, whatever is inside it. Neither `-uall` nor a pathspec
|
|
1470
|
+
* exclusion expands it: `--ignored=matching` reports the directory because
|
|
1471
|
+
* the directory is what the ignore rule matched. Measured.
|
|
1472
|
+
*
|
|
1473
|
+
* So the filter stopped matching, every such workspace read as holding
|
|
1474
|
+
* residue for ever, and `--reap` refused it with a reason about the
|
|
1475
|
+
* person's files that named none of them.
|
|
1476
|
+
*
|
|
1477
|
+
* When git will not say what is in there, LOOK. Anything outside `state/`
|
|
1478
|
+
* is the person's and is named individually; if there is nothing else, this
|
|
1479
|
+
* line is looply's own and is dropped.
|
|
1480
|
+
*/
|
|
1481
|
+
if (shown === '.looply/' || shown === '.looply' || shown === '.looply\\') {
|
|
1482
|
+
out.push(...notOursUnder((0, node_path_1.join)(workspace, git_driver_1.STATE_DIR)));
|
|
1483
|
+
continue;
|
|
1484
|
+
}
|
|
1485
|
+
out.push(line);
|
|
1486
|
+
}
|
|
1487
|
+
return out;
|
|
1488
|
+
}
|
|
1489
|
+
/**
|
|
1490
|
+
* Everything under `<workspace>/.looply` that is not looply's own `state/`.
|
|
1491
|
+
*
|
|
1492
|
+
* ⚠️ A FAILURE TO LOOK IS RESIDUE. If the directory cannot be read, the honest
|
|
1493
|
+
* answer is "there may be something here", not "there is nothing".
|
|
1494
|
+
*/
|
|
1495
|
+
function notOursUnder(looplyDir) {
|
|
1496
|
+
const found = [];
|
|
1497
|
+
const walk = (dir, rel) => {
|
|
1498
|
+
let entries;
|
|
1499
|
+
try {
|
|
1500
|
+
entries = (0, node_fs_1.readdirSync)(dir);
|
|
1501
|
+
}
|
|
1502
|
+
catch (e) {
|
|
1503
|
+
found.push(`${rel || '.looply'} could not be read (${e.message})`);
|
|
1504
|
+
return;
|
|
1505
|
+
}
|
|
1506
|
+
for (const name of entries) {
|
|
1507
|
+
const at = rel === '' ? name : `${rel}/${name}`;
|
|
1508
|
+
if (rel === '' && name === 'state')
|
|
1509
|
+
continue; // looply's own, moved to safety
|
|
1510
|
+
const full = (0, node_path_1.join)(dir, name);
|
|
1511
|
+
let isDir = false;
|
|
1512
|
+
try {
|
|
1513
|
+
isDir = (0, node_fs_1.lstatSync)(full).isDirectory();
|
|
1514
|
+
}
|
|
1515
|
+
catch {
|
|
1516
|
+
found.push(`.looply/${at} could not be read`);
|
|
1517
|
+
continue;
|
|
1518
|
+
}
|
|
1519
|
+
if (isDir)
|
|
1520
|
+
walk(full, at);
|
|
1521
|
+
else
|
|
1522
|
+
found.push(`?? .looply/${at}`);
|
|
1523
|
+
}
|
|
1524
|
+
};
|
|
1525
|
+
walk(looplyDir, '');
|
|
1526
|
+
return found;
|
|
1527
|
+
}
|
|
1528
|
+
/**
|
|
1529
|
+
* Move a dead execution's own state out of the workspace, so removing the
|
|
1530
|
+
* checkout does not take the evidence with it.
|
|
1531
|
+
*
|
|
1532
|
+
* ⚠️ MOVED, NEVER DELETED, and the reap stops if this fails. A crashed run's
|
|
1533
|
+
* ledger is the only account of why it crashed; a reaper that removes the
|
|
1534
|
+
* checkout and loses it has destroyed the one thing somebody would come looking
|
|
1535
|
+
* for. It lands in the PROJECT's state directory, which outlives every workspace.
|
|
1536
|
+
*
|
|
1537
|
+
* ⚠️ AND IT NEVER OVERWRITES. A second reap of the same runId — which can only
|
|
1538
|
+
* happen if the first was interrupted — lands beside the first, not on it.
|
|
1539
|
+
*/
|
|
1540
|
+
function retainEvidence(record, cwd) {
|
|
1541
|
+
const from = (0, node_path_1.join)(record.workspace, git_driver_1.STATE_DIR, 'state');
|
|
1542
|
+
if (!(0, node_fs_1.existsSync)(from))
|
|
1543
|
+
return { ok: true, at: null };
|
|
1544
|
+
/**
|
|
1545
|
+
* 🔴 AND IT MUST REALLY BE INSIDE THE WORKSPACE. `reapVerdict` realpaths the
|
|
1546
|
+
* workspace ROOT; this then built a path two levels below it and ran a
|
|
1547
|
+
* recursive copy AND a recursive delete through it. A junction at
|
|
1548
|
+
* `<workspace>/.looply` — which nothing had checked — pointed both at whatever
|
|
1549
|
+
* an attacker liked, and the delete is the half that does not come back.
|
|
1550
|
+
*/
|
|
1551
|
+
const workspaceReal = realpathDeep(record.workspace);
|
|
1552
|
+
if (!contains(workspaceReal, realpathDeep(from))) {
|
|
1553
|
+
return {
|
|
1554
|
+
ok: false,
|
|
1555
|
+
because: `${from} really lives outside the workspace, so its records were not moved`,
|
|
1556
|
+
};
|
|
1557
|
+
}
|
|
1558
|
+
// ⚠️ THE RUN ID IS A PATH SEGMENT HERE TOO, and this one ends in a recursive
|
|
1559
|
+
// copy and a recursive delete. It comes off disk, so it is checked.
|
|
1560
|
+
const base = (0, node_path_1.join)((0, git_driver_1.projectStateDir)(cwd), 'retired', safeRunId(record.runId));
|
|
1561
|
+
let to = base;
|
|
1562
|
+
for (let n = 2; (0, node_fs_1.existsSync)(to) && n < 100; n += 1)
|
|
1563
|
+
to = `${base}-${n}`;
|
|
1564
|
+
try {
|
|
1565
|
+
(0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(to), { recursive: true });
|
|
1566
|
+
(0, node_fs_1.cpSync)(from, to, { recursive: true });
|
|
1567
|
+
(0, node_fs_1.rmSync)(from, { recursive: true, force: true });
|
|
1568
|
+
return { ok: true, at: to };
|
|
1569
|
+
}
|
|
1570
|
+
catch (e) {
|
|
1571
|
+
return { ok: false, because: `its own records could not be moved to safety (${e.message})` };
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
async function reapWorkspace(git, registry, record, cwd, probe = exports.nodeProbe,
|
|
1575
|
+
/** A driver bound to the given checkout. See `workspaceResidue`. */
|
|
1576
|
+
gitInWorkspace = (dir) => new git_driver_1.GitDriver((0, git_driver_1.nodeGitExec)(dir)), home = (0, node_os_1.homedir)(),
|
|
1577
|
+
/**
|
|
1578
|
+
* `run.carry_into_checkouts` - paths looply provisioned into the checkout
|
|
1579
|
+
* itself. Passed through to `workspaceResidue`, which explains why.
|
|
1580
|
+
*/
|
|
1581
|
+
carried = []) {
|
|
1582
|
+
const verdict = reapVerdict(record, cwd, probe, home);
|
|
1583
|
+
if (!verdict.ok)
|
|
1584
|
+
return { reaped: false, because: verdict.because };
|
|
1585
|
+
/**
|
|
1586
|
+
* 🔴 A QUARANTINE IS CHECKED BEFORE ANY OF THE WALKS, AND IT NEVER EXPIRES.
|
|
1587
|
+
*
|
|
1588
|
+
* Everything below re-derives its answer from the filesystem, which means a
|
|
1589
|
+
* checkout that was refused yesterday for a reason that has since become
|
|
1590
|
+
* invisible — a live orphaned agent, a git config that has since been fixed —
|
|
1591
|
+
* is silently eligible again today. The record is the only thing that
|
|
1592
|
+
* remembers looply already decided, and only a person clears it.
|
|
1593
|
+
*/
|
|
1594
|
+
const held = (0, workspace_quarantine_1.quarantineOf)(cwd, record.workspace);
|
|
1595
|
+
if (held !== null) {
|
|
1596
|
+
return {
|
|
1597
|
+
reaped: false,
|
|
1598
|
+
because: `it is quarantined — ${held.because}. Look at ${record.workspace}, then ` +
|
|
1599
|
+
`\`looply runs --release ${(0, node_path_1.basename)(record.workspace)}\` when you have`,
|
|
1600
|
+
};
|
|
1601
|
+
}
|
|
1602
|
+
let evidenceAt = null;
|
|
1603
|
+
if ((0, node_fs_1.existsSync)(record.workspace)) {
|
|
1604
|
+
/**
|
|
1605
|
+
* 🔴 `git worktree remove` WITHOUT `--force` DELETES IGNORED FILES, and the
|
|
1606
|
+
* first version of this function said the opposite in as many words.
|
|
1607
|
+
*
|
|
1608
|
+
* Measured: a worktree holding only `node_modules` and `.env` — both
|
|
1609
|
+
* ignored, so `git status` reads clean — was removed silently and both were
|
|
1610
|
+
* destroyed. Git refuses over MODIFIED or UNTRACKED files; ignored ones it
|
|
1611
|
+
* simply takes. So "git's refusal is the check" was false, and the reaper
|
|
1612
|
+
* would have deleted a crashed execution's `.env`, its dependencies, and its
|
|
1613
|
+
* `.looply/state` ledger — the evidence of why it crashed — while reporting
|
|
1614
|
+
* success.
|
|
1615
|
+
*
|
|
1616
|
+
* The residue check below is the real one. `--ignored` is the whole point of
|
|
1617
|
+
* it: without that flag it asks git exactly the question git already answers
|
|
1618
|
+
* with a refusal, and learns nothing new.
|
|
1619
|
+
*/
|
|
1620
|
+
const residue = await workspaceResidue(gitInWorkspace(record.workspace), record.workspace, carried);
|
|
1621
|
+
if (residue.length > 0) {
|
|
1622
|
+
const blind = residue.some((r) => r.startsWith('could not be inspected'));
|
|
1623
|
+
const because = keptBecause(residue, record.workspace, blind);
|
|
1624
|
+
/**
|
|
1625
|
+
* 🔴 QUARANTINED ONLY WHEN THE ANSWER CANNOT BE RE-DERIVED, AND FILES ARE
|
|
1626
|
+
* NOT THAT CASE. Caught by a Phase 1 pin the first time this was written.
|
|
1627
|
+
*
|
|
1628
|
+
* The obvious move is to quarantine on any refusal. It is wrong, and the
|
|
1629
|
+
* failure is concrete: a workspace holding a `.env` is refused, the record
|
|
1630
|
+
* is written, the person deletes their `.env` — and `--reap` refuses for
|
|
1631
|
+
* ever, because a quarantine never resets. A person who tidied up their
|
|
1632
|
+
* own checkout can no longer retire it without a second command they have
|
|
1633
|
+
* no reason to know about. The reaper gets worse, not safer.
|
|
1634
|
+
*
|
|
1635
|
+
* Residue is a fact about the filesystem RIGHT NOW. Re-deriving it is
|
|
1636
|
+
* cheap, it is what Phase 1 does, and it self-corrects the moment the
|
|
1637
|
+
* person acts. Remembering it buys nothing and costs that.
|
|
1638
|
+
*
|
|
1639
|
+
* ⚠️ THE UNINSPECTABLE CASE IS THE OPPOSITE, and it is the one the plan's
|
|
1640
|
+
* `repoConfigTrouble` condition names. `workspaceResidue` answers
|
|
1641
|
+
* `could not be inspected (…)` when the checkout's git config trips
|
|
1642
|
+
* `repoConfigTroubleAt` — looply could not form a view at all. That is not
|
|
1643
|
+
* a fact about files that will change when somebody deletes one; it is a
|
|
1644
|
+
* checkout looply cannot reason about, and it is exactly the judgement
|
|
1645
|
+
* that has to survive the process that made it.
|
|
1646
|
+
*/
|
|
1647
|
+
if (residue.some((r) => r.startsWith('could not be inspected'))) {
|
|
1648
|
+
(0, workspace_quarantine_1.quarantine)(cwd, record.workspace, because, 'reap');
|
|
1649
|
+
}
|
|
1650
|
+
return { reaped: false, because };
|
|
1651
|
+
}
|
|
1652
|
+
// The evidence leaves before the checkout does.
|
|
1653
|
+
const kept = retainEvidence(record, cwd);
|
|
1654
|
+
if (!kept.ok)
|
|
1655
|
+
return { reaped: false, because: kept.because };
|
|
1656
|
+
evidenceAt = kept.at;
|
|
1657
|
+
try {
|
|
1658
|
+
await git.removeWorktree(record.workspace);
|
|
1659
|
+
}
|
|
1660
|
+
catch (e) {
|
|
1661
|
+
return { reaped: false, because: `git would not remove it: ${e.message}` };
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
// Only after the tree is gone: the administrative record, then the claim.
|
|
1665
|
+
await git.pruneWorktrees().catch(() => {
|
|
1666
|
+
/* pruning is tidying; a failure here does not make the reap wrong */
|
|
1667
|
+
});
|
|
1668
|
+
/**
|
|
1669
|
+
* 🔴 AND THE SLOT, WHICH THIS DID NOT GIVE BACK. `--reap` reported a run
|
|
1670
|
+
* "retired", removed its checkout and cleared its record — and left the
|
|
1671
|
+
* concurrency slot it held. So retiring a crashed run lowered the project's
|
|
1672
|
+
* usable concurrency by one, permanently, and the only way back was
|
|
1673
|
+
* `--release slot-<n>` on a slot whose record no longer existed to name it.
|
|
1674
|
+
*
|
|
1675
|
+
* `releaseSlot` is keyed on identity, so this can only ever free the slot this
|
|
1676
|
+
* run actually holds.
|
|
1677
|
+
*/
|
|
1678
|
+
for (const s of registry.slots()) {
|
|
1679
|
+
if (s.record?.runId === record.runId)
|
|
1680
|
+
registry.releaseSlot(s.slot, record.runId);
|
|
1681
|
+
}
|
|
1682
|
+
registry.release(record.runId);
|
|
1683
|
+
/**
|
|
1684
|
+
* 🔴 AND WHAT THE EXECUTION LEFT OUTSIDE ITS CHECKOUT — including a copy of
|
|
1685
|
+
* the provider credential.
|
|
1686
|
+
*
|
|
1687
|
+
* Three machine-level directories are keyed on the WORKSPACE path, which is
|
|
1688
|
+
* right: two executions must not share provider state or a container. What
|
|
1689
|
+
* was missing is the other end. This removed the checkout, the record and the
|
|
1690
|
+
* slot and left all three behind for ever, and one of them —
|
|
1691
|
+
* `~/.looply/provider-sessions/<role>-<hex>` — is the transport's HOME, which
|
|
1692
|
+
* is where the provider writes its credential.
|
|
1693
|
+
*
|
|
1694
|
+
* ⚠️ LAST, AND NEVER THE VERDICT. The reap has already succeeded by this
|
|
1695
|
+
* line; a directory that will not delete is a smaller problem than a
|
|
1696
|
+
* workspace that cannot be retired, and turning tidying into a refusal is how
|
|
1697
|
+
* a reaper stops being usable. See `workspace-residue-sweep.ts`.
|
|
1698
|
+
*/
|
|
1699
|
+
/**
|
|
1700
|
+
* The liveness the sweep needs, from the registry it already holds.
|
|
1701
|
+
*
|
|
1702
|
+
* By this line the record is released and the slot freed, so `active()` can
|
|
1703
|
+
* only still name this workspace if ANOTHER record does — which is exactly the
|
|
1704
|
+
* case where its container identities must keep their shared-path grants. A
|
|
1705
|
+
* probe that cannot answer classifies `unknown`, and the sweep treats that as
|
|
1706
|
+
* in use rather than as absent.
|
|
1707
|
+
*/
|
|
1708
|
+
(0, workspace_residue_sweep_1.sweepWorkspaceResidue)(record.workspace, undefined, {
|
|
1709
|
+
usersOf: (ws) => registry.active().filter((r) => r.workspace === ws).map((r) => r.runId),
|
|
1710
|
+
});
|
|
1711
|
+
return evidenceAt === null ? { reaped: true } : { reaped: true, evidenceAt };
|
|
1712
|
+
}
|
|
1713
|
+
/**
|
|
1714
|
+
* Workspaces on disk that no live execution and no registry record account for.
|
|
1715
|
+
*
|
|
1716
|
+
* 🔴 THE LIFECYCLE HAD A THIRD HOLE, and I found this one by using the command I
|
|
1717
|
+
* had just written. `looply run --new-workspace` cuts the checkout BEFORE the
|
|
1718
|
+
* run registers, so anything that ends the run in between — a config error, a
|
|
1719
|
+
* dirty tree, `--dry-run`, a crash — leaves a real worktree that `looply runs`
|
|
1720
|
+
* cannot see and `--reap` cannot reach, because both work from registry records
|
|
1721
|
+
* and there is none. They accumulate silently, one per failed start.
|
|
1722
|
+
*
|
|
1723
|
+
* ⚠️ ABSENCE OF A RECORD IS NOT ENOUGH TO CALL ONE ABANDONED. A run that has
|
|
1724
|
+
* created its workspace and not yet registered is in exactly that state for a
|
|
1725
|
+
* few hundred milliseconds. So the LEASE is the second witness, as it is for a
|
|
1726
|
+
* slot: a workspace whose own execution state holds a lease naming a process
|
|
1727
|
+
* that is not proven dead is left alone.
|
|
1728
|
+
*
|
|
1729
|
+
* ⚠️ AND IT LOOKS ONLY UNDER THE WORKSPACES ROOT — never at a `--in` checkout,
|
|
1730
|
+
* which is the person's and which nothing here may ever remove.
|
|
1731
|
+
*/
|
|
1732
|
+
function abandonedWorkspaces(cwd, registry, probe = exports.nodeProbe, home = (0, node_os_1.homedir)()) {
|
|
1733
|
+
const root = workspacesRoot(cwd, home);
|
|
1734
|
+
let names;
|
|
1735
|
+
try {
|
|
1736
|
+
names = (0, node_fs_1.readdirSync)(root);
|
|
1737
|
+
}
|
|
1738
|
+
catch {
|
|
1739
|
+
return { abandoned: [], busy: [] };
|
|
1740
|
+
}
|
|
1741
|
+
const busy = [];
|
|
1742
|
+
const claimed = new Set(registry
|
|
1743
|
+
.list()
|
|
1744
|
+
.flatMap((e) => (e.kind === 'unreadable' ? [] : [(0, node_path_1.resolve)(e.record.workspace).toLowerCase()])));
|
|
1745
|
+
const out = [];
|
|
1746
|
+
for (const name of names) {
|
|
1747
|
+
const path = (0, node_path_1.join)(root, name);
|
|
1748
|
+
try {
|
|
1749
|
+
if (!(0, node_fs_1.lstatSync)(path).isDirectory())
|
|
1750
|
+
continue;
|
|
1751
|
+
}
|
|
1752
|
+
catch {
|
|
1753
|
+
continue;
|
|
1754
|
+
}
|
|
1755
|
+
if (claimed.has((0, node_path_1.resolve)(path).toLowerCase()))
|
|
1756
|
+
continue;
|
|
1757
|
+
if (checkoutBusy((0, node_path_1.join)(path, git_driver_1.STATE_DIR, 'state'), probe)) {
|
|
1758
|
+
/**
|
|
1759
|
+
* 🔴 THIS USED TO `continue` SILENTLY, and the checkout then existed
|
|
1760
|
+
* nowhere: no row, no count, no reason, and `looply runs` printing
|
|
1761
|
+
* "nothing running, and nothing left behind" — which was false, with a
|
|
1762
|
+
* real checkout on disk that nothing would ever mention again.
|
|
1763
|
+
*
|
|
1764
|
+
* It is BUSY, not abandoned, so it is not offered for reaping; but a
|
|
1765
|
+
* person looking at a directory that is plainly there deserves to see it.
|
|
1766
|
+
*/
|
|
1767
|
+
busy.push(path);
|
|
1768
|
+
continue;
|
|
1769
|
+
}
|
|
1770
|
+
out.push(path);
|
|
1771
|
+
}
|
|
1772
|
+
return { abandoned: out.sort(), busy: busy.sort() };
|
|
1773
|
+
}
|
|
1774
|
+
/**
|
|
1775
|
+
* Whether anything in a checkout is still using it.
|
|
1776
|
+
*
|
|
1777
|
+
* TWO FILES, AND THE SECOND ONE EXISTS BECAUSE OF A RACE THE FIRST CANNOT SEE:
|
|
1778
|
+
*
|
|
1779
|
+
* run.lease.json a run that has started. This is the real answer.
|
|
1780
|
+
* creating.json a run that has CUT the checkout and not yet taken its
|
|
1781
|
+
* lease. `--new-workspace` creates the worktree, then loads
|
|
1782
|
+
* config, then runs git checks, then acquires the lease —
|
|
1783
|
+
* seconds, on a cold repository. For that whole window the
|
|
1784
|
+
* checkout had no lease, so `abandonedWorkspaces` called it
|
|
1785
|
+
* abandoned and a concurrent `--reap` would delete a
|
|
1786
|
+
* starting run's checkout out from under it.
|
|
1787
|
+
*
|
|
1788
|
+
* ⚠️ NEITHER FILE NEEDS CLEANING UP. Both name a pid; once that process is gone
|
|
1789
|
+
* both read as "not busy", so a crash between the two leaves nothing stuck.
|
|
1790
|
+
*
|
|
1791
|
+
* ⚠️ AND IT FAILS TOWARDS BUSY. Absent is an answer; unreadable is not.
|
|
1792
|
+
*/
|
|
1793
|
+
/**
|
|
1794
|
+
* WHICH witness is holding a checkout, in words.
|
|
1795
|
+
*
|
|
1796
|
+
* 🔴 BOTH REFUSALS HARD-CODED "still holding a run lease", and `checkoutBusy`
|
|
1797
|
+
* has two witnesses. So a person told that went looking for a `run.lease.json`
|
|
1798
|
+
* that is not there, while the thing actually holding the checkout was the
|
|
1799
|
+
* creation marker — a starting run, which is a completely different situation
|
|
1800
|
+
* and needs a completely different response.
|
|
1801
|
+
*/
|
|
1802
|
+
function busyBecause(stateDir, probe) {
|
|
1803
|
+
if (holderAlive((0, run_lease_1.leasePathIn)(stateDir), probe))
|
|
1804
|
+
return 'a run lease';
|
|
1805
|
+
if (holderAlive((0, node_path_1.join)(stateDir, CREATING), probe, { corruptIsBusy: false })) {
|
|
1806
|
+
/**
|
|
1807
|
+
* ⚠️ "STARTING OR FINISHING", because the marker covers BOTH ends. It is
|
|
1808
|
+
* written when the checkout is cut and cleared as the last thing a run does,
|
|
1809
|
+
* so a marker with no lease beside it means either a run that has not taken
|
|
1810
|
+
* its lease yet or one that has already given it back and is still tidying.
|
|
1811
|
+
* Saying only "starting up" sent people looking for a run that was in fact
|
|
1812
|
+
* on its way out.
|
|
1813
|
+
*/
|
|
1814
|
+
return 'a run that is still starting up or finishing';
|
|
1815
|
+
}
|
|
1816
|
+
return 'something this could not identify';
|
|
1817
|
+
}
|
|
1818
|
+
function checkoutBusy(stateDir, probe = exports.nodeProbe) {
|
|
1819
|
+
/**
|
|
1820
|
+
* ⚠️ THE TWO FILES ARE JUDGED DIFFERENTLY WHEN THEY CANNOT BE PARSED.
|
|
1821
|
+
*
|
|
1822
|
+
* A LEASE that cannot be read fails towards busy: it is written by a live run
|
|
1823
|
+
* on an ordinary schedule, so an unreadable one is most likely a live write
|
|
1824
|
+
* caught mid-rename, and treating it as free starts a second run in a checkout
|
|
1825
|
+
* somebody is using.
|
|
1826
|
+
*
|
|
1827
|
+
* A CREATING marker is written ONCE, atomically, at the instant the checkout
|
|
1828
|
+
* is cut. It cannot be caught mid-write. So an unparseable one is corruption,
|
|
1829
|
+
* not a race — and treating it as busy would wedge that workspace for ever,
|
|
1830
|
+
* invisible to `looply runs` and unreachable by `--reap`, with no file anyone
|
|
1831
|
+
* could point at.
|
|
1832
|
+
*/
|
|
1833
|
+
if (holderAlive((0, run_lease_1.leasePathIn)(stateDir), probe))
|
|
1834
|
+
return true;
|
|
1835
|
+
return holderAlive((0, node_path_1.join)(stateDir, CREATING), probe, { corruptIsBusy: false });
|
|
1836
|
+
}
|
|
1837
|
+
function holderAlive(path, probe, opts = {}) {
|
|
1838
|
+
/**
|
|
1839
|
+
* 🔴 A PARSE FAILURE AND A READ FAILURE ARE DIFFERENT THINGS, and one flag
|
|
1840
|
+
* covering both was wrong for the creation marker.
|
|
1841
|
+
*
|
|
1842
|
+
* CANNOT OPEN IT (EACCES, EBUSY, EPERM, an I/O error) is a failure to ASK.
|
|
1843
|
+
* Nothing has been learned, so the answer is always "somebody may be here".
|
|
1844
|
+
* Reading that as "free" is how `--reap` deletes a starting run's checkout —
|
|
1845
|
+
* which by construction has no lease yet, so the marker is its only defence.
|
|
1846
|
+
*
|
|
1847
|
+
* CANNOT PARSE IT is a statement about the contents, and what it means depends
|
|
1848
|
+
* on the file. A LEASE is rewritten on an ordinary schedule, so unparseable
|
|
1849
|
+
* most likely means caught mid-write: busy. The MARKER is written once,
|
|
1850
|
+
* temp-then-rename, so it cannot be caught mid-write at all: unparseable means
|
|
1851
|
+
* corrupt, and treating corruption as busy wedges that workspace for ever.
|
|
1852
|
+
*/
|
|
1853
|
+
const corruptIsBusy = opts.corruptIsBusy !== false;
|
|
1854
|
+
let text;
|
|
1855
|
+
try {
|
|
1856
|
+
text = (0, node_fs_1.readFileSync)(path, 'utf8');
|
|
1857
|
+
}
|
|
1858
|
+
catch (e) {
|
|
1859
|
+
if (e.code === 'ENOENT')
|
|
1860
|
+
return false;
|
|
1861
|
+
return true; // could not ask
|
|
1862
|
+
}
|
|
1863
|
+
try {
|
|
1864
|
+
const pid = JSON.parse(text).pid;
|
|
1865
|
+
return typeof pid !== 'number' || probe(pid) !== 'dead';
|
|
1866
|
+
}
|
|
1867
|
+
catch {
|
|
1868
|
+
return corruptIsBusy;
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
/** Written the moment a workspace is cut, so the gap before the lease is covered. */
|
|
1872
|
+
const CREATING = 'creating.json';
|
|
1873
|
+
/** Whose start marker a workspace carries, so a person can be warned before clearing it. */
|
|
1874
|
+
function creationMarkerHolder(cwd, name, probe = exports.nodeProbe) {
|
|
1875
|
+
if (name === '' || /[\/]/.test(name))
|
|
1876
|
+
return null;
|
|
1877
|
+
try {
|
|
1878
|
+
const at = (0, node_path_1.join)(workspacesRoot(cwd), name, git_driver_1.STATE_DIR, 'state', CREATING);
|
|
1879
|
+
const pid = JSON.parse((0, node_fs_1.readFileSync)(at, 'utf8')).pid;
|
|
1880
|
+
return typeof pid === 'number' ? { pid, liveness: probe(pid) } : null;
|
|
1881
|
+
}
|
|
1882
|
+
catch {
|
|
1883
|
+
return null;
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
/**
|
|
1887
|
+
* Drop the creation marker — the LAST thing a run does, and only then.
|
|
1888
|
+
*
|
|
1889
|
+
* 🔴 THE TIMING IS THE WHOLE DESIGN, and both extremes were shipped and wrong.
|
|
1890
|
+
*
|
|
1891
|
+
* Cleared EARLY (the moment the run had its lease and record) it reopened the
|
|
1892
|
+
* window it exists to close: `releaseHold` gives the lease and the record back
|
|
1893
|
+
* at the TOP of the teardown, with work still to do below it, so a concurrent
|
|
1894
|
+
* `--reap` saw no lease, no record and no marker while the run was still
|
|
1895
|
+
* writing.
|
|
1896
|
+
*
|
|
1897
|
+
* NEVER cleared, it becomes a permanent pid-shaped claim on a finished
|
|
1898
|
+
* checkout: once that pid is recycled the workspace reads as busy for ever,
|
|
1899
|
+
* invisible to `--reap`, with nothing a person could point at.
|
|
1900
|
+
*
|
|
1901
|
+
* So: last, after everything else the run does.
|
|
1902
|
+
*/
|
|
1903
|
+
function clearCreationMarker(stateDir) {
|
|
1904
|
+
try {
|
|
1905
|
+
(0, node_fs_1.unlinkSync)((0, node_path_1.join)(stateDir, CREATING));
|
|
1906
|
+
}
|
|
1907
|
+
catch {
|
|
1908
|
+
/* absent is the goal state */
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
/**
|
|
1912
|
+
* Drop this run's addressed control file, once the run is over.
|
|
1913
|
+
*
|
|
1914
|
+
* ⚠️ NOT REQUIRED FOR CORRECTNESS EITHER — it is named for a runId that will
|
|
1915
|
+
* never exist again, so nothing can ever read it. It is cleared because
|
|
1916
|
+
* otherwise `looply stop` leaves one behind per stopped run, for ever, in a
|
|
1917
|
+
* directory that never had them: an ordinary Simple Mode checkout would
|
|
1918
|
+
* accumulate a `control.<runId>.json` every time a person pressed stop.
|
|
1919
|
+
*
|
|
1920
|
+
* ⚠️ AND AT TEARDOWN, NOT AT CONSUME. Between consuming a stop and actually
|
|
1921
|
+
* ending, a run is still running and a second stop is still a thing a person
|
|
1922
|
+
* may send it.
|
|
1923
|
+
*/
|
|
1924
|
+
function clearAddressedControl(stateDir, runId) {
|
|
1925
|
+
try {
|
|
1926
|
+
(0, node_fs_1.unlinkSync)((0, node_path_1.join)(stateDir, `control.${runId}.json`));
|
|
1927
|
+
}
|
|
1928
|
+
catch {
|
|
1929
|
+
/* absent is the goal state */
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
/**
|
|
1933
|
+
* Retire a workspace that no record accounts for.
|
|
1934
|
+
*
|
|
1935
|
+
* Everything the record-based reap checks, minus the record: containment on the
|
|
1936
|
+
* real path, residue, evidence retention, and `worktree remove` without
|
|
1937
|
+
* `--force`. A person's `--in` checkout can never reach this — it is not under
|
|
1938
|
+
* the workspaces root, which is checked first and by realpath.
|
|
1939
|
+
*/
|
|
1940
|
+
async function reapAbandoned(git, cwd, path, probe = exports.nodeProbe, gitInWorkspace = (dir) => new git_driver_1.GitDriver((0, git_driver_1.nodeGitExec)(dir)), home = (0, node_os_1.homedir)(),
|
|
1941
|
+
/**
|
|
1942
|
+
* `run.carry_into_checkouts` - paths looply provisioned into the checkout
|
|
1943
|
+
* itself. Passed through to `workspaceResidue`, which explains why.
|
|
1944
|
+
*/
|
|
1945
|
+
carried = []) {
|
|
1946
|
+
const rootReal = realpathDeep(workspacesRoot(cwd, home));
|
|
1947
|
+
const real = realpathDeep(path);
|
|
1948
|
+
if (!contains(rootReal, real)) {
|
|
1949
|
+
return { reaped: false, because: `${path} is not under this project's workspaces root` };
|
|
1950
|
+
}
|
|
1951
|
+
if (checkoutBusy((0, node_path_1.join)(path, git_driver_1.STATE_DIR, 'state'), probe)) {
|
|
1952
|
+
return { reaped: false, because: `something is still using it (${busyBecause((0, node_path_1.join)(path, git_driver_1.STATE_DIR, 'state'), probe)})` };
|
|
1953
|
+
}
|
|
1954
|
+
/**
|
|
1955
|
+
* 🔴 THE QUARANTINE IS CHECKED HERE TOO, AND LEAVING IT OUT DESTROYED THE
|
|
1956
|
+
* CHECKOUT THE QUARANTINE EXISTED TO PROTECT. Measured end to end.
|
|
1957
|
+
*
|
|
1958
|
+
* `reapWorkspace` consulted the quarantine; this — the function `--reap`
|
|
1959
|
+
* calls for every checkout with NO registry record — did not. And the two are
|
|
1960
|
+
* one step apart, because looply's own refusal printed
|
|
1961
|
+
* `looply runs --release <name>`, a workspace directory is NAMED for its
|
|
1962
|
+
* runId, and that command's ordered chain matches the RUN RECORD branch
|
|
1963
|
+
* first. So the person read the refusal, typed exactly what it said, was told
|
|
1964
|
+
* "cleared — the workspace was left alone", and thereby made the checkout
|
|
1965
|
+
* record-less: abandoned. The very next `--reap` printed the `quarantined`
|
|
1966
|
+
* row and then `retired`, in one invocation, and the checkout was gone with
|
|
1967
|
+
* the "do not touch this" record still sitting on disk.
|
|
1968
|
+
*
|
|
1969
|
+
* A protection that one branch honours and its sibling does not is not a
|
|
1970
|
+
* protection; it is a coin toss decided by which list a directory happens to
|
|
1971
|
+
* be on.
|
|
1972
|
+
*/
|
|
1973
|
+
const held = (0, workspace_quarantine_1.quarantineOf)(cwd, path);
|
|
1974
|
+
if (held !== null) {
|
|
1975
|
+
return {
|
|
1976
|
+
reaped: false,
|
|
1977
|
+
because: `it is quarantined — ${held.because}. Look at ${path}, then ` +
|
|
1978
|
+
`\`looply runs --release ${(0, node_path_1.basename)(path)}\` when you have`,
|
|
1979
|
+
};
|
|
1980
|
+
}
|
|
1981
|
+
if (!(0, node_fs_1.existsSync)(path))
|
|
1982
|
+
return { reaped: true };
|
|
1983
|
+
const residue = await workspaceResidue(gitInWorkspace(path), path, carried);
|
|
1984
|
+
if (residue.length > 0) {
|
|
1985
|
+
const blind = residue.some((r) => r.startsWith('could not be inspected'));
|
|
1986
|
+
const because = keptBecause(residue, path, blind);
|
|
1987
|
+
// Same rule as `reapWorkspace`: only the answer that cannot be re-derived
|
|
1988
|
+
// is written down. See the note there.
|
|
1989
|
+
if (residue.some((r) => r.startsWith('could not be inspected'))) {
|
|
1990
|
+
(0, workspace_quarantine_1.quarantine)(cwd, path, because, 'reap-abandoned');
|
|
1991
|
+
}
|
|
1992
|
+
return { reaped: false, because };
|
|
1993
|
+
}
|
|
1994
|
+
/**
|
|
1995
|
+
* ⚠️ THE SYNTHETIC ID IS SANITISED. `retainEvidence` uses it as a path segment
|
|
1996
|
+
* and `safeRunId` refuses anything that is not `[A-Za-z0-9_-]` — so a
|
|
1997
|
+
* directory under the workspaces root with a dot or a space in its name would
|
|
1998
|
+
* throw out of `reapAbandoned`, and out of the `--reap` loop that calls it,
|
|
1999
|
+
* abandoning every workspace after it in the list.
|
|
2000
|
+
*/
|
|
2001
|
+
const tag = `abandoned-${(0, node_path_1.basename)(path).replace(/[^A-Za-z0-9_-]/g, '-')}`.slice(0, 120);
|
|
2002
|
+
const kept = retainEvidence({ runId: tag, workspace: path }, cwd);
|
|
2003
|
+
if (!kept.ok)
|
|
2004
|
+
return { reaped: false, because: kept.because };
|
|
2005
|
+
try {
|
|
2006
|
+
await git.removeWorktree(path);
|
|
2007
|
+
}
|
|
2008
|
+
catch (e) {
|
|
2009
|
+
return { reaped: false, because: `git would not remove it: ${e.message}` };
|
|
2010
|
+
}
|
|
2011
|
+
await git.pruneWorktrees().catch(() => {
|
|
2012
|
+
/* tidying */
|
|
2013
|
+
});
|
|
2014
|
+
/**
|
|
2015
|
+
* 🔴 AND IT SWEEPS, WHICH IT DID NOT — so the provider-session directory, which
|
|
2016
|
+
* holds a COPY OF THE PROVIDER CREDENTIAL, survived every record-less reap.
|
|
2017
|
+
*
|
|
2018
|
+
* Measured: six of six seeded credential files still on disk after this
|
|
2019
|
+
* function reported `{reaped: true}` and removed the checkout. That is the
|
|
2020
|
+
* exact exposure the sweep was added for, left open on the path that handles
|
|
2021
|
+
* precisely the crashed-before-register workspaces which motivated it.
|
|
2022
|
+
*/
|
|
2023
|
+
(0, workspace_residue_sweep_1.sweepWorkspaceResidue)(path);
|
|
2024
|
+
return { reaped: true };
|
|
2025
|
+
}
|
|
2026
|
+
//# sourceMappingURL=workspace.js.map
|