tumwater 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -108
- package/dist/build-info.json +2 -2
- package/dist/src/agent/agent-args.js +24 -0
- package/dist/src/agent/agent-backend.js +2 -2
- package/dist/src/agent/backend-traits.js +60 -0
- package/dist/src/agent/claude/claude-account.js +61 -8
- package/dist/src/agent/claude/claude-args.js +12 -15
- package/dist/src/agent/claude/claude-backend.js +51 -41
- package/dist/src/agent/claude/claude-env.js +7 -3
- package/dist/src/agent/claude/claude-stream.js +7 -16
- package/dist/src/agent/image-input.js +107 -0
- package/dist/src/agent/notes-shim.js +41 -0
- package/dist/src/agent/opencode/opencode-args.js +61 -0
- package/dist/src/agent/opencode/opencode-backend.js +255 -0
- package/dist/src/agent/opencode/opencode-catalog.js +184 -0
- package/dist/src/agent/opencode/opencode-config.js +188 -0
- package/dist/src/agent/opencode/opencode-env.js +36 -0
- package/dist/src/agent/opencode/opencode-session-id.js +37 -0
- package/dist/src/agent/opencode/opencode-stream.js +278 -0
- package/dist/src/agent/role-notes-write.js +16 -32
- package/dist/src/agent/run-agent.js +95 -18
- package/dist/src/agent/session-ledger.js +57 -1
- package/dist/src/backlog/backlog-archive.js +296 -0
- package/dist/src/backlog/backlog-cli.js +573 -0
- package/dist/src/backlog/backlog-commit.js +31 -0
- package/dist/src/backlog/backlog-eligibility.js +357 -33
- package/dist/src/backlog/backlog-md.js +98 -18
- package/dist/src/backlog/backlog-render.js +62 -11
- package/dist/src/backlog/backlog-structure.js +261 -58
- package/dist/src/backlog/backlog-write.js +197 -319
- package/dist/src/backlog/backlog.js +113 -16
- package/dist/src/backlog/question-answer.js +339 -0
- package/dist/src/baseline/main-baseline.js +75 -88
- package/dist/src/baseline/main-red.js +38 -30
- package/dist/src/brief/brief-edit.js +43 -0
- package/dist/src/{brief.js → brief/brief.js} +66 -10
- package/dist/src/budget/budget.js +32 -15
- package/dist/src/budget/fallback-breaker.js +8 -7
- package/dist/src/build/build-check-detect.js +8 -7
- package/dist/src/build/build-check-events.js +68 -22
- package/dist/src/build/build-check-report.js +22 -23
- package/dist/src/build/build-check-scoped.js +51 -62
- package/dist/src/build/build-check.js +15 -14
- package/dist/src/build/build-info.js +2 -1
- package/dist/src/build/build-stage.js +23 -22
- package/dist/src/build/dep-install.js +22 -21
- package/dist/src/build/host-sleep.js +1 -1
- package/dist/src/change/change-data.js +25 -14
- package/dist/src/change/change-render.js +11 -10
- package/dist/src/cli/brief-command.js +58 -0
- package/dist/src/cli/cli-args.js +25 -5
- package/dist/src/cli/cli-command-args.js +46 -14
- package/dist/src/cli/cli-editor.js +64 -0
- package/dist/src/cli/cli-flag-specs.js +54 -16
- package/dist/src/cli/cli-marker-commands.js +13 -10
- package/dist/src/cli/cli-output.js +9 -0
- package/dist/src/cli/cli-query-commands.js +458 -49
- package/dist/src/cli/cli-repo-gate.js +1 -1
- package/dist/src/cli/cli-run.js +208 -59
- package/dist/src/cli/completion.js +207 -0
- package/dist/src/cli/config-commands.js +14 -11
- package/dist/src/cli/help.js +305 -110
- package/dist/src/cli/log-commands.js +127 -21
- package/dist/src/cli/notify-command.js +33 -18
- package/dist/src/cli/question-commands.js +29 -224
- package/dist/src/cli.js +121 -40
- package/dist/src/collections.js +66 -16
- package/dist/src/concurrency/check-permit.js +16 -17
- package/dist/src/concurrency/lock.js +6 -5
- package/dist/src/concurrency/provider-lease.js +69 -0
- package/dist/src/concurrency/semaphore.js +258 -36
- package/dist/src/config/config-aliases.js +106 -0
- package/dist/src/config/config-editable-keys.js +3 -3
- package/dist/src/config/config-fallbacks.js +379 -0
- package/dist/src/config/config-field-checks.js +19 -10
- package/dist/src/config/config-keys.js +160 -0
- package/dist/src/config/config-live.js +64 -12
- package/dist/src/config/config-schema.js +34 -22
- package/dist/src/config/config-validation.js +132 -48
- package/dist/src/config/config-views.js +119 -337
- package/dist/src/config/config-write.js +138 -177
- package/dist/src/config/config.js +60 -49
- package/dist/src/config/model-selector.js +28 -2
- package/dist/src/doctor/doctor-backlog.js +546 -49
- package/dist/src/doctor/doctor-checks.js +534 -56
- package/dist/src/doctor/doctor-holds.js +193 -53
- package/dist/src/doctor/doctor-install.js +30 -0
- package/dist/src/doctor/doctor-launch-services.js +2 -2
- package/dist/src/doctor/doctor-model-checks.js +549 -157
- package/dist/src/doctor/doctor-notify.js +27 -0
- package/dist/src/doctor/doctor-orphans.js +10 -9
- package/dist/src/doctor/doctor-render.js +36 -9
- package/dist/src/doctor/doctor.js +80 -22
- package/dist/src/doctor/image-probe.js +244 -0
- package/dist/src/doctor/probe.js +64 -0
- package/dist/src/events/event-format.js +226 -102
- package/dist/src/events/event-labels.js +116 -0
- package/dist/src/events/event-legacy.js +140 -0
- package/dist/src/events/event-read.js +58 -22
- package/dist/src/events/event-window.js +27 -7
- package/dist/src/events/events.js +18 -14
- package/dist/src/events/notify.js +137 -38
- package/dist/src/failure/failure-cluster.js +12 -9
- package/dist/src/failure/failure-data.js +96 -48
- package/dist/src/failure/failure-render.js +69 -58
- package/dist/src/failure/failure-state-change.js +33 -26
- package/dist/src/failure/time-spend.js +79 -59
- package/dist/src/files/file-queue.js +16 -4
- package/dist/src/files/files.js +18 -6
- package/dist/src/files/json-object.js +18 -3
- package/dist/src/files/tail.js +1 -1
- package/dist/src/gates/bootstrap-gates.js +5 -4
- package/dist/src/gates/budget-gates.js +250 -34
- package/dist/src/gates/disk-gate.js +18 -18
- package/dist/src/gates/gate-polls.js +117 -69
- package/dist/src/gates/maintenance-quota.js +29 -29
- package/dist/src/gates/pause-gates.js +11 -11
- package/dist/src/gates/readiness.js +2 -1
- package/dist/src/gates/role-cap-gates.js +16 -21
- package/dist/src/gates/startup-gate.js +75 -31
- package/dist/src/gates/streak-gate.js +18 -16
- package/dist/src/git/commit-message.js +12 -5
- package/dist/src/git/git-diff.js +16 -16
- package/dist/src/git/git-run.js +8 -8
- package/dist/src/git/git.js +86 -55
- package/dist/src/git/slots-state.js +14 -5
- package/dist/src/git/worktree-pool.js +25 -18
- package/dist/src/git/worktree-use.js +15 -14
- package/dist/src/git/worktree.js +18 -18
- package/dist/src/gui/gui-args.js +10 -14
- package/dist/src/gui/gui-backlog-commands.js +273 -0
- package/dist/src/gui/gui-command.js +1 -1
- package/dist/src/gui/gui-config-commands.js +440 -0
- package/dist/src/gui/gui-endpoint-commands.js +16 -669
- package/dist/src/gui/gui-endpoint-common.js +49 -0
- package/dist/src/gui/gui-endpoints.js +116 -56
- package/dist/src/gui/gui-operator-commands.js +145 -0
- package/dist/src/gui/gui-prompt-commands.js +234 -0
- package/dist/src/gui/gui-server.js +28 -8
- package/dist/src/gui/gui-team-commands.js +164 -0
- package/dist/src/gui/http-body.js +17 -1
- package/dist/src/history/history-data.js +194 -98
- package/dist/src/history/history-render.js +76 -0
- package/dist/src/history/history.js +37 -58
- package/dist/src/inbox/inbox-attachments.js +75 -14
- package/dist/src/inbox/inbox-cancel.js +7 -5
- package/dist/src/inbox/inbox-edit.js +1 -1
- package/dist/src/inbox/inbox-submit.js +44 -4
- package/dist/src/inbox/inbox-top.js +83 -0
- package/dist/src/inbox/inbox.js +8 -2
- package/dist/src/inbox/pending-prompt.js +1 -1
- package/dist/src/inbox/prompt-commands.js +170 -80
- package/dist/src/init/init-templates.js +16 -14
- package/dist/src/init/init.js +12 -11
- package/dist/src/loop/leftover.js +28 -27
- package/dist/src/loop/loop-fallback.js +2 -2
- package/dist/src/loop/loop-pi.js +106 -52
- package/dist/src/loop/loop-recovery.js +15 -15
- package/dist/src/loop/loop-state.js +139 -13
- package/dist/src/loop/loop-tick.js +194 -86
- package/dist/src/loop/loop.js +72 -36
- package/dist/src/loop/model-fallback.js +3 -2
- package/dist/src/loop/revision.js +16 -7
- package/dist/src/merge/backlog-conflicts.js +155 -0
- package/dist/src/{landing/landing-batch.js → merge/merge-batch.js} +90 -89
- package/dist/src/{landing/landing-check-failures.js → merge/merge-check-failures.js} +87 -41
- package/dist/src/{landing/landing-core.js → merge/merge-core.js} +119 -95
- package/dist/src/{landing/landing-diff.js → merge/merge-diff.js} +1 -1
- package/dist/src/merge/merge-drain.js +212 -0
- package/dist/src/{landing/landing-git.js → merge/merge-git.js} +94 -21
- package/dist/src/merge/merge-pipeline.js +99 -0
- package/dist/src/{landing/landing-questions.js → merge/merge-questions.js} +9 -6
- package/dist/src/merge/merge-queue-render.js +75 -0
- package/dist/src/{landing/landing-queue.js → merge/merge-queue.js} +95 -36
- package/dist/src/merge/merge-slot.js +340 -0
- package/dist/src/{landing/landing-stack.js → merge/merge-stack.js} +66 -62
- package/dist/src/{landing/landing-merge.js → merge/merge-to-main.js} +72 -62
- package/dist/src/merge/merge-verify.js +212 -0
- package/dist/src/notes-cli.js +97 -0
- package/dist/src/operator/operator-commands.js +154 -47
- package/dist/src/operator/operator-intent.js +87 -49
- package/dist/src/operator/operator-requests.js +52 -52
- package/dist/src/operator/retire.js +62 -27
- package/dist/src/operator/revert.js +71 -0
- package/dist/src/orchestrator/orchestrator-launch.js +74 -14
- package/dist/src/orchestrator/orchestrator-scheduling.js +129 -44
- package/dist/src/orchestrator/orchestrator.js +158 -100
- package/dist/src/orchestrator/retention.js +29 -12
- package/dist/src/orchestrator/startup-migration-steps.js +51 -0
- package/dist/src/orchestrator/startup-migrations.js +40 -0
- package/dist/src/paths.js +128 -37
- package/dist/src/pi/pi-args.js +26 -3
- package/dist/src/pi/pi-backend.js +15 -23
- package/dist/src/pi/pi-bin.js +18 -12
- package/dist/src/pi/pi-event-line.js +17 -1
- package/dist/src/pi/pi-failure.js +87 -0
- package/dist/src/pi/pi-models.js +126 -38
- package/dist/src/pi/pi-run-result.js +1 -1
- package/dist/src/pi/pi-settings.js +86 -0
- package/dist/src/pi/pi-stream.js +131 -107
- package/dist/src/pi/pi-watchdogs.js +47 -43
- package/dist/src/pi-extension/bounded-output.js +20 -5
- package/dist/src/pi-extension/context-budget.js +3 -1
- package/dist/src/pi-extension/context-shake.js +54 -21
- package/dist/src/pi-extension/context-window.js +37 -0
- package/dist/src/pi-extension/full-output.js +2 -2
- package/dist/src/pi-extension/strip-thinking.js +80 -0
- package/dist/src/pi-extension/trim-system-prompt.js +38 -0
- package/dist/src/process/launch-services.js +11 -10
- package/dist/src/process/process-table.js +1 -1
- package/dist/src/process/process.js +7 -7
- package/dist/src/process/run-marker.js +29 -6
- package/dist/src/process/supervisor.js +47 -25
- package/dist/src/prompt/dialect.js +30 -10
- package/dist/src/prompt/gate-prompts.js +170 -67
- package/dist/src/prompt/principles.js +1 -0
- package/dist/src/prompt/prompt-followup.js +35 -20
- package/dist/src/prompt/prompt.js +249 -91
- package/dist/src/redeploy/redeploy-policy.js +10 -10
- package/dist/src/redeploy/redeploy.js +6 -4
- package/dist/src/redeploy/redeployer.js +63 -55
- package/dist/src/redeploy/self-reload.js +1 -1
- package/dist/src/report/report-data.js +264 -255
- package/dist/src/report/report-fold.js +353 -0
- package/dist/src/report/report-render.js +270 -33
- package/dist/src/request-timeouts.js +1 -1
- package/dist/src/review/known-flakes.js +34 -14
- package/dist/src/review/protected-edits.js +33 -0
- package/dist/src/review/review-followup.js +16 -4
- package/dist/src/review/review-precheck.js +14 -13
- package/dist/src/review/review-verdict.js +14 -0
- package/dist/src/review/review.js +81 -39
- package/dist/src/review/secret-leaks.js +127 -0
- package/dist/src/review/suite-rerun.js +4 -3
- package/dist/src/roles/legacy-role-ids.js +21 -0
- package/dist/src/roles/legacy-role-migration.js +62 -0
- package/dist/src/roles/loop-ids.js +19 -2
- package/dist/src/roles/role-catalog.js +125 -135
- package/dist/src/roles/role-guidance.js +64 -31
- package/dist/src/roles/role-labels.js +10 -0
- package/dist/src/roles/role-render.js +194 -18
- package/dist/src/roles/role-view.js +216 -17
- package/dist/src/roles/roles.js +18 -15
- package/dist/src/scheduling/backoff.js +29 -19
- package/dist/src/scheduling/claims.js +25 -9
- package/dist/src/scheduling/once-round.js +11 -11
- package/dist/src/scheduling/quiet-hours.js +25 -32
- package/dist/src/scheduling/scheduling.js +43 -24
- package/dist/src/scheduling/{work-landed-cache.js → work-merged-cache.js} +14 -14
- package/dist/src/status/status-budget.js +115 -0
- package/dist/src/status/status-data.js +77 -184
- package/dist/src/status/status-inbox.js +47 -0
- package/dist/src/status/status-install.js +27 -0
- package/dist/src/status/status-polls.js +68 -33
- package/dist/src/status/status-roles.js +105 -0
- package/dist/src/{fleet → team}/error-storm.js +10 -10
- package/dist/src/{fleet → team}/failure-spread.js +8 -8
- package/dist/src/{fleet → team}/orchestrator-info.js +86 -18
- package/dist/src/{fleet → team}/reclaim.js +5 -4
- package/dist/src/{fleet/fleet-hold.js → team/team-hold.js} +55 -21
- package/dist/src/{fleet/fleet-polls.js → team/team-polls.js} +116 -65
- package/dist/src/{fleet/fleet-state.js → team/team-state.js} +62 -37
- package/dist/src/text/datetime.js +42 -2
- package/dist/src/text/format.js +10 -1
- package/dist/src/text/markdown.js +1 -1
- package/dist/src/text/phrases.js +35 -34
- package/dist/src/text/suggest.js +3 -2
- package/dist/src/text/text.js +2 -2
- package/dist/src/tick/qa-coverage.js +2 -1
- package/dist/src/tick/stage-check.js +147 -26
- package/dist/src/tick/telemetry-digest.js +10 -9
- package/dist/src/tick/tick-apply.js +101 -72
- package/dist/src/tick/tick-detail-data.js +32 -18
- package/dist/src/tick/tick-detail.js +62 -21
- package/dist/src/tick/tick-finalize.js +29 -22
- package/dist/src/tick/tick-outcome.js +2 -1
- package/dist/src/tick/tick-prompt.js +94 -26
- package/dist/src/tick/tick-resume.js +29 -13
- package/dist/src/tick/tick-stage.js +91 -47
- package/dist/src/tick/tick-timing.js +60 -59
- package/dist/src/tick/tick-usage.js +53 -23
- package/dist/src/tick/tick-verdict.js +76 -10
- package/dist/src/ui/badges.js +118 -67
- package/dist/src/ui/gui/gui-client-boot.js +209 -30
- package/dist/src/ui/gui/gui-client-composer.js +102 -23
- package/dist/src/ui/gui/gui-client-drawer.js +330 -75
- package/dist/src/ui/gui/gui-client-history.js +96 -38
- package/dist/src/ui/gui/gui-client-labels.js +24 -0
- package/dist/src/ui/gui/gui-client-loops.js +94 -52
- package/dist/src/ui/gui/gui-client-markdown.js +1 -0
- package/dist/src/ui/gui/gui-client-model.js +63 -43
- package/dist/src/ui/gui/gui-client-operator.js +129 -39
- package/dist/src/ui/gui/gui-client-pending.js +36 -32
- package/dist/src/ui/gui/gui-client-report.js +339 -46
- package/dist/src/ui/gui/gui-client-settings.js +148 -14
- package/dist/src/ui/gui/gui-client-sound.js +2 -1
- package/dist/src/ui/gui/{gui-client-fleet.js → gui-client-team.js} +196 -72
- package/dist/src/ui/gui/gui-client.js +26 -20
- package/dist/src/ui/gui/gui-page.js +19 -19
- package/dist/src/ui/gui/gui-styles.js +12 -4
- package/dist/src/ui/progress-data.js +5 -3
- package/dist/src/ui/status-model.js +184 -122
- package/dist/src/ui/status-payload.js +119 -32
- package/dist/src/ui/status-render.js +122 -52
- package/dist/src/ui/team-alerts.js +341 -0
- package/dist/src/ui/tick-progress-model.js +14 -14
- package/dist/src/ui/tone.js +11 -8
- package/dist/src/ui/transcript-tail.js +1 -0
- package/dist/src/ui/transcript.js +23 -16
- package/dist/src/ui/tui/tui-app.js +1 -0
- package/dist/src/ui/tui/tui-backlog.js +85 -11
- package/dist/src/ui/tui/tui-editor-answer.js +97 -0
- package/dist/src/ui/tui/tui-editor-attach.js +107 -0
- package/dist/src/ui/tui/tui-editor-brief.js +107 -0
- package/dist/src/ui/tui/tui-editor-budget.js +155 -0
- package/dist/src/ui/tui/tui-editor-ctx.js +1 -0
- package/dist/src/ui/tui/tui-editor-entry.js +230 -0
- package/dist/src/ui/tui/tui-editor-interval.js +122 -0
- package/dist/src/ui/tui/tui-editor-notes.js +114 -0
- package/dist/src/ui/tui/tui-editor-pause.js +266 -0
- package/dist/src/ui/tui/tui-editor-prompt.js +95 -0
- package/dist/src/ui/tui/tui-editor-queued.js +98 -0
- package/dist/src/ui/tui/tui-editor-quiet.js +106 -0
- package/dist/src/ui/tui/tui-editor-retire.js +83 -0
- package/dist/src/ui/tui/tui-editor-search.js +73 -0
- package/dist/src/ui/tui/tui-editor-session.js +97 -0
- package/dist/src/ui/tui/tui-editors.js +152 -0
- package/dist/src/ui/tui/tui-frame.js +67 -55
- package/dist/src/ui/tui/tui-history.js +12 -7
- package/dist/src/ui/tui/tui-input.js +49 -44
- package/dist/src/ui/tui/tui-keymap.js +1 -0
- package/dist/src/ui/tui/tui-keys.js +91 -1704
- package/dist/src/ui/tui/tui-pane-activity.js +99 -0
- package/dist/src/ui/tui/tui-pane-backlog.js +224 -0
- package/dist/src/ui/tui/tui-pane-brief.js +30 -0
- package/dist/src/ui/tui/tui-pane-ctx.js +11 -0
- package/dist/src/ui/tui/tui-pane-health.js +124 -0
- package/dist/src/ui/tui/tui-pane-history.js +316 -0
- package/dist/src/ui/tui/tui-pane-pending.js +74 -0
- package/dist/src/ui/tui/tui-pane-queued.js +143 -0
- package/dist/src/ui/tui/tui-pane-report.js +122 -0
- package/dist/src/ui/tui/tui-pane-session.js +101 -0
- package/dist/src/ui/tui/tui-pane-transcript.js +150 -0
- package/dist/src/ui/tui/tui-pane.js +66 -28
- package/dist/src/ui/tui/tui-panes.js +294 -0
- package/dist/src/ui/tui/tui.js +45 -25
- package/dist/src/verdict/fix-claim.js +141 -38
- package/dist/src/verdict/no-change.js +19 -0
- package/dist/src/verdict/refusal.js +92 -8
- package/dist/src/verdict/reply-contract.js +17 -5
- package/dist/src/version.js +3 -2
- package/package.json +4 -2
- package/dist/src/agent/mcp/role-notes-server.js +0 -160
- package/dist/src/landing/backlog-conflicts.js +0 -116
- package/dist/src/landing/landing-drain.js +0 -204
- package/dist/src/landing/landing-pipeline.js +0 -96
- package/dist/src/landing/landing-queue-render.js +0 -31
- package/dist/src/landing/landing-slot.js +0 -246
- package/dist/src/landing/landing-vetting.js +0 -196
- package/dist/src/pi-extension/role-notes.js +0 -40
- package/dist/src/ui/fleet-alerts.js +0 -174
|
@@ -1,32 +1,50 @@
|
|
|
1
|
+
// vocabulary: plain
|
|
1
2
|
import fs from "node:fs";
|
|
2
3
|
import os from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
4
|
-
import { enabledRoleIds, loadConfig, loadConfigSafe, slotCount, } from "../config/config.js";
|
|
5
|
+
import { enabledRoleIds, knownRoleIds, parseJsonConfig, loadConfig, loadConfigSafe, slotCount, } from "../config/config.js";
|
|
5
6
|
import { exampleConfigProblem, exampleDrift } from "../config/config-example.js";
|
|
6
7
|
import { detectBuildCheck } from "../build/build-check-detect.js";
|
|
8
|
+
import { isPassingFullCheck } from "../build/build-check-events.js";
|
|
9
|
+
import { CUSTOM_NAME_RE } from "../config/config-schema.js";
|
|
7
10
|
import { bootstrapStatus } from "../gates/bootstrap-gates.js";
|
|
8
11
|
import { buildStaleness, isSelfHosted, readBuildInfo, STALE_INPUTS_LABEL } from "../build/build-info.js";
|
|
9
12
|
import { envPath, findOnPath, subdirectoryNames } from "../files/files.js";
|
|
13
|
+
import { readJsonFile } from "../files/json-files.js";
|
|
10
14
|
import { PACKAGE_JSON, belowNodeFloor, packageEnginesNode } from "../version.js";
|
|
11
15
|
import { GIT_MISSING_MESSAGE } from "../git/git-run.js";
|
|
12
|
-
import { branchExists, currentBranch, hasCommits, branchesPhrase, isGitRepo, refSha, repoToplevel, } from "../git/git.js";
|
|
16
|
+
import { branchExists, currentBranch, hasCommits, branchesPhrase, isGitRepo, listBranches, refSha, repoToplevel, } from "../git/git.js";
|
|
13
17
|
import { DETACHED_HEAD_MESSAGE, NOT_A_REPO_MESSAGE, NOT_INITIALIZED_MESSAGE, NO_COMMITS_MESSAGE, } from "../gates/readiness.js";
|
|
14
18
|
import { agentBinSourceLabelFor, agentMissingMessage, findAgentBinary, resolveAgentBin } from "../pi/pi-bin.js";
|
|
19
|
+
import { piModelsPath } from "../pi/pi-models.js";
|
|
20
|
+
import { piSettingsPath, readPiDefaultModel } from "../pi/pi-settings.js";
|
|
21
|
+
import { seamConcurrencyKey } from "../config/config-views.js";
|
|
22
|
+
import { resolvedAgentConfig } from "../config/config-views.js";
|
|
23
|
+
import { seamsInUse } from "../config/config-fallbacks.js";
|
|
15
24
|
import { classifyLock, readLockPid } from "../concurrency/lock.js";
|
|
16
|
-
import { EXAMPLE_CONFIG_BASENAME, STATE_DIR, configPath, eventsLogPath, mergeLockDir, worktreesDir } from "../paths.js";
|
|
25
|
+
import { CONFIG_BASENAME, EXAMPLE_CONFIG_BASENAME, branchName, STATE_DIR, configPath, eventsLogPath, mergeLockDir, worktreesDir } from "../paths.js";
|
|
17
26
|
import { BYTES_PER_GB, diskVolumePath, sampleFreeBytes } from "../gates/disk-gate.js";
|
|
18
27
|
import { errorMessage, textOr } from "../text/text.js";
|
|
28
|
+
import { lowerMedian } from "../collections.js";
|
|
19
29
|
import { formatGB, shortSha } from "../text/format.js";
|
|
20
|
-
import { briefFile } from "../brief.js";
|
|
21
|
-
import { eventLogState } from "../events/event-window.js";
|
|
30
|
+
import { briefFile } from "../brief/brief.js";
|
|
31
|
+
import { eventLogState, eventsSince, readEventsSince } from "../events/event-window.js";
|
|
32
|
+
import { finiteNumber } from "../files/json-object.js";
|
|
33
|
+
import { agree, plural, shortSpanPhrase } from "../text/phrases.js";
|
|
22
34
|
import { DIRECTOR_ROLE } from "../roles/roles.js";
|
|
23
35
|
import { readSlotsState } from "../git/slots-state.js";
|
|
24
|
-
import { agentsInUse } from "../gates/startup-gate.js";
|
|
25
|
-
import { claudeSeamsInUse } from "./doctor-model-checks.js";
|
|
36
|
+
import { agentsInUse, unregisteredBackendInUse } from "../gates/startup-gate.js";
|
|
37
|
+
import { claudeSeamsInUse, opencodeSeamsInUse, piSeamsInUse, AUTH_CHECK_MAX_OUTPUT_BYTES, AUTH_CHECK_TIMEOUT_MS } from "./doctor-model-checks.js";
|
|
38
|
+
import { claudeConfigDir } from "../agent/claude/claude-backend.js";
|
|
39
|
+
import { boundedProbe } from "./probe.js";
|
|
40
|
+
import { childEnv } from "../process/process.js";
|
|
41
|
+
import { opencodeBackend, OPENCODE_MIN_VERSION } from "../agent/opencode/opencode-backend.js";
|
|
26
42
|
import { failOutcome, okOutcome, warnOutcome } from "./outcome.js";
|
|
43
|
+
import { spawnSync } from "node:child_process";
|
|
27
44
|
import { loadLoopState } from "../loop/loop-state.js";
|
|
28
|
-
import { INSTANCE_ROLES, loopIds } from "../roles/loop-ids.js";
|
|
45
|
+
import { INSTANCE_ROLES, loopEnabled, loopIds } from "../roles/loop-ids.js";
|
|
29
46
|
import { CLAIM_IDLE_MAX_MS, listedKeys } from "../scheduling/claims.js";
|
|
47
|
+
import { flakeCounts, KNOWN_FLAKE_WINDOW_MS } from "../review/known-flakes.js";
|
|
30
48
|
/** The floor doctor falls back to when package.json — the one source of the declared
|
|
31
49
|
* engines.node spec, read below — cannot be read (a broken install, the version command's
|
|
32
50
|
* story). The CLI's startup gate enforces the spec itself; this check stays a warning so a
|
|
@@ -62,7 +80,7 @@ export function checkGitBinary(pathEnv = envPath()) {
|
|
|
62
80
|
/** Repo ready — the git/git.ts predicates in requireReadyRepo's order, so doctor and the
|
|
63
81
|
* readiness gate cannot drift: not a git repo → no commits yet → detached HEAD. Reports the
|
|
64
82
|
* resolved toplevel (not the cwd — doctor must say where .tumwater/ lives) and the branch the
|
|
65
|
-
*
|
|
83
|
+
* team would target: a configured `baseBranch` wins (`run --branch` is per-invocation), and
|
|
66
84
|
* one that does not exist fails here instead of at the first tick. `config` is optional so
|
|
67
85
|
* the check stands alone; runDoctor passes the loaded config behind a guard. */
|
|
68
86
|
export async function checkRepo(root, config) {
|
|
@@ -117,6 +135,18 @@ export function checkInit(root) {
|
|
|
117
135
|
return failOutcome(errorMessage(err));
|
|
118
136
|
}
|
|
119
137
|
}
|
|
138
|
+
/** Whether tumwater.json still spells keys by an old name (config-aliases.ts). Both spellings
|
|
139
|
+
* load, so this only warns, naming each exact rename. Ok when the file is absent or broken
|
|
140
|
+
* (checkInit reports that) or nothing in it is renamed. */
|
|
141
|
+
export function checkConfigKeyNames(root) {
|
|
142
|
+
const file = configPath(root);
|
|
143
|
+
if (!fs.existsSync(file))
|
|
144
|
+
return okOutcome("no tumwater.json");
|
|
145
|
+
const { renamed } = parseJsonConfig(file, CONFIG_BASENAME);
|
|
146
|
+
if (renamed === undefined || renamed.length === 0)
|
|
147
|
+
return okOutcome("current key names");
|
|
148
|
+
return warnOutcome(`old key names: ${renamed.join(", ")} — rename them in tumwater.json; both spellings work until then`);
|
|
149
|
+
}
|
|
120
150
|
/** Which file holds the project brief (the managed initial prompt + status sections,
|
|
121
151
|
* plans/portability.md §7a/7): TUMWATER.md first, README.md as the compatibility path. A repo
|
|
122
152
|
* whose README carries no markers — or that has no brief at all — still boots, but every loop
|
|
@@ -126,7 +156,7 @@ export function checkInit(root) {
|
|
|
126
156
|
export function checkBrief(root) {
|
|
127
157
|
// briefFile throws on a brief candidate that exists but cannot be read (a directory at
|
|
128
158
|
// the README.md path, a permission-lost file) — report it as the check's failure instead
|
|
129
|
-
// of crashing doctor: the
|
|
159
|
+
// of crashing doctor: the team would run without its brief every tick.
|
|
130
160
|
let owner;
|
|
131
161
|
try {
|
|
132
162
|
owner = briefFile(root);
|
|
@@ -137,7 +167,7 @@ export function checkBrief(root) {
|
|
|
137
167
|
if (owner)
|
|
138
168
|
return okOutcome(`brief in ${owner}`);
|
|
139
169
|
if (fs.existsSync(path.join(root, "README.md"))) {
|
|
140
|
-
return warnOutcome(`README.md carries no tumwater:prompt markers — the
|
|
170
|
+
return warnOutcome(`README.md carries no tumwater:prompt markers — the team would run without its project brief; add the block or re-run \`tumwater init\``);
|
|
141
171
|
}
|
|
142
172
|
return warnOutcome("no brief file — run `tumwater init <prompt>` to seed one");
|
|
143
173
|
}
|
|
@@ -149,7 +179,7 @@ export function checkBootstrap(root, config) {
|
|
|
149
179
|
const status = config ? bootstrapStatus(root, config) : null;
|
|
150
180
|
if (!status?.active)
|
|
151
181
|
return okOutcome("not bootstrapping");
|
|
152
|
-
return okOutcome(`bootstrapping: ${status.plansDone}/${status.untilPlansDone} plans done — plan, feature and director
|
|
182
|
+
return okOutcome(`bootstrapping: ${status.plansDone}/${status.untilPlansDone} plans done — plan, feature and director runs`);
|
|
153
183
|
}
|
|
154
184
|
/** Resolve and test ONE agent's binary: the ok detail names the resolved path and its source
|
|
155
185
|
* whenever the value is not the PATH default, so a configured binary is never mistaken for the
|
|
@@ -173,11 +203,18 @@ function agentBinaryCheck(config, agent, pathEnv) {
|
|
|
173
203
|
* path-shaped accessSync(X_OK) rule, and null-on-unusable verdict the startup gate asks — so
|
|
174
204
|
* doctor cannot pass a binary the gate would reject. resolveAgentBin normalizes path-shaped
|
|
175
205
|
* values against the process cwd at resolution time, so what doctor tests is exactly what
|
|
176
|
-
* spawns. A claude-only
|
|
206
|
+
* spawns. A claude-only team checks claude, not pi. The check fails on the first missing
|
|
177
207
|
* binary, naming its agent; an ok detail joins each agent's resolved path and source. */
|
|
178
208
|
export function checkAgentBinary(root, pathEnv = envPath()) {
|
|
179
209
|
const { config } = loadConfigSafe(root);
|
|
180
210
|
const agents = config !== undefined ? agentsInUse(config) : ["pi"];
|
|
211
|
+
// A seam backend with no registered adapter in this build can never run; naming that beats
|
|
212
|
+
// the binary check (which would pass an installed binary only to fall through to pi). The
|
|
213
|
+
// same rule and wording as the startup gate's. After opencode's adapter lands (part 5/7)
|
|
214
|
+
// this stays the general rule for any future id.
|
|
215
|
+
const unregistered = config !== undefined ? unregisteredBackendInUse(config) : undefined;
|
|
216
|
+
if (unregistered !== undefined)
|
|
217
|
+
return failOutcome(`backend ${unregistered} is configured but this build has no adapter for it`);
|
|
181
218
|
const checks = agents.map((agent) => ({
|
|
182
219
|
agent,
|
|
183
220
|
outcome: agentBinaryCheck(config ?? {}, agent, pathEnv),
|
|
@@ -229,18 +266,44 @@ function formatBytes(n) {
|
|
|
229
266
|
return `${(n / 1024).toFixed(1)} KB`;
|
|
230
267
|
return `${n} B`;
|
|
231
268
|
}
|
|
232
|
-
/** claude transcript disk — the total size of
|
|
233
|
-
* "Sessions" retention):
|
|
234
|
-
* operator asking where claude's disk went gets the number
|
|
235
|
-
* every case — a large transcript tree is a fact, not a
|
|
236
|
-
* seam routes to claude: a pi-only
|
|
237
|
-
* host directory that has nothing to do with the
|
|
238
|
-
|
|
269
|
+
/** claude transcript disk — the total size of the claude config dir's `projects/`
|
|
270
|
+
* (plans/agent-backends.md "Sessions" retention): the perf adapter ages transcripts out under
|
|
271
|
+
* its own cleanupPeriodDays, so an operator asking where claude's disk went gets the number
|
|
272
|
+
* without `du`. Informational (ok) in every case — a large transcript tree is a fact, not a
|
|
273
|
+
* misconfiguration. Stands down when no seam routes to claude: a pi-only team reads no claude
|
|
274
|
+
* state, and the report must not walk a host directory that has nothing to do with the team.
|
|
275
|
+
* `homeDir` and `env` are test seams. */
|
|
276
|
+
/** The dotted version in `opencode --version` (prints `opencode v2.0.32`), or undefined when it
|
|
277
|
+
* names none. */
|
|
278
|
+
function parseOpencodeCliVersion(output) {
|
|
279
|
+
return /v(\d+\.\d+\.\d+)/.exec(output)?.[1];
|
|
280
|
+
}
|
|
281
|
+
/** OpenCode CLI-version readiness (plans/opencode-backend.md "Which OpenCode", part 7/7): the
|
|
282
|
+
* team targets V2 at 2.0.32 or later, the version H1 verified live. An installed CLI below that
|
|
283
|
+
* (a 1.x binary included) fails naming `brew install opencode`; at or above it reads the
|
|
284
|
+
* version. Stands down when no seam routes to opencode, so a pi-only team spawns no opencode. */
|
|
285
|
+
export async function checkOpencodeVersion(root, pathEnv = envPath(), timeoutMs = AUTH_CHECK_TIMEOUT_MS) {
|
|
286
|
+
const { config } = loadConfigSafe(root);
|
|
287
|
+
if (config === undefined)
|
|
288
|
+
return okOutcome("no readable tumwater.json — nothing to verify");
|
|
289
|
+
if (!opencodeSeamsInUse(config))
|
|
290
|
+
return okOutcome("no model jobs run on opencode");
|
|
291
|
+
const resolved = opencodeBackend.resolveBin(config);
|
|
292
|
+
const result = await boundedProbe(resolved.bin, ["--version"], childEnv({ PATH: pathEnv }), timeoutMs, AUTH_CHECK_MAX_OUTPUT_BYTES);
|
|
293
|
+
const found = result !== null && result.code === 0 ? parseOpencodeCliVersion(result.stdout) : undefined;
|
|
294
|
+
if (found === undefined)
|
|
295
|
+
return warnOutcome("could not check — `opencode --version` did not run or its output was unreadable");
|
|
296
|
+
if (belowNodeFloor(found, `>=${OPENCODE_MIN_VERSION}`))
|
|
297
|
+
return failOutcome(`opencode ${found} is older than ${OPENCODE_MIN_VERSION} — run \`brew install opencode\``);
|
|
298
|
+
return okOutcome(`opencode ${found} (>= ${OPENCODE_MIN_VERSION})`);
|
|
299
|
+
}
|
|
300
|
+
export function checkClaudeTranscripts(config, homeDir = os.homedir(), env = process.env) {
|
|
239
301
|
if (!config)
|
|
240
302
|
return okOutcome("no readable tumwater.json — nothing to verify");
|
|
241
303
|
if (!claudeSeamsInUse(config))
|
|
242
|
-
return okOutcome("no
|
|
243
|
-
const
|
|
304
|
+
return okOutcome("no model jobs run on claude");
|
|
305
|
+
const configDir = resolvedAgentConfig(config, "claude").configDir;
|
|
306
|
+
const dir = path.join(claudeConfigDir(env, homeDir, configDir), "projects");
|
|
244
307
|
let slugs;
|
|
245
308
|
try {
|
|
246
309
|
slugs = fs
|
|
@@ -254,11 +317,158 @@ export function checkClaudeTranscripts(config, homeDir = os.homedir()) {
|
|
|
254
317
|
let bytes = 0;
|
|
255
318
|
for (const slug of slugs)
|
|
256
319
|
bytes += treeBytes(path.join(dir, slug));
|
|
257
|
-
const projects = slugs.length
|
|
320
|
+
const projects = plural(slugs.length, "project");
|
|
258
321
|
return okOutcome(`${formatBytes(bytes)} across ${projects} in ${dir}`);
|
|
259
322
|
}
|
|
323
|
+
/** Bounding for the `pi list` probe below: the packages a team run now skips are names, so a
|
|
324
|
+
* short deadline and a small output cap keep an unlucky host from stalling doctor. */
|
|
325
|
+
const PI_LIST_TIMEOUT_MS = 5_000;
|
|
326
|
+
const PI_LIST_MAX_BYTES = 64 * 1024;
|
|
327
|
+
/** MCP server names in `~/.pi/agent/mcp.json` (the file pi's built-in MCP extension reads),
|
|
328
|
+
* or `[]` for a missing/malformed file: the `mcpServers` keys, or `mcp.<name>`. Best-effort
|
|
329
|
+
* naming so a server the operator meant for the team is surfaced even when the file is odd. */
|
|
330
|
+
function piMcpServerNames(file) {
|
|
331
|
+
// The tolerant read-and-parse half (missing, torn, scalar, `null`, or array file is "no
|
|
332
|
+
// data", never an error) is src/files/json-files.ts's readJsonFile, the same shared
|
|
333
|
+
// convention every other state/marker/info file reader uses.
|
|
334
|
+
const parsed = readJsonFile(file);
|
|
335
|
+
if (parsed === null)
|
|
336
|
+
return [];
|
|
337
|
+
const names = new Set();
|
|
338
|
+
const serverMap = parsed["mcpServers"];
|
|
339
|
+
if (serverMap && typeof serverMap === "object")
|
|
340
|
+
for (const name of Object.keys(serverMap))
|
|
341
|
+
names.add(name);
|
|
342
|
+
const mcp = parsed["mcp"];
|
|
343
|
+
if (mcp && typeof mcp === "object")
|
|
344
|
+
for (const [name, value] of Object.entries(mcp))
|
|
345
|
+
if (name !== "servers" && value && typeof value === "object")
|
|
346
|
+
names.add(name);
|
|
347
|
+
return [...names];
|
|
348
|
+
}
|
|
349
|
+
/** The operator pi state a team run now skips (plans/notes-command.md "Keeping funes out of
|
|
350
|
+
* team runs"): installed packages (`pi list`, bounded), files in `~/.pi/agent/extensions`, and
|
|
351
|
+
* MCP servers in `~/.pi/agent/mcp.json`. Each is named, so a package, extension file, or server
|
|
352
|
+
* the operator meant for the team does not vanish silently when pi loads none of it. Shown only
|
|
353
|
+
* when a model job runs on pi; returns null otherwise so doctor omits the row and never reads
|
|
354
|
+
* a host `~/.pi` a pi-only-install team does not use. `homeDir` and `pathEnv` are test seams. */
|
|
355
|
+
export function checkPiExtensions(config, homeDir = os.homedir(), pathEnv = envPath()) {
|
|
356
|
+
if (!config)
|
|
357
|
+
return null;
|
|
358
|
+
if (!piSeamsInUse(config))
|
|
359
|
+
return null;
|
|
360
|
+
const parts = [];
|
|
361
|
+
// Installed pi packages (pi list, bounded): each stdout line a package name. A probe that
|
|
362
|
+
// cannot run or produces nothing contributes nothing; the rest still name what team runs skip.
|
|
363
|
+
const resolved = resolveAgentBin(config, "pi");
|
|
364
|
+
const piBin = findAgentBinary(resolved, pathEnv);
|
|
365
|
+
if (piBin !== null) {
|
|
366
|
+
try {
|
|
367
|
+
const stdout = spawnSync(piBin, ["list"], {
|
|
368
|
+
encoding: "utf8",
|
|
369
|
+
timeout: PI_LIST_TIMEOUT_MS,
|
|
370
|
+
maxBuffer: PI_LIST_MAX_BYTES,
|
|
371
|
+
killSignal: "SIGKILL",
|
|
372
|
+
}).stdout;
|
|
373
|
+
// `pi list` is not line-per-package: each package is a two-space-indented source line
|
|
374
|
+
// (optionally followed by its installed path at four spaces), under the bold `User
|
|
375
|
+
// packages:` / `Project packages:` headers. Keep only the two-space source lines so the
|
|
376
|
+
// headers and installed paths are not mistaken for packages.
|
|
377
|
+
const packages = String(stdout)
|
|
378
|
+
.split(/\r?\n/)
|
|
379
|
+
.map((line) => line.replace(/\r$/, ""))
|
|
380
|
+
.map((line) => /^ (\S.*)$/.exec(line)?.[1]?.trim())
|
|
381
|
+
.filter((name) => name !== undefined && name.length > 0);
|
|
382
|
+
if (packages.length > 0)
|
|
383
|
+
parts.push(`packages: ${packages.join(", ")}`);
|
|
384
|
+
}
|
|
385
|
+
catch {
|
|
386
|
+
// A `pi list` that could not be read just names nothing; extensions/servers below still show.
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
// Files in ~/.pi/agent/extensions.
|
|
390
|
+
try {
|
|
391
|
+
const files = fs
|
|
392
|
+
.readdirSync(path.join(homeDir, ".pi", "agent", "extensions"), { withFileTypes: true })
|
|
393
|
+
.filter((entry) => entry.isFile())
|
|
394
|
+
.map((entry) => entry.name);
|
|
395
|
+
if (files.length > 0)
|
|
396
|
+
parts.push(`~/.pi/agent/extensions: ${files.join(", ")}`);
|
|
397
|
+
}
|
|
398
|
+
catch {
|
|
399
|
+
// Absent or unreadable; nothing to name.
|
|
400
|
+
}
|
|
401
|
+
// MCP servers named in ~/.pi/agent/mcp.json (the file pi's MCP built-in reads).
|
|
402
|
+
const mcpNames = piMcpServerNames(path.join(homeDir, ".pi", "agent", "mcp.json"));
|
|
403
|
+
if (mcpNames.length > 0)
|
|
404
|
+
parts.push(`mcp.json servers: ${mcpNames.join(", ")}`);
|
|
405
|
+
if (parts.length === 0)
|
|
406
|
+
return okOutcome("team pi runs skip discovered extensions; none found on this host");
|
|
407
|
+
return okOutcome(`team pi runs skip discovered extensions: ${parts.join("; ")}`);
|
|
408
|
+
}
|
|
409
|
+
/** The operator's global opencode config files, in XDG order — the same home `opencode-config.ts`
|
|
410
|
+
* reads for MCP names. Home is the operator's home; `$XDG_CONFIG_HOME`, when set, wins. */
|
|
411
|
+
function opencodeGlobalConfigPaths(homeDir) {
|
|
412
|
+
const configHome = process.env.XDG_CONFIG_HOME ?? path.join(homeDir, ".config");
|
|
413
|
+
return ["config.json", "opencode.json", "opencode.jsonc"].map((file) => path.join(configHome, "opencode", file));
|
|
414
|
+
}
|
|
415
|
+
/** Report the operator's global opencode config file that mentions funes, or null.
|
|
416
|
+
* A best-effort note (the design's doctor `funes` row) so an operator who installed funes's
|
|
417
|
+
* OpenCode integration is told it could reach an OpenCode team; a malformed file reads nothing. */
|
|
418
|
+
function opencodePluginNamesFunes(homeDir, readPath) {
|
|
419
|
+
for (const file of opencodeGlobalConfigPaths(homeDir)) {
|
|
420
|
+
const text = readPath(file);
|
|
421
|
+
if (text === null)
|
|
422
|
+
continue;
|
|
423
|
+
// A global config that mentions funes in or near a plugin reference is worth naming; the
|
|
424
|
+
// exact JSON shape varies by version (array or object), so a substring scan over the text is
|
|
425
|
+
// the honest check.
|
|
426
|
+
if (/"plugin(s)?"\s*[:\[\{]/.test(text) && /funes/i.test(text))
|
|
427
|
+
return file;
|
|
428
|
+
}
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
/** The `funes` row (plans/notes-command.md "Keeping funes out of team runs"): whether the
|
|
432
|
+
* transcript-recall tool you use for your own sessions can reach the team. Shows only when
|
|
433
|
+
* `funes` is on PATH (else no row at all) and warns when a Claude Code team runs without a
|
|
434
|
+
* `backends.claude.configDir` while funes's Claude Code hook is installed (`~/.funes/
|
|
435
|
+
* integrations/claude-plugin`) — that hook indexes every session under `~/.claude/projects`,
|
|
436
|
+
* which is exactly what a team config dir moves out from under. It also notes a global
|
|
437
|
+
* OpenCode plugin naming funes. `pathEnv`, `homeDir`, and `readPath` are test seams. */
|
|
438
|
+
export function checkFunes(config, pathEnv = envPath(), homeDir = os.homedir(), readPath = (file) => {
|
|
439
|
+
try {
|
|
440
|
+
return fs.readFileSync(file, "utf8");
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
}) {
|
|
446
|
+
if (!config)
|
|
447
|
+
return null;
|
|
448
|
+
if (findOnPath("funes", pathEnv) === null)
|
|
449
|
+
return null;
|
|
450
|
+
const notes = [];
|
|
451
|
+
if (claudeSeamsInUse(config)) {
|
|
452
|
+
const configDir = resolvedAgentConfig(config, "claude").configDir;
|
|
453
|
+
const plugin = path.join(homeDir, ".funes", "integrations", "claude-plugin");
|
|
454
|
+
if (configDir === undefined && fs.existsSync(plugin)) {
|
|
455
|
+
return warnOutcome("funes's Claude Code hook indexes team transcripts into your personal memory — set backends.claude.configDir and log in there once (`CLAUDE_CONFIG_DIR=<dir> claude auth login`)");
|
|
456
|
+
}
|
|
457
|
+
notes.push(configDir === undefined
|
|
458
|
+
? "Claude Code runs use the default config dir (no funes Claude Code hook installed)"
|
|
459
|
+
: "Claude Code runs use their own config dir");
|
|
460
|
+
}
|
|
461
|
+
const opencodePlugin = opencodePluginNamesFunes(homeDir, readPath);
|
|
462
|
+
if (opencodePlugin !== null)
|
|
463
|
+
notes.push(`global OpenCode config mentions funes (${opencodePlugin})`);
|
|
464
|
+
const detail = notes.length > 0 ? `funes on PATH, no team reach: ${notes.join("; ")}` : "funes on PATH, no team reach";
|
|
465
|
+
return okOutcome(detail);
|
|
466
|
+
}
|
|
260
467
|
/** .tumwater writable — absent is fine (created on first run); present, prove it by writing
|
|
261
|
-
* and deleting a temp file inside. The probe leaves the dir's listing unchanged after the run.
|
|
468
|
+
* and deleting a temp file inside. The probe leaves the dir's listing unchanged after the run.
|
|
469
|
+
* The write is the property under test: only a write that fails is "not writable", so the
|
|
470
|
+
* cleanup below — deleting the probe, sweeping stales — never vetoes a dir the write just
|
|
471
|
+
* proved writable, and a probe left over is swept rather than accumulated. */
|
|
262
472
|
export function checkStateDir(root) {
|
|
263
473
|
const dir = path.join(root, STATE_DIR);
|
|
264
474
|
if (!fs.existsSync(dir))
|
|
@@ -266,12 +476,46 @@ export function checkStateDir(root) {
|
|
|
266
476
|
const probe = path.join(dir, `.doctor-probe-${process.pid}`);
|
|
267
477
|
try {
|
|
268
478
|
fs.writeFileSync(probe, "");
|
|
269
|
-
fs.rmSync(probe);
|
|
270
|
-
return okOutcome("writable");
|
|
271
479
|
}
|
|
272
480
|
catch (err) {
|
|
481
|
+
// A partial write may already have created the file — sweep it like any other leftover.
|
|
482
|
+
sweepStaleProbes(dir);
|
|
273
483
|
return failOutcome(`not writable: ${errorMessage(err)}`);
|
|
274
484
|
}
|
|
485
|
+
// The write proved writability. Deleting the probe is cleanup, not measurement: a removal
|
|
486
|
+
// failure must not turn a succeeded write into a false alarm, and the stray is collected by
|
|
487
|
+
// the sweep below (or a later pass's sweep).
|
|
488
|
+
try {
|
|
489
|
+
fs.rmSync(probe);
|
|
490
|
+
}
|
|
491
|
+
catch {
|
|
492
|
+
// Best-effort — the stray is swept in a moment.
|
|
493
|
+
}
|
|
494
|
+
sweepStaleProbes(dir);
|
|
495
|
+
return okOutcome("writable");
|
|
496
|
+
}
|
|
497
|
+
/** Best-effort removal of every `.doctor-probe-*` file in `dir` — the only artifact
|
|
498
|
+
* checkStateDir creates, so any present probe is a leftover: a prior run killed between its
|
|
499
|
+
* write and delete, a partial write above, or a removal that failed. Removed one at a time so
|
|
500
|
+
* a single unremovable probe cannot stop the sweep. */
|
|
501
|
+
function sweepStaleProbes(dir) {
|
|
502
|
+
let names;
|
|
503
|
+
try {
|
|
504
|
+
names = fs.readdirSync(dir);
|
|
505
|
+
}
|
|
506
|
+
catch {
|
|
507
|
+
return; // Unreadable dir — nothing to sweep.
|
|
508
|
+
}
|
|
509
|
+
for (const name of names) {
|
|
510
|
+
if (!name.startsWith(".doctor-probe-"))
|
|
511
|
+
continue;
|
|
512
|
+
try {
|
|
513
|
+
fs.rmSync(path.join(dir, name));
|
|
514
|
+
}
|
|
515
|
+
catch {
|
|
516
|
+
// Best-effort — leave it for the next pass.
|
|
517
|
+
}
|
|
518
|
+
}
|
|
275
519
|
}
|
|
276
520
|
/** Event log readable — open and parse events.jsonl (and its rotated archive) so the one
|
|
277
521
|
* failure the telemetry role is chartered to catch — a feed that hid itself — is named by the
|
|
@@ -288,20 +532,22 @@ export function checkEventLog(root) {
|
|
|
288
532
|
return failOutcome(`could not read ${eventsLogPath(root)}: ${errorMessage(err)}`);
|
|
289
533
|
}
|
|
290
534
|
if (state === "unreadable")
|
|
291
|
-
return failOutcome(`unreadable: ${eventsLogPath(root)} — the
|
|
535
|
+
return failOutcome(`unreadable: ${eventsLogPath(root)} — the team's event feed cannot be read`);
|
|
292
536
|
if (state === "empty")
|
|
293
537
|
return okOutcome("no events yet");
|
|
294
538
|
return okOutcome(`readable: ${eventsLogPath(root)}`);
|
|
295
539
|
}
|
|
296
540
|
/** Disk floor (plans/disk-floor.md, parts 1/4 and 2/4) — is there room on the volume the
|
|
297
|
-
* worktrees live on? Fail below `diskHoldGB`, the same floor the running
|
|
541
|
+
* worktrees live on? Fail below `diskHoldGB`, the same floor the running team holds new work
|
|
298
542
|
* at, so doctor and the gate agree on when the disk is a problem. Warn in the band between
|
|
299
543
|
* `diskHoldGB` and `diskReclaimGB`: pressure reclaim will delete ignored build outputs from
|
|
300
|
-
* idle worktrees but the
|
|
544
|
+
* idle worktrees but the team still runs. Ok at or above `diskReclaimGB` (or when reclaim is
|
|
301
545
|
* disabled and free space is above the hold). A statfs failure is a warning, not a failure: an
|
|
302
|
-
* unmeasurable volume never holds the
|
|
546
|
+
* unmeasurable volume never holds the team, so it must not fail a scripted pre-flight. The
|
|
303
547
|
* detail names the measured path, the free GB to one decimal, and the floor. `holdGB` 0
|
|
304
|
-
* disables the hold, so a free space above `reclaimGB`'s own band (or `reclaimGB` 0) passes.
|
|
548
|
+
* disables the hold, so a free space above `reclaimGB`'s own band (or `reclaimGB` 0) passes.
|
|
549
|
+
* After a successful sample the detail also carries `diskReclaimPhrase` (what `disk_reclaim`
|
|
550
|
+
* passes freed over the last 24 h); it is informational and never changes the level. */
|
|
305
551
|
export function checkDiskSpace(root, holdGB, reclaimGB, sample = sampleFreeBytes) {
|
|
306
552
|
const where = diskVolumePath(root);
|
|
307
553
|
const free = sample(root);
|
|
@@ -309,12 +555,27 @@ export function checkDiskSpace(root, holdGB, reclaimGB, sample = sampleFreeBytes
|
|
|
309
555
|
return warnOutcome(`cannot measure free space at ${where} (statfs failed) — the disk hold is off for this process`);
|
|
310
556
|
const freeGB = free / BYTES_PER_GB;
|
|
311
557
|
const freeText = `${formatGB(freeGB)} free at ${where}`;
|
|
558
|
+
const phrase = diskReclaimPhrase(readEventsSince(root, 24 * 60 * 60 * 1000).events);
|
|
559
|
+
const withReclaim = (detail) => (phrase ? `${detail} · ${phrase}` : detail);
|
|
312
560
|
if (holdGB > 0 && freeGB < holdGB)
|
|
313
|
-
return failOutcome(`${freeText} — below the ${holdGB} GB diskHoldGB floor; the
|
|
561
|
+
return failOutcome(withReclaim(`${freeText} — below the ${holdGB} GB diskHoldGB floor; the team waits to start new work until space recovers`));
|
|
314
562
|
if (reclaimGB > 0 && freeGB < reclaimGB)
|
|
315
|
-
return warnOutcome(`${freeText} — below the ${reclaimGB} GB diskReclaimGB threshold; a pressure reclaim pass deletes ignored build outputs from idle worktrees`);
|
|
563
|
+
return warnOutcome(withReclaim(`${freeText} — below the ${reclaimGB} GB diskReclaimGB threshold; a pressure reclaim pass deletes ignored build outputs from idle worktrees`));
|
|
316
564
|
const floor = holdGB > 0 ? `above the ${holdGB} GB floor` : "diskHoldGB is 0 (hold disabled)";
|
|
317
|
-
return okOutcome(`${freeText}, ${floor}`);
|
|
565
|
+
return okOutcome(withReclaim(`${freeText}, ${floor}`));
|
|
566
|
+
}
|
|
567
|
+
/** The `reclaim in the last 24h freed …` phrase for the `disk space` row: the summed
|
|
568
|
+
* `freedGB` of the `disk_reclaim` passes, their count, and how many ran under pressure (an
|
|
569
|
+
* event without a `mode` counts as pressure, like its feed line). Each pass is a point event,
|
|
570
|
+
* so summing cannot straddle the window edge. Undefined when no pass ran. */
|
|
571
|
+
export function diskReclaimPhrase(events) {
|
|
572
|
+
const passes = events.filter((e) => e.type === "disk_reclaim");
|
|
573
|
+
if (passes.length === 0)
|
|
574
|
+
return undefined;
|
|
575
|
+
const freed = passes.reduce((sum, e) => sum + finiteNumber(e.freedGB, 0), 0);
|
|
576
|
+
const pressure = passes.filter((e) => (typeof e.mode === "string" && e.mode ? e.mode : "pressure") === "pressure").length;
|
|
577
|
+
const base = `reclaim in the last 24h freed ${formatGB(freed)} over ${plural(passes.length, "pass", "passes")}`;
|
|
578
|
+
return pressure > 0 ? `${base}, ${pressure} under pressure` : base;
|
|
318
579
|
}
|
|
319
580
|
/** Merge lock — read-only classification via classifyLock (the same three cases the breaker
|
|
320
581
|
* uses). A stale lock is a warning, not a failure: it self-heals on the next merge. */
|
|
@@ -325,7 +586,7 @@ export function checkMergeLock(root) {
|
|
|
325
586
|
return okOutcome("not held");
|
|
326
587
|
case "live": {
|
|
327
588
|
const pid = readLockPid(dir);
|
|
328
|
-
return okOutcome(pid !== null ? `held by running
|
|
589
|
+
return okOutcome(pid !== null ? `held by running role (pid ${pid})` : "held (pid not yet written)");
|
|
329
590
|
}
|
|
330
591
|
case "stale":
|
|
331
592
|
return warnOutcome("stale — will be broken on next merge");
|
|
@@ -341,9 +602,15 @@ const PIN_STALE_MS = 24 * 60 * 60 * 1000;
|
|
|
341
602
|
function leftoverWorktreeDirs(root) {
|
|
342
603
|
return subdirectoryNames(worktreesDir(root), (name) => name !== DIRECTOR_ROLE && !(name.startsWith("_") && !name.startsWith("_land-")));
|
|
343
604
|
}
|
|
344
|
-
/**
|
|
605
|
+
/** The window of `slot_wait` events the pool row sums, and the total wait inside it that warns. */
|
|
606
|
+
const SLOT_WAIT_CHECK_WINDOW_MS = 24 * 3600_000;
|
|
607
|
+
const SLOT_WAIT_WARN_TOTAL_MS = 10 * 60_000;
|
|
608
|
+
const PROVIDER_WAIT_CHECK_WINDOW_MS = 24 * 3600_000;
|
|
609
|
+
const PROVIDER_WAIT_WARN_TOTAL_MS = 10 * 60_000;
|
|
610
|
+
/** Worktree pool (plans/worktree-pool.md, part 5/5): report the pooled slots and warn on the
|
|
345
611
|
* states an operator should act on — a legacy `<role>` or `_land-*` checkout the pool migration
|
|
346
|
-
* left behind,
|
|
612
|
+
* left behind, a pin older than PIN_STALE_MS (a paused role holding a checkout), and the last
|
|
613
|
+
* 24 h of `slot_wait` events totalling SLOT_WAIT_WARN_TOTAL_MS (the pool is too small). Read-only,
|
|
347
614
|
* like every doctor check; `now` is a test seam for the pin-age arithmetic. */
|
|
348
615
|
export function checkWorktreePool(root, now = Date.now()) {
|
|
349
616
|
const slots = readSlotsState(root).slots;
|
|
@@ -351,7 +618,7 @@ export function checkWorktreePool(root, now = Date.now()) {
|
|
|
351
618
|
const pins = slots.filter((slot) => slot.pinnedFor !== null);
|
|
352
619
|
const stale = pins.filter((slot) => slot.pinnedAt != null && now - slot.pinnedAt > PIN_STALE_MS);
|
|
353
620
|
const leftover = leftoverWorktreeDirs(root);
|
|
354
|
-
const parts = [`${slots.length}
|
|
621
|
+
const parts = [`${plural(slots.length, "workspace")}, ${leased.length} leased, ${pins.length} pinned`];
|
|
355
622
|
if (leftover.length > 0)
|
|
356
623
|
parts.push(`legacy worktrees still present: ${leftover.join(", ")}`);
|
|
357
624
|
if (stale.length > 0) {
|
|
@@ -359,15 +626,137 @@ export function checkWorktreePool(root, now = Date.now()) {
|
|
|
359
626
|
.map((slot) => `${textOr(slot.pinnedFor)} pinned on ${path.basename(slot.dir)} for ${Math.round((now - (slot.pinnedAt ?? now)) / 3_600_000)}h`)
|
|
360
627
|
.join("; "));
|
|
361
628
|
}
|
|
362
|
-
|
|
629
|
+
let contended = false;
|
|
630
|
+
try {
|
|
631
|
+
const waits = eventsSince(root, SLOT_WAIT_CHECK_WINDOW_MS, now, (ev) => ev.type === "slot_wait");
|
|
632
|
+
if (waits.length > 0) {
|
|
633
|
+
let total = 0;
|
|
634
|
+
let longest = waits[0];
|
|
635
|
+
for (const ev of waits) {
|
|
636
|
+
const waited = finiteNumber(ev.waitedMs, 0);
|
|
637
|
+
total += waited;
|
|
638
|
+
if (waited > finiteNumber(longest.waitedMs, 0))
|
|
639
|
+
longest = ev;
|
|
640
|
+
}
|
|
641
|
+
contended = total >= SLOT_WAIT_WARN_TOTAL_MS;
|
|
642
|
+
const purpose = longest.purpose === "verify" ? "check" : "run";
|
|
643
|
+
parts.push(`${plural(waits.length, "wait")} to start in the last 24h, ${shortSpanPhrase(total)} total, longest ${shortSpanPhrase(finiteNumber(longest.waitedMs, 0))} (${textOr(longest.role)} ${purpose})${contended ? ` — raise worktreeSlots above ${slots.length}` : ""}`);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
catch (err) {
|
|
647
|
+
parts.push(`could not read the event log for waits to start: ${errorMessage(err)}`);
|
|
648
|
+
}
|
|
649
|
+
const warn = leftover.length > 0 || stale.length > 0 || contended;
|
|
650
|
+
return (warn ? warnOutcome : okOutcome)(parts.join(" — "));
|
|
651
|
+
}
|
|
652
|
+
/** Provider-slot contention: sums the last 24 h of `provider_wait` events (each a role tick
|
|
653
|
+
* whose semaphore admission waited PROVIDER_WAIT_EVENT_MS or more, recorded in
|
|
654
|
+
* orchestrator-launch.ts) and warns when the total reaches PROVIDER_WAIT_WARN_TOTAL_MS — the
|
|
655
|
+
* provider cap is too small. Names every key that waited, busiest first (the key-less
|
|
656
|
+
* bucket reads "the run cap") and, on a warn, each key's maxConcurrentPerProvider cap to raise. A
|
|
657
|
+
* counterpoint to checkWorktreePool's slot-wait half; read-only, like every doctor check.
|
|
658
|
+
* `now` is a test seam for the window. */
|
|
659
|
+
export function checkProviderContention(root, config, now = Date.now()) {
|
|
660
|
+
let count = 0;
|
|
661
|
+
let total = 0;
|
|
662
|
+
let longestMs = 0;
|
|
663
|
+
const perKey = new Map();
|
|
664
|
+
try {
|
|
665
|
+
const waits = eventsSince(root, PROVIDER_WAIT_CHECK_WINDOW_MS, now, (ev) => ev.type === "provider_wait");
|
|
666
|
+
for (const ev of waits) {
|
|
667
|
+
count += 1;
|
|
668
|
+
const waited = finiteNumber(ev.waitedMs, 0);
|
|
669
|
+
total += waited;
|
|
670
|
+
if (waited > longestMs)
|
|
671
|
+
longestMs = waited;
|
|
672
|
+
const key = typeof ev.key === "string" && ev.key.length > 0 ? ev.key : "";
|
|
673
|
+
perKey.set(key, (perKey.get(key) ?? 0) + waited);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
catch (err) {
|
|
677
|
+
// A read error leaves the level ok, exactly like the slot-wait branch's own read: the
|
|
678
|
+
// check cannot measure, so it says so rather than crying wolf.
|
|
679
|
+
return okOutcome(`could not read the event log for provider waits: ${errorMessage(err)}`);
|
|
680
|
+
}
|
|
681
|
+
if (count === 0)
|
|
682
|
+
return okOutcome("no provider waits");
|
|
683
|
+
// Rank the buckets by wait total, ties breaking on key name so the detail is deterministic.
|
|
684
|
+
// The key-less bucket reads "the run cap" — maxConcurrent, since no seam keys to a provider.
|
|
685
|
+
const ranked = [...perKey.entries()]
|
|
686
|
+
.map(([key, total]) => ({ key, total }))
|
|
687
|
+
.sort((a, b) => b.total - a.total || a.key.localeCompare(b.key));
|
|
688
|
+
const bucketLabel = (key) => (key === "" ? "the run cap" : `the ${key} slot`);
|
|
689
|
+
const cells = ranked.map((b) => `${bucketLabel(b.key)} ${shortSpanPhrase(b.total)}`);
|
|
690
|
+
// A single bucket keeps the pre-existing wording, a bare `(the X slot)` without the total
|
|
691
|
+
// (the total is already named by the `shortSpanPhrase(total)` in the clause before it);
|
|
692
|
+
// two or more name each bucket's total so the ranking reads at a glance.
|
|
693
|
+
const parenthetical = ranked.length === 1
|
|
694
|
+
? `(${bucketLabel(ranked[0].key)})`
|
|
695
|
+
: `(${cells[0]}, also ${cells.slice(1).join(", ")})`;
|
|
696
|
+
let detail = `${plural(count, "wait")} for a provider slot in the last 24h, ${shortSpanPhrase(total)} total, longest ${shortSpanPhrase(longestMs)} ${parenthetical}`;
|
|
697
|
+
const contended = total >= PROVIDER_WAIT_WARN_TOTAL_MS;
|
|
698
|
+
if (contended) {
|
|
699
|
+
const advice = ranked
|
|
700
|
+
.filter((b) => b.key !== "" && config?.maxConcurrentPerProvider?.[b.key] !== undefined)
|
|
701
|
+
.map((b) => `raise maxConcurrentPerProvider.${b.key} above ${config?.maxConcurrentPerProvider?.[b.key]}`)
|
|
702
|
+
.join(" and ");
|
|
703
|
+
if (advice)
|
|
704
|
+
detail += ` — ${advice}`;
|
|
705
|
+
}
|
|
706
|
+
return (contended ? warnOutcome : okOutcome)(detail);
|
|
707
|
+
}
|
|
708
|
+
/** The "leftover role branches" row: `tumwater/<id>` branches whose role is no longer enabled
|
|
709
|
+
* (disabled in tumwater.json, or an instance above the configured count). Retirable ids are
|
|
710
|
+
* the ones `tumwater retire --role <id>` accepts (knownRoleIds, or any well-formed custom-role name
|
|
711
|
+
* with a leftover branch); any other id is listed by
|
|
712
|
+
* full branch name with a manual-delete hint. The director and enabled roles are skipped.
|
|
713
|
+
* Read-only, like every check: it only lists refs; an unreadable tumwater.json skips it. */
|
|
714
|
+
export async function checkLeftoverRoleBranches(root, config) {
|
|
715
|
+
if (!config)
|
|
716
|
+
return okOutcome("no readable tumwater.json — nothing to verify");
|
|
717
|
+
const prefix = branchName("");
|
|
718
|
+
const known = new Set(knownRoleIds(config));
|
|
719
|
+
const retirable = [];
|
|
720
|
+
const unknown = [];
|
|
721
|
+
for (const branch of await listBranches(root)) {
|
|
722
|
+
if (!branch.startsWith(prefix))
|
|
723
|
+
continue;
|
|
724
|
+
const id = branch.slice(prefix.length);
|
|
725
|
+
if (!id || id.includes("/") || id === DIRECTOR_ROLE || loopEnabled(config, id))
|
|
726
|
+
continue;
|
|
727
|
+
if (known.has(id) || CUSTOM_NAME_RE.test(id))
|
|
728
|
+
retirable.push(id);
|
|
729
|
+
else
|
|
730
|
+
unknown.push(branch);
|
|
731
|
+
}
|
|
732
|
+
const parts = [];
|
|
733
|
+
if (retirable.length > 0) {
|
|
734
|
+
const n = retirable.length;
|
|
735
|
+
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)`);
|
|
736
|
+
}
|
|
737
|
+
if (unknown.length > 0) {
|
|
738
|
+
parts.push(`${unknown.join(", ")} ${agree(unknown.length, "names", "name")} no role retire accepts — delete with \`git branch -D\` once its commits are not needed`);
|
|
739
|
+
}
|
|
740
|
+
return parts.length === 0 ? okOutcome("no branches left by disabled roles") : warnOutcome(parts.join("; "));
|
|
363
741
|
}
|
|
364
|
-
/**
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
742
|
+
/** The ok detail of the instance check: non-zero counts, plans first, each pluralized. */
|
|
743
|
+
function claimCounts(claims) {
|
|
744
|
+
const bugs = claims.filter((c) => c.file === "BUGS.md").length;
|
|
745
|
+
const plans = claims.length - bugs;
|
|
746
|
+
const parts = [
|
|
747
|
+
plans > 0 ? plural(plans, "claimed plan") : "",
|
|
748
|
+
bugs > 0 ? plural(bugs, "claimed bug") : "",
|
|
749
|
+
].filter(Boolean);
|
|
750
|
+
return parts.length > 0 ? parts.join(", ") : "no claimed plans or bugs";
|
|
751
|
+
}
|
|
752
|
+
/** Parallel work instances (plans/parallel-work-instances.md "Observability", part 6/7):
|
|
753
|
+
* each instance's claimed plan or bug and the two states an operator should act on — a claim
|
|
754
|
+
* whose key is no longer listed under its role's section (its instance is stuck on an entry
|
|
755
|
+
* that moved), and a claim idle for more than CLAIM_IDLE_MAX_MS (the scheduler releases
|
|
756
|
+
* these, so seeing one means the reader's view and the scheduler's disagree). An enabled
|
|
757
|
+
* multi-instance role configured with fewer pooled slots than maxConcurrent also warns:
|
|
758
|
+
* instances then contend for the same checkouts. A disabled role runs none of its
|
|
759
|
+
* configured instances, so it never contends and
|
|
371
760
|
* stays out of that warning.
|
|
372
761
|
* Read-only, like every check; `now` is the claim-age test seam, and an unreadable
|
|
373
762
|
* tumwater.json (config undefined) skips the walk — checkInit reports the config itself. */
|
|
@@ -378,7 +767,7 @@ export function checkWorkInstances(root, config, now = Date.now()) {
|
|
|
378
767
|
for (const id of loopIds(config)) {
|
|
379
768
|
const claim = loadLoopState(root, id).claim;
|
|
380
769
|
if (claim)
|
|
381
|
-
claims.push({ id, title: claim.title, key: claim.key, at: claim.at });
|
|
770
|
+
claims.push({ id, title: claim.title, key: claim.key, at: claim.at, file: claim.file });
|
|
382
771
|
}
|
|
383
772
|
const gone = claims.filter((c) => !listedKeys(root, c.id).has(c.key));
|
|
384
773
|
const stale = claims.filter((c) => !gone.includes(c) && now - c.at > CLAIM_IDLE_MAX_MS);
|
|
@@ -388,15 +777,80 @@ export function checkWorkInstances(root, config, now = Date.now()) {
|
|
|
388
777
|
const slots = slotCount(config);
|
|
389
778
|
const smallPool = multi.length > 0 && slots < config.maxConcurrent;
|
|
390
779
|
if (gone.length === 0 && stale.length === 0 && !smallPool)
|
|
391
|
-
return okOutcome(claims
|
|
780
|
+
return okOutcome(claimCounts(claims));
|
|
781
|
+
const noun = (c) => (c.file === "BUGS.md" ? "bug" : "plan");
|
|
392
782
|
const parts = [
|
|
393
|
-
...gone.map((c) => `
|
|
394
|
-
...stale.map((c) => `
|
|
783
|
+
...gone.map((c) => `claimed ${noun(c)} "${c.title}" (${c.id}) names an entry no longer listed`),
|
|
784
|
+
...stale.map((c) => `claimed ${noun(c)} "${c.title}" (${c.id}) has been idle ${Math.round((now - c.at) / 3_600_000)}h`),
|
|
395
785
|
];
|
|
396
786
|
if (smallPool)
|
|
397
787
|
parts.push(`${multi.join(", ")} run instances but worktreeSlots ${slots} is below maxConcurrent ${config.maxConcurrent}`);
|
|
398
788
|
return warnOutcome(parts.join("; "));
|
|
399
789
|
}
|
|
790
|
+
/** Provider concurrency caps (plans/provider-concurrency.md §8): audit the
|
|
791
|
+
* `maxConcurrentPerProvider` map against the seams the config can actually spawn — each role,
|
|
792
|
+
* the review gate, the resolver, the director, and each tier's fallback — so the operator
|
|
793
|
+
* sees whether every cap names a provider in use and whether any cap actually binds. Warns on
|
|
794
|
+
* a cap key no seam resolves to (a typo, or a provider not in use), a cap at or above
|
|
795
|
+
* `maxConcurrent` (which never binds — the team total is the real ceiling), and a capped
|
|
796
|
+
* team whose seams include one with no key (pi's default provider is unreadable, so that
|
|
797
|
+
* seam cannot be capped). A config with no per-provider map or no caps is fine, and so is a
|
|
798
|
+
* well-formed map; a broken config is reported by the init check. Takes `root` to read pi's
|
|
799
|
+
* own default model (piSettingsPath), the same seam the orchestrator's admission uses, so a
|
|
800
|
+
* bare no-model pi seam keys exactly as the team would key it. */
|
|
801
|
+
export function checkProviderConcurrency(config, root) {
|
|
802
|
+
if (!config)
|
|
803
|
+
return okOutcome("no config to audit");
|
|
804
|
+
const caps = config.maxConcurrentPerProvider;
|
|
805
|
+
const capEntries = Object.entries(caps ?? {}).filter(([, v]) => Number.isInteger(v) && v > 0);
|
|
806
|
+
if (capEntries.length === 0)
|
|
807
|
+
return okOutcome("no per-provider caps configured");
|
|
808
|
+
// The pi default model the orchestrator's admission seam carries: a bare no-model pi seam
|
|
809
|
+
// resolves its key only through it, so a team whose default is readable has keyed seams and
|
|
810
|
+
// only an unreadable default leaves a key-less seam to warn about.
|
|
811
|
+
const piDefault = readPiDefaultModel(piSettingsPath(piModelsPath()), root);
|
|
812
|
+
const seams = seamsInUse(config);
|
|
813
|
+
const keyed = [];
|
|
814
|
+
for (const s of seams) {
|
|
815
|
+
const k = seamConcurrencyKey(s, piDefault);
|
|
816
|
+
if (k !== undefined && !keyed.includes(k))
|
|
817
|
+
keyed.push(k);
|
|
818
|
+
}
|
|
819
|
+
const keySet = new Set(keyed);
|
|
820
|
+
const capKeys = capEntries.map(([k]) => k);
|
|
821
|
+
const warnings = [];
|
|
822
|
+
for (const key of capKeys) {
|
|
823
|
+
if (!keySet.has(key))
|
|
824
|
+
warnings.push(`cap key "${key}" resolves to no model job (a typo, or a provider not in use)`);
|
|
825
|
+
}
|
|
826
|
+
for (const [key, cap] of capEntries) {
|
|
827
|
+
if (cap >= config.maxConcurrent)
|
|
828
|
+
warnings.push(`cap "${key}" ${cap} is at or above maxConcurrent ${config.maxConcurrent} — it never binds`);
|
|
829
|
+
}
|
|
830
|
+
const capSet = new Set(capKeys);
|
|
831
|
+
const keyless = seams.filter((s) => seamConcurrencyKey(s, piDefault) === undefined);
|
|
832
|
+
if (capSet.size > 0 && keyless.length > 0) {
|
|
833
|
+
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(", ")}`);
|
|
834
|
+
}
|
|
835
|
+
if (warnings.length === 0)
|
|
836
|
+
return okOutcome(`${plural(capEntries.length, "provider cap")} — all model jobs resolve`);
|
|
837
|
+
return warnOutcome(warnings.join("; "));
|
|
838
|
+
}
|
|
839
|
+
/** The `passing merge checks …` phrase for the `check` row: median and slowest wall clock of
|
|
840
|
+
* the passed full-suite runs (`merge`, `batch`, `baseline` scopes — a `gate` check can run a
|
|
841
|
+
* subset, and failed or skipped runs do not measure a full pass). The lower median keeps the
|
|
842
|
+
* figure a run that happened. Undefined when no event qualifies. */
|
|
843
|
+
export function checkDurationPhrase(events) {
|
|
844
|
+
const durations = events
|
|
845
|
+
.filter(isPassingFullCheck)
|
|
846
|
+
.map((e) => finiteNumber(e.durationMs, 0))
|
|
847
|
+
.filter((ms) => ms > 0);
|
|
848
|
+
if (durations.length === 0)
|
|
849
|
+
return undefined;
|
|
850
|
+
const median = lowerMedian(durations);
|
|
851
|
+
const slowest = Math.max(...durations);
|
|
852
|
+
return `passing merge checks in the last 24h took ${shortSpanPhrase(median)} median, ${shortSpanPhrase(slowest)} slowest (${plural(durations.length, "run")})`;
|
|
853
|
+
}
|
|
400
854
|
/** Declared project check — names what the review gate's deterministic pre-check, the
|
|
401
855
|
* red-main baseline, and redeploy's green check will run, without running it: a configured
|
|
402
856
|
* `check.command` (plans/portability.md §6/7), else the npm auto-detection. None declared is
|
|
@@ -409,22 +863,46 @@ export function checkBuildCheck(root, config) {
|
|
|
409
863
|
const check = detectBuildCheck(root, config ?? undefined);
|
|
410
864
|
if (!check)
|
|
411
865
|
return warnOutcome("none declared — the review gate's build pre-check, the red-main baseline, and redeploy's green check are all off (set `check.command` in tumwater.json for a non-npm repo)");
|
|
866
|
+
const phrase = checkDurationPhrase(readEventsSince(root, 24 * 60 * 60 * 1000).events);
|
|
867
|
+
const withTiming = (detail) => okOutcome(phrase ? `${detail} · ${phrase}` : detail);
|
|
412
868
|
if (check.kind === "command") {
|
|
413
869
|
const where = path.relative(root, check.cwd);
|
|
414
|
-
return
|
|
870
|
+
return withTiming(where ? `${check.command} (cwd ${where})` : check.command);
|
|
415
871
|
}
|
|
416
872
|
const where = path.relative(root, check.rootDir);
|
|
417
|
-
return
|
|
873
|
+
return withTiming(where ? `npm ${check.script} in ${where}` : `npm ${check.script}`);
|
|
874
|
+
}
|
|
875
|
+
/** The review gate's flake memory surfaced read-only: names which checks the gate kept excusing as
|
|
876
|
+
* flakes over its own KNOWN_FLAKE_WINDOW_MS window. The gate logs `gate check failed then passed on
|
|
877
|
+
* retry — flaky: <headline>` for each failure that passed on its one immediate re-run, and the
|
|
878
|
+
* in-lock merge check logs the same under MERGE_FLAKY_WARNING_PREFIX when its kept-pin retry passes
|
|
879
|
+
* (src/review/known-flakes.ts); that record is internal to the gate's matching, so a genuinely
|
|
880
|
+
* flaky check silently burns a re-run per merge and hides real instability — exactly the
|
|
881
|
+
* information that explains intermittent rejections. This check reads the same window and folds the
|
|
882
|
+
* same normalized headline the gate matches with (flakeCounts, so the prefix slice-offset and
|
|
883
|
+
* cluster normalization cannot drift), and warns only when a normalized check flapped more than
|
|
884
|
+
* once, so a routine one-off flake stays quiet. ok/warn only — never a fail — so a flaky suite
|
|
885
|
+
* cannot fail a scripted doctor pre-flight (the checkStalledPlans precedent; the exit-code rule is
|
|
886
|
+
* outcome.ts's). */
|
|
887
|
+
export function checkFlakyChecks(root) {
|
|
888
|
+
const { events } = readEventsSince(root, KNOWN_FLAKE_WINDOW_MS);
|
|
889
|
+
const flapped = flakeCounts(events).filter(([, n]) => n >= 2);
|
|
890
|
+
if (flapped.length === 0)
|
|
891
|
+
return okOutcome("no check flapped more than once in the last 24h");
|
|
892
|
+
const render = ([k, n]) => `"${k}" ×${n}`;
|
|
893
|
+
const [first, ...rest] = flapped;
|
|
894
|
+
const more = rest.length > 0 ? ` (and ${rest.length} more: ${rest.map(render).join(", ")})` : "";
|
|
895
|
+
return warnOutcome(`${agree(flapped.length, "check", "checks")} flapped more than once in the last 24h: ${render(first)}${more}`);
|
|
418
896
|
}
|
|
419
897
|
/** Build provenance — is the harness about to run (this process's dist/) the code main
|
|
420
898
|
* describes? Only meaningful when this project IS the harness (isSelfHosted); elsewhere the
|
|
421
|
-
* stamp is reported as-is. A stale build is a warning, not a failure: the
|
|
899
|
+
* stamp is reported as-is. A stale build is a warning, not a failure: the team runs, just not
|
|
422
900
|
* the newest code, and auto-restart (or a rebuild + restart) resolves it. `info`, `head` and
|
|
423
901
|
* `running` are injectable so tests can exercise every branch without compiling anything. */
|
|
424
902
|
export async function checkBuild(root, info = readBuildInfo(), head = undefined,
|
|
425
903
|
/** What the running orchestrator published about this build (orchestrator.json), when one is
|
|
426
904
|
* up. A stale build whose restart was REFUSED must not be reported with the boilerplate
|
|
427
|
-
* "auto-restart does this for a running
|
|
905
|
+
* "auto-restart does this for a running team": nothing will happen until main moves, and
|
|
428
906
|
* doctor is where an operator goes to find out why. */
|
|
429
907
|
running = null) {
|
|
430
908
|
if (!info)
|
|
@@ -433,7 +911,7 @@ running = null) {
|
|
|
433
911
|
if (!(await isSelfHosted(root, info)))
|
|
434
912
|
// Not "dist/ from …": in someone else's project that names a dist/ directory the project
|
|
435
913
|
// does not have — the stamp read here is the running harness install's own, so say that.
|
|
436
|
-
return okOutcome(`
|
|
914
|
+
return okOutcome(`tumwater version ${sha} (this project is not tumwater itself)`);
|
|
437
915
|
const mainHead = head === undefined ? await currentHead(root) : head;
|
|
438
916
|
if (!mainHead)
|
|
439
917
|
return okOutcome(`dist/ from ${sha}`);
|
|
@@ -445,7 +923,7 @@ running = null) {
|
|
|
445
923
|
? `; auto-restart is BLOCKED (${running.restartBlocked}) and will not retry until main moves or the block clears`
|
|
446
924
|
: running?.restartPending
|
|
447
925
|
? "; auto-restart is under way"
|
|
448
|
-
: "; run `npm run build` and restart `tumwater
|
|
926
|
+
: "; run `npm run build` and restart `tumwater start` (auto-restart does this for a running team)";
|
|
449
927
|
return warnOutcome(`dist/ from ${sha} is stale — main has ${stale.aheadCommits} later commit(s) touching ${STALE_INPUTS_LABEL}${next}`);
|
|
450
928
|
}
|
|
451
929
|
return okOutcome(`dist/ from ${sha}, matches main`);
|