@dotdrelle/wiki-manager 0.12.12 → 0.14.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.
@@ -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();
@@ -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
  }
@@ -131,7 +131,7 @@ export async function runRuntimeAgenticWorkflow(agent, session, input, {
131
131
  evaluate = true,
132
132
  maxReplans = resolveMaxReplans(),
133
133
  callTool = null,
134
- dispatcherPollIntervalMs = 250,
134
+ dispatcherPollIntervalMs = 2500,
135
135
  } = {}) {
136
136
  let currentInput = initialInput ?? input;
137
137
  let replansLeft = Math.max(0, Math.floor(Number(maxReplans) || 0));
@@ -251,11 +251,7 @@ export async function runRuntimeAgenticWorkflow(agent, session, input, {
251
251
  origin: 'runtime',
252
252
  runId,
253
253
  payload: {
254
- content: [
255
- `Le run est terminé mais l'évaluation le juge incomplet : ${evaluation.reason}`,
256
- evaluation.suggestedAction ? `Piste suggérée : ${evaluation.suggestedAction}` : null,
257
- 'Aucune tâche supplémentaire n\'a été créée automatiquement — dis-moi si tu veux poursuivre.',
258
- ].filter(Boolean).join('\n'),
254
+ content: `Le run est terminé mais l'évaluation le juge incomplet : ${evaluation.reason}`,
259
255
  },
260
256
  }));
