@dotdrelle/wiki-manager 0.14.13 → 0.14.20

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 (46) hide show
  1. package/.env.example +29 -4
  2. package/README.md +19 -0
  3. package/docker-compose.yml +6 -1
  4. package/package.json +1 -1
  5. package/src/activity/activityAggregator.js +50 -16
  6. package/src/activity/activityAggregator.test.js +67 -4
  7. package/src/agent/graph.js +79 -11
  8. package/src/agent/graph.test.js +43 -3
  9. package/src/cli/wiki-manager.js +74 -11
  10. package/src/cli/wiki-manager.test.js +40 -1
  11. package/src/commands/slash.js +10 -3
  12. package/src/commands/slash.test.js +24 -0
  13. package/src/core/buildInfo.json +2 -2
  14. package/src/core/dockerCompose.test.js +4 -0
  15. package/src/core/env.test.js +3 -0
  16. package/src/core/mcp.js +1 -1
  17. package/src/core/wikiSetup.js +35 -0
  18. package/src/core/wikiWorkspace.test.js +20 -0
  19. package/src/core/workflow.js +72 -0
  20. package/src/core/workflow.test.js +57 -0
  21. package/src/core/workspaces.js +10 -2
  22. package/src/orchestrator/dependencyResolver.js +19 -1
  23. package/src/orchestrator/objectiveResolver.js +24 -0
  24. package/src/orchestrator/objectiveResolver.test.js +23 -1
  25. package/src/orchestrator/scheduler.js +27 -7
  26. package/src/orchestrator/scheduler.test.js +45 -1
  27. package/src/runtime/auth.test.js +65 -1
  28. package/src/runtime/client.js +4 -0
  29. package/src/runtime/donna-contract.test.js +2 -0
  30. package/src/runtime/lifecycle.js +21 -12
  31. package/src/runtime/runner.js +141 -18
  32. package/src/runtime/runner.test.js +30 -0
  33. package/src/runtime/server.js +13 -2
  34. package/src/runtime/server.test.js +30 -1
  35. package/src/runtime/store.js +54 -0
  36. package/src/runtime/store.test.js +42 -0
  37. package/src/shell/FileEditorDialog.tsx +2 -2
  38. package/src/shell/LeftPane.tsx +60 -15
  39. package/src/shell/RightPane.tsx +168 -56
  40. package/src/shell/StartupScreen.tsx +3 -7
  41. package/src/shell/renderer.ts +1 -0
  42. package/src/shell/repl.js +7 -81
  43. package/src/shell/repl.test.js +141 -38
  44. package/src/shell/tui.tsx +65 -65
  45. package/src/shell/useSession.ts +92 -6
  46. package/wiki-workspace +28 -0
@@ -42,9 +42,31 @@ test('resolveObjective selects and validates one real provider', async () => {
42
42
  assert.equal(result.provider.agentInstanceId, 'production-1');
43
43
  });
44
44
 
45
+ test('resolveObjective uses an unambiguously mentioned registry operation without asking the LLM', async () => {
46
+ const session = sessionWithSelection({ capability: 'external-source.export', operation: 'export' });
47
+ session.capabilityRegistry.snapshot = () => ({
48
+ 'knowledge.update@1': [sessionWithSelection({}).capabilityRegistry.snapshot()['knowledge.update@1'][0]],
49
+ 'external-source.export@1': [{
50
+ agentInstanceId: 'cme-1',
51
+ serverName: 'cme',
52
+ capability: { id: 'external-source.export', version: '1', supportedOperations: ['export'] },
53
+ }],
54
+ });
55
+ session.capabilityRegistry.providersFor = (capability) =>
56
+ session.capabilityRegistry.snapshot()[`${capability}@1`] ?? [];
57
+ session.llm.completeWithTools = async () => {
58
+ throw new Error('the explicit operation must not depend on LLM selection');
59
+ };
60
+
61
+ const result = await resolveObjective("lance l'ingestion", session);
62
+ assert.equal(result.capability, 'knowledge.update');
63
+ assert.equal(result.operation, 'ingest');
64
+ assert.equal(result.provider.agentInstanceId, 'production-1');
65
+ });
66
+
45
67
  test('resolveObjective rejects invented capability and operation', async () => {
46
68
  await assert.rejects(
47
- resolveObjective('Ingère tout', sessionWithSelection({ capability: 'ingest', operation: 'ingest_all_pending' })),
69
+ resolveObjective('Traite tout', sessionWithSelection({ capability: 'ingest', operation: 'ingest_all_pending' })),
48
70
  /unknown capability "ingest"/,
49
71
  );
50
72
  });
@@ -9,7 +9,11 @@ export function resolveSchedulerConcurrency(value = process.env.WIKI_MANAGER_SCH
9
9
  : DEFAULT_SCHEDULER_CONCURRENCY;
10
10
  }
11
11
 
