tumwater 0.4.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. package/README.md +4 -6
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/backend-traits.js +14 -0
  4. package/dist/src/agent/claude/claude-stream.js +27 -31
  5. package/dist/src/agent/codex/codex-rollout.js +38 -24
  6. package/dist/src/agent/codex/codex-stream.js +71 -38
  7. package/dist/src/agent/image-input.js +18 -87
  8. package/dist/src/agent/opencode/opencode-catalog.js +2 -2
  9. package/dist/src/agent/opencode/opencode-stream.js +4 -4
  10. package/dist/src/agent/run-agent.js +11 -2
  11. package/dist/src/agent/run-settle.js +22 -13
  12. package/dist/src/agent/run-spawn.js +3 -12
  13. package/dist/src/agent/run-stream.js +2 -10
  14. package/dist/src/agent/session-ledger.js +16 -0
  15. package/dist/src/backlog/backlog-archive.js +64 -165
  16. package/dist/src/backlog/backlog-eligibility.js +50 -58
  17. package/dist/src/backlog/backlog-file-bugs.js +133 -0
  18. package/dist/src/backlog/backlog-index-data.js +2 -2
  19. package/dist/src/backlog/backlog-md.js +38 -27
  20. package/dist/src/backlog/backlog-release.js +11 -19
  21. package/dist/src/backlog/backlog-structure.js +14 -1
  22. package/dist/src/backlog/backlog-write.js +1 -1
  23. package/dist/src/backlog/backlog.js +5 -4
  24. package/dist/src/backlog/question-answer.js +30 -14
  25. package/dist/src/baseline/main-baseline.js +30 -20
  26. package/dist/src/baseline/main-guardrails.js +54 -17
  27. package/dist/src/baseline/main-watch.js +1 -1
  28. package/dist/src/budget/budget-wiring.js +0 -5
  29. package/dist/src/budget/budget.js +2 -1
  30. package/dist/src/build/build-check-scoped.js +70 -41
  31. package/dist/src/change/change-data.js +4 -2
  32. package/dist/src/cli/cli-command-args.js +199 -98
  33. package/dist/src/cli/cli-flag-specs.js +1 -4
  34. package/dist/src/cli/cli-review-commands.js +2 -1
  35. package/dist/src/cli/cli-role-command.js +27 -50
  36. package/dist/src/cli/cli-start.js +62 -12
  37. package/dist/src/cli/follow-view.js +3 -1
  38. package/dist/src/cli/help.js +6 -7
  39. package/dist/src/cli/log-commands.js +3 -1
  40. package/dist/src/cli.js +3 -7
  41. package/dist/src/collections.js +21 -7
  42. package/dist/src/config/config-fallbacks.js +0 -4
  43. package/dist/src/config/config-live.js +179 -147
  44. package/dist/src/config/config-views.js +2 -7
  45. package/dist/src/config/config-write.js +19 -26
  46. package/dist/src/doctor/doctor-backlog-bugs.js +17 -115
  47. package/dist/src/doctor/doctor-backlog-plans.js +8 -8
  48. package/dist/src/doctor/doctor-backlog.js +22 -36
  49. package/dist/src/doctor/doctor-check-names.js +2 -2
  50. package/dist/src/doctor/doctor-claude-checks.js +2 -2
  51. package/dist/src/doctor/doctor-contention-checks.js +1 -1
  52. package/dist/src/doctor/doctor-holds.js +13 -11
  53. package/dist/src/doctor/doctor-model-checks.js +8 -184
  54. package/dist/src/doctor/doctor-notify.js +2 -5
  55. package/dist/src/doctor/doctor-opencode-checks.js +85 -48
  56. package/dist/src/doctor/doctor-project-checks.js +66 -17
  57. package/dist/src/doctor/doctor-role-notes.js +6 -24
  58. package/dist/src/doctor/doctor.js +6 -6
  59. package/dist/src/doctor/probe.js +1 -1
  60. package/dist/src/events/event-format.js +5 -15
  61. package/dist/src/events/event-labels.js +0 -1
  62. package/dist/src/events/event-legacy.js +1 -1
  63. package/dist/src/events/event-window.js +1 -1
  64. package/dist/src/events/events.js +15 -2
  65. package/dist/src/failure/failure-state-change.js +183 -68
  66. package/dist/src/failure/time-spend.js +1 -1
  67. package/dist/src/files/json-object.js +15 -2
  68. package/dist/src/gates/startup-gate.js +28 -28
  69. package/dist/src/git/slot-salvage.js +5 -4
  70. package/dist/src/gui/gui-admin-endpoints.js +2 -3
  71. package/dist/src/gui/gui-backlog-commands.js +1 -1
  72. package/dist/src/gui/gui-backlog-endpoints.js +1 -7
  73. package/dist/src/gui/gui-endpoint-commands.js +1 -1
  74. package/dist/src/gui/gui-operator-commands.js +0 -12
  75. package/dist/src/gui/gui-prompt-commands.js +26 -20
  76. package/dist/src/gui/gui-report-endpoints.js +2 -1
  77. package/dist/src/gui/gui-server.js +1 -2
  78. package/dist/src/history/history.js +102 -67
  79. package/dist/src/inbox/inbox-submit.js +1 -5
  80. package/dist/src/inbox/prompt-commands.js +24 -19
  81. package/dist/src/init/init.js +68 -15
  82. package/dist/src/merge/merge-core.js +1 -1
  83. package/dist/src/merge/merge-post-review-drain.js +1 -1
  84. package/dist/src/operator/operator-commands.js +68 -47
  85. package/dist/src/operator/retire.js +2 -1
  86. package/dist/src/orchestrator/retention.js +6 -5
  87. package/dist/src/paths.js +0 -6
  88. package/dist/src/pi/pi-watchdogs.js +20 -1
  89. package/dist/src/pi-extension/context-shake.js +55 -31
  90. package/dist/src/process/process.js +23 -0
  91. package/dist/src/process/supervisor.js +17 -1
  92. package/dist/src/prompt/gate-notes.js +9 -9
  93. package/dist/src/prompt/notes-backlog.js +11 -24
  94. package/dist/src/prompt/prompt-followup.js +4 -12
  95. package/dist/src/prompt/prompt.js +4 -5
  96. package/dist/src/report/report-data.js +10 -225
  97. package/dist/src/report/report-fold.js +20 -295
  98. package/dist/src/report/report-main-red.js +45 -42
  99. package/dist/src/report/report-render.js +66 -39
  100. package/dist/src/report/report-since-data.js +218 -0
  101. package/dist/src/report/report-usage-fold.js +288 -0
  102. package/dist/src/report/report.js +2 -1
  103. package/dist/src/review/known-flakes.js +3 -3
  104. package/dist/src/review/post-merge-review-accept.js +3 -2
  105. package/dist/src/review/post-merge-review.js +9 -9
  106. package/dist/src/review/quarantine-render.js +2 -1
  107. package/dist/src/roles/role-catalog.js +18 -30
  108. package/dist/src/roles/role-guidance.js +18 -43
  109. package/dist/src/roles/role-view.js +1 -2
  110. package/dist/src/run/ci-parity.js +5 -6
  111. package/dist/src/run/qa-coverage.js +4 -4
  112. package/dist/src/run/run-detail-data.js +23 -7
  113. package/dist/src/run/run-detail-render.js +6 -0
  114. package/dist/src/run/run-detail.js +44 -22
  115. package/dist/src/run/run-dismiss.js +43 -0
  116. package/dist/src/run/run-prompt.js +100 -56
  117. package/dist/src/run/run-resume.js +32 -31
  118. package/dist/src/run/run-stage.js +4 -21
  119. package/dist/src/run/run-timing.js +18 -4
  120. package/dist/src/run/run-verdict.js +8 -73
  121. package/dist/src/run/stage-backlog-checks.js +2 -29
  122. package/dist/src/run/stage-check.js +4 -12
  123. package/dist/src/runner/runner-outcome.js +0 -5
  124. package/dist/src/runner/runner-run.js +2 -79
  125. package/dist/src/runner/runner-state-load.js +0 -10
  126. package/dist/src/runner/runner.js +1 -32
  127. package/dist/src/scheduling/claims.js +3 -10
  128. package/dist/src/status/status-data.js +5 -9
  129. package/dist/src/status/status-role-notes.js +18 -14
  130. package/dist/src/status/status-roles.js +2 -35
  131. package/dist/src/text/datetime.js +8 -1
  132. package/dist/src/text/text.js +12 -0
  133. package/dist/src/ui/alert-feeds.js +7 -212
  134. package/dist/src/ui/badges.js +2 -3
  135. package/dist/src/ui/gui/gui-client-boot.js +19 -4
  136. package/dist/src/ui/gui/gui-client-drawer.js +1 -1
  137. package/dist/src/ui/gui/gui-client-report-chart.js +10 -8
  138. package/dist/src/ui/gui/gui-client-report.js +1 -2
  139. package/dist/src/ui/gui/gui-client-settings.js +0 -23
  140. package/dist/src/ui/gui/gui-client-team.js +26 -0
  141. package/dist/src/ui/gui/gui-icons.js +7 -1
  142. package/dist/src/ui/status-model.js +2 -2
  143. package/dist/src/ui/status-payload.js +3 -3
  144. package/dist/src/ui/status-render.js +3 -3
  145. package/dist/src/ui/team-alert-backlog.js +23 -54
  146. package/dist/src/ui/team-alert-shared.js +23 -10
  147. package/dist/src/ui/team-alert-status.js +8 -3
  148. package/dist/src/ui/team-alert-tail.js +1 -14
  149. package/dist/src/ui/team-alerts.js +6 -17
  150. package/dist/src/ui/transcript-tail.js +137 -94
  151. package/dist/src/ui/tui/tui-editor-answer.js +54 -44
  152. package/dist/src/ui/tui/tui-editor-attach.js +74 -63
  153. package/dist/src/ui/tui/tui-editor-backends.js +62 -56
  154. package/dist/src/ui/tui/tui-editor-brief.js +64 -54
  155. package/dist/src/ui/tui/tui-editor-budget.js +105 -93
  156. package/dist/src/ui/tui/tui-editor-config.js +2 -4
  157. package/dist/src/ui/tui/tui-editor-entry.js +171 -153
  158. package/dist/src/ui/tui/tui-editor-instances.js +2 -1
  159. package/dist/src/ui/tui/tui-editor-pause.js +209 -183
  160. package/dist/src/ui/tui/tui-editor-prompt.js +44 -34
  161. package/dist/src/ui/tui/tui-editor-queued.js +74 -64
  162. package/dist/src/ui/tui/tui-editor-quiet.js +121 -108
  163. package/dist/src/ui/tui/tui-editor-release.js +50 -42
  164. package/dist/src/ui/tui/tui-editor-retire.js +57 -46
  165. package/dist/src/ui/tui/tui-editor-role-enabled.js +90 -80
  166. package/dist/src/ui/tui/tui-editor-role-file.js +58 -46
  167. package/dist/src/ui/tui/tui-editor-role-key.js +49 -42
  168. package/dist/src/ui/tui/tui-editor-search.js +70 -58
  169. package/dist/src/ui/tui/tui-frame.js +1 -1
  170. package/dist/src/ui/tui/tui-keys.js +1 -1
  171. package/dist/src/ui/tui/tui-pane-activity.js +62 -50
  172. package/dist/src/ui/tui/tui-pane-backlog.js +4 -4
  173. package/dist/src/ui/tui/tui-pane-health.js +3 -24
  174. package/dist/src/ui/tui/tui-pane-history.js +20 -24
  175. package/dist/src/ui/tui/tui-pane-report.js +74 -57
  176. package/dist/src/ui/tui/tui-pane-session.js +3 -2
  177. package/dist/src/ui/tui/tui-pane-transcript.js +151 -128
  178. package/dist/src/ui/tui/tui-pane.js +201 -174
  179. package/dist/src/ui/tui/tui-panes.js +2 -3
  180. package/dist/src/ui/tui/tui.js +3 -3
  181. package/dist/src/verdict/fix-claim.js +18 -6
  182. package/dist/src/verdict/refusal.js +30 -6
  183. package/package.json +1 -1
  184. package/dist/src/doctor/image-probe.js +0 -257
  185. package/dist/src/ui/team-alert-backlog-feed.js +0 -181
  186. package/dist/src/ui/team-alert-merge.js +0 -183
