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
|
@@ -1,46 +1,48 @@
|
|
|
1
|
-
|
|
2
|
-
import {
|
|
1
|
+
// vocabulary: plain
|
|
2
|
+
import { deleteRef, headOf, patchId, setRef } from "../git/git.js";
|
|
3
|
+
import { mergeRefName, mergeWorktreePath, rejectedRefName, reviewRunSessionDir } from "../paths.js";
|
|
3
4
|
import { ensureDetachedWorktree } from "../git/worktree.js";
|
|
4
5
|
import { useWorktree } from "../git/worktree-use.js";
|
|
5
|
-
import { mergeToMain } from "./
|
|
6
|
-
import { rebaseOntoMain } from "./
|
|
7
|
-
import {
|
|
6
|
+
import { mergeToMain } from "./merge-to-main.js";
|
|
7
|
+
import { rebaseOntoMain } from "./merge-git.js";
|
|
8
|
+
import { mergeCheckRed, mergeBlocked, noteMergeRetryPassed } from "./merge-check-failures.js";
|
|
8
9
|
import { reviewAheadOfMain } from "../review/review.js";
|
|
9
10
|
import { reviewRunConfig, modelSelectorField } from "../config/config-views.js";
|
|
10
11
|
import { warnEventBestEffort, logEventBestEffort } from "../events/events.js";
|
|
11
12
|
import { saveLoopState } from "../loop/loop-state.js";
|
|
12
|
-
import {
|
|
13
|
+
import { REVISION_FINAL_ON_REPEAT, REVISION_BACKSTOP } from "../loop/revision.js";
|
|
14
|
+
import { priorObjectionNumber } from "../review/review-verdict.js";
|
|
13
15
|
import { DIRECTOR_ROLE } from "../roles/roles.js";
|
|
14
16
|
import { shortSha } from "../text/format.js";
|
|
15
|
-
import {
|
|
17
|
+
import { setMergeStage } from "./merge-slot.js";
|
|
16
18
|
/** Rebase a pinned change in its leased pool slot `wt` (checked out detached at `req.sha`) onto
|
|
17
19
|
* main's CURRENT head before its review gate, and return the request that gate must judge.
|
|
18
20
|
* The gate must review main's current tree, not the main the author started from: when main
|
|
19
|
-
* moved after the pin (the common queued-
|
|
20
|
-
* fails on a failure main already fixed and every queued
|
|
21
|
+
* moved after the pin (the common queued-merge case), a pre-check against the stale tree
|
|
22
|
+
* fails on a failure main already fixed and every queued merge rejects in a cascade — and a
|
|
21
23
|
* reviewer whose checkout is behind main reads main's newer commits as the change deleting
|
|
22
24
|
* them (BUGS.md 2026-09-23: d13cf2e, a sound feature, rejected for "reverting" five commits
|
|
23
|
-
* that
|
|
24
|
-
* the request track the synced head so the strike-cap tell and the
|
|
25
|
-
* that can actually
|
|
26
|
-
* detached pin — the gate reviews the pinned tree and mergeToMain's resolver
|
|
27
|
-
*
|
|
25
|
+
* that merged after its pin). A no-op when main has not moved; on a clean rebase the ref and
|
|
26
|
+
* the request track the synced head so the strike-cap tell and the merge ref name the tree
|
|
27
|
+
* that can actually merge. On a conflict rebaseOntoMain has already aborted and restored the
|
|
28
|
+
* detached pin — the gate reviews the pinned tree and mergeToMain's resolver merges it. Every
|
|
29
|
+
* verify runs it before its gate (merge-batch.ts's verifyRequest), so a gate never judges a stale
|
|
28
30
|
* pin; each call rebases only its own worktree, onto main itself — never onto another queued
|
|
29
|
-
* change — so concurrent
|
|
31
|
+
* change — so concurrent verifies in distinct pool slots cannot interfere. */
|
|
30
32
|
export async function syncPinToMain(ctx, wt, req) {
|
|
31
33
|
if (!(await rebaseOntoMain(wt, ctx.mainBranch)))
|
|
32
34
|
return req;
|
|
33
35
|
const syncedHead = await headOf(wt, "HEAD");
|
|
34
36
|
if (syncedHead === req.sha)
|
|
35
37
|
return req;
|
|
36
|
-
await setRef(ctx.root,
|
|
38
|
+
await setRef(ctx.root, mergeRefName(req.role), syncedHead);
|
|
37
39
|
return { ...req, sha: syncedHead };
|
|
38
40
|
}
|
|
39
|
-
/** The ReviewContext for judging
|
|
40
|
-
* home of that argument object, shared by reviewPinnedChange's pinned
|
|
41
|
-
*
|
|
41
|
+
/** The ReviewContext for judging merge request `req` in worktree `wt` — the single
|
|
42
|
+
* home of that argument object, shared by reviewPinnedChange's pinned verify and
|
|
43
|
+
* mergeApprovedChange's re-review of a resolved conflict (its recheckResolved callback), so the
|
|
42
44
|
* two gate invocations cannot drift on which fields the reviewer sees. Both callers' contexts
|
|
43
|
-
* (ReviewGateContext and
|
|
45
|
+
* (ReviewGateContext and MergerContext) carry the root/mainBranch/config/signal/runGatePi fields
|
|
44
46
|
* this reads. */
|
|
45
47
|
function reviewContextFor(ctx, req, wt) {
|
|
46
48
|
return {
|
|
@@ -50,8 +52,8 @@ function reviewContextFor(ctx, req, wt) {
|
|
|
50
52
|
mainBranch: ctx.mainBranch,
|
|
51
53
|
config: ctx.config,
|
|
52
54
|
tick: req.tick,
|
|
53
|
-
// One directory per review of one change: tick plus the pinned sha, so a re-
|
|
54
|
-
// same sha reuses it and the
|
|
55
|
+
// One directory per review of one change: tick plus the pinned sha, so a re-verify of the
|
|
56
|
+
// same sha reuses it and the verify and the conflict-resolved re-review agree.
|
|
55
57
|
sessionDir: reviewRunSessionDir(ctx.root, req.role, `${req.tick}-${shortSha(req.sha)}`),
|
|
56
58
|
signal: ctx.signal(),
|
|
57
59
|
runGatePi: ctx.runGatePi,
|
|
@@ -60,29 +62,29 @@ function reviewContextFor(ctx, req, wt) {
|
|
|
60
62
|
};
|
|
61
63
|
}
|
|
62
64
|
/** Run one pinned change through the review gate in its leased pool slot `wt` and handle the
|
|
63
|
-
* immediate bookkeeping — the heart of every
|
|
65
|
+
* immediate bookkeeping — the heart of every verify (merge-batch.ts's verifyRequest). Persists the
|
|
64
66
|
* verdict at once, folds the reviewer's usage, and routes
|
|
65
|
-
* the three terminal outcomes: aborted (ref kept — fail closed, the next tick re-
|
|
67
|
+
* the three terminal outcomes: aborted (ref kept — fail closed, the next tick re-merges it),
|
|
66
68
|
* rejected (ref deleted — final for this sha), and failed (a strike-cap discard — the gate
|
|
67
69
|
* reports it as `discarded` — deletes the ref; an under-cap failure, a red main's included,
|
|
68
|
-
* keeps it for the next re-
|
|
69
|
-
* the gate result only when the change may be
|
|
70
|
+
* keeps it for the next re-merge). Returns
|
|
71
|
+
* the gate result only when the change may be merged, alongside the head to merge it at. An
|
|
70
72
|
* abort that has already fired is observed HERE, before the gate starts, not only by the
|
|
71
|
-
* gate's pi runs: a gate that short-circuits on an already-approved head (a re-
|
|
73
|
+
* gate's pi runs: a gate that short-circuits on an already-approved head (a re-verified change's)
|
|
72
74
|
* runs no pi at all, and one that does still spends its build pre-check first — so a stopping
|
|
73
|
-
*
|
|
75
|
+
* merge (a restart hand-off past its deadline, BUGS.md 2026-09-23) would otherwise sail
|
|
74
76
|
* through its gate into the check. */
|
|
75
77
|
export async function reviewPinnedChange(ctx, req, wt, state, foldUsage) {
|
|
76
78
|
const { root, config } = ctx;
|
|
77
79
|
const { role } = req;
|
|
78
|
-
const ref =
|
|
80
|
+
const ref = mergeRefName(role);
|
|
79
81
|
// Both uses below — a rejection and a strike-cap discard — end a pinned change without
|
|
80
82
|
// keeping it: drop the held-selector note and the pin ref together, so the two-part cleanup
|
|
81
|
-
// cannot drift between them. The
|
|
82
|
-
// "Vets and merges"):
|
|
83
|
+
// cannot drift between them. The verify's checkout is a pooled slot (plans/worktree-pool.md,
|
|
84
|
+
// "Vets and merges"): verifyRequest releases the lease in its finally, and a slot is reused, so
|
|
83
85
|
// this path must not remove the worktree it ran in.
|
|
84
86
|
const discardPin = async () => {
|
|
85
|
-
state.
|
|
87
|
+
state.mergeReviewError = undefined;
|
|
86
88
|
await deleteRef(root, ref);
|
|
87
89
|
};
|
|
88
90
|
// Already stopping: the gate never starts, so nothing is persisted or folded — the ref stays
|
|
@@ -90,28 +92,30 @@ export async function reviewPinnedChange(ctx, req, wt, state, foldUsage) {
|
|
|
90
92
|
if (ctx.signal().aborted)
|
|
91
93
|
return { kind: "result", result: "aborted" };
|
|
92
94
|
const gate = await reviewAheadOfMain(reviewContextFor(ctx, req, wt), state, req.summary, req.body, req.highFriction);
|
|
93
|
-
// The gate is over, whatever it decided: move the
|
|
94
|
-
// outside a queued
|
|
95
|
-
// `
|
|
95
|
+
// The gate is over, whatever it decided: move the merge cell off the gate's stages (a no-op
|
|
96
|
+
// outside a queued merge). What follows is the wait for the merge — the verify marks the change
|
|
97
|
+
// `approved` next, and the merge slot re-stages it `merging` when it takes it — and a finished
|
|
96
98
|
// reviewer's last turns left in the cell would accrue a false `no pi output` flag for as long
|
|
97
99
|
// as the change waits.
|
|
98
|
-
|
|
100
|
+
setMergeStage(root, role, "merging");
|
|
99
101
|
// A rejection keeps the rejected commit alive while a revision is due (plans/revise-rejected.md):
|
|
100
102
|
// point the rejected ref at the head the gate judged and record the round, so the author's next
|
|
101
|
-
// tick re-applies it.
|
|
102
|
-
//
|
|
103
|
+
// tick re-applies it. A rejection is final — clear the state, delete the ref, and log
|
|
104
|
+
// `exhausted` — when the same objection has survived two revisions or the chain hit the
|
|
105
|
+
// backstop (BUGS.md 2026-10-14). Done before the save below so the round rides the
|
|
106
|
+
// persisted state.
|
|
103
107
|
if (gate.decision === "rejected") {
|
|
104
|
-
await recordRejectedChange(root, role, state, state.lastReview?.head ?? req.sha, req.revisionRound);
|
|
108
|
+
await recordRejectedChange(root, role, state, state.lastReview?.head ?? req.sha, req.revisionRound, req.priorReview?.counts);
|
|
105
109
|
}
|
|
106
110
|
// Persist the verdict immediately, not at the tick's end save: the gate's bookkeeping is
|
|
107
111
|
// cross-tick memory (a persisted "reject" injects a "your previous change was rejected"
|
|
108
|
-
// note into the next prompt), and this tick's tail — the
|
|
112
|
+
// note into the next prompt), and this tick's tail — the merge plus the still-to-come
|
|
109
113
|
// authoring run — can outlive a sudden death by hours. A mid-run crash (power loss,
|
|
110
114
|
// kill -9) would otherwise roll the state file back to the last tick-boundary snapshot
|
|
111
115
|
// and re-inject a superseded rejection even though its replacement is already on main.
|
|
112
116
|
saveLoopState(root, state);
|
|
113
117
|
// The reviewer's usage is folded exactly once per attempt: runGatePi folds a RETRIED first
|
|
114
|
-
// attempt at retry time (stamping the
|
|
118
|
+
// attempt at retry time (stamping the team-wide rate-limit hold promptly, BUGS.md
|
|
115
119
|
// 2026-10-01), and the final run is folded HERE — not also inside the wiring, which would
|
|
116
120
|
// count the reviewer twice.
|
|
117
121
|
if (gate.run)
|
|
@@ -124,7 +128,7 @@ export async function reviewPinnedChange(ctx, req, wt, state, foldUsage) {
|
|
|
124
128
|
// produced it — one more line here, because a GateResult carries a third run now.
|
|
125
129
|
if (gate.nudgeRun)
|
|
126
130
|
foldUsage(gate.nudgeRun);
|
|
127
|
-
// Shutdown/user abort mid-review: fail closed — the ref stays and the next tick re-
|
|
131
|
+
// Shutdown/user abort mid-review: fail closed — the ref stays and the next tick re-merges it.
|
|
128
132
|
// The caller routes "aborted" through its own abort handling (which discards the pin too when
|
|
129
133
|
// the abort was a deliberate user stop).
|
|
130
134
|
if (gate.aborted)
|
|
@@ -136,7 +140,7 @@ export async function reviewPinnedChange(ctx, req, wt, state, foldUsage) {
|
|
|
136
140
|
}
|
|
137
141
|
if (gate.decision === "failed" && gate.mainRed) {
|
|
138
142
|
// Main is red at its tip: nothing judged this diff and nothing is wrong with the reviewer,
|
|
139
|
-
// so it is main_red, not review_error — the pin stays for a re-
|
|
143
|
+
// so it is main_red, not review_error — the pin stays for a re-merge once main is green.
|
|
140
144
|
state.lastError = `gate check failed: ${gate.detail}`;
|
|
141
145
|
return { kind: "result", result: "main_red" };
|
|
142
146
|
}
|
|
@@ -148,7 +152,7 @@ export async function reviewPinnedChange(ctx, req, wt, state, foldUsage) {
|
|
|
148
152
|
// 2026-10-06). A discarded pin is gone, so no hold is recorded for it.
|
|
149
153
|
if (gate.configError && !gate.discarded) {
|
|
150
154
|
const selector = modelSelectorField(reviewRunConfig(config)).model;
|
|
151
|
-
state.
|
|
155
|
+
state.mergeReviewError = {
|
|
152
156
|
sha: req.sha,
|
|
153
157
|
...(selector ? { selector } : {}),
|
|
154
158
|
message: state.lastError,
|
|
@@ -157,7 +161,7 @@ export async function reviewPinnedChange(ctx, req, wt, state, foldUsage) {
|
|
|
157
161
|
}
|
|
158
162
|
// Strike-cap discard: the gate says so directly (it reset the worktree off the pin) — the
|
|
159
163
|
// commit is gone and the ref goes too. An under-cap failure — a dead reviewer — keeps the
|
|
160
|
-
// pin as it is: the gate never commits, so the pin still names the tree the next re-
|
|
164
|
+
// pin as it is: the gate never commits, so the pin still names the tree the next re-merge
|
|
161
165
|
// reviews.
|
|
162
166
|
if (gate.discarded) {
|
|
163
167
|
await discardPin();
|
|
@@ -166,7 +170,7 @@ export async function reviewPinnedChange(ctx, req, wt, state, foldUsage) {
|
|
|
166
170
|
return { kind: "result", result: "review_error" };
|
|
167
171
|
}
|
|
168
172
|
// Approved or exempt: track the pin to the head the verdict judged, so a merge that cannot
|
|
169
|
-
// finish (conflict/blocked) leaves recovery re-
|
|
173
|
+
// finish (conflict/blocked) leaves recovery re-merging that tree, not a stale pin.
|
|
170
174
|
// verifiedHead carries it when the pre-check ran green; otherwise the worktree head does. An
|
|
171
175
|
// unreadable head keeps the old pin (fail closed).
|
|
172
176
|
const sha = gate.verifiedHead ?? (await headOf(wt, "HEAD").catch(() => req.sha));
|
|
@@ -174,73 +178,92 @@ export async function reviewPinnedChange(ctx, req, wt, state, foldUsage) {
|
|
|
174
178
|
await setRef(root, ref, sha);
|
|
175
179
|
req = { ...req, sha };
|
|
176
180
|
}
|
|
177
|
-
// The change may
|
|
178
|
-
state.
|
|
181
|
+
// The change may merge: nothing is held against the previous reviewer selector any more.
|
|
182
|
+
state.mergeReviewError = undefined;
|
|
179
183
|
return { kind: "gate", gate, sha };
|
|
180
184
|
}
|
|
181
|
-
/** The failed
|
|
182
|
-
* failure, or a merge that could not be rebased/
|
|
185
|
+
/** The failed merge outcomes that KEEP the pin for another attempt: an under-cap review
|
|
186
|
+
* failure, or a merge that could not be rebased/merged. They are exactly a merge's
|
|
183
187
|
* non-terminal failures (`rejected` is final and deletes the pin; `aborted` is a shutdown;
|
|
184
|
-
* `changed`
|
|
188
|
+
* `changed` merged), so a run of them on the leftover-recovery path is what feeds the error
|
|
185
189
|
* streak — a dead reviewer backend raises the alarm instead of resetting it every tick
|
|
186
190
|
* (BUGS.md 2026-09-21). */
|
|
187
|
-
export const
|
|
191
|
+
export const RETRIABLE_MERGE_RESULTS = new Set([
|
|
188
192
|
"review_error",
|
|
189
193
|
"merge_conflict",
|
|
190
194
|
"merge_blocked",
|
|
191
195
|
]);
|
|
192
|
-
/** Record a gate rejection for the revise-rejected feature (plans/revise-rejected.md
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
* with
|
|
199
|
-
|
|
196
|
+
/** Record a gate rejection for the revise-rejected feature (plans/revise-rejected.md; the
|
|
197
|
+
* repeat-finality policy in BUGS.md 2026-10-14): a rejection sends the change back for another
|
|
198
|
+
* round UNLESS the same objection has survived two revisions — a `(prior N)` restatement whose
|
|
199
|
+
* count reaches REVISION_FINAL_ON_REPEAT, counted from `priorCounts` — or the chain has reached
|
|
200
|
+
* the REVISION_BACKSTOP cap, when it ends even if the objections never repeat. While it keeps
|
|
201
|
+
* revising, point `refs/tumwater/rejected/<role>` at the head the gate judged and set
|
|
202
|
+
* LoopState.revision (with each reason's persistence count) so the author's next tick re-applies
|
|
203
|
+
* it; when final, clear the revision, delete the ref and log `exhausted`. `priorCounts` is the
|
|
204
|
+
* previous round's per-reason persistence counts, carried from the queued merge's PriorReview;
|
|
205
|
+
* absent for a fresh change's first rejection and on a recovery merge, a `(prior N)` tag then
|
|
206
|
+
* reads as a fresh objection. Shared by a pinned change's rejection (reviewPinnedChange) and the
|
|
207
|
+
* in-lock re-review rejection of a resolved conflict (mergeApprovedChange), so both send the
|
|
208
|
+
* change back for revision the same way. A rejection with no `revisionRound` is a fresh change's
|
|
209
|
+
* first round (1). The director never revises. */
|
|
210
|
+
async function recordRejectedChange(root, role, state, rejectedHead, revisionRound, priorCounts) {
|
|
200
211
|
if (role === DIRECTOR_ROLE)
|
|
201
212
|
return;
|
|
202
213
|
const nextRound = (revisionRound ?? 0) + 1;
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
214
|
+
const reasons = state.lastReview?.reasons ?? [];
|
|
215
|
+
// Each reason's persistence count: a new (untagged) objection starts at 0; a `(prior N)`
|
|
216
|
+
// restatement has survived one more revision than that objection's own count from the prior
|
|
217
|
+
// round, so an objection standing across two revisions reaches REVISION_FINAL_ON_REPEAT. An
|
|
218
|
+
// out-of-range or missing tag (a stale prior list, no prior round) reads as a fresh objection.
|
|
219
|
+
const prior = priorCounts ?? [];
|
|
220
|
+
const counts = reasons.map((r) => {
|
|
221
|
+
const n = priorObjectionNumber(r);
|
|
222
|
+
return n === null ? 0 : 1 + (prior[n - 1] ?? 0);
|
|
223
|
+
});
|
|
224
|
+
const repeatedFinal = counts.some((c) => c >= REVISION_FINAL_ON_REPEAT);
|
|
225
|
+
if (nextRound >= REVISION_BACKSTOP || repeatedFinal) {
|
|
226
|
+
state.revision = undefined;
|
|
227
|
+
await deleteRef(root, rejectedRefName(role));
|
|
228
|
+
// The next tick gets the plain rejected note, not a revision note; mark it final so that
|
|
229
|
+
// note says the rejected diff is gone rather than inviting another revision.
|
|
230
|
+
if (state.lastReview)
|
|
231
|
+
state.lastReview.exhausted = true;
|
|
232
|
+
logEventBestEffort(root, { source: role, type: "revision", action: "exhausted", round: nextRound, sha: rejectedHead });
|
|
206
233
|
return;
|
|
207
234
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
// The next tick gets the plain rejected note, not a revision note; mark it final so that
|
|
211
|
-
// note says the rejected diff is gone rather than inviting another revision.
|
|
212
|
-
if (state.lastReview)
|
|
213
|
-
state.lastReview.exhausted = true;
|
|
214
|
-
logEventBestEffort(root, { loop: role, type: "revision", action: "exhausted", round: nextRound, sha: rejectedHead });
|
|
235
|
+
await setRef(root, rejectedRefName(role), rejectedHead);
|
|
236
|
+
state.revision = { sha: rejectedHead, round: nextRound, at: Date.now(), reasonCounts: counts };
|
|
215
237
|
}
|
|
216
|
-
/**
|
|
217
|
-
* and each
|
|
238
|
+
/** Merge a head that already passed its OWN gate in its verify — the merge slot's one-change merge
|
|
239
|
+
* and each merge of an abandoned stack's one-at-a-time fallback (merge-batch.ts's mergeApproved)
|
|
218
240
|
* — without a second gate. Re-gating would rebase first, and by then main has usually moved (a
|
|
219
|
-
* fallback's earlier entries just
|
|
220
|
-
* exact-sha `lastApprovedHead` short-circuit misses, and every such
|
|
241
|
+
* fallback's earlier entries just merged): the rebase rewrites the approved sha, the gate's
|
|
242
|
+
* exact-sha `lastApprovedHead` short-circuit misses, and every such merge paid a second build
|
|
221
243
|
* check and model review of a change it had already approved (BUGS.md 2026-09-23). Here a clean
|
|
222
244
|
* rebase of the approved head is accepted as-is: mergeToMain's own in-lock rebase moves it onto
|
|
223
245
|
* main's tip, and because the pre-merge head it captures is the approved `req.sha`,
|
|
224
|
-
*
|
|
246
|
+
* verifyMerge runs one bounded scope-`merge` check whenever that rebase rewrote anything
|
|
225
247
|
* (and skips only for the exact bytes the gate judged, seeding the red-main baseline when
|
|
226
248
|
* `req.verifiedHead` names them). A conflicting rebase gets mergeToMain's resolver — the rule for
|
|
227
249
|
* a pin whose pre-gate rebase conflicted. `lastApprovedHead` is neither read nor widened: the
|
|
228
|
-
* caller vouches for `req.sha` (only approved
|
|
229
|
-
* lifecycle from here: deleted on
|
|
230
|
-
* merge_conflict, merge_blocked) for the next tick's leftover recovery. An abort that has
|
|
250
|
+
* caller vouches for `req.sha` (only changes the gate approved or exempted are merged). Owns
|
|
251
|
+
* the merge ref's lifecycle from here: deleted on merge, KEPT on every non-terminal outcome
|
|
252
|
+
* (aborted, merge_conflict, merge_blocked) for the next tick's leftover recovery. An abort that has
|
|
231
253
|
* already fired returns "aborted" before anything starts, ref kept — the merge's step-boundary
|
|
232
254
|
* stop (BUGS.md 2026-09-23): with no gate here, nothing else on this path would notice it
|
|
233
|
-
* before the in-lock check and the merge. Never throws for a failed
|
|
255
|
+
* before the in-lock check and the merge. Never throws for a failed merge: git-level failures
|
|
234
256
|
* propagate like any other failure. */
|
|
235
|
-
export async function
|
|
257
|
+
export async function mergeApprovedChange(ctx, req) {
|
|
236
258
|
if (ctx.signal().aborted)
|
|
237
259
|
return "aborted";
|
|
238
260
|
// Hold the merge worktree from before ensureDetachedWorktree (its reset is part of the
|
|
239
261
|
// use) through the merge (plans/disk-floor.md, part 2/4).
|
|
240
|
-
return useWorktree(ctx.root, mergeWorktreePath(ctx.root), () =>
|
|
262
|
+
return useWorktree(ctx.root, mergeWorktreePath(ctx.root), () => mergeApprovedChangeIn(ctx, req));
|
|
241
263
|
}
|
|
242
|
-
async function
|
|
264
|
+
async function mergeApprovedChangeIn(ctx, req) {
|
|
243
265
|
const wt = await ensureDetachedWorktree(ctx.root, mergeWorktreePath(ctx.root), req.sha);
|
|
266
|
+
const pinPatch = await patchId(wt, ctx.mainBranch, req.sha).catch(() => null);
|
|
244
267
|
let red;
|
|
245
268
|
let blocked;
|
|
246
269
|
const result = await mergeToMain({
|
|
@@ -251,10 +274,10 @@ async function landApprovedChangeIn(ctx, req) {
|
|
|
251
274
|
config: ctx.config,
|
|
252
275
|
tick: req.tick,
|
|
253
276
|
runPi: ctx.runPi,
|
|
254
|
-
|
|
277
|
+
onMergeCheckRed: (check, outcome) => {
|
|
255
278
|
red = { check, outcome };
|
|
256
279
|
},
|
|
257
|
-
|
|
280
|
+
onMergeBlocked: (reason) => {
|
|
258
281
|
blocked = reason;
|
|
259
282
|
},
|
|
260
283
|
recheckResolved: async (resolvedWt) => {
|
|
@@ -262,7 +285,7 @@ async function landApprovedChangeIn(ctx, req) {
|
|
|
262
285
|
return { verdict: "retry" };
|
|
263
286
|
// A resolved diff that left the reviewed change's lines goes back through the same gate
|
|
264
287
|
// the pin went through (BUGS.md 2026-10-01: a resolution that restored code main had
|
|
265
|
-
// deliberately reverted
|
|
288
|
+
// deliberately reverted merged on the build check alone). Same gate, same worktree
|
|
266
289
|
// convention as reviewPinnedChange: the reject path records the verdict, resets the
|
|
267
290
|
// worktree to main and logs review_rejected itself; the ref and the lastError are ours.
|
|
268
291
|
const gate = await reviewAheadOfMain(reviewContextFor(ctx, req, resolvedWt), ctx.state, req.summary, req.body, req.highFriction);
|
|
@@ -272,7 +295,7 @@ async function landApprovedChangeIn(ctx, req) {
|
|
|
272
295
|
ctx.foldUsage(gate.followUpRun);
|
|
273
296
|
if (gate.nudgeRun)
|
|
274
297
|
ctx.foldUsage(gate.nudgeRun);
|
|
275
|
-
|
|
298
|
+
setMergeStage(ctx.root, req.role, "merging");
|
|
276
299
|
saveLoopState(ctx.root, ctx.state);
|
|
277
300
|
if (gate.aborted)
|
|
278
301
|
return { verdict: "retry" };
|
|
@@ -284,34 +307,35 @@ async function landApprovedChangeIn(ctx, req) {
|
|
|
284
307
|
// author for revision (plans/revise-rejected.md), exactly as a pinned change's
|
|
285
308
|
// rejection does, then drop this pin. Persist before returning — the state save above
|
|
286
309
|
// predates this bookkeeping.
|
|
287
|
-
await recordRejectedChange(ctx.root, req.role, ctx.state, ctx.state.lastReview?.head ?? req.sha, req.revisionRound);
|
|
310
|
+
await recordRejectedChange(ctx.root, req.role, ctx.state, ctx.state.lastReview?.head ?? req.sha, req.revisionRound, req.priorReview?.counts);
|
|
288
311
|
saveLoopState(ctx.root, ctx.state);
|
|
289
|
-
await deleteRef(ctx.root,
|
|
312
|
+
await deleteRef(ctx.root, mergeRefName(req.role)); // the verdict is final for this pin
|
|
290
313
|
ctx.state.lastError = "merge failed: conflict resolution rejected on re-review";
|
|
291
314
|
return { verdict: "rejected" };
|
|
292
315
|
}
|
|
293
316
|
// A failed gate (dead reviewer, aborted run) is no verdict about the tree: under the
|
|
294
|
-
// strike cap the pin stays for recovery's re-
|
|
317
|
+
// strike cap the pin stays for recovery's re-merge (the gate's own discard fires here as
|
|
295
318
|
// "discarded", and then the commit is gone anyway).
|
|
296
319
|
ctx.state.lastError = `merge failed: re-review failed: ${gate.detail}`;
|
|
297
320
|
if (gate.discarded) {
|
|
298
|
-
await deleteRef(ctx.root,
|
|
321
|
+
await deleteRef(ctx.root, mergeRefName(req.role));
|
|
299
322
|
return { verdict: "rejected" };
|
|
300
323
|
}
|
|
301
324
|
return { verdict: "retry" };
|
|
302
325
|
},
|
|
303
326
|
}, wt, req.summary, req.verifiedHead);
|
|
304
327
|
if (result === "changed") {
|
|
305
|
-
await deleteRef(ctx.root,
|
|
328
|
+
await deleteRef(ctx.root, mergeRefName(req.role)); // merged: the pin has done its job
|
|
329
|
+
noteMergeRetryPassed(ctx, req.role, pinPatch);
|
|
306
330
|
return result;
|
|
307
331
|
}
|
|
308
332
|
if (result === "merge_blocked" && red)
|
|
309
|
-
return
|
|
333
|
+
return mergeCheckRed(ctx, req.role, wt, red);
|
|
310
334
|
if (result === "merge_blocked" && blocked !== undefined) {
|
|
311
|
-
return
|
|
335
|
+
return mergeBlocked(ctx, req.role, wt, blocked);
|
|
312
336
|
}
|
|
313
|
-
// merge_conflict / merge_blocked: keep the ref — the next tick's recovery re-
|
|
314
|
-
// (Cross-check blocks no longer come through here:
|
|
337
|
+
// merge_conflict / merge_blocked: keep the ref — the next tick's recovery re-merges it.
|
|
338
|
+
// (Cross-check blocks no longer come through here: merge-check-failures.ts's mergeBlocked
|
|
315
339
|
// counts them.)
|
|
316
340
|
// A rejected recheck already wrote its own lastError and deleted its ref — leave it.
|
|
317
341
|
if (result !== "rejected")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { increment } from "../collections.js";
|
|
2
2
|
import { gitTry } from "../git/git-run.js";
|
|
3
|
-
/** The
|
|
3
|
+
/** The merge-scope DIFF COMPARISON (see merge-to-main.ts for the merge flow that consults
|
|
4
4
|
* it): whether a conflict-resolved tree's diff ahead of main stays inside the diff the review
|
|
5
5
|
* gate approved. Pure multiset comparison over unified diffs, plus the git reads that produce
|
|
6
6
|
* them — kept apart from the merge flow so the hunk-aware parsing (BUGS.md 2026-10-02) is
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// vocabulary: plain
|
|
2
|
+
import { mergeApproved } from "./merge-batch.js";
|
|
3
|
+
import { removeMergeChange, resolveWaitStage, setMergeChangeStatus } from "./merge-slot.js";
|
|
4
|
+
import { seamConcurrencyKey } from "../config/config-views.js";
|
|
5
|
+
import { withProviderLease } from "../concurrency/provider-lease.js";
|
|
6
|
+
import { dropMerge, entryRevisionFields, queuedMergeFiles } from "./merge-queue.js";
|
|
7
|
+
import { errorMessage } from "../text/text.js";
|
|
8
|
+
import { warnEventBestEffort, TUMWATER_SOURCE } from "../events/events.js";
|
|
9
|
+
import { saveLoopState } from "../loop/loop-state.js";
|
|
10
|
+
import { abortOnShutdown, discardPinnedRefs, guardMergeTask, settleMergeOutcome, } from "./merge-pipeline.js";
|
|
11
|
+
import { drainVerify, VERIFY_TIER } from "./merge-verify.js";
|
|
12
|
+
/** The tier the merge slot's conflict-resolution runs wait at: ahead of any verification parked for
|
|
13
|
+
* a permit, because the merge is the one serial step every queued change waits on. (Nothing that
|
|
14
|
+
* holds a permit ever waits on the merge: no verification takes the merge lock, and the resolver
|
|
15
|
+
* runs outside it — merge-to-main.ts's mergeToMain — so this cannot deadlock.) */
|
|
16
|
+
const MERGE_TIER = VERIFY_TIER - 1;
|
|
17
|
+
/** One poll of the merge queue (the scheduler's WHEN: after the gates, never while a restart or
|
|
18
|
+
* a team hold is pending): start a verification for every queued change the pipeline does not
|
|
19
|
+
* already hold (drainVerify, in merge-verify.ts), and start the merge when the slot is free and
|
|
20
|
+
* something is approved (drainMerge). A queued merge is COMMITTED work awaiting completion, not
|
|
21
|
+
* a new tick, so the budget and user-pause gates deliberately do not hold it (pausing it would
|
|
22
|
+
* leave main behind while the interlock blocks that role's next tick forever). Starts tasks and
|
|
23
|
+
* returns; never awaits them — authors keep ticking behind them, which is the entire point. */
|
|
24
|
+
export async function drainMergeQueue(ctx, p) {
|
|
25
|
+
await drainVerify(ctx, p);
|
|
26
|
+
drainMerge(ctx, p);
|
|
27
|
+
}
|
|
28
|
+
/** Settle every approved change `tumwater abort --role` flagged (consumeAbortRequests over
|
|
29
|
+
* abortableMerges): it has no task to stop, so it ends here exactly as an aborted merge
|
|
30
|
+
* does — "aborted" written back, entry dropped, pin discarded (the deliberate stop throws the
|
|
31
|
+
* committed work away). The scheduler runs it every poll right after consuming the requests,
|
|
32
|
+
* held or not: a restart hold that skipped it would hand the flagged change, pin intact, to
|
|
33
|
+
* the next generation to merge.
|
|
34
|
+
*
|
|
35
|
+
* Unlike a verification or a merge, this settles in the poll body with nothing to guard it: a throw
|
|
36
|
+
* out of the outcome write — an unwritable state file — would end the team's run. It must also
|
|
37
|
+
* never leave the deliberately aborted change queued (an abandoned queue file would be
|
|
38
|
+
* re-verified and merged next poll, against the operator's intent) or keep its pin. So a failed
|
|
39
|
+
* write is reported and the abort honored by hand: queue file and marker dropped, pin
|
|
40
|
+
* discarded. */
|
|
41
|
+
export async function settleAbortedApproved(root, p) {
|
|
42
|
+
for (const [role, v] of [...p.approved]) {
|
|
43
|
+
if (!v.userAborted)
|
|
44
|
+
continue;
|
|
45
|
+
p.approved.delete(role);
|
|
46
|
+
try {
|
|
47
|
+
await settleMergeOutcome(root, v.entry, v.author.state, "aborted", Date.now() - v.startedAt, v.usage, v.file);
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
warnEventBestEffort(root, TUMWATER_SOURCE, `the stopped merge for ${role} could not be settled: ${errorMessage(err)}`);
|
|
51
|
+
dropMerge(v.file);
|
|
52
|
+
removeMergeChange(root, role);
|
|
53
|
+
}
|
|
54
|
+
await discardPinnedRefs(root, [role]);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** Start the merge when the slot is free and something is approved: every approved change, up to
|
|
58
|
+
* mergeBatchMax, in queue order among the approved — an unapproved entry ahead of them in the queue
|
|
59
|
+
* (a slow review) does not hold them back. An approved change whose queue entry is gone is
|
|
60
|
+
* forgotten (nothing in-process drops one; defensive). */
|
|
61
|
+
export function drainMerge(ctx, p) {
|
|
62
|
+
if (p.merge !== null || p.approved.size === 0)
|
|
63
|
+
return;
|
|
64
|
+
const queued = queuedMergeFiles(ctx.root);
|
|
65
|
+
const files = new Set(queued.map((q) => q.file));
|
|
66
|
+
for (const [role, v] of [...p.approved]) {
|
|
67
|
+
if (files.has(v.file))
|
|
68
|
+
continue;
|
|
69
|
+
p.approved.delete(role);
|
|
70
|
+
removeMergeChange(ctx.root, role);
|
|
71
|
+
}
|
|
72
|
+
const picks = queued.flatMap((q) => {
|
|
73
|
+
const v = p.approved.get(q.entry.role);
|
|
74
|
+
return v?.file === q.file ? [v] : [];
|
|
75
|
+
});
|
|
76
|
+
picks.splice(ctx.liveConfig.mergeBatchMax);
|
|
77
|
+
if (picks.length === 0)
|
|
78
|
+
return;
|
|
79
|
+
for (const v of picks)
|
|
80
|
+
p.approved.delete(v.entry.role);
|
|
81
|
+
p.merge = startMerge(ctx, p, picks);
|
|
82
|
+
}
|
|
83
|
+
/** The merge task — main's one writer in the pipeline: merge `picks` through mergeApproved on
|
|
84
|
+
* main's current tip — one change through mergeApprovedChange, two or more as one stack with one
|
|
85
|
+
* scope-`batch` check, one fast-forward, and 3d's largest-passing-prefix bisect — with no second
|
|
86
|
+
* review. An approved change whose main moved since its verification is re-checked on the tree that
|
|
87
|
+
* merges (the in-lock re-check, or the stack's check), so an approval that outlived its base never
|
|
88
|
+
* merges unverified. Each defined result is written back and its entry dropped; an unattempted
|
|
89
|
+
* change (behind a bisect's attributed one) goes back to `approved` for the next merge. A plumbing
|
|
90
|
+
* throw keeps every entry queued, but unapproved: each is verified afresh from its pin next poll. A
|
|
91
|
+
* user abort of any merged role stops the whole stack, and every change it had not merged ends
|
|
92
|
+
* "aborted" with its pin discarded; a shutdown keeps the pins. The task takes no permit of its
|
|
93
|
+
* own — its only model run is mergeToMain's conflict resolver, which takes a shared permit at
|
|
94
|
+
* MERGE_TIER for the run's length, outside the merge lock. */
|
|
95
|
+
function startMerge(ctx, p, picks) {
|
|
96
|
+
const { root, mainBranch, signal, roleConfig, semaphore } = ctx;
|
|
97
|
+
const merge = {
|
|
98
|
+
promise: Promise.resolve(), // Replaced below; the placeholder satisfies the type.
|
|
99
|
+
controller: new AbortController(),
|
|
100
|
+
roles: picks.map((v) => v.entry.role),
|
|
101
|
+
userAborted: false,
|
|
102
|
+
};
|
|
103
|
+
const detachShutdown = abortOnShutdown(signal, merge.controller);
|
|
104
|
+
const wiring = new Map(picks.map((v) => [
|
|
105
|
+
v.entry.role,
|
|
106
|
+
{
|
|
107
|
+
state: v.author.state,
|
|
108
|
+
foldUsage: v.foldUsage,
|
|
109
|
+
runGatePi: (opts) => v.author.runGatePi(opts),
|
|
110
|
+
runPi: async (w, prompt, s, cfg) => {
|
|
111
|
+
// A parked resolver names its wait (resolve-wait, then resolving once granted) the way
|
|
112
|
+
// a parked check does (withCheckPermit): on 2026-10-12 one waited 2.5 min for a permit
|
|
113
|
+
// with the merge queue behind it, reading as "merging". The lease is keyed on the
|
|
114
|
+
// resolver config's provider (the `cfg` it is handed), so the conflict resolution
|
|
115
|
+
// counts against the strong tier's provider cap like any other strong-tier run
|
|
116
|
+
// (plans/provider-concurrency.md §4). canLeaseAtOnce is acquireLease's own fast path
|
|
117
|
+
// decided synchronously, so a free permit shows no wait; MERGE_TIER ignores the
|
|
118
|
+
// maintenance reserve.
|
|
119
|
+
const key = cfg ? seamConcurrencyKey(cfg, ctx.piDefault) : undefined;
|
|
120
|
+
const hooks = resolveWaitStage(root, v.entry.role);
|
|
121
|
+
if (!semaphore.canLeaseAtOnce(MERGE_TIER, key))
|
|
122
|
+
hooks.waiting();
|
|
123
|
+
const lease = await semaphore.acquireLease(MERGE_TIER, key);
|
|
124
|
+
hooks.granted();
|
|
125
|
+
try {
|
|
126
|
+
// The resolver's lease rides in the provider-lease store for its run, so the run
|
|
127
|
+
// re-keys it to the provider it actually uses (plans/provider-concurrency.md §5).
|
|
128
|
+
return await withProviderLease(lease, semaphore, () => v.author.runMergePi(w, prompt, s, cfg), { piDefault: ctx.piDefault });
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
lease.release();
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
]));
|
|
136
|
+
const task = (async () => {
|
|
137
|
+
let results;
|
|
138
|
+
let threw = false;
|
|
139
|
+
// A result reaches settleMergeOutcome the moment mergeApproved finalizes it, not after the
|
|
140
|
+
// whole batch; this set lets the end-of-batch loop skip what is already written back.
|
|
141
|
+
const settled = new Set();
|
|
142
|
+
const settleOne = async (s, result) => {
|
|
143
|
+
const v = picks[s];
|
|
144
|
+
settled.add(s);
|
|
145
|
+
await settleMergeOutcome(root, v.entry, v.author.state, result, Date.now() - v.startedAt, v.usage, v.file);
|
|
146
|
+
};
|
|
147
|
+
try {
|
|
148
|
+
results = await mergeApproved({
|
|
149
|
+
root,
|
|
150
|
+
mainBranch,
|
|
151
|
+
config: roleConfig,
|
|
152
|
+
signal: () => merge.controller.signal,
|
|
153
|
+
onChangeStatus: (role, status) => setMergeChangeStatus(root, role, status),
|
|
154
|
+
onResult: settleOne,
|
|
155
|
+
runGatePi: (opts) => picks[0].author.runGatePi(opts),
|
|
156
|
+
}, picks.map((v) => ({
|
|
157
|
+
role: v.entry.role,
|
|
158
|
+
sha: v.sha,
|
|
159
|
+
tick: v.entry.run,
|
|
160
|
+
summary: v.entry.summary,
|
|
161
|
+
body: v.entry.body,
|
|
162
|
+
highFriction: v.entry.highFriction,
|
|
163
|
+
...entryRevisionFields(v.entry),
|
|
164
|
+
...(v.verifiedHead !== undefined ? { verifiedHead: v.verifiedHead } : {}),
|
|
165
|
+
})), (role) => wiring.get(role));
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
threw = true;
|
|
169
|
+
results = picks.map(() => undefined);
|
|
170
|
+
const head = picks[0].author;
|
|
171
|
+
head.state.lastError = errorMessage(err);
|
|
172
|
+
// The catch exists to keep every entry queued and unapproved when mergeApproved throws. A
|
|
173
|
+
// throw out of this write — an unwritable state file — must not skip the settle loop
|
|
174
|
+
// and the finally that frees the merge slot, or the pipeline wedges with p.merge set
|
|
175
|
+
// forever; the write is bookkeeping a later save retries, so warn and keep going.
|
|
176
|
+
try {
|
|
177
|
+
saveLoopState(root, head.state);
|
|
178
|
+
}
|
|
179
|
+
catch (writeErr) {
|
|
180
|
+
warnEventBestEffort(root, TUMWATER_SOURCE, `the merge error for ${head.state.role} could not be saved: ${errorMessage(writeErr)}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
for (let s = 0; s < picks.length; s++) {
|
|
185
|
+
const v = picks[s];
|
|
186
|
+
const { role } = v.entry;
|
|
187
|
+
if (settled.has(s))
|
|
188
|
+
continue; // already written back through onResult
|
|
189
|
+
const result = results[s] ?? (merge.userAborted ? "aborted" : undefined);
|
|
190
|
+
if (result === undefined && !threw) {
|
|
191
|
+
p.approved.set(role, v);
|
|
192
|
+
setMergeChangeStatus(root, role, "approved");
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (result !== undefined) {
|
|
196
|
+
await settleOne(s, result);
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
removeMergeChange(root, role);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
finally {
|
|
204
|
+
detachShutdown();
|
|
205
|
+
if (merge.userAborted)
|
|
206
|
+
await discardPinnedRefs(root, merge.roles);
|
|
207
|
+
p.merge = null;
|
|
208
|
+
}
|
|
209
|
+
})();
|
|
210
|
+
merge.promise = guardMergeTask(root, merge.roles.join(", "), task);
|
|
211
|
+
return merge;
|
|
212
|
+
}
|