dorfl 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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/needs-attention.ts
CHANGED
|
@@ -9,7 +9,10 @@ import {
|
|
|
9
9
|
isWorkItemFile,
|
|
10
10
|
} from './work-layout.js';
|
|
11
11
|
import {run, runAsync, type RunResult} from './git.js';
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
branchAheadOf,
|
|
14
|
+
rebaseContinuedBranchOntoMain,
|
|
15
|
+
} from './continue-branch.js';
|
|
13
16
|
import {
|
|
14
17
|
acquireItemLock,
|
|
15
18
|
releaseItemLock,
|
|
@@ -21,6 +24,17 @@ import {
|
|
|
21
24
|
} from './item-lock.js';
|
|
22
25
|
import {ledgerWrite, type LedgerTransitionKind} from './ledger-write.js';
|
|
23
26
|
import {workBranchRef} from './slug-namespace.js';
|
|
27
|
+
import {
|
|
28
|
+
appendQuestions,
|
|
29
|
+
newSidecar,
|
|
30
|
+
parseSidecar,
|
|
31
|
+
resolveSidecarIdentity,
|
|
32
|
+
serialiseSidecar,
|
|
33
|
+
sidecarPathFor,
|
|
34
|
+
type NewQuestion,
|
|
35
|
+
type SidecarType,
|
|
36
|
+
} from './sidecar.js';
|
|
37
|
+
import {parseFrontmatter, setNeedsAnswersMarker} from './frontmatter.js';
|
|
24
38
|
import {
|
|
25
39
|
retryWithBackoff,
|
|
26
40
|
realSleep,
|
|
@@ -140,11 +154,12 @@ export interface RouteToNeedsAttentionResult {
|
|
|
140
154
|
/** When the branch push FAILED after retries, the last git error (for the report). */
|
|
141
155
|
pushError?: string;
|
|
142
156
|
/**
|
|
143
|
-
* When `moved`, the sha of the
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
157
|
+
* When `moved`, the sha of the `work/<slug>` tip after the RECOVERABLE-half
|
|
158
|
+
* save. Post lock-cutover this is the **wip** commit holding the aborted agent
|
|
159
|
+
* work (`git add -A`); there is no separate `git mv → needs-attention/`
|
|
160
|
+
* move-only commit anymore (that folder is retired). The OBSERVABLE stuck
|
|
161
|
+
* state rides on the per-item lock amend (`state: stuck` + reason), not on this
|
|
162
|
+
* commit.
|
|
148
163
|
*/
|
|
149
164
|
moveCommit?: string;
|
|
150
165
|
/** When NOT moved, why (e.g. the slug was not in-progress). */
|
|
@@ -155,8 +170,9 @@ export interface ReturnToBacklogOptions {
|
|
|
155
170
|
/** The working clone the `work/` tree lives in. */
|
|
156
171
|
cwd: string;
|
|
157
172
|
/**
|
|
158
|
-
* The slug of the stuck item to re-queue — recovered
|
|
159
|
-
*
|
|
173
|
+
* The slug of the stuck item to re-queue — recovered via its per-item lock on
|
|
174
|
+
* the arbiter (post lock-cutover the body never moves into a status folder; it
|
|
175
|
+
* rests in `backlog/` and stuck is the lock `state: stuck`).
|
|
160
176
|
*/
|
|
161
177
|
slug: string;
|
|
162
178
|
/** The arbiter remote to push the transition to. Optional (see above). */
|
|
@@ -176,6 +192,29 @@ export interface ReturnToBacklogOptions {
|
|
|
176
192
|
* path.
|
|
177
193
|
*/
|
|
178
194
|
reset?: boolean;
|
|
195
|
+
/**
|
|
196
|
+
* `requeue --reconcile` (the NON-DESTRUCTIVE recovery verb — the middle rung
|
|
197
|
+
* of the escalation ladder between the default keep+continue and the
|
|
198
|
+
* destructive `--reset`). When the item's per-item lock is held stuck AND the
|
|
199
|
+
* arbiter's `work/<slug>` branch EXISTS and is ahead of main, re-sync the
|
|
200
|
+
* mirror to the arbiter (a prune-fetch that clears the stale-ref residue that
|
|
201
|
+
* historically silently resurrected a supposedly-`--reset`-ed branch) and
|
|
202
|
+
* RETRY the rebase of the kept branch onto latest `<arbiter>/main` in a
|
|
203
|
+
* SCRATCH worktree (never touching the caller's tree, `--force-with-lease`
|
|
204
|
+
* only, NEVER a bare force). On a clean rebase, the reconciled tip is
|
|
205
|
+
* pushed back to the arbiter with a lease + the reconcile completes as a
|
|
206
|
+
* standard keep+continue requeue (lock released, branch preserved). On a
|
|
207
|
+
* genuine content conflict after the clean mirror re-sync, the lock is LEFT
|
|
208
|
+
* HELD (item stays stuck), the branch is LEFT UNTOUCHED on the arbiter
|
|
209
|
+
* (NEVER deleted), and the caller is told the retry happened + pointed at the
|
|
210
|
+
* deferred mirror-side resolve follow-on + `--reset` as the destructive last
|
|
211
|
+
* resort. If the branch is ABSENT (never pushed, or a prior `--reset` already
|
|
212
|
+
* deleted it), reconcile falls through to the default keep+continue path
|
|
213
|
+
* (which itself degrades gracefully to a fresh-claim move). Incompatible
|
|
214
|
+
* with `--reset` (destructive vs non-destructive are exclusive verbs on the
|
|
215
|
+
* same escalation).
|
|
216
|
+
*/
|
|
217
|
+
reconcile?: boolean;
|
|
179
218
|
/**
|
|
180
219
|
* `requeue -m "<note>"` (the handoff note): an optional human steer for the
|
|
181
220
|
* NEXT agent. APPENDED (never overwritten) as a dated `## Requeue YYYY-MM-DD`
|
|
@@ -198,7 +237,15 @@ export interface ReturnToBacklogResult {
|
|
|
198
237
|
commitMessage?: string;
|
|
199
238
|
/** True iff `--reset` deleted the remote `work/<slug>` branch on the arbiter. */
|
|
200
239
|
deletedRemoteBranch?: boolean;
|
|
201
|
-
/**
|
|
240
|
+
/**
|
|
241
|
+
* True iff `--reconcile` re-synced the mirror + rebased the kept branch onto
|
|
242
|
+
* latest `<arbiter>/main` and pushed the reconciled tip back to the arbiter
|
|
243
|
+
* (before the lock release). Absent on the default path / on `--reset` / on
|
|
244
|
+
* a reconcile that fell through (branch absent) — for those, the ordinary
|
|
245
|
+
* keep+continue path ran with no rebase.
|
|
246
|
+
*/
|
|
247
|
+
reconciled?: boolean;
|
|
248
|
+
/** When NOT moved, why (e.g. the slug held no recoverable per-item lock on the arbiter, or a failed --reset delete). */
|
|
202
249
|
reasonNotMoved?: string;
|
|
203
250
|
}
|
|
204
251
|
|
|
@@ -358,7 +405,7 @@ export async function routeToNeedsAttention(
|
|
|
358
405
|
|
|
359
406
|
/**
|
|
360
407
|
* The clean re-queue (ADR §12 / WORK-CONTRACT return path): once the human has
|
|
361
|
-
* resolved the cause, move the stuck item back to `work/
|
|
408
|
+
* resolved the cause, move the stuck item back to `work/tasks/ready/<slug>.md` and
|
|
362
409
|
* commit it so the item can be re-claimed (it must not rot stuck). It recovers a
|
|
363
410
|
* task stuck in EITHER `work/needs-attention/<slug>.md` (the resolved-surface
|
|
364
411
|
* path) OR `work/in-progress/<slug>.md` (a claim that never surfaced — an
|
|
@@ -374,12 +421,26 @@ export async function routeToNeedsAttention(
|
|
|
374
421
|
* it is the durable artifact the next claim CONTINUES from (the continue-
|
|
375
422
|
* detection in `continue-branch.ts` feeds both onboarding paths). This
|
|
376
423
|
* function only does the ledger move.
|
|
377
|
-
* - **`--
|
|
378
|
-
* `
|
|
379
|
-
*
|
|
380
|
-
*
|
|
381
|
-
*
|
|
382
|
-
*
|
|
424
|
+
* - **`--reconcile` = NON-DESTRUCTIVE RECOVERY (middle rung).** When
|
|
425
|
+
* `reconcile` is set AND the arbiter's `work/<slug>` exists + is ahead of
|
|
426
|
+
* main, re-sync the hub mirror to the arbiter (a prune-fetch that clears
|
|
427
|
+
* the stale-ref residue that historically resurrected a supposedly
|
|
428
|
+
* `--reset`-ed branch) and RETRY the rebase of the kept branch onto latest
|
|
429
|
+
* `<arbiter>/main` in a SCRATCH worktree (never touching the caller's
|
|
430
|
+
* tree, `--force-with-lease` only, NEVER a bare force). Clean rebase =>
|
|
431
|
+
* push the reconciled tip back to the arbiter and fall through to the
|
|
432
|
+
* standard keep+continue path (lock released, branch preserved). Genuine
|
|
433
|
+
* content conflict AFTER the clean mirror re-sync => item stays stuck,
|
|
434
|
+
* branch UNTOUCHED (NEVER deleted), message references the deferred
|
|
435
|
+
* mirror-side resolve follow-on and mentions `--reset` LAST as the
|
|
436
|
+
* destructive last resort. Branch absent / not-ahead => fall through to
|
|
437
|
+
* the default keep+continue path (which handles the fresh-claim case).
|
|
438
|
+
* - **`--reset` = DISCARD + FRESH (destructive last resort).** When `reset`
|
|
439
|
+
* is set, DELETE the remote `work/<slug>` branch on `arbiter` FIRST (+ drop
|
|
440
|
+
* any stale local branch), THEN the backlog move. Delete-before-move
|
|
441
|
+
* closes the claim-race window; a FAILED delete ABORTS (no backlog move)
|
|
442
|
+
* so the item stays in needs-attention. The next claim then finds NO
|
|
443
|
+
* arbiter branch and cuts fresh — no special claim-time logic.
|
|
383
444
|
* - **`-m "<note>"` = HANDOFF NOTE.** When `message` is set, APPEND a dated
|
|
384
445
|
* `## Requeue YYYY-MM-DD` section to the item BODY (append-only; accumulates
|
|
385
446
|
* over repeated requeues) for the next agent. Applies to BOTH modes.
|
|
@@ -406,6 +467,92 @@ async function readLocalItemLock(
|
|
|
406
467
|
return parseLockEntry(show.stdout);
|
|
407
468
|
}
|
|
408
469
|
|
|
470
|
+
/**
|
|
471
|
+
* Options the shared {@link deleteRemoteWorkBranchIfPresent} primitive consumes.
|
|
472
|
+
*/
|
|
473
|
+
export interface DeleteRemoteWorkBranchOptions {
|
|
474
|
+
/** Working clone whose remote `arbiter` points at the real arbiter. */
|
|
475
|
+
cwd: string;
|
|
476
|
+
/** The arbiter remote NAME in `cwd`. */
|
|
477
|
+
arbiter: string;
|
|
478
|
+
/** Bare slug (the branch is `work/task-<slug>`). */
|
|
479
|
+
slug: string;
|
|
480
|
+
/** Environment for child git processes. */
|
|
481
|
+
env?: NodeJS.ProcessEnv;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** The three terminal states {@link deleteRemoteWorkBranchIfPresent} can report. */
|
|
485
|
+
export type DeleteRemoteWorkBranchStatus =
|
|
486
|
+
/** The push --delete succeeded; the arbiter branch is gone. */
|
|
487
|
+
| 'deleted'
|
|
488
|
+
/**
|
|
489
|
+
* The branch was already absent from the arbiter (`remote ref does not
|
|
490
|
+
* exist` / `unable to delete`). Local refs may still have been cleaned up.
|
|
491
|
+
* SEMANTICALLY equivalent to `deleted` for callers who only care that the
|
|
492
|
+
* branch is not on the arbiter afterwards — idempotent no-op.
|
|
493
|
+
*/
|
|
494
|
+
| 'already-gone'
|
|
495
|
+
/** The push --delete failed for a reason OTHER than already-gone; caller decides. */
|
|
496
|
+
| 'failed';
|
|
497
|
+
|
|
498
|
+
/** The outcome {@link deleteRemoteWorkBranchIfPresent} reports. */
|
|
499
|
+
export interface DeleteRemoteWorkBranchResult {
|
|
500
|
+
/** The `work/<type>-<slug>` branch ref name that was targeted. */
|
|
501
|
+
branch: string;
|
|
502
|
+
/** See {@link DeleteRemoteWorkBranchStatus}. */
|
|
503
|
+
status: DeleteRemoteWorkBranchStatus;
|
|
504
|
+
/** The stderr from the failed `push --delete` (`''` on success/already-gone). */
|
|
505
|
+
stderr: string;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Delete the remote `work/task-<slug>` branch on the arbiter using the SAME
|
|
510
|
+
* write-through ordering the `requeue --reset` recovery verb uses:
|
|
511
|
+
*
|
|
512
|
+
* 1. Delete the LOCAL tracking ref (`refs/remotes/<arbiter>/work/<...>`)
|
|
513
|
+
* — the ref the continue-detection path READS; if we skip this the
|
|
514
|
+
* staleness silently resurrects a supposedly-discarded branch (verified in
|
|
515
|
+
* `work/notes/observations/requeue-reset-does-not-prune-hub-mirror-stale-branch-ref.md`).
|
|
516
|
+
* 2. Delete any LOCAL head `work/<...>` (best-effort).
|
|
517
|
+
* 3. `git push <arbiter> --delete work/<...>` — the ARBITER delete, the
|
|
518
|
+
* source of truth. A `remote ref does not exist` / `unable to delete`
|
|
519
|
+
* stderr is TOLERATED as `already-gone` so the primitive is IDEMPOTENT
|
|
520
|
+
* and safely callable on an item with no work branch (an observation, or
|
|
521
|
+
* a task never built).
|
|
522
|
+
*
|
|
523
|
+
* Extracted from {@link returnToBacklog}'s `--reset` path so the apply-rung
|
|
524
|
+
* `kind: 'stuck'` answered `reset` verb (task
|
|
525
|
+
* `apply-resolve-reset-flag-discards-work-branch`) can dispatch through the
|
|
526
|
+
* SAME primitive without re-implementing branch deletion — the two callers
|
|
527
|
+
* MUST stay behaviourally identical (delete-before-move / delete-before-clear;
|
|
528
|
+
* local-first write-through; already-gone tolerance).
|
|
529
|
+
*/
|
|
530
|
+
export async function deleteRemoteWorkBranchIfPresent(
|
|
531
|
+
options: DeleteRemoteWorkBranchOptions,
|
|
532
|
+
): Promise<DeleteRemoteWorkBranchResult> {
|
|
533
|
+
const {cwd, arbiter, slug, env} = options;
|
|
534
|
+
const branch = workBranchRef('task', slug);
|
|
535
|
+
await gitSoftAsync(
|
|
536
|
+
['update-ref', '-d', `refs/remotes/${arbiter}/${branch}`],
|
|
537
|
+
cwd,
|
|
538
|
+
env,
|
|
539
|
+
);
|
|
540
|
+
await gitSoftAsync(['branch', '-D', branch], cwd, env);
|
|
541
|
+
const del = await gitSoftAsync(
|
|
542
|
+
['push', arbiter, '--delete', branch],
|
|
543
|
+
cwd,
|
|
544
|
+
env,
|
|
545
|
+
);
|
|
546
|
+
if (del.status === 0) {
|
|
547
|
+
return {branch, status: 'deleted', stderr: ''};
|
|
548
|
+
}
|
|
549
|
+
const stderr = del.stderr.trim();
|
|
550
|
+
if (/remote ref does not exist|unable to delete/i.test(stderr)) {
|
|
551
|
+
return {branch, status: 'already-gone', stderr};
|
|
552
|
+
}
|
|
553
|
+
return {branch, status: 'failed', stderr};
|
|
554
|
+
}
|
|
555
|
+
|
|
409
556
|
export async function returnToBacklog(
|
|
410
557
|
options: ReturnToBacklogOptions,
|
|
411
558
|
): Promise<ReturnToBacklogResult> {
|
|
@@ -476,6 +623,67 @@ export async function returnToBacklog(
|
|
|
476
623
|
};
|
|
477
624
|
}
|
|
478
625
|
|
|
626
|
+
// `--reconcile`: the NON-DESTRUCTIVE recovery rung (task
|
|
627
|
+
// `requeue-reconcile-nondestructive-recovery-verb`, parent observation
|
|
628
|
+
// `rebase-conflict-on-continue-needs-nondestructive-recovery-not-reset`).
|
|
629
|
+
// When the kept `work/<slug>` exists + is ahead of `<arbiter>/main`, re-sync
|
|
630
|
+
// the mirror (prune-fetch — the exact step whose absence let
|
|
631
|
+
// `requeue-reset-does-not-prune-hub-mirror-stale-branch-ref` silently
|
|
632
|
+
// resurrect a stale branch) and RETRY the rebase in a SCRATCH worktree. Clean
|
|
633
|
+
// rebase => push the reconciled tip back (`--force-with-lease`, never bare
|
|
634
|
+
// force, WORK branch only — ADR §11) and fall through to the standard
|
|
635
|
+
// keep+continue path. Genuine content conflict AFTER the clean re-sync =>
|
|
636
|
+
// return with a message that LEADS with what happened, references the
|
|
637
|
+
// deferred mirror-side resolve path, and mentions `--reset` LAST as the
|
|
638
|
+
// destructive last resort. Branch absent / not-ahead => fall through to the
|
|
639
|
+
// default keep+continue path (which handles the fresh-claim case). NEVER
|
|
640
|
+
// deletes the remote branch — this verb's contract is "keep the work".
|
|
641
|
+
let reconciled: boolean | undefined;
|
|
642
|
+
if (options.reconcile) {
|
|
643
|
+
if (options.reset) {
|
|
644
|
+
return {
|
|
645
|
+
moved: false,
|
|
646
|
+
reasonNotMoved:
|
|
647
|
+
`requeue for '${slug}': --reconcile and --reset are mutually ` +
|
|
648
|
+
'exclusive (non-destructive recovery vs destructive last resort). ' +
|
|
649
|
+
'Pick one.',
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
const attempt = await attemptReconcile({
|
|
653
|
+
cwd,
|
|
654
|
+
slug,
|
|
655
|
+
arbiter,
|
|
656
|
+
env,
|
|
657
|
+
note,
|
|
658
|
+
});
|
|
659
|
+
if (attempt.kind === 'conflict') {
|
|
660
|
+
const branch = workBranchRef('task', slug);
|
|
661
|
+
const message =
|
|
662
|
+
`requeue --reconcile for '${slug}': re-synced the ${arbiter} mirror ` +
|
|
663
|
+
`and RETRIED the rebase of ${branch} onto latest ${arbiter}/main, but ` +
|
|
664
|
+
`the rebase still conflicts on genuine content (${attempt.detail}). The ` +
|
|
665
|
+
'kept branch is left UNTOUCHED on the arbiter (nothing deleted) and the ' +
|
|
666
|
+
'item is left stuck. A supported mirror-side "resolve against latest ' +
|
|
667
|
+
'main" command that fetches the kept branch into a scratch worktree, ' +
|
|
668
|
+
'rebases, and re-pushes is planned but not yet built (see observation ' +
|
|
669
|
+
'`rebase-conflict-on-continue-needs-nondestructive-recovery-not-reset`, ' +
|
|
670
|
+
'point 2 of its LIVE residue). LAST RESORT: `requeue --reset` ' +
|
|
671
|
+
'DESTRUCTIVELY discards the branch and starts fresh.';
|
|
672
|
+
note(message);
|
|
673
|
+
return {moved: false, reasonNotMoved: message};
|
|
674
|
+
}
|
|
675
|
+
if (attempt.kind === 'reconciled') {
|
|
676
|
+
reconciled = true;
|
|
677
|
+
note(
|
|
678
|
+
`Reconciled '${slug}': re-synced the ${arbiter} mirror, rebased the ` +
|
|
679
|
+
`kept ${workBranchRef('task', slug)} onto latest ${arbiter}/main, and ` +
|
|
680
|
+
'pushed the reconciled tip back (non-destructive; branch preserved).',
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
// 'no-branch' => fall through to the default keep+continue path (branch
|
|
684
|
+
// absent / not ahead — the existing default guard handles both cases).
|
|
685
|
+
}
|
|
686
|
+
|
|
479
687
|
// `--reset`: DELETE the remote work branch (before the backlog move). The
|
|
480
688
|
// deletion is WRITE-THROUGH: the LOCAL refs that drive continue-detection
|
|
481
689
|
// (`refs/remotes/<arbiter>/work/<slug>` AND any local head `work/<slug>`) are
|
|
@@ -489,49 +697,29 @@ export async function returnToBacklog(
|
|
|
489
697
|
// continue. Delete-before-move also closes the claim-race window.
|
|
490
698
|
let deletedRemoteBranch = false;
|
|
491
699
|
if (options.reset) {
|
|
492
|
-
const
|
|
493
|
-
// LOCAL-FIRST: the tracking ref `branchAheadOf` reads (the one whose
|
|
494
|
-
// staleness today silently turns `--reset` into a no-op — verified live in
|
|
495
|
-
// `work/notes/observations/requeue-reset-does-not-prune-hub-mirror-stale-branch-ref.md`,
|
|
496
|
-
// where `--reset` deleted the arbiter branch but the local tracking ref
|
|
497
|
-
// survived and resurrected a "continue" on the next `do`). Both deletes
|
|
498
|
-
// are best-effort — their absence is fine, what matters is they are not
|
|
499
|
-
// LEFT BEHIND when the arbiter delete succeeds.
|
|
500
|
-
await gitSoftAsync(
|
|
501
|
-
['update-ref', '-d', `refs/remotes/${arbiter}/${branch}`],
|
|
502
|
-
cwd,
|
|
503
|
-
env,
|
|
504
|
-
);
|
|
505
|
-
await gitSoftAsync(['branch', '-D', branch], cwd, env);
|
|
506
|
-
// THEN the arbiter delete (explicit/guarded departure from the "never delete
|
|
507
|
-
// the remote branch" invariant; only on the `--reset` path, never the
|
|
508
|
-
// default).
|
|
509
|
-
const del = await gitSoftAsync(
|
|
510
|
-
['push', arbiter, '--delete', branch],
|
|
700
|
+
const dropped = await deleteRemoteWorkBranchIfPresent({
|
|
511
701
|
cwd,
|
|
702
|
+
arbiter,
|
|
703
|
+
slug,
|
|
512
704
|
env,
|
|
513
|
-
);
|
|
514
|
-
if (
|
|
515
|
-
const stderr =
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
stderr
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
'a subsequent fetch will restore it from the arbiter — the local store ' +
|
|
527
|
-
'is BEHIND the arbiter (self-healing), never AHEAD (which would drive a ' +
|
|
528
|
-
'stale continue).';
|
|
529
|
-
note(message);
|
|
530
|
-
return {moved: false, reasonNotMoved: message};
|
|
531
|
-
}
|
|
705
|
+
});
|
|
706
|
+
if (dropped.status === 'failed') {
|
|
707
|
+
const stderr = dropped.stderr;
|
|
708
|
+
const message =
|
|
709
|
+
`requeue --reset for '${slug}': failed to delete the remote branch ` +
|
|
710
|
+
`${dropped.branch} on ${arbiter} (${stderr || 'unknown error'}); ` +
|
|
711
|
+
'aborting the requeue — item left in needs-attention (no backlog move). ' +
|
|
712
|
+
'The local tracking ref was already cleared (write-through ordering); ' +
|
|
713
|
+
'a subsequent fetch will restore it from the arbiter — the local store ' +
|
|
714
|
+
'is BEHIND the arbiter (self-healing), never AHEAD (which would drive a ' +
|
|
715
|
+
'stale continue).';
|
|
716
|
+
note(message);
|
|
717
|
+
return {moved: false, reasonNotMoved: message};
|
|
532
718
|
}
|
|
533
719
|
deletedRemoteBranch = true;
|
|
534
|
-
note(
|
|
720
|
+
note(
|
|
721
|
+
`Deleted the remote branch ${dropped.branch} on ${arbiter} (--reset).`,
|
|
722
|
+
);
|
|
535
723
|
}
|
|
536
724
|
|
|
537
725
|
// DEFAULT (keep+continue) REQUEUE-SAFETY GUARD: a claimable item's continue-
|
|
@@ -543,20 +731,45 @@ export async function returnToBacklog(
|
|
|
543
731
|
// discards the branch by design).
|
|
544
732
|
if (!options.reset) {
|
|
545
733
|
const branch = workBranchRef('task', slug);
|
|
546
|
-
|
|
734
|
+
// Split the guard into TWO cases (task
|
|
735
|
+
// `default-requeue-succeeds-when-no-work-branch-exists`):
|
|
736
|
+
// (a) the arbiter branch does NOT EXIST at all (never pushed, or a prior
|
|
737
|
+
// `--reset` already deleted it) — there is NO continue-branch a future
|
|
738
|
+
// worker would resume from, so the guard's precondition is vacuously
|
|
739
|
+
// satisfied. Degrade gracefully to the same effective outcome as
|
|
740
|
+
// `--reset` (nothing to discard) and proceed with the keep+continue
|
|
741
|
+
// backlog move: no arbiter delete (there is nothing to delete), no
|
|
742
|
+
// forcing the caller into the destructive `--reset` verb.
|
|
743
|
+
// (b) the arbiter branch EXISTS but is NOT ahead of `<arbiter>/main` — a
|
|
744
|
+
// real anomaly (the continue-branch would resume from a state already
|
|
745
|
+
// reachable from main). Preserve today's refusal so the case surfaces.
|
|
746
|
+
const tip = gitSoftRun(
|
|
747
|
+
['rev-parse', '--verify', '--quiet', `${arbiter}/${branch}^{commit}`],
|
|
547
748
|
cwd,
|
|
548
|
-
`${arbiter}/${branch}`,
|
|
549
|
-
`${arbiter}/main`,
|
|
550
749
|
env,
|
|
551
750
|
);
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
`
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
751
|
+
const arbiterBranchExists = tip.status === 0 && tip.stdout.trim() !== '';
|
|
752
|
+
if (!arbiterBranchExists) {
|
|
753
|
+
note(
|
|
754
|
+
`'${slug}' has no work branch on ${arbiter} — requeueing to backlog ` +
|
|
755
|
+
'for a FRESH claim (nothing to continue from; no --reset needed).',
|
|
756
|
+
);
|
|
757
|
+
} else {
|
|
758
|
+
const onArbiter = branchAheadOf(
|
|
759
|
+
cwd,
|
|
760
|
+
`${arbiter}/${branch}`,
|
|
761
|
+
`${arbiter}/main`,
|
|
762
|
+
env,
|
|
763
|
+
);
|
|
764
|
+
if (!onArbiter) {
|
|
765
|
+
const message =
|
|
766
|
+
`the work branch ${branch} isn't on ${arbiter} (the continue ` +
|
|
767
|
+
`branch a cross-machine worker would resume from) — push it first, or ` +
|
|
768
|
+
'`requeue --reset` to discard and start fresh. Item left stuck (lock not ' +
|
|
769
|
+
'released).';
|
|
770
|
+
note(message);
|
|
771
|
+
return {moved: false, reasonNotMoved: message};
|
|
772
|
+
}
|
|
560
773
|
}
|
|
561
774
|
}
|
|
562
775
|
|
|
@@ -648,7 +861,130 @@ export async function returnToBacklog(
|
|
|
648
861
|
note(
|
|
649
862
|
`Returned '${slug}' to backlog (released the lock; body rests in pool).`,
|
|
650
863
|
);
|
|
651
|
-
return {moved: true, commitMessage, deletedRemoteBranch};
|
|
864
|
+
return {moved: true, commitMessage, deletedRemoteBranch, reconciled};
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* The outcome of an in-progress `--reconcile` attempt (task
|
|
869
|
+
* `requeue-reconcile-nondestructive-recovery-verb`):
|
|
870
|
+
* - `'reconciled'` — mirror re-synced, rebase onto latest `<arbiter>/main`
|
|
871
|
+
* was clean AND the reconciled tip was pushed back to the arbiter. The
|
|
872
|
+
* caller falls through to the default keep+continue path.
|
|
873
|
+
* - `'no-branch'` — the arbiter's `work/<slug>` is absent or not ahead of
|
|
874
|
+
* main after the re-sync. Nothing to reconcile; the caller falls through
|
|
875
|
+
* to the default keep+continue path (which itself handles the fresh-claim
|
|
876
|
+
* case gracefully).
|
|
877
|
+
* - `'conflict'` — the rebase after the CLEAN mirror re-sync still
|
|
878
|
+
* conflicted on genuine content, or the reconciled-tip push failed. The
|
|
879
|
+
* branch is left untouched on the arbiter; the caller returns a stuck
|
|
880
|
+
* message that leads with what was tried and mentions `--reset` LAST.
|
|
881
|
+
*/
|
|
882
|
+
type ReconcileAttempt =
|
|
883
|
+
| {kind: 'reconciled'}
|
|
884
|
+
| {kind: 'no-branch'}
|
|
885
|
+
| {kind: 'conflict'; detail: string};
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* The `--reconcile` recovery attempt — the non-destructive middle rung of the
|
|
889
|
+
* `requeue` escalation ladder. Runs in a SCRATCH worktree so the caller's cwd
|
|
890
|
+
* tree/HEAD/index is NEVER touched (parity with the tree-less move machinery):
|
|
891
|
+
*
|
|
892
|
+
* 1. **Re-sync the mirror.** `git fetch --prune <arbiter>` on the caller's
|
|
893
|
+
* cwd — the exact prune step whose absence let
|
|
894
|
+
* `requeue-reset-does-not-prune-hub-mirror-stale-branch-ref` silently
|
|
895
|
+
* resurrect a supposedly-`--reset`-ed branch. This clears the stale
|
|
896
|
+
* remote-tracking residue that historically fooled the retry.
|
|
897
|
+
* 2. **Guard.** The arbiter's `work/<slug>` must EXIST + be ahead of main
|
|
898
|
+
* (via {@link branchAheadOf} on the freshly-fetched
|
|
899
|
+
* `<arbiter>/work/<slug>` vs `<arbiter>/main`). Absent / not-ahead =>
|
|
900
|
+
* `'no-branch'` and the caller falls through to the default keep+continue
|
|
901
|
+
* path.
|
|
902
|
+
* 3. **Rebase in a scratch worktree.** `git worktree add --detach <scratch>
|
|
903
|
+
* <arbiter>/work/<slug>` and run {@link rebaseContinuedBranchOntoMain}
|
|
904
|
+
* against `<arbiter>/main`. A CLEAN rebase is a full non-destructive fix;
|
|
905
|
+
* a CONFLICT is `--abort`ed (never auto-resolved) => `'conflict'`.
|
|
906
|
+
* 4. **Push the reconciled tip back.** `git push <arbiter> HEAD:work/<slug>
|
|
907
|
+
* --force-with-lease=work/<slug>:<observed-arbiter-tip>` from the scratch
|
|
908
|
+
* worktree. `--force-with-lease` ONLY, NEVER bare `--force`, NEVER
|
|
909
|
+
* `:main`, the WORK branch ONLY (ADR §11). A rejected push (stale lease
|
|
910
|
+
* or otherwise) => `'conflict'` — non-destructive by construction, the
|
|
911
|
+
* user can retry.
|
|
912
|
+
*
|
|
913
|
+
* Cleanup of the scratch worktree is best-effort in a `finally` (never fails
|
|
914
|
+
* the reconcile on cleanup).
|
|
915
|
+
*/
|
|
916
|
+
async function attemptReconcile(params: {
|
|
917
|
+
cwd: string;
|
|
918
|
+
slug: string;
|
|
919
|
+
arbiter: string;
|
|
920
|
+
env: NodeJS.ProcessEnv | undefined;
|
|
921
|
+
note: (message: string) => void;
|
|
922
|
+
}): Promise<ReconcileAttempt> {
|
|
923
|
+
const {cwd, slug, arbiter, env, note} = params;
|
|
924
|
+
const branch = workBranchRef('task', slug);
|
|
925
|
+
const arbBranchRef = `refs/remotes/${arbiter}/${branch}`;
|
|
926
|
+
const arbMainRef = `refs/remotes/${arbiter}/main`;
|
|
927
|
+
|
|
928
|
+
// 1. Re-sync the mirror to the arbiter (prune-fetch clears stale refs).
|
|
929
|
+
await gitSoftAsync(['fetch', '--prune', '--quiet', arbiter], cwd, env);
|
|
930
|
+
|
|
931
|
+
// 2. Guard: branch must exist + be ahead of main.
|
|
932
|
+
if (!branchAheadOf(cwd, arbBranchRef, arbMainRef, env)) {
|
|
933
|
+
return {kind: 'no-branch'};
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// 3. Scratch worktree — the caller's tree is NEVER touched.
|
|
937
|
+
const worktree = join(
|
|
938
|
+
tmpdir(),
|
|
939
|
+
`dorfl-reconcile-${slug}-${process.pid}-${Date.now()}`,
|
|
940
|
+
);
|
|
941
|
+
const wtCreate = gitSoftRun(
|
|
942
|
+
['worktree', 'add', '--quiet', '--detach', worktree, arbBranchRef],
|
|
943
|
+
cwd,
|
|
944
|
+
env,
|
|
945
|
+
);
|
|
946
|
+
if (wtCreate.status !== 0) {
|
|
947
|
+
return {
|
|
948
|
+
kind: 'conflict',
|
|
949
|
+
detail: `could not create scratch worktree (${wtCreate.stderr.trim() || `exit ${wtCreate.status}`})`,
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
try {
|
|
953
|
+
const rebase = rebaseContinuedBranchOntoMain(worktree, arbMainRef, env);
|
|
954
|
+
if (rebase.kind === 'conflict') {
|
|
955
|
+
return {kind: 'conflict', detail: 'rebase conflicted after re-sync'};
|
|
956
|
+
}
|
|
957
|
+
// 4. Push the reconciled tip back (`--force-with-lease`, WORK branch only).
|
|
958
|
+
const observedTip = gitSoftRun(
|
|
959
|
+
['rev-parse', '--verify', '--quiet', `${arbBranchRef}^{commit}`],
|
|
960
|
+
cwd,
|
|
961
|
+
env,
|
|
962
|
+
).stdout.trim();
|
|
963
|
+
const lease =
|
|
964
|
+
observedTip === '' ? `${branch}:` : `${branch}:${observedTip}`;
|
|
965
|
+
const push = gitSoftRun(
|
|
966
|
+
[
|
|
967
|
+
'push',
|
|
968
|
+
arbiter,
|
|
969
|
+
`HEAD:refs/heads/${branch}`,
|
|
970
|
+
`--force-with-lease=${lease}`,
|
|
971
|
+
],
|
|
972
|
+
worktree,
|
|
973
|
+
env,
|
|
974
|
+
);
|
|
975
|
+
if (push.status !== 0) {
|
|
976
|
+
return {
|
|
977
|
+
kind: 'conflict',
|
|
978
|
+
detail: `push of reconciled tip rejected (${push.stderr.trim() || `exit ${push.status}`})`,
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
// Advance the local remote-tracking ref so subsequent reads see the truth.
|
|
982
|
+
await gitSoftAsync(['fetch', '--quiet', arbiter], cwd, env);
|
|
983
|
+
void note;
|
|
984
|
+
return {kind: 'reconciled'};
|
|
985
|
+
} finally {
|
|
986
|
+
await gitSoftAsync(['worktree', 'remove', '--force', worktree], cwd, env);
|
|
987
|
+
}
|
|
652
988
|
}
|
|
653
989
|
|
|
654
990
|
/**
|
|
@@ -656,13 +992,13 @@ export async function returnToBacklog(
|
|
|
656
992
|
* `staging-pool-position-gate-and-trust-model`, task
|
|
657
993
|
* `pre-backlog-staging-folder-and-promote-step-a`, governing ADR
|
|
658
994
|
* `placement-is-runner-deterministic-humanonly-is-agent-judgement`). Moves
|
|
659
|
-
* `work/
|
|
995
|
+
* `work/tasks/backlog/<slug>.md → work/tasks/ready/<slug>.md` as a durable `main`
|
|
660
996
|
* move, the same category as {@link returnToBacklog} (tree-less CAS via
|
|
661
997
|
* {@link runTreelessLedgerMove}). After this transition the task is in the
|
|
662
998
|
* pool and claimable.
|
|
663
999
|
*
|
|
664
1000
|
* **RUNNER/human-owned.** There is no agent-facing path that performs this:
|
|
665
|
-
* the agent's tasking output lands STAGED in `work/
|
|
1001
|
+
* the agent's tasking output lands STAGED in `work/tasks/backlog/` (the runner's
|
|
666
1002
|
* deterministic placement decision), and only a runner/human invocation moves
|
|
667
1003
|
* it into the pool. The agent does no git here, as everywhere.
|
|
668
1004
|
*
|
|
@@ -670,7 +1006,7 @@ export async function returnToBacklog(
|
|
|
670
1006
|
* lands; the sole strategy publishes to `<arbiter>/main`. Like
|
|
671
1007
|
* {@link returnToBacklog} the tree-less CAS needs a ref to push to, so an
|
|
672
1008
|
* `arbiter` is REQUIRED. NEVER throws for the expected
|
|
673
|
-
* "not in
|
|
1009
|
+
* "not in tasks/backlog/" / contention-exhausted cases — it returns
|
|
674
1010
|
* `{moved: false, reasonNotMoved}` so callers can branch cleanly.
|
|
675
1011
|
*/
|
|
676
1012
|
export interface PromoteFromPreBacklogOptions {
|
|
@@ -699,7 +1035,7 @@ export interface PromoteFromPreBacklogResult {
|
|
|
699
1035
|
moved: boolean;
|
|
700
1036
|
/** When `moved`, the committed transition message. */
|
|
701
1037
|
commitMessage?: string;
|
|
702
|
-
/** When NOT moved, why (no such
|
|
1038
|
+
/** When NOT moved, why (no such staged item, already in the pool, contention). */
|
|
703
1039
|
reasonNotMoved?: string;
|
|
704
1040
|
}
|
|
705
1041
|
|
|
@@ -789,14 +1125,16 @@ export async function promoteFromPreBacklog(
|
|
|
789
1125
|
).status === 0;
|
|
790
1126
|
if (!hasSource && !hasDest) {
|
|
791
1127
|
const message =
|
|
792
|
-
`'${slug}' is not staged in
|
|
793
|
-
|
|
794
|
-
'staged?).';
|
|
1128
|
+
`'${slug}' is not staged in ${workFolderPrefix('tasks-backlog')} on ${arbiter}/main ` +
|
|
1129
|
+
`(and not already in ${workFolderPrefix('tasks-ready')}) — nothing to promote ` +
|
|
1130
|
+
'(wrong slug, or never staged?).';
|
|
795
1131
|
note(message);
|
|
796
1132
|
return {moved: false, reasonNotMoved: message};
|
|
797
1133
|
}
|
|
798
1134
|
|
|
799
|
-
const commitMessage = `chore(${slug}): promote
|
|
1135
|
+
const commitMessage = `chore(${slug}): promote ${workFolderPrefix(
|
|
1136
|
+
'tasks-backlog',
|
|
1137
|
+
)} -> ${workFolderPrefix('tasks-ready')}`;
|
|
800
1138
|
const moved = await runTreelessLedgerMove({
|
|
801
1139
|
cwd,
|
|
802
1140
|
slug,
|
|
@@ -824,7 +1162,7 @@ export async function promoteFromPreBacklog(
|
|
|
824
1162
|
base,
|
|
825
1163
|
sourceRel,
|
|
826
1164
|
destRel,
|
|
827
|
-
// The body is carried byte-for-byte from
|
|
1165
|
+
// The body is carried byte-for-byte from tasks/backlog into the
|
|
828
1166
|
// pool — promotion is a placement decision, not a content transform.
|
|
829
1167
|
transformBody: (body) => body,
|
|
830
1168
|
commitMessage,
|
|
@@ -834,14 +1172,14 @@ export async function promoteFromPreBacklog(
|
|
|
834
1172
|
},
|
|
835
1173
|
});
|
|
836
1174
|
if (moved) {
|
|
837
|
-
note(`Promoted '${slug}' from
|
|
1175
|
+
note(`Promoted '${slug}' from tasks/backlog to tasks/ready (claimable).`);
|
|
838
1176
|
return {moved: true, commitMessage};
|
|
839
1177
|
}
|
|
840
1178
|
|
|
841
1179
|
const message =
|
|
842
1180
|
`promote for '${slug}': the arbiter's main kept moving (contended) after ` +
|
|
843
|
-
`${TREELESS_CONTENTION_ATTEMPTS} attempts — item left in
|
|
844
|
-
'move). Try again shortly.';
|
|
1181
|
+
`${TREELESS_CONTENTION_ATTEMPTS} attempts — item left in tasks/backlog ` +
|
|
1182
|
+
'(no move). Try again shortly.';
|
|
845
1183
|
note(message);
|
|
846
1184
|
return {moved: false, reasonNotMoved: message};
|
|
847
1185
|
} finally {
|
|
@@ -863,7 +1201,7 @@ export async function promoteFromPreBacklog(
|
|
|
863
1201
|
* the auto-task pool, not the gates).
|
|
864
1202
|
*
|
|
865
1203
|
* **RUNNER/human-owned.** There is no agent-facing path that performs this:
|
|
866
|
-
* `intake`'s `
|
|
1204
|
+
* `intake`'s `spec` dispatch lands the spec STAGED in `work/specs/proposed/` (the
|
|
867
1205
|
* runner's deterministic placement decision), and only a runner/human
|
|
868
1206
|
* invocation moves it into the pool. The agent does no git here, as
|
|
869
1207
|
* everywhere; this function is not reachable from any agent surface.
|
|
@@ -937,7 +1275,7 @@ export async function promoteFromPreSpec(
|
|
|
937
1275
|
// (spec `prd-to-spec-vocabulary-cutover-and-migration-command`): the lock
|
|
938
1276
|
// identity is `spec:${slug}` to match the `spec-<slug>` entry the tasking/apply
|
|
939
1277
|
// path now acquires (`tasking.ts` releases under `spec:${slug}`); a stale
|
|
940
|
-
//
|
|
1278
|
+
// ''prd:${slug}'' here would key a DIFFERENT ref and break the mutual exclusion.
|
|
941
1279
|
// Loss / crash semantics mirror the task case.
|
|
942
1280
|
const item = `spec:${slug}`;
|
|
943
1281
|
const acquired = await acquireItemLock({
|
|
@@ -1036,9 +1374,9 @@ export async function promoteFromPreSpec(
|
|
|
1036
1374
|
}
|
|
1037
1375
|
}
|
|
1038
1376
|
|
|
1039
|
-
/** One staged item awaiting promotion (a task in `
|
|
1377
|
+
/** One staged item awaiting promotion (a task in `tasks/backlog/` or a spec in `specs/proposed/`). */
|
|
1040
1378
|
export interface PromotableItem {
|
|
1041
|
-
/** `'task'` (staged in `work/
|
|
1379
|
+
/** `'task'` (staged in `work/tasks/backlog/`) or `'spec'` (staged in `work/specs/proposed/`). */
|
|
1042
1380
|
namespace: 'task' | 'spec';
|
|
1043
1381
|
/** The slug (filename minus `.md`). */
|
|
1044
1382
|
slug: string;
|
|
@@ -1062,7 +1400,7 @@ export interface ListPromotableResult {
|
|
|
1062
1400
|
|
|
1063
1401
|
/**
|
|
1064
1402
|
* LIST every staged item awaiting a runner/human promotion — the tasks in
|
|
1065
|
-
* `work/
|
|
1403
|
+
* `work/tasks/backlog/` and the prds in `work/specs/proposed/` on `<arbiter>/main` (the
|
|
1066
1404
|
* discovery half of the `promote` verb, so `promote` with no argument answers
|
|
1067
1405
|
* "what is staged waiting for me?"). It reads the ARBITER's truth (a fetch + a
|
|
1068
1406
|
* tree read), NOT the local working tree (which may be stale) — the same source
|
|
@@ -1341,6 +1679,441 @@ function prepareTreelessMoveCommit(params: {
|
|
|
1341
1679
|
/** The heading that opens an appended requeue handoff note in the item body. */
|
|
1342
1680
|
const REQUEUE_HEADING_PREFIX = '## Requeue';
|
|
1343
1681
|
|
|
1682
|
+
// --- Tree-less SURFACE primitive (PR-1, spec
|
|
1683
|
+
// `surface-stuck-as-questions-and-retire-stuck-lock-state`, task
|
|
1684
|
+
// `bounce-surfaces-stuck-sidecar-and-releases-lock`) --------------------
|
|
1685
|
+
|
|
1686
|
+
/**
|
|
1687
|
+
* PR-1 ADDITIVE primitive (task `bounce-surfaces-stuck-sidecar-and-releases-lock`).
|
|
1688
|
+
* The 2-file SIBLING of {@link prepareTreelessMoveCommit}: pure git plumbing
|
|
1689
|
+
* (`hash-object` / scratch-index `update-index` / `write-tree` /
|
|
1690
|
+
* `commit-tree`, NEVER touches the caller's index/HEAD/working tree) that in
|
|
1691
|
+
* ONE commit off {@link base} both
|
|
1692
|
+
*
|
|
1693
|
+
* 1. writes or appends to the item's `work/questions/<type>-<slug>.md`
|
|
1694
|
+
* sidecar (see {@link sidecarPathFor}) a `stuck`-kind entry carrying the
|
|
1695
|
+
* bounce {@link reason} plus any agent-surfaced {@link questions}, and
|
|
1696
|
+
* 2. sets `needsAnswers: true` on the item body at {@link itemPath} (via
|
|
1697
|
+
* {@link setNeedsAnswersMarker}).
|
|
1698
|
+
*
|
|
1699
|
+
* The current sidecar (if any) and the current item body are read as BLOBS off
|
|
1700
|
+
* `<arbiter>/main` (via {@link catBlob}) — no working tree required and no
|
|
1701
|
+
* dependency on the cwd's `HEAD` matching `main`. That is what makes this the
|
|
1702
|
+
* tree-less path's surface primitive: a `applyTreelessNeedsAttentionTransition`
|
|
1703
|
+
* caller (`continue-push-failure` / rebase-conflict) has NO writable-`main`
|
|
1704
|
+
* checkout, so `persistSurfacedQuestions` (working-tree bound) cannot be reused;
|
|
1705
|
+
* the pure CONTENT builders (`newSidecar` / `appendQuestions` /
|
|
1706
|
+
* `serialiseSidecar` / `setNeedsAnswersMarker`) ARE reused, only the commit
|
|
1707
|
+
* mechanism differs (spec decision #7).
|
|
1708
|
+
*
|
|
1709
|
+
* Returns the throwaway ref + commit sha, exactly like {@link
|
|
1710
|
+
* prepareTreelessMoveCommit}, so the SAME {@link runTreelessLedgerMove} CAS loop
|
|
1711
|
+
* publishes the surface commit through the shared write seam. The
|
|
1712
|
+
* surface-first / release-second ordering + `main`-authoritative crash-safety
|
|
1713
|
+
* (spec decision #4) come FREE from routing through that loop — the caller wires
|
|
1714
|
+
* the release into a `finally` AFTER a successful publish (see the harness
|
|
1715
|
+
* {@link surfaceStuckToNeedsAttention}).
|
|
1716
|
+
*
|
|
1717
|
+
* DECISION — sidecar entry shape for a reason-only bounce (build-time, PR-1):
|
|
1718
|
+
* every bounce always appends ONE engine-authored `stuck`-kind envelope entry
|
|
1719
|
+
* whose `question` names the item and whose `context` is the {@link reason}
|
|
1720
|
+
* prose, THEN any {@link questions} the agent surfaced. So a reason-only bounce
|
|
1721
|
+
* (no agent questions) still surfaces exactly ONE entry a human can answer, and
|
|
1722
|
+
* the agent's own questions (when present) are appended AFTER it, verbatim. The
|
|
1723
|
+
* envelope entry is what turns a raw exit reason into a human-drainable
|
|
1724
|
+
* question; the extra entries are the LLM prose the spec's decision #2 keeps
|
|
1725
|
+
* untouched. Alternative considered — treating an empty `questions` array as a
|
|
1726
|
+
* NO-OP surface — REJECTED because that is the spec's `stuck` retirement
|
|
1727
|
+
* problem all over again (a bounced item with no on-`main` outcome).
|
|
1728
|
+
* Alternative considered — dropping the envelope when agent questions are given
|
|
1729
|
+
* — REJECTED because the reason is engine-authored ground truth; the agent's
|
|
1730
|
+
* questions are advisory prose ABOVE it, not a replacement for it.
|
|
1731
|
+
*
|
|
1732
|
+
* PR-1 boundary: this primitive is EXERCISED BY TESTS ONLY; it is NOT yet
|
|
1733
|
+
* called from any bounce seam. Wiring `applyNeedsAttentionTransition` /
|
|
1734
|
+
* `applyTreelessNeedsAttentionTransition` to it — and migrating the existing
|
|
1735
|
+
* `stuckLockOnArbiter(...).toBe(true)` assertions — is the follow-up PR-2 task
|
|
1736
|
+
* `bounce-atomic-cutover-retire-stuck-lock`.
|
|
1737
|
+
*/
|
|
1738
|
+
export function prepareTreelessSurfaceCommit(params: {
|
|
1739
|
+
/** The origin cwd whose object store the plumbing writes into (never a target). */
|
|
1740
|
+
cwd: string;
|
|
1741
|
+
/** The slug (used only to name the throwaway ref). */
|
|
1742
|
+
slug: string;
|
|
1743
|
+
/** The namespaced item identity (e.g. `task:foo`); drives the sidecar path. */
|
|
1744
|
+
item: string;
|
|
1745
|
+
/** The item body's on-`main` path (e.g. `work/tasks/ready/foo.md`). */
|
|
1746
|
+
itemPath: string;
|
|
1747
|
+
/** The base commit (`<arbiter>/main`) the surface commit parents on. */
|
|
1748
|
+
base: string;
|
|
1749
|
+
/** The bounce reason (envelope entry's context). */
|
|
1750
|
+
reason: string;
|
|
1751
|
+
/** Any agent-surfaced questions to append after the envelope. */
|
|
1752
|
+
questions?: NewQuestion[];
|
|
1753
|
+
/**
|
|
1754
|
+
* OPTIONAL engine-authored envelope entry OVERRIDE. When provided, replaces
|
|
1755
|
+
* the built-in generic `"<item> was bounced — how should we proceed?"`
|
|
1756
|
+
* envelope with a caller-supplied one — the seam the empty-diff bounce path
|
|
1757
|
+
* uses to guarantee a DISPOSE-DEFAULTED disposition question exists on the
|
|
1758
|
+
* surfaced sidecar (spec `surface-stuck-as-questions-and-retire-stuck-lock-state`
|
|
1759
|
+
* resolved decision #2, task
|
|
1760
|
+
* `empty-diff-bounce-surfaces-dispose-defaulted-question`). The override still
|
|
1761
|
+
* defaults its `kind` to `'stuck'` when unset. `reason` (envelope context)
|
|
1762
|
+
* remains the caller-owned prose; the override lets the caller set the
|
|
1763
|
+
* envelope's `question` + `default` so the human sees a one-glance
|
|
1764
|
+
* dispose/cancel prompt instead of the generic "how should we proceed?"
|
|
1765
|
+
* catch-all. When absent, the built-in envelope is used (the reason-only
|
|
1766
|
+
* bounce shape).
|
|
1767
|
+
*/
|
|
1768
|
+
envelope?: NewQuestion;
|
|
1769
|
+
/** The commit subject for the surface commit. */
|
|
1770
|
+
commitMessage: string;
|
|
1771
|
+
/** The throwaway ref namespace (`refs/dorfl/<refNamespace>/<slug>`). */
|
|
1772
|
+
refNamespace: string;
|
|
1773
|
+
env: NodeJS.ProcessEnv | undefined;
|
|
1774
|
+
}): {ref: string; commit: string} {
|
|
1775
|
+
const {
|
|
1776
|
+
cwd,
|
|
1777
|
+
slug,
|
|
1778
|
+
item,
|
|
1779
|
+
itemPath,
|
|
1780
|
+
base,
|
|
1781
|
+
reason,
|
|
1782
|
+
questions,
|
|
1783
|
+
envelope: envelopeOverride,
|
|
1784
|
+
commitMessage,
|
|
1785
|
+
refNamespace,
|
|
1786
|
+
env,
|
|
1787
|
+
} = params;
|
|
1788
|
+
|
|
1789
|
+
const sidecarPath = sidecarPathFor(item);
|
|
1790
|
+
|
|
1791
|
+
// Read the item body off `main` as a BLOB — never off the cwd working tree,
|
|
1792
|
+
// which the tree-less caller does not have on `main`. `catBlob` throws when
|
|
1793
|
+
// the path is not tracked, which is the honest signal: the tree-less bounce
|
|
1794
|
+
// only fires against an item whose body already rests on `main` (the caller's
|
|
1795
|
+
// plan should short-circuit `missing` beforehand).
|
|
1796
|
+
const itemBody = catBlob(`${base}:${itemPath}`, cwd, env);
|
|
1797
|
+
const flagged = setNeedsAnswersMarker(itemBody, true);
|
|
1798
|
+
|
|
1799
|
+
// Defense-in-depth (the `sidecar-without-needsAnswers` guard, mirrored from
|
|
1800
|
+
// `persistSurfacedQuestions`): if the marker did not actually parse back as
|
|
1801
|
+
// `true`, refuse to write the sidecar rather than tear the
|
|
1802
|
+
// `needsAnswers ⟺ sidecar` invariant.
|
|
1803
|
+
if (parseFrontmatter(flagged).needsAnswers !== true) {
|
|
1804
|
+
throw new Error(
|
|
1805
|
+
`prepareTreelessSurfaceCommit: could not set needsAnswers:true on '${itemPath}' ` +
|
|
1806
|
+
`for '${item}' — refusing to surface without the flag.`,
|
|
1807
|
+
);
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
// Compose the entries: an engine-authored envelope carrying the reason, then
|
|
1811
|
+
// any agent-surfaced questions (stamped `stuck`-kind if the caller left the
|
|
1812
|
+
// kind unset — this IS the stuck-surface path). Callers may OVERRIDE the
|
|
1813
|
+
// envelope (e.g. the empty-diff path swaps in a dispose-defaulted question);
|
|
1814
|
+
// the override still defaults `kind` to `stuck` and `context` to the bounce
|
|
1815
|
+
// reason when the override leaves them unset (the caller can restate the
|
|
1816
|
+
// reason in the envelope prose without duplicating it in `context`).
|
|
1817
|
+
const envelope: NewQuestion = envelopeOverride
|
|
1818
|
+
? {
|
|
1819
|
+
kind: envelopeOverride.kind ?? 'stuck',
|
|
1820
|
+
question: envelopeOverride.question,
|
|
1821
|
+
context: envelopeOverride.context ?? reason,
|
|
1822
|
+
...(envelopeOverride.default !== undefined
|
|
1823
|
+
? {default: envelopeOverride.default}
|
|
1824
|
+
: {}),
|
|
1825
|
+
}
|
|
1826
|
+
: {
|
|
1827
|
+
question: `'${item}' was bounced — how should we proceed?`,
|
|
1828
|
+
context: reason,
|
|
1829
|
+
kind: 'stuck',
|
|
1830
|
+
};
|
|
1831
|
+
const surfaced: NewQuestion[] = (questions ?? []).map((q) => ({
|
|
1832
|
+
...q,
|
|
1833
|
+
kind: q.kind ?? 'stuck',
|
|
1834
|
+
}));
|
|
1835
|
+
const additions: NewQuestion[] = [envelope, ...surfaced];
|
|
1836
|
+
|
|
1837
|
+
// APPEND to an existing sidecar on `main` (never overwrite) or CREATE it
|
|
1838
|
+
// first-pass — the same append-never-overwrite rule the working-tree surface
|
|
1839
|
+
// path enforces. Read the current sidecar off `main` as a BLOB.
|
|
1840
|
+
const sidecarExists = pathInCommit(base, sidecarPath, cwd, env);
|
|
1841
|
+
const model = sidecarExists
|
|
1842
|
+
? appendQuestions(
|
|
1843
|
+
parseSidecar(catBlob(`${base}:${sidecarPath}`, cwd, env)),
|
|
1844
|
+
additions,
|
|
1845
|
+
)
|
|
1846
|
+
: newSidecar(item, additions);
|
|
1847
|
+
const sidecarContent = serialiseSidecar(model);
|
|
1848
|
+
|
|
1849
|
+
// Hash both blobs INTO the cwd's object store (no working tree write).
|
|
1850
|
+
const itemBlob = hashObject(flagged, cwd, env);
|
|
1851
|
+
const sidecarBlob = hashObject(sidecarContent, cwd, env);
|
|
1852
|
+
|
|
1853
|
+
// A scratch index so `read-tree` / `update-index` never touch the caller's
|
|
1854
|
+
// index. `--add --cacheinfo` both ADDS a new entry and REPLACES an existing
|
|
1855
|
+
// one (so a re-surface that rewrites the sidecar path is a no-op replace).
|
|
1856
|
+
const scratchIndex = join(
|
|
1857
|
+
tmpdir(),
|
|
1858
|
+
`dorfl-${refNamespace}-${process.pid}-${Date.now()}.index`,
|
|
1859
|
+
);
|
|
1860
|
+
const withIndex: NodeJS.ProcessEnv = {
|
|
1861
|
+
...(env ?? process.env),
|
|
1862
|
+
GIT_INDEX_FILE: scratchIndex,
|
|
1863
|
+
};
|
|
1864
|
+
try {
|
|
1865
|
+
gitHard(['read-tree', base], cwd, withIndex);
|
|
1866
|
+
gitHard(
|
|
1867
|
+
[
|
|
1868
|
+
'update-index',
|
|
1869
|
+
'--add',
|
|
1870
|
+
'--cacheinfo',
|
|
1871
|
+
`100644,${itemBlob},${itemPath}`,
|
|
1872
|
+
],
|
|
1873
|
+
cwd,
|
|
1874
|
+
withIndex,
|
|
1875
|
+
);
|
|
1876
|
+
gitHard(
|
|
1877
|
+
[
|
|
1878
|
+
'update-index',
|
|
1879
|
+
'--add',
|
|
1880
|
+
'--cacheinfo',
|
|
1881
|
+
`100644,${sidecarBlob},${sidecarPath}`,
|
|
1882
|
+
],
|
|
1883
|
+
cwd,
|
|
1884
|
+
withIndex,
|
|
1885
|
+
);
|
|
1886
|
+
const tree = runHard(['write-tree'], cwd, withIndex).stdout.trim();
|
|
1887
|
+
const commit = runHard(
|
|
1888
|
+
['commit-tree', tree, '-p', base, '-m', commitMessage],
|
|
1889
|
+
cwd,
|
|
1890
|
+
env,
|
|
1891
|
+
).stdout.trim();
|
|
1892
|
+
const ref = `refs/dorfl/${refNamespace}/${slug}`;
|
|
1893
|
+
gitHard(['update-ref', ref, commit], cwd, env);
|
|
1894
|
+
return {ref, commit};
|
|
1895
|
+
} finally {
|
|
1896
|
+
rmSync(scratchIndex, {force: true});
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
export interface SurfaceStuckToNeedsAttentionOptions {
|
|
1901
|
+
/**
|
|
1902
|
+
* The working clone the move is ORIGINATED from — purely the ORIGIN SOURCE
|
|
1903
|
+
* (it resolves the arbiter remote + holds the object store the plumbing
|
|
1904
|
+
* writes into), NEVER a write TARGET. Tree-less: the cwd index/HEAD/working
|
|
1905
|
+
* tree are never touched.
|
|
1906
|
+
*/
|
|
1907
|
+
cwd: string;
|
|
1908
|
+
/** The slug of the item to surface as a stuck question. */
|
|
1909
|
+
slug: string;
|
|
1910
|
+
/**
|
|
1911
|
+
* The item body's on-`main` path (e.g. `work/tasks/ready/foo.md`). Provided
|
|
1912
|
+
* by the caller (parity with `persistSurfacedQuestions`). PR-2a made this
|
|
1913
|
+
* OPTIONAL: when absent the harness invokes {@link resolveBounceItemBodyPathOnMain}
|
|
1914
|
+
* to PROBE `<arbiter>/main` in the D1 order for the item's namespace
|
|
1915
|
+
* (task/spec/observation). A body-absent probe (item body never landed on
|
|
1916
|
+
* `main` — e.g. a claim that lost/raced) is a CLEAN NO-OP surface that STILL
|
|
1917
|
+
* RELEASES the lock: never throw, never leave a held lock, never drop the
|
|
1918
|
+
* bounce silently to a dead end.
|
|
1919
|
+
*/
|
|
1920
|
+
itemPath?: string;
|
|
1921
|
+
/** The namespaced item identity. Defaults to `task:${slug}`. */
|
|
1922
|
+
item?: string;
|
|
1923
|
+
/** Why the item is stuck (bounce reason — envelope entry's context). */
|
|
1924
|
+
reason: string;
|
|
1925
|
+
/** Any questions the agent surfaced (appended after the envelope). */
|
|
1926
|
+
questions?: NewQuestion[];
|
|
1927
|
+
/**
|
|
1928
|
+
* OPTIONAL engine-authored envelope override, forwarded to
|
|
1929
|
+
* {@link prepareTreelessSurfaceCommit}. The empty-diff bounce path uses this
|
|
1930
|
+
* to guarantee the first surfaced entry is a DISPOSE-DEFAULTED disposition
|
|
1931
|
+
* question (task `empty-diff-bounce-surfaces-dispose-defaulted-question`,
|
|
1932
|
+
* spec resolved decision #2). Absent ⇒ the built-in generic
|
|
1933
|
+
* "how should we proceed?" envelope (a reason-only bounce).
|
|
1934
|
+
*/
|
|
1935
|
+
envelope?: NewQuestion;
|
|
1936
|
+
/** The arbiter remote the surface commit is CAS-published to. REQUIRED. */
|
|
1937
|
+
arbiter: string;
|
|
1938
|
+
env?: NodeJS.ProcessEnv;
|
|
1939
|
+
note?: (message: string) => void;
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
export interface SurfaceStuckToNeedsAttentionResult {
|
|
1943
|
+
/** True iff the surface commit landed on `<arbiter>/main`. */
|
|
1944
|
+
surfaced: boolean;
|
|
1945
|
+
/** True iff the per-item lock ref was released. On a body-absent probe
|
|
1946
|
+
* (see {@link bodyAbsent}) the lock is STILL released — the bounce cannot
|
|
1947
|
+
* silently strand a held lock over an item with no `main` body. */
|
|
1948
|
+
released: boolean;
|
|
1949
|
+
/** When NOT surfaced, why (missing item, contention exhausted, body-absent probe). */
|
|
1950
|
+
reasonNotSurfaced?: string;
|
|
1951
|
+
/** True iff the D1 probe found no body on `<arbiter>/main` for this item
|
|
1952
|
+
* (task: `tasks/ready` then `tasks/backlog`; spec: `specs/ready` then
|
|
1953
|
+
* `specs/proposed`; observation: `notes/observations`). In this case
|
|
1954
|
+
* {@link surfaced} is false but {@link released} is true — the lock is
|
|
1955
|
+
* always released to prevent a dead-end held lock. */
|
|
1956
|
+
bodyAbsent?: boolean;
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
/**
|
|
1960
|
+
* The PR-1 thin HARNESS around {@link prepareTreelessSurfaceCommit}: run it
|
|
1961
|
+
* through the EXISTING {@link runTreelessLedgerMove} CAS loop, then release the
|
|
1962
|
+
* per-item lock. Ordering is LOAD-BEARING and INHERITED from that harness —
|
|
1963
|
+
* the surface commit lands on `<arbiter>/main` FIRST, and the lock release only
|
|
1964
|
+
* fires on a successful publish. `main` is authoritative on crash recovery
|
|
1965
|
+
* (spec decision #4).
|
|
1966
|
+
*
|
|
1967
|
+
* PR-1 boundary: this is EXERCISED BY TESTS ONLY. Wiring the seams to call it
|
|
1968
|
+
* is the PR-2 task `bounce-atomic-cutover-retire-stuck-lock`.
|
|
1969
|
+
*/
|
|
1970
|
+
export async function surfaceStuckToNeedsAttention(
|
|
1971
|
+
options: SurfaceStuckToNeedsAttentionOptions,
|
|
1972
|
+
): Promise<SurfaceStuckToNeedsAttentionResult> {
|
|
1973
|
+
const note = options.note ?? (() => {});
|
|
1974
|
+
// Merge of #364 (envelope) + PR-2a (itemPath is re-declared as `let` below for
|
|
1975
|
+
// the D1 probe fallback, so it must NOT be in this const destructure).
|
|
1976
|
+
const {cwd, slug, reason, questions, envelope, arbiter, env} = options;
|
|
1977
|
+
const item = options.item ?? `task:${slug}`;
|
|
1978
|
+
|
|
1979
|
+
// PR-2a D1 body-path probe: when the caller does not name an on-`main`
|
|
1980
|
+
// body path, PROBE `<arbiter>/main` in a fixed order per namespace
|
|
1981
|
+
// (task/spec/observation). A body-absent probe is a CLEAN NO-OP surface
|
|
1982
|
+
// that STILL releases the lock — never leave a bounce as a dead-end held
|
|
1983
|
+
// lock.
|
|
1984
|
+
let itemPath = options.itemPath;
|
|
1985
|
+
if (itemPath === undefined) {
|
|
1986
|
+
const probed = await resolveBounceItemBodyPathOnMain({
|
|
1987
|
+
cwd,
|
|
1988
|
+
item,
|
|
1989
|
+
arbiter,
|
|
1990
|
+
env,
|
|
1991
|
+
});
|
|
1992
|
+
if (probed === undefined) {
|
|
1993
|
+
// Body-absent: skip the surface commit entirely and STILL release the
|
|
1994
|
+
// lock (idempotent). Signal the distinction on the result so a caller
|
|
1995
|
+
// can tell a body-absent no-op apart from a `missing` plan on a
|
|
1996
|
+
// caller-provided itemPath.
|
|
1997
|
+
const rel = await releaseItemLock({item, cwd, arbiter, env});
|
|
1998
|
+
const released = rel.outcome === 'released' || rel.outcome === 'not-held';
|
|
1999
|
+
return {
|
|
2000
|
+
surfaced: false,
|
|
2001
|
+
released,
|
|
2002
|
+
bodyAbsent: true,
|
|
2003
|
+
reasonNotSurfaced:
|
|
2004
|
+
`no body for '${item}' on ${arbiter}/main (probed the D1 ` +
|
|
2005
|
+
'candidates in order) — surface skipped as a clean no-op; the ' +
|
|
2006
|
+
'lock was still released to avoid a dead-end held lock.',
|
|
2007
|
+
};
|
|
2008
|
+
}
|
|
2009
|
+
itemPath = probed;
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
const resolvedItemPath = itemPath;
|
|
2013
|
+
const surfaced = await runTreelessLedgerMove({
|
|
2014
|
+
cwd,
|
|
2015
|
+
slug,
|
|
2016
|
+
arbiter,
|
|
2017
|
+
kind: 'needs-attention',
|
|
2018
|
+
onContended: 'surface',
|
|
2019
|
+
explicitMainRefspec: true,
|
|
2020
|
+
env,
|
|
2021
|
+
note,
|
|
2022
|
+
plan: (base) => {
|
|
2023
|
+
if (!pathInCommit(base, resolvedItemPath, cwd, env)) {
|
|
2024
|
+
return 'missing';
|
|
2025
|
+
}
|
|
2026
|
+
return prepareTreelessSurfaceCommit({
|
|
2027
|
+
cwd,
|
|
2028
|
+
slug,
|
|
2029
|
+
item,
|
|
2030
|
+
itemPath: resolvedItemPath,
|
|
2031
|
+
base,
|
|
2032
|
+
reason,
|
|
2033
|
+
questions,
|
|
2034
|
+
envelope,
|
|
2035
|
+
commitMessage: `surface ${item} (stuck): ${reason}`,
|
|
2036
|
+
refNamespace: 'surface-stuck',
|
|
2037
|
+
env,
|
|
2038
|
+
});
|
|
2039
|
+
},
|
|
2040
|
+
});
|
|
2041
|
+
|
|
2042
|
+
if (!surfaced) {
|
|
2043
|
+
return {
|
|
2044
|
+
surfaced: false,
|
|
2045
|
+
released: false,
|
|
2046
|
+
reasonNotSurfaced:
|
|
2047
|
+
`surface for '${item}' did not land on ${arbiter}/main ` +
|
|
2048
|
+
'(item missing on main, or contention exhausted after retries).',
|
|
2049
|
+
};
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
// Surface-first / release-second: only reach here on a successful publish.
|
|
2053
|
+
const rel = await releaseItemLock({item, cwd, arbiter, env});
|
|
2054
|
+
const released = rel.outcome === 'released' || rel.outcome === 'not-held';
|
|
2055
|
+
return {surfaced: true, released};
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
/**
|
|
2059
|
+
* The D1 body-path probe (PR-2a task `bounce-atomic-cutover-retire-stuck-lock`,
|
|
2060
|
+
* spec `surface-stuck-as-questions-and-retire-stuck-lock-state`, decision D1):
|
|
2061
|
+
* resolve the item's on-`main` body path by probing `<arbiter>/main` in a
|
|
2062
|
+
* FIXED order per namespace:
|
|
2063
|
+
* - task: `work/tasks/ready/<slug>.md` then `work/tasks/backlog/<slug>.md`.
|
|
2064
|
+
* - spec: `work/specs/ready/<slug>.md` then `work/specs/proposed/<slug>.md`.
|
|
2065
|
+
* - observation: `work/notes/observations/<slug>.md`.
|
|
2066
|
+
*
|
|
2067
|
+
* Returns the first candidate that EXISTS on `<arbiter>/main`, or `undefined`
|
|
2068
|
+
* when no candidate exists (a bounce for an item whose body never landed on
|
|
2069
|
+
* `main`). The caller MUST handle `undefined` by STILL releasing the lock —
|
|
2070
|
+
* never leave a held lock over a body-absent item.
|
|
2071
|
+
*
|
|
2072
|
+
* The probe fetches `<arbiter>/main` with an EXPLICIT refspec (the
|
|
2073
|
+
* `runTreelessLedgerMove` pattern) so a job-worktree with a narrower default
|
|
2074
|
+
* fetch refspec still resolves `<arbiter>/main` reliably.
|
|
2075
|
+
*/
|
|
2076
|
+
export async function resolveBounceItemBodyPathOnMain(params: {
|
|
2077
|
+
cwd: string;
|
|
2078
|
+
item: string;
|
|
2079
|
+
arbiter: string;
|
|
2080
|
+
env?: NodeJS.ProcessEnv;
|
|
2081
|
+
}): Promise<string | undefined> {
|
|
2082
|
+
const {cwd, item, arbiter, env} = params;
|
|
2083
|
+
const {type, slug} = resolveSidecarIdentity(item);
|
|
2084
|
+
await gitSoftAsync(
|
|
2085
|
+
[
|
|
2086
|
+
'fetch',
|
|
2087
|
+
'--quiet',
|
|
2088
|
+
arbiter,
|
|
2089
|
+
`+refs/heads/main:refs/remotes/${arbiter}/main`,
|
|
2090
|
+
],
|
|
2091
|
+
cwd,
|
|
2092
|
+
env,
|
|
2093
|
+
);
|
|
2094
|
+
const base = `${arbiter}/main`;
|
|
2095
|
+
const folders = BOUNCE_BODY_PROBE_ORDER[type];
|
|
2096
|
+
for (const folder of folders) {
|
|
2097
|
+
const rel = workItemRel(folder, `${slug}.md`);
|
|
2098
|
+
if (pathInCommit(base, rel, cwd, env)) {
|
|
2099
|
+
return rel;
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2102
|
+
return undefined;
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
/** The FIXED per-namespace probe order for {@link resolveBounceItemBodyPathOnMain}
|
|
2106
|
+
* (D1): the FIRST candidate that exists on `<arbiter>/main` is the item's body
|
|
2107
|
+
* path. The ordering encodes the working assumption that a claimed/in-flight
|
|
2108
|
+
* item is in `ready/` and a not-yet-promoted item is in `backlog/`
|
|
2109
|
+
* (spec-tasked/proposed for a spec); an observation has one location.
|
|
2110
|
+
* Callers with an explicit `itemPath` bypass this probe entirely. */
|
|
2111
|
+
const BOUNCE_BODY_PROBE_ORDER: Record<SidecarType, readonly WorkFolderKey[]> = {
|
|
2112
|
+
task: ['tasks-ready', 'tasks-backlog'],
|
|
2113
|
+
spec: ['specs-ready', 'specs-proposed'],
|
|
2114
|
+
observation: ['observations'],
|
|
2115
|
+
};
|
|
2116
|
+
|
|
1344
2117
|
/**
|
|
1345
2118
|
* Append a dated `## Requeue YYYY-MM-DD` handoff section to an item body's TEXT
|
|
1346
2119
|
* (append-only — never overwrites; repeated requeues accumulate a handoff log).
|