@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
@@ -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 {
@@ -8,7 +8,8 @@ import { createAttemptManager } from '../orchestrator/attemptManager.js';
8
8
  import { createBudgetManager, BudgetExceededError } from '../orchestrator/budgetManager.js';
9
9
  import { createDispatcher } from '../orchestrator/dispatcher.js';
10
10
  import { approvalRequestForTask } from '../orchestrator/approvalPolicy.js';
11
- import { PENDING_STATUSES, tasksAwaitingApproval } from '../orchestrator/dependencyResolver.js';
11
+ import { blockedByFailedDependency, tasksAwaitingApproval } from '../orchestrator/dependencyResolver.js';
12
+ import { isFailed, isPending, isSkipped, isSuccessful, isTerminal, isUnknownStatus } from '../orchestrator/taskStatuses.js';
12
13
  import { assertValidatedFragment } from '../orchestrator/planValidator.js';
13
14
  import { createResultAggregator } from '../orchestrator/resultAggregator.js';
14
15
  import { describePlanConcurrency, drainActive, startReadyTasks } from '../orchestrator/scheduler.js';
@@ -298,7 +299,7 @@ async function announceRunOutcome(session, { runId, ok, signal = null } = {}) {
298
299
  let firstError = null;
299
300
  for (const step of plan) {
300
301
  const status = String(step?.status ?? '').toLowerCase();
301
- if (['failed', 'error', 'stalled'].includes(status)) {
302
+ if (isFailed(status)) {
302
303
  failed += 1;
303
304
  firstError ??= String(
304
305
  step?.error?.message ?? step?.error?.code ?? step?.error
@@ -306,7 +307,7 @@ async function announceRunOutcome(session, { runId, ok, signal = null } = {}) {
306
307
  ).trim() || null;
307
308
  } else if (['cancelled', 'canceled'].includes(status)) {
308
309
  cancelled += 1;
309
- } else if (['done', 'complete', 'completed', 'success', 'succeeded'].includes(status)) {
310
+ } else if (isSuccessful(status)) {
310
311
  completed += 1;
311
312
  }
312
313
  }
@@ -594,8 +595,25 @@ export async function runRuntimeParallelPlan(agent, session, input, {
594
595
  }));
595
596
  return { ok: false, stalled: true, reason: 'awaiting_approval', completed: sessionActivities(session), failures };
596
597
  }
598
+ /*
599
+ Une dépendance en échec n'est pas un blocage : c'est une réponse.
600
+
601
+ On arrivait ici avec des tâches en attente dont une dépendance avait
602
+ échoué, et on déclarait le plan bloqué — ce qui déclenchait une
603
+ replanification et laissait le run vivant indéfiniment. Ces tâches ne
604
+ deviendront jamais exécutables : les marquer `skipped` avec le nom de
605
+ la dépendance fautive fait avancer la boucle, laisse les branches
606
+ indépendantes finir, et termine le run sur un résultat partiel
607
+ explicite au lieu d'une panne muette.
608
+ */
609
+ const skippedCount = skipImpossibleTasks(session, runId);
610
+ if (skippedCount > 0) {
611
+ // La boucle reprend : d'autres tâches peuvent être devenues prêtes,
612
+ // notamment derrière une barrière de groupe désormais terminale.
613
+ continue;
614
+ }
597
615
  // Any genuine approval-only block returned above. Remaining tasks are
598
- // unschedulable for another reason (most commonly a failed dependency).
616
+ // unschedulable for another reason.
599
617
  const reason = 'no_ready_plan_task';
600
618
  emitRuntimeLog(session, `scheduler: stalled (${reason})`);
601
619
  return { ok: false, stalled: true, reason, completed: sessionActivities(session), failures };
@@ -653,6 +671,56 @@ export async function runRuntimeParallelPlan(agent, session, input, {
653
671
  }
654
672
  }
655
673
 
674
+ /*
675
+ Propagation d'un échec jusqu'au point fixe.
676
+
677
+ Marquer les seules tâches directement bloquées ne suffisait pas : `A failed`
678
+ rendait `B` impossible, mais `C` — qui dépend de `B` — restait en attente
679
+ d'une tâche désormais `skipped`, donc terminale sans succès. Il fallait un
680
+ second passage pour l'atteindre, un troisième pour la suivante, et la boucle
681
+ du planificateur ne les aurait découvertes qu'au prix d'un aller-retour par
682
+ tour. On itère donc ici jusqu'à ce que plus rien ne change.
683
+
684
+ Le garde-fou n'est pas décoratif : si une passe ne produit aucune transition
685
+ effective — une tâche déjà `skipped` que l'on retrouverait bloquée, par
686
+ exemple —, on s'arrête. Sans lui, une incohérence de statut se paierait en
687
+ boucle infinie, c'est-à-dire en run figé : exactement ce qu'on répare.
688
+ */
689
+ export function skipImpossibleTasks(session, runId, { maxPasses = 50 } = {}) {
690
+ let total = 0;
691
+ for (let pass = 0; pass < maxPasses; pass += 1) {
692
+ // Le plan est relu à chaque passe, jamais capturé : `dispatchAgentEvent`
693
+ // reprojette la session et REMPLACE `headlessPlan` par un nouveau tableau.
694
+ // Une référence prise avant la première dépêche deviendrait orpheline, et
695
+ // les passes suivantes muteraient un plan que plus personne ne lit.
696
+ const plan = session.headlessPlan ?? [];
697
+ const blocked = blockedByFailedDependency(plan);
698
+ if (blocked.length === 0) break;
699
+ let changed = 0;
700
+ for (const { task, dependencies } of blocked) {
701
+ const skippedId = String(task.id ?? task.taskId ?? task.step ?? '');
702
+ const step = plan.find((candidate) => String(candidate?.id ?? candidate?.step ?? '') === skippedId);
703
+ // Déjà terminale : la repasser en `skipped` ne changerait rien et
704
+ // ferait tourner la boucle pour rien.
705
+ if (!step || isSkipped(step.status) || isTerminal(step.status)) continue;
706
+ const because = dependencies.join(', ');
707
+ step.status = 'skipped';
708
+ step.error = { code: 'dependency_failed', message: `Dependency failed: ${because}` };
709
+ changed += 1;
710
+ dispatchAgentEvent(session, createAgentEvent('plan_step_updated', {
711
+ origin: 'runtime',
712
+ runId,
713
+ taskId: skippedId,
714
+ payload: { taskId: skippedId, status: 'skipped', reason: `dependency_failed:${because}` },
715
+ }));
716
+ emitRuntimeLog(session, `scheduler: skipping ${skippedId} (dependency failed: ${because})`);
717
+ }
718
+ total += changed;
719
+ if (changed === 0) break;
720
+ }
721
+ return total;
722
+ }
723
+
656
724
  export function materializeTaskInputs(task, plan = []) {
657
725
  const dependencies = new Set(Array.isArray(task?.dependsOn) ? task.dependsOn.map(String) : []);
658
726
  const replacements = new Map();
@@ -712,7 +780,7 @@ function abortCancelledActiveTasks(session, active) {
712
780
  }
713
781
 
714
782
  function pendingSchedulerStatus(status) {
715
- return PENDING_STATUSES.has(String(status ?? ''));
783
+ return isPending(status);
716
784
  }
717
785
 
718
786
  // Scope the evaluator/replanner's view of "completed" activities to the
@@ -1031,31 +1099,77 @@ export async function evaluateRuntimeRun(session, input, {
1031
1099
  }
1032
1100
  }
1033
1101
 
1034
- function structuredPlanEvaluation(plan) {
1102
+ export function structuredPlanEvaluation(plan) {
1035
1103
  if (!Array.isArray(plan) || plan.length === 0) return null;
1036
1104
  // Only provider TaskGraph tasks are authoritative. Legacy conversational
1037
1105
  // plans contain prose/tool labels and still use the compatibility evaluator.
1038
1106
  if (!plan.every((step) => step?.requiredCapability && step?.operation)) return null;
1039
- const statuses = plan.map((step) => String(step?.status ?? '').toLowerCase());
1040
- const failed = plan.filter((step) => ['failed', 'error', 'cancelled', 'canceled', 'stalled'].includes(String(step?.status ?? '').toLowerCase()));
1041
- const incomplete = plan.filter((step) => !['done', 'complete', 'completed', 'success', 'succeeded'].includes(String(step?.status ?? '').toLowerCase()));
1042
- if (failed.length > 0) {
1107
+ /*
1108
+ Compteurs séparés, jamais de fraction.
1109
+
1110
+ « 9/10 » suppose un dénominateur qui veut dire quelque chose. Le plan mêle
1111
+ des tâches métier (un fichier ingéré) et des étapes techniques (une
1112
+ barrière, une agrégation) : la même fraction aurait dit tantôt « 9 fichiers
1113
+ sur 10 », tantôt « 9 étapes sur 10 », sans que le lecteur puisse savoir
1114
+ laquelle. Trois compteurs qui s'additionnent se vérifient d'un coup d'œil et
1115
+ ne mentent sur rien.
1116
+
1117
+ Et l'inverse compte autant : le message ne listait que les échecs, si bien
1118
+ qu'un run où l'essentiel du travail avait abouti se lisait comme une panne.
1119
+ */
1120
+ const done = plan.filter((step) => isSuccessful(step?.status));
1121
+ const failed = plan.filter((step) => isFailed(step?.status) || isCancelledStatus(step?.status));
1122
+ const skipped = plan.filter((step) => isSkipped(step?.status));
1123
+ const unfinished = plan.filter((step) => !isTerminal(step?.status));
1124
+ const label = (step) => step.label ?? step.description ?? step.id ?? step.step;
1125
+ /*
1126
+ Les compteurs sont rendus tels quels, en plus de la phrase.
1127
+
1128
+ Une phrase est faite pour être lue, pas analysée : tout consommateur qui
1129
+ voudrait savoir « combien ont réussi » devrait la découper, donc dépendre de
1130
+ sa formulation — et casser à la première reformulation. Les nombres sont la
1131
+ donnée, la phrase n'en est qu'un rendu.
1132
+ */
1133
+ const counts = {
1134
+ total: plan.length,
1135
+ done: done.length,
1136
+ failed: failed.length,
1137
+ skipped: skipped.length,
1138
+ unfinished: unfinished.length,
1139
+ };
1140
+ const summary = [
1141
+ `${done.length} réussie(s)`,
1142
+ failed.length > 0 ? `${failed.length} en échec` : null,
1143
+ skipped.length > 0 ? `${skipped.length} ignorée(s) faute de dépendance` : null,
1144
+ unfinished.length > 0 ? `${unfinished.length} non terminée(s)` : null,
1145
+ ].filter(Boolean).join(', ');
1146
+
1147
+ if (failed.length > 0 || skipped.length > 0) {
1043
1148
  return {
1044
1149
  ok: false,
1045
- reason: `${failed.length} tâche(s) du plan ont échoué : ${failed.map((step) => step.label ?? step.description ?? step.id ?? step.step).join(', ')}.`,
1150
+ counts,
1151
+ reason: [
1152
+ `${summary}.`,
1153
+ failed.length > 0 ? `Échecs : ${failed.map(label).join(', ')}.` : null,
1154
+ skipped.length > 0 ? `Ignorées : ${skipped.map(label).join(', ')}.` : null,
1155
+ ].filter(Boolean).join(' '),
1046
1156
  suggestedAction: null,
1047
1157
  };
1048
1158
  }
1049
- if (incomplete.length > 0) {
1159
+ // Un statut actif ou inconnu ne peut jamais valoir un succès : le plan n'est
1160
+ // pas fini, on le dit, on ne le suppose pas terminé.
1161
+ if (unfinished.length > 0) {
1050
1162
  return {
1051
1163
  ok: false,
1052
- reason: `${incomplete.length} tâche(s) du plan ne sont pas terminées.`,
1164
+ counts,
1165
+ reason: `${summary}. Le plan n'est pas terminé.`,
1053
1166
  suggestedAction: null,
1054
1167
  };
1055
1168
  }
1056
1169
  return {
1057
- ok: statuses.length > 0,
1058
- reason: `${statuses.length} tâche(s) du plan terminées avec succès.`,
1170
+ ok: plan.length > 0,
1171
+ counts,
1172
+ reason: `${plan.length} tâche(s) du plan terminées avec succès.`,
1059
1173
  suggestedAction: null,
1060
1174
  };
1061
1175
  }