amicus 4.4.0 → 4.4.1

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 (75) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +32 -0
  3. package/README.md +3 -1
  4. package/docs/DISTRIBUTION.md +234 -0
  5. package/docs/ROADMAP.md +200 -0
  6. package/docs/SHIMS.md +62 -0
  7. package/docs/architecture.md +104 -0
  8. package/docs/configuration.md +371 -0
  9. package/docs/council.md +911 -0
  10. package/docs/doc-system.md +92 -0
  11. package/docs/electron-testing.md +471 -0
  12. package/docs/jsdoc-setup.md +75 -0
  13. package/docs/opencode-integration.md +114 -0
  14. package/docs/publishing.md +60 -0
  15. package/docs/schemas.md +55 -0
  16. package/docs/testing.md +589 -0
  17. package/docs/troubleshooting.md +298 -0
  18. package/docs/usage.md +699 -0
  19. package/electron/fold.js +1 -1
  20. package/electron/main.js +4 -1
  21. package/electron/setup-ui-aliases.js +6 -6
  22. package/electron/workspace-ui/live-model.js +12 -1
  23. package/electron/workspace-ui/md-lite.js +52 -8
  24. package/electron/workspace-ui/workspace-matrix.js +46 -9
  25. package/electron/workspace-ui/workspace-panels.js +14 -3
  26. package/electron/workspace-ui/workspace-render.js +7 -1
  27. package/electron/workspace-ui/workspace-verbs.js +48 -2
  28. package/package.json +8 -3
  29. package/schemas/council-run.schema.json +20 -0
  30. package/schemas/progress.schema.json +12 -0
  31. package/schemas/spend.schema.json +52 -4
  32. package/src/cli-handlers-spend.js +20 -2
  33. package/src/cli-handlers-watch.js +11 -0
  34. package/src/cli.js +4 -2
  35. package/src/council/briefings-debate.js +27 -7
  36. package/src/council/briefings-stage2.js +155 -25
  37. package/src/council/briefings.js +24 -1
  38. package/src/council/findings.js +236 -9
  39. package/src/council/parse-stage2.js +10 -2
  40. package/src/council/report.js +19 -8
  41. package/src/council/run-assemble.js +42 -1
  42. package/src/council/run-budget.js +64 -11
  43. package/src/council/run-chair.js +4 -1
  44. package/src/council/run-debate.js +4 -2
  45. package/src/council/run-finalize.js +102 -0
  46. package/src/council/run-launch.js +29 -1
  47. package/src/council/run-server.js +248 -0
  48. package/src/council/run-stage2.js +118 -0
  49. package/src/council/run-stages.js +132 -111
  50. package/src/council/run-state.js +23 -1
  51. package/src/council/run.js +44 -46
  52. package/src/council/tally.js +10 -0
  53. package/src/headless.js +175 -6
  54. package/src/observe/council-legs.js +60 -3
  55. package/src/observe/live-doc.js +18 -1
  56. package/src/observe/watch-render.js +4 -1
  57. package/src/sidecar/child-sessions.js +1 -2
  58. package/src/sidecar/fanout-leg-fallback.js +69 -21
  59. package/src/sidecar/fanout-leg.js +6 -0
  60. package/src/sidecar/fanout-signals.js +61 -0
  61. package/src/sidecar/fanout-wave-io.js +75 -0
  62. package/src/sidecar/fanout.js +61 -70
  63. package/src/sidecar/progress-fields.js +26 -4
  64. package/src/sidecar/progress.js +8 -1
  65. package/src/sidecar/session-utils.js +23 -14
  66. package/src/spend-query.js +17 -5
  67. package/src/utils/lifecycle.js +37 -1
  68. package/src/utils/path-fence.js +39 -1
  69. package/src/utils/pricing.js +26 -10
  70. package/src/utils/server-setup.js +79 -1
  71. package/src/utils/spend-ledger.js +24 -3
  72. package/src/workspace/artifact-guard.js +22 -1
  73. package/src/workspace/fold-format.js +33 -4
  74. package/src/workspace/live-normalize.js +28 -15
  75. package/src/workspace/run-detail.js +7 -1
@@ -75,10 +75,88 @@ function ensurePortAvailable(port = DEFAULT_PORT) {
75
75
  return false;
76
76
  }
77
77
 
