tumwater 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -13
- package/dist/build-info.json +2 -2
- package/dist/src/agent/agent-args.js +8 -9
- package/dist/src/agent/agent-backend.js +8 -6
- package/dist/src/agent/backend-traits.js +55 -25
- package/dist/src/agent/claude/claude-account.js +71 -204
- package/dist/src/agent/claude/claude-args.js +9 -6
- package/dist/src/agent/claude/claude-backend.js +10 -10
- package/dist/src/agent/claude/claude-env.js +1 -1
- package/dist/src/agent/claude/claude-stream.js +6 -4
- package/dist/src/agent/claude/claude-write-guard.js +49 -0
- package/dist/src/agent/claude/write-guard-hook.js +13 -0
- package/dist/src/agent/codex/codex-account.js +119 -0
- package/dist/src/agent/codex/codex-args.js +181 -0
- package/dist/src/agent/codex/codex-backend.js +279 -0
- package/dist/src/agent/codex/codex-catalog.js +97 -0
- package/dist/src/agent/codex/codex-env.js +18 -0
- package/dist/src/agent/codex/codex-hook-trust.js +246 -0
- package/dist/src/agent/codex/codex-patch-paths.js +36 -0
- package/dist/src/agent/codex/codex-rollout.js +179 -0
- package/dist/src/agent/codex/codex-stream.js +404 -0
- package/dist/src/agent/codex/codex-write-guard.js +86 -0
- package/dist/src/agent/codex/write-guard-hook.js +12 -0
- package/dist/src/agent/image-input.js +29 -24
- package/dist/src/agent/notes-shim.js +11 -12
- package/dist/src/agent/opencode/opencode-args.js +0 -1
- package/dist/src/agent/opencode/opencode-backend.js +12 -9
- package/dist/src/agent/opencode/opencode-catalog.js +55 -29
- package/dist/src/agent/opencode/opencode-config.js +19 -14
- package/dist/src/agent/opencode/opencode-env.js +0 -1
- package/dist/src/agent/opencode/opencode-session-id.js +1 -2
- package/dist/src/agent/opencode/opencode-stream.js +1 -1
- package/dist/src/agent/opencode/write-guard-plugin/index.js +48 -0
- package/dist/src/agent/role-notes-write.js +5 -6
- package/dist/src/agent/run-agent.js +465 -448
- package/dist/src/agent/run-settle.js +214 -0
- package/dist/src/agent/session-ledger.js +86 -16
- package/dist/src/agent/subscription-account.js +220 -0
- package/dist/src/agent/write-guard.js +148 -0
- package/dist/src/backlog/backlog-archive.js +281 -55
- package/dist/src/backlog/backlog-cli-view.js +255 -0
- package/dist/src/backlog/backlog-cli.js +91 -335
- package/dist/src/backlog/backlog-commit.js +20 -13
- package/dist/src/backlog/backlog-eligibility.js +193 -242
- package/dist/src/backlog/backlog-index.js +149 -0
- package/dist/src/backlog/backlog-md.js +54 -6
- package/dist/src/backlog/backlog-parts.js +264 -0
- package/dist/src/backlog/backlog-release.js +134 -0
- package/dist/src/backlog/backlog-render.js +27 -0
- package/dist/src/backlog/backlog-structure.js +99 -156
- package/dist/src/backlog/backlog-write.js +40 -127
- package/dist/src/backlog/backlog.js +21 -6
- package/dist/src/backlog/question-answer.js +21 -15
- package/dist/src/baseline/main-baseline.js +89 -20
- package/dist/src/baseline/main-guardrails.js +136 -0
- package/dist/src/baseline/main-red.js +153 -36
- package/dist/src/baseline/main-watch.js +232 -0
- package/dist/src/brief/brief-edit.js +12 -12
- package/dist/src/brief/brief.js +31 -51
- package/dist/src/budget/budget-fallback-view.js +288 -0
- package/dist/src/budget/budget-wiring.js +158 -0
- package/dist/src/budget/budget.js +31 -20
- package/dist/src/budget/fallback-breaker.js +26 -27
- package/dist/src/build/build-check-detect.js +23 -15
- package/dist/src/build/build-check-events.js +25 -6
- package/dist/src/build/build-check-report.js +49 -9
- package/dist/src/build/build-check-scoped.js +18 -11
- package/dist/src/build/build-check.js +2 -1
- package/dist/src/build/build-info.js +2 -3
- package/dist/src/build/build-stage.js +9 -14
- package/dist/src/build/dep-install.js +65 -3
- package/dist/src/change/change-data.js +123 -31
- package/dist/src/change/change-render.js +46 -5
- package/dist/src/cli/brief-command.js +3 -4
- package/dist/src/cli/cli-args.js +181 -33
- package/dist/src/cli/cli-command-args.js +46 -18
- package/dist/src/cli/cli-editor.js +2 -1
- package/dist/src/cli/cli-flag-specs.js +183 -45
- package/dist/src/cli/cli-init.js +51 -0
- package/dist/src/cli/cli-marker-commands.js +4 -4
- package/dist/src/cli/cli-query-commands.js +291 -598
- package/dist/src/cli/cli-repo-gate.js +11 -1
- package/dist/src/cli/cli-review-commands.js +113 -0
- package/dist/src/cli/cli-role-command.js +525 -0
- package/dist/src/cli/cli-start-summary.js +167 -0
- package/dist/src/cli/{cli-run.js → cli-start.js} +193 -237
- package/dist/src/cli/completion.js +169 -22
- package/dist/src/cli/config-commands.js +58 -26
- package/dist/src/cli/follow-view.js +75 -0
- package/dist/src/cli/help.js +258 -82
- package/dist/src/cli/log-commands.js +164 -85
- package/dist/src/cli/question-commands.js +211 -10
- package/dist/src/cli.js +173 -45
- package/dist/src/collections.js +56 -13
- package/dist/src/concurrency/check-permit.js +20 -3
- package/dist/src/concurrency/lock.js +17 -13
- package/dist/src/concurrency/provider-lease.js +44 -8
- package/dist/src/concurrency/semaphore.js +102 -26
- package/dist/src/config/config-aliases.js +0 -1
- package/dist/src/config/config-editable-keys.js +16 -1
- package/dist/src/config/config-fallbacks.js +94 -56
- package/dist/src/config/config-field-checks.js +40 -10
- package/dist/src/config/config-keys.js +30 -8
- package/dist/src/config/config-live.js +63 -24
- package/dist/src/config/config-request.js +137 -0
- package/dist/src/config/config-schema.js +31 -24
- package/dist/src/config/config-selector-checks.js +123 -0
- package/dist/src/config/config-validation-agents.js +80 -0
- package/dist/src/config/config-validation-model.js +116 -0
- package/dist/src/config/config-validation.js +169 -222
- package/dist/src/config/config-views.js +136 -54
- package/dist/src/config/config-write-core.js +135 -0
- package/dist/src/config/config-write.js +164 -265
- package/dist/src/config/config.js +53 -38
- package/dist/src/config/model-selector.js +34 -34
- package/dist/src/doctor/doctor-backlog-bugs.js +330 -0
- package/dist/src/doctor/doctor-backlog-plans.js +398 -0
- package/dist/src/doctor/doctor-backlog.js +249 -512
- package/dist/src/doctor/doctor-check-names.js +85 -0
- package/dist/src/doctor/doctor-checks.js +61 -593
- package/dist/src/doctor/doctor-claude-checks.js +142 -0
- package/dist/src/doctor/doctor-codex-checks.js +276 -0
- package/dist/src/doctor/doctor-contention-checks.js +506 -0
- package/dist/src/doctor/doctor-fallback-checks.js +130 -0
- package/dist/src/doctor/doctor-holds.js +80 -66
- package/dist/src/doctor/doctor-install.js +2 -1
- package/dist/src/doctor/doctor-model-checks.js +216 -537
- package/dist/src/doctor/doctor-notify.js +9 -5
- package/dist/src/doctor/doctor-opencode-checks.js +164 -0
- package/dist/src/doctor/doctor-orphans.js +182 -63
- package/dist/src/doctor/doctor-pi-checks.js +62 -0
- package/dist/src/doctor/doctor-project-checks.js +397 -0
- package/dist/src/doctor/doctor.js +126 -77
- package/dist/src/doctor/image-probe.js +21 -8
- package/dist/src/doctor/outcome.js +3 -2
- package/dist/src/doctor/probe.js +21 -12
- package/dist/src/events/event-format.js +185 -89
- package/dist/src/events/event-labels.js +11 -5
- package/dist/src/events/event-legacy.js +39 -15
- package/dist/src/events/event-read.js +52 -39
- package/dist/src/events/event-tally.js +16 -0
- package/dist/src/events/event-window.js +80 -26
- package/dist/src/events/events.js +30 -13
- package/dist/src/events/notify.js +7 -7
- package/dist/src/failure/failure-cluster.js +12 -12
- package/dist/src/failure/failure-data.js +65 -60
- package/dist/src/failure/failure-render.js +29 -21
- package/dist/src/failure/failure-state-change.js +17 -12
- package/dist/src/failure/time-spend.js +35 -35
- package/dist/src/files/file-queue.js +17 -0
- package/dist/src/files/files.js +111 -35
- package/dist/src/files/json-files.js +3 -3
- package/dist/src/files/json-object.js +28 -7
- package/dist/src/files/stat-cache.js +2 -2
- package/dist/src/files/tail.js +1 -1
- package/dist/src/gates/bootstrap-gates.js +4 -5
- package/dist/src/gates/budget-gates.js +88 -225
- package/dist/src/gates/disk-gate.js +1 -1
- package/dist/src/gates/gate-polls.js +101 -143
- package/dist/src/gates/maintenance-quota.js +37 -30
- package/dist/src/gates/pause-gates.js +28 -10
- package/dist/src/gates/readiness.js +7 -3
- package/dist/src/gates/role-cap-gates.js +8 -8
- package/dist/src/gates/startup-gate.js +94 -32
- package/dist/src/gates/streak-gate.js +54 -30
- package/dist/src/git/commit-message.js +89 -46
- package/dist/src/git/git-diff.js +16 -10
- package/dist/src/git/git-run.js +79 -38
- package/dist/src/git/git.js +54 -34
- package/dist/src/git/slot-salvage.js +253 -0
- package/dist/src/git/slots-state.js +6 -6
- package/dist/src/git/worktree-pool.js +132 -38
- package/dist/src/git/worktree-use.js +4 -11
- package/dist/src/git/worktree.js +94 -37
- package/dist/src/gui/gui-admin-endpoints.js +96 -0
- package/dist/src/gui/gui-args.js +79 -8
- package/dist/src/gui/gui-backlog-commands.js +43 -19
- package/dist/src/gui/gui-backlog-endpoints.js +156 -0
- package/dist/src/gui/gui-command.js +26 -13
- package/dist/src/gui/gui-config-commands.js +168 -80
- package/dist/src/gui/gui-endpoint-commands.js +5 -4
- package/dist/src/gui/gui-endpoint-common.js +4 -4
- package/dist/src/gui/gui-endpoints.js +11 -380
- package/dist/src/gui/gui-history-endpoints.js +301 -0
- package/dist/src/gui/gui-operator-commands.js +235 -13
- package/dist/src/gui/gui-prompt-commands.js +26 -20
- package/dist/src/gui/gui-report-endpoints.js +97 -0
- package/dist/src/gui/gui-server.js +26 -5
- package/dist/src/gui/gui-team-commands.js +7 -7
- package/dist/src/gui/http-body.js +1 -1
- package/dist/src/history/history-data.js +122 -234
- package/dist/src/history/history-queued.js +155 -0
- package/dist/src/history/history-render.js +42 -22
- package/dist/src/history/history.js +190 -49
- package/dist/src/inbox/inbox-attachments.js +29 -30
- package/dist/src/inbox/inbox-cancel.js +10 -11
- package/dist/src/inbox/inbox-edit.js +9 -9
- package/dist/src/inbox/inbox-submit.js +18 -18
- package/dist/src/inbox/inbox-top.js +28 -17
- package/dist/src/inbox/inbox.js +42 -43
- package/dist/src/inbox/pending-prompt.js +26 -26
- package/dist/src/inbox/prompt-commands.js +62 -49
- package/dist/src/inbox/prompt-not-before.js +2 -2
- package/dist/src/init/init-templates.js +1 -2
- package/dist/src/init/init.js +28 -23
- package/dist/src/merge/backlog-conflicts.js +215 -35
- package/dist/src/merge/merge-batch.js +14 -8
- package/dist/src/merge/merge-cancel.js +53 -0
- package/dist/src/merge/merge-check-failures.js +44 -22
- package/dist/src/merge/merge-core.js +58 -32
- package/dist/src/merge/merge-diff.js +5 -4
- package/dist/src/merge/merge-drain.js +45 -19
- package/dist/src/merge/merge-git.js +177 -38
- package/dist/src/merge/merge-pipeline.js +15 -3
- package/dist/src/merge/merge-post-review.js +214 -0
- package/dist/src/merge/merge-queue-render.js +48 -11
- package/dist/src/merge/merge-queue.js +52 -15
- package/dist/src/merge/merge-requeue.js +103 -0
- package/dist/src/merge/merge-slot.js +61 -16
- package/dist/src/merge/merge-stack.js +27 -11
- package/dist/src/merge/merge-to-main.js +77 -26
- package/dist/src/merge/merge-top.js +83 -0
- package/dist/src/merge/merge-verify.js +75 -18
- package/dist/src/notes-cli.js +35 -15
- package/dist/src/operator/operator-commands.js +192 -81
- package/dist/src/operator/operator-intent.js +69 -42
- package/dist/src/operator/operator-requests.js +35 -40
- package/dist/src/operator/retire.js +5 -6
- package/dist/src/operator/revert.js +142 -34
- package/dist/src/orchestrator/orchestrator-launch.js +60 -52
- package/dist/src/orchestrator/orchestrator-poll.js +352 -0
- package/dist/src/orchestrator/orchestrator-scheduling.js +71 -370
- package/dist/src/orchestrator/orchestrator.js +129 -369
- package/dist/src/orchestrator/retention.js +42 -8
- package/dist/src/orchestrator/scheduling-admit.js +116 -0
- package/dist/src/orchestrator/scheduling-scope.js +245 -0
- package/dist/src/orchestrator/scheduling-skip.js +195 -0
- package/dist/src/orchestrator/startup-migration-steps.js +8 -7
- package/dist/src/orchestrator/startup-migrations.js +11 -7
- package/dist/src/paths.js +126 -49
- package/dist/src/pi/command-shape.js +12 -1
- package/dist/src/pi/pi-args.js +6 -4
- package/dist/src/pi/pi-backend.js +6 -6
- package/dist/src/pi/pi-bin.js +54 -63
- package/dist/src/pi/pi-listed-models.js +53 -0
- package/dist/src/pi/pi-models.js +63 -30
- package/dist/src/pi/pi-run-result.js +1 -1
- package/dist/src/pi/pi-settings.js +1 -1
- package/dist/src/pi/pi-stream.js +6 -6
- package/dist/src/pi/pi-watchdogs.js +18 -19
- package/dist/src/pi-extension/bounded-output.js +5 -3
- package/dist/src/pi-extension/context-budget.js +2 -2
- package/dist/src/pi-extension/context-shake.js +1 -1
- package/dist/src/pi-extension/context-window.js +0 -1
- package/dist/src/pi-extension/full-output.js +3 -10
- package/dist/src/pi-extension/strip-thinking.js +0 -1
- package/dist/src/pi-extension/trim-system-prompt.js +3 -4
- package/dist/src/pi-extension/write-guard.js +37 -0
- package/dist/src/process/launch-services.js +1 -2
- package/dist/src/process/node-bin.js +16 -0
- package/dist/src/process/process-group.js +8 -18
- package/dist/src/process/process.js +41 -2
- package/dist/src/process/run-marker.js +26 -6
- package/dist/src/process/supervisor.js +22 -21
- package/dist/src/prompt/dialect.js +17 -2
- package/dist/src/prompt/gate-notes.js +203 -0
- package/dist/src/prompt/gate-prompts.js +150 -217
- package/dist/src/prompt/notes-backlog.js +184 -0
- package/dist/src/prompt/principles.js +1 -2
- package/dist/src/prompt/prompt-followup.js +19 -14
- package/dist/src/prompt/prompt.js +131 -204
- package/dist/src/redeploy/redeploy-policy.js +24 -19
- package/dist/src/redeploy/redeploy-probes.js +1 -1
- package/dist/src/redeploy/redeploy.js +2 -3
- package/dist/src/redeploy/redeployer.js +20 -20
- package/dist/src/redeploy/self-reload.js +2 -1
- package/dist/src/report/report-data.js +188 -124
- package/dist/src/report/report-fold.js +130 -54
- package/dist/src/report/report-main-red.js +55 -0
- package/dist/src/report/report-render.js +136 -65
- package/dist/src/report/report-types.js +1 -0
- package/dist/src/report/report.js +23 -9
- package/dist/src/request-timeouts.js +2 -2
- package/dist/src/review/diff-rules.js +53 -0
- package/dist/src/review/known-flakes.js +112 -1
- package/dist/src/review/post-merge-review-accept.js +51 -0
- package/dist/src/review/post-merge-review-render.js +112 -0
- package/dist/src/review/post-merge-review.js +239 -0
- package/dist/src/review/protected-edits.js +24 -10
- package/dist/src/review/quarantine-render.js +65 -0
- package/dist/src/review/review-followup.js +8 -8
- package/dist/src/review/review-precheck.js +66 -23
- package/dist/src/review/review-run.js +395 -0
- package/dist/src/review/review-verdict.js +2 -2
- package/dist/src/review/review.js +180 -401
- package/dist/src/review/secret-leaks.js +36 -16
- package/dist/src/review/suite-rerun.js +0 -1
- package/dist/src/roles/legacy-role-migration.js +80 -7
- package/dist/src/roles/role-catalog.js +127 -12
- package/dist/src/roles/role-guidance.js +65 -20
- package/dist/src/roles/role-labels.js +7 -5
- package/dist/src/roles/role-render.js +108 -56
- package/dist/src/roles/role-view.js +95 -56
- package/dist/src/roles/roles.js +33 -33
- package/dist/src/roles/runner-ids.js +97 -0
- package/dist/src/run/acceptance-greps.js +119 -0
- package/dist/src/run/ci-parity.js +121 -0
- package/dist/src/run/feedback-filings.js +51 -0
- package/dist/src/run/qa-coverage.js +125 -0
- package/dist/src/{tick/tick-apply.js → run/run-apply.js} +95 -94
- package/dist/src/run/run-detail-data.js +175 -0
- package/dist/src/run/run-detail-render.js +91 -0
- package/dist/src/run/run-detail.js +168 -0
- package/dist/src/{tick/tick-finalize.js → run/run-finalize.js} +73 -60
- package/dist/src/{tick/tick-outcome.js → run/run-outcome.js} +6 -7
- package/dist/src/{tick/tick-prompt.js → run/run-prompt.js} +122 -73
- package/dist/src/{tick/tick-resume.js → run/run-resume.js} +55 -26
- package/dist/src/{tick/tick-stage.js → run/run-stage.js} +170 -83
- package/dist/src/{tick/tick-timing.js → run/run-timing.js} +43 -41
- package/dist/src/{tick/tick-usage.js → run/run-usage.js} +45 -37
- package/dist/src/{tick/tick-verdict.js → run/run-verdict.js} +105 -57
- package/dist/src/run/selfcheck-digest.js +40 -0
- package/dist/src/run/stage-backlog-checks.js +249 -0
- package/dist/src/run/stage-check.js +178 -0
- package/dist/src/run/stage-checks-shared.js +65 -0
- package/dist/src/run/stage-name-checks.js +204 -0
- package/dist/src/run/stage-path-checks.js +231 -0
- package/dist/src/run/stage-prose-checks.js +261 -0
- package/dist/src/runner/leftover.js +216 -0
- package/dist/src/{loop → runner}/model-fallback.js +12 -12
- package/dist/src/runner/revision.js +191 -0
- package/dist/src/{loop/loop-fallback.js → runner/runner-fallback.js} +8 -8
- package/dist/src/runner/runner-outcome.js +109 -0
- package/dist/src/{loop/loop-pi.js → runner/runner-pi.js} +140 -131
- package/dist/src/{loop/loop-recovery.js → runner/runner-recovery.js} +39 -18
- package/dist/src/{loop/loop-tick.js → runner/runner-run.js} +236 -245
- package/dist/src/{loop/loop-state.js → runner/runner-state-load.js} +61 -114
- package/dist/src/runner/runner-state.js +103 -0
- package/dist/src/{loop/loop.js → runner/runner.js} +123 -120
- package/dist/src/scheduling/backoff.js +38 -38
- package/dist/src/scheduling/claim-entries.js +49 -0
- package/dist/src/scheduling/claims.js +35 -22
- package/dist/src/scheduling/once-round.js +22 -22
- package/dist/src/scheduling/quiet-hours.js +5 -6
- package/dist/src/scheduling/scheduling.js +74 -64
- package/dist/src/scheduling/work-merged-cache.js +2 -2
- package/dist/src/status/status-budget.js +55 -47
- package/dist/src/status/status-data.js +49 -17
- package/dist/src/status/status-inbox.js +2 -2
- package/dist/src/status/status-install.js +0 -1
- package/dist/src/status/status-polls.js +43 -28
- package/dist/src/status/status-roles.js +50 -13
- package/dist/src/team/error-storm.js +8 -8
- package/dist/src/team/failure-spread.js +2 -2
- package/dist/src/team/orchestrator-info.js +15 -9
- package/dist/src/team/reclaim.js +7 -8
- package/dist/src/team/team-hold.js +7 -7
- package/dist/src/team/team-polls.js +91 -50
- package/dist/src/team/team-state.js +8 -2
- package/dist/src/text/datetime.js +65 -18
- package/dist/src/text/format.js +48 -11
- package/dist/src/text/markdown.js +8 -5
- package/dist/src/text/phrases.js +60 -23
- package/dist/src/text/suggest.js +1 -1
- package/dist/src/text/text.js +78 -13
- package/dist/src/ui/badges.js +99 -38
- package/dist/src/ui/gui/gui-client-backlog.js +314 -0
- package/dist/src/ui/gui/gui-client-boot.js +89 -61
- package/dist/src/ui/gui/gui-client-composer.js +8 -9
- package/dist/src/ui/gui/gui-client-drawer-config.js +254 -0
- package/dist/src/ui/gui/gui-client-drawer.js +155 -289
- package/dist/src/ui/gui/gui-client-feed.js +271 -0
- package/dist/src/ui/gui/gui-client-history.js +437 -68
- package/dist/src/ui/gui/gui-client-labels.js +6 -3
- package/dist/src/ui/gui/gui-client-markdown.js +1 -2
- package/dist/src/ui/gui/gui-client-model.js +27 -36
- package/dist/src/ui/gui/gui-client-operator.js +60 -52
- package/dist/src/ui/gui/gui-client-pending.js +405 -24
- package/dist/src/ui/gui/gui-client-report-chart.js +492 -0
- package/dist/src/ui/gui/gui-client-report.js +105 -430
- package/dist/src/ui/gui/{gui-client-loops.js → gui-client-roles.js} +43 -44
- package/dist/src/ui/gui/gui-client-settings.js +163 -5
- package/dist/src/ui/gui/gui-client-team.js +36 -465
- package/dist/src/ui/gui/gui-client.js +38 -26
- package/dist/src/ui/gui/gui-icons.js +0 -2
- package/dist/src/ui/gui/gui-page.js +10 -7
- package/dist/src/ui/gui/gui-styles.js +38 -34
- package/dist/src/ui/progress-data.js +9 -11
- package/dist/src/ui/{tick-progress-model.js → run-progress-model.js} +33 -32
- package/dist/src/ui/status-model.js +133 -125
- package/dist/src/ui/status-payload.js +364 -49
- package/dist/src/ui/status-render.js +109 -82
- package/dist/src/ui/team-alerts.js +728 -62
- package/dist/src/ui/tone.js +12 -6
- package/dist/src/ui/transcript-run.js +58 -0
- package/dist/src/ui/transcript-tail.js +28 -20
- package/dist/src/ui/transcript.js +63 -23
- package/dist/src/ui/tui/tui-app.js +0 -1
- package/dist/src/ui/tui/tui-backlog.js +53 -14
- package/dist/src/ui/tui/tui-editor-answer.js +27 -31
- package/dist/src/ui/tui/tui-editor-attach.js +4 -12
- package/dist/src/ui/tui/tui-editor-backends.js +127 -0
- package/dist/src/ui/tui/tui-editor-brief.js +14 -36
- package/dist/src/ui/tui/tui-editor-budget.js +36 -39
- package/dist/src/ui/tui/tui-editor-check.js +29 -0
- package/dist/src/ui/tui/tui-editor-checks.js +28 -0
- package/dist/src/ui/tui/tui-editor-claude-reserve.js +24 -0
- package/dist/src/ui/tui/tui-editor-concurrency.js +25 -0
- package/dist/src/ui/tui/tui-editor-config.js +99 -0
- package/dist/src/ui/tui/tui-editor-entry.js +39 -64
- package/dist/src/ui/tui/tui-editor-fallback.js +31 -0
- package/dist/src/ui/tui/tui-editor-instances.js +46 -0
- package/dist/src/ui/tui/tui-editor-instructions.js +117 -0
- package/dist/src/ui/tui/tui-editor-interval.js +29 -16
- package/dist/src/ui/tui/tui-editor-model.js +29 -0
- package/dist/src/ui/tui/tui-editor-notes.js +9 -29
- package/dist/src/ui/tui/tui-editor-notify.js +25 -0
- package/dist/src/ui/tui/tui-editor-pause.js +48 -59
- package/dist/src/ui/tui/tui-editor-prompt.js +11 -16
- package/dist/src/ui/tui/tui-editor-provider-caps.js +27 -0
- package/dist/src/ui/tui/tui-editor-queued.js +5 -9
- package/dist/src/ui/tui/tui-editor-quiet.js +95 -41
- package/dist/src/ui/tui/tui-editor-retire.js +5 -13
- package/dist/src/ui/tui/tui-editor-role-enabled.js +126 -0
- package/dist/src/ui/tui/tui-editor-role-key.js +71 -0
- package/dist/src/ui/tui/tui-editor-role-model.js +28 -0
- package/dist/src/ui/tui/tui-editor-search.js +45 -33
- package/dist/src/ui/tui/tui-editor-session.js +101 -34
- package/dist/src/ui/tui/tui-editor-slots.js +24 -0
- package/dist/src/ui/tui/tui-editors.js +44 -2
- package/dist/src/ui/tui/tui-frame.js +68 -11
- package/dist/src/ui/tui/tui-history.js +8 -9
- package/dist/src/ui/tui/tui-input.js +13 -11
- package/dist/src/ui/tui/tui-keymap.js +0 -1
- package/dist/src/ui/tui/tui-keys.js +33 -9
- package/dist/src/ui/tui/tui-pane-activity.js +35 -19
- package/dist/src/ui/tui/tui-pane-backlog.js +198 -126
- package/dist/src/ui/tui/tui-pane-brief.js +0 -1
- package/dist/src/ui/tui/tui-pane-ctx.js +2 -2
- package/dist/src/ui/tui/tui-pane-health.js +168 -8
- package/dist/src/ui/tui/tui-pane-history.js +446 -82
- package/dist/src/ui/tui/tui-pane-pending.js +539 -15
- package/dist/src/ui/tui/tui-pane-queued.js +78 -57
- package/dist/src/ui/tui/tui-pane-report.js +54 -16
- package/dist/src/ui/tui/tui-pane-session.js +34 -6
- package/dist/src/ui/tui/tui-pane-transcript.js +111 -37
- package/dist/src/ui/tui/tui-pane.js +91 -28
- package/dist/src/ui/tui/tui-panes.js +153 -34
- package/dist/src/ui/tui/tui.js +18 -15
- package/dist/src/verdict/fix-claim.js +129 -33
- package/dist/src/verdict/no-change.js +4 -4
- package/dist/src/verdict/refusal.js +40 -16
- package/dist/src/verdict/reply-contract.js +77 -31
- package/dist/src/version.js +0 -1
- package/package.json +2 -1
- package/dist/src/loop/leftover.js +0 -130
- package/dist/src/loop/revision.js +0 -70
- package/dist/src/roles/loop-ids.js +0 -77
- package/dist/src/tick/qa-coverage.js +0 -108
- package/dist/src/tick/stage-check.js +0 -419
- package/dist/src/tick/telemetry-digest.js +0 -25
- package/dist/src/tick/tick-detail-data.js +0 -108
- package/dist/src/tick/tick-detail.js +0 -163
- package/dist/src/ui/gui/gui-client-sound.js +0 -53
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
/** The worktree/process contention checks, split out of doctor-checks.ts (the project's
|
|
2
|
+
* largest module, ~6x the file-size median, holding four unrelated check domains): the merge
|
|
3
|
+
* lock, the worktree pool, the three `_wait` contention rows (slot, provider, check), and the
|
|
4
|
+
* parallel-work-instance, tumwater-pin, leftover-role-branch and provider-concurrency audits.
|
|
5
|
+
* Read-only like every doctor check; they share the wait fold (sumWaits) and the pin/worktree
|
|
6
|
+
* leftovers helpers, all of which live here beside the checks that use them. The environment
|
|
7
|
+
* and repo-setup checks stay in doctor-checks.ts; the project's own state, disk and build
|
|
8
|
+
* checks live in doctor-project-checks.ts; model readiness lives in doctor-model-checks.ts;
|
|
9
|
+
* the per-document backlog checks live in doctor-backlog-plans.ts and doctor-backlog-bugs.ts
|
|
10
|
+
* beside doctor-backlog.ts's shared helpers and queue checks;
|
|
11
|
+
* doctor.ts remains the composition layer
|
|
12
|
+
* that calls each check in fixed order. */
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { defaultConfig, knownRoleIds, slotCount } from "../config/config.js";
|
|
15
|
+
import { CUSTOM_NAME_RE } from "../config/config-schema.js";
|
|
16
|
+
import { subdirectoryNames } from "../files/files.js";
|
|
17
|
+
import { finiteNumber, stringList } from "../files/json-object.js";
|
|
18
|
+
import { commitExists, deleteRefIfAt, listBranches, refSha, refsUnder } from "../git/git.js";
|
|
19
|
+
import { readSlotsState } from "../git/slots-state.js";
|
|
20
|
+
import { eventsSince } from "../events/event-window.js";
|
|
21
|
+
import { DAY_MS } from "../text/datetime.js";
|
|
22
|
+
import { shortSha } from "../text/format.js";
|
|
23
|
+
import { errorMessage, joinParts, textOr } from "../text/text.js";
|
|
24
|
+
import { rankCountEntries } from "../collections.js";
|
|
25
|
+
import { DIRECTOR_ROLE } from "../roles/roles.js";
|
|
26
|
+
import { INSTANCE_ROLES, runnerEnabled, runnerIds } from "../roles/runner-ids.js";
|
|
27
|
+
import { staleClaimDetail, staleClaimEntries } from "../scheduling/claim-entries.js";
|
|
28
|
+
import { piModelsPath } from "../pi/pi-models.js";
|
|
29
|
+
import { piSettingsPath, readPiDefaultModel } from "../pi/pi-settings.js";
|
|
30
|
+
import { seamConcurrencyKey } from "../config/config-views.js";
|
|
31
|
+
import { seamsInUse } from "../config/config-fallbacks.js";
|
|
32
|
+
import { enforcedKeyCaps } from "../concurrency/semaphore.js";
|
|
33
|
+
import { classifyLock, readLockPid } from "../concurrency/lock.js";
|
|
34
|
+
import { branchName, mergeLockDir, worktreesDir } from "../paths.js";
|
|
35
|
+
import { artifactPhrase, retireRole } from "../operator/retire.js";
|
|
36
|
+
import { loadRunnerState } from "../runner/runner-state-load.js";
|
|
37
|
+
import { agree, plural, shortSpanPhrase } from "../text/phrases.js";
|
|
38
|
+
import { failOutcome, okOutcome, warnOutcome } from "./outcome.js";
|
|
39
|
+
/** Merge lock — read-only classification via classifyLock (the same three cases the breaker
|
|
40
|
+
* uses). A stale lock is a warning, not a failure: it self-heals on the next merge. */
|
|
41
|
+
export function checkMergeLock(root) {
|
|
42
|
+
const dir = mergeLockDir(root);
|
|
43
|
+
switch (classifyLock(dir)) {
|
|
44
|
+
case "absent":
|
|
45
|
+
return okOutcome("not held");
|
|
46
|
+
case "live": {
|
|
47
|
+
const pid = readLockPid(dir);
|
|
48
|
+
return okOutcome(pid !== null ? `held by running role (pid ${pid})` : "held (pid not yet written)");
|
|
49
|
+
}
|
|
50
|
+
case "stale":
|
|
51
|
+
return warnOutcome("stale — will be broken on next merge");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** A pin older than a day is the doctor's stuck-pin signal: a paused or retired role is holding
|
|
55
|
+
* a pooled checkout nobody will reuse (plans/worktree-pool.md, "Observability"). */
|
|
56
|
+
const PIN_STALE_MS = DAY_MS;
|
|
57
|
+
/** The single selector of a stale workspace pin, shared by the doctor's `worktree pool` row
|
|
58
|
+
* (checkWorktreePool) and the always-on `workspace-pins` alert, so the two cannot drift on
|
|
59
|
+
* what a stale pin is: every pinned-for slot whose pin is older than PIN_STALE_MS. Empty when
|
|
60
|
+
* nothing is pinned old enough — an unpinned pool, a malformed or absent slots.json, or a
|
|
61
|
+
* timeouted (non-finite) pinnedAt all read as no stale pin, exactly the tolerance
|
|
62
|
+
* checkWorktreePool has today. `now` is a test seam. */
|
|
63
|
+
export function staleWorkspacePins(root, now = Date.now()) {
|
|
64
|
+
return readSlotsState(root).slots
|
|
65
|
+
.filter((slot) => slot.pinnedFor !== null && slot.pinnedAt != null && now - slot.pinnedAt > PIN_STALE_MS)
|
|
66
|
+
.map((slot) => ({
|
|
67
|
+
role: slot.pinnedFor,
|
|
68
|
+
dir: path.basename(slot.dir),
|
|
69
|
+
ageMs: now - slot.pinnedAt,
|
|
70
|
+
}));
|
|
71
|
+
}
|
|
72
|
+
/** The shared one-pin clause of the doctor's `worktree pool` row (checkWorktreePool) and the
|
|
73
|
+
* standing `workspace-pins` alert (team-alerts.ts): `<role> pinned on <dir> for <Nh>`. One
|
|
74
|
+
* home so the row and the alert cannot drift on how a pin's age is phrased (whole hours, off
|
|
75
|
+
* the pin's whole-ms age). `role` is a non-optional string on StaleWorkspacePin, but the
|
|
76
|
+
* clause renders it through textOr like the row always did, so a malformed pin can never
|
|
77
|
+
* print nothing where the phrase expects a role. */
|
|
78
|
+
export function stalePinPhrase(pin) {
|
|
79
|
+
return `${textOr(pin.role)} pinned on ${pin.dir} for ${Math.round(pin.ageMs / 3_600_000)}h`;
|
|
80
|
+
}
|
|
81
|
+
/** Directory names under the worktrees root that the pool no longer owns: a legacy per-role
|
|
82
|
+
* `<role>` checkout (the pre-pool layout) or a `_land-<role>` checkout from the merge queue. The
|
|
83
|
+
* dedicated `director`, `_slot-<n>`, `_merge`, `_main`, `_gate-main` and `_build` checkouts are
|
|
84
|
+
* expected and never reported. An unreadable worktrees dir means nothing to report. */
|
|
85
|
+
function leftoverWorktreeDirs(root) {
|
|
86
|
+
return subdirectoryNames(worktreesDir(root), (name) => name !== DIRECTOR_ROLE && !(name.startsWith("_") && !name.startsWith("_land-")));
|
|
87
|
+
}
|
|
88
|
+
/** The window of `slot_wait` events the pool row sums, and the total wait inside it that warns. */
|
|
89
|
+
export const SLOT_WAIT_CHECK_WINDOW_MS = DAY_MS;
|
|
90
|
+
const SLOT_WAIT_WARN_TOTAL_MS = 10 * 60_000;
|
|
91
|
+
export const PROVIDER_WAIT_CHECK_WINDOW_MS = DAY_MS;
|
|
92
|
+
const PROVIDER_WAIT_WARN_TOTAL_MS = 10 * 60_000;
|
|
93
|
+
export const CHECK_WAIT_CHECK_WINDOW_MS = DAY_MS;
|
|
94
|
+
const CHECK_WAIT_WARN_TOTAL_MS = 10 * 60_000;
|
|
95
|
+
/** Fold a window's `*_wait` events into the three numbers every contention check reads: how
|
|
96
|
+
* many waits there were (each event counts once), the total ms they waited, and the longest
|
|
97
|
+
* single wait — plus the longest event itself, for the check that names the role/purpose off
|
|
98
|
+
* it. The single home of that fold for the three contention checks (checkWorktreePool's
|
|
99
|
+
* `slot_wait` half, checkProviderContention, checkCheckContention), so the sum and the
|
|
100
|
+
* longest-wait selection cannot drift between them. A wait that omits or corrupts `waitedMs`
|
|
101
|
+
* reads as 0 for the sum, exactly as each inline `finiteNumber(ev.waitedMs, 0)` fold did;
|
|
102
|
+
* the longest-wait tie keeps the first event that reached it. */
|
|
103
|
+
function sumWaits(waits) {
|
|
104
|
+
let count = 0;
|
|
105
|
+
let totalMs = 0;
|
|
106
|
+
let longestMs = 0;
|
|
107
|
+
let longest = waits[0];
|
|
108
|
+
for (const ev of waits) {
|
|
109
|
+
count += 1;
|
|
110
|
+
const waited = finiteNumber(ev.waitedMs, 0);
|
|
111
|
+
totalMs += waited;
|
|
112
|
+
if (waited > longestMs) {
|
|
113
|
+
longestMs = waited;
|
|
114
|
+
longest = ev;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { count, totalMs, longestMs, longest };
|
|
118
|
+
}
|
|
119
|
+
/** The `slot_wait` half of checkWorktreePool's rule: whether the window's waits cross
|
|
120
|
+
* SLOT_WAIT_WARN_TOTAL_MS and the pool row's exact detail clause — the wait count, total and
|
|
121
|
+
* longest spans (naming the role and whether it was a check or a run), and, on a warn, the
|
|
122
|
+
* knob to raise past `slotCount`. Shared by the doctor's `workspaces` row and the always-on
|
|
123
|
+
* contention alert, so the trigger and the advice cannot drift. */
|
|
124
|
+
export function slotWaitContention(waits, slotCount) {
|
|
125
|
+
const { count, totalMs, longestMs, longest } = sumWaits(waits);
|
|
126
|
+
const contended = totalMs >= SLOT_WAIT_WARN_TOTAL_MS;
|
|
127
|
+
const purpose = longest.purpose === "verify" ? "check" : "run";
|
|
128
|
+
const detail = `${plural(count, "wait")} to start in the last 24h, ${shortSpanPhrase(totalMs)} total, longest ${shortSpanPhrase(longestMs)} (${textOr(longest.role)} ${purpose})${contended ? ` — raise worktreeSlots above ${slotCount}` : ""}`;
|
|
129
|
+
return { warn: contended, detail };
|
|
130
|
+
}
|
|
131
|
+
/** Provider-slot contention's rule (the body of checkProviderContention from `sumWaits` on):
|
|
132
|
+
* sums the waits, ranks the per-key buckets busiest first (the key-less bucket, which also
|
|
133
|
+
* takes every team-cap wait whatever its key, reads "the run cap") and, on a warn past
|
|
134
|
+
* PROVIDER_WAIT_WARN_TOTAL_MS, names each key's maxConcurrentPerProvider cap to raise plus
|
|
135
|
+
* maxConcurrent for the run cap. Shared by the doctor's `provider contention` row and the
|
|
136
|
+
* always-on contention alert, so the trigger and the advice cannot drift. The shared rule
|
|
137
|
+
* leaves the count-zero and read-error arms to checkProviderContention. */
|
|
138
|
+
export function providerWaitContention(waits, config) {
|
|
139
|
+
const perKey = new Map();
|
|
140
|
+
const { count, totalMs, longestMs } = sumWaits(waits);
|
|
141
|
+
for (const ev of waits) {
|
|
142
|
+
const waited = finiteNumber(ev.waitedMs, 0);
|
|
143
|
+
const key = ev.cap !== "team" && typeof ev.key === "string" && ev.key.length > 0 ? ev.key : "";
|
|
144
|
+
perKey.set(key, (perKey.get(key) ?? 0) + waited);
|
|
145
|
+
}
|
|
146
|
+
// Rank the buckets by wait total, ties breaking on key name so the detail is deterministic.
|
|
147
|
+
// The key-less bucket reads "the run cap" — maxConcurrent, since no seam keys to a provider.
|
|
148
|
+
const ranked = rankCountEntries(perKey).map(([key, total]) => ({ key, total }));
|
|
149
|
+
const bucketLabel = (key) => (key === "" ? "the run cap" : `the ${key} slot`);
|
|
150
|
+
const cells = ranked.map((b) => `${bucketLabel(b.key)} ${shortSpanPhrase(b.total)}`);
|
|
151
|
+
// A single bucket keeps the pre-existing wording, a bare `(the X slot)` without the total
|
|
152
|
+
// (the total is already named by the `shortSpanPhrase(total)` in the clause before it);
|
|
153
|
+
// two or more name each bucket's total so the ranking reads at a glance.
|
|
154
|
+
const parenthetical = ranked.length === 1
|
|
155
|
+
? `(${bucketLabel(ranked[0].key)})`
|
|
156
|
+
: `(${cells[0]}, also ${cells.slice(1).join(", ")})`;
|
|
157
|
+
let detail = `${plural(count, "wait")} for a provider slot in the last 24h, ${shortSpanPhrase(totalMs)} total, longest ${shortSpanPhrase(longestMs)} ${parenthetical}`;
|
|
158
|
+
const contended = totalMs >= PROVIDER_WAIT_WARN_TOTAL_MS;
|
|
159
|
+
if (contended) {
|
|
160
|
+
const advice = ranked
|
|
161
|
+
.filter((b) => b.key !== "" && config?.maxConcurrentPerProvider?.[b.key] !== undefined)
|
|
162
|
+
.map((b) => `raise maxConcurrentPerProvider.${b.key} above ${config?.maxConcurrentPerProvider?.[b.key]}`)
|
|
163
|
+
.join(" and ");
|
|
164
|
+
const teamAdvice = perKey.has("") && config !== undefined ? [`raise maxConcurrent above ${config.maxConcurrent}`] : [];
|
|
165
|
+
const all = [...(advice ? [advice] : []), ...teamAdvice].join(" and ");
|
|
166
|
+
if (all)
|
|
167
|
+
detail += ` — ${all}`;
|
|
168
|
+
}
|
|
169
|
+
return { warn: contended, detail };
|
|
170
|
+
}
|
|
171
|
+
/** Check-slot contention's rule (the body of checkCheckContention from `sumWaits` on): sums
|
|
172
|
+
* the waits and warns past CHECK_WAIT_WARN_TOTAL_MS naming the knob (maxConcurrentChecks),
|
|
173
|
+
* with the `(behind <kind> <span>, …)` clause when the waits carried `heldBy` labels, kinds
|
|
174
|
+
* busiest first. Shared by the doctor's `check contention` row and the always-on contention
|
|
175
|
+
* alert, so the trigger and the advice cannot drift. The shared rule leaves the count-zero
|
|
176
|
+
* and read-error arms to checkCheckContention. */
|
|
177
|
+
export function checkWaitContention(waits, config) {
|
|
178
|
+
const perKind = new Map();
|
|
179
|
+
const { count, totalMs, longestMs } = sumWaits(waits);
|
|
180
|
+
for (const ev of waits) {
|
|
181
|
+
const waited = finiteNumber(ev.waitedMs, 0);
|
|
182
|
+
for (const kind of new Set(stringList(ev.heldBy).map(checkHolderKind))) {
|
|
183
|
+
perKind.set(kind, (perKind.get(kind) ?? 0) + waited);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
let detail = `${plural(count, "wait")} for a check slot in the last 24h, ${shortSpanPhrase(totalMs)} total, longest ${shortSpanPhrase(longestMs)}`;
|
|
187
|
+
if (perKind.size > 0) {
|
|
188
|
+
const ranked = rankCountEntries(perKind);
|
|
189
|
+
detail += ` (behind ${ranked.map(([kind, ms]) => `${kind} ${shortSpanPhrase(ms)}`).join(", ")})`;
|
|
190
|
+
}
|
|
191
|
+
if (totalMs < CHECK_WAIT_WARN_TOTAL_MS)
|
|
192
|
+
return { warn: false, detail };
|
|
193
|
+
detail += ` — raise maxConcurrentChecks above ${config?.maxConcurrentChecks ?? defaultConfig().maxConcurrentChecks}`;
|
|
194
|
+
return { warn: true, detail };
|
|
195
|
+
}
|
|
196
|
+
/** Worktree pool (plans/worktree-pool.md, part 5/5): report the pooled slots and warn on the
|
|
197
|
+
* states an operator should act on — a legacy `<role>` or `_land-*` checkout the pool migration
|
|
198
|
+
* left behind, a pin older than PIN_STALE_MS (a paused role holding a checkout), and the last
|
|
199
|
+
* 24 h of `slot_wait` events totalling SLOT_WAIT_WARN_TOTAL_MS (the pool is too small). The stale
|
|
200
|
+
* pin is also the standing `workspace-pins` alert's selection (staleWorkspacePins). Read-only,
|
|
201
|
+
* like every doctor check; `now` is a test seam for the pin-age arithmetic. */
|
|
202
|
+
export function checkWorktreePool(root, now = Date.now()) {
|
|
203
|
+
const slots = readSlotsState(root).slots;
|
|
204
|
+
const leased = slots.filter((slot) => slot.lease !== null);
|
|
205
|
+
const pins = slots.filter((slot) => slot.pinnedFor !== null);
|
|
206
|
+
const stale = staleWorkspacePins(root, now);
|
|
207
|
+
const leftover = leftoverWorktreeDirs(root);
|
|
208
|
+
const parts = [`${plural(slots.length, "workspace")}, ${leased.length} leased, ${pins.length} pinned`];
|
|
209
|
+
if (leftover.length > 0)
|
|
210
|
+
parts.push(`legacy worktrees still present: ${leftover.join(", ")}`);
|
|
211
|
+
if (stale.length > 0) {
|
|
212
|
+
// The stale-pin clause renders the shared staleWorkspacePins selection (the same list the
|
|
213
|
+
// standing `workspace-pins` alert carries), so the row's wording and the alert's selection
|
|
214
|
+
// cannot disagree on what a stale pin is or how old it is.
|
|
215
|
+
parts.push(stale.map(stalePinPhrase).join("; "));
|
|
216
|
+
}
|
|
217
|
+
let contended = false;
|
|
218
|
+
try {
|
|
219
|
+
const waits = eventsSince(root, SLOT_WAIT_CHECK_WINDOW_MS, now, (ev) => ev.type === "slot_wait");
|
|
220
|
+
if (waits.length > 0) {
|
|
221
|
+
const c = slotWaitContention(waits, slots.length);
|
|
222
|
+
contended = c.warn;
|
|
223
|
+
parts.push(c.detail);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
catch (err) {
|
|
227
|
+
parts.push(`could not read the event log for waits to start: ${errorMessage(err)}`);
|
|
228
|
+
}
|
|
229
|
+
const warn = leftover.length > 0 || stale.length > 0 || contended;
|
|
230
|
+
return (warn ? warnOutcome : okOutcome)(parts.join(" — "));
|
|
231
|
+
}
|
|
232
|
+
/** The shared wrapper of the `checkProviderContention`/`checkCheckContention` twins: read the
|
|
233
|
+
* window's `*_wait` events (a read error leaves the level ok, saying so rather than crying
|
|
234
|
+
* wolf, exactly like the slot-wait branch's own read), sum them, and resolve the row's rule;
|
|
235
|
+
* a window with no waits reads "no <noun>". One home for the try/catch, count-zero and
|
|
236
|
+
* ok-fallback arms the two siblings repeated, so they cannot drift on which inputs read ok;
|
|
237
|
+
* the row-specific rule (`providerWaitContention` / `checkWaitContention`) is `rule`. `noun`
|
|
238
|
+
* names the waits in the ok message. */
|
|
239
|
+
function waitContentionRow(root, windowMs, type, noun, rule, config, now) {
|
|
240
|
+
let count = 0;
|
|
241
|
+
let verdict;
|
|
242
|
+
try {
|
|
243
|
+
const waits = eventsSince(root, windowMs, now, (ev) => ev.type === type);
|
|
244
|
+
count = sumWaits(waits).count;
|
|
245
|
+
verdict = rule(waits, config);
|
|
246
|
+
}
|
|
247
|
+
catch (err) {
|
|
248
|
+
// A read error leaves the level ok, exactly like the slot-wait branch's own read: the
|
|
249
|
+
// check cannot measure, so it says so rather than crying wolf.
|
|
250
|
+
return okOutcome(`could not read the event log for ${noun}: ${errorMessage(err)}`);
|
|
251
|
+
}
|
|
252
|
+
if (count === 0)
|
|
253
|
+
return okOutcome(`no ${noun}`);
|
|
254
|
+
return (verdict.warn ? warnOutcome : okOutcome)(verdict.detail);
|
|
255
|
+
}
|
|
256
|
+
/** Provider-slot contention: sums the last 24 h of `provider_wait` events (each a role run
|
|
257
|
+
* whose semaphore admission waited PROVIDER_WAIT_EVENT_MS or more, recorded in
|
|
258
|
+
* orchestrator-launch.ts) and warns when the total reaches PROVIDER_WAIT_WARN_TOTAL_MS — the
|
|
259
|
+
* provider cap is too small. Names every key that waited, busiest first (the key-less
|
|
260
|
+
* bucket, which also takes every team-cap wait whatever its key, reads "the run cap") and, on
|
|
261
|
+
* a warn, each key's maxConcurrentPerProvider cap to raise, plus maxConcurrent for the run cap.
|
|
262
|
+
* A counterpoint to checkWorktreePool's slot-wait half; read-only, like every doctor check.
|
|
263
|
+
* `now` is a test seam for the window. */
|
|
264
|
+
export function checkProviderContention(root, config, now = Date.now()) {
|
|
265
|
+
return waitContentionRow(root, PROVIDER_WAIT_CHECK_WINDOW_MS, "provider_wait", "provider waits", providerWaitContention, config, now);
|
|
266
|
+
}
|
|
267
|
+
/** Names the kind of check a `heldBy` label stands for, in checkCheckContention's tally. */
|
|
268
|
+
function checkHolderKind(label) {
|
|
269
|
+
if (label === "main baseline")
|
|
270
|
+
return "the main baseline";
|
|
271
|
+
const scope = label.slice(label.lastIndexOf(" ") + 1);
|
|
272
|
+
return scope === "gate" || scope === "merge" || scope === "batch" ? `${scope} checks` : "other checks";
|
|
273
|
+
}
|
|
274
|
+
/** Check-slot contention: sums the last 24 h of `check_wait` events (each a scoped check whose
|
|
275
|
+
* permit wait reached CHECK_WAIT_EVENT_MS, recorded in build-check-events.ts) and warns when
|
|
276
|
+
* the total reaches CHECK_WAIT_WARN_TOTAL_MS — maxConcurrentChecks is too small. The third
|
|
277
|
+
* sibling of checkWorktreePool's slot-wait half and checkProviderContention; read-only.
|
|
278
|
+
* When events carry `heldBy` (the labels of the permit holders the check parked behind), the
|
|
279
|
+
* detail gains `(behind <kind> <span>, …)` after the longest clause, kinds busiest first: a
|
|
280
|
+
* wait counts once toward each distinct kind that held a slot. `now` is a test seam for the
|
|
281
|
+
* window. */
|
|
282
|
+
export function checkCheckContention(root, config, now = Date.now()) {
|
|
283
|
+
return waitContentionRow(root, CHECK_WAIT_CHECK_WINDOW_MS, "check_wait", "check waits", checkWaitContention, config, now);
|
|
284
|
+
}
|
|
285
|
+
/** The ids `leftover role branches` calls retirable and the branches it leaves for manual
|
|
286
|
+
* deletion — the one walk the row and the reap share, so the two can never disagree on what a
|
|
287
|
+
* retirable leftover is. Retirable ids are the branches whose role is no longer enabled (disabled
|
|
288
|
+
* in tumwater.json, or an instance above the configured count) and that `tumwater retire --role
|
|
289
|
+
* <id>` accepts (knownRoleIds, or any well-formed custom-role name); a `tumwater/<id>` branch for
|
|
290
|
+
* any other id — a removed custom role or an id retire can't name — falls to `unknown` as its
|
|
291
|
+
* full branch name. The director and enabled roles are skipped. An unreadable tumwater.json
|
|
292
|
+
* (config null) sees no branches. */
|
|
293
|
+
export async function retirableLeftoverRoles(root, config) {
|
|
294
|
+
if (!config)
|
|
295
|
+
return { retirable: [], unknown: [] };
|
|
296
|
+
const prefix = branchName("");
|
|
297
|
+
const known = new Set(knownRoleIds(config));
|
|
298
|
+
const retirable = [];
|
|
299
|
+
const unknown = [];
|
|
300
|
+
for (const branch of await listBranches(root)) {
|
|
301
|
+
if (!branch.startsWith(prefix))
|
|
302
|
+
continue;
|
|
303
|
+
const id = branch.slice(prefix.length);
|
|
304
|
+
if (!id || id.includes("/") || id === DIRECTOR_ROLE || runnerEnabled(config, id))
|
|
305
|
+
continue;
|
|
306
|
+
if (known.has(id) || CUSTOM_NAME_RE.test(id))
|
|
307
|
+
retirable.push(id);
|
|
308
|
+
else
|
|
309
|
+
unknown.push(branch);
|
|
310
|
+
}
|
|
311
|
+
return { retirable, unknown };
|
|
312
|
+
}
|
|
313
|
+
/** The "leftover role branches" row: `tumwater/<id>` branches whose role is no longer enabled
|
|
314
|
+
* (disabled in tumwater.json, or an instance above the configured count), through the shared
|
|
315
|
+
* retirableLeftoverRoles walk. Any other id is listed by
|
|
316
|
+
* full branch name with a manual-delete hint. Read-only, like every check: it only lists refs;
|
|
317
|
+
* an unreadable tumwater.json skips it. */
|
|
318
|
+
export async function checkLeftoverRoleBranches(root, config) {
|
|
319
|
+
if (!config)
|
|
320
|
+
return okOutcome("no readable tumwater.json — nothing to verify");
|
|
321
|
+
const { retirable, unknown } = await retirableLeftoverRoles(root, config);
|
|
322
|
+
const parts = [];
|
|
323
|
+
if (retirable.length > 0) {
|
|
324
|
+
const n = retirable.length;
|
|
325
|
+
parts.push(`${retirable.join(", ")} ${agree(n, "is", "are")} disabled but still ${agree(n, "has", "have")} a branch — \`tumwater retire --role <id>\` removes each (\`--dry-run\` previews)`);
|
|
326
|
+
}
|
|
327
|
+
if (unknown.length > 0) {
|
|
328
|
+
parts.push(`${unknown.join(", ")} ${agree(unknown.length, "names", "name")} no role retire accepts — delete with \`git branch -D\` once its commits are not needed`);
|
|
329
|
+
}
|
|
330
|
+
return parts.length === 0 ? okOutcome("no branches left by disabled roles") : warnOutcome(parts.join("; "));
|
|
331
|
+
}
|
|
332
|
+
/** The `refs/tumwater/` pins and, among them, the broken ones (whose commit is gone) — the one
|
|
333
|
+
* walk the `tumwater pins` row (checkTumwaterPins) and its reap (dropBrokenPins) share, so the
|
|
334
|
+
* two can never disagree on what a broken pin is. Filtered from `refsUnder(root,
|
|
335
|
+
* "refs/tumwater/")` by `!commitExists(root, pin.sha)`. */
|
|
336
|
+
async function brokenPins(root) {
|
|
337
|
+
const pins = await refsUnder(root, "refs/tumwater/");
|
|
338
|
+
const broken = [];
|
|
339
|
+
for (const pin of pins)
|
|
340
|
+
if (!(await commitExists(root, pin.sha)))
|
|
341
|
+
broken.push(pin);
|
|
342
|
+
return { pins, broken };
|
|
343
|
+
}
|
|
344
|
+
/** The "tumwater pins" row: fails when a `refs/tumwater/` pin (merge, rejected or legacy landing)
|
|
345
|
+
* names a commit that is gone. It fails rather than warns because such a pin stops `git gc`
|
|
346
|
+
* (and the `gc --auto` commits trigger), and a slot reset or leftover recovery only drops one
|
|
347
|
+
* when it reaches it. Read-only: it lists refs and names the `git update-ref -d` fix (the
|
|
348
|
+
* `--drop-broken-pins` flag performs it). */
|
|
349
|
+
export async function checkTumwaterPins(root) {
|
|
350
|
+
const { pins, broken } = await brokenPins(root);
|
|
351
|
+
if (broken.length === 0) {
|
|
352
|
+
return okOutcome(pins.length === 0 ? "no tumwater pins" : `${plural(pins.length, "tumwater pin")}, each naming a commit that exists`);
|
|
353
|
+
}
|
|
354
|
+
const n = broken.length;
|
|
355
|
+
return failOutcome(`${broken.map((p) => `${p.ref} names ${shortSha(p.sha)}, which is gone`).join("; ")} — git gc and a detached checkout abort on ${agree(n, "it", "them")}; \`git update-ref -d <ref>\` drops each (the work ${agree(n, "it", "they")} pinned is already lost)`);
|
|
356
|
+
}
|
|
357
|
+
/** The lines `doctor --drop-broken-pins` prints (and the dashboard's Drop broken pins toast),
|
|
358
|
+
* one per dropped pin and, when any, one for the pins that could not be dropped. No lines for
|
|
359
|
+
* a run that dropped nothing and failed nothing. */
|
|
360
|
+
export function brokenPinDropLines(drop) {
|
|
361
|
+
const lines = [];
|
|
362
|
+
if (drop.dropped.length > 0)
|
|
363
|
+
lines.push(`${plural(drop.dropped.length, "dropped broken pin")}: ${drop.dropped.map((p) => `${p.ref} names ${shortSha(p.sha)}`).join(", ")}`);
|
|
364
|
+
if (drop.failed.length > 0)
|
|
365
|
+
lines.push(`${plural(drop.failed.length, "pin could not be dropped", "pins could not be dropped")}: ${drop.failed.map((f) => `${f.ref} — ${f.reason}`).join(", ")}`);
|
|
366
|
+
return lines;
|
|
367
|
+
}
|
|
368
|
+
/** The lines `doctor --retire-leftovers` prints, one per retired role naming its removed
|
|
369
|
+
* artifacts through the same wording `tumwater retire --role <id>` surfaces, and one naming
|
|
370
|
+
* each failed role and its reason. No lines for a run that retired and failed nothing. */
|
|
371
|
+
export function leftoverRetireLines(report) {
|
|
372
|
+
const lines = [];
|
|
373
|
+
for (const r of report.retired)
|
|
374
|
+
lines.push(`retired ${r.role}: ${r.removed.map((a) => artifactPhrase(a, r.role)).join("; ")}`);
|
|
375
|
+
for (const f of report.failed)
|
|
376
|
+
lines.push(`failed to retire ${f.role}: ${f.reason}`);
|
|
377
|
+
return lines;
|
|
378
|
+
}
|
|
379
|
+
/** Retire each disabled role `retirableLeftoverRoles` calls retirable — the reap behind `doctor
|
|
380
|
+
* --retire-leftovers` — through the same `retireRole` core `tumwater retire --role <id>` uses,
|
|
381
|
+
* never `--force` (exactly the CLI's own refusal policy). A thrown rail refusal is caught into
|
|
382
|
+
* `failed` with its reason and the loop continues, leaving that role exactly as it was.
|
|
383
|
+
* Read-only checks never call this. */
|
|
384
|
+
export async function retireLeftovers(root, config) {
|
|
385
|
+
const { retirable } = await retirableLeftoverRoles(root, config);
|
|
386
|
+
const retired = [];
|
|
387
|
+
const failed = [];
|
|
388
|
+
for (const role of retirable) {
|
|
389
|
+
try {
|
|
390
|
+
const { removed } = await retireRole(root, role, {});
|
|
391
|
+
retired.push({ role, removed });
|
|
392
|
+
}
|
|
393
|
+
catch (err) {
|
|
394
|
+
failed.push({ role, reason: errorMessage(err) });
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return { retired, failed };
|
|
398
|
+
}
|
|
399
|
+
/** Drop each broken `refs/tumwater/` pin — the reap behind `doctor --drop-broken-pins` and the
|
|
400
|
+
* dashboard's Drop broken pins button. Each pin is deleted only while it still names the broken
|
|
401
|
+
* sha (deleteRefIfAt's guarded `git update-ref -d`), so a ref that moved under us is never
|
|
402
|
+
* dropped; a ref that still names a gone commit afterwards is reported failed. Read-only checks
|
|
403
|
+
* never call this. */
|
|
404
|
+
export async function dropBrokenPins(root) {
|
|
405
|
+
const { broken } = await brokenPins(root);
|
|
406
|
+
const dropped = [];
|
|
407
|
+
const failed = [];
|
|
408
|
+
for (const pin of broken) {
|
|
409
|
+
await deleteRefIfAt(root, pin.ref, pin.sha);
|
|
410
|
+
const still = await refSha(root, pin.ref);
|
|
411
|
+
if (still === null)
|
|
412
|
+
dropped.push(pin);
|
|
413
|
+
else if (!(await commitExists(root, still)))
|
|
414
|
+
failed.push({ ref: pin.ref, reason: `still names ${shortSha(still)}, which is gone` });
|
|
415
|
+
}
|
|
416
|
+
return { dropped, failed };
|
|
417
|
+
}
|
|
418
|
+
/** The ok detail of the instance check: non-zero counts, plans first, each pluralized. */
|
|
419
|
+
function claimCounts(claims) {
|
|
420
|
+
const bugs = claims.filter((c) => c.file === "BUGS.md").length;
|
|
421
|
+
const plans = claims.length - bugs;
|
|
422
|
+
const joined = joinParts([
|
|
423
|
+
plans > 0 ? plural(plans, "claimed plan") : "",
|
|
424
|
+
bugs > 0 ? plural(bugs, "claimed bug") : "",
|
|
425
|
+
], ", ");
|
|
426
|
+
return joined || "no claimed plans or bugs";
|
|
427
|
+
}
|
|
428
|
+
/** Parallel work instances (plans/parallel-work-instances.md "Observability", part 6/7):
|
|
429
|
+
* each instance's claimed plan or bug and the two states an operator should act on — a claim
|
|
430
|
+
* whose key is no longer listed under its role's section (its instance is stuck on an entry
|
|
431
|
+
* that moved), and a claim idle for more than CLAIM_IDLE_MAX_MS (the scheduler releases
|
|
432
|
+
* these, so seeing one means the reader's view and the scheduler's disagree). An enabled
|
|
433
|
+
* multi-instance role configured with fewer pooled slots than maxConcurrent also warns:
|
|
434
|
+
* instances then contend for the same checkouts. A disabled role runs none of its
|
|
435
|
+
* configured instances, so it never contends and
|
|
436
|
+
* stays out of that warning.
|
|
437
|
+
* Read-only, like every check; `now` is the claim-age test seam, and an unreadable
|
|
438
|
+
* tumwater.json (config undefined) skips the walk — checkInit reports the config itself. */
|
|
439
|
+
export function checkWorkInstances(root, config, now = Date.now()) {
|
|
440
|
+
if (!config)
|
|
441
|
+
return okOutcome("no readable tumwater.json — nothing to verify");
|
|
442
|
+
const claims = runnerIds(config).map((id) => loadRunnerState(root, id).claim).filter((c) => c !== undefined);
|
|
443
|
+
const entries = staleClaimEntries(root, runnerIds(config), now);
|
|
444
|
+
const gone = entries.filter((e) => e.kind === "gone");
|
|
445
|
+
const stale = entries.filter((e) => e.kind === "idle");
|
|
446
|
+
const multi = Object.entries(config.roles)
|
|
447
|
+
.filter(([id, role]) => INSTANCE_ROLES.has(id) && role.enabled && (role.instances ?? 1) > 1)
|
|
448
|
+
.map(([id]) => id);
|
|
449
|
+
const slots = slotCount(config);
|
|
450
|
+
const smallPool = multi.length > 0 && slots < config.maxConcurrent;
|
|
451
|
+
if (gone.length === 0 && stale.length === 0 && !smallPool)
|
|
452
|
+
return okOutcome(claimCounts(claims));
|
|
453
|
+
const parts = [...gone.map(staleClaimDetail), ...stale.map(staleClaimDetail)];
|
|
454
|
+
if (smallPool)
|
|
455
|
+
parts.push(`${multi.join(", ")} run instances but worktreeSlots ${slots} is below maxConcurrent ${config.maxConcurrent}`);
|
|
456
|
+
return warnOutcome(parts.join("; "));
|
|
457
|
+
}
|
|
458
|
+
/** Provider concurrency caps (plans/provider-concurrency.md §8): audit the
|
|
459
|
+
* `maxConcurrentPerProvider` map against the seams the config can actually spawn — each role,
|
|
460
|
+
* the review gate, the resolver, the director, and each tier's fallback — so the operator
|
|
461
|
+
* sees whether every cap names a provider in use and whether any cap actually binds. Warns on
|
|
462
|
+
* a cap key no seam resolves to (a typo, or a provider not in use), a cap at or above
|
|
463
|
+
* `maxConcurrent` (which never binds — the team total is the real ceiling), and a capped
|
|
464
|
+
* team whose seams include one with no key (pi's default provider is unreadable, so that
|
|
465
|
+
* seam cannot be capped). A config with no per-provider map or no caps is fine, and so is a
|
|
466
|
+
* well-formed map; a broken config is reported by the init check. Takes `root` to read pi's
|
|
467
|
+
* own default model (piSettingsPath), the same seam the orchestrator's admission uses, so a
|
|
468
|
+
* bare no-model pi seam keys exactly as the team would key it. */
|
|
469
|
+
export function checkProviderConcurrency(config, root) {
|
|
470
|
+
if (!config)
|
|
471
|
+
return okOutcome("no config to audit");
|
|
472
|
+
const caps = config.maxConcurrentPerProvider;
|
|
473
|
+
const capEntries = enforcedKeyCaps(caps);
|
|
474
|
+
if (capEntries.length === 0)
|
|
475
|
+
return okOutcome("no per-provider caps configured");
|
|
476
|
+
// The pi default model the orchestrator's admission seam carries: a bare no-model pi seam
|
|
477
|
+
// resolves its key only through it, so a team whose default is readable has keyed seams and
|
|
478
|
+
// only an unreadable default leaves a key-less seam to warn about.
|
|
479
|
+
const piDefault = readPiDefaultModel(piSettingsPath(piModelsPath()), root);
|
|
480
|
+
const seams = seamsInUse(config);
|
|
481
|
+
const keyed = [];
|
|
482
|
+
for (const s of seams) {
|
|
483
|
+
const k = seamConcurrencyKey(s, piDefault);
|
|
484
|
+
if (k !== undefined && !keyed.includes(k))
|
|
485
|
+
keyed.push(k);
|
|
486
|
+
}
|
|
487
|
+
const keySet = new Set(keyed);
|
|
488
|
+
const capKeys = capEntries.map(([k]) => k);
|
|
489
|
+
const warnings = [];
|
|
490
|
+
for (const key of capKeys) {
|
|
491
|
+
if (!keySet.has(key))
|
|
492
|
+
warnings.push(`cap key "${key}" resolves to no model job (a typo, or a provider not in use)`);
|
|
493
|
+
}
|
|
494
|
+
for (const [key, cap] of capEntries) {
|
|
495
|
+
if (cap >= config.maxConcurrent)
|
|
496
|
+
warnings.push(`cap "${key}" ${cap} is at or above maxConcurrent ${config.maxConcurrent} — it never binds`);
|
|
497
|
+
}
|
|
498
|
+
const capSet = new Set(capKeys);
|
|
499
|
+
const keyless = seams.filter((s) => seamConcurrencyKey(s, piDefault) === undefined);
|
|
500
|
+
if (capSet.size > 0 && keyless.length > 0) {
|
|
501
|
+
warnings.push(`${keyless.length} model job(s) resolve to no provider key (pi's default provider is unreadable, so they cannot be capped): ${keyless.map((s) => s.label).join(", ")}`);
|
|
502
|
+
}
|
|
503
|
+
if (warnings.length === 0)
|
|
504
|
+
return okOutcome(`${plural(capEntries.length, "provider cap")} — all model jobs resolve`);
|
|
505
|
+
return warnOutcome(warnings.join("; "));
|
|
506
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { loadConfigForCheck } from "./doctor-model-checks.js";
|
|
2
|
+
import { fallbackPair, tierChainSteps } from "../config/config-fallbacks.js";
|
|
3
|
+
import { fallbackModelPair, piModelsPath, piPairFree, piProviderBaseUrl, readPiProviders } from "../pi/pi-models.js";
|
|
4
|
+
import { readClaudeAccount } from "../agent/claude/claude-account.js";
|
|
5
|
+
import { formatClock } from "../text/datetime.js";
|
|
6
|
+
import { loadRunnerStates } from "./doctor-holds.js";
|
|
7
|
+
import { modelPairName, pairDailyCost } from "../budget/budget.js";
|
|
8
|
+
import { usd, usdCap } from "../text/format.js";
|
|
9
|
+
import { okOutcome, warnOutcome } from "./outcome.js";
|
|
10
|
+
/** The doctor's cap-fallback checks, split out of doctor-model-checks.ts (whose seam framework
|
|
11
|
+
* and declared-tier/image checks stay in a module whose per-backend provider checks live in
|
|
12
|
+
* doctor-{claude,opencode,codex,pi}-checks.ts): the daily-cost budget's third state — whether
|
|
13
|
+
* the configured fallback can really serve at the cap. checkFallbackModel prices the declared
|
|
14
|
+
* fallback pair in pi's definitions, and checkFallbackChain reports one fragment per step of
|
|
15
|
+
* every tier's chain saying whether that step can run now. Together they read config, pi's
|
|
16
|
+
* model definitions and the role states, and are the only doctor checks concerned with the
|
|
17
|
+
* budget's fallback — a distinct concern from the seam framework and declared-model checks,
|
|
18
|
+
* kept separate so a change to fallback pricing does not touch how doctor reads seams. The
|
|
19
|
+
* config guard (loadConfigForCheck) is imported from doctor-model-checks, exactly as the
|
|
20
|
+
* per-backend modules import that file's seam framework. The shared report contract
|
|
21
|
+
* (CheckOutcome) is imported from outcome.ts. */
|
|
22
|
+
/** Fallback model readiness — the daily-cost budget's third state (plans/fallback-model.md): with
|
|
23
|
+
* `fallbackModel` set, pi's definitions must price that pair at zero or the gate refuses it and
|
|
24
|
+
* roles pause at the cap exactly as if no fallback existed. That refusal is the right runtime
|
|
25
|
+
* behavior (spend must never climb past the cap), but a typo'd id would otherwise surface only
|
|
26
|
+
* after the day's budget is already spent — so doctor checks it up front, before the operator
|
|
27
|
+
* needs it. Read-only: it inspects pi's definitions file, never pi or the network. A price is not
|
|
28
|
+
* readiness, though: whether the backend can SERVE is known only from the fallback's own runs
|
|
29
|
+
* (src/budget/fallback-breaker.ts's breaker, BUGS.md 2026-09-20), so a free pair reads "serving not
|
|
30
|
+
* verified" — unless the running orchestrator has published that its breaker demoted the pair
|
|
31
|
+
* (`demoted`, from orchestrator.json; runDoctor passes it only while that orchestrator is alive),
|
|
32
|
+
* which warns with the failure count and the next probe time. No fallbackModel configured is
|
|
33
|
+
* informational, not a warning — plenty of teams intend to stop at the cap. `modelsPath` is
|
|
34
|
+
* injectable so tests need no real pi install. */
|
|
35
|
+
export function checkFallbackModel(root, modelsPath = piModelsPath(), demoted = null) {
|
|
36
|
+
const config = loadConfigForCheck(root);
|
|
37
|
+
if ("level" in config)
|
|
38
|
+
return config;
|
|
39
|
+
const declared = fallbackPair(config);
|
|
40
|
+
if (!declared)
|
|
41
|
+
return okOutcome("none configured — roles pause at the cap");
|
|
42
|
+
// A pair missing either half would fall through to pi's own (unverified) default, so it can
|
|
43
|
+
// never be free; naming that case beats rendering a bare "?/model". The free branch names
|
|
44
|
+
// the price-resolved pair (a map with no `default` entry borrows another tier's), while the
|
|
45
|
+
// refusal names the pair the operator declared.
|
|
46
|
+
const nameOf = (pair) => pair.provider && pair.model ? `${pair.provider}/${pair.model}` : "a half-resolved pair";
|
|
47
|
+
// The claude account answer (6a/7); the fallback price lookup reads it only for "claude"
|
|
48
|
+
// (part 2a/9).
|
|
49
|
+
const account = readClaudeAccount(root);
|
|
50
|
+
const accounts = (b) => (b === "claude" ? account : null);
|
|
51
|
+
const free = fallbackModelPair(config, modelsPath, accounts);
|
|
52
|
+
if (free) {
|
|
53
|
+
const name = nameOf(free);
|
|
54
|
+
if (demoted)
|
|
55
|
+
return warnOutcome(`${name} is priced at zero but not serving — the running team demoted it after ${demoted.failures} consecutive failed runs, so roles pause at the cap; one probe run retries it from ${formatClock(demoted.probeAt)}`);
|
|
56
|
+
return okOutcome(`${name} — priced at zero (cost n/a), serving not verified`);
|
|
57
|
+
}
|
|
58
|
+
return warnOutcome(`${nameOf(declared)} is not priced at zero in ${modelsPath} — at the cap roles pause instead of switching`);
|
|
59
|
+
}
|
|
60
|
+
/** Whether a local server answers `GET <url>`: node's built-in fetch with a short timeout, false
|
|
61
|
+
* on any failure — doctor is a pre-flight, not a place to wait out a hung server. */
|
|
62
|
+
async function probeModels(url) {
|
|
63
|
+
try {
|
|
64
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(2000) });
|
|
65
|
+
return res.ok;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const isLocalUrl = (url) => {
|
|
72
|
+
try {
|
|
73
|
+
const host = new URL(url).hostname;
|
|
74
|
+
return host === "127.0.0.1" || host === "localhost";
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
/** The `fallback chain` check: one fragment per step of every tier's chain saying whether the
|
|
81
|
+
* step can run now — a local pi server that does not answer (warn), a step whose own daily cap is
|
|
82
|
+
* spent, a free pair, a claude step with its billing, or a priced pair. Reads config, pi's
|
|
83
|
+
* models.json and the role states; only a pi step on 127.0.0.1/localhost makes a network
|
|
84
|
+
* request. `probe` is injectable so tests need no real server. */
|
|
85
|
+
export async function checkFallbackChain(root, modelsPath = piModelsPath(), probe = probeModels) {
|
|
86
|
+
const config = loadConfigForCheck(root);
|
|
87
|
+
if ("level" in config)
|
|
88
|
+
return config;
|
|
89
|
+
const steps = tierChainSteps(config);
|
|
90
|
+
if (steps.length === 0)
|
|
91
|
+
return okOutcome("none configured");
|
|
92
|
+
const states = loadRunnerStates(root, config);
|
|
93
|
+
const providers = readPiProviders(modelsPath);
|
|
94
|
+
const account = readClaudeAccount(root);
|
|
95
|
+
// The free-ness lookup reads the one claude account held here (part 2a/9).
|
|
96
|
+
const accounts = (b) => (b === "claude" ? account : null);
|
|
97
|
+
const now = Date.now();
|
|
98
|
+
let down = false;
|
|
99
|
+
const fragments = [];
|
|
100
|
+
for (const { tier, step, pair } of steps) {
|
|
101
|
+
// A provider that names a backend other than pi (claude today; opencode from part 2) never
|
|
102
|
+
// asks pi for a base URL or runs pi's price probe — only a pi step may.
|
|
103
|
+
const nonPiBackend = pair.backend !== undefined && pair.backend !== "pi";
|
|
104
|
+
const base = !nonPiBackend && pair.provider ? piProviderBaseUrl(modelsPath, pair.provider) : null;
|
|
105
|
+
const local = base !== null && isLocalUrl(base);
|
|
106
|
+
const up = local ? await probe(`${base}/models`) : null;
|
|
107
|
+
const cap = pair.maxDailyCostUsd;
|
|
108
|
+
const spent = cap !== undefined ? pairDailyCost(states, modelPairName(pair), now) : 0;
|
|
109
|
+
let verdict;
|
|
110
|
+
if (up === false) {
|
|
111
|
+
down = true;
|
|
112
|
+
verdict = `not running (GET ${base}/models failed)`;
|
|
113
|
+
}
|
|
114
|
+
else if (cap !== undefined && spent >= cap) {
|
|
115
|
+
verdict = `cap reached (${usd(spent)}/${usdCap(cap)})`;
|
|
116
|
+
}
|
|
117
|
+
else if (nonPiBackend) {
|
|
118
|
+
verdict = `claude ${pair.extraUsage ? "extra usage" : (account?.billing ?? "billing unknown")}`;
|
|
119
|
+
}
|
|
120
|
+
else if (piPairFree(providers, pair, accounts)) {
|
|
121
|
+
verdict = up ? "free · server up" : "free";
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
verdict = cap !== undefined ? `priced, ${usd(spent)}/${usdCap(cap)} today` : "priced, team cap only";
|
|
125
|
+
}
|
|
126
|
+
fragments.push(`${tier} ${step + 1}: ${pair.provider ?? "?"}/${pair.model ?? "?"} — ${verdict}`);
|
|
127
|
+
}
|
|
128
|
+
const detail = fragments.join("; ");
|
|
129
|
+
return down ? warnOutcome(detail) : okOutcome(detail);
|
|
130
|
+
}
|