@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
@@ -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
+ }
@@ -0,0 +1,281 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+
4
+ import { delegateWithinRun, integratePreparedDelegation } from './delegation.js';
5
+ import { isTerminal } from '../orchestrator/taskStatuses.js';
6
+
7
+ function task(id) {
8
+ return {
9
+ id,
10
+ label: `Task ${id}`,
11
+ requiredCapability: 'knowledge.update',
12
+ operation: 'ingest',
13
+ arguments: { inputs: [`raw/untracked/${id}.md`] },
14
+ groupId: 'ingest',
15
+ dependsOn: [],
16
+ parallelizable: true,
17
+ inputRefs: [{ type: 'file', ref: `raw/untracked/${id}.md` }],
18
+ expectedOutputRefs: [{ type: 'file', ref: `.wiki/ingest-plans/${id}.json` }],
19
+ locks: ['workspace-write'],
20
+ requiresApproval: true,
21
+ idempotencyKey: `idem-${id}`,
22
+ progressWeight: 1,
23
+ };
24
+ }
25
+
26
+ function fragment(taskId = 'build-1') {
27
+ return {
28
+ contractVersion: '1',
29
+ agentInstanceId: 'production-main',
30
+ capability: 'knowledge.update',
31
+ summary: { label: 'Update knowledge', initialSynthesis: ['Build.'], estimatedTasks: 1 },
32
+ groups: [{ id: 'ingest', label: 'Ingest sources', recommendedConcurrency: 2, progressWeight: 1 }],
33
+ tasks: [task(taskId)],
34
+ expectedOutputs: [{ type: 'directory', ref: 'wiki' }],
35
+ };
36
+ }
37
+
38
+ function preparedDelegation(taskId) {
39
+ return {
40
+ capability: 'knowledge.update',
41
+ operation: 'ingest',
42
+ provider: { serverName: 'production' },
43
+ fragment: fragment(taskId),
44
+ summary: { agent: 'production', capability: 'knowledge.update', operation: 'ingest', tasks: 1 },
45
+ };
46
+ }
47
+
48
+ function runningSession(runId = 'run-conversational') {
49
+ return {
50
+ workspace: 'juno',
51
+ activities: {},
52
+ agentEvents: [],
53
+ headlessPlan: null,
54
+ _currentRunIdentity: { runId, workspace: 'juno' },
55
+ };
56
+ }
57
+
58
+ // Le registre est un double minimal : la question posée ici n'est pas la
59
+ // validation du plan (couverte par planIntegrator.test.js) mais l'identité du
60
+ // run auquel la délégation appartient.
61
+ function registryDouble() {
62
+ return {
63
+ providersFor(capability) {
64
+ if (capability !== 'knowledge.update') return [];
65
+ return [{
66
+ serverName: 'production',
67
+ agentInstanceId: 'production-main',
68
+ health: 'available',
69
+ capability: {
70
+ id: 'knowledge.update',
71
+ version: '1',
72
+ description: 'Knowledge update',
73
+ inputSchema: {
74
+ type: 'object',
75
+ required: ['inputs'],
76
+ additionalProperties: true,
77
+ properties: { inputs: { type: 'array', items: { type: 'string' } } },
78
+ },
79
+ outputSchema: {},
80
+ supportedOperations: ['ingest'],
81
+ mutationClass: 'workspace',
82
+ defaultRequiresApproval: true,
83
+ },
84
+ description: { contractVersion: '1' },
85
+ }];
86
+ },
87
+ isCompatible: (contractVersion) => String(contractVersion) === '1',
88
+ };
89
+ }
90
+
91
+ test('une demande de build délègue dans le run courant, sans en démarrer un second', async () => {
92
+ /*
93
+ Le run conversationnel se marquait actif AVANT que Donna appelle delegate.
94
+ L'appel repartait alors vers POST /delegate, qui refusait par 409 : le run
95
+ refusait sa propre délégation. Déléguer n'est pas démarrer un second run,
96
+ c'est faire passer celui-ci de la décision à l'exécution.
97
+ */
98
+ const session = runningSession('run-fr-build');
99
+ const started = [];
100
+
101
+ const result = await delegateWithinRun(session, 'construis les livrables du workspace juno', {
102
+ prepare: async ({ objective }) => {
103
+ assert.match(objective, /construis les livrables/);
104
+ return preparedDelegation('build-fr');
105
+ },
106
+ registry: registryDouble(),
107
+ startRun: (...args) => started.push(args),
108
+ });
109
+
110
+ assert.equal(result.delegated, true);
111
+ // Identité unique : le runId rendu est celui du run en cours.
112
+ assert.equal(result.runId, 'run-fr-build');
113
+ assert.equal(result.summary.tasks, 1);
114
+ // Aucun second run concurrent n'a été démarré par la bande.
115
+ assert.deepEqual(started, []);
116
+ });
117
+
118
+ test('la délégation interne exige un run actif', async () => {
119
+ // Hors run, il n'y a pas d'exécution à transformer : c'est un vrai démarrage
120
+ // de run, et il doit passer par le chemin normal plutôt que par ici.
121
+ const session = { workspace: 'juno', agentEvents: [] };
122
+
123
+ await assert.rejects(
124
+ () => delegateWithinRun(session, 'construis les livrables', {
125
+ prepare: async () => preparedDelegation(),
126
+ registry: registryDouble(),
127
+ }),
128
+ /in-run delegation requires a running run/,
129
+ );
130
+ });
131
+
132
+ test('un fragment non intégrable est refusé explicitement, sans plan à moitié posé', async () => {
133
+ const session = runningSession();
134
+
135
+ await assert.rejects(
136
+ () => delegateWithinRun(session, 'objectif', {
137
+ prepare: async () => ({ ...preparedDelegation(), fragment: null }),
138
+ registry: registryDouble(),
139
+ }),
140
+ /carries no validated fragment/,
141
+ );
142
+ assert.equal(session.headlessPlan, null);
143
+ });
144
+
145
+ test('l’approbation reste requise par défaut, et n’est levée que sur opt-in', () => {
146
+ const approvals = [];
147
+ const approvalManager = { approve: (request) => { approvals.push(request); return { ok: true }; } };
148
+ const session = runningSession('run-approval');
149
+
150
+ const waiting = integratePreparedDelegation({
151
+ session,
152
+ runId: 'run-approval',
153
+ prepared: preparedDelegation('build-waiting'),
154
+ registry: registryDouble(),
155
+ approvalManager,
156
+ });
157
+ assert.equal(waiting.approval.awaitingApproval, true);
158
+ assert.deepEqual(approvals, []);
159
+
160
+ const auto = integratePreparedDelegation({
161
+ session,
162
+ runId: 'run-approval',
163
+ prepared: preparedDelegation('build-auto'),
164
+ registry: registryDouble(),
165
+ approvalManager,
166
+ autoApprove: true,
167
+ });
168
+ assert.equal(auto.approval.approved, true);
169
+ assert.deepEqual(approvals, [{ scope: 'run', runId: 'run-approval' }]);
170
+ });
171
+
172
+ /*
173
+ Test d'INTÉGRATION, pas de délégation isolée.
174
+
175
+ Les tests unitaires de `delegateWithinRun` passaient tous pendant que le run
176
+ dupliquait son plan à chaud : 5 tâches, puis 10, 15, 20, 35. Ils vérifiaient
177
+ la délégation ; personne ne vérifiait ce que la boucle en faisait ensuite.
178
+
179
+ Le parcours complet est donc rejoué ici : demande française → délégation →
180
+ intégration → bascule → planificateur. Le seul chiffre qui compte est le
181
+ dernier : toujours exactement cinq tâches.
182
+ */
183
+ test('une demande française délègue une fois et bascule, sans jamais dupliquer le plan', async () => {
184
+ const { runRuntimeAgenticWorkflow } = await import('./runner.js');
185
+ const runId = 'run-fr-integration';
186
+ const session = {
187
+ workspace: 'juno',
188
+ activities: {},
189
+ agentEvents: [],
190
+ headlessPlan: null,
191
+ // Posée par executeRun avant l'appel au workflow, comme en production.
192
+ _currentRunIdentity: { runId, workspace: 'juno' },
193
+ llm: { async completeWithTools() { assert.fail('aucune évaluation ne doit avoir lieu ici'); } },
194
+ };
195
+ const fiveTasks = {
196
+ ...fragment('build-a'),
197
+ summary: { label: 'Update knowledge', initialSynthesis: ['Build.'], estimatedTasks: 5 },
198
+ groups: [{ id: 'ingest', label: 'Ingest sources', recommendedConcurrency: 4, progressWeight: 5 }],
199
+ tasks: ['a', 'b', 'c', 'd', 'e'].map((suffix) => ({ ...task(`build-${suffix}`), requiresApproval: false })),
200
+ };
201
+
202
+ let delegations = 0;
203
+ let conversationalTurns = 0;
204
+ const agent = {
205
+ async invoke({ session: turnSession }) {
206
+ conversationalTurns += 1;
207
+ // Le tour conversationnel appelle l'outil de délégation, comme Donna.
208
+ const result = await delegateWithinRun(turnSession, 'construis les livrables du workspace juno', {
209
+ prepare: async () => {
210
+ delegations += 1;
211
+ return { ...preparedDelegation(), fragment: fiveTasks };
212
+ },
213
+ registry: registryDouble(),
214
+ });
215
+ assert.equal(result.runId, runId);
216
+ return { response: 'Action lancée.' };
217
+ },
218
+ };
219
+
220
+ const jobs = new Map();
221
+ const callTool = async (_mcp, _serverName, toolName, args) => {
222
+ const text = (payload) => ({ content: [{ type: 'text', text: JSON.stringify(payload) }] });
223
+ if (toolName === 'agent_execute') {
224
+ const jobId = `job-${args.taskId}`;
225
+ jobs.set(jobId, args.taskId);
226
+ return text({ accepted: true, jobId, status: 'queued' });
227
+ }
228
+ if (toolName === 'agent_status') {
229
+ return text({
230
+ jobId: args.jobId,
231
+ taskId: jobs.get(args.jobId),
232
+ operation: 'ingest',
233
+ status: 'done',
234
+ progress: { percent: 100 },
235
+ result: { status: 'succeeded', outputRefs: [], metrics: { durationMs: 1 } },
236
+ });
237
+ }
238
+ if (toolName === 'agent_cancel') return text({ ok: true });
239
+ throw new Error(`unexpected tool: ${toolName}`);
240
+ };
241
+
242
+ // Borne l'attente d'approbation comme en headless : un test ne doit jamais
243
+ // pouvoir se figer sur une décision humaine qui ne viendra pas.
244
+ session._approvalTimeoutMs = 200;
245
+ await runRuntimeAgenticWorkflow(agent, session, 'construis les livrables du workspace juno', {
246
+ runId,
247
+ timeoutMs: 2000,
248
+ maxTurns: 4,
249
+ maxReplans: 2,
250
+ evaluate: false,
251
+ callTool,
252
+ dispatcherPollIntervalMs: 1,
253
+ });
254
+
255
+ // Une seule délégation, un seul tour conversationnel : la bascule a eu lieu
256
+ // immédiatement après l'intégration.
257
+ assert.equal(delegations, 1);
258
+ assert.equal(conversationalTurns, 1);
259
+ // Et surtout : cinq tâches, pas dix, pas trente-cinq.
260
+ assert.equal(session.headlessPlan.length, 5);
261
+ // Toutes sont passées par le planificateur : plus aucune n'attend. Le double
262
+ // d'agent ne rejoue pas une exécution réelle — ce que ce test garantit est
263
+ // qu'une seule décision a eu lieu et qu'aucune tâche n'a été dupliquée.
264
+ assert.equal(session.headlessPlan.every((step) => isTerminal(step.status)), true);
265
+ assert.deepEqual([...new Set(session.headlessPlan.map((step) => step.id))].length, 5);
266
+ });
267
+
268
+ test('un run qui porte déjà un plan validé refuse une seconde délégation', async () => {
269
+ // Garde-fou défensif : c'est ce qui transforme une bascule ratée en erreur
270
+ // lisible plutôt qu'en plan qui double à chaque tour.
271
+ const session = runningSession('run-guard');
272
+ session.headlessPlan = [task('build-a')];
273
+
274
+ await assert.rejects(
275
+ () => delegateWithinRun(session, 'construis les livrables', {
276
+ prepare: async () => preparedDelegation(),
277
+ registry: registryDouble(),
278
+ }),
279
+ /already carries a validated plan/,
280
+ );
281
+ });
@@ -3,9 +3,9 @@ import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
3
3
  import { formatMcpToolResult, callMcpTool as defaultCallMcpTool } from '../core/mcp.js';
