@dotdrelle/wiki-manager 0.12.12 → 0.14.0
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/docker-compose.yml +1 -1
- package/package.json +1 -1
- package/src/agent/graph.js +331 -143
- package/src/agent/graph.test.js +516 -54
- package/src/agent/llm.js +5 -5
- package/src/cli/wiki-manager.js +225 -6
- package/src/cli/wiki-manager.test.js +28 -0
- package/src/commands/slash.js +32 -11
- package/src/commands/slash.test.js +9 -1
- package/src/core/buildInfo.json +2 -2
- package/src/core/mcp.js +12 -4
- package/src/core/skills.js +0 -28
- package/src/orchestrator/capabilityRegistry.js +14 -0
- package/src/orchestrator/capabilityRegistry.test.js +12 -1
- package/src/orchestrator/dependencyResolver.js +10 -1
- package/src/orchestrator/dispatcher.js +34 -3
- package/src/orchestrator/dispatcher.test.js +34 -0
- package/src/orchestrator/objectiveResolver.js +79 -0
- package/src/orchestrator/objectiveResolver.test.js +50 -0
- package/src/orchestrator/scheduler.test.js +25 -0
- package/src/runtime/client.js +32 -1
- package/src/runtime/recoveryManager.js +14 -7
- package/src/runtime/runner.js +112 -13
- package/src/runtime/runner.test.js +64 -1
- package/src/runtime/server.js +43 -3
- package/src/runtime/supervisor.js +4 -1
- package/src/runtime/supervisor.test.js +49 -0
- package/src/shell/repl.js +45 -38
- package/src/shell/repl.test.js +81 -12
- package/src/shell/useSession.ts +15 -3
|
@@ -8,7 +8,7 @@ const TERMINAL_STATUSES = new Set(['done', 'failed', 'cancelled', 'canceled', 'c
|
|
|
8
8
|
export function createDispatcher({
|
|
9
9
|
session = null,
|
|
10
10
|
callTool = callMcpTool,
|
|
11
|
-
pollIntervalMs =
|
|
11
|
+
pollIntervalMs = 2500,
|
|
12
12
|
} = {}) {
|
|
13
13
|
return {
|
|
14
14
|
execute(task, assignment, options = {}) {
|
|
@@ -30,7 +30,7 @@ export async function execute(task, assignment, {
|
|
|
30
30
|
attempt = null,
|
|
31
31
|
timeoutMs = null,
|
|
32
32
|
pollBusy = new Set(),
|
|
33
|
-
pollIntervalMs =
|
|
33
|
+
pollIntervalMs = 2500,
|
|
34
34
|
} = {}) {
|
|
35
35
|
if (!session) throw new Error('dispatcher.execute requires session.');
|
|
36
36
|
if (!assignment?.serverName) throw new Error(`No MCP server found for agent ${assignment?.agentInstanceId ?? '(unknown)'}.`);
|
|
@@ -57,7 +57,7 @@ export async function execute(task, assignment, {
|
|
|
57
57
|
signal,
|
|
58
58
|
));
|
|
59
59
|
if (accepted?.accepted === false || accepted?.ok === false) {
|
|
60
|
-
|
|
60
|
+
return rejectedTaskResult(task, assignment, accepted, attempt);
|
|
61
61
|
}
|
|
62
62
|
jobId = String(accepted.jobId ?? '');
|
|
63
63
|
if (!jobId) throw new Error('agent_execute did not return jobId.');
|
|
@@ -218,6 +218,37 @@ function taskResultFromStatus(task, assignment, jobId, statusPayload, attempt =
|
|
|
218
218
|
};
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
+
function rejectedTaskResult(task, assignment, payload, attempt = null) {
|
|
222
|
+
const rawError = payload?.error;
|
|
223
|
+
const error = rawError && typeof rawError === 'object'
|
|
224
|
+
? { ...rawError }
|
|
225
|
+
: {
|
|
226
|
+
code: String(rawError ?? 'execution_rejected'),
|
|
227
|
+
message: String(payload?.message ?? rawError ?? 'agent_execute rejected task'),
|
|
228
|
+
retryable: transientError(rawError ?? payload?.message),
|
|
229
|
+
};
|
|
230
|
+
return {
|
|
231
|
+
ok: false,
|
|
232
|
+
taskId: String(task.id ?? task.step),
|
|
233
|
+
attemptId: attempt?.attemptId ?? null,
|
|
234
|
+
jobId: payload?.activeJobId ?? null,
|
|
235
|
+
agentInstanceId: assignment.agentInstanceId,
|
|
236
|
+
status: 'failed',
|
|
237
|
+
outputRefs: [],
|
|
238
|
+
metrics: {},
|
|
239
|
+
error: {
|
|
240
|
+
code: String(error.code ?? 'execution_rejected'),
|
|
241
|
+
message: String(error.message ?? error.code ?? 'agent_execute rejected task'),
|
|
242
|
+
retryable: error.retryable === true || transientError(error.code) || transientError(error.message),
|
|
243
|
+
},
|
|
244
|
+
rawStatus: payload,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function transientError(value) {
|
|
249
|
+
return /(?:429|timeout|temporar|throttl|rate.?limit|quota|busy|unavailable)/i.test(String(value ?? ''));
|
|
250
|
+
}
|
|
251
|
+
|
|
221
252
|
function toolNameFor(session, serverName, baseName) {
|
|
222
253
|
const tools = session.mcp?.[serverName]?.tools ?? [];
|
|
223
254
|
const names = tools.map((tool) => String(tool.name ?? '')).filter(Boolean);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { createDispatcher } from './dispatcher.js';
|
|
4
|
+
|
|
5
|
+
test('dispatcher returns a retryable logical failure when agent_execute reports workspace_busy', async () => {
|
|
6
|
+
const session = {
|
|
7
|
+
workspace: 'test',
|
|
8
|
+
mcp: {
|
|
9
|
+
production: {
|
|
10
|
+
tools: [
|
|
11
|
+
{ name: 'agent_execute' },
|
|
12
|
+
{ name: 'agent_status' },
|
|
13
|
+
{ name: 'agent_cancel' },
|
|
14
|
+
],
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
const dispatcher = createDispatcher({
|
|
19
|
+
session,
|
|
20
|
+
callTool: async () => ({ accepted: false, error: 'workspace_busy', activeJobId: 'job-old' }),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const result = await dispatcher.execute(
|
|
24
|
+
{ id: 'ingest-a', requiredCapability: 'knowledge.update', operation: 'ingest_plan', arguments: {} },
|
|
25
|
+
{ serverName: 'production', agentInstanceId: 'production-main' },
|
|
26
|
+
{ attempt: { attemptId: 'ingest-a:attempt-1', locks: [], release() {} } },
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
assert.equal(result.ok, false);
|
|
30
|
+
assert.equal(result.taskId, 'ingest-a');
|
|
31
|
+
assert.equal(result.attemptId, 'ingest-a:attempt-1');
|
|
32
|
+
assert.equal(result.error.code, 'workspace_busy');
|
|
33
|
+
assert.equal(result.error.retryable, true);
|
|
34
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
export async function resolveObjective(objective, session) {
|
|
2
|
+
const candidates = capabilityCandidates(session);
|
|
3
|
+
if (candidates.length === 0) throw new Error('No orchestrable capability is currently available.');
|
|
4
|
+
const llm = session?.llm;
|
|
5
|
+
if (!llm?.completeWithTools) throw new Error('Objective resolution requires the configured workspace LLM.');
|
|
6
|
+
|
|
7
|
+
const result = await llm.completeWithTools({
|
|
8
|
+
system: [
|
|
9
|
+
'You resolve one user objective against a closed capability registry.',
|
|
10
|
+
'Select exactly one listed capability and one of its supported operations.',
|
|
11
|
+
'Never invent identifiers. Return JSON only: {"capability":"...","operation":"..."}.',
|
|
12
|
+
].join('\n'),
|
|
13
|
+
tools: [],
|
|
14
|
+
messages: [{
|
|
15
|
+
role: 'user',
|
|
16
|
+
content: `Objective:\n${String(objective)}\n\nRegistry:\n${JSON.stringify(candidates, null, 2)}`,
|
|
17
|
+
}],
|
|
18
|
+
signal: session?._abortSignal,
|
|
19
|
+
});
|
|
20
|
+
const selection = parseJson(result?.content);
|
|
21
|
+
const capability = String(selection?.capability ?? '');
|
|
22
|
+
const operation = String(selection?.operation ?? '');
|
|
23
|
+
const candidate = candidates.find((item) => item.id === capability);
|
|
24
|
+
if (!candidate) throw new Error(`Objective resolver selected unknown capability "${capability}".`);
|
|
25
|
+
if (!candidate.operations.includes(operation)) {
|
|
26
|
+
throw new Error(`Objective resolver selected unsupported operation "${operation}" for ${capability}.`);
|
|
27
|
+
}
|
|
28
|
+
const providers = providersFor(session, capability)
|
|
29
|
+
.filter((provider) => !operation || (provider.capability?.supportedOperations ?? []).includes(operation))
|
|
30
|
+
.sort((a, b) => String(a.agentInstanceId).localeCompare(String(b.agentInstanceId)));
|
|
31
|
+
if (providers.length === 0) throw new Error(`No healthy agent provides ${capability}/${operation}.`);
|
|
32
|
+
return { capability, operation, provider: providers[0], candidates };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function capabilityCandidates(session) {
|
|
36
|
+
const snapshot = registrySnapshot(session);
|
|
37
|
+
const byId = new Map();
|
|
38
|
+
for (const [versionedId, providers] of Object.entries(snapshot)) {
|
|
39
|
+
const id = versionedId.includes('@') ? versionedId.slice(0, versionedId.lastIndexOf('@')) : versionedId;
|
|
40
|
+
const operations = [...new Set((providers ?? []).flatMap((provider) => provider?.capability?.supportedOperations ?? []))].sort();
|
|
41
|
+
const description = (providers ?? []).map((provider) => provider?.capability?.description).find(Boolean) ?? '';
|
|
42
|
+
byId.set(id, { id, description, operations });
|
|
43
|
+
}
|
|
44
|
+
return [...byId.values()].filter((item) => item.operations.length > 0).sort((a, b) => a.id.localeCompare(b.id));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function providersFor(session, capability) {
|
|
48
|
+
if (session?.capabilityRegistry?.providersFor) return session.capabilityRegistry.providersFor(capability) ?? [];
|
|
49
|
+
return Object.entries(registrySnapshot(session))
|
|
50
|
+
.filter(([key]) => key === capability || key.startsWith(`${capability}@`))
|
|
51
|
+
.flatMap(([, providers]) => providers ?? []);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function registrySnapshot(session) {
|
|
55
|
+
const registry = session?.capabilityRegistry;
|
|
56
|
+
if (registry?.snapshot) return registry.snapshot();
|
|
57
|
+
if (registry && typeof registry === 'object') return registry;
|
|
58
|
+
const agents = session?.agentRegistry?.snapshot?.() ?? session?.agentRegistrySnapshot ?? [];
|
|
59
|
+
const snapshot = {};
|
|
60
|
+
for (const agent of agents) {
|
|
61
|
+
for (const capability of agent?.description?.capabilities ?? []) {
|
|
62
|
+
const key = `${capability.id}@${capability.version ?? '1'}`;
|
|
63
|
+
(snapshot[key] ??= []).push({
|
|
64
|
+
agentInstanceId: agent.agentInstanceId,
|
|
65
|
+
serverName: agent.serverName,
|
|
66
|
+
capability,
|
|
67
|
+
description: agent.description,
|
|
68
|
+
health: agent.health,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return snapshot;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function parseJson(content) {
|
|
76
|
+
const text = String(content ?? '').trim();
|
|
77
|
+
const fenced = text.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
|
|
78
|
+
return JSON.parse(fenced ? fenced[1] : text);
|
|
79
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { capabilityCandidates, resolveObjective } from './objectiveResolver.js';
|
|
4
|
+
|
|
5
|
+
function sessionWithSelection(selection) {
|
|
6
|
+
const provider = {
|
|
7
|
+
agentInstanceId: 'production-1',
|
|
8
|
+
serverName: 'production',
|
|
9
|
+
capability: {
|
|
10
|
+
id: 'knowledge.update',
|
|
11
|
+
version: '1',
|
|
12
|
+
description: 'Update knowledge from pending sources.',
|
|
13
|
+
supportedOperations: ['ingest'],
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
return {
|
|
17
|
+
capabilityRegistry: {
|
|
18
|
+
snapshot: () => ({ 'knowledge.update@1': [provider] }),
|
|
19
|
+
providersFor: () => [provider],
|
|
20
|
+
},
|
|
21
|
+
llm: {
|
|
22
|
+
completeWithTools: async () => ({ content: JSON.stringify(selection) }),
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test('capabilityCandidates exposes only the closed live registry', () => {
|
|
28
|
+
assert.deepEqual(capabilityCandidates(sessionWithSelection({})), [{
|
|
29
|
+
id: 'knowledge.update',
|
|
30
|
+
description: 'Update knowledge from pending sources.',
|
|
31
|
+
operations: ['ingest'],
|
|
32
|
+
}]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('resolveObjective selects and validates one real provider', async () => {
|
|
36
|
+
const result = await resolveObjective('Ingère tous les fichiers en attente', sessionWithSelection({
|
|
37
|
+
capability: 'knowledge.update',
|
|
38
|
+
operation: 'ingest',
|
|
39
|
+
}));
|
|
40
|
+
assert.equal(result.capability, 'knowledge.update');
|
|
41
|
+
assert.equal(result.operation, 'ingest');
|
|
42
|
+
assert.equal(result.provider.agentInstanceId, 'production-1');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('resolveObjective rejects invented capability and operation', async () => {
|
|
46
|
+
await assert.rejects(
|
|
47
|
+
resolveObjective('Ingère tout', sessionWithSelection({ capability: 'ingest', operation: 'ingest_all_pending' })),
|
|
48
|
+
/unknown capability "ingest"/,
|
|
49
|
+
);
|
|
50
|
+
});
|
|
@@ -35,6 +35,31 @@ test('dependencyResolver orders ready tasks by priority before step', () => {
|
|
|
35
35
|
assert.deepEqual(readyTasks(plan).map((item) => item.id), ['high', 'low', 'none']);
|
|
36
36
|
});
|
|
37
37
|
|
|
38
|
+
test('dependencyResolver releases a waiting task when a run grant covers it', () => {
|
|
39
|
+
const plan = {
|
|
40
|
+
runId: 'run-1',
|
|
41
|
+
workspace: 'test4',
|
|
42
|
+
planRevision: 1,
|
|
43
|
+
tasks: [task('ingest', {
|
|
44
|
+
status: 'waiting_approval',
|
|
45
|
+
requiresApproval: true,
|
|
46
|
+
approvalClass: 'mutation',
|
|
47
|
+
})],
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
assert.deepEqual(readyTasks(plan), []);
|
|
51
|
+
assert.deepEqual(readyTasks(plan, {
|
|
52
|
+
approvals: [{
|
|
53
|
+
status: 'approved',
|
|
54
|
+
scope: 'run',
|
|
55
|
+
runId: 'run-1',
|
|
56
|
+
workspaceId: 'test4',
|
|
57
|
+
planRevision: 1,
|
|
58
|
+
approvalClasses: ['mutation'],
|
|
59
|
+
}],
|
|
60
|
+
}).map((item) => item.id), ['ingest']);
|
|
61
|
+
});
|
|
62
|
+
|
|
38
63
|
test('dependencyResolver skips tasks whose locks are not free and keeps other ready work moving', () => {
|
|
39
64
|
const lockManager = createLockManager();
|
|
40
65
|
const held = lockManager.acquire(['deliverable:a.md']);
|
package/src/runtime/client.js
CHANGED
|
@@ -70,6 +70,25 @@ export async function postRuntimeRun(input, {
|
|
|
70
70
|
return response.json();
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
export async function postRuntimeDelegate(objective, {
|
|
74
|
+
url = runtimeUrlFromEnv(),
|
|
75
|
+
token = runtimeToken(),
|
|
76
|
+
workspace = null,
|
|
77
|
+
} = {}) {
|
|
78
|
+
const response = await fetch(runtimeEndpoint(url, '/delegate', workspace), {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
headers: { ...runtimeHeaders(token), 'Content-Type': 'application/json' },
|
|
81
|
+
body: JSON.stringify({ objective, workspace }),
|
|
82
|
+
});
|
|
83
|
+
const payload = await response.json().catch(() => ({}));
|
|
84
|
+
if (!response.ok) {
|
|
85
|
+
const err = new Error(payload.error ?? `Runtime delegation failed: HTTP ${response.status}`);
|
|
86
|
+
err.status = response.status;
|
|
87
|
+
throw err;
|
|
88
|
+
}
|
|
89
|
+
return payload;
|
|
90
|
+
}
|
|
91
|
+
|
|
73
92
|
export async function postRuntimeControl(action, {
|
|
74
93
|
url = runtimeUrlFromEnv(),
|
|
75
94
|
token = runtimeToken(),
|
|
@@ -152,6 +171,9 @@ export async function postRuntimeApprove({
|
|
|
152
171
|
runId = null,
|
|
153
172
|
itemId = null,
|
|
154
173
|
approvalId = null,
|
|
174
|
+
scope = null,
|
|
175
|
+
planRevision = null,
|
|
176
|
+
approvalClasses = null,
|
|
155
177
|
} = {}) {
|
|
156
178
|
const endpoint = runtimeEndpoint(url, '/approve', workspace);
|
|
157
179
|
const parsed = new URL(endpoint);
|
|
@@ -160,7 +182,16 @@ export async function postRuntimeApprove({
|
|
|
160
182
|
if (approvalId) parsed.searchParams.set('approvalId', approvalId);
|
|
161
183
|
const response = await fetch(parsed.toString(), {
|
|
162
184
|
method: 'POST',
|
|
163
|
-
headers: runtimeHeaders(token),
|
|
185
|
+
headers: { ...runtimeHeaders(token), 'Content-Type': 'application/json' },
|
|
186
|
+
body: JSON.stringify({
|
|
187
|
+
workspace,
|
|
188
|
+
runId,
|
|
189
|
+
itemId,
|
|
190
|
+
approvalId,
|
|
191
|
+
scope,
|
|
192
|
+
planRevision,
|
|
193
|
+
approvalClasses,
|
|
194
|
+
}),
|
|
164
195
|
});
|
|
165
196
|
if (!response.ok) throw new Error(`Runtime approve failed: HTTP ${response.status}`);
|
|
166
197
|
return response.json();
|
|
@@ -37,18 +37,25 @@ export async function recoverActiveRuns({
|
|
|
37
37
|
errors.push({ runId: run.id, taskId: task.id, error: error instanceof Error ? error.message : String(error) });
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
|
-
// A run
|
|
41
|
-
//
|
|
42
|
-
// every
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
40
|
+
// A run that recovery cannot move forward must be closed for good, or it
|
|
41
|
+
// re-attaches as a blocking "a runtime run is already active" zombie on
|
|
42
|
+
// every boot. This covers BOTH cases that can never resume on a fresh boot:
|
|
43
|
+
// - runs whose active tasks were all interrupted, and
|
|
44
|
+
// - runs with no active task to recover at all (e.g. left waiting for
|
|
45
|
+
// approval, or with only un-started pending tasks). Nothing here will
|
|
46
|
+
// ever progress, so finalize it now.
|
|
47
|
+
const progressed = runOutcomes.some((outcome) => outcome?.status === 'recovered' || outcome?.status === 'rescheduled');
|
|
48
|
+
if (!progressed) {
|
|
49
|
+
const reason = activeTasks.length > 0
|
|
50
|
+
? 'Recovery found no recoverable task.'
|
|
51
|
+
: 'Recovery found no active task to resume.';
|
|
52
|
+
const changed = store.interruptRuns?.({ workspace: run.workspace ?? null, runId: run.id, reason }) ?? 0;
|
|
46
53
|
if (changed > 0) {
|
|
47
54
|
dispatch(session, store, 'runtime_log', {
|
|
48
55
|
origin: 'recovery_manager',
|
|
49
56
|
runId: run.id,
|
|
50
57
|
workspace: run.workspace ?? workspaceFromSession(session),
|
|
51
|
-
payload: { message: `recovery: run ${run.id} interrupted (
|
|
58
|
+
payload: { message: `recovery: run ${run.id} interrupted (${reason})` },
|
|
52
59
|
});
|
|
53
60
|
}
|
|
54
61
|
}
|
package/src/runtime/runner.js
CHANGED
|
@@ -131,7 +131,7 @@ export async function runRuntimeAgenticWorkflow(agent, session, input, {
|
|
|
131
131
|
evaluate = true,
|
|
132
132
|
maxReplans = resolveMaxReplans(),
|
|
133
133
|
callTool = null,
|
|
134
|
-
dispatcherPollIntervalMs =
|
|
134
|
+
dispatcherPollIntervalMs = 2500,
|
|
135
135
|
} = {}) {
|
|
136
136
|
let currentInput = initialInput ?? input;
|
|
137
137
|
let replansLeft = Math.max(0, Math.floor(Number(maxReplans) || 0));
|
|
@@ -251,11 +251,7 @@ export async function runRuntimeAgenticWorkflow(agent, session, input, {
|
|
|
251
251
|
origin: 'runtime',
|
|
252
252
|
runId,
|
|
253
253
|
payload: {
|
|
254
|
-
content:
|
|
255
|
-
`Le run est terminé mais l'évaluation le juge incomplet : ${evaluation.reason}`,
|
|
256
|
-
evaluation.suggestedAction ? `Piste suggérée : ${evaluation.suggestedAction}` : null,
|
|
257
|
-
'Aucune tâche supplémentaire n\'a été créée automatiquement — dis-moi si tu veux poursuivre.',
|
|
258
|
-
].filter(Boolean).join('\n'),
|
|
254
|
+
content: `Le run est terminé mais l'évaluation le juge incomplet : ${evaluation.reason}`,
|
|
259
255
|
},
|
|
260
256
|
}));
|
|
261
257
|
dispatchAgentEvent(session, createAgentEvent('run_error', {
|
|
@@ -295,7 +291,7 @@ export async function runRuntimeParallelPlan(agent, session, input, {
|
|
|
295
291
|
budgetManager = null,
|
|
296
292
|
budgets = {},
|
|
297
293
|
callTool = null,
|
|
298
|
-
dispatcherPollIntervalMs =
|
|
294
|
+
dispatcherPollIntervalMs = 2500,
|
|
299
295
|
} = {}) {
|
|
300
296
|
if (fragment != null) assertValidatedFragment(fragment);
|
|
301
297
|
const agents = session.agentRegistry?.snapshot?.() ?? session.agentRegistrySnapshot ?? [];
|
|
@@ -399,8 +395,9 @@ export async function runRuntimeParallelPlan(agent, session, input, {
|
|
|
399
395
|
emitRuntimeLog(session, taskLogPayload('task.starting', task, { runId, detail: `starting task ${taskId}` }));
|
|
400
396
|
},
|
|
401
397
|
startTask: (task, attempt) => {
|
|
398
|
+
const executableTask = materializeTaskInputs(task, session.headlessPlan ?? []);
|
|
402
399
|
const taskAbort = createTaskAbortSignal(signal);
|
|
403
|
-
const promise = runDispatchedTask(
|
|
400
|
+
const promise = runDispatchedTask(executableTask, {
|
|
404
401
|
session,
|
|
405
402
|
assignmentManager: assigner,
|
|
406
403
|
dispatcher: executor,
|
|
@@ -529,6 +526,41 @@ export async function runRuntimeParallelPlan(agent, session, input, {
|
|
|
529
526
|
}
|
|
530
527
|
}
|
|
531
528
|
|
|
529
|
+
export function materializeTaskInputs(task, plan = []) {
|
|
530
|
+
const dependencies = new Set(Array.isArray(task?.dependsOn) ? task.dependsOn.map(String) : []);
|
|
531
|
+
const replacements = new Map();
|
|
532
|
+
for (const dependency of plan ?? []) {
|
|
533
|
+
if (!dependencies.has(String(dependency?.id ?? dependency?.step))) continue;
|
|
534
|
+
const expected = Array.isArray(dependency?.expectedOutputRefs) ? dependency.expectedOutputRefs : [];
|
|
535
|
+
const actual = Array.isArray(dependency?.outputRefs) ? dependency.outputRefs : [];
|
|
536
|
+
for (let index = 0; index < Math.min(expected.length, actual.length); index += 1) {
|
|
537
|
+
const expectedRef = refValue(expected[index]);
|
|
538
|
+
const actualRef = refValue(actual[index]);
|
|
539
|
+
if (expectedRef && actualRef && expectedRef !== actualRef) replacements.set(expectedRef, actualRef);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
if (replacements.size === 0) return task;
|
|
543
|
+
return {
|
|
544
|
+
...task,
|
|
545
|
+
arguments: replaceRefValues(task?.arguments, replacements),
|
|
546
|
+
inputRefs: replaceRefValues(task?.inputRefs, replacements),
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function replaceRefValues(value, replacements) {
|
|
551
|
+
if (typeof value === 'string') return replacements.get(value) ?? value;
|
|
552
|
+
if (Array.isArray(value)) return value.map((item) => replaceRefValues(item, replacements));
|
|
553
|
+
if (value && typeof value === 'object') {
|
|
554
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, replaceRefValues(item, replacements)]));
|
|
555
|
+
}
|
|
556
|
+
return value;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function refValue(value) {
|
|
560
|
+
if (typeof value === 'string') return value;
|
|
561
|
+
return value && typeof value === 'object' ? String(value.ref ?? '') : '';
|
|
562
|
+
}
|
|
563
|
+
|
|
532
564
|
function sanitizeSessionPlanForExecution(session, runId = null) {
|
|
533
565
|
if (!session.headlessPlan) return;
|
|
534
566
|
const sanitized = sanitizePlanForExecution(session.headlessPlan);
|
|
@@ -704,7 +736,7 @@ async function runDispatchedTask(task, {
|
|
|
704
736
|
error: {
|
|
705
737
|
code: 'dispatcher_error',
|
|
706
738
|
message: err instanceof Error ? err.message : String(err),
|
|
707
|
-
retryable:
|
|
739
|
+
retryable: transientRuntimeError(err),
|
|
708
740
|
},
|
|
709
741
|
};
|
|
710
742
|
await resultAggregator.accept(result, { task, assignment });
|
|
@@ -724,8 +756,16 @@ async function runDispatchedTask(task, {
|
|
|
724
756
|
}
|
|
725
757
|
}
|
|
726
758
|
|
|
727
|
-
function shouldUseParallelScheduler(plan) {
|
|
728
|
-
|
|
759
|
+
export function shouldUseParallelScheduler(plan) {
|
|
760
|
+
// A validated provider plan enters the scheduler even while every task is
|
|
761
|
+
// waiting for approval. Looking only at readyPlanTasks() made an all-
|
|
762
|
+
// approval plan fall through to the conversational Donna loop; that loop
|
|
763
|
+
// then ignored the integrated TaskGraph and marked the run done.
|
|
764
|
+
return (plan ?? []).some((task) =>
|
|
765
|
+
task?.requiredCapability
|
|
766
|
+
&& task?.operation
|
|
767
|
+
&& pendingSchedulerStatus(task.status),
|
|
768
|
+
);
|
|
729
769
|
}
|
|
730
770
|
|
|
731
771
|
function taskLogPayload(event, task, {
|
|
@@ -819,6 +859,11 @@ export async function evaluateRuntimeRun(session, input, {
|
|
|
819
859
|
evaluate = true,
|
|
820
860
|
} = {}) {
|
|
821
861
|
if (!shouldEvaluate(evaluate)) return null;
|
|
862
|
+
const structured = structuredPlanEvaluation(session.headlessPlan);
|
|
863
|
+
if (structured) {
|
|
864
|
+
emitRuntimeLog(session, 'runtime: evaluating completed structured plan');
|
|
865
|
+
return structured;
|
|
866
|
+
}
|
|
822
867
|
const llm = session.llm;
|
|
823
868
|
if (!llm || typeof llm.completeWithTools !== 'function') {
|
|
824
869
|
return fallbackEvaluation('Evaluator unavailable: no LLM completeWithTools client.');
|
|
@@ -842,6 +887,40 @@ export async function evaluateRuntimeRun(session, input, {
|
|
|
842
887
|
}
|
|
843
888
|
}
|
|
844
889
|
|
|
890
|
+
function structuredPlanEvaluation(plan) {
|
|
891
|
+
if (!Array.isArray(plan) || plan.length === 0) return null;
|
|
892
|
+
// Only provider TaskGraph tasks are authoritative. Legacy conversational
|
|
893
|
+
// plans contain prose/tool labels and still use the compatibility evaluator.
|
|
894
|
+
if (!plan.every((step) => step?.requiredCapability && step?.operation)) return null;
|
|
895
|
+
const statuses = plan.map((step) => String(step?.status ?? '').toLowerCase());
|
|
896
|
+
const failed = plan.filter((step) => ['failed', 'error', 'cancelled', 'canceled', 'stalled'].includes(String(step?.status ?? '').toLowerCase()));
|
|
897
|
+
const incomplete = plan.filter((step) => !['done', 'complete', 'completed', 'success', 'succeeded'].includes(String(step?.status ?? '').toLowerCase()));
|
|
898
|
+
if (failed.length > 0) {
|
|
899
|
+
return {
|
|
900
|
+
ok: false,
|
|
901
|
+
reason: `${failed.length} tâche(s) du plan ont échoué : ${failed.map((step) => step.label ?? step.description ?? step.id ?? step.step).join(', ')}.`,
|
|
902
|
+
suggestedAction: null,
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
if (incomplete.length > 0) {
|
|
906
|
+
return {
|
|
907
|
+
ok: false,
|
|
908
|
+
reason: `${incomplete.length} tâche(s) du plan ne sont pas terminées.`,
|
|
909
|
+
suggestedAction: null,
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
return {
|
|
913
|
+
ok: statuses.length > 0,
|
|
914
|
+
reason: `${statuses.length} tâche(s) du plan terminées avec succès.`,
|
|
915
|
+
suggestedAction: null,
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
function transientRuntimeError(error) {
|
|
920
|
+
const value = error instanceof Error ? error.message : String(error ?? '');
|
|
921
|
+
return /(?:429|timeout|temporar|throttl|rate.?limit|quota|busy|unavailable)/i.test(value);
|
|
922
|
+
}
|
|
923
|
+
|
|
845
924
|
function shouldEvaluate(value) {
|
|
846
925
|
if (value === false) return false;
|
|
847
926
|
const env = String(process.env.WIKI_MANAGER_EVALUATOR ?? '').trim().toLowerCase();
|
|
@@ -961,10 +1040,30 @@ export async function replanRuntimeRun(session, input, trigger, {
|
|
|
961
1040
|
}
|
|
962
1041
|
}
|
|
963
1042
|
|
|
1043
|
+
function isBusyFailure(failure) {
|
|
1044
|
+
const fields = [
|
|
1045
|
+
failure?.error,
|
|
1046
|
+
failure?.status,
|
|
1047
|
+
failure?.result?.error?.code,
|
|
1048
|
+
failure?.result?.error?.message,
|
|
1049
|
+
failure?.result?.status,
|
|
1050
|
+
].map((value) => String(value ?? '').toLowerCase());
|
|
1051
|
+
return fields.some((value) => value.includes('busy') || value.includes('locked'));
|
|
1052
|
+
}
|
|
1053
|
+
|
|
964
1054
|
function replanTriggerFromLoopResult(result) {
|
|
965
1055
|
// 'awaiting_approval' is not a dead end — it means to wait for a human
|
|
966
1056
|
// decision, not to replan around it.
|
|
967
1057
|
if (result.stalled && result.reason !== 'awaiting_approval') {
|
|
1058
|
+
// A stall caused only by transient lock contention (target_busy /
|
|
1059
|
+
// workspace_busy) must NOT be replanned: the plan is correct, the
|
|
1060
|
+
// workspace was momentarily locked. Replanning around it re-runs the same
|
|
1061
|
+
// tasks, hits the lock again and spins the run into a zombie. Fail cleanly
|
|
1062
|
+
// so the run finalizes instead of lingering 'running'.
|
|
1063
|
+
const blocking = [...(result.failures ?? []), ...terminalFailures(result.completed ?? [])];
|
|
1064
|
+
if (blocking.length > 0 && blocking.every(isBusyFailure)) {
|
|
1065
|
+
return null;
|
|
1066
|
+
}
|
|
968
1067
|
return {
|
|
969
1068
|
kind: 'plan_stalled',
|
|
970
1069
|
reason: `Plan is stalled: ${result.reason ?? 'no ready task'} (pending steps exist but none have their dependencies satisfied).`,
|
|
@@ -973,7 +1072,7 @@ function replanTriggerFromLoopResult(result) {
|
|
|
973
1072
|
};
|
|
974
1073
|
}
|
|
975
1074
|
const failures = terminalFailures(result.completed ?? []);
|
|
976
|
-
const technical = failures.filter((failure) => !isCancelledStatus(failure.status));
|
|
1075
|
+
const technical = failures.filter((failure) => !isCancelledStatus(failure.status) && !isBusyFailure(failure));
|
|
977
1076
|
const cancelled = failures.filter((failure) => isCancelledStatus(failure.status));
|
|
978
1077
|
if (cancelled.length > 0 && technical.length === 0 && (result.failures ?? []).every(isCancelledTaskFailure)) {
|
|
979
1078
|
return null;
|
|
@@ -990,7 +1089,7 @@ function replanTriggerFromLoopResult(result) {
|
|
|
990
1089
|
// The parallel scheduler can fail a task before it ever produces an
|
|
991
1090
|
// _activity (e.g. a thrown error on the first turn) — that failure lives
|
|
992
1091
|
// in result.failures, not in any activity, so it must be checked too.
|
|
993
|
-
const taskFailure = (result.failures ?? []).find((failure) => !isCancelledTaskFailure(failure));
|
|
1092
|
+
const taskFailure = (result.failures ?? []).find((failure) => !isCancelledTaskFailure(failure) && !isBusyFailure(failure));
|
|
994
1093
|
if (!taskFailure) return null;
|
|
995
1094
|
return {
|
|
996
1095
|
kind: 'task_error',
|
|
@@ -1,7 +1,70 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import test from 'node:test';
|
|
3
3
|
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
4
|
-
import { finishRuntimeRun, replanRuntimeRun, runRuntimeAgenticWorkflow, runRuntimeParallelPlan } from './runner.js';
|
|
4
|
+
import { evaluateRuntimeRun, finishRuntimeRun, materializeTaskInputs, replanRuntimeRun, runRuntimeAgenticWorkflow, runRuntimeParallelPlan, shouldUseParallelScheduler } from './runner.js';
|
|
5
|
+
|
|
6
|
+
test('validated tasks waiting for approval stay in the scheduler instead of falling back to Donna', () => {
|
|
7
|
+
assert.equal(shouldUseParallelScheduler([
|
|
8
|
+
{
|
|
9
|
+
id: 'ingest-plan',
|
|
10
|
+
requiredCapability: 'knowledge.update',
|
|
11
|
+
operation: 'ingest_plan',
|
|
12
|
+
status: 'waiting_approval',
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
id: 'ingest-apply',
|
|
16
|
+
requiredCapability: 'knowledge.update',
|
|
17
|
+
operation: 'ingest_apply',
|
|
18
|
+
status: 'waiting_approval',
|
|
19
|
+
},
|
|
20
|
+
]), true);
|
|
21
|
+
assert.equal(shouldUseParallelScheduler([
|
|
22
|
+
{ id: 'finished', requiredCapability: 'knowledge.update', operation: 'ingest', status: 'done' },
|
|
23
|
+
]), false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('downstream tasks consume actual dependency outputs instead of planned placeholder refs', () => {
|
|
27
|
+
const plannedRef = '.wiki/ingest-plans/planned.json';
|
|
28
|
+
const actualRef = '.wiki/ingest-plans/ingest-2026-07-10.json';
|
|
29
|
+
const plan = [{
|
|
30
|
+
id: 'ingest-plan',
|
|
31
|
+
expectedOutputRefs: [{ type: 'file', ref: plannedRef }],
|
|
32
|
+
outputRefs: [{ type: 'file', ref: actualRef }],
|
|
33
|
+
}];
|
|
34
|
+
const apply = {
|
|
35
|
+
id: 'ingest-apply',
|
|
36
|
+
dependsOn: ['ingest-plan'],
|
|
37
|
+
arguments: { inputs: [plannedRef] },
|
|
38
|
+
inputRefs: [{ type: 'file', ref: plannedRef }],
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const executable = materializeTaskInputs(apply, plan);
|
|
42
|
+
assert.deepEqual(executable.arguments.inputs, [actualRef]);
|
|
43
|
+
assert.equal(executable.inputRefs[0].ref, actualRef);
|
|
44
|
+
assert.deepEqual(apply.arguments.inputs, [plannedRef], 'the validated plan declaration remains immutable');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('evaluateRuntimeRun trusts a completed provider TaskGraph without asking an LLM to reinterpret it', async () => {
|
|
48
|
+
const session = {
|
|
49
|
+
headlessPlan: [{
|
|
50
|
+
id: 'ingest-a',
|
|
51
|
+
label: 'Ingest A.md',
|
|
52
|
+
requiredCapability: 'knowledge.update',
|
|
53
|
+
operation: 'ingest_plan',
|
|
54
|
+
status: 'done',
|
|
55
|
+
}],
|
|
56
|
+
llm: {
|
|
57
|
+
async completeWithTools() {
|
|
58
|
+
assert.fail('a structured terminal TaskGraph must not be reinterpreted by an LLM');
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const evaluation = await evaluateRuntimeRun(session, 'ingère A.md');
|
|
64
|
+
|
|
65
|
+
assert.equal(evaluation.ok, true);
|
|
66
|
+
assert.match(evaluation.reason, /1 tâche/);
|
|
67
|
+
});
|
|
5
68
|
|
|
6
69
|
test('runRuntimeAgenticWorkflow completes conversational turns without evaluation or replan', async () => {
|
|
7
70
|
const events = [];
|