@dotdrelle/wiki-manager 0.15.40 → 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 (36) hide show
  1. package/package.json +2 -2
  2. package/src/activity/activityAggregator.js +3 -3
  3. package/src/activity/progressCalculator.js +3 -4
  4. package/src/agent/graph.js +40 -1
  5. package/src/cli/wiki-manager.js +29 -36
  6. package/src/commands/slash.js +11 -1
  7. package/src/core/activity.js +3 -2
  8. package/src/core/agentEvents.js +45 -11
  9. package/src/core/agentEvents.test.js +44 -0
  10. package/src/core/agentLoop.js +32 -6
  11. package/src/core/buildInfo.json +2 -2
  12. package/src/core/jobQueue.js +9 -0
  13. package/src/core/mcp.js +1 -1
  14. package/src/core/plan.js +3 -2
  15. package/src/core/planPatch.js +2 -1
  16. package/src/core/workflow.js +11 -2
  17. package/src/graph/graphVisibilityPolicy.js +2 -2
  18. package/src/orchestrator/agentRegistry.js +50 -0
  19. package/src/orchestrator/agentRegistry.test.js +76 -1
  20. package/src/orchestrator/approvalPolicy.js +2 -2
  21. package/src/orchestrator/dependencyResolver.js +52 -12
  22. package/src/orchestrator/dispatcher.js +2 -5
  23. package/src/orchestrator/planIntegrator.js +5 -5
  24. package/src/orchestrator/resultAggregator.js +2 -1
  25. package/src/orchestrator/scheduler.test.js +62 -1
  26. package/src/orchestrator/taskStatuses.js +99 -0
  27. package/src/orchestrator/taskStatuses.test.js +112 -0
  28. package/src/runtime/delegation.js +158 -0
  29. package/src/runtime/delegation.test.js +281 -0
  30. package/src/runtime/recoveryManager.js +2 -5
  31. package/src/runtime/recoveryManager.test.js +5 -1
  32. package/src/runtime/runner.js +129 -15
  33. package/src/runtime/runner.test.js +153 -6
  34. package/src/runtime/store.js +16 -1
  35. package/src/runtime/store.test.js +50 -0
  36. package/src/shell/repl.js +4 -5
