@dotdrelle/wiki-manager 0.15.40 → 0.15.42

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 (51) 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 +64 -38
  6. package/src/commands/slash.js +47 -9
  7. package/src/core/activity.js +3 -2
  8. package/src/core/agentEvents.js +68 -11
  9. package/src/core/agentEvents.test.js +76 -0
  10. package/src/core/agentLoop.js +32 -6
  11. package/src/core/buildInfo.json +2 -2
  12. package/src/core/compose.js +32 -0
  13. package/src/core/dockerCompose.test.js +32 -0
  14. package/src/core/jobQueue.js +9 -0
  15. package/src/core/mcp.js +1 -1
  16. package/src/core/otherWorkspacesRunning.test.js +51 -0
  17. package/src/core/plan.js +3 -2
  18. package/src/core/planPatch.js +2 -1
  19. package/src/core/profileServiceStatus.test.js +26 -2
  20. package/src/core/toolLoop.js +32 -7
  21. package/src/core/toolLoop.test.js +95 -0
  22. package/src/core/wikiSetup.js +50 -7
  23. package/src/core/wikiWorkspace.test.js +30 -0
  24. package/src/core/wikirc.test.js +121 -2
  25. package/src/core/workflow.js +11 -2
  26. package/src/core/workspaceInherit.js +149 -0
  27. package/src/core/workspaceInherit.test.js +181 -0
  28. package/src/graph/graphVisibilityPolicy.js +2 -2
  29. package/src/orchestrator/agentRegistry.js +50 -0
  30. package/src/orchestrator/agentRegistry.test.js +76 -1
  31. package/src/orchestrator/approvalPolicy.js +2 -2
  32. package/src/orchestrator/dependencyResolver.js +52 -12
  33. package/src/orchestrator/dispatcher.js +2 -5
  34. package/src/orchestrator/planIntegrator.js +5 -5
  35. package/src/orchestrator/resultAggregator.js +2 -1
  36. package/src/orchestrator/scheduler.test.js +62 -1
  37. package/src/orchestrator/taskStatuses.js +99 -0
  38. package/src/orchestrator/taskStatuses.test.js +112 -0
  39. package/src/runtime/delegation.js +158 -0
  40. package/src/runtime/delegation.test.js +281 -0
  41. package/src/runtime/recoveryManager.js +2 -5
  42. package/src/runtime/recoveryManager.test.js +5 -1
  43. package/src/runtime/runner.js +129 -15
  44. package/src/runtime/runner.test.js +153 -6
  45. package/src/runtime/server.test.js +28 -0
  46. package/src/runtime/store.js +16 -1
  47. package/src/runtime/store.test.js +50 -0
  48. package/src/shell/repl.js +21 -11
  49. package/src/shell/setupWizardModality.test.js +55 -0
  50. package/src/shell/tui.tsx +29 -9
  51. package/wiki-workspace +10 -4
