@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
@@ -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
  }
@@ -1,7 +1,10 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
- import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
4
- import { ensurePlanProjection, evaluateRuntimeRun, finishRuntimeRun, materializeTaskInputs, replanRuntimeRun, runRuntimeAgenticWorkflow, runRuntimeParallelPlan, shouldUseParallelScheduler } from './runner.js';
3
+ import { createAgentEvent, dispatchAgentEvent, reduceAgentEvents } from '../core/agentEvents.js';
4
+ import { tasksAwaitingApproval } from '../orchestrator/dependencyResolver.js';
5
+ import { isTerminal } from '../orchestrator/taskStatuses.js';
6
+ import { readyPlanTasks } from '../core/planPatch.js';
7
+ import { skipImpossibleTasks, structuredPlanEvaluation, ensurePlanProjection, evaluateRuntimeRun, finishRuntimeRun, materializeTaskInputs, replanRuntimeRun, runRuntimeAgenticWorkflow, runRuntimeParallelPlan, shouldUseParallelScheduler } from './runner.js';
5
8
 
6
9
  test('ensurePlanProjection re-projects when the chained plan changes shape (step 2)', () => {
7
10
  const session = { agentEvents: [], agentProjection: null };
@@ -821,7 +824,17 @@ test('runRuntimeParallelPlan fails cleanly when scheduler budget is exceeded', a
821
824
  assert.equal(session.headlessPlan[0].status, 'cancelled');
822
825
  });
823
826
 
824
- test('runRuntimeParallelPlan does not wait for approval behind a failed dependency', async () => {
827
+ /*
828
+ Ce test gardait l'ancien comportement : la tâche bloquée derrière un échec
829
+ laissait le run « stalled ». Ne pas attendre d'approbation était déjà acquis,
830
+ mais s'arrêter là déclenchait une replanification et laissait le run vivant.
831
+
832
+ Cas observé le 2026-08-04 (workspace juno) : dix fichiers à ingérer, neuf
833
+ réussis, un en échec sur du JSON malformé — le run n'est jamais retombé.
834
+ Une tâche qui ne deviendra jamais exécutable est donc marquée `skipped` avec
835
+ le nom de la dépendance fautive, et le run se termine sur un résultat partiel.
836
+ */
837
+ test('runRuntimeParallelPlan skips work stuck behind a failed dependency instead of stalling', async () => {
825
838
  const session = {
826
839
  workspace: 'demo-workspace',
827
840
  mcp: { tools: {} },
@@ -844,11 +857,17 @@ test('runRuntimeParallelPlan does not wait for approval behind a failed dependen
844
857
  { runId: 'run-failed-dependency', timeoutMs: 1000, maxTurns: 1 },
845
858
  );
846
859
 
847
- assert.equal(result.ok, false);
848
- assert.equal(result.stalled, true);
849
- assert.equal(result.reason, 'no_ready_plan_task');
860
+ // Plus de blocage : le run retombe au lieu de rester actif indéfiniment.
861
+ assert.notEqual(result.stalled, true);
862
+ assert.equal(session.headlessPlan[1].status, 'skipped');
863
+ assert.equal(session.headlessPlan[1].error?.code, 'dependency_failed');
864
+ // Le motif nomme la dépendance fautive : sans lui, l'utilisateur voit une
865
+ // tâche disparaître sans savoir pourquoi.
866
+ assert.match(session.headlessPlan[1].error.message, /\ba\b/);
850
867
  assert.equal(session.agentEvents.some((event) => event.type === 'assistant_message'
851
868
  && /Approbation requise/.test(event.payload?.content ?? '')), false);
869
+ assert.equal(session.agentEvents.some((event) => event.type === 'plan_step_updated'
870
+ && event.payload?.status === 'skipped'), true);
852
871
  });
853
872
 
854
873
  test('runRuntimeParallelPlan retries a retryable task on a fallback agent', async () => {
@@ -1083,3 +1102,131 @@ test('runtime runs are seeded with the chat that preceded them', async () => {
1083
1102
  assert.equal(clipped.length, 1);
1084
1103
  assert.equal(clipped[0].content.length, 2000);
1085
1104
  });
1105
+
1106
+ test('skipImpossibleTasks propage un échec jusqu’au point fixe', () => {
1107
+ /*
1108
+ Marquer les seules tâches directement bloquées laissait un résidu : A en
1109
+ échec rend B impossible, mais C dépend de B et serait resté en attente
1110
+ d'une tâche désormais terminale sans succès. La propagation doit descendre
1111
+ toute la chaîne en une fois.
1112
+ */
1113
+ const session = { workspace: 'demo-workspace', agentEvents: [] };
1114
+ const steps = [
1115
+ { id: 'a', step: 1, status: 'failed' },
1116
+ { id: 'b', step: 2, status: 'pending', dependsOn: ['a'] },
1117
+ { id: 'c', step: 3, status: 'pending', dependsOn: ['b'] },
1118
+ { id: 'independent', step: 4, status: 'pending', dependsOn: [] },
1119
+ ];
1120
+ // Le plan passe par la projection, comme en production : chaque dépêche
1121
+ // remplace session.headlessPlan, et c'est précisément ce qui rendait une
1122
+ // référence capturée orpheline.
1123
+ dispatchAgentEvent(session, createAgentEvent('plan_set', { origin: 'tool', payload: { steps } }));
1124
+
1125
+ const skipped = skipImpossibleTasks(session, 'run-fixpoint');
1126
+
1127
+ assert.equal(skipped, 2);
1128
+ assert.equal(session.headlessPlan[1].status, 'skipped');
1129
+ assert.equal(session.headlessPlan[2].status, 'skipped');
1130
+ // La branche indépendante n'est pas touchée : c'est tout l'objet du
1131
+ // correctif, laisser finir ce qui peut finir.
1132
+ assert.equal(session.headlessPlan[3].status, 'pending');
1133
+ assert.equal(session.agentEvents.filter((event) => event.payload?.status === 'skipped').length, 2);
1134
+ });
1135
+
1136
+ test('skipImpossibleTasks s’arrête au lieu de tourner sans rien changer', () => {
1137
+ // Sans garde-fou, une incohérence de statut se paierait en boucle infinie,
1138
+ // c'est-à-dire en run figé — exactement ce que l'on répare.
1139
+ const session = { workspace: 'demo-workspace', agentEvents: [] };
1140
+ dispatchAgentEvent(session, createAgentEvent('plan_set', {
1141
+ origin: 'tool',
1142
+ payload: {
1143
+ steps: [
1144
+ { id: 'a', step: 1, status: 'failed' },
1145
+ { id: 'b', step: 2, status: 'skipped', dependsOn: ['a'] },
1146
+ ],
1147
+ },
1148
+ }));
1149
+ const eventsBefore = session.agentEvents.length;
1150
+
1151
+ assert.equal(skipImpossibleTasks(session, 'run-guard', { maxPasses: 3 }), 0);
1152
+ assert.equal(session.agentEvents.length, eventsBefore);
1153
+ });
1154
+
1155
+ test('structuredPlanEvaluation compte séparément, sans fraction trompeuse', () => {
1156
+ const task = (id, status) => ({
1157
+ id, status, label: `Task ${id}`, requiredCapability: 'knowledge.update', operation: 'ingest',
1158
+ });
1159
+
1160
+ const allDone = structuredPlanEvaluation([task('a', 'done'), task('b', 'succeeded')]);
1161
+ assert.equal(allDone.ok, true);
1162
+
1163
+ const partial = structuredPlanEvaluation([
1164
+ task('a', 'done'), task('b', 'failed'), task('c', 'skipped'),
1165
+ ]);
1166
+ assert.equal(partial.ok, false);
1167
+ // Trois compteurs qui s'additionnent, pas un « 1/3 » dont le dénominateur
1168
+ // mêlerait tâches métier et étapes techniques.
1169
+ assert.match(partial.reason, /1 réussie\(s\)/);
1170
+ assert.match(partial.reason, /1 en échec/);
1171
+ assert.match(partial.reason, /1 ignorée\(s\)/);
1172
+ assert.doesNotMatch(partial.reason, /\d+\/\d+/);
1173
+
1174
+ // Uniquement des ignorées : jamais un succès.
1175
+ const onlySkipped = structuredPlanEvaluation([task('a', 'skipped')]);
1176
+ assert.equal(onlySkipped.ok, false);
1177
+
1178
+ // Succès + ignorée sans échec : toujours pas un succès.
1179
+ const doneAndSkipped = structuredPlanEvaluation([task('a', 'done'), task('b', 'skipped')]);
1180
+ assert.equal(doneAndSkipped.ok, false);
1181
+
1182
+ // Statut actif ou inconnu : incomplet, jamais réussi.
1183
+ for (const status of ['running', 'brouette']) {
1184
+ const pending = structuredPlanEvaluation([task('a', 'done'), task('b', status)]);
1185
+ assert.equal(pending.ok, false, status);
1186
+ assert.match(pending.reason, /non terminée/);
1187
+ }
1188
+ });
1189
+
1190
+ test('une tâche ignorée est terminale pour la reprise et pour les approbations', () => {
1191
+ /*
1192
+ Vérifications transversales sur le nouveau statut : il ne suffit pas que
1193
+ l'ordonnanceur le comprenne. S'il n'est pas terminal pour la reprise, le
1194
+ redémarrage réattache un run fantôme ; s'il n'est pas terminal pour la
1195
+ politique d'approbation, on demande un accord pour une tâche qui ne sera
1196
+ jamais exécutée.
1197
+ */
1198
+ const skippedTask = {
1199
+ id: 'ingest-b',
1200
+ status: 'skipped',
1201
+ requiresApproval: true,
1202
+ requiredCapability: 'knowledge.update',
1203
+ operation: 'ingest_apply',
1204
+ };
1205
+
1206
+ assert.equal(isTerminal(skippedTask.status), true);
1207
+ assert.deepEqual(tasksAwaitingApproval([skippedTask], { approvals: [] }), []);
1208
+ assert.deepEqual(readyPlanTasks([skippedTask]).map((task) => task.id), []);
1209
+ });
1210
+
1211
+ test('rejouer les événements redonne exactement les mêmes statuts', () => {
1212
+ // La projection est reconstruite au démarrage à partir du journal : si le
1213
+ // rejeu ne rendait pas le même état, une tâche ignorée pourrait réapparaître
1214
+ // en attente et relancer un travail déjà abandonné.
1215
+ const events = [
1216
+ createAgentEvent('plan_set', { origin: 'tool', payload: { steps: ['A', 'B'] } }),
1217
+ createAgentEvent('plan_step_updated', { origin: 'runtime', payload: { step: 1, status: 'failed' } }),
1218
+ createAgentEvent('plan_step_updated', {
1219
+ origin: 'runtime',
1220
+ payload: { step: 2, status: 'skipped', reason: 'dependency_failed:A' },
1221
+ }),
1222
+ ];
1223
+
1224
+ const first = reduceAgentEvents(events);
1225
+ const replayed = reduceAgentEvents(events);
1226
+
1227
+ assert.deepEqual(
1228
+ replayed.plan.map((step) => step.status),
1229
+ first.plan.map((step) => step.status),
1230
+ );
1231
+ assert.deepEqual(replayed.plan.map((step) => step.status), ['failed', 'skipped']);
1232
+ });
@@ -283,6 +283,27 @@ export function startRuntimeServer({
283
283
  }
284
284
  validateContractInDev('runRequest', { ...body, input });
285
285
  if (context.running) {
286
+ // A structured capability plan must never degrade into a free-text
287
+ // control message: its exact arguments and approval policy would
288
+ // otherwise be discarded by the control lane. It can still be
289
+ // queued as a future run when the caller explicitly opts in via
290
+ // intent: 'enqueue' — the control queue carries capabilityPlan
291
+ // through untouched (see enqueueControlRequest/
292
+ // startNextControlRequest) instead of collapsing it to text.
293
+ if (body.capabilityPlan) {
294
+ if (body.intent === 'enqueue') {
295
+ const item = enqueueControlRequest(context, input, { capabilityPlan: body.capabilityPlan });
296
+ void startNextControlRequest(context);
297
+ sendJson(response, 202, {
298
+ accepted: true,
299
+ item,
300
+ ...controlStatus(context, store),
301
+ });
302
+ return;
303
+ }
304
+ sendJson(response, 409, { error: 'run_active' });
305
+ return;
306
+ }
286
307
  // Blind enqueueing made every message typed during a run
287
308
  // invisible until the run ended (serve UI symptom: "no result
288
309
  // until the job finished or was stopped"). Classify instead:
@@ -630,6 +651,7 @@ export function startRuntimeServer({
630
651
  startRuntimeRun(context, {
631
652
  input: item.input,
632
653
  workspace: item.workspace ?? context.workspace ?? null,
654
+ ...(item.capabilityPlan !== undefined ? { capabilityPlan: item.capabilityPlan } : {}),
633
655
  }, { controlItemId: item.id });
634
656
  return true;
635
657
  }
@@ -843,7 +865,7 @@ async function handleControlMessage(context, store, input, { intent = null, star
843
865
  : controlMessage(context?.session, 'converse_while_idle'));
844
866
  }
845
867
 
846
- function enqueueControlRequest(context, input) {
868
+ function enqueueControlRequest(context, input, { capabilityPlan } = {}) {
847
869
  const now = new Date().toISOString();
848
870
  const item = {
849
871
  id: `control-${randomUUID()}`,
@@ -853,6 +875,7 @@ function enqueueControlRequest(context, input) {
853
875
  status: 'queued',
854
876
  createdAt: now,
855
877
  updatedAt: now,
878
+ ...(capabilityPlan !== undefined ? { capabilityPlan } : {}),
856
879
  };
857
880
  dispatchAgentEvent(context.session, createAgentEvent('control_enqueued', {
858
881
  origin: 'runtime',
@@ -347,6 +347,119 @@ test('runtime server accepts only one active run', async (t) => {
347
347
  }
348
348
  });
349
349
 
350
+ test('runtime server rejects a structured capability plan while a run is active', async (t) => {
351
+ let releaseRun;
352
+ let handle;
353
+ try {
354
+ handle = await startRuntimeServer({
355
+ host: '127.0.0.1',
356
+ port: 0,
357
+ store: {
358
+ dbPath: ':memory:',
359
+ getState: () => ({ status: 'idle' }),
360
+ listEvents: () => [],
361
+ },
362
+ session: {},
363
+ run: async () => new Promise((resolve) => { releaseRun = resolve; }),
364
+ });
365
+ } catch (err) {
366
+ if (err?.code === 'EPERM') {
367
+ t.skip('network listen is not permitted in this sandbox');
368
+ return;
369
+ }
370
+ throw err;
371
+ }
372
+
373
+ try {
374
+ const url = `http://127.0.0.1:${handle.port}/run`;
375
+ const first = await fetch(url, {
376
+ method: 'POST',
377
+ headers: { 'Content-Type': 'application/json' },
378
+ body: JSON.stringify({ input: 'active build' }),
379
+ });
380
+ assert.equal(first.status, 202);
381
+
382
+ const restore = await fetch(url, {
383
+ method: 'POST',
384
+ headers: { 'Content-Type': 'application/json' },
385
+ body: JSON.stringify({
386
+ input: 'restore',
387
+ capabilityPlan: {
388
+ capability: 'workspace.restore',
389
+ operation: 'restore',
390
+ arguments: { run: 'abc123' },
391
+ requireApproval: true,
392
+ },
393
+ }),
394
+ });
395
+ assert.equal(restore.status, 409);
396
+ assert.deepEqual(await restore.json(), { error: 'run_active' });
397
+ } finally {
398
+ releaseRun?.();
399
+ await handle.close();
400
+ }
401
+ });
402
+
403
+ test('runtime server queues a structured capability plan when intent is enqueue', async (t) => {
404
+ let releaseRun;
405
+ let handle;
406
+ try {
407
+ handle = await startRuntimeServer({
408
+ host: '127.0.0.1',
409
+ port: 0,
410
+ store: {
411
+ dbPath: ':memory:',
412
+ getState: () => ({ status: 'idle' }),
413
+ listEvents: () => [],
414
+ },
415
+ session: {},
416
+ run: async () => new Promise((resolve) => { releaseRun = resolve; }),
417
+ });
418
+ } catch (err) {
419
+ if (err?.code === 'EPERM') {
420
+ t.skip('network listen is not permitted in this sandbox');
421
+ return;
422
+ }
423
+ throw err;
424
+ }
425
+
426
+ try {
427
+ const url = `http://127.0.0.1:${handle.port}/run`;
428
+ const first = await fetch(url, {
429
+ method: 'POST',
430
+ headers: { 'Content-Type': 'application/json' },
431
+ body: JSON.stringify({ input: 'active build' }),
432
+ });
433
+ assert.equal(first.status, 202);
434
+
435
+ const capabilityPlan = {
436
+ capability: 'workspace.restore',
437
+ operation: 'restore',
438
+ arguments: { run: 'abc123' },
439
+ requireApproval: true,
440
+ };
441
+ const restore = await fetch(url, {
442
+ method: 'POST',
443
+ headers: { 'Content-Type': 'application/json' },
444
+ body: JSON.stringify({ input: 'restore', intent: 'enqueue', capabilityPlan }),
445
+ });
446
+ assert.equal(restore.status, 202);
447
+ const restoreBody = await restore.json();
448
+ assert.equal(restoreBody.accepted, true);
449
+ assert.deepEqual(restoreBody.item.capabilityPlan, capabilityPlan);
450
+ assert.equal(restoreBody.item.status, 'queued');
451
+
452
+ const status = await fetch(`http://127.0.0.1:${handle.port}/control`);
453
+ const statusBody = await status.json();
454
+ const queued = statusBody.controlQueue.find((item) => item.id === restoreBody.item.id);
455
+ assert.ok(queued, 'queued item should be visible in control status');
456
+ assert.deepEqual(queued.capabilityPlan, capabilityPlan);
457
+ } finally {
458
+ releaseRun?.();
459
+ await handle.close();
460
+ }
461
+ });
462
+
350
463
  test('runtime server returns the accepted run id and passes it to the runner', async (t) => {
351
464
  let receivedBody = null;
352
465
  let handle;
@@ -1,3 +1,12 @@
1
+ /**
2
+ * @statuses-vocabulary
3
+ *
4
+ * RUN lifecycle statuses, not task statuses: a run can be `interrupted`,
5
+ * a task never is.
6
+ *
7
+ * Declared here rather than in a central exception list so the waiver
8
+ * travels with the code it excuses (see orchestrator/taskStatuses.test.js).
9
+ */
1
10
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
11
  import { dirname, join, resolve } from 'node:path';
3
12
  import { DatabaseSync } from 'node:sqlite';
@@ -6,6 +15,8 @@ import { defaultRuntimeStateDir } from '../core/env.js';
6
15
  import { projectQueue } from '../core/jobQueue.js';
7
16
  import { projectWorkflow } from '../core/workflow.js';
8
17
  import { createCapabilityRegistry } from '../orchestrator/capabilityRegistry.js';
18
+ import { isSuccessful } from '../orchestrator/taskStatuses.js';
19
+ import { markPersistedAgentsStale } from '../orchestrator/agentRegistry.js';
9
20
 
10
21
  export { defaultRuntimeStateDir };
11
22
 
@@ -1121,7 +1132,7 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
1121
1132
 
1122
1133
  function attemptStatusForResult(status, eventType) {
1123
1134
  const normalized = String(status ?? '').toLowerCase();
1124
- if (['succeeded', 'success', 'done', 'complete', 'completed'].includes(normalized)) return 'done';
1135
+ if (isSuccessful(normalized)) return 'done';
1125
1136
  if (['cancelled', 'canceled'].includes(normalized)) return 'cancelled';
1126
1137
  if (eventType === 'task.failed') return 'failed';
1127
1138
  return normalized || 'finished';
@@ -1265,6 +1276,10 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
1265
1276
  function hydrateSession(session, { workspace = null } = {}) {
1266
1277
  const projection = replayEvents(session, { workspace });
1267
1278
  applyAgentProjectionToSession(session, projection);
1279
+ // Le journal restitue les agents avec la santé qu'ils avaient à l'écriture
1280
+ // de l'événement. Les reprendre tels quels les faisait réapparaître
1281
+ // « disponibles » après un redémarrage, endpoint éteint compris.
1282
+ markPersistedAgentsStale(session);
1268
1283
  session.jobQueue = listQueue({ workspace });
1269
1284
  return projection;
1270
1285
  }
@@ -6,6 +6,7 @@ import { DatabaseSync } from 'node:sqlite';
6
6
  import test from 'node:test';
7
7
  import { conversationEventSequences, createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
8
8
  import { openRuntimeStore } from './store.js';
9
+ import { capabilityRegistryForSession } from '../orchestrator/capabilityRegistry.js';
9
10
 
10
11
  function runtimeStateDir() {
11
12
  return join(mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-')), '.wiki', 'runtime');
@@ -1106,3 +1107,52 @@ test('deleteEventsAfter removes bookkeeping only for runs entirely produced by t
1106
1107
  assert.equal(store.db.prepare('SELECT COUNT(*) AS n FROM task_attempts').get().n, 1);
1107
1108
  store.close();
1108
1109
  });
1110
+
1111
+ test('un agent restauré par hydrateSession n’est plus routable tant qu’aucun scan n’a réussi', () => {
1112
+ /*
1113
+ Validation à chaud du 2026-08-04 : après redémarrage, `cme-main` restait
1114
+ `available` et `external-source.export` gardait un fournisseur, alors que
1115
+ l'endpoint CME était arrêté. Le test précédent construisait `session.agents`
1116
+ à la main et ratait donc le vrai chemin — celui où l'état vient du journal.
1117
+
1118
+ On hydrate ici depuis de véritables événements persistés, comme au
1119
+ démarrage : hydrate → invalidate → discover, sans scan entre les deux
1120
+ premiers.
1121
+ */
1122
+ const store = openRuntimeStore({ stateDir: mkdtempSync(join(tmpdir(), 'wiki-manager-agent-staleness-')) });
1123
+ const writer = { agentEvents: [], workspace: 'juno' };
1124
+ dispatchAgentEvent(writer, createAgentEvent('agent.registered', {
1125
+ origin: 'runtime',
1126
+ workspace: 'juno',
1127
+ payload: {
1128
+ agent: {
1129
+ agentInstanceId: 'cme-main',
1130
+ serverName: 'cme',
1131
+ health: 'available',
1132
+ description: {
1133
+ agentType: 'cme',
1134
+ contractVersion: '1',
1135
+ agentInstanceId: 'cme-main',
1136
+ capabilities: [{ id: 'external-source.export', version: '1' }],
1137
+ },
1138
+ },
1139
+ },
1140
+ }));
1141
+ for (const event of writer.agentEvents) store.persistEvent(event);
1142
+
1143
+ // Redémarrage : une session neuve, aucun scan encore effectué.
1144
+ const rebooted = { agentEvents: [], workspace: 'juno' };
1145
+ store.hydrateSession(rebooted, { workspace: 'juno' });
1146
+
1147
+ const restored = [...(rebooted.agents ?? []), ...(rebooted.agentRegistrySnapshot ?? [])];
1148
+ assert.ok(restored.length > 0, 'the agent must be restored, only not trusted');
1149
+ for (const agent of restored) {
1150
+ assert.equal(agent.health, 'unknown');
1151
+ assert.equal(agent.stale, true);
1152
+ }
1153
+ // Le seul point qui compte vraiment : plus aucun fournisseur sélectionnable.
1154
+ assert.deepEqual(
1155
+ capabilityRegistryForSession(rebooted).providersFor('external-source.export'),
1156
+ [],
1157
+ );
1158
+ });