@@ -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,
@@ -205,7 +205,7 @@ function dispatchTaskActivity(session, task, assignment, jobId, statusTool, runI
205
205
  function taskResultFromStatus(task, assignment, jobId, statusPayload, attempt = null) {
206
206
  const result = statusPayload?.result ?? {};
207
207
  const resultStatus = String(result.status ?? statusPayload?.status ?? '').toLowerCase();
208
- const ok = ['succeeded', 'success', 'done', 'complete', 'completed'].includes(resultStatus);
208
+ const ok = isSuccessful(resultStatus);
209
209
  return {
210
210
  ok,
211
211
  taskId: String(task.id ?? task.step),
@@ -332,9 +332,6 @@ function taskLogPayload(event, task, assignment, {
332
332
  };
333
333
  }
334
334
 
335
- function isTerminal(status) {
336
- return TERMINAL_STATUSES.has(String(status ?? '').toLowerCase());
337
- }
338
335
 
339
336
  function delay(ms, signal) {
340
337
  return new Promise((resolve, reject) => {
@@ -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
+ }
@@ -0,0 +1,112 @@
1
+ import assert from 'node:assert/strict';
2
+ import { readdirSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import test from 'node:test';
5
+
6
+ import {
7
+ isActive,
8
+ isCancelled,
9
+ isFailed,
10
+ isPending,
11
+ isSkipped,
12
+ isSuccessful,
13
+ isTerminal,
14
+ isUnknownStatus,
15
+ isUnsuccessfulTerminal,
16
+ normalizeTaskStatus,
17
+ } from './taskStatuses.js';
18
+
19
+ test('les alias des agents externes tombent sur le même statut canonique', () => {
20
+ for (const status of ['done', 'complete', 'completed', 'success', 'succeeded', 'DONE', ' Success ']) {
21
+ assert.equal(normalizeTaskStatus(status), 'done', status);
22
+ }
23
+ for (const status of ['failed', 'error', 'stalled']) {
24
+ assert.equal(normalizeTaskStatus(status), 'failed', status);
25
+ }
26
+ for (const status of ['cancelled', 'canceled']) {
27
+ assert.equal(normalizeTaskStatus(status), 'cancelled', status);
28
+ }
29
+ for (const status of ['running', 'in_progress', 'started', 'starting']) {
30
+ assert.equal(normalizeTaskStatus(status), 'running', status);
31
+ }
32
+ });
33
+
34
+ test('les statuts d’attente restent distincts les uns des autres', () => {
35
+ // Les confondre ferait disparaître les demandes d'approbation : une tâche
36
+ // `waiting_approval` réduite à `pending` serait ordonnancée sans accord.
37
+ assert.equal(normalizeTaskStatus('pending'), 'pending');
38
+ assert.equal(normalizeTaskStatus('pending_approval'), 'pending_approval');
39
+ assert.equal(normalizeTaskStatus('waiting_approval'), 'waiting_approval');
40
+ for (const status of ['pending', 'pending_approval', 'waiting_approval']) {
41
+ assert.equal(isPending(status), true, status);
42
+ assert.equal(isTerminal(status), false, status);
43
+ }
44
+ });
45
+
46
+ test('un statut inconnu vaut null, et null n’est ni un succès ni un échec', () => {
47
+ // C'est tout l'objet du module : l'inconnu doit rester inconnu au lieu
48
+ // d'être rangé du côté qui arrange l'appelant.
49
+ assert.equal(normalizeTaskStatus('brouette'), null);
50
+ assert.equal(isUnknownStatus('brouette'), true);
51
+ assert.equal(isSuccessful('brouette'), false);
52
+ assert.equal(isFailed('brouette'), false);
53
+ assert.equal(isTerminal('brouette'), false);
54
+ // Une absence de statut n'est pas un statut inconnu : c'est une absence.
55
+ assert.equal(isUnknownStatus(''), false);
56
+ assert.equal(isUnknownStatus(null), false);
57
+ assert.equal(normalizeTaskStatus(undefined), null);
58
+ });
59
+
60
+ test('skipped est terminal, et terminal sans succès', () => {
61
+ assert.equal(isSkipped('skipped'), true);
62
+ assert.equal(isTerminal('skipped'), true);
63
+ assert.equal(isSuccessful('skipped'), false);
64
+ assert.equal(isUnsuccessfulTerminal('skipped'), true);
65
+ for (const status of ['failed', 'error', 'cancelled', 'canceled']) {
66
+ assert.equal(isUnsuccessfulTerminal(status), true, status);
67
+ }
68
+ assert.equal(isUnsuccessfulTerminal('done'), false);
69
+ assert.equal(isActive('running'), true);
70
+ assert.equal(isCancelled('canceled'), true);
71
+ });
72
+
73
+ /*
74
+ Le vocabulaire ne vaut que s'il est le seul. Ce test échoue dès qu'un module
75
+ réintroduit sa propre liste — c'est exactement par là que la divergence est
76
+ revenue la première fois, une liste à la fois, chacune raisonnable seule.
77
+ */
78
+ test('aucun module ne redéclare sa propre liste de statuts', () => {
79
+ const root = new URL('../..', import.meta.url).pathname;
80
+ const offenders = [];
81
+ const walk = (dir) => {
82
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
83
+ const path = join(dir, entry.name);
84
+ if (entry.isDirectory()) {
85
+ walk(path);
86
+ continue;
87
+ }
88
+ if (!entry.name.endsWith('.js') || entry.name.endsWith('.test.js')) continue;
89
+ if (path.endsWith(join('orchestrator', 'taskStatuses.js'))) continue;
90
+ const source = readFileSync(path, 'utf8');
91
+ /*
92
+ Une dérogation se déclare DANS le fichier concerné, pas dans une liste
93
+ de chemins tenue ici. Un run peut être `interrupted`, un job de la file
94
+ peut expirer : ces vocabulaires sont légitimement distincts. Mais une
95
+ liste d'exceptions centralisée vieillit mal — elle survit au fichier
96
+ qu'elle excusait, et redevient la liste locale qu'on voulait supprimer.
97
+ Le marqueur vit à côté du code, se déplace avec lui et se lit avec lui.
98
+ */
99
+ if (source.includes('@statuses-vocabulary')) continue;
100
+ // Une liste de statuts se reconnaît à la cohabitation de deux marqueurs
101
+ // qui n'ont aucune raison de se croiser ailleurs.
102
+ const suspicious = /['"](?:done|failed)['"][^\n]{0,120}['"](?:cancelled|canceled|succeeded|completed|error)['"]/;
103
+ for (const line of source.split('\n')) {
104
+ if (line.trimStart().startsWith('*') || line.trimStart().startsWith('//')) continue;
105
+ if (suspicious.test(line)) offenders.push(`${path.slice(root.length)}: ${line.trim().slice(0, 100)}`);
106
+ }
107
+ }
108
+ };
109
+ walk(join(root, 'src'));
110
+
111
+ assert.deepEqual(offenders, []);
112
+ });