agent-nuvira 3.5.0 → 3.5.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 (134) hide show
  1. package/README.md +13 -13
  2. package/dist/agent-sdk/src/scaffold.d.ts +1 -1
  3. package/dist/agent-sdk/src/scaffold.js +1 -1
  4. package/dist/cli/chat.d.ts +159 -6
  5. package/dist/cli/chat.d.ts.map +1 -1
  6. package/dist/cli/chat.js +583 -35
  7. package/dist/cli/chat.js.map +1 -1
  8. package/dist/cli/duties.d.ts +11 -0
  9. package/dist/cli/duties.d.ts.map +1 -1
  10. package/dist/cli/duties.js +31 -0
  11. package/dist/cli/duties.js.map +1 -1
  12. package/dist/cli/edit.d.ts.map +1 -1
  13. package/dist/cli/edit.js +3 -2
  14. package/dist/cli/edit.js.map +1 -1
  15. package/dist/cli/execute.d.ts.map +1 -1
  16. package/dist/cli/execute.js +10 -3
  17. package/dist/cli/execute.js.map +1 -1
  18. package/dist/cli/loop-executor.d.ts +16 -0
  19. package/dist/cli/loop-executor.d.ts.map +1 -1
  20. package/dist/cli/loop-executor.js +9 -0
  21. package/dist/cli/loop-executor.js.map +1 -1
  22. package/dist/cli/models.d.ts.map +1 -1
  23. package/dist/cli/models.js +55 -0
  24. package/dist/cli/models.js.map +1 -1
  25. package/dist/cli/plan.d.ts.map +1 -1
  26. package/dist/cli/plan.js +18 -6
  27. package/dist/cli/plan.js.map +1 -1
  28. package/dist/cli/run.d.ts.map +1 -1
  29. package/dist/cli/run.js +23 -8
  30. package/dist/cli/run.js.map +1 -1
  31. package/dist/cli/ui/answer-view.d.ts +38 -0
  32. package/dist/cli/ui/answer-view.d.ts.map +1 -0
  33. package/dist/cli/ui/answer-view.js +72 -0
  34. package/dist/cli/ui/answer-view.js.map +1 -0
  35. package/dist/cli/ui/cards.d.ts +61 -0
  36. package/dist/cli/ui/cards.d.ts.map +1 -0
  37. package/dist/cli/ui/cards.js +141 -0
  38. package/dist/cli/ui/cards.js.map +1 -0
  39. package/dist/cli/ui/highlight.d.ts +32 -0
  40. package/dist/cli/ui/highlight.d.ts.map +1 -0
  41. package/dist/cli/ui/highlight.js +261 -0
  42. package/dist/cli/ui/highlight.js.map +1 -0
  43. package/dist/cli/ui/index.d.ts +21 -0
  44. package/dist/cli/ui/index.d.ts.map +1 -0
  45. package/dist/cli/ui/index.js +14 -0
  46. package/dist/cli/ui/index.js.map +1 -0
  47. package/dist/cli/ui/markdown.d.ts +90 -0
  48. package/dist/cli/ui/markdown.d.ts.map +1 -0
  49. package/dist/cli/ui/markdown.js +292 -0
  50. package/dist/cli/ui/markdown.js.map +1 -0
  51. package/dist/cli/ui/presenter.d.ts +147 -0
  52. package/dist/cli/ui/presenter.d.ts.map +1 -0
  53. package/dist/cli/ui/presenter.js +313 -0
  54. package/dist/cli/ui/presenter.js.map +1 -0
  55. package/dist/cli/ui/render.d.ts +52 -0
  56. package/dist/cli/ui/render.d.ts.map +1 -0
  57. package/dist/cli/ui/render.js +384 -0
  58. package/dist/cli/ui/render.js.map +1 -0
  59. package/dist/cli/ui/theme.d.ts +36 -0
  60. package/dist/cli/ui/theme.d.ts.map +1 -0
  61. package/dist/cli/ui/theme.js +92 -0
  62. package/dist/cli/ui/theme.js.map +1 -0
  63. package/dist/inference/model-entitlement.d.ts +10 -0
  64. package/dist/inference/model-entitlement.d.ts.map +1 -1
  65. package/dist/inference/model-entitlement.js +1 -1
  66. package/dist/inference/model-entitlement.js.map +1 -1
  67. package/dist/learning/auto-router.d.ts +38 -1
  68. package/dist/learning/auto-router.d.ts.map +1 -1
  69. package/dist/learning/auto-router.js +139 -8
  70. package/dist/learning/auto-router.js.map +1 -1
  71. package/dist/learning/duty-lease.d.ts +163 -0
  72. package/dist/learning/duty-lease.d.ts.map +1 -0
  73. package/dist/learning/duty-lease.js +301 -0
  74. package/dist/learning/duty-lease.js.map +1 -0
  75. package/dist/learning/model-first-router.d.ts +14 -0
  76. package/dist/learning/model-first-router.d.ts.map +1 -1
  77. package/dist/learning/model-first-router.js +54 -17
  78. package/dist/learning/model-first-router.js.map +1 -1
  79. package/dist/learning/model-warmup.d.ts +92 -0
  80. package/dist/learning/model-warmup.d.ts.map +1 -1
  81. package/dist/learning/model-warmup.js +137 -2
  82. package/dist/learning/model-warmup.js.map +1 -1
  83. package/dist/learning/reasoning-trace.d.ts +21 -1
  84. package/dist/learning/reasoning-trace.d.ts.map +1 -1
  85. package/dist/learning/reasoning-trace.js.map +1 -1
  86. package/dist/learning/resilient-call.d.ts.map +1 -1
  87. package/dist/learning/resilient-call.js +70 -4
  88. package/dist/learning/resilient-call.js.map +1 -1
  89. package/dist/learning/retry-patience.d.ts +139 -0
  90. package/dist/learning/retry-patience.d.ts.map +1 -0
  91. package/dist/learning/retry-patience.js +169 -0
  92. package/dist/learning/retry-patience.js.map +1 -0
  93. package/dist/tools/process-liveness.d.ts +227 -0
  94. package/dist/tools/process-liveness.d.ts.map +1 -0
  95. package/dist/tools/process-liveness.js +486 -0
  96. package/dist/tools/process-liveness.js.map +1 -0
  97. package/dist/tools/registry.d.ts.map +1 -1
  98. package/dist/tools/registry.js +4 -2
  99. package/dist/tools/registry.js.map +1 -1
  100. package/dist/tools/run-registry.d.ts +282 -0
  101. package/dist/tools/run-registry.d.ts.map +1 -0
  102. package/dist/tools/run-registry.js +544 -0
  103. package/dist/tools/run-registry.js.map +1 -0
  104. package/dist/tools/run-terminal.d.ts +100 -2
  105. package/dist/tools/run-terminal.d.ts.map +1 -1
  106. package/dist/tools/run-terminal.js +841 -67
  107. package/dist/tools/run-terminal.js.map +1 -1
  108. package/dist/tools/tool-loop.d.ts +87 -3
  109. package/dist/tools/tool-loop.d.ts.map +1 -1
  110. package/dist/tools/tool-loop.js +375 -6
  111. package/dist/tools/tool-loop.js.map +1 -1
  112. package/dist/web-dashboard/chat-console.d.ts +25 -0
  113. package/dist/web-dashboard/chat-console.d.ts.map +1 -1
  114. package/dist/web-dashboard/chat-console.js +16 -1
  115. package/dist/web-dashboard/chat-console.js.map +1 -1
  116. package/dist/web-dashboard/server.d.ts.map +1 -1
  117. package/dist/web-dashboard/server.js +7 -1
  118. package/dist/web-dashboard/server.js.map +1 -1
  119. package/dist/web-dashboard/src/types.d.ts +32 -1
  120. package/dist/web-dashboard/src/types.d.ts.map +1 -1
  121. package/dist/web-dashboard/task-registry.d.ts +98 -0
  122. package/dist/web-dashboard/task-registry.d.ts.map +1 -0
  123. package/dist/web-dashboard/task-registry.js +159 -0
  124. package/dist/web-dashboard/task-registry.js.map +1 -0
  125. package/dist/web-dashboard/task-runner.d.ts +155 -5
  126. package/dist/web-dashboard/task-runner.d.ts.map +1 -1
  127. package/dist/web-dashboard/task-runner.js +505 -14
  128. package/dist/web-dashboard/task-runner.js.map +1 -1
  129. package/package.json +4 -1
  130. package/src/web-dashboard/public/assets/index-DycGBWwy.js +236 -0
  131. package/src/web-dashboard/public/assets/index-DycGBWwy.js.map +1 -0
  132. package/src/web-dashboard/public/index.html +1 -1
  133. package/src/web-dashboard/public/assets/index-yky0dw5e.js +0 -236
  134. package/src/web-dashboard/public/assets/index-yky0dw5e.js.map +0 -1