78
+ /**
79
+ * A start failure that is a LOCK RACE, not a deterministic error.
80
+ *
81
+ * OpenCode opens one shared SQLite database (~/.local/share/opencode/opencode.db)
82
+ * at startup, so two servers starting in the same instant can collide on it and
83
+ * the loser exits 1 with `database is locked`. Measured: council run v441plan01
84
+ * lost four of five seats in 736ms to exactly this.
85
+ *
86
+ * Deliberately NARROW. A missing binary, a bad key, a busy port and a config
87
+ * error are all deterministic — retrying them only triples the latency before
88
+ * the same failure, so they must fall straight through untouched.
89
+ */
90
+ const LOCK_CLASS_START_FAILURE = /database is locked|database table is locked|SQLITE_BUSY/i;
91
+
92
+ /**
93
+ * Backoff between start attempts; 5 attempts total, ≤3.75s of added latency.
94
+ *
95
+ * ⚠️ WIDENED from 3 attempts at 250/500ms (≤750ms) by v4.4.1 Step 10.5. 750ms is
96
+ * thin against a multi-megabyte WAL and any concurrent process touching the same
97
+ * OpenCode database: run v441plan02 exhausted it, the council degraded to one
98
+ * server per wave, and then lost four of five seats to the very race the retry
99
+ * exists to survive. The wait is bounded and paid at most once per acquisition;
100
+ * the cost of not waiting is a dead bench.
101
+ *
102
+ * Still lock-class ONLY — a deterministic failure (missing binary, bad key, busy
103
+ * port, config error) never sleeps a single millisecond here.
104
+ */
105
+ const LOCK_RETRY_DELAYS_MS = [250, 500, 1000, 2000];
106
+
107
+ /**
108
+ * @param {Error|null} error
109
+ * @returns {boolean} true only for a lock-class (retryable) start failure
110
+ */
111
+ function isLockClassStartFailure(error) {
112
+ if (!error) { return false; }
113
+ // The real failure arrives as a message with the server's own stdout inlined
114
+ // ("Server exited with code 1 / Server output: … database is locked"), but
115
+ // check the usual carriers too so a wrapped/spawn-shaped error still matches.
116
+ const carriers = [error.message, error.stderr, error.stdout, error.cause && error.cause.message];
117
+ return carriers.some(c => typeof c === 'string' && LOCK_CLASS_START_FAILURE.test(c));
118
+ }
119
+
120
+ /**
121
+ * Run `attempt` with a BOUNDED retry on a lock-class failure only.
122
+ *
123
+ * Never fails closed: the final failure is rethrown unchanged, so every caller
124
+ * that already degrades on a start failure (runFanout writes an error wave; a
125
+ * council run falls back to per-wave servers) degrades exactly as before —
126
+ * just later, and far less often. This is the half of the fix that covers what
127
+ * a per-run shared server cannot: two separate `amicus` processes, or a CLI run
128
+ * beside a live MCP server, still contend for the same database.
129
+ *
130
+ * @param {(attempt: number) => Promise<T>} attempt
131
+ * @param {{retryDelayMs?: number}} [opts] retryDelayMs: test seam — collapses
132
+ * every backoff to this value so a retry test does not sleep for real.
133
+ * @returns {Promise<T>}
134
+ * @template T
135
+ */
136
+ async function retryOnLockRace(attempt, opts = {}) {
137
+ const delays = opts.retryDelayMs === undefined
138
+ ? LOCK_RETRY_DELAYS_MS
139
+ : LOCK_RETRY_DELAYS_MS.map(() => opts.retryDelayMs);
140
+ for (let i = 0; ; i += 1) {
141
+ try {
142
+ return await attempt(i);
143
+ } catch (error) {
144
+ if (i >= delays.length || !isLockClassStartFailure(error)) { throw error; }
145
+ logger.warn('OpenCode server start lost a lock race — retrying', {
146
+ attempt: i + 1, of: delays.length + 1, delayMs: delays[i], error: error.message,
147
+ });
148
+ await new Promise(resolve => setTimeout(resolve, delays[i]));
149
+ }
150
+ }
151
+ }
152
+
78
153
  module.exports = {
79
154
  DEFAULT_PORT,
155
+ LOCK_RETRY_DELAYS_MS,
80
156
  isPortInUse,
81
157
  getPortPid,
82
158
  killPortProcess,
83
- ensurePortAvailable
159
+ ensurePortAvailable,
160
+ isLockClassStartFailure,
161
+ retryOnLockRace
84
162
  };
