tumwater 0.4.2 → 0.4.3
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 +6 -6
- package/dist/build-info.json +2 -2
- package/dist/src/agent/agent-backend.js +6 -6
- package/dist/src/agent/{run-agent.js → agent-run.js} +25 -12
- package/dist/src/agent/{run-settle.js → agent-settle.js} +48 -24
- package/dist/src/agent/{run-spawn.js → agent-spawn.js} +21 -12
- package/dist/src/agent/{run-stream.js → agent-stream.js} +4 -4
- package/dist/src/agent/backend-traits.js +2 -2
- package/dist/src/agent/claude/claude-account.js +1 -8
- package/dist/src/agent/claude/claude-backend.js +3 -3
- package/dist/src/agent/claude/claude-env.js +27 -31
- package/dist/src/agent/claude/claude-stream.js +111 -54
- package/dist/src/agent/codex/codex-account.js +9 -0
- package/dist/src/agent/codex/codex-backend.js +2 -2
- package/dist/src/agent/codex/codex-env.js +28 -15
- package/dist/src/agent/codex/codex-hook-trust.js +39 -20
- package/dist/src/agent/codex/codex-rollout.js +93 -56
- package/dist/src/agent/codex/codex-stream.js +1 -1
- package/dist/src/agent/env-scrub.js +18 -0
- package/dist/src/agent/image-input.js +68 -11
- package/dist/src/agent/notes-shim.js +2 -2
- package/dist/src/agent/opencode/opencode-backend.js +2 -2
- package/dist/src/agent/opencode/opencode-catalog.js +50 -27
- package/dist/src/agent/opencode/opencode-config.js +32 -14
- package/dist/src/agent/opencode/opencode-env.js +12 -13
- package/dist/src/agent/opencode/opencode-stream.js +47 -25
- package/dist/src/agent/session-ledger.js +1 -1
- package/dist/src/agent/subscription-account.js +3 -3
- package/dist/src/backlog/backlog-cli-view.js +81 -41
- package/dist/src/backlog/backlog-cli.js +57 -26
- package/dist/src/backlog/backlog-eligibility.js +58 -102
- package/dist/src/backlog/backlog-md.js +5 -5
- package/dist/src/backlog/backlog-parts.js +65 -27
- package/dist/src/backlog/backlog-release.js +12 -12
- package/dist/src/backlog/backlog-structure.js +114 -61
- package/dist/src/backlog/backlog.js +1 -1
- package/dist/src/baseline/main-baseline.js +148 -84
- package/dist/src/budget/budget-fallback-view.js +100 -243
- package/dist/src/budget/budget-wiring.js +29 -65
- package/dist/src/budget/fallback-breaker.js +2 -2
- package/dist/src/build/build-check-detect.js +43 -14
- package/dist/src/build/build-check-events.js +2 -2
- package/dist/src/build/build-check-scoped.js +40 -16
- package/dist/src/build/build-check.js +13 -5
- package/dist/src/cli/cli-command-args.js +89 -52
- package/dist/src/cli/cli-flag-specs.js +42 -22
- package/dist/src/cli/cli-init.js +34 -12
- package/dist/src/cli/cli-query-commands.js +1 -1
- package/dist/src/cli/cli-repo-gate.js +2 -2
- package/dist/src/cli/cli-start.js +91 -58
- package/dist/src/cli/completion.js +82 -46
- package/dist/src/cli/config-commands.js +82 -71
- package/dist/src/cli/help.js +4 -6
- package/dist/src/cli/log-commands.js +43 -23
- package/dist/src/cli/notify-command.js +2 -2
- package/dist/src/cli/question-commands.js +86 -60
- package/dist/src/cli.js +181 -135
- package/dist/src/collections.js +31 -16
- package/dist/src/concurrency/check-slots.js +70 -0
- package/dist/src/concurrency/lock.js +22 -0
- package/dist/src/concurrency/run-permit.js +49 -0
- package/dist/src/concurrency/semaphore.js +46 -250
- package/dist/src/config/config-editable-keys.js +4 -7
- package/dist/src/config/config-fallbacks.js +81 -101
- package/dist/src/config/config-field-checks.js +66 -37
- package/dist/src/config/config-keys.js +5 -20
- package/dist/src/config/config-live.js +4 -28
- package/dist/src/config/{model-selector.js → config-model-selector.js} +4 -4
- package/dist/src/config/config-request.js +153 -102
- package/dist/src/config/config-schema.js +9 -11
- package/dist/src/config/config-selector-checks.js +1 -1
- package/dist/src/config/config-validation-agents.js +2 -2
- package/dist/src/config/config-validation-roles.js +4 -52
- package/dist/src/config/config-validation.js +4 -38
- package/dist/src/config/config-views.js +9 -34
- package/dist/src/config/config-write.js +6 -23
- package/dist/src/config/config.js +4 -5
- package/dist/src/doctor/doctor-backlog-bugs.js +7 -6
- package/dist/src/doctor/doctor-backlog-plans.js +24 -24
- package/dist/src/doctor/doctor-backlog.js +7 -5
- package/dist/src/doctor/doctor-check-names.js +3 -6
- package/dist/src/doctor/doctor-checks.js +5 -5
- package/dist/src/doctor/doctor-claude-checks.js +44 -28
- package/dist/src/doctor/doctor-codex-checks.js +6 -6
- package/dist/src/doctor/doctor-contention-checks.js +44 -112
- package/dist/src/doctor/doctor-fallback-checks.js +2 -2
- package/dist/src/doctor/doctor-holds.js +6 -45
- package/dist/src/doctor/doctor-install.js +1 -1
- package/dist/src/doctor/doctor-launch-services.js +1 -1
- package/dist/src/doctor/doctor-model-checks.js +178 -94
- package/dist/src/doctor/doctor-notify.js +2 -2
- package/dist/src/doctor/doctor-opencode-checks.js +5 -4
- package/dist/src/doctor/doctor-orphans.js +120 -20
- package/dist/src/doctor/{outcome.js → doctor-outcome.js} +7 -0
- package/dist/src/doctor/doctor-pi-checks.js +2 -2
- package/dist/src/doctor/doctor-principles.js +3 -3
- package/dist/src/doctor/doctor-project-checks.js +3 -3
- package/dist/src/doctor/doctor-role-notes.js +1 -1
- package/dist/src/doctor/doctor.js +8 -11
- package/dist/src/events/event-format.js +30 -54
- package/dist/src/events/event-labels.js +1 -5
- package/dist/src/events/event-legacy.js +1 -3
- package/dist/src/events/{notify.js → event-notify.js} +2 -2
- package/dist/src/events/event-read.js +1 -1
- package/dist/src/failure/failure-cluster.js +6 -6
- package/dist/src/failure/failure-data.js +8 -4
- package/dist/src/failure/failure-render.js +44 -34
- package/dist/src/failure/failure-state-change.js +4 -34
- package/dist/src/failure/{time-spend.js → failure-time-spend.js} +117 -84
- package/dist/src/files/files.js +30 -0
- package/dist/src/files/json-object.js +8 -8
- package/dist/src/files/stat-cache.js +12 -8
- package/dist/src/gates/budget-gates.js +85 -84
- package/dist/src/gates/gate-polls.js +27 -68
- package/dist/src/gates/pause-gates.js +16 -2
- package/dist/src/gates/readiness.js +1 -1
- package/dist/src/gates/role-cap-gates.js +44 -35
- package/dist/src/gates/{startup-gate.js → startup-gates.js} +7 -7
- package/dist/src/gates/{streak-gate.js → streak-gates.js} +75 -52
- package/dist/src/git/git.js +75 -45
- package/dist/src/git/worktree-pool.js +3 -6
- package/dist/src/gui/gui-admin-endpoints.js +3 -3
- package/dist/src/gui/gui-backlog-commands.js +4 -4
- package/dist/src/gui/gui-backlog-endpoints.js +61 -41
- package/dist/src/gui/gui-command.js +43 -24
- package/dist/src/gui/gui-history-endpoints.js +100 -52
- package/dist/src/gui/gui-operator-commands.js +22 -23
- package/dist/src/gui/gui-prompt-commands.js +23 -23
- package/dist/src/gui/gui-report-endpoints.js +1 -1
- package/dist/src/history/history-data.js +71 -44
- package/dist/src/history/history-queued.js +49 -27
- package/dist/src/history/history.js +51 -33
- package/dist/src/inbox/prompt-commands.js +59 -42
- package/dist/src/init/init.js +88 -40
- package/dist/src/merge/merge-backlog-conflicts.js +1 -1
- package/dist/src/merge/merge-core.js +1 -1
- package/dist/src/merge/merge-drain.js +8 -11
- package/dist/src/merge/merge-pipeline.js +6 -7
- package/dist/src/merge/merge-post-review-drain.js +10 -13
- package/dist/src/merge/merge-requeue.js +1 -1
- package/dist/src/merge/merge-slot.js +0 -8
- package/dist/src/merge/merge-verify.js +8 -18
- package/dist/src/notes-cli.js +52 -42
- package/dist/src/operator/operator-commands.js +76 -44
- package/dist/src/operator/operator-intent.js +3 -3
- package/dist/src/orchestrator/orchestrator-launch.js +31 -63
- package/dist/src/orchestrator/orchestrator-poll.js +27 -32
- package/dist/src/orchestrator/orchestrator.js +8 -21
- package/dist/src/orchestrator/scheduling-admit.js +101 -68
- package/dist/src/orchestrator/scheduling-scope.js +1 -1
- package/dist/src/orchestrator/scheduling-skip.js +1 -1
- package/dist/src/orchestrator/startup-migrations.js +1 -1
- package/dist/src/paths.js +8 -7
- package/dist/src/pi/pi-args.js +5 -2
- package/dist/src/pi/pi-backend.js +2 -2
- package/dist/src/pi/pi-bin.js +3 -3
- package/dist/src/pi/pi-event-line.js +1 -1
- package/dist/src/pi/pi-models.js +43 -29
- package/dist/src/pi/pi-settings.js +43 -20
- package/dist/src/pi/pi-stream.js +32 -6
- package/dist/src/pi/pi-watchdogs.js +12 -5
- package/dist/src/pi/pi.js +2 -2
- package/dist/src/pi-extension/context-shake.js +32 -20
- package/dist/src/pi-extension/kill-guard.js +69 -0
- package/dist/src/process/process-group.js +49 -5
- package/dist/src/process/process.js +8 -8
- package/dist/src/process/run-marker.js +31 -13
- package/dist/src/process/supervisor.js +40 -28
- package/dist/src/prompt/dialect.js +1 -1
- package/dist/src/prompt/gate-notes.js +2 -2
- package/dist/src/prompt/notes-backlog.js +66 -37
- package/dist/src/prompt/principles.js +6 -4
- package/dist/src/prompt/prompt-followup.js +12 -4
- package/dist/src/prompt/prompt.js +88 -38
- package/dist/src/report/report-data.js +53 -37
- package/dist/src/report/report-fold.js +16 -9
- package/dist/src/report/report-render.js +21 -194
- package/dist/src/report/report-since-data.js +4 -3
- package/dist/src/report/report-since-render.js +173 -0
- package/dist/src/report/report-usage-fold.js +8 -8
- package/dist/src/report/report.js +46 -22
- package/dist/src/review/post-merge-review-render.js +9 -3
- package/dist/src/review/post-merge-review.js +1 -1
- package/dist/src/review/review-precheck.js +11 -1
- package/dist/src/review/review.js +40 -153
- package/dist/src/review/secret-leaks.js +26 -16
- package/dist/src/roles/role-catalog.js +11 -3
- package/dist/src/roles/role-guidance.js +16 -12
- package/dist/src/roles/role-render.js +1 -14
- package/dist/src/roles/role-view.js +9 -21
- package/dist/src/run/acceptance-greps.js +88 -45
- package/dist/src/run/run-apply.js +12 -6
- package/dist/src/run/run-detail-data.js +121 -68
- package/dist/src/run/run-detail-render.js +92 -35
- package/dist/src/run/run-detail.js +72 -41
- package/dist/src/run/run-prompt.js +63 -50
- package/dist/src/run/run-resume.js +76 -29
- package/dist/src/run/run-stage.js +135 -84
- package/dist/src/run/run-timing.js +8 -2
- package/dist/src/run/run-usage.js +31 -1
- package/dist/src/run/run-verdict.js +119 -50
- package/dist/src/run/stage-check.js +49 -32
- package/dist/src/run/stage-checks-shared.js +28 -0
- package/dist/src/run/stage-name-checks.js +111 -92
- package/dist/src/run/stage-path-checks.js +69 -42
- package/dist/src/run/stage-prose-checks.js +55 -40
- package/dist/src/runner/runner-outcome.js +6 -1
- package/dist/src/runner/runner-pi.js +3 -10
- package/dist/src/runner/runner-recovery.js +1 -1
- package/dist/src/runner/{revision.js → runner-revision.js} +1 -1
- package/dist/src/runner/runner-run.js +97 -35
- package/dist/src/runner/runner-state-load.js +12 -9
- package/dist/src/runner/runner.js +41 -25
- package/dist/src/scheduling/claims.js +0 -4
- package/dist/src/scheduling/scheduling.js +53 -66
- package/dist/src/status/status-data.js +2 -2
- package/dist/src/status/status-role-notes.js +2 -2
- package/dist/src/status/status-roles.js +9 -17
- package/dist/src/team/{error-storm.js → team-error-storm.js} +1 -1
- package/dist/src/team/{failure-spread.js → team-failure-spread.js} +5 -5
- package/dist/src/team/team-hold.js +98 -65
- package/dist/src/team/{orchestrator-info.js → team-orchestrator-info.js} +2 -15
- package/dist/src/team/team-polls.js +28 -126
- package/dist/src/team/{reclaim.js → team-reclaim.js} +61 -31
- package/dist/src/team/team-state.js +4 -13
- package/dist/src/text/markdown.js +1 -1
- package/dist/src/text/phrases.js +20 -15
- package/dist/src/text/text.js +3 -3
- package/dist/src/ui/alert-feeds.js +49 -73
- package/dist/src/ui/badges.js +3 -13
- package/dist/src/ui/gui/gui-client-boot.js +2 -2
- package/dist/src/ui/gui/gui-client-composer.js +1 -1
- package/dist/src/ui/gui/gui-client-drawer-config.js +7 -17
- package/dist/src/ui/gui/gui-client-drawer.js +1 -1
- package/dist/src/ui/gui/gui-client-history.js +28 -14
- package/dist/src/ui/gui/gui-client-model.js +0 -12
- package/dist/src/ui/gui/gui-client-pending.js +7 -6
- package/dist/src/ui/gui/gui-client-report-chart.js +5 -5
- package/dist/src/ui/gui/gui-client-report.js +1 -1
- package/dist/src/ui/gui/gui-client-roles.js +0 -1
- package/dist/src/ui/gui/gui-client-settings.js +43 -84
- package/dist/src/ui/gui/gui-client-team.js +4 -4
- package/dist/src/ui/gui/gui-client.js +7 -1
- package/dist/src/ui/progress-data.js +53 -40
- package/dist/src/ui/run-progress-model.js +9 -2
- package/dist/src/ui/status-model.js +2 -24
- package/dist/src/ui/status-payload.js +3 -7
- package/dist/src/ui/status-render.js +5 -9
- package/dist/src/ui/team-alert-backlog.js +6 -6
- package/dist/src/ui/team-alert-status.js +9 -170
- package/dist/src/ui/team-alert-tail.js +19 -215
- package/dist/src/ui/team-alerts.js +7 -8
- package/dist/src/ui/transcript-run.js +27 -16
- package/dist/src/ui/tui/tui-editor-answer.js +12 -10
- package/dist/src/ui/tui/tui-editor-attach.js +4 -3
- package/dist/src/ui/tui/tui-editor-backends.js +4 -3
- package/dist/src/ui/tui/tui-editor-brief.js +6 -9
- package/dist/src/ui/tui/tui-editor-budget.js +4 -3
- package/dist/src/ui/tui/tui-editor-config.js +80 -68
- package/dist/src/ui/tui/tui-editor-entry.js +12 -20
- package/dist/src/ui/tui/tui-editor-pause.js +51 -57
- package/dist/src/ui/tui/tui-editor-prompt.js +9 -13
- package/dist/src/ui/tui/tui-editor-queued.js +11 -14
- package/dist/src/ui/tui/tui-editor-quiet.js +4 -3
- package/dist/src/ui/tui/tui-editor-release.js +4 -3
- package/dist/src/ui/tui/tui-editor-retire.js +7 -7
- package/dist/src/ui/tui/tui-editor-role-enabled.js +4 -3
- package/dist/src/ui/tui/tui-editor-role-file.js +4 -4
- package/dist/src/ui/tui/tui-editor-role-key.js +4 -3
- package/dist/src/ui/tui/tui-editor-role-model.js +97 -15
- package/dist/src/ui/tui/tui-editor-search.js +4 -3
- package/dist/src/ui/tui/tui-editor-session.js +17 -4
- package/dist/src/ui/tui/tui-editors.js +1 -7
- package/dist/src/ui/tui/tui-frame.js +182 -134
- package/dist/src/ui/tui/tui-guard.js +20 -0
- package/dist/src/ui/tui/tui-input.js +159 -92
- package/dist/src/ui/tui/tui-keymap.js +51 -29
- package/dist/src/ui/tui/tui-keys.js +1 -1
- package/dist/src/ui/tui/tui-pane-activity.js +3 -3
- package/dist/src/ui/tui/tui-pane-backlog.js +74 -36
- package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
- package/dist/src/ui/tui/tui-pane-health.js +252 -228
- package/dist/src/ui/tui/tui-pane-history.js +61 -70
- package/dist/src/ui/tui/tui-pane-pending.js +284 -247
- package/dist/src/ui/tui/tui-pane-queued.js +25 -27
- package/dist/src/ui/tui/tui-pane-report.js +2 -1
- package/dist/src/ui/tui/tui-pane-session.js +11 -7
- package/dist/src/ui/tui/tui-pane-transcript.js +19 -31
- package/dist/src/ui/tui/tui-pane.js +14 -11
- package/dist/src/ui/tui/tui-panes.js +29 -32
- package/dist/src/ui/tui/tui.js +3 -4
- package/dist/src/verdict/fix-claim.js +76 -54
- package/dist/src/verdict/refusal.js +1 -1
- package/package.json +5 -5
- package/dist/src/concurrency/provider-lease.js +0 -105
- package/dist/src/runner/model-fallback.js +0 -76
- package/dist/src/runner/runner-fallback.js +0 -66
- package/dist/src/ui/tui/tui-editor-claude-reserve.js +0 -24
- package/dist/src/ui/tui/tui-editor-provider-caps.js +0 -27
- /package/dist/src/backlog/{question-answer.js → backlog-question-answer.js} +0 -0
- /package/dist/src/doctor/{probe.js → doctor-probe.js} +0 -0
- /package/dist/src/gates/{disk-gate.js → disk-gates.js} +0 -0
- /package/dist/src/operator/{retire.js → operator-retire.js} +0 -0
- /package/dist/src/operator/{revert.js → operator-revert.js} +0 -0
- /package/dist/src/runner/{leftover.js → runner-leftover.js} +0 -0
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { childEnv } from "../process/process.js";
|
|
2
|
-
import { AUTH_CHECK_MAX_OUTPUT_BYTES, AUTH_CHECK_TIMEOUT_MS, boundedProbe } from "./probe.js";
|
|
2
|
+
import { AUTH_CHECK_MAX_OUTPUT_BYTES, AUTH_CHECK_TIMEOUT_MS, boundedProbe } from "./doctor-probe.js";
|
|
3
3
|
import { defaultConfig, loadConfigSafe } from "../config/config.js";
|
|
4
4
|
import { seamsInUse } from "../config/config-fallbacks.js";
|
|
5
5
|
import { cacheReadUnpriced, piModelsPath, readPiProviders } from "../pi/pi-models.js";
|
|
6
|
+
import { roleImageInfo } from "../agent/image-input.js";
|
|
7
|
+
import { readPiListedModels } from "../pi/pi-listed-models.js";
|
|
8
|
+
import { runnerIds } from "../roles/runner-ids.js";
|
|
6
9
|
import { envPath } from "../files/files.js";
|
|
7
10
|
import { piListedModels, piProviderAuth } from "./doctor-pi-checks.js";
|
|
8
11
|
import { agree } from "../text/phrases.js";
|
|
9
12
|
import { joinParts } from "../text/text.js";
|
|
10
|
-
import { failOutcome, okOutcome, warnOutcome } from "./outcome.js";
|
|
13
|
+
import { failOutcome, okOutcome, warnOutcome } from "./doctor-outcome.js";
|
|
11
14
|
import { belowNodeFloor } from "../version.js";
|
|
12
15
|
/** The one phrase a configured-models check uses when the config declares no models at all:
|
|
13
16
|
* informational (capability falls to pi's own defaults), not a warning. Shared by
|
|
@@ -30,8 +33,9 @@ const PHRASE_CANNOT_READ_MODELS = "cannot check — could not read pi's model de
|
|
|
30
33
|
* modules do. The claude, opencode, codex and pi provider checks live in their own per-backend
|
|
31
34
|
* modules (doctor-claude-checks.ts, doctor-opencode-checks.ts, doctor-codex-checks.ts,
|
|
32
35
|
* doctor-pi-checks.ts), each importing this file's shared seam framework and the AUTH_CHECK_*
|
|
33
|
-
* probe budgets from probe.ts rather than duplicating them. The shared report contract
|
|
34
|
-
* (CheckOutcome) is imported from outcome.ts, exactly as the other doctor-*
|
|
36
|
+
* probe budgets from doctor-probe.ts rather than duplicating them. The shared report contract
|
|
37
|
+
* (CheckOutcome) is imported from doctor-outcome.ts, exactly as the other doctor-*
|
|
38
|
+
* modules do. Together
|
|
35
39
|
* these are the doctor's only checks that read pi's model definitions and, for the probes, spawn
|
|
36
40
|
* an agent binary — a distinct concern from the filesystem/git checks, kept together so a change
|
|
37
41
|
* to how doctor reads models does not touch the environment checks. */
|
|
@@ -131,30 +135,8 @@ export function codexSeamsInUse(config) {
|
|
|
131
135
|
export function piSeamsInUse(config) {
|
|
132
136
|
return seamsInUse(config).some((s) => s.agent === "pi");
|
|
133
137
|
}
|
|
134
|
-
/**
|
|
135
|
-
|
|
136
|
-
* agent binary's own auth check. A strong tier pi cannot resolve would fail every review, and so
|
|
137
|
-
* every merge — the check exists so the operator finds out before the team does, mid-review. Pairs
|
|
138
|
-
* collected in the same sweep teamModelsFree prices: each tier's top-level selector (a string is
|
|
139
|
-
* default's), every enabled role's resolved pair (per-role overrides included; tier-name overrides
|
|
140
|
-
* resolve through configForRole), and the reviewer's while review is on. A pair with no model is
|
|
141
|
-
* pi's own default and out of scope; fallback pairs are the sibling check's (checkFallbackModel
|
|
142
|
-
* prices the cap pair). Levels: an unresolvable pair fails, a provider that is not ready warns
|
|
143
|
-
* (credentials are fixable without a config edit), a probe that cannot run warns rather than
|
|
144
|
-
* guesses, and a priced model whose cache reads declare $0 warns — the daily budget would
|
|
145
|
-
* undercount it (BUGS.md 2026-10-06). When one of PI_SMOL_MODEL / PI_SLOW_MODEL / PI_PLAN_MODEL is
|
|
146
|
-
* set the check also reports that tumwater does not read it — the variables are oh-my-pi's, pi
|
|
147
|
-
* ignores them, and with omp as `agentBin` they still reach omp through the inherited environment
|
|
148
|
-
* (src/pi/pi-backend.ts, built on src/process/run-marker.ts's backendBaseEnv) — pointing at the
|
|
149
|
-
* tier keys that do the same job (`model.small` / `model.strong`). `auth` is injectable so tests
|
|
150
|
-
* need no pi on PATH; `env` so the PI_* branches stay hermetic. */
|
|
151
|
-
export async function checkTierModels(root, modelsPath = piModelsPath(), auth = (provider) => piProviderAuth(loadConfigSafe(root).config ?? defaultConfig(), provider), env = process.env, listModels = () => piListedModels(loadConfigSafe(root).config ?? defaultConfig(), envPath(), AUTH_CHECK_TIMEOUT_MS, root)) {
|
|
152
|
-
const config = loadConfigForCheck(root);
|
|
153
|
-
if ("level" in config)
|
|
154
|
-
return config;
|
|
155
|
-
// Asked lazily and at most once: only a pair models.json does not declare needs the listing.
|
|
156
|
-
let listing;
|
|
157
|
-
const listed = () => (listing ??= listModels());
|
|
138
|
+
/** One job: sweep the config's seam pairs into the pair map and the routed-models map. */
|
|
139
|
+
function collectTierPairs(config) {
|
|
158
140
|
const pairs = new Map();
|
|
159
141
|
// Routed (non-pi) models keyed by the backend they resolved to, so the routing note names
|
|
160
142
|
// each backend correctly; opencode is not claude.
|
|
@@ -175,75 +157,100 @@ export async function checkTierModels(root, modelsPath = piModelsPath(), auth =
|
|
|
175
157
|
};
|
|
176
158
|
for (const pair of seamPairs(config))
|
|
177
159
|
add(pair.agent, pair.provider, pair.model);
|
|
160
|
+
return { pairs, routedModels };
|
|
161
|
+
}
|
|
162
|
+
/** One job: compute the oh-my-pi variables note, and whether any is set. */
|
|
163
|
+
function ompTierNote(env) {
|
|
178
164
|
const ompVars = ["PI_SMOL_MODEL", "PI_SLOW_MODEL", "PI_PLAN_MODEL"].filter((v) => env[v] !== undefined && env[v] !== "");
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
:
|
|
182
|
-
|
|
183
|
-
|
|
165
|
+
return {
|
|
166
|
+
set: ompVars.length > 0,
|
|
167
|
+
note: ompVars.length === 0
|
|
168
|
+
? ""
|
|
169
|
+
: ` — ${ompVars.join(" and ")} ${agree(ompVars.length, "is", "are")} set: tumwater does not read ${agree(ompVars.length, "it", "them")} (oh-my-pi's variables; pi ignores them; with omp as agentBin they still reach omp through the inherited environment) — set model.small / model.strong instead`,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
/** One job: read pi's provider definitions, or the empty map when the file is unreadable but
|
|
173
|
+
* every pair names a provider the listing can back (the unreadable case the caller warns on). */
|
|
174
|
+
async function readTierProviders(modelsPath, pairs, listed) {
|
|
184
175
|
let providers = readPiProviders(modelsPath);
|
|
185
176
|
if (!providers && [...pairs.values()].some((p) => p.provider !== undefined) && (await listed()))
|
|
186
177
|
providers = new Map();
|
|
187
|
-
|
|
178
|
+
return providers;
|
|
179
|
+
}
|
|
180
|
+
/** One job: the fail string for a pair neither pi's definitions nor (when reachable) its
|
|
181
|
+
* listing resolve, or undefined when the listing does contain it. */
|
|
182
|
+
function unresolvedModelFail(provider, model, defs, listedModels, modelsPath) {
|
|
183
|
+
if (listedModels?.has(`${provider}/${model}`))
|
|
184
|
+
return undefined;
|
|
185
|
+
const providerListed = listedModels != null && [...listedModels].some((k) => k.startsWith(`${provider}/`));
|
|
186
|
+
if (!defs && !providerListed) {
|
|
187
|
+
return listedModels === null
|
|
188
|
+
? `provider ${provider} is not in pi's definitions (${modelsPath})`
|
|
189
|
+
: `provider ${provider} is not in pi's definitions (${modelsPath}) and \`pi --list-models\` lists none of its models — pi lists only providers it has credentials for`;
|
|
190
|
+
}
|
|
191
|
+
if (listedModels === null) {
|
|
192
|
+
// No exact entry: pi clones the provider's default model for the id, inheriting that
|
|
193
|
+
// default's price and context window (BUGS.md 2026-10-06) — name that consequence
|
|
194
|
+
// instead of only the failed lookup.
|
|
195
|
+
return `${provider}/${model} does not resolve in pi's definitions (${modelsPath}) — pi would price it at the provider default's rates and context window`;
|
|
196
|
+
}
|
|
197
|
+
return `${provider}/${model} is neither in pi's definitions (${modelsPath}) nor listed by \`pi --list-models\``;
|
|
198
|
+
}
|
|
199
|
+
/** One job: the warn string for a priced model whose cache reads declare $0, or undefined. */
|
|
200
|
+
function cacheReadWarning(providers, provider, model, def) {
|
|
201
|
+
if (def && cacheReadUnpriced(providers, provider, model)) {
|
|
202
|
+
return `${provider}/${model} prices cache reads at $0 — the daily budget will undercount its spend`;
|
|
203
|
+
}
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
/** One job: probe one provider's auth once, reporting ready or the warn when it is not. */
|
|
207
|
+
async function probeTierProvider(provider, auth, checkedProviders) {
|
|
208
|
+
if (checkedProviders.has(provider))
|
|
209
|
+
return {};
|
|
210
|
+
checkedProviders.add(provider);
|
|
211
|
+
const verdict = await auth(provider);
|
|
212
|
+
if (verdict === "ready")
|
|
213
|
+
return { ready: true };
|
|
214
|
+
if (verdict === "not-ready")
|
|
215
|
+
return { warn: `provider ${provider} reports not ready — check its credentials` };
|
|
216
|
+
return { warn: `could not verify provider ${provider} — the auth check did not run or its output was unreadable` };
|
|
217
|
+
}
|
|
218
|
+
/** One job: resolve one pi pair — the per-token claude warn, the definitions lookup and its
|
|
219
|
+
* unresolved fail, the free-cache-reads warn, and the once-per-provider auth probe. */
|
|
220
|
+
async function resolveTierPair(provider, model, providers, listed, auth, checkedProviders, modelsPath) {
|
|
188
221
|
const warns = [];
|
|
189
|
-
if (
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
const checkedProviders = new Set();
|
|
193
|
-
for (const { provider, model } of pairs.values()) {
|
|
194
|
-
if (provider === undefined) {
|
|
195
|
-
warns.push(`${model} names no provider — pi resolves it with its own default, tumwater cannot verify it`);
|
|
196
|
-
continue;
|
|
197
|
-
}
|
|
198
|
-
// A Claude model routed to pi (plans/provider-concurrency.md "Routing a bare Claude model id
|
|
199
|
-
// to claude"): a named provider keeps it on pi, but since Anthropic's 2026-04-04
|
|
200
|
-
// third-party-harness change a Claude plan's limits cover only first-party tools, so it is
|
|
201
|
-
// billed per token even under a Claude subscription login — the bare `claude-…` form on the
|
|
202
|
-
// claude CLI uses the plan's included usage instead.
|
|
203
|
-
if (model?.startsWith("claude-")) {
|
|
204
|
-
warns.push(`${provider}/${model} runs on pi and is billed per token, not the Claude plan's included usage — use a bare claude-… id to route it to the claude CLI`);
|
|
205
|
-
}
|
|
206
|
-
const defs = providers?.get(provider);
|
|
207
|
-
const def = defs?.find((d) => d.id === model);
|
|
208
|
-
if (!def) {
|
|
209
|
-
const listedModels = await listed();
|
|
210
|
-
if (!listedModels?.has(`${provider}/${model}`)) {
|
|
211
|
-
const providerListed = listedModels != null && [...listedModels].some((k) => k.startsWith(`${provider}/`));
|
|
212
|
-
if (!defs && !providerListed) {
|
|
213
|
-
fails.push(listedModels === null
|
|
214
|
-
? `provider ${provider} is not in pi's definitions (${modelsPath})`
|
|
215
|
-
: `provider ${provider} is not in pi's definitions (${modelsPath}) and \`pi --list-models\` lists none of its models — pi lists only providers it has credentials for`);
|
|
216
|
-
}
|
|
217
|
-
else if (listedModels === null) {
|
|
218
|
-
// No exact entry: pi clones the provider's default model for the id, inheriting that
|
|
219
|
-
// default's price and context window (BUGS.md 2026-10-06) — name that consequence
|
|
220
|
-
// instead of only the failed lookup.
|
|
221
|
-
fails.push(`${provider}/${model} does not resolve in pi's definitions (${modelsPath}) — pi would price it at the provider default's rates and context window`);
|
|
222
|
-
}
|
|
223
|
-
else {
|
|
224
|
-
fails.push(`${provider}/${model} is neither in pi's definitions (${modelsPath}) nor listed by \`pi --list-models\``);
|
|
225
|
-
}
|
|
226
|
-
continue;
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
if (def && providers && cacheReadUnpriced(providers, provider, model)) {
|
|
230
|
-
// A priced model whose cache reads declare $0: pi bills cache-read tokens at zero and
|
|
231
|
-
// the daily budget undercounts the real spend (BUGS.md 2026-10-06).
|
|
232
|
-
warns.push(`${provider}/${model} prices cache reads at $0 — the daily budget will undercount its spend`);
|
|
233
|
-
}
|
|
234
|
-
if (!checkedProviders.has(provider)) {
|
|
235
|
-
checkedProviders.add(provider);
|
|
236
|
-
const verdict = await auth(provider);
|
|
237
|
-
if (verdict === "ready")
|
|
238
|
-
readyProviders.add(provider);
|
|
239
|
-
else if (verdict === "not-ready")
|
|
240
|
-
warns.push(`provider ${provider} reports not ready — check its credentials`);
|
|
241
|
-
else
|
|
242
|
-
warns.push(`could not verify provider ${provider} — the auth check did not run or its output was unreadable`);
|
|
243
|
-
}
|
|
222
|
+
if (provider === undefined) {
|
|
223
|
+
warns.push(`${model} names no provider — pi resolves it with its own default, tumwater cannot verify it`);
|
|
224
|
+
return { warns };
|
|
244
225
|
}
|
|
245
|
-
|
|
246
|
-
|
|
226
|
+
// A Claude model routed to pi (plans/provider-concurrency.md "Routing a bare Claude model id
|
|
227
|
+
// to claude"): a named provider keeps it on pi, but since Anthropic's 2026-04-04
|
|
228
|
+
// third-party-harness change a Claude plan's limits cover only first-party tools, so it is
|
|
229
|
+
// billed per token even under a Claude subscription login — the bare `claude-…` form on the
|
|
230
|
+
// claude CLI uses the plan's included usage instead.
|
|
231
|
+
if (model?.startsWith("claude-")) {
|
|
232
|
+
warns.push(`${provider}/${model} runs on pi and is billed per token, not the Claude plan's included usage — use a bare claude-… id to route it to the claude CLI`);
|
|
233
|
+
}
|
|
234
|
+
const defs = providers?.get(provider);
|
|
235
|
+
const def = defs?.find((d) => d.id === model);
|
|
236
|
+
if (!def) {
|
|
237
|
+
const listedModels = await listed();
|
|
238
|
+
const fail = unresolvedModelFail(provider, model, defs, listedModels, modelsPath);
|
|
239
|
+
if (fail !== undefined)
|
|
240
|
+
return { warns, fail };
|
|
241
|
+
}
|
|
242
|
+
if (providers) {
|
|
243
|
+
const warning = cacheReadWarning(providers, provider, model, def);
|
|
244
|
+
if (warning !== undefined)
|
|
245
|
+
warns.push(warning);
|
|
246
|
+
}
|
|
247
|
+
const probe = await probeTierProvider(provider, auth, checkedProviders);
|
|
248
|
+
if (probe.warn !== undefined)
|
|
249
|
+
warns.push(probe.warn);
|
|
250
|
+
return { warns, ready: probe.ready };
|
|
251
|
+
}
|
|
252
|
+
/** One job: one routing note per non-pi backend in use, joined. */
|
|
253
|
+
function routedTierNotes(routedModels) {
|
|
247
254
|
// One routing note per non-pi backend in use, so an opencode-routed model is never labeled
|
|
248
255
|
// as claude's. The opencode note also carries the design §2 caveat that a bare `opencode/…`
|
|
249
256
|
// id always means the OpenCode CLI, never pi's built-in `opencode` gateway. Every note opens
|
|
@@ -260,13 +267,67 @@ export async function checkTierModels(root, modelsPath = piModelsPath(), auth =
|
|
|
260
267
|
? routedNote(agent, n, "resolved by the codex backend, not pi's definitions")
|
|
261
268
|
: routedNote(agent, n, "resolved by the claude backend, not pi's definitions"));
|
|
262
269
|
}
|
|
263
|
-
|
|
270
|
+
return routedNotes.length > 0 ? routedNotes.join("; ") : "";
|
|
271
|
+
}
|
|
272
|
+
/** One job: assemble the final fail/warn/ok outcome from the collected verdicts. */
|
|
273
|
+
function tierOutcome(fails, warns, pairsCount, readyProviders, routedText, ompNote, ompSet) {
|
|
274
|
+
const found = [...fails, ...warns].join("; ");
|
|
275
|
+
const resolvedPi = `${pairsCount} declared ${agree(pairsCount, "model", "models")} resolve${readyProviders.size > 0 ? ` and ${agree(readyProviders.size, "provider", "all providers")} ${[...readyProviders].sort().join(", ")} report ready` : ""}`;
|
|
264
276
|
const base = fails.length === 0 && warns.length === 0
|
|
265
|
-
? joinParts([
|
|
277
|
+
? joinParts([pairsCount > 0 ? resolvedPi : "", routedText])
|
|
266
278
|
: found;
|
|
267
279
|
if (fails.length > 0)
|
|
268
280
|
return failOutcome(`${base}${ompNote}`);
|
|
269
|
-
return (warns.length > 0 ||
|
|
281
|
+
return (warns.length > 0 || ompSet ? warnOutcome : okOutcome)(`${base}${ompNote}`);
|
|
282
|
+
}
|
|
283
|
+
/** Declared model readiness — plans/model-tiers.md "Doctor" (part 7c/8): every model the config can
|
|
284
|
+
* put on a seam must resolve in pi's definitions, and its provider must report `ready` from the
|
|
285
|
+
* agent binary's own auth check. A strong tier pi cannot resolve would fail every review, and so
|
|
286
|
+
* every merge — the check exists so the operator finds out before the team does, mid-review. Pairs
|
|
287
|
+
* collected in the same sweep teamModelsFree prices: each tier's top-level selector (a string is
|
|
288
|
+
* default's), every enabled role's resolved pair (per-role overrides included; tier-name overrides
|
|
289
|
+
* resolve through configForRole), and the reviewer's while review is on. A pair with no model is
|
|
290
|
+
* pi's own default and out of scope; fallback pairs are the sibling check's (checkFallbackModel
|
|
291
|
+
* prices the cap pair). Levels: an unresolvable pair fails, a provider that is not ready warns
|
|
292
|
+
* (credentials are fixable without a config edit), a probe that cannot run warns rather than
|
|
293
|
+
* guesses, and a priced model whose cache reads declare $0 warns — the daily budget would
|
|
294
|
+
* undercount it (BUGS.md 2026-10-06). When one of PI_SMOL_MODEL / PI_SLOW_MODEL / PI_PLAN_MODEL is
|
|
295
|
+
* set the check also reports that tumwater does not read it — the variables are oh-my-pi's, pi
|
|
296
|
+
* ignores them, and with omp as `agentBin` they still reach omp through the inherited environment
|
|
297
|
+
* (src/pi/pi-backend.ts, built on src/process/run-marker.ts's backendBaseEnv) — pointing at the
|
|
298
|
+
* tier keys that do the same job (`model.small` / `model.strong`). `auth` is injectable so tests
|
|
299
|
+
* need no pi on PATH; `env` so the PI_* branches stay hermetic. The inline jobs are split into
|
|
300
|
+
* the module-private helpers `collectTierPairs`, `ompTierNote`, `readTierProviders`,
|
|
301
|
+
* `resolveTierPair`, `unresolvedModelFail`, `cacheReadWarning`, `probeTierProvider`,
|
|
302
|
+
* `routedTierNotes` and `tierOutcome` so each is found by name. */
|
|
303
|
+
export async function checkTierModels(root, modelsPath = piModelsPath(), auth = (provider) => piProviderAuth(loadConfigSafe(root).config ?? defaultConfig(), provider), env = process.env, listModels = () => piListedModels(loadConfigSafe(root).config ?? defaultConfig(), envPath(), AUTH_CHECK_TIMEOUT_MS, root)) {
|
|
304
|
+
const config = loadConfigForCheck(root);
|
|
305
|
+
if ("level" in config)
|
|
306
|
+
return config;
|
|
307
|
+
// Asked lazily and at most once: only a pair models.json does not declare needs the listing.
|
|
308
|
+
let listing;
|
|
309
|
+
const listed = () => (listing ??= listModels());
|
|
310
|
+
const { pairs, routedModels } = collectTierPairs(config);
|
|
311
|
+
const { set: ompSet, note: ompNote } = ompTierNote(env);
|
|
312
|
+
if (pairs.size === 0 && routedModels.size === 0)
|
|
313
|
+
return (ompSet ? warnOutcome : okOutcome)(`${PHRASE_NO_MODELS_DECLARED}${ompNote}`);
|
|
314
|
+
const providers = await readTierProviders(modelsPath, pairs, listed);
|
|
315
|
+
const fails = [];
|
|
316
|
+
const warns = [];
|
|
317
|
+
if (pairs.size > 0 && !providers)
|
|
318
|
+
return warnOutcome(`${PHRASE_CANNOT_READ_MODELS}${modelsPath}${ompNote}`);
|
|
319
|
+
const readyProviders = new Set();
|
|
320
|
+
const checkedProviders = new Set();
|
|
321
|
+
for (const { provider, model } of pairs.values()) {
|
|
322
|
+
const result = await resolveTierPair(provider, model, providers, listed, auth, checkedProviders, modelsPath);
|
|
323
|
+
warns.push(...result.warns);
|
|
324
|
+
if (result.fail !== undefined)
|
|
325
|
+
fails.push(result.fail);
|
|
326
|
+
if (result.ready === true)
|
|
327
|
+
readyProviders.add(provider);
|
|
328
|
+
}
|
|
329
|
+
const routedText = routedTierNotes(routedModels);
|
|
330
|
+
return tierOutcome(fails, warns, pairs.size, readyProviders, routedText, ompNote, ompSet);
|
|
270
331
|
}
|
|
271
332
|
/** `gh` readiness for the opt-in `feedback` observer (plans/feedback-role.md "Shape" → "Part 1"):
|
|
272
333
|
* the role files issues as the operator's GitHub login through `gh`, so doctor confirms `gh`
|
|
@@ -289,3 +350,26 @@ export async function checkFeedbackFiling(root, pathEnv = envPath(), probe = (ar
|
|
|
289
350
|
return failOutcome("gh is not authenticated — run `gh auth login` — the feedback role files issues as your GitHub login");
|
|
290
351
|
return okOutcome("gh present and authenticated — the feedback role can file issues on znation/tumwater");
|
|
291
352
|
}
|
|
353
|
+
/** The `image input` row (plans/image-input.md "Surfaces", part 5/5): which enabled roles run
|
|
354
|
+
* on a model that cannot read images, and where each reroutes an image prompt — or that no
|
|
355
|
+
* usable reroute exists. Re-added probe-free by Subtract part 1d/6 after part 5f/6 deleted the
|
|
356
|
+
* probe-backed row: it reads roleImageInfo (the same resolver the status rows, role page, and
|
|
357
|
+
* submit gate share) rather than spawning a backend, so doctor and the surfaces cannot drift on
|
|
358
|
+
* who is text-only. No alert when none is (imageInput true or null — an unknown pair is not
|
|
359
|
+
* counted as text-only, mirroring the submit gate and the `models for each tier` stance). */
|
|
360
|
+
export function checkImageInput(root, modelsPath = piModelsPath(), listed = readPiListedModels(root)) {
|
|
361
|
+
const config = loadConfigForCheck(root);
|
|
362
|
+
if ("level" in config)
|
|
363
|
+
return config;
|
|
364
|
+
const textOnly = runnerIds(config).filter((role) => roleImageInfo(config, role, modelsPath, listed).imageInput === false);
|
|
365
|
+
if (textOnly.length === 0)
|
|
366
|
+
return okOutcome("every enabled role reads images");
|
|
367
|
+
const noTarget = textOnly.filter((role) => {
|
|
368
|
+
const rr = roleImageInfo(config, role, modelsPath, listed).imageReroute;
|
|
369
|
+
return rr === null || rr === "";
|
|
370
|
+
});
|
|
371
|
+
if (noTarget.length > 0)
|
|
372
|
+
return warnOutcome(`${noTarget.join(", ")} ${agree(noTarget.length, "runs", "run")} on models that can't read images and ${agree(noTarget.length, "has", "have")} no reroute target — a run that reads an image fails, and image attachments to these roles are refused. Declare image input on a vision model in pi's models.json, or point a tier at one.`);
|
|
373
|
+
const targets = new Set(textOnly.map((role) => roleImageInfo(config, role, modelsPath, listed).imageReroute ?? ""));
|
|
374
|
+
return okOutcome(`${textOnly.join(", ")} ${agree(textOnly.length, "runs", "run")} on models that can't read images — image prompts rerun on ${[...targets].join(", ")}.`);
|
|
375
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { eventsSince } from "../events/event-window.js";
|
|
2
|
-
import { isNotifyFailure } from "../events/notify.js";
|
|
2
|
+
import { isNotifyFailure } from "../events/event-notify.js";
|
|
3
3
|
import { DAY_MS, humanAgo } from "../text/datetime.js";
|
|
4
4
|
import { plural } from "../text/phrases.js";
|
|
5
5
|
import { errorMessage, squash } from "../text/text.js";
|
|
6
|
-
import { okOutcome, warnOutcome } from "./outcome.js";
|
|
6
|
+
import { okOutcome, warnOutcome } from "./doctor-outcome.js";
|
|
7
7
|
/** How far back the `notify` check looks for failed pages: a day, fixed. An opinionated
|
|
8
8
|
* default with no knob, like STALE_BUG_DAYS: long enough that a transient hook failure shows
|
|
9
9
|
* up, short enough that a page that did not go through is still the operator's business. */
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import os from "node:os";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { childEnv } from "../process/process.js";
|
|
4
|
-
import { AUTH_CHECK_MAX_OUTPUT_BYTES, AUTH_CHECK_TIMEOUT_MS, boundedProbe } from "./probe.js";
|
|
4
|
+
import { AUTH_CHECK_MAX_OUTPUT_BYTES, AUTH_CHECK_TIMEOUT_MS, boundedProbe } from "./doctor-probe.js";
|
|
5
5
|
import { opencodeBackend, opencodeSpillDir } from "../agent/opencode/opencode-backend.js";
|
|
6
6
|
import { opencodeModelFree, opencodeModelKey, readOpencodeCache } from "../agent/opencode/opencode-catalog.js";
|
|
7
7
|
import { formatDate } from "../text/datetime.js";
|
|
@@ -9,7 +9,7 @@ import { opencodeDbPath } from "../paths.js";
|
|
|
9
9
|
import { envPath, isDirectory, readTextOrNull, statOrNull, treeBytes } from "../files/files.js";
|
|
10
10
|
import { formatBytes, tokenWindowLabel } from "../text/format.js";
|
|
11
11
|
import { escapeRegExp } from "../text/text.js";
|
|
12
|
-
import { failOutcome, okOutcome, warnOutcome } from "./outcome.js";
|
|
12
|
+
import { failOutcome, okOutcome, warnOutcome } from "./doctor-outcome.js";
|
|
13
13
|
import { loadConfigForCheck, opencodeSeamsInUse, seamedModels, } from "./doctor-model-checks.js";
|
|
14
14
|
/** The doctor's opencode credential and leak readiness check, split out of doctor-model-checks.ts
|
|
15
15
|
* (whose shared seam/model framework and the pi and tier-models checks stay there):
|
|
@@ -19,8 +19,9 @@ import { loadConfigForCheck, opencodeSeamsInUse, seamedModels, } from "./doctor-
|
|
|
19
19
|
* filesystem/git checks beside it, kept together so a change to how doctor reads opencode does
|
|
20
20
|
* not touch the other model checks. The shared report contract (CheckOutcome) and the
|
|
21
21
|
* seam/model framework (loadConfigForCheck, opencodeSeamsInUse, seamPairs) come from
|
|
22
|
-
* doctor-model-checks.ts, the AUTH_CHECK_* probe budgets from probe.ts, and the
|
|
23
|
-
* contract (CheckOutcome) from outcome.ts, as the other doctor-*
|
|
22
|
+
* doctor-model-checks.ts, the AUTH_CHECK_* probe budgets from doctor-probe.ts, and the
|
|
23
|
+
* shared report contract (CheckOutcome) from doctor-outcome.ts, as the other doctor-*
|
|
24
|
+
* modules do. */
|
|
24
25
|
/** The provider of a non-gateway opencode model id (`opencode/<provider>/<model>`), or
|
|
25
26
|
* undefined for a gateway id (a bare `opencode/<model>` or `<model>`, served by OpenCode's
|
|
26
27
|
* own gateway with no credential). A model id with more than the `opencode/` namespace prefix
|
|
@@ -2,12 +2,13 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { worktreesDir } from "../paths.js";
|
|
4
4
|
import { pidAlive } from "../process/process.js";
|
|
5
|
+
import { rootToken } from "../process/run-marker.js";
|
|
5
6
|
import { GROUP_POLL_MS, KILL_GRACE_MS } from "../process/process-group.js";
|
|
6
7
|
import { systemProcessProbe } from "../process/process-table.js";
|
|
7
8
|
import { warnEventBestEffort, TUMWATER_SOURCE } from "../events/events.js";
|
|
8
9
|
import { errorMessage, truncate } from "../text/text.js";
|
|
9
10
|
import { plural } from "../text/phrases.js";
|
|
10
|
-
import { failOutcome, okOutcome, warnOutcome } from "./outcome.js";
|
|
11
|
+
import { failOutcome, okOutcome, warnOutcome } from "./doctor-outcome.js";
|
|
11
12
|
/** The orphan-process check of `tumwater doctor`, split out of doctor.ts: it is the one
|
|
12
13
|
* doctor check that inspects the operating system's process table (argv, cwd, parent-child
|
|
13
14
|
* tree) rather than the repo's own files, so it carries a different shape of logic — path
|
|
@@ -92,6 +93,36 @@ function harnessGone(marker) {
|
|
|
92
93
|
const m = /^(\d+)-/.exec(marker);
|
|
93
94
|
return m ? !pidAlive(Number(m[1])) : null;
|
|
94
95
|
}
|
|
96
|
+
/** The repo-identity token in a run marker's third dash segment — the team root tag
|
|
97
|
+
* `makeRunMarker` stamps when it knows one — or null for a rootless two-segment mark
|
|
98
|
+
* (`<pid>-<hex>`, or a torn or foreign value with no third segment): there is no evidence of
|
|
99
|
+
* which checkout's harness minted it. */
|
|
100
|
+
export function markRootToken(mark) {
|
|
101
|
+
const third = mark.split("-")[2];
|
|
102
|
+
return third === undefined ? null : third;
|
|
103
|
+
}
|
|
104
|
+
/** The repo-identity token `makeRunMarker` stamps into a team-rooted mark — the same `rootToken`
|
|
105
|
+
* run-marker.ts mints, re-exported here so the orphan scan can spell this root's side of the
|
|
106
|
+
* comparison without importing process/run-marker. */
|
|
107
|
+
export { rootToken };
|
|
108
|
+
/** ps's elapsed-time field (`[[D-]HH:]MM:SS`, the string the doctor line already prints) in
|
|
109
|
+
* milliseconds, the minimum-age gate the automatic reap uses so a just-leaked — still
|
|
110
|
+
* possibly owned — process is not killed. Any shape that does not parse, or that carries an
|
|
111
|
+
* out-of-range minute or second, reads 0: the conservative reading that keeps the reap from
|
|
112
|
+
* acting on uncertain evidence. */
|
|
113
|
+
export function parseEtimeMs(etime) {
|
|
114
|
+
const m = /^(?:\d+-)?(?:(\d+):)?(\d{1,2}):(\d{2})$/.exec(etime.trim());
|
|
115
|
+
if (!m)
|
|
116
|
+
return 0;
|
|
117
|
+
const hh = Number(m[1] ?? 0);
|
|
118
|
+
const mm = Number(m[2]);
|
|
119
|
+
const ss = Number(m[3]);
|
|
120
|
+
if (mm > 59 || ss > 59)
|
|
121
|
+
return 0;
|
|
122
|
+
const days = /^(\d+)-/.exec(etime.trim());
|
|
123
|
+
const d = days ? Number(days[1]) : 0;
|
|
124
|
+
return ((d * 24 + hh) * 3600 + mm * 60 + ss) * 1000;
|
|
125
|
+
}
|
|
95
126
|
/** A parent pid → child pids map of the table. */
|
|
96
127
|
function childrenOf(rows) {
|
|
97
128
|
const children = new Map();
|
|
@@ -111,8 +142,10 @@ function shownCommand(command, root) {
|
|
|
111
142
|
return truncate(cut.reduce((c, rt) => c.split(`${rt}/`).join(""), command), ORPHAN_COMMAND_MAX);
|
|
112
143
|
}
|
|
113
144
|
/** The detection half of checkOrphans, shared with killOrphans so the check and the kill cannot
|
|
114
|
-
* disagree about the set.
|
|
115
|
-
|
|
145
|
+
* disagree about the set. Exported so the always-on orphan alert feed (src/ui/alert-feeds.ts)
|
|
146
|
+
* runs the exact same scan the doctor row reads — the alert and the row cannot drift on the
|
|
147
|
+
* orphan set. */
|
|
148
|
+
export async function findOrphans(root, probe) {
|
|
116
149
|
const roots = rootSpellings(root);
|
|
117
150
|
let rows;
|
|
118
151
|
try {
|
|
@@ -148,12 +181,24 @@ async function findOrphans(root, probe) {
|
|
|
148
181
|
// Best-effort per the probe contract: argv and cwd still carry the check alone.
|
|
149
182
|
}
|
|
150
183
|
}
|
|
184
|
+
const ours = rootToken(root);
|
|
151
185
|
const byMark = new Set();
|
|
152
186
|
for (const r of parentless) {
|
|
153
187
|
if (byArgv.has(r.pid))
|
|
154
188
|
continue;
|
|
155
|
-
const
|
|
156
|
-
|
|
189
|
+
const rMarks = marks.get(r.pid) ?? [];
|
|
190
|
+
const verdicts = rMarks.map(harnessGone);
|
|
191
|
+
// A dead-marked process is this checkout's only when no mark names a different root: a
|
|
192
|
+
// token that is present and not `ours` proves another checkout's harness stamped that
|
|
193
|
+
// process, and signalling it would break the "never signal a process not attributable"
|
|
194
|
+
// rule (BUGS.md, "Reap the doctor's orphans, part 1/3"). A rootless mark keeps today's
|
|
195
|
+
// report — whatever started it, nothing else will reap it.
|
|
196
|
+
if (verdicts.length > 0 &&
|
|
197
|
+
verdicts.every((v) => v === true) &&
|
|
198
|
+
rMarks.every((m) => {
|
|
199
|
+
const t = markRootToken(m);
|
|
200
|
+
return t === null || t === ours;
|
|
201
|
+
}))
|
|
157
202
|
byMark.add(r.pid);
|
|
158
203
|
}
|
|
159
204
|
const orphans = parentless.filter((r) => {
|
|
@@ -199,16 +244,17 @@ async function findOrphans(root, probe) {
|
|
|
199
244
|
* never a crashed doctor; unreadable cwds degrade to the argv match and say so. Any orphan is
|
|
200
245
|
* a fail — the exit code is the point: a scripted doctor must notice. `probe` is injectable so
|
|
201
246
|
* tests run against a fake table. */
|
|
202
|
-
|
|
203
|
-
|
|
247
|
+
/** The orphan-problem sentence for the standing alert feed, shared with checkOrphans so the
|
|
248
|
+
* alert and the doctor row cannot drift on the wording: the count, each listed orphan's pid,
|
|
249
|
+
* age, cpu and command (with its descendant count and the run mark that caught it), and the
|
|
250
|
+
* nothing-reaps-these remediation. null when the scan errored or found no orphans. Public so
|
|
251
|
+
* the feed (src/ui/alert-feeds.ts) can stage a scan and get exactly the row's message. */
|
|
252
|
+
export function orphanFailDetail(root, scan) {
|
|
204
253
|
if ("error" in scan)
|
|
205
|
-
return
|
|
254
|
+
return null;
|
|
206
255
|
const { rows, orphans, marks, cwdProblem } = scan;
|
|
207
|
-
if (orphans.length === 0)
|
|
208
|
-
|
|
209
|
-
return warnOutcome(`none named in argv, but process cwds are unreadable (${cwdProblem}) — an orphan started inside a worktree, like a leaked test runner, would be missed`);
|
|
210
|
-
return okOutcome("none — no process reparented to PID 1 runs from .tumwater/worktrees/ or carries only dead runs' marks");
|
|
211
|
-
}
|
|
256
|
+
if (orphans.length === 0)
|
|
257
|
+
return null;
|
|
212
258
|
const children = childrenOf(rows);
|
|
213
259
|
const listed = orphans.slice(0, ORPHANS_LISTED_MAX).map((r) => {
|
|
214
260
|
const n = descendantPids(children, r.pid).length;
|
|
@@ -221,9 +267,22 @@ export async function checkOrphans(root, probe = systemProcessProbe) {
|
|
|
221
267
|
});
|
|
222
268
|
const more = orphans.length - listed.length;
|
|
223
269
|
const count = `${plural(orphans.length, "orphaned process", "orphaned processes")} (PPID 1)`;
|
|
224
|
-
return
|
|
270
|
+
return `${count}: ${listed.join("; ")}${more > 0 ? `; and ${more} more` : ""}` +
|
|
225
271
|
" — nothing reaps these; `tumwater doctor --kill-orphans` (or the dashboard Health card's Kill orphans, or the TUI Health tab's Ctrl+K pressed twice) kills each with its descendants" +
|
|
226
|
-
(cwdProblem !== null ? ` (process cwds unreadable — ${cwdProblem}; argv matched only)` : "")
|
|
272
|
+
(cwdProblem !== null ? ` (process cwds unreadable — ${cwdProblem}; argv matched only)` : "");
|
|
273
|
+
}
|
|
274
|
+
/** The doctor's `orphans` row. */
|
|
275
|
+
export async function checkOrphans(root, probe = systemProcessProbe) {
|
|
276
|
+
const scan = await findOrphans(root, probe);
|
|
277
|
+
if ("error" in scan)
|
|
278
|
+
return warnOutcome(`could not scan the process table — ${scan.error}`);
|
|
279
|
+
const { orphans, cwdProblem } = scan;
|
|
280
|
+
if (orphans.length === 0) {
|
|
281
|
+
if (cwdProblem !== null)
|
|
282
|
+
return warnOutcome(`none named in argv, but process cwds are unreadable (${cwdProblem}) — an orphan started inside a worktree, like a leaked test runner, would be missed`);
|
|
283
|
+
return okOutcome("none — no process reparented to PID 1 runs from .tumwater/worktrees/ or carries only dead runs' marks");
|
|
284
|
+
}
|
|
285
|
+
return failOutcome(orphanFailDetail(root, scan));
|
|
227
286
|
}
|
|
228
287
|
const systemSignals = {
|
|
229
288
|
kill: (pid, signal) => void process.kill(pid, signal),
|
|
@@ -291,6 +350,20 @@ async function killTrees(root, rows, chosen, signals) {
|
|
|
291
350
|
}
|
|
292
351
|
return { killed, failed };
|
|
293
352
|
}
|
|
353
|
+
/** The `pid N (+N descendant) command` line per killed tree and the `could not kill` line per
|
|
354
|
+
* refused tree, in the read the reapers' best-effort warning logs — the one shape both
|
|
355
|
+
* reapPreviousTeamOrphans and reapAttributableOrphans report, so the two cannot drift on the
|
|
356
|
+
* itemized wording. Callers join `[...items, ...refused]` with "; " behind their own verb
|
|
357
|
+
* phrase. */
|
|
358
|
+
function orphanKillReportLines(killed, failed) {
|
|
359
|
+
return {
|
|
360
|
+
items: killed.map((k) => {
|
|
361
|
+
const tree = k.descendants > 0 ? ` (+${plural(k.descendants, "descendant")})` : "";
|
|
362
|
+
return `pid ${k.pid}${tree} ${k.command}`;
|
|
363
|
+
}),
|
|
364
|
+
refused: failed.map((f) => `could not kill pid ${f.pid}: ${f.reason}`),
|
|
365
|
+
};
|
|
366
|
+
}
|
|
294
367
|
/** Team start's reap of a crashed predecessor's leaks: kill the parentless processes whose run
|
|
295
368
|
* marks all name dead harnesses and at least one names `previousPid`, the orchestrator the
|
|
296
369
|
* info file recorded. Safe without an operator's judgment: a mark proves a tumwater start
|
|
@@ -314,11 +387,7 @@ export async function reapPreviousTeamOrphans(root, previousPid, probe = systemP
|
|
|
314
387
|
if (chosen.length === 0)
|
|
315
388
|
return 0;
|
|
316
389
|
const { killed, failed } = await killTrees(root, scan.rows, chosen, signals);
|
|
317
|
-
const items = killed
|
|
318
|
-
const tree = k.descendants > 0 ? ` (+${plural(k.descendants, "descendant")})` : "";
|
|
319
|
-
return `pid ${k.pid}${tree} ${k.command}`;
|
|
320
|
-
});
|
|
321
|
-
const refused = failed.map((f) => `could not kill pid ${f.pid}: ${f.reason}`);
|
|
390
|
+
const { items, refused } = orphanKillReportLines(killed, failed);
|
|
322
391
|
warnEventBestEffort(root, TUMWATER_SOURCE, `killed ${plural(killed.length, "orphaned process", "orphaned processes")} left by tumwater ` +
|
|
323
392
|
`${previousPid}'s runs at team start: ${[...items, ...refused].join("; ")}`);
|
|
324
393
|
return killed.length;
|
|
@@ -327,3 +396,34 @@ export async function reapPreviousTeamOrphans(root, previousPid, probe = systemP
|
|
|
327
396
|
return 0;
|
|
328
397
|
}
|
|
329
398
|
}
|
|
399
|
+
/** How long an orphan must have been running before the automatic reap may kill it: a young
|
|
400
|
+
* orphan may still be a live run's process that findOrphans read as parentless mid-hand-off,
|
|
401
|
+
* and ten minutes is far longer than any such attribution would hold. The operator's
|
|
402
|
+
* `--kill-orphans`/dashboard/TUI click bypasses this — doctors judge with their eyes. */
|
|
403
|
+
const ORPHAN_REAP_MIN_AGE_MS = 10 * 60 * 1000;
|
|
404
|
+
/** The orchestrator's once-per-interval sweep of the orphans `checkOrphans` already reports:
|
|
405
|
+
* kill the same set the operator's `--kill-orphans` kills, once each is old enough that no
|
|
406
|
+
* live run can still own it. Reuses findOrphans' attribution and killTrees' signal-and-wait,
|
|
407
|
+
* so the automatic reap cannot drift from the doctor row or the operator kill and never
|
|
408
|
+
* signals a process findOrphans does not list. An unreadable table, or no old-enough orphan,
|
|
409
|
+
* kills nothing and logs nothing; it never throws. Returns the number of orphan trees killed. */
|
|
410
|
+
export async function reapAttributableOrphans(root, probe = systemProcessProbe, signals = systemSignals) {
|
|
411
|
+
try {
|
|
412
|
+
const scan = await findOrphans(root, probe);
|
|
413
|
+
if ("error" in scan)
|
|
414
|
+
return 0;
|
|
415
|
+
const chosen = scan.orphans.filter((r) => parseEtimeMs(r.etime) >= ORPHAN_REAP_MIN_AGE_MS);
|
|
416
|
+
if (chosen.length === 0)
|
|
417
|
+
return 0;
|
|
418
|
+
const { killed, failed } = await killTrees(root, scan.rows, chosen, signals);
|
|
419
|
+
if (killed.length === 0)
|
|
420
|
+
return 0;
|
|
421
|
+
const { items, refused } = orphanKillReportLines(killed, failed);
|
|
422
|
+
warnEventBestEffort(root, TUMWATER_SOURCE, `reaped ${plural(killed.length, "orphaned process", "orphaned processes")}: ` +
|
|
423
|
+
[...items, ...refused].join("; "));
|
|
424
|
+
return killed.length;
|
|
425
|
+
}
|
|
426
|
+
catch {
|
|
427
|
+
return 0;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
@@ -9,6 +9,13 @@
|
|
|
9
9
|
export function okOutcome(detail) {
|
|
10
10
|
return { level: "ok", detail };
|
|
11
11
|
}
|
|
12
|
+
/** The doctor's stand-down on a missing or unreadable tumwater.json: an ok outcome saying
|
|
13
|
+
* there is nothing to verify, because the config check reports the broken file itself. Shared
|
|
14
|
+
* by the four checks that skip their walk when config is unavailable, so the message reads
|
|
15
|
+
* identically across them. */
|
|
16
|
+
export function noConfigOutcome() {
|
|
17
|
+
return okOutcome("no readable tumwater.json — nothing to verify");
|
|
18
|
+
}
|
|
12
19
|
export function warnOutcome(detail) {
|
|
13
20
|
return { level: "warn", detail };
|
|
14
21
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { childEnv } from "../process/process.js";
|
|
2
|
-
import { boundedProbe, AUTH_CHECK_MAX_OUTPUT_BYTES, AUTH_CHECK_TIMEOUT_MS } from "./probe.js";
|
|
2
|
+
import { boundedProbe, AUTH_CHECK_MAX_OUTPUT_BYTES, AUTH_CHECK_TIMEOUT_MS } from "./doctor-probe.js";
|
|
3
3
|
import { parseJsonObject } from "../files/json-object.js";
|
|
4
4
|
import { envPath } from "../files/files.js";
|
|
5
5
|
import { resolveBackendBin } from "../pi/pi-bin.js";
|
|
@@ -12,7 +12,7 @@ import { parsePiModelImages, parsePiModelTable, writePiListedModels } from "../p
|
|
|
12
12
|
* a crash, so readiness is read from the body alone and a reply that did not run or does not
|
|
13
13
|
* parse is the only "unknown" (never not-ready, which would blame credentials). Never throws;
|
|
14
14
|
* every failure mode ends on "unknown". The bounded subprocess is the shared boundedProbe
|
|
15
|
-
* (probe.ts), also used by the claude probes. */
|
|
15
|
+
* (doctor-probe.ts), also used by the claude probes. */
|
|
16
16
|
export async function piProviderAuth(config, provider, pathEnv = envPath(), timeoutMs = AUTH_CHECK_TIMEOUT_MS) {
|
|
17
17
|
const resolved = resolveBackendBin(config);
|
|
18
18
|
const result = await boundedProbe(resolved.bin, ["auth", "check", "--provider", provider, "--json"], childEnv({ PATH: pathEnv }), timeoutMs, AUTH_CHECK_MAX_OUTPUT_BYTES);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
import {
|
|
2
|
+
import { readRegularTextOrNull } from "../files/files.js";
|
|
3
3
|
import { PRINCIPLES_MAX_CHARS } from "../prompt/principles.js";
|
|
4
|
-
import { okOutcome, warnOutcome } from "./outcome.js";
|
|
4
|
+
import { okOutcome, warnOutcome } from "./doctor-outcome.js";
|
|
5
5
|
/** Doctor check for PRINCIPLES.md's injection cap. readPrinciples caps the trimmed text at
|
|
6
6
|
* PRINCIPLES_MAX_CHARS for every prompt's <principles> block, and principleBullets keeps only
|
|
7
7
|
* its leading bullets; a file over the cap loses its tail (and a long header eats bullet
|
|
@@ -9,7 +9,7 @@ import { okOutcome, warnOutcome } from "./outcome.js";
|
|
|
9
9
|
* truncate, naming the trimmed count, the cap, the marker, and the fix. Read-only, like every
|
|
10
10
|
* doctor check — it never edits PRINCIPLES.md. */
|
|
11
11
|
export function checkPrinciples(root) {
|
|
12
|
-
const raw =
|
|
12
|
+
const raw = readRegularTextOrNull(path.join(root, "PRINCIPLES.md"));
|
|
13
13
|
if (raw === null)
|
|
14
14
|
return okOutcome("no PRINCIPLES.md");
|
|
15
15
|
const length = raw.trim().length;
|