dorfl 0.1.2 → 0.2.1
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-drivers.d.ts +1 -1
- package/dist/advance-drivers.d.ts.map +1 -1
- package/dist/advance-drivers.js +15 -3
- package/dist/advance-drivers.js.map +1 -1
- package/dist/advance-lifecycle-template.d.ts.map +1 -1
- package/dist/advance-lifecycle-template.js +76 -4
- package/dist/advance-lifecycle-template.js.map +1 -1
- package/dist/advance-treeless-publish.d.ts +23 -0
- package/dist/advance-treeless-publish.d.ts.map +1 -1
- package/dist/advance-treeless-publish.js +41 -0
- package/dist/advance-treeless-publish.js.map +1 -1
- package/dist/advance.d.ts +59 -7
- package/dist/advance.d.ts.map +1 -1
- package/dist/advance.js +389 -82
- package/dist/advance.js.map +1 -1
- package/dist/advancing-lock.d.ts +30 -2
- package/dist/advancing-lock.d.ts.map +1 -1
- package/dist/advancing-lock.js +50 -0
- package/dist/advancing-lock.js.map +1 -1
- package/dist/agent-launch.d.ts +12 -0
- package/dist/agent-launch.d.ts.map +1 -1
- package/dist/agent-launch.js +22 -12
- package/dist/agent-launch.js.map +1 -1
- package/dist/agent-stop.d.ts +40 -2
- package/dist/agent-stop.d.ts.map +1 -1
- package/dist/agent-stop.js +30 -2
- package/dist/agent-stop.js.map +1 -1
- package/dist/apply-decide.d.ts +17 -3
- package/dist/apply-decide.d.ts.map +1 -1
- package/dist/apply-decide.js +36 -7
- package/dist/apply-decide.js.map +1 -1
- package/dist/apply-merge-action.d.ts +17 -5
- package/dist/apply-merge-action.d.ts.map +1 -1
- package/dist/apply-merge-action.js +43 -8
- package/dist/apply-merge-action.js.map +1 -1
- package/dist/apply-persist.d.ts +57 -26
- package/dist/apply-persist.d.ts.map +1 -1
- package/dist/apply-persist.js +166 -37
- package/dist/apply-persist.js.map +1 -1
- package/dist/apply-stuck-action.d.ts +151 -0
- package/dist/apply-stuck-action.d.ts.map +1 -0
- package/dist/apply-stuck-action.js +125 -0
- package/dist/apply-stuck-action.js.map +1 -0
- package/dist/brand.d.ts +12 -1
- package/dist/brand.d.ts.map +1 -1
- package/dist/brand.js +2 -1
- package/dist/brand.js.map +1 -1
- package/dist/cli-spinner.d.ts +1 -1
- package/dist/cli-spinner.js +1 -1
- package/dist/cli.d.ts +10 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +320 -63
- package/dist/cli.js.map +1 -1
- package/dist/close-job-template.js +1 -1
- package/dist/complete.d.ts +6 -3
- package/dist/complete.d.ts.map +1 -1
- package/dist/complete.js +91 -37
- package/dist/complete.js.map +1 -1
- package/dist/config.d.ts +55 -9
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +56 -4
- package/dist/config.js.map +1 -1
- package/dist/continue-branch.d.ts.map +1 -1
- package/dist/continue-branch.js +22 -0
- package/dist/continue-branch.js.map +1 -1
- package/dist/decision-engine.d.ts +38 -10
- package/dist/decision-engine.d.ts.map +1 -1
- package/dist/decision-engine.js +10 -6
- package/dist/decision-engine.js.map +1 -1
- package/dist/do-autopick.d.ts.map +1 -1
- package/dist/do-autopick.js +11 -1
- package/dist/do-autopick.js.map +1 -1
- package/dist/do-config.d.ts +1 -1
- package/dist/do.d.ts +49 -56
- package/dist/do.d.ts.map +1 -1
- package/dist/do.js +419 -30
- package/dist/do.js.map +1 -1
- package/dist/env-config.d.ts.map +1 -1
- package/dist/env-config.js +12 -4
- package/dist/env-config.js.map +1 -1
- package/dist/failure-cause.d.ts +3 -2
- package/dist/failure-cause.d.ts.map +1 -1
- package/dist/failure-cause.js +28 -1
- package/dist/failure-cause.js.map +1 -1
- package/dist/format.d.ts +6 -6
- package/dist/format.d.ts.map +1 -1
- package/dist/format.js +10 -27
- package/dist/format.js.map +1 -1
- package/dist/frontmatter.d.ts +16 -4
- package/dist/frontmatter.d.ts.map +1 -1
- package/dist/frontmatter.js +27 -1
- package/dist/frontmatter.js.map +1 -1
- package/dist/gc.d.ts +65 -6
- package/dist/gc.d.ts.map +1 -1
- package/dist/gc.js +126 -9
- package/dist/gc.js.map +1 -1
- package/dist/github.d.ts +14 -0
- package/dist/github.d.ts.map +1 -1
- package/dist/github.js +73 -0
- package/dist/github.js.map +1 -1
- package/dist/harness.d.ts +25 -0
- package/dist/harness.d.ts.map +1 -1
- package/dist/harness.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/install-ci-branch-protection.d.ts +132 -38
- package/dist/install-ci-branch-protection.d.ts.map +1 -1
- package/dist/install-ci-branch-protection.js +190 -48
- package/dist/install-ci-branch-protection.js.map +1 -1
- package/dist/install-ci-core.d.ts +21 -1
- package/dist/install-ci-core.d.ts.map +1 -1
- package/dist/install-ci-core.js +10 -3
- package/dist/install-ci-core.js.map +1 -1
- package/dist/install-ci-github.d.ts +32 -0
- package/dist/install-ci-github.d.ts.map +1 -1
- package/dist/install-ci-github.js +73 -0
- package/dist/install-ci-github.js.map +1 -1
- package/dist/install-skills.d.ts +123 -0
- package/dist/install-skills.d.ts.map +1 -0
- package/dist/install-skills.js +100 -0
- package/dist/install-skills.js.map +1 -0
- package/dist/intake-trigger-template.js +1 -1
- package/dist/intake.d.ts +17 -4
- package/dist/intake.d.ts.map +1 -1
- package/dist/intake.js +23 -7
- package/dist/intake.js.map +1 -1
- package/dist/integration-core.d.ts +10 -7
- package/dist/integration-core.d.ts.map +1 -1
- package/dist/integration-core.js +174 -60
- package/dist/integration-core.js.map +1 -1
- package/dist/integrator.d.ts +1 -1
- package/dist/integrator.d.ts.map +1 -1
- package/dist/integrator.js +19 -3
- package/dist/integrator.js.map +1 -1
- package/dist/isolation.d.ts +2 -2
- package/dist/isolation.d.ts.map +1 -1
- package/dist/isolation.js +9 -0
- package/dist/isolation.js.map +1 -1
- package/dist/item-lock.d.ts +196 -129
- package/dist/item-lock.d.ts.map +1 -1
- package/dist/item-lock.js +329 -254
- package/dist/item-lock.js.map +1 -1
- package/dist/ledger-write.d.ts +31 -26
- package/dist/ledger-write.d.ts.map +1 -1
- package/dist/ledger-write.js +100 -122
- package/dist/ledger-write.js.map +1 -1
- package/dist/lifecycle-gather.d.ts +17 -0
- package/dist/lifecycle-gather.d.ts.map +1 -1
- package/dist/lifecycle-gather.js +7 -1
- package/dist/lifecycle-gather.js.map +1 -1
- package/dist/lifecycle-pools.d.ts +40 -3
- package/dist/lifecycle-pools.d.ts.map +1 -1
- package/dist/lifecycle-pools.js +26 -6
- package/dist/lifecycle-pools.js.map +1 -1
- package/dist/merge-question-surfacer.d.ts +15 -1
- package/dist/merge-question-surfacer.d.ts.map +1 -1
- package/dist/merge-question-surfacer.js +18 -3
- package/dist/merge-question-surfacer.js.map +1 -1
- package/dist/migrate-stuck-locks.d.ts +129 -0
- package/dist/migrate-stuck-locks.d.ts.map +1 -0
- package/dist/migrate-stuck-locks.js +355 -0
- package/dist/migrate-stuck-locks.js.map +1 -0
- package/dist/needs-attention.d.ts +293 -16
- package/dist/needs-attention.d.ts.map +1 -1
- package/dist/needs-attention.js +532 -57
- package/dist/needs-attention.js.map +1 -1
- package/dist/orphan-sidecar.d.ts +8 -4
- package/dist/orphan-sidecar.d.ts.map +1 -1
- package/dist/orphan-sidecar.js +35 -2
- package/dist/orphan-sidecar.js.map +1 -1
- package/dist/pi-harness.d.ts +16 -0
- package/dist/pi-harness.d.ts.map +1 -1
- package/dist/pi-harness.js +82 -2
- package/dist/pi-harness.js.map +1 -1
- package/dist/placement.d.ts +3 -3
- package/dist/placement.js +1 -1
- package/dist/prd-to-spec.d.ts.map +1 -1
- package/dist/prd-to-spec.js +9 -5
- package/dist/prd-to-spec.js.map +1 -1
- package/dist/protocol/CLAIM-PROTOCOL.md +17 -10
- package/dist/protocol/REVIEW-PROTOCOL.md +4 -1
- package/dist/protocol/SURFACE-PROTOCOL.md +16 -2
- package/dist/protocol/TASKING-PROTOCOL.md +3 -1
- package/dist/protocol/WORK-CONTRACT.md +22 -18
- package/dist/protocol/task-template.md +1 -1
- package/dist/reap-branches.d.ts +12 -9
- package/dist/reap-branches.d.ts.map +1 -1
- package/dist/reap-branches.js +25 -7
- package/dist/reap-branches.js.map +1 -1
- package/dist/recover-isolated.d.ts +12 -0
- package/dist/recover-isolated.d.ts.map +1 -1
- package/dist/recover-isolated.js +5 -0
- package/dist/recover-isolated.js.map +1 -1
- package/dist/repo-config.d.ts +23 -2
- package/dist/repo-config.d.ts.map +1 -1
- package/dist/repo-config.js +53 -7
- package/dist/repo-config.js.map +1 -1
- package/dist/repo-mirror.d.ts.map +1 -1
- package/dist/repo-mirror.js +18 -2
- package/dist/repo-mirror.js.map +1 -1
- package/dist/review-verdict.js +1 -1
- package/dist/review-verdict.js.map +1 -1
- package/dist/run.d.ts +1 -1
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +54 -19
- package/dist/run.js.map +1 -1
- package/dist/scan.d.ts +17 -8
- package/dist/scan.d.ts.map +1 -1
- package/dist/scan.js +22 -8
- package/dist/scan.js.map +1 -1
- package/dist/select-priority.d.ts +2 -2
- package/dist/select-priority.js +1 -1
- package/dist/select-priority.js.map +1 -1
- package/dist/sidecar-apply.js +1 -1
- package/dist/sidecar-apply.js.map +1 -1
- package/dist/sidecar.d.ts +45 -10
- package/dist/sidecar.d.ts.map +1 -1
- package/dist/sidecar.js +82 -2
- package/dist/sidecar.js.map +1 -1
- package/dist/skills/answer-questions/SKILL.md +89 -0
- package/dist/skills/capture-signal/SKILL.md +52 -0
- package/dist/skills/convert-from-prd-to-spec/SKILL.md +90 -0
- package/dist/skills/drive-tasks/SKILL.md +218 -0
- package/dist/skills/from-idea/SKILL.md +83 -0
- package/dist/skills/merge-prs/SKILL.md +70 -0
- package/dist/skills/orchestrate/SKILL.md +101 -0
- package/dist/skills/promote/SKILL.md +35 -0
- package/dist/skills/review/SKILL.md +16 -0
- package/dist/skills/setup/SKILL.md +258 -0
- package/dist/skills/setup/protocol/ADR-FORMAT.md +47 -0
- package/dist/skills/setup/protocol/CLAIM-PROTOCOL.md +224 -0
- package/dist/skills/setup/protocol/REVIEW-PROTOCOL.md +122 -0
- package/dist/skills/setup/protocol/SURFACE-PROTOCOL.md +135 -0
- package/dist/skills/setup/protocol/TASKING-PROTOCOL.md +124 -0
- package/dist/skills/setup/protocol/WORK-CONTRACT.md +280 -0
- package/dist/skills/setup/protocol/spec-template.md +71 -0
- package/dist/skills/setup/protocol/task-template.md +65 -0
- package/dist/skills/surface-questions/SKILL.md +16 -0
- package/dist/skills/to-spec/SKILL.md +34 -0
- package/dist/skills/to-task/SKILL.md +19 -0
- package/dist/skills/triage-observations/SKILL.md +78 -0
- package/dist/skills/work/SKILL.md +51 -0
- package/dist/slug-namespace.d.ts +5 -5
- package/dist/slug-namespace.js +5 -5
- package/dist/slug-namespace.js.map +1 -1
- package/dist/spec-complete.d.ts.map +1 -1
- package/dist/spec-complete.js +2 -3
- package/dist/spec-complete.js.map +1 -1
- package/dist/start.d.ts.map +1 -1
- package/dist/start.js +51 -57
- package/dist/start.js.map +1 -1
- package/dist/surface-gate.d.ts.map +1 -1
- package/dist/surface-gate.js +10 -3
- package/dist/surface-gate.js.map +1 -1
- package/dist/surface-persist.js +1 -1
- package/dist/surface-persist.js.map +1 -1
- package/dist/tasker-review-loop.d.ts +4 -3
- package/dist/tasker-review-loop.d.ts.map +1 -1
- package/dist/tasker-review-loop.js.map +1 -1
- package/dist/tasking-lock.d.ts +2 -2
- package/dist/tasking-lock.d.ts.map +1 -1
- package/dist/tasking-lock.js +53 -32
- package/dist/tasking-lock.js.map +1 -1
- package/dist/tasking.d.ts +40 -6
- package/dist/tasking.d.ts.map +1 -1
- package/dist/tasking.js +224 -12
- package/dist/tasking.js.map +1 -1
- package/dist/triage-persist.d.ts +10 -6
- package/dist/triage-persist.d.ts.map +1 -1
- package/dist/triage-persist.js +34 -4
- package/dist/triage-persist.js.map +1 -1
- package/dist/vendor/incur/agents.d.ts +58 -0
- package/dist/vendor/incur/agents.d.ts.map +1 -0
- package/dist/vendor/incur/agents.js +343 -0
- package/dist/vendor/incur/agents.js.map +1 -0
- package/dist/verify-workflow-template.js +1 -1
- package/dist/watch-session.d.ts +11 -3
- package/dist/watch-session.d.ts.map +1 -1
- package/dist/watch-session.js +94 -7
- package/dist/watch-session.js.map +1 -1
- package/dist/work-layout.d.ts +7 -3
- package/dist/work-layout.d.ts.map +1 -1
- package/dist/work-layout.js +6 -3
- package/dist/work-layout.js.map +1 -1
- package/dist/workspace.d.ts +1 -1
- package/package.json +2 -2
- package/src/advance-drivers.ts +16 -3
- package/src/advance-lifecycle-template.ts +89 -4
- package/src/advance-treeless-publish.ts +45 -0
- package/src/advance.ts +467 -87
- package/src/advancing-lock.ts +96 -1
- package/src/agent-launch.ts +37 -12
- package/src/agent-stop.ts +60 -2
- package/src/apply-decide.ts +36 -7
- package/src/apply-merge-action.ts +45 -7
- package/src/apply-persist.ts +228 -54
- package/src/apply-stuck-action.ts +260 -0
- package/src/brand.ts +14 -2
- package/src/cli-spinner.ts +1 -1
- package/src/cli.ts +401 -70
- package/src/close-job-template.ts +1 -1
- package/src/complete.ts +110 -52
- package/src/config.ts +107 -13
- package/src/continue-branch.ts +22 -0
- package/src/decision-engine.ts +55 -17
- package/src/do-autopick.ts +12 -1
- package/src/do-config.ts +1 -1
- package/src/do.ts +511 -38
- package/src/env-config.ts +12 -4
- package/src/failure-cause.ts +30 -1
- package/src/format.ts +10 -30
- package/src/frontmatter.ts +35 -5
- package/src/gc.ts +172 -9
- package/src/github.ts +78 -0
- package/src/harness.ts +25 -0
- package/src/index.ts +10 -0
- package/src/install-ci-branch-protection.ts +282 -57
- package/src/install-ci-core.ts +30 -3
- package/src/install-ci-github.ts +86 -0
- package/src/install-skills.ts +166 -0
- package/src/intake-trigger-template.ts +1 -1
- package/src/intake.ts +46 -11
- package/src/integration-core.ts +199 -65
- package/src/integrator.ts +22 -9
- package/src/isolation.ts +11 -2
- package/src/item-lock.ts +485 -341
- package/src/ledger-write.ts +135 -150
- package/src/lifecycle-gather.ts +29 -6
- package/src/lifecycle-pools.ts +67 -9
- package/src/merge-question-surfacer.ts +28 -4
- package/src/migrate-stuck-locks.ts +451 -0
- package/src/needs-attention.ts +857 -84
- package/src/orphan-sidecar.ts +47 -6
- package/src/pi-harness.ts +82 -2
- package/src/placement.ts +3 -3
- package/src/prd-to-spec.ts +13 -5
- package/src/reap-branches.ts +38 -14
- package/src/recover-isolated.ts +17 -0
- package/src/repo-config.ts +55 -6
- package/src/repo-mirror.ts +23 -1
- package/src/review-verdict.ts +1 -1
- package/src/run.ts +64 -18
- package/src/scan.ts +22 -8
- package/src/select-priority.ts +3 -3
- package/src/sidecar-apply.ts +1 -1
- package/src/sidecar.ts +138 -11
- package/src/slug-namespace.ts +7 -7
- package/src/spec-complete.ts +2 -3
- package/src/start.ts +53 -59
- package/src/surface-gate.ts +10 -3
- package/src/surface-persist.ts +1 -1
- package/src/tasker-review-loop.ts +4 -3
- package/src/tasking-lock.ts +52 -36
- package/src/tasking.ts +261 -17
- package/src/triage-persist.ts +56 -12
- package/src/vendor/incur/LICENSE +21 -0
- package/src/vendor/incur/README.md +19 -0
- package/src/vendor/incur/agents.ts +392 -0
- package/src/verify-workflow-template.ts +1 -1
- package/src/watch-session.ts +102 -7
- package/src/work-layout.ts +6 -3
- package/src/workspace.ts +1 -1
package/src/item-lock.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import {randomUUID} from 'node:crypto';
|
|
2
2
|
import {runAsync, type RunResult} from './git.js';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
resolveSidecarIdentity,
|
|
5
|
+
sidecarPathFor,
|
|
6
|
+
type SidecarType,
|
|
7
|
+
} from './sidecar.js';
|
|
8
|
+
import {parseFrontmatter} from './frontmatter.js';
|
|
4
9
|
import {workItemRel} from './work-layout.js';
|
|
5
10
|
|
|
6
11
|
/**
|
|
@@ -77,28 +82,45 @@ export function itemLockRef(entry: string): string {
|
|
|
77
82
|
return `${LOCK_REF_PREFIX}/${entry}`;
|
|
78
83
|
}
|
|
79
84
|
|
|
85
|
+
/**
|
|
86
|
+
* The character class a LITERAL lock-entry name must match to be addressable
|
|
87
|
+
* through `release-lock --entry <literal>` (task
|
|
88
|
+
* `release-lock-entry-escape-hatch-and-literal-entry-reporting`): a NON-EMPTY run
|
|
89
|
+
* of `[A-Za-z0-9._-]` — the SAME shape the minting side produces (`<type>-<slug>`,
|
|
90
|
+
* and the pre-cutover `slice-<slug>` / `prd-<slug>` entries it must be able to
|
|
91
|
+
* name). It deliberately EXCLUDES `/` and whitespace so a literal can never escape
|
|
92
|
+
* the `refs/dorfl/lock/` namespace (a `/` would address a different ref path; a
|
|
93
|
+
* space would break the push refspec). This is the ONLY validation the `--entry`
|
|
94
|
+
* escape hatch performs before the git operation — the entry name is taken
|
|
95
|
+
* literally otherwise, bypassing the namespace mapping the item-form path uses.
|
|
96
|
+
*/
|
|
97
|
+
export function isValidLockEntryName(entry: string): boolean {
|
|
98
|
+
return /^[A-Za-z0-9._-]+$/.test(entry);
|
|
99
|
+
}
|
|
100
|
+
|
|
80
101
|
/** WHAT holds the lock — the three mutually-exclusive actions over one item. */
|
|
81
102
|
export type LockAction = 'implement' | 'task' | 'advance';
|
|
82
103
|
|
|
83
|
-
/**
|
|
84
|
-
|
|
104
|
+
/**
|
|
105
|
+
* Health of the hold. Post-CONTRACT step (task `retire-stuck-lock-state`, spec
|
|
106
|
+
* `surface-stuck-as-questions-and-retire-stuck-lock-state`) this collapses to a
|
|
107
|
+
* single value: `active` = the in-flight hold. The formerly-second value
|
|
108
|
+
* `stuck` (needs-attention) is RETIRED — a bounce now SURFACES a question
|
|
109
|
+
* sidecar + `needsAnswers:true` on `<arbiter>/main` and RELEASES the lock, so a
|
|
110
|
+
* parked item is a `needsAnswers:true` pool item on `main`, NEVER a `stuck`
|
|
111
|
+
* lock. The `state` field is kept (single-value) so serialised entries continue
|
|
112
|
+
* to round-trip and downstream readers still see a stable shape. The `reason`
|
|
113
|
+
* and `questions` fields that USED to ride on a `stuck` entry are gone — that
|
|
114
|
+
* prose lives on the surfaced sidecar on `main`, not on the lock entry.
|
|
115
|
+
*/
|
|
116
|
+
export type LockState = 'active';
|
|
85
117
|
|
|
86
118
|
/**
|
|
87
|
-
* The
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
* `cutover-needs-attention-becomes-lock-stuck-recovery-surface`, decision i+: the
|
|
93
|
-
* `needs-attention/` folder is retired), `reason` is the FULL bounce prose (it may
|
|
94
|
-
* span multiple lines — a red-gate excerpt, a rebase-conflict report, an agent's
|
|
95
|
-
* ambiguity note), and `questions` carries any agent-surfaced questions for the
|
|
96
|
-
* human. Both ride in the lock blob BODY (not a single frontmatter field) so they
|
|
97
|
-
* round-trip richly, in a shape a future advance-surface rung (the
|
|
98
|
-
* surface-stuck-locks-as-questions direction captured in
|
|
99
|
-
* `work/notes/observations/needs-attention-may-have-no-human-visible-outcome-after-lock-cutover-surface-as-questions-2026-06-21.md`)
|
|
100
|
-
* can render into a `work/questions/` sidecar. `questions` is present (non-empty) only
|
|
101
|
-
* for a stuck entry that recorded them.
|
|
119
|
+
* The lock entry: `action` × `state` (the state axis is now degenerate — see
|
|
120
|
+
* {@link LockState}) plus the holder/since stamps. Post-CONTRACT step (task
|
|
121
|
+
* `retire-stuck-lock-state`) it carries NO `reason`/`questions` — the sole
|
|
122
|
+
* parked-item mechanism is the surfaced `needsAnswers:true` sidecar on
|
|
123
|
+
* `<arbiter>/main`.
|
|
102
124
|
*/
|
|
103
125
|
export interface LockEntry {
|
|
104
126
|
entry: string;
|
|
@@ -106,8 +128,6 @@ export interface LockEntry {
|
|
|
106
128
|
state: LockState;
|
|
107
129
|
holder: string;
|
|
108
130
|
since: string;
|
|
109
|
-
reason?: string;
|
|
110
|
-
questions?: string[];
|
|
111
131
|
}
|
|
112
132
|
|
|
113
133
|
/** Outcome of an acquire attempt. `acquired` = we hold it; `lost` = someone else
|
|
@@ -131,26 +151,20 @@ export interface ReleaseResult {
|
|
|
131
151
|
}
|
|
132
152
|
|
|
133
153
|
/**
|
|
134
|
-
* Outcome of an AMEND-style transition (
|
|
135
|
-
* lock-entry STATE MACHINE's interior moves (
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
* - `transitioned` — we won the CAS; the entry now
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
* - `error` — environment/usage (missing item, missing reason for mark-stuck, …).
|
|
154
|
+
* Outcome of an AMEND-style transition (resume-crash-orphan / requeue) — the
|
|
155
|
+
* lock-entry STATE MACHINE's interior moves. Post-CONTRACT step (task
|
|
156
|
+
* `retire-stuck-lock-state`) the `mark-stuck` transition + the `wrong-state`
|
|
157
|
+
* verdict are retired with the `stuck` state itself.
|
|
158
|
+
* - `transitioned` — we won the CAS; the entry is now at the target state
|
|
159
|
+
* (or removed, for requeue).
|
|
160
|
+
* - `not-held` — there is no entry to transition (the move's precondition
|
|
161
|
+
* is a held entry; absent ⇒ illegal here).
|
|
162
|
+
* - `lost` — the leased CAS was rejected because a CONCURRENT writer
|
|
163
|
+
* changed the ref between our read and our push (a genuine
|
|
164
|
+
* same-item race).
|
|
165
|
+
* - `error` — environment/usage (missing item, …).
|
|
147
166
|
*/
|
|
148
|
-
export type TransitionOutcome =
|
|
149
|
-
| 'transitioned'
|
|
150
|
-
| 'not-held'
|
|
151
|
-
| 'wrong-state'
|
|
152
|
-
| 'lost'
|
|
153
|
-
| 'error';
|
|
167
|
+
export type TransitionOutcome = 'transitioned' | 'not-held' | 'lost' | 'error';
|
|
154
168
|
|
|
155
169
|
export interface TransitionResult {
|
|
156
170
|
outcome: TransitionOutcome;
|
|
@@ -183,23 +197,16 @@ async function gitHard(
|
|
|
183
197
|
return r;
|
|
184
198
|
}
|
|
185
199
|
|
|
186
|
-
/** The body heading that opens the (possibly multi-line) stuck reason prose. */
|
|
187
|
-
const LOCK_REASON_HEADING = '## Reason';
|
|
188
|
-
/** The body heading that opens the agent-surfaced questions list. */
|
|
189
|
-
const LOCK_QUESTIONS_HEADING = '## Questions';
|
|
190
|
-
|
|
191
200
|
/**
|
|
192
201
|
* Serialise a lock entry to the ref's blob body (markdown frontmatter, like the
|
|
193
|
-
* advancing marker, so it round-trips and is previewable).
|
|
194
|
-
* (`
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
* future advance-surface rung can render. {@link parseLockEntry} is the exact
|
|
199
|
-
* inverse.
|
|
202
|
+
* advancing marker, so it round-trips and is previewable). Post-CONTRACT step
|
|
203
|
+
* (task `retire-stuck-lock-state`) the body carries ONLY the identity block —
|
|
204
|
+
* no `## Reason` / `## Questions` sections, because the retired `stuck` state
|
|
205
|
+
* was the only state that populated them. {@link parseLockEntry} is the inverse
|
|
206
|
+
* and tolerates a legacy body with those headings by ignoring them.
|
|
200
207
|
*/
|
|
201
208
|
export function serialiseLockEntry(e: LockEntry): string {
|
|
202
|
-
|
|
209
|
+
return [
|
|
203
210
|
'---',
|
|
204
211
|
`entry: ${e.entry}`,
|
|
205
212
|
`action: ${e.action}`,
|
|
@@ -209,18 +216,8 @@ export function serialiseLockEntry(e: LockEntry): string {
|
|
|
209
216
|
'---',
|
|
210
217
|
'',
|
|
211
218
|
`Lock held for \`${e.entry}\` (${e.action}/${e.state}).`,
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
lines.push('', LOCK_REASON_HEADING, '', ...e.reason.split('\n'));
|
|
215
|
-
}
|
|
216
|
-
if (e.state === 'stuck' && e.questions && e.questions.length > 0) {
|
|
217
|
-
lines.push('', LOCK_QUESTIONS_HEADING, '');
|
|
218
|
-
for (const q of e.questions) {
|
|
219
|
-
lines.push(`- ${q}`);
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
lines.push('');
|
|
223
|
-
return lines.join('\n');
|
|
219
|
+
'',
|
|
220
|
+
].join('\n');
|
|
224
221
|
}
|
|
225
222
|
|
|
226
223
|
/**
|
|
@@ -367,13 +364,33 @@ export interface ReleaseOptions {
|
|
|
367
364
|
export async function releaseItemLock(
|
|
368
365
|
opts: ReleaseOptions,
|
|
369
366
|
): Promise<ReleaseResult> {
|
|
370
|
-
const arbiter = opts.arbiter ?? 'origin';
|
|
371
|
-
const env = opts.env;
|
|
372
|
-
const cwd = opts.cwd;
|
|
373
367
|
if (!opts.item) {
|
|
374
368
|
return {outcome: 'error', entry: '', ref: '', message: 'missing item'};
|
|
375
369
|
}
|
|
376
|
-
|
|
370
|
+
return releaseLockEntry(
|
|
371
|
+
lockEntryFor(opts.item),
|
|
372
|
+
opts.cwd,
|
|
373
|
+
opts.arbiter ?? 'origin',
|
|
374
|
+
opts.env,
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* The ONE entry-keyed release core shared by BOTH the item-form path
|
|
380
|
+
* ({@link releaseItemLock}, which derives the `<entry>` through the namespace
|
|
381
|
+
* mapping) AND the LITERAL escape hatch ({@link releaseLiteralLockEntry}, which
|
|
382
|
+
* takes the `<entry>` verbatim). It fetches the lock refs, checks the ref, and
|
|
383
|
+
* performs the SAME leased delete (`--force-with-lease=<ref>:<cur>`) with the SAME
|
|
384
|
+
* `not-held` / `released` / `error` semantics — there is deliberately NO second
|
|
385
|
+
* delete mechanism (task `release-lock-entry-escape-hatch-and-literal-entry-reporting`).
|
|
386
|
+
* The ONLY difference between the two callers is HOW the `<entry>` is obtained.
|
|
387
|
+
*/
|
|
388
|
+
async function releaseLockEntry(
|
|
389
|
+
entry: string,
|
|
390
|
+
cwd: string,
|
|
391
|
+
arbiter: string,
|
|
392
|
+
env: NodeJS.ProcessEnv | undefined,
|
|
393
|
+
): Promise<ReleaseResult> {
|
|
377
394
|
const ref = itemLockRef(entry);
|
|
378
395
|
try {
|
|
379
396
|
await gitHard(
|
|
@@ -426,6 +443,55 @@ export async function releaseItemLock(
|
|
|
426
443
|
}
|
|
427
444
|
}
|
|
428
445
|
|
|
446
|
+
export interface ReleaseLiteralOptions {
|
|
447
|
+
/**
|
|
448
|
+
* The LITERAL lock-entry name (`<type>-<slug>`, or a pre-cutover
|
|
449
|
+
* `slice-<slug>` / `prd-<slug>`) to release VERBATIM — taken as-is, bypassing
|
|
450
|
+
* the namespace mapping {@link lockEntryFor} applies. MUST pass
|
|
451
|
+
* {@link isValidLockEntryName}.
|
|
452
|
+
*/
|
|
453
|
+
entry: string;
|
|
454
|
+
cwd: string;
|
|
455
|
+
arbiter?: string;
|
|
456
|
+
env?: NodeJS.ProcessEnv;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* The `release-lock --entry <literal>` ESCAPE HATCH (task
|
|
461
|
+
* `release-lock-entry-escape-hatch-and-literal-entry-reporting`): release a lock
|
|
462
|
+
* whose `<entry>` name is NOT derivable from any CURRENT item-form — a lock minted
|
|
463
|
+
* BEFORE the slice→task / `prd-to-spec` vocabulary cutover (`slice-<slug>`,
|
|
464
|
+
* `prd-<slug>`), which the item-form path can no longer name because there is no
|
|
465
|
+
* item-form that produces those entries anymore.
|
|
466
|
+
*
|
|
467
|
+
* It takes the `<entry>` LITERALLY (bypassing {@link lockEntryFor} /
|
|
468
|
+
* {@link resolveSidecarIdentity}, the namespace mapping the item-form path uses)
|
|
469
|
+
* and targets `refs/dorfl/lock/<entry>` directly, then reuses the SAME entry-keyed
|
|
470
|
+
* leased-delete core {@link releaseItemLock} uses ({@link releaseLockEntry}) — the
|
|
471
|
+
* SAME lock-lease acquisition, push, absent-is-success no-op, exit codes, and
|
|
472
|
+
* mirror handling. There is NO second delete path.
|
|
473
|
+
*
|
|
474
|
+
* The trust model is UNCHANGED: a human still asserts the lock is dead by NAMING
|
|
475
|
+
* it; the only thing `--entry` drops is the assumption that the entry name is
|
|
476
|
+
* derivable from a current item-form. Rejects (`error`) an `<entry>` that fails
|
|
477
|
+
* {@link isValidLockEntryName} BEFORE any git operation, so a literal can never
|
|
478
|
+
* escape the `refs/dorfl/lock/` namespace.
|
|
479
|
+
*/
|
|
480
|
+
export async function releaseLiteralLockEntry(
|
|
481
|
+
opts: ReleaseLiteralOptions,
|
|
482
|
+
): Promise<ReleaseResult> {
|
|
483
|
+
const entry = opts.entry ?? '';
|
|
484
|
+
if (!isValidLockEntryName(entry)) {
|
|
485
|
+
return {
|
|
486
|
+
outcome: 'error',
|
|
487
|
+
entry,
|
|
488
|
+
ref: '',
|
|
489
|
+
message: `invalid --entry '${entry}': a lock-entry name must be a non-empty run of [A-Za-z0-9._-] (no slashes, no whitespace) so it cannot escape the ${LOCK_REF_PREFIX}/ namespace.`,
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
return releaseLockEntry(entry, opts.cwd, opts.arbiter ?? 'origin', opts.env);
|
|
493
|
+
}
|
|
494
|
+
|
|
429
495
|
/** Outcome of a GUARDED release ({@link releaseHeldItemLock}): the caller HELD the
|
|
430
496
|
* lock, so an absent ref is NOT benign — it is `vanished` (the ref was deleted
|
|
431
497
|
* under us, an abort signal). */
|
|
@@ -603,35 +669,25 @@ async function leasedDeleteLockRef(
|
|
|
603
669
|
return 'deleted';
|
|
604
670
|
}
|
|
605
671
|
|
|
672
|
+
/**
|
|
673
|
+
* COMPATIBILITY SHIM (post `retire-stuck-lock-state`): the `stuck` lock STATE
|
|
674
|
+
* is retired — there is no `active → stuck` amend to perform any more, and no
|
|
675
|
+
* live path calls this. It is kept exported as a NO-OP that returns success
|
|
676
|
+
* (`transitioned` when a held entry is present, `not-held` otherwise) so
|
|
677
|
+
* downstream test suites that USED to seed a "stuck" scenario via this
|
|
678
|
+
* primitive continue to build. A shim'd `stuck` lock does NOT actually flip
|
|
679
|
+
* state; the lock stays `active` (which is the only admitted state) and any
|
|
680
|
+
* assertion that keys off `state === 'stuck'` is now vacuously false — the
|
|
681
|
+
* point of the migration.
|
|
682
|
+
*/
|
|
606
683
|
export interface MarkStuckOptions {
|
|
607
|
-
/** The NAMESPACED item identity (same forms as {@link AcquireOptions.item}). */
|
|
608
684
|
item: string;
|
|
609
|
-
|
|
610
|
-
reason: string;
|
|
611
|
-
/**
|
|
612
|
-
* Any agent-surfaced QUESTIONS for the human, recorded on the stuck entry's
|
|
613
|
-
* body (the lock is the SOLE stuck record now). Optional; empty/absent ⇒ no
|
|
614
|
-
* questions block. A future advance-surface rung renders these into a
|
|
615
|
-
* `work/questions/` sidecar.
|
|
616
|
-
*/
|
|
685
|
+
reason?: string;
|
|
617
686
|
questions?: string[];
|
|
618
687
|
cwd: string;
|
|
619
688
|
arbiter?: string;
|
|
620
689
|
env?: NodeJS.ProcessEnv;
|
|
621
690
|
}
|
|
622
|
-
|
|
623
|
-
/**
|
|
624
|
-
* mark-stuck (transition 2): `[action, active] -> [action, stuck] + reason`. The
|
|
625
|
-
* runner bounces (red gate, agent failure, decomposition-unclear). A leased CAS
|
|
626
|
-
* amend of the SAME entry's `state` + `reason`, keeping `action`/`holder`/`since`.
|
|
627
|
-
* It is the source of the needs-attention SURFACE (now read from the lock ref,
|
|
628
|
-
* not a `work/needs-attention/` folder).
|
|
629
|
-
*
|
|
630
|
-
* PRECONDITIONS (the state machine + invariants):
|
|
631
|
-
* - the entry must be HELD and `active` (`not-held` / `wrong-state` otherwise) —
|
|
632
|
-
* stuck is reachable only FROM active, never from absent or already-stuck.
|
|
633
|
-
* - `reason` must be non-empty (the `reason` PRESENT iff `state: stuck` invariant).
|
|
634
|
-
*/
|
|
635
691
|
export async function markStuckItemLock(
|
|
636
692
|
opts: MarkStuckOptions,
|
|
637
693
|
): Promise<TransitionResult> {
|
|
@@ -643,14 +699,6 @@ export async function markStuckItemLock(
|
|
|
643
699
|
}
|
|
644
700
|
const entry = lockEntryFor(opts.item);
|
|
645
701
|
const ref = itemLockRef(entry);
|
|
646
|
-
if (!opts.reason || opts.reason.trim() === '') {
|
|
647
|
-
return {
|
|
648
|
-
outcome: 'error',
|
|
649
|
-
entry,
|
|
650
|
-
ref,
|
|
651
|
-
message: 'mark-stuck requires a reason (reason iff stuck)',
|
|
652
|
-
};
|
|
653
|
-
}
|
|
654
702
|
try {
|
|
655
703
|
const held = await fetchHeldEntry(entry, ref, cwd, arbiter, env);
|
|
656
704
|
if (!held) {
|
|
@@ -658,31 +706,16 @@ export async function markStuckItemLock(
|
|
|
658
706
|
outcome: 'not-held',
|
|
659
707
|
entry,
|
|
660
708
|
ref,
|
|
661
|
-
message: `'${entry}' not locked`,
|
|
709
|
+
message: `'${entry}' not locked (mark-stuck is a no-op shim post retire-stuck-lock-state)`,
|
|
662
710
|
};
|
|
663
711
|
}
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
};
|
|
671
|
-
}
|
|
672
|
-
const next: LockEntry = {
|
|
673
|
-
...held.lock,
|
|
674
|
-
state: 'stuck',
|
|
675
|
-
reason: opts.reason.trim(),
|
|
712
|
+
return {
|
|
713
|
+
outcome: 'transitioned',
|
|
714
|
+
entry,
|
|
715
|
+
ref,
|
|
716
|
+
message: `'${entry}' left active (mark-stuck is a no-op shim post retire-stuck-lock-state; a parked item is now a needsAnswers:true sidecar on main)`,
|
|
717
|
+
lock: held.lock,
|
|
676
718
|
};
|
|
677
|
-
const questions = (opts.questions ?? [])
|
|
678
|
-
.map((q) => q.trim())
|
|
679
|
-
.filter((q) => q !== '');
|
|
680
|
-
if (questions.length > 0) {
|
|
681
|
-
next.questions = questions;
|
|
682
|
-
} else {
|
|
683
|
-
delete next.questions;
|
|
684
|
-
}
|
|
685
|
-
return await amendHeldEntry(next, ref, held.sha, cwd, arbiter, env);
|
|
686
719
|
} catch (err) {
|
|
687
720
|
return {
|
|
688
721
|
outcome: 'error',
|
|
@@ -704,14 +737,19 @@ export interface ResumeOptions {
|
|
|
704
737
|
}
|
|
705
738
|
|
|
706
739
|
/**
|
|
707
|
-
* resume (
|
|
708
|
-
* `
|
|
709
|
-
*
|
|
710
|
-
*
|
|
711
|
-
* lock
|
|
712
|
-
*
|
|
713
|
-
*
|
|
714
|
-
*
|
|
740
|
+
* resume (crash-orphan recovery only, post `retire-stuck-lock-state`): with the
|
|
741
|
+
* `stuck` lock state retired, the ONLY thing to "resume" is the crash-window
|
|
742
|
+
* orphan the ordered bounce transition (surface-FIRST-release-SECOND) leaves
|
|
743
|
+
* when the surface lands on `<arbiter>/main` but the release never runs. If the
|
|
744
|
+
* held lock is `active` AND the item is SURFACED on `<arbiter>/main`
|
|
745
|
+
* (`needsAnswers:true` + sidecar) AND not terminal, `main` is authoritative:
|
|
746
|
+
* clear the ref via the SHARED leased delete (never `--force`) so the lock
|
|
747
|
+
* converges rather than dangling forever. Every OTHER combination is a no-op
|
|
748
|
+
* from this verb's perspective: an in-flight active hold that is NOT surfaced
|
|
749
|
+
* is a healthy build (`wrong-state` — nothing to resume); no lock is
|
|
750
|
+
* `not-held`. There is no `stuck → active` transition anymore: a parked item
|
|
751
|
+
* is a `needsAnswers:true` pool item on `main`, drained by answering the
|
|
752
|
+
* sidecar (via the apply rung), not by "resuming" a lock.
|
|
715
753
|
*/
|
|
716
754
|
export async function resumeItemLock(
|
|
717
755
|
opts: ResumeOptions,
|
|
@@ -734,23 +772,49 @@ export async function resumeItemLock(
|
|
|
734
772
|
message: `'${entry}' not locked`,
|
|
735
773
|
};
|
|
736
774
|
}
|
|
737
|
-
|
|
775
|
+
const {type, slug} = resolveSidecarIdentity(opts.item);
|
|
776
|
+
// Refresh `<arbiter>/main` so the surfaced-on-main probe reads the live
|
|
777
|
+
// snapshot rather than a stale local tracking ref.
|
|
778
|
+
await gitSoft(['fetch', '--quiet', arbiter], cwd, env);
|
|
779
|
+
const terminalOnMain = await isTerminalOnMain(
|
|
780
|
+
type,
|
|
781
|
+
slug,
|
|
782
|
+
arbiter,
|
|
783
|
+
cwd,
|
|
784
|
+
env,
|
|
785
|
+
);
|
|
786
|
+
if (
|
|
787
|
+
!terminalOnMain &&
|
|
788
|
+
(await isItemSurfacedOnMain(type, slug, opts.item, arbiter, cwd, env))
|
|
789
|
+
) {
|
|
790
|
+
const cleared = await leasedDeleteLockRef(
|
|
791
|
+
ref,
|
|
792
|
+
held.sha,
|
|
793
|
+
cwd,
|
|
794
|
+
arbiter,
|
|
795
|
+
env,
|
|
796
|
+
);
|
|
797
|
+
if (cleared === 'deleted') {
|
|
798
|
+
return {
|
|
799
|
+
outcome: 'transitioned',
|
|
800
|
+
entry,
|
|
801
|
+
ref,
|
|
802
|
+
message: `cleared the crash-window orphan lock for '${entry}' (item is SURFACED on ${arbiter}/main via needsAnswers + sidecar; the surface landed but the release never ran) — answer the question sidecar to drain it.`,
|
|
803
|
+
};
|
|
804
|
+
}
|
|
738
805
|
return {
|
|
739
|
-
outcome: '
|
|
806
|
+
outcome: 'lost',
|
|
740
807
|
entry,
|
|
741
808
|
ref,
|
|
742
|
-
message: `'${entry}'
|
|
809
|
+
message: `'${entry}' crash-orphan clear lost the CAS race (concurrent writer); back off and re-run.`,
|
|
743
810
|
};
|
|
744
811
|
}
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
812
|
+
return {
|
|
813
|
+
outcome: 'not-held',
|
|
814
|
+
entry,
|
|
815
|
+
ref,
|
|
816
|
+
message: `'${entry}' is active and not surfaced on ${arbiter}/main — nothing to resume (a healthy in-flight hold; a parked item is drained via its needsAnswers sidecar).`,
|
|
749
817
|
};
|
|
750
|
-
// reason + questions are PRESENT iff stuck: drop them on the way to active.
|
|
751
|
-
delete next.reason;
|
|
752
|
-
delete next.questions;
|
|
753
|
-
return await amendHeldEntry(next, ref, held.sha, cwd, arbiter, env);
|
|
754
818
|
} catch (err) {
|
|
755
819
|
return {
|
|
756
820
|
outcome: 'error',
|
|
@@ -762,15 +826,13 @@ export async function resumeItemLock(
|
|
|
762
826
|
}
|
|
763
827
|
|
|
764
828
|
/**
|
|
765
|
-
* requeue
|
|
766
|
-
*
|
|
767
|
-
*
|
|
768
|
-
*
|
|
769
|
-
*
|
|
770
|
-
*
|
|
771
|
-
*
|
|
772
|
-
* `active` entry — abandoning an in-flight active hold goes through `release`, not
|
|
773
|
-
* `requeue`. The removal itself is a leased delete (a concurrent change ⇒ `lost`).
|
|
829
|
+
* requeue: give up on a held lock and return the item to the pool by REMOVING
|
|
830
|
+
* the entry. Post-CONTRACT step (task `retire-stuck-lock-state`) there is NO
|
|
831
|
+
* `stuck` state to guard against — the held lock is always `active`, so
|
|
832
|
+
* requeue works on any held entry (a leased delete; a concurrent change ⇒
|
|
833
|
+
* `lost`). The body never moved (it rests in the pool on `main`), so requeue
|
|
834
|
+
* is purely "release the lock"; the kept `work/<slug>` branch remains for
|
|
835
|
+
* recovery.
|
|
774
836
|
*/
|
|
775
837
|
export async function requeueItemLock(
|
|
776
838
|
opts: ReleaseOptions,
|
|
@@ -793,14 +855,6 @@ export async function requeueItemLock(
|
|
|
793
855
|
message: `'${entry}' not locked`,
|
|
794
856
|
};
|
|
795
857
|
}
|
|
796
|
-
if (held.lock.state !== 'stuck') {
|
|
797
|
-
return {
|
|
798
|
-
outcome: 'wrong-state',
|
|
799
|
-
entry,
|
|
800
|
-
ref,
|
|
801
|
-
message: `'${entry}' is ${held.lock.state}, not stuck; use release to abort an active hold`,
|
|
802
|
-
};
|
|
803
|
-
}
|
|
804
858
|
const del = await gitSoft(
|
|
805
859
|
[
|
|
806
860
|
'push',
|
|
@@ -896,11 +950,15 @@ export function terminalMainPaths(type: SidecarType, slug: string): string[] {
|
|
|
896
950
|
|
|
897
951
|
/** The outcome of a cross-substrate reconciliation of one item's lock against
|
|
898
952
|
* the authoritative `main` durable record (spec `ledger-status-per-item-lock-refs`
|
|
899
|
-
* US #9/#10; ADR `ledger-status-on-per-item-lock-refs`).
|
|
953
|
+
* US #9/#10; ADR `ledger-status-on-per-item-lock-refs`). Post-CONTRACT step
|
|
954
|
+
* (task `retire-stuck-lock-state`) the two `stuck`-flavoured outcomes
|
|
955
|
+
* (`cleared-stuck-terminal`, `kept-stuck`) are gone with the state itself; a
|
|
956
|
+
* parked item is now a `needsAnswers:true` pool item on `main`, so the
|
|
957
|
+
* `cleared-stale` verdict now ALSO covers the crash-window orphan (active +
|
|
958
|
+
* surfaced-on-main). */
|
|
900
959
|
export type ReconcileOutcome =
|
|
901
|
-
| 'cleared-stale' // `main` is terminal + the lock was `active` (stranded) → cleared
|
|
902
|
-
| 'kept-
|
|
903
|
-
| 'kept-in-flight' // `main` is NOT terminal + a lock is held → the normal in-flight state, kept
|
|
960
|
+
| 'cleared-stale' // `main` is terminal OR surfaced-on-main + the lock was `active` (stranded/orphan) → cleared
|
|
961
|
+
| 'kept-in-flight' // `main` is NOT terminal + a lock is held `active` → the normal in-flight state, kept
|
|
904
962
|
| 'no-lock' // there is no lock to reconcile (already at rest)
|
|
905
963
|
| 'error'; // environment/usage problem (best-effort; never throws)
|
|
906
964
|
|
|
@@ -927,17 +985,23 @@ export interface ReconcileResult {
|
|
|
927
985
|
* per {@link terminalMainPaths}) with a STILL-HELD lock — a stale lock with no
|
|
928
986
|
* in-flight work behind it. This is the recovery that converges it.
|
|
929
987
|
*
|
|
930
|
-
* THE RECOVERY RULE (the `main` record is authoritative over
|
|
931
|
-
*
|
|
932
|
-
*
|
|
933
|
-
* - `main` is TERMINAL + the held lock is `
|
|
934
|
-
*
|
|
935
|
-
*
|
|
936
|
-
*
|
|
937
|
-
*
|
|
938
|
-
*
|
|
988
|
+
* THE RECOVERY RULE (the `main` record is authoritative over an ORPHAN lock,
|
|
989
|
+
* broadened by task `reaper-reap-terminal-stuck-lock-orphans`; ADR
|
|
990
|
+
* `ledger-status-on-per-item-lock-refs` § Addendum 2026-07-10):
|
|
991
|
+
* - `main` is TERMINAL + the held lock is `active` → STRANDED (the crash was
|
|
992
|
+
* after the durable move, before the release) → CLEAR it (`cleared-stale`).
|
|
993
|
+
* - `main` is TERMINAL + the held lock is `stuck` → CRASH-ORPHAN (`done` +
|
|
994
|
+
* `stuck` LEGITIMATELY co-existed during a rebase-conflict bounce, US #10,
|
|
995
|
+
* but the item then reached its terminal folder by ANY path — human finish,
|
|
996
|
+
* re-drive, manual fixup+merge — leaving the stuck lock as an orphan the
|
|
997
|
+
* `main` record supersedes) → CLEAR it (`cleared-stuck-terminal`).
|
|
998
|
+
* - `main` is NOT terminal + the held lock is `stuck` → the GENUINE
|
|
999
|
+
* human-attention case (`kept-stuck`); NEVER auto-cleared here (a human
|
|
1000
|
+
* resolves via `resume`/`requeue`/`release-lock`). This is the invariant
|
|
1001
|
+
* the contract loosening MUST preserve.
|
|
1002
|
+
* - `main` is NOT terminal + the held lock is `active` → the NORMAL in-flight
|
|
939
1003
|
* state (`kept-in-flight`); the lock is doing its job, leave it.
|
|
940
|
-
* - no lock at all
|
|
1004
|
+
* - no lock at all → `no-lock` (at rest).
|
|
941
1005
|
*
|
|
942
1006
|
* Best-effort + idempotent: it NEVER throws (a fetch/read fault degrades to
|
|
943
1007
|
* `error`, leaving the lock untouched — the safe direction), and re-running it
|
|
@@ -1019,7 +1083,61 @@ export async function reconcileItemLockAgainstMain(
|
|
|
1019
1083
|
};
|
|
1020
1084
|
}
|
|
1021
1085
|
if (!terminalOnMain) {
|
|
1022
|
-
//
|
|
1086
|
+
// Post-`retire-stuck-lock-state`: the held lock is ALWAYS `active`. The
|
|
1087
|
+
// split is now solely on whether the item is SURFACED on `<arbiter>/main`
|
|
1088
|
+
// (`needsAnswers:true` + sidecar):
|
|
1089
|
+
// - surfaced ⇒ the CRASH-WINDOW ORPHAN the ordered bounce transition
|
|
1090
|
+
// (surface-FIRST-release-SECOND) leaves when step 1 lands but step 2
|
|
1091
|
+
// never runs. `main` is authoritative — CLEAR via the SHARED leased
|
|
1092
|
+
// delete (`cleared-stale`).
|
|
1093
|
+
// - not surfaced ⇒ the NORMAL in-flight hold (`kept-in-flight`).
|
|
1094
|
+
const surfaced = await isItemSurfacedOnMain(
|
|
1095
|
+
type,
|
|
1096
|
+
slug,
|
|
1097
|
+
opts.item,
|
|
1098
|
+
arbiter,
|
|
1099
|
+
cwd,
|
|
1100
|
+
env,
|
|
1101
|
+
);
|
|
1102
|
+
if (surfaced) {
|
|
1103
|
+
const cleared = await leasedDeleteLockRef(
|
|
1104
|
+
ref,
|
|
1105
|
+
held.sha,
|
|
1106
|
+
cwd,
|
|
1107
|
+
arbiter,
|
|
1108
|
+
env,
|
|
1109
|
+
);
|
|
1110
|
+
if (cleared === 'deleted') {
|
|
1111
|
+
return {
|
|
1112
|
+
outcome: 'cleared-stale',
|
|
1113
|
+
entry,
|
|
1114
|
+
ref,
|
|
1115
|
+
terminalOnMain,
|
|
1116
|
+
message: `cleared the crash-window orphan lock for '${entry}' (item is SURFACED on ${arbiter}/main via needsAnswers:true + sidecar; the surface landed but the release never ran)`,
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
// Leased delete rejected: fall through to the shared rejection
|
|
1120
|
+
// arm below (same distinguish-then-report shape).
|
|
1121
|
+
const remote = await gitSoft(['ls-remote', arbiter, ref], cwd, env);
|
|
1122
|
+
const remoteEmpty = remote.status === 0 && remote.stdout.trim() === '';
|
|
1123
|
+
if (remoteEmpty) {
|
|
1124
|
+
await gitSoft(['update-ref', '-d', ref], cwd, env);
|
|
1125
|
+
return {
|
|
1126
|
+
outcome: 'no-lock',
|
|
1127
|
+
entry,
|
|
1128
|
+
ref,
|
|
1129
|
+
terminalOnMain,
|
|
1130
|
+
message: `'${entry}' has no lock to reconcile (already cleared by another reaper / release-lock / requeue)`,
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
return {
|
|
1134
|
+
outcome: 'error',
|
|
1135
|
+
entry,
|
|
1136
|
+
ref,
|
|
1137
|
+
terminalOnMain,
|
|
1138
|
+
message: `crash-orphan clear for '${entry}' rejected (changed concurrently to a different value); a racer may have moved the ref. Re-run after re-checking.`,
|
|
1139
|
+
};
|
|
1140
|
+
}
|
|
1023
1141
|
return {
|
|
1024
1142
|
outcome: 'kept-in-flight',
|
|
1025
1143
|
entry,
|
|
@@ -1028,21 +1146,8 @@ export async function reconcileItemLockAgainstMain(
|
|
|
1028
1146
|
message: `'${entry}' is in flight (held, not terminal on ${arbiter}/main)`,
|
|
1029
1147
|
};
|
|
1030
1148
|
}
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
// NOT corruption. Keep the stuck lock (it wins the human's attention).
|
|
1034
|
-
return {
|
|
1035
|
-
outcome: 'kept-stuck',
|
|
1036
|
-
entry,
|
|
1037
|
-
ref,
|
|
1038
|
-
terminalOnMain,
|
|
1039
|
-
message: `'${entry}' is terminal on ${arbiter}/main but STUCK — kept for human attention (resume/requeue/release-lock)`,
|
|
1040
|
-
};
|
|
1041
|
-
}
|
|
1042
|
-
// Terminal on `main` + an `active` lock = a STALE lock (the crash was after
|
|
1043
|
-
// the durable move, before the release). The `main` record is authoritative:
|
|
1044
|
-
// clear the stale lock with the SHARED leased delete (the SAME one
|
|
1045
|
-
// `release-lock` / requeue / the reaper use).
|
|
1149
|
+
// Terminal on `main` + a held (active) lock = an ORPHAN over a
|
|
1150
|
+
// durably-completed item. Clear via the SHARED leased delete.
|
|
1046
1151
|
const cleared = await leasedDeleteLockRef(ref, held.sha, cwd, arbiter, env);
|
|
1047
1152
|
if (cleared === 'deleted') {
|
|
1048
1153
|
return {
|
|
@@ -1159,6 +1264,27 @@ export async function classifyItemLockAgainstMain(
|
|
|
1159
1264
|
};
|
|
1160
1265
|
}
|
|
1161
1266
|
if (!terminalOnMain) {
|
|
1267
|
+
// Post-`retire-stuck-lock-state`: the held lock is ALWAYS `active`. If the
|
|
1268
|
+
// item is SURFACED on `<arbiter>/main` (`needsAnswers:true` + sidecar),
|
|
1269
|
+
// the lock is a CRASH-WINDOW ORPHAN (reconcilable, NOT auto-cleared by
|
|
1270
|
+
// the report); otherwise it is the normal in-flight hold.
|
|
1271
|
+
const surfaced = await isItemSurfacedOnMain(
|
|
1272
|
+
type,
|
|
1273
|
+
slug,
|
|
1274
|
+
opts.item,
|
|
1275
|
+
arbiter,
|
|
1276
|
+
cwd,
|
|
1277
|
+
env,
|
|
1278
|
+
);
|
|
1279
|
+
if (surfaced) {
|
|
1280
|
+
return {
|
|
1281
|
+
outcome: 'cleared-stale',
|
|
1282
|
+
entry,
|
|
1283
|
+
ref,
|
|
1284
|
+
terminalOnMain,
|
|
1285
|
+
message: `'${entry}' is a CRASH-WINDOW ORPHAN — SURFACED on ${arbiter}/main (needsAnswers:true + sidecar) but the release never ran; reconcilable, auto-reapable by 'gc --ledger --reap-stale-locks' (NOT auto-cleared by the report)`,
|
|
1286
|
+
};
|
|
1287
|
+
}
|
|
1162
1288
|
return {
|
|
1163
1289
|
outcome: 'kept-in-flight',
|
|
1164
1290
|
entry,
|
|
@@ -1167,24 +1293,16 @@ export async function classifyItemLockAgainstMain(
|
|
|
1167
1293
|
message: `'${entry}' is in flight (held, not terminal on ${arbiter}/main)`,
|
|
1168
1294
|
};
|
|
1169
1295
|
}
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
ref,
|
|
1175
|
-
terminalOnMain,
|
|
1176
|
-
message: `'${entry}' is terminal on ${arbiter}/main but STUCK — kept for human attention (resume/requeue/release-lock)`,
|
|
1177
|
-
};
|
|
1178
|
-
}
|
|
1179
|
-
// Terminal on `main` + an `active` lock = a STALE lock. Unlike
|
|
1180
|
-
// `reconcileItemLockAgainstMain` we do NOT clear it here — the report only
|
|
1181
|
-
// names it as reconcilable; the human asserts the clear (no auto-sweep).
|
|
1296
|
+
// Terminal on `main` + a held (active) lock = an ORPHAN over a
|
|
1297
|
+
// durably-completed item. Unlike `reconcileItemLockAgainstMain` we do NOT
|
|
1298
|
+
// clear it here — the report only names it as reconcilable; the reaper (or
|
|
1299
|
+
// a human) asserts the clear (no auto-sweep from the report path).
|
|
1182
1300
|
return {
|
|
1183
1301
|
outcome: 'cleared-stale',
|
|
1184
1302
|
entry,
|
|
1185
1303
|
ref,
|
|
1186
1304
|
terminalOnMain,
|
|
1187
|
-
message: `'${entry}' is terminal on ${arbiter}/main
|
|
1305
|
+
message: `'${entry}' is terminal on ${arbiter}/main — reconcilable (stale); auto-reapable by 'gc --ledger --reap-stale-locks' (NOT auto-cleared by the report)`,
|
|
1188
1306
|
};
|
|
1189
1307
|
} catch (err) {
|
|
1190
1308
|
return {
|
|
@@ -1197,28 +1315,34 @@ export async function classifyItemLockAgainstMain(
|
|
|
1197
1315
|
}
|
|
1198
1316
|
}
|
|
1199
1317
|
|
|
1200
|
-
/** One lingering lock in the `gc --ledger`
|
|
1201
|
-
* the read-only cross-substrate {@link ReconcileOutcome} classification of
|
|
1202
|
-
* against the authoritative `main` durable record
|
|
1203
|
-
*
|
|
1318
|
+
/** One lingering lock in the `gc --ledger` orphaned-lock REPORT: the held entry
|
|
1319
|
+
* plus the read-only cross-substrate {@link ReconcileOutcome} classification of
|
|
1320
|
+
* it against the authoritative `main` durable record. Reported, NEVER cleared
|
|
1321
|
+
* by the report itself. */
|
|
1204
1322
|
export interface LockReportEntry {
|
|
1205
|
-
/** The full held lock entry (action × state + holder/since
|
|
1323
|
+
/** The full held lock entry (action × state + holder/since). */
|
|
1206
1324
|
lock: LockEntry;
|
|
1207
1325
|
/** The lock ref (`refs/dorfl/lock/<entry>`). */
|
|
1208
1326
|
ref: string;
|
|
1209
1327
|
/**
|
|
1210
1328
|
* The READ-ONLY {@link classifyItemLockAgainstMain} verdict:
|
|
1211
|
-
* - `kept-in-flight` — a normal in-flight hold (not terminal on `main`).
|
|
1212
|
-
* - `
|
|
1213
|
-
*
|
|
1214
|
-
*
|
|
1215
|
-
*
|
|
1216
|
-
* auto-cleared by the report — no auto-sweep).
|
|
1329
|
+
* - `kept-in-flight` — a normal in-flight hold (active, not terminal on `main`).
|
|
1330
|
+
* - `cleared-stale` — the lock is reconcilable: terminal on `main` (stranded)
|
|
1331
|
+
* OR non-terminal + SURFACED (`needsAnswers:true` + sidecar) = crash-window
|
|
1332
|
+
* orphan; a reaper (or `release-lock`) clears it (NOT auto-cleared by the
|
|
1333
|
+
* report — no auto-sweep here).
|
|
1217
1334
|
* - `error` — a per-item classification fault (kept verbatim).
|
|
1218
1335
|
*/
|
|
1219
1336
|
reconcile: ReconcileOutcome;
|
|
1220
1337
|
}
|
|
1221
1338
|
|
|
1339
|
+
/** True iff a {@link ReconcileOutcome} names an ORPHAN class the
|
|
1340
|
+
* `--reap-stale-locks` sweep can auto-clear via the shared leased delete.
|
|
1341
|
+
* Post-`retire-stuck-lock-state` this is just `cleared-stale`. */
|
|
1342
|
+
export function isReapableTerminalOrphan(outcome: ReconcileOutcome): boolean {
|
|
1343
|
+
return outcome === 'cleared-stale';
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1222
1346
|
/** The `gc --ledger` stuck/orphaned-lock REPORT (spec
|
|
1223
1347
|
* `ledger-status-per-item-lock-refs` US #14): every lingering per-item lock on the
|
|
1224
1348
|
* arbiter, each classified read-only against `main`. An EMPTY list = no locks held
|
|
@@ -1293,9 +1417,7 @@ export async function reportItemLocks(
|
|
|
1293
1417
|
* every held lock, in-flight ones informationally).
|
|
1294
1418
|
*/
|
|
1295
1419
|
export function itemLockReportNeedsAttention(report: ItemLockReport): boolean {
|
|
1296
|
-
return report.locks.some(
|
|
1297
|
-
(l) => l.reconcile === 'kept-stuck' || l.reconcile === 'cleared-stale',
|
|
1298
|
-
);
|
|
1420
|
+
return report.locks.some((l) => isReapableTerminalOrphan(l.reconcile));
|
|
1299
1421
|
}
|
|
1300
1422
|
|
|
1301
1423
|
export function formatItemLockReport(report: ItemLockReport): string[] {
|
|
@@ -1308,18 +1430,28 @@ export function formatItemLockReport(report: ItemLockReport): string[] {
|
|
|
1308
1430
|
'a human asserts a stuck/stale lock is dead via `release-lock`):',
|
|
1309
1431
|
];
|
|
1310
1432
|
for (const {lock, reconcile} of report.locks) {
|
|
1311
|
-
const item = itemFromLockEntry(lock.entry);
|
|
1312
1433
|
lines.push(` ${lock.entry} [${lock.action}/${lock.state}]`);
|
|
1313
1434
|
lines.push(
|
|
1314
1435
|
` holder: ${lock.holder || '(unknown)'} since: ${lock.since || '(unknown)'}`,
|
|
1315
1436
|
);
|
|
1316
|
-
if (lock.state === 'stuck' && lock.reason) {
|
|
1317
|
-
lines.push(` reason: ${lock.reason}`);
|
|
1318
|
-
}
|
|
1319
1437
|
lines.push(` ${reconcileNote(reconcile)}`);
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
)
|
|
1438
|
+
// The copy-pasteable clear hint. An entry that reverse-derives to a CURRENT
|
|
1439
|
+
// item-form (`task-`/`spec-`/`observation-`) points at `release-lock <item>`.
|
|
1440
|
+
// A PRE-CUTOVER entry (`slice-<slug>` / `prd-<slug>`) has NO current item-form,
|
|
1441
|
+
// so it is UN-NAMEABLE that way — print ONLY the literal entry name and hint at
|
|
1442
|
+
// the `release-lock --entry <literal>` escape hatch (task
|
|
1443
|
+
// `release-lock-entry-escape-hatch-and-literal-entry-reporting`).
|
|
1444
|
+
if (hasCurrentItemForm(lock.entry)) {
|
|
1445
|
+
lines.push(
|
|
1446
|
+
` resolve (if the lock is dead): \`dorfl release-lock ${itemFromLockEntry(
|
|
1447
|
+
lock.entry,
|
|
1448
|
+
)}\` (never --force).`,
|
|
1449
|
+
);
|
|
1450
|
+
} else {
|
|
1451
|
+
lines.push(
|
|
1452
|
+
` # no current item-form; clear with: dorfl release-lock --entry ${lock.entry}`,
|
|
1453
|
+
);
|
|
1454
|
+
}
|
|
1323
1455
|
}
|
|
1324
1456
|
return lines;
|
|
1325
1457
|
}
|
|
@@ -1329,10 +1461,8 @@ function reconcileNote(reconcile: ReconcileOutcome): string {
|
|
|
1329
1461
|
switch (reconcile) {
|
|
1330
1462
|
case 'kept-in-flight':
|
|
1331
1463
|
return 'in flight (held, not terminal on main) — normal; left untouched.';
|
|
1332
|
-
case 'kept-stuck':
|
|
1333
|
-
return 'terminal on main + STUCK (done+stuck co-exist) — kept for human attention.';
|
|
1334
1464
|
case 'cleared-stale':
|
|
1335
|
-
return '
|
|
1465
|
+
return 'STALE / crash-window orphan (reconcilable) — auto-reapable by --reap-stale-locks (NOT auto-cleared by the report).';
|
|
1336
1466
|
case 'no-lock':
|
|
1337
1467
|
return 'no lock (already at rest).';
|
|
1338
1468
|
case 'error':
|
|
@@ -1340,25 +1470,21 @@ function reconcileNote(reconcile: ReconcileOutcome): string {
|
|
|
1340
1470
|
}
|
|
1341
1471
|
}
|
|
1342
1472
|
|
|
1343
|
-
/** Per-lock outcome of the human-invoked {@link reapStaleItemLocks} SWEEP
|
|
1344
|
-
*
|
|
1345
|
-
*
|
|
1473
|
+
/** Per-lock outcome of the human-invoked {@link reapStaleItemLocks} SWEEP.
|
|
1474
|
+
* Post-`retire-stuck-lock-state` the `stuck`-flavoured verdicts
|
|
1475
|
+
* (`reaped-stuck-terminal`, `kept-stuck`) are gone with the state itself.
|
|
1476
|
+
* - `reaped` — a `cleared-stale` lock cleared via the SHARED leased delete.
|
|
1346
1477
|
* - `already-reaped` — BENIGN: the lock was already gone by the time the sweep
|
|
1347
|
-
* re-read the ref (`no-lock`)
|
|
1348
|
-
*
|
|
1349
|
-
*
|
|
1350
|
-
*
|
|
1351
|
-
* in this task's done record).
|
|
1352
|
-
* - `kept-stuck` — left untouched (terminal + stuck — human attention; US #10).
|
|
1353
|
-
* - `kept-in-flight` — left untouched (active, non-terminal — a healthy build).
|
|
1354
|
-
* - `lost` — a `cleared-stale` candidate whose leased delete was REJECTED
|
|
1478
|
+
* re-read the ref (`no-lock`).
|
|
1479
|
+
* - `kept-in-flight` — left untouched (active, non-terminal, not surfaced — a
|
|
1480
|
+
* healthy build).
|
|
1481
|
+
* - `lost` — a reapable-orphan candidate whose leased delete was REJECTED
|
|
1355
1482
|
* (the ref changed concurrently to a DIFFERENT value);
|
|
1356
1483
|
* REPORTED, never `--force`d.
|
|
1357
1484
|
* - `error` — a per-item classification/clear fault (left untouched). */
|
|
1358
1485
|
export type ReapOutcome =
|
|
1359
1486
|
| 'reaped'
|
|
1360
1487
|
| 'already-reaped'
|
|
1361
|
-
| 'kept-stuck'
|
|
1362
1488
|
| 'kept-in-flight'
|
|
1363
1489
|
| 'lost'
|
|
1364
1490
|
| 'error';
|
|
@@ -1381,10 +1507,10 @@ export interface ReapEntry {
|
|
|
1381
1507
|
* race); a `kept-in-flight` is the normal healthy state and does NOT. */
|
|
1382
1508
|
export interface ReapReport {
|
|
1383
1509
|
entries: ReapEntry[];
|
|
1510
|
+
/** Count of `cleared-stale` locks reaped (terminal-on-main + active, OR
|
|
1511
|
+
* non-terminal + surfaced = crash-window orphan). */
|
|
1384
1512
|
reaped: number;
|
|
1385
|
-
/** BENIGN already-reaped count: the sweep found the ref already gone (`no-lock`)
|
|
1386
|
-
* — another reaper / release-lock / requeue cleared it first. The desired end
|
|
1387
|
-
* state; NOT `lost`, does NOT contribute to needs-attention / a non-zero exit. */
|
|
1513
|
+
/** BENIGN already-reaped count: the sweep found the ref already gone (`no-lock`). */
|
|
1388
1514
|
alreadyReaped: number;
|
|
1389
1515
|
kept: number;
|
|
1390
1516
|
lost: number;
|
|
@@ -1401,13 +1527,19 @@ export interface ReapReport {
|
|
|
1401
1527
|
* {@link reconcileItemLockAgainstMain} (the recovery's clear, re-checked fresh per
|
|
1402
1528
|
* item) — there is NO parallel clear mechanism.
|
|
1403
1529
|
*
|
|
1404
|
-
* SCOPE FENCE (the trust model the default preserves
|
|
1405
|
-
*
|
|
1406
|
-
*
|
|
1407
|
-
*
|
|
1408
|
-
*
|
|
1409
|
-
*
|
|
1410
|
-
*
|
|
1530
|
+
* SCOPE FENCE (the trust model the default preserves; broadened by task
|
|
1531
|
+
* `reaper-reap-terminal-stuck-lock-orphans`; ADR
|
|
1532
|
+
* `ledger-status-on-per-item-lock-refs` § Addendum 2026-07-10):
|
|
1533
|
+
* - it clears the TWO terminal-on-`main` ORPHAN classes ONLY: `cleared-stale`
|
|
1534
|
+
* (terminal + `active` = stranded between move and release) AND
|
|
1535
|
+
* `cleared-stuck-terminal` (terminal + `stuck` = crash-orphan the auto-reaper
|
|
1536
|
+
* used to leave forever). A `kept-stuck` (STUCK + NON-terminal — the
|
|
1537
|
+
* genuine human-attention case) and a `kept-in-flight` (`active` +
|
|
1538
|
+
* non-terminal — a healthy build) are NEVER reaped, even here. Because each
|
|
1539
|
+
* clear goes through {@link reconcileItemLockAgainstMain}, which RE-reads +
|
|
1540
|
+
* RE-classifies before deleting, a lock whose item was un-completed on
|
|
1541
|
+
* `main` between the report and the sweep is still safe (reconcile returns
|
|
1542
|
+
* `kept-*`, not a delete).
|
|
1411
1543
|
* - the clear is a LEASED delete: a concurrent change to the ref makes it REJECT
|
|
1412
1544
|
* (`lost`), reported — never a blind `--force`.
|
|
1413
1545
|
*
|
|
@@ -1428,10 +1560,11 @@ export async function reapStaleItemLocks(
|
|
|
1428
1560
|
let lost = 0;
|
|
1429
1561
|
for (const {lock, ref, reconcile} of report.locks) {
|
|
1430
1562
|
const item = itemFromLockEntry(lock.entry);
|
|
1431
|
-
if (reconcile
|
|
1563
|
+
if (isReapableTerminalOrphan(reconcile)) {
|
|
1432
1564
|
// Re-check + clear through the recovery's SHARED leased delete. Reconcile
|
|
1433
|
-
// re-reads the live ref, so a lock that turned
|
|
1434
|
-
// report is left alone; a concurrent change to the ref makes the lease
|
|
1565
|
+
// re-reads the live ref, so a lock that turned in-flight since the
|
|
1566
|
+
// report is left alone; a concurrent change to the ref makes the lease
|
|
1567
|
+
// lose.
|
|
1435
1568
|
const rec = await reconcileItemLockAgainstMain({
|
|
1436
1569
|
item,
|
|
1437
1570
|
cwd,
|
|
@@ -1442,13 +1575,6 @@ export async function reapStaleItemLocks(
|
|
|
1442
1575
|
reaped++;
|
|
1443
1576
|
entries.push({lock, ref, outcome: 'reaped', message: rec.message});
|
|
1444
1577
|
} else if (rec.outcome === 'no-lock') {
|
|
1445
|
-
// BENIGN: the ref is already gone — the desired end state. The LOSER of
|
|
1446
|
-
// a concurrent double-reap (another reaper deleted the ref between our
|
|
1447
|
-
// report and our re-read), or a `release-lock`/`requeue` that cleared
|
|
1448
|
-
// the same stale lock in the meantime. NOT a lost lease (the lease was
|
|
1449
|
-
// not REJECTED; there was simply nothing left to delete), so this does
|
|
1450
|
-
// NOT count as needs-attention. Kept SEPARATE from `reaped` so the
|
|
1451
|
-
// summary does not lie about who did the deleting.
|
|
1452
1578
|
alreadyReaped++;
|
|
1453
1579
|
entries.push({
|
|
1454
1580
|
lock,
|
|
@@ -1456,25 +1582,16 @@ export async function reapStaleItemLocks(
|
|
|
1456
1582
|
outcome: 'already-reaped',
|
|
1457
1583
|
message: rec.message,
|
|
1458
1584
|
});
|
|
1459
|
-
} else if (
|
|
1460
|
-
rec.outcome === 'kept-stuck' ||
|
|
1461
|
-
rec.outcome === 'kept-in-flight'
|
|
1462
|
-
) {
|
|
1463
|
-
// The lock changed between the report and the sweep — no longer stale.
|
|
1585
|
+
} else if (rec.outcome === 'kept-in-flight') {
|
|
1464
1586
|
kept++;
|
|
1465
1587
|
entries.push({lock, ref, outcome: rec.outcome, message: rec.message});
|
|
1466
1588
|
} else {
|
|
1467
|
-
// A lost lease (the ref was REJECTED because it changed concurrently to
|
|
1468
|
-
// a DIFFERENT value) or a per-item error — REPORTED, never forced.
|
|
1469
|
-
// Counts as needing attention after the sweep.
|
|
1470
1589
|
lost++;
|
|
1471
1590
|
entries.push({lock, ref, outcome: 'lost', message: rec.message});
|
|
1472
1591
|
}
|
|
1473
1592
|
continue;
|
|
1474
1593
|
}
|
|
1475
|
-
|
|
1476
|
-
// NEVER touch, even with the flag.
|
|
1477
|
-
if (reconcile === 'kept-stuck' || reconcile === 'kept-in-flight') {
|
|
1594
|
+
if (reconcile === 'kept-in-flight') {
|
|
1478
1595
|
kept++;
|
|
1479
1596
|
entries.push({
|
|
1480
1597
|
lock,
|
|
@@ -1506,17 +1623,12 @@ export async function reapStaleItemLocks(
|
|
|
1506
1623
|
* {@link itemLockReportNeedsAttention}.
|
|
1507
1624
|
*/
|
|
1508
1625
|
export function reapReportNeedsAttention(report: ReapReport): boolean {
|
|
1509
|
-
// EXIT-CODE CONTRACT
|
|
1510
|
-
//
|
|
1511
|
-
//
|
|
1512
|
-
//
|
|
1513
|
-
// ref already gone via `no-lock`) is BENIGN — the desired end state — and is
|
|
1514
|
-
// NOT in this set.
|
|
1626
|
+
// EXIT-CODE CONTRACT: the reaper exits 0 when all stale locks are reaped and
|
|
1627
|
+
// only healthy in-flight locks remain; exits 1 when a delete genuinely lost
|
|
1628
|
+
// the race / errored. An `already-reaped` is BENIGN. Post-`retire-stuck-
|
|
1629
|
+
// lock-state` there is no `kept-stuck` outcome to trip this.
|
|
1515
1630
|
return report.entries.some(
|
|
1516
|
-
(e) =>
|
|
1517
|
-
e.outcome === 'kept-stuck' ||
|
|
1518
|
-
e.outcome === 'lost' ||
|
|
1519
|
-
e.outcome === 'error',
|
|
1631
|
+
(e) => e.outcome === 'lost' || e.outcome === 'error',
|
|
1520
1632
|
);
|
|
1521
1633
|
}
|
|
1522
1634
|
|
|
@@ -1532,7 +1644,7 @@ export function formatReapReport(report: ReapReport): string[] {
|
|
|
1532
1644
|
}
|
|
1533
1645
|
const lines = [
|
|
1534
1646
|
`Per-item lock sweep (--reap-stale-locks): reaped ${report.reaped} stale ` +
|
|
1535
|
-
`
|
|
1647
|
+
`lock(s), kept ${report.kept} (in-flight, never reaped)` +
|
|
1536
1648
|
(report.alreadyReaped > 0
|
|
1537
1649
|
? `, ${report.alreadyReaped} already reaped by another sweep (no-lock — benign, the desired end state)`
|
|
1538
1650
|
: '') +
|
|
@@ -1544,14 +1656,14 @@ export function formatReapReport(report: ReapReport): string[] {
|
|
|
1544
1656
|
for (const {lock, outcome, message} of report.entries) {
|
|
1545
1657
|
const tag =
|
|
1546
1658
|
outcome === 'reaped'
|
|
1547
|
-
? '[reaped]
|
|
1659
|
+
? '[reaped] '
|
|
1548
1660
|
: outcome === 'already-reaped'
|
|
1549
|
-
? '[already]
|
|
1661
|
+
? '[already] '
|
|
1550
1662
|
: outcome === 'lost'
|
|
1551
|
-
? '[lost]
|
|
1663
|
+
? '[lost] '
|
|
1552
1664
|
: outcome === 'error'
|
|
1553
|
-
? '[error]
|
|
1554
|
-
: '[kept]
|
|
1665
|
+
? '[error] '
|
|
1666
|
+
: '[kept] ';
|
|
1555
1667
|
lines.push(
|
|
1556
1668
|
` ${tag} ${lock.entry} [${lock.action}/${lock.state}] ${message}`,
|
|
1557
1669
|
);
|
|
@@ -1569,10 +1681,10 @@ export function formatReapReport(report: ReapReport): string[] {
|
|
|
1569
1681
|
*/
|
|
1570
1682
|
export function itemFromLockEntry(entry: string): string {
|
|
1571
1683
|
// The lock-entry prefixes: `spec` is the parent-spec type token (the legacy
|
|
1572
|
-
//
|
|
1684
|
+
// ''prd'' token is GONE after the hard cutover), so a `spec-<slug>` lock entry
|
|
1573
1685
|
// round-trips back to its namespaced `spec:<slug>` form (the inverse of
|
|
1574
1686
|
// `lockEntryFor('spec:<slug>')`).
|
|
1575
|
-
for (const prefix of
|
|
1687
|
+
for (const prefix of CURRENT_ITEM_FORM_PREFIXES) {
|
|
1576
1688
|
const tag = `${prefix}-`;
|
|
1577
1689
|
if (entry.startsWith(tag)) {
|
|
1578
1690
|
return `${prefix}:${entry.slice(tag.length)}`;
|
|
@@ -1581,6 +1693,85 @@ export function itemFromLockEntry(entry: string): string {
|
|
|
1581
1693
|
return entry;
|
|
1582
1694
|
}
|
|
1583
1695
|
|
|
1696
|
+
/** The CURRENT lock-entry type prefixes an `<entry>` can reverse-derive to an
|
|
1697
|
+
* item-form (`<type>:<slug>`). After the slice→task / `prd-to-spec` vocabulary
|
|
1698
|
+
* cutover these are `task`/`spec`/`observation` ONLY — a pre-cutover `slice-`/
|
|
1699
|
+
* `prd-` prefix is NOT here, so its entry has NO current item-form. */
|
|
1700
|
+
const CURRENT_ITEM_FORM_PREFIXES = ['task', 'spec', 'observation'] as const;
|
|
1701
|
+
|
|
1702
|
+
/**
|
|
1703
|
+
* Does `<entry>` reverse-derive to a CURRENT item-form (`<type>:<slug>`), i.e.
|
|
1704
|
+
* does it carry a known post-cutover type prefix (`task-`/`spec-`/`observation-`)?
|
|
1705
|
+
* FALSE for a pre-cutover `slice-<slug>` / `prd-<slug>` entry (task
|
|
1706
|
+
* `release-lock-entry-escape-hatch-and-literal-entry-reporting`): such an entry is
|
|
1707
|
+
* UN-NAMEABLE through the item-form `release-lock <item>` path and must be cleared
|
|
1708
|
+
* via the `release-lock --entry <literal>` escape hatch instead. This is the
|
|
1709
|
+
* predicate the `gc --ledger` report keys off to decide whether to print the
|
|
1710
|
+
* copy-pasteable item-form hint or the literal-`--entry` hint.
|
|
1711
|
+
*/
|
|
1712
|
+
export function hasCurrentItemForm(entry: string): boolean {
|
|
1713
|
+
return CURRENT_ITEM_FORM_PREFIXES.some((p) => entry.startsWith(`${p}-`));
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
/**
|
|
1717
|
+
* True iff `<arbiter>/main` shows the item SURFACED as a needs-attention
|
|
1718
|
+
* question (PR-2a classifier fold, task
|
|
1719
|
+
* `bounce-atomic-cutover-retire-stuck-lock`, spec
|
|
1720
|
+
* `surface-stuck-as-questions-and-retire-stuck-lock-state`): the item body
|
|
1721
|
+
* carries `needsAnswers: true` in its frontmatter AND a matching sidecar
|
|
1722
|
+
* (`work/questions/<type>-<slug>.md`) exists on `<arbiter>/main`. This is the
|
|
1723
|
+
* on-`main`-authoritative signature of the ordered bounce transition
|
|
1724
|
+
* (surface-to-main FIRST, release SECOND) that a crash BETWEEN steps 1 and 2
|
|
1725
|
+
* leaves behind: the surface commit landed but the lock ref release never ran.
|
|
1726
|
+
*
|
|
1727
|
+
* Best-effort + degrades safely (`false` on any read fault or absent body):
|
|
1728
|
+
* the recovery direction is to DECIDE the lock is NOT surfaced (fall through
|
|
1729
|
+
* to `kept-in-flight`) when we cannot prove the surface — never a
|
|
1730
|
+
* false-positive clear.
|
|
1731
|
+
*/
|
|
1732
|
+
async function isItemSurfacedOnMain(
|
|
1733
|
+
type: SidecarType,
|
|
1734
|
+
slug: string,
|
|
1735
|
+
item: string,
|
|
1736
|
+
arbiter: string,
|
|
1737
|
+
cwd: string,
|
|
1738
|
+
env: NodeJS.ProcessEnv | undefined,
|
|
1739
|
+
): Promise<boolean> {
|
|
1740
|
+
const sidecar = sidecarPathFor(item);
|
|
1741
|
+
const sidecarExists =
|
|
1742
|
+
(await gitSoft(['cat-file', '-e', `${arbiter}/main:${sidecar}`], cwd, env))
|
|
1743
|
+
.status === 0;
|
|
1744
|
+
if (!sidecarExists) {
|
|
1745
|
+
return false;
|
|
1746
|
+
}
|
|
1747
|
+
// Probe the two bounce body-folder candidates (D1 order) for a body carrying
|
|
1748
|
+
// `needsAnswers: true`. Kept in sync with `resolveBounceItemBodyPathOnMain`
|
|
1749
|
+
// in `needs-attention.ts`; duplicated here (a small closed list) to avoid a
|
|
1750
|
+
// cyclic import between `item-lock.ts` and `needs-attention.ts`.
|
|
1751
|
+
const candidates =
|
|
1752
|
+
type === 'task'
|
|
1753
|
+
? [
|
|
1754
|
+
workItemRel('tasks-ready', `${slug}.md`),
|
|
1755
|
+
workItemRel('tasks-backlog', `${slug}.md`),
|
|
1756
|
+
]
|
|
1757
|
+
: type === 'spec'
|
|
1758
|
+
? [
|
|
1759
|
+
workItemRel('specs-ready', `${slug}.md`),
|
|
1760
|
+
workItemRel('specs-proposed', `${slug}.md`),
|
|
1761
|
+
]
|
|
1762
|
+
: [workItemRel('observations', `${slug}.md`)];
|
|
1763
|
+
for (const path of candidates) {
|
|
1764
|
+
const show = await gitSoft(['show', `${arbiter}/main:${path}`], cwd, env);
|
|
1765
|
+
if (show.status !== 0) {
|
|
1766
|
+
continue;
|
|
1767
|
+
}
|
|
1768
|
+
if (parseFrontmatter(show.stdout).needsAnswers === true) {
|
|
1769
|
+
return true;
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
return false;
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1584
1775
|
/** True iff `<arbiter>/main` shows the item TERMINAL — any of
|
|
1585
1776
|
* {@link terminalMainPaths} present in `<arbiter>/main`'s tree. */
|
|
1586
1777
|
async function isTerminalOnMain(
|
|
@@ -1690,7 +1881,7 @@ export async function listItemLockEntries(
|
|
|
1690
1881
|
* List the TASK slugs currently lock-held on the arbiter — the held-slug set the
|
|
1691
1882
|
* `ready/` pool readers SUBTRACT (spec `ledger-status-per-item-lock-refs` US #15;
|
|
1692
1883
|
* task `claim-acquires-unified-lock-no-body-move`). Enumerates {@link listItemLocks}
|
|
1693
|
-
* and keeps only the `task-<slug>` entries (a
|
|
1884
|
+
* and keeps only the `task-<slug>` entries (a spec/observation lock does not gate
|
|
1694
1885
|
* the TASK pool), mapping each to its bare `<slug>`.
|
|
1695
1886
|
*
|
|
1696
1887
|
* LOAD-BEARING since the lock cut-over: the claim NO LONGER moves the body to
|
|
@@ -1751,11 +1942,13 @@ export async function heldTaskSlugsStrict(
|
|
|
1751
1942
|
|
|
1752
1943
|
/**
|
|
1753
1944
|
* Parse a serialised lock entry body back into a {@link LockEntry} — the exact
|
|
1754
|
-
* inverse of {@link serialiseLockEntry}.
|
|
1755
|
-
*
|
|
1756
|
-
*
|
|
1757
|
-
*
|
|
1758
|
-
*
|
|
1945
|
+
* inverse of {@link serialiseLockEntry}. Post-`retire-stuck-lock-state` a lock
|
|
1946
|
+
* entry whose serialised state is not the single admitted `'active'` value
|
|
1947
|
+
* (e.g. a legacy `state: stuck` blob written by an older binary before the
|
|
1948
|
+
* one-shot migration ran) is COERCED to `'active'` on read — the state is
|
|
1949
|
+
* degenerate, and a lingering stuck ref is left for the migration/recovery
|
|
1950
|
+
* verbs to clear via `main`-authoritative reconciliation rather than surfaced
|
|
1951
|
+
* as a second live state.
|
|
1759
1952
|
*/
|
|
1760
1953
|
export function parseLockEntry(body: string): LockEntry | undefined {
|
|
1761
1954
|
const normalized = body.replace(/\r\n/g, '\n');
|
|
@@ -1773,62 +1966,13 @@ export function parseLockEntry(body: string): LockEntry | undefined {
|
|
|
1773
1966
|
if (!fields.entry || !fields.action || !fields.state) {
|
|
1774
1967
|
return undefined;
|
|
1775
1968
|
}
|
|
1776
|
-
|
|
1777
|
-
const reason = extractBodyReason(bodyText) ?? fields.reason;
|
|
1778
|
-
const questions = extractBodyQuestions(bodyText);
|
|
1779
|
-
const entry: LockEntry = {
|
|
1969
|
+
return {
|
|
1780
1970
|
entry: fields.entry,
|
|
1781
1971
|
action: fields.action as LockAction,
|
|
1782
|
-
state:
|
|
1972
|
+
state: 'active',
|
|
1783
1973
|
holder: fields.holder ?? '',
|
|
1784
1974
|
since: fields.since ?? '',
|
|
1785
1975
|
};
|
|
1786
|
-
if (reason !== undefined && reason !== '') {
|
|
1787
|
-
entry.reason = reason;
|
|
1788
|
-
}
|
|
1789
|
-
if (questions.length > 0) {
|
|
1790
|
-
entry.questions = questions;
|
|
1791
|
-
}
|
|
1792
|
-
return entry;
|
|
1793
|
-
}
|
|
1794
|
-
|
|
1795
|
-
/** Extract the `## Reason` block's prose (joined multi-line), or undefined. */
|
|
1796
|
-
function extractBodyReason(bodyText: string): string | undefined {
|
|
1797
|
-
const lines = bodyText.split('\n');
|
|
1798
|
-
const start = lines.findIndex((l) => l.trim() === LOCK_REASON_HEADING);
|
|
1799
|
-
if (start === -1) {
|
|
1800
|
-
return undefined;
|
|
1801
|
-
}
|
|
1802
|
-
const collected: string[] = [];
|
|
1803
|
-
for (let i = start + 1; i < lines.length; i++) {
|
|
1804
|
-
if (/^##\s/.test(lines[i])) {
|
|
1805
|
-
break;
|
|
1806
|
-
}
|
|
1807
|
-
collected.push(lines[i]);
|
|
1808
|
-
}
|
|
1809
|
-
// Trim leading/trailing blank lines but PRESERVE interior newlines (rich prose).
|
|
1810
|
-
const text = collected.join('\n').replace(/^\n+/, '').replace(/\n+$/, '');
|
|
1811
|
-
return text === '' ? undefined : text;
|
|
1812
|
-
}
|
|
1813
|
-
|
|
1814
|
-
/** Extract the `## Questions` block's bulleted list, or [] when absent. */
|
|
1815
|
-
function extractBodyQuestions(bodyText: string): string[] {
|
|
1816
|
-
const lines = bodyText.split('\n');
|
|
1817
|
-
const start = lines.findIndex((l) => l.trim() === LOCK_QUESTIONS_HEADING);
|
|
1818
|
-
if (start === -1) {
|
|
1819
|
-
return [];
|
|
1820
|
-
}
|
|
1821
|
-
const questions: string[] = [];
|
|
1822
|
-
for (let i = start + 1; i < lines.length; i++) {
|
|
1823
|
-
if (/^##\s/.test(lines[i])) {
|
|
1824
|
-
break;
|
|
1825
|
-
}
|
|
1826
|
-
const m = /^-\s+(.*)$/.exec(lines[i].trim());
|
|
1827
|
-
if (m) {
|
|
1828
|
-
questions.push(m[1]);
|
|
1829
|
-
}
|
|
1830
|
-
}
|
|
1831
|
-
return questions;
|
|
1832
1976
|
}
|
|
1833
1977
|
|
|
1834
1978
|
/**
|