@dotdrelle/wiki-manager 0.14.13 → 0.14.16

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 (39) hide show
  1. package/.env.example +11 -0
  2. package/docker-compose.yml +1 -1
  3. package/package.json +1 -1
  4. package/src/activity/activityAggregator.js +43 -13
  5. package/src/activity/activityAggregator.test.js +51 -2
  6. package/src/agent/graph.js +79 -11
  7. package/src/agent/graph.test.js +43 -3
  8. package/src/cli/wiki-manager.js +44 -3
  9. package/src/commands/slash.js +10 -3
  10. package/src/commands/slash.test.js +24 -0
  11. package/src/core/buildInfo.json +2 -2
  12. package/src/core/dockerCompose.test.js +4 -0
  13. package/src/core/env.test.js +3 -0
  14. package/src/core/mcp.js +1 -1
  15. package/src/core/wikiSetup.js +35 -0
  16. package/src/core/wikiWorkspace.test.js +20 -0
  17. package/src/core/workspaces.js +10 -2
  18. package/src/orchestrator/dependencyResolver.js +19 -1
  19. package/src/orchestrator/objectiveResolver.js +24 -0
  20. package/src/orchestrator/objectiveResolver.test.js +23 -1
  21. package/src/orchestrator/scheduler.test.js +22 -1
  22. package/src/runtime/auth.test.js +65 -1
  23. package/src/runtime/client.js +4 -0
  24. package/src/runtime/donna-contract.test.js +2 -0
  25. package/src/runtime/lifecycle.js +21 -12
  26. package/src/runtime/runner.js +111 -15
  27. package/src/runtime/runner.test.js +30 -0
  28. package/src/runtime/server.js +13 -2
  29. package/src/runtime/server.test.js +30 -1
  30. package/src/shell/FileEditorDialog.tsx +2 -2
  31. package/src/shell/LeftPane.tsx +60 -15
  32. package/src/shell/RightPane.tsx +147 -54
  33. package/src/shell/StartupScreen.tsx +3 -7
  34. package/src/shell/renderer.ts +1 -0
  35. package/src/shell/repl.js +7 -81
  36. package/src/shell/repl.test.js +128 -38
  37. package/src/shell/tui.tsx +59 -64
  38. package/src/shell/useSession.ts +45 -6
  39. package/wiki-workspace +28 -0
@@ -105,6 +105,41 @@ export async function stopAgents(options = {}) {
105
105
  }
106
106
  }
107
107
 
