@dotdrelle/wiki-manager 0.10.4 → 0.11.4

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 CHANGED
@@ -11,6 +11,12 @@ jobs, and run one-shot headless tasks.
11
11
  The manager does not implement the wiki engine or the external agents. It
12
12
  **orchestrates** them.
13
13
 
14
+ Scope note: 0.11.0 is an industrialized single-user deployment baseline. The
15
+ multi-user model is specified in `llm-wiki/docs/industrialisation.md` and
16
+ planned for 0.12.0. Until then, do not expose the runtime as a shared write
17
+ surface; it binds to `127.0.0.1` by default, and `--host 0.0.0.0` must be an
18
+ explicit deployment choice with bearer-token and network protection.
19
+
14
20
  ---
15
21
 
16
22
  ## What it's for, in one sentence
@@ -538,6 +544,14 @@ Runtime split: the host manager/runtime uses Node.js 22+ for `node:sqlite`; the
538
544
  interactive OpenTUI shell uses Bun 1.2+; workspace Docker services run from the
539
545
  published images and do not depend on host `node_modules`.
540
546
 
547
+ As of 0.11.4, the host runtime store carries a minimal format guard:
548
+ `PRAGMA user_version = 1` in SQLite plus `.wiki/meta.json` with
549
+ `schemaVersion: 1`. Unknown future versions stop startup with a clear error.
550
+ On startup, terminal runs older than 30 days are deleted with their events and
551
+ the database is vacuumed. The runtime test suite also includes a fixed-latency
552
+ parallel scheduler guard asserting that two independent build tasks run under
553
+ 65% of the sequential duration.
554
+
541
555
  ```bash
542
556
  wiki-workspace list
543
557
  wiki-workspace agents up
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdrelle/wiki-manager",
3
- "version": "0.10.4",
3
+ "version": "0.11.4",
4
4
  "description": "Agentic shell and orchestration cockpit for llm-wiki workspaces.",
5
5
  "license": "PolyForm-Noncommercial-1.0.0",
6
6
  "author": "dotrelle",
@@ -11,7 +11,7 @@
11
11
  },
12
12
  "scripts": {
13
13
  "start": "bun ./bin/wiki-manager.js",
14
- "test": "node --test src/agent/graph.test.js src/contracts/schemas.test.js src/core/activity.test.js src/core/agentEvents.test.js src/core/workflow.test.js src/core/planPatch.test.js src/core/agentLoop.test.js src/core/plan.test.js src/core/mcp.test.js src/core/documentIntake.test.js src/core/dockerCompose.test.js src/core/wikiWorkspace.test.js src/core/wikirc.test.js src/core/modelFetch.test.js src/core/startupCheck.test.js src/core/queueStore.test.js src/commands/slash.test.js src/shell/repl.test.js src/runtime/store.test.js src/runtime/server.test.js src/runtime/supervisor.test.js src/runtime/runner.test.js src/runtime/auth.test.js",
14
+ "test": "node --test src/agent/graph.test.js src/contracts/schemas.test.js src/core/activity.test.js src/core/agentEvents.test.js src/core/workflow.test.js src/core/planPatch.test.js src/core/agentLoop.test.js src/core/plan.test.js src/core/mcp.test.js src/core/documentIntake.test.js src/core/dockerCompose.test.js src/core/wikiWorkspace.test.js src/core/wikirc.test.js src/core/modelFetch.test.js src/core/startupCheck.test.js src/core/queueStore.test.js src/commands/slash.test.js src/shell/repl.test.js src/runtime/store.test.js src/runtime/server.test.js src/runtime/supervisor.test.js src/runtime/runner.test.js src/runtime/runner.e2e.test.js src/runtime/auth.test.js",
15
15
  "check-versions": "node scripts/check-versions.js",
16
16
  "prepack": "node scripts/check-versions.js",
17
17
  "prepublishOnly": "node scripts/check-versions.js",
@@ -281,7 +281,7 @@ async function runRuntime(argv, agent) {
281
281
  'Runs the local agentic runtime used by wiki-manager Shell and llm-wiki serve.',
282
282
  '',
283
283
  'Defaults:',
284
- ' --host 0.0.0.0',
284
+ ' --host 127.0.0.1',
285
285
  ' --port 7788',
286
286
  ' --state-dir .wiki-manager',
287
287
  ].join('\n'));
@@ -295,7 +295,7 @@ async function runRuntime(argv, agent) {
295
295
  const { createApprovalManager } = await import('../runtime/approvals.js');
296
296
  const { runRuntimeAgenticWorkflow } = await import('../runtime/runner.js');
297
297
 
298
- const host = valueAfter(argv, '--host') ?? process.env.WIKI_MANAGER_RUNTIME_HOST ?? '0.0.0.0';
298
+ const host = valueAfter(argv, '--host') ?? process.env.WIKI_MANAGER_RUNTIME_HOST ?? '127.0.0.1';
299
299
  const port = Number(valueAfter(argv, '--port') ?? process.env.WIKI_MANAGER_RUNTIME_PORT ?? 7788);
300
300
  const stateDir = valueAfter(argv, '--state-dir') ?? defaultRuntimeStateDir();
301
301
  const auth = resolveRuntimeAuthToken({ host, stateDir });
@@ -29,7 +29,12 @@ import {
29
29
  summarizeWikircConfig,
30
30
  } from '../core/wikirc.js';
31
31
  import { applySessionWikircProfile } from '../core/sessionConfig.js';
32
- import { deleteWorkspaceAndFiles, startAgents, stopAgents } from '../core/wikiSetup.js';
32
+ import {
33
+ deleteWorkspaceAndFiles,
34
+ finalizeCreatedWorkspace,
35
+ startAgents,
36
+ stopAgents,
37
+ } from '../core/wikiSetup.js';
33
38
  import {
34
39
  cleanDocumentUploads,
35
40
  convertPendingDocumentUploads,
@@ -454,6 +459,7 @@ async function createWorkspaceCommand(context, workspaceName, targetPath) {
454
459
  try {
455
460
  context.onStep?.(`Workspace: creating ${workspaceName}…`);
456
461
  const output = await createWorkspace(workspaceName, targetPath, { timeout: 600_000 });
462
+ finalizeCreatedWorkspace(workspaceName);
457
463
  return {
458
464
  output: [
459
465
  output,
package/src/core/mcp.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { managerEnvFile, managerMcpEndpointsFile, readEnvFile } from './env.js';
3
3
 
4
- const WIKI_MANAGER_VERSION = '0.10.4';
4
+ const WIKI_MANAGER_VERSION = '0.11.4';
5
5
 
6
6
  function envValue(key) {
7
7
  const filePath = managerEnvFile();
@@ -69,14 +69,19 @@ export async function stopAgents(options = {}) {
69
69
  export async function createNewWorkspace(name, targetPath) {
70
70
  try {
71
71
  const output = await createWorkspace(name, targetPath, { timeout: 600_000 });
72
- const workspace = findWorkspace(name);
73
- if (workspace) initializeWorkspaceWikirc(workspace);
72
+ const workspace = finalizeCreatedWorkspace(name);
74
73
  return { output, workspace };
75
74
  } catch (err) {
76
75
  throw wrapDockerError(err);
77
76
  }
78
77
  }
79
78
 
79
+ export function finalizeCreatedWorkspace(name) {
80
+ const workspace = findWorkspace(name);
81
+ if (workspace) initializeWorkspaceWikirc(workspace);
82
+ return workspace;
83
+ }
84
+
80
85
  export function initializeWorkspaceWikirc(workspace) {
81
86
  const accessKey = workspace?.env?.WIKI_MCP_AUTH_TOKEN;
82
87
  if (!workspace?.workspacePath || !accessKey) return null;
@@ -1,11 +1,11 @@
1
1
  import assert from 'node:assert/strict';
2
- import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import test from 'node:test';
6
6
  import YAML from 'yaml';
7
7
  import { patchWikircProfile } from './wikirc.js';
8
- import { writeVectorConfig } from './wikiSetup.js';
8
+ import { finalizeCreatedWorkspace, writeVectorConfig } from './wikiSetup.js';
9
9
 
10
10
  test('patchWikircProfile merges keys and preserves existing values', () => {
11
11
  const root = mkdtempSync(join(tmpdir(), 'wikirc-patch-'));
@@ -109,3 +109,41 @@ test('writeVectorConfig removes commented vector placeholders it replaces', () =
109
109
  assert.equal(parsed.retrieval.vector.baseUrl, 'http://localhost:7997/v1');
110
110
  assert.equal(parsed.retrieval.vector.apiKey, 'vector-key');
111
111
  });
112
+
113
+ test('finalizeCreatedWorkspace copies generated wiki token into default wikirc', () => {
114
+ const root = mkdtempSync(join(tmpdir(), 'wikirc-workspace-token-'));
115
+ const registryRoot = join(root, 'registry');
116
+ const registryPath = join(registryRoot, 'demo');
117
+ const workspacePath = join(root, 'workspace');
118
+ const token = 'a'.repeat(64);
119
+ mkdirSync(registryPath, { recursive: true });
120
+ mkdirSync(workspacePath, { recursive: true });
121
+ writeFileSync(
122
+ join(registryPath, '.env'),
123
+ [
124
+ 'WORKSPACE_NAME=demo',
125
+ `WIKI_WORKSPACE_PATH=${workspacePath}`,
126
+ `WIKI_MCP_AUTH_TOKEN=${token}`,
127
+ '',
128
+ ].join('\n'),
129
+ 'utf8',
130
+ );
131
+ writeFileSync(
132
+ join(workspacePath, '.wikirc.yaml'),
133
+ ['language: en', 'mcp:', ' # accessKey: your-secret-key', ''].join('\n'),
134
+ 'utf8',
135
+ );
136
+
137
+ const previousDir = process.env.WIKI_WORKSPACES_DIR;
138
+ process.env.WIKI_WORKSPACES_DIR = registryRoot;
139
+ try {
140
+ const workspace = finalizeCreatedWorkspace('demo');
141
+ const parsed = YAML.parse(readFileSync(join(workspacePath, '.wikirc.yaml'), 'utf8'));
142
+
143
+ assert.equal(workspace.name, 'demo');
144
+ assert.equal(parsed.mcp.accessKey, token);
145
+ } finally {
146
+ if (previousDir === undefined) delete process.env.WIKI_WORKSPACES_DIR;
147
+ else process.env.WIKI_WORKSPACES_DIR = previousDir;
148
+ }
149
+ });
@@ -33,7 +33,7 @@ export async function assertRuntimeNode(executable = runtimeNodeExecutable()) {
33
33
  }
34
34
 
35
35
  export async function ensureRuntime({
36
- host = process.env.WIKI_MANAGER_RUNTIME_HOST ?? '0.0.0.0',
36
+ host = process.env.WIKI_MANAGER_RUNTIME_HOST ?? '127.0.0.1',
37
37
  port = Number(process.env.WIKI_MANAGER_RUNTIME_PORT ?? 7788),
38
38
  stateDir = process.env.WIKI_MANAGER_STATE_DIR ?? defaultRuntimeStateDir(),
39
39
  url = process.env.WIKI_MANAGER_RUNTIME_URL ?? `http://127.0.0.1:${port}`,