@@ -11,9 +11,34 @@
11
11
  * - get(id) / list() → serializable task records
12
12
  * - onEvent(cb) → 'log'/'status' events (SSE fan-out)
13
13
  *
14
- * Timeouts SIGTERM the child, then SIGKILL after a grace period. Logs are
15
- * ring-buffered (capped) so a chatty command can't exhaust memory. All state
16
- * is in-memory (P4: persist task history + rerun).
14
+ * An EXPLICIT timeout SIGTERMs the child, then SIGKILLs after a grace period.
15
+ * With no timeout (the default) nothing is killed on a clock — instead a
16
+ * LIVENESS WATCHDOG samples the task's process tree and distinguishes a command
17
+ * that is genuinely HUNG (no output AND no CPU: a prompt with no stdin, a lock,
18
+ * a socket that never answers) from one that is merely slow (silent but still
19
+ * burning CPU). A confirmed hang is flagged on the record and in the log, and —
20
+ * unless the command is a long-lived server/watch command, which is SUPPOSED to
21
+ * be silent and idle — the runner stops it after a further grace, so a hang is
22
+ * never left running forever. Logs are ring-buffered (capped) so a chatty
23
+ * command can't exhaust memory.
24
+ *
25
+ * STATE OUTLIVES THE PROCESS (see `task-registry.ts`). A task is written to a
26
+ * durable record the moment it starts — pid, args, cwd, start time, the OS's
27
+ * start token for that pid — and its output to a bounded log window. A new
28
+ * dashboard process HYDRATES from those: the list and the log pane come back,
29
+ * a task whose process is still alive is re-attached to the liveness watchdog,
30
+ * and one whose pid is gone is settled honestly (no exit code, because none was
31
+ * ever observed). The bug this fixes was measured: restarting the dashboard left
32
+ * the child running and the console empty, so work kept burning CPU that nothing
33
+ * could see, collect or stop.
34
+ *
35
+ * A recovered process is stopped ONLY once its identity is re-verified (the
36
+ * command line AND the OS start token — see `processIdentityVerdict`); a refusal
37
+ * is logged on the task with its pid so it can be stopped by hand.
38
+ *
39
+ * ONE OWNER PER PROFILE, like the monitored-run registry: two dashboards sharing
40
+ * a config dir write the same records and the last write wins. That is a
41
+ * limitation, not a claim.
17
42
  *
