@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
|
@@ -4,6 +4,56 @@ import { assertContract } from '../contracts/schemas.js';
|
|
|
4
4
|
|
|
5
5
|
const AVAILABLE = 'available';
|
|
6
6
|
const UNAVAILABLE = 'unavailable';
|
|
7
|
+
/**
|
|
8
|
+
* Santé d'un agent restauré depuis le journal, tant qu'aucun `agent_describe`
|
|
9
|
+
* n'a réussi dans le processus courant.
|
|
10
|
+
*
|
|
11
|
+
* Ni `available` ni `unavailable` : on ne SAIT pas. Le distinguer de
|
|
12
|
+
* `unavailable` a une conséquence pratique — un agent inconnu redevient
|
|
13
|
+
* disponible en silence dès le premier scan réussi, là où un agent déclaré
|
|
14
|
+
* indisponible mériterait d'être signalé comme tel à l'utilisateur.
|
|
15
|
+
*/
|
|
16
|
+
const UNKNOWN = 'unknown';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Un agent persisté n'est pas un agent joignable.
|
|
20
|
+
*
|
|
21
|
+
* Au redémarrage, `hydrateSession` rejoue le journal d'événements et
|
|
22
|
+
* reconstruit les agents avec la santé qu'ils avaient AU MOMENT où l'événement
|
|
23
|
+
* a été écrit. `cme-main` réapparaissait donc `available` alors que son
|
|
24
|
+
* endpoint n'existe plus, était retenu comme fournisseur, et la tâche partait
|
|
25
|
+
* vers un agent absent — la panne observée le 2026-08-04.
|
|
26
|
+
*
|
|
27
|
+
* La persistance dit ce qui a existé, pas ce qui répond maintenant. Seul un
|
|
28
|
+
* `agent_describe` réussi dans ce processus autorise à parler de disponibilité.
|
|
29
|
+
*/
|
|
30
|
+
export function markPersistedAgentsStale(session) {
|
|
31
|
+
if (!session || typeof session !== 'object') return [];
|
|
32
|
+
const stale = (agent) => ({
|
|
33
|
+
...agent,
|
|
34
|
+
health: UNKNOWN,
|
|
35
|
+
stale: true,
|
|
36
|
+
// La santé d'origine est conservée : elle raconte ce qu'on savait avant
|
|
37
|
+
// l'arrêt, ce qui aide à lire un journal, sans jamais servir au routage.
|
|
38
|
+
healthBeforeRestart: agent?.health ?? null,
|
|
39
|
+
});
|
|
40
|
+
/*
|
|
41
|
+
TOUTES les représentations restaurées, `agentRegistrySnapshot` compris.
|
|
42
|
+
|
|
43
|
+
J'avais d'abord épargné le snapshot, au motif qu'il pouvait porter un scan
|
|
44
|
+
vivant. C'était une inversion : à l'hydratation, aucun scan n'a encore eu
|
|
45
|
+
lieu — l'ordre est hydrate → invalidate → discover, et rien ne s'exécute
|
|
46
|
+
entre les deux premiers. Le snapshot vient donc de la même projection
|
|
47
|
+
persistée que `session.agents`. L'épargner laissait `cme-main` routable avec
|
|
48
|
+
son endpoint éteint, ce que la validation à chaud a montré.
|
|
49
|
+
|
|
50
|
+
Le seul scan qui compte est celui qui suivra : `discover()` réécrit le
|
|
51
|
+
snapshot en entier à partir des `agent_describe` réussis.
|
|
52
|
+
*/
|
|
53
|
+
session.agents = (session.agents ?? []).map(stale);
|
|
54
|
+
session.agentRegistrySnapshot = (session.agentRegistrySnapshot ?? []).map(stale);
|
|
55
|
+
return session.agentRegistrySnapshot;
|
|
56
|
+
}
|
|
7
57
|
|
|
8
58
|
export function createAgentRegistry({
|
|
9
59
|
callTool = callMcpTool,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import test from 'node:test';
|
|
3
|
-
import { createAgentRegistry } from './agentRegistry.js';
|
|
3
|
+
import { createAgentRegistry, markPersistedAgentsStale } from './agentRegistry.js';
|
|
4
|
+
import { createCapabilityRegistry } from './capabilityRegistry.js';
|
|
4
5
|
|
|
5
6
|
function description({ agentInstanceId = 'production-main', health = 'available', contractVersion = '1' } = {}) {
|
|
6
7
|
return {
|
|
@@ -205,3 +206,77 @@ test('discovery sends no workspace argument when no workspace is active', async
|
|
|
205
206
|
|
|
206
207
|
assert.deepEqual(seen, [{}]);
|
|
207
208
|
});
|
|
209
|
+
|
|
210
|
+
/*
|
|
211
|
+
Cas observé le 2026-08-04 : après redémarrage du runtime, `cme-main` était
|
|
212
|
+
toujours présenté `available` alors que son endpoint n'existait plus. La
|
|
213
|
+
relecture du journal restitue la santé qu'un agent avait AU MOMENT où
|
|
214
|
+
l'événement a été écrit — la persistance dit ce qui a existé, pas ce qui
|
|
215
|
+
répond maintenant.
|
|
216
|
+
*/
|
|
217
|
+
test('un agent restauré depuis le journal n’est pas déclaré disponible', () => {
|
|
218
|
+
const session = {
|
|
219
|
+
agents: [
|
|
220
|
+
{ agentInstanceId: 'cme-main', serverName: 'cme', health: 'available', description: { contractVersion: '1', capabilities: [{ id: 'external-source.export', version: '1' }] } },
|
|
221
|
+
{ agentInstanceId: 'production-main', serverName: 'production', health: 'available', description: { contractVersion: '1', capabilities: [{ id: 'knowledge.update', version: '1' }] } },
|
|
222
|
+
],
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
markPersistedAgentsStale(session);
|
|
226
|
+
|
|
227
|
+
for (const agent of session.agents) {
|
|
228
|
+
assert.equal(agent.health, 'unknown');
|
|
229
|
+
assert.equal(agent.stale, true);
|
|
230
|
+
// Ce qu'on savait avant l'arrêt reste lisible, sans jamais servir au routage.
|
|
231
|
+
assert.equal(agent.healthBeforeRestart, 'available');
|
|
232
|
+
}
|
|
233
|
+
// Et surtout : plus aucun fournisseur sélectionnable tant que le scan n'a pas
|
|
234
|
+
// confirmé. C'est la seule chose qui empêche une tâche de partir vers un
|
|
235
|
+
// agent absent.
|
|
236
|
+
const registry = createCapabilityRegistry({ agents: session.agents });
|
|
237
|
+
assert.deepEqual(registry.providersFor('external-source.export'), []);
|
|
238
|
+
assert.deepEqual(registry.providersFor('knowledge.update'), []);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('markPersistedAgentsStale invalide aussi l’instantané routable', () => {
|
|
242
|
+
/*
|
|
243
|
+
J'avais d'abord épargné `agentRegistrySnapshot`, au motif qu'il pouvait
|
|
244
|
+
porter un scan vivant. Inversion : à l'hydratation aucun scan n'a eu lieu,
|
|
245
|
+
l'ordre étant hydrate → invalidate → discover. L'épargner laissait
|
|
246
|
+
`cme-main` routable avec son endpoint éteint — constaté à chaud.
|
|
247
|
+
*/
|
|
248
|
+
const session = {
|
|
249
|
+
agents: [{ agentInstanceId: 'cme-main', health: 'available' }],
|
|
250
|
+
agentRegistrySnapshot: [{ agentInstanceId: 'cme-main', health: 'available' }],
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
markPersistedAgentsStale(session);
|
|
254
|
+
|
|
255
|
+
assert.equal(session.agents[0].health, 'unknown');
|
|
256
|
+
assert.equal(session.agentRegistrySnapshot[0].health, 'unknown');
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test('un agent redevient sélectionnable après un agent_describe réussi', async () => {
|
|
260
|
+
const session = {
|
|
261
|
+
workspace: 'juno',
|
|
262
|
+
agentEvents: [],
|
|
263
|
+
agents: [{ agentInstanceId: 'production-main', serverName: 'production', health: 'available', description: { contractVersion: '1', capabilities: [{ id: 'knowledge.update', version: '1' }] } }],
|
|
264
|
+
mcp: {
|
|
265
|
+
production: { status: 'connected', tools: [{ name: 'agent_describe' }] },
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
markPersistedAgentsStale(session);
|
|
269
|
+
assert.deepEqual(createCapabilityRegistry({ agents: session.agents }).providersFor('knowledge.update'), []);
|
|
270
|
+
|
|
271
|
+
const registry = createAgentRegistry({
|
|
272
|
+
callTool: async () => ({ content: [{ type: 'text', text: JSON.stringify(description()) }] }),
|
|
273
|
+
});
|
|
274
|
+
await registry.discover(session);
|
|
275
|
+
|
|
276
|
+
// La reconnexion suffit : aucune intervention, aucun redémarrage.
|
|
277
|
+
assert.equal(session.agentRegistrySnapshot[0].health, 'available');
|
|
278
|
+
assert.equal(
|
|
279
|
+
createCapabilityRegistry({ agents: session.agentRegistrySnapshot }).providersFor('knowledge.update').length,
|
|
280
|
+
1,
|
|
281
|
+
);
|
|
282
|
+
});
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { isTerminal } from './taskStatuses.js';
|
|
2
3
|
|
|
3
4
|
export const APPROVAL_DEFAULT_CLASS = 'default';
|
|
4
5
|
|
|
5
6
|
const GRANTED_STATUSES = new Set(['approved', 'granted']);
|
|
6
|
-
const TERMINAL_STATUSES = new Set(['done', 'failed', 'cancelled', 'canceled', 'error', 'complete', 'completed', 'success']);
|
|
7
7
|
|
|
8
8
|
export function approvalClassForTask(task) {
|
|
9
9
|
return String(task?.approvalClass ?? task?.mutationClass ?? APPROVAL_DEFAULT_CLASS);
|
|
@@ -74,7 +74,7 @@ export function applyApprovalCoverage(tasks = [], {
|
|
|
74
74
|
} = {}) {
|
|
75
75
|
const requested = [];
|
|
76
76
|
for (const task of tasks) {
|
|
77
|
-
if (task?.requiresApproval !== true ||
|
|
77
|
+
if (task?.requiresApproval !== true || isTerminal(task.status)) continue;
|
|
78
78
|
const covered = approvalCovered(task, approvals, { runId, workspaceId, planRevision });
|
|
79
79
|
if (!covered) {
|
|
80
80
|
task.status = 'waiting_approval';
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { locksForTask } from './lockManager.js';
|
|
2
2
|
import { approvalCovered } from './approvalPolicy.js';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
export
|
|
3
|
+
import { isPending, isSuccessful, isTerminal, isUnsuccessfulTerminal } from './taskStatuses.js';
|
|
4
|
+
// Une tâche dans un de ces statuts n'a pas encore tourné mais peut le devenir.
|
|
5
|
+
// Réexporté depuis le vocabulaire commun : l'ordonnanceur et le contrôle de
|
|
6
|
+
// blocage du runner doivent tester la même chose, et un ensemble local ici
|
|
7
|
+
// était précisément le moyen de les faire diverger.
|
|
8
|
+
export { isPending } from './taskStatuses.js';
|
|
9
9
|
|
|
10
10
|
export function readyTasks(dag, {
|
|
11
11
|
registry = null,
|
|
@@ -15,13 +15,13 @@ export function readyTasks(dag, {
|
|
|
15
15
|
activeTaskIds = [],
|
|
16
16
|
} = {}) {
|
|
17
17
|
const tasks = normalizeTasks(dag);
|
|
18
|
-
const done = new Set(tasks.filter((task) =>
|
|
18
|
+
const done = new Set(tasks.filter((task) => isSuccessful(statusOf(task))).map(taskId));
|
|
19
19
|
const active = new Set([...activeTaskIds].map(String));
|
|
20
20
|
return tasks
|
|
21
21
|
.filter((task) => {
|
|
22
22
|
const status = statusOf(task);
|
|
23
23
|
return status === 'pending'
|
|
24
|
-
|| (
|
|
24
|
+
|| (isPending(status)
|
|
25
25
|
&& approvalCovered(task, approvals, {
|
|
26
26
|
runId: task?.runId ?? dag?.runId ?? null,
|
|
27
27
|
workspaceId: dag?.workspace ?? null,
|
|
@@ -40,9 +40,9 @@ export function readyTasks(dag, {
|
|
|
40
40
|
|
|
41
41
|
export function tasksAwaitingApproval(dag, { approvals = [] } = {}) {
|
|
42
42
|
const tasks = normalizeTasks(dag);
|
|
43
|
-
const done = new Set(tasks.filter((task) =>
|
|
43
|
+
const done = new Set(tasks.filter((task) => isSuccessful(statusOf(task))).map(taskId));
|
|
44
44
|
return tasks
|
|
45
|
-
.filter((task) =>
|
|
45
|
+
.filter((task) => isPending(statusOf(task)))
|
|
46
46
|
.filter((task) => task?.requiresApproval === true)
|
|
47
47
|
.filter((task) => !approvalCovered(task, approvals, {
|
|
48
48
|
runId: task?.runId ?? dag?.runId ?? null,
|
|
@@ -64,12 +64,52 @@ function dependenciesDone(task, done) {
|
|
|
64
64
|
return dependsOn(task).every((dep) => done.has(String(dep)));
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/*
|
|
68
|
+
Une barrière de groupe attend que le groupe soit FINI, pas qu'il soit parfait.
|
|
69
|
+
|
|
70
|
+
Elle exigeait que chaque membre soit `done`. Un seul échec la fermait donc
|
|
71
|
+
définitivement : sur une ingestion de dix fichiers dont neuf réussissent, la
|
|
72
|
+
suite du plan n'était jamais débloquée et le run restait `running` pour
|
|
73
|
+
toujours. Un incident sur un document devenait une panne totale — le coût
|
|
74
|
+
était sans rapport avec le dégât.
|
|
75
|
+
|
|
76
|
+
La barrière s'ouvre donc quand tout le groupe est TERMINAL. Ce que valait
|
|
77
|
+
réellement la garantie « tout est done » est préservé ailleurs, et plus
|
|
78
|
+
finement : une tâche qui dépend explicitement d'une tâche en échec reste
|
|
79
|
+
bloquée par `dependenciesDone`, et le planificateur la marque `skipped`
|
|
80
|
+
(cf. blockedByFailedDependency). On distingue ainsi « la suite ne peut pas se
|
|
81
|
+
faire » de « la suite peut se faire sur ce qui a réussi ».
|
|
82
|
+
*/
|
|
67
83
|
function groupBarrierSatisfied(task, tasks) {
|
|
68
84
|
const groupId = task?.dependsOnGroup;
|
|
69
85
|
if (groupId == null || groupId === '') return true;
|
|
70
86
|
const groupTasks = tasks.filter((candidate) => taskGroupId(candidate) === String(groupId));
|
|
71
87
|
if (groupTasks.length === 0) return false;
|
|
72
|
-
return groupTasks.every((candidate) =>
|
|
88
|
+
return groupTasks.every((candidate) => isTerminal(statusOf(candidate)));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Tâches en attente qui ne deviendront JAMAIS exécutables, parce qu'une de
|
|
93
|
+
* leurs dépendances directes est terminale sans avoir réussi.
|
|
94
|
+
*
|
|
95
|
+
* Sans cette liste, le planificateur ne pouvait que constater « plus aucune
|
|
96
|
+
* tâche prête » et déclarer le plan bloqué — ce qui déclenchait une
|
|
97
|
+
* replanification, donc un run qui ne se termine pas. Les nommer permet de les
|
|
98
|
+
* marquer `skipped` avec leur motif, de finaliser le run sur un résultat
|
|
99
|
+
* partiel, et de dire à l'utilisateur ce qui n'a pas été fait et pourquoi.
|
|
100
|
+
*/
|
|
101
|
+
export function blockedByFailedDependency(dag) {
|
|
102
|
+
const tasks = normalizeTasks(dag);
|
|
103
|
+
const statusById = new Map(tasks.map((task) => [taskId(task), statusOf(task)]));
|
|
104
|
+
return tasks
|
|
105
|
+
.filter((task) => isPending(statusOf(task)))
|
|
106
|
+
.map((task) => {
|
|
107
|
+
const culprits = dependsOn(task)
|
|
108
|
+
.map(String)
|
|
109
|
+
.filter((dependency) => isUnsuccessfulTerminal(statusById.get(dependency) ?? ''));
|
|
110
|
+
return culprits.length > 0 ? { task, dependencies: culprits } : null;
|
|
111
|
+
})
|
|
112
|
+
.filter(Boolean);
|
|
73
113
|
}
|
|
74
114
|
|
|
75
115
|
function agentSane(task, registry) {
|
|
@@ -135,5 +175,5 @@ function taskId(task) {
|
|
|
135
175
|
}
|
|
136
176
|
|
|
137
177
|
export function isTerminalTask(task) {
|
|
138
|
-
return
|
|
178
|
+
return isTerminal(statusOf(task));
|
|
139
179
|
}
|
|
@@ -2,8 +2,8 @@ import { normalizeActivity, parseJsonText } from '../core/activity.js';
|
|
|
2
2
|
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
3
3
|
import { callMcpTool, formatMcpToolResult } from '../core/mcp.js';
|
|
4
4
|
import { emitRuntimeLog, pollActivitiesOnce } from '../runtime/supervisor.js';
|
|
5
|
+
import { isSuccessful, isTerminal } from './taskStatuses.js';
|
|
5
6
|
|
|
6
|
-
const TERMINAL_STATUSES = new Set(['done', 'failed', 'cancelled', 'canceled', 'complete', 'completed', 'success', 'succeeded', 'error']);
|
|
7
7
|
|
|
8
8
|
export function createDispatcher({
|
|
9
9
|
session = null,
|
|
@@ -205,7 +205,7 @@ function dispatchTaskActivity(session, task, assignment, jobId, statusTool, runI
|
|
|
205
205
|
function taskResultFromStatus(task, assignment, jobId, statusPayload, attempt = null) {
|
|
206
206
|
const result = statusPayload?.result ?? {};
|
|
207
207
|
const resultStatus = String(result.status ?? statusPayload?.status ?? '').toLowerCase();
|
|
208
|
-
const ok =
|
|
208
|
+
const ok = isSuccessful(resultStatus);
|
|
209
209
|
return {
|
|
210
210
|
ok,
|
|
211
211
|
taskId: String(task.id ?? task.step),
|
|
@@ -332,9 +332,6 @@ function taskLogPayload(event, task, assignment, {
|
|
|
332
332
|
};
|
|
333
333
|
}
|
|
334
334
|
|
|
335
|
-
function isTerminal(status) {
|
|
336
|
-
return TERMINAL_STATUSES.has(String(status ?? '').toLowerCase());
|
|
337
|
-
}
|
|
338
335
|
|
|
339
336
|
function delay(ms, signal) {
|
|
340
337
|
return new Promise((resolve, reject) => {
|
|
@@ -2,8 +2,8 @@ import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
|
2
2
|
import { readyPlanTasks } from '../core/planPatch.js';
|
|
3
3
|
import { applyApprovalCoverage } from './approvalPolicy.js';
|
|
4
4
|
import { isValidatedFragment, validateFragment } from './planValidator.js';
|
|
5
|
+
import { isTerminal } from './taskStatuses.js';
|
|
5
6
|
|
|
6
|
-
const TERMINAL_STATUSES = new Set(['done', 'failed', 'cancelled', 'canceled', 'error', 'complete', 'completed', 'success']);
|
|
7
7
|
|
|
8
8
|
export function integrate(runId, fragment, {
|
|
9
9
|
registry,
|
|
@@ -218,11 +218,11 @@ function mergePlan(currentPlan, newTasks, { insertBeforeTasks, insertAfterTasks
|
|
|
218
218
|
|
|
219
219
|
function firstTerminalMutation(current, beforeIds, afterIds) {
|
|
220
220
|
for (const id of beforeIds) {
|
|
221
|
-
if (
|
|
221
|
+
if (taskIsTerminal(current.get(id))) return id;
|
|
222
222
|
}
|
|
223
223
|
for (const id of afterIds) {
|
|
224
224
|
const dependents = [...current.values()].filter((task) => (task.dependsOn ?? []).includes(id));
|
|
225
|
-
const terminal = dependents.find(
|
|
225
|
+
const terminal = dependents.find(taskIsTerminal);
|
|
226
226
|
if (terminal) return terminal.id;
|
|
227
227
|
}
|
|
228
228
|
return null;
|
|
@@ -251,8 +251,8 @@ function currentRevision(session) {
|
|
|
251
251
|
return Number.isInteger(session?.planRevision) && session.planRevision >= 0 ? session.planRevision : 0;
|
|
252
252
|
}
|
|
253
253
|
|
|
254
|
-
function
|
|
255
|
-
return
|
|
254
|
+
function taskIsTerminal(task) {
|
|
255
|
+
return isTerminal(task?.status);
|
|
256
256
|
}
|
|
257
257
|
|
|
258
258
|
function stringifyList(value) {
|
|
@@ -5,6 +5,7 @@ import { callMcpTool, formatMcpToolResult } from '../core/mcp.js';
|
|
|
5
5
|
import { resolve as resolveCapability } from './capabilityResolver.js';
|
|
6
6
|
import { integrate } from './planIntegrator.js';
|
|
7
7
|
import { validateFragment } from './planValidator.js';
|
|
8
|
+
import { isSuccessful } from './taskStatuses.js';
|
|
8
9
|
|
|
9
10
|
export function createResultAggregator({
|
|
10
11
|
session = null,
|
|
@@ -188,7 +189,7 @@ function rejectExpansion({ session, runId, taskId, store, errors }) {
|
|
|
188
189
|
|
|
189
190
|
function resultOk(result) {
|
|
190
191
|
const status = String(result?.status ?? result?.result?.status ?? '').toLowerCase();
|
|
191
|
-
return result?.ok === true ||
|
|
192
|
+
return result?.ok === true || isSuccessful(status);
|
|
192
193
|
}
|
|
193
194
|
|
|
194
195
|
function cancelled(result) {
|
|
@@ -4,7 +4,7 @@ import { join } from 'node:path';
|
|
|
4
4
|
import test from 'node:test';
|
|
5
5
|
|
|
6
6
|
import { createBudgetManager } from './budgetManager.js';
|
|
7
|
-
import { readyTasks, tasksAwaitingApproval } from './dependencyResolver.js';
|
|
7
|
+
import { blockedByFailedDependency, readyTasks, tasksAwaitingApproval } from './dependencyResolver.js';
|
|
8
8
|
import { createLockManager } from './lockManager.js';
|
|
9
9
|
import {
|
|
10
10
|
describePlanConcurrency,
|
|
@@ -244,3 +244,64 @@ function task(id, overrides = {}) {
|
|
|
244
244
|
...overrides,
|
|
245
245
|
};
|
|
246
246
|
}
|
|
247
|
+
|
|
248
|
+
/*
|
|
249
|
+
Cas observé le 2026-08-04 (workspace juno) : une ingestion de dix fichiers,
|
|
250
|
+
neuf réussis, le dixième en échec sur du JSON malformé. La barrière de groupe
|
|
251
|
+
exigeait que TOUS les membres soient `done` : elle ne s'est jamais ouverte, le
|
|
252
|
+
planificateur n'a plus trouvé de tâche prête, et le run est resté `running`
|
|
253
|
+
indéfiniment. Un incident sur un document devenait une panne totale.
|
|
254
|
+
*/
|
|
255
|
+
test('une barrière de groupe s’ouvre sur un groupe terminal, pas parfait', () => {
|
|
256
|
+
const plan = [
|
|
257
|
+
{ id: 'f1', groupId: 'ingest', status: 'done', requiredCapability: 'knowledge.ingest' },
|
|
258
|
+
{ id: 'f2', groupId: 'ingest', status: 'failed', requiredCapability: 'knowledge.ingest' },
|
|
259
|
+
{ id: 'index', dependsOnGroup: 'ingest', status: 'pending', requiredCapability: 'knowledge.index' },
|
|
260
|
+
];
|
|
261
|
+
|
|
262
|
+
const ready = readyTasks(plan).map((task) => task.id);
|
|
263
|
+
|
|
264
|
+
// La suite du plan peut se faire sur ce qui a réussi.
|
|
265
|
+
assert.deepEqual(ready, ['index']);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('une barrière reste fermée tant que le groupe n’est pas terminal', () => {
|
|
269
|
+
const plan = [
|
|
270
|
+
{ id: 'f1', groupId: 'ingest', status: 'done', requiredCapability: 'knowledge.ingest' },
|
|
271
|
+
{ id: 'f2', groupId: 'ingest', status: 'running', requiredCapability: 'knowledge.ingest' },
|
|
272
|
+
{ id: 'index', dependsOnGroup: 'ingest', status: 'pending', requiredCapability: 'knowledge.index' },
|
|
273
|
+
];
|
|
274
|
+
|
|
275
|
+
assert.deepEqual(readyTasks(plan).map((task) => task.id), []);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test('une dépendance en échec nomme les tâches à ignorer plutôt que de bloquer', () => {
|
|
279
|
+
const plan = [
|
|
280
|
+
{ id: 'convert', status: 'failed', requiredCapability: 'documents.convert' },
|
|
281
|
+
{ id: 'ingest', dependsOn: ['convert'], status: 'pending', requiredCapability: 'knowledge.ingest' },
|
|
282
|
+
{ id: 'other', status: 'pending', requiredCapability: 'knowledge.ingest' },
|
|
283
|
+
];
|
|
284
|
+
|
|
285
|
+
const blocked = blockedByFailedDependency(plan);
|
|
286
|
+
|
|
287
|
+
// Seule la tâche qui en dépend est concernée : la branche indépendante
|
|
288
|
+
// continue, c'est tout l'objet du correctif.
|
|
289
|
+
assert.equal(blocked.length, 1);
|
|
290
|
+
assert.equal(blocked[0].task.id, 'ingest');
|
|
291
|
+
assert.deepEqual(blocked[0].dependencies, ['convert']);
|
|
292
|
+
assert.deepEqual(readyTasks(plan).map((task) => task.id), ['other']);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test('une tâche ignorée ne rebloque pas ses propres descendants', () => {
|
|
296
|
+
const plan = [
|
|
297
|
+
{ id: 'convert', status: 'failed', requiredCapability: 'documents.convert' },
|
|
298
|
+
{ id: 'ingest', dependsOn: ['convert'], status: 'skipped', requiredCapability: 'knowledge.ingest' },
|
|
299
|
+
{ id: 'publish', dependsOn: ['ingest'], status: 'pending', requiredCapability: 'knowledge.publish' },
|
|
300
|
+
];
|
|
301
|
+
|
|
302
|
+
// `skipped` est terminal et non réussi : le descendant est à son tour
|
|
303
|
+
// signalé, ce qui fait descendre la propagation jusqu'au bout du plan au
|
|
304
|
+
// lieu de laisser un résidu en attente éternelle.
|
|
305
|
+
const blocked = blockedByFailedDependency(plan);
|
|
306
|
+
assert.deepEqual(blocked.map((entry) => entry.task.id), ['publish']);
|
|
307
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vocabulaire unique des statuts de tâche.
|
|
3
|
+
*
|
|
4
|
+
* Quatorze modules portaient chacun sa propre liste : `['done', 'failed',
|
|
5
|
+
* 'cancelled']` ici, un `Set` avec `success` et `succeeded` là, un troisième
|
|
6
|
+
* qui ajoutait `error` mais oubliait `canceled`. Aucune n'était fausse
|
|
7
|
+
* isolément ; ensemble elles ne décrivaient pas le même monde. Un statut
|
|
8
|
+
* `skipped` introduit dans l'ordonnanceur était terminal pour lui, inconnu
|
|
9
|
+
* pour la projection — qui le lisait comme un succès — et non terminal pour
|
|
10
|
+
* les panneaux, où la tâche tournait indéfiniment.
|
|
11
|
+
*
|
|
12
|
+
* Ce module est donc la seule définition. Les alias existent parce que les
|
|
13
|
+
* agents externes en produisent : `error` pour `failed`, `succeeded` pour
|
|
14
|
+
* `done`, `canceled` pour `cancelled`. Les normaliser à l'entrée évite d'avoir
|
|
15
|
+
* à les reconnaître à chaque comparaison.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Succès : la tâche a produit ce qu'on attendait d'elle. */
|
|
19
|
+
export const SUCCESS_STATUSES = Object.freeze(['done', 'complete', 'completed', 'success', 'succeeded']);
|
|
20
|
+
/** Échec : la tâche a été tentée et n'a pas abouti. */
|
|
21
|
+
export const FAILURE_STATUSES = Object.freeze(['failed', 'error', 'stalled']);
|
|
22
|
+
/** Annulation : arrêtée par une décision, pas par un défaut. */
|
|
23
|
+
export const CANCELLED_STATUSES = Object.freeze(['cancelled', 'canceled']);
|
|
24
|
+
/** Abandon : jamais tentée, parce qu'elle ne pouvait plus l'être. */
|
|
25
|
+
export const SKIPPED_STATUSES = Object.freeze(['skipped']);
|
|
26
|
+
/** En attente : pas encore exécutable, mais susceptible de le devenir. */
|
|
27
|
+
export const PENDING_STATUSES_LIST = Object.freeze(['pending', 'pending_approval', 'waiting_approval']);
|
|
28
|
+
/** En cours : un agent y travaille en ce moment. */
|
|
29
|
+
export const ACTIVE_STATUSES = Object.freeze(['running', 'in_progress', 'started', 'starting']);
|
|
30
|
+
|
|
31
|
+
const ALIASES = new Map([
|
|
32
|
+
...SUCCESS_STATUSES.map((status) => [status, 'done']),
|
|
33
|
+
...FAILURE_STATUSES.map((status) => [status, 'failed']),
|
|
34
|
+
...CANCELLED_STATUSES.map((status) => [status, 'cancelled']),
|
|
35
|
+
...SKIPPED_STATUSES.map((status) => [status, 'skipped']),
|
|
36
|
+
...ACTIVE_STATUSES.map((status) => [status, 'running']),
|
|
37
|
+
// Les statuts d'attente restent distincts : `pending_approval` et
|
|
38
|
+
// `waiting_approval` ne demandent pas la même chose que `pending`, et les
|
|
39
|
+
// confondre ferait disparaître les demandes d'approbation.
|
|
40
|
+
...PENDING_STATUSES_LIST.map((status) => [status, status]),
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Statut canonique, ou `null` si le vocabulaire ne le connaît pas.
|
|
45
|
+
*
|
|
46
|
+
* Le `null` est un résultat, pas un accident : c'est lui qui permet aux
|
|
47
|
+
* appelants de traiter l'inconnu comme inconnu plutôt que de le ranger
|
|
48
|
+
* silencieusement du côté qui les arrange.
|
|
49
|
+
*/
|
|
50
|
+
export function normalizeTaskStatus(status) {
|
|
51
|
+
const value = String(status ?? '').trim().toLowerCase();
|
|
52
|
+
if (!value) return null;
|
|
53
|
+
return ALIASES.get(value) ?? null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function isSuccessful(status) {
|
|
57
|
+
return normalizeTaskStatus(status) === 'done';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function isFailed(status) {
|
|
61
|
+
return normalizeTaskStatus(status) === 'failed';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function isCancelled(status) {
|
|
65
|
+
return normalizeTaskStatus(status) === 'cancelled';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function isSkipped(status) {
|
|
69
|
+
return normalizeTaskStatus(status) === 'skipped';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function isPending(status) {
|
|
73
|
+
const normalized = normalizeTaskStatus(status);
|
|
74
|
+
return normalized != null && PENDING_STATUSES_LIST.includes(normalized);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function isActive(status) {
|
|
78
|
+
return normalizeTaskStatus(status) === 'running';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Terminal : plus rien n'arrivera à cette tâche dans ce run. */
|
|
82
|
+
export function isTerminal(status) {
|
|
83
|
+
const normalized = normalizeTaskStatus(status);
|
|
84
|
+
return normalized === 'done' || normalized === 'failed' || normalized === 'cancelled' || normalized === 'skipped';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Terminal sans avoir réussi. Regroupe échec, annulation et abandon — la
|
|
89
|
+
* distinction compte pour le rapport à l'utilisateur, pas pour décider si la
|
|
90
|
+
* suite du plan peut s'appuyer dessus.
|
|
91
|
+
*/
|
|
92
|
+
export function isUnsuccessfulTerminal(status) {
|
|
93
|
+
return isTerminal(status) && !isSuccessful(status);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Vrai pour un statut qu'aucun ensemble ne reconnaît. */
|
|
97
|
+
export function isUnknownStatus(status) {
|
|
98
|
+
return String(status ?? '').trim() !== '' && normalizeTaskStatus(status) == null;
|
|
99
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import test from 'node:test';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
isActive,
|
|
8
|
+
isCancelled,
|
|
9
|
+
isFailed,
|
|
10
|
+
isPending,
|
|
11
|
+
isSkipped,
|
|
12
|
+
isSuccessful,
|
|
13
|
+
isTerminal,
|
|
14
|
+
isUnknownStatus,
|
|
15
|
+
isUnsuccessfulTerminal,
|
|
16
|
+
normalizeTaskStatus,
|
|
17
|
+
} from './taskStatuses.js';
|
|
18
|
+
|
|
19
|
+
test('les alias des agents externes tombent sur le même statut canonique', () => {
|
|
20
|
+
for (const status of ['done', 'complete', 'completed', 'success', 'succeeded', 'DONE', ' Success ']) {
|
|
21
|
+
assert.equal(normalizeTaskStatus(status), 'done', status);
|
|
22
|
+
}
|
|
23
|
+
for (const status of ['failed', 'error', 'stalled']) {
|
|
24
|
+
assert.equal(normalizeTaskStatus(status), 'failed', status);
|
|
25
|
+
}
|
|
26
|
+
for (const status of ['cancelled', 'canceled']) {
|
|
27
|
+
assert.equal(normalizeTaskStatus(status), 'cancelled', status);
|
|
28
|
+
}
|
|
29
|
+
for (const status of ['running', 'in_progress', 'started', 'starting']) {
|
|
30
|
+
assert.equal(normalizeTaskStatus(status), 'running', status);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('les statuts d’attente restent distincts les uns des autres', () => {
|
|
35
|
+
// Les confondre ferait disparaître les demandes d'approbation : une tâche
|
|
36
|
+
// `waiting_approval` réduite à `pending` serait ordonnancée sans accord.
|
|
37
|
+
assert.equal(normalizeTaskStatus('pending'), 'pending');
|
|
38
|
+
assert.equal(normalizeTaskStatus('pending_approval'), 'pending_approval');
|
|
39
|
+
assert.equal(normalizeTaskStatus('waiting_approval'), 'waiting_approval');
|
|
40
|
+
for (const status of ['pending', 'pending_approval', 'waiting_approval']) {
|
|
41
|
+
assert.equal(isPending(status), true, status);
|
|
42
|
+
assert.equal(isTerminal(status), false, status);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('un statut inconnu vaut null, et null n’est ni un succès ni un échec', () => {
|
|
47
|
+
// C'est tout l'objet du module : l'inconnu doit rester inconnu au lieu
|
|
48
|
+
// d'être rangé du côté qui arrange l'appelant.
|
|
49
|
+
assert.equal(normalizeTaskStatus('brouette'), null);
|
|
50
|
+
assert.equal(isUnknownStatus('brouette'), true);
|
|
51
|
+
assert.equal(isSuccessful('brouette'), false);
|
|
52
|
+
assert.equal(isFailed('brouette'), false);
|
|
53
|
+
assert.equal(isTerminal('brouette'), false);
|
|
54
|
+
// Une absence de statut n'est pas un statut inconnu : c'est une absence.
|
|
55
|
+
assert.equal(isUnknownStatus(''), false);
|
|
56
|
+
assert.equal(isUnknownStatus(null), false);
|
|
57
|
+
assert.equal(normalizeTaskStatus(undefined), null);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('skipped est terminal, et terminal sans succès', () => {
|
|
61
|
+
assert.equal(isSkipped('skipped'), true);
|
|
62
|
+
assert.equal(isTerminal('skipped'), true);
|
|
63
|
+
assert.equal(isSuccessful('skipped'), false);
|
|
64
|
+
assert.equal(isUnsuccessfulTerminal('skipped'), true);
|
|
65
|
+
for (const status of ['failed', 'error', 'cancelled', 'canceled']) {
|
|
66
|
+
assert.equal(isUnsuccessfulTerminal(status), true, status);
|
|
67
|
+
}
|
|
68
|
+
assert.equal(isUnsuccessfulTerminal('done'), false);
|
|
69
|
+
assert.equal(isActive('running'), true);
|
|
70
|
+
assert.equal(isCancelled('canceled'), true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/*
|
|
74
|
+
Le vocabulaire ne vaut que s'il est le seul. Ce test échoue dès qu'un module
|
|
75
|
+
réintroduit sa propre liste — c'est exactement par là que la divergence est
|
|
76
|
+
revenue la première fois, une liste à la fois, chacune raisonnable seule.
|
|
77
|
+
*/
|
|
78
|
+
test('aucun module ne redéclare sa propre liste de statuts', () => {
|
|
79
|
+
const root = new URL('../..', import.meta.url).pathname;
|
|
80
|
+
const offenders = [];
|
|
81
|
+
const walk = (dir) => {
|
|
82
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
83
|
+
const path = join(dir, entry.name);
|
|
84
|
+
if (entry.isDirectory()) {
|
|
85
|
+
walk(path);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (!entry.name.endsWith('.js') || entry.name.endsWith('.test.js')) continue;
|
|
89
|
+
if (path.endsWith(join('orchestrator', 'taskStatuses.js'))) continue;
|
|
90
|
+
const source = readFileSync(path, 'utf8');
|
|
91
|
+
/*
|
|
92
|
+
Une dérogation se déclare DANS le fichier concerné, pas dans une liste
|
|
93
|
+
de chemins tenue ici. Un run peut être `interrupted`, un job de la file
|
|
94
|
+
peut expirer : ces vocabulaires sont légitimement distincts. Mais une
|
|
95
|
+
liste d'exceptions centralisée vieillit mal — elle survit au fichier
|
|
96
|
+
qu'elle excusait, et redevient la liste locale qu'on voulait supprimer.
|
|
97
|
+
Le marqueur vit à côté du code, se déplace avec lui et se lit avec lui.
|
|
98
|
+
*/
|
|
99
|
+
if (source.includes('@statuses-vocabulary')) continue;
|
|
100
|
+
// Une liste de statuts se reconnaît à la cohabitation de deux marqueurs
|
|
101
|
+
// qui n'ont aucune raison de se croiser ailleurs.
|
|
102
|
+
const suspicious = /['"](?:done|failed)['"][^\n]{0,120}['"](?:cancelled|canceled|succeeded|completed|error)['"]/;
|
|
103
|
+
for (const line of source.split('\n')) {
|
|
104
|
+
if (line.trimStart().startsWith('*') || line.trimStart().startsWith('//')) continue;
|
|
105
|
+
if (suspicious.test(line)) offenders.push(`${path.slice(root.length)}: ${line.trim().slice(0, 100)}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
walk(join(root, 'src'));
|
|
110
|
+
|
|
111
|
+
assert.deepEqual(offenders, []);
|
|
112
|
+
});
|