@@ -0,0 +1,52 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { runRuntimeParallelPlan } from './runner.js';
4
+
5
+ // Regression guard for plan-0.11.4-stabilisation.md §3: build de 2 templates
6
+ // en séquentiel -> T1, en multi-agent -> T2, assert T2 < 0.65*T1. Exercises
7
+ // the scheduler (runRuntimeParallelPlan) directly with mocked, fixed-latency
8
+ // tasks — it proves the scheduler itself parallelizes ready tasks, not that a
9
+ // real llm-wiki build/provider round-trip shows the same margin.
10
+ const SIMULATED_BUILD_LATENCY_MS = 150;
11
+ const MAX_PARALLEL_TO_SEQUENTIAL_RATIO = 0.65;
12
+
13
+ function buildTwoTemplatePlan() {
14
+ return [
15
+ { step: 1, id: 'template-a', description: 'Build template A', status: 'pending', dependsOn: [] },
16
+ { step: 2, id: 'template-b', description: 'Build template B', status: 'pending', dependsOn: [] },
17
+ ];
18
+ }
19
+
20
+ function latencyAgent() {
21
+ return {
22
+ async invoke() {
23
+ await new Promise((resolve) => setTimeout(resolve, SIMULATED_BUILD_LATENCY_MS));
24
+ return { response: 'done' };
25
+ },
26
+ };
27
+ }
28
+
29
+ async function timedRun(concurrency, runId) {
30
+ const session = { activities: {}, headlessPlan: buildTwoTemplatePlan() };
31
+ const startedAt = Date.now();
32
+ const result = await runRuntimeParallelPlan(latencyAgent(), session, 'Build 2 templates', {
33
+ runId,
34
+ timeoutMs: 10_000,
35
+ maxTurns: 1,
36
+ concurrency,
37
+ });
38
+ return { result, durationMs: Date.now() - startedAt };
39
+ }
40
+
41
+ test('multi-agent scheduler beats sequential on 2 independent build tasks by the required margin (plan 0.11.4 §3 guard)', async () => {
42
+ const { result: sequentialResult, durationMs: sequentialDurationMs } = await timedRun(1, 'e2e-accel-sequential');
43
+ const { result: parallelResult, durationMs: parallelDurationMs } = await timedRun(2, 'e2e-accel-parallel');
44
+
45
+ assert.equal(sequentialResult.ok, true);
46
+ assert.equal(parallelResult.ok, true);
47
+ const thresholdMs = MAX_PARALLEL_TO_SEQUENTIAL_RATIO * sequentialDurationMs;
48
+ assert.ok(
49
+ parallelDurationMs < thresholdMs,
50
+ `expected multi-agent duration (${parallelDurationMs}ms) under ${MAX_PARALLEL_TO_SEQUENTIAL_RATIO * 100}% of sequential duration (${sequentialDurationMs}ms, threshold ${thresholdMs}ms)`,
51
+ );
52
+ });
@@ -199,11 +199,16 @@ export async function runRuntimeParallelPlan(agent, session, input, {
199
199
  const locks = new Set();
200
200
  const failures = [];
201
201
  const previousIdentity = session._currentRunIdentity;
202
+ const previousPlanUpdate = session._onPlanUpdate;
202
203
  session._currentRunIdentity = {
203
204
  ...(previousIdentity ?? {}),
204
205
  runId,
205
206
  workspace: session.workspace ?? previousIdentity?.workspace ?? null,
206
207
  };
208
+ session._onPlanUpdate = () => {
209
+ previousPlanUpdate?.();
210
+ abortCancelledActiveTasks(session, active);
211
+ };
207
212
  ensurePlanProjection(session, runId);
208
213
  emitRuntimeLog(session, `scheduler: parallel plan enabled (concurrency ${limit})`);
209
214
 
@@ -238,11 +243,15 @@ export async function runRuntimeParallelPlan(agent, session, input, {
238
243
  }
239
244
 
240
245
  const settled = await Promise.race([...active.values()].map((entry) => entry.promise));
246
+ const activeEntry = active.get(settled.taskId);
241
247
  active.delete(settled.taskId);
248
+ activeEntry?.cleanup?.();
242
249
  for (const lock of settled.locks) locks.delete(lock);
243
250
  if (settled.cancelled) {
244
- await drainActive(active, locks);
245
- throwIfAborted(signal, 'Runtime run cancelled.');
251
+ if (signal?.aborted) {
252
+ await drainActive(active, locks);
253
+ throwIfAborted(signal, 'Runtime run cancelled.');
254
+ }
246
255
  continue;
247
256
  }
248
257
  if (!settled.ok) failures.push(settled);
@@ -250,16 +259,29 @@ export async function runRuntimeParallelPlan(agent, session, input, {
250
259
  } finally {
251
260
  if (previousIdentity) session._currentRunIdentity = previousIdentity;
252
261
  else delete session._currentRunIdentity;
262
+ if (previousPlanUpdate) session._onPlanUpdate = previousPlanUpdate;
263
+ else delete session._onPlanUpdate;
253
264
  }
254
265
  }
255
266
 
256
267
  async function drainActive(active, locks) {
257
268
  if (active.size === 0) return;
258
- await Promise.all([...active.values()].map((entry) => entry.promise));
269
+ const entries = [...active.values()];
270
+ await Promise.all(entries.map((entry) => entry.promise));
271
+ for (const entry of entries) entry.cleanup?.();
259
272
  active.clear();
260
273
  locks.clear();
261
274
  }
262
275
 
276
+ function abortCancelledActiveTasks(session, active) {
277
+ for (const [taskId, entry] of active.entries()) {
278
+ const current = (session.headlessPlan ?? []).find((step) => planTaskId(step) === taskId);
279
+ if (current?.status === 'cancelled' && !entry.signal?.aborted) {
280
+ entry.controller?.abort();
281
+ }
282
+ }
283
+ }
284
+
263
285
  // Scope the evaluator/replanner's view of "completed" activities to the
264
286
  // tasks that actually failed, instead of every activity the whole run has
265
287
  // ever recorded — a long-running sibling's stale (or unrelated, already
@@ -323,20 +345,44 @@ function startReadyTasks(agent, session, input, {
323
345
  payload: { taskId, status: 'running' },
324
346
  }));
325
347
  emitRuntimeLog(session, `scheduler: starting task ${taskId}`);
348
+ const taskAbort = createTaskAbortSignal(signal);
326
349
  const promise = runParallelTask(agent, session, input, task, {
327
- signal,
350
+ signal: taskAbort.signal,
328
351
  timeoutMs,
329
352
  maxTurns,
330
353
  runId,
331
354
  pollBusy,
332
355
  locks: taskLocks,
333
356
  });
334
- active.set(taskId, { taskId, locks: taskLocks, promise });
357
+ active.set(taskId, {
358
+ taskId,
359
+ locks: taskLocks,
360
+ promise,
361
+ controller: taskAbort.controller,
362
+ signal: taskAbort.signal,
363
+ cleanup: taskAbort.cleanup,
364
+ });
335
365
  started += 1;
336
366
  }
337
367
  return started;
338
368
  }
