dorfl 0.0.0 → 0.1.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/dist/advance-ci-template.d.ts +73 -0
- package/dist/advance-ci-template.d.ts.map +1 -0
- package/dist/advance-ci-template.js +104 -0
- package/dist/advance-ci-template.js.map +1 -0
- package/dist/advance-classify.d.ts +132 -0
- package/dist/advance-classify.d.ts.map +1 -0
- package/dist/advance-classify.js +120 -0
- package/dist/advance-classify.js.map +1 -0
- package/dist/advance-drivers.d.ts +182 -0
- package/dist/advance-drivers.d.ts.map +1 -0
- package/dist/advance-drivers.js +231 -0
- package/dist/advance-drivers.js.map +1 -0
- package/dist/advance-isolated.d.ts +156 -0
- package/dist/advance-isolated.d.ts.map +1 -0
- package/dist/advance-isolated.js +256 -0
- package/dist/advance-isolated.js.map +1 -0
- package/dist/advance-lifecycle-template.d.ts +107 -0
- package/dist/advance-lifecycle-template.d.ts.map +1 -0
- package/dist/advance-lifecycle-template.js +668 -0
- package/dist/advance-lifecycle-template.js.map +1 -0
- package/dist/advance-loop-driver.d.ts +325 -0
- package/dist/advance-loop-driver.d.ts.map +1 -0
- package/dist/advance-loop-driver.js +437 -0
- package/dist/advance-loop-driver.js.map +1 -0
- package/dist/advance-treeless-publish.d.ts +108 -0
- package/dist/advance-treeless-publish.d.ts.map +1 -0
- package/dist/advance-treeless-publish.js +71 -0
- package/dist/advance-treeless-publish.js.map +1 -0
- package/dist/advance.d.ts +340 -0
- package/dist/advance.d.ts.map +1 -0
- package/dist/advance.js +1122 -0
- package/dist/advance.js.map +1 -0
- package/dist/advancing-lock.d.ts +294 -0
- package/dist/advancing-lock.d.ts.map +1 -0
- package/dist/advancing-lock.js +594 -0
- package/dist/advancing-lock.js.map +1 -0
- package/dist/agent-launch.d.ts +79 -0
- package/dist/agent-launch.d.ts.map +1 -0
- package/dist/agent-launch.js +61 -0
- package/dist/agent-launch.js.map +1 -0
- package/dist/agent-stop.d.ts +149 -0
- package/dist/agent-stop.d.ts.map +1 -0
- package/dist/agent-stop.js +307 -0
- package/dist/agent-stop.js.map +1 -0
- package/dist/apply-decide.d.ts +127 -0
- package/dist/apply-decide.d.ts.map +1 -0
- package/dist/apply-decide.js +176 -0
- package/dist/apply-decide.js.map +1 -0
- package/dist/apply-merge-action.d.ts +206 -0
- package/dist/apply-merge-action.d.ts.map +1 -0
- package/dist/apply-merge-action.js +307 -0
- package/dist/apply-merge-action.js.map +1 -0
- package/dist/apply-persist.d.ts +174 -0
- package/dist/apply-persist.d.ts.map +1 -0
- package/dist/apply-persist.js +359 -0
- package/dist/apply-persist.js.map +1 -0
- package/dist/arbiter.d.ts +120 -0
- package/dist/arbiter.d.ts.map +1 -0
- package/dist/arbiter.js +255 -0
- package/dist/arbiter.js.map +1 -0
- package/dist/brand.d.ts +70 -0
- package/dist/brand.d.ts.map +1 -0
- package/dist/brand.js +84 -0
- package/dist/brand.js.map +1 -0
- package/dist/buildable-body.d.ts +132 -0
- package/dist/buildable-body.d.ts.map +1 -0
- package/dist/buildable-body.js +131 -0
- package/dist/buildable-body.js.map +1 -0
- package/dist/categorise.d.ts +66 -0
- package/dist/categorise.d.ts.map +1 -0
- package/dist/categorise.js +106 -0
- package/dist/categorise.js.map +1 -0
- package/dist/claim-cas.d.ts +117 -0
- package/dist/claim-cas.d.ts.map +1 -0
- package/dist/claim-cas.js +312 -0
- package/dist/claim-cas.js.map +1 -0
- package/dist/cli-spinner.d.ts +112 -0
- package/dist/cli-spinner.d.ts.map +1 -0
- package/dist/cli-spinner.js +157 -0
- package/dist/cli-spinner.js.map +1 -0
- package/dist/cli.d.ts +11 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +3094 -0
- package/dist/cli.js.map +1 -0
- package/dist/close-job-template.d.ts +70 -0
- package/dist/close-job-template.d.ts.map +1 -0
- package/dist/close-job-template.js +180 -0
- package/dist/close-job-template.js.map +1 -0
- package/dist/close-job.d.ts +95 -0
- package/dist/close-job.d.ts.map +1 -0
- package/dist/close-job.js +226 -0
- package/dist/close-job.js.map +1 -0
- package/dist/complete.d.ts +361 -0
- package/dist/complete.d.ts.map +1 -0
- package/dist/complete.js +885 -0
- package/dist/complete.js.map +1 -0
- package/dist/concurrency.d.ts +68 -0
- package/dist/concurrency.d.ts.map +1 -0
- package/dist/concurrency.js +112 -0
- package/dist/concurrency.js.map +1 -0
- package/dist/config-override.d.ts +76 -0
- package/dist/config-override.d.ts.map +1 -0
- package/dist/config-override.js +50 -0
- package/dist/config-override.js.map +1 -0
- package/dist/config.d.ts +668 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +241 -0
- package/dist/config.js.map +1 -0
- package/dist/continue-branch.d.ts +249 -0
- package/dist/continue-branch.d.ts.map +1 -0
- package/dist/continue-branch.js +389 -0
- package/dist/continue-branch.js.map +1 -0
- package/dist/cwd-section.d.ts +186 -0
- package/dist/cwd-section.d.ts.map +1 -0
- package/dist/cwd-section.js +209 -0
- package/dist/cwd-section.js.map +1 -0
- package/dist/decision-engine.d.ts +170 -0
- package/dist/decision-engine.d.ts.map +1 -0
- package/dist/decision-engine.js +136 -0
- package/dist/decision-engine.js.map +1 -0
- package/dist/detect.d.ts +17 -0
- package/dist/detect.d.ts.map +1 -0
- package/dist/detect.js +118 -0
- package/dist/detect.js.map +1 -0
- package/dist/do-autopick.d.ts +85 -0
- package/dist/do-autopick.d.ts.map +1 -0
- package/dist/do-autopick.js +112 -0
- package/dist/do-autopick.js.map +1 -0
- package/dist/do-config.d.ts +312 -0
- package/dist/do-config.d.ts.map +1 -0
- package/dist/do-config.js +358 -0
- package/dist/do-config.js.map +1 -0
- package/dist/do-remote-auto.d.ts +75 -0
- package/dist/do-remote-auto.d.ts.map +1 -0
- package/dist/do-remote-auto.js +111 -0
- package/dist/do-remote-auto.js.map +1 -0
- package/dist/do.d.ts +621 -0
- package/dist/do.d.ts.map +1 -0
- package/dist/do.js +1882 -0
- package/dist/do.js.map +1 -0
- package/dist/drop-source.d.ts +96 -0
- package/dist/drop-source.d.ts.map +1 -0
- package/dist/drop-source.js +91 -0
- package/dist/drop-source.js.map +1 -0
- package/dist/eligibility.d.ts +46 -0
- package/dist/eligibility.d.ts.map +1 -0
- package/dist/eligibility.js +34 -0
- package/dist/eligibility.js.map +1 -0
- package/dist/env-config.d.ts +51 -0
- package/dist/env-config.d.ts.map +1 -0
- package/dist/env-config.js +272 -0
- package/dist/env-config.js.map +1 -0
- package/dist/failure-cause.d.ts +70 -0
- package/dist/failure-cause.d.ts.map +1 -0
- package/dist/failure-cause.js +126 -0
- package/dist/failure-cause.js.map +1 -0
- package/dist/format.d.ts +43 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/format.js +256 -0
- package/dist/format.js.map +1 -0
- package/dist/frontmatter.d.ts +215 -0
- package/dist/frontmatter.d.ts.map +1 -0
- package/dist/frontmatter.js +345 -0
- package/dist/frontmatter.js.map +1 -0
- package/dist/gate-readiness.d.ts +84 -0
- package/dist/gate-readiness.d.ts.map +1 -0
- package/dist/gate-readiness.js +103 -0
- package/dist/gate-readiness.js.map +1 -0
- package/dist/gc.d.ts +165 -0
- package/dist/gc.d.ts.map +1 -0
- package/dist/gc.js +313 -0
- package/dist/gc.js.map +1 -0
- package/dist/gh-failure.d.ts +42 -0
- package/dist/gh-failure.d.ts.map +1 -0
- package/dist/gh-failure.js +49 -0
- package/dist/gh-failure.js.map +1 -0
- package/dist/git.d.ts +75 -0
- package/dist/git.d.ts.map +1 -0
- package/dist/git.js +130 -0
- package/dist/git.js.map +1 -0
- package/dist/github.d.ts +187 -0
- package/dist/github.d.ts.map +1 -0
- package/dist/github.js +343 -0
- package/dist/github.js.map +1 -0
- package/dist/harness.d.ts +242 -0
- package/dist/harness.d.ts.map +1 -0
- package/dist/harness.js +157 -0
- package/dist/harness.js.map +1 -0
- package/dist/identity.d.ts +167 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +231 -0
- package/dist/identity.js.map +1 -0
- package/dist/index.d.ts +147 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +76 -0
- package/dist/index.js.map +1 -0
- package/dist/install-ci-branch-protection.d.ts +147 -0
- package/dist/install-ci-branch-protection.d.ts.map +1 -0
- package/dist/install-ci-branch-protection.js +166 -0
- package/dist/install-ci-branch-protection.js.map +1 -0
- package/dist/install-ci-capabilities/advance-lifecycle.d.ts +15 -0
- package/dist/install-ci-capabilities/advance-lifecycle.d.ts.map +1 -0
- package/dist/install-ci-capabilities/advance-lifecycle.js +28 -0
- package/dist/install-ci-capabilities/advance-lifecycle.js.map +1 -0
- package/dist/install-ci-capabilities/close-job.d.ts +13 -0
- package/dist/install-ci-capabilities/close-job.d.ts.map +1 -0
- package/dist/install-ci-capabilities/close-job.js +26 -0
- package/dist/install-ci-capabilities/close-job.js.map +1 -0
- package/dist/install-ci-capabilities/example-noop.d.ts +16 -0
- package/dist/install-ci-capabilities/example-noop.d.ts.map +1 -0
- package/dist/install-ci-capabilities/example-noop.js +23 -0
- package/dist/install-ci-capabilities/example-noop.js.map +1 -0
- package/dist/install-ci-capabilities/intake.d.ts +15 -0
- package/dist/install-ci-capabilities/intake.d.ts.map +1 -0
- package/dist/install-ci-capabilities/intake.js +28 -0
- package/dist/install-ci-capabilities/intake.js.map +1 -0
- package/dist/install-ci-capabilities/verify.d.ts +14 -0
- package/dist/install-ci-capabilities/verify.d.ts.map +1 -0
- package/dist/install-ci-capabilities/verify.js +27 -0
- package/dist/install-ci-capabilities/verify.js.map +1 -0
- package/dist/install-ci-core.d.ts +446 -0
- package/dist/install-ci-core.d.ts.map +1 -0
- package/dist/install-ci-core.js +760 -0
- package/dist/install-ci-core.js.map +1 -0
- package/dist/install-ci-github.d.ts +167 -0
- package/dist/install-ci-github.d.ts.map +1 -0
- package/dist/install-ci-github.js +315 -0
- package/dist/install-ci-github.js.map +1 -0
- package/dist/install-ci.d.ts +105 -0
- package/dist/install-ci.d.ts.map +1 -0
- package/dist/install-ci.js +363 -0
- package/dist/install-ci.js.map +1 -0
- package/dist/intake-event.d.ts +88 -0
- package/dist/intake-event.d.ts.map +1 -0
- package/dist/intake-event.js +66 -0
- package/dist/intake-event.js.map +1 -0
- package/dist/intake-marker.d.ts +95 -0
- package/dist/intake-marker.d.ts.map +1 -0
- package/dist/intake-marker.js +127 -0
- package/dist/intake-marker.js.map +1 -0
- package/dist/intake-triage.d.ts +48 -0
- package/dist/intake-triage.d.ts.map +1 -0
- package/dist/intake-triage.js +95 -0
- package/dist/intake-triage.js.map +1 -0
- package/dist/intake-trigger-template.d.ts +185 -0
- package/dist/intake-trigger-template.d.ts.map +1 -0
- package/dist/intake-trigger-template.js +449 -0
- package/dist/intake-trigger-template.js.map +1 -0
- package/dist/intake.d.ts +569 -0
- package/dist/intake.d.ts.map +1 -0
- package/dist/intake.js +1628 -0
- package/dist/intake.js.map +1 -0
- package/dist/integration-core.d.ts +539 -0
- package/dist/integration-core.d.ts.map +1 -0
- package/dist/integration-core.js +2195 -0
- package/dist/integration-core.js.map +1 -0
- package/dist/integrator.d.ts +343 -0
- package/dist/integrator.d.ts.map +1 -0
- package/dist/integrator.js +400 -0
- package/dist/integrator.js.map +1 -0
- package/dist/isolation.d.ts +219 -0
- package/dist/isolation.d.ts.map +1 -0
- package/dist/isolation.js +261 -0
- package/dist/isolation.js.map +1 -0
- package/dist/issue-provider.d.ts +349 -0
- package/dist/issue-provider.d.ts.map +1 -0
- package/dist/issue-provider.js +360 -0
- package/dist/issue-provider.js.map +1 -0
- package/dist/item-lock.d.ts +626 -0
- package/dist/item-lock.d.ts.map +1 -0
- package/dist/item-lock.js +1381 -0
- package/dist/item-lock.js.map +1 -0
- package/dist/item-path.d.ts +49 -0
- package/dist/item-path.d.ts.map +1 -0
- package/dist/item-path.js +66 -0
- package/dist/item-path.js.map +1 -0
- package/dist/ledger-lint.d.ts +129 -0
- package/dist/ledger-lint.d.ts.map +1 -0
- package/dist/ledger-lint.js +249 -0
- package/dist/ledger-lint.js.map +1 -0
- package/dist/ledger-read.d.ts +357 -0
- package/dist/ledger-read.d.ts.map +1 -0
- package/dist/ledger-read.js +442 -0
- package/dist/ledger-read.js.map +1 -0
- package/dist/ledger-write.d.ts +330 -0
- package/dist/ledger-write.d.ts.map +1 -0
- package/dist/ledger-write.js +411 -0
- package/dist/ledger-write.js.map +1 -0
- package/dist/lifecycle-gather.d.ts +30 -0
- package/dist/lifecycle-gather.d.ts.map +1 -0
- package/dist/lifecycle-gather.js +205 -0
- package/dist/lifecycle-gather.js.map +1 -0
- package/dist/lifecycle-pools.d.ts +180 -0
- package/dist/lifecycle-pools.d.ts.map +1 -0
- package/dist/lifecycle-pools.js +78 -0
- package/dist/lifecycle-pools.js.map +1 -0
- package/dist/merge-question-surfacer.d.ts +166 -0
- package/dist/merge-question-surfacer.d.ts.map +1 -0
- package/dist/merge-question-surfacer.js +297 -0
- package/dist/merge-question-surfacer.js.map +1 -0
- package/dist/mint-adr.d.ts +126 -0
- package/dist/mint-adr.d.ts.map +1 -0
- package/dist/mint-adr.js +257 -0
- package/dist/mint-adr.js.map +1 -0
- package/dist/mirror-pool-scan.d.ts +125 -0
- package/dist/mirror-pool-scan.d.ts.map +1 -0
- package/dist/mirror-pool-scan.js +104 -0
- package/dist/mirror-pool-scan.js.map +1 -0
- package/dist/needs-attention.d.ts +341 -0
- package/dist/needs-attention.d.ts.map +1 -0
- package/dist/needs-attention.js +900 -0
- package/dist/needs-attention.js.map +1 -0
- package/dist/orphan-sidecar.d.ts +79 -0
- package/dist/orphan-sidecar.d.ts.map +1 -0
- package/dist/orphan-sidecar.js +71 -0
- package/dist/orphan-sidecar.js.map +1 -0
- package/dist/output.d.ts +48 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +66 -0
- package/dist/output.js.map +1 -0
- package/dist/pi-harness.d.ts +179 -0
- package/dist/pi-harness.d.ts.map +1 -0
- package/dist/pi-harness.js +342 -0
- package/dist/pi-harness.js.map +1 -0
- package/dist/placement.d.ts +99 -0
- package/dist/placement.d.ts.map +1 -0
- package/dist/placement.js +67 -0
- package/dist/placement.js.map +1 -0
- package/dist/prd-to-spec.d.ts +315 -0
- package/dist/prd-to-spec.d.ts.map +1 -0
- package/dist/prd-to-spec.js +684 -0
- package/dist/prd-to-spec.js.map +1 -0
- package/dist/prepare.d.ts +121 -0
- package/dist/prepare.d.ts.map +1 -0
- package/dist/prepare.js +140 -0
- package/dist/prepare.js.map +1 -0
- package/dist/prompt.d.ts +360 -0
- package/dist/prompt.d.ts.map +1 -0
- package/dist/prompt.js +499 -0
- package/dist/prompt.js.map +1 -0
- package/dist/protocol/ADR-FORMAT.md +47 -0
- package/dist/protocol/CLAIM-PROTOCOL.md +217 -0
- package/dist/protocol/REVIEW-PROTOCOL.md +119 -0
- package/dist/protocol/SURFACE-PROTOCOL.md +121 -0
- package/dist/protocol/TASKING-PROTOCOL.md +122 -0
- package/dist/protocol/WORK-CONTRACT.md +276 -0
- package/dist/protocol/spec-template.md +71 -0
- package/dist/protocol/task-template.md +65 -0
- package/dist/readiness.d.ts +66 -0
- package/dist/readiness.d.ts.map +1 -0
- package/dist/readiness.js +36 -0
- package/dist/readiness.js.map +1 -0
- package/dist/reap-branches.d.ts +102 -0
- package/dist/reap-branches.d.ts.map +1 -0
- package/dist/reap-branches.js +149 -0
- package/dist/reap-branches.js.map +1 -0
- package/dist/recover-isolated.d.ts +72 -0
- package/dist/recover-isolated.d.ts.map +1 -0
- package/dist/recover-isolated.js +188 -0
- package/dist/recover-isolated.js.map +1 -0
- package/dist/registry.d.ts +172 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +296 -0
- package/dist/registry.js.map +1 -0
- package/dist/repo-config.d.ts +201 -0
- package/dist/repo-config.d.ts.map +1 -0
- package/dist/repo-config.js +414 -0
- package/dist/repo-config.js.map +1 -0
- package/dist/repo-key.d.ts +20 -0
- package/dist/repo-key.d.ts.map +1 -0
- package/dist/repo-key.js +68 -0
- package/dist/repo-key.js.map +1 -0
- package/dist/repo-mirror.d.ts +177 -0
- package/dist/repo-mirror.d.ts.map +1 -0
- package/dist/repo-mirror.js +271 -0
- package/dist/repo-mirror.js.map +1 -0
- package/dist/retry-backoff.d.ts +90 -0
- package/dist/retry-backoff.d.ts.map +1 -0
- package/dist/retry-backoff.js +98 -0
- package/dist/retry-backoff.js.map +1 -0
- package/dist/review-gate.d.ts +173 -0
- package/dist/review-gate.d.ts.map +1 -0
- package/dist/review-gate.js +261 -0
- package/dist/review-gate.js.map +1 -0
- package/dist/review-verdict.d.ts +149 -0
- package/dist/review-verdict.d.ts.map +1 -0
- package/dist/review-verdict.js +332 -0
- package/dist/review-verdict.js.map +1 -0
- package/dist/run.d.ts +221 -0
- package/dist/run.d.ts.map +1 -0
- package/dist/run.js +963 -0
- package/dist/run.js.map +1 -0
- package/dist/scan.d.ts +308 -0
- package/dist/scan.d.ts.map +1 -0
- package/dist/scan.js +374 -0
- package/dist/scan.js.map +1 -0
- package/dist/select-order.d.ts +75 -0
- package/dist/select-order.d.ts.map +1 -0
- package/dist/select-order.js +108 -0
- package/dist/select-order.js.map +1 -0
- package/dist/select-priority.d.ts +188 -0
- package/dist/select-priority.d.ts.map +1 -0
- package/dist/select-priority.js +80 -0
- package/dist/select-priority.js.map +1 -0
- package/dist/select.d.ts +25 -0
- package/dist/select.d.ts.map +1 -0
- package/dist/select.js +43 -0
- package/dist/select.js.map +1 -0
- package/dist/session-path.d.ts +36 -0
- package/dist/session-path.d.ts.map +1 -0
- package/dist/session-path.js +129 -0
- package/dist/session-path.js.map +1 -0
- package/dist/sidecar-apply.d.ts +83 -0
- package/dist/sidecar-apply.d.ts.map +1 -0
- package/dist/sidecar-apply.js +111 -0
- package/dist/sidecar-apply.js.map +1 -0
- package/dist/sidecar.d.ts +245 -0
- package/dist/sidecar.d.ts.map +1 -0
- package/dist/sidecar.js +481 -0
- package/dist/sidecar.js.map +1 -0
- package/dist/slug-namespace.d.ts +204 -0
- package/dist/slug-namespace.d.ts.map +1 -0
- package/dist/slug-namespace.js +229 -0
- package/dist/slug-namespace.js.map +1 -0
- package/dist/spec-complete.d.ts +44 -0
- package/dist/spec-complete.d.ts.map +1 -0
- package/dist/spec-complete.js +69 -0
- package/dist/spec-complete.js.map +1 -0
- package/dist/start.d.ts +97 -0
- package/dist/start.d.ts.map +1 -0
- package/dist/start.js +633 -0
- package/dist/start.js.map +1 -0
- package/dist/status.d.ts +199 -0
- package/dist/status.d.ts.map +1 -0
- package/dist/status.js +228 -0
- package/dist/status.js.map +1 -0
- package/dist/surface-gate.d.ts +162 -0
- package/dist/surface-gate.d.ts.map +1 -0
- package/dist/surface-gate.js +206 -0
- package/dist/surface-gate.js.map +1 -0
- package/dist/surface-persist.d.ts +86 -0
- package/dist/surface-persist.d.ts.map +1 -0
- package/dist/surface-persist.js +129 -0
- package/dist/surface-persist.js.map +1 -0
- package/dist/tasker-review-loop.d.ts +249 -0
- package/dist/tasker-review-loop.d.ts.map +1 -0
- package/dist/tasker-review-loop.js +369 -0
- package/dist/tasker-review-loop.js.map +1 -0
- package/dist/tasking-eligibility.d.ts +74 -0
- package/dist/tasking-eligibility.d.ts.map +1 -0
- package/dist/tasking-eligibility.js +52 -0
- package/dist/tasking-eligibility.js.map +1 -0
- package/dist/tasking-lock.d.ts +111 -0
- package/dist/tasking-lock.d.ts.map +1 -0
- package/dist/tasking-lock.js +256 -0
- package/dist/tasking-lock.js.map +1 -0
- package/dist/tasking.d.ts +275 -0
- package/dist/tasking.d.ts.map +1 -0
- package/dist/tasking.js +952 -0
- package/dist/tasking.js.map +1 -0
- package/dist/triage-gate.d.ts +127 -0
- package/dist/triage-gate.d.ts.map +1 -0
- package/dist/triage-gate.js +139 -0
- package/dist/triage-gate.js.map +1 -0
- package/dist/triage-persist.d.ts +163 -0
- package/dist/triage-persist.d.ts.map +1 -0
- package/dist/triage-persist.js +387 -0
- package/dist/triage-persist.js.map +1 -0
- package/dist/verdict-json.d.ts +32 -0
- package/dist/verdict-json.d.ts.map +1 -0
- package/dist/verdict-json.js +74 -0
- package/dist/verdict-json.js.map +1 -0
- package/dist/verify-workflow-template.d.ts +60 -0
- package/dist/verify-workflow-template.d.ts.map +1 -0
- package/dist/verify-workflow-template.js +126 -0
- package/dist/verify-workflow-template.js.map +1 -0
- package/dist/verify.d.ts +60 -0
- package/dist/verify.d.ts.map +1 -0
- package/dist/verify.js +62 -0
- package/dist/verify.js.map +1 -0
- package/dist/watch-session.d.ts +112 -0
- package/dist/watch-session.d.ts.map +1 -0
- package/dist/watch-session.js +347 -0
- package/dist/watch-session.js.map +1 -0
- package/dist/work-layout.d.ts +198 -0
- package/dist/work-layout.d.ts.map +1 -0
- package/dist/work-layout.js +217 -0
- package/dist/work-layout.js.map +1 -0
- package/dist/work-on.d.ts +154 -0
- package/dist/work-on.d.ts.map +1 -0
- package/dist/work-on.js +387 -0
- package/dist/work-on.js.map +1 -0
- package/dist/workspace.d.ts +224 -0
- package/dist/workspace.d.ts.map +1 -0
- package/dist/workspace.js +325 -0
- package/dist/workspace.js.map +1 -0
- package/package.json +46 -2
- package/src/advance-ci-template.ts +203 -0
- package/src/advance-classify.ts +197 -0
- package/src/advance-drivers.ts +414 -0
- package/src/advance-isolated.ts +432 -0
- package/src/advance-lifecycle-template.ts +791 -0
- package/src/advance-loop-driver.ts +745 -0
- package/src/advance-treeless-publish.ts +177 -0
- package/src/advance.ts +1564 -0
- package/src/advancing-lock.ts +988 -0
- package/src/agent-launch.ts +137 -0
- package/src/agent-stop.ts +361 -0
- package/src/apply-decide.ts +242 -0
- package/src/apply-merge-action.ts +502 -0
- package/src/apply-persist.ts +518 -0
- package/src/arbiter.ts +372 -0
- package/src/brand.ts +111 -0
- package/src/buildable-body.ts +196 -0
- package/src/categorise.ts +158 -0
- package/src/claim-cas.ts +513 -0
- package/src/cli-spinner.ts +225 -0
- package/src/cli.ts +4369 -0
- package/src/close-job-template.ts +236 -0
- package/src/close-job.ts +319 -0
- package/src/complete.ts +1379 -0
- package/src/concurrency.ts +151 -0
- package/src/config-override.ts +116 -0
- package/src/config.ts +883 -0
- package/src/continue-branch.ts +542 -0
- package/src/cwd-section.ts +392 -0
- package/src/decision-engine.ts +272 -0
- package/src/detect.ts +124 -0
- package/src/do-autopick.ts +223 -0
- package/src/do-config.ts +589 -0
- package/src/do-remote-auto.ts +197 -0
- package/src/do.ts +2623 -0
- package/src/drop-source.ts +194 -0
- package/src/eligibility.ts +79 -0
- package/src/env-config.ts +305 -0
- package/src/failure-cause.ts +142 -0
- package/src/format.ts +313 -0
- package/src/frontmatter.ts +485 -0
- package/src/gate-readiness.ts +147 -0
- package/src/gc.ts +510 -0
- package/src/gh-failure.ts +53 -0
- package/src/git.ts +186 -0
- package/src/github.ts +468 -0
- package/src/harness.ts +355 -0
- package/src/identity.ts +322 -0
- package/src/index.ts +785 -0
- package/src/install-ci-branch-protection.ts +255 -0
- package/src/install-ci-capabilities/advance-lifecycle.ts +34 -0
- package/src/install-ci-capabilities/close-job.ts +32 -0
- package/src/install-ci-capabilities/example-noop.ts +24 -0
- package/src/install-ci-capabilities/intake.ts +34 -0
- package/src/install-ci-capabilities/verify.ts +33 -0
- package/src/install-ci-core.ts +1088 -0
- package/src/install-ci-github.ts +376 -0
- package/src/install-ci.ts +552 -0
- package/src/intake-event.ts +102 -0
- package/src/intake-marker.ts +195 -0
- package/src/intake-triage.ts +138 -0
- package/src/intake-trigger-template.ts +591 -0
- package/src/intake.ts +2445 -0
- package/src/integration-core.ts +3065 -0
- package/src/integrator.ts +771 -0
- package/src/isolation.ts +484 -0
- package/src/issue-provider.ts +733 -0
- package/src/item-lock.ts +1858 -0
- package/src/item-path.ts +75 -0
- package/src/ledger-lint.ts +332 -0
- package/src/ledger-read.ts +924 -0
- package/src/ledger-write.ts +865 -0
- package/src/lifecycle-gather.ts +298 -0
- package/src/lifecycle-pools.ts +250 -0
- package/src/merge-question-surfacer.ts +496 -0
- package/src/mint-adr.ts +362 -0
- package/src/mirror-pool-scan.ts +240 -0
- package/src/needs-attention.ts +1506 -0
- package/src/orphan-sidecar.ts +150 -0
- package/src/output.ts +89 -0
- package/src/pi-harness.ts +403 -0
- package/src/placement.ts +131 -0
- package/src/prd-to-spec.ts +1023 -0
- package/src/prepare.ts +230 -0
- package/src/prompt.ts +760 -0
- package/src/readiness.ts +98 -0
- package/src/reap-branches.ts +278 -0
- package/src/recover-isolated.ts +276 -0
- package/src/registry.ts +475 -0
- package/src/repo-config.ts +550 -0
- package/src/repo-key.ts +74 -0
- package/src/repo-mirror.ts +367 -0
- package/src/retry-backoff.ts +130 -0
- package/src/review-gate.ts +389 -0
- package/src/review-verdict.ts +422 -0
- package/src/run.ts +1430 -0
- package/src/scan.ts +611 -0
- package/src/select-order.ts +143 -0
- package/src/select-priority.ts +266 -0
- package/src/select.ts +62 -0
- package/src/session-path.ts +153 -0
- package/src/sidecar-apply.ts +216 -0
- package/src/sidecar.ts +700 -0
- package/src/slug-namespace.ts +367 -0
- package/src/spec-complete.ts +118 -0
- package/src/start.ts +974 -0
- package/src/status.ts +441 -0
- package/src/surface-gate.ts +337 -0
- package/src/surface-persist.ts +241 -0
- package/src/tasker-review-loop.ts +671 -0
- package/src/tasking-eligibility.ts +114 -0
- package/src/tasking-lock.ts +416 -0
- package/src/tasking.ts +1438 -0
- package/src/triage-gate.ts +248 -0
- package/src/triage-persist.ts +570 -0
- package/src/verdict-json.ts +73 -0
- package/src/verify-workflow-template.ts +159 -0
- package/src/verify.ts +123 -0
- package/src/watch-session.ts +397 -0
- package/src/work-layout.ts +262 -0
- package/src/work-on.ts +660 -0
- package/src/workspace.ts +502 -0
|
@@ -0,0 +1,1381 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { runAsync } from './git.js';
|
|
3
|
+
import { resolveSidecarIdentity } from './sidecar.js';
|
|
4
|
+
import { workItemRel } from './work-layout.js';
|
|
5
|
+
/**
|
|
6
|
+
* The **unified item-lock module** (prd `ledger-status-per-item-lock-refs`, ADR
|
|
7
|
+
* `ledger-status-on-per-item-lock-refs`). The runner's ONE lock primitive: ONE
|
|
8
|
+
* lock per item, on a PER-ITEM hidden ref `refs/dorfl/lock/<entry>`,
|
|
9
|
+
* acquired by an ATOMIC create-only push and released by DELETING the ref, with a
|
|
10
|
+
* two-axis (`action` × `state`) entry. It collapses the old transient status
|
|
11
|
+
* folders (`in-progress`, `needs-attention`, `tasking`, `advancing`) into ONE
|
|
12
|
+
* lock keyed by item identity; `in-progress` = lock held active for `implement`,
|
|
13
|
+
* `needs-attention` = lock held `stuck`.
|
|
14
|
+
*
|
|
15
|
+
* It GENERALISES the green tracer that proved the dangerous core end-to-end on a
|
|
16
|
+
* bare `file://` arbiter (the tracer is now this file). The one production
|
|
17
|
+
* difference from the tracer is the IDENTITY SEAM: callers pass a NAMESPACED item
|
|
18
|
+
* identity (`task:<slug>` / `prd:<slug>` / `observation:<slug>` / `obs:<slug>`,
|
|
19
|
+
* or a bare `<slug>` = task), and this module derives the type-encoded lock
|
|
20
|
+
* `<entry>` (`<type>-<slug>`) through {@link resolveSidecarIdentity} — the SAME
|
|
21
|
+
* single source of truth the sidecar (`work/questions/<type>-<slug>.md`) and the
|
|
22
|
+
* work branch (`work/<type>-<slug>`) already use. There is deliberately NO second
|
|
23
|
+
* identity scheme: a task, a prd,
|
|
24
|
+
* and an observation that share a slug get DISTINCT lock refs, and the SAME item
|
|
25
|
+
* under different actions shares ONE ref (so implement / task / advance on one
|
|
26
|
+
* item are mutually exclusive by construction).
|
|
27
|
+
*
|
|
28
|
+
* It is NOT yet wired into claim/task/advance — those are separate, dependent
|
|
29
|
+
* tasks — and deliberately does NOT touch `main`.
|
|
30
|
+
*
|
|
31
|
+
* WHY a per-item ref (not a marker on `main` or a tree on one shared ref):
|
|
32
|
+
* - **No false contention, no retry.** The ONLY writer that can contend on item
|
|
33
|
+
* X's lock is another writer FOR X — a GENUINE conflict the loser SHOULD lose.
|
|
34
|
+
* Two writers for DIFFERENT items touch DIFFERENT refs and never serialise. So
|
|
35
|
+
* acquire needs NO refetch-retry budget (contrast the shared-`main` CAS, which
|
|
36
|
+
* falsely-contends under parallelism and exhausts its retry cap → exit 3).
|
|
37
|
+
* - **Self-cleaning, no storage growth.** Acquire CREATES the ref; release
|
|
38
|
+
* DELETES it (not "empties" it), so the live ref set = currently-held items
|
|
39
|
+
* only. The lock commit is PARENTLESS (no `main` parent), so on ref-delete it
|
|
40
|
+
* is immediately unreachable and reclaimed by normal git gc — and the lock ref
|
|
41
|
+
* is fully decoupled from `main`'s object graph.
|
|
42
|
+
* - **Branch-inheritance impossible.** Nothing is in `main`'s tree, so a work
|
|
43
|
+
* branch cut from `main` inherits no lock state.
|
|
44
|
+
* - **Provider-agnostic.** A ref is a ref: the create-only / delete pushes work
|
|
45
|
+
* identically on a `--bare file://` arbiter and a real remote.
|
|
46
|
+
*
|
|
47
|
+
* ATOMICITY is the `--force-with-lease=<ref>:` (EMPTY expected) create-only push:
|
|
48
|
+
* the arbiter accepts it ONLY if the ref is still absent; a racer who lost finds
|
|
49
|
+
* the ref present and is rejected. No nonce gymnastics are needed here (unlike the
|
|
50
|
+
* shared-`main` CAS) because the ref NAME is the identity — two acquires for the
|
|
51
|
+
* same item race the SAME ref, and ref-level create-only is itself the mutex.
|
|
52
|
+
*/
|
|
53
|
+
/** The ref namespace for per-item locks. HIDDEN (not `refs/heads/*`): invisible
|
|
54
|
+
* in the GitHub UI, not swept by branch automation, not fetched by a default
|
|
55
|
+
* clone. Deletion = "all locks released" (recoverable; work is on the work
|
|
56
|
+
* branches + `main`). */
|
|
57
|
+
export const LOCK_REF_PREFIX = 'refs/dorfl/lock';
|
|
58
|
+
/**
|
|
59
|
+
* The single IDENTITY SEAM for the lock: derive the type-encoded lock `<entry>`
|
|
60
|
+
* (`<type>-<slug>`) from a NAMESPACED item identity, through the shared
|
|
61
|
+
* {@link resolveSidecarIdentity} resolver (the single source of truth, which the
|
|
62
|
+
* sidecar filename + the advancing-lock marker also key onto). Accepts the same
|
|
63
|
+
* forms as that resolver: `task:<slug>` / `prd:<slug>` / `observation:<slug>` /
|
|
64
|
+
* `obs:<slug>`, or a bare `<slug>` (= task). Acquire/release/read ALL key
|
|
65
|
+
* through THIS function, so there is one — and only one — addressing scheme.
|
|
66
|
+
*/
|
|
67
|
+
export function lockEntryFor(item) {
|
|
68
|
+
const { type, slug } = resolveSidecarIdentity(item);
|
|
69
|
+
return `${type}-${slug}`;
|
|
70
|
+
}
|
|
71
|
+
/** The lock ref for a type-encoded `<entry>` (`<type>-<slug>`). */
|
|
72
|
+
export function itemLockRef(entry) {
|
|
73
|
+
return `${LOCK_REF_PREFIX}/${entry}`;
|
|
74
|
+
}
|
|
75
|
+
function gitSoft(args, cwd, env) {
|
|
76
|
+
return runAsync('git', args, cwd, { env });
|
|
77
|
+
}
|
|
78
|
+
async function gitHard(args, cwd, env) {
|
|
79
|
+
const r = await runAsync('git', args, cwd, { env });
|
|
80
|
+
if (r.status !== 0) {
|
|
81
|
+
throw new Error(`git ${args.join(' ')} failed (exit ${r.status}): ${r.stderr.trim()}`);
|
|
82
|
+
}
|
|
83
|
+
return r;
|
|
84
|
+
}
|
|
85
|
+
/** The body heading that opens the (possibly multi-line) stuck reason prose. */
|
|
86
|
+
const LOCK_REASON_HEADING = '## Reason';
|
|
87
|
+
/** The body heading that opens the agent-surfaced questions list. */
|
|
88
|
+
const LOCK_QUESTIONS_HEADING = '## Questions';
|
|
89
|
+
/**
|
|
90
|
+
* Serialise a lock entry to the ref's blob body (markdown frontmatter, like the
|
|
91
|
+
* advancing marker, so it round-trips and is previewable). The two-axis state
|
|
92
|
+
* (`entry`/`action`/`state`/`holder`/`since`) lives in the frontmatter; a stuck
|
|
93
|
+
* entry's FULL reason prose + any surfaced questions live in the BODY (under
|
|
94
|
+
* `## Reason` / `## Questions`) so they round-trip RICHLY (multi-line reason,
|
|
95
|
+
* bulleted questions) — the lock entry is the SOLE stuck record now, in a shape a
|
|
96
|
+
* future advance-surface rung can render. {@link parseLockEntry} is the exact
|
|
97
|
+
* inverse.
|
|
98
|
+
*/
|
|
99
|
+
export function serialiseLockEntry(e) {
|
|
100
|
+
const lines = [
|
|
101
|
+
'---',
|
|
102
|
+
`entry: ${e.entry}`,
|
|
103
|
+
`action: ${e.action}`,
|
|
104
|
+
`state: ${e.state}`,
|
|
105
|
+
`holder: ${e.holder}`,
|
|
106
|
+
`since: ${e.since}`,
|
|
107
|
+
'---',
|
|
108
|
+
'',
|
|
109
|
+
`Lock held for \`${e.entry}\` (${e.action}/${e.state}).`,
|
|
110
|
+
];
|
|
111
|
+
if (e.state === 'stuck' && e.reason) {
|
|
112
|
+
lines.push('', LOCK_REASON_HEADING, '', ...e.reason.split('\n'));
|
|
113
|
+
}
|
|
114
|
+
if (e.state === 'stuck' && e.questions && e.questions.length > 0) {
|
|
115
|
+
lines.push('', LOCK_QUESTIONS_HEADING, '');
|
|
116
|
+
for (const q of e.questions) {
|
|
117
|
+
lines.push(`- ${q}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
lines.push('');
|
|
121
|
+
return lines.join('\n');
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Build a PARENTLESS commit whose tree contains the single lock-entry blob, and
|
|
125
|
+
* return its sha. Parentless (no `-p`) so it is decoupled from `main` and becomes
|
|
126
|
+
* unreachable the instant the ref is deleted (gc reclaims it). Uses plumbing
|
|
127
|
+
* (`hash-object` → `mktree` → `commit-tree`), never the working tree/index/HEAD,
|
|
128
|
+
* so it is safe to call from any worktree.
|
|
129
|
+
*/
|
|
130
|
+
async function buildLockCommit(entry, cwd, env) {
|
|
131
|
+
const body = serialiseLockEntry(entry);
|
|
132
|
+
const blob = (await gitHardInput(['hash-object', '-w', '--stdin'], cwd, env, body)).stdout.trim();
|
|
133
|
+
// One tree entry: `lock.md` → the blob.
|
|
134
|
+
const treeInput = `100644 blob ${blob}\tlock.md\n`;
|
|
135
|
+
const tree = (await gitHardInput(['mktree'], cwd, env, treeInput)).stdout.trim();
|
|
136
|
+
const message = `lock: ${entry.entry} (${entry.action}/${entry.state})`;
|
|
137
|
+
// PARENTLESS commit (no -p): the lock graph never joins main's history.
|
|
138
|
+
const commit = (await gitHard(['commit-tree', tree, '-m', message], cwd, env)).stdout.trim();
|
|
139
|
+
return commit;
|
|
140
|
+
}
|
|
141
|
+
/** `gitHard` variant that pipes `input` to stdin (for hash-object/mktree). */
|
|
142
|
+
async function gitHardInput(args, cwd, env, input) {
|
|
143
|
+
const r = await runAsync('git', args, cwd, { env, input });
|
|
144
|
+
if (r.status !== 0) {
|
|
145
|
+
throw new Error(`git ${args.join(' ')} failed (exit ${r.status}): ${r.stderr.trim()}`);
|
|
146
|
+
}
|
|
147
|
+
return r;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Acquire the per-item lock atomically: build a parentless lock commit and push it
|
|
151
|
+
* to `refs/dorfl/lock/<entry>` with `--force-with-lease=<ref>:` (the EMPTY
|
|
152
|
+
* expected value = create-only). The arbiter accepts ONLY if the ref is still
|
|
153
|
+
* absent. A racer who lost finds the ref present → its lease fails → `lost`. No
|
|
154
|
+
* retry loop: a rejection here is a GENUINE same-item conflict, not false
|
|
155
|
+
* contention.
|
|
156
|
+
*/
|
|
157
|
+
export async function acquireItemLock(opts) {
|
|
158
|
+
const arbiter = opts.arbiter ?? 'origin';
|
|
159
|
+
const env = opts.env;
|
|
160
|
+
const cwd = opts.cwd;
|
|
161
|
+
if (!opts.item) {
|
|
162
|
+
return { outcome: 'error', entry: '', ref: '', message: 'missing item' };
|
|
163
|
+
}
|
|
164
|
+
const entry = lockEntryFor(opts.item);
|
|
165
|
+
const ref = itemLockRef(entry);
|
|
166
|
+
try {
|
|
167
|
+
// Fetch the current lock refs so the lease sees the real state.
|
|
168
|
+
await gitHard([
|
|
169
|
+
'fetch',
|
|
170
|
+
'--quiet',
|
|
171
|
+
arbiter,
|
|
172
|
+
`+${LOCK_REF_PREFIX}/*:${LOCK_REF_PREFIX}/*`,
|
|
173
|
+
], cwd, env);
|
|
174
|
+
const holder = opts.holder ?? (await resolveHolder(cwd, env));
|
|
175
|
+
const commit = await buildLockCommit({
|
|
176
|
+
entry,
|
|
177
|
+
action: opts.action,
|
|
178
|
+
state: 'active',
|
|
179
|
+
holder,
|
|
180
|
+
since: new Date().toISOString(),
|
|
181
|
+
}, cwd, env);
|
|
182
|
+
// CREATE-ONLY push: --force-with-lease=<ref>: (empty) succeeds iff ref absent.
|
|
183
|
+
const push = await gitSoft(['push', arbiter, `${commit}:${ref}`, `--force-with-lease=${ref}:`], cwd, env);
|
|
184
|
+
if (push.status === 0) {
|
|
185
|
+
return { outcome: 'acquired', entry, ref, message: `locked ${entry}` };
|
|
186
|
+
}
|
|
187
|
+
// Rejected: the ref already exists (held by someone for the SAME item).
|
|
188
|
+
return {
|
|
189
|
+
outcome: 'lost',
|
|
190
|
+
entry,
|
|
191
|
+
ref,
|
|
192
|
+
message: `'${entry}' is already locked (held by another). Back off.`,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
return {
|
|
197
|
+
outcome: 'error',
|
|
198
|
+
entry,
|
|
199
|
+
ref,
|
|
200
|
+
message: err instanceof Error ? err.message : String(err),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Release the per-item lock by DELETING the ref (`git push <arbiter>
|
|
206
|
+
* :refs/dorfl/lock/<entry>`). Deleting (not emptying) is what makes the
|
|
207
|
+
* lock set self-cleaning: a released item has NO ref, and its parentless commit
|
|
208
|
+
* becomes unreachable for gc. Idempotent: deleting an absent ref is `not-held`.
|
|
209
|
+
*/
|
|
210
|
+
export async function releaseItemLock(opts) {
|
|
211
|
+
const arbiter = opts.arbiter ?? 'origin';
|
|
212
|
+
const env = opts.env;
|
|
213
|
+
const cwd = opts.cwd;
|
|
214
|
+
if (!opts.item) {
|
|
215
|
+
return { outcome: 'error', entry: '', ref: '', message: 'missing item' };
|
|
216
|
+
}
|
|
217
|
+
const entry = lockEntryFor(opts.item);
|
|
218
|
+
const ref = itemLockRef(entry);
|
|
219
|
+
try {
|
|
220
|
+
await gitHard([
|
|
221
|
+
'fetch',
|
|
222
|
+
'--quiet',
|
|
223
|
+
arbiter,
|
|
224
|
+
`+${LOCK_REF_PREFIX}/*:${LOCK_REF_PREFIX}/*`,
|
|
225
|
+
], cwd, env);
|
|
226
|
+
const held = (await gitSoft(['rev-parse', '--verify', '--quiet', ref], cwd, env))
|
|
227
|
+
.status === 0;
|
|
228
|
+
if (!held) {
|
|
229
|
+
return {
|
|
230
|
+
outcome: 'not-held',
|
|
231
|
+
entry,
|
|
232
|
+
ref,
|
|
233
|
+
message: `'${entry}' not locked`,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
// Delete the ref on the arbiter. Lease on the current value guards against a
|
|
237
|
+
// concurrent change between our fetch and the delete.
|
|
238
|
+
const cur = (await gitHard(['rev-parse', ref], cwd, env)).stdout.trim();
|
|
239
|
+
const del = await gitSoft(['push', arbiter, '--delete', ref, `--force-with-lease=${ref}:${cur}`], cwd, env);
|
|
240
|
+
if (del.status === 0) {
|
|
241
|
+
// Drop our local copy of the ref too (best-effort).
|
|
242
|
+
await gitSoft(['update-ref', '-d', ref], cwd, env);
|
|
243
|
+
return { outcome: 'released', entry, ref, message: `released ${entry}` };
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
outcome: 'error',
|
|
247
|
+
entry,
|
|
248
|
+
ref,
|
|
249
|
+
message: `release push rejected: ${del.stderr.trim()}`,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
catch (err) {
|
|
253
|
+
return {
|
|
254
|
+
outcome: 'error',
|
|
255
|
+
entry,
|
|
256
|
+
ref,
|
|
257
|
+
message: err instanceof Error ? err.message : String(err),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* GUARDED release for a runner that KNOWS it acquired and HELD the lock (prd
|
|
263
|
+
* `ledger-status-per-item-lock-refs` US #13): unlike {@link releaseItemLock} —
|
|
264
|
+
* whose `not-held` is a BENIGN idempotent case the complete/tasking/needs-attention
|
|
265
|
+
* callers tolerate (the body may predate the lock, or a crash-recovery may have
|
|
266
|
+
* already cleared it) — here the absence of OUR ref is an ABORT SIGNAL. A held
|
|
267
|
+
* runner whose own lock VANISHED mid-build (someone `release-lock`-ed it, or a
|
|
268
|
+
* `gc`/recovery cleared it) must DETECT it on release and route to
|
|
269
|
+
* needs-attention rather than silently "clean-release" a lock it no longer holds:
|
|
270
|
+
* the work it just did was NOT protected by the exclusion it thought it had.
|
|
271
|
+
*
|
|
272
|
+
* So this maps {@link releaseItemLock}'s `not-held` to a DISTINCT `vanished`
|
|
273
|
+
* outcome the caller branches on (abort / needs-attention), while a genuine
|
|
274
|
+
* `released` is the happy path and `error` stays `error`. It is otherwise the
|
|
275
|
+
* SAME leased delete (no second mechanism): a clean `released` deletes the ref we
|
|
276
|
+
* held. Use this ONLY where the caller provably HELD the lock (the in-flight
|
|
277
|
+
* runner's own release); the tolerant idempotent callers keep using
|
|
278
|
+
* {@link releaseItemLock}.
|
|
279
|
+
*/
|
|
280
|
+
export async function releaseHeldItemLock(opts) {
|
|
281
|
+
const rel = await releaseItemLock(opts);
|
|
282
|
+
if (rel.outcome === 'not-held') {
|
|
283
|
+
return {
|
|
284
|
+
outcome: 'vanished',
|
|
285
|
+
entry: rel.entry,
|
|
286
|
+
ref: rel.ref,
|
|
287
|
+
message: `'${rel.entry}' lock VANISHED before our release (the ref is gone) — our hold was lost mid-build; abort / route to needs-attention rather than clean-release.`,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
outcome: rel.outcome,
|
|
292
|
+
entry: rel.entry,
|
|
293
|
+
ref: rel.ref,
|
|
294
|
+
message: rel.message,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Fetch the lock refs and return the held entry + its current ref sha, or
|
|
299
|
+
* `undefined` when the item is at REST (no ref). Shared read-before-CAS step for
|
|
300
|
+
* the amend-style transitions (mark-stuck / resume / requeue): they all need BOTH
|
|
301
|
+
* the current entry (to check the state precondition + carry forward `action` /
|
|
302
|
+
* `holder` / `since`) and the current sha (to lease the CAS on it).
|
|
303
|
+
*/
|
|
304
|
+
async function fetchHeldEntry(entry, ref, cwd, arbiter, env) {
|
|
305
|
+
await gitHard(['fetch', '--quiet', arbiter, `+${LOCK_REF_PREFIX}/*:${LOCK_REF_PREFIX}/*`], cwd, env);
|
|
306
|
+
const rev = await gitSoft(['rev-parse', '--verify', '--quiet', ref], cwd, env);
|
|
307
|
+
if (rev.status !== 0 || rev.stdout.trim() === '') {
|
|
308
|
+
return undefined;
|
|
309
|
+
}
|
|
310
|
+
const sha = rev.stdout.trim();
|
|
311
|
+
const show = await gitSoft(['show', `${ref}:lock.md`], cwd, env);
|
|
312
|
+
const lock = show.status === 0 ? parseLockEntry(show.stdout) : undefined;
|
|
313
|
+
if (!lock) {
|
|
314
|
+
return undefined;
|
|
315
|
+
}
|
|
316
|
+
return { lock, sha };
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* AMEND the held entry in place via a leased CAS: build a NEW parentless commit
|
|
320
|
+
* carrying `next` and push it to the SAME ref with `--force-with-lease=<ref>:<sha>`
|
|
321
|
+
* (the sha we just read). The arbiter accepts ONLY if the ref is unchanged since
|
|
322
|
+
* our read — a concurrent writer who moved it makes our lease fail (`lost`). No
|
|
323
|
+
* retry loop: a rejection is a genuine same-item race the caller should lose.
|
|
324
|
+
*/
|
|
325
|
+
async function amendHeldEntry(next, ref, expectedSha, cwd, arbiter, env) {
|
|
326
|
+
const commit = await buildLockCommit(next, cwd, env);
|
|
327
|
+
const push = await gitSoft([
|
|
328
|
+
'push',
|
|
329
|
+
arbiter,
|
|
330
|
+
`${commit}:${ref}`,
|
|
331
|
+
`--force-with-lease=${ref}:${expectedSha}`,
|
|
332
|
+
], cwd, env);
|
|
333
|
+
if (push.status === 0) {
|
|
334
|
+
// Move our local copy to the new commit too (best-effort) so a subsequent
|
|
335
|
+
// read in the same clone sees the amended entry without a refetch.
|
|
336
|
+
await gitSoft(['update-ref', ref, commit], cwd, env);
|
|
337
|
+
return {
|
|
338
|
+
outcome: 'transitioned',
|
|
339
|
+
entry: next.entry,
|
|
340
|
+
ref,
|
|
341
|
+
message: `${next.entry} → ${next.action}/${next.state}`,
|
|
342
|
+
lock: next,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
return {
|
|
346
|
+
outcome: 'lost',
|
|
347
|
+
entry: next.entry,
|
|
348
|
+
ref,
|
|
349
|
+
message: `'${next.entry}' lock changed concurrently (CAS lost). Back off.`,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* The ONE leased-delete CLEAR path shared by every code path that removes a held
|
|
354
|
+
* lock ref by lease (the recovery {@link reconcileItemLockAgainstMain}, the
|
|
355
|
+
* human-invoked {@link reapStaleItemLocks} sweep, …): delete `ref` on the arbiter
|
|
356
|
+
* with `--force-with-lease=<ref>:<expectedSha>`, so the arbiter accepts ONLY if the
|
|
357
|
+
* ref is UNCHANGED since the caller read `expectedSha`. A concurrent writer who
|
|
358
|
+
* moved the ref (e.g. a racer who just marked it `stuck`) makes the lease FAIL →
|
|
359
|
+
* `lost` (reported, NEVER force-deleted). On success the local copy is dropped too
|
|
360
|
+
* (best-effort). It is the SAME leased delete `release-lock` / requeue use — there
|
|
361
|
+
* is no second clear mechanism.
|
|
362
|
+
*/
|
|
363
|
+
async function leasedDeleteLockRef(ref, expectedSha, cwd, arbiter, env) {
|
|
364
|
+
const del = await gitSoft([
|
|
365
|
+
'push',
|
|
366
|
+
arbiter,
|
|
367
|
+
'--delete',
|
|
368
|
+
ref,
|
|
369
|
+
`--force-with-lease=${ref}:${expectedSha}`,
|
|
370
|
+
], cwd, env);
|
|
371
|
+
if (del.status !== 0) {
|
|
372
|
+
return 'lost';
|
|
373
|
+
}
|
|
374
|
+
// Drop our local copy of the ref too (best-effort) so a subsequent read in the
|
|
375
|
+
// same clone does not see the now-deleted lock.
|
|
376
|
+
await gitSoft(['update-ref', '-d', ref], cwd, env);
|
|
377
|
+
return 'deleted';
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* mark-stuck (transition 2): `[action, active] -> [action, stuck] + reason`. The
|
|
381
|
+
* runner bounces (red gate, agent failure, decomposition-unclear). A leased CAS
|
|
382
|
+
* amend of the SAME entry's `state` + `reason`, keeping `action`/`holder`/`since`.
|
|
383
|
+
* It is the source of the needs-attention SURFACE (now read from the lock ref,
|
|
384
|
+
* not a `work/needs-attention/` folder).
|
|
385
|
+
*
|
|
386
|
+
* PRECONDITIONS (the state machine + invariants):
|
|
387
|
+
* - the entry must be HELD and `active` (`not-held` / `wrong-state` otherwise) —
|
|
388
|
+
* stuck is reachable only FROM active, never from absent or already-stuck.
|
|
389
|
+
* - `reason` must be non-empty (the `reason` PRESENT iff `state: stuck` invariant).
|
|
390
|
+
*/
|
|
391
|
+
export async function markStuckItemLock(opts) {
|
|
392
|
+
const arbiter = opts.arbiter ?? 'origin';
|
|
393
|
+
const env = opts.env;
|
|
394
|
+
const cwd = opts.cwd;
|
|
395
|
+
if (!opts.item) {
|
|
396
|
+
return { outcome: 'error', entry: '', ref: '', message: 'missing item' };
|
|
397
|
+
}
|
|
398
|
+
const entry = lockEntryFor(opts.item);
|
|
399
|
+
const ref = itemLockRef(entry);
|
|
400
|
+
if (!opts.reason || opts.reason.trim() === '') {
|
|
401
|
+
return {
|
|
402
|
+
outcome: 'error',
|
|
403
|
+
entry,
|
|
404
|
+
ref,
|
|
405
|
+
message: 'mark-stuck requires a reason (reason iff stuck)',
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
try {
|
|
409
|
+
const held = await fetchHeldEntry(entry, ref, cwd, arbiter, env);
|
|
410
|
+
if (!held) {
|
|
411
|
+
return {
|
|
412
|
+
outcome: 'not-held',
|
|
413
|
+
entry,
|
|
414
|
+
ref,
|
|
415
|
+
message: `'${entry}' not locked`,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
if (held.lock.state !== 'active') {
|
|
419
|
+
return {
|
|
420
|
+
outcome: 'wrong-state',
|
|
421
|
+
entry,
|
|
422
|
+
ref,
|
|
423
|
+
message: `'${entry}' is ${held.lock.state}, not active; cannot mark-stuck`,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
const next = {
|
|
427
|
+
...held.lock,
|
|
428
|
+
state: 'stuck',
|
|
429
|
+
reason: opts.reason.trim(),
|
|
430
|
+
};
|
|
431
|
+
const questions = (opts.questions ?? [])
|
|
432
|
+
.map((q) => q.trim())
|
|
433
|
+
.filter((q) => q !== '');
|
|
434
|
+
if (questions.length > 0) {
|
|
435
|
+
next.questions = questions;
|
|
436
|
+
}
|
|
437
|
+
else {
|
|
438
|
+
delete next.questions;
|
|
439
|
+
}
|
|
440
|
+
return await amendHeldEntry(next, ref, held.sha, cwd, arbiter, env);
|
|
441
|
+
}
|
|
442
|
+
catch (err) {
|
|
443
|
+
return {
|
|
444
|
+
outcome: 'error',
|
|
445
|
+
entry,
|
|
446
|
+
ref,
|
|
447
|
+
message: err instanceof Error ? err.message : String(err),
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* resume (transition 3): `[action, stuck] -> [action, active]`. A human (or a
|
|
453
|
+
* `continue`) picks the stuck item up: amend `state` back to `active` and CLEAR
|
|
454
|
+
* `reason` (the `reason` iff `stuck` invariant — an active entry never carries a
|
|
455
|
+
* stuck reason). Keeps the same `action`; `holder` may be reassigned. The
|
|
456
|
+
* lock-entry analogue of the old `needs-attention -> in-progress` folder move.
|
|
457
|
+
*
|
|
458
|
+
* PRECONDITION: the entry must be HELD and `stuck` (`not-held` / `wrong-state`
|
|
459
|
+
* otherwise) — active is reachable from stuck only, not from absent.
|
|
460
|
+
*/
|
|
461
|
+
export async function resumeItemLock(opts) {
|
|
462
|
+
const arbiter = opts.arbiter ?? 'origin';
|
|
463
|
+
const env = opts.env;
|
|
464
|
+
const cwd = opts.cwd;
|
|
465
|
+
if (!opts.item) {
|
|
466
|
+
return { outcome: 'error', entry: '', ref: '', message: 'missing item' };
|
|
467
|
+
}
|
|
468
|
+
const entry = lockEntryFor(opts.item);
|
|
469
|
+
const ref = itemLockRef(entry);
|
|
470
|
+
try {
|
|
471
|
+
const held = await fetchHeldEntry(entry, ref, cwd, arbiter, env);
|
|
472
|
+
if (!held) {
|
|
473
|
+
return {
|
|
474
|
+
outcome: 'not-held',
|
|
475
|
+
entry,
|
|
476
|
+
ref,
|
|
477
|
+
message: `'${entry}' not locked`,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
if (held.lock.state !== 'stuck') {
|
|
481
|
+
return {
|
|
482
|
+
outcome: 'wrong-state',
|
|
483
|
+
entry,
|
|
484
|
+
ref,
|
|
485
|
+
message: `'${entry}' is ${held.lock.state}, not stuck; nothing to resume`,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
const next = {
|
|
489
|
+
...held.lock,
|
|
490
|
+
state: 'active',
|
|
491
|
+
holder: opts.holder ?? held.lock.holder,
|
|
492
|
+
};
|
|
493
|
+
// reason + questions are PRESENT iff stuck: drop them on the way to active.
|
|
494
|
+
delete next.reason;
|
|
495
|
+
delete next.questions;
|
|
496
|
+
return await amendHeldEntry(next, ref, held.sha, cwd, arbiter, env);
|
|
497
|
+
}
|
|
498
|
+
catch (err) {
|
|
499
|
+
return {
|
|
500
|
+
outcome: 'error',
|
|
501
|
+
entry,
|
|
502
|
+
ref,
|
|
503
|
+
message: err instanceof Error ? err.message : String(err),
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* requeue (transition 4): `[action, stuck] -> (absent)`. Give up on a STUCK hold
|
|
509
|
+
* and return the item to the pool by REMOVING the entry. The body never moved
|
|
510
|
+
* (Amendment 5 — it is already resting in `backlog/` on `main`), so requeue is
|
|
511
|
+
* purely "release the lock"; the kept `work/<slug>` branch remains for recovery.
|
|
512
|
+
*
|
|
513
|
+
* Distinct from {@link releaseItemLock} (transition 6, abort from ACTIVE): requeue
|
|
514
|
+
* is the GUARDED give-up from `stuck` only, so it rejects (`wrong-state`) an
|
|
515
|
+
* `active` entry — abandoning an in-flight active hold goes through `release`, not
|
|
516
|
+
* `requeue`. The removal itself is a leased delete (a concurrent change ⇒ `lost`).
|
|
517
|
+
*/
|
|
518
|
+
export async function requeueItemLock(opts) {
|
|
519
|
+
const arbiter = opts.arbiter ?? 'origin';
|
|
520
|
+
const env = opts.env;
|
|
521
|
+
const cwd = opts.cwd;
|
|
522
|
+
if (!opts.item) {
|
|
523
|
+
return { outcome: 'error', entry: '', ref: '', message: 'missing item' };
|
|
524
|
+
}
|
|
525
|
+
const entry = lockEntryFor(opts.item);
|
|
526
|
+
const ref = itemLockRef(entry);
|
|
527
|
+
try {
|
|
528
|
+
const held = await fetchHeldEntry(entry, ref, cwd, arbiter, env);
|
|
529
|
+
if (!held) {
|
|
530
|
+
return {
|
|
531
|
+
outcome: 'not-held',
|
|
532
|
+
entry,
|
|
533
|
+
ref,
|
|
534
|
+
message: `'${entry}' not locked`,
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
if (held.lock.state !== 'stuck') {
|
|
538
|
+
return {
|
|
539
|
+
outcome: 'wrong-state',
|
|
540
|
+
entry,
|
|
541
|
+
ref,
|
|
542
|
+
message: `'${entry}' is ${held.lock.state}, not stuck; use release to abort an active hold`,
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
const del = await gitSoft([
|
|
546
|
+
'push',
|
|
547
|
+
arbiter,
|
|
548
|
+
'--delete',
|
|
549
|
+
ref,
|
|
550
|
+
`--force-with-lease=${ref}:${held.sha}`,
|
|
551
|
+
], cwd, env);
|
|
552
|
+
if (del.status === 0) {
|
|
553
|
+
await gitSoft(['update-ref', '-d', ref], cwd, env);
|
|
554
|
+
return {
|
|
555
|
+
outcome: 'transitioned',
|
|
556
|
+
entry,
|
|
557
|
+
ref,
|
|
558
|
+
message: `requeued ${entry} (lock released, body still in pool)`,
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
return {
|
|
562
|
+
outcome: 'lost',
|
|
563
|
+
entry,
|
|
564
|
+
ref,
|
|
565
|
+
message: `'${entry}' lock changed concurrently (CAS lost). Back off.`,
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
catch (err) {
|
|
569
|
+
return {
|
|
570
|
+
outcome: 'error',
|
|
571
|
+
entry,
|
|
572
|
+
ref,
|
|
573
|
+
message: err instanceof Error ? err.message : String(err),
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Read the current lock entry for an item from the arbiter (the `status`/`scan`
|
|
579
|
+
* read path): fetch the lock refs, read `lock.md` from the ref's tree. Returns
|
|
580
|
+
* `undefined` when the item is not locked.
|
|
581
|
+
*/
|
|
582
|
+
export async function readItemLock(opts) {
|
|
583
|
+
const arbiter = opts.arbiter ?? 'origin';
|
|
584
|
+
const env = opts.env;
|
|
585
|
+
const cwd = opts.cwd;
|
|
586
|
+
const ref = itemLockRef(lockEntryFor(opts.item));
|
|
587
|
+
await gitHard(['fetch', '--quiet', arbiter, `+${LOCK_REF_PREFIX}/*:${LOCK_REF_PREFIX}/*`], cwd, env);
|
|
588
|
+
const show = await gitSoft(['show', `${ref}:lock.md`], cwd, env);
|
|
589
|
+
if (show.status !== 0) {
|
|
590
|
+
return undefined;
|
|
591
|
+
}
|
|
592
|
+
return parseLockEntry(show.stdout);
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* The DURABLE-`main` terminal record paths for an item, by its type — the
|
|
596
|
+
* authoritative resting records the cross-substrate reconciliation reads. An
|
|
597
|
+
* item is TERMINAL on `main` iff ANY of these paths exists on `<arbiter>/main`.
|
|
598
|
+
* The won't-proceed terminal is PER-REGIME (the slug-collision correctness fix:
|
|
599
|
+
* a dropped task and a dropped prd sharing a slug used to collide on one
|
|
600
|
+
* bare-slug `work/dropped/<slug>.md`):
|
|
601
|
+
* - a TASK: `work/tasks/done/<slug>.md` (completed) OR
|
|
602
|
+
* `work/tasks/cancelled/<slug>.md` (the task regime's won't-proceed terminal).
|
|
603
|
+
* - a PRD: `work/prds/tasked/<slug>.md` (tasked) OR `work/prds/dropped/<slug>.md`
|
|
604
|
+
* (the prd regime's won't-proceed terminal).
|
|
605
|
+
* - an OBSERVATION: NONE. A note has no durable terminal folder — it leaves by
|
|
606
|
+
* deletion (its absence, not a terminal record, is the end state). A promoted
|
|
607
|
+
* observation becomes a NEW task/prd with its own ref.
|
|
608
|
+
*/
|
|
609
|
+
export function terminalMainPaths(type, slug) {
|
|
610
|
+
const file = `${slug}.md`;
|
|
611
|
+
switch (type) {
|
|
612
|
+
case 'task':
|
|
613
|
+
return [workItemRel('done', file), workItemRel('cancelled', file)];
|
|
614
|
+
// The parent-spec regime's durable terminals (`specs/tasked` tasked,
|
|
615
|
+
// `specs/dropped` won't-proceed) — a `spec:<slug>` lock resolves to these
|
|
616
|
+
// `work/specs/*` records.
|
|
617
|
+
case 'spec':
|
|
618
|
+
return [
|
|
619
|
+
workItemRel('specs-tasked', file),
|
|
620
|
+
workItemRel('specs-dropped', file),
|
|
621
|
+
];
|
|
622
|
+
case 'observation':
|
|
623
|
+
return [];
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Reconcile ONE item's per-item lock against the AUTHORITATIVE `main` durable
|
|
628
|
+
* record — the heart of complete's cross-substrate crash-safety (prd
|
|
629
|
+
* `ledger-status-per-item-lock-refs` US #9/#10; ADR
|
|
630
|
+
* `ledger-status-on-per-item-lock-refs`; the design trail's Amendment 6).
|
|
631
|
+
*
|
|
632
|
+
* complete's order is hold lock → land the DURABLE `main` move FIRST → release
|
|
633
|
+
* the lock SECOND. A crash BETWEEN the move and the release leaves a
|
|
634
|
+
* terminal-on-`main` item (a completed/cancelled task or a tasked/dropped prd,
|
|
635
|
+
* per {@link terminalMainPaths}) with a STILL-HELD lock — a stale lock with no
|
|
636
|
+
* in-flight work behind it. This is the recovery that converges it.
|
|
637
|
+
*
|
|
638
|
+
* THE RECOVERY RULE (the `main` record is authoritative over a stale lock):
|
|
639
|
+
* - `main` is TERMINAL + the held lock is `active` → the item is RESTED, the
|
|
640
|
+
* lock is STALE (the crash was after the move) → CLEAR it (`cleared-stale`).
|
|
641
|
+
* - `main` is TERMINAL + the held lock is `stuck` → KEEP it (`kept-stuck`).
|
|
642
|
+
* `done` + `stuck` may legitimately CO-EXIST (a rebase-conflict bounce of a
|
|
643
|
+
* just-completed item — US #10). The stuck lock wins the human's attention;
|
|
644
|
+
* the `main` record wins dependency resolution. NOT corruption, never cleared
|
|
645
|
+
* here (a human resolves it via `resume`/`requeue`/`release-lock`).
|
|
646
|
+
* - `main` is NOT terminal + a lock is held → the NORMAL in-flight
|
|
647
|
+
* state (`kept-in-flight`); the lock is doing its job, leave it.
|
|
648
|
+
* - no lock at all → `no-lock` (at rest).
|
|
649
|
+
*
|
|
650
|
+
* Best-effort + idempotent: it NEVER throws (a fetch/read fault degrades to
|
|
651
|
+
* `error`, leaving the lock untouched — the safe direction), and re-running it
|
|
652
|
+
* on an already-reconciled item is a clean `no-lock`. The clear is the SAME
|
|
653
|
+
* leased delete {@link releaseItemLock} uses, so it cannot race off a concurrent
|
|
654
|
+
* change.
|
|
655
|
+
*
|
|
656
|
+
* BROADENED CONTRACT (leased-delete-rejection arm, task
|
|
657
|
+
* `reaper-no-lock-outcome-benign-not-lost`, promote-slice follow-up
|
|
658
|
+
* `reconcile-item-lock-broadened-contract-audit`, review 2026-06-20): when the
|
|
659
|
+
* SHARED leased delete is REJECTED (the arbiter ref moved between our read and
|
|
660
|
+
* our write) this function performs an EXTRA `git ls-remote <arbiter> <ref>`
|
|
661
|
+
* round-trip to distinguish the two rejection sub-cases, and applies to ALL
|
|
662
|
+
* callers — NOT only the reaper:
|
|
663
|
+
* - REMOTE REF IS EMPTY (a concurrent reaper / release-lock / requeue cleared
|
|
664
|
+
* the SAME stale lock first): the desired end state — benign. This function
|
|
665
|
+
* ALSO `git update-ref -d`s the LOCAL stale tracking ref as a SIDE-EFFECT
|
|
666
|
+
* (so a subsequent read in this clone sees the correct deleted state; a
|
|
667
|
+
* non-pruning fetch would otherwise leave a dangling local ref), then
|
|
668
|
+
* returns `no-lock`. Note the outcome-shape change: this rejection USED TO
|
|
669
|
+
* surface as `error` — it now surfaces as `no-lock` for EVERY caller. A
|
|
670
|
+
* caller that keys recovery / surfacing off `error` on this arm must be
|
|
671
|
+
* audited (see the follow-up task's `## Decisions` block).
|
|
672
|
+
* - REMOTE REF STILL EXISTS AT A DIFFERENT SHA (a genuine concurrent mutation
|
|
673
|
+
* — e.g. a racer just marked it `stuck`): back off rather than force —
|
|
674
|
+
* `error`.
|
|
675
|
+
* Because the function name reads "reconcile" (read-style), the local
|
|
676
|
+
* `update-ref -d` is a deliberate SIDE-EFFECT on the local clone's refs — it
|
|
677
|
+
* only fires on this specific rejection arm and is otherwise invisible.
|
|
678
|
+
*/
|
|
679
|
+
export async function reconcileItemLockAgainstMain(opts) {
|
|
680
|
+
const arbiter = opts.arbiter ?? 'origin';
|
|
681
|
+
const env = opts.env;
|
|
682
|
+
const cwd = opts.cwd;
|
|
683
|
+
if (!opts.item) {
|
|
684
|
+
return {
|
|
685
|
+
outcome: 'error',
|
|
686
|
+
entry: '',
|
|
687
|
+
ref: '',
|
|
688
|
+
terminalOnMain: false,
|
|
689
|
+
message: 'missing item',
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
const { type, slug } = resolveSidecarIdentity(opts.item);
|
|
693
|
+
const entry = lockEntryFor(opts.item);
|
|
694
|
+
const ref = itemLockRef(entry);
|
|
695
|
+
try {
|
|
696
|
+
// One fetch refreshes BOTH the lock refs and `<arbiter>/main` so the lock and
|
|
697
|
+
// the durable record are read from the SAME live arbiter snapshot.
|
|
698
|
+
await gitHard([
|
|
699
|
+
'fetch',
|
|
700
|
+
'--quiet',
|
|
701
|
+
arbiter,
|
|
702
|
+
`+${LOCK_REF_PREFIX}/*:${LOCK_REF_PREFIX}/*`,
|
|
703
|
+
], cwd, env);
|
|
704
|
+
await gitHard(['fetch', '--quiet', arbiter], cwd, env);
|
|
705
|
+
const held = await fetchHeldEntry(entry, ref, cwd, arbiter, env);
|
|
706
|
+
const terminalOnMain = await isTerminalOnMain(type, slug, arbiter, cwd, env);
|
|
707
|
+
if (!held) {
|
|
708
|
+
return {
|
|
709
|
+
outcome: 'no-lock',
|
|
710
|
+
entry,
|
|
711
|
+
ref,
|
|
712
|
+
terminalOnMain,
|
|
713
|
+
message: `'${entry}' has no lock to reconcile`,
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
if (!terminalOnMain) {
|
|
717
|
+
// A held lock + a non-terminal `main` is the NORMAL in-flight state.
|
|
718
|
+
return {
|
|
719
|
+
outcome: 'kept-in-flight',
|
|
720
|
+
entry,
|
|
721
|
+
ref,
|
|
722
|
+
terminalOnMain,
|
|
723
|
+
message: `'${entry}' is in flight (held, not terminal on ${arbiter}/main)`,
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
if (held.lock.state === 'stuck') {
|
|
727
|
+
// a terminal-on-main record + `stuck` co-exist legitimately (US #10) —
|
|
728
|
+
// NOT corruption. Keep the stuck lock (it wins the human's attention).
|
|
729
|
+
return {
|
|
730
|
+
outcome: 'kept-stuck',
|
|
731
|
+
entry,
|
|
732
|
+
ref,
|
|
733
|
+
terminalOnMain,
|
|
734
|
+
message: `'${entry}' is terminal on ${arbiter}/main but STUCK — kept for human attention (resume/requeue/release-lock)`,
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
// Terminal on `main` + an `active` lock = a STALE lock (the crash was after
|
|
738
|
+
// the durable move, before the release). The `main` record is authoritative:
|
|
739
|
+
// clear the stale lock with the SHARED leased delete (the SAME one
|
|
740
|
+
// `release-lock` / requeue / the reaper use).
|
|
741
|
+
const cleared = await leasedDeleteLockRef(ref, held.sha, cwd, arbiter, env);
|
|
742
|
+
if (cleared === 'deleted') {
|
|
743
|
+
return {
|
|
744
|
+
outcome: 'cleared-stale',
|
|
745
|
+
entry,
|
|
746
|
+
ref,
|
|
747
|
+
terminalOnMain,
|
|
748
|
+
message: `cleared the stale lock for '${entry}' (terminal on ${arbiter}/main; the durable record is authoritative)`,
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
// The leased delete was REJECTED. Distinguish two sub-cases at the recovery
|
|
752
|
+
// boundary so callers (the reaper) can route them differently:
|
|
753
|
+
// (a) the remote ref is ALREADY GONE — a concurrent reaper / release-lock /
|
|
754
|
+
// requeue cleared the SAME stale lock first. The desired end state;
|
|
755
|
+
// benign — we report `no-lock` (the existing "already at rest" verdict).
|
|
756
|
+
// (b) the remote ref still exists but at a DIFFERENT sha than we leased
|
|
757
|
+
// against — a genuine concurrent mutation (e.g. a racer just marked it
|
|
758
|
+
// `stuck`). Back off rather than force; this is the real `error`.
|
|
759
|
+
const remote = await gitSoft(['ls-remote', arbiter, ref], cwd, env);
|
|
760
|
+
const remoteEmpty = remote.status === 0 && remote.stdout.trim() === '';
|
|
761
|
+
if (remoteEmpty) {
|
|
762
|
+
// Drop our stale local copy too — a non-pruning fetch left it pointing at
|
|
763
|
+
// the now-gone sha; with it gone locally a subsequent read in this clone
|
|
764
|
+
// sees the correct (deleted) state.
|
|
765
|
+
await gitSoft(['update-ref', '-d', ref], cwd, env);
|
|
766
|
+
return {
|
|
767
|
+
outcome: 'no-lock',
|
|
768
|
+
entry,
|
|
769
|
+
ref,
|
|
770
|
+
terminalOnMain,
|
|
771
|
+
message: `'${entry}' has no lock to reconcile (already cleared by another reaper / release-lock / requeue)`,
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
return {
|
|
775
|
+
outcome: 'error',
|
|
776
|
+
entry,
|
|
777
|
+
ref,
|
|
778
|
+
terminalOnMain,
|
|
779
|
+
message: `stale-lock clear for '${entry}' rejected (changed concurrently to a different value); a racer may have moved the ref. Re-run after re-checking.`,
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
catch (err) {
|
|
783
|
+
return {
|
|
784
|
+
outcome: 'error',
|
|
785
|
+
entry,
|
|
786
|
+
ref,
|
|
787
|
+
terminalOnMain: false,
|
|
788
|
+
message: err instanceof Error ? err.message : String(err),
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* The READ-ONLY classifier behind {@link reconcileItemLockAgainstMain}: it derives
|
|
794
|
+
* the SAME {@link ReconcileOutcome} verdict, but NEVER mutates the arbiter — it
|
|
795
|
+
* does NOT clear a stale-active lock, it only REPORTS that the lock IS
|
|
796
|
+
* reconcilable. This is what the `gc --ledger` stuck-lock report keys off (the
|
|
797
|
+
* forward-pointer's wiring): per the no-auto-sweep ADR rule, the plain report
|
|
798
|
+
* surfaces a stale-active lock as `cleared-stale`-eligible WITHOUT clearing it —
|
|
799
|
+
* a human still asserts the clear via `release-lock` (or runs complete's recovery,
|
|
800
|
+
* which DOES clear via {@link reconcileItemLockAgainstMain}). The classification
|
|
801
|
+
* is identical so the report and the recovery never disagree about WHAT a lock is;
|
|
802
|
+
* only the ACTION differs (report vs clear).
|
|
803
|
+
*
|
|
804
|
+
* A `cleared-stale` outcome here therefore means "would be cleared by recovery /
|
|
805
|
+
* `release-lock`" (the lock is terminal-on-`main` + `active` = stale), NOT that
|
|
806
|
+
* anything was cleared. Best-effort + never throws (a fetch/read fault degrades to
|
|
807
|
+
* `error`).
|
|
808
|
+
*/
|
|
809
|
+
export async function classifyItemLockAgainstMain(opts) {
|
|
810
|
+
const arbiter = opts.arbiter ?? 'origin';
|
|
811
|
+
const env = opts.env;
|
|
812
|
+
const cwd = opts.cwd;
|
|
813
|
+
if (!opts.item) {
|
|
814
|
+
return {
|
|
815
|
+
outcome: 'error',
|
|
816
|
+
entry: '',
|
|
817
|
+
ref: '',
|
|
818
|
+
terminalOnMain: false,
|
|
819
|
+
message: 'missing item',
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
const { type, slug } = resolveSidecarIdentity(opts.item);
|
|
823
|
+
const entry = lockEntryFor(opts.item);
|
|
824
|
+
const ref = itemLockRef(entry);
|
|
825
|
+
try {
|
|
826
|
+
await gitHard([
|
|
827
|
+
'fetch',
|
|
828
|
+
'--quiet',
|
|
829
|
+
arbiter,
|
|
830
|
+
`+${LOCK_REF_PREFIX}/*:${LOCK_REF_PREFIX}/*`,
|
|
831
|
+
], cwd, env);
|
|
832
|
+
await gitHard(['fetch', '--quiet', arbiter], cwd, env);
|
|
833
|
+
const held = await fetchHeldEntry(entry, ref, cwd, arbiter, env);
|
|
834
|
+
const terminalOnMain = await isTerminalOnMain(type, slug, arbiter, cwd, env);
|
|
835
|
+
if (!held) {
|
|
836
|
+
return {
|
|
837
|
+
outcome: 'no-lock',
|
|
838
|
+
entry,
|
|
839
|
+
ref,
|
|
840
|
+
terminalOnMain,
|
|
841
|
+
message: `'${entry}' has no lock to reconcile`,
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
if (!terminalOnMain) {
|
|
845
|
+
return {
|
|
846
|
+
outcome: 'kept-in-flight',
|
|
847
|
+
entry,
|
|
848
|
+
ref,
|
|
849
|
+
terminalOnMain,
|
|
850
|
+
message: `'${entry}' is in flight (held, not terminal on ${arbiter}/main)`,
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
if (held.lock.state === 'stuck') {
|
|
854
|
+
return {
|
|
855
|
+
outcome: 'kept-stuck',
|
|
856
|
+
entry,
|
|
857
|
+
ref,
|
|
858
|
+
terminalOnMain,
|
|
859
|
+
message: `'${entry}' is terminal on ${arbiter}/main but STUCK — kept for human attention (resume/requeue/release-lock)`,
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
// Terminal on `main` + an `active` lock = a STALE lock. Unlike
|
|
863
|
+
// `reconcileItemLockAgainstMain` we do NOT clear it here — the report only
|
|
864
|
+
// names it as reconcilable; the human asserts the clear (no auto-sweep).
|
|
865
|
+
return {
|
|
866
|
+
outcome: 'cleared-stale',
|
|
867
|
+
entry,
|
|
868
|
+
ref,
|
|
869
|
+
terminalOnMain,
|
|
870
|
+
message: `'${entry}' is terminal on ${arbiter}/main but the lock is ACTIVE — reconcilable (stale); clear via 'release-lock' (NOT auto-cleared by the report)`,
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
catch (err) {
|
|
874
|
+
return {
|
|
875
|
+
outcome: 'error',
|
|
876
|
+
entry,
|
|
877
|
+
ref,
|
|
878
|
+
terminalOnMain: false,
|
|
879
|
+
message: err instanceof Error ? err.message : String(err),
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* Build the `gc --ledger` stuck/orphaned-lock REPORT (prd
|
|
885
|
+
* `ledger-status-per-item-lock-refs` US #12/#13/#14; ADR
|
|
886
|
+
* `ledger-status-on-per-item-lock-refs`): enumerate every per-item lock currently
|
|
887
|
+
* held on the arbiter ({@link listItemLockEntries} — held active + stuck, with
|
|
888
|
+
* holder/since/reason) and classify EACH read-only against the authoritative
|
|
889
|
+
* `main` durable record via {@link classifyItemLockAgainstMain} (the wiring the
|
|
890
|
+
* `complete-lock-then-durable-main-move-crash-safe` task's
|
|
891
|
+
* `reconcileItemLockAgainstMain` had no production caller for). This is the
|
|
892
|
+
* generalisation of the (now-retired) advancing-marker report from advancing-only
|
|
893
|
+
* to the UNIFIED lock (the `gc --ledger` stuck-lock report).
|
|
894
|
+
*
|
|
895
|
+
* It is a REPORT, never a sweep: it CLEARS nothing (no liveness heartbeat, no
|
|
896
|
+
* auto-sweep — the same trust model as the advancing report; a human asserts a
|
|
897
|
+
* lock is dead via `release-lock`). A stale-active lock over a terminal-on-`main`
|
|
898
|
+
* item is surfaced as `cleared-stale`-eligible ("reconcilable") but left in place.
|
|
899
|
+
*
|
|
900
|
+
* Best-effort + degrades safely: an absent lock-ref namespace ⇒ an EMPTY report
|
|
901
|
+
* ({@link listItemLockEntries} returns `[]`) — so a deleted lock ref reads as "all
|
|
902
|
+
* locks released" (recoverable; work is safe on the `work/<slug>` branches +
|
|
903
|
+
* `main`).
|
|
904
|
+
*/
|
|
905
|
+
export async function reportItemLocks(cwd, arbiter = 'origin', env) {
|
|
906
|
+
const entries = await listItemLockEntries(cwd, arbiter, env);
|
|
907
|
+
const locks = [];
|
|
908
|
+
for (const lock of entries) {
|
|
909
|
+
const ref = itemLockRef(lock.entry);
|
|
910
|
+
// Read-only classification against `main` — NEVER clears (the report is
|
|
911
|
+
// advisory; a human asserts the clear via `release-lock`).
|
|
912
|
+
const verdict = await classifyItemLockAgainstMain({
|
|
913
|
+
item: itemFromLockEntry(lock.entry),
|
|
914
|
+
cwd,
|
|
915
|
+
arbiter,
|
|
916
|
+
env,
|
|
917
|
+
});
|
|
918
|
+
locks.push({ lock, ref, reconcile: verdict.outcome });
|
|
919
|
+
}
|
|
920
|
+
return { locks };
|
|
921
|
+
}
|
|
922
|
+
/**
|
|
923
|
+
* Format the `gc --ledger` stuck/orphaned-lock REPORT for the terminal: one block
|
|
924
|
+
* per lingering lock (entry, action/state, holder, since, the stuck `reason`, the
|
|
925
|
+
* read-only `main`-reconciliation note, and a copy-pasteable `release-lock`
|
|
926
|
+
* hint). An EMPTY report yields NO lines (a clean lock set is silent here, like
|
|
927
|
+
* the duplicate-slug surface). It REPORTS only — the matching wording makes the
|
|
928
|
+
* no-auto-sweep contract explicit (a human asserts a lock is dead).
|
|
929
|
+
*/
|
|
930
|
+
/**
|
|
931
|
+
* Does the `gc --ledger` lock report contain a lock that NEEDS HUMAN ATTENTION
|
|
932
|
+
* (prd US#14/#21; ADR `ledger-status-on-per-item-lock-refs`: this surface is the
|
|
933
|
+
* STUCK / crash-orphaned lock, NOT every held one)? TRUE iff some lock is
|
|
934
|
+
* `kept-stuck` (terminal-on-`main` + stuck) or `cleared-stale`-eligible
|
|
935
|
+
* (terminal-on-`main` + a stale active orphan). A `kept-in-flight` (active,
|
|
936
|
+
* non-terminal) lock is the NORMAL in-flight state of a healthy concurrent build
|
|
937
|
+
* (read by `status` as healthy) and does NOT count — so a routine `gc --ledger`
|
|
938
|
+
* health check whose only locks are healthy in-flight holds exits 0. This is the
|
|
939
|
+
* fail-loud EXIT predicate for the gc lock surface (the report itself still lists
|
|
940
|
+
* every held lock, in-flight ones informationally).
|
|
941
|
+
*/
|
|
942
|
+
export function itemLockReportNeedsAttention(report) {
|
|
943
|
+
return report.locks.some((l) => l.reconcile === 'kept-stuck' || l.reconcile === 'cleared-stale');
|
|
944
|
+
}
|
|
945
|
+
export function formatItemLockReport(report) {
|
|
946
|
+
if (report.locks.length === 0) {
|
|
947
|
+
return [];
|
|
948
|
+
}
|
|
949
|
+
const lines = [
|
|
950
|
+
`Per-item locks: ${report.locks.length} lock(s) held on the arbiter ` +
|
|
951
|
+
'(REPORT only — no automatic sweep; the lock has no liveness heartbeat, so ' +
|
|
952
|
+
'a human asserts a stuck/stale lock is dead via `release-lock`):',
|
|
953
|
+
];
|
|
954
|
+
for (const { lock, reconcile } of report.locks) {
|
|
955
|
+
const item = itemFromLockEntry(lock.entry);
|
|
956
|
+
lines.push(` ${lock.entry} [${lock.action}/${lock.state}]`);
|
|
957
|
+
lines.push(` holder: ${lock.holder || '(unknown)'} since: ${lock.since || '(unknown)'}`);
|
|
958
|
+
if (lock.state === 'stuck' && lock.reason) {
|
|
959
|
+
lines.push(` reason: ${lock.reason}`);
|
|
960
|
+
}
|
|
961
|
+
lines.push(` ${reconcileNote(reconcile)}`);
|
|
962
|
+
lines.push(` resolve (if the lock is dead): \`dorfl release-lock ${item}\` (never --force).`);
|
|
963
|
+
}
|
|
964
|
+
return lines;
|
|
965
|
+
}
|
|
966
|
+
/** The human-readable line for a lock's read-only `main`-reconciliation verdict. */
|
|
967
|
+
function reconcileNote(reconcile) {
|
|
968
|
+
switch (reconcile) {
|
|
969
|
+
case 'kept-in-flight':
|
|
970
|
+
return 'in flight (held, not terminal on main) — normal; left untouched.';
|
|
971
|
+
case 'kept-stuck':
|
|
972
|
+
return 'terminal on main + STUCK (done+stuck co-exist) — kept for human attention.';
|
|
973
|
+
case 'cleared-stale':
|
|
974
|
+
return 'terminal on main + ACTIVE = STALE (reconcilable) — NOT auto-cleared; a human clears it.';
|
|
975
|
+
case 'no-lock':
|
|
976
|
+
return 'no lock (already at rest).';
|
|
977
|
+
case 'error':
|
|
978
|
+
return 'reconciliation against main could not be determined (left untouched — the safe direction).';
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
/**
|
|
982
|
+
* The OPT-IN `gc --ledger --reap-stale-locks` SWEEP (prd
|
|
983
|
+
* `ledger-status-per-item-lock-refs` US #14): a human asserting "clear the dead
|
|
984
|
+
* TERMINAL locks now", so one command sweeps every orphaned terminal lock instead
|
|
985
|
+
* of N hand-run `release-lock`s. It is the WRITE twin of {@link reportItemLocks}
|
|
986
|
+
* (the default report-only surface): it enumerates the SAME held locks, classifies
|
|
987
|
+
* each read-only, and for EXACTLY the `cleared-stale` class (terminal-on-`main` +
|
|
988
|
+
* `active` = stranded) performs the SHARED leased delete via
|
|
989
|
+
* {@link reconcileItemLockAgainstMain} (the recovery's clear, re-checked fresh per
|
|
990
|
+
* item) — there is NO parallel clear mechanism.
|
|
991
|
+
*
|
|
992
|
+
* SCOPE FENCE (the trust model the default preserves):
|
|
993
|
+
* - it clears ONLY `cleared-stale`. A `kept-stuck` (terminal + stuck — human
|
|
994
|
+
* attention) and a `kept-in-flight` (active + non-terminal — a healthy build)
|
|
995
|
+
* are NEVER reaped, even here. Because each clear goes through
|
|
996
|
+
* {@link reconcileItemLockAgainstMain}, which RE-reads + RE-classifies before
|
|
997
|
+
* deleting, a lock that turned stuck/in-flight between the report and the sweep
|
|
998
|
+
* is still safe (reconcile returns `kept-*`, not a delete).
|
|
999
|
+
* - the clear is a LEASED delete: a concurrent change to the ref makes it REJECT
|
|
1000
|
+
* (`lost`), reported — never a blind `--force`.
|
|
1001
|
+
*
|
|
1002
|
+
* The DEFAULT `gc --ledger` (no flag) never calls this: it stays report-only,
|
|
1003
|
+
* fail-loud, delete-nothing. This is gated behind the explicit `--reap-stale-locks`
|
|
1004
|
+
* flag (a human authorising the clear), exactly as `release-lock`'s trust model.
|
|
1005
|
+
*/
|
|
1006
|
+
export async function reapStaleItemLocks(cwd, arbiter = 'origin', env) {
|
|
1007
|
+
const report = await reportItemLocks(cwd, arbiter, env);
|
|
1008
|
+
const entries = [];
|
|
1009
|
+
let reaped = 0;
|
|
1010
|
+
let alreadyReaped = 0;
|
|
1011
|
+
let kept = 0;
|
|
1012
|
+
let lost = 0;
|
|
1013
|
+
for (const { lock, ref, reconcile } of report.locks) {
|
|
1014
|
+
const item = itemFromLockEntry(lock.entry);
|
|
1015
|
+
if (reconcile === 'cleared-stale') {
|
|
1016
|
+
// Re-check + clear through the recovery's SHARED leased delete. Reconcile
|
|
1017
|
+
// re-reads the live ref, so a lock that turned stuck/in-flight since the
|
|
1018
|
+
// report is left alone; a concurrent change to the ref makes the lease lose.
|
|
1019
|
+
const rec = await reconcileItemLockAgainstMain({
|
|
1020
|
+
item,
|
|
1021
|
+
cwd,
|
|
1022
|
+
arbiter,
|
|
1023
|
+
env,
|
|
1024
|
+
});
|
|
1025
|
+
if (rec.outcome === 'cleared-stale') {
|
|
1026
|
+
reaped++;
|
|
1027
|
+
entries.push({ lock, ref, outcome: 'reaped', message: rec.message });
|
|
1028
|
+
}
|
|
1029
|
+
else if (rec.outcome === 'no-lock') {
|
|
1030
|
+
// BENIGN: the ref is already gone — the desired end state. The LOSER of
|
|
1031
|
+
// a concurrent double-reap (another reaper deleted the ref between our
|
|
1032
|
+
// report and our re-read), or a `release-lock`/`requeue` that cleared
|
|
1033
|
+
// the same stale lock in the meantime. NOT a lost lease (the lease was
|
|
1034
|
+
// not REJECTED; there was simply nothing left to delete), so this does
|
|
1035
|
+
// NOT count as needs-attention. Kept SEPARATE from `reaped` so the
|
|
1036
|
+
// summary does not lie about who did the deleting.
|
|
1037
|
+
alreadyReaped++;
|
|
1038
|
+
entries.push({
|
|
1039
|
+
lock,
|
|
1040
|
+
ref,
|
|
1041
|
+
outcome: 'already-reaped',
|
|
1042
|
+
message: rec.message,
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
else if (rec.outcome === 'kept-stuck' ||
|
|
1046
|
+
rec.outcome === 'kept-in-flight') {
|
|
1047
|
+
// The lock changed between the report and the sweep — no longer stale.
|
|
1048
|
+
kept++;
|
|
1049
|
+
entries.push({ lock, ref, outcome: rec.outcome, message: rec.message });
|
|
1050
|
+
}
|
|
1051
|
+
else {
|
|
1052
|
+
// A lost lease (the ref was REJECTED because it changed concurrently to
|
|
1053
|
+
// a DIFFERENT value) or a per-item error — REPORTED, never forced.
|
|
1054
|
+
// Counts as needing attention after the sweep.
|
|
1055
|
+
lost++;
|
|
1056
|
+
entries.push({ lock, ref, outcome: 'lost', message: rec.message });
|
|
1057
|
+
}
|
|
1058
|
+
continue;
|
|
1059
|
+
}
|
|
1060
|
+
// NOT a cleared-stale candidate: a stuck or in-flight lock the reaper must
|
|
1061
|
+
// NEVER touch, even with the flag.
|
|
1062
|
+
if (reconcile === 'kept-stuck' || reconcile === 'kept-in-flight') {
|
|
1063
|
+
kept++;
|
|
1064
|
+
entries.push({
|
|
1065
|
+
lock,
|
|
1066
|
+
ref,
|
|
1067
|
+
outcome: reconcile,
|
|
1068
|
+
message: reconcileNote(reconcile),
|
|
1069
|
+
});
|
|
1070
|
+
}
|
|
1071
|
+
else {
|
|
1072
|
+
// no-lock / error from the classifier — nothing to reap; surface verbatim.
|
|
1073
|
+
lost++;
|
|
1074
|
+
entries.push({
|
|
1075
|
+
lock,
|
|
1076
|
+
ref,
|
|
1077
|
+
outcome: 'error',
|
|
1078
|
+
message: reconcileNote(reconcile),
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
return { entries, reaped, alreadyReaped, kept, lost };
|
|
1083
|
+
}
|
|
1084
|
+
/**
|
|
1085
|
+
* Does a {@link reapStaleItemLocks} sweep leave a lock that STILL needs human
|
|
1086
|
+
* attention? TRUE iff some entry is `kept-stuck` (a stuck lock the reaper rightly
|
|
1087
|
+
* left) or `lost`/`error` (a `cleared-stale` whose leased delete lost the race, or
|
|
1088
|
+
* an unresolvable fault). A `reaped` (successfully cleared) or a `kept-in-flight`
|
|
1089
|
+
* (healthy build) lock does NOT count — so a sweep that cleared every stale lock
|
|
1090
|
+
* and left only healthy in-flight holds exits 0. This is the post-sweep analogue of
|
|
1091
|
+
* {@link itemLockReportNeedsAttention}.
|
|
1092
|
+
*/
|
|
1093
|
+
export function reapReportNeedsAttention(report) {
|
|
1094
|
+
// EXIT-CODE CONTRACT (recorded in this task's done record): the reaper exits 0
|
|
1095
|
+
// when all stale locks are reaped and only healthy in-flight locks remain;
|
|
1096
|
+
// exits 1 when a `kept-stuck` survives or a delete genuinely lost the race /
|
|
1097
|
+
// errored. An `already-reaped` (the loser of a concurrent double-reap saw the
|
|
1098
|
+
// ref already gone via `no-lock`) is BENIGN — the desired end state — and is
|
|
1099
|
+
// NOT in this set.
|
|
1100
|
+
return report.entries.some((e) => e.outcome === 'kept-stuck' ||
|
|
1101
|
+
e.outcome === 'lost' ||
|
|
1102
|
+
e.outcome === 'error');
|
|
1103
|
+
}
|
|
1104
|
+
/**
|
|
1105
|
+
* Format the `gc --ledger --reap-stale-locks` sweep for the terminal: a header
|
|
1106
|
+
* line with the reaped/kept counts, then one line per lock (what was reaped vs
|
|
1107
|
+
* kept vs lost). An EMPTY sweep (no locks held) yields NO lines (silent, like the
|
|
1108
|
+
* report). The wording keeps the no-blind-force contract explicit.
|
|
1109
|
+
*/
|
|
1110
|
+
export function formatReapReport(report) {
|
|
1111
|
+
if (report.entries.length === 0) {
|
|
1112
|
+
return [];
|
|
1113
|
+
}
|
|
1114
|
+
const lines = [
|
|
1115
|
+
`Per-item lock sweep (--reap-stale-locks): reaped ${report.reaped} stale ` +
|
|
1116
|
+
`terminal lock(s), kept ${report.kept} (stuck/in-flight, never reaped)` +
|
|
1117
|
+
(report.alreadyReaped > 0
|
|
1118
|
+
? `, ${report.alreadyReaped} already reaped by another sweep (no-lock — benign, the desired end state)`
|
|
1119
|
+
: '') +
|
|
1120
|
+
(report.lost > 0
|
|
1121
|
+
? `, ${report.lost} could not be cleared (lease lost / error — reported, NEVER forced)`
|
|
1122
|
+
: '') +
|
|
1123
|
+
':',
|
|
1124
|
+
];
|
|
1125
|
+
for (const { lock, outcome, message } of report.entries) {
|
|
1126
|
+
const tag = outcome === 'reaped'
|
|
1127
|
+
? '[reaped] '
|
|
1128
|
+
: outcome === 'already-reaped'
|
|
1129
|
+
? '[already] '
|
|
1130
|
+
: outcome === 'lost'
|
|
1131
|
+
? '[lost] '
|
|
1132
|
+
: outcome === 'error'
|
|
1133
|
+
? '[error] '
|
|
1134
|
+
: '[kept] ';
|
|
1135
|
+
lines.push(` ${tag} ${lock.entry} [${lock.action}/${lock.state}] ${message}`);
|
|
1136
|
+
}
|
|
1137
|
+
return lines;
|
|
1138
|
+
}
|
|
1139
|
+
/**
|
|
1140
|
+
* Convert a type-encoded lock `<entry>` (`<type>-<slug>`) back into the namespaced
|
|
1141
|
+
* item form (`<namespace>:<slug>`) that {@link lockEntryFor} /
|
|
1142
|
+
* {@link resolveSidecarIdentity} accept — so the report's suggested `release-lock`
|
|
1143
|
+
* command is copy-pasteable and the classifier can re-key off the SAME identity.
|
|
1144
|
+
* Unknown prefixes fall back to the raw entry (still copyable). The inverse of
|
|
1145
|
+
* {@link lockEntryFor} for the three known namespaces.
|
|
1146
|
+
*/
|
|
1147
|
+
export function itemFromLockEntry(entry) {
|
|
1148
|
+
// The lock-entry prefixes: `spec` is the parent-spec type token (the legacy
|
|
1149
|
+
// `prd` token is GONE after the hard cutover), so a `spec-<slug>` lock entry
|
|
1150
|
+
// round-trips back to its namespaced `spec:<slug>` form (the inverse of
|
|
1151
|
+
// `lockEntryFor('spec:<slug>')`).
|
|
1152
|
+
for (const prefix of ['task', 'spec', 'observation']) {
|
|
1153
|
+
const tag = `${prefix}-`;
|
|
1154
|
+
if (entry.startsWith(tag)) {
|
|
1155
|
+
return `${prefix}:${entry.slice(tag.length)}`;
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
return entry;
|
|
1159
|
+
}
|
|
1160
|
+
/** True iff `<arbiter>/main` shows the item TERMINAL — any of
|
|
1161
|
+
* {@link terminalMainPaths} present in `<arbiter>/main`'s tree. */
|
|
1162
|
+
async function isTerminalOnMain(type, slug, arbiter, cwd, env) {
|
|
1163
|
+
for (const path of terminalMainPaths(type, slug)) {
|
|
1164
|
+
const exists = (await gitSoft(['cat-file', '-e', `${arbiter}/main:${path}`], cwd, env))
|
|
1165
|
+
.status === 0;
|
|
1166
|
+
if (exists) {
|
|
1167
|
+
return true;
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
return false;
|
|
1171
|
+
}
|
|
1172
|
+
/** List the entries (`<type>-<slug>`) currently locked on the arbiter (the
|
|
1173
|
+
* stuck-lock report / `status` read path). */
|
|
1174
|
+
export async function listItemLocks(cwd, arbiter = 'origin', env) {
|
|
1175
|
+
await gitHard(['fetch', '--quiet', arbiter, `+${LOCK_REF_PREFIX}/*:${LOCK_REF_PREFIX}/*`], cwd, env);
|
|
1176
|
+
const out = await gitSoft(['for-each-ref', '--format=%(refname)', `${LOCK_REF_PREFIX}/*`], cwd, env);
|
|
1177
|
+
if (out.status !== 0) {
|
|
1178
|
+
return [];
|
|
1179
|
+
}
|
|
1180
|
+
return out.stdout
|
|
1181
|
+
.split('\n')
|
|
1182
|
+
.map((l) => l.trim())
|
|
1183
|
+
.filter((l) => l.startsWith(`${LOCK_REF_PREFIX}/`))
|
|
1184
|
+
.map((l) => l.slice(`${LOCK_REF_PREFIX}/`.length))
|
|
1185
|
+
.sort();
|
|
1186
|
+
}
|
|
1187
|
+
/**
|
|
1188
|
+
* Read the FULL lock entries currently held on the arbiter — the `status`/`scan`
|
|
1189
|
+
* in-flight read path (prd `ledger-status-per-item-lock-refs` US #8; task
|
|
1190
|
+
* `needs-attention-as-stuck-lock-state`). One fetch of the lock refs, then read
|
|
1191
|
+
* each held entry's `lock.md` blob, returning the parsed {@link LockEntry} for
|
|
1192
|
+
* every ref (so a caller can surface `active` (in-progress) and `stuck`
|
|
1193
|
+
* (needs-attention) holds + their reasons WITHOUT N fetches). Sorted by `entry`.
|
|
1194
|
+
* Best-effort: a fetch/read fault yields an EMPTY list, so the read-only
|
|
1195
|
+
* `status`/`scan` views degrade to "no in-flight locks" rather than erroring
|
|
1196
|
+
* (parity with {@link heldTaskSlugs}).
|
|
1197
|
+
*/
|
|
1198
|
+
export async function listItemLockEntries(cwd, arbiter = 'origin', env) {
|
|
1199
|
+
try {
|
|
1200
|
+
await gitHard([
|
|
1201
|
+
'fetch',
|
|
1202
|
+
'--quiet',
|
|
1203
|
+
arbiter,
|
|
1204
|
+
`+${LOCK_REF_PREFIX}/*:${LOCK_REF_PREFIX}/*`,
|
|
1205
|
+
], cwd, env);
|
|
1206
|
+
const out = await gitSoft(['for-each-ref', '--format=%(refname)', `${LOCK_REF_PREFIX}/*`], cwd, env);
|
|
1207
|
+
if (out.status !== 0) {
|
|
1208
|
+
return [];
|
|
1209
|
+
}
|
|
1210
|
+
const refs = out.stdout
|
|
1211
|
+
.split('\n')
|
|
1212
|
+
.map((l) => l.trim())
|
|
1213
|
+
.filter((l) => l.startsWith(`${LOCK_REF_PREFIX}/`))
|
|
1214
|
+
.sort();
|
|
1215
|
+
const entries = [];
|
|
1216
|
+
for (const ref of refs) {
|
|
1217
|
+
const show = await gitSoft(['show', `${ref}:lock.md`], cwd, env);
|
|
1218
|
+
if (show.status !== 0) {
|
|
1219
|
+
continue;
|
|
1220
|
+
}
|
|
1221
|
+
const lock = parseLockEntry(show.stdout);
|
|
1222
|
+
if (lock) {
|
|
1223
|
+
entries.push(lock);
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
return entries;
|
|
1227
|
+
}
|
|
1228
|
+
catch {
|
|
1229
|
+
return [];
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
/**
|
|
1233
|
+
* List the TASK slugs currently lock-held on the arbiter — the held-slug set the
|
|
1234
|
+
* `ready/` pool readers SUBTRACT (prd `ledger-status-per-item-lock-refs` US #15;
|
|
1235
|
+
* task `claim-acquires-unified-lock-no-body-move`). Enumerates {@link listItemLocks}
|
|
1236
|
+
* and keeps only the `task-<slug>` entries (a prd/observation lock does not gate
|
|
1237
|
+
* the TASK pool), mapping each to its bare `<slug>`.
|
|
1238
|
+
*
|
|
1239
|
+
* LOAD-BEARING since the lock cut-over: the claim NO LONGER moves the body to
|
|
1240
|
+
* `in-progress/` (it stays in the pool on `main`; the held lock IS the claim), so
|
|
1241
|
+
* this held-slug set is the ONLY signal that keeps a claimed / in-flight item out
|
|
1242
|
+
* of the eligible pool, NOT the redundant subtraction it was while the body-move
|
|
1243
|
+
* still removed claimed items.
|
|
1244
|
+
*
|
|
1245
|
+
* GRACEFUL (fail-OPEN) by design — this is the SURFACE reader: a fetch fault
|
|
1246
|
+
* yields an EMPTY set so the read-only `status`/`scan` views degrade to "no
|
|
1247
|
+
* in-flight locks" rather than erroring. That is WRONG for SELECTION (an empty set
|
|
1248
|
+
* on a read FAULT lets a continuously-held in-flight item leak back into the
|
|
1249
|
+
* eligible pool → re-claimed → empty diff → spurious `stuck`). The SELECTION path
|
|
1250
|
+
* must instead use {@link heldTaskSlugsStrict}, which THROWS on a read fault so
|
|
1251
|
+
* the caller can fail CLOSED (refuse to enumerate an untrusted pool) rather than
|
|
1252
|
+
* subtract nothing. This graceful variant delegates to the strict one and only
|
|
1253
|
+
* swallows the fault.
|
|
1254
|
+
*/
|
|
1255
|
+
export async function heldTaskSlugs(cwd, arbiter = 'origin', env) {
|
|
1256
|
+
try {
|
|
1257
|
+
return await heldTaskSlugsStrict(cwd, arbiter, env);
|
|
1258
|
+
}
|
|
1259
|
+
catch {
|
|
1260
|
+
return new Set();
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
/**
|
|
1264
|
+
* STRICT (fail-CLOSED) twin of {@link heldTaskSlugs} for the SELECTION path: read
|
|
1265
|
+
* the held TASK slugs from the arbiter and THROW on any read fault (offline / dead
|
|
1266
|
+
* arbiter / unreadable lock refs) instead of degrading to an empty set. The
|
|
1267
|
+
* held-lock set lives ONLY on the arbiter and is the LOAD-BEARING signal that
|
|
1268
|
+
* keeps a claimed / in-flight item out of the eligible pool, so a SELECTION read
|
|
1269
|
+
* that cannot reach the arbiter must NOT pretend "no locks held" — it must fail so
|
|
1270
|
+
* the caller refuses to enumerate a pool it cannot trust (the
|
|
1271
|
+
* `scan-cwd-selection-pool-read-local-skips-held-lock-subtraction-offline-must-fail`
|
|
1272
|
+
* decision: offline selection FAILS, there is no `--local` fallback). The
|
|
1273
|
+
* read-only surface keeps the graceful {@link heldTaskSlugs}.
|
|
1274
|
+
*/
|
|
1275
|
+
export async function heldTaskSlugsStrict(cwd, arbiter = 'origin', env) {
|
|
1276
|
+
// `listItemLocks` fetches the lock refs with `gitHard` (throws on a failed
|
|
1277
|
+
// fetch) and returns the held entries; we do NOT swallow that throw here.
|
|
1278
|
+
const entries = await listItemLocks(cwd, arbiter, env);
|
|
1279
|
+
const prefix = 'task-';
|
|
1280
|
+
return new Set(entries
|
|
1281
|
+
.filter((e) => e.startsWith(prefix))
|
|
1282
|
+
.map((e) => e.slice(prefix.length)));
|
|
1283
|
+
}
|
|
1284
|
+
/**
|
|
1285
|
+
* Parse a serialised lock entry body back into a {@link LockEntry} — the exact
|
|
1286
|
+
* inverse of {@link serialiseLockEntry}. Reads the two-axis state from the
|
|
1287
|
+
* frontmatter and, for a stuck entry, the FULL reason prose + any questions from
|
|
1288
|
+
* the body (`## Reason` / `## Questions`). Tolerates a LEGACY entry whose reason
|
|
1289
|
+
* lived in a one-line frontmatter `reason:` field (the pre-cutover shape) so a
|
|
1290
|
+
* lock written by an older binary still reads.
|
|
1291
|
+
*/
|
|
1292
|
+
export function parseLockEntry(body) {
|
|
1293
|
+
const normalized = body.replace(/\r\n/g, '\n');
|
|
1294
|
+
const fm = /^---\n([\s\S]*?)\n---/.exec(normalized);
|
|
1295
|
+
if (!fm) {
|
|
1296
|
+
return undefined;
|
|
1297
|
+
}
|
|
1298
|
+
const fields = {};
|
|
1299
|
+
for (const line of fm[1].split('\n')) {
|
|
1300
|
+
const m = /^([a-zA-Z]+):\s*(.*)$/.exec(line);
|
|
1301
|
+
if (m) {
|
|
1302
|
+
fields[m[1]] = m[2];
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
if (!fields.entry || !fields.action || !fields.state) {
|
|
1306
|
+
return undefined;
|
|
1307
|
+
}
|
|
1308
|
+
const bodyText = normalized.slice(fm[0].length);
|
|
1309
|
+
const reason = extractBodyReason(bodyText) ?? fields.reason;
|
|
1310
|
+
const questions = extractBodyQuestions(bodyText);
|
|
1311
|
+
const entry = {
|
|
1312
|
+
entry: fields.entry,
|
|
1313
|
+
action: fields.action,
|
|
1314
|
+
state: fields.state,
|
|
1315
|
+
holder: fields.holder ?? '',
|
|
1316
|
+
since: fields.since ?? '',
|
|
1317
|
+
};
|
|
1318
|
+
if (reason !== undefined && reason !== '') {
|
|
1319
|
+
entry.reason = reason;
|
|
1320
|
+
}
|
|
1321
|
+
if (questions.length > 0) {
|
|
1322
|
+
entry.questions = questions;
|
|
1323
|
+
}
|
|
1324
|
+
return entry;
|
|
1325
|
+
}
|
|
1326
|
+
/** Extract the `## Reason` block's prose (joined multi-line), or undefined. */
|
|
1327
|
+
function extractBodyReason(bodyText) {
|
|
1328
|
+
const lines = bodyText.split('\n');
|
|
1329
|
+
const start = lines.findIndex((l) => l.trim() === LOCK_REASON_HEADING);
|
|
1330
|
+
if (start === -1) {
|
|
1331
|
+
return undefined;
|
|
1332
|
+
}
|
|
1333
|
+
const collected = [];
|
|
1334
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
1335
|
+
if (/^##\s/.test(lines[i])) {
|
|
1336
|
+
break;
|
|
1337
|
+
}
|
|
1338
|
+
collected.push(lines[i]);
|
|
1339
|
+
}
|
|
1340
|
+
// Trim leading/trailing blank lines but PRESERVE interior newlines (rich prose).
|
|
1341
|
+
const text = collected.join('\n').replace(/^\n+/, '').replace(/\n+$/, '');
|
|
1342
|
+
return text === '' ? undefined : text;
|
|
1343
|
+
}
|
|
1344
|
+
/** Extract the `## Questions` block's bulleted list, or [] when absent. */
|
|
1345
|
+
function extractBodyQuestions(bodyText) {
|
|
1346
|
+
const lines = bodyText.split('\n');
|
|
1347
|
+
const start = lines.findIndex((l) => l.trim() === LOCK_QUESTIONS_HEADING);
|
|
1348
|
+
if (start === -1) {
|
|
1349
|
+
return [];
|
|
1350
|
+
}
|
|
1351
|
+
const questions = [];
|
|
1352
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
1353
|
+
if (/^##\s/.test(lines[i])) {
|
|
1354
|
+
break;
|
|
1355
|
+
}
|
|
1356
|
+
const m = /^-\s+(.*)$/.exec(lines[i].trim());
|
|
1357
|
+
if (m) {
|
|
1358
|
+
questions.push(m[1]);
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
return questions;
|
|
1362
|
+
}
|
|
1363
|
+
/**
|
|
1364
|
+
* Advisory holder id: git user.name, else $USER, else a uuid fragment. Exported
|
|
1365
|
+
* as {@link resolveLockHolder} so callers (e.g. claim's stale-lock self-heal) can
|
|
1366
|
+
* resolve THE SAME holder string the lock would stamp, to compare against a held
|
|
1367
|
+
* entry's `holder` without duplicating the resolution order.
|
|
1368
|
+
*/
|
|
1369
|
+
export async function resolveLockHolder(cwd, env) {
|
|
1370
|
+
return resolveHolder(cwd, env);
|
|
1371
|
+
}
|
|
1372
|
+
/** Advisory holder id: git user.name, else $USER, else a uuid fragment. */
|
|
1373
|
+
async function resolveHolder(cwd, env) {
|
|
1374
|
+
const name = await gitSoft(['config', 'user.name'], cwd, env);
|
|
1375
|
+
if (name.status === 0 && name.stdout.trim() !== '') {
|
|
1376
|
+
return name.stdout.trim();
|
|
1377
|
+
}
|
|
1378
|
+
const e = env ?? process.env;
|
|
1379
|
+
return e.USER ?? e.USERNAME ?? randomUUID().slice(0, 8);
|
|
1380
|
+
}
|
|
1381
|
+
//# sourceMappingURL=item-lock.js.map
|