18
43
  * The spawn target is injectable (`execPath`/`cliEntry`) so unit tests can
19
44
  * point at fixture scripts instead of the real CLI.
@@ -23,6 +48,9 @@ import { existsSync } from 'node:fs';
23
48
  import { dirname, isAbsolute, join } from 'node:path';
24
49
  import { fileURLToPath } from 'node:url';
25
50
  import { randomUUID } from 'node:crypto';
51
+ import { classifyLiveness, formatSpan, killProcessTree, livenessThresholds, lookupProcess, processTableSnapshot, processTreeOf, sampleProcessTree, tableAlive, } from '../tools/process-liveness.js';
52
+ import { processIdentityCheck } from '../tools/run-registry.js';
53
+ import { createTaskLog, loadTasks, readTaskLog, saveTask, sweepTaskLogs, } from './task-registry.js';
26
54
  /** Per-arg length cap (a runaway arg must not wedge the runner). */
27
55
  const MAX_ARG_LENGTH = 512;
28
56
  /** Max args per task (argv is bounded). */
@@ -33,10 +61,56 @@ const MAX_LOG_LINES = 2000;
33
61
  const MAX_LINE_LENGTH = 4000;
34
62
  /** Max tasks kept in history (oldest dropped). */
35
63
  const MAX_TASKS = 50;
36
- /** Default task timeout: 5 minutes (long evals/benchmarks need room). */
37
- const DEFAULT_TIMEOUT_MS = 300_000;
64
+ /**
65
+ * Default task timeout: NONE (0 = run until the command finishes or is
66
+ * cancelled).
67
+ *
68
+ * The 5-minute default was SIGTERMing real work — the commands worth running
69
+ * from the dashboard are the long ones (`npm test`, a build, an eval matrix) —
70
+ * so the page reported a failure about work that was merely unfinished. A
71
+ * caller that genuinely wants a cutoff passes `timeoutMs > 0` (the GUI's
72
+ * timeout picker does exactly that), which is now an explicit choice rather than
73
+ * a trap for everything that forgets to state one.
74
+ */
75
+ const DEFAULT_TIMEOUT_MS = 0;
38
76
  /** Grace after SIGTERM before SIGKILL. */
39
77
  const KILL_GRACE_MS = 5_000;
