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
|
@@ -6,12 +6,13 @@ import { currentBranch, gitLines, headOf } from "../git/git.js";
|
|
|
6
6
|
import { unquotePorcelainPath } from "../git/git-diff.js";
|
|
7
7
|
import { CONFIG_BASENAME, configPath, primaryWorktreeLockPath } from "../paths.js";
|
|
8
8
|
import { withStateLockAsync } from "../concurrency/lock.js";
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
9
|
+
import { errorMessage } from "../text/text.js";
|
|
10
|
+
/** Git plumbing for the merge flow: rebasing a worktree branch onto main, inspecting and
|
|
11
|
+
* finishing a conflicted rebase, and fast-forwarding main — the mechanics, with no merge policy
|
|
12
|
+
* of their own. Merge policy (lock → rebase → verify → ff, conflict resolution, batch ff)
|
|
13
|
+
* lives in merge-to-main.ts on top of these. Moved here from merge-to-main.ts: the cluster started
|
|
14
|
+
* as merge-to-main.ts-only helpers (organize tick 78, moving them out of git/git.ts), but the
|
|
15
|
+
* merger and the batch merger grew to call them directly, leaving merge-to-main.ts exporting
|
|
15
16
|
* generic rebase/ff plumbing that had nothing to do with merging. */
|
|
16
17
|
/** Paths currently in conflict (unmerged) in the worktree. C-quoted names are decoded to
|
|
17
18
|
* real paths — a non-ASCII conflicted file arrives as `"h\303\251llo.ts"` (core.quotePath is on
|
|
@@ -60,7 +61,7 @@ export async function mainCommitsTouching(wt, since, mainBranch, files) {
|
|
|
60
61
|
* cleaning up: "rebased" (including already up to date), "conflict" (the rebase stopped on
|
|
61
62
|
* unmerged paths, which remain in the worktree for a resolver), or "other" (any other
|
|
62
63
|
* failure). Shared by the two rebase wrappers below, which differ only in cleanup policy.
|
|
63
|
-
* Rebase — not merge — keeps main's history linear: each tick
|
|
64
|
+
* Rebase — not merge — keeps main's history linear: each tick merges as its own commit on top
|
|
64
65
|
* of whatever main holds. */
|
|
65
66
|
async function attemptRebase(wt, mainBranch) {
|
|
66
67
|
try {
|
|
@@ -93,6 +94,10 @@ export async function rebaseOntoMainLeaveConflicts(wt, mainBranch) {
|
|
|
93
94
|
await gitTry(wt, "rebase", "--abort");
|
|
94
95
|
return state === "rebased" ? "clean" : state === "conflict" ? "conflict" : "failed";
|
|
95
96
|
}
|
|
97
|
+
/** A conflict block's start or end marker line: seven `<` or `>`, then a space or the line's end.
|
|
98
|
+
* The one marker definition hasConflictMarkers and conflictHunkRanges share, so the ranges a
|
|
99
|
+
* conflict prompt names are exactly the blocks the marker check later rejects. */
|
|
100
|
+
const MARKER_EDGE = /^(<{7}|>{7})( |$)/m;
|
|
96
101
|
/** True if any of the given files still contains a git conflict marker.
|
|
97
102
|
* A deleted file counts as resolved (the resolver chose the deletion). */
|
|
98
103
|
export function hasConflictMarkers(wt, files) {
|
|
@@ -101,21 +106,65 @@ export function hasConflictMarkers(wt, files) {
|
|
|
101
106
|
// characters), and flagging it would reject clean resolutions forever. A resolver that
|
|
102
107
|
// leaves only a separator line behind is treated as resolved; its stray line is content the
|
|
103
108
|
// project's own tests can catch.
|
|
104
|
-
const marker = /^(<{7}|>{7})( |$)/m;
|
|
105
109
|
return files.some((f) => {
|
|
106
110
|
const p = path.join(wt, f);
|
|
107
111
|
try {
|
|
108
|
-
return
|
|
112
|
+
return MARKER_EDGE.test(fs.readFileSync(p, "utf8"));
|
|
109
113
|
}
|
|
110
114
|
catch {
|
|
111
115
|
return false;
|
|
112
116
|
}
|
|
113
117
|
});
|
|
114
118
|
}
|
|
119
|
+
/** How many conflict hunks a conflict prompt lists per file: every hunk of a typical backlog or
|
|
120
|
+
* code conflict, and a bound on a file pasted over wholesale. */
|
|
121
|
+
export const MAX_LISTED_HUNKS = 10;
|
|
122
|
+
/** The 1-based inclusive line ranges of the conflict hunks in `text`, each from a start marker
|
|
123
|
+
* line through the next end marker line. Markers are matched by line prefix alone, so a hunk
|
|
124
|
+
* inside a markdown code fence counts too: git writes its markers wherever the conflict falls.
|
|
125
|
+
* A second start marker inside an open hunk stays part of it, and an unterminated hunk runs to
|
|
126
|
+
* the last line. */
|
|
127
|
+
export function conflictHunkRanges(text) {
|
|
128
|
+
const lines = text.split("\n");
|
|
129
|
+
const ranges = [];
|
|
130
|
+
let start;
|
|
131
|
+
lines.forEach((line, i) => {
|
|
132
|
+
if (!MARKER_EDGE.test(line))
|
|
133
|
+
return;
|
|
134
|
+
if (line.startsWith("<"))
|
|
135
|
+
start ??= i + 1;
|
|
136
|
+
else if (start !== undefined) {
|
|
137
|
+
ranges.push({ start, end: i + 1 });
|
|
138
|
+
start = undefined;
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
const lastLine = text.endsWith("\n") ? lines.length - 1 : lines.length;
|
|
142
|
+
if (start !== undefined)
|
|
143
|
+
ranges.push({ start, end: lastLine });
|
|
144
|
+
return ranges;
|
|
145
|
+
}
|
|
146
|
+
/** Each of `files` (repo-relative, under `wt`) with its conflict hunks' line ranges, for the
|
|
147
|
+
* conflict prompts to name, so whoever resolves them reads the hunks instead of opening the file
|
|
148
|
+
* from line 1. Over 2026-10-17..19 the merge's conflict resolvers spent 2.0M chars reading the
|
|
149
|
+
* conflicted PLANS.md from its top, 16-47K chars a read because its lines are long. Any text
|
|
150
|
+
* file counts, backlog or code; a deleted, unreadable or binary (NUL-bearing) file lists none. */
|
|
151
|
+
export function conflictedFileHunks(wt, files) {
|
|
152
|
+
return files.map((f) => {
|
|
153
|
+
let text = "";
|
|
154
|
+
try {
|
|
155
|
+
text = fs.readFileSync(path.join(wt, f), "utf8");
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
// Deleted or unreadable: no ranges to name.
|
|
159
|
+
}
|
|
160
|
+
const all = text.includes("\0") ? [] : conflictHunkRanges(text);
|
|
161
|
+
return { path: f, hunks: all.slice(0, MAX_LISTED_HUNKS), more: Math.max(0, all.length - MAX_LISTED_HUNKS) };
|
|
162
|
+
});
|
|
163
|
+
}
|
|
115
164
|
/** The reason naming every changed file that still carries a conflict marker, or undefined when
|
|
116
165
|
* none do — the one home of the wording, shared by the stage check's pre-queue finding
|
|
117
166
|
* (stage-check.ts) and the review gate's hard rejection (review.ts), so the two surfaces cannot
|
|
118
|
-
* name the same leftover marker differently. Committing or
|
|
167
|
+
* name the same leftover marker differently. Committing or merge one would put raw `<<<<<<<`
|
|
119
168
|
* lines on main (PLANS.md "Robust conflict landing, part 2/2"). Builds on hasConflictMarkers
|
|
120
169
|
* above, the shared definition of what a marker is. */
|
|
121
170
|
export function conflictMarkerReason(wt, files) {
|
|
@@ -126,14 +175,14 @@ export function conflictMarkerReason(wt, files) {
|
|
|
126
175
|
* GIT_EDITOR=true so `rebase --continue` can never block on a commit-message prompt. A
|
|
127
176
|
* resolution that leaves no unique content (the branch's change was fully superseded by
|
|
128
177
|
* main) is skipped automatically by git, finishing the rebase cleanly. Throws when the
|
|
129
|
-
* rebase stops again — e.g. on a second conflict from
|
|
130
|
-
*
|
|
178
|
+
* rebase stops again — e.g. on a second conflict from a later commit the resolution run
|
|
179
|
+
* added; the caller aborts and reports merge_conflict. */
|
|
131
180
|
export async function continueRebase(wt) {
|
|
132
181
|
await git(wt, "add", "-A");
|
|
133
182
|
await runGit(wt, [...COMMIT_IDENT, "rebase", "--continue"], { GIT_EDITOR: "true" });
|
|
134
183
|
return headOf(wt, "HEAD");
|
|
135
184
|
}
|
|
136
|
-
/** The live config's bytes, when the
|
|
185
|
+
/** The live config's bytes, when the merge about to fast-forward `ref` would delete it
|
|
137
186
|
* (plans/portability.md §4b/7): the config file exists, is tracked in the current index (and
|
|
138
187
|
* HEAD — a staged-or-dirty config makes `git merge --ff-only` refuse below, so the two agree
|
|
139
188
|
* on every merge that can succeed), and is absent from the incoming tree. Anything else —
|
|
@@ -168,24 +217,48 @@ export function restoreConfigBytes(root, saved) {
|
|
|
168
217
|
* post-rebase HEAD — a bare sha, which both arms accept (`merge --ff-only <sha>` and
|
|
169
218
|
* `push . <sha>:<main>`). Uses a working-tree merge when the primary checkout is on main (so its
|
|
170
219
|
* files update), otherwise a local ref push. Returns true on success. The working-tree arm
|
|
171
|
-
* preserves the live config across a
|
|
172
|
-
* without the write-back, the commit that removes the tracked config would delete the
|
|
220
|
+
* preserves the live config across a merge that untracks it (plans/portability.md §4b/7):
|
|
221
|
+
* without the write-back, the commit that removes the tracked config would delete the team's
|
|
173
222
|
* running config out from under it and the next config poll would fall back to defaults. */
|
|
174
|
-
export async function
|
|
223
|
+
export async function ffMainToOrWhy(root, ref, mainBranch) {
|
|
175
224
|
const primaryBranch = await currentBranch(root);
|
|
176
225
|
if (primaryBranch === mainBranch) {
|
|
177
226
|
// The working-tree arm rewrites the same tracked files the CLI backlog writers read and
|
|
178
227
|
// rewrite, so it shares their primary-worktree lock (paths.ts): without it a `tumwater bug`
|
|
179
|
-
// that read before this merge can rename its stale whole-file write over the
|
|
228
|
+
// that read before this merge can rename its stale whole-file write over the merged change.
|
|
180
229
|
// The ref-push arm below touches no working tree and needs no lock; the save/merge/restore
|
|
181
230
|
// stays one short section, never held across a build re-check.
|
|
182
231
|
return withStateLockAsync(primaryWorktreeLockPath(root), async () => {
|
|
183
232
|
const saved = await configBytesToPreserve(root, ref);
|
|
184
|
-
|
|
185
|
-
|
|
233
|
+
try {
|
|
234
|
+
await git(root, "merge", "--ff-only", ref);
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
return ffFailureReason(root, ref, mainBranch, err);
|
|
238
|
+
}
|
|
186
239
|
restoreConfigBytes(root, saved);
|
|
187
|
-
return
|
|
240
|
+
return null;
|
|
188
241
|
});
|
|
189
242
|
}
|
|
190
|
-
|
|
243
|
+
try {
|
|
244
|
+
await git(root, "push", ".", `${ref}:${mainBranch}`);
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
catch (err) {
|
|
248
|
+
return ffFailureReason(root, ref, mainBranch, err);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/** Why a fast-forward to `ref` failed: main having moved off `ref`'s base (a commit that did not
|
|
252
|
+
* take the merge lock) is named with its new head and subject; any other cause reports git's own
|
|
253
|
+
* error, e.g. a dirty primary checkout. */
|
|
254
|
+
async function ffFailureReason(root, ref, mainBranch, err) {
|
|
255
|
+
if ((await gitTry(root, "merge-base", "--is-ancestor", mainBranch, ref)) === null) {
|
|
256
|
+
const now = await gitTry(root, "log", "-1", "--format=%h %s", mainBranch);
|
|
257
|
+
return `main moved outside the merge lock (now ${now?.trim() ?? mainBranch})`;
|
|
258
|
+
}
|
|
259
|
+
return `fast-forward failed: ${errorMessage(err).trim().split("\n")[0]}`;
|
|
260
|
+
}
|
|
261
|
+
/** Boolean form of ffMainToOrWhy for callers that need no reason. */
|
|
262
|
+
export async function ffMainTo(root, ref, mainBranch) {
|
|
263
|
+
return (await ffMainToOrWhy(root, ref, mainBranch)) === null;
|
|
191
264
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { runOnAbort } from "../process/process.js";
|
|
2
|
+
import { warnEventBestEffort, TUMWATER_SOURCE } from "../events/events.js";
|
|
3
|
+
import { errorMessage } from "../text/text.js";
|
|
4
|
+
import { deleteRef } from "../git/git.js";
|
|
5
|
+
import { removeMergeChange, writeMergeOutcome } from "./merge-slot.js";
|
|
6
|
+
import { mergeRefName, rejectedRefName } from "../paths.js";
|
|
7
|
+
/** A fresh pipeline: nothing in flight anywhere — no verification running, no approved change
|
|
8
|
+
* waiting, no merge holding main. The orchestrator builds one per team start (orchestrator.ts). */
|
|
9
|
+
export function newMergePipeline() {
|
|
10
|
+
return { verifying: new Map(), approved: new Map(), merge: null };
|
|
11
|
+
}
|
|
12
|
+
/** Every merge task in flight — each verification holding its permit, and the merge — for the
|
|
13
|
+
* shutdown and restart waits. A parked verification is not one of them: it has started nothing, and
|
|
14
|
+
* a shutdown (its controller) or a closed start gate (at its grant) settles it without a run. */
|
|
15
|
+
export function mergeTasks(p) {
|
|
16
|
+
return [...[...p.verifying.values()].filter((v) => !v.parked), ...(p.merge ? [p.merge] : [])];
|
|
17
|
+
}
|
|
18
|
+
/** Everything `tumwater abort --role` can reach: every task in flight, plus each approved change
|
|
19
|
+
* waiting with no task of its own. A parked verification is out of reach, as a plainly queued entry
|
|
20
|
+
* always was: its change has not started merging. */
|
|
21
|
+
export function abortableMerges(p) {
|
|
22
|
+
return [...mergeTasks(p), ...p.approved.values()];
|
|
23
|
+
}
|
|
24
|
+
/** Wire harness shutdown to one task's own controller — through the shared register/abort
|
|
25
|
+
* race helper, so an already-fired signal aborts at once. Returns the detach function. The
|
|
26
|
+
* team shutdown signal outlives every merge, so each task that settles while the signal is
|
|
27
|
+
* still live must detach in its own finally; without that, the one long-lived signal
|
|
28
|
+
* accumulates a listener (and the retained controller behind it) for every merge the team
|
|
29
|
+
* ever runs, growing without bound until the shutdown it was waiting for. */
|
|
30
|
+
export function abortOnShutdown(signal, controller) {
|
|
31
|
+
return runOnAbort(signal, () => controller.abort());
|
|
32
|
+
}
|
|
33
|
+
/** Wrap a merge task's stored promise so it can never reject: the tasks are fire-and-forget
|
|
34
|
+
* during normal operation (only the shutdown/restart drain awaits them), and an unexpected
|
|
35
|
+
* throw out of their bookkeeping — an unwritable state file, event feed, or marker while
|
|
36
|
+
* settling an outcome — would otherwise surface as an unhandled rejection and take the
|
|
37
|
+
* orchestrator down. The throw is reported best-effort; the task's own finally has already
|
|
38
|
+
* released its slot and controller, and the surviving queue entry and pin recover on the next
|
|
39
|
+
* poll. */
|
|
40
|
+
export function guardMergeTask(root, role, promise) {
|
|
41
|
+
return promise.catch((err) => {
|
|
42
|
+
warnEventBestEffort(root, TUMWATER_SOURCE, `merge task for ${role} could not settle: ${errorMessage(err)}`);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
/** Take a permit at `tier` (on `key` when given) unless `signal` fires first: resolves to its
|
|
46
|
+
* release, or to null when the signal won — a grant that arrives after that is handed straight
|
|
47
|
+
* back (a hop, never a leak), so a parked verification that a shutdown settles holds nothing. When
|
|
48
|
+
* `key` is set the permit is a keyed lease, so the verification also counts against its provider's
|
|
49
|
+
* cap (plans/provider-concurrency.md §4). */
|
|
50
|
+
export async function acquireUnlessAborted(semaphore, tier, signal, key) {
|
|
51
|
+
if (signal.aborted)
|
|
52
|
+
return null;
|
|
53
|
+
const granted = semaphore.acquireLease(tier, key);
|
|
54
|
+
const stopped = new Promise((resolve) => signal.addEventListener("abort", () => resolve(false), { once: true }));
|
|
55
|
+
if (await Promise.race([granted, stopped])) {
|
|
56
|
+
return granted;
|
|
57
|
+
}
|
|
58
|
+
void granted.then((lease) => lease.release());
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
/** The roles the pipeline holds right now — verifying (parked or not), approved, or merging. */
|
|
62
|
+
export function liveRoles(p) {
|
|
63
|
+
return new Set([...p.verifying.keys(), ...p.approved.keys(), ...(p.merge?.roles ?? [])]);
|
|
64
|
+
}
|
|
65
|
+
/** Discard the pinned merge refs of every role in a deliberately-aborted merge.
|
|
66
|
+
* `tumwater abort --role` throws the committed work away, and the pin is what would otherwise
|
|
67
|
+
* recover it, so the ref must go; a shutdown abort leaves `userAborted` unset and every ref
|
|
68
|
+
* survives for recovery. Shared by every task's settling code so their discard semantics
|
|
69
|
+
* cannot drift. A ref that is already gone is not an error. */
|
|
70
|
+
export async function discardPinnedRefs(root, roles) {
|
|
71
|
+
for (const role of roles) {
|
|
72
|
+
try {
|
|
73
|
+
await deleteRef(root, mergeRefName(role));
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
/* already gone */
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/** Settle a merge queue entry that reached a terminal outcome — the one home of the
|
|
81
|
+
* write-plus-drop pairing every settled merge goes through: the result lands in the role's
|
|
82
|
+
* state and the merge_done/merge_failed event (writeMergeOutcome), then the change's marker
|
|
83
|
+
* record is dropped (removeMergeChange — merge-slot leaves the marker to the caller). Shared by
|
|
84
|
+
* settleAbortedApproved, the verification's final-verdict branch, and the merge's per-change
|
|
85
|
+
* write-back, so no settled entry can keep a stale marker or lose its outcome write. The pinned-ref
|
|
86
|
+
* discard stays with the callers: only a user abort throws work away, and the merge discards its
|
|
87
|
+
* whole stack at once rather than change by change. */
|
|
88
|
+
export async function settleMergeOutcome(root, entry, state, result, durationMs, usage, file) {
|
|
89
|
+
writeMergeOutcome(root, entry, state, result, durationMs, usage, file);
|
|
90
|
+
// A revision that merges ends the rejected change's life: drop the rejected ref (plans/revise-
|
|
91
|
+
// rejected.md). A rejection repoints or deletes it in merge-core.ts, so this is the only
|
|
92
|
+
// ref cleanup a merged revision needs here. Guarded on the entry's revisionRound, not just a
|
|
93
|
+
// `changed` result: a fresh change can merge while a different rejection's revision is still
|
|
94
|
+
// pending, and its merge must leave that pending revision's ref alone.
|
|
95
|
+
if (result === "changed" && entry.revisionRound !== undefined) {
|
|
96
|
+
await deleteRef(root, rejectedRefName(entry.role));
|
|
97
|
+
}
|
|
98
|
+
removeMergeChange(root, entry.role);
|
|
99
|
+
}
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
import { openQuestions } from "../backlog/backlog.js";
|
|
2
2
|
import { logEventBestEffort } from "../events/events.js";
|
|
3
3
|
/** Emit one `question_posted` event per entry QUESTIONS.md's ## Open gained since `before` —
|
|
4
|
-
* the capture-and-diff both merge paths record alongside their `merged` events (
|
|
5
|
-
* tryMerge and
|
|
4
|
+
* the capture-and-diff both merge paths record alongside their `merged` events (merge-to-main.ts's
|
|
5
|
+
* tryMerge and merge-stack.ts's ffStackToMain). The capture (`openQuestions(root)` under the
|
|
6
6
|
* merge lock, before the ff) stays with the callers: the lock window is theirs to define, and the
|
|
7
7
|
* diff is only exact while the capture and this call share it. Best-effort: the questions have
|
|
8
8
|
* already been posted to QUESTIONS.md and main already fast-forwarded, so an unwritable feed
|
|
9
|
-
* must not reject the
|
|
9
|
+
* must not reject the merge around it. `position` is the entry's 1-based place in ## Open (the
|
|
10
|
+
* `<n>` `tumwater questions answer` takes now) and `open` is the list's length, so the page can
|
|
11
|
+
* name the answering command (merge-to-main.ts's tryMerge, merge-stack.ts). */
|
|
10
12
|
export function logNewQuestions(root, before, role) {
|
|
11
|
-
|
|
13
|
+
const titles = openQuestions(root);
|
|
14
|
+
titles.forEach((question, index) => {
|
|
12
15
|
if (!before.includes(question)) {
|
|
13
|
-
logEventBestEffort(root, {
|
|
16
|
+
logEventBestEffort(root, { source: role, type: "question_posted", question, position: index + 1, open: titles.length });
|
|
14
17
|
}
|
|
15
|
-
}
|
|
18
|
+
});
|
|
16
19
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// vocabulary: plain
|
|
2
|
+
/** The renderer behind `tumwater queue` — the operator-facing text for the merge queue view the
|
|
3
|
+
* reader gathers (merge-queue.ts's readMergeQueue, beside it): one line per queued merge
|
|
4
|
+
* in execution order, plus the count of queue files the reader skipped. Pure function of the
|
|
5
|
+
* collected view and a `now` instant — no I/O, no clock reads — so the age it prints can be
|
|
6
|
+
* pinned by a test. */
|
|
7
|
+
import { humanSeconds, secondsSince } from "../text/datetime.js";
|
|
8
|
+
import { roleLabel } from "../roles/role-labels.js";
|
|
9
|
+
import { shortSha } from "../text/format.js";
|
|
10
|
+
import { plural } from "../text/phrases.js";
|
|
11
|
+
import { mergeChanges, MERGE_STAGE_LABELS } from "./merge-slot.js";
|
|
12
|
+
/** The bracketed-label text for one queue entry, in the wording the loop rows use (status-model's
|
|
13
|
+
* loopPhase): `merge <elapsed> · <stage>` while a verify or the merge holds the change,
|
|
14
|
+
* `approved, awaiting merge` once approved, and "" when the marker is absent, names no record for
|
|
15
|
+
* `sha`, or the record is `done`. Reads the marker through mergeChanges, so an older-generation
|
|
16
|
+
* single-change marker counts as one `merge` record. */
|
|
17
|
+
export function queueEntryMergeLabel(sha, inFlight, now) {
|
|
18
|
+
if (!inFlight)
|
|
19
|
+
return "";
|
|
20
|
+
const change = mergeChanges(inFlight).find((c) => c.sha === sha);
|
|
21
|
+
if (!change || change.status === "done")
|
|
22
|
+
return "";
|
|
23
|
+
if (change.status === "approved")
|
|
24
|
+
return "approved, waiting to merge";
|
|
25
|
+
let label = "merging";
|
|
26
|
+
if (change.startedAt)
|
|
27
|
+
label += ` ${humanSeconds(secondsSince(change.startedAt, now))}`;
|
|
28
|
+
if (change.stage)
|
|
29
|
+
label += ` · ${MERGE_STAGE_LABELS[change.stage]}`;
|
|
30
|
+
if (change.stageSince !== undefined)
|
|
31
|
+
label += ` ${humanSeconds(secondsSince(change.stageSince, now))}`;
|
|
32
|
+
return label;
|
|
33
|
+
}
|
|
34
|
+
/** One re-submitted change's revision note (plans/revise-rejected.md): `revision <round>`,
|
|
35
|
+
* plus ` · <n> prior objection(s)` when the change answered any prior review objections
|
|
36
|
+
* (its `priorReview.reasons`). "" for a fresh change with no revision-round field, so a
|
|
37
|
+
* first submission's queue line is byte-identical to before. */
|
|
38
|
+
export function revisionNote(entry) {
|
|
39
|
+
if (entry.revisionRound === undefined)
|
|
40
|
+
return "";
|
|
41
|
+
let note = "revision " + entry.revisionRound;
|
|
42
|
+
const reasons = entry.priorReview?.reasons;
|
|
43
|
+
if (Array.isArray(reasons) && reasons.length > 0)
|
|
44
|
+
note += " · " + plural(reasons.length, "prior objection");
|
|
45
|
+
return note;
|
|
46
|
+
}
|
|
47
|
+
/** Render the queue as the text `tumwater queue` prints: a `merge queue (<depth>):` header then
|
|
48
|
+
* one 1-based line per readable entry, oldest first, carrying the loop, tick number, short
|
|
49
|
+
* pinned sha, age, and summary. An empty queue (no entries and nothing skipped) prints
|
|
50
|
+
* `merge queue empty`; a non-zero skip count appends one `(+N unreadable queue file(s) skipped)`
|
|
51
|
+
* line so a torn or foreign file the reader dropped is visible rather than silent. An entry the
|
|
52
|
+
* optional in-flight marker has a live record for carries ` · [<label>]` after its age
|
|
53
|
+
* (queueEntryMergeLabel). */
|
|
54
|
+
export function renderMergeQueue(view, now, inFlight) {
|
|
55
|
+
const depth = view.entries.length + view.unreadable;
|
|
56
|
+
const lines = [];
|
|
57
|
+
if (depth === 0) {
|
|
58
|
+
lines.push("merge queue empty");
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
lines.push(`merge queue (${depth}):`);
|
|
62
|
+
view.entries.forEach((e, i) => {
|
|
63
|
+
const age = humanSeconds(secondsSince(e.enqueuedAt, now));
|
|
64
|
+
const label = queueEntryMergeLabel(e.sha, inFlight, now);
|
|
65
|
+
const tag = label ? ` · [${label}]` : "";
|
|
66
|
+
const note = revisionNote(e);
|
|
67
|
+
const noteTag = note ? ` · ${note}` : "";
|
|
68
|
+
lines.push(` ${i + 1}. ${roleLabel(e.role)} run #${e.run} · ${shortSha(e.sha)} · ${age} ago${tag}${noteTag} · ${e.summary}`);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
if (view.unreadable > 0) {
|
|
72
|
+
lines.push(` (+${plural(view.unreadable, "unreadable queue file")} skipped)`);
|
|
73
|
+
}
|
|
74
|
+
return lines.join("\n");
|
|
75
|
+
}
|
|
@@ -1,41 +1,91 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import { listQueueFiles, queueFileName, removeQueueFile } from "../files/file-queue.js";
|
|
3
4
|
import { isJsonObject, nonNegativeNumber } from "../files/json-object.js";
|
|
4
5
|
import { readJsonFile, writeJsonAtomic } from "../files/json-files.js";
|
|
5
6
|
import { cachedByStat } from "../files/stat-cache.js";
|
|
6
|
-
import {
|
|
7
|
+
import { mergeQueueDir, legacyLandQueueDir } from "../paths.js";
|
|
8
|
+
import { canonicalRoleId } from "../roles/legacy-role-ids.js";
|
|
7
9
|
let seq = 0;
|
|
8
|
-
/** Append one
|
|
10
|
+
/** Append one merge to the queue as a single timestamped file (creating the queue dir if
|
|
9
11
|
* needed). The filename orders entries across processes by wall-clock time; the per-process
|
|
10
12
|
* counter and pid break ties within one process. Written atomically (tmp + rename): the
|
|
11
13
|
* enqueue runs while the process can be hard-killed mid-write (the tick deadline's
|
|
12
14
|
* process-group kill, a shutdown), and a torn entry the crash left behind is not always
|
|
13
15
|
* recoverable — the drain repairs only an unreadable HEAD (staleHeadFile), so a torn file
|
|
14
16
|
* that sorts after a healthy entry would sit in the queue dir unread forever. */
|
|
15
|
-
export function
|
|
16
|
-
const dir =
|
|
17
|
+
export function enqueueMerge(root, entry) {
|
|
18
|
+
const dir = mergeQueueDir(root);
|
|
17
19
|
const name = queueFileName(entry.enqueuedAt, seq++, ".json");
|
|
18
20
|
writeJsonAtomic(path.join(dir, name), entry);
|
|
19
21
|
}
|
|
20
|
-
/**
|
|
21
|
-
*
|
|
22
|
+
/** The revision fields of a queue entry, serialized only when present — the shape merge
|
|
23
|
+
* surfaces (drain, verify) and the tick-stage enqueue re-emit when they build their own
|
|
24
|
+
* entry/request objects, so the `revisionRound`/`priorReview` names live in one place and
|
|
25
|
+
* cannot drift. */
|
|
26
|
+
export function entryRevisionFields(e) {
|
|
27
|
+
return {
|
|
28
|
+
...(e.revisionRound !== undefined ? { revisionRound: e.revisionRound } : {}),
|
|
29
|
+
...(e.priorReview !== undefined ? { priorReview: e.priorReview } : {}),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/** Every queue file in execution order (oldest first) — the same filename sort queuedMerges,
|
|
33
|
+
* headMerge, and the orchestrator's per-poll queued-roles set read. A missing queue dir
|
|
22
34
|
* reads as an empty queue. */
|
|
23
35
|
function queueFiles(root) {
|
|
24
|
-
|
|
36
|
+
// Files an old build enqueued into the legacy directory still count until the startup
|
|
37
|
+
// migration moves them; the sort is by basename because the queue order lives in the name.
|
|
38
|
+
const all = [...listQueueFiles(mergeQueueDir(root), ".json"), ...listQueueFiles(legacyLandQueueDir(root), ".json")];
|
|
39
|
+
return all.sort((a, b) => path.basename(a).localeCompare(path.basename(b)));
|
|
40
|
+
}
|
|
41
|
+
/** The startup migration step: move every `*.json` from the legacy queue directory into the
|
|
42
|
+
* current one, keeping the basename (the order lives in it) and the contents. A basename already
|
|
43
|
+
* present in the new directory stays put and is noted; the old directory is removed once empty.
|
|
44
|
+
* A file that vanished mid-move (a concurrent run) is skipped. */
|
|
45
|
+
export function legacyMigrateLandQueueDir(root) {
|
|
46
|
+
const from = legacyLandQueueDir(root);
|
|
47
|
+
const to = mergeQueueDir(root);
|
|
48
|
+
const notes = [];
|
|
49
|
+
const files = listQueueFiles(from, ".json");
|
|
50
|
+
if (files.length > 0)
|
|
51
|
+
fs.mkdirSync(to, { recursive: true });
|
|
52
|
+
for (const file of files) {
|
|
53
|
+
const name = path.basename(file);
|
|
54
|
+
const dest = path.join(to, name);
|
|
55
|
+
if (fs.existsSync(dest)) {
|
|
56
|
+
notes.push(`${name} already in merge-queue, left in place`);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
fs.renameSync(file, dest);
|
|
61
|
+
notes.push(`moved ${name}`);
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
if (err.code !== "ENOENT")
|
|
65
|
+
throw err;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
fs.rmdirSync(from);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// Missing, or still holding a file left in place or placed since: nothing to remove.
|
|
73
|
+
}
|
|
74
|
+
return notes;
|
|
25
75
|
}
|
|
26
|
-
/** Number of
|
|
76
|
+
/** Number of merges currently queued — a directory listing only; no file content is read.
|
|
27
77
|
* A missing queue dir reads as 0, like every other reader here. */
|
|
28
78
|
export function queueDepth(root) {
|
|
29
79
|
return queueFiles(root).length;
|
|
30
80
|
}
|
|
31
81
|
// Per-poll entry-content cache (stat-cache.cachedByStat): dashboard polls must not re-parse
|
|
32
|
-
// every queued entry — each file is written once by
|
|
82
|
+
// every queued entry — each file is written once by enqueueMerge and only deleted on drop,
|
|
33
83
|
// the same write-once discipline the inbox caches for prompts.
|
|
34
84
|
const entryCache = new Map();
|
|
35
|
-
/** True when `v` carries every field a
|
|
85
|
+
/** True when `v` carries every field a MergeEntry requires, with the right type. The merge
|
|
36
86
|
* queue is one JSON object per file, so a file that parses to anything else — a scalar, an
|
|
37
87
|
* array, an object missing a required field — is foreign or torn and reads as no entry.
|
|
38
|
-
* Checking only `role` and `sha` left the `as
|
|
88
|
+
* Checking only `role` and `sha` left the `as MergeEntry` cast unsound: a foreign file that
|
|
39
89
|
* happened to carry those two fields was handed downstream with `tick` and `summary`
|
|
40
90
|
* undefined, which surface as bogus session names (`tumwater-<role>-undefined-review`) and
|
|
41
91
|
* `undefined` in the reviewer's prompt. Optional fields are checked only when present.
|
|
@@ -49,15 +99,16 @@ function isPriorReview(v) {
|
|
|
49
99
|
return false;
|
|
50
100
|
return (typeof v.sha === "string" &&
|
|
51
101
|
Array.isArray(v.reasons) &&
|
|
52
|
-
v.reasons.every((r) => typeof r === "string")
|
|
102
|
+
v.reasons.every((r) => typeof r === "string") &&
|
|
103
|
+
(v.counts === undefined || (Array.isArray(v.counts) && v.counts.every((c) => nonNegativeNumber(c, null) !== null))));
|
|
53
104
|
}
|
|
54
|
-
function
|
|
105
|
+
function isMergeEntry(v) {
|
|
55
106
|
if (!isJsonObject(v))
|
|
56
107
|
return false;
|
|
57
108
|
const e = v;
|
|
58
109
|
return (typeof e.role === "string" &&
|
|
59
110
|
typeof e.sha === "string" &&
|
|
60
|
-
nonNegativeNumber(e.
|
|
111
|
+
nonNegativeNumber(e.run, null) !== null &&
|
|
61
112
|
typeof e.summary === "string" &&
|
|
62
113
|
nonNegativeNumber(e.enqueuedAt, null) !== null &&
|
|
63
114
|
(e.body === undefined || typeof e.body === "string") &&
|
|
@@ -67,22 +118,30 @@ function isLandingEntry(v) {
|
|
|
67
118
|
}
|
|
68
119
|
/** Read one queue file through the stat-keyed cache: null when it vanishes mid-listing (a
|
|
69
120
|
* concurrent drop — the inbox.ts race policy), is unreadable, or fails to parse or to carry
|
|
70
|
-
* every required
|
|
121
|
+
* every required MergeEntry field (a foreign or torn file is skipped, never thrown on). A
|
|
71
122
|
* shallow copy out: the entry's fields are all scalars and callers treat the result as
|
|
72
123
|
* read-only. */
|
|
73
124
|
function readEntry(file) {
|
|
74
125
|
return cachedByStat(entryCache, file, file, () => {
|
|
75
126
|
// readJsonFile is the shared "missing or torn reads as no data" policy; a foreign shape
|
|
76
|
-
// is still rejected by
|
|
77
|
-
|
|
78
|
-
|
|
127
|
+
// is still rejected by isMergeEntry.
|
|
128
|
+
let parsed = readJsonFile(file);
|
|
129
|
+
// An entry an old build wrote carries `tick` where the current shape has `run`.
|
|
130
|
+
if (isJsonObject(parsed) && parsed.run === undefined && parsed.tick !== undefined) {
|
|
131
|
+
const { tick, ...rest } = parsed;
|
|
132
|
+
parsed = { ...rest, run: tick };
|
|
133
|
+
}
|
|
134
|
+
if (!isMergeEntry(parsed))
|
|
135
|
+
return null;
|
|
136
|
+
const role = canonicalRoleId(parsed.role);
|
|
137
|
+
return role === parsed.role ? parsed : { ...parsed, role };
|
|
79
138
|
}, (e) => ({ ...e }));
|
|
80
139
|
}
|
|
81
|
-
/** Every queued
|
|
82
|
-
* completion. The batch drain (merge queue 5/5) reads this slice because `
|
|
140
|
+
/** Every queued merge in execution order (oldest first) paired with the file to drop on
|
|
141
|
+
* completion. The batch drain (merge queue 5/5) reads this slice because `dropMerge` needs
|
|
83
142
|
* the file, which the bare-entry readers never return. A missing queue directory reads as an
|
|
84
143
|
* empty queue. */
|
|
85
|
-
export function
|
|
144
|
+
export function queuedMergeFiles(root) {
|
|
86
145
|
const out = [];
|
|
87
146
|
for (const file of queueFiles(root)) {
|
|
88
147
|
const entry = readEntry(file);
|
|
@@ -91,21 +150,21 @@ export function queuedLandingFiles(root) {
|
|
|
91
150
|
}
|
|
92
151
|
return out;
|
|
93
152
|
}
|
|
94
|
-
/** All queued
|
|
153
|
+
/** All queued merges in execution order (oldest first). A missing queue directory reads as
|
|
95
154
|
* an empty queue. */
|
|
96
|
-
export function
|
|
97
|
-
return
|
|
155
|
+
export function queuedMerges(root) {
|
|
156
|
+
return queuedMergeFiles(root).map((q) => q.entry);
|
|
98
157
|
}
|
|
99
|
-
/** The roles currently holding a queued
|
|
158
|
+
/** The roles currently holding a queued merge — the maintenance quota's and the permit
|
|
100
159
|
* reserve's "already in flight" input, and the scheduler's per-poll merge-queue interlock.
|
|
101
160
|
* A missing queue directory reads as an empty set, like every reader here. */
|
|
102
|
-
export function
|
|
103
|
-
return new Set(
|
|
161
|
+
export function queuedMergeRoles(root) {
|
|
162
|
+
return new Set(queuedMergeFiles(root).map((q) => q.entry.role));
|
|
104
163
|
}
|
|
105
164
|
/** Read every queue file once through the stat-keyed cache, collecting the readable entries
|
|
106
165
|
* (filename order) and counting the rest — the torn/foreign file policy readEntry already
|
|
107
166
|
* applies, surfacing the skip count instead of hiding it. */
|
|
108
|
-
export function
|
|
167
|
+
export function readMergeQueue(root) {
|
|
109
168
|
const entries = [];
|
|
110
169
|
let unreadable = 0;
|
|
111
170
|
for (const file of queueFiles(root)) {
|
|
@@ -119,7 +178,7 @@ export function readLandingQueue(root) {
|
|
|
119
178
|
}
|
|
120
179
|
/** The head of the queue — its oldest entry — plus the file to drop on completion, or null when
|
|
121
180
|
* the queue is empty or its oldest file is a vanished/torn entry. */
|
|
122
|
-
export function
|
|
181
|
+
export function headMerge(root) {
|
|
123
182
|
const file = queueFiles(root)[0];
|
|
124
183
|
if (!file)
|
|
125
184
|
return null;
|
|
@@ -127,23 +186,23 @@ export function headLanding(root) {
|
|
|
127
186
|
return entry ? { entry, file } : null;
|
|
128
187
|
}
|
|
129
188
|
/** The oldest queue file when the queue is non-empty but its head is unreadable — torn or
|
|
130
|
-
* foreign (a partial write an older build left, a hand-placed file) — i.e. when
|
|
189
|
+
* foreign (a partial write an older build left, a hand-placed file) — i.e. when headMerge
|
|
131
190
|
* reads null while the queue holds files; null when the queue is empty or its head is healthy.
|
|
132
|
-
* The drain drops this file with a warning so the queue can drain:
|
|
191
|
+
* The drain drops this file with a warning so the queue can drain: headMerge reads null for
|
|
133
192
|
* it forever and nothing else will remove it, stranding every live entry behind it (each
|
|
134
193
|
* author's interlock, the per-poll queued-roles set, pins its ticks along with them). The
|
|
135
|
-
* crashed entry's commit, if any, still lives in its
|
|
136
|
-
* re-
|
|
194
|
+
* crashed entry's commit, if any, still lives in its merge ref — next-tick leftover recovery
|
|
195
|
+
* re-merges it — so the drop loses nothing recoverable from the file (BUGS.md 2026-09-16). */
|
|
137
196
|
export function staleHeadFile(root) {
|
|
138
197
|
const file = queueFiles(root)[0];
|
|
139
198
|
if (!file)
|
|
140
199
|
return null;
|
|
141
200
|
return readEntry(file) === null ? file : null;
|
|
142
201
|
}
|
|
143
|
-
/** Remove one queued
|
|
144
|
-
* non-terminal outcome keeps the
|
|
202
|
+
/** Remove one queued merge's file after its merge outcome — terminal or not (a
|
|
203
|
+
* non-terminal outcome keeps the merge ref; the retry is the role's next fresh tick, not a
|
|
145
204
|
* queue re-drain). ENOENT is a no-op: a concurrent drop between listing and removal is a
|
|
146
205
|
* normal race (the inbox.ts policy), not an error. */
|
|
147
|
-
export function
|
|
206
|
+
export function dropMerge(file) {
|
|
148
207
|
removeQueueFile(file);
|
|
149
208
|
}
|