tumwater 0.1.3 → 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 +63 -28
- 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-eligibility.js +332 -43
- package/dist/src/backlog/backlog-md.js +79 -17
- 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 +178 -441
- 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 +32 -30
- package/dist/src/{brief-edit.js → brief/brief-edit.js} +12 -11
- package/dist/src/{brief.js → brief/brief.js} +33 -8
- 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 +13 -11
- package/dist/src/change/change-render.js +11 -10
- package/dist/src/cli/brief-command.js +9 -7
- package/dist/src/cli/cli-args.js +25 -5
- package/dist/src/cli/cli-command-args.js +43 -10
- package/dist/src/cli/cli-editor.js +13 -1
- package/dist/src/cli/cli-flag-specs.js +31 -6
- package/dist/src/cli/cli-marker-commands.js +13 -10
- package/dist/src/cli/cli-query-commands.js +439 -56
- package/dist/src/cli/cli-repo-gate.js +1 -1
- package/dist/src/cli/cli-run.js +124 -68
- package/dist/src/cli/completion.js +70 -12
- package/dist/src/cli/config-commands.js +14 -11
- package/dist/src/cli/help.js +264 -124
- 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 +13 -224
- package/dist/src/cli.js +78 -36
- 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 +107 -325
- 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 +494 -68
- package/dist/src/doctor/doctor-checks.js +509 -58
- package/dist/src/doctor/doctor-holds.js +139 -78
- 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 +2 -3
- package/dist/src/doctor/doctor-orphans.js +10 -9
- package/dist/src/doctor/doctor-render.js +36 -9
- package/dist/src/doctor/doctor.js +76 -25
- package/dist/src/doctor/image-probe.js +244 -0
- package/dist/src/doctor/probe.js +64 -0
- package/dist/src/events/event-format.js +219 -106
- 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 +80 -30
- 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 +6 -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 +79 -59
- 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 -716
- package/dist/src/gui/gui-endpoint-common.js +49 -0
- package/dist/src/gui/gui-endpoints.js +105 -54
- 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 +25 -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 +179 -104
- package/dist/src/history/history-render.js +76 -0
- package/dist/src/history/history.js +29 -70
- package/dist/src/inbox/inbox-attachments.js +66 -10
- 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 +114 -73
- 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 +104 -50
- package/dist/src/loop/loop-recovery.js +15 -15
- package/dist/src/loop/loop-state.js +130 -13
- package/dist/src/loop/loop-tick.js +190 -88
- package/dist/src/loop/loop.js +68 -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 -20
- 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 +113 -45
- package/dist/src/operator/operator-intent.js +74 -53
- package/dist/src/operator/operator-requests.js +52 -52
- package/dist/src/operator/retire.js +47 -28
- package/dist/src/operator/revert.js +71 -0
- package/dist/src/orchestrator/orchestrator-launch.js +74 -14
- package/dist/src/orchestrator/orchestrator-scheduling.js +74 -43
- package/dist/src/orchestrator/orchestrator.js +149 -96
- 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 +124 -38
- 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 +4 -3
- 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 +36 -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 +29 -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 +259 -254
- package/dist/src/report/report-fold.js +353 -0
- package/dist/src/report/report-render.js +235 -44
- package/dist/src/request-timeouts.js +1 -1
- package/dist/src/review/known-flakes.js +34 -14
- package/dist/src/review/protected-edits.js +4 -3
- 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 +55 -34
- 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 +109 -131
- 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 +16 -14
- package/dist/src/scheduling/backoff.js +29 -19
- package/dist/src/scheduling/claims.js +15 -8
- package/dist/src/scheduling/once-round.js +11 -11
- package/dist/src/scheduling/quiet-hours.js +25 -32
- package/dist/src/scheduling/scheduling.js +28 -25
- 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 +72 -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 +50 -39
- 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 +84 -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} +39 -38
- 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 +142 -25
- 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 +23 -22
- package/dist/src/tick/tick-detail.js +37 -24
- 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 +67 -53
- 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 +75 -10
- package/dist/src/ui/badges.js +113 -69
- package/dist/src/ui/gui/gui-client-boot.js +202 -29
- 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 +94 -37
- package/dist/src/ui/gui/gui-client-labels.js +24 -0
- package/dist/src/ui/gui/gui-client-loops.js +90 -47
- package/dist/src/ui/gui/gui-client-markdown.js +1 -0
- package/dist/src/ui/gui/gui-client-model.js +60 -43
- package/dist/src/ui/gui/gui-client-operator.js +73 -43
- package/dist/src/ui/gui/gui-client-pending.js +34 -31
- package/dist/src/ui/gui/gui-client-report.js +330 -47
- package/dist/src/ui/gui/gui-client-settings.js +126 -18
- package/dist/src/ui/gui/gui-client-sound.js +2 -1
- package/dist/src/ui/gui/{gui-client-fleet.js → gui-client-team.js} +190 -74
- package/dist/src/ui/gui/gui-client.js +26 -20
- package/dist/src/ui/gui/gui-page.js +18 -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 +176 -102
- package/dist/src/ui/status-payload.js +114 -36
- package/dist/src/ui/status-render.js +121 -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 +9 -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 +61 -57
- package/dist/src/ui/tui/tui-history.js +12 -7
- package/dist/src/ui/tui/tui-input.js +44 -50
- package/dist/src/ui/tui/tui-keymap.js +1 -0
- package/dist/src/ui/tui/tui-keys.js +87 -1821
- 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 +52 -28
- package/dist/src/ui/tui/tui-panes.js +294 -0
- package/dist/src/ui/tui/tui.js +44 -26
- package/dist/src/verdict/fix-claim.js +56 -33
- package/dist/src/verdict/no-change.js +19 -0
- package/dist/src/verdict/refusal.js +43 -11
- 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 -56
- package/dist/src/landing/landing-slot.js +0 -256
- 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
package/dist/src/git/git.js
CHANGED
|
@@ -38,38 +38,30 @@ export function readBranchHead(root, branch) {
|
|
|
38
38
|
catch {
|
|
39
39
|
return null; // Neither store has it: the branch does not exist (or no repo state).
|
|
40
40
|
}
|
|
41
|
-
|
|
42
|
-
for (const line of packed.split("\n")) {
|
|
43
|
-
if (!line.endsWith(suffix))
|
|
44
|
-
continue; // Skips comments, peel lines, and other refs.
|
|
45
|
-
const sha = line.slice(0, -suffix.length);
|
|
46
|
-
if (isSha(sha))
|
|
47
|
-
return sha;
|
|
48
|
-
}
|
|
49
|
-
return null;
|
|
41
|
+
return shaFromPackedRefs(packed, ` refs/heads/${branch}`) ?? null;
|
|
50
42
|
}
|
|
51
43
|
/** Resolve a branch's head without paying for a spawn when the ref files suffice:
|
|
52
44
|
* readBranchHead (microsecond-scale) first, then `git rev-parse` as the fallback that covers
|
|
53
45
|
* what file reads cannot (a worktree-pointer .git, anything unusual). Returns null when
|
|
54
46
|
* neither resolves it. Shared by the orchestrator's per-poll main watch, each tick's
|
|
55
|
-
* end-of-tick head update, the red-main baseline check, and the stack
|
|
47
|
+
* end-of-tick head update, the red-main baseline check, and the stack merger, so the
|
|
56
48
|
* file-first/spawn-fallback strategy lives in one place. */
|
|
57
49
|
export async function branchHead(root, branch) {
|
|
58
50
|
return readBranchHead(root, branch) ?? (await gitTry(root, "rev-parse", branch));
|
|
59
51
|
}
|
|
60
52
|
// ── Ref reads from files ───────────────────────────────────────────────────────────────────
|
|
61
53
|
// The read-only probes below (isGitRepo, hasCommits, headOf, refSha, branchExists) each used to
|
|
62
|
-
// spawn a `git rev-parse`. The harness asks them constantly: a
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
54
|
+
// spawn a `git rev-parse`. The harness asks them constantly: a merge alone re-reads HEAD a dozen
|
|
55
|
+
// times, and the test suite, which process creation bounds (DEVELOPMENT.md), spawned ~8,000 of them
|
|
56
|
+
// a run. Each probe now answers from the repository's files when the files settle it POSITIVELY: a
|
|
57
|
+
// resolvable git dir, or a ref whose loose file or packed-refs line names a SHA. Everything else
|
|
58
|
+
// spawns git exactly as before: a miss, a symref other than HEAD, a name git's DWIM rules could
|
|
59
|
+
// read another way, a reftable repo, an environment that redirects git (GIT_DIR and friends), or a
|
|
60
|
+
// repository another user owns, which git itself refuses (safe.directory). A hit is the SHA
|
|
61
|
+
// `rev-parse --verify` prints. It resolves the name and does not check that the object exists, and
|
|
62
|
+
// neither does this. The one negative answered from files is a plain full `refs/…` name with no
|
|
63
|
+
// loose file and no packed-refs line (refAbsentFromFiles): in a files-backend repository that is
|
|
64
|
+
// everywhere git looks.
|
|
73
65
|
/** Environment variables that make git look somewhere other than the files `dir` holds. While
|
|
74
66
|
* any is set, every probe spawns git. */
|
|
75
67
|
const GIT_REDIRECT_ENV = ["GIT_DIR", "GIT_COMMON_DIR", "GIT_WORK_TREE", "GIT_NAMESPACE"];
|
|
@@ -120,6 +112,24 @@ function isPlainRefName(ref) {
|
|
|
120
112
|
return false;
|
|
121
113
|
return ref.split("/").every((part) => part !== "" && !part.startsWith(".") && !part.endsWith(".lock"));
|
|
122
114
|
}
|
|
115
|
+
/** The SHA a packed-refs line whose refname ends with `suffix` (e.g. " refs/heads/main")
|
|
116
|
+
* names, else null when no line matches. undefined when a line matches the refname but holds
|
|
117
|
+
* a non-SHA — the "the files say something git would read differently" signal storedRef's
|
|
118
|
+
* callers distinguish from a plain miss. Both packed-refs readers (readBranchHead and
|
|
119
|
+
* storedRef) scan the file this way: git writes every packed-refs line as ``<sha> <refname>``
|
|
120
|
+
* (or ``<sha> <refname>^{}`` peel lines), so skipping lines that do not end in the suffix and
|
|
121
|
+
* slicing that suffix off the first match is the whole format. */
|
|
122
|
+
function shaFromPackedRefs(packed, suffix) {
|
|
123
|
+
for (const line of packed.split("\n")) {
|
|
124
|
+
if (!line.endsWith(suffix))
|
|
125
|
+
continue; // Skips the header, comments, peel lines and other refs.
|
|
126
|
+
const sha = line.slice(0, -suffix.length);
|
|
127
|
+
if (isSha(sha))
|
|
128
|
+
return sha;
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
123
133
|
/** The SHA a full ref name holds in `commondir`: its loose file (which wins in git too), else
|
|
124
134
|
* its packed-refs line. null when neither has it, and undefined when the files hold something
|
|
125
135
|
* git would read differently (a symref, unreadable content). */
|
|
@@ -139,14 +149,7 @@ function storedRef(commondir, ref) {
|
|
|
139
149
|
catch (err) {
|
|
140
150
|
return err.code === "ENOENT" ? null : undefined;
|
|
141
151
|
}
|
|
142
|
-
|
|
143
|
-
for (const line of packed.split("\n")) {
|
|
144
|
-
if (!line.endsWith(suffix))
|
|
145
|
-
continue; // Skips the header, peel lines and other refs.
|
|
146
|
-
const sha = line.slice(0, -suffix.length);
|
|
147
|
-
return isSha(sha) ? sha : undefined;
|
|
148
|
-
}
|
|
149
|
-
return null;
|
|
152
|
+
return shaFromPackedRefs(packed, ` ${ref}`);
|
|
150
153
|
}
|
|
151
154
|
/** The SHA `git rev-parse --verify <ref>` would print in `dir`, read from files, or undefined
|
|
152
155
|
* when the files do not settle it (see the section comment): the caller then spawns git. `ref`
|
|
@@ -232,13 +235,24 @@ export async function hasCommits(root) {
|
|
|
232
235
|
export async function headOf(cwd, ref) {
|
|
233
236
|
return refShaFromFiles(cwd, ref) ?? git(cwd, "rev-parse", "--verify", ref);
|
|
234
237
|
}
|
|
235
|
-
/** Point `ref` at `sha`, creating the ref if needed (git update-ref). The
|
|
238
|
+
/** Point `ref` at `sha`, creating the ref if needed (git update-ref). The merge pin of
|
|
236
239
|
* plans/merge-queue.md invariant 4 uses this to keep a committed sha reachable across the role
|
|
237
|
-
* branch's reset-to-main. Returns false instead of throwing when git fails — the caller then
|
|
238
|
-
*
|
|
240
|
+
* branch's reset-to-main. Returns false instead of throwing when git fails — the caller then keeps
|
|
241
|
+
* the commit on its branch (no reset) so leftover recovery can still reach it. */
|
|
239
242
|
export async function setRef(root, ref, sha) {
|
|
240
243
|
return (await gitTry(root, "update-ref", ref, sha)) !== null;
|
|
241
244
|
}
|
|
245
|
+
/** Every ref under `prefix` with the sha it names (git for-each-ref). Empty when none match. */
|
|
246
|
+
export async function refsUnder(root, prefix) {
|
|
247
|
+
return gitLines(await gitTry(root, "for-each-ref", "--format=%(refname) %(objectname)", prefix)).map((line) => {
|
|
248
|
+
const [ref, sha] = line.split(" ");
|
|
249
|
+
return { ref, sha };
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
/** Delete `ref` only while it still names `sha` (git update-ref -d guarded by the old value). */
|
|
253
|
+
export async function deleteRefIfAt(root, ref, sha) {
|
|
254
|
+
await gitTry(root, "update-ref", "-d", ref, sha);
|
|
255
|
+
}
|
|
242
256
|
/** Delete `ref`; a no-op when it is already gone (idempotent cleanup on terminal outcomes). */
|
|
243
257
|
export async function deleteRef(root, ref) {
|
|
244
258
|
await gitTry(root, "update-ref", "-d", ref);
|
|
@@ -253,7 +267,7 @@ export async function refSha(root, ref) {
|
|
|
253
267
|
return gitTry(root, "rev-parse", "--verify", ref);
|
|
254
268
|
}
|
|
255
269
|
/** True when `sha` is already contained in `branch` (git merge-base --is-ancestor; equality counts
|
|
256
|
-
* as contained). The leftover-recovery entry condition uses this to tell a stale pin —
|
|
270
|
+
* as contained). The leftover-recovery entry condition uses this to tell a stale pin — merged but
|
|
257
271
|
* not yet un-pinned by a crash between the ff and the ref deletion — from real work. */
|
|
258
272
|
export async function isMergedInto(root, sha, branch) {
|
|
259
273
|
return (await gitTry(root, "merge-base", "--is-ancestor", sha, branch)) !== null;
|
|
@@ -304,13 +318,12 @@ export async function branchExists(root, branch) {
|
|
|
304
318
|
return false;
|
|
305
319
|
return (await gitTry(root, "rev-parse", "--verify", ref)) !== null;
|
|
306
320
|
}
|
|
307
|
-
/** The revision a worktree's change should be judged against: the merge-base of its HEAD
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
* Open→Fixed restoration compare as already done (BUGS.md 2026-09-23). `rev` is the commit
|
|
321
|
+
/** The revision a worktree's change should be judged against: the merge-base of its HEAD with
|
|
322
|
+
* `mainBranch`, falling back to main's tip when the merge-base fails (no common ancestor, or no
|
|
323
|
+
* commits yet). The one home of the "diff against the merge-base, not main's tip" policy shared by
|
|
324
|
+
* falseFixReason's BUGS.md base read and backlogStructureReason's per-file base reads — both judge
|
|
325
|
+
* a backlog edit against the tree the change builds on, so a stacked batch's earlier merge cannot
|
|
326
|
+
* make an Open→Fixed restoration compare as already done (BUGS.md 2026-09-23). `rev` is the commit
|
|
314
327
|
* whose merge-base with main is wanted; it defaults to the worktree's HEAD, and the rejected
|
|
315
328
|
* change's revision interdiff asks for its own prior commit instead (git-diff.ts). */
|
|
316
329
|
export async function changeBaseRev(wt, mainBranch, rev = "HEAD") {
|
|
@@ -395,7 +408,7 @@ export async function currentBranch(root) {
|
|
|
395
408
|
const fromFile = currentBranchFromHeadFile(root);
|
|
396
409
|
return fromFile !== undefined ? fromFile : gitTry(root, "symbolic-ref", "--short", "HEAD");
|
|
397
410
|
}
|
|
398
|
-
/** The branch a read-only surface treats as the
|
|
411
|
+
/** The branch a read-only surface treats as the team's main: a configured `baseBranch` wins (`run
|
|
399
412
|
* --branch` is per-invocation and invisible to a later query), else whatever the primary checkout
|
|
400
413
|
* has checked out, else the literal "main". change/change-data.ts's collectRoleChange (async, via
|
|
401
414
|
* currentBranch) and status-polls's mainCheckForPoll (sync, via currentBranchFromHeadFile — a
|
|
@@ -412,12 +425,12 @@ export async function isDirty(cwd) {
|
|
|
412
425
|
const out = await git(cwd, "status", "--porcelain");
|
|
413
426
|
return out.length > 0;
|
|
414
427
|
}
|
|
415
|
-
/** Stage ONLY the given repo-relative paths, commit them with `message`, then discard every
|
|
416
|
-
*
|
|
417
|
-
*
|
|
418
|
-
*
|
|
419
|
-
*
|
|
420
|
-
*
|
|
428
|
+
/** Stage ONLY the given repo-relative paths, commit them with `message`, then discard every other
|
|
429
|
+
* change in the worktree — tracked edits via reset --hard HEAD (the committed content is safe at
|
|
430
|
+
* HEAD by the time the reset runs) and untracked files via clean -fd. Returns the new commit hash,
|
|
431
|
+
* or null when nothing was stageable under those paths (the caller then decides what to do with the
|
|
432
|
+
* rest). The refusal path uses this so a refusing run's objection note is kept while its half-done
|
|
433
|
+
* code work is not. */
|
|
421
434
|
export async function commitPathsAndDiscardRest(wt, message, paths) {
|
|
422
435
|
if (paths.length === 0)
|
|
423
436
|
return null;
|
|
@@ -432,10 +445,10 @@ export async function commitPathsAndDiscardRest(wt, message, paths) {
|
|
|
432
445
|
await git(wt, "clean", "-fd");
|
|
433
446
|
return headOf(wt, "HEAD");
|
|
434
447
|
}
|
|
435
|
-
/** Commit subjects
|
|
436
|
-
*
|
|
437
|
-
*
|
|
438
|
-
*
|
|
448
|
+
/** Commit subjects merged on `mainBranch` since `sinceHead`, newest first — the range is exclusive
|
|
449
|
+
* of its base, so a head that is an ancestor of main yields exactly what moved past it (empty when
|
|
450
|
+
* nothing has). Null when the range cannot be resolved (unknown head, not a repo), so callers fall
|
|
451
|
+
* back conservatively. */
|
|
439
452
|
export async function subjectsBetween(root, sinceHead, mainBranch) {
|
|
440
453
|
const out = await gitTry(root, "log", "--format=%s", `${sinceHead}..${mainBranch}`);
|
|
441
454
|
if (out === null)
|
|
@@ -446,10 +459,17 @@ export async function subjectsBetween(root, sinceHead, mainBranch) {
|
|
|
446
459
|
export async function commitMessage(cwd, sha) {
|
|
447
460
|
return gitTry(cwd, "log", "-1", "--format=%B", sha);
|
|
448
461
|
}
|
|
462
|
+
/** The number of commits in a `A..B` range: `git rev-list --count <range>` parsed as an
|
|
463
|
+
* integer. The one home of the count idiom, so every surface that measures how far one ref
|
|
464
|
+
* is ahead of another (a branch over main, main over a build) reads identically instead of
|
|
465
|
+
* re-spelling the git call. Throws through `git` on failure; callers that must tolerate an
|
|
466
|
+
* unknown ref use `gitTry` directly (as build-info's buildStaleness does for its sentinel). */
|
|
467
|
+
export async function countCommitsInRange(cwd, range) {
|
|
468
|
+
return Number.parseInt(await git(cwd, "rev-list", "--count", range), 10);
|
|
469
|
+
}
|
|
449
470
|
/** Commits ahead of main on the worktree's branch. */
|
|
450
471
|
export async function aheadOfMain(wt, mainBranch) {
|
|
451
|
-
|
|
452
|
-
return Number.parseInt(out, 10);
|
|
472
|
+
return countCommitsInRange(wt, `${mainBranch}..HEAD`);
|
|
453
473
|
}
|
|
454
474
|
/** The patch-id of the change `head` makes on top of `base`: `git diff` over `base...head` (from
|
|
455
475
|
* their merge-base, the same range aheadOfMainDiff hands the reviewer) piped into
|
|
@@ -464,8 +484,8 @@ export async function aheadOfMain(wt, mainBranch) {
|
|
|
464
484
|
* as "no match", never an error. */
|
|
465
485
|
export async function patchId(wt, base, head) {
|
|
466
486
|
try {
|
|
467
|
-
// Both children run through execGitBounded, so a hung diff or patch-id is SIGTERMed
|
|
468
|
-
//
|
|
487
|
+
// Both children run through execGitBounded, so a hung diff or patch-id is SIGTERMed group-wide
|
|
488
|
+
// and SIGKILLed after the grace instead of stalling merge's approval check.
|
|
469
489
|
const { stdout: diff } = await execGitBounded(["diff", "--no-color", "--no-ext-diff", "--no-textconv", "--binary", `${base}...${head}`], { cwd: wt, timeoutMs: GIT_TIMEOUT_MS });
|
|
470
490
|
if (diff === "")
|
|
471
491
|
return null;
|
|
@@ -481,13 +501,13 @@ export async function patchId(wt, base, head) {
|
|
|
481
501
|
}
|
|
482
502
|
}
|
|
483
503
|
/** Undo commits the agent made on top of `base` (INV-H1): a soft reset keeps their content as
|
|
484
|
-
* staged edits, so the harness's own commitAll
|
|
485
|
-
* were folded — 0 when HEAD never moved. A count of 0 from rev-list with HEAD moved means the
|
|
486
|
-
*
|
|
504
|
+
* staged edits, so the harness's own commitAll records it as one commit. Returns how many commits
|
|
505
|
+
* were folded — 0 when HEAD never moved. A count of 0 from rev-list with HEAD moved means the agent
|
|
506
|
+
* amended or reset away from `base`; that still folds, and counts as 1. */
|
|
487
507
|
export async function foldCommitsSince(wt, base) {
|
|
488
508
|
if ((await headOf(wt, "HEAD")) === base)
|
|
489
509
|
return 0;
|
|
490
|
-
const count =
|
|
510
|
+
const count = await countCommitsInRange(wt, `${base}..HEAD`);
|
|
491
511
|
await git(wt, "reset", "--soft", base);
|
|
492
512
|
return count > 0 ? count : 1;
|
|
493
513
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// vocabulary: plain
|
|
1
2
|
/** The persisted slot layout of the worktree pool (plans/worktree-pool.md): which
|
|
2
3
|
* `.tumwater/worktrees/_slot-<n>` checkouts exist, who leases each, and which role each is pinned
|
|
3
4
|
* for. Kept in its own module, apart from the pool's lease machinery, so the readers that run in
|
|
@@ -11,6 +12,7 @@ import { finiteNumber, isJsonObject, nonNegativeNumber } from "../files/json-obj
|
|
|
11
12
|
import { withStateLock } from "../concurrency/lock.js";
|
|
12
13
|
import { slotsLockPath, slotsStatePath, worktreePath } from "../paths.js";
|
|
13
14
|
import { isUsableWorktree } from "./worktree.js";
|
|
15
|
+
import { canonicalRoleId } from "../roles/legacy-role-ids.js";
|
|
14
16
|
/** Run `fn` holding the cross-process slots lock. withStateLock creates the `.tumwater/state/`
|
|
15
17
|
* parent first, since a reader or `retire` may run before any state write has made it. */
|
|
16
18
|
function withSlotsLock(root, fn) {
|
|
@@ -44,10 +46,17 @@ export function readSlotsState(root) {
|
|
|
44
46
|
function isSlotLease(value) {
|
|
45
47
|
return (isJsonObject(value) &&
|
|
46
48
|
typeof value.role === "string" &&
|
|
47
|
-
(value.purpose === "tick" || value.purpose === "vet") &&
|
|
49
|
+
(value.purpose === "run" || value.purpose === "verify" || value.purpose === "tick" || value.purpose === "vet") && // vocabulary-ok: legacy value read from old files
|
|
48
50
|
nonNegativeNumber(value.since, null) !== null &&
|
|
49
51
|
nonNegativeNumber(value.pid, null) !== null);
|
|
50
52
|
}
|
|
53
|
+
/** A lease an older build wrote carries the pre-rename purpose names; read them as
|
|
54
|
+
* `run`/`verify`. */
|
|
55
|
+
function readSlotLease(lease) {
|
|
56
|
+
const l = lease;
|
|
57
|
+
const purpose = l.purpose === "tick" ? "run" : l.purpose === "vet" ? "verify" : l.purpose; // vocabulary-ok: legacy value read from old files
|
|
58
|
+
return { ...l, role: canonicalRoleId(l.role), purpose };
|
|
59
|
+
}
|
|
51
60
|
/** Coerce one persisted entry into a SlotRecord, defaulting the known fields a hand edit or an
|
|
52
61
|
* older writer may omit. Each field is the value or its empty form, so a record missing
|
|
53
62
|
* `pinnedFor` is not frozen as permanently pinned (chooseSlot treats `!== null` as pinned),
|
|
@@ -63,11 +72,11 @@ function readSlotRecord(slot) {
|
|
|
63
72
|
return {
|
|
64
73
|
...slot,
|
|
65
74
|
dir: slot.dir,
|
|
66
|
-
lease: isSlotLease(slot.lease) ? slot.lease : null,
|
|
67
|
-
pinnedFor: typeof slot.pinnedFor === "string" ? slot.pinnedFor : null,
|
|
75
|
+
lease: isSlotLease(slot.lease) ? readSlotLease(slot.lease) : null,
|
|
76
|
+
pinnedFor: typeof slot.pinnedFor === "string" ? canonicalRoleId(slot.pinnedFor) : null,
|
|
68
77
|
// Absent stays absent (an older writer's shape is preserved); present-but-foreign is null.
|
|
69
78
|
pinnedAt: slot.pinnedAt === undefined ? undefined : finiteNumber(slot.pinnedAt, null),
|
|
70
|
-
lastRole: typeof slot.lastRole === "string" ? slot.lastRole : null,
|
|
79
|
+
lastRole: typeof slot.lastRole === "string" ? canonicalRoleId(slot.lastRole) : null,
|
|
71
80
|
lastReleasedAt: finiteNumber(slot.lastReleasedAt, null),
|
|
72
81
|
};
|
|
73
82
|
}
|
|
@@ -98,7 +107,7 @@ export function slotForDir(root, dir) {
|
|
|
98
107
|
* ticks lease slots (part 4/5), because nothing writes those leases yet. */
|
|
99
108
|
export async function roleWorktreeDir(root, role) {
|
|
100
109
|
const slots = readSlotsState(root).slots;
|
|
101
|
-
const leased = slots.find((slot) => slot.lease?.role === role && slot.lease.purpose === "
|
|
110
|
+
const leased = slots.find((slot) => slot.lease?.role === role && slot.lease.purpose === "run");
|
|
102
111
|
if (leased !== undefined)
|
|
103
112
|
return leased.dir;
|
|
104
113
|
const pinned = slots.find((slot) => slot.pinnedFor === role);
|
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
1
|
+
// vocabulary: plain
|
|
2
|
+
/** The worktree pool's lease machinery (plans/worktree-pool.md, "Leases"; part 2a/5): a fixed set
|
|
3
|
+
* of `.tumwater/worktrees/_slot-<n>` checkouts that ticks and verifies lease and switch commits in
|
|
4
|
+
* place, instead of keeping one `_land-<role>` checkout per role. The persisted layout and its
|
|
5
|
+
* cross-process lock live in slots-state.ts, which other processes (`tumwater diff`, `retire`) also
|
|
6
|
+
* read; this module owns choosing a slot, holding it through the disk-floor registry, and handing
|
|
7
|
+
* it back. Nothing leases a slot yet — part 4/5 wires the loops to it.
|
|
7
8
|
*
|
|
8
|
-
* The lease decision is synchronous: `updateSlotsState` writes slots.json under the slots lock,
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* The lease decision is synchronous: `updateSlotsState` writes slots.json under the slots lock, and
|
|
10
|
+
* one Node process runs every tick and merge, so two concurrent `leaseSlot` calls claim distinct
|
|
11
|
+
* free slots and a call with none free registers an in-process waiter. A release wakes the waiters,
|
|
12
|
+
* which re-decide; `signal` aborts a waiter. */
|
|
12
13
|
import path from "node:path";
|
|
13
14
|
import { loadConfig, slotCount } from "../config/config.js";
|
|
14
15
|
import { worktreesDir, slotWorktreePath } from "../paths.js";
|
|
@@ -17,9 +18,10 @@ import { DIRECTOR_ROLE } from "../roles/roles.js";
|
|
|
17
18
|
import { readSlotsState, slotForDir, updateSlotsState } from "./slots-state.js";
|
|
18
19
|
import { beginWorktreeUse } from "./worktree-use.js";
|
|
19
20
|
import { ensureDetachedWorktree, removeWorktreeDir } from "./worktree.js";
|
|
20
|
-
import { logEventBestEffort, warnEventBestEffort } from "../events/events.js";
|
|
21
|
+
import { logEventBestEffort, warnEventBestEffort, TUMWATER_SOURCE } from "../events/events.js";
|
|
21
22
|
import { errorMessage } from "../text/text.js";
|
|
22
23
|
import { subdirectoryNames } from "../files/files.js";
|
|
24
|
+
import { dropProviderLeaseKey } from "../concurrency/provider-lease.js";
|
|
23
25
|
/** In-process waiters per root, woken when any slot is released. Keyed by resolved root so two
|
|
24
26
|
* roots never wake each other. */
|
|
25
27
|
const waiters = new Map();
|
|
@@ -27,7 +29,7 @@ function rootKey(root) {
|
|
|
27
29
|
return path.resolve(root);
|
|
28
30
|
}
|
|
29
31
|
function abortError() {
|
|
30
|
-
const err = new Error("
|
|
32
|
+
const err = new Error("the workspace request was stopped before one was free");
|
|
31
33
|
err.name = "AbortError";
|
|
32
34
|
return err;
|
|
33
35
|
}
|
|
@@ -146,7 +148,7 @@ function logSlotWait(root, role, purpose, waitedMs) {
|
|
|
146
148
|
return;
|
|
147
149
|
const slots = readSlotsState(root).slots;
|
|
148
150
|
logEventBestEffort(root, {
|
|
149
|
-
|
|
151
|
+
source: role,
|
|
150
152
|
type: "slot_wait",
|
|
151
153
|
role,
|
|
152
154
|
purpose,
|
|
@@ -177,6 +179,11 @@ async function acquire(root, role, purpose, signal, slotBudget) {
|
|
|
177
179
|
logSlotWait(root, role, purpose, Date.now() - startedAt);
|
|
178
180
|
return chosen;
|
|
179
181
|
}
|
|
182
|
+
// The pool-slot wait rule (plans/provider-concurrency.md §5 "No deadlock"): a lease never holds
|
|
183
|
+
// a provider key while it waits for a pool slot. Drop the calling lease's key (which never
|
|
184
|
+
// waits) before parking here, so this tick or verify holds no provider slot whose in-use a
|
|
185
|
+
// re-key waiter is waiting to drain; the next run re-takes the key as an ordinary re-key.
|
|
186
|
+
await dropProviderLeaseKey();
|
|
180
187
|
const waiter = registerWaiter(root);
|
|
181
188
|
try {
|
|
182
189
|
await raceAbort(waiter.promise, signal);
|
|
@@ -258,7 +265,7 @@ export async function removeDroppedSlots(root, dirs, remove = removeWorktreeDir)
|
|
|
258
265
|
await remove(root, dir);
|
|
259
266
|
}
|
|
260
267
|
catch (err) {
|
|
261
|
-
warnEventBestEffort(root,
|
|
268
|
+
warnEventBestEffort(root, TUMWATER_SOURCE, `could not remove idle workspace ${dir}: ${errorMessage(err)}`);
|
|
262
269
|
}
|
|
263
270
|
}
|
|
264
271
|
}
|
|
@@ -276,7 +283,7 @@ export async function removeDroppedSlots(root, dirs, remove = removeWorktreeDir)
|
|
|
276
283
|
* are already updated, so a leaked directory is only a leak the next prune collects. */
|
|
277
284
|
export async function retireLegacyRoleWorktrees(root, configured) {
|
|
278
285
|
const dir = worktreesDir(root);
|
|
279
|
-
// A missing worktrees dir (a
|
|
286
|
+
// A missing worktrees dir (a team that has not started) yields no names to migrate; the pin
|
|
280
287
|
// retirement pass below still runs over whatever slots.json holds.
|
|
281
288
|
const names = subdirectoryNames(dir, (name) => name !== DIRECTOR_ROLE && !name.startsWith("_"));
|
|
282
289
|
const configuredSet = new Set(configured);
|
|
@@ -289,7 +296,7 @@ export async function retireLegacyRoleWorktrees(root, configured) {
|
|
|
289
296
|
const ownRecord = state.slots.find((slot) => slot.dir === legacyDir);
|
|
290
297
|
if (resumable && configuredSet.has(role)) {
|
|
291
298
|
const owned = state.slots.find((slot) => slot.pinnedFor === role ||
|
|
292
|
-
(slot.lease?.role === role && slot.lease.purpose === "
|
|
299
|
+
(slot.lease?.role === role && slot.lease.purpose === "run"));
|
|
293
300
|
if (owned !== undefined) {
|
|
294
301
|
// The resume happens in the owned slot: drop the legacy record and remove its dir.
|
|
295
302
|
if (owned.dir !== legacyDir) {
|
|
@@ -343,7 +350,7 @@ export async function retireLegacyRoleWorktrees(root, configured) {
|
|
|
343
350
|
await removeWorktreeDir(root, victim);
|
|
344
351
|
}
|
|
345
352
|
catch (err) {
|
|
346
|
-
warnEventBestEffort(root,
|
|
353
|
+
warnEventBestEffort(root, TUMWATER_SOURCE, `could not remove legacy role workspace ${victim}: ${errorMessage(err)}`);
|
|
347
354
|
}
|
|
348
355
|
}
|
|
349
356
|
}
|
|
@@ -363,7 +370,7 @@ export async function leaseSlot(root, options) {
|
|
|
363
370
|
useRelease();
|
|
364
371
|
}
|
|
365
372
|
catch (err) {
|
|
366
|
-
warnEventBestEffort(root,
|
|
373
|
+
warnEventBestEffort(root, TUMWATER_SOURCE, `could not record use release for ${path.basename(dir)}: ${errorMessage(err)}`);
|
|
367
374
|
}
|
|
368
375
|
};
|
|
369
376
|
if (!preserved) {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
1
|
+
// vocabulary: plain
|
|
2
|
+
/** The worktree-use registry (plans/disk-floor.md, "Reclaiming build outputs", part 2/4): which
|
|
3
|
+
* harness worktrees are in use right now, and when each was last used. The orchestrator is the one
|
|
4
|
+
* process that runs ticks and merges, so a plain in-process map is enough for the "in use" half;
|
|
5
|
+
* the "last used" half is persisted to `.tumwater/state/worktree-use.json` so least-recently-used
|
|
6
|
+
* reclaim ordering survives a restart.
|
|
6
7
|
*
|
|
7
8
|
* A reclaim claims a worktree before it cleans it, so a `useWorktree` that arrives mid-clean
|
|
8
9
|
* waits for the clean to finish instead of starting work in a half-emptied tree. Every wrapped
|
|
@@ -12,7 +13,7 @@ import path from "node:path";
|
|
|
12
13
|
import { readJsonFile, writeJsonAtomic } from "../files/json-files.js";
|
|
13
14
|
import { finiteNumber, isJsonObject } from "../files/json-object.js";
|
|
14
15
|
import { withStateLock } from "../concurrency/lock.js";
|
|
15
|
-
import { warnEventBestEffort } from "../events/events.js";
|
|
16
|
+
import { warnEventBestEffort, TUMWATER_SOURCE } from "../events/events.js";
|
|
16
17
|
import { errorMessage } from "../text/text.js";
|
|
17
18
|
import { worktreeUseLockPath, worktreeUsePath } from "../paths.js";
|
|
18
19
|
/** In-process state, keyed by the resolved directory so two roots' same-named worktrees never
|
|
@@ -37,7 +38,7 @@ function liveEntry(dir) {
|
|
|
37
38
|
* produces one). An Infinity `lastUsedAt` would sort a worktree as used forever —
|
|
38
39
|
* `now - Infinity` is below any idle window — so the worktree could never be idle-reclaimed;
|
|
39
40
|
* an Infinity `reclaimedAt` would read as reclaimed after every use, blocking the same
|
|
40
|
-
* reclaim. Either reads as absent, the same finiteNumber rule
|
|
41
|
+
* reclaim. Either reads as absent, the same finiteNumber rule team-state.ts's and
|
|
41
42
|
* operator-requests.ts's persisted deadlines read by. */
|
|
42
43
|
function readWorktreeRecord(value) {
|
|
43
44
|
if (!isJsonObject(value))
|
|
@@ -83,19 +84,19 @@ export function updateWorktreeUse(root, change) {
|
|
|
83
84
|
return registry;
|
|
84
85
|
});
|
|
85
86
|
}
|
|
86
|
-
/** `updateWorktreeUse`, but best-effort: the durable registry only orders reclaim (see the
|
|
87
|
-
*
|
|
88
|
-
*
|
|
87
|
+
/** `updateWorktreeUse`, but best-effort: the durable registry only orders reclaim (see the module
|
|
88
|
+
* doc), so a write that fails — the lock times out behind a CLI `tumwater reclaim`, the state
|
|
89
|
+
* volume is full — is warned and dropped rather than thrown. Both release paths use it:
|
|
89
90
|
* `beginWorktreeUse`'s release and `releaseReclaim`, the latter in a `finally` after a reclaim.
|
|
90
|
-
* Their bookkeeping must not become the caller's failure — a throw there would mask a
|
|
91
|
-
*
|
|
92
|
-
*
|
|
91
|
+
* Their bookkeeping must not become the caller's failure — a throw there would mask a successful
|
|
92
|
+
* tick, merge, or clean with a registry error. The in-process `uses`/`reclaiming` state is updated
|
|
93
|
+
* before this runs, so only reclaim ordering degrades. */
|
|
93
94
|
function updateWorktreeUseBestEffort(root, change) {
|
|
94
95
|
try {
|
|
95
96
|
updateWorktreeUse(root, change);
|
|
96
97
|
}
|
|
97
98
|
catch (err) {
|
|
98
|
-
warnEventBestEffort(root,
|
|
99
|
+
warnEventBestEffort(root, TUMWATER_SOURCE, `could not update the worktree-use registry: ${errorMessage(err)}`);
|
|
99
100
|
}
|
|
100
101
|
}
|
|
101
102
|
/** True while a `useWorktree` body holds this worktree. */
|
package/dist/src/git/worktree.js
CHANGED
|
@@ -53,13 +53,13 @@ function pruneStaleBuildDir(wt) {
|
|
|
53
53
|
const setupQueues = new Map();
|
|
54
54
|
/** Run `setup` — a clear-and-add of one worktree — after every earlier setup for the same
|
|
55
55
|
* repository has finished. `git worktree add` creates the new registration's directory under
|
|
56
|
-
* .git/worktrees a moment before it writes the `locked` file that shields it from prune, and a
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
56
|
+
* .git/worktrees a moment before it writes the `locked` file that shields it from prune, and a `git
|
|
57
|
+
* worktree prune` running in between deletes the registration: the add then dies with "could not
|
|
58
|
+
* open '.git/worktrees/<name>/locked' for writing". The merge pipeline verifies its changes
|
|
59
|
+
* concurrently, each verify ensuring its own leased pool slot, so two verifies' clear-and-add steps
|
|
60
|
+
* did interleave — and the loser's verify ended in a terminal "error" that dropped its queue entry.
|
|
61
|
+
* Serializing the harness's own setups per repository closes that; the usable-worktree fast path
|
|
62
|
+
* never takes the queue. */
|
|
63
63
|
async function serializeSetup(root, setup) {
|
|
64
64
|
const key = path.resolve(root);
|
|
65
65
|
const run = (setupQueues.get(key) ?? Promise.resolve()).then(setup);
|
|
@@ -73,11 +73,11 @@ async function serializeSetup(root, setup) {
|
|
|
73
73
|
setupQueues.delete(key);
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
|
-
/** Ensure a persistent worktree + branch exists for a role. Returns the worktree path.
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
76
|
+
/** Ensure a persistent worktree + branch exists for a role. Returns the worktree path. Self-heals
|
|
77
|
+
* when the directory exists but is no longer a usable worktree: it removes and re-adds the
|
|
78
|
+
* directory instead of failing every tick (see clearStaleWorktree). Since the worktree pool merged
|
|
79
|
+
* (plans/worktree-pool.md, "Role ticks lease slots"), this serves the director only; every other
|
|
80
|
+
* role leases a pooled `_slot-<n>` via leaseSlot instead. */
|
|
81
81
|
export async function ensureWorktree(root, role, mainBranch) {
|
|
82
82
|
const wt = worktreePath(root, role);
|
|
83
83
|
const branch = branchName(role);
|
|
@@ -130,14 +130,14 @@ export async function removeWorktreeDir(root, dir) {
|
|
|
130
130
|
export async function removeWorktree(root, role) {
|
|
131
131
|
await removeWorktreeDir(root, worktreePath(root, role));
|
|
132
132
|
}
|
|
133
|
-
/** Remove
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
133
|
+
/** Remove the `_land-<role>` checkouts left by a pre-pool build: the pool's ticks keep their
|
|
134
|
+
* commit on the role branch and a queued merge re-verifies from its pinned ref, so the checkout
|
|
135
|
+
* holds nothing to keep. Called once at orchestrator startup, before any tick; each removal goes
|
|
136
|
+
* through removeWorktreeDir (force `worktree remove`, with the shared dead-registration fallback),
|
|
137
|
+
* then one prune registers the vanished checkouts. */
|
|
138
138
|
export async function removeLegacyLandWorktrees(root) {
|
|
139
139
|
const dir = worktreesDir(root);
|
|
140
|
-
// A missing worktrees dir (a
|
|
140
|
+
// A missing worktrees dir (a team that has not started) yields no names to remove.
|
|
141
141
|
const names = subdirectoryNames(dir, (name) => name.startsWith("_land-"));
|
|
142
142
|
for (const name of names)
|
|
143
143
|
await removeWorktreeDir(root, path.join(dir, name));
|
package/dist/src/gui/gui-args.js
CHANGED
|
@@ -16,9 +16,9 @@ import { queueFileNameProblem } from "../inbox/inbox-cancel.js";
|
|
|
16
16
|
import { DIRECTOR_ROLE } from "../roles/roles.js";
|
|
17
17
|
import { gotSuffix, parseNonNegativeInt, parsePositiveInt } from "../text/text.js";
|
|
18
18
|
import { typoSuffix } from "../text/suggest.js";
|
|
19
|
-
import { sendJson } from "./http-body.js";
|
|
19
|
+
import { badRequest, sendJson } from "./http-body.js";
|
|
20
20
|
/** The role ids a loop-targeting endpoint accepts: when tumwater.json parses, catalog +
|
|
21
|
-
*
|
|
21
|
+
* customRoles (knownRoleIds); a transiently broken file falls back to the built-in catalog
|
|
22
22
|
* rather than refusing every id — the rule config.knownRoleIdsCached owns. Shared by
|
|
23
23
|
* /api/transcript and the two operator endpoints so their validation and 400 wording cannot
|
|
24
24
|
* drift. */
|
|
@@ -41,13 +41,11 @@ export function rejectBadRole(root, res, role, allowMissing = false) {
|
|
|
41
41
|
return false;
|
|
42
42
|
const validIds = validRoleIds(root);
|
|
43
43
|
if (role === undefined || role === null) {
|
|
44
|
-
|
|
44
|
+
badRequest(res, `role required (valid ids: ${validIds.join(", ")})`);
|
|
45
45
|
return true;
|
|
46
46
|
}
|
|
47
47
|
if (typeof role !== "string" || !validIds.includes(role)) {
|
|
48
|
-
|
|
49
|
-
error: `unknown role ${JSON.stringify(role)} (valid ids: ${validIds.join(", ")})${typeof role === "string" ? typoSuffix(role, validIds) : ""}`,
|
|
50
|
-
});
|
|
48
|
+
badRequest(res, `unknown role ${JSON.stringify(role)} (valid ids: ${validIds.join(", ")})${typeof role === "string" ? typoSuffix(role, validIds) : ""}`);
|
|
51
49
|
return true;
|
|
52
50
|
}
|
|
53
51
|
return false;
|
|
@@ -61,7 +59,7 @@ export function rejectBadQueueFileName(res, name) {
|
|
|
61
59
|
const problem = queueFileNameProblem(name);
|
|
62
60
|
if (problem === null)
|
|
63
61
|
return false;
|
|
64
|
-
|
|
62
|
+
badRequest(res, problem);
|
|
65
63
|
return true;
|
|
66
64
|
}
|
|
67
65
|
/** Read an integer query parameter with the GUI's established discipline for an explicit
|
|
@@ -77,7 +75,7 @@ export function intQuery(q, res, name, kind, fallback) {
|
|
|
77
75
|
if (raw === null) {
|
|
78
76
|
if (fallback !== undefined)
|
|
79
77
|
return fallback;
|
|
80
|
-
|
|
78
|
+
badRequest(res, `${name} required`);
|
|
81
79
|
return null;
|
|
82
80
|
}
|
|
83
81
|
const parsed = kind === "positive" ? parsePositiveInt(raw) : parseNonNegativeInt(raw);
|
|
@@ -113,12 +111,12 @@ export function requirePromptText(res, body, role = DIRECTOR_ROLE) {
|
|
|
113
111
|
return null;
|
|
114
112
|
}
|
|
115
113
|
if (!text.trim()) {
|
|
116
|
-
|
|
114
|
+
badRequest(res, "text required");
|
|
117
115
|
return null;
|
|
118
116
|
}
|
|
119
117
|
const tooLong = promptLengthProblem(text, role);
|
|
120
118
|
if (tooLong) {
|
|
121
|
-
|
|
119
|
+
badRequest(res, tooLong);
|
|
122
120
|
return null;
|
|
123
121
|
}
|
|
124
122
|
return text;
|
|
@@ -137,7 +135,7 @@ export function sinceMsQuery(q, res, maxMs, overCap) {
|
|
|
137
135
|
if (sinceMs === null)
|
|
138
136
|
return null;
|
|
139
137
|
if (sinceMs > maxMs) {
|
|
140
|
-
|
|
138
|
+
badRequest(res, overCap(sinceMs));
|
|
141
139
|
return null;
|
|
142
140
|
}
|
|
143
141
|
return sinceMs;
|
|
@@ -151,9 +149,7 @@ export function sinceMsQuery(q, res, maxMs, overCap) {
|
|
|
151
149
|
export function requireConfigKey(res, body) {
|
|
152
150
|
const key = body.key;
|
|
153
151
|
if (typeof key !== "string" || !EDITABLE_CONFIG_KEYS.includes(key)) {
|
|
154
|
-
|
|
155
|
-
error: `key must be one of ${EDITABLE_CONFIG_KEYS.join(", ")}${key === undefined ? "" : gotSuffix(key)}`,
|
|
156
|
-
});
|
|
152
|
+
badRequest(res, `key must be one of ${EDITABLE_CONFIG_KEYS.join(", ")}${key === undefined ? "" : gotSuffix(key)}${typeof key === "string" ? typoSuffix(key, EDITABLE_CONFIG_KEYS) : ""}`);
|
|
157
153
|
return null;
|
|
158
154
|
}
|
|
159
155
|
return key;
|