@dotdrelle/wiki-manager 0.15.38 → 0.15.41

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 (42) hide show
  1. package/README.md +5 -0
  2. package/docker-compose.yml +1 -1
  3. package/package.json +2 -2
  4. package/src/activity/activityAggregator.js +3 -3
  5. package/src/activity/progressCalculator.js +3 -4
  6. package/src/agent/graph.js +40 -1
  7. package/src/cli/wiki-manager.js +34 -39
  8. package/src/commands/slash.js +11 -1
  9. package/src/core/activity.js +3 -2
  10. package/src/core/agentEvents.js +46 -11
  11. package/src/core/agentEvents.test.js +76 -0
  12. package/src/core/agentLoop.js +32 -6
  13. package/src/core/buildInfo.json +2 -2
  14. package/src/core/dockerCompose.test.js +8 -0
  15. package/src/core/jobQueue.js +9 -0
  16. package/src/core/mcp.js +1 -1
  17. package/src/core/plan.js +3 -2
  18. package/src/core/planPatch.js +2 -1
  19. package/src/core/workflow.js +11 -2
  20. package/src/graph/graphVisibilityPolicy.js +2 -2
  21. package/src/orchestrator/agentRegistry.js +50 -0
  22. package/src/orchestrator/agentRegistry.test.js +76 -1
  23. package/src/orchestrator/approvalPolicy.js +2 -2
  24. package/src/orchestrator/dependencyResolver.js +52 -12
  25. package/src/orchestrator/dispatcher.js +6 -7
  26. package/src/orchestrator/dispatcher.test.js +33 -0
  27. package/src/orchestrator/planIntegrator.js +5 -5
  28. package/src/orchestrator/resultAggregator.js +2 -1
  29. package/src/orchestrator/scheduler.test.js +62 -1
  30. package/src/orchestrator/taskStatuses.js +99 -0
  31. package/src/orchestrator/taskStatuses.test.js +112 -0
  32. package/src/runtime/delegation.js +158 -0
  33. package/src/runtime/delegation.test.js +281 -0
  34. package/src/runtime/recoveryManager.js +2 -5
  35. package/src/runtime/recoveryManager.test.js +5 -1
  36. package/src/runtime/runner.js +129 -15
  37. package/src/runtime/runner.test.js +153 -6
  38. package/src/runtime/server.js +24 -1
  39. package/src/runtime/server.test.js +113 -0
  40. package/src/runtime/store.js +16 -1
  41. package/src/runtime/store.test.js +50 -0
  42. package/src/shell/repl.js +4 -5
@@ -1,4 +1,5 @@
1
1
  import { validateContractInDev } from '../contracts/schemas.js';
2
+ import { isUnsuccessfulTerminal } from '../orchestrator/taskStatuses.js';
2
3
 