339
369
 
370
+ function createTaskAbortSignal(parentSignal) {
371
+ const controller = new AbortController();
372
+ if (!parentSignal) return { controller, signal: controller.signal, cleanup: null };
373
+ if (parentSignal.aborted) {
374
+ controller.abort();
375
+ return { controller, signal: controller.signal, cleanup: null };
376
+ }
377
+ const abort = () => controller.abort();
378
+ parentSignal.addEventListener('abort', abort, { once: true });
379
+ return {
380
+ controller,
381
+ signal: controller.signal,
382
+ cleanup: () => parentSignal.removeEventListener('abort', abort),
383
+ };
384
+ }
385
+
340
386
  async function runParallelTask(agent, parentSession, input, task, {
341
387
  signal,
342
388
  timeoutMs,
@@ -364,6 +410,9 @@ async function runParallelTask(agent, parentSession, input, task, {
364
410
  }));
365
411
  return { ok: false, taskId, locks, result };
366
412
  }
413
+ if ((parentSession.headlessPlan ?? []).find((step) => planTaskId(step) === taskId)?.status === 'cancelled') {
414
+ return { ok: false, taskId, locks, cancelled: true };
415
+ }
367
416
  dispatchAgentEvent(parentSession, createAgentEvent('plan_step_updated', {
368
417
  origin: 'runtime',
369
418
  runId,
@@ -337,6 +337,232 @@ test('runRuntimeParallelPlan executes ready tasks concurrently in one parent run
337
337
  assert.ok(session.agentEvents.some((event) => event.taskId === 'b'));
338
338
  });
339
339
 
340
+ test('runRuntimeParallelPlan schedules a plan patch applied during an active parallel run', async () => {
341
+ const started = [];
342
+ const release = {};
343
+ const session = {
344
+ activities: {},
345
+ headlessPlan: [
346
+ { step: 1, id: 'a', description: 'Task A', status: 'pending', dependsOn: [] },
347
+ { step: 2, id: 'b', description: 'Task B', status: 'pending', dependsOn: [] },
348
+ ],
349
+ };
350
+ const agent = {
351
+ async invoke({ input }) {
352
+ const id = input.match(/Task id: ([^\n]+)/)?.[1];
353
+ started.push(id);
354
+ if (id === 'a' || id === 'b') {
355
+ await new Promise((resolve) => { release[id] = resolve; });
356
+ }
357
+ return { response: `done ${id}` };
358
+ },
359
+ };
360
+
361
+ const running = runRuntimeParallelPlan(agent, session, 'Do patched work', {
362
+ runId: 'run-patch-active',
363
+ timeoutMs: 1000,
364
+ maxTurns: 1,
365
+ concurrency: 2,
366
+ });
367
+ await waitFor(() => started.includes('a') && started.includes('b'));
368
+ const patch = {
369
+ targetRunId: 'run-patch-active',
370
+ basePlanRevision: session.agentProjection.planRevision,
371
+ operations: [
372
+ {
373
+ op: 'add_task',
374
+ task: {
375
+ id: 'c',
376
+ description: 'Task C added during run',
377
+ status: 'pending',
378
+ dependsOn: ['a'],
379
+ executor: null,
380
+ outputRefs: [],
381
+ },
382
+ },
383
+ ],
384
+ };
385
+ dispatchAgentEvent(session, createAgentEvent('plan_patch_proposed', {
386
+ origin: 'runtime',
387
+ runId: 'run-patch-active',
388
+ payload: { id: 'patch-active', patch },
389
+ }));
390
+ dispatchAgentEvent(session, createAgentEvent('plan_patch_approved', {
391
+ origin: 'runtime',
392
+ runId: 'run-patch-active',
393
+ payload: { patchId: 'patch-active' },
394
+ }));
395
+ dispatchAgentEvent(session, createAgentEvent('plan_patch_applied', {
396
+ origin: 'runtime',
397
+ runId: 'run-patch-active',
398
+ payload: { patchId: 'patch-active', patch },
399
+ }));
400
+
401
+ assert.deepEqual(session.headlessPlan.map((step) => step.id), ['a', 'b', 'c']);
402
+ release.a();
403
+ await waitFor(() => started.includes('c'));
404
+ release.b();
405
+
406
+ const result = await running;
407
+
408
+ assert.equal(result.ok, true);
409
+ assert.deepEqual(started, ['a', 'b', 'c']);
410
+ assert.deepEqual(session.headlessPlan.map((step) => step.status), ['done', 'done', 'done']);
411
+ assert.equal(session.headlessPlan.find((step) => step.id === 'c').dependsOn[0], 'a');
412
+ });
413
+
414
+ test('runRuntimeParallelPlan rejects a stale patch during an active parallel run without silently applying it', async () => {
415
+ const release = {};
416
+ const session = {
417
+ activities: {},
418
+ headlessPlan: [
419
+ { step: 1, id: 'a', description: 'Task A', status: 'pending', dependsOn: [] },
420
+ { step: 2, id: 'b', description: 'Task B', status: 'pending', dependsOn: [] },
421
+ ],
422
+ };
423
+ const agent = {
424
+ async invoke({ input }) {
425
+ const id = input.match(/Task id: ([^\n]+)/)?.[1];
426
+ await new Promise((resolve) => { release[id] = resolve; });
427
+ return { response: `done ${id}` };
428
+ },
429
+ };
430
+
431
+ const running = runRuntimeParallelPlan(agent, session, 'Do stale patch work', {
432
+ runId: 'run-stale-patch',
433
+ timeoutMs: 1000,
434
+ maxTurns: 1,
435
+ concurrency: 2,
436
+ });
437
+ await waitFor(() => release.a && release.b);
438
+ const patch = {
439
+ targetRunId: 'run-stale-patch',
440
+ basePlanRevision: session.agentProjection.planRevision + 99,
441
+ operations: [
442
+ { op: 'add_task', task: { id: 'c', description: 'Should not apply', dependsOn: ['a'] } },
443
+ ],
444
+ };
445
+ dispatchAgentEvent(session, createAgentEvent('plan_patch_proposed', {
446
+ origin: 'runtime',
447
+ runId: 'run-stale-patch',
448
+ payload: { id: 'patch-stale', patch },
449
+ }));
450
+ dispatchAgentEvent(session, createAgentEvent('plan_patch_applied', {
451
+ origin: 'runtime',
452
+ runId: 'run-stale-patch',
453
+ payload: { patchId: 'patch-stale', patch },
454
+ }));
455
+
456
+ assert.deepEqual(session.headlessPlan.map((step) => step.id), ['a', 'b']);
457
+ assert.equal(session.agentProjection.planPatches.find((item) => item.id === 'patch-stale').status, 'rejected');
458
+ assert.equal(session.agentProjection.planPatches.find((item) => item.id === 'patch-stale').rejectionReason, 'revision_mismatch');
459
+ release.a();
460
+ release.b();
461
+
462
+ const result = await running;
463
+
464
+ assert.equal(result.ok, true);
465
+ assert.deepEqual(session.headlessPlan.map((step) => step.status), ['done', 'done']);
466
+ });
467
+
468
+ test('runRuntimeParallelPlan aborts a running child task cancelled by plan patch without stopping siblings', async () => {
469
+ const started = [];
470
+ let bAborted = false;
471
+ const session = {
472
+ activities: {},
473
+ headlessPlan: [
474
+ { step: 1, id: 'a', description: 'Task A', status: 'pending', dependsOn: [] },
475
+ { step: 2, id: 'b', description: 'Task B', status: 'pending', dependsOn: [] },
476
+ ],
477
+ };
478
+ const agent = {
479
+ async invoke({ input, signal }) {
480
+ const id = input.match(/Task id: ([^\n]+)/)?.[1];
481
+ started.push(id);
482
+ if (id === 'b') {
483
+ await new Promise((resolve, reject) => {
484
+ signal.addEventListener('abort', () => {
485
+ bAborted = true;
486
+ const err = new Error('cancelled');
487
+ err.name = 'AbortError';
488
+ reject(err);
489
+ }, { once: true });
490
+ });
491
+ }
492
+ return { response: `done ${id}` };
493
+ },
494
+ };
495
+
496
+ const running = runRuntimeParallelPlan(agent, session, 'Cancel one child', {
497
+ runId: 'run-cancel-task',
498
+ timeoutMs: 1000,
499
+ maxTurns: 1,
500
+ concurrency: 2,
501
+ });
502
+ await waitFor(() => started.includes('a') && started.includes('b'));
503
+ await waitFor(() => session.headlessPlan.find((step) => step.id === 'a')?.status === 'done');
504
+ const patch = {
505
+ targetRunId: 'run-cancel-task',
506
+ basePlanRevision: session.agentProjection.planRevision,
507
+ operations: [{ op: 'cancel_task', taskId: 'b' }],
508
+ };
509
+ dispatchAgentEvent(session, createAgentEvent('plan_patch_applied', {
510
+ origin: 'runtime',
511
+ runId: 'run-cancel-task',
512
+ payload: { patchId: 'patch-cancel-b', patch },
513
+ }));
514
+
515
+ const result = await running;
516
+
517
+ assert.equal(result.ok, true);
518
+ assert.equal(bAborted, true);
519
+ assert.deepEqual(session.headlessPlan.map((step) => step.status), ['done', 'cancelled']);
520
+ assert.equal(session.agentProjection.planPatches.find((item) => item.id === 'patch-cancel-b').status, 'applied');
521
+ });
522
+
523
+ test('runRuntimeParallelPlan never exceeds the configured concurrency with twice the limit ready', async () => {
524
+ let active = 0;
525
+ let maxActive = 0;
526
+ const release = {};
527
+ const session = {
528
+ activities: {},
529
+ headlessPlan: Array.from({ length: 6 }, (_, index) => ({
530
+ step: index + 1,
531
+ id: `task-${index + 1}`,
532
+ description: `Task ${index + 1}`,
533
+ status: 'pending',
534
+ dependsOn: [],
535
+ })),
536
+ };
537
+ const agent = {
538
+ async invoke({ input }) {
539
+ const id = input.match(/Task id: ([^\n]+)/)?.[1];
540
+ active += 1;
541
+ maxActive = Math.max(maxActive, active);
542
+ await new Promise((resolve) => { release[id] = resolve; });
543
+ active -= 1;
544
+ return { response: `done ${id}` };
545
+ },
546
+ };
547
+
548
+ const running = runRuntimeParallelPlan(agent, session, 'Respect limit', {
549
+ runId: 'run-limit',
550
+ timeoutMs: 1000,
551
+ maxTurns: 1,
552
+ concurrency: 3,
553
+ });
554
+ for (let batch = 0; batch < 2; batch += 1) {
555
+ await waitFor(() => Object.keys(release).length >= (batch + 1) * 3);
556
+ for (const id of Object.keys(release).slice(batch * 3, (batch + 1) * 3)) release[id]();
557
+ }
558
+
559
+ const result = await running;
560
+
561
+ assert.equal(result.ok, true);
562
+ assert.equal(maxActive, 3);
563
+ assert.deepEqual(session.headlessPlan.map((step) => step.status), ['done', 'done', 'done', 'done', 'done', 'done']);
564
+ });
565
+
340
566
  test('runRuntimeParallelPlan serializes conflicting write locks', async () => {
341
567
  let active = 0;
342
568
  let maxActive = 0;
@@ -441,6 +667,52 @@ test('runRuntimeParallelPlan propagates parent cancellation to child tasks', asy
441
667
  assert.equal(session.headlessPlan[0].status, 'cancelled');
442
668
  });
443
669
 
670
+ test('runRuntimeParallelPlan aborts every active child on parent cancellation and records terminal updates after running updates', async () => {
671
+ const controller = new AbortController();
672
+ const aborted = [];
673
+ const session = {
674
+ activities: {},
675
+ headlessPlan: [
676
+ { step: 1, id: 'a', description: 'Task A', status: 'pending', dependsOn: [] },
677
+ { step: 2, id: 'b', description: 'Task B', status: 'pending', dependsOn: [] },
678
+ { step: 3, id: 'c', description: 'Task C', status: 'pending', dependsOn: [] },
679
+ ],
680
+ };
681
+ const agent = {
682
+ async invoke({ input, signal }) {
683
+ const id = input.match(/Task id: ([^\n]+)/)?.[1];
684
+ await new Promise((resolve, reject) => {
685
+ signal.addEventListener('abort', () => {
686
+ aborted.push(id);
687
+ const err = new Error('cancelled');
688
+ err.name = 'AbortError';
689
+ reject(err);
690
+ }, { once: true });
691
+ });
692
+ return { response: `done ${id}` };
693
+ },
694
+ };
695
+
696
+ const running = runRuntimeParallelPlan(agent, session, 'Cancel all children', {
697
+ runId: 'run-cancel-all',
698
+ signal: controller.signal,
699
+ timeoutMs: 1000,
700
+ maxTurns: 1,
701
+ concurrency: 3,
702
+ });
703
+ await waitFor(() => session.headlessPlan.every((step) => step.status === 'running'));
704
+ controller.abort();
705
+
706
+ await assert.rejects(running, { name: 'AbortError' });
707
+ assert.deepEqual(aborted.sort(), ['a', 'b', 'c']);
708
+ assert.deepEqual(session.headlessPlan.map((step) => step.status), ['cancelled', 'cancelled', 'cancelled']);
709
+ const updates = session.agentEvents
710
+ .filter((event) => event.type === 'plan_step_updated')
711
+ .map((event) => `${event.taskId}:${event.payload.status}`);
712
+ assert.deepEqual(updates.slice(0, 3).sort(), ['a:running', 'b:running', 'c:running']);
713
+ assert.deepEqual(updates.slice(3).sort(), ['a:cancelled', 'b:cancelled', 'c:cancelled']);
714
+ });
715
+
444
716
  test('runRuntimeAgenticWorkflow hands off to the parallel scheduler when turn 1 sets a multi-task ready plan', async () => {
445
717
  const session = {
446
718
  activities: {},
@@ -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 = '0.0.0.0',
9
+ host = '127.0.0.1',
10
10
  port = 7788,
11
11
  token = runtimeTokenFromEnv(),
12
12
  store,
@@ -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,
@@ -1,5 +1,5 @@
1
1
  import assert from 'node:assert/strict';
2
- import { mkdtempSync } from 'node:fs';
2
+ import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { DatabaseSync } from 'node:sqlite';
@@ -7,6 +7,10 @@ import test from 'node:test';
7
7
  import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
8
8
  import { openRuntimeStore } from './store.js';
9
9
 
10
+ function runtimeStateDir() {
11
+ return join(mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-')), '.wiki-manager');
12
+ }
13
+
10
14
  test('runtime store persists and replays agent events into a projection', () => {
11
15
  const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
12
16
  const store = openRuntimeStore({ stateDir });
@@ -29,6 +33,43 @@ test('runtime store persists and replays agent events into a projection', () =>
29
33
  reopened.close();
30
34
  });
31
35
 
36
+ test('runtime store writes schema versions to sqlite and meta file', () => {
37
+ const root = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
38
+ const stateDir = join(root, '.wiki-manager');
39
+ const store = openRuntimeStore({ stateDir });
40
+ const version = store.db.prepare('PRAGMA user_version').get().user_version;
41
+ const meta = JSON.parse(readFileSync(join(root, '.wiki', 'meta.json'), 'utf8'));
42
+
43
+ assert.equal(version, 1);
44
+ assert.equal(meta.schemaVersion, 1);
45
+ store.close();
46
+ });
47
+
48
+ test('runtime store refuses unknown sqlite schema versions', () => {
49
+ const stateDir = runtimeStateDir();
50
+ mkdirSync(stateDir, { recursive: true });
51
+ const db = new DatabaseSync(join(stateDir, 'runtime.db'));
52
+ db.exec('PRAGMA user_version = 99');
53
+ db.close();
54
+
55
+ assert.throws(
56
+ () => openRuntimeStore({ stateDir }),
57
+ /Unsupported runtime store schema version 99/,
58
+ );
59
+ });
60
+
61
+ test('runtime store refuses unknown runtime metadata versions', () => {
62
+ const root = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
63
+ const stateDir = join(root, '.wiki-manager');
64
+ mkdirSync(join(root, '.wiki'), { recursive: true });
65
+ writeFileSync(join(root, '.wiki', 'meta.json'), '{"schemaVersion":99}\n', 'utf8');
66
+
67
+ assert.throws(
68
+ () => openRuntimeStore({ stateDir }),
69
+ /Unsupported runtime metadata schemaVersion 99/,
70
+ );
71
+ });
72
+
32
73
  test('runtime store persists duplicate events idempotently', () => {
33
74
  const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
34
75
  const store = openRuntimeStore({ stateDir });
@@ -52,6 +93,50 @@ test('runtime store persists duplicate events idempotently', () => {
52
93
  store.close();
53
94
  });
54
95
 
96
+ test('runtime store purges terminal runs older than thirty days on open', () => {
97
+ const stateDir = runtimeStateDir();
98
+ const first = openRuntimeStore({ stateDir });
99
+ for (const runId of ['old-done', 'recent-done', 'old-running']) {
100
+ first.persistEvent(createAgentEvent('run_started', {
101
+ origin: 'test',
102
+ runId,
103
+ turnId: `${runId}:turn-0`,
104
+ workspace: 'docs',
105
+ payload: { input: runId, workspace: 'docs' },
106
+ }));
107
+ }
108
+ first.persistEvent(createAgentEvent('run_done', {
109
+ origin: 'test',
110
+ runId: 'old-done',
111
+ workspace: 'docs',
112
+ payload: { runId: 'old-done', workspace: 'docs' },
113
+ }));
114
+ first.persistEvent(createAgentEvent('assistant_message', {
115
+ origin: 'test',
116
+ runId: 'old-done',
117
+ workspace: 'docs',
118
+ payload: { runId: 'old-done', content: 'old related event' },
119
+ }));
120
+ first.persistEvent(createAgentEvent('run_done', {
121
+ origin: 'test',
122
+ runId: 'recent-done',
123
+ workspace: 'docs',
124
+ payload: { runId: 'recent-done', workspace: 'docs' },
125
+ }));
126
+ first.close();
127
+
128
+ const db = new DatabaseSync(join(stateDir, 'runtime.db'));
129
+ db.prepare("UPDATE runs SET updated_at = '2000-01-01T00:00:00.000Z' WHERE id IN ('old-done', 'old-running')").run();
130
+ db.close();
131
+
132
+ const reopened = openRuntimeStore({ stateDir });
133
+ assert.deepEqual(reopened.listRuns().map((run) => run.id).sort(), ['old-running', 'recent-done']);
134
+ assert.equal(reopened.listEvents().some((event) => event.runId === 'old-done'), false);
135
+ assert.equal(reopened.listEvents().some((event) => event.runId === 'recent-done'), true);
136
+ assert.equal(reopened.listEvents().some((event) => event.runId === 'old-running'), true);
137
+ reopened.close();
138
+ });
139
+
55
140
  test('runtime store persists run identity fields on events', () => {
56
141
  const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
57
142
  const store = openRuntimeStore({ stateDir });
@@ -483,6 +568,118 @@ test('runtime store persists and replays control queue events without mixing MCP
483
568
  reopened.close();
484
569
  });
485
570
 
571
+ test('runtime store replays a parallel run into a deterministic workflow projection', () => {
572
+ const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
573
+ const store = openRuntimeStore({ stateDir });
574
+ const events = [
575
+ createAgentEvent('run_started', {
576
+ origin: 'runtime',
577
+ runId: 'run-parallel-replay',
578
+ workspace: 'docs',
579
+ payload: { input: 'parallel build', workspace: 'docs' },
580
+ }),
581
+ createAgentEvent('plan_set', {
582
+ origin: 'runtime',
583
+ runId: 'run-parallel-replay',
584
+ workspace: 'docs',
585
+ payload: {
586
+ steps: [
587
+ { step: 1, id: 'a', description: 'Task A', status: 'pending', dependsOn: [] },
588
+ { step: 2, id: 'b', description: 'Task B', status: 'pending', dependsOn: [] },
589
+ { step: 3, id: 'join', description: 'Join', status: 'pending', dependsOn: ['a', 'b'] },
590
+ ],
591
+ },
592
+ }),
593
+ createAgentEvent('plan_step_updated', {
594
+ origin: 'runtime',
595
+ runId: 'run-parallel-replay',
596
+ taskId: 'a',
597
+ workspace: 'docs',
598
+ payload: { taskId: 'a', status: 'running' },
599
+ }),
600
+ createAgentEvent('plan_step_updated', {
601
+ origin: 'runtime',
602
+ runId: 'run-parallel-replay',
603
+ taskId: 'b',
604
+ workspace: 'docs',
605
+ payload: { taskId: 'b', status: 'running' },
606
+ }),
607
+ createAgentEvent('activity_upserted', {
608
+ origin: 'tool',
609
+ runId: 'run-parallel-replay',
610
+ taskId: 'a',
611
+ workspace: 'docs',
612
+ payload: {
613
+ activity: {
614
+ id: 'job-a',
615
+ source: 'production',
616
+ label: 'Task A job',
617
+ status: 'done',
618
+ terminal: true,
619
+ startedAt: '2026-07-04T10:00:00.000Z',
620
+ updatedAt: '2026-07-04T10:00:01.000Z',
621
+ progress: {},
622
+ outputRefs: ['deliverables/a.md'],
623
+ },
624
+ },
625
+ }),
626
+ createAgentEvent('plan_step_updated', {
627
+ origin: 'runtime',
628
+ runId: 'run-parallel-replay',
629
+ taskId: 'a',
630
+ workspace: 'docs',
631
+ payload: { taskId: 'a', status: 'done' },
632
+ }),
633
+ createAgentEvent('plan_step_updated', {
634
+ origin: 'runtime',
635
+ runId: 'run-parallel-replay',
636
+ taskId: 'b',
637
+ workspace: 'docs',
638
+ payload: { taskId: 'b', status: 'done' },
639
+ }),
640
+ createAgentEvent('plan_step_updated', {
641
+ origin: 'runtime',
642
+ runId: 'run-parallel-replay',
643
+ taskId: 'join',
644
+ workspace: 'docs',
645
+ payload: { taskId: 'join', status: 'running' },
646
+ }),
647
+ createAgentEvent('plan_step_updated', {
648
+ origin: 'runtime',
649
+ runId: 'run-parallel-replay',
650
+ taskId: 'join',
651
+ workspace: 'docs',
652
+ payload: { taskId: 'join', status: 'done' },
653
+ }),
654
+ createAgentEvent('run_done', {
655
+ origin: 'runtime',
656
+ runId: 'run-parallel-replay',
657
+ workspace: 'docs',
658
+ payload: { runId: 'run-parallel-replay' },
659
+ }),
660
+ ];
661
+ for (const event of events) store.persistEvent(event);
662
+ store.close();
663
+
664
+ const first = openRuntimeStore({ stateDir });
665
+ const firstSession = { activities: {}, headlessPlan: null };
666
+ first.hydrateSession(firstSession, { workspace: 'docs' });
667
+ const firstWorkflow = first.getState(firstSession, { workspace: 'docs' }).workflow;
668
+ first.close();
669
+
670
+ const second = openRuntimeStore({ stateDir });
671
+ const secondSession = { activities: {}, headlessPlan: null };
672
+ second.hydrateSession(secondSession, { workspace: 'docs' });
673
+ const secondWorkflow = second.getState(secondSession, { workspace: 'docs' }).workflow;
674
+
675
+ assert.deepEqual(secondWorkflow, firstWorkflow);
676
+ assert.deepEqual(secondWorkflow.relations.filter((relation) => relation.type === 'depends_on'), [
677
+ { type: 'depends_on', from: 'task:join', to: 'task:a' },
678
+ { type: 'depends_on', from: 'task:join', to: 'task:b' },
679
+ ]);
680
+ second.close();
681
+ });
682
+
486
683
  test('runtime store identifies recoverable workspaces and interrupts stale runs', () => {
487
684
  const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
488
685
  const store = openRuntimeStore({ stateDir });