4
4
  import { createCapabilityRegistry } from '../orchestrator/capabilityRegistry.js';
5
5
  import { accept as acceptResult } from '../orchestrator/resultAggregator.js';
6
+ import { isSuccessful, isTerminal } from '../orchestrator/taskStatuses.js';
6
7
 
7
8
  const ACTIVE_TASK_STATUSES = new Set(['running', 'queued', 'starting', 'assigned']);
8
- const TERMINAL_STATUSES = new Set(['done', 'failed', 'cancelled', 'canceled', 'completed', 'complete', 'success', 'succeeded']);
9
9
 
10
10
  export async function recoverActiveRuns({
11
11
  store,
@@ -169,9 +169,6 @@ function latestAssignment(assignments, attemptId) {
169
169
  return [...assignments].sort((a, b) => String(b.assignedAt ?? b.attemptId).localeCompare(String(a.assignedAt ?? a.attemptId)))[0] ?? null;
170
170
  }
171
171
 
172
- function isTerminal(status) {
173
- return TERMINAL_STATUSES.has(String(status ?? '').toLowerCase());
174
- }
175
172
 
176
173
  function capabilityResolvable(session, capability) {
177
174
  const registry = session.capabilityRegistry
@@ -195,7 +192,7 @@ function parseToolPayload(result) {
195
192
  function taskResultFromStatus(task, assignment, jobId, statusPayload, attempt) {
196
193
  const result = statusPayload?.result ?? {};
197
194
  const status = String(result.status ?? statusPayload?.status ?? '').toLowerCase();
198
- const ok = ['succeeded', 'success', 'done', 'complete', 'completed'].includes(status);
195
+ const ok = isSuccessful(status);
199
196
  return {
200
197
  ok,
201
198
  taskId: task.id,
@@ -167,6 +167,11 @@ test('recoveryManager fails unresolvable-capability tasks and interrupts the run
167
167
  // A registry that DOES know capabilities, but not the task's one: the plan
168
168
  // came from a hallucinated capability (the 0.12.1 incident) — re-attaching
169
169
  // it would recreate a forever-waiting queue on every boot.
170
+ store.hydrateSession(session, { workspace: 'docs' });
171
+ // Le registre vivant se pose APRÈS l'hydratation : c'est l'ordre réel
172
+ // (hydrate → invalidate → discover). Le poser avant faisait passer un
173
+ // instantané frais pour une restauration, et masquait le fait que les agents
174
+ // persistés doivent être invalidés.
170
175
  session.agentRegistrySnapshot = [{
171
176
  agentInstanceId: 'production-main',
172
177
  serverName: 'production',
@@ -177,7 +182,6 @@ test('recoveryManager fails unresolvable-capability tasks and interrupts the run
177
182
  capabilities: [{ id: 'knowledge.pipeline', version: '1' }],
178
183
  },
179
184
  }];
180
- store.hydrateSession(session, { workspace: 'docs' });
181
185
  const statusCalls = [];
182
186
 
183
187
  try {