@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
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Intégration d'une délégation préparée dans un run.
|
|
3
|
+
*
|
|
4
|
+
* Ce code n'existait qu'en un point : le handler `/run`, qui recevait une
|
|
5
|
+
* délégation préparée par `/delegate` et la posait comme plan d'un run NEUF.
|
|
6
|
+
* D'où le blocage : un run conversationnel déjà actif appelait `/delegate`
|
|
7
|
+
* pour déléguer sa propre décision, et l'endpoint refusait — à juste titre de
|
|
8
|
+
* son point de vue — parce qu'un run tournait déjà. Le run refusait sa propre
|
|
9
|
+
* délégation.
|
|
10
|
+
*
|
|
11
|
+
* Déléguer n'est pas démarrer un second run : c'est faire passer le run en
|
|
12
|
+
* cours de la décision à l'exécution. La boucle sait déjà le faire — elle
|
|
13
|
+
* repasse au planificateur parallèle dès qu'un plan validé apparaît
|
|
14
|
+
* (`parallelHandoff`) — il ne lui manquait qu'un moyen d'intégrer le fragment
|
|
15
|
+
* sans sortir par le réseau. C'est ce que fait cette fonction, appelée aussi
|
|
16
|
+
* bien par `/run` (nouveau run) que depuis l'intérieur d'un run (délégation
|
|
17
|
+
* interne), avec le même résultat et la même identité de run.
|
|
18
|
+
*/
|
|
19
|
+
import { integrate } from '../orchestrator/planIntegrator.js';
|
|
20
|
+
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
21
|
+
import { emitRuntimeLog } from './supervisor.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Une délégation n'est auto-approuvée que sur opt-in explicite : par défaut le
|
|
25
|
+
* run attend une décision humaine, et la fenêtre `pending_approval` doit être
|
|
26
|
+
* visible assez longtemps pour qu'une UI la rende.
|
|
27
|
+
*/
|
|
28
|
+
export function resolvePreparedDelegationApproval({
|
|
29
|
+
autoApprove = false,
|
|
30
|
+
approvalManager = null,
|
|
31
|
+
runId,
|
|
32
|
+
} = {}) {
|
|
33
|
+
if (autoApprove !== true || typeof approvalManager?.approve !== 'function') {
|
|
34
|
+
return { approved: false, awaitingApproval: true };
|
|
35
|
+
}
|
|
36
|
+
const result = approvalManager.approve({ scope: 'run', runId });
|
|
37
|
+
return { approved: true, awaitingApproval: false, result };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Le run porte-t-il déjà un plan validé par un agent ?
|
|
42
|
+
*
|
|
43
|
+
* Une tâche structurée se reconnaît à son couple capacité/opération : c'est ce
|
|
44
|
+
* qui la distingue d'une étape de plan conversationnelle, qui n'est qu'une
|
|
45
|
+
* phrase. Le critère est celui de `shouldUseParallelScheduler`, à dessein —
|
|
46
|
+
* ce qui déclenche la bascule et ce qui interdit une seconde délégation
|
|
47
|
+
* doivent désigner le même objet.
|
|
48
|
+
*/
|
|
49
|
+
export function hasStructuredPlan(session) {
|
|
50
|
+
return (session?.headlessPlan ?? []).some((task) => task?.requiredCapability && task?.operation);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function integratePreparedDelegation({
|
|
54
|
+
session,
|
|
55
|
+
store = null,
|
|
56
|
+
runId,
|
|
57
|
+
prepared,
|
|
58
|
+
registry,
|
|
59
|
+
approvalManager = null,
|
|
60
|
+
autoApprove = false,
|
|
61
|
+
}) {
|
|
62
|
+
if (!prepared?.fragment) throw new Error('Delegation carries no validated fragment.');
|
|
63
|
+
const integrated = integrate(runId, prepared.fragment, {
|
|
64
|
+
registry,
|
|
65
|
+
session,
|
|
66
|
+
store,
|
|
67
|
+
workspace: session.workspace ?? null,
|
|
68
|
+
enforceApprovalCoverage: true,
|
|
69
|
+
});
|
|
70
|
+
if (!integrated.ok) {
|
|
71
|
+
throw new Error(`Delegated plan integration failed: ${(integrated.errors ?? [])
|
|
72
|
+
.map((error) => error.message ?? error.code ?? String(error))
|
|
73
|
+
.join('; ')}`);
|
|
74
|
+
}
|
|
75
|
+
emitRuntimeLog(
|
|
76
|
+
session,
|
|
77
|
+
`delegation: ${prepared.fragment.tasks.length} validated task(s) integrated from ${prepared.provider?.serverName ?? 'agent'}.agent_plan (${prepared.capability}/${prepared.operation})`,
|
|
78
|
+
);
|
|
79
|
+
/*
|
|
80
|
+
Marqueur de bascule.
|
|
81
|
+
|
|
82
|
+
La boucle conversationnelle ne pouvait pas deviner qu'un plan structuré
|
|
83
|
+
venait d'apparaître : elle ne regardait que les tâches PRÊTES, et un plan
|
|
84
|
+
intégralement en attente d'approbation n'en compte aucune. Elle concluait
|
|
85
|
+
donc « plus rien à faire », l'évaluateur jugeait le plan incomplet, le
|
|
86
|
+
replanificateur relançait une délégation — et cinq tâches devenaient dix,
|
|
87
|
+
puis quinze. Le drapeau dit ce que ni le nombre de tâches prêtes ni le
|
|
88
|
+
statut ne pouvaient dire : une décision vient d'être prise, la suite n'est
|
|
89
|
+
plus conversationnelle.
|
|
90
|
+
*/
|
|
91
|
+
session._structuredPlanIntegrated = true;
|
|
92
|
+
const approval = resolvePreparedDelegationApproval({ autoApprove, approvalManager, runId });
|
|
93
|
+
emitRuntimeLog(
|
|
94
|
+
session,
|
|
95
|
+
approval.approved
|
|
96
|
+
? `approval: run ${runId} auto-approved (autoApprove opt-in)`
|
|
97
|
+
: `approval: run ${runId} awaiting explicit approval before mutations (/approve or « valide tout »)`,
|
|
98
|
+
);
|
|
99
|
+
return { integrated, approval };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Délégation depuis l'INTÉRIEUR du run courant.
|
|
104
|
+
*
|
|
105
|
+
* Rend un résumé destiné au modèle qui a appelé l'outil. Le `runId` rendu est
|
|
106
|
+
* celui du run en cours, jamais un nouveau : c'est la garantie qu'on n'a pas
|
|
107
|
+
* démarré un second run par la bande, et c'est ce que vérifient les tests.
|
|
108
|
+
*/
|
|
109
|
+
export async function delegateWithinRun(session, objective, {
|
|
110
|
+
prepare,
|
|
111
|
+
registry,
|
|
112
|
+
store = null,
|
|
113
|
+
approvalManager = null,
|
|
114
|
+
autoApprove = false,
|
|
115
|
+
}) {
|
|
116
|
+
const runId = session?._currentRunIdentity?.runId ?? null;
|
|
117
|
+
if (!runId) throw new Error('No active run identity: in-run delegation requires a running run.');
|
|
118
|
+
/*
|
|
119
|
+
Un run n'a qu'un plan.
|
|
120
|
+
|
|
121
|
+
Garde-fou défensif : si la boucle rappelle l'outil alors qu'un plan
|
|
122
|
+
structuré est déjà en place, intégrer un second fragment dupliquerait le
|
|
123
|
+
travail au lieu de le remplacer — c'est très exactement ce qu'on a observé,
|
|
124
|
+
cinq tâches devenues trente-cinq. Le refus est explicite plutôt que
|
|
125
|
+
silencieux : le modèle doit lire qu'il redemande une chose déjà faite.
|
|
126
|
+
*/
|
|
127
|
+
if (hasStructuredPlan(session)) {
|
|
128
|
+
throw new Error('This run already carries a validated plan: it is executing, not deciding.');
|
|
129
|
+
}
|
|
130
|
+
const prepared = await prepare({ session, objective });
|
|
131
|
+
const { approval } = integratePreparedDelegation({
|
|
132
|
+
session,
|
|
133
|
+
store,
|
|
134
|
+
runId,
|
|
135
|
+
prepared,
|
|
136
|
+
registry,
|
|
137
|
+
approvalManager,
|
|
138
|
+
autoApprove,
|
|
139
|
+
});
|
|
140
|
+
// Le plan validé est en place : la boucle du run le verra au tour suivant et
|
|
141
|
+
// basculera d'elle-même sur le planificateur parallèle (parallelHandoff).
|
|
142
|
+
dispatchAgentEvent(session, createAgentEvent('runtime_log', {
|
|
143
|
+
origin: 'runtime',
|
|
144
|
+
runId,
|
|
145
|
+
payload: { message: `delegation: run ${runId} switched from decision to execution` },
|
|
146
|
+
}));
|
|
147
|
+
return {
|
|
148
|
+
delegated: true,
|
|
149
|
+
runId,
|
|
150
|
+
awaitingApproval: approval.awaitingApproval === true,
|
|
151
|
+
summary: prepared.summary ?? {
|
|
152
|
+
agent: prepared.provider?.serverName ?? null,
|
|
153
|
+
capability: prepared.capability ?? null,
|
|
154
|
+
operation: prepared.operation ?? null,
|
|
155
|
+
tasks: prepared.fragment.tasks.length,
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
@@ -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 {
|