@dotdrelle/wiki-manager 0.14.13 → 0.14.20
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/.env.example +29 -4
- package/README.md +19 -0
- package/docker-compose.yml +6 -1
- package/package.json +1 -1
- package/src/activity/activityAggregator.js +50 -16
- package/src/activity/activityAggregator.test.js +67 -4
- package/src/agent/graph.js +79 -11
- package/src/agent/graph.test.js +43 -3
- package/src/cli/wiki-manager.js +74 -11
- package/src/cli/wiki-manager.test.js +40 -1
- package/src/commands/slash.js +10 -3
- package/src/commands/slash.test.js +24 -0
- package/src/core/buildInfo.json +2 -2
- package/src/core/dockerCompose.test.js +4 -0
- package/src/core/env.test.js +3 -0
- package/src/core/mcp.js +1 -1
- package/src/core/wikiSetup.js +35 -0
- package/src/core/wikiWorkspace.test.js +20 -0
- package/src/core/workflow.js +72 -0
- package/src/core/workflow.test.js +57 -0
- package/src/core/workspaces.js +10 -2
- package/src/orchestrator/dependencyResolver.js +19 -1
- package/src/orchestrator/objectiveResolver.js +24 -0
- package/src/orchestrator/objectiveResolver.test.js +23 -1
- package/src/orchestrator/scheduler.js +27 -7
- package/src/orchestrator/scheduler.test.js +45 -1
- package/src/runtime/auth.test.js +65 -1
- package/src/runtime/client.js +4 -0
- package/src/runtime/donna-contract.test.js +2 -0
- package/src/runtime/lifecycle.js +21 -12
- package/src/runtime/runner.js +141 -18
- package/src/runtime/runner.test.js +30 -0
- package/src/runtime/server.js +13 -2
- package/src/runtime/server.test.js +30 -1
- package/src/runtime/store.js +54 -0
- package/src/runtime/store.test.js +42 -0
- package/src/shell/FileEditorDialog.tsx +2 -2
- package/src/shell/LeftPane.tsx +60 -15
- package/src/shell/RightPane.tsx +168 -56
- package/src/shell/StartupScreen.tsx +3 -7
- package/src/shell/renderer.ts +1 -0
- package/src/shell/repl.js +7 -81
- package/src/shell/repl.test.js +141 -38
- package/src/shell/tui.tsx +65 -65
- package/src/shell/useSession.ts +92 -6
- package/wiki-workspace +28 -0
|
@@ -42,9 +42,31 @@ test('resolveObjective selects and validates one real provider', async () => {
|
|
|
42
42
|
assert.equal(result.provider.agentInstanceId, 'production-1');
|
|
43
43
|
});
|
|
44
44
|
|
|
45
|
+
test('resolveObjective uses an unambiguously mentioned registry operation without asking the LLM', async () => {
|
|
46
|
+
const session = sessionWithSelection({ capability: 'external-source.export', operation: 'export' });
|
|
47
|
+
session.capabilityRegistry.snapshot = () => ({
|
|
48
|
+
'knowledge.update@1': [sessionWithSelection({}).capabilityRegistry.snapshot()['knowledge.update@1'][0]],
|
|
49
|
+
'external-source.export@1': [{
|
|
50
|
+
agentInstanceId: 'cme-1',
|
|
51
|
+
serverName: 'cme',
|
|
52
|
+
capability: { id: 'external-source.export', version: '1', supportedOperations: ['export'] },
|
|
53
|
+
}],
|
|
54
|
+
});
|
|
55
|
+
session.capabilityRegistry.providersFor = (capability) =>
|
|
56
|
+
session.capabilityRegistry.snapshot()[`${capability}@1`] ?? [];
|
|
57
|
+
session.llm.completeWithTools = async () => {
|
|
58
|
+
throw new Error('the explicit operation must not depend on LLM selection');
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const result = await resolveObjective("lance l'ingestion", session);
|
|
62
|
+
assert.equal(result.capability, 'knowledge.update');
|
|
63
|
+
assert.equal(result.operation, 'ingest');
|
|
64
|
+
assert.equal(result.provider.agentInstanceId, 'production-1');
|
|
65
|
+
});
|
|
66
|
+
|
|
45
67
|
test('resolveObjective rejects invented capability and operation', async () => {
|
|
46
68
|
await assert.rejects(
|
|
47
|
-
resolveObjective('
|
|
69
|
+
resolveObjective('Traite tout', sessionWithSelection({ capability: 'ingest', operation: 'ingest_all_pending' })),
|
|
48
70
|
/unknown capability "ingest"/,
|
|
49
71
|
);
|
|
50
72
|
});
|
|
@@ -9,7 +9,11 @@ export function resolveSchedulerConcurrency(value = process.env.WIKI_MANAGER_SCH
|
|
|
9
9
|
: DEFAULT_SCHEDULER_CONCURRENCY;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
// Display-only breakdown of the SAME computation resolvePlanConcurrency uses.
|
|
13
|
+
// resolvePlanConcurrency delegates to this so the number surfaced to the UIs can
|
|
14
|
+
// never diverge from the number the scheduler actually enforces. Never used to
|
|
15
|
+
// gate scheduling — only `.limit` feeds startReadyTasks.
|
|
16
|
+
export function describePlanConcurrency({ plan = [], agents = [], configured = null } = {}) {
|
|
13
17
|
const capabilities = new Set(plan.map((task) => task?.requiredCapability).filter(Boolean).map(String));
|
|
14
18
|
const assignedAgents = new Set(plan.map((task) => task?.agentInstanceId).filter(Boolean).map(String));
|
|
15
19
|
const relevantAgents = agents.filter((agent) => {
|
|
@@ -17,12 +21,28 @@ export function resolvePlanConcurrency({ plan = [], agents = [], configured = nu
|
|
|
17
21
|
if (id && assignedAgents.has(id)) return true;
|
|
18
22
|
return (agent?.description?.capabilities ?? []).some((capability) => capabilities.has(String(capability?.id ?? '')));
|
|
19
23
|
});
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
const ceiling = positiveInteger(configured);
|
|
25
|
+
const agentValues = relevantAgents.flatMap(concurrencyValues).filter(Boolean);
|
|
26
|
+
const taskValues = plan.flatMap(concurrencyValues).filter(Boolean);
|
|
27
|
+
const values = [ceiling, ...taskValues, ...agentValues].filter(Boolean);
|
|
28
|
+
const limit = values.length > 0 ? Math.max(1, Math.min(...values)) : DEFAULT_SCHEDULER_CONCURRENCY;
|
|
29
|
+
const otherMin = [...taskValues, ...agentValues].length > 0
|
|
30
|
+
? Math.min(...taskValues, ...agentValues)
|
|
31
|
+
: null;
|
|
32
|
+
// The manager ceiling "bit" when it is the (uniquely) binding constraint: it
|
|
33
|
+
// is set, equals the resolved limit, and is strictly below every other input.
|
|
34
|
+
const cappedByCeiling = ceiling != null && limit === ceiling && (otherMin == null || ceiling < otherMin);
|
|
35
|
+
return {
|
|
36
|
+
limit,
|
|
37
|
+
ceiling: ceiling ?? null,
|
|
38
|
+
agentLimit: agentValues.length > 0 ? Math.min(...agentValues) : null,
|
|
39
|
+
taskLimit: taskValues.length > 0 ? Math.min(...taskValues) : null,
|
|
40
|
+
cappedByCeiling,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function resolvePlanConcurrency(options = {}) {
|
|
45
|
+
return describePlanConcurrency(options).limit;
|
|
26
46
|
}
|
|
27
47
|
|
|
28
48
|
export function resolveCapabilityConcurrency(agent = null, ...constraints) {
|
|
@@ -4,9 +4,10 @@ import { join } from 'node:path';
|
|
|
4
4
|
import test from 'node:test';
|
|
5
5
|
|
|
6
6
|
import { createBudgetManager } from './budgetManager.js';
|
|
7
|
-
import { readyTasks } from './dependencyResolver.js';
|
|
7
|
+
import { readyTasks, tasksAwaitingApproval } from './dependencyResolver.js';
|
|
8
8
|
import { createLockManager } from './lockManager.js';
|
|
9
9
|
import {
|
|
10
|
+
describePlanConcurrency,
|
|
10
11
|
effectiveConcurrency,
|
|
11
12
|
resolveCapabilityConcurrency,
|
|
12
13
|
resolvePlanConcurrency,
|
|
@@ -60,6 +61,27 @@ test('dependencyResolver releases a waiting task when a run grant covers it', ()
|
|
|
60
61
|
}).map((item) => item.id), ['ingest']);
|
|
61
62
|
});
|
|
62
63
|
|
|
64
|
+
test('dependencyResolver does not request approval for a task blocked by a failed dependency', () => {
|
|
65
|
+
const plan = {
|
|
66
|
+
runId: 'run-1',
|
|
67
|
+
workspace: 'test4',
|
|
68
|
+
planRevision: 1,
|
|
69
|
+
tasks: [
|
|
70
|
+
task('plan', { status: 'failed' }),
|
|
71
|
+
task('apply', {
|
|
72
|
+
status: 'waiting_approval',
|
|
73
|
+
dependsOn: ['plan'],
|
|
74
|
+
requiresApproval: true,
|
|
75
|
+
approvalClass: 'mutation',
|
|
76
|
+
}),
|
|
77
|
+
],
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
assert.deepEqual(tasksAwaitingApproval(plan), []);
|
|
81
|
+
plan.tasks[0].status = 'done';
|
|
82
|
+
assert.deepEqual(tasksAwaitingApproval(plan).map((item) => item.id), ['apply']);
|
|
83
|
+
});
|
|
84
|
+
|
|
63
85
|
test('dependencyResolver skips tasks whose locks are not free and keeps other ready work moving', () => {
|
|
64
86
|
const lockManager = createLockManager();
|
|
65
87
|
const held = lockManager.acquire(['deliverable:a.md']);
|
|
@@ -120,6 +142,28 @@ test('scheduler uses the relevant agent declaration instead of hard-capping plan
|
|
|
120
142
|
assert.equal(resolvePlanConcurrency({ plan, agents, configured: 20 }), 10);
|
|
121
143
|
});
|
|
122
144
|
|
|
145
|
+
test('describePlanConcurrency mirrors resolvePlanConcurrency and flags the ceiling', () => {
|
|
146
|
+
const plan = [{ id: 'task-1', requiredCapability: 'ingest' }];
|
|
147
|
+
const agents = [{ description: { capabilities: [{ id: 'ingest' }], limits: { recommendedConcurrency: 10, maxConcurrency: 12 } } }];
|
|
148
|
+
|
|
149
|
+
// The number must never diverge from what the scheduler enforces.
|
|
150
|
+
for (const configured of [undefined, 3, 20]) {
|
|
151
|
+
const opts = configured === undefined ? { plan, agents } : { plan, agents, configured };
|
|
152
|
+
assert.equal(describePlanConcurrency(opts).limit, resolvePlanConcurrency(opts));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Ceiling binds → flagged.
|
|
156
|
+
const capped = describePlanConcurrency({ plan, agents, configured: 3 });
|
|
157
|
+
assert.equal(capped.limit, 3);
|
|
158
|
+
assert.equal(capped.ceiling, 3);
|
|
159
|
+
assert.equal(capped.cappedByCeiling, true);
|
|
160
|
+
|
|
161
|
+
// Ceiling above the agent declaration → does not bind, not flagged.
|
|
162
|
+
const loose = describePlanConcurrency({ plan, agents, configured: 20 });
|
|
163
|
+
assert.equal(loose.limit, 10);
|
|
164
|
+
assert.equal(loose.cappedByCeiling, false);
|
|
165
|
+
});
|
|
166
|
+
|
|
123
167
|
test('scheduler ignores unrelated agents and falls back to three without declarations', () => {
|
|
124
168
|
const plan = [{ id: 'task-1', requiredCapability: 'ingest' }];
|
|
125
169
|
const agents = [{
|
package/src/runtime/auth.test.js
CHANGED
|
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os';
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import test from 'node:test';
|
|
6
6
|
import { resolveRuntimeAuthToken } from './auth.js';
|
|
7
|
-
import { assertRuntimeNode, runtimeNodeExecutable } from './lifecycle.js';
|
|
7
|
+
import { assertRuntimeNode, runtimeNodeExecutable, shutdownOwnedRuntime } from './lifecycle.js';
|
|
8
8
|
|
|
9
9
|
test('resolveRuntimeAuthToken: loopback host does not require token', () => {
|
|
10
10
|
const result = resolveRuntimeAuthToken({ host: '127.0.0.1', explicitToken: null });
|
|
@@ -27,3 +27,67 @@ test('runtime lifecycle uses a Node executable with node:sqlite support', async
|
|
|
27
27
|
assert.ok(runtimeNode.executable);
|
|
28
28
|
assert.ok(Number(runtimeNode.version.split('.')[0]) >= 22);
|
|
29
29
|
});
|
|
30
|
+
|
|
31
|
+
test('shutdownOwnedRuntime reports progress and stops an idle owned runtime', async () => {
|
|
32
|
+
const originalFetch = globalThis.fetch;
|
|
33
|
+
const calls = [];
|
|
34
|
+
const logs = [];
|
|
35
|
+
globalThis.fetch = async (url, options = {}) => {
|
|
36
|
+
calls.push({ url: String(url), method: options.method ?? 'GET' });
|
|
37
|
+
return new Response(JSON.stringify(calls.length === 1 ? { activeRuns: [] } : { shutdown: true }), {
|
|
38
|
+
status: calls.length === 1 ? 200 : 202,
|
|
39
|
+
headers: { 'content-type': 'application/json' },
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
try {
|
|
43
|
+
const result = await shutdownOwnedRuntime(
|
|
44
|
+
{ url: 'http://127.0.0.1:7788', started: true, token: null },
|
|
45
|
+
{ log: (message) => logs.push(message), timeoutMs: 100 },
|
|
46
|
+
);
|
|
47
|
+
assert.equal(result.action, 'shutdown');
|
|
48
|
+
assert.deepEqual(calls.map((call) => call.method), ['GET', 'POST']);
|
|
49
|
+
assert.match(logs.join('\n'), /runtime arrêté/);
|
|
50
|
+
} finally {
|
|
51
|
+
globalThis.fetch = originalFetch;
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('shutdownOwnedRuntime also stops an idle reused runtime', async () => {
|
|
56
|
+
const originalFetch = globalThis.fetch;
|
|
57
|
+
const calls = [];
|
|
58
|
+
globalThis.fetch = async (_url, options = {}) => {
|
|
59
|
+
calls.push(options.method ?? 'GET');
|
|
60
|
+
return new Response(JSON.stringify(calls.length === 1 ? { activeRuns: [] } : { shutdown: true }), {
|
|
61
|
+
status: calls.length === 1 ? 200 : 202,
|
|
62
|
+
headers: { 'content-type': 'application/json' },
|
|
63
|
+
});
|
|
64
|
+
};
|
|
65
|
+
try {
|
|
66
|
+
const result = await shutdownOwnedRuntime(
|
|
67
|
+
{ url: 'http://127.0.0.1:7788', started: false, token: null },
|
|
68
|
+
{ timeoutMs: 100 },
|
|
69
|
+
);
|
|
70
|
+
assert.equal(result.action, 'shutdown');
|
|
71
|
+
assert.deepEqual(calls, ['GET', 'POST']);
|
|
72
|
+
} finally {
|
|
73
|
+
globalThis.fetch = originalFetch;
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('shutdownOwnedRuntime bounds an unresponsive shutdown', async () => {
|
|
78
|
+
const originalFetch = globalThis.fetch;
|
|
79
|
+
const logs = [];
|
|
80
|
+
globalThis.fetch = async (_url, { signal } = {}) => new Promise((_resolve, reject) => {
|
|
81
|
+
signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')), { once: true });
|
|
82
|
+
});
|
|
83
|
+
try {
|
|
84
|
+
const result = await shutdownOwnedRuntime(
|
|
85
|
+
{ url: 'http://127.0.0.1:7788', started: true, token: null },
|
|
86
|
+
{ log: (message) => logs.push(message), timeoutMs: 5 },
|
|
87
|
+
);
|
|
88
|
+
assert.equal(result.action, 'timeout');
|
|
89
|
+
assert.match(logs.join('\n'), /délai de fermeture/);
|
|
90
|
+
} finally {
|
|
91
|
+
globalThis.fetch = originalFetch;
|
|
92
|
+
}
|
|
93
|
+
});
|
package/src/runtime/client.js
CHANGED
|
@@ -38,9 +38,11 @@ export async function checkRuntimeHealth({
|
|
|
38
38
|
url = runtimeUrlFromEnv(),
|
|
39
39
|
token = runtimeToken(),
|
|
40
40
|
workspace = null,
|
|
41
|
+
signal = null,
|
|
41
42
|
} = {}) {
|
|
42
43
|
const response = await fetch(runtimeEndpoint(url, '/health', workspace), {
|
|
43
44
|
headers: runtimeHeaders(token),
|
|
45
|
+
signal,
|
|
44
46
|
});
|
|
45
47
|
if (!response.ok) return null;
|
|
46
48
|
return response.json();
|
|
@@ -143,10 +145,12 @@ export async function postRuntimeKill({
|
|
|
143
145
|
export async function postRuntimeShutdown({
|
|
144
146
|
url = runtimeUrlFromEnv(),
|
|
145
147
|
token = runtimeToken(),
|
|
148
|
+
signal = null,
|
|
146
149
|
} = {}) {
|
|
147
150
|
const response = await fetch(runtimeEndpoint(url, '/shutdown'), {
|
|
148
151
|
method: 'POST',
|
|
149
152
|
headers: runtimeHeaders(token),
|
|
153
|
+
signal,
|
|
150
154
|
});
|
|
151
155
|
if (!response.ok) throw new Error(`Runtime shutdown failed: HTTP ${response.status}`);
|
|
152
156
|
return response.json();
|
|
@@ -259,6 +259,8 @@ test('CME export is dispatched only from an approved DAG task', async () => {
|
|
|
259
259
|
assert.equal(result.reason, 'awaiting_approval');
|
|
260
260
|
assert.equal(executeCalls, 0);
|
|
261
261
|
assert.equal(session.headlessPlan[0].status, 'waiting_approval');
|
|
262
|
+
assert.ok(session.agentEvents.some((event) => event.type === 'approval.requested'
|
|
263
|
+
&& event.taskId === session.headlessPlan[0].id));
|
|
262
264
|
});
|
|
263
265
|
|
|
264
266
|
function buildSingleTaskAgent({ taskId, description, finalResponse }) {
|
package/src/runtime/lifecycle.js
CHANGED
|
@@ -127,22 +127,25 @@ async function waitForRuntimeShutdown(url, token, timeoutMs) {
|
|
|
127
127
|
}
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
-
export async function runtimeHealthOrNull(url = runtimeUrlFromEnv(), token = runtimeTokenFromEnv()) {
|
|
130
|
+
export async function runtimeHealthOrNull(url = runtimeUrlFromEnv(), token = runtimeTokenFromEnv(), signal = null) {
|
|
131
131
|
try {
|
|
132
|
-
return await checkRuntimeHealth({ url, token });
|
|
133
|
-
} catch {
|
|
132
|
+
return await checkRuntimeHealth({ url, token, signal });
|
|
133
|
+
} catch (err) {
|
|
134
|
+
if (signal?.aborted) throw err;
|
|
134
135
|
return null;
|
|
135
136
|
}
|
|
136
137
|
}
|
|
137
138
|
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
export async function shutdownOwnedRuntime(runtime, { log = (_message) => {} } = {}) {
|
|
143
|
-
if (!runtime?.url
|
|
139
|
+
// Close the runtime attached to the shell when it is idle, including a process
|
|
140
|
+
// reused at startup. Restricting cleanup to `started: true` left ownerless
|
|
141
|
+
// runtimes occupying port 7788 with another manager directory/token.
|
|
142
|
+
// An active run still survives the shell.
|
|
143
|
+
export async function shutdownOwnedRuntime(runtime, { log = (_message) => {}, timeoutMs = 3000 } = {}) {
|
|
144
|
+
if (!runtime?.url) return { action: 'kept', reason: 'unavailable' };
|
|
145
|
+
const controller = new AbortController();
|
|
146
|
+
const timeout = setTimeout(() => controller.abort(), Math.max(1, Number(timeoutMs) || 3000));
|
|
144
147
|
try {
|
|
145
|
-
const health = await runtimeHealthOrNull(runtime.url, runtime.token);
|
|
148
|
+
const health = await runtimeHealthOrNull(runtime.url, runtime.token, controller.signal);
|
|
146
149
|
if (!health) return { action: 'kept', reason: 'unreachable' };
|
|
147
150
|
const activeRuns = Array.isArray(health.activeRuns) ? health.activeRuns : [];
|
|
148
151
|
if (activeRuns.length > 0) {
|
|
@@ -152,10 +155,16 @@ export async function shutdownOwnedRuntime(runtime, { log = (_message) => {} } =
|
|
|
152
155
|
log(`runtime laissé actif : run en cours (${labels}) — il survivra à ce shell ; relance wiki-manager pour le retrouver.`);
|
|
153
156
|
return { action: 'kept', reason: 'run_active', activeRuns };
|
|
154
157
|
}
|
|
155
|
-
await postRuntimeShutdown({ url: runtime.url, token: runtime.token });
|
|
156
|
-
log('runtime arrêté (
|
|
158
|
+
await postRuntimeShutdown({ url: runtime.url, token: runtime.token, signal: controller.signal });
|
|
159
|
+
log('runtime arrêté (aucun run en cours).');
|
|
157
160
|
return { action: 'shutdown' };
|
|
158
161
|
} catch (err) {
|
|
162
|
+
if (controller.signal.aborted) {
|
|
163
|
+
log('délai de fermeture du runtime dépassé — le shell termine sans attendre davantage.');
|
|
164
|
+
return { action: 'timeout', reason: 'shutdown_timeout' };
|
|
165
|
+
}
|
|
159
166
|
return { action: 'error', reason: err instanceof Error ? err.message : String(err) };
|
|
167
|
+
} finally {
|
|
168
|
+
clearTimeout(timeout);
|
|
160
169
|
}
|
|
161
170
|
}
|
package/src/runtime/runner.js
CHANGED
|
@@ -7,9 +7,11 @@ import { createAssignmentManager } from '../orchestrator/assignmentManager.js';
|
|
|
7
7
|
import { createAttemptManager } from '../orchestrator/attemptManager.js';
|
|
8
8
|
import { createBudgetManager, BudgetExceededError } from '../orchestrator/budgetManager.js';
|
|
9
9
|
import { createDispatcher } from '../orchestrator/dispatcher.js';
|
|
10
|
+
import { approvalRequestForTask } from '../orchestrator/approvalPolicy.js';
|
|
11
|
+
import { PENDING_STATUSES, tasksAwaitingApproval } from '../orchestrator/dependencyResolver.js';
|
|
10
12
|
import { assertValidatedFragment } from '../orchestrator/planValidator.js';
|
|
11
13
|
import { createResultAggregator } from '../orchestrator/resultAggregator.js';
|
|
12
|
-
import {
|
|
14
|
+
import { describePlanConcurrency, drainActive, startReadyTasks } from '../orchestrator/scheduler.js';
|
|
13
15
|
import { emitRuntimeLog, pollActivitiesOnce } from './supervisor.js';
|
|
14
16
|
|
|
15
17
|
// 0 by default: automatic replans turn evaluator/replanner TEXT into
|
|
@@ -139,9 +141,14 @@ export async function runRuntimeAgenticWorkflow(agent, session, input, {
|
|
|
139
141
|
// loop would re-ingest this run's own turns and duplicate them.
|
|
140
142
|
const runConversationSeed = conversationSeed(session, currentInput);
|
|
141
143
|
|
|
144
|
+
// The conversational loop path ends with the agent's own natural-language
|
|
145
|
+
// reply; the deterministic parallel scheduler has no agent voice, so only
|
|
146
|
+
// that path gets a synthesized outcome summary (announceRunOutcome).
|
|
147
|
+
let usedParallelScheduler = false;
|
|
142
148
|
while (true) {
|
|
143
149
|
sanitizeSessionPlanForExecution(session, runId);
|
|
144
|
-
|
|
150
|
+
usedParallelScheduler = shouldUseParallelScheduler(session.headlessPlan);
|
|
151
|
+
const result = usedParallelScheduler
|
|
145
152
|
? await runRuntimeParallelPlan(agent, session, input, {
|
|
146
153
|
signal,
|
|
147
154
|
timeoutMs,
|
|
@@ -188,6 +195,7 @@ export async function runRuntimeAgenticWorkflow(agent, session, input, {
|
|
|
188
195
|
emitRuntimeLog(session, 'runtime: run ended by user cancellation (no replan)');
|
|
189
196
|
return { ok: false, result, cancelled: true };
|
|
190
197
|
}
|
|
198
|
+
if (usedParallelScheduler) await announceRunOutcome(session, { runId, ok: false, signal });
|
|
191
199
|
dispatchAgentEvent(session, createAgentEvent('run_error', {
|
|
192
200
|
origin: 'runtime',
|
|
193
201
|
runId,
|
|
@@ -267,6 +275,7 @@ export async function runRuntimeAgenticWorkflow(agent, session, input, {
|
|
|
267
275
|
}
|
|
268
276
|
}
|
|
269
277
|
|
|
278
|
+
if (usedParallelScheduler) await announceRunOutcome(session, { runId, ok: true, signal });
|
|
270
279
|
dispatchAgentEvent(session, createAgentEvent('run_done', {
|
|
271
280
|
origin: 'runtime',
|
|
272
281
|
runId,
|
|
@@ -276,6 +285,62 @@ export async function runRuntimeAgenticWorkflow(agent, session, input, {
|
|
|
276
285
|
}
|
|
277
286
|
}
|
|
278
287
|
|
|
288
|
+
// Emit ONE natural-language Donna message summarizing how the run finished,
|
|
289
|
+
// instead of the client streaming a per-job line for every task. Uses the
|
|
290
|
+
// workspace LLM to phrase it, degrading to a plain templated fact line if the
|
|
291
|
+
// LLM is unavailable or errors — the run must never block on this summary.
|
|
292
|
+
async function announceRunOutcome(session, { runId, ok, signal = null } = {}) {
|
|
293
|
+
const plan = Array.isArray(session.headlessPlan) ? session.headlessPlan : [];
|
|
294
|
+
if (plan.length === 0) return;
|
|
295
|
+
let failed = 0;
|
|
296
|
+
let cancelled = 0;
|
|
297
|
+
let completed = 0;
|
|
298
|
+
let firstError = null;
|
|
299
|
+
for (const step of plan) {
|
|
300
|
+
const status = String(step?.status ?? '').toLowerCase();
|
|
301
|
+
if (['failed', 'error', 'stalled'].includes(status)) {
|
|
302
|
+
failed += 1;
|
|
303
|
+
firstError ??= String(
|
|
304
|
+
step?.error?.message ?? step?.error?.code ?? step?.error
|
|
305
|
+
?? step?.result?.error?.message ?? step?.result?.error?.code ?? '',
|
|
306
|
+
).trim() || null;
|
|
307
|
+
} else if (['cancelled', 'canceled'].includes(status)) {
|
|
308
|
+
cancelled += 1;
|
|
309
|
+
} else if (['done', 'complete', 'completed', 'success', 'succeeded'].includes(status)) {
|
|
310
|
+
completed += 1;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const total = plan.length;
|
|
314
|
+
const factLine = ok && failed === 0
|
|
315
|
+
? `Plan terminé avec succès — ${completed}/${total} tâche(s) réussie(s).`
|
|
316
|
+
: `Plan terminé en erreur — ${completed}/${total} tâche(s) réussie(s), ${failed} en erreur${cancelled ? `, ${cancelled} annulée(s)` : ''}.${firstError ? ` Première erreur : ${firstError}.` : ''}`;
|
|
317
|
+
let content = factLine;
|
|
318
|
+
const llm = session.llm;
|
|
319
|
+
if (llm && typeof llm.completeWithTools === 'function') {
|
|
320
|
+
try {
|
|
321
|
+
const result = await llm.completeWithTools({
|
|
322
|
+
system: [
|
|
323
|
+
'You are Donna, an orchestration assistant reporting a run result to the user.',
|
|
324
|
+
'Rephrase the outcome facts in ONE short, natural sentence, in the same language as the facts.',
|
|
325
|
+
'No lists, no headers, no raw job ids — just a concise human summary.',
|
|
326
|
+
].join('\n'),
|
|
327
|
+
tools: [],
|
|
328
|
+
messages: [{ role: 'user', content: `Run outcome facts:\n${factLine}` }],
|
|
329
|
+
signal,
|
|
330
|
+
});
|
|
331
|
+
const phrased = String(result?.content ?? '').trim();
|
|
332
|
+
if (phrased) content = phrased;
|
|
333
|
+
} catch {
|
|
334
|
+
// Degrade to the templated fact line — never fail the run on the summary.
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
dispatchAgentEvent(session, createAgentEvent('assistant_message', {
|
|
338
|
+
origin: 'runtime',
|
|
339
|
+
runId,
|
|
340
|
+
payload: { content },
|
|
341
|
+
}));
|
|
342
|
+
}
|
|
343
|
+
|
|
279
344
|
export async function runRuntimeParallelPlan(agent, session, input, {
|
|
280
345
|
signal = null,
|
|
281
346
|
timeoutMs,
|
|
@@ -298,11 +363,24 @@ export async function runRuntimeParallelPlan(agent, session, input, {
|
|
|
298
363
|
const configuredConcurrency = Number(concurrency) > 0
|
|
299
364
|
? Number(concurrency)
|
|
300
365
|
: Number(process.env.WIKI_MANAGER_CAPABILITY_CONCURRENCY || process.env.WIKI_MANAGER_SCHEDULER_CONCURRENCY);
|
|
301
|
-
const
|
|
366
|
+
const concurrencyDetail = describePlanConcurrency({
|
|
302
367
|
plan: session.headlessPlan ?? [],
|
|
303
368
|
agents,
|
|
304
369
|
configured: configuredConcurrency,
|
|
305
370
|
});
|
|
371
|
+
const limit = concurrencyDetail.limit;
|
|
372
|
+
// Publish the RESOLVED concurrency so both UIs show the real dispatch cap
|
|
373
|
+
// (not a value re-derived from the fragment). Display-only: scheduling still
|
|
374
|
+
// uses `limit` exactly as before. NOTE: this is a plain field assignment — do
|
|
375
|
+
// NOT emit a runtime_log here (it runs before ensurePlanProjection and would
|
|
376
|
+
// clobber session.headlessPlan via applyAgentProjectionToSession). The audit
|
|
377
|
+
// line is folded into the existing "parallel plan enabled" log below.
|
|
378
|
+
session._runConcurrency = {
|
|
379
|
+
limit,
|
|
380
|
+
ceiling: concurrencyDetail.ceiling,
|
|
381
|
+
agentLimit: concurrencyDetail.agentLimit,
|
|
382
|
+
cappedByCeiling: concurrencyDetail.cappedByCeiling,
|
|
383
|
+
};
|
|
306
384
|
const active = new Map();
|
|
307
385
|
const attempts = attemptManager ?? createAttemptManager();
|
|
308
386
|
const assigner = assignmentManager ?? createAssignmentManager({ session });
|
|
@@ -327,8 +405,7 @@ export async function runRuntimeParallelPlan(agent, session, input, {
|
|
|
327
405
|
};
|
|
328
406
|
sanitizeSessionPlanForExecution(session, runId);
|
|
329
407
|
ensurePlanProjection(session, runId);
|
|
330
|
-
emitRuntimeLog(session, `scheduler: parallel plan enabled (concurrency ${limit})`);
|
|
331
|
-
let approvalNoticeSent = false;
|
|
408
|
+
emitRuntimeLog(session, `scheduler: parallel plan enabled (concurrency ${limit}; agent=${concurrencyDetail.agentLimit ?? 'n/a'}, ceiling=${concurrencyDetail.ceiling ?? 'none'}${concurrencyDetail.cappedByCeiling ? ' → capped by manager ceiling' : ''})`);
|
|
332
409
|
// Interactive approvals do NOT expire: the user has /approve, "valide
|
|
333
410
|
// tout", /cancel and /run kill — an arbitrary timer only created mystery
|
|
334
411
|
// failures. A deadline exists only when explicitly configured (headless
|
|
@@ -431,26 +508,74 @@ export async function runRuntimeParallelPlan(agent, session, input, {
|
|
|
431
508
|
emitRuntimeLog(session, `scheduler: budget exceeded (${exceeded.reason})`);
|
|
432
509
|
return { ok: false, budgetExceeded: true, reason: exceeded.reason, budget: exceeded, completed: sessionActivities(session), failures };
|
|
433
510
|
}
|
|
434
|
-
|
|
511
|
+
// Only wait for a human when approval is the sole remaining blocker.
|
|
512
|
+
// A task whose dependency failed cannot become runnable by approving
|
|
513
|
+
// it; treating it as an approval wait leaves the run alive forever.
|
|
514
|
+
const approvalContext = {
|
|
515
|
+
runId,
|
|
516
|
+
workspace: session.workspace ?? null,
|
|
517
|
+
planRevision: session.planRevision ?? session.agentProjection?.planRevision ?? null,
|
|
518
|
+
tasks: session.headlessPlan ?? [],
|
|
519
|
+
};
|
|
520
|
+
// One snapshot of the approvals list, reused for both the
|
|
521
|
+
// awaiting-approval computation and the per-task dedup below so the two
|
|
522
|
+
// can never diverge mid-iteration.
|
|
523
|
+
const approvals = session.agentProjection?.approvals ?? session.approvals ?? [];
|
|
524
|
+
const needingApproval = tasksAwaitingApproval(approvalContext, { approvals });
|
|
435
525
|
if (needingApproval.length > 0) {
|
|
436
526
|
// The plan is only blocked on a HUMAN decision — wait for it
|
|
437
527
|
// (bounded) instead of declaring the run stalled. Announce once in
|
|
438
528
|
// the chat: users cannot approve what they never saw asked.
|
|
439
|
-
|
|
440
|
-
|
|
529
|
+
const newlyRequested = [];
|
|
530
|
+
for (const task of needingApproval) {
|
|
531
|
+
const taskId = String(task.id ?? task.taskId ?? task.step ?? '');
|
|
532
|
+
const alreadyRequested = approvals.some((approval) =>
|
|
533
|
+
approval.status === 'pending_approval'
|
|
534
|
+
&& String(approval.taskId ?? approval.itemId ?? '') === taskId
|
|
535
|
+
&& Number(approval.planRevision ?? approvalContext.planRevision) === Number(approvalContext.planRevision));
|
|
536
|
+
if (alreadyRequested) continue;
|
|
537
|
+
newlyRequested.push(task);
|
|
538
|
+
const request = approvalRequestForTask(task, {
|
|
539
|
+
runId,
|
|
540
|
+
workspaceId: session.workspace ?? null,
|
|
541
|
+
planRevision: approvalContext.planRevision,
|
|
542
|
+
});
|
|
543
|
+
dispatchAgentEvent(session, createAgentEvent('approval.requested', {
|
|
544
|
+
origin: 'runtime',
|
|
545
|
+
runId,
|
|
546
|
+
taskId: request.taskId,
|
|
547
|
+
workspace: session.workspace ?? null,
|
|
548
|
+
payload: request,
|
|
549
|
+
}));
|
|
550
|
+
// Reflect the block on the task's plan step so the UIs can render it
|
|
551
|
+
// distinctly (amber "[⏸]" in the Shell, banner/badge in serve)
|
|
552
|
+
// instead of leaving it as a neutral "pending" indistinguishable
|
|
553
|
+
// from a not-started step. Only promote a plain pending task — never
|
|
554
|
+
// overwrite a status the planner already set (e.g. waiting_approval).
|
|
555
|
+
const currentStatus = String(task.status ?? '').toLowerCase();
|
|
556
|
+
if (!['pending_approval', 'waiting_approval'].includes(currentStatus)) {
|
|
557
|
+
dispatchAgentEvent(session, createAgentEvent('plan_step_updated', {
|
|
558
|
+
origin: 'runtime',
|
|
559
|
+
runId,
|
|
560
|
+
taskId,
|
|
561
|
+
payload: { taskId, status: 'pending_approval' },
|
|
562
|
+
}));
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
if (newlyRequested.length > 0) {
|
|
441
566
|
dispatchAgentEvent(session, createAgentEvent('assistant_message', {
|
|
442
567
|
origin: 'runtime',
|
|
443
568
|
runId,
|
|
444
569
|
payload: {
|
|
445
570
|
content: [
|
|
446
|
-
`⏸ Approbation requise avant exécution : ${
|
|
447
|
-
...
|
|
448
|
-
|
|
571
|
+
`⏸ Approbation requise avant exécution : ${newlyRequested.length} tâche(s) mutante(s) en attente.`,
|
|
572
|
+
...newlyRequested.slice(0, 5).map((step) => ` - ${step.description ?? step.id}`),
|
|
573
|
+
newlyRequested.length > 5 ? ` … et ${newlyRequested.length - 5} autre(s).` : null,
|
|
449
574
|
'Réponds « valide tout » (ou tape /approve) pour lancer, « annule » pour abandonner.',
|
|
450
575
|
].filter(Boolean).join('\n'),
|
|
451
576
|
},
|
|
452
577
|
}));
|
|
453
|
-
emitRuntimeLog(session, `scheduler: waiting for approval (${
|
|
578
|
+
emitRuntimeLog(session, `scheduler: waiting for approval (${newlyRequested.length} new task(s))`);
|
|
454
579
|
}
|
|
455
580
|
if (Date.now() < approvalDeadline) {
|
|
456
581
|
await new Promise((resolveDelay) => setTimeout(resolveDelay, 500));
|
|
@@ -469,7 +594,9 @@ export async function runRuntimeParallelPlan(agent, session, input, {
|
|
|
469
594
|
}));
|
|
470
595
|
return { ok: false, stalled: true, reason: 'awaiting_approval', completed: sessionActivities(session), failures };
|
|
471
596
|
}
|
|
472
|
-
|
|
597
|
+
// Any genuine approval-only block returned above. Remaining tasks are
|
|
598
|
+
// unschedulable for another reason (most commonly a failed dependency).
|
|
599
|
+
const reason = 'no_ready_plan_task';
|
|
473
600
|
emitRuntimeLog(session, `scheduler: stalled (${reason})`);
|
|
474
601
|
return { ok: false, stalled: true, reason, completed: sessionActivities(session), failures };
|
|
475
602
|
}
|
|
@@ -585,11 +712,7 @@ function abortCancelledActiveTasks(session, active) {
|
|
|
585
712
|
}
|
|
586
713
|
|
|
587
714
|
function pendingSchedulerStatus(status) {
|
|
588
|
-
return
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
function approvalWaitingStatus(status) {
|
|
592
|
-
return ['pending_approval', 'waiting_approval'].includes(String(status ?? ''));
|
|
715
|
+
return PENDING_STATUSES.has(String(status ?? ''));
|
|
593
716
|
}
|
|
594
717
|
|
|
595
718
|
// Scope the evaluator/replanner's view of "completed" activities to the
|
|
@@ -812,6 +812,36 @@ test('runRuntimeParallelPlan fails cleanly when scheduler budget is exceeded', a
|
|
|
812
812
|
assert.equal(session.headlessPlan[0].status, 'cancelled');
|
|
813
813
|
});
|
|
814
814
|
|
|
815
|
+
test('runRuntimeParallelPlan does not wait for approval behind a failed dependency', async () => {
|
|
816
|
+
const session = {
|
|
817
|
+
workspace: 'demo-workspace',
|
|
818
|
+
mcp: { tools: {} },
|
|
819
|
+
agentEvents: [],
|
|
820
|
+
headlessPlan: [
|
|
821
|
+
{ ...plannedDoctorTask('a', []), status: 'failed' },
|
|
822
|
+
{
|
|
823
|
+
...plannedDoctorTask('b', ['a']),
|
|
824
|
+
status: 'waiting_approval',
|
|
825
|
+
requiresApproval: true,
|
|
826
|
+
approvalClass: 'mutation',
|
|
827
|
+
},
|
|
828
|
+
],
|
|
829
|
+
};
|
|
830
|
+
|
|
831
|
+
const result = await runRuntimeParallelPlan(
|
|
832
|
+
{ invoke: async () => assert.fail('blocked work must not invoke an agent') },
|
|
833
|
+
session,
|
|
834
|
+
'Apply after failed plan',
|
|
835
|
+
{ runId: 'run-failed-dependency', timeoutMs: 1000, maxTurns: 1 },
|
|
836
|
+
);
|
|
837
|
+
|
|
838
|
+
assert.equal(result.ok, false);
|
|
839
|
+
assert.equal(result.stalled, true);
|
|
840
|
+
assert.equal(result.reason, 'no_ready_plan_task');
|
|
841
|
+
assert.equal(session.agentEvents.some((event) => event.type === 'assistant_message'
|
|
842
|
+
&& /Approbation requise/.test(event.payload?.content ?? '')), false);
|
|
843
|
+
});
|
|
844
|
+
|
|
815
845
|
test('runRuntimeParallelPlan retries a retryable task on a fallback agent', async () => {
|
|
816
846
|
const executeServers = [];
|
|
817
847
|
const session = {
|
package/src/runtime/server.js
CHANGED
|
@@ -6,6 +6,8 @@ import { normalizePlanPatch, rebasePlanPatch } from '../core/planPatch.js';
|
|
|
6
6
|
import { validateContractInDev } from '../contracts/schemas.js';
|
|
7
7
|
import { runtimeTokenFromEnv } from './auth.js';
|
|
8
8
|
import { controlMessage } from './controlMessages.js';
|
|
9
|
+
import { tasksAwaitingApproval } from '../orchestrator/dependencyResolver.js';
|
|
10
|
+
import { approvalClassForTask } from '../orchestrator/approvalPolicy.js';
|
|
9
11
|
|
|
10
12
|
export function startRuntimeServer({
|
|
11
13
|
host = '127.0.0.1',
|
|
@@ -679,10 +681,19 @@ function readOnlyControlResponse(kind, classification, status, explanation, { ac
|
|
|
679
681
|
};
|
|
680
682
|
}
|
|
681
683
|
|
|
682
|
-
function approvalRequestFromStatus(status) {
|
|
684
|
+
export function approvalRequestFromStatus(status) {
|
|
683
685
|
const runId = status.runId ?? status.runs?.find((run) => run.status === 'running' || run.status === 'pending_approval')?.id ?? null;
|
|
684
686
|
const pending = (status.approvals ?? []).filter((approval) => approval.status === 'pending_approval');
|
|
685
|
-
const
|
|
687
|
+
const waitingTasks = tasksAwaitingApproval({
|
|
688
|
+
runId,
|
|
689
|
+
workspace: status.workspace ?? null,
|
|
690
|
+
planRevision: status.planRevision ?? null,
|
|
691
|
+
tasks: status.plan ?? [],
|
|
692
|
+
}, { approvals: status.approvals ?? [] });
|
|
693
|
+
const classes = [...new Set([
|
|
694
|
+
...pending.flatMap((approval) => readOptionalList(approval.approvalClasses ?? approval.approvalClass)),
|
|
695
|
+
...waitingTasks.map((task) => approvalClassForTask(task)),
|
|
696
|
+
].filter(Boolean))];
|
|
686
697
|
return {
|
|
687
698
|
workspace: status.workspace ?? null,
|
|
688
699
|
workspaceId: status.workspace ?? null,
|