@dotdrelle/wiki-manager 0.12.1 → 0.12.7

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.
@@ -259,6 +259,30 @@ test('replanRuntimeRun preserves completed outputs when replacing remaining work
259
259
  assert.deepEqual(session.headlessPlan[1].dependsOn, ['export']);
260
260
  });
261
261
 
262
+ test('replanRuntimeRun normalizes object steps without leaking object strings', async () => {
263
+ const session = {
264
+ activities: {},
265
+ headlessPlan: [{ step: 1, id: 'done', description: 'Done', status: 'done' }],
266
+ llm: {
267
+ async completeWithTools({ system }) {
268
+ assert.match(system, /Each step MUST be a plain string, not an object\./);
269
+ return { content: '{"steps":["a",{"description":"b"},{"id":"c"},{},null]}' };
270
+ },
271
+ },
272
+ };
273
+
274
+ const result = await replanRuntimeRun(session, 'Build deliverable', {
275
+ kind: 'activity_error',
276
+ reason: 'Build failed.',
277
+ }, { runId: 'run-replan-object-steps', replansLeft: 1 });
278
+
279
+ assert.equal(result.ok, true);
280
+ assert.deepEqual(result.steps, ['a', 'b', 'c']);
281
+ assert.deepEqual(session.agentProjection.replans[0].plan, ['a', 'b', 'c']);
282
+ assert.deepEqual(session.headlessPlan.slice(1).map((step) => step.description), ['a', 'b', 'c']);
283
+ assert.equal(JSON.stringify(session.headlessPlan).includes('[object Object]'), false);
284
+ });
285
+
262
286
  test('replanRuntimeRun requires runtime approval for mutating replanned work', async () => {
263
287
  const session = {
264
288
  activities: {},
@@ -370,6 +394,154 @@ test('runRuntimeAgenticWorkflow replans after terminal activity error', async ()
370
394
  }
371
395
  });
372
396
 
397
+ test('runRuntimeAgenticWorkflow treats user-cancelled activity as terminal cancellation without replan', async () => {
398
+ const originalFetch = globalThis.fetch;
399
+ let pollAttempts = 0;
400
+ const events = [];
401
+ globalThis.fetch = async () => {
402
+ pollAttempts += 1;
403
+ return mcpActivityResponse({
404
+ id: 'job-cancelled',
405
+ source: 'production',
406
+ label: 'Production build',
407
+ status: 'cancelled',
408
+ terminal: true,
409
+ });
410
+ };
411
+ const session = {
412
+ mcp: {
413
+ production: {
414
+ status: 'connected',
415
+ url: 'http://127.0.0.1:3000/mcp/',
416
+ retry: { maxAttempts: 1, backoffMs: 0 },
417
+ },
418
+ },
419
+ activities: {},
420
+ headlessPlan: null,
421
+ llm: {
422
+ async completeWithTools() {
423
+ assert.fail('cancelled activity must not call evaluator or replanner');
424
+ },
425
+ },
426
+ _onAgentEvent: (event) => events.push(event),
427
+ };
428
+ const agent = {
429
+ async invoke({ session: turnSession }) {
430
+ dispatchAgentEvent(turnSession, createAgentEvent('activity_upserted', {
431
+ payload: {
432
+ activity: runningPollableActivity('job-cancelled', 'Production build'),
433
+ },
434
+ }));
435
+ return { response: 'Started build.' };
436
+ },
437
+ };
438
+
439
+ try {
440
+ const result = await runRuntimeAgenticWorkflow(agent, session, 'Build workspace', {
441
+ runId: 'run-cancelled-activity',
442
+ timeoutMs: 1000,
443
+ maxTurns: 1,
444
+ maxReplans: 1,
445
+ });
446
+
447
+ assert.equal(result.ok, false);
448
+ assert.equal(result.cancelled, true);
449
+ assert.equal(pollAttempts, 1);
450
+ assert.equal(events.some((event) => event.type === 'run_replanned'), false);
451
+ assert.ok(events.some((event) => event.type === 'run_cancelled' && event.payload?.cancelled === true));
452
+ assert.equal(session.agentProjection.status, 'cancelled');
453
+ assert.match(session.agentProjection.logs.join('\n'), /run ended by user cancellation \(no replan\)/);
454
+ } finally {
455
+ globalThis.fetch = originalFetch;
456
+ }
457
+ });
458
+
459
+ test('runRuntimeAgenticWorkflow replans from failed activity when another activity is cancelled', async () => {
460
+ const originalFetch = globalThis.fetch;
461
+ const events = [];
462
+ const pollStatuses = new Map([
463
+ ['job-failed', {
464
+ id: 'job-failed',
465
+ source: 'production',
466
+ label: 'Production build',
467
+ status: 'failed',
468
+ terminal: true,
469
+ error: 'build failed',
470
+ }],
471
+ ['job-cancelled', {
472
+ id: 'job-cancelled',
473
+ source: 'production',
474
+ label: 'Production cleanup',
475
+ status: 'cancelled',
476
+ terminal: true,
477
+ }],
478
+ ]);
479
+ globalThis.fetch = async (_url, options = {}) => {
480
+ const body = JSON.parse(String(options.body ?? '{}'));
481
+ return mcpActivityResponse(pollStatuses.get(body.params?.arguments?.jobId));
482
+ };
483
+ const session = {
484
+ mcp: {
485
+ production: {
486
+ status: 'connected',
487
+ url: 'http://127.0.0.1:3000/mcp/',
488
+ retry: { maxAttempts: 1, backoffMs: 0 },
489
+ },
490
+ },
491
+ activities: {},
492
+ headlessPlan: null,
493
+ llm: {
494
+ async completeWithTools({ system }) {
495
+ if (/replanner/.test(system)) return { content: '{"steps":["Retry build"]}' };
496
+ return { content: '{"ok":true,"reason":"Build complete.","suggestedAction":null}' };
497
+ },
498
+ },
499
+ _onAgentEvent: (event) => events.push(event),
500
+ };
501
+ let turns = 0;
502
+ const agent = {
503
+ async invoke({ session: turnSession }) {
504
+ turns += 1;
505
+ if (turns === 1) {
506
+ dispatchAgentEvent(turnSession, createAgentEvent('activity_upserted', {
507
+ payload: { activity: runningPollableActivity('job-failed', 'Production build') },
508
+ }));
509
+ dispatchAgentEvent(turnSession, createAgentEvent('activity_upserted', {
510
+ payload: { activity: runningPollableActivity('job-cancelled', 'Production cleanup') },
511
+ }));
512
+ return { response: 'Started build.' };
513
+ }
514
+ const pending = turnSession.headlessPlan?.find((step) => step.status === 'pending');
515
+ if (pending) {
516
+ dispatchAgentEvent(turnSession, createAgentEvent('plan_step_updated', {
517
+ origin: 'tool',
518
+ payload: { taskId: pending.id, status: 'done' },
519
+ }));
520
+ }
521
+ return { response: 'Retry done.' };
522
+ },
523
+ };
524
+
525
+ try {
526
+ const result = await runRuntimeAgenticWorkflow(agent, session, 'Build workspace', {
527
+ runId: 'run-failed-and-cancelled',
528
+ timeoutMs: 1000,
529
+ maxTurns: 3,
530
+ maxReplans: 1,
531
+ });
532
+
533
+ assert.equal(result.ok, true);
534
+ assert.equal(turns, 2);
535
+ assert.equal(events.some((event) => event.type === 'run_cancelled'), false);
536
+ assert.ok(events.some((event) => event.type === 'run_replanned'));
537
+ assert.equal(session.agentProjection.replans[0].reason, 'Production build ended with failed: build failed');
538
+ assert.deepEqual(session.agentProjection.replans[0].plan, ['Retry build']);
539
+ assert.equal(session.agentProjection.status, 'done');
540
+ } finally {
541
+ globalThis.fetch = originalFetch;
542
+ }
543
+ });
544
+
373
545
  test('runRuntimeAgenticWorkflow stops after replan budget is exhausted', async () => {
374
546
  const session = {
375
547
  activities: {},
@@ -707,6 +879,30 @@ function productionAgent() {
707
879
  };
708
880
  }
709
881
 
882
+ function runningPollableActivity(id, label) {
883
+ return {
884
+ id,
885
+ source: 'production',
886
+ label,
887
+ status: 'running',
888
+ terminal: false,
889
+ poll: { server: 'production', tool: 'production_job_status', args: { jobId: id }, intervalMs: 0 },
890
+ };
891
+ }
892
+
893
+ function mcpActivityResponse(activity) {
894
+ return {
895
+ ok: true,
896
+ status: 200,
897
+ headers: { get: () => null },
898
+ text: async () => JSON.stringify({
899
+ result: {
900
+ content: [{ type: 'text', text: JSON.stringify({ _activity: activity }) }],
901
+ },
902
+ }),
903
+ };
904
+ }
905
+
710
906
  function toolResult(payload) {
711
907
  return { content: [{ type: 'text', text: JSON.stringify(payload) }] };
712
908
  }
@@ -242,15 +242,19 @@ export function startRuntimeServer({
242
242
  }
243
243
  validateContractInDev('runRequest', { ...body, input });
244
244
  if (context.running) {
245
- const item = enqueueControlRequest(context, input);
246
- sendJson(response, 202, {
247
- accepted: true,
248
- queued: true,
249
- kind: 'enqueue_run',
250
- item,
251
- ...controlStatus(context, store),
252
- explanation: controlMessage(context?.session, 'queued_for_future_run'),
245
+ // Blind enqueueing made every message typed during a run
246
+ // invisible until the run ended (serve UI symptom: "no result
247
+ // until the job finished or was stopped"). Classify instead:
248
+ // observe → immediate status explanation, cancel → abort,
249
+ // ambiguous action → explicit choices, explicit "later" intent
250
+ // (body.intent = 'enqueue') → enqueue as before.
251
+ const result = await handleControlMessage(context, store, input, {
252
+ intent: body.intent,
253
+ startNextControlRequest,
254
+ cancel,
255
+ approve,
253
256
  });
257
+ sendJson(response, result.statusCode, result.body);
254
258
  return;
255
259
  }
256
260
  const accepted = startRuntimeRun(context, body);
@@ -274,6 +278,16 @@ export function startRuntimeServer({
274
278
  sendJson(response, 202, { cancelled: true, workspace: context.workspace ?? workspace ?? null });
275
279
  return;
276
280
  }
281
+ if (request.method === 'POST' && url.pathname === '/kill') {
282
+ const body = await readJson(request);
283
+ const workspace = workspaceFromBody(body) ?? workspaceFromUrl(url);
284
+ const runId = url.searchParams.get('runId') ?? body.runId ?? null;
285
+ const context = await resolveContext({ workspace });
286
+ const result = await killRuntimeRuns(context, { workspace, runId });
287
+ publishState(context.workspace ?? workspace ?? null, context);
288
+ sendJson(response, 202, result);
289
+ return;
290
+ }
277
291
  if (request.method === 'POST' && url.pathname === '/shutdown') {
278
292
  const workspace = workspaceFromUrl(url);
279
293
  const context = await resolveContext({ workspace });
@@ -355,6 +369,23 @@ export function startRuntimeServer({
355
369
  return { body, workspace, context };
356
370
  }
357
371
 
372
+ async function killRuntimeRuns(context, { workspace = null, runId = null } = {}) {
373
+ const targetWorkspace = context?.workspace ?? workspace ?? null;
374
+ const targetRunId = runId ? String(runId) : null;
375
+ if (!targetRunId || targetRunId === context?.currentRunId) {
376
+ context?.currentAbortController?.abort();
377
+ await cancel?.(context);
378
+ }
379
+ const runs = typeof store.interruptRuns === 'function'
380
+ ? store.interruptRuns({ workspace: targetWorkspace, runId: targetRunId, reason: 'Runtime run killed by user.' })
381
+ : 0;
382
+ const tasks = typeof store.cancelActiveTasksForInterruptedRuns === 'function'
383
+ ? store.cancelActiveTasksForInterruptedRuns({ workspace: targetWorkspace, runId: targetRunId })
384
+ : 0;
385
+ const queued = cancelQueuedControlItems(context?.session, targetWorkspace);
386
+ return { killed: true, workspace: targetWorkspace, runId: targetRunId, runs, tasks, queued };
387
+ }
388
+
358
389
  function startRuntimeRun(context, body, { controlItemId = null } = {}) {
359
390
  const runId = randomUUID();
360
391
  const runWorkspace = context.workspace ?? body.workspace ?? null;
@@ -472,6 +503,34 @@ function controlQueueFor(session) {
472
503
  return Array.isArray(session?.controlQueue) ? session.controlQueue : [];
473
504
  }
474
505
 
506
+ function cancelQueuedControlItems(session, workspace = null) {
507
+ const now = new Date().toISOString();
508
+ const counted = new Set();
509
+ let count = 0;
510
+ for (const queue of [controlQueueFor(session), session?.agentProjection?.controlQueue].filter(Array.isArray)) {
511
+ for (const item of queue) {
512
+ if (item.status !== 'queued') continue;
513
+ if (workspace && item.workspace && item.workspace !== workspace) continue;
514
+ item.status = 'cancelled';
515
+ item.finishedAt = now;
516
+ item.updatedAt = now;
517
+ const id = item.id ?? item.runId ?? `${item.input}:${item.createdAt}`;
518
+ if (!counted.has(id)) {
519
+ counted.add(id);
520
+ if (session) {
521
+ dispatchAgentEvent(session, createAgentEvent('control_cancelled', {
522
+ origin: 'runtime',
523
+ workspace: item.workspace ?? workspace ?? null,
524
+ payload: { id, finishedAt: now },
525
+ }));
526
+ }
527
+ count += 1;
528
+ }
529
+ }
530
+ }
531
+ return count;
532
+ }
533
+
475
534
  function readOnlyControlResponse(kind, classification, status, explanation, { accepted = true, extra = {} } = {}) {
476
535
  return {
477
536
  statusCode: 200,
@@ -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,