@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.
- package/README.md +5 -0
- package/docker-compose.yml +1 -1
- 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 +34 -39
- package/src/commands/slash.js +11 -1
- package/src/core/activity.js +3 -2
- package/src/core/agentEvents.js +46 -11
- package/src/core/agentEvents.test.js +76 -0
- package/src/core/agentLoop.js +32 -6
- package/src/core/buildInfo.json +2 -2
- package/src/core/dockerCompose.test.js +8 -0
- 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 +6 -7
- package/src/orchestrator/dispatcher.test.js +33 -0
- 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/server.js +24 -1
- package/src/runtime/server.test.js +113 -0
- package/src/runtime/store.js +16 -1
- package/src/runtime/store.test.js +50 -0
- package/src/shell/repl.js +4 -5
package/src/core/planPatch.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { validateContractInDev } from '../contracts/schemas.js';
|
|
2
|
+
import { isUnsuccessfulTerminal } from '../orchestrator/taskStatuses.js';
|
|
2
3
|
|
|
3
4
|
const PATCH_OPS = new Set([
|
|
4
5
|
'add_task',
|
|
@@ -164,7 +165,7 @@ export function sanitizePlanForExecution(plan) {
|
|
|
164
165
|
const done = new Set(tasks.filter((task) => task.status === 'done').map(taskId));
|
|
165
166
|
const terminalBlocked = new Set(
|
|
166
167
|
tasks
|
|
167
|
-
.filter((task) =>
|
|
168
|
+
.filter((task) => isUnsuccessfulTerminal(task.status))
|
|
168
169
|
.map(taskId),
|
|
169
170
|
);
|
|
170
171
|
const hasReady = pending.some((task) => task.dependsOn.every((dep) => done.has(String(dep))));
|
package/src/core/workflow.js
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @statuses-vocabulary
|
|
3
|
+
*
|
|
4
|
+
* DISPLAY normalization for the execution graph, with `added_during_run`,
|
|
5
|
+
* which exists only as a rendering hint.
|
|
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 { aggregateActivity } from '../activity/activityAggregator.js';
|
|
2
11
|
import { calculateWeightedProgress } from '../activity/progressCalculator.js';
|
|
3
12
|
import { aggregateGraph } from '../graph/graphAggregator.js';
|
|
13
|
+
import { isTerminal } from '../orchestrator/taskStatuses.js';
|
|
4
14
|
|
|
5
|
-
const TERMINAL_STATUSES = new Set(['done', 'failed', 'cancelled', 'canceled', 'error', 'complete', 'completed', 'success']);
|
|
6
15
|
const RUNNING_STATUSES = new Set(['running', 'starting', 'queued', 'waiting', 'pending_approval']);
|
|
7
16
|
|
|
8
17
|
// Canonical workflow projection for 0.9.6.
|
|
@@ -278,7 +287,7 @@ function isActiveStatus(status) {
|
|
|
278
287
|
}
|
|
279
288
|
|
|
280
289
|
function isTerminalStatus(status) {
|
|
281
|
-
return
|
|
290
|
+
return isTerminal(status);
|
|
282
291
|
}
|
|
283
292
|
|
|
284
293
|
function findCurrentNode(nodes) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
+
import { isFailed, isCancelled } from '../orchestrator/taskStatuses.js';
|
|
1
2
|
const ALWAYS_VISIBLE = new Set(['run', 'task_group', 'barrier', 'approval']);
|
|
2
3
|
const ACTIVE = new Set(['running', 'queued', 'pending_approval', 'waiting_approval']);
|
|
3
|
-
const ERROR = new Set(['failed', 'error', 'cancelled']);
|
|
4
4
|
|
|
5
5
|
export function applyGraphVisibility(graph, { maxNodes = 18 } = {}) {
|
|
6
6
|
const nodes = graph.nodes ?? [];
|
|
@@ -35,6 +35,6 @@ function mustShow(node) {
|
|
|
35
35
|
const status = String(node.status ?? '').toLowerCase();
|
|
36
36
|
return ALWAYS_VISIBLE.has(node.type)
|
|
37
37
|
|| ACTIVE.has(status)
|
|
38
|
-
||
|
|
38
|
+
|| isFailed(status) || isCancelled(status)
|
|
39
39
|
|| node.type === 'plan_expansion';
|
|
40
40
|
}
|
|
@@ -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,
|
|
@@ -53,7 +53,7 @@ export async function execute(task, assignment, {
|
|
|
53
53
|
session.mcp,
|
|
54
54
|
serverName,
|
|
55
55
|
executeTool,
|
|
56
|
-
executeRequest(task, session),
|
|
56
|
+
executeRequest(task, session, runId),
|
|
57
57
|
signal,
|
|
58
58
|
));
|
|
59
59
|
if (accepted?.accepted === false || accepted?.ok === false) {
|
|
@@ -157,9 +157,11 @@ export async function execute(task, assignment, {
|
|
|
157
157
|
}
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
-
function executeRequest(task, session) {
|
|
160
|
+
function executeRequest(task, session, runId) {
|
|
161
161
|
return {
|
|
162
162
|
taskId: String(task.id ?? task.step),
|
|
163
|
+
...(runId ? { runId: String(runId) } : {}),
|
|
164
|
+
...(task.requiredCapability ? { capability: String(task.requiredCapability) } : {}),
|
|
163
165
|
idempotencyKey: task.idempotencyKey ?? undefined,
|
|
164
166
|
operation: task.operation,
|
|
165
167
|
workspace: workspaceRequest(session),
|
|
@@ -203,7 +205,7 @@ function dispatchTaskActivity(session, task, assignment, jobId, statusTool, runI
|
|
|
203
205
|
function taskResultFromStatus(task, assignment, jobId, statusPayload, attempt = null) {
|
|
204
206
|
const result = statusPayload?.result ?? {};
|
|
205
207
|
const resultStatus = String(result.status ?? statusPayload?.status ?? '').toLowerCase();
|
|
206
|
-
const ok =
|
|
208
|
+
const ok = isSuccessful(resultStatus);
|
|
207
209
|
return {
|
|
208
210
|
ok,
|
|
209
211
|
taskId: String(task.id ?? task.step),
|
|
@@ -330,9 +332,6 @@ function taskLogPayload(event, task, assignment, {
|
|
|
330
332
|
};
|
|
331
333
|
}
|
|
332
334
|
|
|
333
|
-
function isTerminal(status) {
|
|
334
|
-
return TERMINAL_STATUSES.has(String(status ?? '').toLowerCase());
|
|
335
|
-
}
|
|
336
335
|
|
|
337
336
|
function delay(ms, signal) {
|
|
338
337
|
return new Promise((resolve, reject) => {
|
|
@@ -33,6 +33,39 @@ test('dispatcher returns a retryable logical failure when agent_execute reports
|
|
|
33
33
|
assert.equal(result.error.retryable, true);
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
+
test('dispatcher forwards the orchestration run and required capability to agent_execute', async () => {
|
|
37
|
+
let executeArgs;
|
|
38
|
+
const session = {
|
|
39
|
+
workspace: 'test',
|
|
40
|
+
mcp: {
|
|
41
|
+
production: {
|
|
42
|
+
tools: [{ name: 'agent_execute' }, { name: 'agent_status' }, { name: 'agent_cancel' }],
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
activities: {},
|
|
46
|
+
};
|
|
47
|
+
const dispatcher = createDispatcher({
|
|
48
|
+
session,
|
|
49
|
+
pollIntervalMs: 1,
|
|
50
|
+
callTool: async (_mcp, _server, tool, args) => {
|
|
51
|
+
if (tool === 'agent_execute') {
|
|
52
|
+
executeArgs = args;
|
|
53
|
+
return { accepted: true, jobId: 'job-build', status: 'queued' };
|
|
54
|
+
}
|
|
55
|
+
return { jobId: 'job-build', status: 'succeeded', terminal: true, result: { status: 'succeeded' } };
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
await dispatcher.execute(
|
|
60
|
+
{ id: 'build-a', requiredCapability: 'document.build', operation: 'build', arguments: {} },
|
|
61
|
+
{ serverName: 'production', agentInstanceId: 'production-main' },
|
|
62
|
+
{ runId: 'run-donna-1', attempt: { attemptId: 'build-a:attempt-1', locks: [], release() {} } },
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
assert.equal(executeArgs.runId, 'run-donna-1');
|
|
66
|
+
assert.equal(executeArgs.capability, 'document.build');
|
|
67
|
+
});
|
|
68
|
+
|
|
36
69
|
test('dispatcher completes when an executor-only agent reports succeeded', async () => {
|
|
37
70
|
const session = {
|
|
38
71
|
workspace: 'test',
|
|
@@ -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
|
+
}
|