@dotdrelle/wiki-manager 0.15.40 → 0.15.42
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +64 -38
- package/src/commands/slash.js +47 -9
- package/src/core/activity.js +3 -2
- package/src/core/agentEvents.js +68 -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/compose.js +32 -0
- package/src/core/dockerCompose.test.js +32 -0
- package/src/core/jobQueue.js +9 -0
- package/src/core/mcp.js +1 -1
- package/src/core/otherWorkspacesRunning.test.js +51 -0
- package/src/core/plan.js +3 -2
- package/src/core/planPatch.js +2 -1
- package/src/core/profileServiceStatus.test.js +26 -2
- package/src/core/toolLoop.js +32 -7
- package/src/core/toolLoop.test.js +95 -0
- package/src/core/wikiSetup.js +50 -7
- package/src/core/wikiWorkspace.test.js +30 -0
- package/src/core/wikirc.test.js +121 -2
- package/src/core/workflow.js +11 -2
- package/src/core/workspaceInherit.js +149 -0
- package/src/core/workspaceInherit.test.js +181 -0
- 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/server.test.js +28 -0
- package/src/runtime/store.js +16 -1
- package/src/runtime/store.test.js +50 -0
- package/src/shell/repl.js +21 -11
- package/src/shell/setupWizardModality.test.js +55 -0
- package/src/shell/tui.tsx +29 -9
- package/wiki-workspace +10 -4
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
|
|
4
|
+
import { delegateWithinRun, integratePreparedDelegation } from './delegation.js';
|
|
5
|
+
import { isTerminal } from '../orchestrator/taskStatuses.js';
|
|
6
|
+
|
|
7
|
+
function task(id) {
|
|
8
|
+
return {
|
|
9
|
+
id,
|
|
10
|
+
label: `Task ${id}`,
|
|
11
|
+
requiredCapability: 'knowledge.update',
|
|
12
|
+
operation: 'ingest',
|
|
13
|
+
arguments: { inputs: [`raw/untracked/${id}.md`] },
|
|
14
|
+
groupId: 'ingest',
|
|
15
|
+
dependsOn: [],
|
|
16
|
+
parallelizable: true,
|
|
17
|
+
inputRefs: [{ type: 'file', ref: `raw/untracked/${id}.md` }],
|
|
18
|
+
expectedOutputRefs: [{ type: 'file', ref: `.wiki/ingest-plans/${id}.json` }],
|
|
19
|
+
locks: ['workspace-write'],
|
|
20
|
+
requiresApproval: true,
|
|
21
|
+
idempotencyKey: `idem-${id}`,
|
|
22
|
+
progressWeight: 1,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function fragment(taskId = 'build-1') {
|
|
27
|
+
return {
|
|
28
|
+
contractVersion: '1',
|
|
29
|
+
agentInstanceId: 'production-main',
|
|
30
|
+
capability: 'knowledge.update',
|
|
31
|
+
summary: { label: 'Update knowledge', initialSynthesis: ['Build.'], estimatedTasks: 1 },
|
|
32
|
+
groups: [{ id: 'ingest', label: 'Ingest sources', recommendedConcurrency: 2, progressWeight: 1 }],
|
|
33
|
+
tasks: [task(taskId)],
|
|
34
|
+
expectedOutputs: [{ type: 'directory', ref: 'wiki' }],
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function preparedDelegation(taskId) {
|
|
39
|
+
return {
|
|
40
|
+
capability: 'knowledge.update',
|
|
41
|
+
operation: 'ingest',
|
|
42
|
+
provider: { serverName: 'production' },
|
|
43
|
+
fragment: fragment(taskId),
|
|
44
|
+
summary: { agent: 'production', capability: 'knowledge.update', operation: 'ingest', tasks: 1 },
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function runningSession(runId = 'run-conversational') {
|
|
49
|
+
return {
|
|
50
|
+
workspace: 'juno',
|
|
51
|
+
activities: {},
|
|
52
|
+
agentEvents: [],
|
|
53
|
+
headlessPlan: null,
|
|
54
|
+
_currentRunIdentity: { runId, workspace: 'juno' },
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Le registre est un double minimal : la question posée ici n'est pas la
|
|
59
|
+
// validation du plan (couverte par planIntegrator.test.js) mais l'identité du
|
|
60
|
+
// run auquel la délégation appartient.
|
|
61
|
+
function registryDouble() {
|
|
62
|
+
return {
|
|
63
|
+
providersFor(capability) {
|
|
64
|
+
if (capability !== 'knowledge.update') return [];
|
|
65
|
+
return [{
|
|
66
|
+
serverName: 'production',
|
|
67
|
+
agentInstanceId: 'production-main',
|
|
68
|
+
health: 'available',
|
|
69
|
+
capability: {
|
|
70
|
+
id: 'knowledge.update',
|
|
71
|
+
version: '1',
|
|
72
|
+
description: 'Knowledge update',
|
|
73
|
+
inputSchema: {
|
|
74
|
+
type: 'object',
|
|
75
|
+
required: ['inputs'],
|
|
76
|
+
additionalProperties: true,
|
|
77
|
+
properties: { inputs: { type: 'array', items: { type: 'string' } } },
|
|
78
|
+
},
|
|
79
|
+
outputSchema: {},
|
|
80
|
+
supportedOperations: ['ingest'],
|
|
81
|
+
mutationClass: 'workspace',
|
|
82
|
+
defaultRequiresApproval: true,
|
|
83
|
+
},
|
|
84
|
+
description: { contractVersion: '1' },
|
|
85
|
+
}];
|
|
86
|
+
},
|
|
87
|
+
isCompatible: (contractVersion) => String(contractVersion) === '1',
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
test('une demande de build délègue dans le run courant, sans en démarrer un second', async () => {
|
|
92
|
+
/*
|
|
93
|
+
Le run conversationnel se marquait actif AVANT que Donna appelle delegate.
|
|
94
|
+
L'appel repartait alors vers POST /delegate, qui refusait par 409 : le run
|
|
95
|
+
refusait sa propre délégation. Déléguer n'est pas démarrer un second run,
|
|
96
|
+
c'est faire passer celui-ci de la décision à l'exécution.
|
|
97
|
+
*/
|
|
98
|
+
const session = runningSession('run-fr-build');
|
|
99
|
+
const started = [];
|
|
100
|
+
|
|
101
|
+
const result = await delegateWithinRun(session, 'construis les livrables du workspace juno', {
|
|
102
|
+
prepare: async ({ objective }) => {
|
|
103
|
+
assert.match(objective, /construis les livrables/);
|
|
104
|
+
return preparedDelegation('build-fr');
|
|
105
|
+
},
|
|
106
|
+
registry: registryDouble(),
|
|
107
|
+
startRun: (...args) => started.push(args),
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
assert.equal(result.delegated, true);
|
|
111
|
+
// Identité unique : le runId rendu est celui du run en cours.
|
|
112
|
+
assert.equal(result.runId, 'run-fr-build');
|
|
113
|
+
assert.equal(result.summary.tasks, 1);
|
|
114
|
+
// Aucun second run concurrent n'a été démarré par la bande.
|
|
115
|
+
assert.deepEqual(started, []);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('la délégation interne exige un run actif', async () => {
|
|
119
|
+
// Hors run, il n'y a pas d'exécution à transformer : c'est un vrai démarrage
|
|
120
|
+
// de run, et il doit passer par le chemin normal plutôt que par ici.
|
|
121
|
+
const session = { workspace: 'juno', agentEvents: [] };
|
|
122
|
+
|
|
123
|
+
await assert.rejects(
|
|
124
|
+
() => delegateWithinRun(session, 'construis les livrables', {
|
|
125
|
+
prepare: async () => preparedDelegation(),
|
|
126
|
+
registry: registryDouble(),
|
|
127
|
+
}),
|
|
128
|
+
/in-run delegation requires a running run/,
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('un fragment non intégrable est refusé explicitement, sans plan à moitié posé', async () => {
|
|
133
|
+
const session = runningSession();
|
|
134
|
+
|
|
135
|
+
await assert.rejects(
|
|
136
|
+
() => delegateWithinRun(session, 'objectif', {
|
|
137
|
+
prepare: async () => ({ ...preparedDelegation(), fragment: null }),
|
|
138
|
+
registry: registryDouble(),
|
|
139
|
+
}),
|
|
140
|
+
/carries no validated fragment/,
|
|
141
|
+
);
|
|
142
|
+
assert.equal(session.headlessPlan, null);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('l’approbation reste requise par défaut, et n’est levée que sur opt-in', () => {
|
|
146
|
+
const approvals = [];
|
|
147
|
+
const approvalManager = { approve: (request) => { approvals.push(request); return { ok: true }; } };
|
|
148
|
+
const session = runningSession('run-approval');
|
|
149
|
+
|
|
150
|
+
const waiting = integratePreparedDelegation({
|
|
151
|
+
session,
|
|
152
|
+
runId: 'run-approval',
|
|
153
|
+
prepared: preparedDelegation('build-waiting'),
|
|
154
|
+
registry: registryDouble(),
|
|
155
|
+
approvalManager,
|
|
156
|
+
});
|
|
157
|
+
assert.equal(waiting.approval.awaitingApproval, true);
|
|
158
|
+
assert.deepEqual(approvals, []);
|
|
159
|
+
|
|
160
|
+
const auto = integratePreparedDelegation({
|
|
161
|
+
session,
|
|
162
|
+
runId: 'run-approval',
|
|
163
|
+
prepared: preparedDelegation('build-auto'),
|
|
164
|
+
registry: registryDouble(),
|
|
165
|
+
approvalManager,
|
|
166
|
+
autoApprove: true,
|
|
167
|
+
});
|
|
168
|
+
assert.equal(auto.approval.approved, true);
|
|
169
|
+
assert.deepEqual(approvals, [{ scope: 'run', runId: 'run-approval' }]);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
/*
|
|
173
|
+
Test d'INTÉGRATION, pas de délégation isolée.
|
|
174
|
+
|
|
175
|
+
Les tests unitaires de `delegateWithinRun` passaient tous pendant que le run
|
|
176
|
+
dupliquait son plan à chaud : 5 tâches, puis 10, 15, 20, 35. Ils vérifiaient
|
|
177
|
+
la délégation ; personne ne vérifiait ce que la boucle en faisait ensuite.
|
|
178
|
+
|
|
179
|
+
Le parcours complet est donc rejoué ici : demande française → délégation →
|
|
180
|
+
intégration → bascule → planificateur. Le seul chiffre qui compte est le
|
|
181
|
+
dernier : toujours exactement cinq tâches.
|
|
182
|
+
*/
|
|
183
|
+
test('une demande française délègue une fois et bascule, sans jamais dupliquer le plan', async () => {
|
|
184
|
+
const { runRuntimeAgenticWorkflow } = await import('./runner.js');
|
|
185
|
+
const runId = 'run-fr-integration';
|
|
186
|
+
const session = {
|
|
187
|
+
workspace: 'juno',
|
|
188
|
+
activities: {},
|
|
189
|
+
agentEvents: [],
|
|
190
|
+
headlessPlan: null,
|
|
191
|
+
// Posée par executeRun avant l'appel au workflow, comme en production.
|
|
192
|
+
_currentRunIdentity: { runId, workspace: 'juno' },
|
|
193
|
+
llm: { async completeWithTools() { assert.fail('aucune évaluation ne doit avoir lieu ici'); } },
|
|
194
|
+
};
|
|
195
|
+
const fiveTasks = {
|
|
196
|
+
...fragment('build-a'),
|
|
197
|
+
summary: { label: 'Update knowledge', initialSynthesis: ['Build.'], estimatedTasks: 5 },
|
|
198
|
+
groups: [{ id: 'ingest', label: 'Ingest sources', recommendedConcurrency: 4, progressWeight: 5 }],
|
|
199
|
+
tasks: ['a', 'b', 'c', 'd', 'e'].map((suffix) => ({ ...task(`build-${suffix}`), requiresApproval: false })),
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
let delegations = 0;
|
|
203
|
+
let conversationalTurns = 0;
|
|
204
|
+
const agent = {
|
|
205
|
+
async invoke({ session: turnSession }) {
|
|
206
|
+
conversationalTurns += 1;
|
|
207
|
+
// Le tour conversationnel appelle l'outil de délégation, comme Donna.
|
|
208
|
+
const result = await delegateWithinRun(turnSession, 'construis les livrables du workspace juno', {
|
|
209
|
+
prepare: async () => {
|
|
210
|
+
delegations += 1;
|
|
211
|
+
return { ...preparedDelegation(), fragment: fiveTasks };
|
|
212
|
+
},
|
|
213
|
+
registry: registryDouble(),
|
|
214
|
+
});
|
|
215
|
+
assert.equal(result.runId, runId);
|
|
216
|
+
return { response: 'Action lancée.' };
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const jobs = new Map();
|
|
221
|
+
const callTool = async (_mcp, _serverName, toolName, args) => {
|
|
222
|
+
const text = (payload) => ({ content: [{ type: 'text', text: JSON.stringify(payload) }] });
|
|
223
|
+
if (toolName === 'agent_execute') {
|
|
224
|
+
const jobId = `job-${args.taskId}`;
|
|
225
|
+
jobs.set(jobId, args.taskId);
|
|
226
|
+
return text({ accepted: true, jobId, status: 'queued' });
|
|
227
|
+
}
|
|
228
|
+
if (toolName === 'agent_status') {
|
|
229
|
+
return text({
|
|
230
|
+
jobId: args.jobId,
|
|
231
|
+
taskId: jobs.get(args.jobId),
|
|
232
|
+
operation: 'ingest',
|
|
233
|
+
status: 'done',
|
|
234
|
+
progress: { percent: 100 },
|
|
235
|
+
result: { status: 'succeeded', outputRefs: [], metrics: { durationMs: 1 } },
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
if (toolName === 'agent_cancel') return text({ ok: true });
|
|
239
|
+
throw new Error(`unexpected tool: ${toolName}`);
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
// Borne l'attente d'approbation comme en headless : un test ne doit jamais
|
|
243
|
+
// pouvoir se figer sur une décision humaine qui ne viendra pas.
|
|
244
|
+
session._approvalTimeoutMs = 200;
|
|
245
|
+
await runRuntimeAgenticWorkflow(agent, session, 'construis les livrables du workspace juno', {
|
|
246
|
+
runId,
|
|
247
|
+
timeoutMs: 2000,
|
|
248
|
+
maxTurns: 4,
|
|
249
|
+
maxReplans: 2,
|
|
250
|
+
evaluate: false,
|
|
251
|
+
callTool,
|
|
252
|
+
dispatcherPollIntervalMs: 1,
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// Une seule délégation, un seul tour conversationnel : la bascule a eu lieu
|
|
256
|
+
// immédiatement après l'intégration.
|
|
257
|
+
assert.equal(delegations, 1);
|
|
258
|
+
assert.equal(conversationalTurns, 1);
|
|
259
|
+
// Et surtout : cinq tâches, pas dix, pas trente-cinq.
|
|
260
|
+
assert.equal(session.headlessPlan.length, 5);
|
|
261
|
+
// Toutes sont passées par le planificateur : plus aucune n'attend. Le double
|
|
262
|
+
// d'agent ne rejoue pas une exécution réelle — ce que ce test garantit est
|
|
263
|
+
// qu'une seule décision a eu lieu et qu'aucune tâche n'a été dupliquée.
|
|
264
|
+
assert.equal(session.headlessPlan.every((step) => isTerminal(step.status)), true);
|
|
265
|
+
assert.deepEqual([...new Set(session.headlessPlan.map((step) => step.id))].length, 5);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('un run qui porte déjà un plan validé refuse une seconde délégation', async () => {
|
|
269
|
+
// Garde-fou défensif : c'est ce qui transforme une bascule ratée en erreur
|
|
270
|
+
// lisible plutôt qu'en plan qui double à chaque tour.
|
|
271
|
+
const session = runningSession('run-guard');
|
|
272
|
+
session.headlessPlan = [task('build-a')];
|
|
273
|
+
|
|
274
|
+
await assert.rejects(
|
|
275
|
+
() => delegateWithinRun(session, 'construis les livrables', {
|
|
276
|
+
prepare: async () => preparedDelegation(),
|
|
277
|
+
registry: registryDouble(),
|
|
278
|
+
}),
|
|
279
|
+
/already carries a validated plan/,
|
|
280
|
+
);
|
|
281
|
+
});
|
|
@@ -3,9 +3,9 @@ import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
|
3
3
|
import { formatMcpToolResult, callMcpTool as defaultCallMcpTool } from '../core/mcp.js';
|
|
4
4
|
import { createCapabilityRegistry } from '../orchestrator/capabilityRegistry.js';
|
|
5
5
|
import { accept as acceptResult } from '../orchestrator/resultAggregator.js';
|
|
6
|
+
import { isSuccessful, isTerminal } from '../orchestrator/taskStatuses.js';
|
|
6
7
|
|
|
7
8
|
const ACTIVE_TASK_STATUSES = new Set(['running', 'queued', 'starting', 'assigned']);
|
|
8
|
-
const TERMINAL_STATUSES = new Set(['done', 'failed', 'cancelled', 'canceled', 'completed', 'complete', 'success', 'succeeded']);
|
|
9
9
|
|
|
10
10
|
export async function recoverActiveRuns({
|
|
11
11
|
store,
|
|
@@ -169,9 +169,6 @@ function latestAssignment(assignments, attemptId) {
|
|
|
169
169
|
return [...assignments].sort((a, b) => String(b.assignedAt ?? b.attemptId).localeCompare(String(a.assignedAt ?? a.attemptId)))[0] ?? null;
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
-
function isTerminal(status) {
|
|
173
|
-
return TERMINAL_STATUSES.has(String(status ?? '').toLowerCase());
|
|
174
|
-
}
|
|
175
172
|
|
|
176
173
|
function capabilityResolvable(session, capability) {
|
|
177
174
|
const registry = session.capabilityRegistry
|
|
@@ -195,7 +192,7 @@ function parseToolPayload(result) {
|
|
|
195
192
|
function taskResultFromStatus(task, assignment, jobId, statusPayload, attempt) {
|
|
196
193
|
const result = statusPayload?.result ?? {};
|
|
197
194
|
const status = String(result.status ?? statusPayload?.status ?? '').toLowerCase();
|
|
198
|
-
const ok =
|
|
195
|
+
const ok = isSuccessful(status);
|
|
199
196
|
return {
|
|
200
197
|
ok,
|
|
201
198
|
taskId: task.id,
|
|
@@ -167,6 +167,11 @@ test('recoveryManager fails unresolvable-capability tasks and interrupts the run
|
|
|
167
167
|
// A registry that DOES know capabilities, but not the task's one: the plan
|
|
168
168
|
// came from a hallucinated capability (the 0.12.1 incident) — re-attaching
|
|
169
169
|
// it would recreate a forever-waiting queue on every boot.
|
|
170
|
+
store.hydrateSession(session, { workspace: 'docs' });
|
|
171
|
+
// Le registre vivant se pose APRÈS l'hydratation : c'est l'ordre réel
|
|
172
|
+
// (hydrate → invalidate → discover). Le poser avant faisait passer un
|
|
173
|
+
// instantané frais pour une restauration, et masquait le fait que les agents
|
|
174
|
+
// persistés doivent être invalidés.
|
|
170
175
|
session.agentRegistrySnapshot = [{
|
|
171
176
|
agentInstanceId: 'production-main',
|
|
172
177
|
serverName: 'production',
|
|
@@ -177,7 +182,6 @@ test('recoveryManager fails unresolvable-capability tasks and interrupts the run
|
|
|
177
182
|
capabilities: [{ id: 'knowledge.pipeline', version: '1' }],
|
|
178
183
|
},
|
|
179
184
|
}];
|
|
180
|
-
store.hydrateSession(session, { workspace: 'docs' });
|
|
181
185
|
const statusCalls = [];
|
|
182
186
|
|
|
183
187
|
try {
|
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
|
}
|