@@ -1,17 +1,14 @@
1
1
  /** The seam's answer to "who in this team can read images" (plans/image-input.md
2
- * "Capability"). One resolver, `seamImageInput`, is the only place that rule lives; the doctor
3
- * (src/doctor/doctor-model-checks.ts) and the later reroute parts both read it, so the answer
4
- * is never reached two ways. It lives at runtime level because it needs the backend registry:
5
- * a claude seam is always capable through the AgentBackend.imageInput hook, a pi seam reads the
6
- * model's `input` in models.json, and any other backend answers null unless it declares a hook. */
7
- import { configForRole, roleSeamTier, withModelOverrides } from "../config/config-views.js";
8
- import { BORROW_ORDER, resolveTierFallbacks } from "../config/config-fallbacks.js";
2
+ * "Capability"). One resolver, `seamImageInput`, is the only place that rule lives, so the
3
+ * answer is never reached two ways. It lives at runtime level because it needs the backend
4
+ * registry: a claude seam is always capable through the AgentBackend.imageInput hook, a pi
5
+ * seam reads the model's `input` in models.json, and any other backend answers null unless it
6
+ * declares a hook. */
7
+ import { configForRole } from "../config/config-views.js";
9
8
  import { seamAgent } from "../config/model-selector.js";
10
- import { modelLabel } from "./backend-traits.js";
11
- import { piModelImageInput, piPairFree, piModelsPath, readPiProviders } from "../pi/pi-models.js";
9
+ import { piModelImageInput, piModelsPath, readPiProviders } from "../pi/pi-models.js";
12
10
  import { piListedImageInput } from "../pi/pi-listed-models.js";
