@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.
@@ -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
+ });
@@ -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) throw new Error(`Runtime run failed: HTTP ${response.status}`);
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
 
@@ -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 = await runRuntimeAgenticLoop(agent, session, currentInput, {
93
- signal,
94
- timeoutMs,
95
- maxTurns,
96
- runId,
97
- pollBusy,
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: steps.map((description, index) => ({
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 (!failure) return null;
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: 'activity_error',
348
- reason: `${failure.label ?? failure.id ?? 'Activity'} ended with ${failure.status}${failure.error ? `: ${failure.error}` : ''}`,
349
- suggestedAction: failure.error ?? null,
350
- activity: failure,
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
+ }