3
4
  const PATCH_OPS = new Set([
4
5
  'add_task',
@@ -164,7 +165,7 @@ export function sanitizePlanForExecution(plan) {
164
165
  const done = new Set(tasks.filter((task) => task.status === 'done').map(taskId));
165
166
  const terminalBlocked = new Set(
166
167
  tasks
167
- .filter((task) => ['failed', 'cancelled', 'canceled'].includes(String(task.status).toLowerCase()))
168
+ .filter((task) => isUnsuccessfulTerminal(task.status))
168
169
  .map(taskId),
169
170
  );
170
171
  const hasReady = pending.some((task) => task.dependsOn.every((dep) => done.has(String(dep))));
@@ -1,8 +1,17 @@
1
+ /**
2
+ * @statuses-vocabulary
3
+ *
4
+ * DISPLAY normalization for the execution graph, with `added_during_run`,
5
+ * which exists only as a rendering hint.
6
+ *
7
+ * Declared here rather than in a central exception list so the waiver
8
+ * travels with the code it excuses (see orchestrator/taskStatuses.test.js).
9
+ */
1
10
  import { aggregateActivity } from '../activity/activityAggregator.js';
2
11
  import { calculateWeightedProgress } from '../activity/progressCalculator.js';
3
12
  import { aggregateGraph } from '../graph/graphAggregator.js';
13
+ import { isTerminal } from '../orchestrator/taskStatuses.js';
4
14
 
5
- const TERMINAL_STATUSES = new Set(['done', 'failed', 'cancelled', 'canceled', 'error', 'complete', 'completed', 'success']);
6
15
  const RUNNING_STATUSES = new Set(['running', 'starting', 'queued', 'waiting', 'pending_approval']);
7
16
 
8
17
  // Canonical workflow projection for 0.9.6.
@@ -278,7 +287,7 @@ function isActiveStatus(status) {
278
287
  }
279
288
 
280
289
  function isTerminalStatus(status) {
281
- return TERMINAL_STATUSES.has(normalizeStatus(status));
290
+ return isTerminal(status);
282
291
  }
283
292
 
284
293
  function findCurrentNode(nodes) {
@@ -1,6 +1,6 @@
1
+ import { isFailed, isCancelled } from '../orchestrator/taskStatuses.js';
1
2
  const ALWAYS_VISIBLE = new Set(['run', 'task_group', 'barrier', 'approval']);
2
3
  const ACTIVE = new Set(['running', 'queued', 'pending_approval', 'waiting_approval']);
3
- const ERROR = new Set(['failed', 'error', 'cancelled']);
4
4
 
5
5
  export function applyGraphVisibility(graph, { maxNodes = 18 } = {}) {
6
6
  const nodes = graph.nodes ?? [];
@@ -35,6 +35,6 @@ function mustShow(node) {
35
35
  const status = String(node.status ?? '').toLowerCase();
36
36
  return ALWAYS_VISIBLE.has(node.type)
37
37
  || ACTIVE.has(status)
38
- || ERROR.has(status)
38
+ || isFailed(status) || isCancelled(status)
39
39
  || node.type === 'plan_expansion';
40
40
  }
@@ -4,6 +4,56 @@ import { assertContract } from '../contracts/schemas.js';
4
4
 
5
5
  const AVAILABLE = 'available';
6
6
  const UNAVAILABLE = 'unavailable';
7
+ /**
8
+ * Santé d'un agent restauré depuis le journal, tant qu'aucun `agent_describe`
9
+ * n'a réussi dans le processus courant.
10
+ *
11
+ * Ni `available` ni `unavailable` : on ne SAIT pas. Le distinguer de
12
+ * `unavailable` a une conséquence pratique — un agent inconnu redevient
13
+ * disponible en silence dès le premier scan réussi, là où un agent déclaré
14
+ * indisponible mériterait d'être signalé comme tel à l'utilisateur.
15
+ */
16
+ const UNKNOWN = 'unknown';
17
+
18
+ /**
19
+ * Un agent persisté n'est pas un agent joignable.
20
+ *
21
+ * Au redémarrage, `hydrateSession` rejoue le journal d'événements et
22
+ * reconstruit les agents avec la santé qu'ils avaient AU MOMENT où l'événement
23
+ * a été écrit. `cme-main` réapparaissait donc `available` alors que son
24
+ * endpoint n'existe plus, était retenu comme fournisseur, et la tâche partait
25
+ * vers un agent absent — la panne observée le 2026-08-04.
26
+ *
27
+ * La persistance dit ce qui a existé, pas ce qui répond maintenant. Seul un
28
+ * `agent_describe` réussi dans ce processus autorise à parler de disponibilité.
29
+ */
30
+ export function markPersistedAgentsStale(session) {
31
+ if (!session || typeof session !== 'object') return [];
32
+ const stale = (agent) => ({
33
+ ...agent,
34
+ health: UNKNOWN,
35
+ stale: true,
36
+ // La santé d'origine est conservée : elle raconte ce qu'on savait avant
37
+ // l'arrêt, ce qui aide à lire un journal, sans jamais servir au routage.
38
+ healthBeforeRestart: agent?.health ?? null,
39
+ });
40
+ /*
41
+ TOUTES les représentations restaurées, `agentRegistrySnapshot` compris.
42
+
43
+ J'avais d'abord épargné le snapshot, au motif qu'il pouvait porter un scan
44
+ vivant. C'était une inversion : à l'hydratation, aucun scan n'a encore eu
45
+ lieu — l'ordre est hydrate → invalidate → discover, et rien ne s'exécute
46
+ entre les deux premiers. Le snapshot vient donc de la même projection
47
+ persistée que `session.agents`. L'épargner laissait `cme-main` routable avec
48
+ son endpoint éteint, ce que la validation à chaud a montré.
49
+
50
+ Le seul scan qui compte est celui qui suivra : `discover()` réécrit le
51
+ snapshot en entier à partir des `agent_describe` réussis.
52
+ */
53
+ session.agents = (session.agents ?? []).map(stale);
54
+ session.agentRegistrySnapshot = (session.agentRegistrySnapshot ?? []).map(stale);
55
+ return session.agentRegistrySnapshot;
56
+ }
7
57
 
8
58
  export function createAgentRegistry({
9
59
  callTool = callMcpTool,
@@ -1,6 +1,7 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
- import { createAgentRegistry } from './agentRegistry.js';
3
+ import { createAgentRegistry, markPersistedAgentsStale } from './agentRegistry.js';
4
+ import { createCapabilityRegistry } from './capabilityRegistry.js';
4
5
 
5
6
  function description({ agentInstanceId = 'production-main', health = 'available', contractVersion = '1' } = {}) {
6
7
  return {
@@ -205,3 +206,77 @@ test('discovery sends no workspace argument when no workspace is active', async
205
206
 
206
207
  assert.deepEqual(seen, [{}]);
207
208
  });
209
+
210
+ /*
211
+ Cas observé le 2026-08-04 : après redémarrage du runtime, `cme-main` était
212
+ toujours présenté `available` alors que son endpoint n'existait plus. La
213
+ relecture du journal restitue la santé qu'un agent avait AU MOMENT où
214
+ l'événement a été écrit — la persistance dit ce qui a existé, pas ce qui
215
+ répond maintenant.
216
+ */
217
+ test('un agent restauré depuis le journal n’est pas déclaré disponible', () => {
218
+ const session = {
219
+ agents: [
220
+ { agentInstanceId: 'cme-main', serverName: 'cme', health: 'available', description: { contractVersion: '1', capabilities: [{ id: 'external-source.export', version: '1' }] } },
221
+ { agentInstanceId: 'production-main', serverName: 'production', health: 'available', description: { contractVersion: '1', capabilities: [{ id: 'knowledge.update', version: '1' }] } },
222
+ ],
223
+ };
224
+
225
+ markPersistedAgentsStale(session);
226
+
227
+ for (const agent of session.agents) {
228
+ assert.equal(agent.health, 'unknown');
229
+ assert.equal(agent.stale, true);
230
+ // Ce qu'on savait avant l'arrêt reste lisible, sans jamais servir au routage.
231
+ assert.equal(agent.healthBeforeRestart, 'available');
232
+ }
233
+ // Et surtout : plus aucun fournisseur sélectionnable tant que le scan n'a pas
234
+ // confirmé. C'est la seule chose qui empêche une tâche de partir vers un
235
+ // agent absent.
236
+ const registry = createCapabilityRegistry({ agents: session.agents });
237
+ assert.deepEqual(registry.providersFor('external-source.export'), []);
238
+ assert.deepEqual(registry.providersFor('knowledge.update'), []);
239
+ });
240
+
241
+ test('markPersistedAgentsStale invalide aussi l’instantané routable', () => {
242
+ /*
243
+ J'avais d'abord épargné `agentRegistrySnapshot`, au motif qu'il pouvait
244
+ porter un scan vivant. Inversion : à l'hydratation aucun scan n'a eu lieu,
245
+ l'ordre étant hydrate → invalidate → discover. L'épargner laissait
246
+ `cme-main` routable avec son endpoint éteint — constaté à chaud.
247
+ */
248
+ const session = {
249
+ agents: [{ agentInstanceId: 'cme-main', health: 'available' }],
250
+ agentRegistrySnapshot: [{ agentInstanceId: 'cme-main', health: 'available' }],
251
+ };
252
+
253
+ markPersistedAgentsStale(session);
254
+
255
+ assert.equal(session.agents[0].health, 'unknown');
256
+ assert.equal(session.agentRegistrySnapshot[0].health, 'unknown');
257
+ });
258
+
259
+ test('un agent redevient sélectionnable après un agent_describe réussi', async () => {
260
+ const session = {
261
+ workspace: 'juno',
262
+ agentEvents: [],
263
+ agents: [{ agentInstanceId: 'production-main', serverName: 'production', health: 'available', description: { contractVersion: '1', capabilities: [{ id: 'knowledge.update', version: '1' }] } }],
264
+ mcp: {
265
+ production: { status: 'connected', tools: [{ name: 'agent_describe' }] },
266
+ },
267
+ };
268
+ markPersistedAgentsStale(session);
269
+ assert.deepEqual(createCapabilityRegistry({ agents: session.agents }).providersFor('knowledge.update'), []);
270
+
271
+ const registry = createAgentRegistry({
272
+ callTool: async () => ({ content: [{ type: 'text', text: JSON.stringify(description()) }] }),
273
+ });
274
+ await registry.discover(session);
275
+
276
+ // La reconnexion suffit : aucune intervention, aucun redémarrage.
277
+ assert.equal(session.agentRegistrySnapshot[0].health, 'available');
278
+ assert.equal(
279
+ createCapabilityRegistry({ agents: session.agentRegistrySnapshot }).providersFor('knowledge.update').length,
280
+ 1,
281
+ );
282
+ });
@@ -1,9 +1,9 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { isTerminal } from './taskStatuses.js';
2
3
 
3
4
  export const APPROVAL_DEFAULT_CLASS = 'default';
4
5
 
5
6
  const GRANTED_STATUSES = new Set(['approved', 'granted']);
6
- const TERMINAL_STATUSES = new Set(['done', 'failed', 'cancelled', 'canceled', 'error', 'complete', 'completed', 'success']);
7
7
 
8
8
  export function approvalClassForTask(task) {
9
9
  return String(task?.approvalClass ?? task?.mutationClass ?? APPROVAL_DEFAULT_CLASS);
@@ -74,7 +74,7 @@ export function applyApprovalCoverage(tasks = [], {
74
74
  } = {}) {
75
75
  const requested = [];
76
76
  for (const task of tasks) {
77
- if (task?.requiresApproval !== true || TERMINAL_STATUSES.has(String(task.status ?? '').toLowerCase())) continue;
77
+ if (task?.requiresApproval !== true || isTerminal(task.status)) continue;
78
78
  const covered = approvalCovered(task, approvals, { runId, workspaceId, planRevision });
79
79
  if (!covered) {
80
80
  task.status = 'waiting_approval';
@@ -1,11 +1,11 @@
1
1
  import { locksForTask } from './lockManager.js';
2
2
  import { approvalCovered } from './approvalPolicy.js';
3
-
4
- const DONE_STATUSES = new Set(['done', 'completed', 'complete', 'success', 'succeeded']);
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']);
3
+ import { isPending, isSuccessful, isTerminal, isUnsuccessfulTerminal } from './taskStatuses.js';
4
+ // Une tâche dans un de ces statuts n'a pas encore tourné mais peut le devenir.
5
+ // Réexporté depuis le vocabulaire commun : l'ordonnanceur et le contrôle de
6
+ // blocage du runner doivent tester la même chose, et un ensemble local ici
7
+ // était précisément le moyen de les faire diverger.
8
+ export { isPending } from './taskStatuses.js';
9
9
 
10
10
  export function readyTasks(dag, {
11
11
  registry = null,
@@ -15,13 +15,13 @@ export function readyTasks(dag, {
15
15
  activeTaskIds = [],
16
16
  } = {}) {
17
17
  const tasks = normalizeTasks(dag);
18
- const done = new Set(tasks.filter((task) => DONE_STATUSES.has(statusOf(task))).map(taskId));
18
+ const done = new Set(tasks.filter((task) => isSuccessful(statusOf(task))).map(taskId));
19
19
  const active = new Set([...activeTaskIds].map(String));
20
20
  return tasks
21
21
  .filter((task) => {
22
22
  const status = statusOf(task);
23
23
  return status === 'pending'
24
- || (PENDING_STATUSES.has(status)
24
+ || (isPending(status)
25
25
  && approvalCovered(task, approvals, {
26
26
  runId: task?.runId ?? dag?.runId ?? null,
27
27
  workspaceId: dag?.workspace ?? null,
@@ -40,9 +40,9 @@ export function readyTasks(dag, {
40
40
 
41
41
  export function tasksAwaitingApproval(dag, { approvals = [] } = {}) {
42
42
  const tasks = normalizeTasks(dag);
43
- const done = new Set(tasks.filter((task) => DONE_STATUSES.has(statusOf(task))).map(taskId));
43
+ const done = new Set(tasks.filter((task) => isSuccessful(statusOf(task))).map(taskId));
44
44
  return tasks
45
- .filter((task) => PENDING_STATUSES.has(statusOf(task)))
45
+ .filter((task) => isPending(statusOf(task)))
46
46
  .filter((task) => task?.requiresApproval === true)
47
47
  .filter((task) => !approvalCovered(task, approvals, {
48
48
  runId: task?.runId ?? dag?.runId ?? null,
@@ -64,12 +64,52 @@ function dependenciesDone(task, done) {
64
64
  return dependsOn(task).every((dep) => done.has(String(dep)));
65
65
  }
66
66
 
67
+ /*
68
+ Une barrière de groupe attend que le groupe soit FINI, pas qu'il soit parfait.
69
+
70
+ Elle exigeait que chaque membre soit `done`. Un seul échec la fermait donc
71
+ définitivement : sur une ingestion de dix fichiers dont neuf réussissent, la
72
+ suite du plan n'était jamais débloquée et le run restait `running` pour
73
+ toujours. Un incident sur un document devenait une panne totale — le coût
74
+ était sans rapport avec le dégât.
75
+
76
+ La barrière s'ouvre donc quand tout le groupe est TERMINAL. Ce que valait
77
+ réellement la garantie « tout est done » est préservé ailleurs, et plus
78
+ finement : une tâche qui dépend explicitement d'une tâche en échec reste
79
+ bloquée par `dependenciesDone`, et le planificateur la marque `skipped`
80
+ (cf. blockedByFailedDependency). On distingue ainsi « la suite ne peut pas se
81
+ faire » de « la suite peut se faire sur ce qui a réussi ».
82
+ */
67
83
  function groupBarrierSatisfied(task, tasks) {
68
84
  const groupId = task?.dependsOnGroup;
69
85
  if (groupId == null || groupId === '') return true;
70
86
  const groupTasks = tasks.filter((candidate) => taskGroupId(candidate) === String(groupId));
71
87
  if (groupTasks.length === 0) return false;
72
- return groupTasks.every((candidate) => DONE_STATUSES.has(statusOf(candidate)));
88
+ return groupTasks.every((candidate) => isTerminal(statusOf(candidate)));
89
+ }
90
+
91
+ /**
92
+ * Tâches en attente qui ne deviendront JAMAIS exécutables, parce qu'une de
93
+ * leurs dépendances directes est terminale sans avoir réussi.
94
+ *
95
+ * Sans cette liste, le planificateur ne pouvait que constater « plus aucune
96
+ * tâche prête » et déclarer le plan bloqué — ce qui déclenchait une
97
+ * replanification, donc un run qui ne se termine pas. Les nommer permet de les
98
+ * marquer `skipped` avec leur motif, de finaliser le run sur un résultat
99
+ * partiel, et de dire à l'utilisateur ce qui n'a pas été fait et pourquoi.
100
+ */
101
+ export function blockedByFailedDependency(dag) {
102
+ const tasks = normalizeTasks(dag);
103
+ const statusById = new Map(tasks.map((task) => [taskId(task), statusOf(task)]));
104
+ return tasks
105
+ .filter((task) => isPending(statusOf(task)))
106
+ .map((task) => {
107
+ const culprits = dependsOn(task)
108
+ .map(String)
109
+ .filter((dependency) => isUnsuccessfulTerminal(statusById.get(dependency) ?? ''));
110
+ return culprits.length > 0 ? { task, dependencies: culprits } : null;
111
+ })
112
+ .filter(Boolean);
73
113
  }
74
114
 
75
115
  function agentSane(task, registry) {
@@ -135,5 +175,5 @@ function taskId(task) {
135
175
  }
136
176
 
137
177
  export function isTerminalTask(task) {
138
- return TERMINAL_STATUSES.has(statusOf(task));
178
+ return isTerminal(statusOf(task));
139
179
  }
@@ -2,8 +2,8 @@ import { normalizeActivity, parseJsonText } from '../core/activity.js';
2
2
  import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
3
3
  import { callMcpTool, formatMcpToolResult } from '../core/mcp.js';
4
4
  import { emitRuntimeLog, pollActivitiesOnce } from '../runtime/supervisor.js';
5
+ import { isSuccessful, isTerminal } from './taskStatuses.js';
5
6
 
6
- const TERMINAL_STATUSES = new Set(['done', 'failed', 'cancelled', 'canceled', 'complete', 'completed', 'success', 'succeeded', 'error']);
7
7
 
8
8
  export function createDispatcher({
9
9
  session = null,
@@ -53,7 +53,7 @@ export async function execute(task, assignment, {
53
53
  session.mcp,
54
54
  serverName,
55
55
  executeTool,
56
- executeRequest(task, session),
56
+ executeRequest(task, session, runId),
57
57
  signal,
58
58
  ));
59
59
  if (accepted?.accepted === false || accepted?.ok === false) {
@@ -157,9 +157,11 @@ export async function execute(task, assignment, {
157
157
  }
158
158
  }
159
159
 
160
- function executeRequest(task, session) {
160
+ function executeRequest(task, session, runId) {
161
161
  return {
162
162
  taskId: String(task.id ?? task.step),
163
+ ...(runId ? { runId: String(runId) } : {}),
164
+ ...(task.requiredCapability ? { capability: String(task.requiredCapability) } : {}),
163
165
  idempotencyKey: task.idempotencyKey ?? undefined,
164
166
  operation: task.operation,
165
167
  workspace: workspaceRequest(session),
@@ -203,7 +205,7 @@ function dispatchTaskActivity(session, task, assignment, jobId, statusTool, runI
203
205
  function taskResultFromStatus(task, assignment, jobId, statusPayload, attempt = null) {
204
206
  const result = statusPayload?.result ?? {};
205
207
  const resultStatus = String(result.status ?? statusPayload?.status ?? '').toLowerCase();
206
- const ok = ['succeeded', 'success', 'done', 'complete', 'completed'].includes(resultStatus);
208
+ const ok = isSuccessful(resultStatus);
207
209
  return {
208
210
  ok,
209
211
  taskId: String(task.id ?? task.step),
@@ -330,9 +332,6 @@ function taskLogPayload(event, task, assignment, {
330
332
  };
331
333
  }
332
334
 
333
- function isTerminal(status) {
334
- return TERMINAL_STATUSES.has(String(status ?? '').toLowerCase());
335
- }
336
335
 
337
336
  function delay(ms, signal) {
338
337
  return new Promise((resolve, reject) => {
@@ -33,6 +33,39 @@ test('dispatcher returns a retryable logical failure when agent_execute reports
33
33
  assert.equal(result.error.retryable, true);
34
34
  });
35
35
 
36
+ test('dispatcher forwards the orchestration run and required capability to agent_execute', async () => {
37
+ let executeArgs;
38
+ const session = {
39
+ workspace: 'test',
40
+ mcp: {
41
+ production: {
42
+ tools: [{ name: 'agent_execute' }, { name: 'agent_status' }, { name: 'agent_cancel' }],
43
+ },
44
+ },
45
+ activities: {},
46
+ };
47
+ const dispatcher = createDispatcher({
48
+ session,
49
+ pollIntervalMs: 1,
50
+ callTool: async (_mcp, _server, tool, args) => {
51
+ if (tool === 'agent_execute') {
52
+ executeArgs = args;
53
+ return { accepted: true, jobId: 'job-build', status: 'queued' };
54
+ }
55
+ return { jobId: 'job-build', status: 'succeeded', terminal: true, result: { status: 'succeeded' } };
56
+ },
57
+ });
58
+
59
+ await dispatcher.execute(
60
+ { id: 'build-a', requiredCapability: 'document.build', operation: 'build', arguments: {} },
61
+ { serverName: 'production', agentInstanceId: 'production-main' },
62
+ { runId: 'run-donna-1', attempt: { attemptId: 'build-a:attempt-1', locks: [], release() {} } },
63
+ );
64
+
65
+ assert.equal(executeArgs.runId, 'run-donna-1');
66
+ assert.equal(executeArgs.capability, 'document.build');
67
+ });
68
+
36
69
  test('dispatcher completes when an executor-only agent reports succeeded', async () => {
37
70
  const session = {
38
71
  workspace: 'test',
@@ -2,8 +2,8 @@ import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
2
2
  import { readyPlanTasks } from '../core/planPatch.js';
3
3
  import { applyApprovalCoverage } from './approvalPolicy.js';
4
4
  import { isValidatedFragment, validateFragment } from './planValidator.js';
5
+ import { isTerminal } from './taskStatuses.js';
5
6
 
6
- const TERMINAL_STATUSES = new Set(['done', 'failed', 'cancelled', 'canceled', 'error', 'complete', 'completed', 'success']);
7
7
 
8
8
  export function integrate(runId, fragment, {
9
9
  registry,
@@ -218,11 +218,11 @@ function mergePlan(currentPlan, newTasks, { insertBeforeTasks, insertAfterTasks
218
218
 
219
219
  function firstTerminalMutation(current, beforeIds, afterIds) {
220
220
  for (const id of beforeIds) {
221
- if (isTerminal(current.get(id))) return id;
221
+ if (taskIsTerminal(current.get(id))) return id;
222
222
  }
223
223
  for (const id of afterIds) {
224
224
  const dependents = [...current.values()].filter((task) => (task.dependsOn ?? []).includes(id));
225
- const terminal = dependents.find(isTerminal);
225
+ const terminal = dependents.find(taskIsTerminal);
226
226
  if (terminal) return terminal.id;
227
227
  }
228
228
  return null;
@@ -251,8 +251,8 @@ function currentRevision(session) {
251
251
  return Number.isInteger(session?.planRevision) && session.planRevision >= 0 ? session.planRevision : 0;
252
252
  }
253
253
 
254
- function isTerminal(task) {
255
- return TERMINAL_STATUSES.has(String(task?.status ?? '').toLowerCase());
254
+ function taskIsTerminal(task) {
255
+ return isTerminal(task?.status);
256
256
  }
257
257
 
258
258
  function stringifyList(value) {
@@ -5,6 +5,7 @@ import { callMcpTool, formatMcpToolResult } from '../core/mcp.js';
5
5
  import { resolve as resolveCapability } from './capabilityResolver.js';
6
6
  import { integrate } from './planIntegrator.js';
7
7
  import { validateFragment } from './planValidator.js';
8
+ import { isSuccessful } from './taskStatuses.js';
8
9
 
9
10
  export function createResultAggregator({
10
11
  session = null,
@@ -188,7 +189,7 @@ function rejectExpansion({ session, runId, taskId, store, errors }) {
188
189
 
189
190
  function resultOk(result) {
190
191
  const status = String(result?.status ?? result?.result?.status ?? '').toLowerCase();
191
- return result?.ok === true || ['succeeded', 'success', 'done', 'complete', 'completed'].includes(status);
192
+ return result?.ok === true || isSuccessful(status);
192
193
  }
193
194
 
194
195
  function cancelled(result) {
@@ -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, tasksAwaitingApproval } from './dependencyResolver.js';
7
+ import { blockedByFailedDependency, readyTasks, tasksAwaitingApproval } from './dependencyResolver.js';
8
8
  import { createLockManager } from './lockManager.js';
9
9
  import {
10
10
  describePlanConcurrency,
@@ -244,3 +244,64 @@ function task(id, overrides = {}) {
244
244
  ...overrides,
245
245
  };
246
246
  }
247
+
248
+ /*
249
+ Cas observé le 2026-08-04 (workspace juno) : une ingestion de dix fichiers,
250
+ neuf réussis, le dixième en échec sur du JSON malformé. La barrière de groupe
251
+ exigeait que TOUS les membres soient `done` : elle ne s'est jamais ouverte, le
252
+ planificateur n'a plus trouvé de tâche prête, et le run est resté `running`
253
+ indéfiniment. Un incident sur un document devenait une panne totale.
254
+ */
255
+ test('une barrière de groupe s’ouvre sur un groupe terminal, pas parfait', () => {
256
+ const plan = [
257
+ { id: 'f1', groupId: 'ingest', status: 'done', requiredCapability: 'knowledge.ingest' },
258
+ { id: 'f2', groupId: 'ingest', status: 'failed', requiredCapability: 'knowledge.ingest' },
259
+ { id: 'index', dependsOnGroup: 'ingest', status: 'pending', requiredCapability: 'knowledge.index' },
260
+ ];
261
+
262
+ const ready = readyTasks(plan).map((task) => task.id);
263
+
264
+ // La suite du plan peut se faire sur ce qui a réussi.
265
+ assert.deepEqual(ready, ['index']);
266
+ });
267
+
268
+ test('une barrière reste fermée tant que le groupe n’est pas terminal', () => {
269
+ const plan = [
270
+ { id: 'f1', groupId: 'ingest', status: 'done', requiredCapability: 'knowledge.ingest' },
271
+ { id: 'f2', groupId: 'ingest', status: 'running', requiredCapability: 'knowledge.ingest' },
272
+ { id: 'index', dependsOnGroup: 'ingest', status: 'pending', requiredCapability: 'knowledge.index' },
273
+ ];
274
+
275
+ assert.deepEqual(readyTasks(plan).map((task) => task.id), []);
276
+ });
277
+
278
+ test('une dépendance en échec nomme les tâches à ignorer plutôt que de bloquer', () => {
279
+ const plan = [
280
+ { id: 'convert', status: 'failed', requiredCapability: 'documents.convert' },
281
+ { id: 'ingest', dependsOn: ['convert'], status: 'pending', requiredCapability: 'knowledge.ingest' },
282
+ { id: 'other', status: 'pending', requiredCapability: 'knowledge.ingest' },
283
+ ];
284
+
285
+ const blocked = blockedByFailedDependency(plan);
286
+
287
+ // Seule la tâche qui en dépend est concernée : la branche indépendante
288
+ // continue, c'est tout l'objet du correctif.
289
+ assert.equal(blocked.length, 1);
290
+ assert.equal(blocked[0].task.id, 'ingest');
291
+ assert.deepEqual(blocked[0].dependencies, ['convert']);
292
+ assert.deepEqual(readyTasks(plan).map((task) => task.id), ['other']);
293
+ });
294
+
295
+ test('une tâche ignorée ne rebloque pas ses propres descendants', () => {
296
+ const plan = [
297
+ { id: 'convert', status: 'failed', requiredCapability: 'documents.convert' },
298
+ { id: 'ingest', dependsOn: ['convert'], status: 'skipped', requiredCapability: 'knowledge.ingest' },
299
+ { id: 'publish', dependsOn: ['ingest'], status: 'pending', requiredCapability: 'knowledge.publish' },
300
+ ];
301
+
302
+ // `skipped` est terminal et non réussi : le descendant est à son tour
303
+ // signalé, ce qui fait descendre la propagation jusqu'au bout du plan au
304
+ // lieu de laisser un résidu en attente éternelle.
305
+ const blocked = blockedByFailedDependency(plan);
306
+ assert.deepEqual(blocked.map((entry) => entry.task.id), ['publish']);
307
+ });
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Vocabulaire unique des statuts de tâche.
3
+ *
4
+ * Quatorze modules portaient chacun sa propre liste : `['done', 'failed',
5
+ * 'cancelled']` ici, un `Set` avec `success` et `succeeded` là, un troisième
6
+ * qui ajoutait `error` mais oubliait `canceled`. Aucune n'était fausse
7
+ * isolément ; ensemble elles ne décrivaient pas le même monde. Un statut
8
+ * `skipped` introduit dans l'ordonnanceur était terminal pour lui, inconnu
9
+ * pour la projection — qui le lisait comme un succès — et non terminal pour
10
+ * les panneaux, où la tâche tournait indéfiniment.
11
+ *
12
+ * Ce module est donc la seule définition. Les alias existent parce que les
13
+ * agents externes en produisent : `error` pour `failed`, `succeeded` pour
14
+ * `done`, `canceled` pour `cancelled`. Les normaliser à l'entrée évite d'avoir
15
+ * à les reconnaître à chaque comparaison.
16
+ */
17
+
18
+ /** Succès : la tâche a produit ce qu'on attendait d'elle. */
19
+ export const SUCCESS_STATUSES = Object.freeze(['done', 'complete', 'completed', 'success', 'succeeded']);
20
+ /** Échec : la tâche a été tentée et n'a pas abouti. */
21
+ export const FAILURE_STATUSES = Object.freeze(['failed', 'error', 'stalled']);
22
+ /** Annulation : arrêtée par une décision, pas par un défaut. */
23
+ export const CANCELLED_STATUSES = Object.freeze(['cancelled', 'canceled']);
24
+ /** Abandon : jamais tentée, parce qu'elle ne pouvait plus l'être. */
25
+ export const SKIPPED_STATUSES = Object.freeze(['skipped']);
26
+ /** En attente : pas encore exécutable, mais susceptible de le devenir. */
27
+ export const PENDING_STATUSES_LIST = Object.freeze(['pending', 'pending_approval', 'waiting_approval']);
28
+ /** En cours : un agent y travaille en ce moment. */
29
+ export const ACTIVE_STATUSES = Object.freeze(['running', 'in_progress', 'started', 'starting']);
30
+
31
+ const ALIASES = new Map([
32
+ ...SUCCESS_STATUSES.map((status) => [status, 'done']),
33
+ ...FAILURE_STATUSES.map((status) => [status, 'failed']),
34
+ ...CANCELLED_STATUSES.map((status) => [status, 'cancelled']),
35
+ ...SKIPPED_STATUSES.map((status) => [status, 'skipped']),
36
+ ...ACTIVE_STATUSES.map((status) => [status, 'running']),
37
+ // Les statuts d'attente restent distincts : `pending_approval` et
38
+ // `waiting_approval` ne demandent pas la même chose que `pending`, et les
39
+ // confondre ferait disparaître les demandes d'approbation.
40
+ ...PENDING_STATUSES_LIST.map((status) => [status, status]),
41
+ ]);
42
+
43
+ /**
44
+ * Statut canonique, ou `null` si le vocabulaire ne le connaît pas.
45
+ *
46
+ * Le `null` est un résultat, pas un accident : c'est lui qui permet aux
47
+ * appelants de traiter l'inconnu comme inconnu plutôt que de le ranger
48
+ * silencieusement du côté qui les arrange.
49
+ */
50
+ export function normalizeTaskStatus(status) {
51
+ const value = String(status ?? '').trim().toLowerCase();
52
+ if (!value) return null;
53
+ return ALIASES.get(value) ?? null;
54
+ }
55
+
56
+ export function isSuccessful(status) {
57
+ return normalizeTaskStatus(status) === 'done';
58
+ }
59
+
60
+ export function isFailed(status) {
61
+ return normalizeTaskStatus(status) === 'failed';
62
+ }
63
+
64
+ export function isCancelled(status) {
65
+ return normalizeTaskStatus(status) === 'cancelled';
66
+ }
67
+
68
+ export function isSkipped(status) {
69
+ return normalizeTaskStatus(status) === 'skipped';
70
+ }
71
+
72
+ export function isPending(status) {
73
+ const normalized = normalizeTaskStatus(status);
74
+ return normalized != null && PENDING_STATUSES_LIST.includes(normalized);
75
+ }
76
+
77
+ export function isActive(status) {
78
+ return normalizeTaskStatus(status) === 'running';
79
+ }
80
+
81
+ /** Terminal : plus rien n'arrivera à cette tâche dans ce run. */
82
+ export function isTerminal(status) {
83
+ const normalized = normalizeTaskStatus(status);
84
+ return normalized === 'done' || normalized === 'failed' || normalized === 'cancelled' || normalized === 'skipped';
85
+ }
86
+
87
+ /**
88
+ * Terminal sans avoir réussi. Regroupe échec, annulation et abandon — la
89
+ * distinction compte pour le rapport à l'utilisateur, pas pour décider si la
90
+ * suite du plan peut s'appuyer dessus.
91
+ */
92
+ export function isUnsuccessfulTerminal(status) {
93
+ return isTerminal(status) && !isSuccessful(status);
94
+ }
95
+
96
+ /** Vrai pour un statut qu'aucun ensemble ne reconnaît. */
97
+ export function isUnknownStatus(status) {
98
+ return String(status ?? '').trim() !== '' && normalizeTaskStatus(status) == null;
99
+ }