tumwater 0.3.0 → 0.4.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 +1 -1
- package/dist/build-info.json +2 -2
- package/dist/src/agent/backend-traits.js +13 -0
- package/dist/src/agent/claude/claude-backend.js +2 -2
- package/dist/src/agent/claude/claude-stream.js +3 -2
- package/dist/src/agent/codex/codex-backend.js +3 -3
- package/dist/src/agent/codex/codex-stream.js +3 -1
- package/dist/src/agent/opencode/opencode-backend.js +2 -2
- package/dist/src/backlog/backlog-archive.js +60 -25
- package/dist/src/backlog/backlog-cli-view.js +3 -28
- package/dist/src/backlog/backlog-cli.js +9 -64
- package/dist/src/backlog/backlog-commit.js +17 -9
- package/dist/src/backlog/backlog-eligibility.js +44 -26
- package/dist/src/backlog/backlog-parts.js +23 -13
- package/dist/src/backlog/backlog-release.js +33 -16
- package/dist/src/baseline/main-guardrails.js +0 -73
- package/dist/src/baseline/main-red.js +31 -21
- package/dist/src/baseline/main-watch.js +82 -34
- package/dist/src/brief/brief-edit.js +3 -1
- package/dist/src/budget/budget-fallback-view.js +288 -0
- package/dist/src/budget/budget-wiring.js +158 -0
- package/dist/src/budget/budget.js +2 -1
- package/dist/src/build/dep-install.js +64 -1
- package/dist/src/cli/cli-args.js +63 -0
- package/dist/src/cli/cli-flag-specs.js +33 -0
- package/dist/src/cli/cli-query-commands.js +16 -41
- package/dist/src/cli/cli-review-commands.js +113 -0
- package/dist/src/cli/cli-role-command.js +118 -13
- package/dist/src/cli/cli-start-summary.js +10 -3
- package/dist/src/cli/cli-start.js +33 -5
- package/dist/src/cli/completion.js +21 -1
- package/dist/src/cli/config-commands.js +17 -8
- package/dist/src/cli/help.js +74 -14
- package/dist/src/cli/log-commands.js +26 -13
- package/dist/src/cli/question-commands.js +7 -1
- package/dist/src/cli.js +71 -6
- package/dist/src/collections.js +31 -4
- package/dist/src/config/config-editable-keys.js +3 -0
- package/dist/src/config/config-fallbacks.js +3 -2
- package/dist/src/config/config-validation.js +47 -42
- package/dist/src/config/config-views.js +10 -2
- package/dist/src/config/config-write.js +33 -1
- package/dist/src/config/config.js +7 -2
- package/dist/src/doctor/doctor-backlog-bugs.js +85 -42
- package/dist/src/doctor/doctor-backlog-plans.js +214 -79
- package/dist/src/doctor/doctor-backlog.js +155 -22
- package/dist/src/doctor/doctor-check-names.js +5 -1
- package/dist/src/doctor/doctor-checks.js +10 -15
- package/dist/src/doctor/doctor-claude-checks.js +6 -9
- package/dist/src/doctor/doctor-codex-checks.js +6 -9
- package/dist/src/doctor/doctor-contention-checks.js +244 -118
- package/dist/src/doctor/doctor-fallback-checks.js +130 -0
- package/dist/src/doctor/doctor-holds.js +4 -6
- package/dist/src/doctor/doctor-model-checks.js +45 -200
- package/dist/src/doctor/doctor-notify.js +7 -2
- package/dist/src/doctor/doctor-opencode-checks.js +7 -5
- package/dist/src/doctor/doctor-pi-checks.js +62 -0
- package/dist/src/doctor/doctor-project-checks.js +106 -12
- package/dist/src/doctor/doctor.js +10 -5
- package/dist/src/doctor/probe.js +14 -1
- package/dist/src/events/event-format.js +7 -6
- package/dist/src/events/event-labels.js +0 -1
- package/dist/src/events/event-read.js +13 -0
- package/dist/src/events/event-window.js +20 -2
- package/dist/src/failure/failure-data.js +9 -4
- package/dist/src/failure/failure-render.js +8 -8
- package/dist/src/failure/time-spend.js +0 -64
- package/dist/src/files/file-queue.js +17 -0
- package/dist/src/gates/budget-gates.js +27 -257
- package/dist/src/gates/gate-polls.js +49 -141
- package/dist/src/gates/maintenance-quota.js +27 -17
- package/dist/src/git/commit-message.js +12 -0
- package/dist/src/git/git.js +18 -0
- package/dist/src/git/slot-salvage.js +3 -3
- package/dist/src/gui/gui-backlog-commands.js +3 -3
- package/dist/src/gui/gui-backlog-endpoints.js +5 -4
- package/dist/src/gui/gui-command.js +19 -1
- package/dist/src/gui/gui-config-commands.js +75 -9
- package/dist/src/gui/gui-endpoint-commands.js +2 -2
- package/dist/src/gui/gui-operator-commands.js +134 -7
- package/dist/src/gui/gui-prompt-commands.js +15 -6
- package/dist/src/gui/gui-report-endpoints.js +10 -3
- package/dist/src/gui/gui-server.js +16 -2
- package/dist/src/history/history-queued.js +8 -0
- package/dist/src/history/history.js +2 -5
- package/dist/src/inbox/inbox-top.js +4 -9
- package/dist/src/merge/merge-core.js +2 -25
- package/dist/src/merge/merge-drain.js +22 -135
- package/dist/src/merge/merge-pipeline.js +1 -1
- package/dist/src/merge/merge-post-review.js +154 -30
- package/dist/src/merge/merge-queue-render.js +11 -12
- package/dist/src/merge/merge-queue.js +22 -1
- package/dist/src/merge/merge-requeue.js +1 -0
- package/dist/src/merge/merge-stack.js +1 -7
- package/dist/src/merge/merge-to-main.js +1 -9
- package/dist/src/merge/merge-top.js +33 -19
- package/dist/src/merge/merge-verify.js +64 -9
- package/dist/src/notes-cli.js +8 -3
- package/dist/src/orchestrator/orchestrator-poll.js +47 -15
- package/dist/src/orchestrator/orchestrator-scheduling.js +4 -2
- package/dist/src/orchestrator/orchestrator.js +5 -4
- package/dist/src/orchestrator/scheduling-scope.js +51 -7
- package/dist/src/orchestrator/scheduling-skip.js +15 -5
- package/dist/src/paths.js +16 -6
- package/dist/src/pi/pi-backend.js +2 -2
- package/dist/src/process/run-marker.js +8 -0
- package/dist/src/prompt/gate-notes.js +1 -1
- package/dist/src/prompt/gate-prompts.js +30 -7
- package/dist/src/prompt/notes-backlog.js +13 -10
- package/dist/src/prompt/prompt.js +3 -1
- package/dist/src/report/report-data.js +49 -69
- package/dist/src/report/report-fold.js +11 -12
- package/dist/src/report/report-render.js +6 -4
- package/dist/src/report/report.js +2 -5
- package/dist/src/review/diff-rules.js +53 -0
- package/dist/src/review/known-flakes.js +12 -0
- package/dist/src/review/post-merge-review-accept.js +51 -0
- package/dist/src/review/post-merge-review-render.js +112 -0
- package/dist/src/review/post-merge-review.js +157 -24
- package/dist/src/review/protected-edits.js +22 -7
- package/dist/src/review/quarantine-render.js +65 -0
- package/dist/src/review/review.js +33 -49
- package/dist/src/roles/role-catalog.js +60 -2
- package/dist/src/roles/role-guidance.js +10 -0
- package/dist/src/roles/role-labels.js +6 -3
- package/dist/src/roles/role-render.js +43 -8
- package/dist/src/roles/role-view.js +36 -9
- package/dist/src/roles/roles.js +3 -3
- package/dist/src/run/ci-parity.js +121 -0
- package/dist/src/run/qa-coverage.js +50 -15
- package/dist/src/run/run-detail-data.js +9 -13
- package/dist/src/run/run-detail-render.js +1 -8
- package/dist/src/run/run-finalize.js +1 -0
- package/dist/src/run/run-prompt.js +14 -0
- package/dist/src/run/run-stage.js +66 -37
- package/dist/src/run/run-verdict.js +14 -1
- package/dist/src/run/stage-check.js +17 -11
- package/dist/src/run/stage-checks-shared.js +65 -0
- package/dist/src/run/stage-name-checks.js +204 -0
- package/dist/src/run/stage-path-checks.js +16 -515
- package/dist/src/run/stage-prose-checks.js +261 -0
- package/dist/src/runner/leftover.js +50 -19
- package/dist/src/runner/revision.js +12 -23
- package/dist/src/runner/runner-outcome.js +4 -2
- package/dist/src/runner/runner-recovery.js +18 -3
- package/dist/src/runner/runner-run.js +21 -8
- package/dist/src/runner/runner-state.js +4 -3
- package/dist/src/scheduling/claim-entries.js +49 -0
- package/dist/src/status/status-data.js +6 -1
- package/dist/src/status/status-polls.js +15 -0
- package/dist/src/status/status-roles.js +11 -0
- package/dist/src/team/reclaim.js +2 -2
- package/dist/src/text/datetime.js +3 -2
- package/dist/src/text/phrases.js +6 -3
- package/dist/src/text/text.js +11 -0
- package/dist/src/ui/badges.js +34 -1
- package/dist/src/ui/gui/gui-client-backlog.js +25 -3
- package/dist/src/ui/gui/gui-client-boot.js +63 -41
- package/dist/src/ui/gui/gui-client-drawer-config.js +56 -14
- package/dist/src/ui/gui/gui-client-drawer.js +29 -4
- package/dist/src/ui/gui/gui-client-feed.js +1 -1
- package/dist/src/ui/gui/gui-client-history.js +74 -72
- package/dist/src/ui/gui/gui-client-labels.js +5 -1
- package/dist/src/ui/gui/gui-client-operator.js +6 -2
- package/dist/src/ui/gui/gui-client-pending.js +188 -11
- package/dist/src/ui/gui/gui-client-report-chart.js +36 -8
- package/dist/src/ui/gui/gui-client-report.js +20 -5
- package/dist/src/ui/gui/gui-client-settings.js +109 -3
- package/dist/src/ui/gui/gui-client-team.js +11 -0
- package/dist/src/ui/gui/gui-client.js +11 -1
- package/dist/src/ui/gui/gui-page.js +1 -0
- package/dist/src/ui/gui/gui-styles.js +4 -0
- package/dist/src/ui/run-progress-model.js +2 -1
- package/dist/src/ui/status-payload.js +237 -9
- package/dist/src/ui/status-render.js +12 -4
- package/dist/src/ui/team-alerts.js +512 -37
- package/dist/src/ui/tone.js +7 -0
- package/dist/src/ui/tui/tui-backlog.js +7 -5
- package/dist/src/ui/tui/tui-editor-attach.js +1 -7
- package/dist/src/ui/tui/tui-editor-backends.js +1 -6
- package/dist/src/ui/tui/tui-editor-brief.js +4 -24
- package/dist/src/ui/tui/tui-editor-budget.js +1 -8
- package/dist/src/ui/tui/tui-editor-check.js +29 -0
- package/dist/src/ui/tui/tui-editor-checks.js +28 -0
- package/dist/src/ui/tui/tui-editor-concurrency.js +1 -0
- package/dist/src/ui/tui/tui-editor-config.js +19 -6
- package/dist/src/ui/tui/tui-editor-entry.js +3 -17
- package/dist/src/ui/tui/tui-editor-instances.js +9 -0
- package/dist/src/ui/tui/tui-editor-instructions.js +7 -26
- package/dist/src/ui/tui/tui-editor-interval.js +18 -7
- package/dist/src/ui/tui/tui-editor-notes.js +6 -24
- package/dist/src/ui/tui/tui-editor-quiet.js +2 -12
- package/dist/src/ui/tui/tui-editor-retire.js +1 -7
- package/dist/src/ui/tui/tui-editor-role-enabled.js +126 -0
- package/dist/src/ui/tui/tui-editor-role-key.js +1 -6
- package/dist/src/ui/tui/tui-editor-session.js +41 -0
- package/dist/src/ui/tui/tui-editor-slots.js +1 -0
- package/dist/src/ui/tui/tui-editors.js +10 -1
- package/dist/src/ui/tui/tui-frame.js +18 -6
- package/dist/src/ui/tui/tui-keys.js +9 -0
- package/dist/src/ui/tui/tui-pane-activity.js +21 -8
- package/dist/src/ui/tui/tui-pane-backlog.js +36 -9
- package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
- package/dist/src/ui/tui/tui-pane-health.js +113 -3
- package/dist/src/ui/tui/tui-pane-history.js +44 -0
- package/dist/src/ui/tui/tui-pane-pending.js +285 -56
- package/dist/src/ui/tui/tui-pane-queued.js +7 -2
- package/dist/src/ui/tui/tui-pane-report.js +9 -3
- package/dist/src/ui/tui/tui-pane-session.js +9 -0
- package/dist/src/ui/tui/tui-pane.js +16 -10
- package/dist/src/ui/tui/tui-panes.js +56 -7
- package/dist/src/ui/tui/tui.js +8 -5
- package/dist/src/verdict/fix-claim.js +47 -1
- package/dist/src/verdict/reply-contract.js +35 -0
- package/package.json +1 -1
- package/dist/src/baseline/main-culprit.js +0 -94
- package/dist/src/baseline/main-revert.js +0 -133
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/** The fallback config view the budget gate derives each poll, split out of pollBudgetGate's
|
|
2
|
+
* event-and-verdict half (src/gates/budget-gates.ts) so the two halves each have a plain
|
|
3
|
+
* function boundary and their own tests (the same split stage-path-checks.ts made of
|
|
4
|
+
* stage-check.ts). This module owns everything pollBudgetGate does from its `onFallback` seam
|
|
5
|
+
* through the end of the function: the seam/backends walk and used-up window moves
|
|
6
|
+
* (`windowOut`, `movedBackends`, `windowMove`, `windowTiers`, `windowResumedBackends`,
|
|
7
|
+
* `windowResumed`), the motion keys (`resolvedKey`, `movedKey`, `fromKey`), the two view
|
|
8
|
+
* derivations (`fallbackConfig`/`directorConfig`), the `movedTiers`/`applyFallbackModel`
|
|
9
|
+
* application, `chainInfo`, and the `pairs` handback list. Its one entry reads the inputs
|
|
10
|
+
* pollBudgetGate's first half already resolved and returns the view fields `BudgetGatePoll`
|
|
11
|
+
* carries, so pollBudgetGate keeps its call signature and src/gates/gate-polls.ts's
|
|
12
|
+
* destructure of it is unchanged. The plan references the moved comments carry (part 5b/8,
|
|
13
|
+
* part 5c/8, 6a/7) hold here. */
|
|
14
|
+
import { MODEL_TIERS, } from "../config/config-schema.js";
|
|
15
|
+
import { modelPairName, pairDailyCost } from "../budget/budget.js";
|
|
16
|
+
import { fallbackServingPair, } from "../budget/fallback-breaker.js";
|
|
17
|
+
import { applyFallbackModel, seamsInUse, tierChainSteps, tierFallbackLabels, } from "../config/config-fallbacks.js";
|
|
18
|
+
import { DIRECTOR_ROLE } from "../roles/roles.js";
|
|
19
|
+
import { enabledRoleIds } from "../config/config.js";
|
|
20
|
+
import { formatModelSelector, seamAgent } from "../config/model-selector.js";
|
|
21
|
+
import { configForRole, isTierName, reviewConfig, roleSeamTier, withModelOverrides, } from "../config/config-views.js";
|
|
22
|
+
import { piPairFree, seamFree } from "../pi/pi-models.js";
|
|
23
|
+
import { dayAt } from "../text/datetime.js";
|
|
24
|
+
/** Can the account not take extra usage: some window is rejected, or the newest non-reset
|
|
25
|
+
* window's `overageStatus` is `rejected`. Shared with budget-gates.ts's verdict half (its
|
|
26
|
+
* per-pair `pairFreeAt` reads the same overage refusal). */
|
|
27
|
+
export function claudeOverageRefused(account) {
|
|
28
|
+
const live = Object.values(account.windows).filter((w) => w.status !== "reset");
|
|
29
|
+
if (live.some((w) => w.status === "rejected"))
|
|
30
|
+
return true;
|
|
31
|
+
const newest = live.reduce((a, w) => (a === null || w.observedAt > a.observedAt ? w : a), null);
|
|
32
|
+
return newest?.overageStatus === "rejected";
|
|
33
|
+
}
|
|
34
|
+
/** The subscription backend a fallback pair or model seam routes to, when that backend's
|
|
35
|
+
* account reads subscription billing: a resolved seam's own routed agent, else a fallback
|
|
36
|
+
* pair's `backend` (`x.backend`), whose account is `billing === "subscription"`, else null.
|
|
37
|
+
* One backend can be a subscription while another bills by API, so a used-up window is judged
|
|
38
|
+
* per backend. Exported for the gate-polls window loop (part 2e/9), which reads it per
|
|
39
|
+
* backend in use. */
|
|
40
|
+
export function subscriptionBackendOf(x, accounts) {
|
|
41
|
+
const seam = x;
|
|
42
|
+
// A resolved seam carries its routed backend in `agent` (fallback pairs never do); a raw
|
|
43
|
+
// fallback pair carries the backend in its own `backend` field.
|
|
44
|
+
const id = seam.agent !== undefined ? seamAgent({ agent: seam.agent }) : seam.backend;
|
|
45
|
+
if (id === undefined)
|
|
46
|
+
return null;
|
|
47
|
+
const account = accounts(id);
|
|
48
|
+
return account?.billing === "subscription" ? id : null;
|
|
49
|
+
}
|
|
50
|
+
/** Derive the fallback view for one poll from the verdict half's already-resolved inputs
|
|
51
|
+
* (`priceResolved`, `servingResolved`, `chainPairs`, `pairNames`) and the `reached` flag
|
|
52
|
+
* and the shared reads (`state`, `config`, `accounts`, `windowOutFor`/`directorWindowOutFor`,
|
|
53
|
+
* `providers`, `catalog`), writing the cross-poll view state (`from`/`fromKey`/`fallbackConfig`,
|
|
54
|
+
* `directorFrom`/`directorKey`/`directorView`, `prevWindowMoved`) and returning the view fields
|
|
55
|
+
* pollBudgetGate hands back. */
|
|
56
|
+
export function budgetFallbackView(a) {
|
|
57
|
+
const { state, config: liveConfig, now, states, providers, catalog, accounts, windowOutFor, directorWindowOutFor, priceResolved, servingResolved, chainPairs, pairNames, reached, } = a;
|
|
58
|
+
// While the fallback holds, roles run under the derived view — the free pair installed
|
|
59
|
+
// top-level and every per-role/reviewer model override dropped, so no seam can reach a priced
|
|
60
|
+
// model. A breaker-demoted fallback keeps the view too: its gate is `paused`, but a run parked
|
|
61
|
+
// in the semaphore when it tripped, the half-open probe, and the merges must still run on the
|
|
62
|
+
// free pair — a demotion must never promote them to the priced model the cap already spent.
|
|
63
|
+
const onFallback = reached && pairNames.length > 0;
|
|
64
|
+
// A used-up window moves the subscription jobs of the backend whose window stands to their
|
|
65
|
+
// tier's fallback below the cap too; with no fallback to move to, nothing changes and that
|
|
66
|
+
// backend's hold applies. Every backend with a subscription seam in the config counts
|
|
67
|
+
// (seamsInUse enumerates them: every enabled role, the reviewer, the resolver, the director,
|
|
68
|
+
// and every tier fallback step), so a used-up window with no seam on that backend (or no
|
|
69
|
+
// fallback) moves nothing.
|
|
70
|
+
const seamBackends = new Set();
|
|
71
|
+
for (const seam of seamsInUse(liveConfig)) {
|
|
72
|
+
if (seam.agent === undefined)
|
|
73
|
+
continue;
|
|
74
|
+
const acc = accounts(seam.agent);
|
|
75
|
+
if (acc?.billing === "subscription")
|
|
76
|
+
seamBackends.add(seam.agent);
|
|
77
|
+
}
|
|
78
|
+
// The used-up windows behind this poll's move, keyed by backend: a backend counts only when
|
|
79
|
+
// a subscription seam of it is in the config, its own window is used up, and there is a
|
|
80
|
+
// fallback pair to move to. `windowOut` names only those backends; a healthy or absent
|
|
81
|
+
// backend's window is untouched.
|
|
82
|
+
const windowOut = {};
|
|
83
|
+
const movedBackends = new Set();
|
|
84
|
+
for (const backend of seamBackends) {
|
|
85
|
+
const win = windowOutFor(backend);
|
|
86
|
+
if (win !== null && pairNames.length > 0) {
|
|
87
|
+
windowOut[backend] = win;
|
|
88
|
+
movedBackends.add(backend);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const windowMove = movedBackends.size > 0;
|
|
92
|
+
// The resolved pairs are part of the key: a priced step crossing its own daily cap changes
|
|
93
|
+
// the resolution without a config or window change, and the view must follow it.
|
|
94
|
+
const resolvedKey = MODEL_TIERS.map((t) => {
|
|
95
|
+
const rp = priceResolved[t].pair;
|
|
96
|
+
return rp ? modelPairName(rp) : "-";
|
|
97
|
+
}).join(",");
|
|
98
|
+
// The set of window-moved backends is part of the key too (not just the `w` flag): a second
|
|
99
|
+
// backend's window can come or go while the move still stands, and the view must re-derive
|
|
100
|
+
// for the roles it newly blocks or frees.
|
|
101
|
+
const movedKey = [...movedBackends].sort().join("+");
|
|
102
|
+
const fromKey = `${windowMove ? "w" : ""}${reached ? "c" : ""}|${movedKey}|${resolvedKey}`;
|
|
103
|
+
if ((onFallback || windowMove) && (state.from !== liveConfig || state.fromKey !== fromKey)) {
|
|
104
|
+
state.from = liveConfig;
|
|
105
|
+
state.fromKey = fromKey;
|
|
106
|
+
// A job keeps its own model while that model is available: below the cap every seam but a
|
|
107
|
+
// subscription one whose own backend's window is used up; at the cap it must also be free.
|
|
108
|
+
const seamBlocked = (seam) => {
|
|
109
|
+
const b = subscriptionBackendOf(seam, accounts);
|
|
110
|
+
return b !== null && windowOut[b] !== undefined;
|
|
111
|
+
};
|
|
112
|
+
const available = (seam) => !seamBlocked(seam) && (!reached || seamFree(providers, seam, accounts, catalog));
|
|
113
|
+
const view = applyFallbackModel(liveConfig, priceResolved, available);
|
|
114
|
+
// A role pinned to a subscription backend under a tier of another backend has no tier pair
|
|
115
|
+
// to fall onto — dropping its override would put it on the tier's priced primary — so it
|
|
116
|
+
// keeps its pin and that backend's hold stops it, as before the window move.
|
|
117
|
+
if (movedBackends.size > 0) {
|
|
118
|
+
for (const role of enabledRoleIds(liveConfig)) {
|
|
119
|
+
const tier = roleSeamTier(liveConfig, role);
|
|
120
|
+
const pin = liveConfig.roles[role];
|
|
121
|
+
const roleBackend = subscriptionBackendOf(configForRole(liveConfig, role), accounts);
|
|
122
|
+
const tierBackend = subscriptionBackendOf(withModelOverrides(liveConfig, tier, {}), accounts);
|
|
123
|
+
if (pin && roleBackend !== null && roleBackend !== tierBackend)
|
|
124
|
+
view.roles[role] = pin;
|
|
125
|
+
}
|
|
126
|
+
// A reviewer pinned to a subscription backend under such a tier would likewise end up on
|
|
127
|
+
// the tier's priced primary; it goes to the tier's fallback pair instead (a held reviewer
|
|
128
|
+
// would park the roles a pre-merge review gates).
|
|
129
|
+
const rm = liveConfig.review.model;
|
|
130
|
+
const reviewTier = isTierName(rm) ? rm : "strong";
|
|
131
|
+
const reviewPair = priceResolved[reviewTier].pair;
|
|
132
|
+
const reviewBackend = subscriptionBackendOf(reviewConfig(liveConfig), accounts);
|
|
133
|
+
const reviewTierBackend = subscriptionBackendOf(withModelOverrides(liveConfig, reviewTier, {}), accounts);
|
|
134
|
+
// Only the reviewer's own backend's window counts: a reviewer on a healthy backend must
|
|
135
|
+
// stay put even while another backend's window moves its tier.
|
|
136
|
+
if (reviewPair &&
|
|
137
|
+
reviewBackend !== null &&
|
|
138
|
+
windowOut[reviewBackend] !== undefined &&
|
|
139
|
+
reviewBackend !== reviewTierBackend)
|
|
140
|
+
view.review = {
|
|
141
|
+
...view.review,
|
|
142
|
+
backend: reviewPair.backend ?? "pi",
|
|
143
|
+
provider: undefined,
|
|
144
|
+
model: formatModelSelector({
|
|
145
|
+
provider: reviewPair.backend === undefined ? reviewPair.provider : undefined,
|
|
146
|
+
model: reviewPair.model ?? "",
|
|
147
|
+
thinking: reviewPair.thinking,
|
|
148
|
+
}),
|
|
149
|
+
thinking: undefined,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
state.fallbackConfig = view;
|
|
153
|
+
}
|
|
154
|
+
// The tiers the window move put on a fallback pair: those whose own seam is a subscription
|
|
155
|
+
// backend whose window stands.
|
|
156
|
+
const windowTiers = {};
|
|
157
|
+
if (windowMove) {
|
|
158
|
+
const moved = new Set();
|
|
159
|
+
for (const tier of MODEL_TIERS) {
|
|
160
|
+
const b = subscriptionBackendOf(withModelOverrides(liveConfig, tier, {}), accounts);
|
|
161
|
+
if (b !== null && windowOut[b] !== undefined)
|
|
162
|
+
moved.add(tier);
|
|
163
|
+
}
|
|
164
|
+
const labels = tierFallbackLabels(priceResolved);
|
|
165
|
+
for (const tier of MODEL_TIERS)
|
|
166
|
+
if (moved.has(tier) && labels[tier] !== undefined)
|
|
167
|
+
windowTiers[tier] = labels[tier];
|
|
168
|
+
}
|
|
169
|
+
// The director follows availability, not the cap: while the window move stands and its own
|
|
170
|
+
// model job is a subscription backend, its tier's resolved pair replaces the live config (only
|
|
171
|
+
// subscription jobs of a used-up window are unavailable, so a priced director primary keeps
|
|
172
|
+
// its model over the cap). With no fallback (windowMove false) it stays on the live config and
|
|
173
|
+
// the scheduler holds it on that backend's hold.
|
|
174
|
+
const directorBackend = subscriptionBackendOf(configForRole(liveConfig, DIRECTOR_ROLE), accounts);
|
|
175
|
+
const directorMoved = directorBackend !== null && pairNames.length > 0 && directorWindowOutFor(directorBackend) !== null;
|
|
176
|
+
if (!directorMoved)
|
|
177
|
+
state.directorFrom = null;
|
|
178
|
+
else if (state.directorFrom !== liveConfig || state.directorKey !== resolvedKey) {
|
|
179
|
+
state.directorFrom = liveConfig;
|
|
180
|
+
state.directorKey = resolvedKey;
|
|
181
|
+
state.directorView = applyFallbackModel(liveConfig, priceResolved, (seam) => {
|
|
182
|
+
const b = subscriptionBackendOf(seam, accounts);
|
|
183
|
+
return b === null || windowOut[b] === undefined;
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
const windowResumedBackends = [...state.prevWindowMoved].filter((b) => !movedBackends.has(b));
|
|
187
|
+
const windowResumed = windowResumedBackends.length > 0;
|
|
188
|
+
state.prevWindowMoved = movedBackends;
|
|
189
|
+
const movedTiers = new Set();
|
|
190
|
+
// The tiers `applyFallbackModel` moved: those whose own seam fails the `available` test above.
|
|
191
|
+
for (const tier of MODEL_TIERS) {
|
|
192
|
+
const seam = withModelOverrides(liveConfig, tier, {});
|
|
193
|
+
const b = subscriptionBackendOf(seam, accounts);
|
|
194
|
+
const out = (windowMove && b !== null && windowOut[b] !== undefined) || (reached && !seamFree(providers, seam, accounts, catalog));
|
|
195
|
+
if (out)
|
|
196
|
+
movedTiers.add(tier);
|
|
197
|
+
}
|
|
198
|
+
const chain = chainInfo({
|
|
199
|
+
config: liveConfig,
|
|
200
|
+
resolved: servingResolved,
|
|
201
|
+
moved: movedTiers,
|
|
202
|
+
breakers: state.breakers,
|
|
203
|
+
states,
|
|
204
|
+
now,
|
|
205
|
+
windowOutFor,
|
|
206
|
+
accounts,
|
|
207
|
+
isPriced: (p) => !piPairFree(providers, p, accounts, catalog),
|
|
208
|
+
});
|
|
209
|
+
return {
|
|
210
|
+
onFallback,
|
|
211
|
+
chain,
|
|
212
|
+
roleConfig: onFallback || windowMove ? state.fallbackConfig : liveConfig,
|
|
213
|
+
pairs: distinctPairs(priceResolved, chainPairs),
|
|
214
|
+
windowOut,
|
|
215
|
+
windowTiers,
|
|
216
|
+
windowResumed,
|
|
217
|
+
windowResumedBackends,
|
|
218
|
+
directorConfig: directorMoved ? state.directorView : liveConfig,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
/** The `chain` block: for each tier this poll moved off its primary, the step it runs on and
|
|
222
|
+
* why the position before it cannot run (plans/fallback-chain.md "Events and surfaces").
|
|
223
|
+
* `moved` names the tiers the poll moved; a tier the gate pauses (`pair: null`) gets no entry.
|
|
224
|
+
* The reason is read from the position just before the active step — the previous chain step,
|
|
225
|
+
* or the tier's primary at step 0 — in the order demoted, step-cap, window/reserve, overage,
|
|
226
|
+
* cap. */
|
|
227
|
+
export function chainInfo(a) {
|
|
228
|
+
const out = {};
|
|
229
|
+
const steps = tierChainSteps(a.config);
|
|
230
|
+
const midnight = dayAt(-1, new Date(a.now)).getTime();
|
|
231
|
+
for (const tier of MODEL_TIERS) {
|
|
232
|
+
const r = a.resolved[tier];
|
|
233
|
+
if (!a.moved.has(tier) || r.pair === null)
|
|
234
|
+
continue;
|
|
235
|
+
const own = steps.filter((s) => s.tier === (r.from ?? tier)).map((s) => s.pair);
|
|
236
|
+
const idx = r.step ?? 0;
|
|
237
|
+
const prior = idx > 0 ? own[idx - 1] : null;
|
|
238
|
+
// The backend whose used-up window this tier's chain reads for the window/reserve reason:
|
|
239
|
+
// the prior step's own backend when a prior step is subscription, else the primary's.
|
|
240
|
+
const priorSub = prior ? subscriptionBackendOf(prior, a.accounts) : null;
|
|
241
|
+
const primarySub = subscriptionBackendOf(withModelOverrides(a.config, tier, {}), a.accounts);
|
|
242
|
+
const windowBackend = (prior !== null ? priorSub : null) ?? primarySub;
|
|
243
|
+
const info = { pair: modelPairName(r.pair), step: idx + 1, of: own.length, reason: "cap" };
|
|
244
|
+
const priorName = prior ? modelPairName(prior) : "";
|
|
245
|
+
if (prior && a.breakers[priorName] !== undefined && !fallbackServingPair(a.breakers, priorName)) {
|
|
246
|
+
info.reason = "demoted";
|
|
247
|
+
}
|
|
248
|
+
else if (prior?.maxDailyCostUsd !== undefined &&
|
|
249
|
+
pairDailyCost(a.states, priorName, a.now) >= prior.maxDailyCostUsd) {
|
|
250
|
+
info.reason = "step-cap";
|
|
251
|
+
info.returnsAt = midnight;
|
|
252
|
+
}
|
|
253
|
+
else if (windowBackend !== null && a.windowOutFor(windowBackend) !== null) {
|
|
254
|
+
const win = a.windowOutFor(windowBackend);
|
|
255
|
+
info.reason = win.reserve ? "reserve" : "window";
|
|
256
|
+
info.returnsAt = win.resetsAt;
|
|
257
|
+
}
|
|
258
|
+
else if (prior &&
|
|
259
|
+
subscriptionBackendOf(prior, a.accounts) !== null &&
|
|
260
|
+
prior.extraUsage &&
|
|
261
|
+
a.accounts("claude") &&
|
|
262
|
+
claudeOverageRefused(a.accounts("claude"))) {
|
|
263
|
+
info.reason = "overage";
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
info.returnsAt = midnight;
|
|
267
|
+
}
|
|
268
|
+
if (r.pair.maxDailyCostUsd !== undefined && a.isPriced(r.pair)) {
|
|
269
|
+
info.spentUsd = pairDailyCost(a.states, modelPairName(r.pair), a.now);
|
|
270
|
+
info.capUsd = r.pair.maxDailyCostUsd;
|
|
271
|
+
}
|
|
272
|
+
out[tier] = info;
|
|
273
|
+
}
|
|
274
|
+
return out;
|
|
275
|
+
}
|
|
276
|
+
/** The distinct non-null fallback pairs a resolution hands out (tier order, first occurrence
|
|
277
|
+
* first) — the handback match list: a reopen hands back every in-flight run on any of
|
|
278
|
+
* them (part 5c/8's per-tier handback). */
|
|
279
|
+
function distinctPairs(resolved, chainPairs) {
|
|
280
|
+
const out = [];
|
|
281
|
+
// The resolved pairs first, then every other price-free chain step: a run can be on any step.
|
|
282
|
+
const candidates = [...MODEL_TIERS.map((t) => resolved[t].pair), ...chainPairs];
|
|
283
|
+
for (const p of candidates) {
|
|
284
|
+
if (p !== null && !out.some((q) => modelPairName(q) === modelPairName(p)))
|
|
285
|
+
out.push(p);
|
|
286
|
+
}
|
|
287
|
+
return out;
|
|
288
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { MODEL_TIERS } from "../config/config-schema.js";
|
|
2
|
+
import { publishInfoFields } from "../team/orchestrator-info.js";
|
|
3
|
+
import { DIRECTOR_ROLE } from "../roles/roles.js";
|
|
4
|
+
import { logEventBestEffort, TUMWATER_SOURCE } from "../events/events.js";
|
|
5
|
+
import { fallbackDemotion } from "./fallback-breaker.js";
|
|
6
|
+
import { gateRoleConfig, runOnPair, subscriptionBackendOf, } from "../gates/budget-gates.js";
|
|
7
|
+
import { configForRole, roleSeamTier, withModelOverrides } from "../config/config-views.js";
|
|
8
|
+
/** Perform the budget gate's wiring for one poll, exactly as `pollTeamGates` did inline:
|
|
9
|
+
* publish the demotions and the switch/serve info fields, log the one `fallback_switched`
|
|
10
|
+
* transition event per tier change, assign every runner's config and cap flag, and hand
|
|
11
|
+
* in-flight fallback runs back on a cap reopen or window reset. */
|
|
12
|
+
export function applyBudgetGateWiring(input) {
|
|
13
|
+
const { root, runners, liveConfig, info, accounts, budget, view } = input;
|
|
14
|
+
// Publish what observers cannot derive themselves: every pair's demotion — a per-tier
|
|
15
|
+
// fallback can demote a pair the legacy single `fallback` never names, and a dashboard that
|
|
16
|
+
// only sees the engaged pair's verdict would show a tier's roles running while the
|
|
17
|
+
// scheduler holds them (part 5c/8) — plus the engaged pair's own (the legacy
|
|
18
|
+
// `fallbackDemoted` field, what `tumwater doctor` and the failure digest read) and the
|
|
19
|
+
// gate's own spend/cap pair — summed over the runners' live states, which every
|
|
20
|
+
// persisted-file reader lags by the in-flight runs' charges. Both rewritten only when they
|
|
21
|
+
// change, like the build status; the exit removes the whole file, so no stale pair survives
|
|
22
|
+
// a stop.
|
|
23
|
+
const demotions = {};
|
|
24
|
+
for (const [name, b] of Object.entries(budget.breakers)) {
|
|
25
|
+
const d = fallbackDemotion(b);
|
|
26
|
+
if (d)
|
|
27
|
+
demotions[name] = d;
|
|
28
|
+
}
|
|
29
|
+
const engagedDemotion = budget.engaged !== null ? demotions[budget.engaged] : undefined;
|
|
30
|
+
const spend = { spentUsd: view.spentUsd, capUsd: view.capUsd };
|
|
31
|
+
publishInfoFields(root, info, [
|
|
32
|
+
["fallbackDemotions", demotions],
|
|
33
|
+
["fallbackDemoted", engagedDemotion],
|
|
34
|
+
["budget", spend],
|
|
35
|
+
["chain", Object.keys(view.chain).length > 0 ? view.chain : undefined],
|
|
36
|
+
]);
|
|
37
|
+
// One fallback_switched per poll in which a tier's pair or step changed (a return to the
|
|
38
|
+
// primary reads `to: null`, reason "available").
|
|
39
|
+
const switched = {};
|
|
40
|
+
const prevChain = budget.prevChain;
|
|
41
|
+
for (const tier of MODEL_TIERS) {
|
|
42
|
+
const was = prevChain[tier];
|
|
43
|
+
const cur = view.chain[tier];
|
|
44
|
+
if (was?.pair === cur?.pair && was?.step === cur?.step)
|
|
45
|
+
continue;
|
|
46
|
+
switched[tier] = {
|
|
47
|
+
from: was?.pair ?? null,
|
|
48
|
+
to: cur?.pair ?? null,
|
|
49
|
+
reason: cur?.reason ?? "available",
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
if (Object.keys(switched).length > 0)
|
|
53
|
+
logEventBestEffort(root, {
|
|
54
|
+
source: TUMWATER_SOURCE,
|
|
55
|
+
type: "fallback_switched",
|
|
56
|
+
tiers: switched,
|
|
57
|
+
});
|
|
58
|
+
budget.prevChain = view.chain;
|
|
59
|
+
// The director keeps the live config — an explicit human prompt outranks the
|
|
60
|
+
// autonomous-spend cap (gateRoleConfig). Assigned every poll (not only on transitions)
|
|
61
|
+
// so a runner created mid-gate, or one left behind by a broken-file poll that skipped
|
|
62
|
+
// the reload, can never run on the wrong model.
|
|
63
|
+
for (const r of runners) {
|
|
64
|
+
r.config = gateRoleConfig(r.role, liveConfig, view.roleConfig, view.directorConfig);
|
|
65
|
+
// An image reroute must never spend past the daily cap: while the gate has the team on
|
|
66
|
+
// its free fallback pairs, a reroute looks only among those pairs (plans/image-input.md
|
|
67
|
+
// "Reroute target" "Regime"). Set beside the config assignment so the scheduler's regime
|
|
68
|
+
// and the roles' never disagree.
|
|
69
|
+
r.budgetFallbackActive = view.onFallback;
|
|
70
|
+
}
|
|
71
|
+
// The budget reopened: a run that started on a fallback pair keeps it until it ends, so hand
|
|
72
|
+
// those runs back — abort them resumably (session and worktree edits kept) and let their next
|
|
73
|
+
// run continue the same session on the primary, whose config the assignment above already
|
|
74
|
+
// installed (PLANS.md 2026-09-30). Per-tier fallbacks put different roles on different pairs
|
|
75
|
+
// (part 5c/8), so the match runs over EVERY pair the price-based resolution hands out, not only
|
|
76
|
+
// one. A run started on the primary keeps running, the director is exempt, and the merges (the
|
|
77
|
+
// slot's own runs) are untouched. One event per pair names who was handed back, so the resulting
|
|
78
|
+
// aborted runs read as the budget reopening, not as unexplained failures. The subscription
|
|
79
|
+
// window reset (below the cap): the same hand-back for the pairs the window move used. A window
|
|
80
|
+
// that resets while the cap still holds hands back nothing.
|
|
81
|
+
const handBack = view.resumed || (view.windowResumedBackends.length > 0 && !view.budgetActive);
|
|
82
|
+
// The window handback names the backend whose window resumed (the entry's goal) — not the
|
|
83
|
+
// handed-back pair's own backend: for claude-only the fallback pair carries no backend (it would
|
|
84
|
+
// read undefined), and with two subscription backends it would name the opposite one. A pair
|
|
85
|
+
// belongs to those resumed backends whose tiers' price-resolved pair it is.
|
|
86
|
+
const resumedBackendSet = new Set(view.windowResumedBackends);
|
|
87
|
+
const resumedBackendForPair = (leftPair) => {
|
|
88
|
+
for (const tier of MODEL_TIERS) {
|
|
89
|
+
const p = view.priceResolved[tier]?.pair;
|
|
90
|
+
if (p && p.provider === leftPair.provider && p.model === leftPair.model) {
|
|
91
|
+
const b = subscriptionBackendOf(withModelOverrides(liveConfig, tier, {}), accounts);
|
|
92
|
+
if (b !== null && resumedBackendSet.has(b))
|
|
93
|
+
return b;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return undefined;
|
|
97
|
+
};
|
|
98
|
+
if (handBack && view.pairs.length > 0) {
|
|
99
|
+
// A cap reopen while the window is still used up leaves the window's tiers on their
|
|
100
|
+
// fallback pairs: handing those runs back would abort them to restart on the same pair.
|
|
101
|
+
const stillMoved = Object.keys(view.windowTiers).flatMap((t) => {
|
|
102
|
+
const p = view.priceResolved[t].pair;
|
|
103
|
+
return p ? [p] : [];
|
|
104
|
+
});
|
|
105
|
+
for (const leftPair of view.pairs) {
|
|
106
|
+
if (stillMoved.some((p) => p.provider === leftPair.provider && p.model === leftPair.model))
|
|
107
|
+
continue;
|
|
108
|
+
const matching = runners.filter((r) => r.role !== DIRECTOR_ROLE && runOnPair(r.runModelPair(), leftPair));
|
|
109
|
+
if (matching.length > 0) {
|
|
110
|
+
logEventBestEffort(root, {
|
|
111
|
+
source: TUMWATER_SOURCE,
|
|
112
|
+
type: "budget_handback",
|
|
113
|
+
roles: matching.map((r) => r.role),
|
|
114
|
+
provider: leftPair.provider,
|
|
115
|
+
model: leftPair.model,
|
|
116
|
+
...(view.resumed
|
|
117
|
+
? {}
|
|
118
|
+
: { reason: "window", backend: resumedBackendForPair(leftPair) }),
|
|
119
|
+
});
|
|
120
|
+
for (const r of matching)
|
|
121
|
+
r.handBackRun();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** The per-tier budget pause subject sets (part 5c/8), computed exactly as `pollTeamGates` did
|
|
127
|
+
* inline: `budgetPausedRoles` holds every role whose model tier resolved to no usable free pair
|
|
128
|
+
* while the cap is reached, and `windowMovedRoles` the role whose own subscription backend owns
|
|
129
|
+
* a used-up window's moved tier. The director is exempt (as under every autonomous gate), a role
|
|
130
|
+
* below the cap is held only when it was window-moved, and the demoted pair's probe pierces the
|
|
131
|
+
* set for its own tier's roles only — probeRoles in the orchestrator's scheduling pass. */
|
|
132
|
+
export function budgetPausedSubjects(input) {
|
|
133
|
+
const { runners, liveConfig, accounts, budgetActive, servingResolved, windowTiers } = input;
|
|
134
|
+
const budgetPausedRoles = new Set();
|
|
135
|
+
const windowMovedRoles = new Set();
|
|
136
|
+
const movedTiers = Object.keys(windowTiers);
|
|
137
|
+
// The backend whose used-up window moved a given tier: the tier's own seam's backend — the same
|
|
138
|
+
// read budget-gates made to put it in `windowTiers`. A role on a moved tier whose own backend is
|
|
139
|
+
// DIFFERENT (a healthy backend riding the same tier) keeps its pair: only the role whose own
|
|
140
|
+
// backend owns the out window is moved.
|
|
141
|
+
const tierMovedBackend = (tier) => subscriptionBackendOf(withModelOverrides(liveConfig, tier, {}), accounts);
|
|
142
|
+
for (const r of runners) {
|
|
143
|
+
if (r.role === DIRECTOR_ROLE)
|
|
144
|
+
continue;
|
|
145
|
+
const tier = roleSeamTier(liveConfig, r.role);
|
|
146
|
+
// A role pinned to the moved tier's own subscription backend keeps it through the window move
|
|
147
|
+
// (it is not on a pair); a role on the same tier pinning a healthy backend does not.
|
|
148
|
+
if (movedTiers.includes(tier) &&
|
|
149
|
+
subscriptionBackendOf(configForRole(liveConfig, r.role), accounts) === tierMovedBackend(tier))
|
|
150
|
+
windowMovedRoles.add(r.role);
|
|
151
|
+
// Below the cap only a window-moved role can sit on a demoted fallback.
|
|
152
|
+
if (!budgetActive && !windowMovedRoles.has(r.role))
|
|
153
|
+
continue;
|
|
154
|
+
if (servingResolved[tier].pair === null)
|
|
155
|
+
budgetPausedRoles.add(r.role);
|
|
156
|
+
}
|
|
157
|
+
return { budgetPausedRoles, windowMovedRoles };
|
|
158
|
+
}
|
|
@@ -151,7 +151,8 @@ export function modelPairName(p) {
|
|
|
151
151
|
* `strong` tiers resolved to a usable free pair (resolveTierFallbacks over the full usable
|
|
152
152
|
* predicate — price AND breaker serving) — plus whether the review gate is on. The gate is
|
|
153
153
|
* `paused` when `default` resolves to pause (nothing for the team's bulk to run on), or when
|
|
154
|
-
* review is on and `strong` does (nothing could merge
|
|
154
|
+
* review is on and `strong` does (nothing could merge for the roles a pre-merge review gates, so
|
|
155
|
+
* their authoring runs are wasted work — a `review.afterMerge` role's is not);
|
|
155
156
|
* `fallback` otherwise, with the tiers that did resolve running their pairs and the rest held
|
|
156
157
|
* per role. The single definition of the gate: the orchestrator enforces it and both dashboards
|
|
157
158
|
* display it, so what an operator sees is what the scheduler is doing. */
|
|
@@ -3,8 +3,11 @@ import { resolveFromNodeModules } from "./build-check-detect.js";
|
|
|
3
3
|
import { readJsonFile } from "../files/json-files.js";
|
|
4
4
|
import { EXEC_MAX_BUFFER } from "../process/process.js";
|
|
5
5
|
import { KILL_GRACE_MS, runScriptGroup } from "../process/process-group.js";
|
|
6
|
-
import { logEventBestEffort, warnEventBestEffort } from "../events/events.js";
|
|
6
|
+
import { logEventBestEffort, warnEventBestEffort, TUMWATER_SOURCE } from "../events/events.js";
|
|
7
|
+
import { withLock } from "../concurrency/lock.js";
|
|
8
|
+
import { mergeLockDir } from "../paths.js";
|
|
7
9
|
import { errorMessage } from "../text/text.js";
|
|
10
|
+
import { shortSpanPhrase } from "../text/phrases.js";
|
|
8
11
|
/** Keeping a tree's install in step with its lockfile (BUGS.md 2026-10-01). node_modules is
|
|
9
12
|
* gitignored, so a tumwater worktree has none of its own: its toolchain and every import resolve by
|
|
10
13
|
* walking UP to the root checkout's install (detectBuildCheck, resolveFromNodeModules). That
|
|
@@ -116,3 +119,63 @@ export async function syncRootInstall(root, role, install = npmInstall) {
|
|
|
116
119
|
if (!r.ok)
|
|
117
120
|
warnEventBestEffort(root, role, `the root install did not pick up main's dependencies (${r.packages.join(", ")})${r.detail ? `: ${r.detail}` : ""}; checks install into their own trees until a later merge re-syncs it`);
|
|
118
121
|
}
|
|
122
|
+
/** Re-sync the root checkout's install on demand — the `tumwater doctor --resync-install` verb
|
|
123
|
+
* (and, in part 2/2, the Health surfaces' button), the always-on `install` alert's advice made a
|
|
124
|
+
* harness verb. Runs the same `syncInstall` the merge path runs, under the merge lock (the lock
|
|
125
|
+
* tryMerge holds around its own syncRootInstall, merge-to-main.ts), so a merge and the operator
|
|
126
|
+
* never run two root installs at once. No drift spawns nothing and logs nothing; a re-sync logs
|
|
127
|
+
* the same dep_install event syncRootInstall does, under TUMWATER_SOURCE, warning on failure.
|
|
128
|
+
* Never throws. */
|
|
129
|
+
export async function resyncRootInstall(root, install = npmInstall) {
|
|
130
|
+
// Nothing is pinned when no readable package-lock.json names a top-level project — that is not
|
|
131
|
+
// "the tree matches the lockfile" (there is none), so `pinned` tells the two no-drift cases
|
|
132
|
+
// apart for the renderer.
|
|
133
|
+
const readPinned = () => !!readJsonFile(path.join(root, "package-lock.json"))
|
|
134
|
+
?.packages?.[""];
|
|
135
|
+
try {
|
|
136
|
+
return await withLock(mergeLockDir(root), async () => {
|
|
137
|
+
// Read inside the lock: a concurrent merge can swap package-lock.json between a read before
|
|
138
|
+
// the lock and syncInstall, and an `attempted`-false run must match the lockfile the sync
|
|
139
|
+
// would have aligned the tree against.
|
|
140
|
+
const pinned = readPinned();
|
|
141
|
+
const r = await syncInstall(root, install);
|
|
142
|
+
if (!r)
|
|
143
|
+
return { attempted: false, ok: true, packages: [], durationMs: 0, pinned };
|
|
144
|
+
logEventBestEffort(root, {
|
|
145
|
+
source: TUMWATER_SOURCE,
|
|
146
|
+
type: "dep_install",
|
|
147
|
+
packages: r.packages,
|
|
148
|
+
status: r.ok ? "passed" : "failed",
|
|
149
|
+
durationMs: r.durationMs,
|
|
150
|
+
...(r.detail ? { error: r.detail } : {}),
|
|
151
|
+
});
|
|
152
|
+
if (!r.ok)
|
|
153
|
+
warnEventBestEffort(root, TUMWATER_SOURCE, `the manual root install re-sync did not pick up the root lockfile's dependencies (${r.packages.join(", ")})${r.detail ? `: ${r.detail}` : ""}; checks install into their own trees until a later merge re-syncs it`);
|
|
154
|
+
return {
|
|
155
|
+
attempted: true,
|
|
156
|
+
ok: r.ok,
|
|
157
|
+
packages: r.packages,
|
|
158
|
+
durationMs: r.durationMs,
|
|
159
|
+
pinned,
|
|
160
|
+
...(r.detail ? { detail: r.detail } : {}),
|
|
161
|
+
};
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
// A withLock timeout/throw must not throw to the CLI: report it as a failed re-sync so the
|
|
166
|
+
// doctor run still renders its verdict rather than crashing.
|
|
167
|
+
return { attempted: true, ok: false, packages: [], durationMs: 0, pinned: readPinned(), detail: errorMessage(err) };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/** The one renderer the CLI and part 2/2's Health surfaces share for a RootInstallResync — a
|
|
171
|
+
* no-drift run (attempted false) says nothing to do, a success names the re-synced packages, and
|
|
172
|
+
* a failure names its detail. */
|
|
173
|
+
export function rootInstallResyncLines(r) {
|
|
174
|
+
if (!r.attempted)
|
|
175
|
+
return r.pinned
|
|
176
|
+
? ["node_modules already matches package-lock.json — nothing to re-sync"]
|
|
177
|
+
: ["no package-lock.json to match — nothing to re-sync"];
|
|
178
|
+
if (r.ok)
|
|
179
|
+
return [`re-synced the root install (${r.packages.join(", ")}) in ${shortSpanPhrase(r.durationMs)}`];
|
|
180
|
+
return [`the root install re-sync failed: ${r.detail ?? "unknown error"}`];
|
|
181
|
+
}
|
package/dist/src/cli/cli-args.js
CHANGED
|
@@ -125,6 +125,14 @@ export function parseResultFlag(args, missingValueError) {
|
|
|
125
125
|
const { rest, value } = takeValuedFlag(args, "--result", missingValueError, true);
|
|
126
126
|
return { rest, raw: value };
|
|
127
127
|
}
|
|
128
|
+
/** Parse an optional `--status <state>[,...]` flag for the post-merge review view: the raw value
|
|
129
|
+
* (null when absent) alongside `rest`, the args with the value token removed so a state name
|
|
130
|
+
* cannot be read as a positional. Validation of the states belongs to the caller
|
|
131
|
+
* (cmdReviews); a valueless or empty value fails with `missingValueError`. */
|
|
132
|
+
export function parseStatusFlag(args, missingValueError) {
|
|
133
|
+
const { rest, value } = takeValuedFlag(args, "--status", missingValueError, true);
|
|
134
|
+
return { rest, raw: value };
|
|
135
|
+
}
|
|
128
136
|
/** Parse an optional `--model <selector>[,...]` flag for the history trail form: the raw value
|
|
129
137
|
* (null when absent) alongside `rest`, the args with the value token removed so a selector
|
|
130
138
|
* cannot be read as a positional. Validation belongs to the caller (parseHistoryModelList); a
|
|
@@ -165,6 +173,44 @@ export function parsePositionFlag(flag, raw, example) {
|
|
|
165
173
|
fail(`${flag} needs a positive integer${gotSuffix(raw)}`);
|
|
166
174
|
return n;
|
|
167
175
|
}
|
|
176
|
+
/** Scan a queue-style command's edge-triggered flags in one pass: the one valued position
|
|
177
|
+
* flag `--<flag> <n>` (at most once, parsed through parsePositionFlag with the caller's
|
|
178
|
+
* `example` spelling), the `--json` boolean (at most once), any extra boolean edge flags in
|
|
179
|
+
* `extraBooleans` (each at most once), `--help`/`-h` tolerated (dropped), and every other
|
|
180
|
+
* token collected into `rest` for the caller's rejectUnknownArgs. Returns the parsed position,
|
|
181
|
+
* the --json flag, the extra booleans' presence (for the --done/--ready variants), and rest.
|
|
182
|
+
* One home for the scan loop the position-flag command shells used to repeat by hand
|
|
183
|
+
* (`--cancel`, `--top`, `--release`, `--accept`, `--show`), so the at-most-once wording, the
|
|
184
|
+
* plain-decimal rule, and the help-tolerance rule cannot drift among them. */
|
|
185
|
+
export function scanPositionArgs(args, flag, example, extraBooleans = []) {
|
|
186
|
+
let json = false;
|
|
187
|
+
let position = null;
|
|
188
|
+
const flags = {};
|
|
189
|
+
const rest = [];
|
|
190
|
+
for (let i = 0; i < args.length; i++) {
|
|
191
|
+
const arg = args[i] ?? "";
|
|
192
|
+
if (arg === "--json") {
|
|
193
|
+
rejectRepeatFlag(json, "--json");
|
|
194
|
+
json = true;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (extraBooleans.includes(arg)) {
|
|
198
|
+
rejectRepeatFlag(flags[arg] === true, arg);
|
|
199
|
+
flags[arg] = true;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (arg === flag) {
|
|
203
|
+
rejectRepeatFlag(position !== null, flag);
|
|
204
|
+
position = parsePositionFlag(flag, args[i + 1], example);
|
|
205
|
+
i++;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (arg === "--help" || arg === "-h")
|
|
209
|
+
continue;
|
|
210
|
+
rest.push(arg);
|
|
211
|
+
}
|
|
212
|
+
return { json, flags, position, rest };
|
|
213
|
+
}
|
|
168
214
|
/** Resolve a read-only command's `--role <id>` scope: the role id, or null when the flag is
|
|
169
215
|
* absent. The id is validated against built-ins PLUS user-defined roles, read through
|
|
170
216
|
* loadConfigCached (which never throws) so a transiently broken tumwater.json cannot take a
|
|
@@ -316,6 +362,23 @@ export function failOverDurationCap(flag, ms, maxMs, hint) {
|
|
|
316
362
|
export function failRivalShapes(subject, rival, why) {
|
|
317
363
|
fail(`${subject} cannot be combined with ${rival} (${why})`);
|
|
318
364
|
}
|
|
365
|
+
/** Guard a trailing-window command's `--until <window>` against its `--since <window>` pair —
|
|
366
|
+
* the shared fail sentences the three windowed views (logs, history, report) print, so the
|
|
367
|
+
* wording cannot drift per command. When `--until` is present but `--since` is not, it fails
|
|
368
|
+
* `` `${label} --until requires --since` ``; when both are present and the until window reaches
|
|
369
|
+
* or exceeds the since window, it fails `` `${label} --until (<until>) must be shorter than
|
|
370
|
+
* --since (<since>)` `` — each named through the same durationLabel the parser reads, so an
|
|
371
|
+
* operator could retype the window. `sinceMs` is the command's parsed `--since` window (null
|
|
372
|
+
* when it read the day-shape instead), the single value the until window must stay below.
|
|
373
|
+
* Returns silently when both checks pass, like failOverDurationCap above. */
|
|
374
|
+
export function failUntilShorterThanSince(label, untilMs, sinceMs) {
|
|
375
|
+
if (untilMs === null)
|
|
376
|
+
return;
|
|
377
|
+
if (sinceMs === null)
|
|
378
|
+
fail(`${label} --until requires --since`);
|
|
379
|
+
if (sinceMs !== null && untilMs >= sinceMs)
|
|
380
|
+
fail(`${label} --until (${durationLabel(untilMs)}) must be shorter than --since (${durationLabel(sinceMs)})`);
|
|
381
|
+
}
|
|
319
382
|
/** Read an optional `--since <duration>` window from a command's args and cap it: the one
|
|
320
383
|
* home of the flagValue → parseDurationFlag → failOverDurationCap idiom the three windowed
|
|
321
384
|
* read-only views (logs, history, report) share. Returns the parsed milliseconds, or null
|