@dotdrelle/wiki-manager 0.14.13 → 0.14.16
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 +11 -0
- package/docker-compose.yml +1 -1
- package/package.json +1 -1
- package/src/activity/activityAggregator.js +43 -13
- package/src/activity/activityAggregator.test.js +51 -2
- package/src/agent/graph.js +79 -11
- package/src/agent/graph.test.js +43 -3
- package/src/cli/wiki-manager.js +44 -3
- 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/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.test.js +22 -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 +111 -15
- 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/shell/FileEditorDialog.tsx +2 -2
- package/src/shell/LeftPane.tsx +60 -15
- package/src/shell/RightPane.tsx +147 -54
- 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 +128 -38
- package/src/shell/tui.tsx +59 -64
- package/src/shell/useSession.ts +45 -6
- package/wiki-workspace +28 -0
package/src/core/wikiSetup.js
CHANGED
|
@@ -105,6 +105,41 @@ export async function stopAgents(options = {}) {
|
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
export async function refreshRunningContainers(options = {}) {
|
|
109
|
+
if (process.env.WIKI_MANAGER_AUTO_UPDATE === '0') {
|
|
110
|
+
return { skipped: true, refreshed: [] };
|
|
111
|
+
}
|
|
112
|
+
const script = join(managerRoot(), 'wiki-workspace');
|
|
113
|
+
const common = {
|
|
114
|
+
cwd: dirname(managerEnvFile()),
|
|
115
|
+
env: {
|
|
116
|
+
...process.env,
|
|
117
|
+
WIKI_WORKSPACES_DIR: workspacesDir(),
|
|
118
|
+
WIKI_MANAGER_ENV_FILE: managerEnvFile(),
|
|
119
|
+
WIKI_MANAGER_ENDPOINTS_FILE: managerMcpEndpointsFile(),
|
|
120
|
+
AGENTS_DATA_DIR: resolveAgentsDataDir(),
|
|
121
|
+
},
|
|
122
|
+
timeout: options.timeout ?? 600_000,
|
|
123
|
+
maxBuffer: options.maxBuffer ?? 1024 * 1024 * 8,
|
|
124
|
+
};
|
|
125
|
+
const targets = [['agents', 'refresh'], ...listWorkspaces().map((workspace) => ['wiki', workspace.name, 'refresh'])];
|
|
126
|
+
// Each target is an independent Compose project (its own agents/workspace
|
|
127
|
+
// stack) — refresh them concurrently instead of summing every target's
|
|
128
|
+
// pull/restart time into one sequential wait.
|
|
129
|
+
const results = await Promise.all(targets.map(async (args) => {
|
|
130
|
+
options.onStep?.(`Images: checking ${args[0] === 'agents' ? 'running agents' : `workspace ${args[1]}`}…`);
|
|
131
|
+
try {
|
|
132
|
+
const { stdout, stderr } = await execFileAsync(script, args, common);
|
|
133
|
+
return { ok: true, output: [stdout, stderr].filter(Boolean).join('\n').trim() };
|
|
134
|
+
} catch (err) {
|
|
135
|
+
return { ok: false, error: wrapDockerError(err).message };
|
|
136
|
+
}
|
|
137
|
+
}));
|
|
138
|
+
const refreshed = results.filter((result) => result.ok).map((result) => result.output).filter(Boolean);
|
|
139
|
+
const errors = results.filter((result) => !result.ok).map((result) => result.error);
|
|
140
|
+
return { skipped: false, refreshed, errors };
|
|
141
|
+
}
|
|
142
|
+
|
|
108
143
|
export async function createNewWorkspace(name, targetPath) {
|
|
109
144
|
try {
|
|
110
145
|
const output = await createWorkspace(name, targetPath, { timeout: 600_000 });
|
|
@@ -31,3 +31,23 @@ test('wiki-workspace regenerates CA compose overrides instead of retaining remov
|
|
|
31
31
|
assert.match(script, /mv "\$tmp_override" "\$override_path"/);
|
|
32
32
|
assert.match(script, /Changes are overwritten on the next compose command/);
|
|
33
33
|
});
|
|
34
|
+
|
|
35
|
+
test('workspace creation keeps mutable manager files outside the installed package', async () => {
|
|
36
|
+
const source = await readFile(new URL('./workspaces.js', import.meta.url), 'utf8');
|
|
37
|
+
|
|
38
|
+
assert.match(source, /const stateDir = dirname\(managerEnvFile\(\)\)/);
|
|
39
|
+
assert.match(source, /cwd: stateDir/);
|
|
40
|
+
assert.match(source, /WIKI_MANAGER_ENV_FILE: managerEnvFile\(\)/);
|
|
41
|
+
assert.match(source, /WIKI_MANAGER_ENDPOINTS_FILE: managerMcpEndpointsFile\(\)/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('container refresh pulls and renews only services that are already running', async () => {
|
|
45
|
+
const script = await readFile(new URL('../../wiki-workspace', import.meta.url), 'utf8');
|
|
46
|
+
|
|
47
|
+
assert.match(script, /refresh_running_services\(\) \{/);
|
|
48
|
+
assert.match(script, /\[\[ -n "\$\{line\/\/\[\[:space:\]\]\/\}" \]\] \|\| continue/);
|
|
49
|
+
assert.match(script, /ps --status running --services/);
|
|
50
|
+
assert.match(script, /"\$@" pull "\$\{running_services\[@\]\}"/);
|
|
51
|
+
assert.match(script, /refresh_running_services 'No running external agent containers to refresh' 'Refreshed running agents: %s' _agents_dc/);
|
|
52
|
+
assert.match(script, /refresh_running_services "No running workspace containers to refresh: \$workspace" "Refreshed running workspace containers: \$workspace \(%s\)" compose_for_workspace "\$workspace"/);
|
|
53
|
+
});
|
package/src/core/workspaces.js
CHANGED
|
@@ -3,7 +3,7 @@ import { existsSync, readdirSync, realpathSync } from 'node:fs';
|
|
|
3
3
|
import { dirname, join, resolve } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
|
-
import { managerEnvFile, readEnvFile, userManagerDir } from './env.js';
|
|
6
|
+
import { managerEnvFile, managerMcpEndpointsFile, readEnvFile, userManagerDir } from './env.js';
|
|
7
7
|
|
|
8
8
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
9
9
|
const packageRoot = resolve(__dirname, '../..');
|
|
@@ -70,15 +70,23 @@ export async function createWorkspace(name, targetPath = null, options = {}) {
|
|
|
70
70
|
}
|
|
71
71
|
const args = ['config', name];
|
|
72
72
|
if (targetPath) args.push(targetPath);
|
|
73
|
+
const stateDir = dirname(managerEnvFile());
|
|
73
74
|
const { stdout, stderr } = await execFileAsync(
|
|
74
75
|
join(managerRoot(), 'wiki-workspace'),
|
|
75
76
|
args,
|
|
76
77
|
{
|
|
77
|
-
|
|
78
|
+
// Relative Compose mounts and default scaffold paths must resolve from
|
|
79
|
+
// the user's manager state, never from the globally installed package.
|
|
80
|
+
cwd: stateDir,
|
|
78
81
|
env: {
|
|
79
82
|
...process.env,
|
|
80
83
|
WIKI_WORKSPACES_DIR: workspacesDir(),
|
|
81
84
|
WIKI_MANAGER_ENV_FILE: managerEnvFile(),
|
|
85
|
+
// The script runs from the installed package directory so it can find
|
|
86
|
+
// its Compose templates. Pin mutable manager state to the user's
|
|
87
|
+
// launch directory; a global npm package is commonly owned by root
|
|
88
|
+
// and must never become the destination for this scaffold.
|
|
89
|
+
WIKI_MANAGER_ENDPOINTS_FILE: managerMcpEndpointsFile(),
|
|
82
90
|
},
|
|
83
91
|
maxBuffer: options.maxBuffer ?? 1024 * 1024 * 8,
|
|
84
92
|
timeout: options.timeout ?? 600_000,
|
|
@@ -3,6 +3,9 @@ import { approvalCovered } from './approvalPolicy.js';
|
|
|
3
3
|
|
|
4
4
|
const DONE_STATUSES = new Set(['done', 'completed', 'complete', 'success', 'succeeded']);
|
|
5
5
|
const TERMINAL_STATUSES = new Set([...DONE_STATUSES, 'failed', 'cancelled', 'canceled', 'skipped']);
|
|
6
|
+
// A task in one of these statuses hasn't run yet but could become ready —
|
|
7
|
+
// shared with runner.js's scheduler-stall check so the two can't drift apart.
|
|
8
|
+
export const PENDING_STATUSES = new Set(['pending', 'pending_approval', 'waiting_approval']);
|
|
6
9
|
|
|
7
10
|
export function readyTasks(dag, {
|
|
8
11
|
registry = null,
|
|
@@ -18,7 +21,7 @@ export function readyTasks(dag, {
|
|
|
18
21
|
.filter((task) => {
|
|
19
22
|
const status = statusOf(task);
|
|
20
23
|
return status === 'pending'
|
|
21
|
-
|| ((status
|
|
24
|
+
|| (PENDING_STATUSES.has(status)
|
|
22
25
|
&& approvalCovered(task, approvals, {
|
|
23
26
|
runId: task?.runId ?? dag?.runId ?? null,
|
|
24
27
|
workspaceId: dag?.workspace ?? null,
|
|
@@ -35,6 +38,21 @@ export function readyTasks(dag, {
|
|
|
35
38
|
.sort(compareTaskPriority);
|
|
36
39
|
}
|
|
37
40
|
|
|
41
|
+
export function tasksAwaitingApproval(dag, { approvals = [] } = {}) {
|
|
42
|
+
const tasks = normalizeTasks(dag);
|
|
43
|
+
const done = new Set(tasks.filter((task) => DONE_STATUSES.has(statusOf(task))).map(taskId));
|
|
44
|
+
return tasks
|
|
45
|
+
.filter((task) => PENDING_STATUSES.has(statusOf(task)))
|
|
46
|
+
.filter((task) => task?.requiresApproval === true)
|
|
47
|
+
.filter((task) => !approvalCovered(task, approvals, {
|
|
48
|
+
runId: task?.runId ?? dag?.runId ?? null,
|
|
49
|
+
workspaceId: dag?.workspace ?? null,
|
|
50
|
+
planRevision: dag?.planRevision ?? null,
|
|
51
|
+
}))
|
|
52
|
+
.filter((task) => dependenciesDone(task, done))
|
|
53
|
+
.filter((task) => groupBarrierSatisfied(task, tasks));
|
|
54
|
+
}
|
|
55
|
+
|
|
38
56
|
function normalizeTasks(dag) {
|
|
39
57
|
if (Array.isArray(dag)) return dag;
|
|
40
58
|
if (Array.isArray(dag?.tasks)) return dag.tasks;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export async function resolveObjective(objective, session) {
|
|
2
2
|
const candidates = capabilityCandidates(session);
|
|
3
3
|
if (candidates.length === 0) throw new Error('No orchestrable capability is currently available.');
|
|
4
|
+
const deterministic = resolveMentionedRegistryOperation(objective, candidates);
|
|
5
|
+
if (deterministic) return selectionWithProvider(session, deterministic, candidates);
|
|
4
6
|
const llm = session?.llm;
|
|
5
7
|
if (!llm?.completeWithTools) throw new Error('Objective resolution requires the configured workspace LLM.');
|
|
6
8
|
|
|
@@ -25,6 +27,28 @@ export async function resolveObjective(objective, session) {
|
|
|
25
27
|
if (!candidate.operations.includes(operation)) {
|
|
26
28
|
throw new Error(`Objective resolver selected unsupported operation "${operation}" for ${capability}.`);
|
|
27
29
|
}
|
|
30
|
+
return selectionWithProvider(session, { capability, operation }, candidates);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Prefer an operation explicitly named by the user when that name resolves to
|
|
34
|
+
// exactly one entry in the live registry. This is deliberately generic: the
|
|
35
|
+
// resolver knows neither capability ids nor business verbs. Prefix matching
|
|
36
|
+
// covers natural inflections such as an operation name followed by a suffix.
|
|
37
|
+
function resolveMentionedRegistryOperation(objective, candidates) {
|
|
38
|
+
const words = String(objective ?? '')
|
|
39
|
+
.normalize('NFKD')
|
|
40
|
+
.replace(/\p{Diacritic}/gu, '')
|
|
41
|
+
.toLowerCase()
|
|
42
|
+
.match(/[a-z0-9]+/g) ?? [];
|
|
43
|
+
const matches = candidates.flatMap((candidate) => candidate.operations
|
|
44
|
+
.filter((operation) => String(operation).split(/[._-]+/).some((token) =>
|
|
45
|
+
token.length >= 4 && words.some((word) => word.startsWith(token))))
|
|
46
|
+
.map((operation) => ({ capability: candidate.id, operation })));
|
|
47
|
+
return matches.length === 1 ? matches[0] : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function selectionWithProvider(session, selection, candidates) {
|
|
51
|
+
const { capability, operation } = selection;
|
|
28
52
|
const providers = providersFor(session, capability)
|
|
29
53
|
.filter((provider) => !operation || (provider.capability?.supportedOperations ?? []).includes(operation))
|
|
30
54
|
.sort((a, b) => String(a.agentInstanceId).localeCompare(String(b.agentInstanceId)));
|
|
@@ -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
|
});
|
|
@@ -4,7 +4,7 @@ import { join } from 'node:path';
|
|
|
4
4
|
import test from 'node:test';
|
|
5
5
|
|
|
6
6
|
import { createBudgetManager } from './budgetManager.js';
|
|
7
|
-
import { readyTasks } from './dependencyResolver.js';
|
|
7
|
+
import { readyTasks, tasksAwaitingApproval } from './dependencyResolver.js';
|
|
8
8
|
import { createLockManager } from './lockManager.js';
|
|
9
9
|
import {
|
|
10
10
|
effectiveConcurrency,
|
|
@@ -60,6 +60,27 @@ test('dependencyResolver releases a waiting task when a run grant covers it', ()
|
|
|
60
60
|
}).map((item) => item.id), ['ingest']);
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
+
test('dependencyResolver does not request approval for a task blocked by a failed dependency', () => {
|
|
64
|
+
const plan = {
|
|
65
|
+
runId: 'run-1',
|
|
66
|
+
workspace: 'test4',
|
|
67
|
+
planRevision: 1,
|
|
68
|
+
tasks: [
|
|
69
|
+
task('plan', { status: 'failed' }),
|
|
70
|
+
task('apply', {
|
|
71
|
+
status: 'waiting_approval',
|
|
72
|
+
dependsOn: ['plan'],
|
|
73
|
+
requiresApproval: true,
|
|
74
|
+
approvalClass: 'mutation',
|
|
75
|
+
}),
|
|
76
|
+
],
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
assert.deepEqual(tasksAwaitingApproval(plan), []);
|
|
80
|
+
plan.tasks[0].status = 'done';
|
|
81
|
+
assert.deepEqual(tasksAwaitingApproval(plan).map((item) => item.id), ['apply']);
|
|
82
|
+
});
|
|
83
|
+
|
|
63
84
|
test('dependencyResolver skips tasks whose locks are not free and keeps other ready work moving', () => {
|
|
64
85
|
const lockManager = createLockManager();
|
|
65
86
|
const held = lockManager.acquire(['deliverable:a.md']);
|
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,6 +7,8 @@ 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
14
|
import { drainActive, resolvePlanConcurrency, startReadyTasks } from '../orchestrator/scheduler.js';
|
|
@@ -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,
|
|
@@ -328,7 +393,6 @@ export async function runRuntimeParallelPlan(agent, session, input, {
|
|
|
328
393
|
sanitizeSessionPlanForExecution(session, runId);
|
|
329
394
|
ensurePlanProjection(session, runId);
|
|
330
395
|
emitRuntimeLog(session, `scheduler: parallel plan enabled (concurrency ${limit})`);
|
|
331
|
-
let approvalNoticeSent = false;
|
|
332
396
|
// Interactive approvals do NOT expire: the user has /approve, "valide
|
|
333
397
|
// tout", /cancel and /run kill — an arbitrary timer only created mystery
|
|
334
398
|
// failures. A deadline exists only when explicitly configured (headless
|
|
@@ -431,26 +495,60 @@ export async function runRuntimeParallelPlan(agent, session, input, {
|
|
|
431
495
|
emitRuntimeLog(session, `scheduler: budget exceeded (${exceeded.reason})`);
|
|
432
496
|
return { ok: false, budgetExceeded: true, reason: exceeded.reason, budget: exceeded, completed: sessionActivities(session), failures };
|
|
433
497
|
}
|
|
434
|
-
|
|
498
|
+
// Only wait for a human when approval is the sole remaining blocker.
|
|
499
|
+
// A task whose dependency failed cannot become runnable by approving
|
|
500
|
+
// it; treating it as an approval wait leaves the run alive forever.
|
|
501
|
+
const approvalContext = {
|
|
502
|
+
runId,
|
|
503
|
+
workspace: session.workspace ?? null,
|
|
504
|
+
planRevision: session.planRevision ?? session.agentProjection?.planRevision ?? null,
|
|
505
|
+
tasks: session.headlessPlan ?? [],
|
|
506
|
+
};
|
|
507
|
+
// One snapshot of the approvals list, reused for both the
|
|
508
|
+
// awaiting-approval computation and the per-task dedup below so the two
|
|
509
|
+
// can never diverge mid-iteration.
|
|
510
|
+
const approvals = session.agentProjection?.approvals ?? session.approvals ?? [];
|
|
511
|
+
const needingApproval = tasksAwaitingApproval(approvalContext, { approvals });
|
|
435
512
|
if (needingApproval.length > 0) {
|
|
436
513
|
// The plan is only blocked on a HUMAN decision — wait for it
|
|
437
514
|
// (bounded) instead of declaring the run stalled. Announce once in
|
|
438
515
|
// the chat: users cannot approve what they never saw asked.
|
|
439
|
-
|
|
440
|
-
|
|
516
|
+
const newlyRequested = [];
|
|
517
|
+
for (const task of needingApproval) {
|
|
518
|
+
const taskId = String(task.id ?? task.taskId ?? task.step ?? '');
|
|
519
|
+
const alreadyRequested = approvals.some((approval) =>
|
|
520
|
+
approval.status === 'pending_approval'
|
|
521
|
+
&& String(approval.taskId ?? approval.itemId ?? '') === taskId
|
|
522
|
+
&& Number(approval.planRevision ?? approvalContext.planRevision) === Number(approvalContext.planRevision));
|
|
523
|
+
if (alreadyRequested) continue;
|
|
524
|
+
newlyRequested.push(task);
|
|
525
|
+
const request = approvalRequestForTask(task, {
|
|
526
|
+
runId,
|
|
527
|
+
workspaceId: session.workspace ?? null,
|
|
528
|
+
planRevision: approvalContext.planRevision,
|
|
529
|
+
});
|
|
530
|
+
dispatchAgentEvent(session, createAgentEvent('approval.requested', {
|
|
531
|
+
origin: 'runtime',
|
|
532
|
+
runId,
|
|
533
|
+
taskId: request.taskId,
|
|
534
|
+
workspace: session.workspace ?? null,
|
|
535
|
+
payload: request,
|
|
536
|
+
}));
|
|
537
|
+
}
|
|
538
|
+
if (newlyRequested.length > 0) {
|
|
441
539
|
dispatchAgentEvent(session, createAgentEvent('assistant_message', {
|
|
442
540
|
origin: 'runtime',
|
|
443
541
|
runId,
|
|
444
542
|
payload: {
|
|
445
543
|
content: [
|
|
446
|
-
`⏸ Approbation requise avant exécution : ${
|
|
447
|
-
...
|
|
448
|
-
|
|
544
|
+
`⏸ Approbation requise avant exécution : ${newlyRequested.length} tâche(s) mutante(s) en attente.`,
|
|
545
|
+
...newlyRequested.slice(0, 5).map((step) => ` - ${step.description ?? step.id}`),
|
|
546
|
+
newlyRequested.length > 5 ? ` … et ${newlyRequested.length - 5} autre(s).` : null,
|
|
449
547
|
'Réponds « valide tout » (ou tape /approve) pour lancer, « annule » pour abandonner.',
|
|
450
548
|
].filter(Boolean).join('\n'),
|
|
451
549
|
},
|
|
452
550
|
}));
|
|
453
|
-
emitRuntimeLog(session, `scheduler: waiting for approval (${
|
|
551
|
+
emitRuntimeLog(session, `scheduler: waiting for approval (${newlyRequested.length} new task(s))`);
|
|
454
552
|
}
|
|
455
553
|
if (Date.now() < approvalDeadline) {
|
|
456
554
|
await new Promise((resolveDelay) => setTimeout(resolveDelay, 500));
|
|
@@ -469,7 +567,9 @@ export async function runRuntimeParallelPlan(agent, session, input, {
|
|
|
469
567
|
}));
|
|
470
568
|
return { ok: false, stalled: true, reason: 'awaiting_approval', completed: sessionActivities(session), failures };
|
|
471
569
|
}
|
|
472
|
-
|
|
570
|
+
// Any genuine approval-only block returned above. Remaining tasks are
|
|
571
|
+
// unschedulable for another reason (most commonly a failed dependency).
|
|
572
|
+
const reason = 'no_ready_plan_task';
|
|
473
573
|
emitRuntimeLog(session, `scheduler: stalled (${reason})`);
|
|
474
574
|
return { ok: false, stalled: true, reason, completed: sessionActivities(session), failures };
|
|
475
575
|
}
|
|
@@ -585,11 +685,7 @@ function abortCancelledActiveTasks(session, active) {
|
|
|
585
685
|
}
|
|
586
686
|
|
|
587
687
|
function pendingSchedulerStatus(status) {
|
|
588
|
-
return
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
function approvalWaitingStatus(status) {
|
|
592
|
-
return ['pending_approval', 'waiting_approval'].includes(String(status ?? ''));
|
|
688
|
+
return PENDING_STATUSES.has(String(status ?? ''));
|
|
593
689
|
}
|
|
594
690
|
|
|
595
691
|
// 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 = {
|