@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.
- package/package.json +2 -2
- package/src/activity/activityAggregator.js +34 -3
- package/src/activity/activityAggregator.test.js +32 -0
- package/src/agent/graph.js +272 -22
- package/src/agent/graph.test.js +357 -1
- package/src/cli/wiki-manager.js +24 -1
- package/src/commands/slash.js +74 -1
- package/src/commands/slash.test.js +36 -0
- package/src/core/activity.js +4 -0
- package/src/core/activity.test.js +9 -1
- package/src/core/agentEvents.js +30 -0
- package/src/core/agentEvents.test.js +42 -1
- package/src/core/buildInfo.js +58 -0
- package/src/core/buildInfo.json +4 -0
- package/src/core/buildInfo.test.js +14 -0
- package/src/core/mcp.js +21 -1
- package/src/core/mcp.test.js +25 -0
- package/src/core/runtimeLog.js +6 -1
- package/src/core/runtimeLog.test.js +3 -1
- package/src/runtime/client.js +22 -0
- package/src/runtime/controlMessages.js +2 -2
- package/src/runtime/recoveryManager.js +54 -0
- package/src/runtime/recoveryManager.test.js +73 -0
- package/src/runtime/runner.js +49 -13
- package/src/runtime/runner.test.js +196 -0
- package/src/runtime/server.js +67 -8
- package/src/runtime/server.test.js +223 -6
- package/src/runtime/store.js +48 -3
- package/src/runtime/store.test.js +32 -0
- package/src/runtime/supervisor.js +77 -1
- package/src/shell/RightPane.tsx +124 -31
- package/src/shell/SetupWizard.tsx +13 -1
- package/src/shell/repl.js +78 -9
- package/src/shell/repl.test.js +114 -1
- package/src/shell/tui.tsx +4 -1
- package/src/shell/useAgent.ts +32 -3
- package/src/shell/useSession.ts +14 -1
|
@@ -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
|
}
|
package/src/runtime/server.js
CHANGED
|
@@ -242,15 +242,19 @@ export function startRuntimeServer({
|
|
|
242
242
|
}
|
|
243
243
|
validateContractInDev('runRequest', { ...body, input });
|
|
244
244
|
if (context.running) {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
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.
|
|
234
|
-
const queuedRun = bodies.find((body) => body.
|
|
235
|
-
assert.equal(queuedRun.
|
|
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()).
|
|
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
|
+
});
|
package/src/runtime/store.js
CHANGED
|
@@ -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 =
|
|
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,
|