@dotdrelle/wiki-manager 0.12.1 → 0.12.8

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 (43) hide show
  1. package/README.md +49 -8
  2. package/bin/wiki-manager +23 -12
  3. package/docker-compose.yml +0 -1
  4. package/mcp.endpoints.example.json +0 -6
  5. package/package.json +3 -2
  6. package/src/activity/activityAggregator.js +34 -3
  7. package/src/activity/activityAggregator.test.js +32 -0
  8. package/src/agent/graph.js +272 -22
  9. package/src/agent/graph.test.js +357 -1
  10. package/src/cli/wiki-manager.js +33 -2
  11. package/src/commands/slash.js +74 -1
  12. package/src/commands/slash.test.js +36 -0
  13. package/src/core/activity.js +4 -0
  14. package/src/core/activity.test.js +9 -1
  15. package/src/core/agentEvents.js +30 -0
  16. package/src/core/agentEvents.test.js +42 -1
  17. package/src/core/buildInfo.js +58 -0
  18. package/src/core/buildInfo.json +4 -0
  19. package/src/core/buildInfo.test.js +14 -0
  20. package/src/core/env.js +30 -1
  21. package/src/core/mcp.js +21 -1
  22. package/src/core/mcp.test.js +25 -0
  23. package/src/core/runtimeLog.js +6 -1
  24. package/src/core/runtimeLog.test.js +3 -1
  25. package/src/runtime/client.js +22 -0
  26. package/src/runtime/controlMessages.js +2 -2
  27. package/src/runtime/recoveryManager.js +54 -0
  28. package/src/runtime/recoveryManager.test.js +73 -0
  29. package/src/runtime/runner.js +49 -13
  30. package/src/runtime/runner.test.js +196 -0
  31. package/src/runtime/server.js +67 -8
  32. package/src/runtime/server.test.js +223 -6
  33. package/src/runtime/store.js +48 -3
  34. package/src/runtime/store.test.js +32 -0
  35. package/src/runtime/supervisor.js +77 -1
  36. package/src/shell/RightPane.tsx +124 -31
  37. package/src/shell/SetupWizard.tsx +13 -1
  38. package/src/shell/repl.js +78 -9
  39. package/src/shell/repl.test.js +114 -1
  40. package/src/shell/tui.tsx +4 -1
  41. package/src/shell/useAgent.ts +32 -3
  42. package/src/shell/useSession.ts +14 -1
  43. package/wiki-workspace +33 -0
@@ -224,15 +224,18 @@ test('runtime server accepts only one active run', async (t) => {
224
224
  fetch(url, {
225
225
  method: 'POST',
226
226
  headers: { 'Content-Type': 'application/json' },
227
- body: JSON.stringify({ input: 'second' }),
227
+ // Messages during an active run are now CLASSIFIED (observe answers
228
+ // immediately, cancel aborts…) — enqueueing a future run is the
229
+ // explicit intent.
230
+ body: JSON.stringify({ input: 'second', intent: 'enqueue' }),
228
231
  }),
229
232
  ]);
230
233
 
231
234
  assert.deepEqual([first.status, second.status], [202, 202]);
232
235
  const bodies = [await first.json(), await second.json()];
233
- const acceptedRun = bodies.find((body) => body.accepted && !body.queued);
234
- const queuedRun = bodies.find((body) => body.queued);
235
- assert.equal(queuedRun.kind, 'enqueue_run');
236
+ const acceptedRun = bodies.find((body) => body.runId && body.accepted);
237
+ const queuedRun = bodies.find((body) => body.kind === 'enqueue_run');
238
+ assert.equal(queuedRun.accepted, true);
236
239
  assert.equal(acceptedRun.accepted, true);
237
240
  assert.match(acceptedRun.runId, /^[0-9a-f-]{36}$/);
238
241
  assert.equal(runCount, 1);
@@ -287,6 +290,158 @@ test('runtime server returns the accepted run id and passes it to the runner', a
287
290
  }
288
291
  });
289
292
 
