@venturewild/workspace 0.6.16 → 0.6.18
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/package.json +1 -1
- package/server/src/background-tasks.mjs +366 -0
- package/server/src/index.mjs +61 -0
- package/server/src/process-holders.mjs +122 -0
- package/web/dist/assets/{index-MGyHnwbP.css → index-BPNfw1Sq.css} +1 -1
- package/web/dist/assets/index-VXldPvIc.js +131 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-yMZQt9XE.js +0 -131
package/package.json
CHANGED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
// Background-task registry — the "what's running in the background" surface.
|
|
2
|
+
//
|
|
3
|
+
// WHY this exists: the agent can launch a long job with the Bash tool's
|
|
4
|
+
// `run_in_background:true` (e.g. "run the scored-verdict audit in the background").
|
|
5
|
+
// We verified empirically how `claude` behaves here:
|
|
6
|
+
// - The launch tool_result is text: `Command running in background with ID:
|
|
7
|
+
// <shellId>. Output is being written to: <file>. You will be notified…`.
|
|
8
|
+
// - The task OUTLIVES the `claude -p` turn that spawned it (it's detached) — the
|
|
9
|
+
// output file keeps growing on disk after the process exits.
|
|
10
|
+
// - BUT in our one-process-per-turn model there is NO completion event delivered
|
|
11
|
+
// back into the stream, and a resumed session can't re-poll the old shell. So
|
|
12
|
+
// "I'll be notified when it finishes" never actually fires for the user.
|
|
13
|
+
// - Completion IS visible IF the agent polls in the same turn: the poll tool
|
|
14
|
+
// (newer `TaskOutput` w/ `task_id`, older `BashOutput` w/ `bash_id`) returns
|
|
15
|
+
// `<status>completed</status>` + `<exit_code>N</exit_code>`.
|
|
16
|
+
//
|
|
17
|
+
// So this registry fuses two signals, both of which the server already sees or can
|
|
18
|
+
// reach: (1) the agent's tool-use/tool-result chunk stream (launch + any polls) and
|
|
19
|
+
// (2) the on-disk output file's size/mtime (process-independent liveness). It is the
|
|
20
|
+
// single source of truth behind the chat's background-task card, the topbar activity
|
|
21
|
+
// tray, and the canvas Tasks block.
|
|
22
|
+
//
|
|
23
|
+
// HONESTY (CLAUDE.md: never fake): we only assert `completed`/`failed` from an
|
|
24
|
+
// authoritative poll result or an exit-code we actually observed. Without that, a
|
|
25
|
+
// task is `running`, optionally flagged `quiet` when the output file has been
|
|
26
|
+
// silent past a threshold — surfaced as "no output for Xm", never a false "done".
|
|
27
|
+
|
|
28
|
+
import fs from 'node:fs';
|
|
29
|
+
import { resolveHolders as defaultResolveHolders, killTree as defaultKillTree } from './process-holders.mjs';
|
|
30
|
+
|
|
31
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
32
|
+
|
|
33
|
+
const DEFAULT_POLL_MS = 3000; // how often we stat each live task's output file
|
|
34
|
+
const DEFAULT_QUIET_MS = 90_000; // silence past this → flag `quiet` (maybe stuck / maybe just slow)
|
|
35
|
+
const DEFAULT_MAX_PER_WS = 40; // retained tasks per workspace (terminal ones pruned oldest-first)
|
|
36
|
+
const TAIL_BYTES = 16 * 1024; // how much of the output file the tail endpoint returns
|
|
37
|
+
|
|
38
|
+
const TERMINAL = new Set(['completed', 'failed', 'killed']);
|
|
39
|
+
|
|
40
|
+
/** Parse a background-launch tool_result into { shellId, outputFile }. */
|
|
41
|
+
export function parseLaunchResult(text) {
|
|
42
|
+
if (typeof text !== 'string') return null;
|
|
43
|
+
const idM = text.match(/background with ID:\s*([A-Za-z0-9_-]+)/i);
|
|
44
|
+
if (!idM) return null;
|
|
45
|
+
const fileM = text.match(/written to:\s*(\S+)/i);
|
|
46
|
+
let outputFile = fileM ? fileM[1] : null;
|
|
47
|
+
// The path is a contiguous no-space token; the sentence's trailing punctuation
|
|
48
|
+
// ("…/x.output. You will be notified") clings to it — strip it (output files end
|
|
49
|
+
// in letters, so this never eats a real filename char).
|
|
50
|
+
if (outputFile) outputFile = outputFile.replace(/[.,;:]+$/, '');
|
|
51
|
+
return { shellId: idM[1], outputFile: outputFile || null };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Parse a poll tool_result (`TaskOutput`/`BashOutput`) into { status, exitCode }. */
|
|
55
|
+
export function parsePollResult(text) {
|
|
56
|
+
if (typeof text !== 'string') return null;
|
|
57
|
+
const sM = text.match(/<status>\s*([a-z_]+)\s*<\/status>/i);
|
|
58
|
+
if (!sM) return null;
|
|
59
|
+
const raw = sM[1].toLowerCase();
|
|
60
|
+
const eM = text.match(/<exit_code>\s*(-?\d+)\s*<\/exit_code>/i);
|
|
61
|
+
const exitCode = eM ? Number(eM[1]) : null;
|
|
62
|
+
let status = 'running';
|
|
63
|
+
if (raw === 'completed' || raw === 'done' || raw === 'finished') {
|
|
64
|
+
status = exitCode !== null && exitCode !== 0 ? 'failed' : 'completed';
|
|
65
|
+
} else if (raw === 'killed' || raw === 'stopped') {
|
|
66
|
+
status = 'killed';
|
|
67
|
+
} else if (raw === 'failed' || raw === 'error') {
|
|
68
|
+
status = 'failed';
|
|
69
|
+
}
|
|
70
|
+
return { status, exitCode };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The shell id a poll/kill tool call targets, across the new + old vocabularies.
|
|
74
|
+
function targetShellId(input) {
|
|
75
|
+
if (!input || typeof input !== 'object') return null;
|
|
76
|
+
return input.task_id || input.bash_id || input.shell_id || null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const POLL_TOOLS = new Set(['TaskOutput', 'BashOutput']);
|
|
80
|
+
const KILL_TOOLS = new Set(['TaskStop', 'KillShell', 'KillBash']);
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {object} opts
|
|
84
|
+
* @param {(task:object)=>void} [opts.broadcast] called with the public task view on every change
|
|
85
|
+
* @param {()=>number} [opts.now]
|
|
86
|
+
* @param {number} [opts.pollMs]
|
|
87
|
+
* @param {number} [opts.quietMs]
|
|
88
|
+
* @param {number} [opts.maxPerWorkspace]
|
|
89
|
+
* @param {object} [opts.fsImpl] injectable fs (tests)
|
|
90
|
+
*/
|
|
91
|
+
export function createBackgroundTasks(opts = {}) {
|
|
92
|
+
const broadcast = typeof opts.broadcast === 'function' ? opts.broadcast : () => {};
|
|
93
|
+
const now = opts.now || Date.now;
|
|
94
|
+
const pollMs = opts.pollMs || DEFAULT_POLL_MS;
|
|
95
|
+
const quietMs = opts.quietMs || DEFAULT_QUIET_MS;
|
|
96
|
+
const maxPerWs = opts.maxPerWorkspace || DEFAULT_MAX_PER_WS;
|
|
97
|
+
const fsi = opts.fsImpl || fs;
|
|
98
|
+
// The OS holder/kill engine (Restart Manager on Windows, lsof on Unix). Injectable
|
|
99
|
+
// so tests never spawn PowerShell or kill a real process.
|
|
100
|
+
const holders = opts.holders || { resolveHolders: defaultResolveHolders, killTree: defaultKillTree };
|
|
101
|
+
|
|
102
|
+
// taskId (= the LAUNCH tool_use id) → task. Keying by the launch id (not the
|
|
103
|
+
// shellId) means the chat card, which is created at launch time and only knows the
|
|
104
|
+
// tool_use id, can correlate to live updates without a round-trip.
|
|
105
|
+
const tasks = new Map();
|
|
106
|
+
const byShell = new Map(); // shellId → taskId (for poll/kill correlation)
|
|
107
|
+
const pollTargets = new Map(); // poll tool_use id → shellId (result arrives later, idless)
|
|
108
|
+
|
|
109
|
+
let timer = null;
|
|
110
|
+
|
|
111
|
+
function publicView(t) {
|
|
112
|
+
return {
|
|
113
|
+
id: t.id,
|
|
114
|
+
shellId: t.shellId,
|
|
115
|
+
command: t.command,
|
|
116
|
+
description: t.description,
|
|
117
|
+
status: t.status,
|
|
118
|
+
exitCode: t.exitCode,
|
|
119
|
+
quiet: t.quiet,
|
|
120
|
+
startedAt: t.startedAt,
|
|
121
|
+
endedAt: t.endedAt,
|
|
122
|
+
lastOutputAt: t.lastOutputAt,
|
|
123
|
+
hasOutput: t.everExisted,
|
|
124
|
+
workspaceId: t.workspaceId,
|
|
125
|
+
threadId: t.threadId,
|
|
126
|
+
// NB: the absolute outputFile path is deliberately NOT exposed (it leaks the
|
|
127
|
+
// home dir — same rule the Logs route follows). Tail it via the by-id endpoint.
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function emit(t) {
|
|
132
|
+
broadcast(publicView(t));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function prune(workspaceId) {
|
|
136
|
+
const ours = [...tasks.values()].filter((t) => t.workspaceId === workspaceId);
|
|
137
|
+
if (ours.length <= maxPerWs) return;
|
|
138
|
+
// Drop oldest TERMINAL tasks first; never evict something still running.
|
|
139
|
+
const terminal = ours.filter((t) => TERMINAL.has(t.status)).sort((a, b) => a.startedAt - b.startedAt);
|
|
140
|
+
let over = ours.length - maxPerWs;
|
|
141
|
+
for (const t of terminal) {
|
|
142
|
+
if (over <= 0) break;
|
|
143
|
+
tasks.delete(t.id);
|
|
144
|
+
if (t.shellId) byShell.delete(t.shellId);
|
|
145
|
+
over -= 1;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function startLaunch(workspaceId, threadId, chunk) {
|
|
150
|
+
const id = chunk.id;
|
|
151
|
+
if (!id || tasks.has(id)) return;
|
|
152
|
+
const input = chunk.input || {};
|
|
153
|
+
const t = {
|
|
154
|
+
id,
|
|
155
|
+
shellId: null,
|
|
156
|
+
outputFile: null,
|
|
157
|
+
command: String(input.command || '').slice(0, 2000),
|
|
158
|
+
description: String(input.description || '').slice(0, 300),
|
|
159
|
+
status: 'running',
|
|
160
|
+
exitCode: null,
|
|
161
|
+
quiet: false,
|
|
162
|
+
startedAt: now(),
|
|
163
|
+
endedAt: null,
|
|
164
|
+
lastOutputAt: null,
|
|
165
|
+
lastSize: 0,
|
|
166
|
+
everExisted: false,
|
|
167
|
+
workspaceId,
|
|
168
|
+
threadId: threadId || null,
|
|
169
|
+
};
|
|
170
|
+
tasks.set(id, t);
|
|
171
|
+
prune(workspaceId);
|
|
172
|
+
emit(t);
|
|
173
|
+
ensureTimer();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function fillLaunchResult(id, content) {
|
|
177
|
+
const t = tasks.get(id);
|
|
178
|
+
if (!t || t.shellId) return; // not a launch, or already resolved
|
|
179
|
+
const parsed = parseLaunchResult(content);
|
|
180
|
+
if (!parsed) return;
|
|
181
|
+
t.shellId = parsed.shellId;
|
|
182
|
+
t.outputFile = parsed.outputFile;
|
|
183
|
+
byShell.set(parsed.shellId, id);
|
|
184
|
+
emit(t);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function applyPoll(shellId, parsed) {
|
|
188
|
+
const id = byShell.get(shellId);
|
|
189
|
+
if (!id) return;
|
|
190
|
+
const t = tasks.get(id);
|
|
191
|
+
if (!t || TERMINAL.has(t.status)) return;
|
|
192
|
+
if (parsed.status === 'running') {
|
|
193
|
+
// A live poll proves it's alive right now — clears any `quiet` flag.
|
|
194
|
+
t.lastOutputAt = now();
|
|
195
|
+
if (t.quiet) { t.quiet = false; emit(t); }
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
t.status = parsed.status;
|
|
199
|
+
t.exitCode = parsed.exitCode;
|
|
200
|
+
t.endedAt = now();
|
|
201
|
+
t.quiet = false;
|
|
202
|
+
emit(t);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function applyKill(shellId) {
|
|
206
|
+
const id = byShell.get(shellId);
|
|
207
|
+
if (!id) return;
|
|
208
|
+
const t = tasks.get(id);
|
|
209
|
+
if (!t || TERMINAL.has(t.status)) return;
|
|
210
|
+
t.status = 'killed';
|
|
211
|
+
t.endedAt = now();
|
|
212
|
+
t.quiet = false;
|
|
213
|
+
emit(t);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Fold one normalized chunk (from session.on('chunk')) into the registry. Only
|
|
218
|
+
* tool-use/tool-result chunks matter; everything else is ignored cheaply.
|
|
219
|
+
*/
|
|
220
|
+
function observe(workspaceId, threadId, chunk) {
|
|
221
|
+
if (!chunk || !workspaceId) return;
|
|
222
|
+
if (chunk.type === 'tool-use') {
|
|
223
|
+
const name = chunk.name;
|
|
224
|
+
const input = chunk.input || {};
|
|
225
|
+
if (name === 'Bash' && input.run_in_background === true) {
|
|
226
|
+
startLaunch(workspaceId, threadId, chunk);
|
|
227
|
+
} else if (POLL_TOOLS.has(name)) {
|
|
228
|
+
const sid = targetShellId(input);
|
|
229
|
+
if (sid && chunk.id) pollTargets.set(chunk.id, sid);
|
|
230
|
+
} else if (KILL_TOOLS.has(name)) {
|
|
231
|
+
const sid = targetShellId(input);
|
|
232
|
+
if (sid) applyKill(sid);
|
|
233
|
+
}
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (chunk.type === 'tool-result') {
|
|
237
|
+
const id = chunk.id;
|
|
238
|
+
if (!id) return;
|
|
239
|
+
if (tasks.has(id)) {
|
|
240
|
+
// The launching Bash's own result — carries the shell id + output path.
|
|
241
|
+
fillLaunchResult(id, chunk.content);
|
|
242
|
+
} else if (pollTargets.has(id)) {
|
|
243
|
+
const sid = pollTargets.get(id);
|
|
244
|
+
pollTargets.delete(id);
|
|
245
|
+
const parsed = parsePollResult(chunk.content);
|
|
246
|
+
if (parsed) applyPoll(sid, parsed);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// --- file-watcher: process-independent liveness for the gap between launch and
|
|
252
|
+
// the agent's next poll (which, in the per-turn model, may never come). -------
|
|
253
|
+
function tick() {
|
|
254
|
+
const t = now();
|
|
255
|
+
let anyLive = false;
|
|
256
|
+
for (const task of tasks.values()) {
|
|
257
|
+
if (TERMINAL.has(task.status)) continue;
|
|
258
|
+
anyLive = true;
|
|
259
|
+
if (!task.outputFile) continue;
|
|
260
|
+
let size = null;
|
|
261
|
+
try {
|
|
262
|
+
size = fsi.statSync(task.outputFile).size;
|
|
263
|
+
} catch {
|
|
264
|
+
size = null; // not created yet, or cleaned up after finishing
|
|
265
|
+
}
|
|
266
|
+
if (size !== null) {
|
|
267
|
+
if (!task.everExisted) task.everExisted = true;
|
|
268
|
+
if (size > task.lastSize) {
|
|
269
|
+
task.lastSize = size;
|
|
270
|
+
task.lastOutputAt = t;
|
|
271
|
+
if (task.quiet) { task.quiet = false; emit(task); }
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
// No new bytes this tick. Flag (or clear) the `quiet` hint honestly.
|
|
276
|
+
const idle = t - (task.lastOutputAt || task.startedAt);
|
|
277
|
+
const quiet = idle >= quietMs;
|
|
278
|
+
if (quiet !== task.quiet) { task.quiet = quiet; emit(task); }
|
|
279
|
+
}
|
|
280
|
+
if (!anyLive) stopTimer(); // nothing live → stop polling; observe() restarts it
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function ensureTimer() {
|
|
284
|
+
if (timer) return;
|
|
285
|
+
timer = setInterval(tick, pollMs);
|
|
286
|
+
if (typeof timer.unref === 'function') timer.unref(); // never hold the process open
|
|
287
|
+
}
|
|
288
|
+
function stopTimer() {
|
|
289
|
+
if (timer) { clearInterval(timer); timer = null; }
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
observe,
|
|
294
|
+
/** All tasks for a workspace, newest first. */
|
|
295
|
+
list(workspaceId) {
|
|
296
|
+
return [...tasks.values()]
|
|
297
|
+
.filter((t) => t.workspaceId === workspaceId)
|
|
298
|
+
.sort((a, b) => b.startedAt - a.startedAt)
|
|
299
|
+
.map(publicView);
|
|
300
|
+
},
|
|
301
|
+
/**
|
|
302
|
+
* Stop a running task: find who holds its output file open (Restart Manager /
|
|
303
|
+
* lsof) and kill that tree. No PID was captured at launch — the output-file path
|
|
304
|
+
* we already track IS the handle. Returns {ok, status, ...}.
|
|
305
|
+
*
|
|
306
|
+
* `excludePids` MUST include any live claude pids; this method always also
|
|
307
|
+
* excludes our own pid. That guard is load-bearing: RM/lsof report READERS too,
|
|
308
|
+
* and the server tails this very file — without it we could kill ourselves.
|
|
309
|
+
*/
|
|
310
|
+
async stop(workspaceId, taskId, { excludePids = [], retries = 4, retryDelayMs = 400 } = {}) {
|
|
311
|
+
const t = tasks.get(taskId);
|
|
312
|
+
if (!t || t.workspaceId !== workspaceId) return { ok: false, error: 'not-found' };
|
|
313
|
+
if (TERMINAL.has(t.status)) return { ok: true, status: t.status, alreadyTerminal: true };
|
|
314
|
+
if (!t.outputFile) return { ok: false, error: 'no-output-file' };
|
|
315
|
+
const exclude = new Set([process.pid, ...excludePids].map(Number).filter(Boolean));
|
|
316
|
+
// Retry: a single RM/lsof shot occasionally misses a live task (measured ~10%).
|
|
317
|
+
// Three clean misses on a live task is ~0.1%; a genuine empty means it finished.
|
|
318
|
+
let pids = [];
|
|
319
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
320
|
+
const found = await holders.resolveHolders(t.outputFile);
|
|
321
|
+
pids = (found || []).filter((p) => !exclude.has(Number(p)));
|
|
322
|
+
if (pids.length) break;
|
|
323
|
+
if (attempt < retries) await sleep(retryDelayMs);
|
|
324
|
+
}
|
|
325
|
+
if (!pids.length) {
|
|
326
|
+
// Nobody holds it after retries → the process already exited. It finished; we
|
|
327
|
+
// did NOT stop it — say so honestly (never claim a kill we didn't make).
|
|
328
|
+
if (!TERMINAL.has(t.status)) { t.status = 'completed'; t.endedAt = now(); t.quiet = false; emit(t); }
|
|
329
|
+
return { ok: true, status: 'completed', note: 'already-finished' };
|
|
330
|
+
}
|
|
331
|
+
const killed = await holders.killTree(pids);
|
|
332
|
+
t.status = 'killed';
|
|
333
|
+
t.endedAt = now();
|
|
334
|
+
t.exitCode = null;
|
|
335
|
+
t.quiet = false;
|
|
336
|
+
emit(t);
|
|
337
|
+
return { ok: true, status: 'killed', pids, killed };
|
|
338
|
+
},
|
|
339
|
+
/** Tail the output file for one task (best-effort; null if unavailable). */
|
|
340
|
+
tail(workspaceId, taskId, bytes = TAIL_BYTES) {
|
|
341
|
+
const t = tasks.get(taskId);
|
|
342
|
+
if (!t || t.workspaceId !== workspaceId || !t.outputFile) return null;
|
|
343
|
+
try {
|
|
344
|
+
const stat = fsi.statSync(t.outputFile);
|
|
345
|
+
const start = Math.max(0, stat.size - bytes);
|
|
346
|
+
const fd = fsi.openSync(t.outputFile, 'r');
|
|
347
|
+
try {
|
|
348
|
+
const len = stat.size - start;
|
|
349
|
+
const buf = Buffer.alloc(len);
|
|
350
|
+
fsi.readSync(fd, buf, 0, len, start);
|
|
351
|
+
return buf.toString('utf8');
|
|
352
|
+
} finally {
|
|
353
|
+
fsi.closeSync(fd);
|
|
354
|
+
}
|
|
355
|
+
} catch {
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
},
|
|
359
|
+
/** Test/diagnostic: the internal task (incl. outputFile). */
|
|
360
|
+
_get(taskId) {
|
|
361
|
+
return tasks.get(taskId) || null;
|
|
362
|
+
},
|
|
363
|
+
_tick: tick,
|
|
364
|
+
dispose: stopTimer,
|
|
365
|
+
};
|
|
366
|
+
}
|
package/server/src/index.mjs
CHANGED
|
@@ -84,6 +84,7 @@ import { loadAccount } from './account.mjs';
|
|
|
84
84
|
import { getOperatorToken } from './operator.mjs';
|
|
85
85
|
import { runDoctor } from './doctor.mjs';
|
|
86
86
|
import { appendLine, tailFile, logFile, listLogs, TAILABLE, globalDir } from './logpaths.mjs';
|
|
87
|
+
import { createBackgroundTasks } from './background-tasks.mjs';
|
|
87
88
|
import { SessionReporter } from './session-reporter.mjs';
|
|
88
89
|
import { TranscriptRecorder } from './transcript.mjs';
|
|
89
90
|
import { loadObservabilityConsent, setObservabilityConsent } from './observability.mjs';
|
|
@@ -578,6 +579,23 @@ export async function createServer(overrides = {}) {
|
|
|
578
579
|
}
|
|
579
580
|
}
|
|
580
581
|
|
|
582
|
+
// Background-task frames are WORKSPACE-scoped but THREAD-agnostic: a job the agent
|
|
583
|
+
// launched in chat A is still "running in this workspace", so the topbar tray and
|
|
584
|
+
// the canvas Tasks block (which span threads) must see it. Sent to every socket on
|
|
585
|
+
// the workspace, regardless of which chat thread it's bound to.
|
|
586
|
+
function broadcastTask(task) {
|
|
587
|
+
if (!task || !task.workspaceId) return;
|
|
588
|
+
const data = JSON.stringify({ type: 'task', task });
|
|
589
|
+
for (const ws of chatClients) {
|
|
590
|
+
if (ws.readyState !== ws.OPEN) continue;
|
|
591
|
+
if (ws._wsWorkspaceId !== task.workspaceId) continue;
|
|
592
|
+
ws.send(data);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
// The registry persists ACROSS turns (a background job outlives the `claude -p`
|
|
596
|
+
// process that spawned it), so it lives here in server scope — never per-session.
|
|
597
|
+
const bgTasks = createBackgroundTasks({ broadcast: broadcastTask });
|
|
598
|
+
|
|
581
599
|
/**
|
|
582
600
|
* Run one chat turn: spawn the agent, stream every chunk to every chat
|
|
583
601
|
* client, and persist the resulting session id so the next turn resumes it.
|
|
@@ -644,6 +662,10 @@ export async function createServer(overrides = {}) {
|
|
|
644
662
|
return;
|
|
645
663
|
}
|
|
646
664
|
if (chunk.type === 'error') sawError = true;
|
|
665
|
+
// Track background jobs (Bash run_in_background:true + any TaskOutput/BashOutput
|
|
666
|
+
// polls) so the chat card, topbar tray, and Tasks block can show live status.
|
|
667
|
+
// Cheap no-op for every other chunk type.
|
|
668
|
+
bgTasks.observe(workspace.id, threadId, chunk);
|
|
647
669
|
broadcastChat({ type: 'chunk', messageId: id, chunk }, workspace.id, threadId);
|
|
648
670
|
activityBus.publish({ type: 'chat-stream', messageId: id, chunk });
|
|
649
671
|
// Surface the turn's token/cost totals so the activity bar can show
|
|
@@ -2554,6 +2576,45 @@ export async function createServer(overrides = {}) {
|
|
|
2554
2576
|
return c.json({ name, lines, body: tailFile(logFile(name), lines) });
|
|
2555
2577
|
});
|
|
2556
2578
|
|
|
2579
|
+
// Background tasks (the agent's run_in_background jobs) — what's running, finished,
|
|
2580
|
+
// or gone quiet. Owner-only (fileTree): these expose the commands the agent ran on
|
|
2581
|
+
// the host, which a share-link viewer must not see (same posture as Logs). The
|
|
2582
|
+
// absolute output-file path is never returned (it would leak the home dir); tail it
|
|
2583
|
+
// by id instead.
|
|
2584
|
+
app.get('/api/workspace/tasks', (c) => {
|
|
2585
|
+
const forbidden = require(c, 'fileTree');
|
|
2586
|
+
if (forbidden) return forbidden;
|
|
2587
|
+
return c.json({ tasks: bgTasks.list(workspaceFor(c).id) });
|
|
2588
|
+
});
|
|
2589
|
+
|
|
2590
|
+
app.get('/api/workspace/tasks/:id/output', (c) => {
|
|
2591
|
+
const forbidden = require(c, 'fileTree');
|
|
2592
|
+
if (forbidden) return forbidden;
|
|
2593
|
+
const id = c.req.param('id');
|
|
2594
|
+
const bytes = Math.min(Number(c.req.query('bytes')) || 16384, 200_000);
|
|
2595
|
+
const output = bgTasks.tail(workspaceFor(c).id, id, bytes);
|
|
2596
|
+
if (output === null) return c.json({ error: 'no-output', id }, 404);
|
|
2597
|
+
return c.json({ id, output });
|
|
2598
|
+
});
|
|
2599
|
+
|
|
2600
|
+
// Stop a running background task — resolve who holds its output file open and kill
|
|
2601
|
+
// that tree (Restart Manager on Windows, lsof on Unix). Owner-only. We pass every
|
|
2602
|
+
// LIVE claude pid to exclude so a Stop during an in-flight turn can't kill the
|
|
2603
|
+
// active agent (and `stop` always also excludes our own server pid — the guard that
|
|
2604
|
+
// matters, since RM/lsof report the file's readers too).
|
|
2605
|
+
app.post('/api/workspace/tasks/:id/stop', async (c) => {
|
|
2606
|
+
const forbidden = require(c, 'fileTree');
|
|
2607
|
+
if (forbidden) return forbidden;
|
|
2608
|
+
const id = c.req.param('id');
|
|
2609
|
+
const claudePids = [...currentTurns.values()]
|
|
2610
|
+
.map((t) => t?.session?.proc?.pid)
|
|
2611
|
+
.filter((p) => Number.isInteger(p));
|
|
2612
|
+
auditAction(c, 'task-stop', `id=${id}`);
|
|
2613
|
+
const res = await bgTasks.stop(workspaceFor(c).id, id, { excludePids: claudePids });
|
|
2614
|
+
if (!res.ok) return c.json(res, res.error === 'not-found' ? 404 : 400);
|
|
2615
|
+
return c.json(res);
|
|
2616
|
+
});
|
|
2617
|
+
|
|
2557
2618
|
// --- component inbox ---
|
|
2558
2619
|
app.get('/api/inbox', async (c) => {
|
|
2559
2620
|
// Enforce the `inbox` capability (partner-only). It existed in the matrix
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Cross-platform "who is running this background task, and stop it" — without ever
|
|
2
|
+
// capturing a PID at launch (which fighting claude's opaque background mechanism
|
|
3
|
+
// proved unreliable). Instead we use the one thing we already know: the task's
|
|
4
|
+
// OUTPUT FILE path. The running process holds that file open, so:
|
|
5
|
+
//
|
|
6
|
+
// resolveHolders(file) -> the PIDs holding it open
|
|
7
|
+
// killTree(pids) -> stop them (whole tree)
|
|
8
|
+
//
|
|
9
|
+
// Windows: Restart Manager (rstrtmgr.dll) — the API installers use to find which
|
|
10
|
+
// process locks a file — driven by a tiny embedded PowerShell helper.
|
|
11
|
+
// macOS/Linux: `lsof -t` (terse, PIDs only).
|
|
12
|
+
//
|
|
13
|
+
// SAFETY (proven necessary in the spike): RM/lsof also report READERS — our own
|
|
14
|
+
// server tails the file, so a naive kill could `taskkill` the wild-workspace server
|
|
15
|
+
// itself. Callers MUST pass the pids to exclude (our pid + any live claude pids);
|
|
16
|
+
// this module never kills blindly.
|
|
17
|
+
|
|
18
|
+
import { execFile } from 'node:child_process';
|
|
19
|
+
import { promisify } from 'node:util';
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import os from 'node:os';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
|
|
24
|
+
const execFileP = promisify(execFile);
|
|
25
|
+
|
|
26
|
+
// PowerShell Restart Manager locker-finder. Embedded (not a shipped .ps1) so it
|
|
27
|
+
// works regardless of how the package is published; written to a temp file on first
|
|
28
|
+
// use and invoked with -File. Prints the holding PIDs, one per line.
|
|
29
|
+
const RM_PS = [
|
|
30
|
+
'param([string]$Path)',
|
|
31
|
+
"$ErrorActionPreference='SilentlyContinue'",
|
|
32
|
+
"$sig = @'",
|
|
33
|
+
'using System; using System.Runtime.InteropServices; using System.Collections.Generic;',
|
|
34
|
+
'public static class RmFile {',
|
|
35
|
+
' [StructLayout(LayoutKind.Sequential)] struct RM_UNIQUE_PROCESS { public int dwProcessId; public System.Runtime.InteropServices.ComTypes.FILETIME ProcessStartTime; }',
|
|
36
|
+
' [StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)] struct RM_PROCESS_INFO {',
|
|
37
|
+
' public RM_UNIQUE_PROCESS Process;',
|
|
38
|
+
' [MarshalAs(UnmanagedType.ByValTStr, SizeConst=256)] public string strAppName;',
|
|
39
|
+
' [MarshalAs(UnmanagedType.ByValTStr, SizeConst=64)] public string strServiceShortName;',
|
|
40
|
+
' public int ApplicationType; public uint AppStatus; public uint TSSessionId; [MarshalAs(UnmanagedType.Bool)] public bool bRestartable; }',
|
|
41
|
+
' [DllImport("rstrtmgr.dll", CharSet=CharSet.Unicode)] static extern int RmStartSession(out uint h, int flags, string key);',
|
|
42
|
+
' [DllImport("rstrtmgr.dll")] static extern int RmEndSession(uint h);',
|
|
43
|
+
' [DllImport("rstrtmgr.dll", CharSet=CharSet.Unicode)] static extern int RmRegisterResources(uint h, uint nf, string[] files, uint na, RM_UNIQUE_PROCESS[] apps, uint ns, string[] svc);',
|
|
44
|
+
' [DllImport("rstrtmgr.dll")] static extern int RmGetList(uint h, out uint need, ref uint count, [In,Out] RM_PROCESS_INFO[] info, ref uint reasons);',
|
|
45
|
+
' public static List<int> Lockers(string p){ var r=new List<int>(); uint h; if(RmStartSession(out h,0,Guid.NewGuid().ToString())!=0)return r;',
|
|
46
|
+
' try{ if(RmRegisterResources(h,1,new[]{p},0,null,0,null)!=0)return r; uint need=0,count=0,reasons=0; int res=RmGetList(h,out need,ref count,null,ref reasons);',
|
|
47
|
+
' if(res==234){ var info=new RM_PROCESS_INFO[need]; count=need; if(RmGetList(h,out need,ref count,info,ref reasons)==0){ for(int i=0;i<count;i++) r.Add(info[i].Process.dwProcessId);} } }',
|
|
48
|
+
' finally{ RmEndSession(h); } return r; } }',
|
|
49
|
+
"'@",
|
|
50
|
+
'Add-Type -TypeDefinition $sig',
|
|
51
|
+
'[RmFile]::Lockers($Path) | ForEach-Object { $_ }',
|
|
52
|
+
].join('\r\n');
|
|
53
|
+
|
|
54
|
+
let _rmScriptPath = null;
|
|
55
|
+
function ensureRmScript() {
|
|
56
|
+
if (_rmScriptPath && fs.existsSync(_rmScriptPath)) return _rmScriptPath;
|
|
57
|
+
const p = path.join(os.tmpdir(), 'wild-workspace-rm-holders.ps1');
|
|
58
|
+
try { fs.writeFileSync(p, RM_PS, 'utf8'); _rmScriptPath = p; } catch { _rmScriptPath = null; }
|
|
59
|
+
return _rmScriptPath;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function parsePids(stdout) {
|
|
63
|
+
return String(stdout || '')
|
|
64
|
+
.split(/\r?\n/)
|
|
65
|
+
.map((l) => parseInt(l.trim(), 10))
|
|
66
|
+
.filter((n) => Number.isInteger(n) && n > 0);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* PIDs currently holding `filePath` open (the running task's process tree + any
|
|
71
|
+
* readers). Returns [] on any failure or when nothing holds it (task finished).
|
|
72
|
+
*/
|
|
73
|
+
export async function resolveHolders(filePath, { exec = execFileP, platform = process.platform } = {}) {
|
|
74
|
+
if (!filePath) return [];
|
|
75
|
+
try {
|
|
76
|
+
if (platform === 'win32') {
|
|
77
|
+
const script = ensureRmScript();
|
|
78
|
+
if (!script) return [];
|
|
79
|
+
const { stdout } = await exec(
|
|
80
|
+
'powershell',
|
|
81
|
+
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', script, '-Path', filePath],
|
|
82
|
+
{ timeout: 9000, windowsHide: true },
|
|
83
|
+
);
|
|
84
|
+
return uniq(parsePids(stdout));
|
|
85
|
+
}
|
|
86
|
+
const { stdout } = await exec('lsof', ['-t', '--', filePath], { timeout: 9000 });
|
|
87
|
+
return uniq(parsePids(stdout));
|
|
88
|
+
} catch (e) {
|
|
89
|
+
// lsof exits 1 with no output when nothing holds the file — that's "empty", not
|
|
90
|
+
// an error. Salvage any stdout the failed call captured, else treat as empty.
|
|
91
|
+
if (e && e.stdout) return uniq(parsePids(e.stdout));
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Force-kill each pid AND its child tree. Returns per-pid {pid, ok}. */
|
|
97
|
+
export async function killTree(pids, { exec = execFileP, platform = process.platform, kill = (p, s) => process.kill(p, s) } = {}) {
|
|
98
|
+
const out = [];
|
|
99
|
+
for (const pid of uniq(pids)) {
|
|
100
|
+
try {
|
|
101
|
+
if (platform === 'win32') {
|
|
102
|
+
await exec('taskkill', ['/PID', String(pid), '/T', '/F'], { timeout: 9000, windowsHide: true });
|
|
103
|
+
} else {
|
|
104
|
+
// SIGKILL the holder; lsof returned the whole fd-sharing chain, so killing
|
|
105
|
+
// each holder takes down the tree. A transient child (e.g. `sleep`) exits on
|
|
106
|
+
// its own once its parent is gone.
|
|
107
|
+
try { kill(pid, 'SIGKILL'); } catch { /* already gone */ }
|
|
108
|
+
}
|
|
109
|
+
out.push({ pid, ok: true });
|
|
110
|
+
} catch (e) {
|
|
111
|
+
// taskkill exits non-zero with "process not found" when /T already reaped it as
|
|
112
|
+
// part of an earlier pid's tree — that's success, not failure.
|
|
113
|
+
const msg = String((e && (e.stderr || e.message)) || '');
|
|
114
|
+
out.push({ pid, ok: /not found|not be terminated/i.test(msg) });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function uniq(arr) {
|
|
121
|
+
return [...new Set(arr)];
|
|
122
|
+
}
|