78
+ /**
79
+ * How often a running task's process tree is sampled for liveness.
80
+ *
81
+ * `NUVIRA_TASK_HANG_POLL_MS` overrides it — the sample rate is a cost/precision
82
+ * trade (one `ps` per running task per interval), and a test needs it fast.
83
+ */
84
+ function taskHangPollMs() {
85
+ return Math.max(50, envMs('NUVIRA_TASK_HANG_POLL_MS', 30_000));
86
+ }
87
+ /**
88
+ * Commands that are SUPPOSED to be silent and idle: a server, a watcher, a
89
+ * daemon. They burn no CPU and print nothing while they wait for work, which is
90
+ * exactly the signature of a hang — so they are flagged like anything else but
91
+ * are NEVER stopped for it. Getting this wrong in the other direction (reaping a
92
+ * running server) is the failure the old one-preset-at-a-time `gateway start`
93
+ * exemption was papering over; this is that exemption, made a class.
94
+ */
95
+ const LONG_LIVED_COMMAND_RE = /\b(gateway\s+start|supervise|dashboard|serve|server|dev|watch|worker|daemon|listen|tail\s+-f|smee|tunnel)\b/i;
96
+ /** True when this command is a long-lived server/watcher (never reaped for a hang). */
97
+ export function isLongLivedCommand(args) {
98
+ return LONG_LIVED_COMMAND_RE.test(args.join(' '));
99
+ }
100
+ function envMs(name, fallback) {
101
+ const raw = Number.parseInt(String(process.env[name] ?? '').trim(), 10);
102
+ return Number.isFinite(raw) && raw >= 0 ? raw : fallback;
103
+ }
104
+ /**
105
+ * How long a CONFIRMED hang runs before the runner stops it (0 = never — flag it
106
+ * and leave the decision to the operator, who can always cancel).
107
+ *
108
+ * Ten minutes by default, and the grace only starts once a hang has been
109
+ * ESTABLISHED from evidence, so a slow-but-working command is never touched.
110
+ */
111
+ export function taskHangReapMs() {
112
+ return envMs('NUVIRA_TASK_HANG_REAP_MS', 600_000);
113
+ }
40
114
  function repoRootDir() {
41
115
  // Compiled: dist/web-dashboard/task-runner.js → repo root is ../../dist/index.js.
42
116
  // Dev (tsx): src/web-dashboard/task-runner.ts → repo root is the same ../../.
@@ -49,9 +123,216 @@ export class TaskRunner {
49
123
  children = new Map();
50
124
  cancelled = new Set();
51
125
  timedOut = new Set();
126
+ /** Tasks the liveness watchdog stopped (distinct from a user's cancel). */
127
+ hung = new Set();
128
+ /** The last CPU sample per task — the delta is what proves a hang. */
129
+ cpuSamples = new Map();
130
+ watchers = new Map();
52
131
  listeners = new Set();
132
+ /** The bounded on-disk log window per task (what a restart reads back). */
133
+ logs = new Map();
134
+ /**
135
+ * Recovered RUNNING tasks: their pid, and whether it could be re-verified as
136
+ * the process this dashboard started. A recovered task has no child handle, so
137
+ * this is what the watchdog samples and what a cancel is gated on.
138
+ */
139
+ recoveredRuns = new Map();
140
+ /**
141
+ * The identity of each task's process (OS start token + live command line at
142
+ * spawn), remembered so every later write of the record keeps it — a record
143
+ * that lost its token could no longer be re-verified after a restart.
144
+ */
145
+ identities = new Map();
53
146
  constructor(opts = {}) {
54
147
  this.opts = opts;
148
+ // Recover BEFORE returning: a caller that lists tasks right after construction
149
+ // must see the work a previous process left running, not an empty console.
150
+ this.hydrate();
151
+ }
152
+ /**
153
+ * Restore the previous process's task list, logs, and live pids.
154
+ *
155
+ * This is the whole point of the registry: without it, a dashboard restart
156
+ * orphaned a child that kept running while the console forgot it existed.
157
+ *
158
+ * A recovered task whose pid is GONE is settled honestly — with no exit code,
159
+ * because none was ever observed — rather than being left "running" forever or
160
+ * quietly deleted. A recovered task whose pid may belong to someone else is
161
+ * followed and reported, and only stopped once its identity is re-verified.
162
+ */
163
+ hydrate() {
164
+ const persisted = loadTasks();
165
+ const now = Date.now();
166
+ for (const entry of persisted) {
167
+ if (this.tasks.has(entry.id))
168
+ continue;
169
+ const record = {
170
+ id: entry.id,
171
+ command: entry.args.join(' '),
172
+ args: entry.args,
173
+ cwd: entry.cwd,
174
+ status: entry.status,
175
+ exitCode: entry.exitCode,
176
+ startedAt: entry.startedAt,
177
+ finishedAt: entry.finishedAt ?? null,
178
+ durationMs: entry.durationMs ?? null,
179
+ timeoutMs: entry.timeoutMs,
180
+ logs: this.restoreLogs(entry.id),
181
+ // The silence clock restarts here: there is no output timeline to measure
182
+ // from after a restart, and "quiet since before the restart" would be a
183
+ // fact about the downtime rather than about the task.
184
+ lastOutputAt: now,
185
+ ...(entry.stopReason ? { stopReason: entry.stopReason } : {}),
186
+ ...(entry.error ? { error: entry.error } : {}),
187
+ ...(entry.pid !== null ? { pid: entry.pid } : {}),
188
+ };
189
+ this.tasks.set(entry.id, record);
190
+ this.order.push(entry.id);
191
+ // Keep the identity with the record, so a later write cannot lose the very
192
+ // token that makes a recovered task stoppable.
193
+ if (entry.osStartedAt || entry.osCommand) {
194
+ this.identities.set(entry.id, {
195
+ ...(entry.osStartedAt ? { osStartedAt: entry.osStartedAt } : {}),
196
+ ...(entry.osCommand ? { osCommand: entry.osCommand } : {}),
197
+ });
198
+ }
199
+ if (entry.status !== 'running' || entry.pid === null)
200
+ continue;
201
+ const check = processIdentityCheck({ command: entry.osCommand ?? '', osStartedAt: entry.osStartedAt ?? null }, lookupProcess(entry.pid));
202
+ record.recovered = { verified: check.verdict, why: check.why };
203
+ if (check.kind === 'gone') {
204
+ // The ONLY branch that may settle a task from an identity check: the pid
205
+ // is not in the process table at all, which is a fact about liveness. No
206
+ // exit code was observed, and the record says so rather than guessing.
207
+ this.settleRecovered(entry.id, 'error', 'it ended while the dashboard was not running — its exit code was not recorded');
208
+ continue;
209
+ }
210
+ // Everything else is a claim about IDENTITY, not about liveness, so the
211
+ // task stays listed and is watched. A pid that now belongs to another
212
+ // process is never signalled (that is what `verified` gates), and it is
213
+ // reported as such instead of being deleted from the console — deleting it
214
+ // would hide work the operator may still need to stop by hand.
215
+ this.recoveredRuns.set(entry.id, { pid: entry.pid });
216
+ this.startWatching(entry.id);
217
+ this.appendLog(record, 'system', check.kind === 'verified'
218
+ ? `↩ recovered from a previous dashboard process — still running, and re-verified as this task (${check.why}).`
219
+ : `↩ recovered from a previous dashboard process — still running, but NOT re-verified as this task (${check.why}). It will be followed, and it will NOT be stopped automatically: stop it by hand with pid ${entry.pid} if you know it is still yours.`);
220
+ }
221
+ // Logs nobody refers to must not accumulate: the records are capped, so the
222
+ // files have to be swept or the directory grows forever.
223
+ sweepTaskLogs([...this.tasks.keys()]);
224
+ }
225
+ /** The restored transcript of a task, capped like the live ring buffer. */
226
+ restoreLogs(id) {
227
+ const text = readTaskLog(id);
228
+ if (!text)
229
+ return [];
230
+ const lines = text.split('\n').filter((l) => l.length > 0);
231
+ return lines.slice(-MAX_LOG_LINES).map((line) => ({ stream: 'stdout', text: line, at: Date.now() }));
232
+ }
233
+ /** Write one task's record to the registry (best-effort — never throws). */
234
+ persist(id) {
235
+ const record = this.tasks.get(id);
236
+ if (!record)
237
+ return;
238
+ const identity = this.identities.get(id);
239
+ const entry = {
240
+ id,
241
+ args: record.args,
242
+ cwd: record.cwd,
243
+ timeoutMs: record.timeoutMs,
244
+ pid: record.pid ?? this.recoveredRuns.get(id)?.pid ?? null,
245
+ startedAt: record.startedAt,
246
+ status: record.status,
247
+ exitCode: record.exitCode,
248
+ ...(record.finishedAt !== null ? { finishedAt: record.finishedAt } : {}),
249
+ ...(record.durationMs !== null ? { durationMs: record.durationMs } : {}),
250
+ ...(record.stopReason ? { stopReason: record.stopReason } : {}),
251
+ ...(record.error ? { error: record.error } : {}),
252
+ ...(identity?.osStartedAt ? { osStartedAt: identity.osStartedAt } : {}),
253
+ ...(identity?.osCommand ? { osCommand: identity.osCommand } : {}),
254
+ };
255
+ saveTask(entry);
256
+ }
257
+ /**
258
+ * Capture the identity of a task's process, for a restart that has to tell it
259
+ * apart from whatever holds that pid later.
260
+ *
261
+ * Read NOW, at spawn, because that is the last moment it is certainly ours. If
262
+ * the read misses (a freshly forked child can take a few milliseconds to appear
263
+ * in the table) it is retried once off the request path; a task that still has
264
+ * no token is followable after a restart but not stoppable, and it SAYS so in
265
+ * its own log rather than failing quietly.
266
+ */
267
+ captureIdentity(id, pid) {
268
+ if (pid === null || pid <= 0)
269
+ return;
270
+ // Either witness is worth keeping: the token is the stronger one, and the
271
+ // command line still works on a platform whose `ps` has no `lstart`. Only a
272
+ // read that gives NEITHER leaves the task followable-but-not-stoppable.
273
+ const remember = (row) => {
274
+ if (!row?.found || (!row.startedAtText && !row.command))
275
+ return false;
276
+ this.identities.set(id, {
277
+ ...(row.startedAtText ? { osStartedAt: row.startedAtText } : {}),
278
+ ...(row.command ? { osCommand: row.command } : {}),
279
+ });
280
+ this.persist(id);
281
+ return true;
282
+ };
283
+ if (remember(lookupProcess(pid)))
284
+ return;
285
+ const retry = setTimeout(() => {
286
+ if (remember(lookupProcess(pid)))
287
+ return;
288
+ const record = this.tasks.get(id);
289
+ if (record) {
290
+ this.appendLog(record, 'system', '⚠️ could not read this task\'s OS start token — if the dashboard restarts it will still be listed, but it can only be stopped by hand.');
291
+ }
292
+ }, 250);
293
+ retry.unref?.();
294
+ }
295
+ /** Start the liveness watchdog for one task (spawned or recovered). */
296
+ startWatching(id) {
297
+ const watch = setInterval(() => this.sampleLiveness(id), taskHangPollMs());
298
+ // A monitored task must never hold the process open: the dashboard exits on
299
+ // its own terms, and the run continues without it.
300
+ watch.unref?.();
301
+ this.watchers.set(id, watch);
302
+ }
303
+ /**
304
+ * Settle a RECOVERED task — one with no child handle, so nothing will ever
305
+ * fire an exit event for it. The status is the best evidence available, and the
306
+ * exit code is `null` because none was observed: inventing one would be the
307
+ * exact lie this feature exists to avoid.
308
+ */
309
+ settleRecovered(id, status, reason) {
310
+ const record = this.tasks.get(id);
311
+ if (!record || record.status !== 'running')
312
+ return;
313
+ this.stopWatching(id);
314
+ this.cpuSamples.delete(id);
315
+ this.recoveredRuns.delete(id);
316
+ record.status = status;
317
+ record.exitCode = null;
318
+ record.finishedAt = Date.now();
319
+ record.durationMs = record.finishedAt - record.startedAt;
320
+ if (status === 'error')
321
+ record.error = reason;
322
+ else
323
+ record.stopReason = reason;
324
+ this.appendLog(record, 'system', `— ${reason}`);
325
+ this.persist(id);
326
+ this.emit(id, {
327
+ kind: 'status',
328
+ status: {
329
+ status,
330
+ exitCode: null,
331
+ durationMs: record.durationMs,
332
+ stopReason: reason,
333
+ ...(status === 'error' ? { error: reason } : {}),
334
+ },
335
+ });
55
336
  }
56
337
  /** Subscribe to task events ('log' + 'status'). Returns an unsubscribe fn. */
57
338
  onEvent(cb) {
@@ -86,6 +367,16 @@ export class TaskRunner {
86
367
  const clean = text.replace(/\r\n/g, '\n').replace(/\n+$/, '');
87
368
  if (!clean)
88
369
  return;
370
+ // Output from the CHILD is evidence of life: it resets the silence half of
371
+ // the liveness check (and so clears a pending hang — see `sampleLiveness`).
372
+ //
373
+ // `system` lines are the RUNNER talking, and they must NOT count: the
374
+ // watchdog writes "looks HUNG" through this very function, so counting its
375
+ // own notice as output made the run look alive again on the next sample —
376
+ // measured: the verdict oscillated hung → working → stalled → hung forever,
377
+ // the log filled with the same warning, and the hang was never reaped.
378
+ if (stream !== 'system')
379
+ record.lastOutputAt = Date.now();
89
380
  for (const raw of clean.split('\n')) {
90
381
  const line = raw.length > MAX_LINE_LENGTH ? `${raw.slice(0, MAX_LINE_LENGTH)}…` : raw;
91
382
  record.logs.push({ stream, text: line, at: Date.now() });
@@ -93,6 +384,10 @@ export class TaskRunner {
93
384
  if (record.logs.length > MAX_LOG_LINES) {
94
385
  record.logs.splice(0, record.logs.length - MAX_LOG_LINES);
95
386
  }
387
+ // …and to the durable window, so a restart can read the transcript back.
388
+ // Everything that reaches the pane goes through here, INCLUDING the runner's
389
+ // own notes: a refusal a restart cannot show is a refusal nobody will read.
390
+ this.logs.get(record.id)?.write(`${clean}\n`);
96
391
  this.emit(record.id, { kind: 'log', line: { stream, text: clean, at: Date.now() } });
97
392
  }
98
393
  /**
@@ -137,6 +432,7 @@ export class TaskRunner {
137
432
  durationMs: null,
138
433
  timeoutMs,
139
434
  logs: [],
435
+ lastOutputAt: Date.now(),
140
436
  };
141
437
  this.tasks.set(id, record);
142
438
  this.order.push(id);
@@ -147,8 +443,20 @@ export class TaskRunner {
147
443
  this.children.delete(old);
148
444
  this.cancelled.delete(old);
149
445
  this.timedOut.delete(old);
446
+ this.hung.delete(old);
447
+ this.cpuSamples.delete(old);
448
+ this.recoveredRuns.delete(old);
449
+ this.identities.delete(old);
450
+ this.stopWatching(old);
451
+ // Its record is leaving the history with it, so its log goes too — the
452
+ // alternative is a log directory that only ever grows.
453
+ this.logs.get(old)?.discard();
454
+ this.logs.delete(old);
150
455
  }
151
456
  }
457
+ // The durable log window opens BEFORE the spawn, so no early output can be
458
+ // lost to the gap between `spawn` returning and the first listener firing.
459
+ this.logs.set(id, createTaskLog(id));
152
460
  let child;
153
461
  try {
154
462
  child = spawn(process.execPath, [entry, ...args], {
@@ -168,22 +476,44 @@ export class TaskRunner {
168
476
  return { ok: true, task: record };
169
477
  }
170
478
  this.children.set(id, child);
479
+ record.pid = child.pid ?? null;
171
480
  let settled = false;
172
481
  let killGrace = null;
482
+ // Durable from here on: the child outliving this process is the case being
483
+ // fixed, so its record and pid exist before it has produced a byte.
484
+ this.captureIdentity(id, record.pid);
485
+ this.persist(id);
173
486
  const settle = (status, exitCode) => {
174
487
  if (settled)
175
488
  return;
176
489
  settled = true;
177
490
  if (killGrace)
178
491
  clearTimeout(killGrace);
492
+ this.stopWatching(id);
493
+ this.cpuSamples.delete(id);
179
494
  record.status = status;
180
495
  record.exitCode = exitCode;
181
496
  record.finishedAt = Date.now();
182
497
  record.durationMs = record.finishedAt - record.startedAt;
183
498
  this.children.delete(id);
184
499
  this.timedOut.delete(id);
185
- this.emit(id, { kind: 'status', status: { status, exitCode, durationMs: record.durationMs } });
500
+ // Final flush; the file STAYS — the transcript is why the record exists.
501
+ this.logs.get(id)?.close();
502
+ this.persist(id);
503
+ this.emit(id, {
504
+ kind: 'status',
505
+ status: {
506
+ status,
507
+ exitCode,
508
+ durationMs: record.durationMs,
509
+ ...(record.stopReason ? { stopReason: record.stopReason } : {}),
510
+ },
511
+ });
186
512
  };
513
+ // Liveness watchdog: silence ALONE is never a hang (a real suite is quiet
514
+ // for minutes), so the sample is the task's whole process tree CPU — and a
515
+ // hang needs BOTH no output and no CPU. See ../tools/process-liveness.ts.
516
+ this.startWatching(id);
187
517
  let timer = null;
188
518
  if (timeoutMs > 0) {
189
519
  timer = setTimeout(() => {
@@ -206,6 +536,7 @@ export class TaskRunner {
206
536
  }
207
537
  child.stdout?.on('data', (chunk) => this.appendLog(record, 'stdout', chunk.toString('utf-8')));
208
538
  child.stderr?.on('data', (chunk) => this.appendLog(record, 'stderr', chunk.toString('utf-8')));
539
+ child.on('close', () => this.logs.get(id)?.close());
209
540
  child.on('error', (err) => {
210
541
  record.error = err.message;
211
542
  this.appendLog(record, 'system', `Spawn error: ${err.message}`);
@@ -214,6 +545,11 @@ export class TaskRunner {
214
545
  child.on('exit', (code) => {
215
546
  if (timer)
216
547
  clearTimeout(timer);
548
+ // Hung FIRST: the watchdog's stop is its own outcome, not a user cancel.
549
+ if (this.hung.has(id)) {
550
+ settle('hung', code ?? null);
551
+ return;
552
+ }
217
553
  if (this.cancelled.has(id)) {
218
554
  settle('cancelled', code ?? null);
219
555
  return;
@@ -226,18 +562,173 @@ export class TaskRunner {
226
562
  });
227
563
  return { ok: true, task: record };
228
564
  }
229
- /** Cancel a running task (SIGTERM). True when a task existed. */
565
+ stopWatching(id) {
566
+ const watch = this.watchers.get(id);
567
+ if (watch)
568
+ clearInterval(watch);
569
+ this.watchers.delete(id);
570
+ }
571
+ /**
572
+ * One liveness sample for a running task: classify it, flag a hang on the
573
+ * record (and in the log, live), and — only for a task that is not a
574
+ * long-lived server, and only after the grace — stop it.
575
+ *
576
+ * Exported behaviour is reached through the watchdog interval; this method is
577
+ * also the seam a test drives directly.
578
+ */
579
+ sampleLiveness(id) {
580
+ const record = this.tasks.get(id);
581
+ if (!record || record.status !== 'running')
582
+ return;
583
+ const child = this.children.get(id);
584
+ const pid = child?.pid ?? this.recoveredRuns.get(id)?.pid ?? null;
585
+ if (pid === null)
586
+ return;
587
+ const now = Date.now();
588
+ const previous = this.cpuSamples.get(id) ?? null;
589
+ let alive;
590
+ let sample;
591
+ if (child) {
592
+ alive = child.exitCode === null && child.signalCode === null;
593
+ sample = sampleProcessTree(pid);
594
+ }
595
+ else {
596
+ // A recovered task has no child handle, so the process table is the only
597
+ // witness — and ONE read answers both questions. An UNREADABLE table is not
598
+ // evidence of death (`tableAlive` returns null): the task stays alive rather
599
+ // than being declared finished by a probe that failed.
600
+ const table = processTableSnapshot();
601
+ alive = tableAlive(table, pid) ?? true;
602
+ sample = processTreeOf(table ?? new Map(), pid);
603
+ }
604
+ const verdict = classifyLiveness({
605
+ alive,
606
+ // The runner counts LINES, not bytes; "has it ever spoken" is all the
607
+ // classifier needs from this (recency comes from `quietMs`).
608
+ bytes: record.logs.length,
609
+ quietMs: now - record.lastOutputAt,
610
+ ageMs: now - record.startedAt,
611
+ cpuMs: sample.cpuMs,
612
+ prevCpuMs: previous ? previous.cpuMs : null,
613
+ sampleWindowMs: previous ? now - previous.at : taskHangPollMs(),
614
+ }, livenessThresholds());
615
+ this.cpuSamples.set(id, { cpuMs: sample.cpuMs, at: now });
616
+ const changed = record.liveness?.verdict !== verdict.verdict;
617
+ record.liveness = { verdict: verdict.verdict, why: verdict.why, at: now };
618
+ // A RECOVERED task that has exited: nothing will ever fire an exit event for
619
+ // it, so the sample is what settles it — reported with no exit code, because
620
+ // none was observed.
621
+ if (verdict.verdict === 'finished') {
622
+ if (this.cancelled.has(id))
623
+ this.settleRecovered(id, 'cancelled', 'stopped on request');
624
+ else if (this.hung.has(id))
625
+ this.settleRecovered(id, 'hung', `hung: no output and no CPU (${verdict.why})`);
626
+ else
627
+ this.settleRecovered(id, 'error', 'it ended while the dashboard was not watching — its exit code was not recorded');
628
+ return;
629
+ }
630
+ if (verdict.verdict === 'hung') {
631
+ record.hungSince = record.hungSince ?? now;
632
+ if (changed) {
633
+ // Live, in the log pane AND on the record: the operator sees WHY it is
634
+ // believed stuck, not just that time passed.
635
+ this.appendLog(record, 'system', `🚫 no output and no CPU — looks HUNG. ${verdict.why}`);
636
+ }
637
+ const reapAfter = taskHangReapMs();
638
+ if (reapAfter > 0 && !isLongLivedCommand(record.args) && now - record.hungSince >= reapAfter) {
639
+ if (child) {
640
+ this.stopHungTask(id, record, child, `hung: no output and no CPU for ${formatSpan(now - record.hungSince)}`);
641
+ }
642
+ else {
643
+ this.stopRecoveredHung(id, record, pid, `hung: no output and no CPU for ${formatSpan(now - record.hungSince)}`);
644
+ }
645
+ }
646
+ return;
647
+ }
648
+ // It spoke or it computed — not hung, and any pending grace starts over.
649
+ record.hungSince = null;
650
+ if (changed && verdict.verdict === 'stalled') {
651
+ this.appendLog(record, 'system', `⚠️ quiet for a while — ${verdict.why}`);
652
+ }
653
+ }
654
+ /** Stop a task the watchdog has established is hung, and say why. */
655
+ stopHungTask(id, record, child, why) {
656
+ this.hung.add(id);
657
+ record.stopReason = why;
658
+ killProcessTree(child.pid ?? -1, 'SIGTERM', child);
659
+ const grace = setTimeout(() => {
660
+ killProcessTree(child.pid ?? -1, 'SIGKILL', child);
661
+ }, KILL_GRACE_MS);
662
+ grace.unref?.();
663
+ }
664
+ /**
665
+ * The same, for a task recovered from a previous dashboard process.
666
+ *
667
+ * Gated on the identity verdict. A pid that outlived the process table it came
668
+ * from may now belong to something else, and this runner only ever signals work
669
+ * IT started — so an unverifiable "hang" is reported and left running, and the
670
+ * refusal says why. That is the honest trade: a hung command that keeps running
671
+ * is bad, and stopping a stranger's process is worse.
672
+ */
673
+ stopRecoveredHung(id, record, pid, why) {
674
+ if (record.recovered?.verified !== true) {
675
+ record.hungSince = Date.now(); // re-arms the grace rather than re-noticing every tick
676
+ this.appendLog(record, 'system', `🚫 looks HUNG, but it was recovered from a previous dashboard process and its pid could not ` +
677
+ `be re-verified (${record.recovered?.why ?? 'the check could not be completed'}) — so it is ` +
678
+ `deliberately NOT being stopped. Stop it by hand with pid ${pid} if you know it is still yours.`);
679
+ this.persist(id);
680
+ return;
681
+ }
682
+ this.hung.add(id);
683
+ record.stopReason = why;
684
+ killProcessTree(pid, 'SIGTERM');
685
+ const grace = setTimeout(() => {
686
+ killProcessTree(pid, 'SIGKILL');
687
+ }, KILL_GRACE_MS);
688
+ grace.unref?.();
689
+ this.persist(id);
690
+ }
691
+ /**
692
+ * Cancel a running task (SIGTERM). True when a task existed and was signalled.
693
+ *
694
+ * A task recovered from a previous dashboard process has no child handle, so it
695
+ * is stoppable only once its pid was re-verified as the process this dashboard
696
+ * started. A refusal is logged on the task itself (not just returned) so the
697
+ * operator sees it in the pane, with the pid for a manual stop.
698
+ */
230
699
  cancel(id) {
231
700
  const child = this.children.get(id);
232
- if (!child)
233
- return false;
234
- this.cancelled.add(id);
235
- try {
236
- child.kill('SIGTERM');
701
+ if (child) {
702
+ this.cancelled.add(id);
703
+ try {
704
+ child.kill('SIGTERM');
705
+ }
706
+ catch {
707
+ /* best-effort — the exit handler still settles */
708
+ }
709
+ this.persist(id);
710
+ return true;
237
711
  }
238
- catch {
239
- /* best-effort — the exit handler still settles */
712
+ const record = this.tasks.get(id);
713
+ const recovered = this.recoveredRuns.get(id);
714
+ const pid = recovered?.pid ?? null;
715
+ if (!record || !recovered || record.status !== 'running' || pid === null)
716
+ return false;
717
+ if (record.recovered?.verified !== true) {
718
+ this.appendLog(record, 'system', `refused to stop this task: it was recovered from a previous dashboard process and its pid could ` +
719
+ `not be re-verified as this task (${record.recovered?.why ?? 'the check could not be completed'}). ` +
720
+ `Stop it by hand with pid ${pid} if you know it is still yours.`);
721
+ this.persist(id);
722
+ return false;
240
723
  }
724
+ this.cancelled.add(id);
725
+ killProcessTree(pid, 'SIGTERM');
726
+ const grace = setTimeout(() => {
727
+ killProcessTree(pid, 'SIGKILL');
728
+ }, KILL_GRACE_MS);
729
+ grace.unref?.();
730
+ this.appendLog(record, 'system', `SIGTERM sent to pid ${pid} and everything under it (SIGKILL follows in ${formatSpan(KILL_GRACE_MS)} if it does not exit).`);
731
+ this.persist(id);
241
732
  return true;
242
733
  }
243
734
  }