tumwater 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -108
- package/dist/build-info.json +2 -2
- package/dist/src/agent/agent-args.js +24 -0
- package/dist/src/agent/agent-backend.js +2 -2
- package/dist/src/agent/backend-traits.js +60 -0
- package/dist/src/agent/claude/claude-account.js +61 -8
- package/dist/src/agent/claude/claude-args.js +12 -15
- package/dist/src/agent/claude/claude-backend.js +51 -41
- package/dist/src/agent/claude/claude-env.js +7 -3
- package/dist/src/agent/claude/claude-stream.js +7 -16
- package/dist/src/agent/image-input.js +107 -0
- package/dist/src/agent/notes-shim.js +41 -0
- package/dist/src/agent/opencode/opencode-args.js +61 -0
- package/dist/src/agent/opencode/opencode-backend.js +255 -0
- package/dist/src/agent/opencode/opencode-catalog.js +184 -0
- package/dist/src/agent/opencode/opencode-config.js +188 -0
- package/dist/src/agent/opencode/opencode-env.js +36 -0
- package/dist/src/agent/opencode/opencode-session-id.js +37 -0
- package/dist/src/agent/opencode/opencode-stream.js +278 -0
- package/dist/src/agent/role-notes-write.js +16 -32
- package/dist/src/agent/run-agent.js +95 -18
- package/dist/src/agent/session-ledger.js +57 -1
- package/dist/src/backlog/backlog-archive.js +296 -0
- package/dist/src/backlog/backlog-cli.js +573 -0
- package/dist/src/backlog/backlog-commit.js +31 -0
- package/dist/src/backlog/backlog-eligibility.js +357 -33
- package/dist/src/backlog/backlog-md.js +98 -18
- package/dist/src/backlog/backlog-render.js +62 -11
- package/dist/src/backlog/backlog-structure.js +261 -58
- package/dist/src/backlog/backlog-write.js +197 -319
- package/dist/src/backlog/backlog.js +113 -16
- package/dist/src/backlog/question-answer.js +339 -0
- package/dist/src/baseline/main-baseline.js +75 -88
- package/dist/src/baseline/main-red.js +38 -30
- package/dist/src/brief/brief-edit.js +43 -0
- package/dist/src/{brief.js → brief/brief.js} +66 -10
- package/dist/src/budget/budget.js +32 -15
- package/dist/src/budget/fallback-breaker.js +8 -7
- package/dist/src/build/build-check-detect.js +8 -7
- package/dist/src/build/build-check-events.js +68 -22
- package/dist/src/build/build-check-report.js +22 -23
- package/dist/src/build/build-check-scoped.js +51 -62
- package/dist/src/build/build-check.js +15 -14
- package/dist/src/build/build-info.js +2 -1
- package/dist/src/build/build-stage.js +23 -22
- package/dist/src/build/dep-install.js +22 -21
- package/dist/src/build/host-sleep.js +1 -1
- package/dist/src/change/change-data.js +25 -14
- package/dist/src/change/change-render.js +11 -10
- package/dist/src/cli/brief-command.js +58 -0
- package/dist/src/cli/cli-args.js +25 -5
- package/dist/src/cli/cli-command-args.js +46 -14
- package/dist/src/cli/cli-editor.js +64 -0
- package/dist/src/cli/cli-flag-specs.js +54 -16
- package/dist/src/cli/cli-marker-commands.js +13 -10
- package/dist/src/cli/cli-output.js +9 -0
- package/dist/src/cli/cli-query-commands.js +458 -49
- package/dist/src/cli/cli-repo-gate.js +1 -1
- package/dist/src/cli/cli-run.js +208 -59
- package/dist/src/cli/completion.js +207 -0
- package/dist/src/cli/config-commands.js +14 -11
- package/dist/src/cli/help.js +305 -110
- package/dist/src/cli/log-commands.js +127 -21
- package/dist/src/cli/notify-command.js +33 -18
- package/dist/src/cli/question-commands.js +29 -224
- package/dist/src/cli.js +121 -40
- package/dist/src/collections.js +66 -16
- package/dist/src/concurrency/check-permit.js +16 -17
- package/dist/src/concurrency/lock.js +6 -5
- package/dist/src/concurrency/provider-lease.js +69 -0
- package/dist/src/concurrency/semaphore.js +258 -36
- package/dist/src/config/config-aliases.js +106 -0
- package/dist/src/config/config-editable-keys.js +3 -3
- package/dist/src/config/config-fallbacks.js +379 -0
- package/dist/src/config/config-field-checks.js +19 -10
- package/dist/src/config/config-keys.js +160 -0
- package/dist/src/config/config-live.js +64 -12
- package/dist/src/config/config-schema.js +34 -22
- package/dist/src/config/config-validation.js +132 -48
- package/dist/src/config/config-views.js +119 -337
- package/dist/src/config/config-write.js +138 -177
- package/dist/src/config/config.js +60 -49
- package/dist/src/config/model-selector.js +28 -2
- package/dist/src/doctor/doctor-backlog.js +546 -49
- package/dist/src/doctor/doctor-checks.js +534 -56
- package/dist/src/doctor/doctor-holds.js +193 -53
- package/dist/src/doctor/doctor-install.js +30 -0
- package/dist/src/doctor/doctor-launch-services.js +2 -2
- package/dist/src/doctor/doctor-model-checks.js +549 -157
- package/dist/src/doctor/doctor-notify.js +27 -0
- package/dist/src/doctor/doctor-orphans.js +10 -9
- package/dist/src/doctor/doctor-render.js +36 -9
- package/dist/src/doctor/doctor.js +80 -22
- package/dist/src/doctor/image-probe.js +244 -0
- package/dist/src/doctor/probe.js +64 -0
- package/dist/src/events/event-format.js +226 -102
- package/dist/src/events/event-labels.js +116 -0
- package/dist/src/events/event-legacy.js +140 -0
- package/dist/src/events/event-read.js +58 -22
- package/dist/src/events/event-window.js +27 -7
- package/dist/src/events/events.js +18 -14
- package/dist/src/events/notify.js +137 -38
- package/dist/src/failure/failure-cluster.js +12 -9
- package/dist/src/failure/failure-data.js +96 -48
- package/dist/src/failure/failure-render.js +69 -58
- package/dist/src/failure/failure-state-change.js +33 -26
- package/dist/src/failure/time-spend.js +79 -59
- package/dist/src/files/file-queue.js +16 -4
- package/dist/src/files/files.js +18 -6
- package/dist/src/files/json-object.js +18 -3
- package/dist/src/files/tail.js +1 -1
- package/dist/src/gates/bootstrap-gates.js +5 -4
- package/dist/src/gates/budget-gates.js +250 -34
- package/dist/src/gates/disk-gate.js +18 -18
- package/dist/src/gates/gate-polls.js +117 -69
- package/dist/src/gates/maintenance-quota.js +29 -29
- package/dist/src/gates/pause-gates.js +11 -11
- package/dist/src/gates/readiness.js +2 -1
- package/dist/src/gates/role-cap-gates.js +16 -21
- package/dist/src/gates/startup-gate.js +75 -31
- package/dist/src/gates/streak-gate.js +18 -16
- package/dist/src/git/commit-message.js +12 -5
- package/dist/src/git/git-diff.js +16 -16
- package/dist/src/git/git-run.js +8 -8
- package/dist/src/git/git.js +86 -55
- package/dist/src/git/slots-state.js +14 -5
- package/dist/src/git/worktree-pool.js +25 -18
- package/dist/src/git/worktree-use.js +15 -14
- package/dist/src/git/worktree.js +18 -18
- package/dist/src/gui/gui-args.js +10 -14
- package/dist/src/gui/gui-backlog-commands.js +273 -0
- package/dist/src/gui/gui-command.js +1 -1
- package/dist/src/gui/gui-config-commands.js +440 -0
- package/dist/src/gui/gui-endpoint-commands.js +16 -669
- package/dist/src/gui/gui-endpoint-common.js +49 -0
- package/dist/src/gui/gui-endpoints.js +116 -56
- package/dist/src/gui/gui-operator-commands.js +145 -0
- package/dist/src/gui/gui-prompt-commands.js +234 -0
- package/dist/src/gui/gui-server.js +28 -8
- package/dist/src/gui/gui-team-commands.js +164 -0
- package/dist/src/gui/http-body.js +17 -1
- package/dist/src/history/history-data.js +194 -98
- package/dist/src/history/history-render.js +76 -0
- package/dist/src/history/history.js +37 -58
- package/dist/src/inbox/inbox-attachments.js +75 -14
- package/dist/src/inbox/inbox-cancel.js +7 -5
- package/dist/src/inbox/inbox-edit.js +1 -1
- package/dist/src/inbox/inbox-submit.js +44 -4
- package/dist/src/inbox/inbox-top.js +83 -0
- package/dist/src/inbox/inbox.js +8 -2
- package/dist/src/inbox/pending-prompt.js +1 -1
- package/dist/src/inbox/prompt-commands.js +170 -80
- package/dist/src/init/init-templates.js +16 -14
- package/dist/src/init/init.js +12 -11
- package/dist/src/loop/leftover.js +28 -27
- package/dist/src/loop/loop-fallback.js +2 -2
- package/dist/src/loop/loop-pi.js +106 -52
- package/dist/src/loop/loop-recovery.js +15 -15
- package/dist/src/loop/loop-state.js +139 -13
- package/dist/src/loop/loop-tick.js +194 -86
- package/dist/src/loop/loop.js +72 -36
- package/dist/src/loop/model-fallback.js +3 -2
- package/dist/src/loop/revision.js +16 -7
- package/dist/src/merge/backlog-conflicts.js +155 -0
- package/dist/src/{landing/landing-batch.js → merge/merge-batch.js} +90 -89
- package/dist/src/{landing/landing-check-failures.js → merge/merge-check-failures.js} +87 -41
- package/dist/src/{landing/landing-core.js → merge/merge-core.js} +119 -95
- package/dist/src/{landing/landing-diff.js → merge/merge-diff.js} +1 -1
- package/dist/src/merge/merge-drain.js +212 -0
- package/dist/src/{landing/landing-git.js → merge/merge-git.js} +94 -21
- package/dist/src/merge/merge-pipeline.js +99 -0
- package/dist/src/{landing/landing-questions.js → merge/merge-questions.js} +9 -6
- package/dist/src/merge/merge-queue-render.js +75 -0
- package/dist/src/{landing/landing-queue.js → merge/merge-queue.js} +95 -36
- package/dist/src/merge/merge-slot.js +340 -0
- package/dist/src/{landing/landing-stack.js → merge/merge-stack.js} +66 -62
- package/dist/src/{landing/landing-merge.js → merge/merge-to-main.js} +72 -62
- package/dist/src/merge/merge-verify.js +212 -0
- package/dist/src/notes-cli.js +97 -0
- package/dist/src/operator/operator-commands.js +154 -47
- package/dist/src/operator/operator-intent.js +87 -49
- package/dist/src/operator/operator-requests.js +52 -52
- package/dist/src/operator/retire.js +62 -27
- package/dist/src/operator/revert.js +71 -0
- package/dist/src/orchestrator/orchestrator-launch.js +74 -14
- package/dist/src/orchestrator/orchestrator-scheduling.js +129 -44
- package/dist/src/orchestrator/orchestrator.js +158 -100
- package/dist/src/orchestrator/retention.js +29 -12
- package/dist/src/orchestrator/startup-migration-steps.js +51 -0
- package/dist/src/orchestrator/startup-migrations.js +40 -0
- package/dist/src/paths.js +128 -37
- package/dist/src/pi/pi-args.js +26 -3
- package/dist/src/pi/pi-backend.js +15 -23
- package/dist/src/pi/pi-bin.js +18 -12
- package/dist/src/pi/pi-event-line.js +17 -1
- package/dist/src/pi/pi-failure.js +87 -0
- package/dist/src/pi/pi-models.js +126 -38
- package/dist/src/pi/pi-run-result.js +1 -1
- package/dist/src/pi/pi-settings.js +86 -0
- package/dist/src/pi/pi-stream.js +131 -107
- package/dist/src/pi/pi-watchdogs.js +47 -43
- package/dist/src/pi-extension/bounded-output.js +20 -5
- package/dist/src/pi-extension/context-budget.js +3 -1
- package/dist/src/pi-extension/context-shake.js +54 -21
- package/dist/src/pi-extension/context-window.js +37 -0
- package/dist/src/pi-extension/full-output.js +2 -2
- package/dist/src/pi-extension/strip-thinking.js +80 -0
- package/dist/src/pi-extension/trim-system-prompt.js +38 -0
- package/dist/src/process/launch-services.js +11 -10
- package/dist/src/process/process-table.js +1 -1
- package/dist/src/process/process.js +7 -7
- package/dist/src/process/run-marker.js +29 -6
- package/dist/src/process/supervisor.js +47 -25
- package/dist/src/prompt/dialect.js +30 -10
- package/dist/src/prompt/gate-prompts.js +170 -67
- package/dist/src/prompt/principles.js +1 -0
- package/dist/src/prompt/prompt-followup.js +35 -20
- package/dist/src/prompt/prompt.js +249 -91
- package/dist/src/redeploy/redeploy-policy.js +10 -10
- package/dist/src/redeploy/redeploy.js +6 -4
- package/dist/src/redeploy/redeployer.js +63 -55
- package/dist/src/redeploy/self-reload.js +1 -1
- package/dist/src/report/report-data.js +264 -255
- package/dist/src/report/report-fold.js +353 -0
- package/dist/src/report/report-render.js +270 -33
- package/dist/src/request-timeouts.js +1 -1
- package/dist/src/review/known-flakes.js +34 -14
- package/dist/src/review/protected-edits.js +33 -0
- package/dist/src/review/review-followup.js +16 -4
- package/dist/src/review/review-precheck.js +14 -13
- package/dist/src/review/review-verdict.js +14 -0
- package/dist/src/review/review.js +81 -39
- package/dist/src/review/secret-leaks.js +127 -0
- package/dist/src/review/suite-rerun.js +4 -3
- package/dist/src/roles/legacy-role-ids.js +21 -0
- package/dist/src/roles/legacy-role-migration.js +62 -0
- package/dist/src/roles/loop-ids.js +19 -2
- package/dist/src/roles/role-catalog.js +125 -135
- package/dist/src/roles/role-guidance.js +64 -31
- package/dist/src/roles/role-labels.js +10 -0
- package/dist/src/roles/role-render.js +194 -18
- package/dist/src/roles/role-view.js +216 -17
- package/dist/src/roles/roles.js +18 -15
- package/dist/src/scheduling/backoff.js +29 -19
- package/dist/src/scheduling/claims.js +25 -9
- package/dist/src/scheduling/once-round.js +11 -11
- package/dist/src/scheduling/quiet-hours.js +25 -32
- package/dist/src/scheduling/scheduling.js +43 -24
- package/dist/src/scheduling/{work-landed-cache.js → work-merged-cache.js} +14 -14
- package/dist/src/status/status-budget.js +115 -0
- package/dist/src/status/status-data.js +77 -184
- package/dist/src/status/status-inbox.js +47 -0
- package/dist/src/status/status-install.js +27 -0
- package/dist/src/status/status-polls.js +68 -33
- package/dist/src/status/status-roles.js +105 -0
- package/dist/src/{fleet → team}/error-storm.js +10 -10
- package/dist/src/{fleet → team}/failure-spread.js +8 -8
- package/dist/src/{fleet → team}/orchestrator-info.js +86 -18
- package/dist/src/{fleet → team}/reclaim.js +5 -4
- package/dist/src/{fleet/fleet-hold.js → team/team-hold.js} +55 -21
- package/dist/src/{fleet/fleet-polls.js → team/team-polls.js} +116 -65
- package/dist/src/{fleet/fleet-state.js → team/team-state.js} +62 -37
- package/dist/src/text/datetime.js +42 -2
- package/dist/src/text/format.js +10 -1
- package/dist/src/text/markdown.js +1 -1
- package/dist/src/text/phrases.js +35 -34
- package/dist/src/text/suggest.js +3 -2
- package/dist/src/text/text.js +2 -2
- package/dist/src/tick/qa-coverage.js +2 -1
- package/dist/src/tick/stage-check.js +147 -26
- package/dist/src/tick/telemetry-digest.js +10 -9
- package/dist/src/tick/tick-apply.js +101 -72
- package/dist/src/tick/tick-detail-data.js +32 -18
- package/dist/src/tick/tick-detail.js +62 -21
- package/dist/src/tick/tick-finalize.js +29 -22
- package/dist/src/tick/tick-outcome.js +2 -1
- package/dist/src/tick/tick-prompt.js +94 -26
- package/dist/src/tick/tick-resume.js +29 -13
- package/dist/src/tick/tick-stage.js +91 -47
- package/dist/src/tick/tick-timing.js +60 -59
- package/dist/src/tick/tick-usage.js +53 -23
- package/dist/src/tick/tick-verdict.js +76 -10
- package/dist/src/ui/badges.js +118 -67
- package/dist/src/ui/gui/gui-client-boot.js +209 -30
- package/dist/src/ui/gui/gui-client-composer.js +102 -23
- package/dist/src/ui/gui/gui-client-drawer.js +330 -75
- package/dist/src/ui/gui/gui-client-history.js +96 -38
- package/dist/src/ui/gui/gui-client-labels.js +24 -0
- package/dist/src/ui/gui/gui-client-loops.js +94 -52
- package/dist/src/ui/gui/gui-client-markdown.js +1 -0
- package/dist/src/ui/gui/gui-client-model.js +63 -43
- package/dist/src/ui/gui/gui-client-operator.js +129 -39
- package/dist/src/ui/gui/gui-client-pending.js +36 -32
- package/dist/src/ui/gui/gui-client-report.js +339 -46
- package/dist/src/ui/gui/gui-client-settings.js +148 -14
- package/dist/src/ui/gui/gui-client-sound.js +2 -1
- package/dist/src/ui/gui/{gui-client-fleet.js → gui-client-team.js} +196 -72
- package/dist/src/ui/gui/gui-client.js +26 -20
- package/dist/src/ui/gui/gui-page.js +19 -19
- package/dist/src/ui/gui/gui-styles.js +12 -4
- package/dist/src/ui/progress-data.js +5 -3
- package/dist/src/ui/status-model.js +184 -122
- package/dist/src/ui/status-payload.js +119 -32
- package/dist/src/ui/status-render.js +122 -52
- package/dist/src/ui/team-alerts.js +341 -0
- package/dist/src/ui/tick-progress-model.js +14 -14
- package/dist/src/ui/tone.js +11 -8
- package/dist/src/ui/transcript-tail.js +1 -0
- package/dist/src/ui/transcript.js +23 -16
- package/dist/src/ui/tui/tui-app.js +1 -0
- package/dist/src/ui/tui/tui-backlog.js +85 -11
- package/dist/src/ui/tui/tui-editor-answer.js +97 -0
- package/dist/src/ui/tui/tui-editor-attach.js +107 -0
- package/dist/src/ui/tui/tui-editor-brief.js +107 -0
- package/dist/src/ui/tui/tui-editor-budget.js +155 -0
- package/dist/src/ui/tui/tui-editor-ctx.js +1 -0
- package/dist/src/ui/tui/tui-editor-entry.js +230 -0
- package/dist/src/ui/tui/tui-editor-interval.js +122 -0
- package/dist/src/ui/tui/tui-editor-notes.js +114 -0
- package/dist/src/ui/tui/tui-editor-pause.js +266 -0
- package/dist/src/ui/tui/tui-editor-prompt.js +95 -0
- package/dist/src/ui/tui/tui-editor-queued.js +98 -0
- package/dist/src/ui/tui/tui-editor-quiet.js +106 -0
- package/dist/src/ui/tui/tui-editor-retire.js +83 -0
- package/dist/src/ui/tui/tui-editor-search.js +73 -0
- package/dist/src/ui/tui/tui-editor-session.js +97 -0
- package/dist/src/ui/tui/tui-editors.js +152 -0
- package/dist/src/ui/tui/tui-frame.js +67 -55
- package/dist/src/ui/tui/tui-history.js +12 -7
- package/dist/src/ui/tui/tui-input.js +49 -44
- package/dist/src/ui/tui/tui-keymap.js +1 -0
- package/dist/src/ui/tui/tui-keys.js +91 -1704
- package/dist/src/ui/tui/tui-pane-activity.js +99 -0
- package/dist/src/ui/tui/tui-pane-backlog.js +224 -0
- package/dist/src/ui/tui/tui-pane-brief.js +30 -0
- package/dist/src/ui/tui/tui-pane-ctx.js +11 -0
- package/dist/src/ui/tui/tui-pane-health.js +124 -0
- package/dist/src/ui/tui/tui-pane-history.js +316 -0
- package/dist/src/ui/tui/tui-pane-pending.js +74 -0
- package/dist/src/ui/tui/tui-pane-queued.js +143 -0
- package/dist/src/ui/tui/tui-pane-report.js +122 -0
- package/dist/src/ui/tui/tui-pane-session.js +101 -0
- package/dist/src/ui/tui/tui-pane-transcript.js +150 -0
- package/dist/src/ui/tui/tui-pane.js +66 -28
- package/dist/src/ui/tui/tui-panes.js +294 -0
- package/dist/src/ui/tui/tui.js +45 -25
- package/dist/src/verdict/fix-claim.js +141 -38
- package/dist/src/verdict/no-change.js +19 -0
- package/dist/src/verdict/refusal.js +92 -8
- package/dist/src/verdict/reply-contract.js +17 -5
- package/dist/src/version.js +3 -2
- package/package.json +4 -2
- package/dist/src/agent/mcp/role-notes-server.js +0 -160
- package/dist/src/landing/backlog-conflicts.js +0 -116
- package/dist/src/landing/landing-drain.js +0 -204
- package/dist/src/landing/landing-pipeline.js +0 -96
- package/dist/src/landing/landing-queue-render.js +0 -31
- package/dist/src/landing/landing-slot.js +0 -246
- package/dist/src/landing/landing-vetting.js +0 -196
- package/dist/src/pi-extension/role-notes.js +0 -40
- package/dist/src/ui/fleet-alerts.js +0 -174
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
// vocabulary: plain
|
|
2
|
+
import { dropMerge } from "./merge-queue.js";
|
|
3
|
+
import { runCostIsReal } from "../pi/pi-run-result.js";
|
|
4
|
+
import { reviewRunConfig, seamHoldKey, seamPairName } from "../config/config-views.js";
|
|
5
|
+
import { saveLoopState } from "../loop/loop-state.js";
|
|
6
|
+
import { applyMergeOutcome, ERROR_STREAK_WARN } from "../tick/tick-apply.js";
|
|
7
|
+
import { observeClaudeAccount } from "../agent/claude/claude-account.js";
|
|
8
|
+
import { logEventBestEffort, usageFragment, warnEventBestEffort } from "../events/events.js";
|
|
9
|
+
import { readJsonFile, writeJsonAtomic } from "../files/json-files.js";
|
|
10
|
+
import { isJsonObject, nonNegativeNumber } from "../files/json-object.js";
|
|
11
|
+
import { removeQuiet } from "../files/files.js";
|
|
12
|
+
import { mergeStatePath } from "../paths.js";
|
|
13
|
+
import { consecutiveFailuresWarning } from "../text/phrases.js";
|
|
14
|
+
import { canonicalRoleId } from "../roles/legacy-role-ids.js";
|
|
15
|
+
/** How each merge stage names itself in the merge cell. */
|
|
16
|
+
export const MERGE_STAGE_LABELS = {
|
|
17
|
+
rebasing: "rebasing",
|
|
18
|
+
"check-wait": "waiting to check",
|
|
19
|
+
"build-check": "checking",
|
|
20
|
+
reviewing: "reviewing",
|
|
21
|
+
"resolve-wait": "waiting to resolve conflicts",
|
|
22
|
+
resolving: "resolving conflicts",
|
|
23
|
+
"provider-wait": "waiting for the provider",
|
|
24
|
+
merging: "finishing the merge",
|
|
25
|
+
};
|
|
26
|
+
/** Publish the in-flight merge marker — the one place its shape is constructed, so the
|
|
27
|
+
* interface is enforced here rather than at each caller's inline `writeJsonFile`: every
|
|
28
|
+
* writer names a stage. Atomic (tmp + rename) because the marker is rewritten mid-merge at
|
|
29
|
+
* every stage and per-change status transition while observers poll it every second — a plain
|
|
30
|
+
* overwrite would let a poll read a torn file as "no merge" and flicker the merge row back
|
|
31
|
+
* to its idle label. */
|
|
32
|
+
export function writeMergeMarker(root, marker) {
|
|
33
|
+
writeJsonAtomic(mergeStatePath(root), marker);
|
|
34
|
+
}
|
|
35
|
+
/** Read the in-flight merge marker; null when missing or unreadable. Never throws —
|
|
36
|
+
* observers poll it every second, and a torn write (a crash mid-write) must not take them
|
|
37
|
+
* down (the readOrchestratorInfo precedent). */
|
|
38
|
+
export function readMergeMarker(root) {
|
|
39
|
+
const marker = readJsonFile(mergeStatePath(root));
|
|
40
|
+
if (!marker || typeof marker.role !== "string")
|
|
41
|
+
return marker;
|
|
42
|
+
const out = { ...marker, role: canonicalRoleId(marker.role) };
|
|
43
|
+
if (Array.isArray(marker.changes)) {
|
|
44
|
+
out.changes = marker.changes.map((c) => (c && typeof c.role === "string" ? { ...c, role: canonicalRoleId(c.role) } : c));
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
/** Rewrite the marker after one of its records changed: re-point the top level at the first
|
|
49
|
+
* change in `merging` (see MergeInFlight — left as is while none is), then write it. Never
|
|
50
|
+
* throws: the marker is display-only, and a failed write must not fail a merge (the
|
|
51
|
+
* observers keep the previous frame's record until the next write). */
|
|
52
|
+
function rewriteMarker(root, marker) {
|
|
53
|
+
const headline = marker.changes.find((c) => isMergeChange(c) && c.status === "merging");
|
|
54
|
+
if (headline) {
|
|
55
|
+
marker.role = headline.role;
|
|
56
|
+
marker.sha = headline.sha;
|
|
57
|
+
marker.summary = headline.summary;
|
|
58
|
+
marker.startedAt = headline.startedAt ?? marker.startedAt;
|
|
59
|
+
marker.stage = headline.stage ?? "merging";
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
writeMergeMarker(root, { ...marker, stage: marker.stage ?? "merging" });
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
/* display-only — see above */
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** Read-modify-write one change's record in the live marker — the skeleton setMergeChangeStatus
|
|
69
|
+
* and setMergeStage share: read the marker off disk, hand `apply` the change named by `role`,
|
|
70
|
+
* and write the marker back through rewriteMarker. A read-modify-write of the file, not a
|
|
71
|
+
* rewrite from memory, so the stage a gate last set (the same process) survives. A no-op when
|
|
72
|
+
* there is no marker or it has no record for `role`; `apply` returns whether to write the
|
|
73
|
+
* marker — true when it changed anything, false to skip the write (setMergeStage's guard
|
|
74
|
+
* against re-staging an unchanged stage). Never throws (rewriteMarker). */
|
|
75
|
+
function updateMergeChange(root, role, apply) {
|
|
76
|
+
const marker = readMergeMarker(root);
|
|
77
|
+
// A `changes` field that is not an array is as unusable as an absent one — treat it as no
|
|
78
|
+
// per-change records (the legacy shape) and leave the marker untouched, rather than reading
|
|
79
|
+
// `.find` off a scalar and throwing into a live merge.
|
|
80
|
+
if (!marker || !Array.isArray(marker.changes))
|
|
81
|
+
return;
|
|
82
|
+
const change = marker.changes.find((c) => isMergeChange(c) && c.role === role);
|
|
83
|
+
if (!change)
|
|
84
|
+
return;
|
|
85
|
+
if (!apply(change))
|
|
86
|
+
return;
|
|
87
|
+
rewriteMarker(root, { ...marker, changes: marker.changes });
|
|
88
|
+
}
|
|
89
|
+
/** Advance one change's record in the live marker — the verification's move to `approved` and the
|
|
90
|
+
* merge's per-change status hook (BatchContext.onChangeStatus). A read-modify-write of the file,
|
|
91
|
+
* not a rewrite from memory, so the stage a gate last set (setMergeStage, the same process)
|
|
92
|
+
* survives. Each move to `merging` resets the change's stage to `merging` and stamps its
|
|
93
|
+
* `startedAt` if it has none. A no-op when there is no marker or it has no record for `role`.
|
|
94
|
+
* Never throws (rewriteMarker). */
|
|
95
|
+
export function setMergeChangeStatus(root, role, status) {
|
|
96
|
+
updateMergeChange(root, role, (change) => {
|
|
97
|
+
change.status = status;
|
|
98
|
+
if (status === "merging") {
|
|
99
|
+
change.startedAt ??= Date.now();
|
|
100
|
+
change.stage = "merging";
|
|
101
|
+
}
|
|
102
|
+
return true;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
/** Add one change's record as its verification starts: each change is its own task, so records come
|
|
106
|
+
* and go one at a time. The new record starts `merging` at `rebasing` with its own `startedAt` (a
|
|
107
|
+
* verification opens with its checkout and rebase onto main). Every other record is kept only while
|
|
108
|
+
* its role is in `live` — the roles the pipeline holds right now — so a record a crashed generation
|
|
109
|
+
* left behind never rides along. Never throws (rewriteMarker). */
|
|
110
|
+
export function addMergeChange(root, entry, live) {
|
|
111
|
+
const marker = readMergeMarker(root);
|
|
112
|
+
const kept = marker ? mergeChanges(marker).filter((c) => c.role !== entry.role && live.has(c.role)) : [];
|
|
113
|
+
const startedAt = Date.now();
|
|
114
|
+
const change = {
|
|
115
|
+
role: entry.role,
|
|
116
|
+
sha: entry.sha,
|
|
117
|
+
summary: entry.summary,
|
|
118
|
+
status: "merging",
|
|
119
|
+
startedAt,
|
|
120
|
+
stage: "rebasing",
|
|
121
|
+
stageSince: startedAt,
|
|
122
|
+
};
|
|
123
|
+
rewriteMarker(root, {
|
|
124
|
+
role: entry.role,
|
|
125
|
+
sha: entry.sha,
|
|
126
|
+
summary: entry.summary,
|
|
127
|
+
startedAt,
|
|
128
|
+
stage: "rebasing",
|
|
129
|
+
stageSince: startedAt,
|
|
130
|
+
changes: [...kept, change],
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
/** Remove one change's record once the pipeline has written its outcome (or dropped it as
|
|
134
|
+
* already on main), deleting the marker when it was the last. A no-op when the marker names no
|
|
135
|
+
* such role. Never throws. */
|
|
136
|
+
export function removeMergeChange(root, role) {
|
|
137
|
+
const marker = readMergeMarker(root);
|
|
138
|
+
if (!marker)
|
|
139
|
+
return;
|
|
140
|
+
const all = mergeChanges(marker);
|
|
141
|
+
const changes = all.filter((c) => c.role !== role);
|
|
142
|
+
if (changes.length === all.length)
|
|
143
|
+
return;
|
|
144
|
+
if (changes.length === 0) {
|
|
145
|
+
removeQuiet(mergeStatePath(root));
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
rewriteMarker(root, { ...marker, changes });
|
|
149
|
+
}
|
|
150
|
+
/** Advance a merge's stage — called by the merge path at each transition: review.ts before
|
|
151
|
+
* its pre-check (`build-check`) and before its reviewer run (`reviewing`), merge-to-main.ts
|
|
152
|
+
* around its conflict resolver run (`resolving`, then `merging`), merge-core.ts's
|
|
153
|
+
* reviewPinnedChange once the gate returns (`merging`, whatever it decided — a finished
|
|
154
|
+
* reviewer's last turns must not sit in the cell accruing a false `no pi output` flag while the
|
|
155
|
+
* change waits for its merge), and merge-stack.ts around a stack's shared check. It stages
|
|
156
|
+
* `role`'s own record — every concurrent verification advances its own change's cell. A no-op for a
|
|
157
|
+
* role with no record (a gate run outside the pipeline, as the unit tests drive it). Only the stage
|
|
158
|
+
* changes: sha and startedAt are what the snapshot cross-check and the merge's elapsed read. */
|
|
159
|
+
export function setMergeStage(root, role, stage) {
|
|
160
|
+
updateMergeChange(root, role, (change) => {
|
|
161
|
+
if (change.stage === stage)
|
|
162
|
+
return false; // already there — keep the marker as written
|
|
163
|
+
change.stage = stage;
|
|
164
|
+
change.stageSince = Date.now();
|
|
165
|
+
return true;
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
/** The merge cell's save-point hooks one of the pipeline's permit waits parks on: `wait` (the
|
|
169
|
+
* stage shown while the caller is parked) set on each of `roles`' records, and `granted` (the
|
|
170
|
+
* stage once the permit arrives). The three permit waits the pipeline uses — the build check's,
|
|
171
|
+
* the conflict resolver's, and the re-key verification's — share this one pair of callbacks,
|
|
172
|
+
* which differ only in their two stage names, so a fourth wait reuses it instead of re-copying
|
|
173
|
+
* the waiting/granted shape. A no-op for a role with no record, like setMergeStage. */
|
|
174
|
+
function waitStageHooks(root, roles, wait, granted) {
|
|
175
|
+
return {
|
|
176
|
+
waiting: () => roles.forEach((role) => setMergeStage(root, role, wait)),
|
|
177
|
+
granted: () => roles.forEach((role) => setMergeStage(root, role, granted)),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
/** The merge cell's hooks around a build check's wait for its permit (runScopedBuildCheck's
|
|
181
|
+
* `permitWait`): `check-wait` on each of `roles`' records while parked, `build-check` once the
|
|
182
|
+
* permit arrives. Called only when the permit was not free, so an unqueued check never
|
|
183
|
+
* flickers. */
|
|
184
|
+
export function checkWaitStage(root, roles) {
|
|
185
|
+
return waitStageHooks(root, roles, "check-wait", "build-check");
|
|
186
|
+
}
|
|
187
|
+
/** The merge cell's hooks around the merge's conflict resolver waiting for its agent permit
|
|
188
|
+
* (merge-drain.ts's merge wiring): `resolve-wait` while parked, `resolving` once the permit
|
|
189
|
+
* arrives — checkWaitStage's shape for the maxConcurrent semaphore, so a resolver queued behind
|
|
190
|
+
* busy agents never reads as one running. Called only when the permit was not free. */
|
|
191
|
+
export function resolveWaitStage(root, role) {
|
|
192
|
+
return waitStageHooks(root, [role], "resolve-wait", "resolving");
|
|
193
|
+
}
|
|
194
|
+
/** The verification's hooks around a re-key onto a full provider (plans/provider-concurrency.md §5,
|
|
195
|
+
* "A re-key wait is visible"): `provider-wait` while the verification's lease parks on the target
|
|
196
|
+
* provider's full cap, `reviewing` once the key frees and the review proceeds. resolveWaitStage's
|
|
197
|
+
* shape for the re-key path — a verification that normally shares its review seam never
|
|
198
|
+
* re-keys, so the stage fires only when a run actually moves to a different, full provider. */
|
|
199
|
+
export function providerWaitStage(root, role) {
|
|
200
|
+
return waitStageHooks(root, [role], "provider-wait", "reviewing");
|
|
201
|
+
}
|
|
202
|
+
/** The marker's per-change records, whichever shape it has: `changes` as written, or — for a
|
|
203
|
+
* marker an older generation wrote with one top-level change — that change, `merging` since the
|
|
204
|
+
* marker's start at the marker's stage. Reads both shapes into records, so addMergeChange's
|
|
205
|
+
* stale-record filtering and status-model's per-role cell cannot disagree about which changes a
|
|
206
|
+
* marker names. */
|
|
207
|
+
export function mergeChanges(marker) {
|
|
208
|
+
return Array.isArray(marker.changes)
|
|
209
|
+
? marker.changes.filter(isMergeChange)
|
|
210
|
+
: [
|
|
211
|
+
{
|
|
212
|
+
role: marker.role,
|
|
213
|
+
sha: marker.sha,
|
|
214
|
+
summary: marker.summary,
|
|
215
|
+
status: "merging",
|
|
216
|
+
startedAt: marker.startedAt,
|
|
217
|
+
stage: marker.stage,
|
|
218
|
+
...(marker.stageSince !== undefined ? { stageSince: marker.stageSince } : {}),
|
|
219
|
+
},
|
|
220
|
+
];
|
|
221
|
+
}
|
|
222
|
+
/** True when a persisted `changes` entry is a record the pipeline and the observers can act on:
|
|
223
|
+
* a role, sha and summary string and one of the three statuses. A hand-edited, torn, or foreign
|
|
224
|
+
* entry (a `null`/scalar, or one missing `status`) is dropped by mergeChanges rather than read
|
|
225
|
+
* field-by-field — without this, `c.role` on a `null` entry threw `Cannot read properties of
|
|
226
|
+
* null` straight into setMergeChangeStatus and the every-second status poll. */
|
|
227
|
+
export function isMergeChange(value) {
|
|
228
|
+
return (isJsonObject(value) &&
|
|
229
|
+
typeof value.role === "string" &&
|
|
230
|
+
typeof value.sha === "string" &&
|
|
231
|
+
typeof value.summary === "string" &&
|
|
232
|
+
(value.status === "merging" || value.status === "approved" || value.status === "done") &&
|
|
233
|
+
(value.stageSince === undefined || nonNegativeNumber(value.stageSince, null) !== null));
|
|
234
|
+
}
|
|
235
|
+
/** Fold one merge's outcome into its role's state and drop its queue entry — the 3/5
|
|
236
|
+
* write-back every pipeline outcome goes through (a verification's final verdict, a merged change,
|
|
237
|
+
* an aborted approved change): apply the result to the live state object (paired with the entry's
|
|
238
|
+
* change for the last-result cell — applyMergeOutcome), persist it, log merge_done/merge_failed
|
|
239
|
+
* with the merge's own duration (from its verification's start) and usage (omitted when zero — the
|
|
240
|
+
* 4/5 idiom, so review-exempt merges render bare), and drop the entry: EVERY defined result
|
|
241
|
+
* drops. Both event writes are best-effort: an unwritable feed (ENOSPC, EACCES) must not
|
|
242
|
+
* skip the drop, or the settled entry would stay queued and be re-verified and re-merged on
|
|
243
|
+
* the next drain — the queue's "drop after EVERY outcome" contract (merge-queue.ts). The
|
|
244
|
+
* change's marker record is the caller's concern (removeMergeChange). */
|
|
245
|
+
export function writeMergeOutcome(root, entry, state, result, durationMs, usage, file) {
|
|
246
|
+
applyMergeOutcome(state, result, entry);
|
|
247
|
+
saveLoopState(root, state);
|
|
248
|
+
// One warning per error episode, merge side (BUGS.md 2026-09-30): a review rejection
|
|
249
|
+
// feeds the same streak the tick-side warn reads (src/tick/tick-finalize.ts), but it resolves
|
|
250
|
+
// AFTER the tick's end-save, so the crossing warn belongs here — same shape, same bar.
|
|
251
|
+
if (result === "rejected" && (state.consecutiveErrors ?? 0) === ERROR_STREAK_WARN) {
|
|
252
|
+
warnEventBestEffort(root, entry.role, consecutiveFailuresWarning(state.consecutiveErrors ?? 0, state.lastError ?? "review rejected"));
|
|
253
|
+
}
|
|
254
|
+
// `merged` still fires from merge-to-main.ts itself — these events mark the QUEUE's
|
|
255
|
+
// bookkeeping: the slot picked the entry up (merge_queued, logged at enqueue) and finished
|
|
256
|
+
// with or without merge.
|
|
257
|
+
logEventBestEffort(root, {
|
|
258
|
+
source: entry.role,
|
|
259
|
+
type: result === "changed" ? "merge_done" : "merge_failed",
|
|
260
|
+
commit: entry.sha,
|
|
261
|
+
result,
|
|
262
|
+
summary: entry.summary,
|
|
263
|
+
durationMs,
|
|
264
|
+
// Enqueue-to-outcome span, including the slot's own durationMs. A leftover-recovery
|
|
265
|
+
// re-enqueue writes a new entry with its own enqueuedAt, so a retried change counts from
|
|
266
|
+
// its last enqueue.
|
|
267
|
+
queuedMs: Math.max(0, Date.now() - entry.enqueuedAt),
|
|
268
|
+
...usageFragment(usage.tokens, usage.cost, usage.notionalCost),
|
|
269
|
+
// The merge's per-provider spend, keyed to its reviewer seam (the same reviewProvider
|
|
270
|
+
// key the author-side foldMergeUsage charges), so the report's `Cost by provider` line
|
|
271
|
+
// sees a merge's spend under its reviewer's provider. Omitted when empty (a merge
|
|
272
|
+
// whose runs carried no provider), the same omit-when-empty idiom the usage fields use.
|
|
273
|
+
...(Object.keys(usage.providerCost).length > 0 ? { providerCost: usage.providerCost } : {}),
|
|
274
|
+
});
|
|
275
|
+
dropMerge(file);
|
|
276
|
+
}
|
|
277
|
+
/** A fresh usage accumulator for one merge plus the foldUsage callback that charges each of
|
|
278
|
+
* its pi runs to BOTH the authoring runner's live state (foldMergeUsage: reviewer and
|
|
279
|
+
* conflict-resolution spend belongs to the authoring role) and the merge's own accumulator
|
|
280
|
+
* (the merge_done/merge_failed event's usage). Every verification builds its wiring through this,
|
|
281
|
+
* and its merge reuses it, so the accounting — and any future change to it — lives in one place.
|
|
282
|
+
* `reviewProvider` is this merge's reviewer seam hold key (seamHoldKey of reviewRunConfig),
|
|
283
|
+
* folded onto the rate-limit and backend-failure stamps this fold writes: a 429 on a claude
|
|
284
|
+
* reviewer must hold `claude`, not the pi author's provider (PLANS.md "Mixed claude + pi
|
|
285
|
+
* fleets, part 2/4"). The reviewer's final runs are folded HERE (merge-core.ts's
|
|
286
|
+
* reviewPinnedChange), not inside LoopPi — loop-pi.ts stamps the retried first attempt; this
|
|
287
|
+
* stamps the final run, which ends on a different seam when the reviewer runs on a
|
|
288
|
+
* non-pi backend. */
|
|
289
|
+
/** Fetch-or-create a provider's accumulator entry in `usage.providerCost` and add `amount`
|
|
290
|
+
* to one of its two fields — the single home of the real/notional provider bump both
|
|
291
|
+
* branches of foldUsage do, so a future change to the entry shape (or the
|
|
292
|
+
* noUncheckedIndexedAccess seed) is merged once instead of twice. */
|
|
293
|
+
function addProviderCost(usage, provider, amount, notional) {
|
|
294
|
+
const e = usage.providerCost[provider] ?? { costUsd: 0, notionalCostUsd: 0 };
|
|
295
|
+
if (notional)
|
|
296
|
+
e.notionalCostUsd += amount;
|
|
297
|
+
else
|
|
298
|
+
e.costUsd += amount;
|
|
299
|
+
usage.providerCost[provider] = e;
|
|
300
|
+
}
|
|
301
|
+
export function mergeUsage(author, root) {
|
|
302
|
+
const usage = { tokens: 0, cost: 0, providerCost: {} };
|
|
303
|
+
const reviewConfig = reviewRunConfig(author.config);
|
|
304
|
+
const reviewProvider = seamHoldKey(reviewConfig);
|
|
305
|
+
const reviewPair = seamPairName(reviewConfig);
|
|
306
|
+
return {
|
|
307
|
+
usage,
|
|
308
|
+
foldUsage: (run) => {
|
|
309
|
+
author.foldMergeUsage(run, reviewProvider, reviewPair);
|
|
310
|
+
// A claude reviewer's or resolver's billing/window readings reach the account file the
|
|
311
|
+
// moment this run folds, not at the author's next tick end — so a pi-author team's
|
|
312
|
+
// claude reviewer still updates the five-hour window before the author's next tick
|
|
313
|
+
// starts (PLANS.md "Mixed claude + pi fleets, part 3/4"). Best-effort like tick-finalize.ts's
|
|
314
|
+
// call; the file is a cache and an unwritable state dir must not fail the merge.
|
|
315
|
+
if (run.billing !== undefined || run.rateWindows !== undefined) {
|
|
316
|
+
try {
|
|
317
|
+
observeClaudeAccount(root, { billing: run.billing, rateWindows: run.rateWindows });
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
// The next reporting run rewrites it; nothing this merge depends on is lost.
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
usage.tokens += run.outputTokens;
|
|
324
|
+
if (runCostIsReal(run)) {
|
|
325
|
+
usage.cost += run.costUsd;
|
|
326
|
+
// Only a resolved reviewer seam bears a provider: a pi reviewer whose provider was
|
|
327
|
+
// never pinned (reviewProvider undefined) attributes nothing, like the author side.
|
|
328
|
+
if (reviewProvider !== undefined) {
|
|
329
|
+
addProviderCost(usage, reviewProvider, run.costUsd, false);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
usage.notionalCost = (usage.notionalCost ?? 0) + run.costUsd;
|
|
334
|
+
if (reviewProvider !== undefined) {
|
|
335
|
+
addProviderCost(usage, reviewProvider, run.costUsd, true);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
},
|
|
339
|
+
};
|
|
340
|
+
}
|
|
@@ -1,42 +1,43 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
*
|
|
1
|
+
// vocabulary: plain
|
|
2
|
+
/** The stack merger — the merge-half machinery of a multi-change batch (extracted from
|
|
3
|
+
* merge-batch.ts, whose header scopes that file to the verify and merge halves): assemble the
|
|
4
|
+
* approved changes as a cherry-picked stack on main's current tip, run ONE scope-`batch` build
|
|
4
5
|
* check over the combined tree, and fast-forward main through the captured shas — with the
|
|
5
6
|
* re-stack loop for a main that moves mid-check, the doc-only exemption and its cross-checks,
|
|
6
|
-
* and the per-attempt abort observation. The bisect over a red stack (
|
|
7
|
-
*
|
|
7
|
+
* and the per-attempt abort observation. The bisect over a red stack (mergeApproved, in
|
|
8
|
+
* merge-batch.ts) drives this file's mergeStack one prefix at a time. */
|
|
8
9
|
import { COMMIT_IDENT, gitTry } from "../git/git-run.js";
|
|
9
10
|
import { branchHead, deleteRef, gitLines, headOf } from "../git/git.js";
|
|
10
|
-
import {
|
|
11
|
+
import { mergeRefName, mergeLockDir } from "../paths.js";
|
|
11
12
|
import { ensureDetachedWorktree } from "../git/worktree.js";
|
|
12
13
|
import { useWorktree } from "../git/worktree-use.js";
|
|
13
|
-
import { exemptSkipBlockReason } from "./
|
|
14
|
-
import { logNewQuestions } from "./
|
|
14
|
+
import { exemptSkipBlockReason } from "./merge-to-main.js";
|
|
15
|
+
import { logNewQuestions } from "./merge-questions.js";
|
|
15
16
|
import { openQuestions } from "../backlog/backlog.js";
|
|
16
|
-
import { logEventBestEffort } from "../events/events.js";
|
|
17
|
-
import {
|
|
17
|
+
import { logEventBestEffort, warnEventBestEffort } from "../events/events.js";
|
|
18
|
+
import { ffMainToOrWhy } from "./merge-git.js";
|
|
18
19
|
import { syncRootInstall } from "../build/dep-install.js";
|
|
19
20
|
import { withLock } from "../concurrency/lock.js";
|
|
20
21
|
import { runScopedBuildCheck } from "../build/build-check-scoped.js";
|
|
21
22
|
import { noteGreenBaseline } from "../baseline/main-baseline.js";
|
|
22
23
|
import { isExemptDiff } from "../review/exemptions.js";
|
|
23
|
-
import { checkWaitStage,
|
|
24
|
+
import { checkWaitStage, setMergeStage } from "./merge-slot.js";
|
|
24
25
|
/** How many times a batch whose fast-forward lost the race to a moved main re-stacks onto the
|
|
25
26
|
* new tip and goes round again before handing every change to leftover recovery as
|
|
26
27
|
* `merge_blocked`. The race window is the whole batch check, and main still has writers
|
|
27
|
-
* outside the
|
|
28
|
+
* outside the merge queue (a human commit, or a second team on the same repo), so a
|
|
28
29
|
* lost race is routine and one re-stack almost always wins it — the second is headroom for a
|
|
29
30
|
* busy stretch. The bound keeps a main that moves faster than a check completes from holding
|
|
30
|
-
* the merge slot (and every
|
|
31
|
+
* the merge slot (and every approved merge behind it) indefinitely: each re-stack
|
|
31
32
|
* whose new tree is not doc-only pays one more full check. Past it the per-change path takes
|
|
32
|
-
* over, whose in-lock re-check another harness
|
|
33
|
+
* over, whose in-lock re-check another harness merge cannot race. */
|
|
33
34
|
export const BATCH_RESTACK_ATTEMPTS = 2;
|
|
34
|
-
/** Assemble a batch's stack in `wtPath` (the shared `_merge` checkout a
|
|
35
|
+
/** Assemble a batch's stack in `wtPath` (the shared `_merge` checkout a merge drain passes)
|
|
35
36
|
* on main's CURRENT tip, once
|
|
36
37
|
* per attempt — a re-stack after a lost fast-forward race is the same assembly on the tip that
|
|
37
|
-
* won. `entries` carry each change's head to
|
|
38
|
+
* won. `entries` carry each change's head to merge (its synced pin); the result carries that
|
|
38
39
|
* base (`base` — the tip the stack's own delta is measured against) plus each change's
|
|
39
|
-
* captured post-pick sha, in queue order — the stack ffStackToMain
|
|
40
|
+
* captured post-pick sha, in queue order — the stack ffStackToMain merges. Returns null when
|
|
40
41
|
* the stack cannot be assembled on this tip: main is unreadable, or a pick conflicts (or
|
|
41
42
|
* applies nothing — the crash-window re-drain); the caller abandons to one-at-a-time. */
|
|
42
43
|
async function assembleStack(root, mainBranch, wtPath, entries) {
|
|
@@ -45,21 +46,21 @@ async function assembleStack(root, mainBranch, wtPath, entries) {
|
|
|
45
46
|
// main the FIRST batch already moved; stacking from S[0].sha there would put the ff
|
|
46
47
|
// against diverged history on every attempt. When main hasn't moved since the pins were
|
|
47
48
|
// created (the common single-batch case) the picks reconstruct the same tree and the ff
|
|
48
|
-
//
|
|
49
|
+
// merges the same tip. Every entry — the head included — carries its captured post-pick sha
|
|
49
50
|
// into the ff and the per-change merged events.
|
|
50
51
|
const base = await branchHead(root, mainBranch);
|
|
51
52
|
if (base === null)
|
|
52
53
|
return null; // main unreadable: cannot stack
|
|
53
54
|
// One idempotent ensure at the fresh base covers the worktree-a-moment-ago case (the
|
|
54
|
-
// change's
|
|
55
|
+
// change's verify, or the previous attempt's assembly).
|
|
55
56
|
const wt = await ensureDetachedWorktree(root, wtPath, base);
|
|
56
|
-
const
|
|
57
|
+
const mergeDone = [];
|
|
57
58
|
for (const entry of entries) {
|
|
58
|
-
// Cherry-pick the whole RANGE from main's tip to the entry's head to
|
|
59
|
+
// Cherry-pick the whole RANGE from main's tip to the entry's head to merge — `base..sha`,
|
|
59
60
|
// every commit ahead of main, in queue order. A pin is normally one commit, so the range is
|
|
60
61
|
// that commit; picking the range rather than the head alone keeps a pin that carries more
|
|
61
|
-
// from
|
|
62
|
-
// rebase: after 2/5 the role branches sit at main and each
|
|
62
|
+
// from merging only its last diff and orphaning the work beneath it. (Not a
|
|
63
|
+
// rebase: after 2/5 the role branches sit at main and each merge lives only in its
|
|
63
64
|
// pinned ref — there is nothing to rebase.)
|
|
64
65
|
const pick = await gitTry(wt, ...COMMIT_IDENT, "cherry-pick", `${base}..${entry.sha}`);
|
|
65
66
|
if (pick === null) {
|
|
@@ -69,44 +70,44 @@ async function assembleStack(root, mainBranch, wtPath, entries) {
|
|
|
69
70
|
await gitTry(wt, "cherry-pick", "--abort");
|
|
70
71
|
return null;
|
|
71
72
|
}
|
|
72
|
-
|
|
73
|
+
mergeDone.push({ role: entry.role, sha: await headOf(wt, "HEAD"), summary: entry.summary });
|
|
73
74
|
}
|
|
74
|
-
return { base,
|
|
75
|
+
return { base, mergeDone };
|
|
75
76
|
}
|
|
76
77
|
/** True when the tree at `to` differs from the tree at `from` only in review-exempt paths —
|
|
77
78
|
* the gate's own doc-only test (isExemptDiff over config.review.exemptPaths). A re-stack whose
|
|
78
79
|
* new commits from main were doc-only rebuilds the checked tree with nothing but doc bytes
|
|
79
|
-
* changed, and a doc-only delta cannot break the build: the reasoning
|
|
80
|
-
* (src/
|
|
81
|
-
* doc-only
|
|
82
|
-
* commits main already
|
|
80
|
+
* changed, and a doc-only delta cannot break the build: the reasoning verifyMerge
|
|
81
|
+
* (src/merge/merge-to-main.ts) applies when it skips its in-lock re-check for a moved
|
|
82
|
+
* doc-only merge. Its false-fix cross-check has no counterpart here — the delta is
|
|
83
|
+
* commits main already merged through their own gate, not a claim this batch makes.
|
|
83
84
|
* --no-renames so a rename out of a code path lists the deleted source, not only its exempt
|
|
84
85
|
* destination. An unreadable diff is not exempt: the caller runs the check. */
|
|
85
86
|
async function exemptTreeDelta(root, from, to, exemptPaths) {
|
|
86
87
|
const out = await gitTry(root, "diff", "--no-renames", "--name-only", from, to);
|
|
87
88
|
return out !== null && isExemptDiff(gitLines(out), exemptPaths);
|
|
88
89
|
}
|
|
89
|
-
/**
|
|
90
|
+
/** Merge `entries` — the whole stack, or one bisect prefix of it — as ONE fast-forward on its
|
|
90
91
|
* own green check: assemble them on main's current tip in `wtPath`, run ONE scope-`batch`
|
|
91
92
|
* check over the combined tree, and ff main through the captured shas (ffStackToMain) with
|
|
92
93
|
* nothing rewritten in between — the in-lock invariant. A lost race re-stacks on the tip that
|
|
93
94
|
* won, up to BATCH_RESTACK_ATTEMPTS times, and a re-stack whose tree is the checked tree plus
|
|
94
95
|
* doc-only commits skips its re-check (exemptTreeDelta). A stack whose whole delta ahead of
|
|
95
96
|
* its assembly base is review-exempt skips the check too — a doc-only delta cannot break the
|
|
96
|
-
* build (the reasoning
|
|
97
|
+
* build (the reasoning verifyMerge's exempt arm applies) — and runs the same cross-checks
|
|
97
98
|
* that arm runs, the backlog heading structure and the false-fix claim; one that fails either
|
|
98
99
|
* returns `conflict`, so the per-change fallback applies the gate's own verdict per change.
|
|
99
100
|
* On `landed` every entry's ref is deleted and the red-main baseline is seeded with the tip
|
|
100
|
-
* when a check PASSED on exactly it — a skipped check seeds nothing, like
|
|
101
|
+
* when a check PASSED on exactly it — a skipped check seeds nothing, like verifyMerge's
|
|
101
102
|
* exempt arm. The check's events log under the first entry's role. */
|
|
102
|
-
export async function
|
|
103
|
+
export async function mergeStack(ctx, wtPath, entries) {
|
|
103
104
|
// Hold the merge checkout from before assembleStack (its reset is part of the use) through
|
|
104
105
|
// the ff (plans/disk-floor.md, part 2/4).
|
|
105
|
-
return useWorktree(ctx.root, wtPath, () =>
|
|
106
|
+
return useWorktree(ctx.root, wtPath, () => mergeStackIn(ctx, wtPath, entries));
|
|
106
107
|
}
|
|
107
|
-
async function
|
|
108
|
+
async function mergeStackIn(ctx, wtPath, entries) {
|
|
108
109
|
// The last stacked tip a build check actually ran on: a re-stack whose tree differs from it
|
|
109
|
-
// only in doc-only paths
|
|
110
|
+
// only in doc-only paths merges on that run's verdict instead of paying another.
|
|
110
111
|
let checkedTip = null;
|
|
111
112
|
const exemptPaths = ctx.config.review.exemptPaths;
|
|
112
113
|
for (let attempt = 0; attempt <= BATCH_RESTACK_ATTEMPTS; attempt++) {
|
|
@@ -118,10 +119,10 @@ async function landStackIn(ctx, wtPath, entries) {
|
|
|
118
119
|
const assembled = await assembleStack(ctx.root, ctx.mainBranch, wtPath, entries);
|
|
119
120
|
if (assembled === null)
|
|
120
121
|
return { kind: "conflict" }; // main unreadable or a pick conflicted
|
|
121
|
-
const { base,
|
|
122
|
-
const tip =
|
|
122
|
+
const { base, mergeDone } = assembled;
|
|
123
|
+
const tip = mergeDone.at(-1).sha;
|
|
123
124
|
// The expensive deterministic half, shared: ONE run over the combined tree. Outcome
|
|
124
|
-
// routing — null: no declared check,
|
|
125
|
+
// routing — null: no declared check, merge directly; "failed": red or a merge-scope
|
|
125
126
|
// timeout, the caller bisects; "skipped": no npm / broken toolchain (the helper warned),
|
|
126
127
|
// proceed — never fail-closed; "passed": green. A re-stack skips the run only when its
|
|
127
128
|
// tree is the checked tree plus doc-only changes (exemptTreeDelta).
|
|
@@ -129,13 +130,13 @@ async function landStackIn(ctx, wtPath, entries) {
|
|
|
129
130
|
const docOnlyRestack = checkedTip !== null && (await exemptTreeDelta(ctx.root, checkedTip, tip, exemptPaths));
|
|
130
131
|
if (!docOnlyRestack) {
|
|
131
132
|
// The stack's own delta ahead of its assembly base — main here is the tip assembleStack
|
|
132
|
-
// stacked on — decides like
|
|
133
|
-
// check runs (a doc-only stack, or a bisect's size-1 doc-only remainder,
|
|
133
|
+
// stacked on — decides like verifyMerge does: doc-only cannot break the build, so no
|
|
134
|
+
// check runs (a doc-only stack, or a bisect's size-1 doc-only remainder, merges on the
|
|
134
135
|
// exemption instead of paying a suite run no code change asked for).
|
|
135
136
|
const stackDiff = await gitTry(ctx.root, "diff", "--no-renames", "--name-only", base, tip);
|
|
136
137
|
const stackFiles = stackDiff === null ? null : gitLines(stackDiff);
|
|
137
138
|
if (stackFiles !== null && isExemptDiff(stackFiles, exemptPaths)) {
|
|
138
|
-
// The same cross-checks
|
|
139
|
+
// The same cross-checks verifyMerge's exempt arm runs (merge-to-main.ts's
|
|
139
140
|
// exemptSkipBlockReason): the skip must not wave through an md-only edit the gate would
|
|
140
141
|
// have rejected. A failure here cannot name a red check (none ran), so it hands the
|
|
141
142
|
// stack to the one-at-a-time fallback, whose exempt arm blocks or rejects each change
|
|
@@ -144,14 +145,14 @@ async function landStackIn(ctx, wtPath, entries) {
|
|
|
144
145
|
return { kind: "conflict" };
|
|
145
146
|
}
|
|
146
147
|
else {
|
|
147
|
-
// The
|
|
148
|
+
// The merge cell names the check while it runs, then the merge steps after it — the ff
|
|
148
149
|
// or what the caller does next — on every stacked change's own record.
|
|
149
150
|
const roles = entries.map((e) => e.role);
|
|
150
151
|
for (const role of roles)
|
|
151
|
-
|
|
152
|
+
setMergeStage(ctx.root, role, "build-check");
|
|
152
153
|
const check = await runScopedBuildCheck(ctx.root, roles[0], "batch", wtPath, ctx.config, undefined, undefined, undefined, checkWaitStage(ctx.root, roles));
|
|
153
154
|
for (const e of entries)
|
|
154
|
-
|
|
155
|
+
setMergeStage(ctx.root, e.role, "merging");
|
|
155
156
|
if (check !== null && check.outcome.status === "failed")
|
|
156
157
|
return { kind: "red", ...check };
|
|
157
158
|
checkedTip = tip;
|
|
@@ -159,57 +160,60 @@ async function landStackIn(ctx, wtPath, entries) {
|
|
|
159
160
|
seed = tip;
|
|
160
161
|
}
|
|
161
162
|
}
|
|
162
|
-
if ((await ffStackToMain(ctx.root, ctx.mainBranch,
|
|
163
|
+
if ((await ffStackToMain(ctx.root, ctx.mainBranch, mergeDone)) === "changed") {
|
|
163
164
|
// A PASSED stack check ran on exactly the future main tip — seed the red-main
|
|
164
165
|
// baseline after (not before) the successful ff, so a merge_blocked stack seeds
|
|
165
166
|
// nothing; a skipped check seeds nothing either (skips never seed), and neither does
|
|
166
|
-
// a doc-only re-stack (like
|
|
167
|
+
// a doc-only re-stack (like verifyMerge's exempt arm: nothing ran on this tip).
|
|
167
168
|
if (seed !== undefined)
|
|
168
169
|
noteGreenBaseline(seed);
|
|
169
170
|
for (const e of entries)
|
|
170
|
-
await deleteRef(ctx.root,
|
|
171
|
-
return { kind: "landed" };
|
|
171
|
+
await deleteRef(ctx.root, mergeRefName(e.role));
|
|
172
|
+
return { kind: "landed" }; // vocabulary-ok: persisted state value or event type, renamed by a later series
|
|
172
173
|
}
|
|
173
174
|
// Main moved under the batch while the check ran (a human commit): diverged history, ff
|
|
174
175
|
// failed. Re-stack on the tip that won.
|
|
175
176
|
}
|
|
176
177
|
return { kind: "blocked" };
|
|
177
178
|
}
|
|
178
|
-
/** Fast-forward main through a WHOLE batch of already-reviewed
|
|
179
|
-
* 5/5): `
|
|
179
|
+
/** Fast-forward main through a WHOLE batch of already-reviewed merges in one (merge queue
|
|
180
|
+
* 5/5): `mergeDone` is the stack in queue order, each entry the change's captured post-pick sha
|
|
180
181
|
* plus its own role and summary. Under the merge lock: one `ffMainTo` to the stacked tip (the
|
|
181
182
|
* LAST entry's sha — a single ff through N stacked commits), one `merged` event PER entry so
|
|
182
183
|
* the report counts the batch as N commits, and the question_posted diff around that single ff
|
|
183
184
|
* (one-shot, as tryMerge's). No rebase and no in-lock re-check inside this helper — that is
|
|
184
185
|
* what makes the batch's one shared stack check sufficient (the design invariant): nothing
|
|
185
|
-
* rewrites between the
|
|
186
|
+
* rewrites between the merger's green check and this ff, so a successful ff makes main
|
|
186
187
|
* byte-identical to the checked tip (or, after a doc-only re-stack, to that tip plus the
|
|
187
|
-
* doc-only commits main gained). `noteGreenBaseline` stays out of it too — the
|
|
188
|
+
* doc-only commits main gained). `noteGreenBaseline` stays out of it too — the merger
|
|
188
189
|
* seeds the stacked tip, because it alone knows whether its own check passed. A failed ff
|
|
189
190
|
* (main moved under the batch — diverged history) returns "merge_blocked" with NO events and
|
|
190
|
-
* no ref changes: the
|
|
191
|
+
* no ref changes: the merger re-stacks onto the new tip and calls this again (bounded by
|
|
191
192
|
* BATCH_RESTACK_ATTEMPTS), and only a race lost on every attempt keeps every
|
|
192
193
|
* change's ref for one-at-a-time recovery, whose tryMerge carries the in-lock check. The
|
|
193
194
|
* single-change path is untouched. */
|
|
194
|
-
export async function ffStackToMain(root, mainBranch,
|
|
195
|
-
const tip =
|
|
195
|
+
export async function ffStackToMain(root, mainBranch, mergeDone) {
|
|
196
|
+
const tip = mergeDone.at(-1);
|
|
196
197
|
if (!tip)
|
|
197
|
-
return "changed"; // Empty stack: nothing to fast-forward (never called in production — the
|
|
198
|
+
return "changed"; // Empty stack: nothing to fast-forward (never called in production — the merger stacks at least one change).
|
|
198
199
|
return withLock(mergeLockDir(root), async () => {
|
|
199
200
|
const before = openQuestions(root);
|
|
200
|
-
|
|
201
|
+
const ffFailure = await ffMainToOrWhy(root, tip.sha, mainBranch);
|
|
202
|
+
if (ffFailure !== null) {
|
|
203
|
+
warnEventBestEffort(root, tip.role, `merge blocked: ${ffFailure}`);
|
|
201
204
|
return "merge_blocked";
|
|
205
|
+
}
|
|
202
206
|
// Best-effort like tryMerge's merged events: the stack is already fast-forwarded, so an
|
|
203
|
-
// unwritable feed must not flip a
|
|
204
|
-
for (const entry of
|
|
207
|
+
// unwritable feed must not flip a merged batch to a failure and skip syncRootInstall below.
|
|
208
|
+
for (const entry of mergeDone) {
|
|
205
209
|
logEventBestEffort(root, {
|
|
206
|
-
|
|
210
|
+
source: entry.role,
|
|
207
211
|
type: "merged",
|
|
208
212
|
commit: entry.sha,
|
|
209
213
|
summary: entry.summary,
|
|
210
214
|
});
|
|
211
215
|
}
|
|
212
|
-
logNewQuestions(root, before,
|
|
216
|
+
logNewQuestions(root, before, mergeDone[0].role);
|
|
213
217
|
await syncRootInstall(root, tip.role); // As tryMerge: re-sync the root install under the lock.
|
|
214
218
|
return "changed";
|
|
215
219
|
});
|