@dotdrelle/wiki-manager 0.12.12 → 0.14.1

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.
Files changed (36) hide show
  1. package/docker-compose.yml +1 -1
  2. package/mcp.endpoints.example.json +7 -0
  3. package/package.json +2 -2
  4. package/src/agent/graph.js +354 -143
  5. package/src/agent/graph.test.js +516 -54
  6. package/src/agent/llm.js +5 -5
  7. package/src/cli/wiki-manager.js +234 -6
  8. package/src/cli/wiki-manager.test.js +28 -0
  9. package/src/commands/slash.js +32 -11
  10. package/src/commands/slash.test.js +9 -1
  11. package/src/core/agentEvents.js +7 -1
  12. package/src/core/agentEvents.test.js +13 -1
  13. package/src/core/buildInfo.json +2 -2
  14. package/src/core/mcp.js +46 -4
  15. package/src/core/skills.js +0 -28
  16. package/src/core/toolLoop.js +56 -0
  17. package/src/core/toolLoop.test.js +88 -0
  18. package/src/orchestrator/capabilityRegistry.js +14 -0
  19. package/src/orchestrator/capabilityRegistry.test.js +12 -1
  20. package/src/orchestrator/dependencyResolver.js +10 -1
  21. package/src/orchestrator/dispatcher.js +34 -3
  22. package/src/orchestrator/dispatcher.test.js +34 -0
  23. package/src/orchestrator/objectiveResolver.js +79 -0
  24. package/src/orchestrator/objectiveResolver.test.js +50 -0
  25. package/src/orchestrator/scheduler.test.js +25 -0
  26. package/src/runtime/client.js +32 -1
  27. package/src/runtime/lifecycle.js +32 -2
  28. package/src/runtime/recoveryManager.js +14 -7
  29. package/src/runtime/runner.js +112 -13
  30. package/src/runtime/runner.test.js +64 -1
  31. package/src/runtime/server.js +47 -3
  32. package/src/runtime/supervisor.js +4 -1
  33. package/src/runtime/supervisor.test.js +49 -0
  34. package/src/shell/repl.js +134 -55
  35. package/src/shell/repl.test.js +151 -12
  36. package/src/shell/useSession.ts +15 -3
@@ -131,7 +131,7 @@ export async function runRuntimeAgenticWorkflow(agent, session, input, {
131
131
  evaluate = true,
132
132
  maxReplans = resolveMaxReplans(),
133
133
  callTool = null,
134
- dispatcherPollIntervalMs = 250,
134
+ dispatcherPollIntervalMs = 2500,
135
135
  } = {}) {
136
136
  let currentInput = initialInput ?? input;
137
137
  let replansLeft = Math.max(0, Math.floor(Number(maxReplans) || 0));
@@ -251,11 +251,7 @@ export async function runRuntimeAgenticWorkflow(agent, session, input, {
251
251
  origin: 'runtime',
252
252
  runId,
253
253
  payload: {
254
- content: [
255
- `Le run est terminé mais l'évaluation le juge incomplet : ${evaluation.reason}`,
256
- evaluation.suggestedAction ? `Piste suggérée : ${evaluation.suggestedAction}` : null,
257
- 'Aucune tâche supplémentaire n\'a été créée automatiquement — dis-moi si tu veux poursuivre.',
258
- ].filter(Boolean).join('\n'),
254
+ content: `Le run est terminé mais l'évaluation le juge incomplet : ${evaluation.reason}`,
259
255
  },
260
256
  }));
