@dotdrelle/wiki-manager 0.12.12 → 0.14.1

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.
Files changed (36) hide show
  1. package/docker-compose.yml +1 -1
  2. package/mcp.endpoints.example.json +7 -0
  3. package/package.json +2 -2
  4. package/src/agent/graph.js +354 -143
  5. package/src/agent/graph.test.js +516 -54
  6. package/src/agent/llm.js +5 -5
  7. package/src/cli/wiki-manager.js +234 -6
  8. package/src/cli/wiki-manager.test.js +28 -0
  9. package/src/commands/slash.js +32 -11
  10. package/src/commands/slash.test.js +9 -1
  11. package/src/core/agentEvents.js +7 -1
  12. package/src/core/agentEvents.test.js +13 -1
  13. package/src/core/buildInfo.json +2 -2
  14. package/src/core/mcp.js +46 -4
  15. package/src/core/skills.js +0 -28
  16. package/src/core/toolLoop.js +56 -0
  17. package/src/core/toolLoop.test.js +88 -0
  18. package/src/orchestrator/capabilityRegistry.js +14 -0
  19. package/src/orchestrator/capabilityRegistry.test.js +12 -1
  20. package/src/orchestrator/dependencyResolver.js +10 -1
  21. package/src/orchestrator/dispatcher.js +34 -3
  22. package/src/orchestrator/dispatcher.test.js +34 -0
  23. package/src/orchestrator/objectiveResolver.js +79 -0
  24. package/src/orchestrator/objectiveResolver.test.js +50 -0
  25. package/src/orchestrator/scheduler.test.js +25 -0
  26. package/src/runtime/client.js +32 -1
  27. package/src/runtime/lifecycle.js +32 -2
  28. package/src/runtime/recoveryManager.js +14 -7
  29. package/src/runtime/runner.js +112 -13
  30. package/src/runtime/runner.test.js +64 -1
  31. package/src/runtime/server.js +47 -3
  32. package/src/runtime/supervisor.js +4 -1
  33. package/src/runtime/supervisor.test.js +49 -0
  34. package/src/shell/repl.js +134 -55
  35. package/src/shell/repl.test.js +151 -12
  36. package/src/shell/useSession.ts +15 -3
@@ -69,32 +69,6 @@ function readWorkspaceManifest(workspacePath) {
69
69
  }
70
70
  }
71
71
 
