@dotdrelle/wiki-manager 0.11.0 → 0.11.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -0
- package/agents.docker-compose.yml +3 -3
- package/package.json +2 -2
- package/src/agent/graph.js +99 -3
- package/src/agent/graph.test.js +81 -0
- package/src/cli/wiki-manager.js +11 -4
- package/src/commands/slash.js +7 -1
- package/src/core/agentLoop.js +20 -5
- package/src/core/agentLoop.test.js +19 -1
- package/src/core/mcp.js +1 -1
- package/src/core/planPatch.js +39 -1
- package/src/core/planPatch.test.js +23 -1
- package/src/core/wikiSetup.js +7 -2
- package/src/core/wikirc.test.js +40 -2
- package/src/runtime/client.js +12 -0
- package/src/runtime/donna-contract.test.js +359 -0
- package/src/runtime/lifecycle.js +1 -1
- package/src/runtime/runner.e2e.test.js +52 -0
- package/src/runtime/runner.js +126 -8
- package/src/runtime/runner.test.js +385 -3
- package/src/runtime/server.js +28 -2
- package/src/runtime/server.test.js +38 -0
- package/src/runtime/store.js +72 -2
- package/src/runtime/store.test.js +198 -1
- package/src/shell/LeftPane.tsx +2 -1
- package/src/shell/StartupScreen.tsx +101 -21
- package/src/shell/repl.js +60 -15
- package/src/shell/repl.test.js +33 -1
- package/src/shell/tui.tsx +90 -75
- package/src/shell/useAgent.ts +14 -4
- package/src/shell/useSession.ts +92 -17
|
@@ -3,6 +3,41 @@ import test from 'node:test';
|
|
|
3
3
|
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
4
4
|
import { finishRuntimeRun, replanRuntimeRun, runRuntimeAgenticWorkflow, runRuntimeParallelPlan } from './runner.js';
|
|
5
5
|
|
|
6
|
+
test('runRuntimeAgenticWorkflow completes conversational turns without evaluation or replan', async () => {
|
|
7
|
+
const events = [];
|
|
8
|
+
const session = {
|
|
9
|
+
activities: {},
|
|
10
|
+
headlessPlan: null,
|
|
11
|
+
llm: {
|
|
12
|
+
async completeWithTools() {
|
|
13
|
+
assert.fail('conversation-only turn must not call evaluator or replanner');
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
_onAgentEvent: (event) => events.push(event),
|
|
17
|
+
};
|
|
18
|
+
const agent = {
|
|
19
|
+
async invoke() {
|
|
20
|
+
return { response: 'Salut.' };
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const started = Date.now();
|
|
25
|
+
const result = await runRuntimeAgenticWorkflow(agent, session, 'salut', {
|
|
26
|
+
runId: 'run-chat',
|
|
27
|
+
timeoutMs: 1000,
|
|
28
|
+
maxTurns: 1,
|
|
29
|
+
maxReplans: 1,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
assert.equal(result.ok, true);
|
|
33
|
+
assert.ok(Date.now() - started < 5000);
|
|
34
|
+
assert.equal(session.headlessPlan, null);
|
|
35
|
+
assert.equal(Object.keys(session.activities).length, 0);
|
|
36
|
+
assert.ok(events.some((event) => event.type === 'run_done'));
|
|
37
|
+
assert.equal(events.some((event) => event.type === 'run_evaluated'), false);
|
|
38
|
+
assert.equal(events.some((event) => event.type === 'run_replanned'), false);
|
|
39
|
+
});
|
|
40
|
+
|
|
6
41
|
test('finishRuntimeRun emits evaluation before run_done', async () => {
|
|
7
42
|
const events = [];
|
|
8
43
|
const session = {
|
|
@@ -33,6 +68,47 @@ test('finishRuntimeRun emits evaluation before run_done', async () => {
|
|
|
33
68
|
assert.equal(session.agentProjection.status, 'done');
|
|
34
69
|
});
|
|
35
70
|
|
|
71
|
+
test('runRuntimeAgenticWorkflow clarifies vague evaluations without replan', async () => {
|
|
72
|
+
const events = [];
|
|
73
|
+
const session = {
|
|
74
|
+
activities: {},
|
|
75
|
+
headlessPlan: [{ step: 1, id: 'task', description: 'Task', status: 'done' }],
|
|
76
|
+
llm: {
|
|
77
|
+
async completeWithTools({ system }) {
|
|
78
|
+
assert.match(system, /strict evaluator/);
|
|
79
|
+
return { content: '{"ok":false,"reason":"demande vague / objectif indefini","suggestedAction":"clarifier l objectif"}' };
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
_onAgentEvent: (event) => events.push(event),
|
|
83
|
+
};
|
|
84
|
+
const agent = {
|
|
85
|
+
async invoke({ session: turnSession }) {
|
|
86
|
+
if (turnSession.headlessPlan === null) {
|
|
87
|
+
dispatchAgentEvent(turnSession, createAgentEvent('plan_set', {
|
|
88
|
+
origin: 'tool',
|
|
89
|
+
payload: { steps: [{ step: 1, id: 'task', description: 'Task', status: 'done' }] },
|
|
90
|
+
}));
|
|
91
|
+
}
|
|
92
|
+
return { response: 'Terminé.' };
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const result = await runRuntimeAgenticWorkflow(agent, session, 'fais le truc', {
|
|
97
|
+
runId: 'run-vague',
|
|
98
|
+
timeoutMs: 1000,
|
|
99
|
+
maxTurns: 1,
|
|
100
|
+
maxReplans: 1,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
assert.equal(result.ok, true);
|
|
104
|
+
assert.equal(result.clarified, true);
|
|
105
|
+
assert.ok(session.agentProjection.conversation.at(-1).content.includes('clarifier'));
|
|
106
|
+
assert.ok(events.some((event) => event.type === 'run_evaluated'));
|
|
107
|
+
assert.equal(events.some((event) => event.type === 'run_replanned'), false);
|
|
108
|
+
assert.equal(events.some((event) => event.type === 'tool_call_started'), false);
|
|
109
|
+
assert.equal(session.agentProjection.status, 'done');
|
|
110
|
+
});
|
|
111
|
+
|
|
36
112
|
test('finishRuntimeRun turns negative evaluation into run_error', async () => {
|
|
37
113
|
const events = [];
|
|
38
114
|
const session = {
|
|
@@ -62,7 +138,7 @@ test('finishRuntimeRun turns negative evaluation into run_error', async () => {
|
|
|
62
138
|
test('finishRuntimeRun falls back open when evaluator response is invalid', async () => {
|
|
63
139
|
const session = {
|
|
64
140
|
activities: {},
|
|
65
|
-
headlessPlan:
|
|
141
|
+
headlessPlan: [{ step: 1, description: 'Do work', status: 'done' }],
|
|
66
142
|
agentProjection: { conversation: [] },
|
|
67
143
|
llm: {
|
|
68
144
|
async completeWithTools() {
|
|
@@ -126,13 +202,20 @@ test('runRuntimeAgenticWorkflow replans after negative evaluation', async () =>
|
|
|
126
202
|
const agent = {
|
|
127
203
|
async invoke({ session: turnSession }) {
|
|
128
204
|
turns += 1;
|
|
205
|
+
if (turnSession.headlessPlan === null) {
|
|
206
|
+
dispatchAgentEvent(turnSession, createAgentEvent('plan_set', {
|
|
207
|
+
origin: 'tool',
|
|
208
|
+
payload: { steps: [{ step: 1, id: 'initial', description: 'Initial work', status: 'done' }] },
|
|
209
|
+
}));
|
|
210
|
+
return { response: 'Initial done.' };
|
|
211
|
+
}
|
|
129
212
|
if (turnSession.headlessPlan?.[0]?.status === 'pending') {
|
|
130
213
|
dispatchAgentEvent(turnSession, createAgentEvent('plan_step_updated', {
|
|
131
214
|
origin: 'tool',
|
|
132
215
|
payload: { step: 1, status: 'done' },
|
|
133
216
|
}));
|
|
134
217
|
}
|
|
135
|
-
return { response:
|
|
218
|
+
return { response: 'Export done.' };
|
|
136
219
|
},
|
|
137
220
|
};
|
|
138
221
|
|
|
@@ -175,6 +258,27 @@ test('replanRuntimeRun preserves completed outputs when replacing remaining work
|
|
|
175
258
|
assert.deepEqual(session.headlessPlan[1].dependsOn, ['export']);
|
|
176
259
|
});
|
|
177
260
|
|
|
261
|
+
test('replanRuntimeRun requires runtime approval for mutating replanned work', async () => {
|
|
262
|
+
const session = {
|
|
263
|
+
activities: {},
|
|
264
|
+
headlessPlan: [{ step: 1, id: 'build', description: 'Build', status: 'failed' }],
|
|
265
|
+
llm: {
|
|
266
|
+
async completeWithTools() {
|
|
267
|
+
return { content: '{"steps":["Run production build"]}' };
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const result = await replanRuntimeRun(session, 'Build deliverable', {
|
|
273
|
+
kind: 'evaluation',
|
|
274
|
+
reason: 'Build missing.',
|
|
275
|
+
}, { runId: 'run-replan-approval', replansLeft: 1 });
|
|
276
|
+
|
|
277
|
+
assert.equal(result.ok, true);
|
|
278
|
+
assert.equal(session._runApprovalRequired, true);
|
|
279
|
+
assert.equal(session._runApprovalResolved, false);
|
|
280
|
+
});
|
|
281
|
+
|
|
178
282
|
test('runRuntimeAgenticWorkflow replans after terminal activity error', async () => {
|
|
179
283
|
const originalFetch = globalThis.fetch;
|
|
180
284
|
let pollAttempts = 0;
|
|
@@ -276,7 +380,13 @@ test('runRuntimeAgenticWorkflow stops after replan budget is exhausted', async (
|
|
|
276
380
|
},
|
|
277
381
|
};
|
|
278
382
|
const agent = {
|
|
279
|
-
async invoke() {
|
|
383
|
+
async invoke({ session: turnSession }) {
|
|
384
|
+
if (turnSession.headlessPlan === null) {
|
|
385
|
+
dispatchAgentEvent(turnSession, createAgentEvent('plan_set', {
|
|
386
|
+
origin: 'tool',
|
|
387
|
+
payload: { steps: [{ step: 1, id: 'task', description: 'Task', status: 'done' }] },
|
|
388
|
+
}));
|
|
389
|
+
}
|
|
280
390
|
return { response: 'Done.' };
|
|
281
391
|
},
|
|
282
392
|
};
|
|
@@ -337,6 +447,232 @@ test('runRuntimeParallelPlan executes ready tasks concurrently in one parent run
|
|
|
337
447
|
assert.ok(session.agentEvents.some((event) => event.taskId === 'b'));
|
|
338
448
|
});
|
|
339
449
|
|
|
450
|
+
test('runRuntimeParallelPlan schedules a plan patch applied during an active parallel run', async () => {
|
|
451
|
+
const started = [];
|
|
452
|
+
const release = {};
|
|
453
|
+
const session = {
|
|
454
|
+
activities: {},
|
|
455
|
+
headlessPlan: [
|
|
456
|
+
{ step: 1, id: 'a', description: 'Task A', status: 'pending', dependsOn: [] },
|
|
457
|
+
{ step: 2, id: 'b', description: 'Task B', status: 'pending', dependsOn: [] },
|
|
458
|
+
],
|
|
459
|
+
};
|
|
460
|
+
const agent = {
|
|
461
|
+
async invoke({ input }) {
|
|
462
|
+
const id = input.match(/Task id: ([^\n]+)/)?.[1];
|
|
463
|
+
started.push(id);
|
|
464
|
+
if (id === 'a' || id === 'b') {
|
|
465
|
+
await new Promise((resolve) => { release[id] = resolve; });
|
|
466
|
+
}
|
|
467
|
+
return { response: `done ${id}` };
|
|
468
|
+
},
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
const running = runRuntimeParallelPlan(agent, session, 'Do patched work', {
|
|
472
|
+
runId: 'run-patch-active',
|
|
473
|
+
timeoutMs: 1000,
|
|
474
|
+
maxTurns: 1,
|
|
475
|
+
concurrency: 2,
|
|
476
|
+
});
|
|
477
|
+
await waitFor(() => started.includes('a') && started.includes('b'));
|
|
478
|
+
const patch = {
|
|
479
|
+
targetRunId: 'run-patch-active',
|
|
480
|
+
basePlanRevision: session.agentProjection.planRevision,
|
|
481
|
+
operations: [
|
|
482
|
+
{
|
|
483
|
+
op: 'add_task',
|
|
484
|
+
task: {
|
|
485
|
+
id: 'c',
|
|
486
|
+
description: 'Task C added during run',
|
|
487
|
+
status: 'pending',
|
|
488
|
+
dependsOn: ['a'],
|
|
489
|
+
executor: null,
|
|
490
|
+
outputRefs: [],
|
|
491
|
+
},
|
|
492
|
+
},
|
|
493
|
+
],
|
|
494
|
+
};
|
|
495
|
+
dispatchAgentEvent(session, createAgentEvent('plan_patch_proposed', {
|
|
496
|
+
origin: 'runtime',
|
|
497
|
+
runId: 'run-patch-active',
|
|
498
|
+
payload: { id: 'patch-active', patch },
|
|
499
|
+
}));
|
|
500
|
+
dispatchAgentEvent(session, createAgentEvent('plan_patch_approved', {
|
|
501
|
+
origin: 'runtime',
|
|
502
|
+
runId: 'run-patch-active',
|
|
503
|
+
payload: { patchId: 'patch-active' },
|
|
504
|
+
}));
|
|
505
|
+
dispatchAgentEvent(session, createAgentEvent('plan_patch_applied', {
|
|
506
|
+
origin: 'runtime',
|
|
507
|
+
runId: 'run-patch-active',
|
|
508
|
+
payload: { patchId: 'patch-active', patch },
|
|
509
|
+
}));
|
|
510
|
+
|
|
511
|
+
assert.deepEqual(session.headlessPlan.map((step) => step.id), ['a', 'b', 'c']);
|
|
512
|
+
release.a();
|
|
513
|
+
await waitFor(() => started.includes('c'));
|
|
514
|
+
release.b();
|
|
515
|
+
|
|
516
|
+
const result = await running;
|
|
517
|
+
|
|
518
|
+
assert.equal(result.ok, true);
|
|
519
|
+
assert.deepEqual(started, ['a', 'b', 'c']);
|
|
520
|
+
assert.deepEqual(session.headlessPlan.map((step) => step.status), ['done', 'done', 'done']);
|
|
521
|
+
assert.equal(session.headlessPlan.find((step) => step.id === 'c').dependsOn[0], 'a');
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
test('runRuntimeParallelPlan rejects a stale patch during an active parallel run without silently applying it', async () => {
|
|
525
|
+
const release = {};
|
|
526
|
+
const session = {
|
|
527
|
+
activities: {},
|
|
528
|
+
headlessPlan: [
|
|
529
|
+
{ step: 1, id: 'a', description: 'Task A', status: 'pending', dependsOn: [] },
|
|
530
|
+
{ step: 2, id: 'b', description: 'Task B', status: 'pending', dependsOn: [] },
|
|
531
|
+
],
|
|
532
|
+
};
|
|
533
|
+
const agent = {
|
|
534
|
+
async invoke({ input }) {
|
|
535
|
+
const id = input.match(/Task id: ([^\n]+)/)?.[1];
|
|
536
|
+
await new Promise((resolve) => { release[id] = resolve; });
|
|
537
|
+
return { response: `done ${id}` };
|
|
538
|
+
},
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
const running = runRuntimeParallelPlan(agent, session, 'Do stale patch work', {
|
|
542
|
+
runId: 'run-stale-patch',
|
|
543
|
+
timeoutMs: 1000,
|
|
544
|
+
maxTurns: 1,
|
|
545
|
+
concurrency: 2,
|
|
546
|
+
});
|
|
547
|
+
await waitFor(() => release.a && release.b);
|
|
548
|
+
const patch = {
|
|
549
|
+
targetRunId: 'run-stale-patch',
|
|
550
|
+
basePlanRevision: session.agentProjection.planRevision + 99,
|
|
551
|
+
operations: [
|
|
552
|
+
{ op: 'add_task', task: { id: 'c', description: 'Should not apply', dependsOn: ['a'] } },
|
|
553
|
+
],
|
|
554
|
+
};
|
|
555
|
+
dispatchAgentEvent(session, createAgentEvent('plan_patch_proposed', {
|
|
556
|
+
origin: 'runtime',
|
|
557
|
+
runId: 'run-stale-patch',
|
|
558
|
+
payload: { id: 'patch-stale', patch },
|
|
559
|
+
}));
|
|
560
|
+
dispatchAgentEvent(session, createAgentEvent('plan_patch_applied', {
|
|
561
|
+
origin: 'runtime',
|
|
562
|
+
runId: 'run-stale-patch',
|
|
563
|
+
payload: { patchId: 'patch-stale', patch },
|
|
564
|
+
}));
|
|
565
|
+
|
|
566
|
+
assert.deepEqual(session.headlessPlan.map((step) => step.id), ['a', 'b']);
|
|
567
|
+
assert.equal(session.agentProjection.planPatches.find((item) => item.id === 'patch-stale').status, 'rejected');
|
|
568
|
+
assert.equal(session.agentProjection.planPatches.find((item) => item.id === 'patch-stale').rejectionReason, 'revision_mismatch');
|
|
569
|
+
release.a();
|
|
570
|
+
release.b();
|
|
571
|
+
|
|
572
|
+
const result = await running;
|
|
573
|
+
|
|
574
|
+
assert.equal(result.ok, true);
|
|
575
|
+
assert.deepEqual(session.headlessPlan.map((step) => step.status), ['done', 'done']);
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
test('runRuntimeParallelPlan aborts a running child task cancelled by plan patch without stopping siblings', async () => {
|
|
579
|
+
const started = [];
|
|
580
|
+
let bAborted = false;
|
|
581
|
+
const session = {
|
|
582
|
+
activities: {},
|
|
583
|
+
headlessPlan: [
|
|
584
|
+
{ step: 1, id: 'a', description: 'Task A', status: 'pending', dependsOn: [] },
|
|
585
|
+
{ step: 2, id: 'b', description: 'Task B', status: 'pending', dependsOn: [] },
|
|
586
|
+
],
|
|
587
|
+
};
|
|
588
|
+
const agent = {
|
|
589
|
+
async invoke({ input, signal }) {
|
|
590
|
+
const id = input.match(/Task id: ([^\n]+)/)?.[1];
|
|
591
|
+
started.push(id);
|
|
592
|
+
if (id === 'b') {
|
|
593
|
+
await new Promise((resolve, reject) => {
|
|
594
|
+
signal.addEventListener('abort', () => {
|
|
595
|
+
bAborted = true;
|
|
596
|
+
const err = new Error('cancelled');
|
|
597
|
+
err.name = 'AbortError';
|
|
598
|
+
reject(err);
|
|
599
|
+
}, { once: true });
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
return { response: `done ${id}` };
|
|
603
|
+
},
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
const running = runRuntimeParallelPlan(agent, session, 'Cancel one child', {
|
|
607
|
+
runId: 'run-cancel-task',
|
|
608
|
+
timeoutMs: 1000,
|
|
609
|
+
maxTurns: 1,
|
|
610
|
+
concurrency: 2,
|
|
611
|
+
});
|
|
612
|
+
await waitFor(() => started.includes('a') && started.includes('b'));
|
|
613
|
+
await waitFor(() => session.headlessPlan.find((step) => step.id === 'a')?.status === 'done');
|
|
614
|
+
const patch = {
|
|
615
|
+
targetRunId: 'run-cancel-task',
|
|
616
|
+
basePlanRevision: session.agentProjection.planRevision,
|
|
617
|
+
operations: [{ op: 'cancel_task', taskId: 'b' }],
|
|
618
|
+
};
|
|
619
|
+
dispatchAgentEvent(session, createAgentEvent('plan_patch_applied', {
|
|
620
|
+
origin: 'runtime',
|
|
621
|
+
runId: 'run-cancel-task',
|
|
622
|
+
payload: { patchId: 'patch-cancel-b', patch },
|
|
623
|
+
}));
|
|
624
|
+
|
|
625
|
+
const result = await running;
|
|
626
|
+
|
|
627
|
+
assert.equal(result.ok, true);
|
|
628
|
+
assert.equal(bAborted, true);
|
|
629
|
+
assert.deepEqual(session.headlessPlan.map((step) => step.status), ['done', 'cancelled']);
|
|
630
|
+
assert.equal(session.agentProjection.planPatches.find((item) => item.id === 'patch-cancel-b').status, 'applied');
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
test('runRuntimeParallelPlan never exceeds the configured concurrency with twice the limit ready', async () => {
|
|
634
|
+
let active = 0;
|
|
635
|
+
let maxActive = 0;
|
|
636
|
+
const release = {};
|
|
637
|
+
const session = {
|
|
638
|
+
activities: {},
|
|
639
|
+
headlessPlan: Array.from({ length: 6 }, (_, index) => ({
|
|
640
|
+
step: index + 1,
|
|
641
|
+
id: `task-${index + 1}`,
|
|
642
|
+
description: `Task ${index + 1}`,
|
|
643
|
+
status: 'pending',
|
|
644
|
+
dependsOn: [],
|
|
645
|
+
})),
|
|
646
|
+
};
|
|
647
|
+
const agent = {
|
|
648
|
+
async invoke({ input }) {
|
|
649
|
+
const id = input.match(/Task id: ([^\n]+)/)?.[1];
|
|
650
|
+
active += 1;
|
|
651
|
+
maxActive = Math.max(maxActive, active);
|
|
652
|
+
await new Promise((resolve) => { release[id] = resolve; });
|
|
653
|
+
active -= 1;
|
|
654
|
+
return { response: `done ${id}` };
|
|
655
|
+
},
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
const running = runRuntimeParallelPlan(agent, session, 'Respect limit', {
|
|
659
|
+
runId: 'run-limit',
|
|
660
|
+
timeoutMs: 1000,
|
|
661
|
+
maxTurns: 1,
|
|
662
|
+
concurrency: 3,
|
|
663
|
+
});
|
|
664
|
+
for (let batch = 0; batch < 2; batch += 1) {
|
|
665
|
+
await waitFor(() => Object.keys(release).length >= (batch + 1) * 3);
|
|
666
|
+
for (const id of Object.keys(release).slice(batch * 3, (batch + 1) * 3)) release[id]();
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const result = await running;
|
|
670
|
+
|
|
671
|
+
assert.equal(result.ok, true);
|
|
672
|
+
assert.equal(maxActive, 3);
|
|
673
|
+
assert.deepEqual(session.headlessPlan.map((step) => step.status), ['done', 'done', 'done', 'done', 'done', 'done']);
|
|
674
|
+
});
|
|
675
|
+
|
|
340
676
|
test('runRuntimeParallelPlan serializes conflicting write locks', async () => {
|
|
341
677
|
let active = 0;
|
|
342
678
|
let maxActive = 0;
|
|
@@ -441,6 +777,52 @@ test('runRuntimeParallelPlan propagates parent cancellation to child tasks', asy
|
|
|
441
777
|
assert.equal(session.headlessPlan[0].status, 'cancelled');
|
|
442
778
|
});
|
|
443
779
|
|
|
780
|
+
test('runRuntimeParallelPlan aborts every active child on parent cancellation and records terminal updates after running updates', async () => {
|
|
781
|
+
const controller = new AbortController();
|
|
782
|
+
const aborted = [];
|
|
783
|
+
const session = {
|
|
784
|
+
activities: {},
|
|
785
|
+
headlessPlan: [
|
|
786
|
+
{ step: 1, id: 'a', description: 'Task A', status: 'pending', dependsOn: [] },
|
|
787
|
+
{ step: 2, id: 'b', description: 'Task B', status: 'pending', dependsOn: [] },
|
|
788
|
+
{ step: 3, id: 'c', description: 'Task C', status: 'pending', dependsOn: [] },
|
|
789
|
+
],
|
|
790
|
+
};
|
|
791
|
+
const agent = {
|
|
792
|
+
async invoke({ input, signal }) {
|
|
793
|
+
const id = input.match(/Task id: ([^\n]+)/)?.[1];
|
|
794
|
+
await new Promise((resolve, reject) => {
|
|
795
|
+
signal.addEventListener('abort', () => {
|
|
796
|
+
aborted.push(id);
|
|
797
|
+
const err = new Error('cancelled');
|
|
798
|
+
err.name = 'AbortError';
|
|
799
|
+
reject(err);
|
|
800
|
+
}, { once: true });
|
|
801
|
+
});
|
|
802
|
+
return { response: `done ${id}` };
|
|
803
|
+
},
|
|
804
|
+
};
|
|
805
|
+
|
|
806
|
+
const running = runRuntimeParallelPlan(agent, session, 'Cancel all children', {
|
|
807
|
+
runId: 'run-cancel-all',
|
|
808
|
+
signal: controller.signal,
|
|
809
|
+
timeoutMs: 1000,
|
|
810
|
+
maxTurns: 1,
|
|
811
|
+
concurrency: 3,
|
|
812
|
+
});
|
|
813
|
+
await waitFor(() => session.headlessPlan.every((step) => step.status === 'running'));
|
|
814
|
+
controller.abort();
|
|
815
|
+
|
|
816
|
+
await assert.rejects(running, { name: 'AbortError' });
|
|
817
|
+
assert.deepEqual(aborted.sort(), ['a', 'b', 'c']);
|
|
818
|
+
assert.deepEqual(session.headlessPlan.map((step) => step.status), ['cancelled', 'cancelled', 'cancelled']);
|
|
819
|
+
const updates = session.agentEvents
|
|
820
|
+
.filter((event) => event.type === 'plan_step_updated')
|
|
821
|
+
.map((event) => `${event.taskId}:${event.payload.status}`);
|
|
822
|
+
assert.deepEqual(updates.slice(0, 3).sort(), ['a:running', 'b:running', 'c:running']);
|
|
823
|
+
assert.deepEqual(updates.slice(3).sort(), ['a:cancelled', 'b:cancelled', 'c:cancelled']);
|
|
824
|
+
});
|
|
825
|
+
|
|
444
826
|
test('runRuntimeAgenticWorkflow hands off to the parallel scheduler when turn 1 sets a multi-task ready plan', async () => {
|
|
445
827
|
const session = {
|
|
446
828
|
activities: {},
|
package/src/runtime/server.js
CHANGED
|
@@ -6,7 +6,7 @@ import { validateContractInDev } from '../contracts/schemas.js';
|
|
|
6
6
|
import { runtimeTokenFromEnv } from './auth.js';
|
|
7
7
|
|
|
8
8
|
export function startRuntimeServer({
|
|
9
|
-
host = '
|
|
9
|
+
host = '127.0.0.1',
|
|
10
10
|
port = 7788,
|
|
11
11
|
token = runtimeTokenFromEnv(),
|
|
12
12
|
store,
|
|
@@ -18,6 +18,7 @@ export function startRuntimeServer({
|
|
|
18
18
|
approve,
|
|
19
19
|
configProfiles,
|
|
20
20
|
useConfigProfile,
|
|
21
|
+
exitOnShutdown = process.env.WIKI_MANAGER_RUNTIME_CHILD === '1',
|
|
21
22
|
} = {}) {
|
|
22
23
|
const clients = new Set();
|
|
23
24
|
const defaultContext = { workspace: null, session, running: false, currentAbortController: null, currentRunId: null };
|
|
@@ -31,6 +32,13 @@ export function startRuntimeServer({
|
|
|
31
32
|
}
|
|
32
33
|
}
|
|
33
34
|
|
|
35
|
+
function publishState(workspace = null, context = null) {
|
|
36
|
+
for (const client of clients) {
|
|
37
|
+
if (client.workspace && client.workspace !== workspace) continue;
|
|
38
|
+
client.response.write(`event: state\ndata: ${JSON.stringify(runtimeState(context, store, { workspace, session }))}\n\n`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
34
42
|
const server = createServer(async (request, response) => {
|
|
35
43
|
try {
|
|
36
44
|
if (!isAuthorized(request, token)) {
|
|
@@ -223,6 +231,23 @@ export function startRuntimeServer({
|
|
|
223
231
|
sendJson(response, 202, { cancelled: true, workspace: context.workspace ?? workspace ?? null });
|
|
224
232
|
return;
|
|
225
233
|
}
|
|
234
|
+
if (request.method === 'POST' && url.pathname === '/shutdown') {
|
|
235
|
+
const workspace = workspaceFromUrl(url);
|
|
236
|
+
const context = await resolveContext({ workspace });
|
|
237
|
+
if (context?.running && context.currentAbortController) {
|
|
238
|
+
context.currentAbortController.abort();
|
|
239
|
+
await cancel?.(context);
|
|
240
|
+
}
|
|
241
|
+
sendJson(response, 202, { shutdown: true });
|
|
242
|
+
setImmediate(() => {
|
|
243
|
+
for (const client of clients) client.response.end();
|
|
244
|
+
clients.clear();
|
|
245
|
+
server.close(() => {
|
|
246
|
+
if (exitOnShutdown) process.exit(0);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
226
251
|
if (request.method === 'POST' && url.pathname === '/resume') {
|
|
227
252
|
const workspace = workspaceFromUrl(url);
|
|
228
253
|
const result = await resume?.({ workspace });
|
|
@@ -307,6 +332,7 @@ export function startRuntimeServer({
|
|
|
307
332
|
context.currentAbortController = null;
|
|
308
333
|
context.currentRunId = null;
|
|
309
334
|
context.currentRunWorkspace = null;
|
|
335
|
+
publishState(runWorkspace, context);
|
|
310
336
|
void startNextControlRequest(context);
|
|
311
337
|
});
|
|
312
338
|
return { accepted: true, runId, workspace: runWorkspace };
|
|
@@ -610,7 +636,7 @@ function classifyControlMessage(input, status, forcedIntent = null) {
|
|
|
610
636
|
if (['observe', 'converse', 'mutate', 'enqueue'].includes(intent)) {
|
|
611
637
|
return { kind: intent, confidence: 1, reason: 'explicit_intent' };
|
|
612
638
|
}
|
|
613
|
-
if (/\b(o[uù] en
|
|
639
|
+
if (/\b(o[uù] en es[t-]|status|statut|progress|progression|build|run|job|queue|file|logs?|explique|explain|inspect|show|montre|quoi de neuf)\b/i.test(lower)) {
|
|
614
640
|
return { kind: 'observe', confidence: 0.86, reason: 'status_or_explanation_request' };
|
|
615
641
|
}
|
|
616
642
|
if (status.running && /\b(ajoute|add|change|modifie|modify|remplace|replace|retire|remove|skip|ignore|apr[eè]s|before|after|chaque|each|plan|step|t[aâ]che)\b/i.test(lower)) {
|
|
@@ -46,6 +46,44 @@ test('runtime server checks bearer and x-runtime-token credentials', async (t) =
|
|
|
46
46
|
}
|
|
47
47
|
});
|
|
48
48
|
|
|
49
|
+
test('runtime server shutdown endpoint closes the listener', async (t) => {
|
|
50
|
+
let handle;
|
|
51
|
+
try {
|
|
52
|
+
handle = await startRuntimeServer({
|
|
53
|
+
host: '127.0.0.1',
|
|
54
|
+
port: 0,
|
|
55
|
+
token: 'runtime-secret',
|
|
56
|
+
exitOnShutdown: false,
|
|
57
|
+
store: {
|
|
58
|
+
dbPath: ':memory:',
|
|
59
|
+
getState: () => ({ status: 'idle' }),
|
|
60
|
+
listEvents: () => [],
|
|
61
|
+
},
|
|
62
|
+
session: {},
|
|
63
|
+
run: async () => {},
|
|
64
|
+
});
|
|
65
|
+
} catch (err) {
|
|
66
|
+
if (err?.code === 'EPERM') {
|
|
67
|
+
t.skip('network listen is not permitted in this sandbox');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
throw err;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const url = `http://127.0.0.1:${handle.port}`;
|
|
74
|
+
const response = await fetch(`${url}/shutdown`, {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
headers: { authorization: 'Bearer runtime-secret' },
|
|
77
|
+
});
|
|
78
|
+
assert.equal(response.status, 202);
|
|
79
|
+
assert.equal((await response.json()).shutdown, true);
|
|
80
|
+
|
|
81
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
82
|
+
await assert.rejects(fetch(`${url}/health`, {
|
|
83
|
+
headers: { authorization: 'Bearer runtime-secret' },
|
|
84
|
+
}));
|
|
85
|
+
});
|
|
86
|
+
|
|
49
87
|
test('runtime server accepts only one active run', async (t) => {
|
|
50
88
|
let releaseRun;
|
|
51
89
|
let runCount = 0;
|
package/src/runtime/store.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdirSync } from 'node:fs';
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, join, resolve } from 'node:path';
|
|
3
3
|
import { DatabaseSync } from 'node:sqlite';
|
|
4
4
|
import { applyAgentProjectionToSession, dispatchAgentEvent, reduceAgentEvents } from '../core/agentEvents.js';
|
|
@@ -9,6 +9,9 @@ import { projectWorkflow } from '../core/workflow.js';
|
|
|
9
9
|
export { defaultRuntimeStateDir };
|
|
10
10
|
|
|
11
11
|
const NON_PERSISTED_EVENT_TYPES = new Set(['runtime_log']);
|
|
12
|
+
export const RUNTIME_STORE_SCHEMA_VERSION = 1;
|
|
13
|
+
const RUNTIME_RETENTION_DAYS = 30;
|
|
14
|
+
const TERMINAL_RUN_STATUSES = ['done', 'error', 'cancelled', 'interrupted'];
|
|
12
15
|
|
|
13
16
|
const RUN_STATUS_BY_EVENT = {
|
|
14
17
|
run_done: 'done',
|
|
@@ -21,13 +24,20 @@ const RUN_STATUS_BY_EVENT = {
|
|
|
21
24
|
const RECOVERABLE_RUN_STATUSES = ['running', 'waiting', 'pending_approval'];
|
|
22
25
|
export const RECOVERABLE_QUEUE_STATUSES = ['waiting', 'queued', 'starting', 'running', 'blocked', 'pending_approval'];
|
|
23
26
|
|
|
24
|
-
export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName = 'runtime.db' } = {}) {
|
|
27
|
+
export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName = 'runtime.db', metaPath = null } = {}) {
|
|
25
28
|
const resolvedStateDir = resolve(stateDir);
|
|
26
29
|
mkdirSync(resolvedStateDir, { recursive: true });
|
|
27
30
|
const dbPath = join(resolvedStateDir, fileName);
|
|
28
31
|
const db = new DatabaseSync(dbPath);
|
|
29
32
|
db.exec('PRAGMA journal_mode = WAL');
|
|
30
33
|
db.exec('PRAGMA foreign_keys = ON');
|
|
34
|
+
try {
|
|
35
|
+
ensureKnownStoreVersion(db, dbPath);
|
|
36
|
+
ensureRuntimeMeta(metaPath ?? defaultRuntimeMetaPath(resolvedStateDir));
|
|
37
|
+
} catch (error) {
|
|
38
|
+
db.close();
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
31
41
|
db.exec(`
|
|
32
42
|
CREATE TABLE IF NOT EXISTS events (
|
|
33
43
|
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -76,6 +86,8 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
|
|
|
76
86
|
backfillEventSequence(db);
|
|
77
87
|
db.exec('CREATE INDEX IF NOT EXISTS idx_events_workspace ON events(workspace)');
|
|
78
88
|
db.exec('CREATE INDEX IF NOT EXISTS idx_events_sequence ON events(sequence)');
|
|
89
|
+
db.exec(`PRAGMA user_version = ${RUNTIME_STORE_SCHEMA_VERSION}`);
|
|
90
|
+
purgeOldTerminalRuns(db);
|
|
79
91
|
|
|
80
92
|
let lastEventId = null;
|
|
81
93
|
let lastEventSequence = null;
|
|
@@ -423,6 +435,64 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
|
|
|
423
435
|
};
|
|
424
436
|
}
|
|
425
437
|
|
|
438
|
+
function defaultRuntimeMetaPath(stateDir) {
|
|
439
|
+
return join(dirname(stateDir), '.wiki', 'meta.json');
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function ensureKnownStoreVersion(db, dbPath) {
|
|
443
|
+
const row = db.prepare('PRAGMA user_version').get();
|
|
444
|
+
const version = Number(row?.user_version ?? 0);
|
|
445
|
+
if (version > RUNTIME_STORE_SCHEMA_VERSION) {
|
|
446
|
+
throw new Error(`Unsupported runtime store schema version ${version} in ${dbPath}; this manager supports version ${RUNTIME_STORE_SCHEMA_VERSION}. Upgrade llm-wiki-manager before opening this runtime store.`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function ensureRuntimeMeta(metaPath) {
|
|
451
|
+
mkdirSync(dirname(metaPath), { recursive: true });
|
|
452
|
+
if (!existsSync(metaPath)) {
|
|
453
|
+
writeFileSync(metaPath, `${JSON.stringify({ schemaVersion: RUNTIME_STORE_SCHEMA_VERSION }, null, 2)}\n`, 'utf8');
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
let parsed;
|
|
457
|
+
try {
|
|
458
|
+
parsed = JSON.parse(readFileSync(metaPath, 'utf8'));
|
|
459
|
+
} catch (error) {
|
|
460
|
+
throw new Error(`Invalid runtime metadata file ${metaPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
461
|
+
}
|
|
462
|
+
const version = Number(parsed?.schemaVersion ?? 0);
|
|
463
|
+
if (version > RUNTIME_STORE_SCHEMA_VERSION) {
|
|
464
|
+
throw new Error(`Unsupported runtime metadata schemaVersion ${version} in ${metaPath}; this manager supports version ${RUNTIME_STORE_SCHEMA_VERSION}. Upgrade llm-wiki-manager before opening this runtime state.`);
|
|
465
|
+
}
|
|
466
|
+
if (!version) {
|
|
467
|
+
writeFileSync(metaPath, `${JSON.stringify({ ...parsed, schemaVersion: RUNTIME_STORE_SCHEMA_VERSION }, null, 2)}\n`, 'utf8');
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function purgeOldTerminalRuns(db, now = new Date()) {
|
|
472
|
+
const cutoff = new Date(now.getTime() - RUNTIME_RETENTION_DAYS * 24 * 60 * 60 * 1000).toISOString();
|
|
473
|
+
const placeholders = TERMINAL_RUN_STATUSES.map(() => '?').join(', ');
|
|
474
|
+
const oldRuns = db
|
|
475
|
+
.prepare(`SELECT id FROM runs WHERE status IN (${placeholders}) AND updated_at < ?`)
|
|
476
|
+
.all(...TERMINAL_RUN_STATUSES, cutoff)
|
|
477
|
+
.map((row) => row.id);
|
|
478
|
+
if (oldRuns.length === 0) return 0;
|
|
479
|
+
db.exec('BEGIN');
|
|
480
|
+
try {
|
|
481
|
+
const deleteEvents = db.prepare('DELETE FROM events WHERE run_id = ? OR json_extract(payload, \'$.runId\') = ?');
|
|
482
|
+
const deleteRun = db.prepare('DELETE FROM runs WHERE id = ?');
|
|
483
|
+
for (const runId of oldRuns) {
|
|
484
|
+
deleteEvents.run(runId, runId);
|
|
485
|
+
deleteRun.run(runId);
|
|
486
|
+
}
|
|
487
|
+
db.exec('COMMIT');
|
|
488
|
+
} catch (error) {
|
|
489
|
+
db.exec('ROLLBACK');
|
|
490
|
+
throw error;
|
|
491
|
+
}
|
|
492
|
+
db.exec('VACUUM');
|
|
493
|
+
return oldRuns.length;
|
|
494
|
+
}
|
|
495
|
+
|
|
426
496
|
function rowToEvent(row) {
|
|
427
497
|
return {
|
|
428
498
|
sequence: row.sequence ?? null,
|