@dotdrelle/wiki-manager 0.9.3 → 0.10.4
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/README.md +21 -6
- package/agents.docker-compose.yml +5 -0
- package/package.json +5 -2
- package/src/agent/graph.js +68 -9
- package/src/agent/graph.test.js +64 -0
- package/src/cli/wiki-manager.js +1 -1
- package/src/commands/slash.js +6 -2
- package/src/contracts/README.md +16 -0
- package/src/contracts/schemas.js +302 -0
- package/src/contracts/schemas.test.js +93 -0
- package/src/core/activity.js +14 -2
- package/src/core/activity.test.js +4 -2
- package/src/core/agentEvents.js +158 -15
- package/src/core/agentEvents.test.js +54 -12
- package/src/core/agentLoop.js +32 -7
- package/src/core/mcp.js +3 -1
- package/src/core/plan.js +4 -0
- package/src/core/planPatch.js +224 -0
- package/src/core/planPatch.test.js +63 -0
- package/src/core/workflow.js +264 -0
- package/src/core/workflow.test.js +66 -0
- package/src/runtime/client.js +28 -1
- package/src/runtime/runner.js +432 -20
- package/src/runtime/runner.test.js +273 -1
- package/src/runtime/server.js +322 -15
- package/src/runtime/server.test.js +339 -0
- package/src/runtime/store.js +59 -10
- package/src/runtime/store.test.js +47 -1
- package/src/shell/RightPane.tsx +1 -7
- package/src/shell/StartupScreen.tsx +212 -0
- package/src/shell/repl.js +51 -7
- package/src/shell/repl.test.js +77 -1
- package/src/shell/textFit.ts +6 -0
- package/src/shell/tui.tsx +163 -0
- package/src/shell/useAgent.ts +17 -9
- package/src/shell/useSession.ts +34 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { projectWorkflow } from './workflow.js';
|
|
4
|
+
|
|
5
|
+
test('projectWorkflow links structured plan tasks to activities and executors', () => {
|
|
6
|
+
const workflow = projectWorkflow({
|
|
7
|
+
status: 'running',
|
|
8
|
+
runId: 'run-1',
|
|
9
|
+
workspace: 'docs',
|
|
10
|
+
plan: [
|
|
11
|
+
{ step: 1, id: 'export', description: 'Export CME', status: 'done', executor: 'cme.cme_export_run', outputRefs: ['raw/untracked'] },
|
|
12
|
+
{ step: 2, id: 'build', description: 'Build deliverable', status: 'running', dependsOn: ['export'], executor: 'production.production_start_job', activityKey: 'production:job-1' },
|
|
13
|
+
],
|
|
14
|
+
activities: [{
|
|
15
|
+
key: 'production:job-1',
|
|
16
|
+
id: 'job-1',
|
|
17
|
+
source: 'production',
|
|
18
|
+
label: 'Production build',
|
|
19
|
+
status: 'running',
|
|
20
|
+
progress: { percent: 42, stepId: 'build' },
|
|
21
|
+
}],
|
|
22
|
+
queue: [],
|
|
23
|
+
approvals: [],
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
assert.equal(workflow.summary.status, 'running');
|
|
27
|
+
assert.equal(workflow.current.id, 'task:build');
|
|
28
|
+
assert.equal(workflow.next, null);
|
|
29
|
+
assert.equal(workflow.progress.mode, 'activity_percent');
|
|
30
|
+
assert.equal(workflow.progress.percent, 42);
|
|
31
|
+
assert.ok(workflow.relations.some((relation) => relation.type === 'depends_on' && relation.from === 'task:build' && relation.to === 'task:export'));
|
|
32
|
+
assert.ok(workflow.relations.some((relation) => relation.type === 'executed_by' && relation.from === 'task:build' && relation.to === 'activity:production:job-1'));
|
|
33
|
+
assert.ok(workflow.relations.some((relation) => relation.type === 'produces' && relation.from === 'task:export' && relation.to === 'output:raw/untracked'));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('projectWorkflow keeps old sequential runs readable', () => {
|
|
37
|
+
const workflow = projectWorkflow({
|
|
38
|
+
status: 'done',
|
|
39
|
+
plan: [
|
|
40
|
+
{ step: 1, description: 'Analyze', status: 'done' },
|
|
41
|
+
{ step: 2, description: 'Execute', status: 'done' },
|
|
42
|
+
],
|
|
43
|
+
activities: [],
|
|
44
|
+
queue: [],
|
|
45
|
+
approvals: [],
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
assert.equal(workflow.summary.status, 'done');
|
|
49
|
+
assert.equal(workflow.progress.mode, 'task_count');
|
|
50
|
+
assert.equal(workflow.progress.percent, 100);
|
|
51
|
+
assert.deepEqual(workflow.warnings, ['legacy_sequential_plan']);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('projectWorkflow reports approval and queue waiting reasons', () => {
|
|
55
|
+
const workflow = projectWorkflow({
|
|
56
|
+
status: 'running',
|
|
57
|
+
plan: [{ step: 1, id: 'send', description: 'Send email', status: 'pending_approval' }],
|
|
58
|
+
activities: [],
|
|
59
|
+
queue: [{ id: 'queued-1', status: 'queued', label: 'Future run' }],
|
|
60
|
+
approvals: [{ id: 'approval-1', status: 'pending_approval', reason: 'Confirm email' }],
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
assert.equal(workflow.current.id, 'task:send');
|
|
64
|
+
assert.ok(workflow.waitingReasons.includes('approval:approval-1'));
|
|
65
|
+
assert.ok(workflow.waitingReasons.includes('queue:queued-1'));
|
|
66
|
+
});
|
package/src/runtime/client.js
CHANGED
|
@@ -53,7 +53,34 @@ export async function postRuntimeRun(input, {
|
|
|
53
53
|
},
|
|
54
54
|
body: JSON.stringify(Object.assign({ input, workspace }, evaluate !== undefined && { evaluate }, replans !== undefined && { replans })),
|
|
55
55
|
});
|
|
56
|
-
if (!response.ok)
|
|
56
|
+
if (!response.ok) {
|
|
57
|
+
const err = new Error(`Runtime run failed: HTTP ${response.status}`);
|
|
58
|
+
err.status = response.status;
|
|
59
|
+
throw err;
|
|
60
|
+
}
|
|
61
|
+
return response.json();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function postRuntimeControl(action, {
|
|
65
|
+
url = runtimeUrlFromEnv(),
|
|
66
|
+
token = runtimeToken(),
|
|
67
|
+
workspace = null,
|
|
68
|
+
input = undefined,
|
|
69
|
+
intent = undefined,
|
|
70
|
+
} = {}) {
|
|
71
|
+
const response = await fetch(runtimeEndpoint(url, '/control', workspace), {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: {
|
|
74
|
+
...runtimeHeaders(token),
|
|
75
|
+
'Content-Type': 'application/json',
|
|
76
|
+
},
|
|
77
|
+
body: JSON.stringify(Object.assign({ action }, input !== undefined && { input }, intent !== undefined && { intent })),
|
|
78
|
+
});
|
|
79
|
+
if (!response.ok) {
|
|
80
|
+
const err = new Error(`Runtime control failed: HTTP ${response.status}`);
|
|
81
|
+
err.status = response.status;
|
|
82
|
+
throw err;
|
|
83
|
+
}
|
|
57
84
|
return response.json();
|
|
58
85
|
}
|
|
59
86
|
|
package/src/runtime/runner.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
2
|
-
import { sessionActivities, terminalFailures } from '../core/activity.js';
|
|
2
|
+
import { activityKey, sessionActivities, terminalFailures } from '../core/activity.js';
|
|
3
3
|
import { runAgenticLoop, throwIfAborted } from '../core/agentLoop.js';
|
|
4
4
|
import { formatPlanStatus } from '../core/plan.js';
|
|
5
|
+
import { formatReadyTaskPrompt, readyPlanTasks } from '../core/planPatch.js';
|
|
5
6
|
import { emitRuntimeLog, pollActivitiesOnce } from './supervisor.js';
|
|
6
7
|
|
|
7
8
|
const DEFAULT_MAX_REPLANS = 2;
|
|
9
|
+
const DEFAULT_SCHEDULER_CONCURRENCY = 3;
|
|
8
10
|
|
|
9
11
|
async function waitForRuntimeActivities(session, startedActivities, { timeoutMs, signal, pollBusy }) {
|
|
10
12
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -35,12 +37,13 @@ async function waitForRuntimeActivities(session, startedActivities, { timeoutMs,
|
|
|
35
37
|
return { ok: false, timedOut: true, completed: tracked };
|
|
36
38
|
}
|
|
37
39
|
|
|
38
|
-
export async function runRuntimeAgenticLoop(agent, session, initialInput, { signal, timeoutMs, maxTurns, runId, pollBusy }) {
|
|
40
|
+
export async function runRuntimeAgenticLoop(agent, session, initialInput, { signal, timeoutMs, maxTurns, runId, pollBusy, parallelHandoff = false }) {
|
|
39
41
|
return runAgenticLoop(agent, session, initialInput, {
|
|
40
42
|
signal,
|
|
41
43
|
timeoutMs,
|
|
42
44
|
maxTurns,
|
|
43
45
|
runId,
|
|
46
|
+
parallelHandoff,
|
|
44
47
|
abortMessage: 'Runtime run cancelled.',
|
|
45
48
|
waitForActivities: (turnSession, startedActivities, waitOptions) =>
|
|
46
49
|
waitForRuntimeActivities(turnSession, startedActivities, { ...waitOptions, pollBusy }),
|
|
@@ -58,7 +61,7 @@ export async function runRuntimeAgenticLoop(agent, session, initialInput, { sign
|
|
|
58
61
|
}
|
|
59
62
|
},
|
|
60
63
|
onPlanExtracted: ({ steps }) => {
|
|
61
|
-
emitRuntimeLog(session, `agentic-loop: plan extracted from text (${steps.length} steps)`);
|
|
64
|
+
emitRuntimeLog(session, `agentic-loop: plan extracted from text (${steps.length} steps, deprecated fallback)`);
|
|
62
65
|
},
|
|
63
66
|
onComplete: () => {
|
|
64
67
|
emitRuntimeLog(session, 'agentic-loop: no pending activity or plan step');
|
|
@@ -89,13 +92,23 @@ export async function runRuntimeAgenticWorkflow(agent, session, input, {
|
|
|
89
92
|
let replansLeft = Math.max(0, Math.floor(Number(maxReplans) || 0));
|
|
90
93
|
|
|
91
94
|
while (true) {
|
|
92
|
-
const result =
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
95
|
+
const result = shouldUseParallelScheduler(session.headlessPlan)
|
|
96
|
+
? await runRuntimeParallelPlan(agent, session, input, {
|
|
97
|
+
signal,
|
|
98
|
+
timeoutMs,
|
|
99
|
+
maxTurns,
|
|
100
|
+
runId,
|
|
101
|
+
pollBusy,
|
|
102
|
+
})
|
|
103
|
+
: await runRuntimeAgenticLoop(agent, session, currentInput, {
|
|
104
|
+
signal,
|
|
105
|
+
timeoutMs,
|
|
106
|
+
maxTurns,
|
|
107
|
+
runId,
|
|
108
|
+
pollBusy,
|
|
109
|
+
parallelHandoff: true,
|
|
110
|
+
});
|
|
111
|
+
if (result.ok && result.handoff) continue;
|
|
99
112
|
if (!result.ok) {
|
|
100
113
|
const trigger = replanTriggerFromLoopResult(result);
|
|
101
114
|
if (trigger && replansLeft > 0) {
|
|
@@ -173,6 +186,336 @@ export async function runRuntimeAgenticWorkflow(agent, session, input, {
|
|
|
173
186
|
}
|
|
174
187
|
}
|
|
175
188
|
|
|
189
|
+
export async function runRuntimeParallelPlan(agent, session, input, {
|
|
190
|
+
signal = null,
|
|
191
|
+
timeoutMs,
|
|
192
|
+
maxTurns,
|
|
193
|
+
runId = null,
|
|
194
|
+
pollBusy,
|
|
195
|
+
concurrency = resolveSchedulerConcurrency(),
|
|
196
|
+
} = {}) {
|
|
197
|
+
const limit = Math.max(1, Math.floor(Number(concurrency) || 1));
|
|
198
|
+
const active = new Map();
|
|
199
|
+
const locks = new Set();
|
|
200
|
+
const failures = [];
|
|
201
|
+
const previousIdentity = session._currentRunIdentity;
|
|
202
|
+
session._currentRunIdentity = {
|
|
203
|
+
...(previousIdentity ?? {}),
|
|
204
|
+
runId,
|
|
205
|
+
workspace: session.workspace ?? previousIdentity?.workspace ?? null,
|
|
206
|
+
};
|
|
207
|
+
ensurePlanProjection(session, runId);
|
|
208
|
+
emitRuntimeLog(session, `scheduler: parallel plan enabled (concurrency ${limit})`);
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
while (true) {
|
|
212
|
+
if (signal?.aborted) {
|
|
213
|
+
await drainActive(active, locks);
|
|
214
|
+
throwIfAborted(signal, 'Runtime run cancelled.');
|
|
215
|
+
}
|
|
216
|
+
const pending = (session.headlessPlan ?? []).filter((step) => step.status === 'pending' || step.status === 'pending_approval');
|
|
217
|
+
if (pending.length === 0 && active.size === 0) {
|
|
218
|
+
emitRuntimeLog(session, 'scheduler: all plan tasks terminal');
|
|
219
|
+
return failures.length > 0
|
|
220
|
+
? { ok: false, completed: failedTaskActivities(session), failures }
|
|
221
|
+
: { ok: true };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const started = startReadyTasks(agent, session, input, {
|
|
225
|
+
active,
|
|
226
|
+
locks,
|
|
227
|
+
signal,
|
|
228
|
+
timeoutMs,
|
|
229
|
+
maxTurns,
|
|
230
|
+
runId,
|
|
231
|
+
pollBusy,
|
|
232
|
+
limit,
|
|
233
|
+
});
|
|
234
|
+
if (started === 0 && active.size === 0) {
|
|
235
|
+
const reason = pending.every((step) => step.status === 'pending_approval') ? 'awaiting_approval' : 'no_ready_plan_task';
|
|
236
|
+
emitRuntimeLog(session, `scheduler: stalled (${reason})`);
|
|
237
|
+
return { ok: false, stalled: true, reason, completed: sessionActivities(session), failures };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const settled = await Promise.race([...active.values()].map((entry) => entry.promise));
|
|
241
|
+
active.delete(settled.taskId);
|
|
242
|
+
for (const lock of settled.locks) locks.delete(lock);
|
|
243
|
+
if (settled.cancelled) {
|
|
244
|
+
await drainActive(active, locks);
|
|
245
|
+
throwIfAborted(signal, 'Runtime run cancelled.');
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (!settled.ok) failures.push(settled);
|
|
249
|
+
}
|
|
250
|
+
} finally {
|
|
251
|
+
if (previousIdentity) session._currentRunIdentity = previousIdentity;
|
|
252
|
+
else delete session._currentRunIdentity;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function drainActive(active, locks) {
|
|
257
|
+
if (active.size === 0) return;
|
|
258
|
+
await Promise.all([...active.values()].map((entry) => entry.promise));
|
|
259
|
+
active.clear();
|
|
260
|
+
locks.clear();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Scope the evaluator/replanner's view of "completed" activities to the
|
|
264
|
+
// tasks that actually failed, instead of every activity the whole run has
|
|
265
|
+
// ever recorded — a long-running sibling's stale (or unrelated, already
|
|
266
|
+
// resolved) activity must not shadow the real failure.
|
|
267
|
+
function failedTaskActivities(session) {
|
|
268
|
+
const failedKeys = new Set(
|
|
269
|
+
(session.headlessPlan ?? [])
|
|
270
|
+
.filter((step) => step.status === 'failed')
|
|
271
|
+
.map((step) => step.ownerActivityKey ?? step.activityKey)
|
|
272
|
+
.filter(Boolean),
|
|
273
|
+
);
|
|
274
|
+
const all = sessionActivities(session);
|
|
275
|
+
return failedKeys.size > 0 ? all.filter((activity) => failedKeys.has(activity.key)) : all;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function ensurePlanProjection(session, runId) {
|
|
279
|
+
if (!session.headlessPlan || session.agentProjection?.plan) return;
|
|
280
|
+
dispatchAgentEvent(session, createAgentEvent('plan_set', {
|
|
281
|
+
origin: 'runtime',
|
|
282
|
+
runId,
|
|
283
|
+
payload: {
|
|
284
|
+
steps: session.headlessPlan,
|
|
285
|
+
planRevision: session.planRevision ?? 0,
|
|
286
|
+
},
|
|
287
|
+
}));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function startReadyTasks(agent, session, input, {
|
|
291
|
+
active,
|
|
292
|
+
locks,
|
|
293
|
+
signal,
|
|
294
|
+
timeoutMs,
|
|
295
|
+
maxTurns,
|
|
296
|
+
runId,
|
|
297
|
+
pollBusy,
|
|
298
|
+
limit,
|
|
299
|
+
}) {
|
|
300
|
+
let started = 0;
|
|
301
|
+
const seenTaskIds = new Set(active.keys());
|
|
302
|
+
for (const task of readyPlanTasks(session.headlessPlan)) {
|
|
303
|
+
if (active.size >= limit) break;
|
|
304
|
+
const taskId = planTaskId(task);
|
|
305
|
+
if (active.has(taskId)) continue;
|
|
306
|
+
if (seenTaskIds.has(taskId)) {
|
|
307
|
+
// Two distinct ready tasks resolved to the same id/step — starting
|
|
308
|
+
// both would let plan_step_updated resolve to whichever one the
|
|
309
|
+
// reducer finds first, silently marking the wrong task done. Skip the
|
|
310
|
+
// duplicate rather than risk misattribution; it stays pending and
|
|
311
|
+
// will surface as a stalled plan instead of corrupting sibling state.
|
|
312
|
+
emitRuntimeLog(session, `scheduler: skipping task with duplicate id/step "${taskId}"`);
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
seenTaskIds.add(taskId);
|
|
316
|
+
const taskLocks = locksForTask(task);
|
|
317
|
+
if (taskLocks.some((lock) => locks.has(lock))) continue;
|
|
318
|
+
for (const lock of taskLocks) locks.add(lock);
|
|
319
|
+
dispatchAgentEvent(session, createAgentEvent('plan_step_updated', {
|
|
320
|
+
origin: 'runtime',
|
|
321
|
+
runId,
|
|
322
|
+
taskId,
|
|
323
|
+
payload: { taskId, status: 'running' },
|
|
324
|
+
}));
|
|
325
|
+
emitRuntimeLog(session, `scheduler: starting task ${taskId}`);
|
|
326
|
+
const promise = runParallelTask(agent, session, input, task, {
|
|
327
|
+
signal,
|
|
328
|
+
timeoutMs,
|
|
329
|
+
maxTurns,
|
|
330
|
+
runId,
|
|
331
|
+
pollBusy,
|
|
332
|
+
locks: taskLocks,
|
|
333
|
+
});
|
|
334
|
+
active.set(taskId, { taskId, locks: taskLocks, promise });
|
|
335
|
+
started += 1;
|
|
336
|
+
}
|
|
337
|
+
return started;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function runParallelTask(agent, parentSession, input, task, {
|
|
341
|
+
signal,
|
|
342
|
+
timeoutMs,
|
|
343
|
+
maxTurns,
|
|
344
|
+
runId,
|
|
345
|
+
pollBusy,
|
|
346
|
+
locks,
|
|
347
|
+
}) {
|
|
348
|
+
const taskId = planTaskId(task);
|
|
349
|
+
const childSession = createTaskSession(parentSession, task, { runId, taskId });
|
|
350
|
+
try {
|
|
351
|
+
const result = await runRuntimeAgenticLoop(agent, childSession, buildTaskPrompt(input, parentSession.headlessPlan, task), {
|
|
352
|
+
signal,
|
|
353
|
+
timeoutMs,
|
|
354
|
+
maxTurns,
|
|
355
|
+
runId,
|
|
356
|
+
pollBusy,
|
|
357
|
+
});
|
|
358
|
+
if (!result.ok) {
|
|
359
|
+
dispatchAgentEvent(parentSession, createAgentEvent('plan_step_updated', {
|
|
360
|
+
origin: 'runtime',
|
|
361
|
+
runId,
|
|
362
|
+
taskId,
|
|
363
|
+
payload: { taskId, status: 'failed' },
|
|
364
|
+
}));
|
|
365
|
+
return { ok: false, taskId, locks, result };
|
|
366
|
+
}
|
|
367
|
+
dispatchAgentEvent(parentSession, createAgentEvent('plan_step_updated', {
|
|
368
|
+
origin: 'runtime',
|
|
369
|
+
runId,
|
|
370
|
+
taskId,
|
|
371
|
+
payload: { taskId, status: 'done' },
|
|
372
|
+
}));
|
|
373
|
+
emitRuntimeLog(parentSession, `scheduler: task ${taskId} done`);
|
|
374
|
+
return { ok: true, taskId, locks, result };
|
|
375
|
+
} catch (err) {
|
|
376
|
+
if (err?.name === 'AbortError') {
|
|
377
|
+
dispatchAgentEvent(parentSession, createAgentEvent('plan_step_updated', {
|
|
378
|
+
origin: 'runtime',
|
|
379
|
+
runId,
|
|
380
|
+
taskId,
|
|
381
|
+
payload: { taskId, status: 'cancelled' },
|
|
382
|
+
}));
|
|
383
|
+
return { ok: false, taskId, locks, cancelled: true };
|
|
384
|
+
}
|
|
385
|
+
dispatchAgentEvent(parentSession, createAgentEvent('plan_step_updated', {
|
|
386
|
+
origin: 'runtime',
|
|
387
|
+
runId,
|
|
388
|
+
taskId,
|
|
389
|
+
payload: { taskId, status: 'failed' },
|
|
390
|
+
}));
|
|
391
|
+
return {
|
|
392
|
+
ok: false,
|
|
393
|
+
taskId,
|
|
394
|
+
locks,
|
|
395
|
+
error: err instanceof Error ? err.message : String(err),
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function createTaskSession(parent, task, { runId, taskId }) {
|
|
401
|
+
const child = {
|
|
402
|
+
...parent,
|
|
403
|
+
// Copy (not share) the parent's activities: a child's own diffing needs
|
|
404
|
+
// isolation from mutation, but local guards like productionLockBusy must
|
|
405
|
+
// still see activities already running when this task started.
|
|
406
|
+
activities: { ...parent.activities },
|
|
407
|
+
agentEvents: [],
|
|
408
|
+
agentProjection: null,
|
|
409
|
+
_agentProjectionState: null,
|
|
410
|
+
headlessPlan: null,
|
|
411
|
+
_currentRunIdentity: {
|
|
412
|
+
...(parent._currentRunIdentity ?? {}),
|
|
413
|
+
runId,
|
|
414
|
+
taskId,
|
|
415
|
+
workspace: parent.workspace ?? parent._currentRunIdentity?.workspace ?? null,
|
|
416
|
+
},
|
|
417
|
+
_onAgentEvent: (event) => {
|
|
418
|
+
if (event.type === 'plan_set') return;
|
|
419
|
+
if (event.type === 'plan_step_updated') {
|
|
420
|
+
// The child always targets its own single task, regardless of what
|
|
421
|
+
// step/id the child's LLM passed to wiki__plan_done — a child has no
|
|
422
|
+
// visibility into the parent's real multi-task numbering.
|
|
423
|
+
dispatchAgentEvent(parent, {
|
|
424
|
+
...event,
|
|
425
|
+
taskId: taskId,
|
|
426
|
+
runId: event.runId ?? runId,
|
|
427
|
+
payload: { ...event.payload, taskId },
|
|
428
|
+
});
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
if (event.type === 'activity_upserted') {
|
|
432
|
+
stampOwnerActivityKey(parent, taskId, event.payload?.activity);
|
|
433
|
+
}
|
|
434
|
+
dispatchAgentEvent(parent, {
|
|
435
|
+
...event,
|
|
436
|
+
taskId: event.taskId ?? taskId,
|
|
437
|
+
runId: event.runId ?? runId,
|
|
438
|
+
});
|
|
439
|
+
},
|
|
440
|
+
};
|
|
441
|
+
Object.defineProperty(child, '_runApprovalResolved', {
|
|
442
|
+
get: () => parent._runApprovalResolved,
|
|
443
|
+
set: (value) => { parent._runApprovalResolved = value; },
|
|
444
|
+
enumerable: true,
|
|
445
|
+
configurable: true,
|
|
446
|
+
});
|
|
447
|
+
if (parent._requestApproval) {
|
|
448
|
+
child._requestApproval = (request) => {
|
|
449
|
+
if (request?.scope === 'tool') return parent._requestApproval(request);
|
|
450
|
+
// Run-level approval is one decision for the whole run: concurrent
|
|
451
|
+
// children must join the same in-flight request instead of each
|
|
452
|
+
// minting their own (only one of which /approve could ever resolve).
|
|
453
|
+
parent._pendingRunApproval ??= parent._requestApproval(request)
|
|
454
|
+
.finally(() => { delete parent._pendingRunApproval; });
|
|
455
|
+
return parent._pendingRunApproval;
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
// Seed the plan through the real reducer (not a raw property write): the
|
|
459
|
+
// child's own _agentProjectionState knows nothing about this task
|
|
460
|
+
// otherwise, and the very next session-projection event (even a plain
|
|
461
|
+
// runtime_log from onTurnStart) would overwrite headlessPlan back to null
|
|
462
|
+
// via applyAgentProjectionToSession. The _onAgentEvent filter above already
|
|
463
|
+
// keeps this plan_set from being forwarded to the parent.
|
|
464
|
+
dispatchAgentEvent(child, createAgentEvent('plan_set', {
|
|
465
|
+
origin: 'runtime',
|
|
466
|
+
runId,
|
|
467
|
+
taskId,
|
|
468
|
+
payload: { steps: [{ ...task, status: 'running' }] },
|
|
469
|
+
}));
|
|
470
|
+
return child;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function stampOwnerActivityKey(parent, taskId, rawActivity) {
|
|
474
|
+
if (!rawActivity) return;
|
|
475
|
+
const entry = (parent.headlessPlan ?? []).find((item) => String(item.id ?? item.step) === taskId);
|
|
476
|
+
if (!entry || entry.ownerActivityKey) return;
|
|
477
|
+
entry.ownerActivityKey = activityKey(rawActivity);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function buildTaskPrompt(input, plan, task) {
|
|
481
|
+
return [
|
|
482
|
+
'Original task:',
|
|
483
|
+
input || '(unknown)',
|
|
484
|
+
'',
|
|
485
|
+
`Plan status:\n${formatPlanStatus(plan)}`,
|
|
486
|
+
'',
|
|
487
|
+
formatReadyTaskPrompt(task),
|
|
488
|
+
'',
|
|
489
|
+
'Execute exactly this task as an autonomous child task.',
|
|
490
|
+
'Do not start sibling tasks. Do not modify unrelated plan steps.',
|
|
491
|
+
'If this task cannot complete, report the blocker clearly.',
|
|
492
|
+
].join('\n');
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function shouldUseParallelScheduler(plan) {
|
|
496
|
+
return readyPlanTasks(plan).length > 0;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function planTaskId(task) {
|
|
500
|
+
return String(task.id ?? task.step);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function locksForTask(task) {
|
|
504
|
+
const locks = new Set();
|
|
505
|
+
const explicit = task.locks ?? task.writeLocks ?? null;
|
|
506
|
+
if (Array.isArray(explicit)) {
|
|
507
|
+
for (const lock of explicit) if (lock) locks.add(String(lock));
|
|
508
|
+
} else if (explicit && typeof explicit === 'object') {
|
|
509
|
+
if (explicit.workspaceWrite || explicit.workspace) locks.add('workspace:write');
|
|
510
|
+
for (const value of explicit.deliverableWrites ?? explicit.deliverables ?? []) locks.add(`deliverable:${value}`);
|
|
511
|
+
for (const value of explicit.wikiPageWrites ?? explicit.wikiPages ?? []) locks.add(`wiki-page:${value}`);
|
|
512
|
+
}
|
|
513
|
+
for (const value of task.deliverableWrites ?? []) locks.add(`deliverable:${value}`);
|
|
514
|
+
for (const value of task.wikiPageWrites ?? []) locks.add(`wiki-page:${value}`);
|
|
515
|
+
if (task.workspaceWrite) locks.add('workspace:write');
|
|
516
|
+
return [...locks].sort();
|
|
517
|
+
}
|
|
518
|
+
|
|
176
519
|
export async function finishRuntimeRun(session, input, {
|
|
177
520
|
runId,
|
|
178
521
|
signal = null,
|
|
@@ -298,6 +641,7 @@ export async function replanRuntimeRun(session, input, trigger, {
|
|
|
298
641
|
return { ok: false, reason: 'Replanner unavailable: no LLM completeWithTools client.' };
|
|
299
642
|
}
|
|
300
643
|
try {
|
|
644
|
+
const currentPlan = session.headlessPlan;
|
|
301
645
|
emitRuntimeLog(session, 'runtime: replanning remaining work');
|
|
302
646
|
const result = await llm.completeWithTools({
|
|
303
647
|
system: [
|
|
@@ -312,6 +656,7 @@ export async function replanRuntimeRun(session, input, trigger, {
|
|
|
312
656
|
});
|
|
313
657
|
const steps = normalizeReplan(parseJsonFenced(result.content, 'replan response').steps);
|
|
314
658
|
if (steps.length === 0) throw new Error('empty replan');
|
|
659
|
+
const mergedSteps = mergeReplanWithCompleted(currentPlan, steps);
|
|
315
660
|
dispatchAgentEvent(session, createAgentEvent('run_replanned', {
|
|
316
661
|
origin: 'runtime',
|
|
317
662
|
runId,
|
|
@@ -326,11 +671,7 @@ export async function replanRuntimeRun(session, input, trigger, {
|
|
|
326
671
|
origin: 'runtime',
|
|
327
672
|
runId,
|
|
328
673
|
payload: {
|
|
329
|
-
steps:
|
|
330
|
-
step: index + 1,
|
|
331
|
-
description,
|
|
332
|
-
status: 'pending',
|
|
333
|
-
})),
|
|
674
|
+
steps: mergedSteps,
|
|
334
675
|
},
|
|
335
676
|
}));
|
|
336
677
|
return { ok: true, steps };
|
|
@@ -340,14 +681,36 @@ export async function replanRuntimeRun(session, input, trigger, {
|
|
|
340
681
|
}
|
|
341
682
|
|
|
342
683
|
function replanTriggerFromLoopResult(result) {
|
|
684
|
+
// 'awaiting_approval' is not a dead end — it means to wait for a human
|
|
685
|
+
// decision, not to replan around it.
|
|
686
|
+
if (result.stalled && result.reason !== 'awaiting_approval') {
|
|
687
|
+
return {
|
|
688
|
+
kind: 'plan_stalled',
|
|
689
|
+
reason: `Plan is stalled: ${result.reason ?? 'no ready task'} (pending steps exist but none have their dependencies satisfied).`,
|
|
690
|
+
suggestedAction: 'Drop or replace the unsatisfiable dependency.',
|
|
691
|
+
activity: null,
|
|
692
|
+
};
|
|
693
|
+
}
|
|
343
694
|
const failures = terminalFailures(result.completed ?? []);
|
|
344
695
|
const failure = failures[0];
|
|
345
|
-
if (
|
|
696
|
+
if (failure) {
|
|
697
|
+
return {
|
|
698
|
+
kind: 'activity_error',
|
|
699
|
+
reason: `${failure.label ?? failure.id ?? 'Activity'} ended with ${failure.status}${failure.error ? `: ${failure.error}` : ''}`,
|
|
700
|
+
suggestedAction: failure.error ?? null,
|
|
701
|
+
activity: failure,
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
// The parallel scheduler can fail a task before it ever produces an
|
|
705
|
+
// _activity (e.g. a thrown error on the first turn) — that failure lives
|
|
706
|
+
// in result.failures, not in any activity, so it must be checked too.
|
|
707
|
+
const taskFailure = (result.failures ?? [])[0];
|
|
708
|
+
if (!taskFailure) return null;
|
|
346
709
|
return {
|
|
347
|
-
kind: '
|
|
348
|
-
reason:
|
|
349
|
-
suggestedAction:
|
|
350
|
-
activity:
|
|
710
|
+
kind: 'task_error',
|
|
711
|
+
reason: `Task ${taskFailure.taskId} failed${taskFailure.error ? `: ${taskFailure.error}` : ''}`,
|
|
712
|
+
suggestedAction: taskFailure.error ?? null,
|
|
713
|
+
activity: null,
|
|
351
714
|
};
|
|
352
715
|
}
|
|
353
716
|
|
|
@@ -399,6 +762,48 @@ function normalizeReplan(steps) {
|
|
|
399
762
|
.slice(0, 12);
|
|
400
763
|
}
|
|
401
764
|
|
|
765
|
+
function mergeReplanWithCompleted(plan, steps) {
|
|
766
|
+
const completed = (plan ?? [])
|
|
767
|
+
.filter((step) => step.status === 'done')
|
|
768
|
+
.map((step, index) => ({
|
|
769
|
+
...step,
|
|
770
|
+
step: index + 1,
|
|
771
|
+
status: 'done',
|
|
772
|
+
dependsOn: Array.isArray(step.dependsOn) ? step.dependsOn.map(String) : [],
|
|
773
|
+
outputRefs: Array.isArray(step.outputRefs) ? step.outputRefs.map(String) : [],
|
|
774
|
+
}));
|
|
775
|
+
const completedIds = completed.map((step) => String(step.id ?? step.step));
|
|
776
|
+
// Start numbering past any `replan-N` id already present in the incoming
|
|
777
|
+
// plan (e.g. a `done` step from an earlier replan in the same run) so a
|
|
778
|
+
// second/third replan in one run can't mint an id that collides with one
|
|
779
|
+
// already carried over in `completed`.
|
|
780
|
+
const nextReplanIndex = nextReplanIdIndex(plan);
|
|
781
|
+
// The replanner returns a flat, inherently-ordered list of description
|
|
782
|
+
// strings with no independence information — chain each step to the
|
|
783
|
+
// previous one (in addition to the prior completed steps for the first)
|
|
784
|
+
// so replanned work still runs sequentially instead of fanning out
|
|
785
|
+
// through the parallel scheduler now that any ready task triggers it.
|
|
786
|
+
const replanned = steps.map((description, index) => ({
|
|
787
|
+
step: completed.length + index + 1,
|
|
788
|
+
id: `replan-${nextReplanIndex + index}`,
|
|
789
|
+
description,
|
|
790
|
+
status: 'pending',
|
|
791
|
+
dependsOn: index === 0 ? completedIds : [`replan-${nextReplanIndex + index - 1}`],
|
|
792
|
+
executor: null,
|
|
793
|
+
outputRefs: [],
|
|
794
|
+
}));
|
|
795
|
+
return [...completed, ...replanned];
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function nextReplanIdIndex(plan) {
|
|
799
|
+
const used = (plan ?? [])
|
|
800
|
+
.map((step) => String(step.id ?? ''))
|
|
801
|
+
.filter((id) => id.startsWith('replan-'))
|
|
802
|
+
.map((id) => Number(id.slice('replan-'.length)))
|
|
803
|
+
.filter((n) => Number.isFinite(n));
|
|
804
|
+
return used.length ? Math.max(...used) + 1 : 1;
|
|
805
|
+
}
|
|
806
|
+
|
|
402
807
|
function formatRecentConversation(session, n = 12) {
|
|
403
808
|
const conversation = session.agentProjection?.conversation ?? [];
|
|
404
809
|
return conversation
|
|
@@ -411,3 +816,10 @@ function resolveMaxReplans(value = process.env.WIKI_MANAGER_REPLANNER_MAX_REPLAN
|
|
|
411
816
|
const parsed = Number(value);
|
|
412
817
|
return Number.isFinite(parsed) ? Math.max(0, Math.floor(parsed)) : DEFAULT_MAX_REPLANS;
|
|
413
818
|
}
|
|
819
|
+
|
|
820
|
+
function resolveSchedulerConcurrency(value = process.env.WIKI_MANAGER_SCHEDULER_CONCURRENCY) {
|
|
821
|
+
const parsed = Number(value);
|
|
822
|
+
return Number.isFinite(parsed) && parsed > 0
|
|
823
|
+
? Math.max(1, Math.floor(parsed))
|
|
824
|
+
: DEFAULT_SCHEDULER_CONCURRENCY;
|
|
825
|
+
}
|