tumwater 0.4.1 → 0.4.2
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 +4 -6
- package/dist/build-info.json +2 -2
- package/dist/src/agent/backend-traits.js +14 -0
- package/dist/src/agent/claude/claude-stream.js +27 -31
- package/dist/src/agent/codex/codex-rollout.js +38 -24
- package/dist/src/agent/codex/codex-stream.js +71 -38
- package/dist/src/agent/image-input.js +18 -87
- package/dist/src/agent/opencode/opencode-catalog.js +2 -2
- package/dist/src/agent/opencode/opencode-stream.js +4 -4
- package/dist/src/agent/run-agent.js +11 -2
- package/dist/src/agent/run-settle.js +22 -13
- package/dist/src/agent/run-spawn.js +3 -12
- package/dist/src/agent/run-stream.js +2 -10
- package/dist/src/agent/session-ledger.js +16 -0
- package/dist/src/backlog/backlog-archive.js +64 -165
- package/dist/src/backlog/backlog-eligibility.js +50 -58
- package/dist/src/backlog/backlog-file-bugs.js +133 -0
- package/dist/src/backlog/backlog-index-data.js +2 -2
- package/dist/src/backlog/backlog-md.js +38 -27
- package/dist/src/backlog/backlog-release.js +11 -19
- package/dist/src/backlog/backlog-structure.js +14 -1
- package/dist/src/backlog/backlog-write.js +1 -1
- package/dist/src/backlog/backlog.js +5 -4
- package/dist/src/backlog/question-answer.js +30 -14
- package/dist/src/baseline/main-baseline.js +30 -20
- package/dist/src/baseline/main-guardrails.js +54 -17
- package/dist/src/baseline/main-watch.js +1 -1
- package/dist/src/budget/budget-wiring.js +0 -5
- package/dist/src/budget/budget.js +2 -1
- package/dist/src/build/build-check-scoped.js +70 -41
- package/dist/src/change/change-data.js +4 -2
- package/dist/src/cli/cli-command-args.js +199 -98
- package/dist/src/cli/cli-flag-specs.js +1 -4
- package/dist/src/cli/cli-review-commands.js +2 -1
- package/dist/src/cli/cli-role-command.js +27 -50
- package/dist/src/cli/cli-start.js +62 -12
- package/dist/src/cli/follow-view.js +3 -1
- package/dist/src/cli/help.js +6 -7
- package/dist/src/cli/log-commands.js +3 -1
- package/dist/src/cli.js +3 -7
- package/dist/src/collections.js +21 -7
- package/dist/src/config/config-fallbacks.js +0 -4
- package/dist/src/config/config-live.js +179 -147
- package/dist/src/config/config-views.js +2 -7
- package/dist/src/config/config-write.js +19 -26
- package/dist/src/doctor/doctor-backlog-bugs.js +17 -115
- package/dist/src/doctor/doctor-backlog-plans.js +8 -8
- package/dist/src/doctor/doctor-backlog.js +22 -36
- package/dist/src/doctor/doctor-check-names.js +2 -2
- package/dist/src/doctor/doctor-claude-checks.js +2 -2
- package/dist/src/doctor/doctor-contention-checks.js +1 -1
- package/dist/src/doctor/doctor-holds.js +13 -11
- package/dist/src/doctor/doctor-model-checks.js +8 -184
- package/dist/src/doctor/doctor-notify.js +2 -5
- package/dist/src/doctor/doctor-opencode-checks.js +85 -48
- package/dist/src/doctor/doctor-project-checks.js +66 -17
- package/dist/src/doctor/doctor-role-notes.js +6 -24
- package/dist/src/doctor/doctor.js +6 -6
- package/dist/src/doctor/probe.js +1 -1
- package/dist/src/events/event-format.js +5 -15
- package/dist/src/events/event-labels.js +0 -1
- package/dist/src/events/event-legacy.js +1 -1
- package/dist/src/events/event-window.js +1 -1
- package/dist/src/events/events.js +15 -2
- package/dist/src/failure/failure-state-change.js +183 -68
- package/dist/src/failure/time-spend.js +1 -1
- package/dist/src/files/json-object.js +15 -2
- package/dist/src/gates/startup-gate.js +28 -28
- package/dist/src/git/slot-salvage.js +5 -4
- package/dist/src/gui/gui-admin-endpoints.js +2 -3
- package/dist/src/gui/gui-backlog-commands.js +1 -1
- package/dist/src/gui/gui-backlog-endpoints.js +1 -7
- package/dist/src/gui/gui-endpoint-commands.js +1 -1
- package/dist/src/gui/gui-operator-commands.js +0 -12
- package/dist/src/gui/gui-prompt-commands.js +26 -20
- package/dist/src/gui/gui-report-endpoints.js +2 -1
- package/dist/src/gui/gui-server.js +1 -2
- package/dist/src/history/history.js +102 -67
- package/dist/src/inbox/inbox-submit.js +1 -5
- package/dist/src/inbox/prompt-commands.js +24 -19
- package/dist/src/init/init.js +68 -15
- package/dist/src/merge/merge-core.js +1 -1
- package/dist/src/merge/merge-post-review-drain.js +1 -1
- package/dist/src/operator/operator-commands.js +68 -47
- package/dist/src/operator/retire.js +2 -1
- package/dist/src/orchestrator/retention.js +6 -5
- package/dist/src/paths.js +0 -6
- package/dist/src/pi/pi-watchdogs.js +20 -1
- package/dist/src/pi-extension/context-shake.js +55 -31
- package/dist/src/process/process.js +23 -0
- package/dist/src/process/supervisor.js +17 -1
- package/dist/src/prompt/gate-notes.js +9 -9
- package/dist/src/prompt/notes-backlog.js +11 -24
- package/dist/src/prompt/prompt-followup.js +4 -12
- package/dist/src/prompt/prompt.js +4 -5
- package/dist/src/report/report-data.js +10 -225
- package/dist/src/report/report-fold.js +20 -295
- package/dist/src/report/report-main-red.js +45 -42
- package/dist/src/report/report-render.js +66 -39
- package/dist/src/report/report-since-data.js +218 -0
- package/dist/src/report/report-usage-fold.js +288 -0
- package/dist/src/report/report.js +2 -1
- package/dist/src/review/known-flakes.js +3 -3
- package/dist/src/review/post-merge-review-accept.js +3 -2
- package/dist/src/review/post-merge-review.js +9 -9
- package/dist/src/review/quarantine-render.js +2 -1
- package/dist/src/roles/role-catalog.js +18 -30
- package/dist/src/roles/role-guidance.js +18 -43
- package/dist/src/roles/role-view.js +1 -2
- package/dist/src/run/ci-parity.js +5 -6
- package/dist/src/run/qa-coverage.js +4 -4
- package/dist/src/run/run-detail-data.js +23 -7
- package/dist/src/run/run-detail-render.js +6 -0
- package/dist/src/run/run-detail.js +44 -22
- package/dist/src/run/run-dismiss.js +43 -0
- package/dist/src/run/run-prompt.js +100 -56
- package/dist/src/run/run-resume.js +32 -31
- package/dist/src/run/run-stage.js +4 -21
- package/dist/src/run/run-timing.js +18 -4
- package/dist/src/run/run-verdict.js +8 -73
- package/dist/src/run/stage-backlog-checks.js +2 -29
- package/dist/src/run/stage-check.js +4 -12
- package/dist/src/runner/runner-outcome.js +0 -5
- package/dist/src/runner/runner-run.js +2 -79
- package/dist/src/runner/runner-state-load.js +0 -10
- package/dist/src/runner/runner.js +1 -32
- package/dist/src/scheduling/claims.js +3 -10
- package/dist/src/status/status-data.js +5 -9
- package/dist/src/status/status-role-notes.js +18 -14
- package/dist/src/status/status-roles.js +2 -35
- package/dist/src/text/datetime.js +8 -1
- package/dist/src/text/text.js +12 -0
- package/dist/src/ui/alert-feeds.js +7 -212
- package/dist/src/ui/badges.js +2 -3
- package/dist/src/ui/gui/gui-client-boot.js +19 -4
- package/dist/src/ui/gui/gui-client-drawer.js +1 -1
- package/dist/src/ui/gui/gui-client-report-chart.js +10 -8
- package/dist/src/ui/gui/gui-client-report.js +1 -2
- package/dist/src/ui/gui/gui-client-settings.js +0 -23
- package/dist/src/ui/gui/gui-client-team.js +26 -0
- package/dist/src/ui/gui/gui-icons.js +7 -1
- package/dist/src/ui/status-model.js +2 -2
- package/dist/src/ui/status-payload.js +3 -3
- package/dist/src/ui/status-render.js +3 -3
- package/dist/src/ui/team-alert-backlog.js +23 -54
- package/dist/src/ui/team-alert-shared.js +23 -10
- package/dist/src/ui/team-alert-status.js +8 -3
- package/dist/src/ui/team-alert-tail.js +1 -14
- package/dist/src/ui/team-alerts.js +6 -17
- package/dist/src/ui/transcript-tail.js +137 -94
- package/dist/src/ui/tui/tui-editor-answer.js +54 -44
- package/dist/src/ui/tui/tui-editor-attach.js +74 -63
- package/dist/src/ui/tui/tui-editor-backends.js +62 -56
- package/dist/src/ui/tui/tui-editor-brief.js +64 -54
- package/dist/src/ui/tui/tui-editor-budget.js +105 -93
- package/dist/src/ui/tui/tui-editor-config.js +2 -4
- package/dist/src/ui/tui/tui-editor-entry.js +171 -153
- package/dist/src/ui/tui/tui-editor-instances.js +2 -1
- package/dist/src/ui/tui/tui-editor-pause.js +209 -183
- package/dist/src/ui/tui/tui-editor-prompt.js +44 -34
- package/dist/src/ui/tui/tui-editor-queued.js +74 -64
- package/dist/src/ui/tui/tui-editor-quiet.js +121 -108
- package/dist/src/ui/tui/tui-editor-release.js +50 -42
- package/dist/src/ui/tui/tui-editor-retire.js +57 -46
- package/dist/src/ui/tui/tui-editor-role-enabled.js +90 -80
- package/dist/src/ui/tui/tui-editor-role-file.js +58 -46
- package/dist/src/ui/tui/tui-editor-role-key.js +49 -42
- package/dist/src/ui/tui/tui-editor-search.js +70 -58
- package/dist/src/ui/tui/tui-frame.js +1 -1
- package/dist/src/ui/tui/tui-keys.js +1 -1
- package/dist/src/ui/tui/tui-pane-activity.js +62 -50
- package/dist/src/ui/tui/tui-pane-backlog.js +4 -4
- package/dist/src/ui/tui/tui-pane-health.js +3 -24
- package/dist/src/ui/tui/tui-pane-history.js +20 -24
- package/dist/src/ui/tui/tui-pane-report.js +74 -57
- package/dist/src/ui/tui/tui-pane-session.js +3 -2
- package/dist/src/ui/tui/tui-pane-transcript.js +151 -128
- package/dist/src/ui/tui/tui-pane.js +201 -174
- package/dist/src/ui/tui/tui-panes.js +2 -3
- package/dist/src/ui/tui/tui.js +3 -3
- package/dist/src/verdict/fix-claim.js +18 -6
- package/dist/src/verdict/refusal.js +30 -6
- package/package.json +1 -1
- package/dist/src/doctor/image-probe.js +0 -257
- package/dist/src/ui/team-alert-backlog-feed.js +0 -181
- package/dist/src/ui/team-alert-merge.js +0 -183
|
@@ -47,163 +47,195 @@ export function newLiveConfigReload(deps) {
|
|
|
47
47
|
// the config application that follows the emission. bestEffortEventLogger (events.ts) holds
|
|
48
48
|
// the shared "logging must not throw" policy.
|
|
49
49
|
const { emit, warn } = bestEffortEventLogger(deps.root);
|
|
50
|
+
// Warn once per vanish and once per reappearance of tumwater.json.
|
|
51
|
+
function noteConfigPresence(reloaded) {
|
|
52
|
+
// A missing file keeps the last-known-good config too, and never reloads as defaults: the
|
|
53
|
+
// team was started on a real file (startup refuses to run without one), so its vanishing
|
|
54
|
+
// mid-run is an incident — a merge's fast-forward deleted it on 2026-09-22 and the team
|
|
55
|
+
// silently ran 8.6 h on defaults (BUGS.md 2026-09-23). One warning per vanish, not per
|
|
56
|
+
// poll; its return logs one line, then the normal reload below diffs it against the
|
|
57
|
+
// retained config — so config_changed names only what the returned file really changed.
|
|
58
|
+
if (reloaded.missing) {
|
|
59
|
+
if (!configMissing) {
|
|
60
|
+
warn(`tumwater.json missing — keeping current config until it returns (${configPath(deps.root)})`);
|
|
61
|
+
configMissing = true;
|
|
62
|
+
lastConfigError = null; // Whatever state it returns in is stated afresh.
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
else if (configMissing) {
|
|
66
|
+
warn("tumwater.json reappeared — reloading it");
|
|
67
|
+
configMissing = false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// Refuse a live edit that routes a seam to an API-key-logged-in codex, keeping prevLive.
|
|
71
|
+
function refuseApiKeyCodex(nextConfig, prevBackends) {
|
|
72
|
+
// A live edit that routes a seam to a codex logged in with an API key is refused before
|
|
73
|
+
// the reload is applied: startup refuses the same codex seam (decision 5), and here a
|
|
74
|
+
// warn-only would let the running team spend per-token Codex the budget cap cannot see
|
|
75
|
+
// (review objection 1). Refusing keeps the last-known-good config, like a missing or
|
|
76
|
+
// broken file. The seed runs once per root (`codex login status` is a real probe); the
|
|
77
|
+
// persisted account file is the voice of the refusal on later polls, re-probing a stale
|
|
78
|
+
// api answer (codex-account.ts) so the operator's `codex login --device-auth` remedy
|
|
79
|
+
// clears it at the next seed. A subscription codex passes through to the backend loop.
|
|
80
|
+
const newlyRoutedCodex = !prevBackends.has("codex") && backendsInUse(nextConfig).includes("codex");
|
|
81
|
+
if (newlyRoutedCodex && !liveCodexSeedAttempted.has(deps.root)) {
|
|
82
|
+
liveCodexSeedAttempted.add(deps.root);
|
|
83
|
+
seedCodexAccount(deps.root, nextConfig);
|
|
84
|
+
}
|
|
85
|
+
if (newlyRoutedCodex && readCodexAccount(deps.root)?.billing === "api") {
|
|
86
|
+
if (codexLiveRefused)
|
|
87
|
+
return true;
|
|
88
|
+
codexLiveRefused = true;
|
|
89
|
+
warn("refusing live edit: Codex is logged in with an API key; tumwater runs Codex on a ChatGPT subscription only — run `CODEX_HOME=<home> codex login --device-auth`");
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
// A poll that did not refuse clears the warn-once marker so a later re-entry into the
|
|
93
|
+
// refusal (the operator reverts, then re-adds the API-billed codex seam) warns again.
|
|
94
|
+
codexLiveRefused = false;
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
// Emit config_changed and model_changed for what this reload actually changed.
|
|
98
|
+
function emitConfigChangeEvents(nextConfig) {
|
|
99
|
+
// A live edit that changes behavior elsewhere logs one event naming the settings that
|
|
100
|
+
// changed (maxConcurrent and sessionRetentionDays have their own events elsewhere).
|
|
101
|
+
const changedKeys = changedConfigKeys(prevLive, nextConfig);
|
|
102
|
+
if (changedKeys.length > 0) {
|
|
103
|
+
emit({ source: TUMWATER_SOURCE, type: "config_changed", keys: changedKeys });
|
|
104
|
+
// A real model-wiring change gets its own event naming the new selector(s), which the
|
|
105
|
+
// key list cannot show; a non-model edit (instructions, an interval) emits nothing.
|
|
106
|
+
const fromModel = teamModelLabel(prevLive);
|
|
107
|
+
const toModel = teamModelLabel(nextConfig);
|
|
108
|
+
const roleDiffs = changedKeys
|
|
109
|
+
.filter((key) => key.startsWith("roles."))
|
|
110
|
+
.map((key) => {
|
|
111
|
+
const role = key.slice("roles.".length);
|
|
112
|
+
const from = modelSelectorField(configForRole(prevLive, role)).model ?? null;
|
|
113
|
+
const to = modelSelectorField(configForRole(nextConfig, role)).model ?? null;
|
|
114
|
+
return { role, from, to };
|
|
115
|
+
})
|
|
116
|
+
.filter((d) => d.from !== d.to);
|
|
117
|
+
if (fromModel !== toModel || roleDiffs.length > 0)
|
|
118
|
+
emit({
|
|
119
|
+
source: TUMWATER_SOURCE,
|
|
120
|
+
type: "model_changed",
|
|
121
|
+
from: fromModel,
|
|
122
|
+
to: toModel,
|
|
123
|
+
...(roleDiffs.length > 0 ? { roles: roleDiffs } : {}),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// Verify and seed each backend a live edit newly routes to.
|
|
128
|
+
function seedNewlyRoutedBackends(nextConfig, prevBackends) {
|
|
129
|
+
// A live edit that routes a seam to a backend the previous config did not use: the
|
|
130
|
+
// team keeps running, but that backend must be verified and its account (claude) or
|
|
131
|
+
// models catalog (opencode) seeded, exactly as startup would — so a claude reviewer
|
|
132
|
+
// added to a pi team does not start uncheckable. A missing binary is a warning event,
|
|
133
|
+
// never a crash; a present one logs its verdict as a backend_binary event. The seed is
|
|
134
|
+
// attempted once per root. (The codex seed moved to the refusal check at the top of
|
|
135
|
+
// this block, which also refuses an API-key-logged-in codex seam.)
|
|
136
|
+
for (const backend of backendsInUse(nextConfig)) {
|
|
137
|
+
if (prevBackends.has(backend))
|
|
138
|
+
continue;
|
|
139
|
+
const resolved = resolveBackendBin(nextConfig, backend);
|
|
140
|
+
if (findBackendBinary(resolved)) {
|
|
141
|
+
emit({ source: TUMWATER_SOURCE, type: "backend_binary", agent: backend, found: true });
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
warn(`newly routed ${backend} backend: ${backendMissingMessage(resolved, backend)}`);
|
|
145
|
+
}
|
|
146
|
+
if (backend === "claude" && !liveClaudeSeedAttempted.has(deps.root)) {
|
|
147
|
+
liveClaudeSeedAttempted.add(deps.root);
|
|
148
|
+
seedClaudeAccount(deps.root, nextConfig);
|
|
149
|
+
}
|
|
150
|
+
if (backend === "opencode" && !liveOpencodeSeedAttempted.has(deps.root)) {
|
|
151
|
+
liveOpencodeSeedAttempted.add(deps.root);
|
|
152
|
+
// Not awaited: poll() is synchronous and a models.dev fetch must not stall the loop.
|
|
153
|
+
void seedOpencodeCatalog(deps.root, nextConfig, { ...deps.opencodeSeed, warn }).catch((err) => warn(`open code model catalog seed failed: ${errorMessage(err)}`));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// Live-resize the concurrency cap and the per-provider key caps.
|
|
158
|
+
function resizeConcurrency(nextConfig) {
|
|
159
|
+
// Live-resize the concurrency cap: a mid-run edit changes how many pi runs execute
|
|
160
|
+
// concurrently within this poll — no restart. Growing admits already-queued runs;
|
|
161
|
+
// shrinking never preempts in-flight work, it only caps future grants.
|
|
162
|
+
const newMaxConcurrent = Math.max(1, nextConfig.maxConcurrent);
|
|
163
|
+
if (newMaxConcurrent !== lastMaxConcurrent) {
|
|
164
|
+
deps.semaphore.setCapacity(newMaxConcurrent);
|
|
165
|
+
emit({ source: TUMWATER_SOURCE, type: "max_concurrent_changed", from: lastMaxConcurrent, to: newMaxConcurrent });
|
|
166
|
+
lastMaxConcurrent = newMaxConcurrent;
|
|
167
|
+
}
|
|
168
|
+
// Live-resize the per-provider caps (plans/provider-concurrency.md §7): an edit
|
|
169
|
+
// re-applies the whole map to the pool, and each changed provider fires its own
|
|
170
|
+
// `provider_concurrency_changed` event. setKeyCaps never preempts — shrinking only
|
|
171
|
+
// withholds future grants on that key until releases drain it under the new cap.
|
|
172
|
+
const nextKeyCaps = nextConfig.maxConcurrentPerProvider ?? {};
|
|
173
|
+
deps.semaphore.setKeyCaps(nextKeyCaps);
|
|
174
|
+
for (const provider of new Set([...Object.keys(lastKeyCaps), ...Object.keys(nextKeyCaps)])) {
|
|
175
|
+
const from = lastKeyCaps[provider];
|
|
176
|
+
const to = nextKeyCaps[provider];
|
|
177
|
+
if (from !== to) {
|
|
178
|
+
emit({
|
|
179
|
+
source: TUMWATER_SOURCE,
|
|
180
|
+
type: "provider_concurrency_changed",
|
|
181
|
+
provider,
|
|
182
|
+
from: from ?? 0,
|
|
183
|
+
to: to ?? 0,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
lastKeyCaps = nextKeyCaps;
|
|
188
|
+
}
|
|
189
|
+
// Sync the runner list to newly enabled roles and warn on enable/disable.
|
|
190
|
+
function syncRunners(nextConfig) {
|
|
191
|
+
const nowEnabled = runnerIds(nextConfig);
|
|
192
|
+
// Enabling a role — or raising `roles.<id>.instances` — mid-run starts it: create the
|
|
193
|
+
// runner for each newly present role id (its persisted state survives). A lowered
|
|
194
|
+
// instance count keeps its runner; `runnerEnabled` skips its future runs. A scoped round
|
|
195
|
+
// skips every other id: the enabling is logged (the warn loop below), but its runner
|
|
196
|
+
// waits for the next unscoped round.
|
|
197
|
+
for (const role of nowEnabled) {
|
|
198
|
+
if (deps.roleFilter !== undefined && !deps.roleFilter.includes(role))
|
|
199
|
+
continue;
|
|
200
|
+
if (!deps.runners.some((r) => r.role === role))
|
|
201
|
+
deps.runners.push(new RoleRunner(deps.root, role, nextConfig, deps.mainBranch, deps.runnerSignal));
|
|
202
|
+
}
|
|
203
|
+
for (const role of prevEnabled)
|
|
204
|
+
if (!nowEnabled.includes(role))
|
|
205
|
+
warn(`role ${role} disabled — no new runs`);
|
|
206
|
+
for (const role of nowEnabled)
|
|
207
|
+
if (!prevEnabled.has(role))
|
|
208
|
+
warn(`role ${role} enabled — starting runs`);
|
|
209
|
+
prevEnabled = new Set(nowEnabled);
|
|
210
|
+
}
|
|
211
|
+
// Warn once per distinct broken-file error text.
|
|
212
|
+
function noteInvalidConfig(reloaded) {
|
|
213
|
+
if (reloaded.error && reloaded.error !== lastConfigError) {
|
|
214
|
+
warn(`tumwater.json invalid — keeping current config: ${reloaded.error}`);
|
|
215
|
+
lastConfigError = reloaded.error;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
50
218
|
return {
|
|
51
219
|
poll() {
|
|
52
220
|
const reloaded = loadConfigCached(deps.root);
|
|
53
|
-
|
|
54
|
-
// team was started on a real file (startup refuses to run without one), so its vanishing
|
|
55
|
-
// mid-run is an incident — a merge's fast-forward deleted it on 2026-09-22 and the team
|
|
56
|
-
// silently ran 8.6 h on defaults (BUGS.md 2026-09-23). One warning per vanish, not per
|
|
57
|
-
// poll; its return logs one line, then the normal reload below diffs it against the
|
|
58
|
-
// retained config — so config_changed names only what the returned file really changed.
|
|
59
|
-
if (reloaded.missing) {
|
|
60
|
-
if (!configMissing) {
|
|
61
|
-
warn(`tumwater.json missing — keeping current config until it returns (${configPath(deps.root)})`);
|
|
62
|
-
configMissing = true;
|
|
63
|
-
lastConfigError = null; // Whatever state it returns in is stated afresh.
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
else if (configMissing) {
|
|
67
|
-
warn("tumwater.json reappeared — reloading it");
|
|
68
|
-
configMissing = false;
|
|
69
|
-
}
|
|
221
|
+
noteConfigPresence(reloaded);
|
|
70
222
|
if (reloaded.config) {
|
|
71
223
|
const nextConfig = reloaded.config;
|
|
72
|
-
// A live edit that routes a seam to a codex logged in with an API key is refused before
|
|
73
|
-
// the reload is applied: startup refuses the same codex seam (decision 5), and here a
|
|
74
|
-
// warn-only would let the running team spend per-token Codex the budget cap cannot see
|
|
75
|
-
// (review objection 1). Refusing keeps the last-known-good config, like a missing or
|
|
76
|
-
// broken file. The seed runs once per root (`codex login status` is a real probe); the
|
|
77
|
-
// persisted account file is the voice of the refusal on later polls, re-probing a stale
|
|
78
|
-
// api answer (codex-account.ts) so the operator's `codex login --device-auth` remedy
|
|
79
|
-
// clears it at the next seed. A subscription codex passes through to the backend loop.
|
|
80
224
|
const prevBackends = new Set(backendsInUse(prevLive));
|
|
81
|
-
|
|
82
|
-
if (newlyRoutedCodex && !liveCodexSeedAttempted.has(deps.root)) {
|
|
83
|
-
liveCodexSeedAttempted.add(deps.root);
|
|
84
|
-
seedCodexAccount(deps.root, nextConfig);
|
|
85
|
-
}
|
|
86
|
-
if (newlyRoutedCodex && readCodexAccount(deps.root)?.billing === "api") {
|
|
87
|
-
if (codexLiveRefused)
|
|
88
|
-
return prevLive;
|
|
89
|
-
codexLiveRefused = true;
|
|
90
|
-
warn("refusing live edit: Codex is logged in with an API key; tumwater runs Codex on a ChatGPT subscription only — run `CODEX_HOME=<home> codex login --device-auth`");
|
|
225
|
+
if (refuseApiKeyCodex(nextConfig, prevBackends))
|
|
91
226
|
return prevLive;
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
live = reloaded.config;
|
|
97
|
-
// A live edit that changes behavior elsewhere logs one event naming the settings that
|
|
98
|
-
// changed (maxConcurrent and sessionRetentionDays have their own events elsewhere).
|
|
99
|
-
const changedKeys = changedConfigKeys(prevLive, reloaded.config);
|
|
100
|
-
if (changedKeys.length > 0) {
|
|
101
|
-
emit({ source: TUMWATER_SOURCE, type: "config_changed", keys: changedKeys });
|
|
102
|
-
// A real model-wiring change gets its own event naming the new selector(s), which the
|
|
103
|
-
// key list cannot show; a non-model edit (instructions, an interval) emits nothing.
|
|
104
|
-
const fromModel = teamModelLabel(prevLive);
|
|
105
|
-
const toModel = teamModelLabel(reloaded.config);
|
|
106
|
-
const roleDiffs = changedKeys
|
|
107
|
-
.filter((key) => key.startsWith("roles."))
|
|
108
|
-
.map((key) => {
|
|
109
|
-
const role = key.slice("roles.".length);
|
|
110
|
-
const from = modelSelectorField(configForRole(prevLive, role)).model ?? null;
|
|
111
|
-
const to = modelSelectorField(configForRole(nextConfig, role)).model ?? null;
|
|
112
|
-
return { role, from, to };
|
|
113
|
-
})
|
|
114
|
-
.filter((d) => d.from !== d.to);
|
|
115
|
-
if (fromModel !== toModel || roleDiffs.length > 0)
|
|
116
|
-
emit({
|
|
117
|
-
source: TUMWATER_SOURCE,
|
|
118
|
-
type: "model_changed",
|
|
119
|
-
from: fromModel,
|
|
120
|
-
to: toModel,
|
|
121
|
-
...(roleDiffs.length > 0 ? { roles: roleDiffs } : {}),
|
|
122
|
-
});
|
|
123
|
-
}
|
|
124
|
-
// A live edit that routes a seam to a backend the previous config did not use: the
|
|
125
|
-
// team keeps running, but that backend must be verified and its account (claude) or
|
|
126
|
-
// models catalog (opencode) seeded, exactly as startup would — so a claude reviewer
|
|
127
|
-
// added to a pi team does not start uncheckable. A missing binary is a warning event,
|
|
128
|
-
// never a crash; a present one logs its verdict as a backend_binary event. The seed is
|
|
129
|
-
// attempted once per root. (The codex seed moved to the refusal check at the top of
|
|
130
|
-
// this block, which also refuses an API-key-logged-in codex seam.)
|
|
131
|
-
for (const backend of backendsInUse(reloaded.config)) {
|
|
132
|
-
if (prevBackends.has(backend))
|
|
133
|
-
continue;
|
|
134
|
-
const resolved = resolveBackendBin(reloaded.config, backend);
|
|
135
|
-
if (findBackendBinary(resolved)) {
|
|
136
|
-
emit({ source: TUMWATER_SOURCE, type: "backend_binary", agent: backend, found: true });
|
|
137
|
-
}
|
|
138
|
-
else {
|
|
139
|
-
warn(`newly routed ${backend} backend: ${backendMissingMessage(resolved, backend)}`);
|
|
140
|
-
}
|
|
141
|
-
if (backend === "claude" && !liveClaudeSeedAttempted.has(deps.root)) {
|
|
142
|
-
liveClaudeSeedAttempted.add(deps.root);
|
|
143
|
-
seedClaudeAccount(deps.root, reloaded.config);
|
|
144
|
-
}
|
|
145
|
-
if (backend === "opencode" && !liveOpencodeSeedAttempted.has(deps.root)) {
|
|
146
|
-
liveOpencodeSeedAttempted.add(deps.root);
|
|
147
|
-
// Not awaited: poll() is synchronous and a models.dev fetch must not stall the loop.
|
|
148
|
-
void seedOpencodeCatalog(deps.root, reloaded.config, { ...deps.opencodeSeed, warn }).catch((err) => warn(`open code model catalog seed failed: ${errorMessage(err)}`));
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
prevLive = reloaded.config;
|
|
227
|
+
live = nextConfig;
|
|
228
|
+
emitConfigChangeEvents(nextConfig);
|
|
229
|
+
seedNewlyRoutedBackends(nextConfig, prevBackends);
|
|
230
|
+
prevLive = nextConfig;
|
|
152
231
|
for (const r of deps.runners)
|
|
153
|
-
r.config =
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
// shrinking never preempts in-flight work, it only caps future grants.
|
|
157
|
-
const newMaxConcurrent = Math.max(1, reloaded.config.maxConcurrent);
|
|
158
|
-
if (newMaxConcurrent !== lastMaxConcurrent) {
|
|
159
|
-
deps.semaphore.setCapacity(newMaxConcurrent);
|
|
160
|
-
emit({ source: TUMWATER_SOURCE, type: "max_concurrent_changed", from: lastMaxConcurrent, to: newMaxConcurrent });
|
|
161
|
-
lastMaxConcurrent = newMaxConcurrent;
|
|
162
|
-
}
|
|
163
|
-
// Live-resize the per-provider caps (plans/provider-concurrency.md §7): an edit
|
|
164
|
-
// re-applies the whole map to the pool, and each changed provider fires its own
|
|
165
|
-
// `provider_concurrency_changed` event. setKeyCaps never preempts — shrinking only
|
|
166
|
-
// withholds future grants on that key until releases drain it under the new cap.
|
|
167
|
-
const nextKeyCaps = reloaded.config.maxConcurrentPerProvider ?? {};
|
|
168
|
-
deps.semaphore.setKeyCaps(nextKeyCaps);
|
|
169
|
-
for (const provider of new Set([...Object.keys(lastKeyCaps), ...Object.keys(nextKeyCaps)])) {
|
|
170
|
-
const from = lastKeyCaps[provider];
|
|
171
|
-
const to = nextKeyCaps[provider];
|
|
172
|
-
if (from !== to) {
|
|
173
|
-
emit({
|
|
174
|
-
source: TUMWATER_SOURCE,
|
|
175
|
-
type: "provider_concurrency_changed",
|
|
176
|
-
provider,
|
|
177
|
-
from: from ?? 0,
|
|
178
|
-
to: to ?? 0,
|
|
179
|
-
});
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
lastKeyCaps = nextKeyCaps;
|
|
183
|
-
const nowEnabled = runnerIds(reloaded.config);
|
|
184
|
-
// Enabling a role — or raising `roles.<id>.instances` — mid-run starts it: create the
|
|
185
|
-
// runner for each newly present role id (its persisted state survives). A lowered
|
|
186
|
-
// instance count keeps its runner; `runnerEnabled` skips its future runs. A scoped round
|
|
187
|
-
// skips every other id: the enabling is logged (the warn loop below), but its runner
|
|
188
|
-
// waits for the next unscoped round.
|
|
189
|
-
for (const role of nowEnabled) {
|
|
190
|
-
if (deps.roleFilter !== undefined && !deps.roleFilter.includes(role))
|
|
191
|
-
continue;
|
|
192
|
-
if (!deps.runners.some((r) => r.role === role))
|
|
193
|
-
deps.runners.push(new RoleRunner(deps.root, role, reloaded.config, deps.mainBranch, deps.runnerSignal));
|
|
194
|
-
}
|
|
195
|
-
for (const role of prevEnabled)
|
|
196
|
-
if (!nowEnabled.includes(role))
|
|
197
|
-
warn(`role ${role} disabled — no new runs`);
|
|
198
|
-
for (const role of nowEnabled)
|
|
199
|
-
if (!prevEnabled.has(role))
|
|
200
|
-
warn(`role ${role} enabled — starting runs`);
|
|
201
|
-
prevEnabled = new Set(nowEnabled);
|
|
232
|
+
r.config = nextConfig;
|
|
233
|
+
resizeConcurrency(nextConfig);
|
|
234
|
+
syncRunners(nextConfig);
|
|
202
235
|
lastConfigError = null;
|
|
203
236
|
}
|
|
204
|
-
else
|
|
205
|
-
|
|
206
|
-
lastConfigError = reloaded.error;
|
|
237
|
+
else {
|
|
238
|
+
noteInvalidConfig(reloaded);
|
|
207
239
|
}
|
|
208
240
|
return live;
|
|
209
241
|
},
|
|
@@ -109,12 +109,7 @@ export function tierModel(config, tier) {
|
|
|
109
109
|
* key it overrides would.
|
|
110
110
|
*
|
|
111
111
|
* `tier` is the seam's effective tier, already resolved by the caller from any tier-name
|
|
112
|
-
* override — so `o.model` here is always a selector string, never a tier reference.
|
|
113
|
-
*
|
|
114
|
-
* Exported so imageRerouteTarget (src/agent/image-input.ts) can probe a peer tier's
|
|
115
|
-
* top-level model (`withModelOverrides(config, tier, {})`, the same shape resolverConfig
|
|
116
|
-
* builds strong with) and reshape a tier's fallback pair into a runnable config — the
|
|
117
|
-
* tier-config builder the plan names. */
|
|
112
|
+
* override — so `o.model` here is always a selector string, never a tier reference. */
|
|
118
113
|
export function withModelOverrides(config, tier, o) {
|
|
119
114
|
// The top-level model resolves at the seam's tier: a string form is the default tier's
|
|
120
115
|
// selector; a map form takes the tier's entry, else `default`'s.
|
|
@@ -204,7 +199,7 @@ export function configForRole(config, role) {
|
|
|
204
199
|
}
|
|
205
200
|
/** The config a role's runs SCHEDULE from: configForRole, except that while PLANS.md still
|
|
206
201
|
* has planning room (planBacklogOpen — below the plan role's eligible-plan target, or holding
|
|
207
|
-
* a plan that needs review
|
|
202
|
+
* a plan that needs review) the plan role schedules with a ZERO min gap, so a
|
|
208
203
|
* productive plan run reruns on the next poll and an unproductive one drops to the idle
|
|
209
204
|
* backoff. The plan role's own machine signal is `planBacklogFull` (src/scheduling/claims.ts):
|
|
210
205
|
* a fresh project's empty backlog is not full, so this subsumes the old placeholder-status
|
|
@@ -13,12 +13,28 @@
|
|
|
13
13
|
import { isDeepStrictEqual } from "node:util";
|
|
14
14
|
import { modelTierMap } from "./config-schema.js";
|
|
15
15
|
import { isJsonObject, objectOrEmpty } from "../files/json-object.js";
|
|
16
|
+
import { isExponentSpelling } from "../text/text.js";
|
|
16
17
|
import { show } from "./config-field-checks.js";
|
|
17
18
|
import { canonicalConfigKey } from "./config-aliases.js";
|
|
18
19
|
import { unknownRoleMessage } from "../roles/roles.js";
|
|
19
20
|
import { loadConfig, collapseBackendAliases, knownRoleIdsCached, } from "./config.js";
|
|
20
21
|
import { checkDailyBudgetUsd, checkProviderConcurrencyCap, checkQuietHours, parseConfigKey, PER_KEY_VALIDATORS, unknownConfigKeyError, unknownRoleFieldError, } from "./config-keys.js";
|
|
21
22
|
import { writeConfigMutation, fail, rawConfigObject, without, preservingFileRoles, preserveFileRoles, withSectionOrDropped, } from "./config-write-core.js";
|
|
23
|
+
/** Drop `field` from the `backends.<backend>` entry of the config `next`, dropping the whole
|
|
24
|
+
* backend entry when it empties. The one home of the "remove a nested backend field, drop the
|
|
25
|
+
* backend when empty" step the legacy `agentBin`/`piArgs` collapse and the `claudeWindowReserve`
|
|
26
|
+
* collapse share, so the empty-entry rule cannot drift between the two. */
|
|
27
|
+
function dropNestedBackendField(next, backend, field) {
|
|
28
|
+
const record = next;
|
|
29
|
+
const backends = { ...(record.backends ?? {}) };
|
|
30
|
+
const entry = { ...(backends[backend] ?? {}) };
|
|
31
|
+
delete entry[field];
|
|
32
|
+
if (Object.keys(entry).length === 0)
|
|
33
|
+
delete backends[backend];
|
|
34
|
+
else
|
|
35
|
+
backends[backend] = entry;
|
|
36
|
+
return { ...next, backends: backends };
|
|
37
|
+
}
|
|
22
38
|
/** Set the daily cost budget cap in tumwater.json through the shared writer idiom (see
|
|
23
39
|
* writeConfigMutation), after checkDailyBudgetUsd has screened the value with its own
|
|
24
40
|
* actionable message — the one both dashboards flash. A cap the config already resolves to
|
|
@@ -80,10 +96,7 @@ export function setConfigKey(root, key, rawValue) {
|
|
|
80
96
|
(parsed.kind === "map" && parsed.map === "maxConcurrentPerProvider") ||
|
|
81
97
|
(parsed.kind === "role" && parsed.field === "instances") ||
|
|
82
98
|
(parsed.kind === "section" && parsed.section === "bootstrap" && parsed.field === "untilPlansDone");
|
|
83
|
-
if (isPlainIntegerKey &&
|
|
84
|
-
trimmed !== "" &&
|
|
85
|
-
!/^\d*\.?\d+$/.test(trimmed) &&
|
|
86
|
-
/^\d+(\.\d+)?[eE][+-]?\d+$/.test(trimmed))
|
|
99
|
+
if (isPlainIntegerKey && trimmed !== "" && isExponentSpelling(trimmed))
|
|
87
100
|
return `${key} must be a plain integer — no exponent notation`;
|
|
88
101
|
return null;
|
|
89
102
|
};
|
|
@@ -267,31 +280,11 @@ export function setConfigKey(root, key, rawValue) {
|
|
|
267
280
|
let unchanged;
|
|
268
281
|
// Writers keep one spelling: a legacy top-level set drops the matching backends.pi field, so
|
|
269
282
|
// the file can never hold both (validateConfig's raw pass rejects a file that does).
|
|
270
|
-
const dropLegacyPiAlias = (next, key) =>
|
|
271
|
-
const record = next;
|
|
272
|
-
const backends = { ...(record.backends ?? {}) };
|
|
273
|
-
const entry = { ...(backends.pi ?? {}) };
|
|
274
|
-
delete entry[key === "agentBin" ? "bin" : "args"];
|
|
275
|
-
if (Object.keys(entry).length === 0)
|
|
276
|
-
delete backends.pi;
|
|
277
|
-
else
|
|
278
|
-
backends.pi = entry;
|
|
279
|
-
return { ...next, backends: backends };
|
|
280
|
-
};
|
|
283
|
+
const dropLegacyPiAlias = (next, key) => dropNestedBackendField(next, "pi", key === "agentBin" ? "bin" : "args");
|
|
281
284
|
// Writers keep one spelling: setting the top-level `claudeWindowReserve` drops the nested
|
|
282
285
|
// `backends.claude.windowReserve`, so a supported `config set` never writes the rejected pair
|
|
283
286
|
// (validateConfig still refuses a hand-edited file holding both).
|
|
284
|
-
const dropNestedReserve = (next) =>
|
|
285
|
-
const record = next;
|
|
286
|
-
const backends = { ...(record.backends ?? {}) };
|
|
287
|
-
const entry = { ...(backends.claude ?? {}) };
|
|
288
|
-
delete entry.windowReserve;
|
|
289
|
-
if (Object.keys(entry).length === 0)
|
|
290
|
-
delete backends.claude;
|
|
291
|
-
else
|
|
292
|
-
backends.claude = entry;
|
|
293
|
-
return { ...next, backends: backends };
|
|
294
|
-
};
|
|
287
|
+
const dropNestedReserve = (next) => dropNestedBackendField(next, "claude", "windowReserve");
|
|
295
288
|
const result = writeConfigMutation(root, (cfg) => {
|
|
296
289
|
oldValue = cfg[key];
|
|
297
290
|
if (isDeepStrictEqual(oldValue, value)) {
|
|
@@ -1,20 +1,18 @@
|
|
|
1
|
-
import { andMore, quoteHeading, readBacklogChecked, FIX_CLAIMS_CHECKED, REFUSAL_NOTE_MAX, STALE_QUESTION_DAYS, STALE_BUG_DAYS
|
|
1
|
+
import { andMore, quoteHeading, readBacklogChecked, FIX_CLAIMS_CHECKED, REFUSAL_NOTE_MAX, STALE_QUESTION_DAYS, STALE_BUG_DAYS } from "./doctor-backlog.js";
|
|
2
2
|
import { truncate } from "../text/text.js";
|
|
3
|
+
import { plural, agree } from "../text/phrases.js";
|
|
3
4
|
import { ageInDays, filingDate, fixedBugEntries, openBugEntries, openQuestionEntries, plannedPlanEntries } from "../backlog/backlog.js";
|
|
4
|
-
import { stripEntryStamp } from "../backlog/backlog-structure.js";
|
|
5
5
|
import { questionFilingDate } from "../backlog/question-answer.js";
|
|
6
|
-
import {
|
|
6
|
+
import { needsReviewNote, refusalNote } from "../backlog/backlog-eligibility.js";
|
|
7
7
|
import { plannedPartKeys, plansWaitingOn, prerequisitesMerged } from "../backlog/backlog-parts.js";
|
|
8
8
|
import { lastDate } from "../backlog/backlog-md.js";
|
|
9
9
|
import { formatTimestamp } from "../text/datetime.js";
|
|
10
10
|
import { stringList } from "../files/json-object.js";
|
|
11
|
-
import { byDaysThenIndex } from "../collections.js";
|
|
12
11
|
import { readEventsSince } from "../events/event-window.js";
|
|
13
12
|
import { okOutcome, warnOutcome } from "./outcome.js";
|
|
14
13
|
/** The doctor's open-bug and question checks, split out of doctor-backlog.ts: questions that
|
|
15
14
|
* have waited too long for an answer (checkStaleQuestions), bugs left unfixed (checkStaleBugs),
|
|
16
|
-
*
|
|
17
|
-
* failureMentions helpers), fixed records whose quoted test regressed (checkRegressedFixes), and
|
|
15
|
+
* fixed records whose quoted test regressed (checkRegressedFixes), and
|
|
18
16
|
* entries parked by a Refused note (checkRefusedEntries). They import the shared helpers
|
|
19
17
|
* (readBacklogChecked, andMore) from doctor-backlog.ts, plus the age/trim bounds, and
|
|
20
18
|
* all warn instead of failing: a stale or
|
|
@@ -48,17 +46,18 @@ export function checkStaleQuestions(root) {
|
|
|
48
46
|
return warnOutcome(`QUESTIONS.md question ${quoteHeading(first.title)} has waited ${first.days} d for an answer${more} — ` +
|
|
49
47
|
`a question that waits too long for a human answer is worth your attention`);
|
|
50
48
|
}
|
|
51
|
-
/** Stale bugs — unrefused open BUGS.md bugs whose `(found by … YYYY-MM-DD)` or
|
|
52
|
-
* stamp is at least 3 days old. The bugfix role fixes a bug within hours, so one
|
|
53
|
-
* no role is fixing it. Positions are the 1-based `bug --list` numbers; a refused
|
|
54
|
-
* (checkRefusedEntries names it), a
|
|
55
|
-
*
|
|
49
|
+
/** Stale bugs — unrefused, unheld open BUGS.md bugs whose `(found by … YYYY-MM-DD)` or
|
|
50
|
+
* `(reported …)` stamp is at least 3 days old. The bugfix role fixes a bug within hours, so one
|
|
51
|
+
* that sits means no role is fixing it. Positions are the 1-based `bug --list` numbers; a refused
|
|
52
|
+
* bug is skipped (checkRefusedEntries names it), a bug parked by a Needs-review note is skipped
|
|
53
|
+
* (the note names what would make it fail, so it is not unfixed), and so is an undated heading.
|
|
54
|
+
* A warn, never a fail. */
|
|
56
55
|
export function checkStaleBugs(root) {
|
|
57
56
|
const doc = readBacklogChecked(root, "BUGS.md");
|
|
58
57
|
if (typeof doc !== "string")
|
|
59
58
|
return doc;
|
|
60
59
|
const stale = openBugEntries(root).flatMap((entry, index) => {
|
|
61
|
-
if (refusalNote(entry.body) !== null ||
|
|
60
|
+
if (refusalNote(entry.body) !== null || needsReviewNote(entry.body) !== null)
|
|
62
61
|
return [];
|
|
63
62
|
const stamp = filingDate(entry.title);
|
|
64
63
|
if (stamp === null)
|
|
@@ -85,10 +84,6 @@ export function quotedTestName(title) {
|
|
|
85
84
|
function dayAfterMs(date) {
|
|
86
85
|
return new Date(Number(date.slice(0, 4)), Number(date.slice(5, 7)) - 1, Number(date.slice(8, 10)) + 1).getTime();
|
|
87
86
|
}
|
|
88
|
-
/** The first `YYYY-MM-DD` in a Waiting for the next failure note's text, or null. */
|
|
89
|
-
export function waitingNoteDate(note) {
|
|
90
|
-
return /\d{4}-\d{2}-\d{2}/.exec(note)?.[0] ?? null;
|
|
91
|
-
}
|
|
92
87
|
/** True when `text` is a check-failure report that names `name` as a test that failed — the
|
|
93
88
|
* doctors' recurrence/regression evidence. The recurrence scans must not count any line that
|
|
94
89
|
* merely *contains* the quoted test name as the flake's return, or a warning that only quotes
|
|
@@ -127,8 +122,8 @@ export function failureMentions(events, name, sinceMs) {
|
|
|
127
122
|
}
|
|
128
123
|
/** The newest failure report at or after `since` naming `name` — `failureMentions`'s last
|
|
129
124
|
* mention — or undefined when none. The single home of the "did this flake come back after
|
|
130
|
-
* its boundary?" step, shared by the
|
|
131
|
-
*
|
|
125
|
+
* its boundary?" step, shared by the regressed-fix match (matchedRegressedFixes), so both
|
|
126
|
+
* keep the same last-mention rule. */
|
|
132
127
|
function lastFailureMention(events, name, sinceMs) {
|
|
133
128
|
const mentions = failureMentions(events, name, sinceMs);
|
|
134
129
|
return mentions[mentions.length - 1];
|
|
@@ -136,104 +131,11 @@ function lastFailureMention(events, name, sinceMs) {
|
|
|
136
131
|
/** The read-events window (ms) that reaches the oldest of `sinces` — `Date.now()` minus the
|
|
137
132
|
* oldest timestamp, floored at 0 — so a recurrence check reads events past every candidate's
|
|
138
133
|
* `since`. The single home of the "oldest of a set of since-times" step, shared by
|
|
139
|
-
*
|
|
134
|
+
* checkRegressedFixes, so those sites cannot drift on the clamp or the
|
|
140
135
|
* time base. */
|
|
141
136
|
function sinceWindowMs(sinces) {
|
|
142
137
|
return Math.max(0, Date.now() - Math.min(...sinces));
|
|
143
138
|
}
|
|
144
|
-
/** Open BUGS.md bugs held by a **Waiting for the next failure …** note, in file order: the
|
|
145
|
-
* doctor's `checkWaitingBugs` selection and anchor, factored out so the always-on stale-wait
|
|
146
|
-
* feed (staleWaitingBugRows) reads the same held set and cannot drift from the doctor's rule.
|
|
147
|
-
* A bug that is also Refused is left to checkRefusedEntries. `since` is the day-after the
|
|
148
|
-
* note's date — the recurrence scan's boundary; `date` the note's own `YYYY-MM-DD` or null. */
|
|
149
|
-
function heldWaitingBugs(root) {
|
|
150
|
-
return openBugEntries(root).flatMap((entry, index) => {
|
|
151
|
-
const note = waitingNote(entry.body);
|
|
152
|
-
if (note === null || refusalNote(entry.body) !== null)
|
|
153
|
-
return [];
|
|
154
|
-
const name = quotedTestName(entry.title);
|
|
155
|
-
const date = waitingNoteDate(note);
|
|
156
|
-
const since = name !== null && date !== null ? dayAfterMs(date) : null;
|
|
157
|
-
return [{ index, position: index + 1, title: entry.title, note, name, since, date }];
|
|
158
|
-
});
|
|
159
|
-
}
|
|
160
|
-
/** Waiting bugs — open BUGS.md bugs held by a **Waiting for the next failure …** note, which
|
|
161
|
-
* parks a bug until it fails again. Positions are the 1-based `bug --list` numbers; a bug that
|
|
162
|
-
* is also Refused is left to checkRefusedEntries. Ok when the hold is undisturbed — doctor
|
|
163
|
-
* then only names the bugs and how to release one — and a warn when a held bug's quoted test
|
|
164
|
-
* has failed in a gate or merge check since the day after its note (the note carries only a
|
|
165
|
-
* date, so a same-day failure cannot be told from the one it was written about). A warn is
|
|
166
|
-
* never a fail, and doctor writes nothing to BUGS.md. */
|
|
167
|
-
export function checkWaitingBugs(root) {
|
|
168
|
-
const doc = readBacklogChecked(root, "BUGS.md");
|
|
169
|
-
if (typeof doc !== "string")
|
|
170
|
-
return doc;
|
|
171
|
-
const held = heldWaitingBugs(root);
|
|
172
|
-
const [first, ...rest] = held;
|
|
173
|
-
if (!first)
|
|
174
|
-
return okOutcome("no open bug is waiting for its next failure");
|
|
175
|
-
const sinces = held.flatMap((b) => (b.since !== null && !Number.isNaN(b.since) ? [b.since] : []));
|
|
176
|
-
if (sinces.length > 0) {
|
|
177
|
-
const { events } = readEventsSince(root, sinceWindowMs(sinces));
|
|
178
|
-
const recurred = held.flatMap((b) => {
|
|
179
|
-
if (b.name === null || b.since === null)
|
|
180
|
-
return [];
|
|
181
|
-
const newest = lastFailureMention(events, b.name, b.since);
|
|
182
|
-
return newest ? [{ ...b, newest }] : [];
|
|
183
|
-
});
|
|
184
|
-
const [again, ...others] = recurred;
|
|
185
|
-
if (again) {
|
|
186
|
-
const more = andMore(others, (b) => `#${b.position} ${quoteHeading(b.title)}`);
|
|
187
|
-
return warnOutcome(`BUGS.md bug #${again.position} ${quoteHeading(again.title)} failed again since its Waiting for the next failure note ` +
|
|
188
|
-
`(${formatTimestamp(again.newest.ts)}: ${truncate(again.newest.text, REFUSAL_NOTE_MAX)})${more} — ` +
|
|
189
|
-
`record the occurrence under the entry and release it with \`tumwater bug --release ${again.position}\``);
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
/* A Waiting hold has no expiry of its own, so a bug whose flake never comes back parks
|
|
193
|
-
* indefinitely; a hold that has outlived STALE_WAIT_DAYS without a recurrence is escalated
|
|
194
|
-
* from the ok below to a warn so the operator is nudged to release or re-hold it. */
|
|
195
|
-
const lapsed = held.flatMap((b) => {
|
|
196
|
-
/* An undated note (no YYYY-MM-DD in its text) has no hold-start marker, and checkStaleBugs
|
|
197
|
-
* skips any bug carrying a waiting note, so without a fallback it would park indefinitely
|
|
198
|
-
* too; the filing stamp anchors it — the hold cannot be younger than the bug it dates from. */
|
|
199
|
-
const anchor = b.date ?? filingDate(b.title);
|
|
200
|
-
if (anchor === null)
|
|
201
|
-
return [];
|
|
202
|
-
const days = ageInDays(anchor);
|
|
203
|
-
return days >= STALE_WAIT_DAYS ? [{ ...b, days }] : [];
|
|
204
|
-
});
|
|
205
|
-
const [firstLapsed, ...restLapsed] = lapsed;
|
|
206
|
-
if (firstLapsed) {
|
|
207
|
-
const more = andMore(restLapsed, (b) => `#${b.position} ${quoteHeading(b.title)}`);
|
|
208
|
-
return warnOutcome(`BUGS.md bug #${firstLapsed.position} ${quoteHeading(firstLapsed.title)} has waited ${firstLapsed.days} d for its next failure without one ` +
|
|
209
|
-
`— its flake likely never comes back, so it parks indefinitely; release it with \`tumwater bug --release ${firstLapsed.position}\`${more}`);
|
|
210
|
-
}
|
|
211
|
-
const more = andMore(rest, (b) => `#${b.position} ${quoteHeading(b.title)}`);
|
|
212
|
-
return okOutcome(`BUGS.md bug #${first.position} ${quoteHeading(first.title)} is held until it fails again ` +
|
|
213
|
-
`(${truncate(first.note, REFUSAL_NOTE_MAX)})${more} — selfcheck lifts the note when it records a new occurrence; ` +
|
|
214
|
-
`release it now with \`tumwater bug --release ${first.position}\``);
|
|
215
|
-
}
|
|
216
|
-
/** The always-on rows behind the dashboard/TUI/`tumwater status` waiting-bug alert: open BUGS.md
|
|
217
|
-
* bugs held by a **Waiting for the next failure …** note whose anchor (the note's `YYYY-MM-DD`,
|
|
218
|
-
* else the heading's filing stamp) is at least STALE_WAIT_DAYS old. Shares the doctor's
|
|
219
|
-
* STALE_WAIT_DAYS threshold and the heldWaitingBugs selection/anchor, so the alert and the
|
|
220
|
-
* doctor's `waiting bugs` row cannot drift on the rule; an undated note-and-heading is skipped.
|
|
221
|
-
* The returned objects carry the 0-based index (drawer address `bugs:<index>`), the 1-based
|
|
222
|
-
* position (`bug --release <position>`), the stamp-stripped title, and the whole-day age.
|
|
223
|
-
* Returned most-days-waited-first, ties in file order, like staleOpenBugs. This deliberately
|
|
224
|
-
* omits `checkWaitingBugs`'s recurrence scan, because that scan reads events back to the oldest
|
|
225
|
-
* hold and is too costly on the status poll path; the doctor still reports a recurrence first. */
|
|
226
|
-
export function staleWaitingBugRows(root, now = Date.now()) {
|
|
227
|
-
return heldWaitingBugs(root)
|
|
228
|
-
.flatMap((b) => {
|
|
229
|
-
const anchor = b.date ?? filingDate(b.title);
|
|
230
|
-
if (anchor === null)
|
|
231
|
-
return [];
|
|
232
|
-
const days = ageInDays(anchor, now);
|
|
233
|
-
return days >= STALE_WAIT_DAYS ? [{ index: b.index, position: b.position, title: stripEntryStamp(b.title), days }] : [];
|
|
234
|
-
})
|
|
235
|
-
.sort(byDaysThenIndex);
|
|
236
|
-
}
|
|
237
139
|
/** The regression candidates for the `regressed fixes` feed: a newest-FIX_CLAIMS_CHECKED BUGS.md
|
|
238
140
|
* Fixed record whose heading quotes a test name and carries a `YYYY-MM-DD` date, unless an Open
|
|
239
141
|
* bug already quotes the same test (which means the regression is filed). Shared by the doctor's
|
|
@@ -316,12 +218,12 @@ export function checkRefusedEntries(root) {
|
|
|
316
218
|
return okOutcome("no planned plan or open bug is held by a Refused note");
|
|
317
219
|
const more = andMore(rest, (e) => `${e.file} ${e.command} #${e.position} ${quoteHeading(e.title)}` +
|
|
318
220
|
(e.mergeDone.length > 0 ? " [prerequisites no longer planned]" : "") +
|
|
319
|
-
(e.waiting > 0 ? ` [${e.waiting
|
|
221
|
+
(e.waiting > 0 ? ` [${plural(e.waiting, "part waits on it", "parts wait on it")}]` : ""));
|
|
320
222
|
const stale = first.mergeDone.length > 0
|
|
321
|
-
? `; its prerequisite
|
|
223
|
+
? `; its ${agree(first.mergeDone.length, "prerequisite", "prerequisites")} ${first.mergeDone.map((r) => `${r.series} ${r.part}/${r.of}`).join(", ")} ${agree(first.mergeDone.length, "is", "are")} no longer planned, so the refusal may be stale`
|
|
322
224
|
: "";
|
|
323
225
|
const waiting = first.waiting > 0
|
|
324
|
-
? `; ${first.waiting
|
|
226
|
+
? `; ${plural(first.waiting, "planned part waits on it", "planned parts wait on it")}, directly or through an earlier part`
|
|
325
227
|
: "";
|
|
326
228
|
return warnOutcome(`${first.file} ${first.command} #${first.position} "${first.title}" is held by a Refused note ` +
|
|
327
229
|
`(${truncate(first.note, REFUSAL_NOTE_MAX)})${stale}${waiting}${more} — no role takes a refused entry until it is edited: ` +
|