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
package/src/integration-core.ts
CHANGED
|
@@ -27,16 +27,21 @@ import {
|
|
|
27
27
|
formatBlockReason,
|
|
28
28
|
reviewRoundsExhaustedReason,
|
|
29
29
|
} from './review-gate.js';
|
|
30
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
type IntegrateResult,
|
|
32
|
+
type ReviewProvider,
|
|
33
|
+
proposeRequestNotOpened,
|
|
34
|
+
} from './integrator.js';
|
|
31
35
|
import {extractDecisionsBlock} from './agent-stop.js';
|
|
32
36
|
import {ledgerWrite} from './ledger-write.js';
|
|
33
37
|
import {selectProvider} from './github.js';
|
|
34
38
|
import type {IntegrationMode} from './config.js';
|
|
35
|
-
import {parseFrontmatter} from './frontmatter.js';
|
|
39
|
+
import {parseFrontmatter, unquoteYamlScalar} from './frontmatter.js';
|
|
36
40
|
import {git, run, runAsync, type RunResult} from './git.js';
|
|
37
41
|
import {realSleep, type Sleep} from './retry-backoff.js';
|
|
38
42
|
import {workBranchRef} from './slug-namespace.js';
|
|
39
43
|
import {isAncestor} from './gc.js';
|
|
44
|
+
import {clearOwnQuestionResidueOnLand} from './land-question-residue.js';
|
|
40
45
|
import {
|
|
41
46
|
detectColocatedSidecars,
|
|
42
47
|
formatSidecarGuardReason,
|
|
@@ -204,6 +209,16 @@ export interface IntegrationLifecycle {
|
|
|
204
209
|
* WHICH lifecycle landed; it gates nothing.
|
|
205
210
|
*/
|
|
206
211
|
commitTag?: string;
|
|
212
|
+
/**
|
|
213
|
+
* The reviewer POINTER line heading the propose-mode PR body (task
|
|
214
|
+
* `propose-pr-bodies-point-at-the-real-item`): it names the item's REAL resting
|
|
215
|
+
* path after this lifecycle lands, so a reviewer can open the file the PR
|
|
216
|
+
* carries. The task build default (`Task: work/tasks/done/<slug>.md`) is wrong
|
|
217
|
+
* for a lifecycle (a tasked spec rests in `work/specs/tasked/`, an intake
|
|
218
|
+
* document in its staged folder), so every lifecycle states its own. Compose it
|
|
219
|
+
* with {@link proposePointer}.
|
|
220
|
+
*/
|
|
221
|
+
proposeHeader: string;
|
|
207
222
|
}
|
|
208
223
|
|
|
209
224
|
/**
|
|
@@ -429,6 +444,14 @@ export interface IntegrationCoreInput {
|
|
|
429
444
|
reviewModel?: string;
|
|
430
445
|
/** Bound the revise↔review loop (Gate 2). Defaults to 2. */
|
|
431
446
|
reviewMaxRounds?: number;
|
|
447
|
+
/**
|
|
448
|
+
* The approved review prose of a review that already ran ELSEWHERE, set only
|
|
449
|
+
* by the CI tasking apply phase (task `ci-split-tasking`): the task-set review
|
|
450
|
+
* ran in the agent phase, so this land runs with `review` off and posts that
|
|
451
|
+
* prose as the PR comment instead. Ignored when this band's own review runs
|
|
452
|
+
* and approves (its verdict's prose wins). Unset everywhere else.
|
|
453
|
+
*/
|
|
454
|
+
approvedReviewProse?: string;
|
|
432
455
|
/** Integration mode the caller REQUESTED (`propose` default, or `merge`). */
|
|
433
456
|
mode: IntegrationMode;
|
|
434
457
|
/**
|
|
@@ -681,7 +704,6 @@ export async function performIntegration(
|
|
|
681
704
|
const arbiter = input.arbiter;
|
|
682
705
|
const slug = input.slug;
|
|
683
706
|
const source = input.source;
|
|
684
|
-
const recovering = input.recovering;
|
|
685
707
|
const note = input.note ?? (() => {});
|
|
686
708
|
// The work branch: the caller is on it (it carries the namespaced identity).
|
|
687
709
|
// Prefer the explicit `branch`; else read the branch HEAD is on; only fall
|
|
@@ -1119,6 +1141,19 @@ export async function performIntegration(
|
|
|
1119
1141
|
transcribeDecisionsIntoDoneRecord({cwd, slug, output: input.body, note});
|
|
1120
1142
|
}
|
|
1121
1143
|
|
|
1144
|
+
// 2c. CLEAR THE TASK'S OWN STALE QUESTION STATE in the SAME done-move commit
|
|
1145
|
+
// (task `a-recovered-task-lands-without-stale-question-state`). A task that
|
|
1146
|
+
// was surfaced to needs-attention and then recovered carries
|
|
1147
|
+
// `needsAnswers: true` + its `work/questions/task-<slug>.md` sidecar on
|
|
1148
|
+
// this branch; the done-move makes both moot, so they leave in the commit
|
|
1149
|
+
// that makes them moot rather than on some later claim's reconcile. A
|
|
1150
|
+
// normal land is untouched. Residue that only ARRIVES with the rebase (the
|
|
1151
|
+
// surface landed on `main` after this branch was cut) is folded in after
|
|
1152
|
+
// the rebase instead ({@link foldQuestionResidueIntoTip}).
|
|
1153
|
+
if (!lifecycle) {
|
|
1154
|
+
clearOwnQuestionResidueOnLand({cwd, slug, note});
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1122
1157
|
// 3. Commit: git add -A (the agent's uncommitted work + the move) into ONE
|
|
1123
1158
|
// atomic commit. Nothing to commit is FATAL (no-op-is-fatal, like claim.sh).
|
|
1124
1159
|
await gitHard(['add', '-A'], cwd, env);
|
|
@@ -1150,6 +1185,29 @@ export async function performIntegration(
|
|
|
1150
1185
|
await gitHard(['commit', '-q', '-m', commitMessage], cwd, env);
|
|
1151
1186
|
note(`Committed: ${commitMessage}`);
|
|
1152
1187
|
|
|
1188
|
+
// The rebase-to-integrate TAIL (step 4 fetch+rebase → step 5 integrate) is the
|
|
1189
|
+
// ONLY region serialised per repo under the `run` concurrency seam: it is the
|
|
1190
|
+
// land-on-`main` band where two concurrent SAME-repo merge jobs would otherwise
|
|
1191
|
+
// race (the loser pushing a non-fast-forward `${branch}:main`). Wrapping ONLY
|
|
1192
|
+
// this tail keeps the front-of-band gate (`prepare`+`verify`) and the Gate-2
|
|
1193
|
+
// review agent CONCURRENT across same-repo jobs (run's parallelism); inside the
|
|
1194
|
+
// lock the loser re-fetches + rebases onto the winner's now-advanced main, so
|
|
1195
|
+
// its push is a clean fast-forward (a genuine conflict routes ONE to
|
|
1196
|
+
// needs-attention). Single-job callers pass no lock ⇒ the tail runs directly,
|
|
1197
|
+
// byte-for-byte unchanged. The lock is keyed per repo, so cross-repo
|
|
1198
|
+
// integration stays fully concurrent.
|
|
1199
|
+
const reconcileCtx: RebaseReconcileContext = {
|
|
1200
|
+
cwd,
|
|
1201
|
+
arbiter,
|
|
1202
|
+
slug,
|
|
1203
|
+
branch,
|
|
1204
|
+
lifecycle: lifecycle !== undefined,
|
|
1205
|
+
source,
|
|
1206
|
+
surfaceArbiter: input.surfaceArbiter,
|
|
1207
|
+
commitMessage,
|
|
1208
|
+
env,
|
|
1209
|
+
note,
|
|
1210
|
+
};
|
|
1153
1211
|
// The rebase-to-integrate TAIL (step 4 fetch+rebase → step 5 integrate) is the
|
|
1154
1212
|
// ONLY region serialised per repo under the `run` concurrency seam: it is the
|
|
1155
1213
|
// land-on-`main` band where two concurrent SAME-repo merge jobs would otherwise
|
|
@@ -1162,223 +1220,12 @@ export async function performIntegration(
|
|
|
1162
1220
|
// byte-for-byte unchanged. The lock is keyed per repo, so cross-repo
|
|
1163
1221
|
// integration stays fully concurrent.
|
|
1164
1222
|
const runRebaseToIntegrateTail = async (): Promise<IntegrationCoreResult> => {
|
|
1165
|
-
// The step-4 rebase-onto-`<arbiter>/main` (with BOTH reconciliation arms: the
|
|
1166
|
-
// sibling-slug ledger arm and the divergent-done-move recovery), factored so it
|
|
1167
|
-
// can run ONCE before the gate AND be RE-RUN in the Race-1 merge-push retry loop
|
|
1168
|
-
// (a sibling advancing main mid-push needs the SAME reconcile, not a bare
|
|
1169
|
-
// rebase). Returns `{}` on a clean rebase (fall through to gate/integrate) or
|
|
1170
|
-
// `{route}` when a genuine conflict / invariant violation must stop the tail.
|
|
1171
|
-
const rebaseOntoMainWithReconcile = async (): Promise<{
|
|
1172
|
-
route?: IntegrationCoreResult;
|
|
1173
|
-
}> => {
|
|
1174
|
-
// 4. Rebase-before-integrate (ADR §10): rebase the work branch onto the
|
|
1175
|
-
// latest <arbiter>/main. Clean → continue. Conflict → abort + stop.
|
|
1176
|
-
//
|
|
1177
|
-
// RECOVERY reconciliation: post the per-item-lock cut-over, a stuck item's
|
|
1178
|
-
// body never moved into a `needs-attention/` folder (stuck is the lock
|
|
1179
|
-
// `state: stuck`; the body rests in `backlog/`), so a recovery `complete`'s
|
|
1180
|
-
// kept branch carries NO historical `in-progress → needs-attention`
|
|
1181
|
-
// move-only commit to drop and `<arbiter>/main` holds no surface move to
|
|
1182
|
-
// conflict with. The old recovery drop (`rebaseDroppingNeedsAttentionSurface`,
|
|
1183
|
-
// drop-bookkeeping-rebase) is deleted; recovery is now the SAME plain replay
|
|
1184
|
-
// onto `<arbiter>/main` as any other build (see the plain-rebase note below).
|
|
1185
|
-
// Fetch the arbiter's `main` into the `<arbiter>/main` remote-tracking ref
|
|
1186
|
-
// EXPLICITLY. A `run` JOB WORKTREE is cut from a bare hub mirror whose remote
|
|
1187
|
-
// has no fetch refspec (so `<arbiter>/main` would not otherwise resolve / would
|
|
1188
|
-
// be stale, causing a spurious rebase conflict); a regular clone (`do`/
|
|
1189
|
-
// `complete`) already has it, where the explicit refspec is harmless (the same
|
|
1190
|
-
// refspec `rebaseOntoArbiterMain` used before the convergence).
|
|
1191
|
-
await gitHard(
|
|
1192
|
-
[
|
|
1193
|
-
'fetch',
|
|
1194
|
-
'--quiet',
|
|
1195
|
-
arbiter,
|
|
1196
|
-
`+refs/heads/main:refs/remotes/${arbiter}/main`,
|
|
1197
|
-
],
|
|
1198
|
-
cwd,
|
|
1199
|
-
env,
|
|
1200
|
-
);
|
|
1201
|
-
// ONE-SLUG-ONE-FOLDER guard + divergent-base PRE-CHECK, read from the
|
|
1202
|
-
// freshly-fetched `<arbiter>/main` (a READ of the tracking ref the fetch above
|
|
1203
|
-
// just populated — NO new fetch, so no shared-mirror race). It (a) FAILS LOUD if
|
|
1204
|
-
// the arbiter already holds the slug in >1 status folder with differing content
|
|
1205
|
-
// (a corrupt ledger; never publish over it), and (b) detects the DIVERGENT base
|
|
1206
|
-
// — the arbiter holds the slug's source in a DIFFERENT folder than our local
|
|
1207
|
-
// done-move removed — which is the case that turns the rebased "move" into a
|
|
1208
|
-
// "copy" (PR #86). The tasking lifecycle is exempt (its move is not a task
|
|
1209
|
-
// done-move).
|
|
1210
|
-
// CONTINUE-BUILD EXEMPTION (task
|
|
1211
|
-
// `complete-builds-on-already-done-moved-continue`): on `source: 'done'`
|
|
1212
|
-
// there was no first-time move on this commit (the slug was already in
|
|
1213
|
-
// `done/` on the kept branch + on the arbiter), so the divergent-done-move
|
|
1214
|
-
// reconcile reasoning does not apply. Skip the arbiter ledger placement
|
|
1215
|
-
// pre-check too: it is structurally an instrument FOR that reconcile, and a
|
|
1216
|
-
// continue-build is by construction already in `done/` on both sides.
|
|
1217
|
-
if (!lifecycle && source !== 'done') {
|
|
1218
|
-
const arbiterPlacement = readArbiterLedgerPlacement(
|
|
1219
|
-
cwd,
|
|
1220
|
-
arbiter,
|
|
1221
|
-
slug,
|
|
1222
|
-
env,
|
|
1223
|
-
);
|
|
1224
|
-
if (arbiterPlacement.error) {
|
|
1225
|
-
note(arbiterPlacement.error);
|
|
1226
|
-
return {
|
|
1227
|
-
route: {
|
|
1228
|
-
outcome: 'invariant-violation',
|
|
1229
|
-
routedToNeedsAttention: false,
|
|
1230
|
-
branch,
|
|
1231
|
-
reason: arbiterPlacement.error,
|
|
1232
|
-
},
|
|
1233
|
-
};
|
|
1234
|
-
}
|
|
1235
|
-
}
|
|
1236
|
-
// PLAIN rebase. After the per-item-lock cut-over (spec
|
|
1237
|
-
// `ledger-status-per-item-lock-refs`, tasks 9a–9d) no transient status
|
|
1238
|
-
// lands on a work branch: needs-attention is the lock `state: stuck` (not a
|
|
1239
|
-
// `git mv` to `needs-attention/`), the body rests in `backlog/` while
|
|
1240
|
-
// claimed, and the tasking/advancing markers are gone. So a recovery
|
|
1241
|
-
// complete's kept branch carries NO historical route-to-needs-attention
|
|
1242
|
-
// move-only commit to drop — the old `rebaseDroppingNeedsAttentionSurface`
|
|
1243
|
-
// (drop-bookkeeping-rebase) is deleted and BOTH recovering and lifecycle
|
|
1244
|
-
// rebases are the same plain replay onto `<arbiter>/main`.
|
|
1245
|
-
void recovering;
|
|
1246
|
-
void lifecycle;
|
|
1247
|
-
// RENAME-DETECTION-OFF (task
|
|
1248
|
-
// `disable-rename-detection-on-continue-rebase`): scope
|
|
1249
|
-
// `-c merge.directoryRenames=false` to THIS rebase invocation, so a single
|
|
1250
|
-
// durable folder-transition `git mv` out of a SPARSE work/<from>/ folder is
|
|
1251
|
-
// NOT misread by git's directory-rename heuristic as a whole-DIRECTORY
|
|
1252
|
-
// rename `work/<from>/ → work/<to>/` (which would spuriously flag every
|
|
1253
|
-
// sibling file `<arbiter>/main` added into that folder as `CONFLICT (file
|
|
1254
|
-
// location)` and force a FALSE needs-attention). Content-rename detection
|
|
1255
|
-
// (`-Xno-renames`/`merge.renames`/`diff.renames`) is the wrong knob and
|
|
1256
|
-
// does NOT suppress this directory-rename conflict; only
|
|
1257
|
-
// `merge.directoryRenames=false` does. NEVER a persistent `git config`
|
|
1258
|
-
// write — the repo's config stays clean so a user's interactive
|
|
1259
|
-
// `git rebase` is unaffected. A GENUINE content conflict still surfaces
|
|
1260
|
-
// and still routes via `rebaseConflictRoute()` below.
|
|
1261
|
-
const rebase = await gitSoft(
|
|
1262
|
-
['-c', 'merge.directoryRenames=false', 'rebase', `${arbiter}/main`],
|
|
1263
|
-
cwd,
|
|
1264
|
-
env,
|
|
1265
|
-
);
|
|
1266
|
-
if (rebase.status !== 0) {
|
|
1267
|
-
// NEVER auto-resolve a genuine CODE conflict. But FIRST, a SIBLING-SLUG
|
|
1268
|
-
// LEDGER conflict (the replay conflicts ONLY on OTHER slugs'
|
|
1269
|
-
// `work/<status>/<otherslug>.md` ledger files — a sibling job landed its own
|
|
1270
|
-
// status-folder move on `<arbiter>/main` between our base and this rebase) is
|
|
1271
|
-
// a benign ledger-only divergence with NO semantic judgement: the reconcile
|
|
1272
|
-
// ABORTS the rebase, then redoes OUR work as one clean commit on top of
|
|
1273
|
-
// `<arbiter>/main` (taking the arbiter's version of every sibling ledger file
|
|
1274
|
-
// automatically). It is scoped STRICTLY to other slugs' ledger files — a
|
|
1275
|
-
// conflict touching ANY code file, or THIS slug's own ledger, returns `false`
|
|
1276
|
-
// (and leaves the rebase in progress), so the divergent-done-move recovery /
|
|
1277
|
-
// needs-attention route below handles it; it NEVER widens to code. The tasking
|
|
1278
|
-
// lifecycle is exempt (its move is not a task done-move).
|
|
1279
|
-
const siblingReconciled = lifecycle
|
|
1280
|
-
? false
|
|
1281
|
-
: await reconcileSiblingLedgerConflict({
|
|
1282
|
-
cwd,
|
|
1283
|
-
arbiter,
|
|
1284
|
-
slug,
|
|
1285
|
-
env,
|
|
1286
|
-
note,
|
|
1287
|
-
});
|
|
1288
|
-
if (siblingReconciled) {
|
|
1289
|
-
// The branch is now cleanly on top of `<arbiter>/main` with OUR work + the
|
|
1290
|
-
// arbiter's sibling-ledger files — fall through to the fresh-gate + integrate
|
|
1291
|
-
// band. THIS slug's own move is untouched.
|
|
1292
|
-
note(
|
|
1293
|
-
`Reconciled a sibling-slug ledger conflict during the rebase onto ` +
|
|
1294
|
-
`${arbiter}/main (took the arbiter's version of the other slugs' ` +
|
|
1295
|
-
`work/<status>/<slug>.md ledger files; no code file was touched).`,
|
|
1296
|
-
);
|
|
1297
|
-
} else if (!lifecycle && source !== 'done') {
|
|
1298
|
-
// NEVER auto-resolve a genuine CODE conflict: abort the rebase. But FIRST, a
|
|
1299
|
-
// DIVERGENT-LEDGER conflict (the arbiter holds the slug's source in a folder
|
|
1300
|
-
// our local done-move did not remove — PR #86) is auto-RECONCILABLE without
|
|
1301
|
-
// any semantic judgement: redo the done-move arbiter-resolved (remove the
|
|
1302
|
-
// arbiter's actual source folder, add `done/`) on top of `<arbiter>/main`. We
|
|
1303
|
-
// only do this when the post-abort tree's ONLY divergence is the slug's ledger
|
|
1304
|
-
// file; a real code conflict still routes to needs-attention untouched.
|
|
1305
|
-
await gitSoft(['rebase', '--abort'], cwd, env);
|
|
1306
|
-
const recovered = await reconcileDivergentDoneMove({
|
|
1307
|
-
cwd,
|
|
1308
|
-
arbiter,
|
|
1309
|
-
slug,
|
|
1310
|
-
branch,
|
|
1311
|
-
localSource: source,
|
|
1312
|
-
env,
|
|
1313
|
-
note,
|
|
1314
|
-
});
|
|
1315
|
-
if (recovered) {
|
|
1316
|
-
// The branch is now cleanly on top of `<arbiter>/main` with the slug in
|
|
1317
|
-
// `done/` ONLY — fall through to integrate (skip the needs-attention
|
|
1318
|
-
// route below).
|
|
1319
|
-
note(
|
|
1320
|
-
`Reconciled the done-move against ${arbiter}/main: '${slug}' is in ` +
|
|
1321
|
-
'work/done/ ONLY (the divergent source folder was removed; the move ' +
|
|
1322
|
-
'is a move, not a copy).',
|
|
1323
|
-
);
|
|
1324
|
-
} else {
|
|
1325
|
-
return {route: await rebaseConflictRoute()};
|
|
1326
|
-
}
|
|
1327
|
-
} else {
|
|
1328
|
-
await gitSoft(['rebase', '--abort'], cwd, env);
|
|
1329
|
-
return {route: await rebaseConflictRoute()};
|
|
1330
|
-
}
|
|
1331
|
-
}
|
|
1332
|
-
// Clean rebase (or a reconciled one): fall through to the gate + integrate.
|
|
1333
|
-
return {};
|
|
1334
|
-
};
|
|
1335
|
-
|
|
1336
1223
|
// Run the step-4 rebase ONCE up front (before the slow fresh gate).
|
|
1337
|
-
const firstRebase = await rebaseOntoMainWithReconcile();
|
|
1224
|
+
const firstRebase = await rebaseOntoMainWithReconcile(reconcileCtx);
|
|
1338
1225
|
if (firstRebase.route) {
|
|
1339
1226
|
return firstRebase.route;
|
|
1340
1227
|
}
|
|
1341
1228
|
|
|
1342
|
-
// The rebase-conflict needs-attention route, factored so the divergent-ledger
|
|
1343
|
-
// recovery above can fall through to integrate while a genuine code conflict
|
|
1344
|
-
// still routes here.
|
|
1345
|
-
async function rebaseConflictRoute(): Promise<IntegrationCoreResult> {
|
|
1346
|
-
// Then mark the item stuck on its per-item lock with the conflict reason
|
|
1347
|
-
// (ADR §12) THROUGH the ledger write seam's needs-attention transition,
|
|
1348
|
-
// rather than leaving it dangling. Post lock-cutover this is a lock amend
|
|
1349
|
-
// (`state: stuck` + the reason on the lock entry, no `done/ →
|
|
1350
|
-
// needs-attention/` folder move); the done-move already committed above
|
|
1351
|
-
// stands. No partial state.
|
|
1352
|
-
const reason = `rebase onto ${arbiter}/main conflicted (aborted, never auto-resolved)`;
|
|
1353
|
-
const routed = await ledgerWrite.applyNeedsAttentionTransition({
|
|
1354
|
-
cwd,
|
|
1355
|
-
slug,
|
|
1356
|
-
reason,
|
|
1357
|
-
// Autonomous caller (`do`) passes the arbiter so the seam both surfaces the
|
|
1358
|
-
// conflict on `main` (OBSERVABLE) AND pushes the `work/<slug>` branch
|
|
1359
|
-
// (RECOVERABLE, cross-machine). The human `complete` leaves it unset →
|
|
1360
|
-
// no surface, no push, local-only. The push lives in the seam (HEAD's
|
|
1361
|
-
// branch is `work/<slug>` here) — no bolted-on push.
|
|
1362
|
-
arbiter: input.surfaceArbiter,
|
|
1363
|
-
env,
|
|
1364
|
-
note,
|
|
1365
|
-
});
|
|
1366
|
-
return {
|
|
1367
|
-
outcome: 'rebase-conflict',
|
|
1368
|
-
routedToNeedsAttention: routed.moved,
|
|
1369
|
-
branch,
|
|
1370
|
-
commitMessage,
|
|
1371
|
-
reason: routed.moved
|
|
1372
|
-
? `Rebasing ${branch} onto ${arbiter}/main conflicted; the rebase was ` +
|
|
1373
|
-
`aborted (never auto-resolved) and '${slug}' was marked stuck on ` +
|
|
1374
|
-
'its per-item lock (surfaced by status). Resolve against the latest ' +
|
|
1375
|
-
'main, then `requeue` to release the stuck lock and re-run.'
|
|
1376
|
-
: `Rebasing ${branch} onto ${arbiter}/main conflicted; the rebase was ` +
|
|
1377
|
-
'aborted (never auto-resolved). Resolve against the latest main, ' +
|
|
1378
|
-
'then re-run complete.',
|
|
1379
|
-
};
|
|
1380
|
-
}
|
|
1381
|
-
|
|
1382
1229
|
// 4c. FRESH-WORKTREE GATE (task `gate-on-rebased-tip-fresh-worktree`): when ON,
|
|
1383
1230
|
// the acceptance gate (`prepare` then `verify`) runs HERE — on the work
|
|
1384
1231
|
// branch tip the rebase above just produced (the would-be-integrated tip) —
|
|
@@ -1510,220 +1357,627 @@ export async function performIntegration(
|
|
|
1510
1357
|
}
|
|
1511
1358
|
}
|
|
1512
1359
|
|
|
1513
|
-
// 5.
|
|
1514
|
-
//
|
|
1515
|
-
//
|
|
1516
|
-
//
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
input.
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1360
|
+
// 5. The LAND half (`landIntegration`, reached through the `integrationLand`
|
|
1361
|
+
// seam): integrate per mode with the merge-mode CAS loop, then the review
|
|
1362
|
+
// comment. The CI agent phase records this call as its boundary; the apply
|
|
1363
|
+
// phase resumes here (task `ci-split-build-path`).
|
|
1364
|
+
return await integrationLand.land({
|
|
1365
|
+
...reconcileCtx,
|
|
1366
|
+
mode,
|
|
1367
|
+
noPR: input.noPR,
|
|
1368
|
+
providerInstance: input.providerInstance,
|
|
1369
|
+
openPr: input.openPr,
|
|
1370
|
+
title: prTitle,
|
|
1371
|
+
body: composeProposeBody({
|
|
1372
|
+
slug,
|
|
1373
|
+
body: input.body,
|
|
1374
|
+
header: lifecycle?.proposeHeader,
|
|
1375
|
+
}),
|
|
1376
|
+
reviewProse: approvedVerdict?.review ?? input.approvedReviewProse,
|
|
1377
|
+
mergeRetries: input.mergeRetries,
|
|
1378
|
+
mergeJitterMs: input.mergeJitterMs,
|
|
1379
|
+
});
|
|
1380
|
+
};
|
|
1381
|
+
|
|
1382
|
+
// Serialise ONLY this tail per repo when the `run` seam is wired; absent ⇒ run
|
|
1383
|
+
// it directly (an un-contended no-op, single-job behaviour unchanged).
|
|
1384
|
+
return input.integrateLock && input.integrateLockKey !== undefined
|
|
1385
|
+
? await input.integrateLock(
|
|
1386
|
+
input.integrateLockKey,
|
|
1387
|
+
runRebaseToIntegrateTail,
|
|
1388
|
+
)
|
|
1389
|
+
: await runRebaseToIntegrateTail();
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
/**
|
|
1393
|
+
* What {@link rebaseOntoMainWithReconcile} (and its conflict route) needs: the
|
|
1394
|
+
* work branch in `cwd`, the arbiter, and which ledger reconcile arms apply.
|
|
1395
|
+
*/
|
|
1396
|
+
export interface RebaseReconcileContext {
|
|
1397
|
+
/** The checkout / worktree the work branch is checked out in. */
|
|
1398
|
+
cwd: string;
|
|
1399
|
+
/** Name of the arbiter git remote (valid in `cwd`). */
|
|
1400
|
+
arbiter: string;
|
|
1401
|
+
slug: string;
|
|
1402
|
+
/** The work branch (checked out in `cwd`). */
|
|
1403
|
+
branch: string;
|
|
1404
|
+
/**
|
|
1405
|
+
* True for a lifecycle (tasking / intake) transition: the task done-move
|
|
1406
|
+
* reconcile arms are exempt.
|
|
1407
|
+
*/
|
|
1408
|
+
lifecycle: boolean;
|
|
1409
|
+
/** The done-move source folder (`done` exempts the ledger reconcile arms). */
|
|
1410
|
+
source: IntegrationCoreInput['source'];
|
|
1411
|
+
/** The arbiter a conflict surfaces on (the autonomous path); unset ⇒ local-only. */
|
|
1412
|
+
surfaceArbiter?: string;
|
|
1413
|
+
/** The completion commit's message (reported on the result). */
|
|
1414
|
+
commitMessage: string;
|
|
1415
|
+
env?: NodeJS.ProcessEnv;
|
|
1416
|
+
note: (message: string) => void;
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
/** The inputs of {@link landIntegration}, the land half of the tail. */
|
|
1420
|
+
export interface IntegrationLandInput extends RebaseReconcileContext {
|
|
1421
|
+
/** The resolved integration mode. */
|
|
1422
|
+
mode: IntegrationMode;
|
|
1423
|
+
/** PR-INTENT: push the branch but open no PR (propose). */
|
|
1424
|
+
noPR?: boolean;
|
|
1425
|
+
/** An injected fully-formed provider (tests / embeddings); else selected from the arbiter URL. */
|
|
1426
|
+
providerInstance?: ReviewProvider;
|
|
1427
|
+
/** The legacy `openPr` bridge. */
|
|
1428
|
+
openPr?: IntegrationCoreInput['openPr'];
|
|
1429
|
+
/** The single-line propose PR title. */
|
|
1430
|
+
title: string;
|
|
1431
|
+
/** The composed propose PR body (header + summary); undefined ⇒ `--fill`. */
|
|
1432
|
+
body?: string;
|
|
1433
|
+
/** The approved Gate-2 review prose, posted as a PR comment after a propose. */
|
|
1434
|
+
reviewProse?: string;
|
|
1435
|
+
/** The merge-mode CAS-loop liveness ceiling ({@link DEFAULT_MERGE_RETRIES}). */
|
|
1436
|
+
mergeRetries?: number;
|
|
1437
|
+
/** The merge-mode refetch jitter ({@link DEFAULT_MERGE_JITTER_MS}). */
|
|
1438
|
+
mergeJitterMs?: number;
|
|
1439
|
+
/**
|
|
1440
|
+
* The tip the acceptance gate ran on, set ONLY by the CI apply phase (the
|
|
1441
|
+
* validated bundle tip, `ci-phase-build.ts`). When set, a merge-mode land
|
|
1442
|
+
* whose landed tree differs from this tip's tree reports "landed without
|
|
1443
|
+
* re-gate after N lost races" in the run output and in a
|
|
1444
|
+
* {@link LANDED_WITHOUT_REGATE_TRAILER} trailer on the landed commit (ADR
|
|
1445
|
+
* `ci-agent-job-holds-no-write-token` decision 2). Unset (every non-phase
|
|
1446
|
+
* land: `do`, `complete`, `run`) ⇒ no report and no rewrite, today's
|
|
1447
|
+
* behaviour unchanged.
|
|
1448
|
+
*/
|
|
1449
|
+
gatedTip?: string;
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
/**
|
|
1453
|
+
* The seam `performIntegration` reaches the land half through: a process-wide
|
|
1454
|
+
* object (like `ledgerWrite`) so the CI agent phase can RECORD the land as the
|
|
1455
|
+
* boundary (`phase-recorder.ts`) instead of performing it, and the apply phase
|
|
1456
|
+
* can resume at it (`ci-phase-build.ts`).
|
|
1457
|
+
*/
|
|
1458
|
+
export interface IntegrationLandSeam {
|
|
1459
|
+
land(input: IntegrationLandInput): Promise<IntegrationCoreResult>;
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
/** The process-wide {@link IntegrationLandSeam} (replaced only by the phase recorder). */
|
|
1463
|
+
export const integrationLand: IntegrationLandSeam = {
|
|
1464
|
+
land: (input) => landIntegration(input),
|
|
1465
|
+
};
|
|
1466
|
+
|
|
1467
|
+
/**
|
|
1468
|
+
* The step-4 rebase-onto-`<arbiter>/main` (with BOTH reconciliation arms: the
|
|
1469
|
+
* sibling-slug ledger arm and the divergent-done-move recovery), factored so it
|
|
1470
|
+
* can run ONCE before the gate AND be RE-RUN in the Race-1 merge-push retry loop
|
|
1471
|
+
* (a sibling advancing main mid-push needs the SAME reconcile, not a bare
|
|
1472
|
+
* rebase). Returns `{}` on a clean rebase (fall through to gate/integrate) or
|
|
1473
|
+
* `{route}` when a genuine conflict / invariant violation must stop the tail.
|
|
1474
|
+
* Module-level (not a closure) so BOTH halves of the rebase-to-integrate tail
|
|
1475
|
+
* reach it: the agent half runs it once before the fresh gate, and the land
|
|
1476
|
+
* half ({@link landIntegration}) re-runs it in the merge-mode CAS loop.
|
|
1477
|
+
*/
|
|
1478
|
+
async function rebaseOntoMainWithReconcile(
|
|
1479
|
+
ctx: RebaseReconcileContext,
|
|
1480
|
+
): Promise<{route?: IntegrationCoreResult}> {
|
|
1481
|
+
const {cwd, arbiter, slug, branch, lifecycle, source, env} = ctx;
|
|
1482
|
+
const note = ctx.note;
|
|
1483
|
+
// 4. Rebase-before-integrate (ADR §10): rebase the work branch onto the
|
|
1484
|
+
// latest <arbiter>/main. Clean → continue. Conflict → abort + stop.
|
|
1485
|
+
//
|
|
1486
|
+
// RECOVERY reconciliation: post the per-item-lock cut-over, a stuck item's
|
|
1487
|
+
// body never moved into a `needs-attention/` folder (stuck is the lock
|
|
1488
|
+
// `state: stuck`; the body rests in `backlog/`), so a recovery `complete`'s
|
|
1489
|
+
// kept branch carries NO historical `in-progress → needs-attention`
|
|
1490
|
+
// move-only commit to drop and `<arbiter>/main` holds no surface move to
|
|
1491
|
+
// conflict with. The old recovery drop (`rebaseDroppingNeedsAttentionSurface`,
|
|
1492
|
+
// drop-bookkeeping-rebase) is deleted; recovery is now the SAME plain replay
|
|
1493
|
+
// onto `<arbiter>/main` as any other build (see the plain-rebase note below).
|
|
1494
|
+
// Fetch the arbiter's `main` into the `<arbiter>/main` remote-tracking ref
|
|
1495
|
+
// EXPLICITLY. A `run` JOB WORKTREE is cut from a bare hub mirror whose remote
|
|
1496
|
+
// has no fetch refspec (so `<arbiter>/main` would not otherwise resolve / would
|
|
1497
|
+
// be stale, causing a spurious rebase conflict); a regular clone (`do`/
|
|
1498
|
+
// `complete`) already has it, where the explicit refspec is harmless (the same
|
|
1499
|
+
// refspec `rebaseOntoArbiterMain` used before the convergence).
|
|
1500
|
+
await gitHard(
|
|
1501
|
+
[
|
|
1502
|
+
'fetch',
|
|
1503
|
+
'--quiet',
|
|
1504
|
+
arbiter,
|
|
1505
|
+
`+refs/heads/main:refs/remotes/${arbiter}/main`,
|
|
1506
|
+
],
|
|
1507
|
+
cwd,
|
|
1508
|
+
env,
|
|
1509
|
+
);
|
|
1510
|
+
// ONE-SLUG-ONE-FOLDER guard + divergent-base PRE-CHECK, read from the
|
|
1511
|
+
// freshly-fetched `<arbiter>/main` (a READ of the tracking ref the fetch above
|
|
1512
|
+
// just populated — NO new fetch, so no shared-mirror race). It (a) FAILS LOUD if
|
|
1513
|
+
// the arbiter already holds the slug in >1 status folder with differing content
|
|
1514
|
+
// (a corrupt ledger; never publish over it), and (b) detects the DIVERGENT base
|
|
1515
|
+
// — the arbiter holds the slug's source in a DIFFERENT folder than our local
|
|
1516
|
+
// done-move removed — which is the case that turns the rebased "move" into a
|
|
1517
|
+
// "copy" (PR #86). The tasking lifecycle is exempt (its move is not a task
|
|
1518
|
+
// done-move).
|
|
1519
|
+
// CONTINUE-BUILD EXEMPTION (task
|
|
1520
|
+
// `complete-builds-on-already-done-moved-continue`): on `source: 'done'`
|
|
1521
|
+
// there was no first-time move on this commit (the slug was already in
|
|
1522
|
+
// `done/` on the kept branch + on the arbiter), so the divergent-done-move
|
|
1523
|
+
// reconcile reasoning does not apply. Skip the arbiter ledger placement
|
|
1524
|
+
// pre-check too: it is structurally an instrument FOR that reconcile, and a
|
|
1525
|
+
// continue-build is by construction already in `done/` on both sides.
|
|
1526
|
+
if (!lifecycle && source !== 'done') {
|
|
1527
|
+
const arbiterPlacement = readArbiterLedgerPlacement(
|
|
1528
|
+
cwd,
|
|
1529
|
+
arbiter,
|
|
1530
|
+
slug,
|
|
1531
|
+
env,
|
|
1532
|
+
);
|
|
1533
|
+
if (arbiterPlacement.error) {
|
|
1534
|
+
note(arbiterPlacement.error);
|
|
1535
|
+
return {
|
|
1536
|
+
route: {
|
|
1537
|
+
outcome: 'invariant-violation',
|
|
1538
|
+
routedToNeedsAttention: false,
|
|
1539
|
+
branch,
|
|
1540
|
+
reason: arbiterPlacement.error,
|
|
1541
|
+
},
|
|
1542
|
+
};
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
// PLAIN rebase. After the per-item-lock cut-over (spec
|
|
1546
|
+
// `ledger-status-per-item-lock-refs`, tasks 9a–9d) no transient status
|
|
1547
|
+
// lands on a work branch: needs-attention is the lock `state: stuck` (not a
|
|
1548
|
+
// `git mv` to `needs-attention/`), the body rests in `backlog/` while
|
|
1549
|
+
// claimed, and the tasking/advancing markers are gone. So a recovery
|
|
1550
|
+
// complete's kept branch carries NO historical route-to-needs-attention
|
|
1551
|
+
// move-only commit to drop — the old `rebaseDroppingNeedsAttentionSurface`
|
|
1552
|
+
// (drop-bookkeeping-rebase) is deleted and BOTH recovering and lifecycle
|
|
1553
|
+
// rebases are the same plain replay onto `<arbiter>/main`.
|
|
1554
|
+
// RENAME-DETECTION-OFF (task
|
|
1555
|
+
// `disable-rename-detection-on-continue-rebase`): scope
|
|
1556
|
+
// `-c merge.directoryRenames=false` to THIS rebase invocation, so a single
|
|
1557
|
+
// durable folder-transition `git mv` out of a SPARSE work/<from>/ folder is
|
|
1558
|
+
// NOT misread by git's directory-rename heuristic as a whole-DIRECTORY
|
|
1559
|
+
// rename `work/<from>/ → work/<to>/` (which would spuriously flag every
|
|
1560
|
+
// sibling file `<arbiter>/main` added into that folder as `CONFLICT (file
|
|
1561
|
+
// location)` and force a FALSE needs-attention). Content-rename detection
|
|
1562
|
+
// (`-Xno-renames`/`merge.renames`/`diff.renames`) is the wrong knob and
|
|
1563
|
+
// does NOT suppress this directory-rename conflict; only
|
|
1564
|
+
// `merge.directoryRenames=false` does. NEVER a persistent `git config`
|
|
1565
|
+
// write — the repo's config stays clean so a user's interactive
|
|
1566
|
+
// `git rebase` is unaffected. A GENUINE content conflict still surfaces
|
|
1567
|
+
// and still routes via `rebaseConflictRoute(ctx)` below.
|
|
1568
|
+
const rebase = await gitSoft(
|
|
1569
|
+
['-c', 'merge.directoryRenames=false', 'rebase', `${arbiter}/main`],
|
|
1570
|
+
cwd,
|
|
1571
|
+
env,
|
|
1572
|
+
);
|
|
1573
|
+
if (rebase.status !== 0) {
|
|
1574
|
+
// NEVER auto-resolve a genuine CODE conflict. But FIRST, a SIBLING-SLUG
|
|
1575
|
+
// LEDGER conflict (the replay conflicts ONLY on OTHER slugs'
|
|
1576
|
+
// `work/<status>/<otherslug>.md` ledger files — a sibling job landed its own
|
|
1577
|
+
// status-folder move on `<arbiter>/main` between our base and this rebase) is
|
|
1578
|
+
// a benign ledger-only divergence with NO semantic judgement: the reconcile
|
|
1579
|
+
// ABORTS the rebase, then redoes OUR work as one clean commit on top of
|
|
1580
|
+
// `<arbiter>/main` (taking the arbiter's version of every sibling ledger file
|
|
1581
|
+
// automatically). It is scoped STRICTLY to other slugs' ledger files — a
|
|
1582
|
+
// conflict touching ANY code file, or THIS slug's own ledger, returns `false`
|
|
1583
|
+
// (and leaves the rebase in progress), so the divergent-done-move recovery /
|
|
1584
|
+
// needs-attention route below handles it; it NEVER widens to code. The tasking
|
|
1585
|
+
// lifecycle is exempt (its move is not a task done-move).
|
|
1586
|
+
const siblingReconciled = lifecycle
|
|
1587
|
+
? false
|
|
1588
|
+
: await reconcileSiblingLedgerConflict({
|
|
1589
|
+
cwd,
|
|
1590
|
+
arbiter,
|
|
1591
|
+
slug,
|
|
1592
|
+
env,
|
|
1593
|
+
note,
|
|
1594
|
+
});
|
|
1595
|
+
if (siblingReconciled) {
|
|
1596
|
+
// The branch is now cleanly on top of `<arbiter>/main` with OUR work + the
|
|
1597
|
+
// arbiter's sibling-ledger files — fall through to the fresh-gate + integrate
|
|
1598
|
+
// band. THIS slug's own move is untouched.
|
|
1599
|
+
note(
|
|
1600
|
+
`Reconciled a sibling-slug ledger conflict during the rebase onto ` +
|
|
1601
|
+
`${arbiter}/main (took the arbiter's version of the other slugs' ` +
|
|
1602
|
+
`work/<status>/<slug>.md ledger files; no code file was touched).`,
|
|
1603
|
+
);
|
|
1604
|
+
} else if (!lifecycle && source !== 'done') {
|
|
1605
|
+
// NEVER auto-resolve a genuine CODE conflict: abort the rebase. But FIRST, a
|
|
1606
|
+
// DIVERGENT-LEDGER conflict (the arbiter holds the slug's source in a folder
|
|
1607
|
+
// our local done-move did not remove — PR #86) is auto-RECONCILABLE without
|
|
1608
|
+
// any semantic judgement: redo the done-move arbiter-resolved (remove the
|
|
1609
|
+
// arbiter's actual source folder, add `done/`) on top of `<arbiter>/main`. We
|
|
1610
|
+
// only do this when the post-abort tree's ONLY divergence is the slug's ledger
|
|
1611
|
+
// file; a real code conflict still routes to needs-attention untouched.
|
|
1612
|
+
await gitSoft(['rebase', '--abort'], cwd, env);
|
|
1613
|
+
const recovered = await reconcileDivergentDoneMove({
|
|
1614
|
+
cwd,
|
|
1568
1615
|
arbiter,
|
|
1616
|
+
slug,
|
|
1569
1617
|
branch,
|
|
1570
|
-
|
|
1571
|
-
provider,
|
|
1572
|
-
// PR-INTENT: when set (propose mode), push the branch but skip the PR.
|
|
1573
|
-
noPR: input.noPR,
|
|
1574
|
-
// Half A: an explicit single-line PR title (propose mode), so `gh` no longer
|
|
1575
|
-
// derives a run-on title from the commit subject via `--fill`.
|
|
1576
|
-
title: prTitle,
|
|
1577
|
-
// Half B: the propose-mode PR body — the agent's summary under a deterministic
|
|
1578
|
-
// runner header (task pointer). Undefined when no body was supplied (the
|
|
1579
|
-
// header is only scaffolded when there IS a body) ⇒ today's `--fill` (no
|
|
1580
|
-
// regression). Ignored in merge mode by the provider/integrator.
|
|
1581
|
-
body: composeProposeBody({slug, body: input.body}),
|
|
1582
|
-
// Part (b) of the merged-branch hygiene task: when WE perform the merge
|
|
1583
|
-
// (this resolved `merge` mode), reap the remote `work/<slug>` HEAD branch
|
|
1584
|
-
// INLINE right after the merge lands — the commits are now on `main`, so the
|
|
1585
|
-
// head is provably merged and safe to delete (ancestor-guarded inside the
|
|
1586
|
-
// integrator). Idempotent no-op when no remote head exists (the plain
|
|
1587
|
-
// `${branch}:main` push opened none); ignored in `propose` mode (its branch is
|
|
1588
|
-
// the review surface, reaped later by `gc --remote-branches`). NEVER `--force`.
|
|
1589
|
-
deleteMergedHead: true,
|
|
1590
|
-
cwd,
|
|
1618
|
+
localSource: source,
|
|
1591
1619
|
env,
|
|
1620
|
+
note,
|
|
1592
1621
|
});
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
// forever or force-pushing main. A RARE outcome under realistic load thanks
|
|
1602
|
-
// to the jitter below + the rebase-until-real-conflict semantics; tests
|
|
1603
|
-
// reach it deterministically by injecting a small `mergeRetries`.
|
|
1604
|
-
return await mergeNonFastForwardRoute(
|
|
1605
|
-
`integrating ${branch} onto ${arbiter}/main kept hitting a ` +
|
|
1606
|
-
`non-fast-forward push (a sibling advanced main ${mergeAttempt + 1} ` +
|
|
1607
|
-
`times); gave up cleanly without --force`,
|
|
1622
|
+
if (recovered) {
|
|
1623
|
+
// The branch is now cleanly on top of `<arbiter>/main` with the slug in
|
|
1624
|
+
// `done/` ONLY — fall through to integrate (skip the needs-attention
|
|
1625
|
+
// route below).
|
|
1626
|
+
note(
|
|
1627
|
+
`Reconciled the done-move against ${arbiter}/main: '${slug}' is in ` +
|
|
1628
|
+
'work/done/ ONLY (the divergent source folder was removed; the move ' +
|
|
1629
|
+
'is a move, not a copy).',
|
|
1608
1630
|
);
|
|
1631
|
+
} else {
|
|
1632
|
+
return {route: await rebaseConflictRoute(ctx)};
|
|
1609
1633
|
}
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
// Skipped when `mergeJitterMs === 0` (the test seam).
|
|
1614
|
-
if (mergeJitterMs > 0) {
|
|
1615
|
-
await sleepMs(Math.floor(Math.random() * (mergeJitterMs + 1)));
|
|
1616
|
-
}
|
|
1617
|
-
// A sibling advanced main: re-run the step-4 rebase (with the reconcile arms)
|
|
1618
|
-
// before retrying the push. A genuine conflict on the re-rebase routes via
|
|
1619
|
-
// `rebaseOntoMainWithReconcile`'s `route` (the existing source-folder /
|
|
1620
|
-
// one-slug placement recheck IS the genuine-conflict terminator — see the
|
|
1621
|
-
// `DEFAULT_MERGE_RETRIES` docstring for the C2 SCOPE box). A clean re-rebase
|
|
1622
|
-
// (no route) loops without counting against a small budget.
|
|
1623
|
-
const reRebase = await rebaseOntoMainWithReconcile();
|
|
1624
|
-
if (reRebase.route) {
|
|
1625
|
-
return reRebase.route;
|
|
1626
|
-
}
|
|
1634
|
+
} else {
|
|
1635
|
+
await gitSoft(['rebase', '--abort'], cwd, env);
|
|
1636
|
+
return {route: await rebaseConflictRoute(ctx)};
|
|
1627
1637
|
}
|
|
1638
|
+
}
|
|
1639
|
+
// Clean rebase (or a reconciled one): fold any question residue the rebase
|
|
1640
|
+
// brought in into the tip, then fall through to the gate + integrate.
|
|
1641
|
+
if (!lifecycle) {
|
|
1642
|
+
await foldQuestionResidueIntoTip(ctx);
|
|
1643
|
+
}
|
|
1644
|
+
return {};
|
|
1645
|
+
}
|
|
1628
1646
|
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1647
|
+
/**
|
|
1648
|
+
* After a rebase onto `<arbiter>/main`, CLEAR the landing task's own stale
|
|
1649
|
+
* question state that the rebase BROUGHT IN, and AMEND it into the tip commit
|
|
1650
|
+
* (the completion commit), so the land still carries it in the done-move commit
|
|
1651
|
+
* itself (task `a-recovered-task-lands-without-stale-question-state`).
|
|
1652
|
+
*
|
|
1653
|
+
* This is the half step 2c cannot see: a kept branch cut BEFORE the item was
|
|
1654
|
+
* surfaced (a re-dispatch continuing from the kept branch, a stranded-branch
|
|
1655
|
+
* finish) replays its `git mv ... → tasks/done/` over a `main` whose body now
|
|
1656
|
+
* carries `needsAnswers: true` and a sidecar, and git carries the flag into
|
|
1657
|
+
* `tasks/done/` and keeps the sidecar. Runs on every rebase of the land: the
|
|
1658
|
+
* build path's first rebase, the merge CAS loop's re-rebase (so the CI apply
|
|
1659
|
+
* phase is covered), and the stranded-recovery tail.
|
|
1660
|
+
*
|
|
1661
|
+
* A no-op (no amend) when there is nothing to clear, and when the tip is already
|
|
1662
|
+
* on `main` (never rewrite a commit that is not ours). Best-effort: a fault is
|
|
1663
|
+
* reported and the land proceeds; the claim-time reconcile remains the backstop.
|
|
1664
|
+
*/
|
|
1665
|
+
async function foldQuestionResidueIntoTip(params: {
|
|
1666
|
+
cwd: string;
|
|
1667
|
+
arbiter: string;
|
|
1668
|
+
slug: string;
|
|
1669
|
+
env?: NodeJS.ProcessEnv;
|
|
1670
|
+
note: (message: string) => void;
|
|
1671
|
+
}): Promise<void> {
|
|
1672
|
+
const {cwd, arbiter, slug, env, note} = params;
|
|
1673
|
+
try {
|
|
1674
|
+
if (isAncestor(cwd, 'HEAD', `${arbiter}/main`, env)) {
|
|
1675
|
+
return; // no commit of ours on top of main to fold into.
|
|
1676
|
+
}
|
|
1677
|
+
const cleared = clearOwnQuestionResidueOnLand({cwd, slug, note});
|
|
1678
|
+
if (cleared.changed.length === 0) {
|
|
1679
|
+
return;
|
|
1680
|
+
}
|
|
1681
|
+
await gitHard(['add', '-A', '--', ...cleared.changed], cwd, env);
|
|
1682
|
+
await gitHard(['commit', '-q', '--amend', '--no-edit'], cwd, env);
|
|
1683
|
+
} catch (err) {
|
|
1684
|
+
note(
|
|
1685
|
+
`Could not fold the stale question state of '${slug}' into its land ` +
|
|
1686
|
+
`(${err instanceof Error ? err.message : String(err)}); the next ` +
|
|
1687
|
+
'claim reconcile will drain it.',
|
|
1688
|
+
);
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
// The rebase-conflict needs-attention route, factored so the divergent-ledger
|
|
1693
|
+
// recovery above can fall through to integrate while a genuine code conflict
|
|
1694
|
+
// still routes here.
|
|
1695
|
+
async function rebaseConflictRoute(
|
|
1696
|
+
ctx: RebaseReconcileContext,
|
|
1697
|
+
): Promise<IntegrationCoreResult> {
|
|
1698
|
+
const {cwd, arbiter, slug, branch, commitMessage, env, note} = ctx;
|
|
1699
|
+
// Then mark the item stuck on its per-item lock with the conflict reason
|
|
1700
|
+
// (ADR §12) THROUGH the ledger write seam's needs-attention transition,
|
|
1701
|
+
// rather than leaving it dangling. Post lock-cutover this is a lock amend
|
|
1702
|
+
// (`state: stuck` + the reason on the lock entry, no `done/ →
|
|
1703
|
+
// needs-attention/` folder move); the done-move already committed above
|
|
1704
|
+
// stands. No partial state.
|
|
1705
|
+
const reason = `rebase onto ${arbiter}/main conflicted (aborted, never auto-resolved)`;
|
|
1706
|
+
const routed = await ledgerWrite.applyNeedsAttentionTransition({
|
|
1707
|
+
cwd,
|
|
1708
|
+
slug,
|
|
1709
|
+
reason,
|
|
1710
|
+
// Autonomous caller (`do`) passes the arbiter so the seam both surfaces the
|
|
1711
|
+
// conflict on `main` (OBSERVABLE) AND pushes the `work/<slug>` branch
|
|
1712
|
+
// (RECOVERABLE, cross-machine). The human `complete` leaves it unset →
|
|
1713
|
+
// no surface, no push, local-only. The push lives in the seam (HEAD's
|
|
1714
|
+
// branch is `work/<slug>` here) — no bolted-on push.
|
|
1715
|
+
arbiter: ctx.surfaceArbiter,
|
|
1716
|
+
env,
|
|
1717
|
+
note,
|
|
1718
|
+
});
|
|
1719
|
+
return {
|
|
1720
|
+
outcome: 'rebase-conflict',
|
|
1721
|
+
routedToNeedsAttention: routed.moved,
|
|
1722
|
+
branch,
|
|
1723
|
+
commitMessage,
|
|
1724
|
+
reason: routed.moved
|
|
1725
|
+
? `Rebasing ${branch} onto ${arbiter}/main conflicted; the rebase was ` +
|
|
1726
|
+
`aborted (never auto-resolved) and '${slug}' was marked stuck on ` +
|
|
1727
|
+
'its per-item lock (surfaced by status). Resolve against the latest ' +
|
|
1728
|
+
'main, then `requeue` to release the stuck lock and re-run.'
|
|
1729
|
+
: `Rebasing ${branch} onto ${arbiter}/main conflicted; the rebase was ` +
|
|
1730
|
+
'aborted (never auto-resolved). Resolve against the latest main, ' +
|
|
1731
|
+
'then re-run complete.',
|
|
1732
|
+
};
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
/**
|
|
1736
|
+
* **The LAND half of the rebase-to-integrate tail** (task `ci-split-build-path`,
|
|
1737
|
+
* spec `ci-agent-job-without-write-token` §8): integrate per mode through
|
|
1738
|
+
* `ledgerWrite.applyCompleteTransition`, with the merge-mode compare-and-swap
|
|
1739
|
+
* loop (a non-fast-forward push re-runs {@link rebaseOntoMainWithReconcile} and
|
|
1740
|
+
* retries, never `--force`), then post the approved Gate-2 review prose on the
|
|
1741
|
+
* opened PR. It launches no agent and runs no `prepare` / `verify`: the fresh
|
|
1742
|
+
* gate ran once, before it, in the other half. `performIntegration` reaches it
|
|
1743
|
+
* through the {@link integrationLand} seam, so the CI agent phase RECORDS this
|
|
1744
|
+
* call (the first write of the success path) and the apply phase resumes here
|
|
1745
|
+
* with trusted inputs and the validated work-branch tip.
|
|
1746
|
+
*/
|
|
1747
|
+
export async function landIntegration(
|
|
1748
|
+
input: IntegrationLandInput,
|
|
1749
|
+
): Promise<IntegrationCoreResult> {
|
|
1750
|
+
const {cwd, arbiter, slug, branch, mode, commitMessage, env} = input;
|
|
1751
|
+
const note = input.note;
|
|
1752
|
+
// 5. Integrate per mode through the ledger write seam's COMPLETE transition
|
|
1753
|
+
// (ADR §6 + `docs/adr/claim-ledger-vs-protected-main.md`). The rebase above
|
|
1754
|
+
// already brought the branch up to date, so the seam's sole strategy uses
|
|
1755
|
+
// `integrate` (not `integrateWithRebase`) and never --forces. Provider
|
|
1756
|
+
// selection: an injected `openPr` wins (legacy bridge); otherwise pick by
|
|
1757
|
+
// the arbiter's remote URL (a GitHub remote ⇒ `gh pr create`, else push-only
|
|
1758
|
+
// `none`) — PURELY arbiter-derived, no override axis. A missing/unauthenticated
|
|
1759
|
+
// `gh` degrades to push-only at runtime — never a hard failure (and the
|
|
1760
|
+
// start-of-run unauthed case is caught UP FRONT by the pre-flight `gh` probe).
|
|
1761
|
+
// The seam is storage-agnostic: we hand it the work branch, the integration
|
|
1762
|
+
// mode, the provider, and the PR-INTENT (`noPR`) — `main` lives only in the
|
|
1763
|
+
// strategy.
|
|
1764
|
+
// Provider precedence: an injected fully-formed provider wins (the `run`
|
|
1765
|
+
// stubbed-provider seam, carrying title/body/url); else the legacy `openPr`
|
|
1766
|
+
// bridge; else select PURELY from the arbiter URL (no override). The orthogonal
|
|
1767
|
+
// `noPR` INTENT (suppress the PR) is threaded SEPARATELY — it does NOT pick a
|
|
1768
|
+
// provider, the integrator simply skips `openRequest` when it is set.
|
|
1769
|
+
const provider =
|
|
1770
|
+
input.providerInstance ??
|
|
1771
|
+
(input.openPr
|
|
1772
|
+
? bridgeProvider(input.openPr)
|
|
1773
|
+
: selectProvider({
|
|
1774
|
+
arbiterUrl: await arbiterUrl(cwd, arbiter, env),
|
|
1775
|
+
}));
|
|
1776
|
+
// Race-1 (claim-vs-integrate, task
|
|
1777
|
+
// `run-fleet-claim-integrate-and-sibling-rebase-concurrency-safe`): integrate,
|
|
1778
|
+
// and on a non-fast-forward `${branch}:main` push (a SIBLING same-repo CLAIM —
|
|
1779
|
+
// under the SEPARATE claim lock — or a sibling integrate advanced
|
|
1780
|
+
// `<arbiter>/main` during our push window) RE-RUN the step-4 rebase (which
|
|
1781
|
+
// carries the sibling-ledger + divergent-done-move reconcile arms — a bare
|
|
1782
|
+
// re-rebase would MISS them) and RETRY the push, up to a small cap. INSTANT
|
|
1783
|
+
// retry (contention, not an outage; see `claim-cas.ts`). We NEVER `--force`
|
|
1784
|
+
// main: each retry re-rebases to a clean fast-forward. A genuine code conflict
|
|
1785
|
+
// on a re-rebase routes to needs-attention via the SAME `route` the up-front
|
|
1786
|
+
// rebase uses. A persistent non-fast-forward past the cap also routes (never a
|
|
1787
|
+
// silent drop). `input.mergeRetries` overrides the cap (tests; `0` ⇒ no retry).
|
|
1788
|
+
//
|
|
1789
|
+
// **C2 rebase-until-real-conflict (task `c2-rebase-until-real-on-durable-main-
|
|
1790
|
+
// promotions`):** the loop's TERMINATION CHANGED. A CLEAN re-rebase no longer
|
|
1791
|
+
// counts against a tiny give-up budget — only a GENUINE conflict surfaced by
|
|
1792
|
+
// `rebaseOntoMainWithReconcile` (a `route` ⇒ `rebase-conflict` or
|
|
1793
|
+
// `invariant-violation`) stops the loop. The step-4 rebase IS the source-folder
|
|
1794
|
+
// precondition recheck reused verbatim: if the slug is GONE from its expected
|
|
1795
|
+
// source folder on the new `main` (a concurrent legitimate same-item winner
|
|
1796
|
+
// already moved it), the `git mv` replay fails and `rebaseConflictRoute` routes
|
|
1797
|
+
// definitively — never a silent re-push that would clobber the winner. The
|
|
1798
|
+
// `maxMergeRetries` cap survives ONLY as a large liveness ceiling on the
|
|
1799
|
+
// pathological livelock tail (default {@link DEFAULT_MERGE_RETRIES} = 1000);
|
|
1800
|
+
// modest jitter on the refetch desynchronises a herd so the tail is not
|
|
1801
|
+
// reached under sustained parallel load.
|
|
1802
|
+
const maxMergeRetries = input.mergeRetries ?? DEFAULT_MERGE_RETRIES;
|
|
1803
|
+
const mergeJitterMs = input.mergeJitterMs ?? DEFAULT_MERGE_JITTER_MS;
|
|
1804
|
+
// The landed-vs-gated report (ADR `ci-agent-job-holds-no-write-token`
|
|
1805
|
+
// decision 2, task `ci-split-landed-vs-gated-report`), scoped to the CI apply
|
|
1806
|
+
// phase: only it sets `gatedTip` (the validated bundle tip the agent job
|
|
1807
|
+
// gated). The CAS loop below never re-gates, so a re-rebase after a lost race
|
|
1808
|
+
// can land a tree the gate never saw: {@link stampLandedWithoutRegate}
|
|
1809
|
+
// records that on the landed commit. Without `gatedTip` (every non-phase
|
|
1810
|
+
// land) the loop is exactly today's: no tree compare, no message rewrite.
|
|
1811
|
+
const gatedTree =
|
|
1812
|
+
input.gatedTip === undefined
|
|
1813
|
+
? undefined
|
|
1814
|
+
: await treeOf(cwd, input.gatedTip, env);
|
|
1815
|
+
let regateReport: string | undefined;
|
|
1816
|
+
let integration!: IntegrateResult;
|
|
1817
|
+
for (let mergeAttempt = 0; ; mergeAttempt++) {
|
|
1818
|
+
integration = await ledgerWrite.applyCompleteTransition({
|
|
1819
|
+
arbiter,
|
|
1820
|
+
branch,
|
|
1821
|
+
mode,
|
|
1822
|
+
provider,
|
|
1823
|
+
// PR-INTENT: when set (propose mode), push the branch but skip the PR.
|
|
1824
|
+
noPR: input.noPR,
|
|
1825
|
+
// Half A: an explicit single-line PR title (propose mode), so `gh` no longer
|
|
1826
|
+
// derives a run-on title from the commit subject via `--fill`.
|
|
1827
|
+
title: input.title,
|
|
1828
|
+
// Half B: the propose-mode PR body — the agent's summary under a deterministic
|
|
1829
|
+
// runner header (task pointer). Undefined when no body was supplied (the
|
|
1830
|
+
// header is only scaffolded when there IS a body) ⇒ today's `--fill` (no
|
|
1831
|
+
// regression). Ignored in merge mode by the provider/integrator.
|
|
1832
|
+
body: input.body,
|
|
1833
|
+
// Part (b) of the merged-branch hygiene task: when WE perform the merge
|
|
1834
|
+
// (this resolved `merge` mode), reap the remote `work/<slug>` HEAD branch
|
|
1835
|
+
// INLINE right after the merge lands — the commits are now on `main`, so the
|
|
1836
|
+
// head is provably merged and safe to delete (ancestor-guarded inside the
|
|
1837
|
+
// integrator). Idempotent no-op when no remote head exists (the plain
|
|
1838
|
+
// `${branch}:main` push opened none); ignored in `propose` mode (its branch is
|
|
1839
|
+
// the review surface, reaped later by `gc --remote-branches`). NEVER `--force`.
|
|
1840
|
+
deleteMergedHead: true,
|
|
1841
|
+
cwd,
|
|
1842
|
+
env,
|
|
1843
|
+
});
|
|
1844
|
+
// Only the merge push can be non-fast-forward (propose pushes its own ref).
|
|
1845
|
+
if (integration.mergeNonFastForward !== true) {
|
|
1846
|
+
break;
|
|
1847
|
+
}
|
|
1848
|
+
if (mergeAttempt >= maxMergeRetries) {
|
|
1849
|
+
// LIVENESS CEILING hit (default 1000; previously the small Race-1 cap of 5,
|
|
1850
|
+
// now reinterpreted by C2 as the pathological-livelock-tail bound, NOT a
|
|
1851
|
+
// false-contention budget). Route to needs-attention rather than looping
|
|
1852
|
+
// forever or force-pushing main. A RARE outcome under realistic load thanks
|
|
1853
|
+
// to the jitter below + the rebase-until-real-conflict semantics; tests
|
|
1854
|
+
// reach it deterministically by injecting a small `mergeRetries`.
|
|
1855
|
+
return await mergeNonFastForwardRoute(
|
|
1856
|
+
`integrating ${branch} onto ${arbiter}/main kept hitting a ` +
|
|
1857
|
+
`non-fast-forward push (a sibling advanced main ${mergeAttempt + 1} ` +
|
|
1858
|
+
`times); gave up cleanly without --force`,
|
|
1859
|
+
);
|
|
1860
|
+
}
|
|
1861
|
+
// Modest jitter on the refetch (C2): an instant lockstep refetch→re-push loop
|
|
1862
|
+
// maximises mutual rejection under sustained parallel load (thundering herd).
|
|
1863
|
+
// A uniformly-random `[0, mergeJitterMs]` ms sleep desynchronises the herd.
|
|
1864
|
+
// Skipped when `mergeJitterMs === 0` (the test seam).
|
|
1865
|
+
if (mergeJitterMs > 0) {
|
|
1866
|
+
await sleepMs(Math.floor(Math.random() * (mergeJitterMs + 1)));
|
|
1867
|
+
}
|
|
1868
|
+
// A sibling advanced main: re-run the step-4 rebase (with the reconcile arms)
|
|
1869
|
+
// before retrying the push. A genuine conflict on the re-rebase routes via
|
|
1870
|
+
// `rebaseOntoMainWithReconcile`'s `route` (the existing source-folder /
|
|
1871
|
+
// one-slug placement recheck IS the genuine-conflict terminator — see the
|
|
1872
|
+
// `DEFAULT_MERGE_RETRIES` docstring for the C2 SCOPE box). A clean re-rebase
|
|
1873
|
+
// (no route) loops without counting against a small budget.
|
|
1874
|
+
const reRebase = await rebaseOntoMainWithReconcile(input);
|
|
1875
|
+
if (reRebase.route) {
|
|
1876
|
+
return reRebase.route;
|
|
1877
|
+
}
|
|
1878
|
+
if (gatedTree !== undefined) {
|
|
1879
|
+
regateReport = await stampLandedWithoutRegate({
|
|
1638
1880
|
cwd,
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1881
|
+
branch,
|
|
1882
|
+
gatedTree,
|
|
1883
|
+
lostRaces: mergeAttempt + 1,
|
|
1642
1884
|
env,
|
|
1643
|
-
note,
|
|
1644
1885
|
});
|
|
1645
|
-
return {
|
|
1646
|
-
outcome: 'rebase-conflict',
|
|
1647
|
-
routedToNeedsAttention: routed.moved,
|
|
1648
|
-
branch,
|
|
1649
|
-
commitMessage,
|
|
1650
|
-
reason: routed.moved
|
|
1651
|
-
? `Integrating ${branch} onto ${arbiter}/main kept hitting a ` +
|
|
1652
|
-
`non-fast-forward push (a sibling advanced main); '${slug}' was ` +
|
|
1653
|
-
`marked stuck on its per-item lock (surfaced by status). Resolve ` +
|
|
1654
|
-
`against the latest main, then \`requeue\` to release the stuck ` +
|
|
1655
|
-
`lock and re-run.`
|
|
1656
|
-
: `Integrating ${branch} onto ${arbiter}/main kept hitting a ` +
|
|
1657
|
-
`non-fast-forward push (a sibling advanced main). Resolve against the ` +
|
|
1658
|
-
`latest main, then re-run complete.`,
|
|
1659
|
-
};
|
|
1660
|
-
}
|
|
1661
|
-
|
|
1662
|
-
// 6. Make the Gate-2 review VISIBLE on the PR (task `review-comment-prose-field`,
|
|
1663
|
-
// refining `review-gate-pr-comment`): AFTER the propose integrate, where the
|
|
1664
|
-
// approved verdict (with its deliberately-authored `review` prose), the
|
|
1665
|
-
// resolved `provider`, AND the opened PR url (`integration.url`) are ALL in
|
|
1666
|
-
// scope, post `verdict.review` as a comment on that PR — INCLUDING on approve
|
|
1667
|
-
// (the audit trail; decided 2026-06-06). The `review` field is a first-class
|
|
1668
|
-
// AUTHORED review (the prompt requires it), NOT the residue around the JSON
|
|
1669
|
-
// — posting the residue was the bug
|
|
1670
|
-
// (`work/findings/review-comment-posts-agent-thinking-not-a-review.md`). It
|
|
1671
|
-
// reuses the SAME `provider` the integrate used (the core never imports `gh`).
|
|
1672
|
-
// The comment is ADVISORY: it changes no gate/verdict/merge/integration logic
|
|
1673
|
-
// — by here the verdict has ALREADY routed (block never reaches this point; it
|
|
1674
|
-
// routed to needs-attention above) and the integrate has ALREADY happened.
|
|
1675
|
-
// The PR identity is resolved in PRECEDENCE: a parsed `integration.url` wins
|
|
1676
|
-
// (the normal path — post on it directly); else, when a PR WAS opened but its
|
|
1677
|
-
// url was unparseable (`integration.requestOpened` true, `url` undefined — the
|
|
1678
|
-
// `gh pr create` exit-0-but-unparseable-stdout degradation), FALL BACK to the
|
|
1679
|
-
// BRANCH-resolved comment (task `review-comment-fallback-on-unparsed-pr-url`):
|
|
1680
|
-
// the provider resolves the branch's open PR and comments on it, instead of
|
|
1681
|
-
// silently dropping a review on a PR that genuinely exists. Only when NO PR was
|
|
1682
|
-
// opened at all (merge mode, or a degraded/push-only propose ⇒ `requestOpened`
|
|
1683
|
-
// false) is it the honest clean no-op — and the branch-resolved fallback's own
|
|
1684
|
-
// "no PR resolvable" path is a clean no-op too (it tries first). Either way
|
|
1685
|
-
// `postPRComment*` never throws; the review stays in the run output. Because
|
|
1686
|
-
// this lives in the shared core, BOTH `do`/`complete` AND `run` post the
|
|
1687
|
-
// comment — no per-caller wiring.
|
|
1688
|
-
if (approvedVerdict?.review !== undefined) {
|
|
1689
|
-
if (integration.url !== undefined) {
|
|
1690
|
-
const posted = provider.postPRComment({
|
|
1691
|
-
cwd,
|
|
1692
|
-
url: integration.url,
|
|
1693
|
-
body: approvedVerdict.review,
|
|
1694
|
-
env,
|
|
1695
|
-
});
|
|
1696
|
-
note(posted.instruction);
|
|
1697
|
-
} else if (integration.requestOpened) {
|
|
1698
|
-
// A PR opened but its url was unparseable — resolve it from the branch
|
|
1699
|
-
// rather than dropping the review (the audit-trail fallback).
|
|
1700
|
-
const posted = provider.postPRCommentOnBranch({
|
|
1701
|
-
cwd,
|
|
1702
|
-
branch,
|
|
1703
|
-
body: approvedVerdict.review,
|
|
1704
|
-
env,
|
|
1705
|
-
});
|
|
1706
|
-
note(posted.instruction);
|
|
1707
|
-
}
|
|
1708
1886
|
}
|
|
1709
|
-
|
|
1887
|
+
}
|
|
1888
|
+
if (regateReport !== undefined && integration.mergedToMain === true) {
|
|
1889
|
+
note(`${branch} ${regateReport}.`);
|
|
1890
|
+
}
|
|
1891
|
+
noteRequestNotOpened(integration, branch, note);
|
|
1892
|
+
|
|
1893
|
+
// The Race-1 needs-attention route for a merge that could not land (a genuine
|
|
1894
|
+
// re-rebase conflict is handled by `rebaseOntoMainWithReconcile`'s `route`; this
|
|
1895
|
+
// covers the cap-exhausted persistent-contention case). Mirrors
|
|
1896
|
+
// `rebaseConflictRoute`: the done-move was already committed (steps 2–3), so the
|
|
1897
|
+
// slug sits in work/done/ and the seam bounces it from there.
|
|
1898
|
+
async function mergeNonFastForwardRoute(
|
|
1899
|
+
reason: string,
|
|
1900
|
+
): Promise<IntegrationCoreResult> {
|
|
1901
|
+
const routed = await ledgerWrite.applyNeedsAttentionTransition({
|
|
1902
|
+
cwd,
|
|
1903
|
+
slug,
|
|
1904
|
+
reason,
|
|
1905
|
+
arbiter: input.surfaceArbiter,
|
|
1906
|
+
env,
|
|
1907
|
+
note,
|
|
1908
|
+
});
|
|
1710
1909
|
return {
|
|
1711
|
-
outcome: '
|
|
1712
|
-
routedToNeedsAttention:
|
|
1910
|
+
outcome: 'rebase-conflict',
|
|
1911
|
+
routedToNeedsAttention: routed.moved,
|
|
1713
1912
|
branch,
|
|
1714
1913
|
commitMessage,
|
|
1715
|
-
|
|
1914
|
+
reason: routed.moved
|
|
1915
|
+
? `Integrating ${branch} onto ${arbiter}/main kept hitting a ` +
|
|
1916
|
+
`non-fast-forward push (a sibling advanced main); '${slug}' was ` +
|
|
1917
|
+
`marked stuck on its per-item lock (surfaced by status). Resolve ` +
|
|
1918
|
+
`against the latest main, then \`requeue\` to release the stuck ` +
|
|
1919
|
+
`lock and re-run.`
|
|
1920
|
+
: `Integrating ${branch} onto ${arbiter}/main kept hitting a ` +
|
|
1921
|
+
`non-fast-forward push (a sibling advanced main). Resolve against the ` +
|
|
1922
|
+
`latest main, then re-run complete.`,
|
|
1716
1923
|
};
|
|
1717
|
-
}
|
|
1924
|
+
}
|
|
1718
1925
|
|
|
1719
|
-
//
|
|
1720
|
-
//
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1926
|
+
// 6. Make the Gate-2 review VISIBLE on the PR (task `review-comment-prose-field`,
|
|
1927
|
+
// refining `review-gate-pr-comment`): AFTER the propose integrate, where the
|
|
1928
|
+
// approved verdict (with its deliberately-authored `review` prose), the
|
|
1929
|
+
// resolved `provider`, AND the opened PR url (`integration.url`) are ALL in
|
|
1930
|
+
// scope, post `verdict.review` as a comment on that PR — INCLUDING on approve
|
|
1931
|
+
// (the audit trail; decided 2026-06-06). The `review` field is a first-class
|
|
1932
|
+
// AUTHORED review (the prompt requires it), NOT the residue around the JSON
|
|
1933
|
+
// — posting the residue was the bug
|
|
1934
|
+
// (`work/findings/review-comment-posts-agent-thinking-not-a-review.md`). It
|
|
1935
|
+
// reuses the SAME `provider` the integrate used (the core never imports `gh`).
|
|
1936
|
+
// The comment is ADVISORY: it changes no gate/verdict/merge/integration logic
|
|
1937
|
+
// — by here the verdict has ALREADY routed (block never reaches this point; it
|
|
1938
|
+
// routed to needs-attention above) and the integrate has ALREADY happened.
|
|
1939
|
+
// The PR identity is resolved in PRECEDENCE: a parsed `integration.url` wins
|
|
1940
|
+
// (the normal path — post on it directly); else, when a PR WAS opened but its
|
|
1941
|
+
// url was unparseable (`integration.requestOpened` true, `url` undefined — the
|
|
1942
|
+
// `gh pr create` exit-0-but-unparseable-stdout degradation), FALL BACK to the
|
|
1943
|
+
// BRANCH-resolved comment (task `review-comment-fallback-on-unparsed-pr-url`):
|
|
1944
|
+
// the provider resolves the branch's open PR and comments on it, instead of
|
|
1945
|
+
// silently dropping a review on a PR that genuinely exists. Only when NO PR was
|
|
1946
|
+
// opened at all (merge mode, or a degraded/push-only propose ⇒ `requestOpened`
|
|
1947
|
+
// false) is it the honest clean no-op — and the branch-resolved fallback's own
|
|
1948
|
+
// "no PR resolvable" path is a clean no-op too (it tries first). Either way
|
|
1949
|
+
// `postPRComment*` never throws; the review stays in the run output. Because
|
|
1950
|
+
// this lives in the shared core, BOTH `do`/`complete` AND `run` post the
|
|
1951
|
+
// comment — no per-caller wiring.
|
|
1952
|
+
if (input.reviewProse !== undefined) {
|
|
1953
|
+
if (integration.url !== undefined) {
|
|
1954
|
+
const posted = provider.postPRComment({
|
|
1955
|
+
cwd,
|
|
1956
|
+
url: integration.url,
|
|
1957
|
+
body: input.reviewProse,
|
|
1958
|
+
env,
|
|
1959
|
+
});
|
|
1960
|
+
note(posted.instruction);
|
|
1961
|
+
} else if (integration.requestOpened) {
|
|
1962
|
+
// A PR opened but its url was unparseable — resolve it from the branch
|
|
1963
|
+
// rather than dropping the review (the audit-trail fallback).
|
|
1964
|
+
const posted = provider.postPRCommentOnBranch({
|
|
1965
|
+
cwd,
|
|
1966
|
+
branch,
|
|
1967
|
+
body: input.reviewProse,
|
|
1968
|
+
env,
|
|
1969
|
+
});
|
|
1970
|
+
note(posted.instruction);
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
return {
|
|
1975
|
+
outcome: 'completed',
|
|
1976
|
+
routedToNeedsAttention: false,
|
|
1977
|
+
branch,
|
|
1978
|
+
commitMessage,
|
|
1979
|
+
integration,
|
|
1980
|
+
};
|
|
1727
1981
|
}
|
|
1728
1982
|
|
|
1729
1983
|
/**
|
|
@@ -1944,7 +2198,10 @@ async function recoverAlreadyCommitted(params: {
|
|
|
1944
2198
|
for (;;) {
|
|
1945
2199
|
const rebase = await gitSoft(rebaseArgs(), cwd, env);
|
|
1946
2200
|
if (rebase.status === 0) {
|
|
1947
|
-
|
|
2201
|
+
// Clean rebase ⇒ fold in any question residue it brought (the kept
|
|
2202
|
+
// branch predates a surface on `main`), then fall through to integrate.
|
|
2203
|
+
await foldQuestionResidueIntoTip({cwd, arbiter, slug, env, note});
|
|
2204
|
+
break;
|
|
1948
2205
|
}
|
|
1949
2206
|
// ALWAYS abort on conflict — never leave mid-rebase between attempts.
|
|
1950
2207
|
await gitSoft(['rebase', '--abort'], cwd, env);
|
|
@@ -2134,6 +2391,7 @@ async function recoverAlreadyCommitted(params: {
|
|
|
2134
2391
|
`(absorbed a moving ${arbiter}/main across ${attempt} re-fetch+re-` +
|
|
2135
2392
|
`rebase attempt${attempt === 1 ? '' : 's'}).`,
|
|
2136
2393
|
);
|
|
2394
|
+
noteRequestNotOpened(integration, branch, note);
|
|
2137
2395
|
// Post the Gate-2 review as a PR comment (task `committed-recovery-always-
|
|
2138
2396
|
// reviews`): the SAME post-integrate audit trail the build path keeps — the
|
|
2139
2397
|
// approved verdict's authored `review` prose, on the opened PR. Advisory only
|
|
@@ -2168,6 +2426,26 @@ async function recoverAlreadyCommitted(params: {
|
|
|
2168
2426
|
};
|
|
2169
2427
|
}
|
|
2170
2428
|
|
|
2429
|
+
/**
|
|
2430
|
+
* Say so when a propose land opened NO review request (task
|
|
2431
|
+
* `intake-reports-the-pr-it-actually-opened`): the branch is pushed, but the
|
|
2432
|
+
* provider could not (or was told not to) open a PR, so the log prints the
|
|
2433
|
+
* provider's instruction (the real `gh` error + the manual `gh pr create`
|
|
2434
|
+
* command) instead of leaving it unread. Every land (build, tasking, intake,
|
|
2435
|
+
* recovery) passes through here, so no caller has to remember to print it. A
|
|
2436
|
+
* merge land, an opened request and an already-landed no-op print nothing.
|
|
2437
|
+
*/
|
|
2438
|
+
function noteRequestNotOpened(
|
|
2439
|
+
integration: IntegrateResult,
|
|
2440
|
+
branch: string,
|
|
2441
|
+
note: (message: string) => void,
|
|
2442
|
+
): void {
|
|
2443
|
+
const reason = proposeRequestNotOpened(integration);
|
|
2444
|
+
if (reason !== undefined) {
|
|
2445
|
+
note(`No PR was opened for ${branch}: ${reason}`);
|
|
2446
|
+
}
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2171
2449
|
/**
|
|
2172
2450
|
* Resolve the work branch the integration runs on: the branch HEAD is currently
|
|
2173
2451
|
* on (the caller is ALWAYS on the work branch — the agent built there / the
|
|
@@ -2377,20 +2655,34 @@ export function synthesiseProposeTitle(input: {
|
|
|
2377
2655
|
* header that points a reviewer back to the task file. Returns `undefined` when
|
|
2378
2656
|
* no body was supplied — so the provider degrades to today's `gh ... --fill` (no
|
|
2379
2657
|
* regression); the header is ONLY scaffolded when there IS prose to carry.
|
|
2658
|
+
* `header` overrides the default task-build pointer (`Task: work/tasks/done/<slug>.md`)
|
|
2659
|
+
* for a non-task lifecycle (tasking, intake), whose item rests elsewhere.
|
|
2380
2660
|
* Exported for unit tests of the header + pointer.
|
|
2381
2661
|
*/
|
|
2382
2662
|
export function composeProposeBody(input: {
|
|
2383
2663
|
slug: string;
|
|
2384
2664
|
body?: string;
|
|
2665
|
+
header?: string;
|
|
2385
2666
|
}): string | undefined {
|
|
2386
2667
|
const prose = input.body?.trim();
|
|
2387
2668
|
if (!prose) {
|
|
2388
2669
|
return undefined;
|
|
2389
2670
|
}
|
|
2390
|
-
const header =
|
|
2671
|
+
const header =
|
|
2672
|
+
input.header ??
|
|
2673
|
+
proposePointer('Task', workItemRel('done', `${input.slug}.md`));
|
|
2391
2674
|
return `${header}\n\n${prose}`;
|
|
2392
2675
|
}
|
|
2393
2676
|
|
|
2677
|
+
/**
|
|
2678
|
+
* The one-line reviewer pointer a propose PR body opens with: `<label>: `<path>``,
|
|
2679
|
+
* where `path` is the repo-relative file the PR carries (task
|
|
2680
|
+
* `propose-pr-bodies-point-at-the-real-item`).
|
|
2681
|
+
*/
|
|
2682
|
+
export function proposePointer(label: 'Task' | 'Spec', path: string): string {
|
|
2683
|
+
return `${label}: \`${path}\``;
|
|
2684
|
+
}
|
|
2685
|
+
|
|
2394
2686
|
/**
|
|
2395
2687
|
* On a review APPROVE that carries ≥1 NON-BLOCKING finding, write ONE per-run
|
|
2396
2688
|
* observation `work/notes/observations/review-nits-<slug>-<YYYY-MM-DD>.md` capturing all
|
|
@@ -2502,23 +2794,14 @@ function readTitle(content: string): string | undefined {
|
|
|
2502
2794
|
const match = /^title\s*:\s*(.*)$/.exec(line);
|
|
2503
2795
|
if (match) {
|
|
2504
2796
|
const value = match[1].trim();
|
|
2505
|
-
|
|
2797
|
+
// The shared frontmatter unquoter, so a title written by
|
|
2798
|
+
// `quoteYamlScalar` (`''` for a literal `'`) reads back verbatim.
|
|
2799
|
+
return value === '' ? undefined : unquoteYamlScalar(value);
|
|
2506
2800
|
}
|
|
2507
2801
|
}
|
|
2508
2802
|
return undefined;
|
|
2509
2803
|
}
|
|
2510
2804
|
|
|
2511
|
-
function unquote(value: string): string {
|
|
2512
|
-
if (value.length >= 2) {
|
|
2513
|
-
const first = value[0];
|
|
2514
|
-
const last = value[value.length - 1];
|
|
2515
|
-
if ((first === '"' || first === "'") && last === first) {
|
|
2516
|
-
return value.slice(1, -1);
|
|
2517
|
-
}
|
|
2518
|
-
}
|
|
2519
|
-
return value;
|
|
2520
|
-
}
|
|
2521
|
-
|
|
2522
2805
|
function escapeRegExp(value: string): string {
|
|
2523
2806
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
2524
2807
|
}
|
|
@@ -3068,6 +3351,97 @@ type Gate2ReviewOutcome =
|
|
|
3068
3351
|
verdict: ReviewVerdict | undefined;
|
|
3069
3352
|
};
|
|
3070
3353
|
|
|
3354
|
+
/** The outcome of {@link runReviewRounds}: the verdict data, with no routing done. */
|
|
3355
|
+
export type ReviewRoundsOutcome =
|
|
3356
|
+
| {kind: 'approved'; verdict: ReviewVerdict | undefined}
|
|
3357
|
+
| {kind: 'blocked'; verdict: ReviewVerdict | undefined}
|
|
3358
|
+
| {
|
|
3359
|
+
kind: 'unparseable';
|
|
3360
|
+
/** The needs-attention reason (names the parse failure, a transient class). */
|
|
3361
|
+
reason: string;
|
|
3362
|
+
};
|
|
3363
|
+
|
|
3364
|
+
/**
|
|
3365
|
+
* Run a review gate for up to `maxRounds` rounds on ONE tree and return the
|
|
3366
|
+
* verdict as DATA, writing nothing (the WRITE-FREE core of {@link runGate2Review},
|
|
3367
|
+
* which adds the needs-attention routing). Factored out so the CI tasking agent
|
|
3368
|
+
* phase (task `ci-split-tasking`) runs the one-round task-set review the SAME
|
|
3369
|
+
* way `performIntegration` does, in the job that may launch agents, without the
|
|
3370
|
+
* routing writes (the apply phase routes). A non-parse error is re-thrown.
|
|
3371
|
+
*/
|
|
3372
|
+
export async function runReviewRounds(params: {
|
|
3373
|
+
reviewGate: ReviewGate;
|
|
3374
|
+
slug: string;
|
|
3375
|
+
/** The tree the review AGENT inspects. */
|
|
3376
|
+
reviewCwd: string;
|
|
3377
|
+
maxRounds: number;
|
|
3378
|
+
reviewModel?: string;
|
|
3379
|
+
watch?: boolean;
|
|
3380
|
+
watchSink?: (line: string) => void;
|
|
3381
|
+
color?: boolean;
|
|
3382
|
+
sessionsDir?: string;
|
|
3383
|
+
/** The AGENT env (ambient, never the identity-scoped one). */
|
|
3384
|
+
env: NodeJS.ProcessEnv | undefined;
|
|
3385
|
+
}): Promise<ReviewRoundsOutcome> {
|
|
3386
|
+
const {reviewGate, slug, reviewCwd, maxRounds} = params;
|
|
3387
|
+
// CORROBORATED-APPROVAL semantics (NOT retry-until-pass): the gate runs the
|
|
3388
|
+
// reviewer up to `reviewMaxRounds` times on the SAME tip and approves ONLY if
|
|
3389
|
+
// EVERY round approves. A `block` is TERMINAL — it short-circuits the loop and
|
|
3390
|
+
// is never re-rolled, because the reviewer is stochastic and re-reviewing an
|
|
3391
|
+
// UNCHANGED tip after a block would just be a dice re-roll that could launder a
|
|
3392
|
+
// real reject into a pass. The extra rounds therefore exist to make a FALSE
|
|
3393
|
+
// APPROVE harder to slip through (a second reviewer gets a veto), never to give
|
|
3394
|
+
// blocked work a second chance. (A future builder-REVISE step that mutates the
|
|
3395
|
+
// tree between rounds is the ONLY thing that should make a block retryable; it
|
|
3396
|
+
// would change the artifact under review and is not implemented here.)
|
|
3397
|
+
let approved = false;
|
|
3398
|
+
let lastVerdict: ReviewVerdict | undefined;
|
|
3399
|
+
for (let round = 1; round <= maxRounds; round++) {
|
|
3400
|
+
let verdict: ReviewVerdict;
|
|
3401
|
+
try {
|
|
3402
|
+
verdict = await reviewGate({
|
|
3403
|
+
slug,
|
|
3404
|
+
cwd: reviewCwd,
|
|
3405
|
+
reviewModel: params.reviewModel,
|
|
3406
|
+
round,
|
|
3407
|
+
watch: params.watch,
|
|
3408
|
+
watchSink: params.watchSink,
|
|
3409
|
+
color: params.color,
|
|
3410
|
+
sessionsDir: params.sessionsDir,
|
|
3411
|
+
env: params.env,
|
|
3412
|
+
});
|
|
3413
|
+
} catch (err) {
|
|
3414
|
+
if (!(err instanceof ReviewParseError)) {
|
|
3415
|
+
// Anything else (a harness/connection throw, a programmer bug) is NOT this
|
|
3416
|
+
// gate's concern: re-throw so the existing catch sites classify it.
|
|
3417
|
+
throw err;
|
|
3418
|
+
}
|
|
3419
|
+
// A parse failure in ANY round is TERMINAL (never re-roll the remaining
|
|
3420
|
+
// rounds). The reason carries the parse-failure phrase the
|
|
3421
|
+
// `failure-cause.ts` signature matches (a transient class).
|
|
3422
|
+
return {
|
|
3423
|
+
kind: 'unparseable',
|
|
3424
|
+
reason:
|
|
3425
|
+
`PR/code review (Gate 2) ran but its verdict could not be parsed: ` +
|
|
3426
|
+
`${err.message}`,
|
|
3427
|
+
};
|
|
3428
|
+
}
|
|
3429
|
+
lastVerdict = verdict;
|
|
3430
|
+
if (verdict.verdict !== 'approve') {
|
|
3431
|
+
// A `block` is TERMINAL: stop now (never re-roll an unchanged tip) and route
|
|
3432
|
+
// the blocking findings to needs-attention below. `approved` stays false.
|
|
3433
|
+
approved = false;
|
|
3434
|
+
break;
|
|
3435
|
+
}
|
|
3436
|
+
// An `approve`: provisionally approved, but keep going — every remaining round
|
|
3437
|
+
// must ALSO approve for the gate to pass (corroboration, not first-approve-wins).
|
|
3438
|
+
approved = true;
|
|
3439
|
+
}
|
|
3440
|
+
return approved
|
|
3441
|
+
? {kind: 'approved', verdict: lastVerdict}
|
|
3442
|
+
: {kind: 'blocked', verdict: lastVerdict};
|
|
3443
|
+
}
|
|
3444
|
+
|
|
3071
3445
|
/**
|
|
3072
3446
|
* Run the Gate-2 PR/code REVIEW gate against a given tree ({@param reviewCwd}) and
|
|
3073
3447
|
* route a BLOCK to needs-attention, returning DATA the caller acts on. Factored out
|
|
@@ -3112,105 +3486,75 @@ async function runGate2Review(params: {
|
|
|
3112
3486
|
`for '${slug}' (this is a wiring bug; the gate must not be skipped).`,
|
|
3113
3487
|
);
|
|
3114
3488
|
}
|
|
3115
|
-
const maxRounds = Math.max(1, input.reviewMaxRounds ?? 2);
|
|
3116
3489
|
note('Running the PR/code review gate (Gate 2)…');
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
cwd,
|
|
3180
|
-
slug,
|
|
3490
|
+
const maxRounds = Math.max(1, input.reviewMaxRounds ?? 2);
|
|
3491
|
+
const rounds = await runReviewRounds({
|
|
3492
|
+
reviewGate,
|
|
3493
|
+
slug,
|
|
3494
|
+
reviewCwd,
|
|
3495
|
+
maxRounds,
|
|
3496
|
+
reviewModel: input.reviewModel,
|
|
3497
|
+
// `--watch` threading (task `watch-review-session`): when on, the production
|
|
3498
|
+
// gate tails the review session live. OFF ⇒ the plain sync launch, unchanged.
|
|
3499
|
+
watch: input.watch,
|
|
3500
|
+
watchSink: input.watchSink,
|
|
3501
|
+
color: input.color,
|
|
3502
|
+
sessionsDir: input.sessionsDir,
|
|
3503
|
+
// The review AGENT launches with the AMBIENT env, never the identity-scoped
|
|
3504
|
+
// `env` (an agent must not act as the bot). Falls back to `env` when no
|
|
3505
|
+
// identity is configured (unchanged for non-identity callers).
|
|
3506
|
+
env: input.agentEnv ?? env,
|
|
3507
|
+
});
|
|
3508
|
+
if (rounds.kind === 'unparseable') {
|
|
3509
|
+
// THE GATE RAN BUT ITS VERDICT WAS UNREADABLE (direction 1, the safety net):
|
|
3510
|
+
// the reviewer did NOT block — the gate's OUTPUT could not be parsed (a
|
|
3511
|
+
// malformed JSON verdict, common on large diffs + weaker models, AFTER the
|
|
3512
|
+
// direction-2 repair pass could not salvage it). WITHOUT this catch the throw
|
|
3513
|
+
// escapes the core and `performComplete` maps it to the generic `usage-error`
|
|
3514
|
+
// (verbatim, no push, no surface) AFTER the green build but BEFORE the
|
|
3515
|
+
// done-move/push — STRANDING the lock + work branch with no PR.
|
|
3516
|
+
//
|
|
3517
|
+
// A parse failure in ANY round is TERMINAL: route IMMEDIATELY, never re-roll
|
|
3518
|
+
// the remaining rounds (mirroring the block-is-terminal rule — re-reviewing
|
|
3519
|
+
// the same tip would just be the dice re-roll the corroboration loop forbids).
|
|
3520
|
+
// We route through the SAME work-preserving `applyNeedsAttentionTransition`
|
|
3521
|
+
// seam the block path uses (it PUSHES the work branch + surfaces the item on
|
|
3522
|
+
// `surfaceArbiter` for the autonomous path), targeting `cwd` (the work branch +
|
|
3523
|
+
// ledger), NOT the throwaway `reviewCwd` — so BOTH the direct `!freshWorktreeGate`
|
|
3524
|
+
// path AND the fresh-worktree `review:` callback are covered by this ONE catch.
|
|
3525
|
+
// The recorded reason carries the parse-failure phrase the `failure-cause.ts`
|
|
3526
|
+
// signature matches → the `do`/`run` tail classifies it `transient-infra`
|
|
3527
|
+
// (retry the SAME work: the gate output is STOCHASTIC, so a re-run CAN differ,
|
|
3528
|
+
// and the direction-2 repair makes a re-run far more likely to parse). NEVER a
|
|
3529
|
+
// silent approve.
|
|
3530
|
+
const reason = rounds.reason;
|
|
3531
|
+
const routed = await ledgerWrite.applyNeedsAttentionTransition({
|
|
3532
|
+
cwd,
|
|
3533
|
+
slug,
|
|
3534
|
+
reason,
|
|
3535
|
+
arbiter: input.surfaceArbiter,
|
|
3536
|
+
env,
|
|
3537
|
+
note,
|
|
3538
|
+
});
|
|
3539
|
+
const message = routed.moved
|
|
3540
|
+
? `PR/code review (Gate 2) produced an UNPARSEABLE verdict for '${slug}'; ` +
|
|
3541
|
+
'marked it stuck on its per-item lock (work branch pushed + lock ' +
|
|
3542
|
+
'surfaced; transient-infra — re-run). NOT integrated.'
|
|
3543
|
+
: `PR/code review (Gate 2) produced an UNPARSEABLE verdict for '${slug}'; ` +
|
|
3544
|
+
'NOT integrating.';
|
|
3545
|
+
note(message);
|
|
3546
|
+
return {
|
|
3547
|
+
kind: 'blocked',
|
|
3548
|
+
result: {
|
|
3549
|
+
outcome: 'review-unparseable',
|
|
3550
|
+
routedToNeedsAttention: routed.moved,
|
|
3551
|
+
branch,
|
|
3181
3552
|
reason,
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
note,
|
|
3185
|
-
});
|
|
3186
|
-
const message = routed.moved
|
|
3187
|
-
? `PR/code review (Gate 2) produced an UNPARSEABLE verdict for '${slug}'; ` +
|
|
3188
|
-
'marked it stuck on its per-item lock (work branch pushed + lock ' +
|
|
3189
|
-
'surfaced; transient-infra — re-run). NOT integrated.'
|
|
3190
|
-
: `PR/code review (Gate 2) produced an UNPARSEABLE verdict for '${slug}'; ` +
|
|
3191
|
-
'NOT integrating.';
|
|
3192
|
-
note(message);
|
|
3193
|
-
return {
|
|
3194
|
-
kind: 'blocked',
|
|
3195
|
-
result: {
|
|
3196
|
-
outcome: 'review-unparseable',
|
|
3197
|
-
routedToNeedsAttention: routed.moved,
|
|
3198
|
-
branch,
|
|
3199
|
-
reason,
|
|
3200
|
-
},
|
|
3201
|
-
};
|
|
3202
|
-
}
|
|
3203
|
-
lastVerdict = verdict;
|
|
3204
|
-
if (verdict.verdict !== 'approve') {
|
|
3205
|
-
// A `block` is TERMINAL: stop now (never re-roll an unchanged tip) and route
|
|
3206
|
-
// the blocking findings to needs-attention below. `approved` stays false.
|
|
3207
|
-
approved = false;
|
|
3208
|
-
break;
|
|
3209
|
-
}
|
|
3210
|
-
// An `approve`: provisionally approved, but keep going — every remaining round
|
|
3211
|
-
// must ALSO approve for the gate to pass (corroboration, not first-approve-wins).
|
|
3212
|
-
approved = true;
|
|
3553
|
+
},
|
|
3554
|
+
};
|
|
3213
3555
|
}
|
|
3556
|
+
const lastVerdict = rounds.verdict;
|
|
3557
|
+
const approved = rounds.kind === 'approved';
|
|
3214
3558
|
if (!approved) {
|
|
3215
3559
|
// NON-approve verdict: route to needs-attention via the SAME seam the red gate
|
|
3216
3560
|
// uses, NEVER integrate. We reach here EITHER because a round returned a
|
|
@@ -3403,6 +3747,113 @@ async function runFreshWorktreeGate(params: {
|
|
|
3403
3747
|
}
|
|
3404
3748
|
}
|
|
3405
3749
|
|
|
3750
|
+
/**
|
|
3751
|
+
* The git trailer key that marks a merge-mode land whose tree differs from the
|
|
3752
|
+
* gated tree (ADR `ci-agent-job-holds-no-write-token` decision 2). Its value is
|
|
3753
|
+
* the report, e.g. `landed without re-gate after 2 lost races`. A git trailer
|
|
3754
|
+
* like `CAS-Nonce` (`ledger-write.ts`), so `git log --format=%(trailers)`
|
|
3755
|
+
* reads it back.
|
|
3756
|
+
*/
|
|
3757
|
+
export const LANDED_WITHOUT_REGATE_TRAILER = 'Landed-Without-Regate';
|
|
3758
|
+
|
|
3759
|
+
/** The landed-vs-gated report for `lostRaces` lost pushes. */
|
|
3760
|
+
export function landedWithoutRegateReport(lostRaces: number): string {
|
|
3761
|
+
return (
|
|
3762
|
+
`landed without re-gate after ${lostRaces} lost ` +
|
|
3763
|
+
(lostRaces === 1 ? 'race' : 'races')
|
|
3764
|
+
);
|
|
3765
|
+
}
|
|
3766
|
+
|
|
3767
|
+
/** The tree id of `rev` in `cwd`. */
|
|
3768
|
+
async function treeOf(
|
|
3769
|
+
cwd: string,
|
|
3770
|
+
rev: string,
|
|
3771
|
+
env: NodeJS.ProcessEnv | undefined,
|
|
3772
|
+
): Promise<string> {
|
|
3773
|
+
return (
|
|
3774
|
+
await gitHard(['rev-parse', `${rev}^{tree}`], cwd, env)
|
|
3775
|
+
).stdout.trim();
|
|
3776
|
+
}
|
|
3777
|
+
|
|
3778
|
+
/**
|
|
3779
|
+
* After the merge-mode CAS loop re-rebased `branch` for the `lostRaces`-th
|
|
3780
|
+
* time, rewrite its tip so its message carries exactly the right
|
|
3781
|
+
* {@link LANDED_WITHOUT_REGATE_TRAILER}: set (with this count) when the tip's
|
|
3782
|
+
* tree differs from `gatedTree`, removed when it is equal again. A previous
|
|
3783
|
+
* attempt's trailer rides the re-rebase as part of the message, which is why an
|
|
3784
|
+
* existing one is always replaced, never appended to. Only the message changes:
|
|
3785
|
+
* the tree, the parents and the author are the tip's own, and the branch ref
|
|
3786
|
+
* moves by a compare-and-swap `update-ref` (the working tree and the index
|
|
3787
|
+
* already match that tree). Returns the report when the trailer is set.
|
|
3788
|
+
*/
|
|
3789
|
+
async function stampLandedWithoutRegate(params: {
|
|
3790
|
+
cwd: string;
|
|
3791
|
+
branch: string;
|
|
3792
|
+
gatedTree: string;
|
|
3793
|
+
lostRaces: number;
|
|
3794
|
+
env: NodeJS.ProcessEnv | undefined;
|
|
3795
|
+
}): Promise<string | undefined> {
|
|
3796
|
+
const {cwd, branch, gatedTree, lostRaces, env} = params;
|
|
3797
|
+
const ref = `refs/heads/${branch}`;
|
|
3798
|
+
const FIELD_SEP = '\u0000';
|
|
3799
|
+
const raw = (
|
|
3800
|
+
await gitHard(
|
|
3801
|
+
['log', '-1', '--format=%H%x00%T%x00%P%x00%an%x00%ae%x00%aI%x00%B', ref],
|
|
3802
|
+
cwd,
|
|
3803
|
+
env,
|
|
3804
|
+
)
|
|
3805
|
+
).stdout;
|
|
3806
|
+
const [head, tree, parentList, authorName, authorEmail, authorDate, body] =
|
|
3807
|
+
raw.split(FIELD_SEP);
|
|
3808
|
+
const report =
|
|
3809
|
+
tree.trim() === gatedTree
|
|
3810
|
+
? undefined
|
|
3811
|
+
: landedWithoutRegateReport(lostRaces);
|
|
3812
|
+
const original = (body ?? '').replace(/\s+$/, '');
|
|
3813
|
+
const prefix = `${LANDED_WITHOUT_REGATE_TRAILER}:`;
|
|
3814
|
+
const stripped = original
|
|
3815
|
+
.split('\n')
|
|
3816
|
+
.filter((line) => !line.startsWith(prefix))
|
|
3817
|
+
.join('\n')
|
|
3818
|
+
.replace(/\s+$/, '');
|
|
3819
|
+
const message =
|
|
3820
|
+
report === undefined
|
|
3821
|
+
? stripped
|
|
3822
|
+
: (
|
|
3823
|
+
await gitHard(
|
|
3824
|
+
[
|
|
3825
|
+
'interpret-trailers',
|
|
3826
|
+
'--trailer',
|
|
3827
|
+
`${LANDED_WITHOUT_REGATE_TRAILER}: ${report}`,
|
|
3828
|
+
],
|
|
3829
|
+
cwd,
|
|
3830
|
+
env,
|
|
3831
|
+
`${stripped}\n`,
|
|
3832
|
+
)
|
|
3833
|
+
).stdout.replace(/\s+$/, '');
|
|
3834
|
+
if (message === original) return report;
|
|
3835
|
+
const parents = parentList
|
|
3836
|
+
.trim()
|
|
3837
|
+
.split(/\s+/)
|
|
3838
|
+
.filter((p) => p !== '')
|
|
3839
|
+
.flatMap((p) => ['-p', p]);
|
|
3840
|
+
const rewritten = (
|
|
3841
|
+
await gitHard(
|
|
3842
|
+
['commit-tree', tree.trim(), ...parents, '-F', '-'],
|
|
3843
|
+
cwd,
|
|
3844
|
+
{
|
|
3845
|
+
...(env ?? process.env),
|
|
3846
|
+
GIT_AUTHOR_NAME: authorName,
|
|
3847
|
+
GIT_AUTHOR_EMAIL: authorEmail,
|
|
3848
|
+
GIT_AUTHOR_DATE: authorDate,
|
|
3849
|
+
},
|
|
3850
|
+
`${message}\n`,
|
|
3851
|
+
)
|
|
3852
|
+
).stdout.trim();
|
|
3853
|
+
await gitHard(['update-ref', ref, rewritten, head.trim()], cwd, env);
|
|
3854
|
+
return report;
|
|
3855
|
+
}
|
|
3856
|
+
|
|
3406
3857
|
/** Run git, returning the raw result (no throw) — for soft checks. */
|
|
3407
3858
|
function gitSoft(
|
|
3408
3859
|
args: string[],
|
|
@@ -3417,8 +3868,9 @@ async function gitHard(
|
|
|
3417
3868
|
args: string[],
|
|
3418
3869
|
cwd: string,
|
|
3419
3870
|
env: NodeJS.ProcessEnv | undefined,
|
|
3871
|
+
input?: string,
|
|
3420
3872
|
): Promise<RunResult> {
|
|
3421
|
-
const result = await runAsync('git', args, cwd, {env});
|
|
3873
|
+
const result = await runAsync('git', args, cwd, {env, input});
|
|
3422
3874
|
if (result.status !== 0) {
|
|
3423
3875
|
throw new Error(
|
|
3424
3876
|
`git ${args.join(' ')} failed (exit ${result.status}): ${result.stderr.trim()}`,
|