13
11
  import { backendById } from "./agent-backend.js";
14
- import { imageProbeEntryFresh, imageProbeKey } from "../doctor/image-probe.js";
15
12
  /** One seam's image-input capability (plans/image-input.md "Capability"): `true` when the
16
13
  * seam's backend declares image support, `false` when it is text-only, `null` when it cannot
17
14
  * be answered from what the team knows (a pair neither models.json nor the pi listing names, a
@@ -21,11 +18,10 @@ import { imageProbeEntryFresh, imageProbeKey } from "../doctor/image-probe.js";
21
18
  * the default pi backend leaves it to models.json's `input` field, where absent means text-only
22
19
  * (pi's own default for a custom model); a pair absent from models.json answers what
23
20
  * `pi --list-models` last said (src/pi/pi-listed-models.ts), else null.
24
- * `probes` is the doctor's live-probe cache (part 2/5): a claimed pi seam whose probe FAILED
25
- * reads false — a claim is only as good as the verified
26
- * answer — while a cached pass (or an unprobed claim) stands on the catalog. claude seams are
27
- * never probed, so the hook answer is never overridden. */
28
- export function seamImageInput(seam, providers, probes, listed = null) {
21
+ * A claim in models.json is trusted without a live probe (the doctor's live probe was
22
+ * subtracted): a claimed pi seam reads true on the catalog. claude seams are answered by the
23
+ * hook. */
24
+ export function seamImageInput(seam, providers, listed = null) {
29
25
  const backendId = seamAgent(seam);
30
26
  const backend = backendById(backendId);
31
27
  // A backend with an imageInput hook owns the answer outright (claude: true for every model).
@@ -34,79 +30,14 @@ export function seamImageInput(seam, providers, probes, listed = null) {
34
30
  // An unknown backend without a hook has no catalog to consult — unknown, never assume.
35
31
  if (backendId !== "pi")
36
32
  return null;
37
- const base = piModelImageInput(providers, seam.provider, seam.model) ?? piListedImageInput(listed, seam.provider, seam.model);
38
- if (base !== true)
39
- return base;
40
- // A claimed pi seam whose live probe failed (fresh, claim unchanged) reads false. Only pi is
41
- // probed, so the probe key carries the pi backend.
42
- const entry = probes?.[imageProbeKey(backendId, seam.provider, seam.model)];
43
- if (entry?.verdict === "fail" && imageProbeEntryFresh(entry))
44
- return false;
45
- return true;
33
+ return piModelImageInput(providers, seam.provider, seam.model) ?? piListedImageInput(listed, seam.provider, seam.model);
46
34
  }
47
- /** The image-capable reroute target for `role`, or null when no peer tier can read images
48
- * (plans/image-input.md "Reroute target"). Walks the role's seam tier through BORROW_ORDER,
49
- * skipping the tier itself — small → default → strong, default → strong → small, strong →
50
- * default (a strong-tier seam never drops to small, the same rule as the budget fallback) —
51
- * and takes the first tier whose model reads images. `regime` picks the pair set (see
52
- * RerouteRegime); `modelsPath` and `account` are injectable so tests need no real pi install
53
- * or account. */
54
- export function imageReroute(config, role, regime = "primary", modelsPath = piModelsPath(), account = null, probes, providers = null, listed = null) {
55
- const tier = roleSeamTier(config, role);
56
- const candidates = BORROW_ORDER[tier].filter((t) => t !== tier);
57
- if (candidates.length === 0)
58
- return null;
59
- const providerMap = providers ?? readPiProviders(modelsPath);
60
- const capable = (rc) => seamImageInput(rc, providerMap, probes, listed) === true;
61
- if (regime === "primary") {
62
- for (const t of candidates) {
63
- const rc = withModelOverrides(config, t, {});
64
- if (capable(rc))
65
- return { tier: t, config: rc };
66
- }
67
- return null;
68
- }
69
- // Fallback regime: only the tiers' free fallback pairs qualify (resolveTierFallbacks over the
70
- // price check), never a priced primary model — a reroute must not spend past the cap.
71
- const resolved = resolveTierFallbacks(config, (p) =>
72
- // The free-ness lookup reads the one claude account held here (part 2a/9).
73
- piPairFree(providerMap, p, (b) => (b === "claude" ? account : null)));
74
- for (const t of candidates) {
75
- const pair = resolved[t].pair;
76
- if (!pair)
77
- continue;
78
- const rc = withModelOverrides(config, t, {
79
- provider: pair.provider,
80
- model: pair.model,
81
- thinking: pair.thinking,
82
- });
83
- if (capable(rc))
84
- return { tier: t, config: rc };
85
- }
86
- return null;
87
- }
88
- /** The config of imageReroute, for callers that want only the target model. */
89
- export function imageRerouteTarget(config, role, regime = "primary", modelsPath = piModelsPath(), account = null, probes, providers = null, listed = null) {
90
- return imageReroute(config, role, regime, modelsPath, account, probes, providers, listed)?.config ?? null;
91
- }
92
- /** A seam's model as one readable token, the same label the doctor's `image input` check uses
93
- * (`claude/<id>` for a claude seam, `provider/model` for pi). The one home of the label, so
94
- * the surface strings (the images alert, the role page, the status rows) and the doctor report
95
- * cannot drift, and delegates to backend-traits' modelLabel. */
96
- export function seamImageLabel(backend, provider, model) {
97
- return modelLabel(backend, provider, model);
98
- }
99
- /** Compute RoleImageInfo for one role. `modelsPath` and `probes` are injectable; the providers
100
- * catalog is read (stat-cached) here and also handed to imageReroute so a poll that asks many
101
- * roles still parses the catalog once. All reads mirror the doctor's `image input` check, so
102
- * the surface and the check agree about capability. */
103
- export function roleImageInfo(config, role, modelsPath = piModelsPath(), probes, listed = null) {
35
+ /** Compute RoleImageInfo for one role. `modelsPath` and `listed` are injectable; the providers
36
+ * catalog is read (stat-cached) here. The reroute field is a literal null — the peer-tier image
37
+ * reroute was subtracted (part 5g/6), so no role names a reroute target; 5h removes the field. */
38
+ export function roleImageInfo(config, role, modelsPath = piModelsPath(), listed = null) {
104
39
  const providers = readPiProviders(modelsPath);
105
40
  const rc = configForRole(config, role);
106
- const imageInput = seamImageInput(rc, providers, probes, listed);
107
- const rr = imageReroute(config, role, "primary", modelsPath, null, probes, providers, listed);
108
- const imageRerouteStr = rr
109
- ? `${rr.tier} (${seamImageLabel(rr.config.agent, rr.config.provider, rr.config.model)})`
110
- : null;
111
- return { imageInput, imageReroute: imageRerouteStr };
41
+ const imageInput = seamImageInput(rc, providers, listed);
42
+ return { imageInput, imageReroute: null };
112
43
  }
@@ -4,8 +4,8 @@
4
4
  * `.tumwater/state/opencode-models.json` that survives a failed fetch, and
5
5
  * `opencodeModelFree` as the one predicate part 6b/7's seam reads. No seam is wired here: that
6
6
  * is part 6b/7 (the budget gate), part 6c/7 (a billedAt demotion on an observed cost) and
7
- * part 7/7 (a startup-cache seed and doctor), so this module's only callers today are its own
8
- * tests. */
7
+ * part 7/7 (a startup-cache seed and doctor), so this module only publishes the data and
8
+ * predicates those seam consumers read. */
9
9
  import { spawnSync } from "node:child_process";
10
10
  import { isJsonObject, validEntries } from "../../files/json-object.js";
11
11
  import { readJsonFile, writeJsonAtomic } from "../../files/json-files.js";
@@ -16,6 +16,7 @@
16
16
  * `opencode_free_tier_refused` is added there once when any step was refused. `--thinking` is
17
17
  * never passed, so no `reasoning` event appears; the mapping below still recognizes one so a
18
18
  * future `--thinking` run degrades gracefully. */
19
+ import { transientProseKind } from "../backend-traits.js";
19
20
  import { canonicalUsageBlock, isJsonObject, nonNegativeNumber, objectOrEmpty, parseJsonObject } from "../../files/json-object.js";
20
21
  /** opencode's tool names → the canonical pi names the readers already know. `shell`→`bash` and
21
22
  * `patch`→`edit` (pi has one edit tool); anything else is lower-cased so a future tool still
@@ -66,10 +67,9 @@ function classifyError(err) {
66
67
  // Transient server, connection and timeout failures.
67
68
  if (/\b(500|501|502|503|504|529)\b|internal server error|bad gateway|service unavailable|gateway timeout|overloaded/i.test(message))
68
69
  return { kind: "transientBackend", backendKind: "server" };
69
- if (/connection|econn|refused|reset|terminated/i.test(message))
70
- return { kind: "transientBackend", backendKind: "connection" };
71
- if (/timed out|timeout/i.test(message))
72
- return { kind: "transientBackend", backendKind: "timeout" };
70
+ const prose = transientProseKind(message);
71
+ if (prose)
72
+ return prose;
73
73
  return {};
74
74
  }
75
75
  /** The step's token counts and cost → pi's canonical usage block: the four counts, their sum
@@ -15,7 +15,7 @@
15
15
  import fs from "node:fs";
16
16
  import os from "node:os";
17
17
  import path from "node:path";
18
- import { ensureDir } from "../files/files.js";
18
+ import { ensureDir, removeTree } from "../files/files.js";
19
19
  import { runOnAbort, terminateChild } from "../process/process.js";
20
20
  import { startPiWatchdogs } from "../pi/pi-watchdogs.js";
21
21
  import { DEFAULT_BACKEND, backendById, registerAgentBackend } from "./agent-backend.js";
@@ -208,7 +208,16 @@ export function runAgent(opts) {
208
208
  catch (err) {
209
209
  // A synchronous throw in the run's build-and-spawn stage (claude's invocation rejects a
210
210
  // run with no resolved model) rejects the run, preserving the contract a synchronous
211
- // executor's throw used to get from the Promise constructor.
211
+ // executor's throw used to get from the Promise constructor. No child was spawned, so no
212
+ // `error`/`close` ever fires and the settle path that removes the private TMPDIR
213
+ // (run-stream.ts's finish) never runs — remove it here, best-effort like finish does, so
214
+ // a broken run does not leave a `tumwater-run-*` temp dir behind.
215
+ try {
216
+ removeTree(runTmp);
217
+ }
218
+ catch {
219
+ /* left for the OS temp sweep */
220
+ }
212
221
  reject(err instanceof Error ? err : new Error(`runAgent: ${String(err)}`));
213
222
  }
214
223
  });