@@ -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
+ });
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Intégration d'une délégation préparée dans un run.
3
+ *
4
+ * Ce code n'existait qu'en un point : le handler `/run`, qui recevait une
5
+ * délégation préparée par `/delegate` et la posait comme plan d'un run NEUF.
6
+ * D'où le blocage : un run conversationnel déjà actif appelait `/delegate`
7
+ * pour déléguer sa propre décision, et l'endpoint refusait — à juste titre de
8
+ * son point de vue — parce qu'un run tournait déjà. Le run refusait sa propre
9
+ * délégation.
10
+ *
11
+ * Déléguer n'est pas démarrer un second run : c'est faire passer le run en
12
+ * cours de la décision à l'exécution. La boucle sait déjà le faire — elle
13
+ * repasse au planificateur parallèle dès qu'un plan validé apparaît
14
+ * (`parallelHandoff`) — il ne lui manquait qu'un moyen d'intégrer le fragment
15
+ * sans sortir par le réseau. C'est ce que fait cette fonction, appelée aussi
16
+ * bien par `/run` (nouveau run) que depuis l'intérieur d'un run (délégation
17
+ * interne), avec le même résultat et la même identité de run.
18
+ */
19
+ import { integrate } from '../orchestrator/planIntegrator.js';
20
+ import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
21
+ import { emitRuntimeLog } from './supervisor.js';
22
+
23
+ /**
24
+ * Une délégation n'est auto-approuvée que sur opt-in explicite : par défaut le
25
+ * run attend une décision humaine, et la fenêtre `pending_approval` doit être
26
+ * visible assez longtemps pour qu'une UI la rende.
27
+ */
28
+ export function resolvePreparedDelegationApproval({
29
+ autoApprove = false,
30
+ approvalManager = null,
31
+ runId,
32
+ } = {}) {
33
+ if (autoApprove !== true || typeof approvalManager?.approve !== 'function') {
34
+ return { approved: false, awaitingApproval: true };
35
+ }
36
+ const result = approvalManager.approve({ scope: 'run', runId });
37
+ return { approved: true, awaitingApproval: false, result };
38
+ }
39
+
40
+ /**
41
+ * Le run porte-t-il déjà un plan validé par un agent ?
42
+ *
43
+ * Une tâche structurée se reconnaît à son couple capacité/opération : c'est ce
44
+ * qui la distingue d'une étape de plan conversationnelle, qui n'est qu'une
45
+ * phrase. Le critère est celui de `shouldUseParallelScheduler`, à dessein —
46
+ * ce qui déclenche la bascule et ce qui interdit une seconde délégation
47
+ * doivent désigner le même objet.
48
+ */
49
+ export function hasStructuredPlan(session) {
50
+ return (session?.headlessPlan ?? []).some((task) => task?.requiredCapability && task?.operation);
51
+ }
52
+
53
+ export function integratePreparedDelegation({
54
+ session,
55
+ store = null,
56
+ runId,
57
+ prepared,
58
+ registry,
59
+ approvalManager = null,
60
+ autoApprove = false,
61
+ }) {
62
+ if (!prepared?.fragment) throw new Error('Delegation carries no validated fragment.');
63
+ const integrated = integrate(runId, prepared.fragment, {
64
+ registry,
65
+ session,
66
+ store,
67
+ workspace: session.workspace ?? null,
68
+ enforceApprovalCoverage: true,
69
+ });
70
+ if (!integrated.ok) {
71
+ throw new Error(`Delegated plan integration failed: ${(integrated.errors ?? [])
72
+ .map((error) => error.message ?? error.code ?? String(error))
73
+ .join('; ')}`);
74
+ }
75
+ emitRuntimeLog(
76
+ session,
77
+ `delegation: ${prepared.fragment.tasks.length} validated task(s) integrated from ${prepared.provider?.serverName ?? 'agent'}.agent_plan (${prepared.capability}/${prepared.operation})`,
78
+ );
79
+ /*
80
+ Marqueur de bascule.
81
+
82
+ La boucle conversationnelle ne pouvait pas deviner qu'un plan structuré
83
+ venait d'apparaître : elle ne regardait que les tâches PRÊTES, et un plan
84
+ intégralement en attente d'approbation n'en compte aucune. Elle concluait
85
+ donc « plus rien à faire », l'évaluateur jugeait le plan incomplet, le
86
+ replanificateur relançait une délégation — et cinq tâches devenaient dix,
87
+ puis quinze. Le drapeau dit ce que ni le nombre de tâches prêtes ni le
88
+ statut ne pouvaient dire : une décision vient d'être prise, la suite n'est
89
+ plus conversationnelle.
90
+ */
91
+ session._structuredPlanIntegrated = true;
92
+ const approval = resolvePreparedDelegationApproval({ autoApprove, approvalManager, runId });
93
+ emitRuntimeLog(
94
+ session,
95
+ approval.approved
96
+ ? `approval: run ${runId} auto-approved (autoApprove opt-in)`
97
+ : `approval: run ${runId} awaiting explicit approval before mutations (/approve or « valide tout »)`,
98
+ );
99
+ return { integrated, approval };
100
+ }
101
+
102
+ /**
103
+ * Délégation depuis l'INTÉRIEUR du run courant.
104
+ *
105
+ * Rend un résumé destiné au modèle qui a appelé l'outil. Le `runId` rendu est
106
+ * celui du run en cours, jamais un nouveau : c'est la garantie qu'on n'a pas
107
+ * démarré un second run par la bande, et c'est ce que vérifient les tests.
108
+ */
109
+ export async function delegateWithinRun(session, objective, {
110
+ prepare,
111
+ registry,
112
+ store = null,
113
+ approvalManager = null,
114
+ autoApprove = false,
115
+ }) {
116
+ const runId = session?._currentRunIdentity?.runId ?? null;
117
+ if (!runId) throw new Error('No active run identity: in-run delegation requires a running run.');
118
+ /*
119
+ Un run n'a qu'un plan.
120
+
121
+ Garde-fou défensif : si la boucle rappelle l'outil alors qu'un plan
122
+ structuré est déjà en place, intégrer un second fragment dupliquerait le
123
+ travail au lieu de le remplacer — c'est très exactement ce qu'on a observé,
124
+ cinq tâches devenues trente-cinq. Le refus est explicite plutôt que
125
+ silencieux : le modèle doit lire qu'il redemande une chose déjà faite.
126
+ */
127
+ if (hasStructuredPlan(session)) {
128
+ throw new Error('This run already carries a validated plan: it is executing, not deciding.');
129
+ }
130
+ const prepared = await prepare({ session, objective });
131
+ const { approval } = integratePreparedDelegation({
132
+ session,
133
+ store,
134
+ runId,
135
+ prepared,
136
+ registry,
137
+ approvalManager,
138
+ autoApprove,
139
+ });
140
+ // Le plan validé est en place : la boucle du run le verra au tour suivant et
141
+ // basculera d'elle-même sur le planificateur parallèle (parallelHandoff).
142
+ dispatchAgentEvent(session, createAgentEvent('runtime_log', {
143
+ origin: 'runtime',
144
+ runId,
145
+ payload: { message: `delegation: run ${runId} switched from decision to execution` },
146
+ }));
147
+ return {
148
+ delegated: true,
149
+ runId,
150
+ awaitingApproval: approval.awaitingApproval === true,
151
+ summary: prepared.summary ?? {
152
+ agent: prepared.provider?.serverName ?? null,
153
+ capability: prepared.capability ?? null,
154
+ operation: prepared.operation ?? null,
155
+ tasks: prepared.fragment.tasks.length,
156
+ },
157
+ };
158
+ }