@dotdrelle/wiki-manager 0.9.3 → 0.11.0
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 +21 -6
- package/agents.docker-compose.yml +5 -0
- package/package.json +5 -2
- package/src/agent/graph.js +68 -9
- package/src/agent/graph.test.js +64 -0
- package/src/cli/wiki-manager.js +1 -1
- package/src/commands/slash.js +6 -2
- package/src/contracts/README.md +16 -0
- package/src/contracts/schemas.js +302 -0
- package/src/contracts/schemas.test.js +93 -0
- package/src/core/activity.js +14 -2
- package/src/core/activity.test.js +4 -2
- package/src/core/agentEvents.js +158 -15
- package/src/core/agentEvents.test.js +54 -12
- package/src/core/agentLoop.js +32 -7
- package/src/core/mcp.js +3 -1
- package/src/core/plan.js +4 -0
- package/src/core/planPatch.js +224 -0
- package/src/core/planPatch.test.js +63 -0
- package/src/core/workflow.js +264 -0
- package/src/core/workflow.test.js +66 -0
- package/src/runtime/client.js +28 -1
- package/src/runtime/runner.js +432 -20
- package/src/runtime/runner.test.js +273 -1
- package/src/runtime/server.js +322 -15
- package/src/runtime/server.test.js +339 -0
- package/src/runtime/store.js +59 -10
- package/src/runtime/store.test.js +47 -1
- package/src/shell/RightPane.tsx +1 -7
- package/src/shell/StartupScreen.tsx +212 -0
- package/src/shell/repl.js +51 -7
- package/src/shell/repl.test.js +77 -1
- package/src/shell/textFit.ts +6 -0
- package/src/shell/tui.tsx +163 -0
- package/src/shell/useAgent.ts +17 -9
- package/src/shell/useSession.ts +34 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import test from 'node:test';
|
|
3
3
|
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
4
|
-
import { finishRuntimeRun, runRuntimeAgenticWorkflow } from './runner.js';
|
|
4
|
+
import { finishRuntimeRun, replanRuntimeRun, runRuntimeAgenticWorkflow, runRuntimeParallelPlan } from './runner.js';
|
|
5
5
|
|
|
6
6
|
test('finishRuntimeRun emits evaluation before run_done', async () => {
|
|
7
7
|
const events = [];
|
|
@@ -150,6 +150,31 @@ test('runRuntimeAgenticWorkflow replans after negative evaluation', async () =>
|
|
|
150
150
|
assert.equal(session.agentProjection.status, 'done');
|
|
151
151
|
});
|
|
152
152
|
|
|
153
|
+
test('replanRuntimeRun preserves completed outputs when replacing remaining work', async () => {
|
|
154
|
+
const session = {
|
|
155
|
+
activities: {},
|
|
156
|
+
headlessPlan: [
|
|
157
|
+
{ step: 1, id: 'export', description: 'Export', status: 'done', outputRefs: ['raw/export.json'] },
|
|
158
|
+
{ step: 2, id: 'build', description: 'Build', status: 'failed' },
|
|
159
|
+
],
|
|
160
|
+
llm: {
|
|
161
|
+
async completeWithTools() {
|
|
162
|
+
return { content: '{"steps":["Retry build"]}' };
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const result = await replanRuntimeRun(session, 'Build deliverable', {
|
|
168
|
+
kind: 'activity_error',
|
|
169
|
+
reason: 'Build failed.',
|
|
170
|
+
}, { runId: 'run-replan-preserve', replansLeft: 1 });
|
|
171
|
+
|
|
172
|
+
assert.equal(result.ok, true);
|
|
173
|
+
assert.deepEqual(session.headlessPlan.map((step) => step.id), ['export', 'replan-1']);
|
|
174
|
+
assert.deepEqual(session.headlessPlan[0].outputRefs, ['raw/export.json']);
|
|
175
|
+
assert.deepEqual(session.headlessPlan[1].dependsOn, ['export']);
|
|
176
|
+
});
|
|
177
|
+
|
|
153
178
|
test('runRuntimeAgenticWorkflow replans after terminal activity error', async () => {
|
|
154
179
|
const originalFetch = globalThis.fetch;
|
|
155
180
|
let pollAttempts = 0;
|
|
@@ -268,3 +293,250 @@ test('runRuntimeAgenticWorkflow stops after replan budget is exhausted', async (
|
|
|
268
293
|
assert.equal(session.agentProjection.status, 'error');
|
|
269
294
|
assert.equal(session.agentProjection.replans.length, 0);
|
|
270
295
|
});
|
|
296
|
+
|
|
297
|
+
test('runRuntimeParallelPlan executes ready tasks concurrently in one parent run', async () => {
|
|
298
|
+
const started = [];
|
|
299
|
+
const release = {};
|
|
300
|
+
const session = {
|
|
301
|
+
activities: {},
|
|
302
|
+
headlessPlan: [
|
|
303
|
+
{ step: 1, id: 'a', description: 'Task A', status: 'pending', dependsOn: [] },
|
|
304
|
+
{ step: 2, id: 'b', description: 'Task B', status: 'pending', dependsOn: [] },
|
|
305
|
+
{ step: 3, id: 'join', description: 'Join', status: 'pending', dependsOn: ['a', 'b'] },
|
|
306
|
+
],
|
|
307
|
+
};
|
|
308
|
+
const agent = {
|
|
309
|
+
async invoke({ input }) {
|
|
310
|
+
const id = input.match(/Task id: ([^\n]+)/)?.[1];
|
|
311
|
+
started.push(id);
|
|
312
|
+
if (id !== 'join') {
|
|
313
|
+
await new Promise((resolve) => { release[id] = resolve; });
|
|
314
|
+
}
|
|
315
|
+
return { response: `done ${id}` };
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
const running = runRuntimeParallelPlan(agent, session, 'Do work', {
|
|
320
|
+
runId: 'run-parallel',
|
|
321
|
+
timeoutMs: 1000,
|
|
322
|
+
maxTurns: 1,
|
|
323
|
+
concurrency: 2,
|
|
324
|
+
});
|
|
325
|
+
await waitFor(() => started.includes('a') && started.includes('b'));
|
|
326
|
+
assert.deepEqual(session.headlessPlan.slice(0, 2).map((step) => step.status), ['running', 'running']);
|
|
327
|
+
release.a();
|
|
328
|
+
release.b();
|
|
329
|
+
|
|
330
|
+
const result = await running;
|
|
331
|
+
|
|
332
|
+
assert.equal(result.ok, true);
|
|
333
|
+
assert.deepEqual(started, ['a', 'b', 'join']);
|
|
334
|
+
assert.deepEqual(session.headlessPlan.map((step) => step.status), ['done', 'done', 'done']);
|
|
335
|
+
assert.ok(session.agentEvents.every((event) => event.runId === 'run-parallel'));
|
|
336
|
+
assert.ok(session.agentEvents.some((event) => event.taskId === 'a'));
|
|
337
|
+
assert.ok(session.agentEvents.some((event) => event.taskId === 'b'));
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test('runRuntimeParallelPlan serializes conflicting write locks', async () => {
|
|
341
|
+
let active = 0;
|
|
342
|
+
let maxActive = 0;
|
|
343
|
+
const session = {
|
|
344
|
+
activities: {},
|
|
345
|
+
headlessPlan: [
|
|
346
|
+
{ step: 1, id: 'a', description: 'Write A', status: 'pending', dependsOn: [], locks: ['workspace:write'] },
|
|
347
|
+
{ step: 2, id: 'b', description: 'Write B', status: 'pending', dependsOn: [], locks: ['workspace:write'] },
|
|
348
|
+
],
|
|
349
|
+
};
|
|
350
|
+
const agent = {
|
|
351
|
+
async invoke() {
|
|
352
|
+
active += 1;
|
|
353
|
+
maxActive = Math.max(maxActive, active);
|
|
354
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
355
|
+
active -= 1;
|
|
356
|
+
return { response: 'done' };
|
|
357
|
+
},
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
const result = await runRuntimeParallelPlan(agent, session, 'Write safely', {
|
|
361
|
+
runId: 'run-locks',
|
|
362
|
+
timeoutMs: 1000,
|
|
363
|
+
maxTurns: 1,
|
|
364
|
+
concurrency: 2,
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
assert.equal(result.ok, true);
|
|
368
|
+
assert.equal(maxActive, 1);
|
|
369
|
+
assert.deepEqual(session.headlessPlan.map((step) => step.status), ['done', 'done']);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test('runRuntimeParallelPlan keeps independent child running after sibling failure', async () => {
|
|
373
|
+
const releaseB = {};
|
|
374
|
+
const session = {
|
|
375
|
+
activities: {},
|
|
376
|
+
headlessPlan: [
|
|
377
|
+
{ step: 1, id: 'a', description: 'Failing branch', status: 'pending', dependsOn: [] },
|
|
378
|
+
{ step: 2, id: 'b', description: 'Independent branch', status: 'pending', dependsOn: [] },
|
|
379
|
+
{ step: 3, id: 'join', description: 'Converge', status: 'pending', dependsOn: ['a', 'b'] },
|
|
380
|
+
],
|
|
381
|
+
};
|
|
382
|
+
const agent = {
|
|
383
|
+
async invoke({ input }) {
|
|
384
|
+
const id = input.match(/Task id: ([^\n]+)/)?.[1];
|
|
385
|
+
if (id === 'a') throw new Error('branch failed');
|
|
386
|
+
if (id === 'b') await new Promise((resolve) => { releaseB.resolve = resolve; });
|
|
387
|
+
return { response: `done ${id}` };
|
|
388
|
+
},
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
const running = runRuntimeParallelPlan(agent, session, 'Parallel failure', {
|
|
392
|
+
runId: 'run-failure',
|
|
393
|
+
timeoutMs: 1000,
|
|
394
|
+
maxTurns: 1,
|
|
395
|
+
concurrency: 2,
|
|
396
|
+
});
|
|
397
|
+
await waitFor(() => session.headlessPlan.find((step) => step.id === 'a')?.status === 'failed');
|
|
398
|
+
assert.equal(session.headlessPlan.find((step) => step.id === 'b')?.status, 'running');
|
|
399
|
+
releaseB.resolve();
|
|
400
|
+
|
|
401
|
+
const result = await running;
|
|
402
|
+
|
|
403
|
+
assert.equal(result.ok, false);
|
|
404
|
+
assert.equal(result.reason, 'no_ready_plan_task');
|
|
405
|
+
assert.deepEqual(session.headlessPlan.map((step) => step.status), ['failed', 'done', 'pending']);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test('runRuntimeParallelPlan propagates parent cancellation to child tasks', async () => {
|
|
409
|
+
const controller = new AbortController();
|
|
410
|
+
const session = {
|
|
411
|
+
activities: {},
|
|
412
|
+
headlessPlan: [
|
|
413
|
+
{ step: 1, id: 'a', description: 'Long task', status: 'pending', dependsOn: [] },
|
|
414
|
+
],
|
|
415
|
+
};
|
|
416
|
+
const agent = {
|
|
417
|
+
async invoke({ session: childSession, signal }) {
|
|
418
|
+
assert.equal(childSession._abortSignal, signal);
|
|
419
|
+
await new Promise((resolve, reject) => {
|
|
420
|
+
signal.addEventListener('abort', () => {
|
|
421
|
+
const err = new Error('cancelled');
|
|
422
|
+
err.name = 'AbortError';
|
|
423
|
+
reject(err);
|
|
424
|
+
}, { once: true });
|
|
425
|
+
});
|
|
426
|
+
return { response: 'unreachable' };
|
|
427
|
+
},
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
const running = runRuntimeParallelPlan(agent, session, 'Cancel work', {
|
|
431
|
+
runId: 'run-cancel',
|
|
432
|
+
signal: controller.signal,
|
|
433
|
+
timeoutMs: 1000,
|
|
434
|
+
maxTurns: 1,
|
|
435
|
+
concurrency: 1,
|
|
436
|
+
});
|
|
437
|
+
await waitFor(() => session.headlessPlan[0]?.status === 'running');
|
|
438
|
+
controller.abort();
|
|
439
|
+
|
|
440
|
+
await assert.rejects(running, { name: 'AbortError' });
|
|
441
|
+
assert.equal(session.headlessPlan[0].status, 'cancelled');
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test('runRuntimeAgenticWorkflow hands off to the parallel scheduler when turn 1 sets a multi-task ready plan', async () => {
|
|
445
|
+
const session = {
|
|
446
|
+
activities: {},
|
|
447
|
+
headlessPlan: null,
|
|
448
|
+
llm: {
|
|
449
|
+
async completeWithTools() {
|
|
450
|
+
return { content: '{"ok":true,"reason":"Done.","suggestedAction":null}' };
|
|
451
|
+
},
|
|
452
|
+
},
|
|
453
|
+
};
|
|
454
|
+
const started = [];
|
|
455
|
+
const agent = {
|
|
456
|
+
async invoke({ session: turnSession, input }) {
|
|
457
|
+
if (turnSession.headlessPlan === null) {
|
|
458
|
+
dispatchAgentEvent(turnSession, createAgentEvent('plan_set', {
|
|
459
|
+
origin: 'tool',
|
|
460
|
+
payload: {
|
|
461
|
+
steps: [
|
|
462
|
+
{ id: 'a', description: 'Task A', dependsOn: [] },
|
|
463
|
+
{ id: 'b', description: 'Task B', dependsOn: [] },
|
|
464
|
+
],
|
|
465
|
+
},
|
|
466
|
+
}));
|
|
467
|
+
return { response: 'Plan set.' };
|
|
468
|
+
}
|
|
469
|
+
const id = input.match(/Task id: ([^\n]+)/)?.[1];
|
|
470
|
+
started.push(id);
|
|
471
|
+
return { response: `done ${id}` };
|
|
472
|
+
},
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
const result = await runRuntimeAgenticWorkflow(agent, session, 'Do two independent things', {
|
|
476
|
+
runId: 'run-handoff',
|
|
477
|
+
timeoutMs: 1000,
|
|
478
|
+
maxTurns: 3,
|
|
479
|
+
maxReplans: 0,
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
assert.equal(result.ok, true);
|
|
483
|
+
assert.deepEqual(started.sort(), ['a', 'b']);
|
|
484
|
+
assert.deepEqual(session.headlessPlan.map((step) => step.status), ['done', 'done']);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
test('runRuntimeParallelPlan drains other active tasks before rejecting on cancellation', async () => {
|
|
488
|
+
const controller = new AbortController();
|
|
489
|
+
let bSettled = false;
|
|
490
|
+
const session = {
|
|
491
|
+
activities: {},
|
|
492
|
+
headlessPlan: [
|
|
493
|
+
{ step: 1, id: 'a', description: 'Task A', status: 'pending', dependsOn: [] },
|
|
494
|
+
{ step: 2, id: 'b', description: 'Task B', status: 'pending', dependsOn: [] },
|
|
495
|
+
],
|
|
496
|
+
};
|
|
497
|
+
const agent = {
|
|
498
|
+
async invoke({ input, signal }) {
|
|
499
|
+
const id = input.match(/Task id: ([^\n]+)/)?.[1];
|
|
500
|
+
if (id === 'a') {
|
|
501
|
+
await new Promise((resolve, reject) => {
|
|
502
|
+
signal.addEventListener('abort', () => {
|
|
503
|
+
const err = new Error('cancelled');
|
|
504
|
+
err.name = 'AbortError';
|
|
505
|
+
reject(err);
|
|
506
|
+
}, { once: true });
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
if (id === 'b') {
|
|
510
|
+
await new Promise((resolve) => {
|
|
511
|
+
signal.addEventListener('abort', () => {
|
|
512
|
+
setTimeout(() => { bSettled = true; resolve(); }, 20);
|
|
513
|
+
}, { once: true });
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
return { response: `done ${id}` };
|
|
517
|
+
},
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
const running = runRuntimeParallelPlan(agent, session, 'Cancel work', {
|
|
521
|
+
runId: 'run-cancel-drain',
|
|
522
|
+
signal: controller.signal,
|
|
523
|
+
timeoutMs: 1000,
|
|
524
|
+
maxTurns: 1,
|
|
525
|
+
concurrency: 2,
|
|
526
|
+
});
|
|
527
|
+
await waitFor(() => session.headlessPlan.every((step) => step.status === 'running'));
|
|
528
|
+
controller.abort();
|
|
529
|
+
|
|
530
|
+
await assert.rejects(running, { name: 'AbortError' });
|
|
531
|
+
assert.equal(bSettled, true);
|
|
532
|
+
assert.equal(session.headlessPlan.find((step) => step.id === 'b').status, 'done');
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
async function waitFor(predicate, timeoutMs = 500) {
|
|
536
|
+
const deadline = Date.now() + timeoutMs;
|
|
537
|
+
while (Date.now() < deadline) {
|
|
538
|
+
if (predicate()) return;
|
|
539
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
540
|
+
}
|
|
541
|
+
assert.fail('condition was not met before timeout');
|
|
542
|
+
}
|