293
+ test('runtime server kill aborts active run and interrupts workspace work', async (t) => {
294
+ let abortSeen = false;
295
+ let cancelCalled = false;
296
+ let interruptArgs = null;
297
+ let taskCancelArgs = null;
298
+ let handle;
299
+ try {
300
+ handle = await startRuntimeServer({
301
+ host: '127.0.0.1',
302
+ port: 0,
303
+ store: {
304
+ dbPath: ':memory:',
305
+ getState: () => ({ status: 'running' }),
306
+ listEvents: () => [],
307
+ interruptRuns: (args) => {
308
+ interruptArgs = args;
309
+ return 1;
310
+ },
311
+ cancelActiveTasksForInterruptedRuns: (args) => {
312
+ taskCancelArgs = args;
313
+ return 3;
314
+ },
315
+ },
316
+ session: { controlQueue: [{ id: 'control-1', workspace: 'docs', status: 'queued' }] },
317
+ run: async (_context, _body, { signal }) => {
318
+ signal.addEventListener('abort', () => { abortSeen = true; }, { once: true });
319
+ await new Promise(() => {});
320
+ },
321
+ cancel: async () => {
322
+ cancelCalled = true;
323
+ },
324
+ });
325
+ } catch (err) {
326
+ if (err?.code === 'EPERM') {
327
+ t.skip('network listen is not permitted in this sandbox');
328
+ return;
329
+ }
330
+ throw err;
331
+ }
332
+
333
+ try {
334
+ const base = `http://127.0.0.1:${handle.port}`;
335
+ const runResponse = await fetch(`${base}/run`, {
336
+ method: 'POST',
337
+ headers: { 'Content-Type': 'application/json' },
338
+ body: JSON.stringify({ input: 'build', workspace: 'docs' }),
339
+ });
340
+ assert.equal(runResponse.status, 202);
341
+
342
+ const killResponse = await fetch(`${base}/kill?workspace=docs`, { method: 'POST' });
343
+ assert.equal(killResponse.status, 202);
344
+ assert.deepEqual(await killResponse.json(), {
345
+ killed: true,
346
+ workspace: 'docs',
347
+ runId: null,
348
+ runs: 1,
349
+ tasks: 3,
350
+ queued: 1,
351
+ });
352
+ assert.equal(abortSeen, true);
353
+ assert.equal(cancelCalled, true);
354
+ assert.equal(interruptArgs.workspace, 'docs');
355
+ assert.equal(interruptArgs.runId, null);
356
+ assert.deepEqual(taskCancelArgs, { workspace: 'docs', runId: null });
357
+ } finally {
358
+ await handle.close();
359
+ }
360
+ });
361
+
362
+ test('runtime server kill succeeds without an active run', async (t) => {
363
+ let handle;
364
+ try {
365
+ handle = await startRuntimeServer({
366
+ host: '127.0.0.1',
367
+ port: 0,
368
+ store: {
369
+ dbPath: ':memory:',
370
+ getState: () => ({ status: 'idle' }),
371
+ listEvents: () => [],
372
+ interruptRuns: () => 0,
373
+ cancelActiveTasksForInterruptedRuns: () => 0,
374
+ },
375
+ session: {},
376
+ run: async () => {},
377
+ });
378
+ } catch (err) {
379
+ if (err?.code === 'EPERM') {
380
+ t.skip('network listen is not permitted in this sandbox');
381
+ return;
382
+ }
383
+ throw err;
384
+ }
385
+
386
+ try {
387
+ const response = await fetch(`http://127.0.0.1:${handle.port}/kill?workspace=docs`, { method: 'POST' });
388
+ assert.equal(response.status, 202);
389
+ assert.deepEqual(await response.json(), {
390
+ killed: true,
391
+ workspace: 'docs',
392
+ runId: null,
393
+ runs: 0,
394
+ tasks: 0,
395
+ queued: 0,
396
+ });
397
+ } finally {
398
+ await handle.close();
399
+ }
400
+ });
401
+
402
+ test('runtime server kill can target a specific run id', async (t) => {
403
+ let interruptArgs = null;
404
+ let taskCancelArgs = null;
405
+ let handle;
406
+ try {
407
+ handle = await startRuntimeServer({
408
+ host: '127.0.0.1',
409
+ port: 0,
410
+ store: {
411
+ dbPath: ':memory:',
412
+ getState: () => ({ status: 'idle' }),
413
+ listEvents: () => [],
414
+ interruptRuns: (args) => {
415
+ interruptArgs = args;
416
+ return 1;
417
+ },
418
+ cancelActiveTasksForInterruptedRuns: (args) => {
419
+ taskCancelArgs = args;
420
+ return 2;
421
+ },
422
+ },
423
+ session: {},
424
+ run: async () => {},
425
+ });
426
+ } catch (err) {
427
+ if (err?.code === 'EPERM') {
428
+ t.skip('network listen is not permitted in this sandbox');
429
+ return;
430
+ }
431
+ throw err;
432
+ }
433
+
434
+ try {
435
+ const response = await fetch(`http://127.0.0.1:${handle.port}/kill?workspace=docs&runId=run-1`, { method: 'POST' });
436
+ assert.equal(response.status, 202);
437
+ assert.equal((await response.json()).runId, 'run-1');
438
+ assert.equal(interruptArgs.runId, 'run-1');
439
+ assert.deepEqual(taskCancelArgs, { workspace: 'docs', runId: 'run-1' });
440
+ } finally {
441
+ await handle.close();
442
+ }
443
+ });
444
+
290
445
  test('runtime server state exposes active run identity while running', async (t) => {
291
446
  let releaseRun;
292
447
  const context = {
@@ -398,10 +553,10 @@ test('runtime server isolates active runs by workspace', async (t) => {
398
553
  const queued = await fetch(`${url}?workspace=acme`, {
399
554
  method: 'POST',
400
555
  headers: { 'Content-Type': 'application/json' },
401
- body: JSON.stringify({ input: 'third' }),
556
+ body: JSON.stringify({ input: 'third', intent: 'enqueue' }),
402
557
  });
403
558
  assert.equal(queued.status, 202);
404
- assert.equal((await queued.json()).queued, true);
559
+ assert.equal((await queued.json()).kind, 'enqueue_run');
405
560
  assert.deepEqual(runWorkspaces.sort(), ['acme', 'docs']);
406
561
  } finally {
407
562
  releases.get('acme')?.();
@@ -1179,3 +1334,65 @@ test('runtime server rejects config switching while a run is active', async (t)
1179
1334
  await handle.close();
1180
1335
  }
1181
1336
  });
1337
+
1338
+ test('runtime server answers control messages posted to /run during an active run', async (t) => {
1339
+ let releaseRun;
1340
+ let handle;
1341
+ try {
1342
+ handle = await startRuntimeServer({
1343
+ host: '127.0.0.1',
1344
+ port: 0,
1345
+ store: {
1346
+ dbPath: ':memory:',
1347
+ getState: () => ({ status: 'running' }),
1348
+ listEvents: () => [],
1349
+ },
1350
+ session: {},
1351
+ run: async () => {
1352
+ await new Promise((resolve) => { releaseRun = resolve; });
1353
+ },
1354
+ });
1355
+ } catch (err) {
1356
+ if (err?.code === 'EPERM') {
1357
+ t.skip('network listen is not permitted in this sandbox');
1358
+ return;
1359
+ }
1360
+ throw err;
1361
+ }
1362
+
1363
+ try {
1364
+ const url = `http://127.0.0.1:${handle.port}/run`;
1365
+ const started = await fetch(url, {
1366
+ method: 'POST',
1367
+ headers: { 'Content-Type': 'application/json' },
1368
+ body: JSON.stringify({ input: 'lance le pipeline' }),
1369
+ });
1370
+ assert.equal(started.status, 202);
1371
+
1372
+ // A status question must be answered NOW, not parked until the run ends
1373
+ // (regression: serve UI got no result until the job finished/stopped).
1374
+ const observe = await fetch(url, {
1375
+ method: 'POST',
1376
+ headers: { 'Content-Type': 'application/json' },
1377
+ body: JSON.stringify({ input: 'où en est le run ?' }),
1378
+ });
1379
+ assert.equal(observe.status, 200);
1380
+ const observeBody = await observe.json();
1381
+ assert.equal(observeBody.kind, 'observe');
1382
+ assert.ok(String(observeBody.explanation ?? '').length > 0, 'observe must carry an explanation');
1383
+
1384
+ // A cancel must abort the active run instead of being enqueued.
1385
+ const cancel = await fetch(url, {
1386
+ method: 'POST',
1387
+ headers: { 'Content-Type': 'application/json' },
1388
+ body: JSON.stringify({ input: 'annule le run' }),
1389
+ });
1390
+ assert.equal(cancel.status, 200);
1391
+ const cancelBody = await cancel.json();
1392
+ assert.equal(cancelBody.kind, 'cancel');
1393
+ assert.equal(cancelBody.accepted, true);
1394
+ } finally {
1395
+ releaseRun?.();
1396
+ await handle.close();
1397
+ }
1398
+ });
@@ -279,6 +279,11 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
279
279
  SET status = 'interrupted', updated_at = ?
280
280
  WHERE workspace = ? AND status IN (${RECOVERABLE_RUN_STATUSES.map(() => '?').join(', ')})
281
281
  `);
282
+ const interruptRunByIdStatement = db.prepare(`
283
+ UPDATE runs
284
+ SET status = 'interrupted', updated_at = ?
285
+ WHERE id = ? AND (? IS NULL OR workspace = ?) AND status IN (${RECOVERABLE_RUN_STATUSES.map(() => '?').join(', ')})
286
+ `);
282
287
  const upsertQueueItem = db.prepare(`
283
288
  INSERT INTO queue_items (
284
289
  id, workspace, server, tool, args, lock_key, status, reason, job_id, activity_key,
@@ -413,6 +418,25 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
413
418
  `);
414
419
  const deleteTaskDependenciesStatement = db.prepare('DELETE FROM task_dependencies WHERE run_id = ? AND task_id = ?');
415
420
  const updateTaskStatusStatement = db.prepare('UPDATE tasks SET status = ?, declaration = ?, updated_at = ? WHERE run_id = ? AND id = ?');
421
+ const listActiveTasksForInterruptedRunsStatement = db.prepare(`
422
+ SELECT tasks.*
423
+ FROM tasks
424
+ JOIN runs ON runs.id = tasks.run_id
425
+ WHERE runs.workspace = ?
426
+ AND runs.status = 'interrupted'
427
+ AND tasks.status IN ('pending', 'pending_approval', 'waiting_approval', 'running', 'starting', 'blocked')
428
+ ORDER BY tasks.run_id ASC, tasks.id ASC
429
+ `);
430
+ const listActiveTasksForInterruptedRunByIdStatement = db.prepare(`
431
+ SELECT tasks.*
432
+ FROM tasks
433
+ JOIN runs ON runs.id = tasks.run_id
434
+ WHERE runs.id = ?
435
+ AND (? IS NULL OR runs.workspace = ?)
436
+ AND runs.status = 'interrupted'
437
+ AND tasks.status IN ('pending', 'pending_approval', 'waiting_approval', 'running', 'starting', 'blocked')
438
+ ORDER BY tasks.run_id ASC, tasks.id ASC
439
+ `);
416
440
  const insertTaskDependencyStatement = db.prepare(`
417
441
  INSERT OR IGNORE INTO task_dependencies (run_id, task_id, depends_on_task_id, created_at)
418
442
  VALUES (?, ?, ?, ?)
@@ -619,13 +643,33 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
619
643
  .map((row) => row.workspace);
620
644
  }
621
645
 
622
- function interruptRuns({ workspace, reason = 'Runtime restart recovery failed.' } = {}) {
623
- if (!workspace) return 0;
646
+ function interruptRuns({ workspace, runId = null, reason = 'Runtime restart recovery failed.' } = {}) {
647
+ if (!workspace && !runId) return 0;
624
648
  const now = new Date().toISOString();
625
- const result = interruptRunsStatement.run(now, workspace, ...RECOVERABLE_RUN_STATUSES);
649
+ const result = runId
650
+ ? interruptRunByIdStatement.run(now, String(runId), workspace ?? null, workspace ?? null, ...RECOVERABLE_RUN_STATUSES)
651
+ : interruptRunsStatement.run(now, workspace, ...RECOVERABLE_RUN_STATUSES);
626
652
  return Number(result.changes ?? 0);
627
653
  }
628
654
 
655
+ function cancelActiveTasksForInterruptedRuns({ workspace = null, runId = null } = {}) {
656
+ if (!workspace && !runId) return 0;
657
+ const now = new Date().toISOString();
658
+ const tasks = runId
659
+ ? listActiveTasksForInterruptedRunByIdStatement.all(String(runId), workspace ?? null, workspace ?? null).map(rowToTask)
660
+ : listActiveTasksForInterruptedRunsStatement.all(workspace).map(rowToTask);
661
+ for (const task of tasks) {
662
+ updateTaskStatusStatement.run(
663
+ 'cancelled',
664
+ JSON.stringify({ ...task.declaration, status: 'cancelled' }),
665
+ now,
666
+ task.runId,
667
+ task.id,
668
+ );
669
+ }
670
+ return tasks.length;
671
+ }
672
+
629
673
  function saveQueue(queue = [], { workspace = null } = {}) {
630
674
  const items = Array.isArray(queue) ? queue : [];
631
675
  const now = new Date().toISOString();
@@ -1121,6 +1165,7 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
1121
1165
  listRecoverableRuns,
1122
1166
  listRecoverableWorkspaces,
1123
1167
  interruptRuns,
1168
+ cancelActiveTasksForInterruptedRuns,
1124
1169
  saveQueue,
1125
1170
  listQueue,
1126
1171
  listTasks,
@@ -606,6 +606,38 @@ test('runtime store persists cancelled run status', () => {
606
606
  store.close();
607
607
  });
608
608
 
609
+ test('runtime store interrupts runs by runId and does not recover killed runs after reopen', () => {
610
+ const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
611
+ const store = openRuntimeStore({ stateDir });
612
+ for (const runId of ['run-kill', 'run-keep']) {
613
+ store.persistEvent(createAgentEvent('run_started', {
614
+ origin: 'test',
615
+ runId,
616
+ workspace: 'docs',
617
+ payload: { input: runId, workspace: 'docs' },
618
+ }));
619
+ store.persistEvent(createAgentEvent('task.created', {
620
+ origin: 'test',
621
+ runId,
622
+ taskId: `${runId}:task`,
623
+ workspace: 'docs',
624
+ payload: { runId, task: { ...plannedTask(`${runId}:task`), status: 'running' } },
625
+ }));
626
+ }
627
+
628
+ assert.equal(store.interruptRuns({ workspace: 'docs', runId: 'run-kill' }), 1);
629
+ assert.equal(store.cancelActiveTasksForInterruptedRuns({ workspace: 'docs', runId: 'run-kill' }), 1);
630
+ assert.equal(store.listRuns().find((run) => run.id === 'run-kill').status, 'interrupted');
631
+ assert.equal(store.listRuns().find((run) => run.id === 'run-keep').status, 'running');
632
+ assert.equal(store.listTasks({ runId: 'run-kill' })[0].status, 'cancelled');
633
+ assert.equal(store.listTasks({ runId: 'run-keep' })[0].status, 'running');
634
+ store.close();
635
+
636
+ const reopened = openRuntimeStore({ stateDir });
637
+ assert.deepEqual(reopened.listRecoverableRuns({ workspace: 'docs' }).map((run) => run.id), ['run-keep']);
638
+ reopened.close();
639
+ });
640
+
609
641
  test('runtime store migrates legacy databases without workspace columns', () => {
610
642
  const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
611
643
  const db = new DatabaseSync(join(stateDir, 'runtime.db'));
@@ -98,8 +98,55 @@ export async function pollActivitiesOnce(session, {
98
98
  payload: { activity: polledActivity },
99
99
  }));
100
100
  syncQueueWithActivity(session, polledActivity);
101
- emitRuntimeLog(session, `activity: ${polledActivity.label} -> ${polledActivity.status}`);
101
+ // Only log when something actually changed: the poll fires every
102
+ // second and an unchanged "activity: X -> running" line repeated
103
+ // dozens of times drowned Logs/Trace and looked like a frozen system.
104
+ // The line itself carries the operational signal (current file,
105
+ // percent, batch, last trace event, retry/backoff) so the panel
106
+ // reads as a narrative of the job, not a heartbeat.
107
+ const progress = payload?.progress ?? polledActivity.progress ?? {};
108
+ const percent = Number(progress.percent ?? polledActivity.progress?.percent);
109
+ const detail = progress.detail ?? progress.step ?? '';
110
+ // The trace's batchIndex is 0-based and the human detail already says
111
+ // "Batch 1/2" — printing both gave "Batch 1/2 · batch 0/2". Display
112
+ // 1-based, and only when the detail doesn't already carry it.
113
+ const batch = progress.batchIndex != null && progress.batchCount != null && !/batch/i.test(String(detail))
114
+ ? `batch ${Number(progress.batchIndex) + 1}/${progress.batchCount}`
115
+ : null;
116
+ const lastEvent = progress.lastEvent ? String(progress.lastEvent) : null;
117
+ const retry = progress.retryAt
118
+ ? `retry ${progress.retryAt}`
119
+ : (progress.waitMs ? `wait ${progress.waitMs}ms` : null);
120
+ const progressKey = `${polledActivity.status}:${Number.isFinite(percent) ? Math.round(percent) : ''}:${detail}:${batch ?? ''}:${lastEvent ?? ''}:${retry ?? ''}`;
121
+ session._activityLogKeys ??= {};
122
+ if (session._activityLogKeys[key] !== progressKey) {
123
+ session._activityLogKeys[key] = progressKey;
124
+ const progressLabel = [
125
+ Number.isFinite(percent) ? `${Math.round(percent)}%` : null,
126
+ detail ? String(detail) : null,
127
+ batch,
128
+ lastEvent,
129
+ retry,
130
+ ].filter(Boolean).join(' · ');
131
+ emitRuntimeLog(session, `activity: ${polledActivity.label} -> ${polledActivity.status}${progressLabel ? ` (${progressLabel})` : ''}`);
132
+ }
133
+ // Stream NEW job-log lines into Logs/Trace: document names, chunking,
134
+ // LLM call/token summaries and errors live in the job log, not in the
135
+ // status fields. Diff against the last line already shown.
136
+ const logTail = Array.isArray(payload?.logTail) ? payload.logTail.map(String) : [];
137
+ if (logTail.length > 0) {
138
+ session._activityLogCursors ??= {};
139
+ const lastSeen = session._activityLogCursors[key];
140
+ const lastIndex = lastSeen ? logTail.lastIndexOf(lastSeen) : -1;
141
+ for (const line of logTail.slice(lastIndex + 1)) {
142
+ const clean = line.trim();
143
+ if (clean) emitRuntimeLog(session, `job ${polledActivity.id ?? key}: ${clean}`);
144
+ }
145
+ session._activityLogCursors[key] = logTail.at(-1);
146
+ }
102
147
  if (polledActivity.terminal) {
148
+ delete session._activityLogKeys[key];
149
+ delete session._activityLogCursors?.[key];
103
150
  await startNextQueuedJob(session, {
104
151
  addLog: (message) => emitRuntimeLog(session, message),
105
152
  });
@@ -134,3 +181,32 @@ function registryIntervalFromEnv() {
134
181
  const value = Number(process.env.WIKI_MANAGER_AGENT_REGISTRY_INTERVAL_MS);
135
182
  return Number.isFinite(value) && value >= 0 ? Math.floor(value) : 60000;
136
183
  }
184
+
185
+ // Cancelling a runtime run must also cancel the asynchronous jobs it started:
186
+ // the run's abort only stopped the manager-side loop while the agent kept
187
+ // executing (an ingest subprocess ran on for minutes after "cancel requested",
188
+ // with the panels frozen on its last known state). Best effort: prefer the
189
+ // orchestration contract's agent_cancel, fall back to legacy *_cancel_job.
190
+ export async function cancelActiveActivityJobs(session, { callTool = callMcpTool } = {}) {
191
+ const cancelled = [];
192
+ for (const activity of sessionActivities(session)) {
193
+ if (activity.terminal || !activity.poll) continue;
194
+ const jobId = activity.poll.args?.jobId ?? activity.id ?? null;
195
+ const server = activity.poll.server;
196
+ const endpoint = session.mcp?.[server];
197
+ if (!jobId || !endpoint || endpoint.status !== 'connected') continue;
198
+ const names = (endpoint.tools ?? []).map((tool) => String(tool.name ?? ''));
199
+ const cancelTool = names.find((name) => /(^|__)agent_cancel$/.test(name))
200
+ ?? names.find((name) => /_cancel_job$/.test(name))
201
+ ?? names.find((name) => /(^|__)[a-z]*_?cancel$/.test(name));
202
+ if (!cancelTool) continue;
203
+ try {
204
+ await callTool(session.mcp, server, cancelTool, { jobId });
205
+ emitRuntimeLog(session, `cancel: requested ${server}.${cancelTool} for job ${jobId}`);
206
+ cancelled.push({ server, jobId });
207
+ } catch (err) {
208
+ emitRuntimeLog(session, `cancel: ${server}.${cancelTool} failed for ${jobId}: ${err instanceof Error ? err.message : String(err)}`);
209
+ }
210
+ }
211
+ return cancelled;
212
+ }