108
+ export async function refreshRunningContainers(options = {}) {
109
+ if (process.env.WIKI_MANAGER_AUTO_UPDATE === '0') {
110
+ return { skipped: true, refreshed: [] };
111
+ }
112
+ const script = join(managerRoot(), 'wiki-workspace');
113
+ const common = {
114
+ cwd: dirname(managerEnvFile()),
115
+ env: {
116
+ ...process.env,
117
+ WIKI_WORKSPACES_DIR: workspacesDir(),
118
+ WIKI_MANAGER_ENV_FILE: managerEnvFile(),
119
+ WIKI_MANAGER_ENDPOINTS_FILE: managerMcpEndpointsFile(),
120
+ AGENTS_DATA_DIR: resolveAgentsDataDir(),
121
+ },
122
+ timeout: options.timeout ?? 600_000,
123
+ maxBuffer: options.maxBuffer ?? 1024 * 1024 * 8,
124
+ };
125
+ const targets = [['agents', 'refresh'], ...listWorkspaces().map((workspace) => ['wiki', workspace.name, 'refresh'])];
126
+ // Each target is an independent Compose project (its own agents/workspace
127
+ // stack) — refresh them concurrently instead of summing every target's
128
+ // pull/restart time into one sequential wait.
129
+ const results = await Promise.all(targets.map(async (args) => {
130
+ options.onStep?.(`Images: checking ${args[0] === 'agents' ? 'running agents' : `workspace ${args[1]}`}…`);
131
+ try {
132
+ const { stdout, stderr } = await execFileAsync(script, args, common);
133
+ return { ok: true, output: [stdout, stderr].filter(Boolean).join('\n').trim() };
134
+ } catch (err) {
135
+ return { ok: false, error: wrapDockerError(err).message };
136
+ }
137
+ }));
138
+ const refreshed = results.filter((result) => result.ok).map((result) => result.output).filter(Boolean);
139
+ const errors = results.filter((result) => !result.ok).map((result) => result.error);
140
+ return { skipped: false, refreshed, errors };
141
+ }
142
+
108
143
  export async function createNewWorkspace(name, targetPath) {
109
144
  try {
110
145
  const output = await createWorkspace(name, targetPath, { timeout: 600_000 });
@@ -31,3 +31,23 @@ test('wiki-workspace regenerates CA compose overrides instead of retaining remov
31
31
  assert.match(script, /mv "\$tmp_override" "\$override_path"/);
32
32
  assert.match(script, /Changes are overwritten on the next compose command/);
33
33
  });
34
+
35
+ test('workspace creation keeps mutable manager files outside the installed package', async () => {
36
+ const source = await readFile(new URL('./workspaces.js', import.meta.url), 'utf8');
37
+
38
+ assert.match(source, /const stateDir = dirname\(managerEnvFile\(\)\)/);
39
+ assert.match(source, /cwd: stateDir/);
40
+ assert.match(source, /WIKI_MANAGER_ENV_FILE: managerEnvFile\(\)/);
41
+ assert.match(source, /WIKI_MANAGER_ENDPOINTS_FILE: managerMcpEndpointsFile\(\)/);
42
+ });
43
+
44
+ test('container refresh pulls and renews only services that are already running', async () => {
45
+ const script = await readFile(new URL('../../wiki-workspace', import.meta.url), 'utf8');
46
+
47
+ assert.match(script, /refresh_running_services\(\) \{/);
48
+ assert.match(script, /\[\[ -n "\$\{line\/\/\[\[:space:\]\]\/\}" \]\] \|\| continue/);
49
+ assert.match(script, /ps --status running --services/);
50
+ assert.match(script, /"\$@" pull "\$\{running_services\[@\]\}"/);
51
+ assert.match(script, /refresh_running_services 'No running external agent containers to refresh' 'Refreshed running agents: %s' _agents_dc/);
52
+ assert.match(script, /refresh_running_services "No running workspace containers to refresh: \$workspace" "Refreshed running workspace containers: \$workspace \(%s\)" compose_for_workspace "\$workspace"/);
53
+ });
@@ -3,7 +3,7 @@ import { existsSync, readdirSync, realpathSync } from 'node:fs';
3
3
  import { dirname, join, resolve } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { promisify } from 'node:util';
6
- import { managerEnvFile, readEnvFile, userManagerDir } from './env.js';
6
+ import { managerEnvFile, managerMcpEndpointsFile, readEnvFile, userManagerDir } from './env.js';
7
7
 
8
8
  const __dirname = dirname(fileURLToPath(import.meta.url));
9
9
  const packageRoot = resolve(__dirname, '../..');
@@ -70,15 +70,23 @@ export async function createWorkspace(name, targetPath = null, options = {}) {
70
70
  }
71
71
  const args = ['config', name];
72
72
  if (targetPath) args.push(targetPath);
73
+ const stateDir = dirname(managerEnvFile());
73
74
  const { stdout, stderr } = await execFileAsync(
74
75
  join(managerRoot(), 'wiki-workspace'),
75
76
  args,
76
77
  {
77
- cwd: managerRoot(),
78
+ // Relative Compose mounts and default scaffold paths must resolve from
79
+ // the user's manager state, never from the globally installed package.
80
+ cwd: stateDir,
78
81
  env: {
79
82
  ...process.env,
80
83
  WIKI_WORKSPACES_DIR: workspacesDir(),
81
84
  WIKI_MANAGER_ENV_FILE: managerEnvFile(),
85
+ // The script runs from the installed package directory so it can find
86
+ // its Compose templates. Pin mutable manager state to the user's
87
+ // launch directory; a global npm package is commonly owned by root
88
+ // and must never become the destination for this scaffold.
89
+ WIKI_MANAGER_ENDPOINTS_FILE: managerMcpEndpointsFile(),
82
90
  },
83
91
  maxBuffer: options.maxBuffer ?? 1024 * 1024 * 8,
84
92
  timeout: options.timeout ?? 600_000,
@@ -3,6 +3,9 @@ import { approvalCovered } from './approvalPolicy.js';
3
3
 
4
4
  const DONE_STATUSES = new Set(['done', 'completed', 'complete', 'success', 'succeeded']);
5
5
  const TERMINAL_STATUSES = new Set([...DONE_STATUSES, 'failed', 'cancelled', 'canceled', 'skipped']);
6
+ // A task in one of these statuses hasn't run yet but could become ready —
7
+ // shared with runner.js's scheduler-stall check so the two can't drift apart.
8
+ export const PENDING_STATUSES = new Set(['pending', 'pending_approval', 'waiting_approval']);
6
9
 
7
10
  export function readyTasks(dag, {
8
11
  registry = null,
@@ -18,7 +21,7 @@ export function readyTasks(dag, {
18
21
  .filter((task) => {
19
22
  const status = statusOf(task);
20
23
  return status === 'pending'
21
- || ((status === 'waiting_approval' || status === 'pending_approval')
24
+ || (PENDING_STATUSES.has(status)
22
25
  && approvalCovered(task, approvals, {
23
26
  runId: task?.runId ?? dag?.runId ?? null,
24
27
  workspaceId: dag?.workspace ?? null,
@@ -35,6 +38,21 @@ export function readyTasks(dag, {
35
38
  .sort(compareTaskPriority);
36
39
  }
37
40
 
41
+ export function tasksAwaitingApproval(dag, { approvals = [] } = {}) {
42
+ const tasks = normalizeTasks(dag);
43
+ const done = new Set(tasks.filter((task) => DONE_STATUSES.has(statusOf(task))).map(taskId));
44
+ return tasks
45
+ .filter((task) => PENDING_STATUSES.has(statusOf(task)))
46
+ .filter((task) => task?.requiresApproval === true)
47
+ .filter((task) => !approvalCovered(task, approvals, {
48
+ runId: task?.runId ?? dag?.runId ?? null,
49
+ workspaceId: dag?.workspace ?? null,
50
+ planRevision: dag?.planRevision ?? null,
51
+ }))
52
+ .filter((task) => dependenciesDone(task, done))
53
+ .filter((task) => groupBarrierSatisfied(task, tasks));
54
+ }
55
+
38
56
  function normalizeTasks(dag) {
39
57
  if (Array.isArray(dag)) return dag;
40
58
  if (Array.isArray(dag?.tasks)) return dag.tasks;
@@ -1,6 +1,8 @@
1
1
  export async function resolveObjective(objective, session) {
2
2
  const candidates = capabilityCandidates(session);
3
3
  if (candidates.length === 0) throw new Error('No orchestrable capability is currently available.');
4
+ const deterministic = resolveMentionedRegistryOperation(objective, candidates);
5
+ if (deterministic) return selectionWithProvider(session, deterministic, candidates);
4
6
  const llm = session?.llm;
5
7
  if (!llm?.completeWithTools) throw new Error('Objective resolution requires the configured workspace LLM.');
6
8
 
@@ -25,6 +27,28 @@ export async function resolveObjective(objective, session) {
25
27
  if (!candidate.operations.includes(operation)) {
26
28
  throw new Error(`Objective resolver selected unsupported operation "${operation}" for ${capability}.`);
27
29
  }
30
+ return selectionWithProvider(session, { capability, operation }, candidates);
31
+ }
32
+
33
+ // Prefer an operation explicitly named by the user when that name resolves to
34
+ // exactly one entry in the live registry. This is deliberately generic: the
35
+ // resolver knows neither capability ids nor business verbs. Prefix matching
36
+ // covers natural inflections such as an operation name followed by a suffix.
37
+ function resolveMentionedRegistryOperation(objective, candidates) {
38
+ const words = String(objective ?? '')
39
+ .normalize('NFKD')
40
+ .replace(/\p{Diacritic}/gu, '')
41
+ .toLowerCase()
42
+ .match(/[a-z0-9]+/g) ?? [];
43
+ const matches = candidates.flatMap((candidate) => candidate.operations
44
+ .filter((operation) => String(operation).split(/[._-]+/).some((token) =>
45
+ token.length >= 4 && words.some((word) => word.startsWith(token))))
46
+ .map((operation) => ({ capability: candidate.id, operation })));
47
+ return matches.length === 1 ? matches[0] : null;
48
+ }
49
+
50
+ function selectionWithProvider(session, selection, candidates) {
51
+ const { capability, operation } = selection;
28
52
  const providers = providersFor(session, capability)
29
53
  .filter((provider) => !operation || (provider.capability?.supportedOperations ?? []).includes(operation))
30
54
  .sort((a, b) => String(a.agentInstanceId).localeCompare(String(b.agentInstanceId)));
@@ -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
  });
@@ -4,7 +4,7 @@ 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
10
  effectiveConcurrency,
@@ -60,6 +60,27 @@ test('dependencyResolver releases a waiting task when a run grant covers it', ()
60
60
  }).map((item) => item.id), ['ingest']);
61
61
  });
62
62
 
63
+ test('dependencyResolver does not request approval for a task blocked by a failed dependency', () => {
64
+ const plan = {
65
+ runId: 'run-1',
66
+ workspace: 'test4',
67
+ planRevision: 1,
68
+ tasks: [
69
+ task('plan', { status: 'failed' }),
70
+ task('apply', {
71
+ status: 'waiting_approval',
72
+ dependsOn: ['plan'],
73
+ requiresApproval: true,
74
+ approvalClass: 'mutation',
75
+ }),
76
+ ],
77
+ };
78
+
79
+ assert.deepEqual(tasksAwaitingApproval(plan), []);
80
+ plan.tasks[0].status = 'done';
81
+ assert.deepEqual(tasksAwaitingApproval(plan).map((item) => item.id), ['apply']);
82
+ });
83
+
63
84
  test('dependencyResolver skips tasks whose locks are not free and keeps other ready work moving', () => {
64
85
  const lockManager = createLockManager();
65
86
  const held = lockManager.acquire(['deliverable:a.md']);
@@ -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,6 +7,8 @@ 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
14
  import { drainActive, resolvePlanConcurrency, startReadyTasks } from '../orchestrator/scheduler.js';
@@ -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,
@@ -328,7 +393,6 @@ export async function runRuntimeParallelPlan(agent, session, input, {
328
393
  sanitizeSessionPlanForExecution(session, runId);
329
394
  ensurePlanProjection(session, runId);
330
395
  emitRuntimeLog(session, `scheduler: parallel plan enabled (concurrency ${limit})`);
331
- let approvalNoticeSent = false;
332
396
  // Interactive approvals do NOT expire: the user has /approve, "valide
333
397
  // tout", /cancel and /run kill — an arbitrary timer only created mystery
334
398
  // failures. A deadline exists only when explicitly configured (headless
@@ -431,26 +495,60 @@ export async function runRuntimeParallelPlan(agent, session, input, {
431
495
  emitRuntimeLog(session, `scheduler: budget exceeded (${exceeded.reason})`);
432
496
  return { ok: false, budgetExceeded: true, reason: exceeded.reason, budget: exceeded, completed: sessionActivities(session), failures };
433
497
  }
434
- const needingApproval = pending.filter((step) => step.requiresApproval === true);
498
+ // Only wait for a human when approval is the sole remaining blocker.
499
+ // A task whose dependency failed cannot become runnable by approving
500
+ // it; treating it as an approval wait leaves the run alive forever.
501
+ const approvalContext = {
502
+ runId,
503
+ workspace: session.workspace ?? null,
504
+ planRevision: session.planRevision ?? session.agentProjection?.planRevision ?? null,
505
+ tasks: session.headlessPlan ?? [],
506
+ };
507
+ // One snapshot of the approvals list, reused for both the
508
+ // awaiting-approval computation and the per-task dedup below so the two
509
+ // can never diverge mid-iteration.
510
+ const approvals = session.agentProjection?.approvals ?? session.approvals ?? [];
511
+ const needingApproval = tasksAwaitingApproval(approvalContext, { approvals });
435
512
  if (needingApproval.length > 0) {
436
513
  // The plan is only blocked on a HUMAN decision — wait for it
437
514
  // (bounded) instead of declaring the run stalled. Announce once in
438
515
  // the chat: users cannot approve what they never saw asked.
439
- if (!approvalNoticeSent) {
440
- approvalNoticeSent = true;
516
+ const newlyRequested = [];
517
+ for (const task of needingApproval) {
518
+ const taskId = String(task.id ?? task.taskId ?? task.step ?? '');
519
+ const alreadyRequested = approvals.some((approval) =>
520
+ approval.status === 'pending_approval'
521
+ && String(approval.taskId ?? approval.itemId ?? '') === taskId
522
+ && Number(approval.planRevision ?? approvalContext.planRevision) === Number(approvalContext.planRevision));
523
+ if (alreadyRequested) continue;
524
+ newlyRequested.push(task);
525
+ const request = approvalRequestForTask(task, {
526
+ runId,
527
+ workspaceId: session.workspace ?? null,
528
+ planRevision: approvalContext.planRevision,
529
+ });
530
+ dispatchAgentEvent(session, createAgentEvent('approval.requested', {
531
+ origin: 'runtime',
532
+ runId,
533
+ taskId: request.taskId,
534
+ workspace: session.workspace ?? null,
535
+ payload: request,
536
+ }));
537
+ }
538
+ if (newlyRequested.length > 0) {
441
539
  dispatchAgentEvent(session, createAgentEvent('assistant_message', {
442
540
  origin: 'runtime',
443
541
  runId,
444
542
  payload: {
445
543
  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,
544
+ `⏸ Approbation requise avant exécution : ${newlyRequested.length} tâche(s) mutante(s) en attente.`,
545
+ ...newlyRequested.slice(0, 5).map((step) => ` - ${step.description ?? step.id}`),
546
+ newlyRequested.length > 5 ? ` … et ${newlyRequested.length - 5} autre(s).` : null,
449
547
  'Réponds « valide tout » (ou tape /approve) pour lancer, « annule » pour abandonner.',
450
548
  ].filter(Boolean).join('\n'),
451
549
  },
452
550
  }));
453
- emitRuntimeLog(session, `scheduler: waiting for approval (${needingApproval.length} task(s))`);
551
+ emitRuntimeLog(session, `scheduler: waiting for approval (${newlyRequested.length} new task(s))`);
454
552
  }
455
553
  if (Date.now() < approvalDeadline) {
456
554
  await new Promise((resolveDelay) => setTimeout(resolveDelay, 500));
@@ -469,7 +567,9 @@ export async function runRuntimeParallelPlan(agent, session, input, {
469
567
  }));
470
568
  return { ok: false, stalled: true, reason: 'awaiting_approval', completed: sessionActivities(session), failures };
471
569
  }
472
- const reason = pending.every((step) => approvalWaitingStatus(step.status)) ? 'awaiting_approval' : 'no_ready_plan_task';
570
+ // Any genuine approval-only block returned above. Remaining tasks are
571
+ // unschedulable for another reason (most commonly a failed dependency).
572
+ const reason = 'no_ready_plan_task';
473
573
  emitRuntimeLog(session, `scheduler: stalled (${reason})`);
474
574
  return { ok: false, stalled: true, reason, completed: sessionActivities(session), failures };
475
575
  }
@@ -585,11 +685,7 @@ function abortCancelledActiveTasks(session, active) {
585
685
  }
586
686
 
587
687
  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 ?? ''));
688
+ return PENDING_STATUSES.has(String(status ?? ''));
593
689
  }
594
690
 
595
691
  // 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 = {