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.
- package/README.md +13 -13
- package/dist/agent-sdk/src/scaffold.d.ts +1 -1
- package/dist/agent-sdk/src/scaffold.js +1 -1
- package/dist/cli/chat.d.ts +159 -6
- package/dist/cli/chat.d.ts.map +1 -1
- package/dist/cli/chat.js +583 -35
- package/dist/cli/chat.js.map +1 -1
- package/dist/cli/duties.d.ts +11 -0
- package/dist/cli/duties.d.ts.map +1 -1
- package/dist/cli/duties.js +31 -0
- package/dist/cli/duties.js.map +1 -1
- package/dist/cli/edit.d.ts.map +1 -1
- package/dist/cli/edit.js +3 -2
- package/dist/cli/edit.js.map +1 -1
- package/dist/cli/execute.d.ts.map +1 -1
- package/dist/cli/execute.js +10 -3
- package/dist/cli/execute.js.map +1 -1
- package/dist/cli/loop-executor.d.ts +16 -0
- package/dist/cli/loop-executor.d.ts.map +1 -1
- package/dist/cli/loop-executor.js +9 -0
- package/dist/cli/loop-executor.js.map +1 -1
- package/dist/cli/models.d.ts.map +1 -1
- package/dist/cli/models.js +55 -0
- package/dist/cli/models.js.map +1 -1
- package/dist/cli/plan.d.ts.map +1 -1
- package/dist/cli/plan.js +18 -6
- package/dist/cli/plan.js.map +1 -1
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +23 -8
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/ui/answer-view.d.ts +38 -0
- package/dist/cli/ui/answer-view.d.ts.map +1 -0
- package/dist/cli/ui/answer-view.js +72 -0
- package/dist/cli/ui/answer-view.js.map +1 -0
- package/dist/cli/ui/cards.d.ts +61 -0
- package/dist/cli/ui/cards.d.ts.map +1 -0
- package/dist/cli/ui/cards.js +141 -0
- package/dist/cli/ui/cards.js.map +1 -0
- package/dist/cli/ui/highlight.d.ts +32 -0
- package/dist/cli/ui/highlight.d.ts.map +1 -0
- package/dist/cli/ui/highlight.js +261 -0
- package/dist/cli/ui/highlight.js.map +1 -0
- package/dist/cli/ui/index.d.ts +21 -0
- package/dist/cli/ui/index.d.ts.map +1 -0
- package/dist/cli/ui/index.js +14 -0
- package/dist/cli/ui/index.js.map +1 -0
- package/dist/cli/ui/markdown.d.ts +90 -0
- package/dist/cli/ui/markdown.d.ts.map +1 -0
- package/dist/cli/ui/markdown.js +292 -0
- package/dist/cli/ui/markdown.js.map +1 -0
- package/dist/cli/ui/presenter.d.ts +147 -0
- package/dist/cli/ui/presenter.d.ts.map +1 -0
- package/dist/cli/ui/presenter.js +313 -0
- package/dist/cli/ui/presenter.js.map +1 -0
- package/dist/cli/ui/render.d.ts +52 -0
- package/dist/cli/ui/render.d.ts.map +1 -0
- package/dist/cli/ui/render.js +384 -0
- package/dist/cli/ui/render.js.map +1 -0
- package/dist/cli/ui/theme.d.ts +36 -0
- package/dist/cli/ui/theme.d.ts.map +1 -0
- package/dist/cli/ui/theme.js +92 -0
- package/dist/cli/ui/theme.js.map +1 -0
- package/dist/inference/model-entitlement.d.ts +10 -0
- package/dist/inference/model-entitlement.d.ts.map +1 -1
- package/dist/inference/model-entitlement.js +1 -1
- package/dist/inference/model-entitlement.js.map +1 -1
- package/dist/learning/auto-router.d.ts +38 -1
- package/dist/learning/auto-router.d.ts.map +1 -1
- package/dist/learning/auto-router.js +139 -8
- package/dist/learning/auto-router.js.map +1 -1
- package/dist/learning/duty-lease.d.ts +163 -0
- package/dist/learning/duty-lease.d.ts.map +1 -0
- package/dist/learning/duty-lease.js +301 -0
- package/dist/learning/duty-lease.js.map +1 -0
- package/dist/learning/model-first-router.d.ts +14 -0
- package/dist/learning/model-first-router.d.ts.map +1 -1
- package/dist/learning/model-first-router.js +54 -17
- package/dist/learning/model-first-router.js.map +1 -1
- package/dist/learning/model-warmup.d.ts +92 -0
- package/dist/learning/model-warmup.d.ts.map +1 -1
- package/dist/learning/model-warmup.js +137 -2
- package/dist/learning/model-warmup.js.map +1 -1
- package/dist/learning/reasoning-trace.d.ts +21 -1
- package/dist/learning/reasoning-trace.d.ts.map +1 -1
- package/dist/learning/reasoning-trace.js.map +1 -1
- package/dist/learning/resilient-call.d.ts.map +1 -1
- package/dist/learning/resilient-call.js +70 -4
- package/dist/learning/resilient-call.js.map +1 -1
- package/dist/learning/retry-patience.d.ts +139 -0
- package/dist/learning/retry-patience.d.ts.map +1 -0
- package/dist/learning/retry-patience.js +169 -0
- package/dist/learning/retry-patience.js.map +1 -0
- package/dist/tools/process-liveness.d.ts +227 -0
- package/dist/tools/process-liveness.d.ts.map +1 -0
- package/dist/tools/process-liveness.js +486 -0
- package/dist/tools/process-liveness.js.map +1 -0
- package/dist/tools/registry.d.ts.map +1 -1
- package/dist/tools/registry.js +4 -2
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/run-registry.d.ts +282 -0
- package/dist/tools/run-registry.d.ts.map +1 -0
- package/dist/tools/run-registry.js +544 -0
- package/dist/tools/run-registry.js.map +1 -0
- package/dist/tools/run-terminal.d.ts +100 -2
- package/dist/tools/run-terminal.d.ts.map +1 -1
- package/dist/tools/run-terminal.js +841 -67
- package/dist/tools/run-terminal.js.map +1 -1
- package/dist/tools/tool-loop.d.ts +87 -3
- package/dist/tools/tool-loop.d.ts.map +1 -1
- package/dist/tools/tool-loop.js +375 -6
- package/dist/tools/tool-loop.js.map +1 -1
- package/dist/web-dashboard/chat-console.d.ts +25 -0
- package/dist/web-dashboard/chat-console.d.ts.map +1 -1
- package/dist/web-dashboard/chat-console.js +16 -1
- package/dist/web-dashboard/chat-console.js.map +1 -1
- package/dist/web-dashboard/server.d.ts.map +1 -1
- package/dist/web-dashboard/server.js +7 -1
- package/dist/web-dashboard/server.js.map +1 -1
- package/dist/web-dashboard/src/types.d.ts +32 -1
- package/dist/web-dashboard/src/types.d.ts.map +1 -1
- package/dist/web-dashboard/task-registry.d.ts +98 -0
- package/dist/web-dashboard/task-registry.d.ts.map +1 -0
- package/dist/web-dashboard/task-registry.js +159 -0
- package/dist/web-dashboard/task-registry.js.map +1 -0
- package/dist/web-dashboard/task-runner.d.ts +155 -5
- package/dist/web-dashboard/task-runner.d.ts.map +1 -1
- package/dist/web-dashboard/task-runner.js +505 -14
- package/dist/web-dashboard/task-runner.js.map +1 -1
- package/package.json +4 -1
- package/src/web-dashboard/public/assets/index-DycGBWwy.js +236 -0
- package/src/web-dashboard/public/assets/index-DycGBWwy.js.map +1 -0
- package/src/web-dashboard/public/index.html +1 -1
- package/src/web-dashboard/public/assets/index-yky0dw5e.js +0 -236
- 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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
/**
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
239
|
-
|
|
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
|
}
|