@dotdrelle/wiki-manager 0.15.40 → 0.15.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/activity/activityAggregator.js +3 -3
- package/src/activity/progressCalculator.js +3 -4
- package/src/agent/graph.js +40 -1
- package/src/cli/wiki-manager.js +29 -36
- package/src/commands/slash.js +11 -1
- package/src/core/activity.js +3 -2
- package/src/core/agentEvents.js +45 -11
- package/src/core/agentEvents.test.js +44 -0
- package/src/core/agentLoop.js +32 -6
- package/src/core/buildInfo.json +2 -2
- package/src/core/jobQueue.js +9 -0
- package/src/core/mcp.js +1 -1
- package/src/core/plan.js +3 -2
- package/src/core/planPatch.js +2 -1
- package/src/core/workflow.js +11 -2
- package/src/graph/graphVisibilityPolicy.js +2 -2
- package/src/orchestrator/agentRegistry.js +50 -0
- package/src/orchestrator/agentRegistry.test.js +76 -1
- package/src/orchestrator/approvalPolicy.js +2 -2
- package/src/orchestrator/dependencyResolver.js +52 -12
- package/src/orchestrator/dispatcher.js +2 -5
- package/src/orchestrator/planIntegrator.js +5 -5
- package/src/orchestrator/resultAggregator.js +2 -1
- package/src/orchestrator/scheduler.test.js +62 -1
- package/src/orchestrator/taskStatuses.js +99 -0
- package/src/orchestrator/taskStatuses.test.js +112 -0
- package/src/runtime/delegation.js +158 -0
- package/src/runtime/delegation.test.js +281 -0
- package/src/runtime/recoveryManager.js +2 -5
- package/src/runtime/recoveryManager.test.js +5 -1
- package/src/runtime/runner.js +129 -15
- package/src/runtime/runner.test.js +153 -6
- package/src/runtime/store.js +16 -1
- package/src/runtime/store.test.js +50 -0
- package/src/shell/repl.js +4 -5
package/src/runtime/runner.js
CHANGED
|
@@ -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 {
|
|
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 (
|
|
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 (
|
|
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
|
|
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
|
|
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
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1164
|
+
counts,
|
|
1165
|
+
reason: `${summary}. Le plan n'est pas terminé.`,
|
|
1053
1166
|
suggestedAction: null,
|
|
1054
1167
|
};
|
|
1055
1168
|
}
|
|
1056
1169
|
return {
|
|
1057
|
-
ok:
|
|
1058
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
848
|
-
assert.
|
|
849
|
-
assert.equal(
|
|
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
|
+
});
|
package/src/runtime/store.js
CHANGED
|
@@ -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 (
|
|
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
|
+
});
|
package/src/shell/repl.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isTerminal } from '../orchestrator/taskStatuses.js';
|
|
1
2
|
import { createInterface } from 'node:readline';
|
|
2
3
|
import { emitKeypressEvents } from 'node:readline';
|
|
3
4
|
import { Transform } from 'node:stream';
|
|
@@ -1006,7 +1007,7 @@ function productionActivityFromPayload(payload) {
|
|
|
1006
1007
|
jobId: jobId ?? null,
|
|
1007
1008
|
status,
|
|
1008
1009
|
label: detail ? `Production: ${detail}` : `Production: ${status}`,
|
|
1009
|
-
terminal:
|
|
1010
|
+
terminal: isTerminal(status),
|
|
1010
1011
|
updatedAt: new Date().toISOString(),
|
|
1011
1012
|
};
|
|
1012
1013
|
}
|
|
@@ -1027,7 +1028,7 @@ export function applyRuntimeStateToShellSession(session, state) {
|
|
|
1027
1028
|
const displayState = sanitizeRuntimeStateForDisplay(state);
|
|
1028
1029
|
const terminalStateDismissed = session._dismissedTerminalRunId
|
|
1029
1030
|
&& state.runId === session._dismissedTerminalRunId
|
|
1030
|
-
&&
|
|
1031
|
+
&& isTerminal(displayState.status);
|
|
1031
1032
|
session._lastRuntimeRunId = state.runId ?? session._lastRuntimeRunId ?? null;
|
|
1032
1033
|
session.agentProjection = {
|
|
1033
1034
|
conversation: Array.isArray(displayState.conversation) ? displayState.conversation.map((message) => ({ ...message })) : [],
|
|
@@ -1324,9 +1325,7 @@ async function runAgentTurn(input, {
|
|
|
1324
1325
|
session.packageJson = session.packageJson ?? {};
|
|
1325
1326
|
if (
|
|
1326
1327
|
session._lastRuntimeRunId
|
|
1327
|
-
&&
|
|
1328
|
-
String(session.agentProjection?.status ?? '').toLowerCase(),
|
|
1329
|
-
)
|
|
1328
|
+
&& isTerminal(session.agentProjection?.status)
|
|
1330
1329
|
) {
|
|
1331
1330
|
session._dismissedTerminalRunId = session._lastRuntimeRunId;
|
|
1332
1331
|
}
|