@@ -45,7 +45,10 @@ const SPEND_LEDGER_FILE = 'spend-ledger.jsonl';
45
45
  * @param {string} [opts.waveId] present for a fanout leg
46
46
  * @param {string} opts.model resolved model id (or alias, if that's all the caller has)
47
47
  * @param {'headless'|'interactive'|'leg'} opts.mode
48
- * @param {{tokens:object, cost:{amount:number|null,currency:string,source:string}}|null} opts.usage
48
+ * @param {{tokens:object, cost:{amount:number|null,currency:string,source:string},
49
+ * subtreeUnknown?:boolean}|null} opts.usage `subtreeUnknown` (v4.4.1 CA-2) is
50
+ * copied onto the row when truthy — this leg's own cost resolved, but a child
51
+ * session it spawned could not be priced, so the row's `cost` is a FLOOR
49
52
  * @param {string} [opts.op] 'leg' | 'start' | 'continue' | 'resume'
50
53
  * @param {string} [opts.status] terminal status
51
54
  * @param {string} [opts.councilRunId] council run id (additive attribution)
@@ -86,18 +89,36 @@ function appendSpend({ taskId, waveId, model, mode, usage,
86
89
  if (attempt !== undefined) { row.attempt = attempt; }
87
90
  if (substitutedFor !== undefined) { row.substitutedFor = substitutedFor; }
88
91
  if (retryOfWaveId !== undefined) { row.retryOfWaveId = retryOfWaveId; }
92
+ // v4.4.1 CA-2: a leg whose OWN cost is known but which spawned a child
93
+ // session the walk could not price writes a PRICED row — so `unpricedRows`
94
+ // never catches it and `amicus spend` reads as a complete measurement while
95
+ // `council run` says `costExact:false` about the same dollars. Omitted (not
96
+ // `|| false`) so a pre-4.4.1 row and an ordinary row stay identical, matching
97
+ // the linkage-field convention above.
98
+ if (usage.subtreeUnknown) { row.subtreeUnknown = true; }
89
99
  fs.appendFileSync(path.join(dir, SPEND_LEDGER_FILE), JSON.stringify(row) + '\n');
90
100
  } catch (e) {
91
101
  logger.debug('spend-ledger append failed (best-effort, run unaffected)', { taskId, error: e.message });
92
102
  }
93
103
  }
94
104
 
95
- /** @param {string} [dir] @returns {Array<object>} parsed rows; corrupt lines skipped */
105
+ /**
106
+ * Read the ledger. Corrupt lines are skipped — and v4.4.1 A2 widens "corrupt"
107
+ * from "does not parse" to "does not parse AS A ROW". A line that is valid JSON
108
+ * but not a plain object (`"foo"`, `42`, `[1,2]`) used to survive `filter(Boolean)`
109
+ * and be treated as a row by every consumer: `aggregateSpend` counted it in
110
+ * `runs`, scored it into `unpricedRows`/`sourceMix.unknown` off its absent cost
111
+ * block, and `--rows` echoed it into a published document that says rows are
112
+ * objects. A scalar in a JSONL ledger of row objects is a corrupt line, so it is
113
+ * now dropped like any other — one fewer way the totals can be inflated by damage.
114
+ * @param {string} [dir] @returns {Array<object>} parsed rows; corrupt lines skipped
115
+ */
96
116
  function readSpendRows(dir) {
97
117
  const file = path.join(dir || getConfigDir(), SPEND_LEDGER_FILE);
98
118
  if (!fs.existsSync(file)) { return []; }
99
119
  return fs.readFileSync(file, 'utf-8').split('\n').map(l => l.trim()).filter(Boolean)
100
- .map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
120
+ .map(l => { try { return JSON.parse(l); } catch { return null; } })
121
+ .filter(r => r !== null && typeof r === 'object' && !Array.isArray(r));
101
122
  }
102
123
 
103
124
  module.exports = { appendSpend, readSpendRows, SPEND_LEDGER_FILE, SPEND_LEDGER_SCHEMA_VERSION };
@@ -24,6 +24,9 @@ const FIXED_ARTIFACTS = Object.freeze(['briefing-stage1.md', 'bundle-stage2.md',
24
24
  // `artifact not allowed: <name>`. Writers: tally-provisional.json = src/council/run.js:199;
25
25
  // revote-bundle.md = run-debate.js:119; debate.json = run-debate.js:261; the per-seat
26
26
  // rebuttal-/revote- pair = materializeDebate (run-launch.js:127-136).
27
+ // ⚠️ FIVE KINDS, THREE ENTRIES — that is not a miscount (v4.4.1 DOC-7, re-verified). This const
28
+ // holds only the three RUN-LEVEL names; the last two of the five, the rebuttal-/revote- pair, are
29
+ // per BENCH MODEL and are appended inside artifactAllowlist below, next to review-/judge-.
27
30
  const DEBATE_ARTIFACTS = Object.freeze(['tally-provisional.json', 'revote-bundle.md', 'debate.json']);
28
31
  const MAX_ARTIFACT_BYTES = 200 * 1024;
29
32
 
@@ -167,7 +170,25 @@ function readRunArtifact(project, runId, name, deps = {}) {
167
170
 
168
171
  let realTarget;
169
172
  try { realTarget = realpathSync(path.join(ptr.runDir, name)); }
170
- catch { return { error: `not written yet: ${name}` }; }
173
+ catch (err) {
174
+ // ⚠️ v4.4.1 RN-10: this catch used to answer `not written yet: <name>` for ANY realpath
175
+ // failure — ENOENT, EACCES, EPERM, EIO, ELOOP, a dangling symlink — so a permission problem
176
+ // was indistinguishable from a file the council simply has not produced yet. That is not a
177
+ // cosmetic conflation: electron/ipc-workspace.js's workspace:fold reads chair-output.md
178
+ // through this function, and on a permission error it produced a silent CHAIRLESS fold that
179
+ // still reported {ok: true}. The logger.warn it now emits was the mitigation — but it logged
180
+ // this string, so the log said "not written yet" about a file that was right there.
181
+ //
182
+ // ⚠️ Keep the sanitization. Do NOT re-interpolate `err.message`: a realpath failure's message
183
+ // embeds the full resolved path it tried to open, which round 4 deliberately stopped handing
184
+ // back over IPC (see the run.json catch above). `err.code` is a bare symbolic errno with no
185
+ // path in it, and it is the one piece an operator reading the fold warning actually needs —
186
+ // whitelisted to the errno character class so nothing else can ever ride out through here.
187
+ const code = err && typeof err.code === 'string' && /^[A-Z][A-Z0-9_]{1,15}$/.test(err.code)
188
+ ? err.code : 'unknown';
189
+ if (code === 'ENOENT') { return { error: `not written yet: ${name}` }; }
190
+ return { error: `artifact unreadable (${code}): ${name}` };
191
+ }
171
192
  if (!isRealpathContained(realDir, realTarget)) {
172
193
  return { error: 'artifact escapes run directory' };
173
194
  }
@@ -2,9 +2,16 @@
2
2
  * Council Workspace — fold payload builder (v4.4 §7).
3
3
  *
4
4
  * MIRRORS the shipped fold-header builder `formatFoldOutput`
5
- * (src/headless.js:775-789, exported at :797, re-exported from src/index.js:20)
6
- * — byte-for-byte the same 8-line head. src/headless.js is the SOURCE OF
7
- * TRUTH; keep the head in sync with it. The duplication is deliberate:
5
+ * (src/headless.js `formatFoldOutput`, exported from src/headless.js and
6
+ * re-exported from src/index.js) — byte-for-byte the same **7-line** head:
7
+ * marker, Model, Session, Client, CWD, Mode, `---`. ⚠️ v4.4.1 DOC-5: this said
8
+ * "8-line" for two releases. Line 8 (`VERDICT:`) is council's OWN addition and
9
+ * has no counterpart in formatFoldOutput, whose 8th element is the summary
10
+ * body. Only the first 7 lines are the shared contract; anyone changing the
11
+ * shared format must sync those and leave `VERDICT:` alone.
12
+ * src/headless.js is the SOURCE OF TRUTH; keep the head in sync with it.
13
+ * (Line numbers deliberately omitted — the previous `:775-789`/`:797` citation
14
+ * had drifted by ~500 lines.) The duplication is deliberate:
8
15
  * requiring headless.js transitively pulls opencode-client / progress /
9
16
  * conversation-mirror at require time (src/headless.js:8-17), which
10
17
  * src/workspace/ must stay free of.
@@ -79,7 +86,29 @@ function buildFoldText(o) {
79
86
  } else {
80
87
  head.push(`Run: ${run.status || 'unknown'} — ${stageSummary(run)}`);
81
88
  }
82
- head.push(`Cost: ${formatCost(cost)}${cost && cost.source ? ` (${cost.source})` : ''}`);
89
+ // ⚠️ v4.4.1 DOC-5: this used to append ` (${cost.source})` for EVERY source,
90
+ // on top of a glyph formatCost had already spent on the same fact —
91
+ // `~$0.0100 (estimated)`, `? (unknown)`. formatCost (src/utils/pricing.js)
92
+ // prefixes `~` for both 'estimated' and 'mixed', and returns a bare `?` when
93
+ // the source is 'unknown'. So for 'estimated' and 'unknown' the word was pure
94
+ // repetition and is gone.
95
+ //
96
+ // Two sources still spell themselves out, because the glyph vocabulary cannot
97
+ // express them:
98
+ // - `reported` — a plain `$0.4321` is also what an unrecognised/absent
99
+ // source renders as, so the absence of a glyph cannot mean "exact".
100
+ // - `mixed` — `~` encodes *inexact*, not *which kind of inexact*. Collapsing
101
+ // 'mixed' into a bare `~$…` makes it indistinguishable from 'estimated',
102
+ // and the two are not the same claim: 'mixed' means some legs reported
103
+ // real usage and some were estimated, i.e. part of this number is
104
+ // measured. That is strictly more information than 'estimated', and this
105
+ // is the fold line of a release whose theme is cost truthfulness.
106
+ // Each thing is still said exactly once — the `~` says "inexact", the word
107
+ // says "which kind", and neither restates the other.
108
+ const costSourceLabel = cost && (cost.source === 'reported' || cost.source === 'mixed')
109
+ ? ` (${cost.source})`
110
+ : '';
111
+ head.push(`Cost: ${formatCost(cost)}${costSourceLabel}`);
83
112
 
84
113
  // Review follow-up #1: strip FIRST, then test emptiness on the STRIPPED
85
114
  // result — not the raw one. A chair body consisting solely of a marker
@@ -20,10 +20,14 @@
20
20
  const { formatCost } = require('../utils/pricing');
21
21
  const { TERMINAL_STATUSES } = require('./run-detail');
22
22
 
23
+ // ⚠️ v4.4.1 RN-7: the `doc.wave.legs` fallback that used to sit here is GONE, deliberately.
24
+ // No producer nests legs under `wave` — the WAVE composed doc (src/mcp-server.js:592-662)
25
+ // carries a TOP-LEVEL `legs`, exactly like the council one, and this file's header already
26
+ // says not to model the wave doc at all. The arm asserted a shape that does not exist, and
27
+ // wsgate01's reviewer believed it. If a nested shape ever appears, add it back WITH a
28
+ // producer to point at.
23
29
  function legRowsOf(doc) {
24
- if (Array.isArray(doc.legs)) { return doc.legs; }
25
- if (doc.wave && Array.isArray(doc.wave.legs)) { return doc.wave.legs; }
26
- return [];
30
+ return Array.isArray(doc.legs) ? doc.legs : [];
27
31
  }
28
32
 
29
33
  function numOrNull(v) { return typeof v === 'number' ? v : null; }
@@ -32,7 +36,9 @@ function seatOf(leg) {
32
36
  const usage = leg.usage || null;
33
37
  const tokens = usage && usage.tokens ? usage.tokens : null;
34
38
  return {
35
- id: leg.taskId || leg.legId || null,
39
+ // ⚠️ v4.4.1 RN-7: `leg.legId` was a second dead fallback here — no leg row, council or
40
+ // wave, has ever carried it (src/observe/council-legs.js stamps `taskId`). Deleted.
41
+ id: leg.taskId || null,
36
42
  // ⚠️ DE-ROT (F34/F36): `model` and `modelInput` are TWO SEPARATE fields, never collapsed.
37
43
  // A live leg's `model` is the resolved executable id (e.g. `google/gemini-2.5`); `modelInput`
38
44
  // is the council ALIAS (e.g. `gemini`) that run.json's labelMap and blind mode's labelFor()
@@ -108,19 +114,26 @@ function normalizeLive(doc) {
108
114
  view: doc.view || null,
109
115
  runId: doc.runId || doc.taskId || null,
110
116
  status,
111
- // ⚠️ PRE-FLIGHT (P6): the fallback read `doc.stage`, which NO producer emits — the council
112
- // payload's field is `currentStage` (src/mcp-council-awareness.js:155). Harmless today only by
113
- // coincidence: `currentStage` is computed from the same `stages.find(status === 'running')`
114
- // predicate as `active`, so both arms are null in exactly the same cases. But it is the same
115
- // read-a-field-nobody-writes class as P5, and this is the arm that would matter if `stages`
116
- // ever arrived non-array. Read the field that exists.
117
- stageName: active ? active.name : (doc.currentStage || null),
117
+ // ⚠️ v4.4.1 RN-7 (was PRE-FLIGHT P6): the `|| doc.currentStage` fallback is GONE. P6 had
118
+ // already corrected it once — from the phantom `doc.stage` to the real `currentStage` — and
119
+ // its own comment conceded the arm was "harmless today only by coincidence". It is in fact
120
+ // strictly unreachable: buildCouncilStatusPayload writes `stages` as `(run.stages || []).map(…)`
121
+ // (always an array) and derives `currentStage` from the SAME
122
+ // `stages.find(s => s.status === 'running')` predicate `active` uses one line up
123
+ // (src/mcp-council-awareness.js:155-157), so `active` is null in exactly the cases
124
+ // `currentStage` is too. Keeping it asserted a divergence between the two fields that no
125
+ // producer can create.
126
+ stageName: active ? active.name : null,
118
127
  stages,
119
128
  seats: legRowsOf(doc).map(seatOf),
120
- // The two counters the payload already ships (src/mcp-council-awareness.js:149) the honest
121
- // fallback readout if a seat row is ever unavailable.
122
- legsTotal: typeof doc.legsTotal === 'number' ? doc.legsTotal : null,
123
- legsComplete: typeof doc.legsComplete === 'number' ? doc.legsComplete : null,
129
+ // ⚠️ v4.4.1 RN-8 (D1 ruling: delete the promise). `legsTotal`/`legsComplete` used to be
130
+ // mapped here under a comment promising them as "the honest fallback readout if a seat row
131
+ // is ever unavailable" a UI fallback nobody ever wired. Nothing in electron/workspace-ui/
132
+ // read either field. Documenting a feature that does not exist is worse than not having it,
133
+ // so the fields and the promise are both gone. ⚠️ The SAME names on the composed doc
134
+ // (src/mcp-council-awareness.js) and in src/cli-handlers-status.js / src/mcp-wait.js ARE
135
+ // consumed — this deletion is scoped to the workspace's LiveModel only. If a future task
136
+ // wants the counters in the GUI, re-add them WITH the renderer that paints them.
124
137
  // ⚠️ DE-ROT (F39): these two are ACTIVE-STAGE spend, not the run total.
125
138
  // buildCouncilStatusPayload rolls up only the legs of the currently-RUNNING stage's sub-waves
126
139
  // (mcp-council-awareness.js:136-146) and omits `usage` entirely until one of those legs flushes
@@ -23,7 +23,13 @@ const { isRealpathContained } = require('../utils/path-fence');
23
23
  * ("zero v4.3") and live-doc.js is v4.3. Not to be confused with the shipped
24
24
  * src/utils/result-schema.js:13 TERMINAL_STATUSES, which is the LEG set (no 'partial').
25
25
  */
26
- const TERMINAL_STATUSES = ['complete', 'partial', 'error', 'crashed', 'aborted', 'timeout', 'idle-timeout'];
26
+ // ⚠️ v4.4.1 A1: 'timed-out' added alongside 'timeout' — see the long note at
27
+ // src/observe/live-doc.js:18 for why both spellings are real and which producer writes each.
28
+ // Council run.json only ever carries aborted|complete|error|partial (run-finalize.js:38
29
+ // statusForExit), so the new name is inert for THIS module's own reads; it is carried anyway
30
+ // because the drift pin demands byte-identity with the source list, and that pin is the only
31
+ // thing keeping the three copies honest.
32
+ const TERMINAL_STATUSES = ['complete', 'partial', 'error', 'crashed', 'aborted', 'timeout', 'timed-out', 'idle-timeout'];
27
33
 
28
34
  /** Friendly labels for known v4.0 stage names; unknown names pass through raw
29
35
  * — the graceful-when-present rule (spec §5.2). */