@@ -144,6 +144,27 @@ export function settleExit(state) {
144
144
  })
145
145
  .catch(() => { });
146
146
  }
147
+ /** Settle the run's `lastError` text in priority order: a failed blind image read names both
148
+ * the file and the model (plans/image-input.md "Failing the run"), then the abort, quiet-kill
149
+ * and timeout causes the watchdogs and signal path set, then the parser's own event-derived
150
+ * error, else the raw stderr tail. Undefined for a run that simply ended with a message. */
151
+ function settleErrorMessage(state, blindRead, failed, stderr, code) {
152
+ if (blindRead !== undefined) {
153
+ return `read ${blindRead}, but ${state.config.model ?? "the model"} cannot read images`;
154
+ }
155
+ if (state.aborted())
156
+ return "stopped by tumwater shutdown";
157
+ if (state.wd.quietKilled) {
158
+ return `killed as hung: no agent progress for over ${state.config.quietTimeoutSeconds}s`;
159
+ }
160
+ if (state.wd.timedOut) {
161
+ return state.wd.timedOutProgressing
162
+ ? `timed out after ${state.wd.timeoutBudgetMs / 1000}s while still making progress — session and worktree edits preserved for resume`
163
+ : `timed out after ${state.wd.timeoutBudgetMs / 1000}s`;
164
+ }
165
+ return (state.parser.errorMessage ??
166
+ (failed ? stderr.trim().slice(-500) || `the agent exited ${code}` : undefined));
167
+ }
147
168
  /** The run's `close` event: the process died and all its streams closed. Flush what the
148
169
  * decoders held back, reconcile the backend's post-run work, classify the exit cause, and
149
170
  * settle the run's result. */