261
257
  dispatchAgentEvent(session, createAgentEvent('run_error', {
@@ -295,7 +291,7 @@ export async function runRuntimeParallelPlan(agent, session, input, {
295
291
  budgetManager = null,
296
292
  budgets = {},
297
293
  callTool = null,
298
- dispatcherPollIntervalMs = 250,
294
+ dispatcherPollIntervalMs = 2500,
299
295
  } = {}) {
300
296
  if (fragment != null) assertValidatedFragment(fragment);
301
297
  const agents = session.agentRegistry?.snapshot?.() ?? session.agentRegistrySnapshot ?? [];
@@ -399,8 +395,9 @@ export async function runRuntimeParallelPlan(agent, session, input, {
399
395
  emitRuntimeLog(session, taskLogPayload('task.starting', task, { runId, detail: `starting task ${taskId}` }));
400
396
  },
401
397
  startTask: (task, attempt) => {
398
+ const executableTask = materializeTaskInputs(task, session.headlessPlan ?? []);
402
399
  const taskAbort = createTaskAbortSignal(signal);
403
- const promise = runDispatchedTask(task, {
400
+ const promise = runDispatchedTask(executableTask, {
404
401
  session,
405
402
  assignmentManager: assigner,
406
403
  dispatcher: executor,
@@ -529,6 +526,41 @@ export async function runRuntimeParallelPlan(agent, session, input, {
529
526
  }
530
527
  }
531
528
 
529
+ export function materializeTaskInputs(task, plan = []) {
530
+ const dependencies = new Set(Array.isArray(task?.dependsOn) ? task.dependsOn.map(String) : []);
531
+ const replacements = new Map();
532
+ for (const dependency of plan ?? []) {
533
+ if (!dependencies.has(String(dependency?.id ?? dependency?.step))) continue;
534
+ const expected = Array.isArray(dependency?.expectedOutputRefs) ? dependency.expectedOutputRefs : [];
535
+ const actual = Array.isArray(dependency?.outputRefs) ? dependency.outputRefs : [];
536
+ for (let index = 0; index < Math.min(expected.length, actual.length); index += 1) {
537
+ const expectedRef = refValue(expected[index]);
538
+ const actualRef = refValue(actual[index]);
539
+ if (expectedRef && actualRef && expectedRef !== actualRef) replacements.set(expectedRef, actualRef);
540
+ }
541
+ }
542
+ if (replacements.size === 0) return task;
543
+ return {
544
+ ...task,
545
+ arguments: replaceRefValues(task?.arguments, replacements),
546
+ inputRefs: replaceRefValues(task?.inputRefs, replacements),
547
+ };
548
+ }
549
+
550
+ function replaceRefValues(value, replacements) {
551
+ if (typeof value === 'string') return replacements.get(value) ?? value;
552
+ if (Array.isArray(value)) return value.map((item) => replaceRefValues(item, replacements));
553
+ if (value && typeof value === 'object') {
554
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, replaceRefValues(item, replacements)]));
555
+ }
556
+ return value;
557
+ }
558
+
559
+ function refValue(value) {
560
+ if (typeof value === 'string') return value;
561
+ return value && typeof value === 'object' ? String(value.ref ?? '') : '';
562
+ }
563
+
532
564
  function sanitizeSessionPlanForExecution(session, runId = null) {
533
565
  if (!session.headlessPlan) return;
534
566
  const sanitized = sanitizePlanForExecution(session.headlessPlan);
@@ -704,7 +736,7 @@ async function runDispatchedTask(task, {
704
736
  error: {
705
737
  code: 'dispatcher_error',
706
738
  message: err instanceof Error ? err.message : String(err),
707
- retryable: false,
739
+ retryable: transientRuntimeError(err),
708
740
  },
709
741
  };
710
742
  await resultAggregator.accept(result, { task, assignment });
@@ -724,8 +756,16 @@ async function runDispatchedTask(task, {
724
756
  }
725
757
  }
726
758
 
727
- function shouldUseParallelScheduler(plan) {
728
- return readyPlanTasks(plan).some((task) => task.requiredCapability && task.operation);
759
+ export function shouldUseParallelScheduler(plan) {
760
+ // A validated provider plan enters the scheduler even while every task is
761
+ // waiting for approval. Looking only at readyPlanTasks() made an all-
762
+ // approval plan fall through to the conversational Donna loop; that loop
763
+ // then ignored the integrated TaskGraph and marked the run done.
764
+ return (plan ?? []).some((task) =>
765
+ task?.requiredCapability
766
+ && task?.operation
767
+ && pendingSchedulerStatus(task.status),
768
+ );
729
769
  }
730
770
 
731
771
  function taskLogPayload(event, task, {
@@ -819,6 +859,11 @@ export async function evaluateRuntimeRun(session, input, {
819
859
  evaluate = true,
820
860
  } = {}) {
821
861
  if (!shouldEvaluate(evaluate)) return null;
862
+ const structured = structuredPlanEvaluation(session.headlessPlan);
863
+ if (structured) {
864
+ emitRuntimeLog(session, 'runtime: evaluating completed structured plan');
865
+ return structured;
866
+ }
822
867
  const llm = session.llm;
823
868
  if (!llm || typeof llm.completeWithTools !== 'function') {
824
869
  return fallbackEvaluation('Evaluator unavailable: no LLM completeWithTools client.');
@@ -842,6 +887,40 @@ export async function evaluateRuntimeRun(session, input, {
842
887
  }
843
888
  }
844
889
 
890
+ function structuredPlanEvaluation(plan) {
891
+ if (!Array.isArray(plan) || plan.length === 0) return null;
892
+ // Only provider TaskGraph tasks are authoritative. Legacy conversational
893
+ // plans contain prose/tool labels and still use the compatibility evaluator.
894
+ if (!plan.every((step) => step?.requiredCapability && step?.operation)) return null;
895
+ const statuses = plan.map((step) => String(step?.status ?? '').toLowerCase());
896
+ const failed = plan.filter((step) => ['failed', 'error', 'cancelled', 'canceled', 'stalled'].includes(String(step?.status ?? '').toLowerCase()));
897
+ const incomplete = plan.filter((step) => !['done', 'complete', 'completed', 'success', 'succeeded'].includes(String(step?.status ?? '').toLowerCase()));
898
+ if (failed.length > 0) {
899
+ return {
900
+ ok: false,
901
+ reason: `${failed.length} tâche(s) du plan ont échoué : ${failed.map((step) => step.label ?? step.description ?? step.id ?? step.step).join(', ')}.`,
902
+ suggestedAction: null,
903
+ };
904
+ }
905
+ if (incomplete.length > 0) {
906
+ return {
907
+ ok: false,
908
+ reason: `${incomplete.length} tâche(s) du plan ne sont pas terminées.`,
909
+ suggestedAction: null,
910
+ };
911
+ }
912
+ return {
913
+ ok: statuses.length > 0,
914
+ reason: `${statuses.length} tâche(s) du plan terminées avec succès.`,
915
+ suggestedAction: null,
916
+ };
917
+ }
918
+
919
+ function transientRuntimeError(error) {
920
+ const value = error instanceof Error ? error.message : String(error ?? '');
921
+ return /(?:429|timeout|temporar|throttl|rate.?limit|quota|busy|unavailable)/i.test(value);
922
+ }
923
+
845
924
  function shouldEvaluate(value) {
846
925
  if (value === false) return false;
847
926
  const env = String(process.env.WIKI_MANAGER_EVALUATOR ?? '').trim().toLowerCase();
@@ -961,10 +1040,30 @@ export async function replanRuntimeRun(session, input, trigger, {
961
1040
  }
962
1041
  }
963
1042
 
1043
+ function isBusyFailure(failure) {
1044
+ const fields = [
1045
+ failure?.error,
1046
+ failure?.status,
1047
+ failure?.result?.error?.code,
1048
+ failure?.result?.error?.message,
1049
+ failure?.result?.status,
1050
+ ].map((value) => String(value ?? '').toLowerCase());
1051
+ return fields.some((value) => value.includes('busy') || value.includes('locked'));
1052
+ }
1053
+
964
1054
  function replanTriggerFromLoopResult(result) {
965
1055
  // 'awaiting_approval' is not a dead end — it means to wait for a human
966
1056
  // decision, not to replan around it.
967
1057
  if (result.stalled && result.reason !== 'awaiting_approval') {
1058
+ // A stall caused only by transient lock contention (target_busy /
1059
+ // workspace_busy) must NOT be replanned: the plan is correct, the
1060
+ // workspace was momentarily locked. Replanning around it re-runs the same
1061
+ // tasks, hits the lock again and spins the run into a zombie. Fail cleanly
1062
+ // so the run finalizes instead of lingering 'running'.
1063
+ const blocking = [...(result.failures ?? []), ...terminalFailures(result.completed ?? [])];
1064
+ if (blocking.length > 0 && blocking.every(isBusyFailure)) {
1065
+ return null;
1066
+ }
968
1067
  return {
969
1068
  kind: 'plan_stalled',
970
1069
  reason: `Plan is stalled: ${result.reason ?? 'no ready task'} (pending steps exist but none have their dependencies satisfied).`,
@@ -973,7 +1072,7 @@ function replanTriggerFromLoopResult(result) {
973
1072
  };
974
1073
  }
975
1074
  const failures = terminalFailures(result.completed ?? []);
976
- const technical = failures.filter((failure) => !isCancelledStatus(failure.status));
1075
+ const technical = failures.filter((failure) => !isCancelledStatus(failure.status) && !isBusyFailure(failure));
977
1076
  const cancelled = failures.filter((failure) => isCancelledStatus(failure.status));
978
1077
  if (cancelled.length > 0 && technical.length === 0 && (result.failures ?? []).every(isCancelledTaskFailure)) {
979
1078
  return null;
@@ -990,7 +1089,7 @@ function replanTriggerFromLoopResult(result) {
990
1089
  // The parallel scheduler can fail a task before it ever produces an
991
1090
  // _activity (e.g. a thrown error on the first turn) — that failure lives
992
1091
  // in result.failures, not in any activity, so it must be checked too.
993
- const taskFailure = (result.failures ?? []).find((failure) => !isCancelledTaskFailure(failure));
1092
+ const taskFailure = (result.failures ?? []).find((failure) => !isCancelledTaskFailure(failure) && !isBusyFailure(failure));
994
1093
  if (!taskFailure) return null;
995
1094
  return {
996
1095
  kind: 'task_error',
@@ -1,7 +1,70 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
3
  import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
4
- import { finishRuntimeRun, replanRuntimeRun, runRuntimeAgenticWorkflow, runRuntimeParallelPlan } from './runner.js';
4
+ import { evaluateRuntimeRun, finishRuntimeRun, materializeTaskInputs, replanRuntimeRun, runRuntimeAgenticWorkflow, runRuntimeParallelPlan, shouldUseParallelScheduler } from './runner.js';
5
+
6
+ test('validated tasks waiting for approval stay in the scheduler instead of falling back to Donna', () => {
7
+ assert.equal(shouldUseParallelScheduler([
8
+ {
9
+ id: 'ingest-plan',
10
+ requiredCapability: 'knowledge.update',
11
+ operation: 'ingest_plan',
12
+ status: 'waiting_approval',
13
+ },
14
+ {
15
+ id: 'ingest-apply',
16
+ requiredCapability: 'knowledge.update',
17
+ operation: 'ingest_apply',
18
+ status: 'waiting_approval',
19
+ },
20
+ ]), true);
21
+ assert.equal(shouldUseParallelScheduler([
22
+ { id: 'finished', requiredCapability: 'knowledge.update', operation: 'ingest', status: 'done' },
23
+ ]), false);
24
+ });
25
+
26
+ test('downstream tasks consume actual dependency outputs instead of planned placeholder refs', () => {
27
+ const plannedRef = '.wiki/ingest-plans/planned.json';
28
+ const actualRef = '.wiki/ingest-plans/ingest-2026-07-10.json';
29
+ const plan = [{
30
+ id: 'ingest-plan',
31
+ expectedOutputRefs: [{ type: 'file', ref: plannedRef }],
32
+ outputRefs: [{ type: 'file', ref: actualRef }],
33
+ }];
34
+ const apply = {
35
+ id: 'ingest-apply',
36
+ dependsOn: ['ingest-plan'],
37
+ arguments: { inputs: [plannedRef] },
38
+ inputRefs: [{ type: 'file', ref: plannedRef }],
39
+ };
40
+
41
+ const executable = materializeTaskInputs(apply, plan);
42
+ assert.deepEqual(executable.arguments.inputs, [actualRef]);
43
+ assert.equal(executable.inputRefs[0].ref, actualRef);
44
+ assert.deepEqual(apply.arguments.inputs, [plannedRef], 'the validated plan declaration remains immutable');
45
+ });
46
+
47
+ test('evaluateRuntimeRun trusts a completed provider TaskGraph without asking an LLM to reinterpret it', async () => {
48
+ const session = {
49
+ headlessPlan: [{
50
+ id: 'ingest-a',
51
+ label: 'Ingest A.md',
52
+ requiredCapability: 'knowledge.update',
53
+ operation: 'ingest_plan',
54
+ status: 'done',
55
+ }],
56
+ llm: {
57
+ async completeWithTools() {
58
+ assert.fail('a structured terminal TaskGraph must not be reinterpreted by an LLM');
59
+ },
60
+ },
61
+ };
62
+
63
+ const evaluation = await evaluateRuntimeRun(session, 'ingère A.md');
64
+
65
+ assert.equal(evaluation.ok, true);
66
+ assert.match(evaluation.reason, /1 tâche/);
67
+ });
5
68
 
6
69
  test('runRuntimeAgenticWorkflow completes conversational turns without evaluation or replan', async () => {
7
70
  const events = [];
@@ -15,6 +15,7 @@ export function startRuntimeServer({
15
15
  session = null,
16
16
  getContext,
17
17
  run,
18
+ delegate,
18
19
  cancel,
19
20
  resume,
20
21
  approve,
@@ -24,6 +25,9 @@ export function startRuntimeServer({
24
25
  exitOnShutdown = process.env.WIKI_MANAGER_RUNTIME_CHILD === '1',
25
26
  } = {}) {
26
27
  const clients = new Set();
28
+ // When this runtime process started — used by ensureRuntime to detect that
29
+ // the manager source has been edited since (dev staleness) and auto-restart.
30
+ const runtimeStartedAtMs = Date.now();
27
31
  const defaultContext = { workspace: null, session, running: false, currentAbortController: null, currentRunId: null };
28
32
  const resolvedGetContext = getContext ?? (() => defaultContext);
29
33
 
@@ -61,6 +65,7 @@ export function startRuntimeServer({
61
65
  status: context?.running ? 'running' : 'idle',
62
66
  workspace: context?.workspace ?? workspace ?? null,
63
67
  activeRuns,
68
+ startedAtMs: runtimeStartedAtMs,
64
69
  dbPath: store.dbPath,
65
70
  cacertPath: activeCacertPath(),
66
71
  nodeExtraCaCerts: process.env.NODE_EXTRA_CA_CERTS ?? null,
@@ -271,6 +276,36 @@ export function startRuntimeServer({
271
276
  }
272
277
  return;
273
278
  }
279
+ if (request.method === 'POST' && url.pathname === '/delegate') {
280
+ const { body, context } = await resolveBodyContext(request, url);
281
+ const objective = String(body.objective ?? '').trim();
282
+ if (!objective) {
283
+ sendJson(response, 400, { error: 'Missing objective.' });
284
+ return;
285
+ }
286
+ if (context.running) {
287
+ sendJson(response, 409, { error: 'A runtime run is already active.' });
288
+ return;
289
+ }
290
+ if (typeof delegate !== 'function') {
291
+ sendJson(response, 501, { error: 'Runtime delegation is unavailable.' });
292
+ return;
293
+ }
294
+ try {
295
+ const prepared = await delegate(context, { objective, workspace: body.workspace ?? context.workspace ?? null });
296
+ const started = startRuntimeRun(context, {
297
+ input: objective,
298
+ workspace: body.workspace ?? context.workspace ?? null,
299
+ preparedDelegation: prepared,
300
+ evaluate: false,
301
+ }, { waitForPlan: true });
302
+ await started.ready;
303
+ sendJson(response, 202, { accepted: true, runId: started.runId, workspace: started.workspace, delegation: prepared.summary ?? null });
304
+ } catch (err) {
305
+ sendJson(response, 422, { error: err instanceof Error ? err.message : String(err) });
306
+ }
307
+ return;
308
+ }
274
309
  if (request.method === 'POST' && url.pathname === '/cancel') {
275
310
  const workspace = workspaceFromUrl(url);
276
311
  const context = await resolveContext({ workspace });
@@ -391,14 +426,22 @@ export function startRuntimeServer({
391
426
  return { killed: true, workspace: targetWorkspace, runId: targetRunId, runs, tasks, queued };
392
427
  }
393
428
 
394
- function startRuntimeRun(context, body, { controlItemId = null } = {}) {
429
+ function startRuntimeRun(context, body, { controlItemId = null, waitForPlan = false } = {}) {
395
430
  const runId = randomUUID();
396
431
  const runWorkspace = context.workspace ?? body.workspace ?? null;
397
432
  context.running = true;
398
433
  context.currentAbortController = new AbortController();
399
434
  context.currentRunId = runId;
400
435
  context.currentRunWorkspace = runWorkspace;
401
- const runBody = { ...body, workspace: runWorkspace, runId };
436
+ let resolveReady;
437
+ let rejectReady;
438
+ const ready = waitForPlan ? new Promise((resolve, reject) => { resolveReady = resolve; rejectReady = reject; }) : null;
439
+ const runBody = {
440
+ ...body,
441
+ workspace: runWorkspace,
442
+ runId,
443
+ ...(waitForPlan ? { _planReady: { resolve: resolveReady, reject: rejectReady } } : {}),
444
+ };
402
445
  if (controlItemId) {
403
446
  dispatchAgentEvent(context.session, createAgentEvent('control_started', {
404
447
  origin: 'runtime',
@@ -410,6 +453,7 @@ export function startRuntimeServer({
410
453
  const runPromise = run(context, runBody, { signal: context.currentAbortController.signal, runId });
411
454
  runPromise
412
455
  .catch((err) => {
456
+ rejectReady?.(err);
413
457
  context.session?._onRuntimeError?.(err);
414
458
  })
415
459
  .finally(() => {
@@ -420,7 +464,7 @@ export function startRuntimeServer({
420
464
  publishState(runWorkspace, context);
421
465
  void startNextControlRequest(context);
422
466
  });
423
- return { accepted: true, runId, workspace: runWorkspace };
467
+ return { accepted: true, runId, workspace: runWorkspace, ...(ready ? { ready } : {}) };
424
468
  }
425
469
 
426
470
  function startNextControlRequest(context) {
@@ -119,7 +119,10 @@ export async function pollActivitiesOnce(session, {
119
119
  const retry = progress.retryAt
120
120
  ? `retry ${progress.retryAt}`
121
121
  : (progress.waitMs ? `wait ${progress.waitMs}ms` : null);
122
- const progressKey = `${polledActivity.status}:${Number.isFinite(percent) ? Math.round(percent) : ''}:${detail}:${batch ?? ''}:${lastEvent ?? ''}:${retry ?? ''}`;
122
+ // retryAt/waitMs are scheduling metadata and may be recomputed on every
123
+ // status poll. They must remain visible in the first log line, but must
124
+ // not turn an unchanged quota/backoff state into a new trace event.
125
+ const progressKey = `${polledActivity.status}:${Number.isFinite(percent) ? Math.round(percent) : ''}:${detail}:${batch ?? ''}:${lastEvent ?? ''}`;
123
126
  session._activityLogKeys ??= {};
124
127
  if (session._activityLogKeys[key] !== progressKey) {
125
128
  session._activityLogKeys[key] = progressKey;
@@ -41,6 +41,55 @@ test('pollActivitiesOnce updates activity through the event reducer', async () =
41
41
  assert.ok(session.agentProjection.logs.some((line) => line.includes('activity:')));
42
42
  });
43
43
 
44
+ test('pollActivitiesOnce does not repeat an unchanged retry state when retryAt moves', async () => {
45
+ const session = {
46
+ mcp: { production: { status: 'connected' } },
47
+ activities: {},
48
+ headlessPlan: null,
49
+ jobQueue: [],
50
+ };
51
+ dispatchAgentEvent(session, createAgentEvent('activity_upserted', {
52
+ payload: {
53
+ activity: {
54
+ id: 'job-quota',
55
+ source: 'production',
56
+ label: 'Production · ingest',
57
+ status: 'running',
58
+ poll: { server: 'production', tool: 'production_job_status', args: { jobId: 'job-quota' }, intervalMs: 0 },
59
+ },
60
+ },
61
+ }));
62
+
63
+ let poll = 0;
64
+ const callTool = async () => {
65
+ poll += 1;
66
+ return {
67
+ content: [{ type: 'text', text: JSON.stringify({
68
+ _activity: {
69
+ id: 'job-quota',
70
+ source: 'production',
71
+ label: 'Production · ingest',
72
+ status: 'running',
73
+ terminal: false,
74
+ progress: {
75
+ percent: 15,
76
+ detail: 'LLM quota wait',
77
+ lastEvent: 'llm:rate-limit-wait',
78
+ retryAt: `2026-07-10T20:31:5${poll}.000Z`,
79
+ },
80
+ },
81
+ }) }],
82
+ };
83
+ };
84
+
85
+ await pollActivitiesOnce(session, { callTool });
86
+ await pollActivitiesOnce(session, { callTool });
87
+
88
+ const lines = session.agentProjection.logs.filter((line) => line.includes('activity: Production · ingest'));
89
+ assert.equal(lines.length, 1);
90
+ assert.match(lines[0], /retry 2026-07-10T20:31:51\.000Z/);
91
+ });
92
+
44
93
  test('pollActivitiesOnce retries transient MCP poll failures', async () => {
45
94
  const originalFetch = globalThis.fetch;
46
95
  let attempts = 0;