72
- function readWorkspaceManifestSkill(workspacePath, loadedManifest = null) {
73
- const loaded = loadedManifest ?? readWorkspaceManifest(workspacePath);
74
- if (!loaded) return null;
75
- const { manifest, manifestPath } = loaded;
76
- const name = String(manifest.name || basename(workspacePath)).trim();
77
- if (!SKILL_NAME_RE.test(name)) return null;
78
- const entrypoints = manifest.entrypoints && typeof manifest.entrypoints === 'object'
79
- ? manifest.entrypoints
80
- : {};
81
- const claude = safeRelativeEntry(entrypoints.claude, 'CLAUDE.md');
82
- const body = readOptionalText(join(workspacePath, claude));
83
- return {
84
- name,
85
- title: String(manifest.title || name).trim(),
86
- description: String(manifest.description || '').trim(),
87
- params: [],
88
- body,
89
- scope: 'workspace',
90
- path: manifestPath,
91
- manifest,
92
- entrypoints,
93
- version: manifest.version ? String(manifest.version) : null,
94
- language: manifest.language ? String(manifest.language) : null,
95
- };
96
- }
97
-
98
72
  function workspaceUiSkillDir(loadedManifest = null) {
99
73
  if (!loadedManifest) return DEFAULT_UI_SKILL_DIR;
100
74
  const { manifest } = loadedManifest;
@@ -119,8 +93,6 @@ export function listSkills(session = {}) {
119
93
  const skills = [];
120
94
  if (session.workspacePath) {
121
95
  const loadedManifest = readWorkspaceManifest(session.workspacePath);
122
- const manifestSkill = readWorkspaceManifestSkill(session.workspacePath, loadedManifest);
123
- if (manifestSkill) skills.push(manifestSkill);
124
96
  skills.push(...collectDirectorySkills(join(session.workspacePath, workspaceUiSkillDir(loadedManifest)), 'workspace'));
125
97
  }
126
98
 
@@ -0,0 +1,56 @@
1
+ // Minimal, side-effect-free bounded tool-use loop.
2
+ //
3
+ // This is the shared mechanic of "ask the LLM with a tool set, run the tool
4
+ // calls it emits, feed results back, repeat up to a cap". The caller injects
5
+ // the ONLY policy that varies: `executeCall(call) -> string` decides whether a
6
+ // requested tool is allowed and produces its textual result (allow-list check,
7
+ // MCP dispatch, error formatting). The loop itself owns no plan, no delegation,
8
+ // no run identity and no agent events — deliberately unlike the /agent
9
+ // orchestration loop in createAgentGraph, which is a stateful LangGraph node
10
+ // graph and stays separate. Use this for stateless tool-answer turns (e.g.
11
+ // /chat read-only questions).
12
+ //
13
+ // `executeCall` may throw to abort the whole loop (e.g. an AbortError on
14
+ // cancel); anything it returns is treated as the tool result for that call.
15
+ export async function runBoundedToolLoop({
16
+ llm,
17
+ system,
18
+ messages,
19
+ tools,
20
+ executeCall,
21
+ maxIterations = 4,
22
+ signal,
23
+ onStep,
24
+ } = {}) {
25
+ const cap = Math.max(1, Math.floor(maxIterations) || 1);
26
+ const convo = [...(messages ?? [])];
27
+ for (let i = 0; i < cap; i += 1) {
28
+ onStep?.(i + 1, cap);
29
+ const result = await llm.completeWithTools({
30
+ system,
31
+ tools,
32
+ messages: convo,
33
+ toolChoice: 'auto',
34
+ signal,
35
+ });
36
+ const calls = result?.tool_calls ?? [];
37
+ if (calls.length === 0) {
38
+ return {
39
+ content: result?.content ?? result?.message?.content ?? '',
40
+ iterations: i + 1,
41
+ capped: false,
42
+ };
43
+ }
44
+ convo.push(result.message ?? { role: 'assistant', content: result.content ?? '', tool_calls: calls });
45
+ // Tool calls within one turn are independent: dispatch concurrently, then
46
+ // replay results in the model's call order so the transcript stays stable.
47
+ const outcomes = await Promise.all(calls.map(async (call) => ({
48
+ tool_call_id: call.id,
49
+ content: await executeCall(call),
50
+ })));
51
+ for (const outcome of outcomes) {
52
+ convo.push({ role: 'tool', tool_call_id: outcome.tool_call_id, content: outcome.content });
53
+ }
54
+ }
55
+ return { content: '', iterations: cap, capped: true };
56
+ }
@@ -0,0 +1,88 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { runBoundedToolLoop } from './toolLoop.js';
4
+
5
+ function toolCall(id, name, args = '{}') {
6
+ return { id, function: { name, arguments: args } };
7
+ }
8
+
9
+ test('returns the model answer directly when no tool is called', async () => {
10
+ const llm = {
11
+ async completeWithTools() {
12
+ return { content: 'plain answer', tool_calls: [] };
13
+ },
14
+ };
15
+ const out = await runBoundedToolLoop({ llm, tools: [], executeCall: async () => 'unused' });
16
+ assert.deepEqual(out, { content: 'plain answer', iterations: 1, capped: false });
17
+ });
18
+
19
+ test('dispatches a tool call, feeds the result back, then returns the final answer', async () => {
20
+ let round = 0;
21
+ const seen = [];
22
+ const llm = {
23
+ async completeWithTools({ messages }) {
24
+ round += 1;
25
+ if (round === 1) return { message: { role: 'assistant', content: '', tool_calls: [toolCall('c1', 'cme__cme_status')] }, tool_calls: [toolCall('c1', 'cme__cme_status')] };
26
+ seen.push(messages.find((m) => m.role === 'tool')?.content);
27
+ return { content: 'configured', tool_calls: [] };
28
+ },
29
+ };
30
+ const out = await runBoundedToolLoop({
31
+ llm,
32
+ tools: [{ function: { name: 'cme__cme_status' } }],
33
+ executeCall: async (call) => `RESULT(${call.function.name})`,
34
+ });
35
+ assert.equal(out.content, 'configured');
36
+ assert.equal(out.iterations, 2);
37
+ assert.equal(out.capped, false);
38
+ assert.deepEqual(seen, ['RESULT(cme__cme_status)']);
39
+ });
40
+
41
+ test('runs concurrent tool calls and replays results in call order', async () => {
42
+ let round = 0;
43
+ const order = [];
44
+ const llm = {
45
+ async completeWithTools({ messages }) {
46
+ round += 1;
47
+ if (round === 1) {
48
+ const calls = [toolCall('a', 's__list'), toolCall('b', 's__status')];
49
+ return { message: { role: 'assistant', content: '', tool_calls: calls }, tool_calls: calls };
50
+ }
51
+ order.push(...messages.filter((m) => m.role === 'tool').map((m) => m.tool_call_id));
52
+ return { content: 'done', tool_calls: [] };
53
+ },
54
+ };
55
+ const out = await runBoundedToolLoop({
56
+ llm,
57
+ tools: [],
58
+ executeCall: async (call) => call.id,
59
+ });
60
+ assert.equal(out.content, 'done');
61
+ assert.deepEqual(order, ['a', 'b']); // preserved model call order
62
+ });
63
+
64
+ test('reports capped when the model keeps calling tools past the cap', async () => {
65
+ const llm = {
66
+ async completeWithTools() {
67
+ const calls = [toolCall('x', 's__status')];
68
+ return { message: { role: 'assistant', content: '', tool_calls: calls }, tool_calls: calls };
69
+ },
70
+ };
71
+ const out = await runBoundedToolLoop({ llm, tools: [], executeCall: async () => 'r', maxIterations: 3 });
72
+ assert.equal(out.capped, true);
73
+ assert.equal(out.iterations, 3);
74
+ });
75
+
76
+ test('propagates an abort thrown by executeCall', async () => {
77
+ const llm = {
78
+ async completeWithTools() {
79
+ const calls = [toolCall('x', 's__status')];
80
+ return { message: { role: 'assistant', content: '', tool_calls: calls }, tool_calls: calls };
81
+ },
82
+ };
83
+ const abort = Object.assign(new Error('aborted'), { name: 'AbortError' });
84
+ await assert.rejects(
85
+ runBoundedToolLoop({ llm, tools: [], executeCall: async () => { throw abort; } }),
86
+ /aborted/,
87
+ );
88
+ });
@@ -45,6 +45,20 @@ export function createCapabilityRegistry({ agents = [], compatibleContractVersio
45
45
  };
46
46
  }
47
47
 
48
+ // Discovery and registry construction are asynchronous and are not always
49
+ // completed in the same order. Consumers must nevertheless validate against
50
+ // the live discovered agents instead of treating a temporarily absent cached
51
+ // registry as an empty registry.
52
+ export function capabilityRegistryForSession(session) {
53
+ const agents = session?.agentRegistry?.snapshot?.()
54
+ ?? session?.agentRegistrySnapshot
55
+ ?? session?.agents
56
+ ?? [];
57
+ if (agents.length > 0) return createCapabilityRegistry({ agents });
58
+ if (session?.capabilityRegistry?.providersFor) return session.capabilityRegistry;
59
+ return createCapabilityRegistry();
60
+ }
61
+
48
62
  function isProviderAgent(agent, compatible) {
49
63
  if (!agent || agent.legacy || agent.orchestrable === false) return false;
50
64
  if (!compatible.has(String(agent.description?.contractVersion ?? ''))) return false;
@@ -1,6 +1,6 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
- import { createCapabilityRegistry } from './capabilityRegistry.js';
3
+ import { capabilityRegistryForSession, createCapabilityRegistry } from './capabilityRegistry.js';
4
4
 
5
5
  function agent(agentInstanceId, capabilityId, { contractVersion = '1', health = 'available', version = '1' } = {}) {
6
6
  return {
@@ -23,6 +23,17 @@ function agent(agentInstanceId, capabilityId, { contractVersion = '1', health =
23
23
  };
24
24
  }
25
25
 
26
+ test('capabilityRegistryForSession rebuilds the registry from live discovery when the cache is absent', () => {
27
+ const discoveredAgent = agent('production-main', 'knowledge.update');
28
+ const registry = capabilityRegistryForSession({
29
+ capabilityRegistry: createCapabilityRegistry(),
30
+ agentRegistry: { snapshot: () => [discoveredAgent] },
31
+ agentRegistrySnapshot: [],
32
+ });
33
+
34
+ assert.equal(registry.providersFor('knowledge.update').length, 1);
35
+ });
36
+
26
37
  test('capabilityRegistry indexes two agents for the same capability', () => {
27
38
  const registry = createCapabilityRegistry({
28
39
  agents: [
@@ -15,7 +15,16 @@ export function readyTasks(dag, {
15
15
  const done = new Set(tasks.filter((task) => DONE_STATUSES.has(statusOf(task))).map(taskId));
16
16
  const active = new Set([...activeTaskIds].map(String));
17
17
  return tasks
18
- .filter((task) => statusOf(task) === 'pending')
18
+ .filter((task) => {
19
+ const status = statusOf(task);
20
+ return status === 'pending'
21
+ || ((status === 'waiting_approval' || status === 'pending_approval')
22
+ && approvalCovered(task, approvals, {
23
+ runId: task?.runId ?? dag?.runId ?? null,
24
+ workspaceId: dag?.workspace ?? null,
25
+ planRevision: dag?.planRevision ?? null,
26
+ }));
27
+ })
19
28
  .filter((task) => !active.has(taskId(task)))
20
29
  .filter((task) => dependenciesDone(task, done))
21
30
  .filter((task) => groupBarrierSatisfied(task, tasks))
@@ -8,7 +8,7 @@ const TERMINAL_STATUSES = new Set(['done', 'failed', 'cancelled', 'canceled', 'c
8
8
  export function createDispatcher({
9
9
  session = null,
10
10
  callTool = callMcpTool,
11
- pollIntervalMs = 250,
11
+ pollIntervalMs = 2500,
12
12
  } = {}) {
13
13
  return {
14
14
  execute(task, assignment, options = {}) {
@@ -30,7 +30,7 @@ export async function execute(task, assignment, {
30
30
  attempt = null,
31
31
  timeoutMs = null,
32
32
  pollBusy = new Set(),
33
- pollIntervalMs = 250,
33
+ pollIntervalMs = 2500,
34
34
  } = {}) {
35
35
  if (!session) throw new Error('dispatcher.execute requires session.');
36
36
  if (!assignment?.serverName) throw new Error(`No MCP server found for agent ${assignment?.agentInstanceId ?? '(unknown)'}.`);
@@ -57,7 +57,7 @@ export async function execute(task, assignment, {
57
57
  signal,
58
58
  ));
59
59
  if (accepted?.accepted === false || accepted?.ok === false) {
60
- throw new Error(String(accepted.error ?? 'agent_execute rejected task'));
60
+ return rejectedTaskResult(task, assignment, accepted, attempt);
61
61
  }
62
62
  jobId = String(accepted.jobId ?? '');
63
63
  if (!jobId) throw new Error('agent_execute did not return jobId.');
@@ -218,6 +218,37 @@ function taskResultFromStatus(task, assignment, jobId, statusPayload, attempt =
218
218
  };
219
219
  }
220
220
 
221
+ function rejectedTaskResult(task, assignment, payload, attempt = null) {
222
+ const rawError = payload?.error;
223
+ const error = rawError && typeof rawError === 'object'
224
+ ? { ...rawError }
225
+ : {
226
+ code: String(rawError ?? 'execution_rejected'),
227
+ message: String(payload?.message ?? rawError ?? 'agent_execute rejected task'),
228
+ retryable: transientError(rawError ?? payload?.message),
229
+ };
230
+ return {
231
+ ok: false,
232
+ taskId: String(task.id ?? task.step),
233
+ attemptId: attempt?.attemptId ?? null,
234
+ jobId: payload?.activeJobId ?? null,
235
+ agentInstanceId: assignment.agentInstanceId,
236
+ status: 'failed',
237
+ outputRefs: [],
238
+ metrics: {},
239
+ error: {
240
+ code: String(error.code ?? 'execution_rejected'),
241
+ message: String(error.message ?? error.code ?? 'agent_execute rejected task'),
242
+ retryable: error.retryable === true || transientError(error.code) || transientError(error.message),
243
+ },
244
+ rawStatus: payload,
245
+ };
246
+ }
247
+
248
+ function transientError(value) {
249
+ return /(?:429|timeout|temporar|throttl|rate.?limit|quota|busy|unavailable)/i.test(String(value ?? ''));
250
+ }
251
+
221
252
  function toolNameFor(session, serverName, baseName) {
222
253
  const tools = session.mcp?.[serverName]?.tools ?? [];
223
254
  const names = tools.map((tool) => String(tool.name ?? '')).filter(Boolean);
@@ -0,0 +1,34 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { createDispatcher } from './dispatcher.js';
4
+
5
+ test('dispatcher returns a retryable logical failure when agent_execute reports workspace_busy', async () => {
6
+ const session = {
7
+ workspace: 'test',
8
+ mcp: {
9
+ production: {
10
+ tools: [
11
+ { name: 'agent_execute' },
12
+ { name: 'agent_status' },
13
+ { name: 'agent_cancel' },
14
+ ],
15
+ },
16
+ },
17
+ };
18
+ const dispatcher = createDispatcher({
19
+ session,
20
+ callTool: async () => ({ accepted: false, error: 'workspace_busy', activeJobId: 'job-old' }),
21
+ });
22
+
23
+ const result = await dispatcher.execute(
24
+ { id: 'ingest-a', requiredCapability: 'knowledge.update', operation: 'ingest_plan', arguments: {} },
25
+ { serverName: 'production', agentInstanceId: 'production-main' },
26
+ { attempt: { attemptId: 'ingest-a:attempt-1', locks: [], release() {} } },
27
+ );
28
+
29
+ assert.equal(result.ok, false);
30
+ assert.equal(result.taskId, 'ingest-a');
31
+ assert.equal(result.attemptId, 'ingest-a:attempt-1');
32
+ assert.equal(result.error.code, 'workspace_busy');
33
+ assert.equal(result.error.retryable, true);
34
+ });
@@ -0,0 +1,79 @@
1
+ export async function resolveObjective(objective, session) {
2
+ const candidates = capabilityCandidates(session);
3
+ if (candidates.length === 0) throw new Error('No orchestrable capability is currently available.');
4
+ const llm = session?.llm;
5
+ if (!llm?.completeWithTools) throw new Error('Objective resolution requires the configured workspace LLM.');
6
+
7
+ const result = await llm.completeWithTools({
8
+ system: [
9
+ 'You resolve one user objective against a closed capability registry.',
10
+ 'Select exactly one listed capability and one of its supported operations.',
11
+ 'Never invent identifiers. Return JSON only: {"capability":"...","operation":"..."}.',
12
+ ].join('\n'),
13
+ tools: [],
14
+ messages: [{
15
+ role: 'user',
16
+ content: `Objective:\n${String(objective)}\n\nRegistry:\n${JSON.stringify(candidates, null, 2)}`,
17
+ }],
18
+ signal: session?._abortSignal,
19
+ });
20
+ const selection = parseJson(result?.content);
21
+ const capability = String(selection?.capability ?? '');
22
+ const operation = String(selection?.operation ?? '');
23
+ const candidate = candidates.find((item) => item.id === capability);
24
+ if (!candidate) throw new Error(`Objective resolver selected unknown capability "${capability}".`);
25
+ if (!candidate.operations.includes(operation)) {
26
+ throw new Error(`Objective resolver selected unsupported operation "${operation}" for ${capability}.`);
27
+ }
28
+ const providers = providersFor(session, capability)
29
+ .filter((provider) => !operation || (provider.capability?.supportedOperations ?? []).includes(operation))
30
+ .sort((a, b) => String(a.agentInstanceId).localeCompare(String(b.agentInstanceId)));
31
+ if (providers.length === 0) throw new Error(`No healthy agent provides ${capability}/${operation}.`);
32
+ return { capability, operation, provider: providers[0], candidates };
33
+ }
34
+
35
+ export function capabilityCandidates(session) {
36
+ const snapshot = registrySnapshot(session);
37
+ const byId = new Map();
38
+ for (const [versionedId, providers] of Object.entries(snapshot)) {
39
+ const id = versionedId.includes('@') ? versionedId.slice(0, versionedId.lastIndexOf('@')) : versionedId;
40
+ const operations = [...new Set((providers ?? []).flatMap((provider) => provider?.capability?.supportedOperations ?? []))].sort();
41
+ const description = (providers ?? []).map((provider) => provider?.capability?.description).find(Boolean) ?? '';
42
+ byId.set(id, { id, description, operations });
43
+ }
44
+ return [...byId.values()].filter((item) => item.operations.length > 0).sort((a, b) => a.id.localeCompare(b.id));
45
+ }
46
+
47
+ function providersFor(session, capability) {
48
+ if (session?.capabilityRegistry?.providersFor) return session.capabilityRegistry.providersFor(capability) ?? [];
49
+ return Object.entries(registrySnapshot(session))
50
+ .filter(([key]) => key === capability || key.startsWith(`${capability}@`))
51
+ .flatMap(([, providers]) => providers ?? []);
52
+ }
53
+
54
+ function registrySnapshot(session) {
55
+ const registry = session?.capabilityRegistry;
56
+ if (registry?.snapshot) return registry.snapshot();
57
+ if (registry && typeof registry === 'object') return registry;
58
+ const agents = session?.agentRegistry?.snapshot?.() ?? session?.agentRegistrySnapshot ?? [];
59
+ const snapshot = {};
60
+ for (const agent of agents) {
61
+ for (const capability of agent?.description?.capabilities ?? []) {
62
+ const key = `${capability.id}@${capability.version ?? '1'}`;
63
+ (snapshot[key] ??= []).push({
64
+ agentInstanceId: agent.agentInstanceId,
65
+ serverName: agent.serverName,
66
+ capability,
67
+ description: agent.description,
68
+ health: agent.health,
69
+ });
70
+ }
71
+ }
72
+ return snapshot;
73
+ }
74
+
75
+ function parseJson(content) {
76
+ const text = String(content ?? '').trim();
77
+ const fenced = text.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
78
+ return JSON.parse(fenced ? fenced[1] : text);
79
+ }
@@ -0,0 +1,50 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { capabilityCandidates, resolveObjective } from './objectiveResolver.js';
4
+
5
+ function sessionWithSelection(selection) {
6
+ const provider = {
7
+ agentInstanceId: 'production-1',
8
+ serverName: 'production',
9
+ capability: {
10
+ id: 'knowledge.update',
11
+ version: '1',
12
+ description: 'Update knowledge from pending sources.',
13
+ supportedOperations: ['ingest'],
14
+ },
15
+ };
16
+ return {
17
+ capabilityRegistry: {
18
+ snapshot: () => ({ 'knowledge.update@1': [provider] }),
19
+ providersFor: () => [provider],
20
+ },
21
+ llm: {
22
+ completeWithTools: async () => ({ content: JSON.stringify(selection) }),
23
+ },
24
+ };
25
+ }
26
+
27
+ test('capabilityCandidates exposes only the closed live registry', () => {
28
+ assert.deepEqual(capabilityCandidates(sessionWithSelection({})), [{
29
+ id: 'knowledge.update',
30
+ description: 'Update knowledge from pending sources.',
31
+ operations: ['ingest'],
32
+ }]);
33
+ });
34
+
35
+ test('resolveObjective selects and validates one real provider', async () => {
36
+ const result = await resolveObjective('Ingère tous les fichiers en attente', sessionWithSelection({
37
+ capability: 'knowledge.update',
38
+ operation: 'ingest',
39
+ }));
40
+ assert.equal(result.capability, 'knowledge.update');
41
+ assert.equal(result.operation, 'ingest');
42
+ assert.equal(result.provider.agentInstanceId, 'production-1');
43
+ });
44
+
45
+ test('resolveObjective rejects invented capability and operation', async () => {
46
+ await assert.rejects(
47
+ resolveObjective('Ingère tout', sessionWithSelection({ capability: 'ingest', operation: 'ingest_all_pending' })),
48
+ /unknown capability "ingest"/,
49
+ );
50
+ });
@@ -35,6 +35,31 @@ test('dependencyResolver orders ready tasks by priority before step', () => {
35
35
  assert.deepEqual(readyTasks(plan).map((item) => item.id), ['high', 'low', 'none']);
36
36
  });
37
37
 
38
+ test('dependencyResolver releases a waiting task when a run grant covers it', () => {
39
+ const plan = {
40
+ runId: 'run-1',
41
+ workspace: 'test4',
42
+ planRevision: 1,
43
+ tasks: [task('ingest', {
44
+ status: 'waiting_approval',
45
+ requiresApproval: true,
46
+ approvalClass: 'mutation',
47
+ })],
48
+ };
49
+
50
+ assert.deepEqual(readyTasks(plan), []);
51
+ assert.deepEqual(readyTasks(plan, {
52
+ approvals: [{
53
+ status: 'approved',
54
+ scope: 'run',
55
+ runId: 'run-1',
56
+ workspaceId: 'test4',
57
+ planRevision: 1,
58
+ approvalClasses: ['mutation'],
59
+ }],
60
+ }).map((item) => item.id), ['ingest']);
61
+ });
62
+
38
63
  test('dependencyResolver skips tasks whose locks are not free and keeps other ready work moving', () => {
39
64
  const lockManager = createLockManager();
40
65
  const held = lockManager.acquire(['deliverable:a.md']);
@@ -70,6 +70,25 @@ export async function postRuntimeRun(input, {
70
70
  return response.json();
71
71
  }
72
72
 
73
+ export async function postRuntimeDelegate(objective, {
74
+ url = runtimeUrlFromEnv(),
75
+ token = runtimeToken(),
76
+ workspace = null,
77
+ } = {}) {
78
+ const response = await fetch(runtimeEndpoint(url, '/delegate', workspace), {
79
+ method: 'POST',
80
+ headers: { ...runtimeHeaders(token), 'Content-Type': 'application/json' },
81
+ body: JSON.stringify({ objective, workspace }),
82
+ });
83
+ const payload = await response.json().catch(() => ({}));
84
+ if (!response.ok) {
85
+ const err = new Error(payload.error ?? `Runtime delegation failed: HTTP ${response.status}`);
86
+ err.status = response.status;
87
+ throw err;
88
+ }
89
+ return payload;
90
+ }
91
+
73
92
  export async function postRuntimeControl(action, {
74
93
  url = runtimeUrlFromEnv(),
75
94
  token = runtimeToken(),
@@ -152,6 +171,9 @@ export async function postRuntimeApprove({
152
171
  runId = null,
153
172
  itemId = null,
154
173
  approvalId = null,
174
+ scope = null,
175
+ planRevision = null,
176
+ approvalClasses = null,
155
177
  } = {}) {
156
178
  const endpoint = runtimeEndpoint(url, '/approve', workspace);
157
179
  const parsed = new URL(endpoint);
@@ -160,7 +182,16 @@ export async function postRuntimeApprove({
160
182
  if (approvalId) parsed.searchParams.set('approvalId', approvalId);
161
183
  const response = await fetch(parsed.toString(), {
162
184
  method: 'POST',
163
- headers: runtimeHeaders(token),
185
+ headers: { ...runtimeHeaders(token), 'Content-Type': 'application/json' },
186
+ body: JSON.stringify({
187
+ workspace,
188
+ runId,
189
+ itemId,
190
+ approvalId,
191
+ scope,
192
+ planRevision,
193
+ approvalClasses,
194
+ }),
164
195
  });
165
196
  if (!response.ok) throw new Error(`Runtime approve failed: HTTP ${response.status}`);
166
197
  return response.json();
@@ -1,5 +1,6 @@
1
1
  import { execFile, spawn } from 'node:child_process';
2
- import { dirname, resolve } from 'node:path';
2
+ import { readdirSync, statSync } from 'node:fs';
3
+ import { dirname, join, resolve } from 'node:path';
3
4
  import { fileURLToPath } from 'node:url';
4
5
  import { activeCacertPath } from '../core/cacert.js';
5
6
  import { checkRuntimeHealth, postRuntimeShutdown, runtimeUrlFromEnv } from './client.js';
@@ -10,6 +11,26 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
10
11
  const managerRoot = resolve(__dirname, '../..');
11
12
  const binPath = resolve(managerRoot, 'bin/wiki-manager.js');
12
13
 
14
+ // Newest mtime (ms) of the manager's own source tree. Used to detect that the
15
+ // code was edited after a reused runtime started, so ensureRuntime can restart
16
+ // it instead of serving stale code. Returns 0 if the source tree is unreadable
17
+ // (e.g. running from a packed install) — in that case staleness is not checked.
18
+ function newestManagerSourceMtimeMs() {
19
+ const srcDir = join(managerRoot, 'src');
20
+ let newest = 0;
21
+ try {
22
+ for (const entry of readdirSync(srcDir, { recursive: true })) {
23
+ const name = String(entry);
24
+ if (!(name.endsWith('.js') || name.endsWith('.ts') || name.endsWith('.tsx'))) continue;
25
+ try {
26
+ const mtime = statSync(join(srcDir, name)).mtimeMs;
27
+ if (mtime > newest) newest = mtime;
28
+ } catch { /* file vanished mid-scan */ }
29
+ }
30
+ } catch { return 0; }
31
+ return newest;
32
+ }
33
+
13
34
  export function runtimeNodeExecutable() {
14
35
  return process.versions.bun
15
36
  ? (process.env.WIKI_MANAGER_NODE_BIN ?? 'node')
@@ -47,13 +68,22 @@ export async function ensureRuntime({
47
68
  if (existing) {
48
69
  const expectedCacertPath = activeCacertPath();
49
70
  const actualCacertPath = existing.cacertPath ? resolve(existing.cacertPath) : null;
71
+ // Dev staleness: if the manager source was edited after this runtime
72
+ // started, the reused process would keep serving old code (the recurring
73
+ // "my change is not taking effect" trap). Treat it as stale and restart.
74
+ // Packed installs report mtime 0 (unreadable src) → never flagged stale.
75
+ // Opt out with WIKI_MANAGER_RUNTIME_NO_STALE_CHECK=1.
76
+ const startedAtMs = Number(existing.startedAtMs) || 0;
77
+ const sourceMtimeMs = process.env.WIKI_MANAGER_RUNTIME_NO_STALE_CHECK === '1' ? 0 : newestManagerSourceMtimeMs();
78
+ const stale = startedAtMs > 0 && sourceMtimeMs > startedAtMs;
50
79
  // forceRestart: the caller knows the manager configuration just changed
51
80
  // (e.g. mcp.endpoints.json scaffolded on first run) — a runtime started
52
81
  // BEFORE that only knows the old endpoints and would keep answering
53
82
  // without the agents until manually restarted.
54
- if (!forceRestart && actualCacertPath === expectedCacertPath) {
83
+ if (!forceRestart && !stale && actualCacertPath === expectedCacertPath) {
55
84
  return { url, started: false, health: existing, token: auth.token, tokenPath: auth.tokenPath };
56
85
  }
86
+ if (stale) console.error('runtime: source changed since start — restarting for fresh code.');
57
87
  await postRuntimeShutdown({ url, token: auth.token });
58
88
  await waitForRuntimeShutdown(url, auth.token, 2500);
59
89
  }
@@ -37,18 +37,25 @@ export async function recoverActiveRuns({
37
37
  errors.push({ runId: run.id, taskId: task.id, error: error instanceof Error ? error.message : String(error) });
38
38
  }
39
39
  }
40
- // A run in which nothing was recovered or rescheduled can never progress:
41
- // leaving it 'running' in the store would re-attach it as a zombie on
42
- // every subsequent boot. Close it for good.
43
- if (activeTasks.length > 0 && runOutcomes.length === activeTasks.length
44
- && runOutcomes.every((outcome) => outcome?.status === 'interrupted')) {
45
- const changed = store.interruptRuns?.({ workspace: run.workspace ?? null, runId: run.id, reason: 'Recovery found no recoverable task.' }) ?? 0;
40
+ // A run that recovery cannot move forward must be closed for good, or it
41
+ // re-attaches as a blocking "a runtime run is already active" zombie on
42
+ // every boot. This covers BOTH cases that can never resume on a fresh boot:
43
+ // - runs whose active tasks were all interrupted, and
44
+ // - runs with no active task to recover at all (e.g. left waiting for
45
+ // approval, or with only un-started pending tasks). Nothing here will
46
+ // ever progress, so finalize it now.
47
+ const progressed = runOutcomes.some((outcome) => outcome?.status === 'recovered' || outcome?.status === 'rescheduled');
48
+ if (!progressed) {
49
+ const reason = activeTasks.length > 0
50
+ ? 'Recovery found no recoverable task.'
51
+ : 'Recovery found no active task to resume.';
52
+ const changed = store.interruptRuns?.({ workspace: run.workspace ?? null, runId: run.id, reason }) ?? 0;
46
53
  if (changed > 0) {
47
54
  dispatch(session, store, 'runtime_log', {
48
55
  origin: 'recovery_manager',
49
56
  runId: run.id,
50
57
  workspace: run.workspace ?? workspaceFromSession(session),
51
- payload: { message: `recovery: run ${run.id} interrupted (no recoverable task)` },
58
+ payload: { message: `recovery: run ${run.id} interrupted (${reason})` },
52
59
  });
53
60
  }
54
61
  }