261
257
  dispatchAgentEvent(session, createAgentEvent('run_error', {
@@ -295,7 +291,7 @@ export async function runRuntimeParallelPlan(agent, session, input, {
295
291
  budgetManager = null,
296
292
  budgets = {},
297
293
  callTool = null,
298
- dispatcherPollIntervalMs = 250,
294
+ dispatcherPollIntervalMs = 2500,
299
295
  } = {}) {
300
296
  if (fragment != null) assertValidatedFragment(fragment);
301
297
  const agents = session.agentRegistry?.snapshot?.() ?? session.agentRegistrySnapshot ?? [];
@@ -399,8 +395,9 @@ export async function runRuntimeParallelPlan(agent, session, input, {
399
395
  emitRuntimeLog(session, taskLogPayload('task.starting', task, { runId, detail: `starting task ${taskId}` }));
400
396
  },
401
397
  startTask: (task, attempt) => {
398
+ const executableTask = materializeTaskInputs(task, session.headlessPlan ?? []);
402
399
  const taskAbort = createTaskAbortSignal(signal);
403
- const promise = runDispatchedTask(task, {
400
+ const promise = runDispatchedTask(executableTask, {
404
401
  session,
405
402
  assignmentManager: assigner,
406
403
  dispatcher: executor,
@@ -529,6 +526,41 @@ export async function runRuntimeParallelPlan(agent, session, input, {
529
526
  }
530
527
  }
531
528
 
529
+ export function materializeTaskInputs(task, plan = []) {
530
+ const dependencies = new Set(Array.isArray(task?.dependsOn) ? task.dependsOn.map(String) : []);
531
+ const replacements = new Map();
532
+ for (const dependency of plan ?? []) {
533
+ if (!dependencies.has(String(dependency?.id ?? dependency?.step))) continue;
534
+ const expected = Array.isArray(dependency?.expectedOutputRefs) ? dependency.expectedOutputRefs : [];
535
+ const actual = Array.isArray(dependency?.outputRefs) ? dependency.outputRefs : [];
536
+ for (let index = 0; index < Math.min(expected.length, actual.length); index += 1) {
537
+ const expectedRef = refValue(expected[index]);
538
+ const actualRef = refValue(actual[index]);
539
+ if (expectedRef && actualRef && expectedRef !== actualRef) replacements.set(expectedRef, actualRef);
540
+ }
541
+ }
542
+ if (replacements.size === 0) return task;
543
+ return {
544
+ ...task,
545
+ arguments: replaceRefValues(task?.arguments, replacements),
546
+ inputRefs: replaceRefValues(task?.inputRefs, replacements),
547
+ };
548
+ }
549
+
550
+ function replaceRefValues(value, replacements) {
551
+ if (typeof value === 'string') return replacements.get(value) ?? value;
552
+ if (Array.isArray(value)) return value.map((item) => replaceRefValues(item, replacements));
553
+ if (value && typeof value === 'object') {
554
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, replaceRefValues(item, replacements)]));
555
+ }
556
+ return value;
557
+ }
558
+
559
+ function refValue(value) {
560
+ if (typeof value === 'string') return value;
561
+ return value && typeof value === 'object' ? String(value.ref ?? '') : '';
562
+ }
563
+
532
564
  function sanitizeSessionPlanForExecution(session, runId = null) {
533
565
  if (!session.headlessPlan) return;
534
566
  const sanitized = sanitizePlanForExecution(session.headlessPlan);
@@ -704,7 +736,7 @@ async function runDispatchedTask(task, {
704
736
  error: {
705
737
  code: 'dispatcher_error',
706
738
  message: err instanceof Error ? err.message : String(err),
707
- retryable: false,
739
+ retryable: transientRuntimeError(err),
708
740
  },
709
741
  };
710
742
  await resultAggregator.accept(result, { task, assignment });
@@ -724,8 +756,16 @@ async function runDispatchedTask(task, {
724
756
  }
725
757
  }
726
758
 
727
- function shouldUseParallelScheduler(plan) {
728
- return readyPlanTasks(plan).some((task) => task.requiredCapability && task.operation);
759
+ export function shouldUseParallelScheduler(plan) {
760
+ // A validated provider plan enters the scheduler even while every task is
761
+ // waiting for approval. Looking only at readyPlanTasks() made an all-
762
+ // approval plan fall through to the conversational Donna loop; that loop
763
+ // then ignored the integrated TaskGraph and marked the run done.
764
+ return (plan ?? []).some((task) =>
765
+ task?.requiredCapability
766
+ && task?.operation
767
+ && pendingSchedulerStatus(task.status),
768
+ );
729
769
  }
730
770
 
731
771
  function taskLogPayload(event, task, {
@@ -819,6 +859,11 @@ export async function evaluateRuntimeRun(session, input, {
819
859
  evaluate = true,
820
860
  } = {}) {
821
861
  if (!shouldEvaluate(evaluate)) return null;
862
+ const structured = structuredPlanEvaluation(session.headlessPlan);
863
+ if (structured) {
864
+ emitRuntimeLog(session, 'runtime: evaluating completed structured plan');
865
+ return structured;
866
+ }
822
867
  const llm = session.llm;
823
868
  if (!llm || typeof llm.completeWithTools !== 'function') {
824
869
  return fallbackEvaluation('Evaluator unavailable: no LLM completeWithTools client.');
@@ -842,6 +887,40 @@ export async function evaluateRuntimeRun(session, input, {
842
887
  }
843
888
  }
844
889
 
890
+ function structuredPlanEvaluation(plan) {
891
+ if (!Array.isArray(plan) || plan.length === 0) return null;
892
+ // Only provider TaskGraph tasks are authoritative. Legacy conversational
893
+ // plans contain prose/tool labels and still use the compatibility evaluator.
894
+ if (!plan.every((step) => step?.requiredCapability && step?.operation)) return null;
895
+ const statuses = plan.map((step) => String(step?.status ?? '').toLowerCase());
896
+ const failed = plan.filter((step) => ['failed', 'error', 'cancelled', 'canceled', 'stalled'].includes(String(step?.status ?? '').toLowerCase()));
897
+ const incomplete = plan.filter((step) => !['done', 'complete', 'completed', 'success', 'succeeded'].includes(String(step?.status ?? '').toLowerCase()));
898
+ if (failed.length > 0) {
899
+ return {
900
+ ok: false,
901
+ reason: `${failed.length} tâche(s) du plan ont échoué : ${failed.map((step) => step.label ?? step.description ?? step.id ?? step.step).join(', ')}.`,
902
+ suggestedAction: null,
903
+ };
904
+ }
905
+ if (incomplete.length > 0) {
906
+ return {
907
+ ok: false,
908
+ reason: `${incomplete.length} tâche(s) du plan ne sont pas terminées.`,
909
+ suggestedAction: null,
910
+ };
911
+ }
912
+ return {
913
+ ok: statuses.length > 0,
914
+ reason: `${statuses.length} tâche(s) du plan terminées avec succès.`,
915
+ suggestedAction: null,
916
+ };
917
+ }
918
+
919
+ function transientRuntimeError(error) {
920
+ const value = error instanceof Error ? error.message : String(error ?? '');
921
+ return /(?:429|timeout|temporar|throttl|rate.?limit|quota|busy|unavailable)/i.test(value);
922
+ }
923
+
845
924
  function shouldEvaluate(value) {
846
925
  if (value === false) return false;
847
926
  const env = String(process.env.WIKI_MANAGER_EVALUATOR ?? '').trim().toLowerCase();
@@ -961,10 +1040,30 @@ export async function replanRuntimeRun(session, input, trigger, {
961
1040
  }
962
1041
  }
963
1042
 
1043
+ function isBusyFailure(failure) {
1044
+ const fields = [
1045
+ failure?.error,
1046
+ failure?.status,
1047
+ failure?.result?.error?.code,
1048
+ failure?.result?.error?.message,
1049
+ failure?.result?.status,
1050
+ ].map((value) => String(value ?? '').toLowerCase());
1051
+ return fields.some((value) => value.includes('busy') || value.includes('locked'));
1052
+ }
1053
+
964
1054
  function replanTriggerFromLoopResult(result) {
965
1055
  // 'awaiting_approval' is not a dead end — it means to wait for a human
966
1056
  // decision, not to replan around it.
967
1057
  if (result.stalled && result.reason !== 'awaiting_approval') {
1058
+ // A stall caused only by transient lock contention (target_busy /
1059
+ // workspace_busy) must NOT be replanned: the plan is correct, the
1060
+ // workspace was momentarily locked. Replanning around it re-runs the same
1061
+ // tasks, hits the lock again and spins the run into a zombie. Fail cleanly
1062
+ // so the run finalizes instead of lingering 'running'.
1063
+ const blocking = [...(result.failures ?? []), ...terminalFailures(result.completed ?? [])];
1064
+ if (blocking.length > 0 && blocking.every(isBusyFailure)) {
1065
+ return null;
1066
+ }
968
1067
  return {
969
1068
  kind: 'plan_stalled',
970
1069
  reason: `Plan is stalled: ${result.reason ?? 'no ready task'} (pending steps exist but none have their dependencies satisfied).`,
@@ -973,7 +1072,7 @@ function replanTriggerFromLoopResult(result) {
973
1072
  };
974
1073
  }
975
1074
  const failures = terminalFailures(result.completed ?? []);
976
- const technical = failures.filter((failure) => !isCancelledStatus(failure.status));
1075
+ const technical = failures.filter((failure) => !isCancelledStatus(failure.status) && !isBusyFailure(failure));
977
1076
  const cancelled = failures.filter((failure) => isCancelledStatus(failure.status));
978
1077
  if (cancelled.length > 0 && technical.length === 0 && (result.failures ?? []).every(isCancelledTaskFailure)) {
979
1078
  return null;
@@ -990,7 +1089,7 @@ function replanTriggerFromLoopResult(result) {
990
1089
  // The parallel scheduler can fail a task before it ever produces an
991
1090
  // _activity (e.g. a thrown error on the first turn) — that failure lives
992
1091
  // in result.failures, not in any activity, so it must be checked too.
993
- const taskFailure = (result.failures ?? []).find((failure) => !isCancelledTaskFailure(failure));
1092
+ const taskFailure = (result.failures ?? []).find((failure) => !isCancelledTaskFailure(failure) && !isBusyFailure(failure));
994
1093
  if (!taskFailure) return null;
995
1094
  return {
996
1095
  kind: 'task_error',
@@ -1,7 +1,70 @@
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, replanRuntimeRun, runRuntimeAgenticWorkflow, runRuntimeParallelPlan } from './runner.js';
4
+ import { evaluateRuntimeRun, finishRuntimeRun, materializeTaskInputs, replanRuntimeRun, runRuntimeAgenticWorkflow, runRuntimeParallelPlan, shouldUseParallelScheduler } from './runner.js';
5
+
6
+ test('validated tasks waiting for approval stay in the scheduler instead of falling back to Donna', () => {
7
+ assert.equal(shouldUseParallelScheduler([
8
+ {
9
+ id: 'ingest-plan',
10
+ requiredCapability: 'knowledge.update',
11
+ operation: 'ingest_plan',
12
+ status: 'waiting_approval',
13
+ },
14
+ {
15
+ id: 'ingest-apply',
16
+ requiredCapability: 'knowledge.update',
17
+ operation: 'ingest_apply',
18
+ status: 'waiting_approval',
19
+ },
20
+ ]), true);
21
+ assert.equal(shouldUseParallelScheduler([
22
+ { id: 'finished', requiredCapability: 'knowledge.update', operation: 'ingest', status: 'done' },
23
+ ]), false);
24
+ });
25
+
26
+ test('downstream tasks consume actual dependency outputs instead of planned placeholder refs', () => {
27
+ const plannedRef = '.wiki/ingest-plans/planned.json';
28
+ const actualRef = '.wiki/ingest-plans/ingest-2026-07-10.json';
29
+ const plan = [{
30
+ id: 'ingest-plan',
31
+ expectedOutputRefs: [{ type: 'file', ref: plannedRef }],
32
+ outputRefs: [{ type: 'file', ref: actualRef }],
33
+ }];
34
+ const apply = {
35
+ id: 'ingest-apply',
36
+ dependsOn: ['ingest-plan'],
37
+ arguments: { inputs: [plannedRef] },
38
+ inputRefs: [{ type: 'file', ref: plannedRef }],
39
+ };
40
+
41
+ const executable = materializeTaskInputs(apply, plan);
42
+ assert.deepEqual(executable.arguments.inputs, [actualRef]);
43
+ assert.equal(executable.inputRefs[0].ref, actualRef);
44
+ assert.deepEqual(apply.arguments.inputs, [plannedRef], 'the validated plan declaration remains immutable');
45
+ });
46
+
47
+ test('evaluateRuntimeRun trusts a completed provider TaskGraph without asking an LLM to reinterpret it', async () => {
48
+ const session = {
49
+ headlessPlan: [{
50
+ id: 'ingest-a',
51
+ label: 'Ingest A.md',
52
+ requiredCapability: 'knowledge.update',
53
+ operation: 'ingest_plan',
54
+ status: 'done',
55
+ }],
56
+ llm: {
57
+ async completeWithTools() {
58
+ assert.fail('a structured terminal TaskGraph must not be reinterpreted by an LLM');
59
+ },
60
+ },
61
+ };
62
+
63
+ const evaluation = await evaluateRuntimeRun(session, 'ingère A.md');
64
+
65
+ assert.equal(evaluation.ok, true);
66
+ assert.match(evaluation.reason, /1 tâche/);
67
+ });
5
68
 
6
69
  test('runRuntimeAgenticWorkflow completes conversational turns without evaluation or replan', async () => {
7
70
  const events = [];