12
- export function resolvePlanConcurrency({ plan = [], agents = [], configured = null } = {}) {
12
+ // Display-only breakdown of the SAME computation resolvePlanConcurrency uses.
13
+ // resolvePlanConcurrency delegates to this so the number surfaced to the UIs can
14
+ // never diverge from the number the scheduler actually enforces. Never used to
15
+ // gate scheduling — only `.limit` feeds startReadyTasks.
16
+ export function describePlanConcurrency({ plan = [], agents = [], configured = null } = {}) {
13
17
  const capabilities = new Set(plan.map((task) => task?.requiredCapability).filter(Boolean).map(String));
14
18
  const assignedAgents = new Set(plan.map((task) => task?.agentInstanceId).filter(Boolean).map(String));
15
19
  const relevantAgents = agents.filter((agent) => {
@@ -17,12 +21,28 @@ export function resolvePlanConcurrency({ plan = [], agents = [], configured = nu
17
21
  if (id && assignedAgents.has(id)) return true;
18
22
  return (agent?.description?.capabilities ?? []).some((capability) => capabilities.has(String(capability?.id ?? '')));
19
23
  });
20
- const values = [
21
- positiveInteger(configured),
22
- ...plan.flatMap(concurrencyValues),
23
- ...relevantAgents.flatMap(concurrencyValues),
24
- ].filter(Boolean);
25
- return values.length > 0 ? Math.max(1, Math.min(...values)) : DEFAULT_SCHEDULER_CONCURRENCY;
24
+ const ceiling = positiveInteger(configured);
25
+ const agentValues = relevantAgents.flatMap(concurrencyValues).filter(Boolean);
26
+ const taskValues = plan.flatMap(concurrencyValues).filter(Boolean);
27
+ const values = [ceiling, ...taskValues, ...agentValues].filter(Boolean);
28
+ const limit = values.length > 0 ? Math.max(1, Math.min(...values)) : DEFAULT_SCHEDULER_CONCURRENCY;
29
+ const otherMin = [...taskValues, ...agentValues].length > 0
30
+ ? Math.min(...taskValues, ...agentValues)
31
+ : null;
32
+ // The manager ceiling "bit" when it is the (uniquely) binding constraint: it
33
+ // is set, equals the resolved limit, and is strictly below every other input.
34
+ const cappedByCeiling = ceiling != null && limit === ceiling && (otherMin == null || ceiling < otherMin);
35
+ return {
36
+ limit,
37
+ ceiling: ceiling ?? null,
38
+ agentLimit: agentValues.length > 0 ? Math.min(...agentValues) : null,
39
+ taskLimit: taskValues.length > 0 ? Math.min(...taskValues) : null,
40
+ cappedByCeiling,
41
+ };
42
+ }
43
+
44
+ export function resolvePlanConcurrency(options = {}) {
45
+ return describePlanConcurrency(options).limit;
26
46
  }
27
47
 
28
48
  export function resolveCapabilityConcurrency(agent = null, ...constraints) {
@@ -4,9 +4,10 @@ import { join } from 'node:path';
4
4
  import test from 'node:test';
5
5
 
6
6
  import { createBudgetManager } from './budgetManager.js';
7
- import { readyTasks } from './dependencyResolver.js';
7
+ import { readyTasks, tasksAwaitingApproval } from './dependencyResolver.js';
8
8
  import { createLockManager } from './lockManager.js';
9
9
  import {
10
+ describePlanConcurrency,
10
11
  effectiveConcurrency,
11
12
  resolveCapabilityConcurrency,
12
13
  resolvePlanConcurrency,
@@ -60,6 +61,27 @@ test('dependencyResolver releases a waiting task when a run grant covers it', ()
60
61
  }).map((item) => item.id), ['ingest']);
61
62
  });
62
63
 
64
+ test('dependencyResolver does not request approval for a task blocked by a failed dependency', () => {
65
+ const plan = {
66
+ runId: 'run-1',
67
+ workspace: 'test4',
68
+ planRevision: 1,
69
+ tasks: [
70
+ task('plan', { status: 'failed' }),
71
+ task('apply', {
72
+ status: 'waiting_approval',
73
+ dependsOn: ['plan'],
74
+ requiresApproval: true,
75
+ approvalClass: 'mutation',
76
+ }),
77
+ ],
78
+ };
79
+
80
+ assert.deepEqual(tasksAwaitingApproval(plan), []);
81
+ plan.tasks[0].status = 'done';
82
+ assert.deepEqual(tasksAwaitingApproval(plan).map((item) => item.id), ['apply']);
83
+ });
84
+
63
85
  test('dependencyResolver skips tasks whose locks are not free and keeps other ready work moving', () => {
64
86
  const lockManager = createLockManager();
65
87
  const held = lockManager.acquire(['deliverable:a.md']);
@@ -120,6 +142,28 @@ test('scheduler uses the relevant agent declaration instead of hard-capping plan
120
142
  assert.equal(resolvePlanConcurrency({ plan, agents, configured: 20 }), 10);
121
143
  });
122
144
 
145
+ test('describePlanConcurrency mirrors resolvePlanConcurrency and flags the ceiling', () => {
146
+ const plan = [{ id: 'task-1', requiredCapability: 'ingest' }];
147
+ const agents = [{ description: { capabilities: [{ id: 'ingest' }], limits: { recommendedConcurrency: 10, maxConcurrency: 12 } } }];
148
+
149
+ // The number must never diverge from what the scheduler enforces.
150
+ for (const configured of [undefined, 3, 20]) {
151
+ const opts = configured === undefined ? { plan, agents } : { plan, agents, configured };
152
+ assert.equal(describePlanConcurrency(opts).limit, resolvePlanConcurrency(opts));
153
+ }
154
+
155
+ // Ceiling binds → flagged.
156
+ const capped = describePlanConcurrency({ plan, agents, configured: 3 });
157
+ assert.equal(capped.limit, 3);
158
+ assert.equal(capped.ceiling, 3);
159
+ assert.equal(capped.cappedByCeiling, true);
160
+
161
+ // Ceiling above the agent declaration → does not bind, not flagged.
162
+ const loose = describePlanConcurrency({ plan, agents, configured: 20 });
163
+ assert.equal(loose.limit, 10);
164
+ assert.equal(loose.cappedByCeiling, false);
165
+ });
166
+
123
167
  test('scheduler ignores unrelated agents and falls back to three without declarations', () => {
124
168
  const plan = [{ id: 'task-1', requiredCapability: 'ingest' }];
125
169
  const agents = [{
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import test from 'node:test';
6
6
  import { resolveRuntimeAuthToken } from './auth.js';
7
- import { assertRuntimeNode, runtimeNodeExecutable } from './lifecycle.js';
7
+ import { assertRuntimeNode, runtimeNodeExecutable, shutdownOwnedRuntime } from './lifecycle.js';
8
8
 
9
9
  test('resolveRuntimeAuthToken: loopback host does not require token', () => {
10
10
  const result = resolveRuntimeAuthToken({ host: '127.0.0.1', explicitToken: null });
@@ -27,3 +27,67 @@ test('runtime lifecycle uses a Node executable with node:sqlite support', async
27
27
  assert.ok(runtimeNode.executable);
28
28
  assert.ok(Number(runtimeNode.version.split('.')[0]) >= 22);
29
29
  });
30
+
31
+ test('shutdownOwnedRuntime reports progress and stops an idle owned runtime', async () => {
32
+ const originalFetch = globalThis.fetch;
33
+ const calls = [];
34
+ const logs = [];
35
+ globalThis.fetch = async (url, options = {}) => {
36
+ calls.push({ url: String(url), method: options.method ?? 'GET' });
37
+ return new Response(JSON.stringify(calls.length === 1 ? { activeRuns: [] } : { shutdown: true }), {
38
+ status: calls.length === 1 ? 200 : 202,
39
+ headers: { 'content-type': 'application/json' },
40
+ });
41
+ };
42
+ try {
43
+ const result = await shutdownOwnedRuntime(
44
+ { url: 'http://127.0.0.1:7788', started: true, token: null },
45
+ { log: (message) => logs.push(message), timeoutMs: 100 },
46
+ );
47
+ assert.equal(result.action, 'shutdown');
48
+ assert.deepEqual(calls.map((call) => call.method), ['GET', 'POST']);
49
+ assert.match(logs.join('\n'), /runtime arrêté/);
50
+ } finally {
51
+ globalThis.fetch = originalFetch;
52
+ }
53
+ });
54
+
55
+ test('shutdownOwnedRuntime also stops an idle reused runtime', async () => {
56
+ const originalFetch = globalThis.fetch;
57
+ const calls = [];
58
+ globalThis.fetch = async (_url, options = {}) => {
59
+ calls.push(options.method ?? 'GET');
60
+ return new Response(JSON.stringify(calls.length === 1 ? { activeRuns: [] } : { shutdown: true }), {
61
+ status: calls.length === 1 ? 200 : 202,
62
+ headers: { 'content-type': 'application/json' },
63
+ });
64
+ };
65
+ try {
66
+ const result = await shutdownOwnedRuntime(
67
+ { url: 'http://127.0.0.1:7788', started: false, token: null },
68
+ { timeoutMs: 100 },
69
+ );
70
+ assert.equal(result.action, 'shutdown');
71
+ assert.deepEqual(calls, ['GET', 'POST']);
72
+ } finally {
73
+ globalThis.fetch = originalFetch;
74
+ }
75
+ });
76
+
77
+ test('shutdownOwnedRuntime bounds an unresponsive shutdown', async () => {
78
+ const originalFetch = globalThis.fetch;
79
+ const logs = [];
80
+ globalThis.fetch = async (_url, { signal } = {}) => new Promise((_resolve, reject) => {
81
+ signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')), { once: true });
82
+ });
83
+ try {
84
+ const result = await shutdownOwnedRuntime(
85
+ { url: 'http://127.0.0.1:7788', started: true, token: null },
86
+ { log: (message) => logs.push(message), timeoutMs: 5 },
87
+ );
88
+ assert.equal(result.action, 'timeout');
89
+ assert.match(logs.join('\n'), /délai de fermeture/);
90
+ } finally {
91
+ globalThis.fetch = originalFetch;
92
+ }
93
+ });
@@ -38,9 +38,11 @@ export async function checkRuntimeHealth({
38
38
  url = runtimeUrlFromEnv(),
39
39
  token = runtimeToken(),
40
40
  workspace = null,
41
+ signal = null,
41
42
  } = {}) {
42
43
  const response = await fetch(runtimeEndpoint(url, '/health', workspace), {
43
44
  headers: runtimeHeaders(token),
45
+ signal,
44
46
  });
45
47
  if (!response.ok) return null;
46
48
  return response.json();
@@ -143,10 +145,12 @@ export async function postRuntimeKill({
143
145
  export async function postRuntimeShutdown({
144
146
  url = runtimeUrlFromEnv(),
145
147
  token = runtimeToken(),
148
+ signal = null,
146
149
  } = {}) {
147
150
  const response = await fetch(runtimeEndpoint(url, '/shutdown'), {
148
151
  method: 'POST',
149
152
  headers: runtimeHeaders(token),
153
+ signal,
150
154
  });
151
155
  if (!response.ok) throw new Error(`Runtime shutdown failed: HTTP ${response.status}`);
152
156
  return response.json();
@@ -259,6 +259,8 @@ test('CME export is dispatched only from an approved DAG task', async () => {
259
259
  assert.equal(result.reason, 'awaiting_approval');
260
260
  assert.equal(executeCalls, 0);
261
261
  assert.equal(session.headlessPlan[0].status, 'waiting_approval');
262
+ assert.ok(session.agentEvents.some((event) => event.type === 'approval.requested'
263
+ && event.taskId === session.headlessPlan[0].id));
262
264
  });
263
265
 
264
266
  function buildSingleTaskAgent({ taskId, description, finalResponse }) {
@@ -127,22 +127,25 @@ async function waitForRuntimeShutdown(url, token, timeoutMs) {
127
127
  }
128
128
  }
129
129
 
130
- export async function runtimeHealthOrNull(url = runtimeUrlFromEnv(), token = runtimeTokenFromEnv()) {
130
+ export async function runtimeHealthOrNull(url = runtimeUrlFromEnv(), token = runtimeTokenFromEnv(), signal = null) {
131
131
  try {
132
- return await checkRuntimeHealth({ url, token });
133
- } catch {
132
+ return await checkRuntimeHealth({ url, token, signal });
133
+ } catch (err) {
134
+ if (signal?.aborted) throw err;
134
135
  return null;
135
136
  }
136
137
  }
137
138
 
138
- // Contract with the user: the shell OWNS the runtime it started — leaving it
139
- // alive after exit produced zombie runtimes running yesterday's code and
140
- // yesterday's endpoints. Nuance preserved: if a run is active anywhere, the
141
- // runtime is left alive so the run survives the shell (that promise stays).
142
- export async function shutdownOwnedRuntime(runtime, { log = (_message) => {} } = {}) {
143
- if (!runtime?.url || !runtime?.started) return { action: 'kept', reason: 'not_owned' };
139
+ // Close the runtime attached to the shell when it is idle, including a process
140
+ // reused at startup. Restricting cleanup to `started: true` left ownerless
141
+ // runtimes occupying port 7788 with another manager directory/token.
142
+ // An active run still survives the shell.
143
+ export async function shutdownOwnedRuntime(runtime, { log = (_message) => {}, timeoutMs = 3000 } = {}) {
144
+ if (!runtime?.url) return { action: 'kept', reason: 'unavailable' };
145
+ const controller = new AbortController();
146
+ const timeout = setTimeout(() => controller.abort(), Math.max(1, Number(timeoutMs) || 3000));
144
147
  try {
145
- const health = await runtimeHealthOrNull(runtime.url, runtime.token);
148
+ const health = await runtimeHealthOrNull(runtime.url, runtime.token, controller.signal);
146
149
  if (!health) return { action: 'kept', reason: 'unreachable' };
147
150
  const activeRuns = Array.isArray(health.activeRuns) ? health.activeRuns : [];
148
151
  if (activeRuns.length > 0) {
@@ -152,10 +155,16 @@ export async function shutdownOwnedRuntime(runtime, { log = (_message) => {} } =
152
155
  log(`runtime laissé actif : run en cours (${labels}) — il survivra à ce shell ; relance wiki-manager pour le retrouver.`);
153
156
  return { action: 'kept', reason: 'run_active', activeRuns };
154
157
  }
155
- await postRuntimeShutdown({ url: runtime.url, token: runtime.token });
156
- log('runtime arrêté (démarré par ce shell, aucun run en cours).');
158
+ await postRuntimeShutdown({ url: runtime.url, token: runtime.token, signal: controller.signal });
159
+ log('runtime arrêté (aucun run en cours).');
157
160
  return { action: 'shutdown' };
158
161
  } catch (err) {
162
+ if (controller.signal.aborted) {
163
+ log('délai de fermeture du runtime dépassé — le shell termine sans attendre davantage.');
164
+ return { action: 'timeout', reason: 'shutdown_timeout' };
165
+ }
159
166
  return { action: 'error', reason: err instanceof Error ? err.message : String(err) };
167
+ } finally {
168
+ clearTimeout(timeout);
160
169
  }
161
170
  }
@@ -7,9 +7,11 @@ import { createAssignmentManager } from '../orchestrator/assignmentManager.js';
7
7
  import { createAttemptManager } from '../orchestrator/attemptManager.js';
8
8
  import { createBudgetManager, BudgetExceededError } from '../orchestrator/budgetManager.js';
9
9
  import { createDispatcher } from '../orchestrator/dispatcher.js';
10
+ import { approvalRequestForTask } from '../orchestrator/approvalPolicy.js';
11
+ import { PENDING_STATUSES, tasksAwaitingApproval } from '../orchestrator/dependencyResolver.js';
10
12
  import { assertValidatedFragment } from '../orchestrator/planValidator.js';
11
13
  import { createResultAggregator } from '../orchestrator/resultAggregator.js';
12
- import { drainActive, resolvePlanConcurrency, startReadyTasks } from '../orchestrator/scheduler.js';
14
+ import { describePlanConcurrency, drainActive, startReadyTasks } from '../orchestrator/scheduler.js';
13
15
  import { emitRuntimeLog, pollActivitiesOnce } from './supervisor.js';
14
16
 
15
17
  // 0 by default: automatic replans turn evaluator/replanner TEXT into
@@ -139,9 +141,14 @@ export async function runRuntimeAgenticWorkflow(agent, session, input, {
139
141
  // loop would re-ingest this run's own turns and duplicate them.
140
142
  const runConversationSeed = conversationSeed(session, currentInput);
141
143
 
144
+ // The conversational loop path ends with the agent's own natural-language
145
+ // reply; the deterministic parallel scheduler has no agent voice, so only
146
+ // that path gets a synthesized outcome summary (announceRunOutcome).
147
+ let usedParallelScheduler = false;
142
148
  while (true) {
143
149
  sanitizeSessionPlanForExecution(session, runId);
144
- const result = shouldUseParallelScheduler(session.headlessPlan)
150
+ usedParallelScheduler = shouldUseParallelScheduler(session.headlessPlan);
151
+ const result = usedParallelScheduler
145
152
  ? await runRuntimeParallelPlan(agent, session, input, {
146
153
  signal,
147
154
  timeoutMs,
@@ -188,6 +195,7 @@ export async function runRuntimeAgenticWorkflow(agent, session, input, {
188
195
  emitRuntimeLog(session, 'runtime: run ended by user cancellation (no replan)');
189
196
  return { ok: false, result, cancelled: true };
190
197
  }
198
+ if (usedParallelScheduler) await announceRunOutcome(session, { runId, ok: false, signal });
191
199
  dispatchAgentEvent(session, createAgentEvent('run_error', {
192
200
  origin: 'runtime',
193
201
  runId,
@@ -267,6 +275,7 @@ export async function runRuntimeAgenticWorkflow(agent, session, input, {
267
275
  }
268
276
  }
269
277
 
278
+ if (usedParallelScheduler) await announceRunOutcome(session, { runId, ok: true, signal });
270
279
  dispatchAgentEvent(session, createAgentEvent('run_done', {
271
280
  origin: 'runtime',
272
281
  runId,
@@ -276,6 +285,62 @@ export async function runRuntimeAgenticWorkflow(agent, session, input, {
276
285
  }
277
286
  }
278
287
 
288
+ // Emit ONE natural-language Donna message summarizing how the run finished,
289
+ // instead of the client streaming a per-job line for every task. Uses the
290
+ // workspace LLM to phrase it, degrading to a plain templated fact line if the
291
+ // LLM is unavailable or errors — the run must never block on this summary.
292
+ async function announceRunOutcome(session, { runId, ok, signal = null } = {}) {
293
+ const plan = Array.isArray(session.headlessPlan) ? session.headlessPlan : [];
294
+ if (plan.length === 0) return;
295
+ let failed = 0;
296
+ let cancelled = 0;
297
+ let completed = 0;
298
+ let firstError = null;
299
+ for (const step of plan) {
300
+ const status = String(step?.status ?? '').toLowerCase();
301
+ if (['failed', 'error', 'stalled'].includes(status)) {
302
+ failed += 1;
303
+ firstError ??= String(
304
+ step?.error?.message ?? step?.error?.code ?? step?.error
305
+ ?? step?.result?.error?.message ?? step?.result?.error?.code ?? '',
306
+ ).trim() || null;
307
+ } else if (['cancelled', 'canceled'].includes(status)) {
308
+ cancelled += 1;
309
+ } else if (['done', 'complete', 'completed', 'success', 'succeeded'].includes(status)) {
310
+ completed += 1;
311
+ }
312
+ }
313
+ const total = plan.length;
314
+ const factLine = ok && failed === 0
315
+ ? `Plan terminé avec succès — ${completed}/${total} tâche(s) réussie(s).`
316
+ : `Plan terminé en erreur — ${completed}/${total} tâche(s) réussie(s), ${failed} en erreur${cancelled ? `, ${cancelled} annulée(s)` : ''}.${firstError ? ` Première erreur : ${firstError}.` : ''}`;
317
+ let content = factLine;
318
+ const llm = session.llm;
319
+ if (llm && typeof llm.completeWithTools === 'function') {
320
+ try {
321
+ const result = await llm.completeWithTools({
322
+ system: [
323
+ 'You are Donna, an orchestration assistant reporting a run result to the user.',
324
+ 'Rephrase the outcome facts in ONE short, natural sentence, in the same language as the facts.',
325
+ 'No lists, no headers, no raw job ids — just a concise human summary.',
326
+ ].join('\n'),
327
+ tools: [],
328
+ messages: [{ role: 'user', content: `Run outcome facts:\n${factLine}` }],
329
+ signal,
330
+ });
331
+ const phrased = String(result?.content ?? '').trim();
332
+ if (phrased) content = phrased;
333
+ } catch {
334
+ // Degrade to the templated fact line — never fail the run on the summary.
335
+ }
336
+ }
337
+ dispatchAgentEvent(session, createAgentEvent('assistant_message', {
338
+ origin: 'runtime',
339
+ runId,
340
+ payload: { content },
341
+ }));
342
+ }
343
+
279
344
  export async function runRuntimeParallelPlan(agent, session, input, {
280
345
  signal = null,
281
346
  timeoutMs,
@@ -298,11 +363,24 @@ export async function runRuntimeParallelPlan(agent, session, input, {
298
363
  const configuredConcurrency = Number(concurrency) > 0
299
364
  ? Number(concurrency)
300
365
  : Number(process.env.WIKI_MANAGER_CAPABILITY_CONCURRENCY || process.env.WIKI_MANAGER_SCHEDULER_CONCURRENCY);
301
- const limit = resolvePlanConcurrency({
366
+ const concurrencyDetail = describePlanConcurrency({
302
367
  plan: session.headlessPlan ?? [],
303
368
  agents,
304
369
  configured: configuredConcurrency,
305
370
  });
371
+ const limit = concurrencyDetail.limit;
372
+ // Publish the RESOLVED concurrency so both UIs show the real dispatch cap
373
+ // (not a value re-derived from the fragment). Display-only: scheduling still
374
+ // uses `limit` exactly as before. NOTE: this is a plain field assignment — do
375
+ // NOT emit a runtime_log here (it runs before ensurePlanProjection and would
376
+ // clobber session.headlessPlan via applyAgentProjectionToSession). The audit
377
+ // line is folded into the existing "parallel plan enabled" log below.
378
+ session._runConcurrency = {
379
+ limit,
380
+ ceiling: concurrencyDetail.ceiling,
381
+ agentLimit: concurrencyDetail.agentLimit,
382
+ cappedByCeiling: concurrencyDetail.cappedByCeiling,
383
+ };
306
384
  const active = new Map();
307
385
  const attempts = attemptManager ?? createAttemptManager();
308
386
  const assigner = assignmentManager ?? createAssignmentManager({ session });
@@ -327,8 +405,7 @@ export async function runRuntimeParallelPlan(agent, session, input, {
327
405
  };
328
406
  sanitizeSessionPlanForExecution(session, runId);
329
407
  ensurePlanProjection(session, runId);
330
- emitRuntimeLog(session, `scheduler: parallel plan enabled (concurrency ${limit})`);
331
- let approvalNoticeSent = false;
408
+ emitRuntimeLog(session, `scheduler: parallel plan enabled (concurrency ${limit}; agent=${concurrencyDetail.agentLimit ?? 'n/a'}, ceiling=${concurrencyDetail.ceiling ?? 'none'}${concurrencyDetail.cappedByCeiling ? ' → capped by manager ceiling' : ''})`);
332
409
  // Interactive approvals do NOT expire: the user has /approve, "valide
333
410
  // tout", /cancel and /run kill — an arbitrary timer only created mystery
334
411
  // failures. A deadline exists only when explicitly configured (headless
@@ -431,26 +508,74 @@ export async function runRuntimeParallelPlan(agent, session, input, {
431
508
  emitRuntimeLog(session, `scheduler: budget exceeded (${exceeded.reason})`);
432
509
  return { ok: false, budgetExceeded: true, reason: exceeded.reason, budget: exceeded, completed: sessionActivities(session), failures };
433
510
  }
434
- const needingApproval = pending.filter((step) => step.requiresApproval === true);
511
+ // Only wait for a human when approval is the sole remaining blocker.
512
+ // A task whose dependency failed cannot become runnable by approving
513
+ // it; treating it as an approval wait leaves the run alive forever.
514
+ const approvalContext = {
515
+ runId,
516
+ workspace: session.workspace ?? null,
517
+ planRevision: session.planRevision ?? session.agentProjection?.planRevision ?? null,
518
+ tasks: session.headlessPlan ?? [],
519
+ };
520
+ // One snapshot of the approvals list, reused for both the
521
+ // awaiting-approval computation and the per-task dedup below so the two
522
+ // can never diverge mid-iteration.
523
+ const approvals = session.agentProjection?.approvals ?? session.approvals ?? [];
524
+ const needingApproval = tasksAwaitingApproval(approvalContext, { approvals });
435
525
  if (needingApproval.length > 0) {
436
526
  // The plan is only blocked on a HUMAN decision — wait for it
437
527
  // (bounded) instead of declaring the run stalled. Announce once in
438
528
  // the chat: users cannot approve what they never saw asked.
439
- if (!approvalNoticeSent) {
440
- approvalNoticeSent = true;
529
+ const newlyRequested = [];
530
+ for (const task of needingApproval) {
531
+ const taskId = String(task.id ?? task.taskId ?? task.step ?? '');
532
+ const alreadyRequested = approvals.some((approval) =>
533
+ approval.status === 'pending_approval'
534
+ && String(approval.taskId ?? approval.itemId ?? '') === taskId
535
+ && Number(approval.planRevision ?? approvalContext.planRevision) === Number(approvalContext.planRevision));
536
+ if (alreadyRequested) continue;
537
+ newlyRequested.push(task);
538
+ const request = approvalRequestForTask(task, {
539
+ runId,
540
+ workspaceId: session.workspace ?? null,
541
+ planRevision: approvalContext.planRevision,
542
+ });
543
+ dispatchAgentEvent(session, createAgentEvent('approval.requested', {
544
+ origin: 'runtime',
545
+ runId,
546
+ taskId: request.taskId,
547
+ workspace: session.workspace ?? null,
548
+ payload: request,
549
+ }));
550
+ // Reflect the block on the task's plan step so the UIs can render it
551
+ // distinctly (amber "[⏸]" in the Shell, banner/badge in serve)
552
+ // instead of leaving it as a neutral "pending" indistinguishable
553
+ // from a not-started step. Only promote a plain pending task — never
554
+ // overwrite a status the planner already set (e.g. waiting_approval).
555
+ const currentStatus = String(task.status ?? '').toLowerCase();
556
+ if (!['pending_approval', 'waiting_approval'].includes(currentStatus)) {
557
+ dispatchAgentEvent(session, createAgentEvent('plan_step_updated', {
558
+ origin: 'runtime',
559
+ runId,
560
+ taskId,
561
+ payload: { taskId, status: 'pending_approval' },
562
+ }));
563
+ }
564
+ }
565
+ if (newlyRequested.length > 0) {
441
566
  dispatchAgentEvent(session, createAgentEvent('assistant_message', {
442
567
  origin: 'runtime',
443
568
  runId,
444
569
  payload: {
445
570
  content: [
446
- `⏸ Approbation requise avant exécution : ${needingApproval.length} tâche(s) mutante(s) en attente.`,
447
- ...needingApproval.slice(0, 5).map((step) => ` - ${step.description ?? step.id}`),
448
- needingApproval.length > 5 ? ` … et ${needingApproval.length - 5} autre(s).` : null,
571
+ `⏸ Approbation requise avant exécution : ${newlyRequested.length} tâche(s) mutante(s) en attente.`,
572
+ ...newlyRequested.slice(0, 5).map((step) => ` - ${step.description ?? step.id}`),
573
+ newlyRequested.length > 5 ? ` … et ${newlyRequested.length - 5} autre(s).` : null,
449
574
  'Réponds « valide tout » (ou tape /approve) pour lancer, « annule » pour abandonner.',
450
575
  ].filter(Boolean).join('\n'),
451
576
  },
452
577
  }));
453
- emitRuntimeLog(session, `scheduler: waiting for approval (${needingApproval.length} task(s))`);
578
+ emitRuntimeLog(session, `scheduler: waiting for approval (${newlyRequested.length} new task(s))`);
454
579
  }
455
580
  if (Date.now() < approvalDeadline) {
456
581
  await new Promise((resolveDelay) => setTimeout(resolveDelay, 500));
@@ -469,7 +594,9 @@ export async function runRuntimeParallelPlan(agent, session, input, {
469
594
  }));
470
595
  return { ok: false, stalled: true, reason: 'awaiting_approval', completed: sessionActivities(session), failures };
471
596
  }
472
- const reason = pending.every((step) => approvalWaitingStatus(step.status)) ? 'awaiting_approval' : 'no_ready_plan_task';
597
+ // Any genuine approval-only block returned above. Remaining tasks are
598
+ // unschedulable for another reason (most commonly a failed dependency).
599
+ const reason = 'no_ready_plan_task';
473
600
  emitRuntimeLog(session, `scheduler: stalled (${reason})`);
474
601
  return { ok: false, stalled: true, reason, completed: sessionActivities(session), failures };
475
602
  }
@@ -585,11 +712,7 @@ function abortCancelledActiveTasks(session, active) {
585
712
  }
586
713
 
587
714
  function pendingSchedulerStatus(status) {
588
- return ['pending', 'pending_approval', 'waiting_approval'].includes(String(status ?? ''));
589
- }
590
-
591
- function approvalWaitingStatus(status) {
592
- return ['pending_approval', 'waiting_approval'].includes(String(status ?? ''));
715
+ return PENDING_STATUSES.has(String(status ?? ''));
593
716
  }
594
717
 
595
718
  // Scope the evaluator/replanner's view of "completed" activities to the
@@ -812,6 +812,36 @@ test('runRuntimeParallelPlan fails cleanly when scheduler budget is exceeded', a
812
812
  assert.equal(session.headlessPlan[0].status, 'cancelled');
813
813
  });
814
814
 
815
+ test('runRuntimeParallelPlan does not wait for approval behind a failed dependency', async () => {
816
+ const session = {
817
+ workspace: 'demo-workspace',
818
+ mcp: { tools: {} },
819
+ agentEvents: [],
820
+ headlessPlan: [
821
+ { ...plannedDoctorTask('a', []), status: 'failed' },
822
+ {
823
+ ...plannedDoctorTask('b', ['a']),
824
+ status: 'waiting_approval',
825
+ requiresApproval: true,
826
+ approvalClass: 'mutation',
827
+ },
828
+ ],
829
+ };
830
+
831
+ const result = await runRuntimeParallelPlan(
832
+ { invoke: async () => assert.fail('blocked work must not invoke an agent') },
833
+ session,
834
+ 'Apply after failed plan',
835
+ { runId: 'run-failed-dependency', timeoutMs: 1000, maxTurns: 1 },
836
+ );
837
+
838
+ assert.equal(result.ok, false);
839
+ assert.equal(result.stalled, true);
840
+ assert.equal(result.reason, 'no_ready_plan_task');
841
+ assert.equal(session.agentEvents.some((event) => event.type === 'assistant_message'
842
+ && /Approbation requise/.test(event.payload?.content ?? '')), false);
843
+ });
844
+
815
845
  test('runRuntimeParallelPlan retries a retryable task on a fallback agent', async () => {
816
846
  const executeServers = [];
817
847
  const session = {
@@ -6,6 +6,8 @@ import { normalizePlanPatch, rebasePlanPatch } from '../core/planPatch.js';
6
6
  import { validateContractInDev } from '../contracts/schemas.js';
7
7
  import { runtimeTokenFromEnv } from './auth.js';
8
8
  import { controlMessage } from './controlMessages.js';
9
+ import { tasksAwaitingApproval } from '../orchestrator/dependencyResolver.js';
10
+ import { approvalClassForTask } from '../orchestrator/approvalPolicy.js';
9
11
 
10
12
  export function startRuntimeServer({
11
13
  host = '127.0.0.1',
@@ -679,10 +681,19 @@ function readOnlyControlResponse(kind, classification, status, explanation, { ac
679
681
  };
680
682
  }
681
683
 
682
- function approvalRequestFromStatus(status) {
684
+ export function approvalRequestFromStatus(status) {
683
685
  const runId = status.runId ?? status.runs?.find((run) => run.status === 'running' || run.status === 'pending_approval')?.id ?? null;
684
686
  const pending = (status.approvals ?? []).filter((approval) => approval.status === 'pending_approval');
685
- const classes = [...new Set(pending.flatMap((approval) => readOptionalList(approval.approvalClasses ?? approval.approvalClass)))];
687
+ const waitingTasks = tasksAwaitingApproval({
688
+ runId,
689
+ workspace: status.workspace ?? null,
690
+ planRevision: status.planRevision ?? null,
691
+ tasks: status.plan ?? [],
692
+ }, { approvals: status.approvals ?? [] });
693
+ const classes = [...new Set([
694
+ ...pending.flatMap((approval) => readOptionalList(approval.approvalClasses ?? approval.approvalClass)),
695
+ ...waitingTasks.map((task) => approvalClassForTask(task)),
696
+ ].filter(Boolean))];
686
697
  return {
687
698
  workspace: status.workspace ?? null,
688
699
  workspaceId: status.workspace ?? null,