@@ -182,20 +203,8 @@ export function settleClose(state, code) {
182
203
  cls.transientPiCrash !== true &&
183
204
  stderrBackend === undefined &&
184
205
  (state.parser.configError || cls.configError === true);
185
- // The blind-read failure names both the file and the model (plans/image-input.md
186
- // "Failing the run"): take it over any generic exit text so lastError is actionable.
187
206
  const blindRead = state.blindRead();
188
- const errorMessage = blindRead !== undefined
189
- ? `read ${blindRead}, but ${state.config.model ?? "the model"} cannot read images`
190
- : state.aborted()
191
- ? "stopped by tumwater shutdown"
192
- : state.wd.quietKilled
193
- ? `killed as hung: no agent progress for over ${state.config.quietTimeoutSeconds}s`
194
- : state.wd.timedOut
195
- ? state.wd.timedOutProgressing
196
- ? `timed out after ${state.wd.timeoutBudgetMs / 1000}s while still making progress — session and worktree edits preserved for resume`
197
- : `timed out after ${state.wd.timeoutBudgetMs / 1000}s`
198
- : (state.parser.errorMessage ?? (failed ? stderr.trim().slice(-500) || `the agent exited ${code}` : undefined));
207
+ const errorMessage = settleErrorMessage(state, blindRead, failed, stderr, code);
199
208
  state.finish(resultFromParser(state, {
200
209
  ok: !failed,
201
210
  transientPiCrash: cls.transientPiCrash === true,
@@ -15,7 +15,7 @@ import { ensureParentDir, rotateIfLarge } from "../files/files.js";
15
15
  import { terminateChild } from "../process/process.js";
16
16
  import { makeRunMarker } from "../process/run-marker.js";
17
17
  import { PiStreamParser } from "../pi/pi-stream.js";
18
- import { recordSession } from "./session-ledger.js";
18
+ import { recordSession, sessionRecordFor } from "./session-ledger.js";
19
19
  import { errorMessage } from "../text/text.js";
20
20
  import { warnEventBestEffort, TUMWATER_SOURCE } from "../events/events.js";
21
21
  import { seamImageInput } from "./image-input.js";
@@ -44,15 +44,7 @@ function buildAndSpawn(backend, opts, pre) {
44
44
  // session of its own kind instead of whatever file is newest in the dir. The id is the backend's
45
45
  // own when it mints one ahead of the run (claude's UUID, or the resumed record's id); pi mints
46
46
  // none and keeps the session name.
47
- recordSession(opts.sessionDir, {
48
- agent: backend.id,
49
- id: preSpawnSessionId,
50
- kind: opts.kind,
51
- name: opts.sessionName,
52
- cwd: opts.cwd,
53
- ...(opts.config.model !== undefined ? { model: opts.config.model } : {}),
54
- at: Date.now(),
55
- });
47
+ recordSession(opts.sessionDir, sessionRecordFor(backend.id, preSpawnSessionId, opts));
56
48
  const translator = backend.translator();
57
49
  // Decode stdout incrementally instead of per chunk: a raw Buffer.toString("utf8") replaces any
58
50
  // multi-byte character whose bytes straddle two 'data' events with U+FFFD, corrupting that
@@ -146,8 +138,7 @@ export function spawnPiRun(backend, logs, runTmp, opts) {
146
138
  }
147
139
  const parser = new PiStreamParser(opts.onToolCallStart, {
148
140
  // The seam's effective image input (src/agent/image-input.ts): a text-only seam makes every
149
- // image read blind even when pi adds no note — a claim its doctor probe proved false — which
150
- // the parser checks against its own note rule.
141
+ // image read blind even when pi adds no note — the parser checks against its own note rule.
151
142
  imageInput: seamImageInput(opts.config, readPiProviders(piModelsPath())),
152
143
  onBlindRead: (path) => {
153
144
  // Kill an authoring run at once, before the agent goes on working from a picture it never
@@ -9,7 +9,7 @@
9
9
  * `error`/`exit`/`close` listeners itself. This module imports only run-agent's, run-spawn's
10
10
  * and run-settle's type surfaces, so there is no runtime import cycle. */
11
11
  import { removeTree } from "../files/files.js";
12
- import { recordSession } from "./session-ledger.js";
12
+ import { recordSession, sessionRecordFor } from "./session-ledger.js";
13
13
  /** How many trailing stderr characters are carried into the next chunk's warning match, so a
14
14
  * warning split across two `data` events is still seen. The warnings are short lines, so a few
15
15
  * hundred characters always cover a straddle. */
@@ -60,15 +60,7 @@ export function createAgentStream(input) {
60
60
  const sid = translator.sessionId?.();
61
61
  if (sid !== undefined && sid !== preSpawnSessionId && !streamMintedSession) {
62
62
  streamMintedSession = true;
63
- recordSession(opts.sessionDir, {
64
- agent: backend.id,
65
- id: sid,
66
- kind: opts.kind,
67
- name: opts.sessionName,
68
- cwd: opts.cwd,
69
- ...(opts.config.model !== undefined ? { model: opts.config.model } : {}),
70
- at: Date.now(),
71
- });
63
+ recordSession(opts.sessionDir, sessionRecordFor(backend.id, sid, opts));
72
64
  }
73
65
  };
74
66
  // Split native stdout into complete lines, translate each, and feed the canonical lines to
@@ -38,6 +38,22 @@ export function recordSession(dir, record) {
38
38
  // Best effort: a lost ledger line costs resumability, not the run.
39
39
  }
40
40
  }
41
+ /** The one run's ledger record, minus the id the two recording sites mint differently — the
42
+ * pre-spawn id in run-spawn.ts, or the stream-minted session id in run-stream.ts. One place
43
+ * for the record's non-id shape (agent, kind, name, cwd, the optional model and the stamp), so
44
+ * the two appends cannot drift on the model spread or the required fields. `agent` is the
45
+ * owning backend's id; the run options supply the rest. */
46
+ export function sessionRecordFor(agent, id, opts) {
47
+ return {
48
+ agent,
49
+ id,
50
+ kind: opts.kind,
51
+ name: opts.sessionName,
52
+ cwd: opts.cwd,
53
+ ...(opts.config.model !== undefined ? { model: opts.config.model } : {}),
54
+ at: Date.now(),
55
+ };
56
+ }
41
57
  /** True when `dir` holds at least one session file a backend could resume by directory. This is
42
58
  * the backend-neutral filesystem check behind pi's `--continue` and the fallback for an
43
59
  * unregistered backend. The ledger file shares the dir and is a `.jsonl` too, so it is
@@ -30,11 +30,9 @@ import { commitAuthorArgs, committerIdentArgs, git, gitTry } from "../git/git-ru
30
30
  import { mergeLockDir } from "../paths.js";
31
31
  import { plural } from "../text/phrases.js";
32
32
  import { collapseWhitespace, errorFirstLine, errorMessage } from "../text/text.js";
33
- import { FINISHED_ARCHIVES, fenceTracker, fencedHeadingTitle, parseEntryDetails } from "./backlog-md.js";
33
+ import { FINISHED_ARCHIVES, fenceTracker, fencedHeadingTitle } from "./backlog-md.js";
34
34
  import { actionableEntryRanges } from "./backlog-index-data.js";
35
35
  import { strandedPlanEntries } from "./backlog-structure.js";
36
- import { appendBugEntryLocked } from "./backlog-write.js";
37
- import { formatDate } from "../text/datetime.js";
38
36
  /** The placeholder a role leaves under an emptied section: never archived content. */
39
37
  const PLACEHOLDER = /^_None yet\._$/;
40
38
  /** The text a new archive file starts with: a title, how to read the file, and the section
@@ -133,6 +131,57 @@ function entryBlocks(lines, entries) {
133
131
  }
134
132
  return out;
135
133
  }
134
+ /** The selected entries of `entries`: the keep slice, or the baseKeys/park filter. */
135
+ function selectArchiveEntries(entries, select) {
136
+ return "keep" in select
137
+ ? entries.slice(Math.max(0, select.keep))
138
+ : entries.filter((e) => !select.baseKeys.has(archiveKey(e.title)) && !(select.park?.has(archiveKey(e.title)) ?? false));
139
+ }
140
+ /** Add the 0-based live lines of `entries` to `into`. */
141
+ function addEntryLines(entries, into) {
142
+ for (const e of entries)
143
+ for (let i = e.start - 1; i < e.end; i++)
144
+ into.add(i);
145
+ }
146
+ /** The bulk-archive split: the tail past the kept entries moves but for the dropped and staying
147
+ * entries, and a `_None yet._` placeholder stays in the live section, never in the archive.
148
+ * null when no entry sits past the kept ones. */
149
+ function splitByKeep(lines, span, entries, keep, staying, droppedLine) {
150
+ const from = keep <= 0 ? span.heading + 1 : (entries[keep]?.start ?? 0) - 1;
151
+ if (from < 0)
152
+ return null; // no entry past the kept ones
153
+ // The tail moves but for the dropped and staying entries; a `_None yet._` placeholder stays
154
+ // in the live section, never in the archive. Staying entries follow the kept ones.
155
+ const stayLine = new Set();
156
+ addEntryLines(staying, stayLine);
157
+ const tail = [];
158
+ for (let i = from; i < span.end; i++)
159
+ if (!droppedLine.has(i) && !stayLine.has(i))
160
+ tail.push(lines[i] ?? "");
161
+ const moved = trimBlank(tail.filter((l) => !PLACEHOLDER.test(l.trim())));
162
+ const stayingBlocks = entryBlocks(lines, staying);
163
+ const head = keep <= 0 ? [] : trimBlank(lines.slice(span.heading + 1, from));
164
+ const kept = head.length > 0 && stayingBlocks.length > 0 ? [...head, "", ...stayingBlocks] : [...head, ...stayingBlocks];
165
+ return {
166
+ kept: kept.length === 0 ? tail.filter((l) => PLACEHOLDER.test(l.trim())).slice(0, 1) : kept,
167
+ moved,
168
+ };
169
+ }
170
+ /** The commit-time-sweep split: the kept body drops the moved and dropped lines, refusing when
171
+ * no archive exists and other content would stay in the live section. */
172
+ function splitByBaseKeys(lines, span, archiveMd, moving, droppedLine) {
173
+ const leaves = new Set(droppedLine);
174
+ addEntryLines(moving, leaves);
175
+ let kept = [];
176
+ for (let i = span.heading + 1; i < span.end; i++)
177
+ if (!leaves.has(i))
178
+ kept.push(lines[i] ?? "");
179
+ kept = trimBlank(kept);
180
+ if (archiveMd === null && kept.some((l) => l.trim() !== "" && !PLACEHOLDER.test(l.trim())))
181
+ return null;
182
+ const moved = entryBlocks(lines, moving);
183
+ return { kept, moved };
184
+ }
136
185
  /** Move the selected part of `liveMd`'s finished section to the top of `archiveMd`'s (a new
137
186
  * archive starts from archiveTemplate), keeping the moved lines' order. A selected entry whose
138
187
  * heading the archive already holds never moves (EntryFate): with the same text it is dropped
@@ -147,9 +196,7 @@ export function archiveFinishedText(liveMd, archiveMd, spec, select) {
147
196
  if (span === null)
148
197
  return null;
149
198
  const entries = entriesIn(liveMd, span, spec.section);
150
- const selected = "keep" in select
151
- ? entries.slice(Math.max(0, select.keep))
152
- : entries.filter((e) => !select.baseKeys.has(archiveKey(e.title)) && !(select.park?.has(archiveKey(e.title)) ?? false));
199
+ const selected = selectArchiveEntries(entries, select);
153
200
  if (!("keep" in select) && selected.length === 0)
154
201
  return null;
155
202
  const archiveText = archiveMd ?? archiveTemplate(spec);
@@ -161,46 +208,14 @@ export function archiveFinishedText(liveMd, archiveMd, spec, select) {
161
208
  const withFate = (fate) => selected.filter((_, i) => fates[i] === fate);
162
209
  // The 0-based live lines of the entries that leave the live section without moving.
163
210
  const droppedLine = new Set();
164
- for (const e of withFate("drop"))
165
- for (let i = e.start - 1; i < e.end; i++)
166
- droppedLine.add(i);
167
- let moved;
168
- let kept;
169
- if ("keep" in select) {
170
- const from = select.keep <= 0 ? span.heading + 1 : (entries[select.keep]?.start ?? 0) - 1;
171
- if (from < 0)
172
- return null; // no entry past the kept ones
173
- // The tail moves but for the dropped and staying entries; a `_None yet._` placeholder stays
174
- // in the live section, never in the archive. Staying entries follow the kept ones.
175
- const stayLine = new Set();
176
- for (const e of withFate("stay"))
177
- for (let i = e.start - 1; i < e.end; i++)
178
- stayLine.add(i);
179
- const tail = [];
180
- for (let i = from; i < span.end; i++)
181
- if (!droppedLine.has(i) && !stayLine.has(i))
182
- tail.push(lines[i] ?? "");
183
- moved = trimBlank(tail.filter((l) => !PLACEHOLDER.test(l.trim())));
184
- const staying = entryBlocks(lines, withFate("stay"));
185
- const head = select.keep <= 0 ? [] : trimBlank(lines.slice(span.heading + 1, from));
186
- kept = head.length > 0 && staying.length > 0 ? [...head, "", ...staying] : [...head, ...staying];
187
- if (kept.length === 0)
188
- kept = tail.filter((l) => PLACEHOLDER.test(l.trim())).slice(0, 1);
189
- }
190
- else {
191
- const leaves = new Set(droppedLine);
192
- for (const e of withFate("move"))
193
- for (let i = e.start - 1; i < e.end; i++)
194
- leaves.add(i);
195
- kept = [];
196
- for (let i = span.heading + 1; i < span.end; i++)
197
- if (!leaves.has(i))
198
- kept.push(lines[i] ?? "");
199
- kept = trimBlank(kept);
200
- if (archiveMd === null && kept.some((l) => l.trim() !== "" && !PLACEHOLDER.test(l.trim())))
201
- return null;
202
- moved = entryBlocks(lines, withFate("move"));
203
- }
211
+ addEntryLines(withFate("drop"), droppedLine);
212
+ const split = "keep" in select
213
+ ? splitByKeep(lines, span, entries, select.keep, withFate("stay"), droppedLine)
214
+ : splitByBaseKeys(lines, span, archiveMd, withFate("move"), droppedLine);
215
+ if (split === null)
216
+ return null;
217
+ const moved = split.moved;
218
+ const kept = split.kept;
204
219
  const dropped = withFate("drop").map((e) => e.title);
205
220
  if (moved.length === 0 && dropped.length === 0)
206
221
  return null;
@@ -327,9 +342,9 @@ function planOrSkip(root, keep) {
327
342
  }
328
343
  }
329
344
  /** If the primary checkout is not on the base branch, the reason a main-side write should be
330
- * skipped, else null. Both `archiveFinishedOnMain` and `fileBugsOnMain` guard on this before
331
- * committing a backlog file. */
332
- async function primaryOffBaseBranchReason(root) {
345
+ * skipped, else null. Both `archiveFinishedOnMain` and `fileBugsOnMain` (backlog-file-bugs.ts)
346
+ * guard on this before committing a backlog file. */
347
+ export async function primaryOffBaseBranchReason(root) {
333
348
  const primary = await currentBranch(root);
334
349
  const base = targetBranch(loadConfigSafe(root).config?.baseBranch, primary);
335
350
  if (primary !== base) {
@@ -421,119 +436,3 @@ export async function archiveAtTeamStart(root) {
421
436
  warnEventBestEffort(root, TUMWATER_SOURCE, `finished backlog entries were not archived: ${errorMessage(err)}`);
422
437
  }
423
438
  }
424
- /** The start of a quarantine entry's title for `test`, up to the date. appendBugEntryLocked
425
- * collapses whitespace in the title, so the test part is collapsed the same way; collapsing the
426
- * whole prefix would trim its trailing space. */
427
- function quarantineTitlePrefix(test) {
428
- return collapseWhitespace(`\`${test}\` failed, then passed on the same tree`) + " (quarantined by tumwater ";
429
- }
430
- /** Output fenced into a bug body: a fence of its own length cannot be closed by the quoted text. */
431
- function fencedTail(tail) {
432
- return ["~~~~", ...tail.replaceAll("~~~~", "~~~").split("\n").slice(-30), "~~~~"].join("\n");
433
- }
434
- /** File one BUGS.md `## Open` entry per given entry and commit BUGS.md on the base branch as one
435
- * `tumwater(operator):` commit (the subject built from the number of entries actually appended is
436
- * stamped under the operator role), under the merge lock and the primary-worktree lock exactly as
437
- * archiveFinishedOnMain does. Skipped,
438
- * writing nothing, when the primary checkout is off the base branch, BUGS.md has uncommitted
439
- * changes, or BUGS.md exists but cannot be read; a failed append or commit restores BUGS.md.
440
- * Every entry is appended verbatim as its own `## Open` heading — unless its `ifOpen` dedupe
441
- * matches an existing heading, which is reused instead — and the returned headings are the
442
- * entry headings in input order. */
443
- export async function fileBugsOnMain(root, entries, subject, opts = {}) {
444
- return withLock(mergeLockDir(root), () => withPrimaryWorktreeLockAsync(root, async () => {
445
- const offBase = await primaryOffBaseBranchReason(root);
446
- if (offBase !== null)
447
- return { status: "skipped", reason: offBase };
448
- const status = await gitTry(root, "status", "--porcelain", "--", "BUGS.md");
449
- if (status === null)
450
- return { status: "skipped", reason: "could not read git status" };
451
- if (status.trim() !== "" && !status.startsWith("??")) {
452
- return { status: "skipped", reason: "BUGS.md has uncommitted changes" };
453
- }
454
- const file = path.join(root, "BUGS.md");
455
- let before;
456
- try {
457
- before = readTextIfExists(file);
458
- }
459
- catch (err) {
460
- return { status: "skipped", reason: `BUGS.md could not be read: ${errorFirstLine(err)}` };
461
- }
462
- const open = before === null ? [] : parseEntryDetails(before, "Open").map((e) => e.title);
463
- const headings = [];
464
- let appended = 0;
465
- const restoreBugs = async () => {
466
- await gitTry(root, "reset", "-q", "--", "BUGS.md");
467
- if (before === null)
468
- fs.rmSync(file, { force: true });
469
- else
470
- writeTextAtomic(file, before);
471
- };
472
- try {
473
- for (const entry of entries) {
474
- if (entry.ifOpen !== undefined) {
475
- const existing = entry.ifOpen(open);
476
- if (existing !== undefined) {
477
- headings.push(existing);
478
- continue;
479
- }
480
- }
481
- headings.push(appendBugEntryLocked(root, entry.title, entry.body));
482
- appended++;
483
- }
484
- }
485
- catch (err) {
486
- await restoreBugs();
487
- return { status: "skipped", reason: `a BUGS.md entry could not be written: ${errorFirstLine(err)}` };
488
- }
489
- if (appended === 0)
490
- return { status: "filed", headings };
491
- const commitSubject = stampedSubject("operator", // vocabulary-ok: the tumwater(operator): commit prefix is a machine string
492
- subject(appended));
493
- try {
494
- await git(root, "add", "--", "BUGS.md");
495
- await git(root, ...(await committerIdentArgs(root)), "commit", ...commitAuthorArgs(), "--only", "-m", commitSubject, "--", "BUGS.md");
496
- }
497
- catch (err) {
498
- await restoreBugs();
499
- return { status: "skipped", reason: `the commit failed: ${errorFirstLine(err)}` };
500
- }
501
- return { status: "filed", headings };
502
- }), opts.lockTimeoutMs);
503
- }
504
- /** File one BUGS.md `## Open` entry per newly quarantined test and commit BUGS.md on the base
505
- * branch as one `tumwater(operator):` commit, delegating the locked write/commit to
506
- * fileBugsOnMain and preserving the existing subject. A test whose quarantine entry is already
507
- * under `## Open` (a filing whose markQuarantineFiled failed after the commit) gets that heading
508
- * back and is not filed again — the dedupe runs inside fileBugsOnMain under the merge lock (via
509
- * each entry's `ifOpen`), so concurrent quarantine filings cannot both append a duplicate, and
510
- * the real skip on a dirty or off-base BUGS.md is enforced under the same lock. */
511
- export async function fileQuarantineBugsOnMain(root, tests, opts = {}) {
512
- const date = formatDate(new Date());
513
- const toFile = [];
514
- for (const { test, tail } of tests) {
515
- const prefix = quarantineTitlePrefix(test);
516
- const body = [
517
- `The check failed on this test and then passed on a re-run of the same tree, so tumwater quarantined it: it counts as a known failure until this entry leaves \`## Open\`. Find why it is unstable and fix it.`,
518
- ...(tail ? ["", fencedTail(tail)] : []),
519
- ].join("\n");
520
- toFile.push({
521
- title: `${prefix}${date})`,
522
- body,
523
- // Dedupe under the lock, against the headings fileBugsOnMain reads: the reused heading
524
- // (not a duplicate) is returned in headings and zipped back to this test below.
525
- ifOpen: (open) => open.find((t) => t.startsWith(prefix)),
526
- });
527
- }
528
- const outcome = await fileBugsOnMain(root, toFile,
529
- // Size the subject by what was actually appended: tests whose `## Open` entry was already
530
- // present are reused, not filed, so a mixed batch keeps the original "File N …" wording
531
- // with N the count of new entries only.
532
- (appended) => `File ${plural(appended, "quarantined flaky test", "quarantined flaky tests")} in BUGS.md`, opts);
533
- if (outcome.status === "skipped")
534
- return { status: "skipped", reason: outcome.reason };
535
- // fileBugsOnMain's headings align with `tests` in order (reused headings or appended titles).
536
- const bugs = new Map();
537
- outcome.headings.forEach((heading, i) => bugs.set(tests[i].test, heading));
538
- return { status: "filed", bugs };
539
- }