dorfl 0.14.2 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/advance-ci-template.d.ts +17 -16
- package/dist/advance-ci-template.d.ts.map +1 -1
- package/dist/advance-ci-template.js +42 -53
- package/dist/advance-ci-template.js.map +1 -1
- package/dist/advance-drivers.d.ts +8 -0
- package/dist/advance-drivers.d.ts.map +1 -1
- package/dist/advance-drivers.js +28 -4
- package/dist/advance-drivers.js.map +1 -1
- package/dist/advance-isolated.d.ts.map +1 -1
- package/dist/advance-isolated.js +3 -2
- package/dist/advance-isolated.js.map +1 -1
- package/dist/advance-lifecycle-template.d.ts +41 -40
- package/dist/advance-lifecycle-template.d.ts.map +1 -1
- package/dist/advance-lifecycle-template.js +309 -489
- package/dist/advance-lifecycle-template.js.map +1 -1
- package/dist/advance-loop-driver.d.ts.map +1 -1
- package/dist/advance-loop-driver.js +3 -2
- package/dist/advance-loop-driver.js.map +1 -1
- package/dist/advance-treeless-publish.d.ts +31 -0
- package/dist/advance-treeless-publish.d.ts.map +1 -1
- package/dist/advance-treeless-publish.js +40 -0
- package/dist/advance-treeless-publish.js.map +1 -1
- package/dist/advance.d.ts +63 -3
- package/dist/advance.d.ts.map +1 -1
- package/dist/advance.js +80 -12
- package/dist/advance.js.map +1 -1
- package/dist/advancing-lock.d.ts +9 -0
- package/dist/advancing-lock.d.ts.map +1 -1
- package/dist/advancing-lock.js +21 -1
- package/dist/advancing-lock.js.map +1 -1
- package/dist/agent-env.d.ts +61 -0
- package/dist/agent-env.d.ts.map +1 -0
- package/dist/agent-env.js +144 -0
- package/dist/agent-env.js.map +1 -0
- package/dist/agent-stop.d.ts +35 -0
- package/dist/agent-stop.d.ts.map +1 -1
- package/dist/agent-stop.js +62 -0
- package/dist/agent-stop.js.map +1 -1
- package/dist/apply-merge-action.d.ts +76 -34
- package/dist/apply-merge-action.d.ts.map +1 -1
- package/dist/apply-merge-action.js +339 -129
- package/dist/apply-merge-action.js.map +1 -1
- package/dist/apply-stuck-action.js +2 -2
- package/dist/apply-stuck-action.js.map +1 -1
- package/dist/ci-agent-result.d.ts +151 -0
- package/dist/ci-agent-result.d.ts.map +1 -0
- package/dist/ci-agent-result.js +314 -0
- package/dist/ci-agent-result.js.map +1 -0
- package/dist/ci-handoff-apply.d.ts +162 -0
- package/dist/ci-handoff-apply.d.ts.map +1 -0
- package/dist/ci-handoff-apply.js +497 -0
- package/dist/ci-handoff-apply.js.map +1 -0
- package/dist/ci-handoff-format.d.ts +369 -0
- package/dist/ci-handoff-format.d.ts.map +1 -0
- package/dist/ci-handoff-format.js +646 -0
- package/dist/ci-handoff-format.js.map +1 -0
- package/dist/ci-handoff-lfs.d.ts +146 -0
- package/dist/ci-handoff-lfs.d.ts.map +1 -0
- package/dist/ci-handoff-lfs.js +397 -0
- package/dist/ci-handoff-lfs.js.map +1 -0
- package/dist/ci-handoff.d.ts +99 -0
- package/dist/ci-handoff.d.ts.map +1 -0
- package/dist/ci-handoff.js +277 -0
- package/dist/ci-handoff.js.map +1 -0
- package/dist/ci-lock-outputs.d.ts +93 -0
- package/dist/ci-lock-outputs.d.ts.map +1 -0
- package/dist/ci-lock-outputs.js +223 -0
- package/dist/ci-lock-outputs.js.map +1 -0
- package/dist/ci-phase-build.d.ts +178 -0
- package/dist/ci-phase-build.d.ts.map +1 -0
- package/dist/ci-phase-build.js +989 -0
- package/dist/ci-phase-build.js.map +1 -0
- package/dist/ci-phase-driver.d.ts +247 -0
- package/dist/ci-phase-driver.d.ts.map +1 -0
- package/dist/ci-phase-driver.js +370 -0
- package/dist/ci-phase-driver.js.map +1 -0
- package/dist/ci-phase-intake.d.ts +172 -0
- package/dist/ci-phase-intake.d.ts.map +1 -0
- package/dist/ci-phase-intake.js +555 -0
- package/dist/ci-phase-intake.js.map +1 -0
- package/dist/ci-phase-tasking.d.ts +205 -0
- package/dist/ci-phase-tasking.d.ts.map +1 -0
- package/dist/ci-phase-tasking.js +906 -0
- package/dist/ci-phase-tasking.js.map +1 -0
- package/dist/ci-phase-treeless.d.ts +224 -0
- package/dist/ci-phase-treeless.d.ts.map +1 -0
- package/dist/ci-phase-treeless.js +1482 -0
- package/dist/ci-phase-treeless.js.map +1 -0
- package/dist/ci-read-token.d.ts +47 -0
- package/dist/ci-read-token.d.ts.map +1 -0
- package/dist/ci-read-token.js +80 -0
- package/dist/ci-read-token.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +447 -22
- package/dist/cli.js.map +1 -1
- package/dist/close-job-template.d.ts +10 -1
- package/dist/close-job-template.d.ts.map +1 -1
- package/dist/close-job-template.js +46 -14
- package/dist/close-job-template.js.map +1 -1
- package/dist/close-job.d.ts +20 -5
- package/dist/close-job.d.ts.map +1 -1
- package/dist/close-job.js +65 -12
- package/dist/close-job.js.map +1 -1
- package/dist/complete.d.ts.map +1 -1
- package/dist/complete.js +20 -1
- package/dist/complete.js.map +1 -1
- package/dist/config.d.ts +41 -44
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +2 -4
- package/dist/config.js.map +1 -1
- package/dist/continue-branch.d.ts +63 -15
- package/dist/continue-branch.d.ts.map +1 -1
- package/dist/continue-branch.js +75 -8
- package/dist/continue-branch.js.map +1 -1
- package/dist/crashed-run-save.d.ts +73 -0
- package/dist/crashed-run-save.d.ts.map +1 -0
- package/dist/crashed-run-save.js +86 -0
- package/dist/crashed-run-save.js.map +1 -0
- package/dist/cwd-section.d.ts.map +1 -1
- package/dist/cwd-section.js +10 -2
- package/dist/cwd-section.js.map +1 -1
- package/dist/decision-engine.d.ts +10 -0
- package/dist/decision-engine.d.ts.map +1 -1
- package/dist/decision-engine.js.map +1 -1
- package/dist/do-config.d.ts +2 -24
- package/dist/do-config.d.ts.map +1 -1
- package/dist/do-config.js +1 -33
- package/dist/do-config.js.map +1 -1
- package/dist/do.d.ts +149 -1
- package/dist/do.d.ts.map +1 -1
- package/dist/do.js +115 -25
- package/dist/do.js.map +1 -1
- package/dist/dorfl-item-template.d.ts +98 -0
- package/dist/dorfl-item-template.d.ts.map +1 -0
- package/dist/dorfl-item-template.js +504 -0
- package/dist/dorfl-item-template.js.map +1 -0
- package/dist/env-config.d.ts.map +1 -1
- package/dist/env-config.js +7 -5
- package/dist/env-config.js.map +1 -1
- package/dist/frontmatter.d.ts +52 -0
- package/dist/frontmatter.d.ts.map +1 -1
- package/dist/frontmatter.js +136 -2
- package/dist/frontmatter.js.map +1 -1
- package/dist/gc.d.ts +13 -0
- package/dist/gc.d.ts.map +1 -1
- package/dist/gc.js +16 -2
- package/dist/gc.js.map +1 -1
- package/dist/git.d.ts +18 -1
- package/dist/git.d.ts.map +1 -1
- package/dist/git.js +70 -5
- package/dist/git.js.map +1 -1
- package/dist/harness.d.ts +44 -5
- package/dist/harness.d.ts.map +1 -1
- package/dist/harness.js +24 -1
- package/dist/harness.js.map +1 -1
- package/dist/index.d.ts +11 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -3
- package/dist/index.js.map +1 -1
- package/dist/install-ci-action-pins.d.ts +123 -0
- package/dist/install-ci-action-pins.d.ts.map +1 -0
- package/dist/install-ci-action-pins.js +190 -0
- package/dist/install-ci-action-pins.js.map +1 -0
- package/dist/install-ci-actions-pr-creation.d.ts +70 -0
- package/dist/install-ci-actions-pr-creation.d.ts.map +1 -0
- package/dist/install-ci-actions-pr-creation.js +106 -0
- package/dist/install-ci-actions-pr-creation.js.map +1 -0
- package/dist/install-ci-capabilities/dorfl-item.d.ts +12 -0
- package/dist/install-ci-capabilities/dorfl-item.d.ts.map +1 -0
- package/dist/install-ci-capabilities/dorfl-item.js +26 -0
- package/dist/install-ci-capabilities/dorfl-item.js.map +1 -0
- package/dist/install-ci-core.d.ts +155 -37
- package/dist/install-ci-core.d.ts.map +1 -1
- package/dist/install-ci-core.js +304 -200
- package/dist/install-ci-core.js.map +1 -1
- package/dist/install-ci-github.d.ts +47 -2
- package/dist/install-ci-github.d.ts.map +1 -1
- package/dist/install-ci-github.js +113 -1
- package/dist/install-ci-github.js.map +1 -1
- package/dist/install-ci.d.ts +25 -6
- package/dist/install-ci.d.ts.map +1 -1
- package/dist/install-ci.js +72 -36
- package/dist/install-ci.js.map +1 -1
- package/dist/intake-marker.d.ts +6 -1
- package/dist/intake-marker.d.ts.map +1 -1
- package/dist/intake-marker.js.map +1 -1
- package/dist/intake-triage.d.ts.map +1 -1
- package/dist/intake-triage.js +6 -2
- package/dist/intake-triage.js.map +1 -1
- package/dist/intake-trigger-template.d.ts +31 -25
- package/dist/intake-trigger-template.d.ts.map +1 -1
- package/dist/intake-trigger-template.js +107 -292
- package/dist/intake-trigger-template.js.map +1 -1
- package/dist/intake.d.ts +186 -4
- package/dist/intake.d.ts.map +1 -1
- package/dist/intake.js +533 -416
- package/dist/intake.js.map +1 -1
- package/dist/integration-core.d.ts +154 -1
- package/dist/integration-core.d.ts.map +1 -1
- package/dist/integration-core.js +759 -486
- package/dist/integration-core.js.map +1 -1
- package/dist/integrator.d.ts +12 -0
- package/dist/integrator.d.ts.map +1 -1
- package/dist/integrator.js +21 -0
- package/dist/integrator.js.map +1 -1
- package/dist/isolation.d.ts +12 -2
- package/dist/isolation.d.ts.map +1 -1
- package/dist/isolation.js +19 -8
- package/dist/isolation.js.map +1 -1
- package/dist/item-lock.d.ts +93 -11
- package/dist/item-lock.d.ts.map +1 -1
- package/dist/item-lock.js +233 -65
- package/dist/item-lock.js.map +1 -1
- package/dist/land-question-residue.d.ts +58 -0
- package/dist/land-question-residue.d.ts.map +1 -0
- package/dist/land-question-residue.js +95 -0
- package/dist/land-question-residue.js.map +1 -0
- package/dist/lifecycle-gather.d.ts +16 -0
- package/dist/lifecycle-gather.d.ts.map +1 -1
- package/dist/lifecycle-gather.js +54 -6
- package/dist/lifecycle-gather.js.map +1 -1
- package/dist/merge-answer.d.ts +41 -0
- package/dist/merge-answer.d.ts.map +1 -0
- package/dist/merge-answer.js +60 -0
- package/dist/merge-answer.js.map +1 -0
- package/dist/merge-question-surfacer.d.ts +105 -30
- package/dist/merge-question-surfacer.d.ts.map +1 -1
- package/dist/merge-question-surfacer.js +202 -91
- package/dist/merge-question-surfacer.js.map +1 -1
- package/dist/merge-question-tick.d.ts +64 -0
- package/dist/merge-question-tick.d.ts.map +1 -0
- package/dist/merge-question-tick.js +96 -0
- package/dist/merge-question-tick.js.map +1 -0
- package/dist/mint-adr.d.ts.map +1 -1
- package/dist/mint-adr.js +20 -2
- package/dist/mint-adr.js.map +1 -1
- package/dist/needs-attention.d.ts +72 -2
- package/dist/needs-attention.d.ts.map +1 -1
- package/dist/needs-attention.js +275 -38
- package/dist/needs-attention.js.map +1 -1
- package/dist/phase-recorder.d.ts +168 -0
- package/dist/phase-recorder.d.ts.map +1 -0
- package/dist/phase-recorder.js +218 -0
- package/dist/phase-recorder.js.map +1 -0
- package/dist/phase.d.ts +94 -0
- package/dist/phase.d.ts.map +1 -0
- package/dist/phase.js +148 -0
- package/dist/phase.js.map +1 -0
- package/dist/pi-harness.d.ts.map +1 -1
- package/dist/pi-harness.js +19 -6
- package/dist/pi-harness.js.map +1 -1
- package/dist/prepare.d.ts.map +1 -1
- package/dist/prepare.js +4 -0
- package/dist/prepare.js.map +1 -1
- package/dist/prompt.d.ts +4 -0
- package/dist/prompt.d.ts.map +1 -1
- package/dist/prompt.js +48 -1
- package/dist/prompt.js.map +1 -1
- package/dist/protocol/CLAIM-PROTOCOL.md +3 -1
- package/dist/protocol/WORK-CONTRACT.md +1 -1
- package/dist/reap-branches.d.ts +48 -0
- package/dist/reap-branches.d.ts.map +1 -1
- package/dist/reap-branches.js +64 -0
- package/dist/reap-branches.js.map +1 -1
- package/dist/ref-write.d.ts +198 -0
- package/dist/ref-write.d.ts.map +1 -0
- package/dist/ref-write.js +79 -0
- package/dist/ref-write.js.map +1 -0
- package/dist/repo-config.js +8 -8
- package/dist/repo-config.js.map +1 -1
- package/dist/repo-mirror.d.ts +36 -0
- package/dist/repo-mirror.d.ts.map +1 -1
- package/dist/repo-mirror.js +91 -1
- package/dist/repo-mirror.js.map +1 -1
- package/dist/review-verdict.d.ts +1 -1
- package/dist/review-verdict.js +1 -1
- package/dist/run.d.ts +7 -1
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +28 -4
- package/dist/run.js.map +1 -1
- package/dist/scan.d.ts +19 -1
- package/dist/scan.d.ts.map +1 -1
- package/dist/scan.js +21 -8
- package/dist/scan.js.map +1 -1
- package/dist/sidecar.d.ts +14 -0
- package/dist/sidecar.d.ts.map +1 -1
- package/dist/sidecar.js +21 -0
- package/dist/sidecar.js.map +1 -1
- package/dist/single-job-warning.d.ts +63 -0
- package/dist/single-job-warning.d.ts.map +1 -0
- package/dist/single-job-warning.js +110 -0
- package/dist/single-job-warning.js.map +1 -0
- package/dist/skills/setup/protocol/CLAIM-PROTOCOL.md +3 -1
- package/dist/skills/setup/protocol/WORK-CONTRACT.md +1 -1
- package/dist/spec-complete.d.ts +13 -6
- package/dist/spec-complete.d.ts.map +1 -1
- package/dist/spec-complete.js +23 -11
- package/dist/spec-complete.js.map +1 -1
- package/dist/start.d.ts.map +1 -1
- package/dist/start.js +47 -14
- package/dist/start.js.map +1 -1
- package/dist/status.d.ts +12 -0
- package/dist/status.d.ts.map +1 -1
- package/dist/status.js +11 -3
- package/dist/status.js.map +1 -1
- package/dist/tasking.d.ts +246 -1
- package/dist/tasking.d.ts.map +1 -1
- package/dist/tasking.js +452 -358
- package/dist/tasking.js.map +1 -1
- package/dist/triage-persist.d.ts.map +1 -1
- package/dist/triage-persist.js +21 -2
- package/dist/triage-persist.js.map +1 -1
- package/dist/verify-workflow-template.d.ts.map +1 -1
- package/dist/verify-workflow-template.js +8 -4
- package/dist/verify-workflow-template.js.map +1 -1
- package/dist/verify.d.ts.map +1 -1
- package/dist/verify.js +4 -0
- package/dist/verify.js.map +1 -1
- package/dist/watch-session.d.ts +42 -13
- package/dist/watch-session.d.ts.map +1 -1
- package/dist/watch-session.js +52 -23
- package/dist/watch-session.js.map +1 -1
- package/dist/work-layout.d.ts +10 -4
- package/dist/work-layout.d.ts.map +1 -1
- package/dist/work-layout.js +11 -3
- package/dist/work-layout.js.map +1 -1
- package/dist/workspace.d.ts +65 -4
- package/dist/workspace.d.ts.map +1 -1
- package/dist/workspace.js +159 -11
- package/dist/workspace.js.map +1 -1
- package/package.json +1 -1
- package/src/advance-ci-template.ts +80 -81
- package/src/advance-drivers.ts +45 -6
- package/src/advance-isolated.ts +3 -5
- package/src/advance-lifecycle-template.ts +375 -538
- package/src/advance-loop-driver.ts +3 -5
- package/src/advance-treeless-publish.ts +61 -0
- package/src/advance.ts +145 -15
- package/src/advancing-lock.ts +36 -1
- package/src/agent-env.ts +174 -0
- package/src/agent-stop.ts +81 -0
- package/src/apply-merge-action.ts +401 -154
- package/src/apply-stuck-action.ts +2 -2
- package/src/ci-agent-result.ts +421 -0
- package/src/ci-handoff-apply.ts +737 -0
- package/src/ci-handoff-format.ts +926 -0
- package/src/ci-handoff-lfs.ts +519 -0
- package/src/ci-handoff.ts +392 -0
- package/src/ci-lock-outputs.ts +273 -0
- package/src/ci-phase-build.ts +1328 -0
- package/src/ci-phase-driver.ts +548 -0
- package/src/ci-phase-intake.ts +861 -0
- package/src/ci-phase-tasking.ts +1255 -0
- package/src/ci-phase-treeless.ts +2028 -0
- package/src/ci-read-token.ts +90 -0
- package/src/cli.ts +591 -29
- package/src/close-job-template.ts +52 -15
- package/src/close-job.ts +77 -12
- package/src/complete.ts +19 -1
- package/src/config.ts +43 -48
- package/src/continue-branch.ts +121 -16
- package/src/crashed-run-save.ts +162 -0
- package/src/cwd-section.ts +10 -0
- package/src/decision-engine.ts +10 -0
- package/src/do-config.ts +2 -57
- package/src/do.ts +170 -28
- package/src/dorfl-item-template.ts +556 -0
- package/src/env-config.ts +7 -5
- package/src/frontmatter.ts +155 -2
- package/src/gc.ts +27 -2
- package/src/git.ts +83 -5
- package/src/harness.ts +58 -6
- package/src/index.ts +50 -1
- package/src/install-ci-action-pins.ts +209 -0
- package/src/install-ci-actions-pr-creation.ts +186 -0
- package/src/install-ci-capabilities/dorfl-item.ts +33 -0
- package/src/install-ci-core.ts +388 -233
- package/src/install-ci-github.ts +149 -1
- package/src/install-ci.ts +83 -37
- package/src/intake-marker.ts +6 -1
- package/src/intake-triage.ts +7 -3
- package/src/intake-trigger-template.ts +126 -327
- package/src/intake.ts +725 -544
- package/src/integration-core.ts +975 -523
- package/src/integrator.ts +28 -0
- package/src/isolation.ts +29 -10
- package/src/item-lock.ts +308 -92
- package/src/land-question-residue.ts +122 -0
- package/src/lifecycle-gather.ts +82 -10
- package/src/merge-answer.ts +82 -0
- package/src/merge-question-surfacer.ts +320 -112
- package/src/merge-question-tick.ts +182 -0
- package/src/mint-adr.ts +28 -2
- package/src/needs-attention.ts +350 -42
- package/src/phase-recorder.ts +350 -0
- package/src/phase.ts +177 -0
- package/src/pi-harness.ts +25 -5
- package/src/prepare.ts +4 -0
- package/src/prompt.ts +68 -1
- package/src/reap-branches.ts +107 -0
- package/src/ref-write.ts +324 -0
- package/src/repo-config.ts +8 -8
- package/src/repo-mirror.ts +107 -1
- package/src/review-verdict.ts +1 -1
- package/src/run.ts +47 -6
- package/src/scan.ts +37 -7
- package/src/sidecar.ts +36 -0
- package/src/single-job-warning.ts +135 -0
- package/src/spec-complete.ts +36 -14
- package/src/start.ts +55 -16
- package/src/status.ts +29 -3
- package/src/tasking.ts +593 -378
- package/src/triage-persist.ts +24 -2
- package/src/verify-workflow-template.ts +8 -4
- package/src/verify.ts +4 -0
- package/src/watch-session.ts +86 -28
- package/src/work-layout.ts +11 -3
- package/src/workspace.ts +210 -14
|
@@ -0,0 +1,1482 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **The tree-less rungs split into the three CI phases** (spec
|
|
3
|
+
* `ci-agent-job-without-write-token` §3 surface, triage and apply rows, ADR
|
|
4
|
+
* `ci-agent-job-holds-no-write-token`, task `ci-split-treeless-rungs`).
|
|
5
|
+
*
|
|
6
|
+
* `advance <item> --phase` on a `surface`, `triage-observation` or `apply`
|
|
7
|
+
* rung runs ONE of three halves of the one rung body in `advance.ts`
|
|
8
|
+
* (`triageRung`, `surfaceRung`, `applyRung` with `applyAgenticDecision` and
|
|
9
|
+
* `maybeRunStuckAction`), never a second implementation. The phase boundary is
|
|
10
|
+
* the rung's existing injectable seams: its agent seams (`surfaceGate`,
|
|
11
|
+
* `triageGate`, `applyDecide`) on one side, its write seams (`surfacePersist`,
|
|
12
|
+
* `applyPersist`, `stampTriaged`, `autoDisposition`, `promote`, `mintAdr`,
|
|
13
|
+
* `stuckAction`) on the other.
|
|
14
|
+
*
|
|
15
|
+
* - **lock** ({@link performTreelessLockPhase}): fetch `<arbiter>/main` (its
|
|
16
|
+
* tip is `baseSha`); in a throwaway worktree of it, classify the item and
|
|
17
|
+
* work out whether the rung launches an agent (`treelessAgentNeed`, decision
|
|
18
|
+
* 11: the triage back-fill and settled no-op, the surface short-circuit, a
|
|
19
|
+
* `kind: stuck` answer and a task/spec content answer need none); take the
|
|
20
|
+
* advancing lock (the unified per-item lock, `action: advance`); publish
|
|
21
|
+
* `acquired`, `needsAgent`, `rung`, `baseSha`, `lockSha`, `handoffName` and
|
|
22
|
+
* `agentTimeoutMinutes`. No agent, no repository code. An answered
|
|
23
|
+
* `kind: merge` entry answered `merge` also publishes `continueTip`, the
|
|
24
|
+
* kept `work/task-<slug>` tip it observed, and needs the agent job only when
|
|
25
|
+
* that branch carries work `main` lacks (task
|
|
26
|
+
* `ci-split-answered-merge-action`).
|
|
27
|
+
* - **agent** ({@link performTreelessAgentPhase}): check, read-only, that the
|
|
28
|
+
* lock ref still equals `lockSha`; run the rung body in a worktree of
|
|
29
|
+
* `baseSha` with the REAL agent seams, each wrapped to capture what the agent
|
|
30
|
+
* emitted, and every write seam replaced by a stub that writes nothing; hand
|
|
31
|
+
* over what the agents decided: `surface` (the surface questions), `triage`
|
|
32
|
+
* (the triage gate's disposition, or `keep` plus the surface questions on
|
|
33
|
+
* the fall-through) or `apply-decision` (the agentic verdict). For an
|
|
34
|
+
* answered merge the rung's merge-action seam runs the agent half of the
|
|
35
|
+
* land instead (`prepareMergeLand`: the `createJob` checkout, the LOCAL
|
|
36
|
+
* rebase, the optional `strictMergeApproval` re-stale check, the
|
|
37
|
+
* fresh-worktree gate on the rebased tip) and hands over `integrate` (the
|
|
38
|
+
* rebased tip bundled), `merge-restale` or `needs-attention`. Writes nothing
|
|
39
|
+
* to the arbiter. A rung that fails writes no handoff and fails the job.
|
|
40
|
+
* - **apply** ({@link performTreelessApplyPhase}): refuse to write unless the
|
|
41
|
+
* lock ref still equals `lockSha`; act on the agent job's result first
|
|
42
|
+
* (`failure` / timeout surfaces the item, a real cancel only releases the
|
|
43
|
+
* lock, `skipped` with `needsAgent: false` runs the deterministic rung); read
|
|
44
|
+
* the handoff as hostile (`readHandoff`: the item, the rung's intent kind,
|
|
45
|
+
* every enum and every slug); then, on a FRESH checkout of `<arbiter>/main`,
|
|
46
|
+
* re-classify the item (a rung or agent need that changed is `stale`, nothing
|
|
47
|
+
* written) and run the SAME rung body with the agent seams replaced by
|
|
48
|
+
* replays of the checked handoff and the real write seams, so the commits are
|
|
49
|
+
* today's; check the publish carries only the rung's own commit
|
|
50
|
+
* (`checkTreelessPublishScope`), publish it (`publishTreelessResult`), and
|
|
51
|
+
* release the lock, leased on `lockSha`. It launches no agent: a gate the
|
|
52
|
+
* handoff does not answer throws. An answered merge's handoff is validated
|
|
53
|
+
* as a bundle (`validateApplyHandoff`, the trusted merge mode); its apply
|
|
54
|
+
* pushes the bundle's LFS objects, then the rebased branch leased on
|
|
55
|
+
* `continueTip` (a stale lease writes nothing), lands it through the
|
|
56
|
+
* unchanged compare-and-swap loop (`integrationLand`, with the gated tip),
|
|
57
|
+
* and only then records the answer on a fresh checkout of the new `main`
|
|
58
|
+
* (two writes to `main`, as on the laptop).
|
|
59
|
+
*/
|
|
60
|
+
import { answersMerge, defaultRungExecutor, findItemPath, readItemSignals, sidecarTypeFor, treelessAgentNeed, } from './advance.js';
|
|
61
|
+
import { classifyTick } from './advance-classify.js';
|
|
62
|
+
import { DEFAULT_TREELESS_JITTER_MS, checkTreelessPublishScope, } from './advance-treeless-publish.js';
|
|
63
|
+
import { acquireAdvancingLock } from './advancing-lock.js';
|
|
64
|
+
import { detectAnsweredMergeAction, performMergeAction, prepareMergeLand, } from './apply-merge-action.js';
|
|
65
|
+
import { HANDOFF_LIMITS, HandoffRejected, canonicalHandoffItem, handoffName as deriveHandoffName, } from './ci-handoff-format.js';
|
|
66
|
+
import { readHandoff, writeHandoff } from './ci-handoff.js';
|
|
67
|
+
import { validateApplyHandoff } from './ci-handoff-apply.js';
|
|
68
|
+
import { readLockOutputsFromEnv } from './ci-lock-outputs.js';
|
|
69
|
+
import { PhaseDriverError, agentTimeoutMinutesAt, arbiterLockSha, arbiterRefSha, boundHandoffText, acquireNotingOnce, checkLockOwnership, emitLockOutputs, fetchArbiterMain, noSmudgeEnv, pushHandoffLfs, releaseLockLeased, requireHeldLock, resolveAgentResult, runnerTempFrom, taskSourceAtBase, withBaseWorktree, } from './ci-phase-driver.js';
|
|
70
|
+
import { harnessApplyDecider } from './apply-decide.js';
|
|
71
|
+
import { runAsync } from './git.js';
|
|
72
|
+
import { identityEnv } from './identity.js';
|
|
73
|
+
import { integrationLand } from './integration-core.js';
|
|
74
|
+
import { leaseLockReleases } from './item-lock.js';
|
|
75
|
+
import { ledgerRead } from './ledger-read.js';
|
|
76
|
+
import { ledgerWrite } from './ledger-write.js';
|
|
77
|
+
import { surfaceStuckToNeedsAttention } from './needs-attention.js';
|
|
78
|
+
import { createPhaseRecorder, runAgentPhase } from './phase-recorder.js';
|
|
79
|
+
import { refWrite } from './ref-write.js';
|
|
80
|
+
import { sidecarPathFor } from './sidecar.js';
|
|
81
|
+
import { ensureSafeSlug } from './slug-safety.js';
|
|
82
|
+
import { SlugResolutionError, parseSlugArg, resolveAdvanceArg, workBranchRef, } from './slug-namespace.js';
|
|
83
|
+
import { harnessSurfaceGate, } from './surface-gate.js';
|
|
84
|
+
import { harnessTriageGate } from './triage-gate.js';
|
|
85
|
+
const DEFAULT_ARBITER = 'origin';
|
|
86
|
+
/** The tree-less rungs this module splits. */
|
|
87
|
+
export const TREELESS_PHASE_RUNGS = [
|
|
88
|
+
'surface',
|
|
89
|
+
'triage-observation',
|
|
90
|
+
'apply',
|
|
91
|
+
];
|
|
92
|
+
/** Whether `rung` is one of the tree-less rungs this module splits. */
|
|
93
|
+
export function isTreelessPhaseRung(rung) {
|
|
94
|
+
return TREELESS_PHASE_RUNGS.includes(rung ?? '');
|
|
95
|
+
}
|
|
96
|
+
/** The handoff intent each tree-less rung hands over. */
|
|
97
|
+
const INTENT_FOR_RUNG = {
|
|
98
|
+
surface: 'surface',
|
|
99
|
+
'triage-observation': 'triage',
|
|
100
|
+
apply: 'apply-decision',
|
|
101
|
+
};
|
|
102
|
+
/**
|
|
103
|
+
* Run one phase of a tree-less rung. The CLI calls this for `advance <item>
|
|
104
|
+
* --phase` when the item classifies (lock) or was classified (agent, apply) on
|
|
105
|
+
* the `surface`, `triage-observation` or `apply` rung.
|
|
106
|
+
*/
|
|
107
|
+
export async function performTreelessPhase(options) {
|
|
108
|
+
try {
|
|
109
|
+
switch (options.phase) {
|
|
110
|
+
case 'lock':
|
|
111
|
+
return await performTreelessLockPhase(options);
|
|
112
|
+
case 'agent':
|
|
113
|
+
return await performTreelessAgentPhase(options);
|
|
114
|
+
case 'apply':
|
|
115
|
+
return await performTreelessApplyPhase(options);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
if (err instanceof PhaseDriverError || err instanceof SlugResolutionError) {
|
|
120
|
+
return { exitCode: 1, outcome: 'usage-error', message: err.message };
|
|
121
|
+
}
|
|
122
|
+
throw err;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/** The item a tree-less phase acts on, from the (trusted) workflow argument. */
|
|
126
|
+
function treelessItem(options, repoPath) {
|
|
127
|
+
const resolved = resolveAdvanceArg({
|
|
128
|
+
arg: options.arg,
|
|
129
|
+
repoPath,
|
|
130
|
+
read: options.read ?? ledgerRead,
|
|
131
|
+
});
|
|
132
|
+
return {
|
|
133
|
+
namespace: resolved.namespace,
|
|
134
|
+
slug: resolved.slug,
|
|
135
|
+
item: `${resolved.namespace}:${resolved.slug}`,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
/** Classify `resolved` in the tree at `dir` (read-only, no model). */
|
|
139
|
+
function classifyIn(dir, resolved) {
|
|
140
|
+
const type = sidecarTypeFor(resolved.namespace);
|
|
141
|
+
const signals = readItemSignals({
|
|
142
|
+
repoPath: dir,
|
|
143
|
+
type,
|
|
144
|
+
slug: resolved.slug,
|
|
145
|
+
item: resolved.item,
|
|
146
|
+
});
|
|
147
|
+
return classifyTick({ type, ...signals });
|
|
148
|
+
}
|
|
149
|
+
/** The rung input for `resolved` with `context`. */
|
|
150
|
+
function rungInput(resolved, classification, context) {
|
|
151
|
+
return {
|
|
152
|
+
item: resolved.item,
|
|
153
|
+
namespace: resolved.namespace,
|
|
154
|
+
slug: resolved.slug,
|
|
155
|
+
classification,
|
|
156
|
+
context,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* The kept `work/task-<slug>` tip of an answered merge, when it carries work
|
|
161
|
+
* `mainSha` lacks; `undefined` when the branch is absent or already on `main`
|
|
162
|
+
* (nothing to land: the laptop's `already-integrated`). Reads the arbiter and
|
|
163
|
+
* fetches the branch's objects only: nothing is checked out and no repository
|
|
164
|
+
* code runs, so the lock phase may call it.
|
|
165
|
+
*/
|
|
166
|
+
async function keptMergeTip(params) {
|
|
167
|
+
const { cwd, arbiter, slug, mainSha, env } = params;
|
|
168
|
+
const branch = workBranchRef('task', slug);
|
|
169
|
+
const ref = `refs/heads/${branch}`;
|
|
170
|
+
if ((await arbiterRefSha({ cwd, arbiter, ref, env })) === undefined) {
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
const tracking = `refs/remotes/${arbiter}/${branch}`;
|
|
174
|
+
const fetched = await runAsync('git', ['fetch', '--quiet', '--no-tags', arbiter, `+${ref}:${tracking}`], cwd, { env });
|
|
175
|
+
if (fetched.status !== 0) {
|
|
176
|
+
throw new Error(`fetching ${branch} from ${arbiter} failed: ${fetched.stderr.trim()}`);
|
|
177
|
+
}
|
|
178
|
+
const tip = (await runAsync('git', ['rev-parse', '--verify', tracking], cwd, { env })).stdout.trim();
|
|
179
|
+
const onMain = await runAsync('git', ['merge-base', '--is-ancestor', tip, mainSha], cwd, { env });
|
|
180
|
+
return onMain.status === 0 ? undefined : tip;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* The agent need of a tree-less rung as the CI phases see it:
|
|
184
|
+
* {@link treelessAgentNeed} (the tree), refined for an answered `merge` by the
|
|
185
|
+
* arbiter's kept branch ({@link keptMergeTip}), which the tree cannot show. The
|
|
186
|
+
* lock phase publishes the result (and the branch tip as `continueTip`); the
|
|
187
|
+
* apply phase recomputes it on its fresh checkout.
|
|
188
|
+
*/
|
|
189
|
+
async function treelessPhaseNeed(input, params) {
|
|
190
|
+
const need = treelessAgentNeed(input);
|
|
191
|
+
if (need.needsAgent !== true || input.classification.kind !== 'apply') {
|
|
192
|
+
return { need };
|
|
193
|
+
}
|
|
194
|
+
const merge = detectAnsweredMergeAction(input.context.cwd, input.item);
|
|
195
|
+
if (merge === undefined)
|
|
196
|
+
return { need };
|
|
197
|
+
const tip = await keptMergeTip({ ...params, slug: input.slug });
|
|
198
|
+
return tip === undefined
|
|
199
|
+
? { need: { needsAgent: false } }
|
|
200
|
+
: { need, continueTip: tip };
|
|
201
|
+
}
|
|
202
|
+
/** Dispatch the tree-less rung to its body. */
|
|
203
|
+
function dispatchTreeless(executor, rung, input) {
|
|
204
|
+
switch (rung) {
|
|
205
|
+
case 'triage-observation':
|
|
206
|
+
return executor.triageObservation(input);
|
|
207
|
+
case 'surface':
|
|
208
|
+
return executor.surface(input);
|
|
209
|
+
case 'apply':
|
|
210
|
+
return executor.apply(input);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/** The held lock of an agent or apply phase, whose rung must be a tree-less one. */
|
|
214
|
+
function requireTreelessLock(options, env) {
|
|
215
|
+
const facts = options.lockOutputs ?? readLockOutputsFromEnv(env);
|
|
216
|
+
if (!isTreelessPhaseRung(facts.rung)) {
|
|
217
|
+
throw new PhaseDriverError(`the lock job classified the rung ${String(facts.rung)}, not a tree-less rung`);
|
|
218
|
+
}
|
|
219
|
+
const held = requireHeldLock({ lockOutputs: facts, env, rung: facts.rung });
|
|
220
|
+
return { ...held, rung: facts.rung };
|
|
221
|
+
}
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
// lock
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
/**
|
|
226
|
+
* The lock phase: classify the item at the arbiter's `main`, take the advancing
|
|
227
|
+
* lock and publish the trusted facts. Runs no agent and no repository code.
|
|
228
|
+
*/
|
|
229
|
+
export async function performTreelessLockPhase(options) {
|
|
230
|
+
const note = options.note ?? (() => { });
|
|
231
|
+
const arbiter = options.arbiter ?? DEFAULT_ARBITER;
|
|
232
|
+
const cwd = options.cwd;
|
|
233
|
+
const env = identityEnv(options.identity, options.env ?? process.env);
|
|
234
|
+
const githubOutput = options.githubOutput ?? env.GITHUB_OUTPUT;
|
|
235
|
+
const publish = (facts) => {
|
|
236
|
+
emitLockOutputs(facts, { githubOutput, note });
|
|
237
|
+
return facts;
|
|
238
|
+
};
|
|
239
|
+
const baseSha = await fetchArbiterMain({ cwd, arbiter, env });
|
|
240
|
+
const verdict = await withBaseWorktree({ cwd, baseSha, env }, async (base) => {
|
|
241
|
+
const resolved = treelessItem(options, base);
|
|
242
|
+
const classification = classifyIn(base, resolved);
|
|
243
|
+
const kind = classification.kind;
|
|
244
|
+
if (kind === 'no-op') {
|
|
245
|
+
return {
|
|
246
|
+
resolved,
|
|
247
|
+
skip: `no-op for ${resolved.item} at ${baseSha} (${classification.reason ?? 'nothing to advance'})`,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
if (kind === 'invariant-violation') {
|
|
251
|
+
return {
|
|
252
|
+
resolved,
|
|
253
|
+
invariant: `refusing to advance ${resolved.item}: the \`needsAnswers\` flag and ` +
|
|
254
|
+
`the sidecar disagree at ${baseSha} (${classification.reason ?? 'invariant violation'}). ` +
|
|
255
|
+
'A human must reconcile them.',
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
if (!isTreelessPhaseRung(kind)) {
|
|
259
|
+
throw new PhaseDriverError(`${resolved.item} classifies as ${kind} at ${baseSha}, not a tree-less rung`);
|
|
260
|
+
}
|
|
261
|
+
if (findItemPath(base, resolved.namespace, resolved.slug) === undefined) {
|
|
262
|
+
return {
|
|
263
|
+
resolved,
|
|
264
|
+
skip: `no-op for ${resolved.item}: its item file is not on ${arbiter}/main (${baseSha})`,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
const { need, continueTip } = await treelessPhaseNeed(rungInput(resolved, classification, { ...options, cwd: base }), { cwd, arbiter, mainSha: baseSha, env });
|
|
268
|
+
if (need.needsAgent === undefined) {
|
|
269
|
+
throw new PhaseDriverError(`--phase does not split ${resolved.item} yet: ${need.unsplit}`);
|
|
270
|
+
}
|
|
271
|
+
return {
|
|
272
|
+
resolved,
|
|
273
|
+
rung: kind,
|
|
274
|
+
needsAgent: need.needsAgent,
|
|
275
|
+
...(continueTip === undefined ? {} : { continueTip }),
|
|
276
|
+
takeOverProposeKept: answersMerge(base, resolved.item, resolved.namespace, classification),
|
|
277
|
+
};
|
|
278
|
+
});
|
|
279
|
+
const { item } = verdict.resolved;
|
|
280
|
+
if ('skip' in verdict) {
|
|
281
|
+
return {
|
|
282
|
+
exitCode: 0,
|
|
283
|
+
outcome: 'no-op',
|
|
284
|
+
item,
|
|
285
|
+
message: verdict.skip,
|
|
286
|
+
lockOutputs: publish({ acquired: false, baseSha }),
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
if ('invariant' in verdict) {
|
|
290
|
+
return {
|
|
291
|
+
exitCode: 1,
|
|
292
|
+
outcome: 'invariant-violation',
|
|
293
|
+
item,
|
|
294
|
+
message: verdict.invariant,
|
|
295
|
+
lockOutputs: publish({ acquired: false, baseSha }),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
const { rung, needsAgent, continueTip, takeOverProposeKept } = verdict;
|
|
299
|
+
// An answered `merge` may take over the lock a finished propose build of the
|
|
300
|
+
// same item keeps held (task `wire-merge-questions-into-the-advance-tick`,
|
|
301
|
+
// decisions 2 and 4), so it does not back off on a held lock here: the
|
|
302
|
+
// acquire below takes the lock over ONLY when it carries the `propose-pr`
|
|
303
|
+
// marker, and reports any other hold as `lost`.
|
|
304
|
+
if (!takeOverProposeKept &&
|
|
305
|
+
(await arbiterLockSha({ cwd, arbiter, item, env })) !== undefined) {
|
|
306
|
+
const message = `'${item}' is already locked on ${arbiter}; backing off.`;
|
|
307
|
+
return {
|
|
308
|
+
exitCode: 0,
|
|
309
|
+
outcome: 'lost',
|
|
310
|
+
item,
|
|
311
|
+
message,
|
|
312
|
+
lockOutputs: publish({ acquired: false, rung, baseSha }),
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
const acquired = await acquireNotingOnce(note, (acquireNote) => acquireAdvancingLock({
|
|
316
|
+
item,
|
|
317
|
+
cwd,
|
|
318
|
+
arbiter,
|
|
319
|
+
acquireUnified: true,
|
|
320
|
+
takeOverProposeKept,
|
|
321
|
+
env,
|
|
322
|
+
note: acquireNote,
|
|
323
|
+
}));
|
|
324
|
+
if (acquired.exitCode !== 0) {
|
|
325
|
+
const outcome = acquired.outcome === 'usage-error' ? 'usage-error' : 'lost';
|
|
326
|
+
return {
|
|
327
|
+
// A lock lost to another run is handled, so green ("A handled
|
|
328
|
+
// outcome is green", `ci-phase-driver.ts`).
|
|
329
|
+
exitCode: outcome === 'lost' ? 0 : acquired.exitCode,
|
|
330
|
+
outcome,
|
|
331
|
+
item,
|
|
332
|
+
message: acquired.message,
|
|
333
|
+
lockOutputs: publish({ acquired: false, rung, baseSha }),
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
const lockSha = await arbiterLockSha({ cwd, arbiter, item, env });
|
|
337
|
+
if (lockSha === undefined) {
|
|
338
|
+
throw new Error(`the lock of ${item} vanished right after the acquire`);
|
|
339
|
+
}
|
|
340
|
+
const facts = publish({
|
|
341
|
+
acquired: true,
|
|
342
|
+
needsAgent,
|
|
343
|
+
rung,
|
|
344
|
+
baseSha,
|
|
345
|
+
lockSha,
|
|
346
|
+
// An answered merge's kept branch as the lock job saw it: the apply job
|
|
347
|
+
// pushes the rebased tip leased on it (decision 7).
|
|
348
|
+
...(continueTip === undefined ? {} : { continueTip }),
|
|
349
|
+
handoffName: deriveHandoffName(item, options.runAttempt ?? env.GITHUB_RUN_ATTEMPT ?? '1'),
|
|
350
|
+
agentTimeoutMinutes: agentTimeoutMinutesAt(cwd, baseSha, env),
|
|
351
|
+
});
|
|
352
|
+
return {
|
|
353
|
+
exitCode: 0,
|
|
354
|
+
outcome: 'locked',
|
|
355
|
+
item,
|
|
356
|
+
message: `locked ${item} on the ${rung} rung at ${baseSha} (lock ${lockSha}; ` +
|
|
357
|
+
`${needsAgent ? 'an agent runs next' : 'no agent is needed'})`,
|
|
358
|
+
lockOutputs: facts,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
const RECORDED = 'recorded for the CI apply phase (the agent job writes nothing)';
|
|
362
|
+
/**
|
|
363
|
+
* The advance context of the agent phase: the real agent seams, each wrapped to
|
|
364
|
+
* capture its emit, and every write seam a stub that writes nothing.
|
|
365
|
+
*/
|
|
366
|
+
function agentPhaseContext(options, dir, captured) {
|
|
367
|
+
const surfaceGate = options.surfaceGate ?? harnessSurfaceGate();
|
|
368
|
+
const triageGate = options.triageGate ?? harnessTriageGate();
|
|
369
|
+
const decider = options.applyDecide ?? harnessApplyDecider();
|
|
370
|
+
return {
|
|
371
|
+
...options,
|
|
372
|
+
cwd: dir,
|
|
373
|
+
surfaceGate: async (input) => {
|
|
374
|
+
const emit = await surfaceGate(input);
|
|
375
|
+
captured.surface = emit;
|
|
376
|
+
return emit;
|
|
377
|
+
},
|
|
378
|
+
triageGate: async (input) => {
|
|
379
|
+
const emit = await triageGate(input);
|
|
380
|
+
captured.triage = emit;
|
|
381
|
+
return emit;
|
|
382
|
+
},
|
|
383
|
+
applyDecide: async (input) => {
|
|
384
|
+
const verdict = await decider(input);
|
|
385
|
+
captured.verdict = verdict;
|
|
386
|
+
return verdict;
|
|
387
|
+
},
|
|
388
|
+
surfacePersist: (o) => {
|
|
389
|
+
captured.surfacePersisted = true;
|
|
390
|
+
return {
|
|
391
|
+
outcome: o.questions.length > 0 ? 'surfaced' : 'nothing',
|
|
392
|
+
sidecarPath: sidecarPathFor(o.item),
|
|
393
|
+
entryCount: o.questions.length,
|
|
394
|
+
};
|
|
395
|
+
},
|
|
396
|
+
applyPersist: (o) => ({
|
|
397
|
+
outcome: o.appendQuestions !== undefined && o.appendQuestions.length > 0
|
|
398
|
+
? 'repaused'
|
|
399
|
+
: o.dispose !== undefined
|
|
400
|
+
? 'disposed'
|
|
401
|
+
: 'resolved',
|
|
402
|
+
sidecarPath: sidecarPathFor(o.item),
|
|
403
|
+
itemPath: o.itemPath,
|
|
404
|
+
message: RECORDED,
|
|
405
|
+
}),
|
|
406
|
+
stampTriaged: () => ({ message: RECORDED }),
|
|
407
|
+
autoDisposition: (o) => {
|
|
408
|
+
captured.autoDisposed = true;
|
|
409
|
+
return {
|
|
410
|
+
outcome: 'deleted',
|
|
411
|
+
commit: '',
|
|
412
|
+
itemPath: o.itemPath,
|
|
413
|
+
message: RECORDED,
|
|
414
|
+
};
|
|
415
|
+
},
|
|
416
|
+
promote: async () => ({
|
|
417
|
+
outcome: 'promoted',
|
|
418
|
+
exitCode: 0,
|
|
419
|
+
message: RECORDED,
|
|
420
|
+
}),
|
|
421
|
+
mintAdr: async () => ({ outcome: 'minted', exitCode: 0, message: RECORDED }),
|
|
422
|
+
stuckAction: async () => ({ outcome: 'keep', message: RECORDED }),
|
|
423
|
+
mergeAction: async (input) => {
|
|
424
|
+
if (input.workspacesDir === '') {
|
|
425
|
+
throw new Error('the answered merge action needs a workspacesDir to check the ' +
|
|
426
|
+
'work branch out (createJob)');
|
|
427
|
+
}
|
|
428
|
+
const prepared = await prepareMergeLand(input);
|
|
429
|
+
captured.merge = prepared;
|
|
430
|
+
return mergeResultFor(prepared.preparation);
|
|
431
|
+
},
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* The merge-action result the rung body sees for the agent half's preparation
|
|
436
|
+
* (it only steers the rung's recording stubs; the handoff comes from the
|
|
437
|
+
* preparation itself, {@link mergeHandover}).
|
|
438
|
+
*/
|
|
439
|
+
function mergeResultFor(p) {
|
|
440
|
+
switch (p.kind) {
|
|
441
|
+
case 'integrate':
|
|
442
|
+
return { outcome: 'landed', message: RECORDED };
|
|
443
|
+
case 'restale':
|
|
444
|
+
return { outcome: 'restale', message: p.message, main: p.main };
|
|
445
|
+
case 'needs-attention':
|
|
446
|
+
return { outcome: 'refused', message: p.reason };
|
|
447
|
+
case 'already-integrated':
|
|
448
|
+
return { outcome: 'already-integrated', message: p.message };
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* The handoff of an answered merge's agent half: `integrate` with the rebased
|
|
453
|
+
* tip, `merge-restale`, or `needs-attention` (the red rebased tip bundled, or
|
|
454
|
+
* nothing on a rebase conflict). A branch that landed since the lock job ran
|
|
455
|
+
* has nothing to hand over, so the agent job fails and the apply job surfaces
|
|
456
|
+
* the item.
|
|
457
|
+
*/
|
|
458
|
+
function mergeHandover(item, p, baseSha) {
|
|
459
|
+
switch (p.kind) {
|
|
460
|
+
case 'integrate':
|
|
461
|
+
return {
|
|
462
|
+
record: { schema: 1, item, intent: { kind: 'integrate' }, products: {} },
|
|
463
|
+
bundle: { repo: p.repo, workBranch: p.workBranch, baseSha },
|
|
464
|
+
};
|
|
465
|
+
case 'restale':
|
|
466
|
+
return {
|
|
467
|
+
record: {
|
|
468
|
+
schema: 1,
|
|
469
|
+
item,
|
|
470
|
+
intent: { kind: 'merge-restale' },
|
|
471
|
+
products: {},
|
|
472
|
+
},
|
|
473
|
+
};
|
|
474
|
+
case 'needs-attention': {
|
|
475
|
+
const reason = (t) => boundHandoffText(t, HANDOFF_LIMITS.reasonChars);
|
|
476
|
+
const products = {
|
|
477
|
+
reason: reason(p.reason),
|
|
478
|
+
...(p.questions === undefined || p.questions.length === 0
|
|
479
|
+
? {}
|
|
480
|
+
: { questions: p.questions.map(reason) }),
|
|
481
|
+
};
|
|
482
|
+
return {
|
|
483
|
+
record: {
|
|
484
|
+
schema: 1,
|
|
485
|
+
item,
|
|
486
|
+
intent: { kind: 'needs-attention' },
|
|
487
|
+
products,
|
|
488
|
+
},
|
|
489
|
+
...(p.bundle === undefined ? {} : { bundle: { ...p.bundle, baseSha } }),
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
case 'already-integrated':
|
|
493
|
+
throw new Error(`the answered merge of ${item} has nothing to land: ${p.message}`);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* The surface questions of a captured emit, bounded for the handoff, each with
|
|
498
|
+
* the context and suggested default the agent gave it. A question with neither
|
|
499
|
+
* travels as its bare text (the plain form every schema-1 reader accepts), so
|
|
500
|
+
* only a question that has more to carry needs the object form.
|
|
501
|
+
*/
|
|
502
|
+
function questionEntries(emit) {
|
|
503
|
+
const bound = (t) => boundHandoffText(t, HANDOFF_LIMITS.reasonChars);
|
|
504
|
+
const entries = [];
|
|
505
|
+
for (const q of emit?.questions ?? []) {
|
|
506
|
+
const question = bound(q.question);
|
|
507
|
+
if (question.trim() === '')
|
|
508
|
+
continue;
|
|
509
|
+
if (q.context === undefined && q.default === undefined) {
|
|
510
|
+
entries.push(question);
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
entries.push({
|
|
514
|
+
question,
|
|
515
|
+
...(q.context === undefined ? {} : { context: bound(q.context) }),
|
|
516
|
+
...(q.default === undefined ? {} : { default: bound(q.default) }),
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
return entries;
|
|
520
|
+
}
|
|
521
|
+
/** The ask's follow-up questions of a verdict, kept separate (never joined). */
|
|
522
|
+
function askQuestions(verdict) {
|
|
523
|
+
const questions = verdict.questions !== undefined && verdict.questions.length > 0
|
|
524
|
+
? verdict.questions
|
|
525
|
+
: [verdict.question ?? ''];
|
|
526
|
+
return questions.map((q) => boundHandoffText(q, HANDOFF_LIMITS.reasonChars));
|
|
527
|
+
}
|
|
528
|
+
/** Control characters and line separators (a one-line handoff field refuses them). */
|
|
529
|
+
// eslint-disable-next-line no-control-regex
|
|
530
|
+
const LINE_BREAKING_RE = /[\u0000-\u001f\u007f\u2028\u2029]+/g;
|
|
531
|
+
/** A drafted title as the handoff's one-line field (or `fallback`). */
|
|
532
|
+
function oneLine(text, fallback) {
|
|
533
|
+
const line = (text ?? '')
|
|
534
|
+
.replace(LINE_BREAKING_RE, ' ')
|
|
535
|
+
.trim()
|
|
536
|
+
.slice(0, HANDOFF_LIMITS.documentTitleChars)
|
|
537
|
+
.trim();
|
|
538
|
+
return line === '' ? fallback : line;
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* Map the agentic apply verdict to the `apply-decision` products. A mint keeps
|
|
542
|
+
* the laptop defaults: no drafted slug is the observation's slug (sanitised as
|
|
543
|
+
* `promoteObservation` / `mintAdr` sanitise it), no drafted body is `''` (the
|
|
544
|
+
* writer then builds the self-contained body from the note, as it does when the
|
|
545
|
+
* verdict carries none), and no title is the slug (the ADR default; a task or
|
|
546
|
+
* spec draft's title is not used by the writer today).
|
|
547
|
+
*/
|
|
548
|
+
function decisionProducts(verdict, itemSlug) {
|
|
549
|
+
const reason = (r) => r === undefined || r.trim() === ''
|
|
550
|
+
? {}
|
|
551
|
+
: { reason: boundHandoffText(r, HANDOFF_LIMITS.reasonChars) };
|
|
552
|
+
const mint = (outcome, slug, title, body) => {
|
|
553
|
+
const safe = ensureSafeSlug(slug ?? itemSlug) || itemSlug;
|
|
554
|
+
return {
|
|
555
|
+
outcome,
|
|
556
|
+
slug: safe,
|
|
557
|
+
title: oneLine(title, safe),
|
|
558
|
+
body: boundHandoffText(body ?? '', HANDOFF_LIMITS.documentChars),
|
|
559
|
+
};
|
|
560
|
+
};
|
|
561
|
+
switch (verdict.outcome) {
|
|
562
|
+
case 'task':
|
|
563
|
+
return mint('task', verdict.taskSlug, verdict.taskTitle, verdict.taskBody);
|
|
564
|
+
case 'spec':
|
|
565
|
+
return mint('spec', verdict.specSlug, verdict.specTitle, verdict.specBody);
|
|
566
|
+
case 'adr':
|
|
567
|
+
return mint('adr', verdict.adrSlug, verdict.adrTitle, verdict.adrBody);
|
|
568
|
+
case 'dispose':
|
|
569
|
+
return { outcome: 'dispose', ...reason(verdict.disposeReason) };
|
|
570
|
+
case 'resolve':
|
|
571
|
+
return { outcome: 'resolve', ...reason(verdict.resolveReason) };
|
|
572
|
+
case 'ask':
|
|
573
|
+
return {
|
|
574
|
+
outcome: 'ask',
|
|
575
|
+
questions: askQuestions(verdict),
|
|
576
|
+
};
|
|
577
|
+
default:
|
|
578
|
+
throw new Error(`the decision verdict ${String(verdict.outcome)} cannot be handed over`);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Run the rung body under the recorder and map what its agents decided to the
|
|
583
|
+
* handoff record. Throws when the rung failed or reached nothing to hand over.
|
|
584
|
+
*/
|
|
585
|
+
async function treelessHandover(params) {
|
|
586
|
+
const { options, rung, resolved, dir, captured } = params;
|
|
587
|
+
const { item } = resolved;
|
|
588
|
+
const classification = classifyIn(dir, resolved);
|
|
589
|
+
if (classification.kind !== rung) {
|
|
590
|
+
throw new Error(`${item} classifies as ${classification.kind} at the base, not ${rung}`);
|
|
591
|
+
}
|
|
592
|
+
const exec = await dispatchTreeless(options.executor ?? defaultRungExecutor, rung, rungInput(resolved, classification, agentPhaseContext(options, dir, captured)));
|
|
593
|
+
// The answered merge: what its agent half found IS the handover (a red gate
|
|
594
|
+
// or a conflict ends the rung as `merge-refused`, which is still handed over).
|
|
595
|
+
if (captured.merge !== undefined) {
|
|
596
|
+
return mergeHandover(item, captured.merge.preparation, params.baseSha);
|
|
597
|
+
}
|
|
598
|
+
if (exec.exitCode !== 0) {
|
|
599
|
+
throw new Error(`the ${rung} rung failed: ${exec.message}`);
|
|
600
|
+
}
|
|
601
|
+
const nothing = () => {
|
|
602
|
+
throw new Error(`the ${rung} rung of ${item} reached nothing to hand over (${exec.outcome}: ${exec.message})`);
|
|
603
|
+
};
|
|
604
|
+
return { record: capturedRecord(rung, resolved, captured, nothing) };
|
|
605
|
+
}
|
|
606
|
+
/** The handoff record of what the rung's agents decided (see {@link treelessHandover}). */
|
|
607
|
+
function capturedRecord(rung, resolved, captured, nothing) {
|
|
608
|
+
const { item } = resolved;
|
|
609
|
+
switch (rung) {
|
|
610
|
+
case 'surface':
|
|
611
|
+
if (!captured.surfacePersisted)
|
|
612
|
+
nothing();
|
|
613
|
+
return {
|
|
614
|
+
schema: 1,
|
|
615
|
+
item,
|
|
616
|
+
intent: { kind: 'surface' },
|
|
617
|
+
products: {
|
|
618
|
+
questions: questionEntries(captured.surface),
|
|
619
|
+
},
|
|
620
|
+
};
|
|
621
|
+
case 'triage-observation': {
|
|
622
|
+
const t = captured.triage;
|
|
623
|
+
if (captured.autoDisposed && t?.auto === true) {
|
|
624
|
+
return {
|
|
625
|
+
schema: 1,
|
|
626
|
+
item,
|
|
627
|
+
intent: { kind: 'triage' },
|
|
628
|
+
products: {
|
|
629
|
+
disposition: t.kind,
|
|
630
|
+
target: canonicalHandoffItem(t.existing),
|
|
631
|
+
...(t.reason.trim() === ''
|
|
632
|
+
? {}
|
|
633
|
+
: {
|
|
634
|
+
reason: boundHandoffText(t.reason, HANDOFF_LIMITS.reasonChars),
|
|
635
|
+
}),
|
|
636
|
+
},
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
if (!captured.surfacePersisted)
|
|
640
|
+
nothing();
|
|
641
|
+
return {
|
|
642
|
+
schema: 1,
|
|
643
|
+
item,
|
|
644
|
+
intent: { kind: 'triage' },
|
|
645
|
+
products: {
|
|
646
|
+
disposition: 'keep',
|
|
647
|
+
questions: questionEntries(captured.surface),
|
|
648
|
+
},
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
case 'apply':
|
|
652
|
+
if (captured.verdict === undefined)
|
|
653
|
+
nothing();
|
|
654
|
+
return {
|
|
655
|
+
schema: 1,
|
|
656
|
+
item,
|
|
657
|
+
intent: { kind: 'apply-decision' },
|
|
658
|
+
products: decisionProducts(captured.verdict, resolved.slug),
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* The agent phase: after a read-only lock-ownership check, run the rung body in
|
|
664
|
+
* a worktree of `baseSha` with the real agents and no write, and write the
|
|
665
|
+
* handoff. Holds a read-only token. Any failure writes no handoff and fails the
|
|
666
|
+
* job (the apply job then surfaces the item).
|
|
667
|
+
*/
|
|
668
|
+
export async function performTreelessAgentPhase(options) {
|
|
669
|
+
const note = options.note ?? (() => { });
|
|
670
|
+
const arbiter = options.arbiter ?? DEFAULT_ARBITER;
|
|
671
|
+
const cwd = options.cwd;
|
|
672
|
+
const env = options.env ?? process.env;
|
|
673
|
+
const held = requireTreelessLock(options, env);
|
|
674
|
+
if (options.handoffDir === undefined) {
|
|
675
|
+
throw new PhaseDriverError('the agent phase needs a handoff directory');
|
|
676
|
+
}
|
|
677
|
+
if (held.needsAgent === false) {
|
|
678
|
+
throw new PhaseDriverError(`the lock job said the ${held.rung} rung needs no agent; the agent job ` +
|
|
679
|
+
'must be skipped');
|
|
680
|
+
}
|
|
681
|
+
const resolved = treelessItem(options, options.repoPath ?? cwd);
|
|
682
|
+
const { item } = resolved;
|
|
683
|
+
const ownership = await checkLockOwnership({
|
|
684
|
+
cwd,
|
|
685
|
+
arbiter,
|
|
686
|
+
item,
|
|
687
|
+
lockSha: held.lockSha,
|
|
688
|
+
env,
|
|
689
|
+
});
|
|
690
|
+
if (!ownership.owned) {
|
|
691
|
+
return {
|
|
692
|
+
exitCode: 0,
|
|
693
|
+
outcome: 'stale-lock',
|
|
694
|
+
item,
|
|
695
|
+
message: ownership.message,
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
let record;
|
|
699
|
+
const captured = { surfacePersisted: false, autoDisposed: false };
|
|
700
|
+
try {
|
|
701
|
+
// A read-only fetch, so the lock job's base is in this checkout.
|
|
702
|
+
await fetchArbiterMain({ cwd, arbiter, env });
|
|
703
|
+
const handover = await withBaseWorktree({ cwd, baseSha: held.baseSha, env }, async (dir) => {
|
|
704
|
+
const outcome = await runAgentPhase(createPhaseRecorder({ record: [] }), () => treelessHandover({
|
|
705
|
+
options,
|
|
706
|
+
rung: held.rung,
|
|
707
|
+
resolved,
|
|
708
|
+
dir,
|
|
709
|
+
baseSha: held.baseSha,
|
|
710
|
+
captured,
|
|
711
|
+
}));
|
|
712
|
+
if (outcome.halted) {
|
|
713
|
+
throw new Error(`the ${held.rung} rung reached a write (${outcome.intent.seam}.${outcome.intent.method})`);
|
|
714
|
+
}
|
|
715
|
+
return outcome.result;
|
|
716
|
+
});
|
|
717
|
+
record = handover.record;
|
|
718
|
+
const written = writeHandoff({
|
|
719
|
+
dir: options.handoffDir,
|
|
720
|
+
rung: held.rung,
|
|
721
|
+
record,
|
|
722
|
+
bundle: handover.bundle,
|
|
723
|
+
});
|
|
724
|
+
if (written.lfsMissing.length > 0) {
|
|
725
|
+
note(`the local LFS store lacks ${written.lfsMissing.length} object(s) a new ` +
|
|
726
|
+
`commit points at (${written.lfsMissing.slice(0, 5).join(', ')}); the ` +
|
|
727
|
+
'apply job will reject the handoff');
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
catch (err) {
|
|
731
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
732
|
+
return { exitCode: 1, outcome: 'agent-failed', item, message };
|
|
733
|
+
}
|
|
734
|
+
finally {
|
|
735
|
+
// The answered merge's job worktree, once its bundle is written.
|
|
736
|
+
captured.merge?.dispose();
|
|
737
|
+
}
|
|
738
|
+
const kind = record.intent.kind;
|
|
739
|
+
const message = `handed over ${kind} for ${item}`;
|
|
740
|
+
return { exitCode: 0, outcome: 'handed-over', item, message, intent: kind };
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* The apply phase: check the lock is still this run's, act on the agent job's
|
|
744
|
+
* result, validate the handoff as hostile, then run the rung's write half on a
|
|
745
|
+
* fresh checkout of `<arbiter>/main`, publish and release. Runs no agent and no
|
|
746
|
+
* repository code.
|
|
747
|
+
*/
|
|
748
|
+
export async function performTreelessApplyPhase(options) {
|
|
749
|
+
const note = options.note ?? (() => { });
|
|
750
|
+
const arbiter = options.arbiter ?? DEFAULT_ARBITER;
|
|
751
|
+
const cwd = options.cwd;
|
|
752
|
+
const env = noSmudgeEnv(identityEnv(options.identity, options.env ?? process.env));
|
|
753
|
+
const held = requireTreelessLock(options, env);
|
|
754
|
+
if (options.agentResult === undefined) {
|
|
755
|
+
throw new PhaseDriverError('the apply phase needs the agent job result (--agent-result, ' +
|
|
756
|
+
'needs.agent.result): only success reads the handoff');
|
|
757
|
+
}
|
|
758
|
+
const agentResult = options.agentResult;
|
|
759
|
+
const resolved = treelessItem(options, options.repoPath ?? cwd);
|
|
760
|
+
const { item } = resolved;
|
|
761
|
+
// Lock ownership BEFORE the first write (spec §8).
|
|
762
|
+
const ownership = await checkLockOwnership({
|
|
763
|
+
cwd,
|
|
764
|
+
arbiter,
|
|
765
|
+
item,
|
|
766
|
+
lockSha: held.lockSha,
|
|
767
|
+
env,
|
|
768
|
+
});
|
|
769
|
+
if (!ownership.owned) {
|
|
770
|
+
return {
|
|
771
|
+
exitCode: 0,
|
|
772
|
+
outcome: 'stale-lock',
|
|
773
|
+
item,
|
|
774
|
+
message: ownership.message,
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
// Every lock release from here on (the surface's, the final one) is leased
|
|
778
|
+
// on the sha this run owns.
|
|
779
|
+
const restoreLease = leaseLockReleases(item, held.lockSha);
|
|
780
|
+
try {
|
|
781
|
+
const ctx = {
|
|
782
|
+
options,
|
|
783
|
+
held,
|
|
784
|
+
resolved,
|
|
785
|
+
arbiter,
|
|
786
|
+
cwd,
|
|
787
|
+
env,
|
|
788
|
+
note,
|
|
789
|
+
};
|
|
790
|
+
const decision = await resolveAgentResult({
|
|
791
|
+
result: agentResult,
|
|
792
|
+
held,
|
|
793
|
+
agentTimeoutMinutes: options.agentTimeoutMinutes,
|
|
794
|
+
api: options.actionsApi,
|
|
795
|
+
env,
|
|
796
|
+
});
|
|
797
|
+
switch (decision.action) {
|
|
798
|
+
case 'deterministic':
|
|
799
|
+
return await runRungAndPublish(ctx, undefined);
|
|
800
|
+
case 'read-handoff': {
|
|
801
|
+
if (held.needsAgent === false) {
|
|
802
|
+
// No agent was needed: whatever the agent job left is not read.
|
|
803
|
+
note('the lock job said no agent is needed; the handoff is not read ' +
|
|
804
|
+
'and the rung runs deterministically');
|
|
805
|
+
return await runRungAndPublish(ctx, undefined);
|
|
806
|
+
}
|
|
807
|
+
if (isAnsweredMergeRun(held)) {
|
|
808
|
+
let validated;
|
|
809
|
+
try {
|
|
810
|
+
validated = readAnsweredMergeHandoff(ctx);
|
|
811
|
+
}
|
|
812
|
+
catch (err) {
|
|
813
|
+
if (!(err instanceof HandoffRejected))
|
|
814
|
+
throw err;
|
|
815
|
+
return await surfaceItem(ctx, err.message, 'rejected');
|
|
816
|
+
}
|
|
817
|
+
return await applyAnsweredMerge(ctx, validated);
|
|
818
|
+
}
|
|
819
|
+
let record;
|
|
820
|
+
try {
|
|
821
|
+
record = readTreelessHandoff(ctx);
|
|
822
|
+
}
|
|
823
|
+
catch (err) {
|
|
824
|
+
if (!(err instanceof HandoffRejected))
|
|
825
|
+
throw err;
|
|
826
|
+
return await surfaceItem(ctx, err.message, 'rejected');
|
|
827
|
+
}
|
|
828
|
+
return await runRungAndPublish(ctx, record);
|
|
829
|
+
}
|
|
830
|
+
case 'surface':
|
|
831
|
+
return await surfaceItem(ctx, decision.reason, 'surfaced');
|
|
832
|
+
case 'release': {
|
|
833
|
+
const released = await releaseLockLeased({
|
|
834
|
+
cwd,
|
|
835
|
+
arbiter,
|
|
836
|
+
item,
|
|
837
|
+
expectedSha: held.lockSha,
|
|
838
|
+
env,
|
|
839
|
+
});
|
|
840
|
+
note(released.message);
|
|
841
|
+
return released.released
|
|
842
|
+
? {
|
|
843
|
+
exitCode: 0,
|
|
844
|
+
outcome: 'released',
|
|
845
|
+
item,
|
|
846
|
+
message: `Released '${item}': ${decision.reason}.`,
|
|
847
|
+
}
|
|
848
|
+
: {
|
|
849
|
+
exitCode: 1,
|
|
850
|
+
outcome: 'release-refused',
|
|
851
|
+
item,
|
|
852
|
+
message: `Could not release '${item}' (${released.message}): ${decision.reason}.`,
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
finally {
|
|
858
|
+
restoreLease();
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* Read the handoff as hostile. `readHandoff` validates the item, the intent
|
|
863
|
+
* kind against the rung, every enum, slug and item reference and every limit;
|
|
864
|
+
* on top, the rules the record format cannot know: the rung's own intent kind,
|
|
865
|
+
* a triage auto-disposition only under `observationTriage: auto` (the triage
|
|
866
|
+
* gate never runs otherwise), and a triage target that is not the note itself.
|
|
867
|
+
* Throws {@link HandoffRejected}.
|
|
868
|
+
*/
|
|
869
|
+
function readTreelessHandoff(ctx) {
|
|
870
|
+
const { options, held, resolved, env } = ctx;
|
|
871
|
+
if (options.handoffDir === undefined) {
|
|
872
|
+
throw new HandoffRejected('layout', 'no handoff directory was given');
|
|
873
|
+
}
|
|
874
|
+
const record = readHandoff({
|
|
875
|
+
dir: options.handoffDir,
|
|
876
|
+
runnerTemp: runnerTempFrom(options.runnerTemp, env),
|
|
877
|
+
trust: { item: resolved.item, rung: held.rung },
|
|
878
|
+
}).record;
|
|
879
|
+
if (record.intent.kind !== INTENT_FOR_RUNG[held.rung]) {
|
|
880
|
+
throw new HandoffRejected('kind-for-rung', `a ${held.rung} run hands over ${INTENT_FOR_RUNG[held.rung]}, not ${record.intent.kind}`);
|
|
881
|
+
}
|
|
882
|
+
if (record.intent.kind === 'triage') {
|
|
883
|
+
const t = record.products;
|
|
884
|
+
if (t.disposition !== 'keep' && options.observationTriage !== 'auto') {
|
|
885
|
+
throw new HandoffRejected('field', `products.disposition is ${t.disposition}, but observationTriage is ` +
|
|
886
|
+
`${options.observationTriage ?? 'unset'}: only 'auto' runs the triage gate`);
|
|
887
|
+
}
|
|
888
|
+
if (t.target === resolved.item) {
|
|
889
|
+
throw new HandoffRejected('field', 'products.target names the observation itself');
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
return record;
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* Whether this run carries an answered merge's agent job: the lock job
|
|
896
|
+
* publishes `continueTip` on the apply rung only for an answered `merge` whose
|
|
897
|
+
* kept branch carries work `main` lacks (trusted lock outputs, never the
|
|
898
|
+
* artifact).
|
|
899
|
+
*/
|
|
900
|
+
function isAnsweredMergeRun(held) {
|
|
901
|
+
return held.rung === 'apply' && held.continueTip !== undefined;
|
|
902
|
+
}
|
|
903
|
+
/** The intents an answered merge's agent job may hand over. */
|
|
904
|
+
const ANSWERED_MERGE_INTENTS = [
|
|
905
|
+
'integrate',
|
|
906
|
+
'merge-restale',
|
|
907
|
+
'needs-attention',
|
|
908
|
+
];
|
|
909
|
+
/**
|
|
910
|
+
* Read an answered merge's handoff as hostile, bundle included
|
|
911
|
+
* (`validateApplyHandoff`: the bundle's one ref is the item's work branch, its
|
|
912
|
+
* history descends from `baseSha`, and every path, size and LFS rule holds). The
|
|
913
|
+
* integration mode is the trusted `merge`: the human's answer is the checkpoint,
|
|
914
|
+
* so the untrusted-origin rule (a BUILD rule) does not apply. Throws
|
|
915
|
+
* {@link HandoffRejected}.
|
|
916
|
+
*/
|
|
917
|
+
function readAnsweredMergeHandoff(ctx) {
|
|
918
|
+
const { options, held, resolved, arbiter, cwd, env } = ctx;
|
|
919
|
+
if (options.handoffDir === undefined) {
|
|
920
|
+
throw new HandoffRejected('layout', 'no handoff directory was given');
|
|
921
|
+
}
|
|
922
|
+
const validated = validateApplyHandoff({
|
|
923
|
+
dir: options.handoffDir,
|
|
924
|
+
runnerTemp: runnerTempFrom(options.runnerTemp, env),
|
|
925
|
+
repo: cwd,
|
|
926
|
+
trust: {
|
|
927
|
+
item: resolved.item,
|
|
928
|
+
rung: held.rung,
|
|
929
|
+
baseSha: held.baseSha,
|
|
930
|
+
arbiter,
|
|
931
|
+
integrationMode: 'merge',
|
|
932
|
+
},
|
|
933
|
+
env,
|
|
934
|
+
});
|
|
935
|
+
const kind = validated.handoff.record.intent.kind;
|
|
936
|
+
if (!ANSWERED_MERGE_INTENTS.includes(kind)) {
|
|
937
|
+
throw new HandoffRejected('kind-for-rung', `an answered merge hands over ${ANSWERED_MERGE_INTENTS.join(', ')}, not ${kind}`);
|
|
938
|
+
}
|
|
939
|
+
return validated;
|
|
940
|
+
}
|
|
941
|
+
/**
|
|
942
|
+
* The write half of an answered merge (spec §3, the `apply, kind: merge` row).
|
|
943
|
+
* `merge-restale` re-pauses through the rung body. Otherwise, after checking the
|
|
944
|
+
* item still carries the answered `merge` on a fresh `main`: the handoff's LFS
|
|
945
|
+
* objects first, then the rebased branch pushed leased on `continueTip` (a
|
|
946
|
+
* branch that moved writes nothing more), then either the needs-attention route
|
|
947
|
+
* or the land through the unchanged compare-and-swap loop in merge mode, with
|
|
948
|
+
* the gated tip (a land after a lost race carries `Landed-Without-Regate`), and
|
|
949
|
+
* only then the answer recorded and published on the new `main`.
|
|
950
|
+
*/
|
|
951
|
+
async function applyAnsweredMerge(ctx, validated) {
|
|
952
|
+
const { options, held, resolved, arbiter, cwd, env, note } = ctx;
|
|
953
|
+
const { item, slug } = resolved;
|
|
954
|
+
const record = validated.handoff.record;
|
|
955
|
+
if (record.intent.kind === 'merge-restale') {
|
|
956
|
+
return runRungAndPublish(ctx, record);
|
|
957
|
+
}
|
|
958
|
+
// Nothing is written unless the item still carries its answered merge.
|
|
959
|
+
const stale = await answeredMergeGone(ctx);
|
|
960
|
+
if (stale !== undefined)
|
|
961
|
+
return releaseAfter(ctx, stale);
|
|
962
|
+
const lfs = await pushHandoffLfs({
|
|
963
|
+
cwd,
|
|
964
|
+
arbiter,
|
|
965
|
+
objects: validated.lfsObjects,
|
|
966
|
+
env,
|
|
967
|
+
note,
|
|
968
|
+
});
|
|
969
|
+
if (!lfs.ok) {
|
|
970
|
+
return surfaceItem(ctx, lfs.reason, lfs.rejected ? 'rejected' : 'surfaced');
|
|
971
|
+
}
|
|
972
|
+
const bundle = validated.bundle;
|
|
973
|
+
if (bundle !== undefined) {
|
|
974
|
+
await gitHard(['checkout', '--quiet', '-B', bundle.workBranch, bundle.tip], cwd, env);
|
|
975
|
+
// The rebased branch (decision 7): the agent job rebased it locally; publish
|
|
976
|
+
// it leased on the tip the LOCK job observed, before any other ref.
|
|
977
|
+
const pushed = await refWrite.pushLeasedWorkBranch({
|
|
978
|
+
arbiter,
|
|
979
|
+
branch: bundle.workBranch,
|
|
980
|
+
commit: bundle.tip,
|
|
981
|
+
expectedTip: held.continueTip,
|
|
982
|
+
cwd,
|
|
983
|
+
env,
|
|
984
|
+
});
|
|
985
|
+
if (pushed.status !== 0) {
|
|
986
|
+
const message = `${bundle.workBranch} on ${arbiter} moved since the lock job saw it ` +
|
|
987
|
+
`at ${held.continueTip}, so the leased push of the rebased branch was ` +
|
|
988
|
+
`refused (${pushed.stderr.trim()}). Nothing was written and the lock ` +
|
|
989
|
+
`of ${item} is still held: inspect the branch, then \`dorfl ` +
|
|
990
|
+
`release-lock ${item}\` to retry the item.`;
|
|
991
|
+
return { exitCode: 1, outcome: 'stale-lease', item, message };
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
if (record.intent.kind === 'needs-attention') {
|
|
995
|
+
const { reason, questions } = record.products;
|
|
996
|
+
const routed = bundle !== undefined
|
|
997
|
+
? await ledgerWrite.applyNeedsAttentionTransition({
|
|
998
|
+
cwd,
|
|
999
|
+
slug,
|
|
1000
|
+
reason,
|
|
1001
|
+
questions,
|
|
1002
|
+
arbiter,
|
|
1003
|
+
env,
|
|
1004
|
+
note,
|
|
1005
|
+
})
|
|
1006
|
+
: await ledgerWrite.applyTreelessNeedsAttentionTransition({
|
|
1007
|
+
cwd,
|
|
1008
|
+
slug,
|
|
1009
|
+
reason,
|
|
1010
|
+
questions,
|
|
1011
|
+
arbiter,
|
|
1012
|
+
env,
|
|
1013
|
+
note,
|
|
1014
|
+
});
|
|
1015
|
+
const message = routed.moved
|
|
1016
|
+
? `Surfaced '${item}' to needs-attention: ${reason}`
|
|
1017
|
+
: `Could not surface '${item}' (${routed.reasonNotMoved ?? 'unknown'}): ${reason}`;
|
|
1018
|
+
return {
|
|
1019
|
+
exitCode: routed.moved ? 0 : 1,
|
|
1020
|
+
outcome: routed.moved ? 'surfaced' : 'surface-unmoved',
|
|
1021
|
+
item,
|
|
1022
|
+
message,
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
// `integrate`: the bundle is required by the intent table.
|
|
1026
|
+
if (bundle === undefined) {
|
|
1027
|
+
throw new Error('an answered-merge integrate handoff carries no bundle');
|
|
1028
|
+
}
|
|
1029
|
+
const commitMessage = (await gitHard(['log', '-1', '--format=%s', bundle.tip], cwd, env)).trim();
|
|
1030
|
+
const land = await integrationLand.land({
|
|
1031
|
+
cwd,
|
|
1032
|
+
arbiter,
|
|
1033
|
+
slug,
|
|
1034
|
+
branch: bundle.workBranch,
|
|
1035
|
+
lifecycle: false,
|
|
1036
|
+
source: taskSourceAtBase(cwd, held.baseSha, slug, env),
|
|
1037
|
+
surfaceArbiter: arbiter,
|
|
1038
|
+
commitMessage,
|
|
1039
|
+
env,
|
|
1040
|
+
note,
|
|
1041
|
+
mode: 'merge',
|
|
1042
|
+
title: commitMessage,
|
|
1043
|
+
mergeRetries: options.mergeRetries ?? options.doOptions?.mergeRetries,
|
|
1044
|
+
mergeJitterMs: options.mergeJitterMs,
|
|
1045
|
+
// The landed-vs-gated report (decision 2): the agent job gated the
|
|
1046
|
+
// rebased tip, so a land after a lost race says it landed another tree.
|
|
1047
|
+
gatedTip: bundle.tip,
|
|
1048
|
+
});
|
|
1049
|
+
const landed = land.outcome === 'completed' &&
|
|
1050
|
+
(land.integration?.mergedToMain === true ||
|
|
1051
|
+
land.integration?.alreadyLanded === true);
|
|
1052
|
+
if (!landed) {
|
|
1053
|
+
const message = land.reason ?? `the land ended as ${land.outcome}`;
|
|
1054
|
+
if (land.routedToNeedsAttention) {
|
|
1055
|
+
return { exitCode: 0, outcome: 'surfaced', item, message };
|
|
1056
|
+
}
|
|
1057
|
+
return releaseAfter(ctx, {
|
|
1058
|
+
exitCode: 1,
|
|
1059
|
+
outcome: 'land-failed',
|
|
1060
|
+
item,
|
|
1061
|
+
message,
|
|
1062
|
+
});
|
|
1063
|
+
}
|
|
1064
|
+
note(`landed ${bundle.workBranch} on ${arbiter}/main`);
|
|
1065
|
+
// The answer is recorded AFTER the land, a second write to `main`, as on the
|
|
1066
|
+
// laptop.
|
|
1067
|
+
return runRungAndPublish(ctx, record, { landed: true });
|
|
1068
|
+
}
|
|
1069
|
+
/**
|
|
1070
|
+
* The `stale` result when the item no longer carries an answered `merge` on
|
|
1071
|
+
* the arbiter's current `main` (answered again, landed, or gone), else
|
|
1072
|
+
* `undefined`. Read-only.
|
|
1073
|
+
*/
|
|
1074
|
+
async function answeredMergeGone(ctx) {
|
|
1075
|
+
const { resolved, arbiter, cwd, env } = ctx;
|
|
1076
|
+
const { item } = resolved;
|
|
1077
|
+
const tip = await fetchArbiterMain({ cwd, arbiter, env });
|
|
1078
|
+
return withBaseWorktree({ cwd, baseSha: tip, env }, async (dir) => {
|
|
1079
|
+
const kind = classifyIn(dir, resolved).kind;
|
|
1080
|
+
const verb = kind === 'apply' ? detectAnsweredMergeAction(dir, item)?.verb : undefined;
|
|
1081
|
+
if (verb === 'merge')
|
|
1082
|
+
return undefined;
|
|
1083
|
+
return {
|
|
1084
|
+
exitCode: 1,
|
|
1085
|
+
outcome: 'stale',
|
|
1086
|
+
item,
|
|
1087
|
+
message: `${item} no longer carries an answered merge on ${arbiter}/main ` +
|
|
1088
|
+
`(${tip}: ${kind}${verb === undefined ? '' : `, answered ${verb}`}); nothing was written`,
|
|
1089
|
+
};
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
/** Release the lock (leased on `lockSha`) after `result`, folding a refused release into it. */
|
|
1093
|
+
async function releaseAfter(ctx, result) {
|
|
1094
|
+
const { held, resolved, arbiter, cwd, env, note } = ctx;
|
|
1095
|
+
const released = await releaseLockLeased({
|
|
1096
|
+
cwd,
|
|
1097
|
+
arbiter,
|
|
1098
|
+
item: resolved.item,
|
|
1099
|
+
expectedSha: held.lockSha,
|
|
1100
|
+
env,
|
|
1101
|
+
});
|
|
1102
|
+
note(released.message);
|
|
1103
|
+
if (!released.released) {
|
|
1104
|
+
return {
|
|
1105
|
+
...result,
|
|
1106
|
+
exitCode: result.exitCode === 0 ? 1 : result.exitCode,
|
|
1107
|
+
message: `${result.message} (${released.message})`,
|
|
1108
|
+
};
|
|
1109
|
+
}
|
|
1110
|
+
return result;
|
|
1111
|
+
}
|
|
1112
|
+
async function gitHard(args, cwd, env) {
|
|
1113
|
+
const r = await runAsync('git', args, cwd, { env });
|
|
1114
|
+
if (r.status !== 0) {
|
|
1115
|
+
throw new Error(`git ${args.join(' ')} failed (exit ${r.status}): ${r.stderr.trim()}`);
|
|
1116
|
+
}
|
|
1117
|
+
return r.stdout;
|
|
1118
|
+
}
|
|
1119
|
+
/** A gate the apply phase never launches: the handoff did not answer it. */
|
|
1120
|
+
function noAgent(what) {
|
|
1121
|
+
throw new PhaseDriverError(`the apply phase launches no agent (${what}); the handoff does not answer it`);
|
|
1122
|
+
}
|
|
1123
|
+
/**
|
|
1124
|
+
* Replay the checked handoff's surface questions as the surface gate's emit,
|
|
1125
|
+
* with each question's context and suggested default, so the rung persists the
|
|
1126
|
+
* same sidecar the laptop does.
|
|
1127
|
+
*/
|
|
1128
|
+
function replaySurface(questions) {
|
|
1129
|
+
return async () => ({
|
|
1130
|
+
questions: (questions ?? []).map((q) => typeof q === 'string'
|
|
1131
|
+
? { question: q }
|
|
1132
|
+
: {
|
|
1133
|
+
question: q.question,
|
|
1134
|
+
...(q.context === undefined ? {} : { context: q.context }),
|
|
1135
|
+
...(q.default === undefined ? {} : { default: q.default }),
|
|
1136
|
+
}),
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
/** Replay the checked `apply-decision` as the decider's verdict. */
|
|
1140
|
+
function replayDecision(p) {
|
|
1141
|
+
return async () => {
|
|
1142
|
+
switch (p.outcome) {
|
|
1143
|
+
case 'task':
|
|
1144
|
+
return {
|
|
1145
|
+
outcome: 'task',
|
|
1146
|
+
taskSlug: p.slug,
|
|
1147
|
+
taskTitle: p.title,
|
|
1148
|
+
taskBody: p.body,
|
|
1149
|
+
};
|
|
1150
|
+
case 'spec':
|
|
1151
|
+
return {
|
|
1152
|
+
outcome: 'spec',
|
|
1153
|
+
specSlug: p.slug,
|
|
1154
|
+
specTitle: p.title,
|
|
1155
|
+
specBody: p.body,
|
|
1156
|
+
};
|
|
1157
|
+
case 'adr':
|
|
1158
|
+
return {
|
|
1159
|
+
outcome: 'adr',
|
|
1160
|
+
adrSlug: p.slug,
|
|
1161
|
+
adrTitle: p.title,
|
|
1162
|
+
adrBody: p.body,
|
|
1163
|
+
};
|
|
1164
|
+
case 'dispose':
|
|
1165
|
+
return { outcome: 'dispose', disposeReason: p.reason };
|
|
1166
|
+
case 'resolve':
|
|
1167
|
+
return { outcome: 'resolve', resolveReason: p.reason };
|
|
1168
|
+
case 'ask':
|
|
1169
|
+
// One follow-up question per handed-over question (never joined).
|
|
1170
|
+
return { outcome: 'ask', questions: p.questions ?? [] };
|
|
1171
|
+
}
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
1174
|
+
/**
|
|
1175
|
+
* The answered merge's action in the apply phase, which never runs the
|
|
1176
|
+
* repository's code: the land the apply phase itself just made (`landed`), the
|
|
1177
|
+
* agent job's `merge-restale`, and, with no agent job, `hold` / `drop` (the
|
|
1178
|
+
* laptop dispatcher, which touches nothing for them) or a `merge` whose kept
|
|
1179
|
+
* branch is absent or already on `main` (`already-integrated`, as the laptop
|
|
1180
|
+
* reports it). Anything else would need the agent job, so it throws.
|
|
1181
|
+
*/
|
|
1182
|
+
function replayMergeAction(record, landed) {
|
|
1183
|
+
return async (input) => {
|
|
1184
|
+
const kind = record?.intent.kind;
|
|
1185
|
+
if (kind === 'integrate' && landed) {
|
|
1186
|
+
return {
|
|
1187
|
+
outcome: 'landed',
|
|
1188
|
+
message: `merge-question for ${input.item} answered MERGE: landed ` +
|
|
1189
|
+
`\`work/task-${input.slug}\` (rebased and gated by the agent job).`,
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
if (kind === 'merge-restale') {
|
|
1193
|
+
// The follow-up is asked against the fresh `main` this rung runs on
|
|
1194
|
+
// (the base worktree's HEAD), which it records as its `askedAtMain`: a
|
|
1195
|
+
// re-answer while `main` stays put then lands (no re-stale livelock).
|
|
1196
|
+
const head = await runAsync('git', ['rev-parse', 'HEAD'], input.cwd, {
|
|
1197
|
+
env: input.env,
|
|
1198
|
+
});
|
|
1199
|
+
const main = head.status === 0 ? head.stdout.trim() : '';
|
|
1200
|
+
return {
|
|
1201
|
+
outcome: 'restale',
|
|
1202
|
+
message: `merge-question for ${input.item} answered MERGE, but ` +
|
|
1203
|
+
'strictMergeApproval is ON and `main` moved since the question was ' +
|
|
1204
|
+
'asked (checked by the agent job): re-surfacing the merge-question.',
|
|
1205
|
+
...(main === '' ? {} : { main }),
|
|
1206
|
+
};
|
|
1207
|
+
}
|
|
1208
|
+
if (record === undefined) {
|
|
1209
|
+
if (input.action.verb !== 'merge')
|
|
1210
|
+
return performMergeAction(input);
|
|
1211
|
+
return {
|
|
1212
|
+
outcome: 'already-integrated',
|
|
1213
|
+
message: `merge-question for ${input.item} answered MERGE: ` +
|
|
1214
|
+
`\`work/task-${input.slug}\` is absent or already on main (nothing to land).`,
|
|
1215
|
+
};
|
|
1216
|
+
}
|
|
1217
|
+
return noAgent('the answered merge action');
|
|
1218
|
+
};
|
|
1219
|
+
}
|
|
1220
|
+
/**
|
|
1221
|
+
* The agent seams of the apply phase: replays of the checked handoff, and a
|
|
1222
|
+
* throwing gate for every seam the handoff does not answer (all of them when
|
|
1223
|
+
* the rung runs deterministically).
|
|
1224
|
+
*/
|
|
1225
|
+
function replayGates(record, landed = false) {
|
|
1226
|
+
const gates = {
|
|
1227
|
+
surfaceGate: async () => noAgent('the surface-questions agent'),
|
|
1228
|
+
triageGate: async () => noAgent('the triage gate'),
|
|
1229
|
+
applyDecide: async () => noAgent('the apply decision agent'),
|
|
1230
|
+
mergeAction: replayMergeAction(record, landed),
|
|
1231
|
+
};
|
|
1232
|
+
if (record === undefined)
|
|
1233
|
+
return gates;
|
|
1234
|
+
if (record.intent.kind === 'surface') {
|
|
1235
|
+
gates.surfaceGate = replaySurface(record.products.questions);
|
|
1236
|
+
}
|
|
1237
|
+
else if (record.intent.kind === 'triage') {
|
|
1238
|
+
const t = record.products;
|
|
1239
|
+
gates.surfaceGate = replaySurface(t.questions);
|
|
1240
|
+
gates.triageGate = async () => t.disposition === 'keep'
|
|
1241
|
+
? { auto: false }
|
|
1242
|
+
: {
|
|
1243
|
+
auto: true,
|
|
1244
|
+
kind: t.disposition,
|
|
1245
|
+
existing: t.target,
|
|
1246
|
+
reason: t.reason ?? '',
|
|
1247
|
+
};
|
|
1248
|
+
}
|
|
1249
|
+
else if (record.intent.kind === 'apply-decision') {
|
|
1250
|
+
gates.applyDecide = replayDecision(record.products);
|
|
1251
|
+
}
|
|
1252
|
+
return gates;
|
|
1253
|
+
}
|
|
1254
|
+
/** Whether `HEAD` of `dir` is now on the arbiter's `main` (after the publish). */
|
|
1255
|
+
async function publishedToMain(dir, arbiter, env) {
|
|
1256
|
+
await runAsync('git', [
|
|
1257
|
+
'fetch',
|
|
1258
|
+
'--quiet',
|
|
1259
|
+
'--no-tags',
|
|
1260
|
+
arbiter,
|
|
1261
|
+
`+refs/heads/main:refs/remotes/${arbiter}/main`,
|
|
1262
|
+
], dir, { env });
|
|
1263
|
+
const r = await runAsync('git', ['merge-base', '--is-ancestor', 'HEAD', `refs/remotes/${arbiter}/main`], dir, { env });
|
|
1264
|
+
return r.status === 0;
|
|
1265
|
+
}
|
|
1266
|
+
/**
|
|
1267
|
+
* Run the rung body's write half on a FRESH checkout of `<arbiter>/main`: check
|
|
1268
|
+
* the item still classifies on the lock job's rung with the same agent need,
|
|
1269
|
+
* run the rung with the replayed (or refusing) agent seams and the real write
|
|
1270
|
+
* seams, check the publish scope and publish. Then release the lock (leased),
|
|
1271
|
+
* whatever happened past the ownership check.
|
|
1272
|
+
*/
|
|
1273
|
+
async function runRungAndPublish(ctx, record, opts = {}) {
|
|
1274
|
+
const landed = opts.landed === true;
|
|
1275
|
+
const { options, held, resolved, arbiter, cwd, env, note } = ctx;
|
|
1276
|
+
const { item } = resolved;
|
|
1277
|
+
const tip = await fetchArbiterMain({ cwd, arbiter, env });
|
|
1278
|
+
const result = await withBaseWorktree({ cwd, baseSha: tip, env }, async (dir) => {
|
|
1279
|
+
const classification = classifyIn(dir, resolved);
|
|
1280
|
+
const input = rungInput(resolved, classification, {
|
|
1281
|
+
...options,
|
|
1282
|
+
cwd: dir,
|
|
1283
|
+
arbiter,
|
|
1284
|
+
note,
|
|
1285
|
+
...replayGates(record, landed),
|
|
1286
|
+
});
|
|
1287
|
+
let need;
|
|
1288
|
+
if (classification.kind === held.rung && landed) {
|
|
1289
|
+
// The land already happened, so the kept branch is on main and the
|
|
1290
|
+
// need would recompute to none: an agent job did run, and its
|
|
1291
|
+
// answered merge is what is left to record.
|
|
1292
|
+
need =
|
|
1293
|
+
detectAnsweredMergeAction(dir, item)?.verb === 'merge'
|
|
1294
|
+
? { needsAgent: true }
|
|
1295
|
+
: { unsplit: 'no answered merge left to record after the land' };
|
|
1296
|
+
}
|
|
1297
|
+
else if (classification.kind === held.rung) {
|
|
1298
|
+
need = (await treelessPhaseNeed(input, { cwd, arbiter, mainSha: tip, env })).need;
|
|
1299
|
+
}
|
|
1300
|
+
if (need?.needsAgent !== (record !== undefined)) {
|
|
1301
|
+
const what = need === undefined
|
|
1302
|
+
? `classifies as ${classification.kind}, not ${held.rung}`
|
|
1303
|
+
: need.needsAgent === undefined
|
|
1304
|
+
? `needs ${need.unsplit}`
|
|
1305
|
+
: need.needsAgent
|
|
1306
|
+
? 'now needs an agent, but none ran'
|
|
1307
|
+
: 'now needs no agent, but one ran';
|
|
1308
|
+
return {
|
|
1309
|
+
exitCode: 1,
|
|
1310
|
+
outcome: 'stale',
|
|
1311
|
+
item,
|
|
1312
|
+
message: `${item} ${what} on ${arbiter}/main (${tip}); ` +
|
|
1313
|
+
(landed
|
|
1314
|
+
? 'its merge landed, but the answer was not recorded'
|
|
1315
|
+
: 'nothing was written'),
|
|
1316
|
+
};
|
|
1317
|
+
}
|
|
1318
|
+
const exec = await dispatchTreeless(options.executor ?? defaultRungExecutor, held.rung, input);
|
|
1319
|
+
if (exec.exitCode !== 0) {
|
|
1320
|
+
return {
|
|
1321
|
+
exitCode: exec.exitCode,
|
|
1322
|
+
outcome: 'rung-failed',
|
|
1323
|
+
item,
|
|
1324
|
+
message: exec.message,
|
|
1325
|
+
rungOutcome: exec.outcome,
|
|
1326
|
+
};
|
|
1327
|
+
}
|
|
1328
|
+
const scope = await checkTreelessPublishScope({
|
|
1329
|
+
cwd: dir,
|
|
1330
|
+
base: tip,
|
|
1331
|
+
env,
|
|
1332
|
+
});
|
|
1333
|
+
if (scope.kind === 'refused') {
|
|
1334
|
+
return {
|
|
1335
|
+
exitCode: 1,
|
|
1336
|
+
outcome: 'publish-refused',
|
|
1337
|
+
item,
|
|
1338
|
+
message: `${exec.message} ${scope.message}; nothing was published`,
|
|
1339
|
+
rungOutcome: exec.outcome,
|
|
1340
|
+
};
|
|
1341
|
+
}
|
|
1342
|
+
if (scope.kind === 'rung-commit') {
|
|
1343
|
+
await refWrite.publishTreelessResult({
|
|
1344
|
+
cwd: dir,
|
|
1345
|
+
arbiter,
|
|
1346
|
+
retries: options.publishRetries ?? 1000,
|
|
1347
|
+
jitterMs: options.publishJitterMs ?? DEFAULT_TREELESS_JITTER_MS,
|
|
1348
|
+
env,
|
|
1349
|
+
note,
|
|
1350
|
+
});
|
|
1351
|
+
if (!(await publishedToMain(dir, arbiter, env))) {
|
|
1352
|
+
return {
|
|
1353
|
+
exitCode: 1,
|
|
1354
|
+
outcome: 'publish-failed',
|
|
1355
|
+
item,
|
|
1356
|
+
message: `${exec.message} The result did not reach ${arbiter}/main.`,
|
|
1357
|
+
rungOutcome: exec.outcome,
|
|
1358
|
+
};
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
return {
|
|
1362
|
+
exitCode: 0,
|
|
1363
|
+
outcome: 'applied',
|
|
1364
|
+
item,
|
|
1365
|
+
message: exec.message,
|
|
1366
|
+
rungOutcome: exec.outcome,
|
|
1367
|
+
};
|
|
1368
|
+
});
|
|
1369
|
+
// The release, leased on `lockSha`, AFTER the publish: the lock covers the
|
|
1370
|
+
// write. It runs whatever happened above, so a refused or failed publish
|
|
1371
|
+
// leaves the item for the next tick rather than locked.
|
|
1372
|
+
const released = await releaseLockLeased({
|
|
1373
|
+
cwd,
|
|
1374
|
+
arbiter,
|
|
1375
|
+
item,
|
|
1376
|
+
expectedSha: held.lockSha,
|
|
1377
|
+
env,
|
|
1378
|
+
});
|
|
1379
|
+
note(released.message);
|
|
1380
|
+
if (!released.released) {
|
|
1381
|
+
return {
|
|
1382
|
+
...result,
|
|
1383
|
+
exitCode: result.exitCode === 0 ? 1 : result.exitCode,
|
|
1384
|
+
message: `${result.message} (${released.message})`,
|
|
1385
|
+
};
|
|
1386
|
+
}
|
|
1387
|
+
return result;
|
|
1388
|
+
}
|
|
1389
|
+
/**
|
|
1390
|
+
* Surface the item to needs-attention (decision 5, and a rejected handoff): a
|
|
1391
|
+
* `kind: stuck` question in its sidecar and `needsAnswers: true` on `main`, then
|
|
1392
|
+
* the lock released (leased). Nothing from the agent job is read or written.
|
|
1393
|
+
*/
|
|
1394
|
+
async function surfaceItem(ctx, reason, outcome) {
|
|
1395
|
+
const { resolved, arbiter, cwd, env, note } = ctx;
|
|
1396
|
+
const { item } = resolved;
|
|
1397
|
+
const r = await surfaceStuckToNeedsAttention({
|
|
1398
|
+
cwd,
|
|
1399
|
+
slug: resolved.slug,
|
|
1400
|
+
item,
|
|
1401
|
+
reason,
|
|
1402
|
+
arbiter,
|
|
1403
|
+
env,
|
|
1404
|
+
note,
|
|
1405
|
+
});
|
|
1406
|
+
const message = r.surfaced
|
|
1407
|
+
? `Surfaced '${item}' to needs-attention: ${reason}`
|
|
1408
|
+
: `Could not surface '${item}' (${r.reasonNotSurfaced ?? 'unknown'}): ${reason}`;
|
|
1409
|
+
if (!r.surfaced) {
|
|
1410
|
+
return { exitCode: 1, outcome: 'surface-unmoved', item, message };
|
|
1411
|
+
}
|
|
1412
|
+
// A clean surface is green, a rejected handoff's included ("A handled
|
|
1413
|
+
// outcome is green", `ci-phase-driver.ts`).
|
|
1414
|
+
return {
|
|
1415
|
+
exitCode: 0,
|
|
1416
|
+
outcome,
|
|
1417
|
+
item,
|
|
1418
|
+
message,
|
|
1419
|
+
};
|
|
1420
|
+
}
|
|
1421
|
+
function pathForRung(rung) {
|
|
1422
|
+
if (rung === 'build-task')
|
|
1423
|
+
return 'build';
|
|
1424
|
+
if (rung === 'task-spec')
|
|
1425
|
+
return 'tasking';
|
|
1426
|
+
if (isTreelessPhaseRung(rung))
|
|
1427
|
+
return 'treeless';
|
|
1428
|
+
return undefined;
|
|
1429
|
+
}
|
|
1430
|
+
/**
|
|
1431
|
+
* Route `advance <item> --phase` to its split path. The agent and apply phases
|
|
1432
|
+
* follow the lock job's trusted `rung` output. The lock phase classifies the
|
|
1433
|
+
* item at the arbiter's `main` (read-only; the chosen path's lock phase fetches
|
|
1434
|
+
* and classifies again, and refuses a rung it does not split). Anything that
|
|
1435
|
+
* does not classify (a pending sidecar, an unreadable argument, no lock
|
|
1436
|
+
* outputs) falls back to the argument's namespace, so the path's own lock or
|
|
1437
|
+
* usage report runs: `spec:` is tasking, `obs:` is tree-less, anything else is
|
|
1438
|
+
* the build path.
|
|
1439
|
+
*/
|
|
1440
|
+
export async function advancePhasePath(params) {
|
|
1441
|
+
const explicit = parseSlugArg(params.arg).explicit;
|
|
1442
|
+
const fallback = explicit === 'spec'
|
|
1443
|
+
? 'tasking'
|
|
1444
|
+
: explicit === 'observation'
|
|
1445
|
+
? 'treeless'
|
|
1446
|
+
: 'build';
|
|
1447
|
+
const env = params.env ?? process.env;
|
|
1448
|
+
if (params.phase !== 'lock') {
|
|
1449
|
+
try {
|
|
1450
|
+
const facts = params.lockOutputs ?? readLockOutputsFromEnv(env);
|
|
1451
|
+
return pathForRung(facts.rung) ?? fallback;
|
|
1452
|
+
}
|
|
1453
|
+
catch {
|
|
1454
|
+
return fallback;
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
try {
|
|
1458
|
+
const cwd = params.cwd;
|
|
1459
|
+
const baseSha = await fetchArbiterMain({
|
|
1460
|
+
cwd,
|
|
1461
|
+
arbiter: params.arbiter ?? DEFAULT_ARBITER,
|
|
1462
|
+
env,
|
|
1463
|
+
});
|
|
1464
|
+
const kind = await withBaseWorktree({ cwd, baseSha, env }, async (base) => {
|
|
1465
|
+
const resolved = resolveAdvanceArg({
|
|
1466
|
+
arg: params.arg,
|
|
1467
|
+
repoPath: base,
|
|
1468
|
+
read: params.read ?? ledgerRead,
|
|
1469
|
+
});
|
|
1470
|
+
return classifyIn(base, {
|
|
1471
|
+
namespace: resolved.namespace,
|
|
1472
|
+
slug: resolved.slug,
|
|
1473
|
+
item: `${resolved.namespace}:${resolved.slug}`,
|
|
1474
|
+
}).kind;
|
|
1475
|
+
});
|
|
1476
|
+
return pathForRung(kind) ?? fallback;
|
|
1477
|
+
}
|
|
1478
|
+
catch {
|
|
1479
|
+
return fallback;
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
//# sourceMappingURL=ci-phase-treeless.js.map
|