@dotdrelle/wiki-manager 0.11.9 → 0.12.0

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 (81) hide show
  1. package/README.md +32 -7
  2. package/agents.docker-compose.yml +3 -0
  3. package/docker-compose.yml +1 -0
  4. package/package.json +3 -2
  5. package/src/activity/activityAggregator.js +109 -0
  6. package/src/activity/activityAggregator.test.js +60 -0
  7. package/src/activity/activityDeduplicator.js +50 -0
  8. package/src/activity/progressCalculator.js +61 -0
  9. package/src/activity/runSynthesis.js +15 -0
  10. package/src/agent/graph.js +65 -30
  11. package/src/agent/graph.test.js +28 -5
  12. package/src/cli/wiki-manager.js +22 -0
  13. package/src/contracts/schemas.js +249 -13
  14. package/src/contracts/schemas.test.js +145 -0
  15. package/src/core/activity.js +8 -5
  16. package/src/core/agentEvents.js +260 -23
  17. package/src/core/agentEvents.test.js +53 -0
  18. package/src/core/compose.js +6 -1
  19. package/src/core/dockerCompose.test.js +12 -0
  20. package/src/core/documentIntake.js +33 -38
  21. package/src/core/documentIntake.test.js +25 -0
  22. package/src/core/env.js +11 -1
  23. package/src/core/jobQueue.js +26 -2
  24. package/src/core/mcp.js +1 -1
  25. package/src/core/plan.js +46 -4
  26. package/src/core/plan.test.js +16 -1
  27. package/src/core/planPatch.js +64 -3
  28. package/src/core/planPatch.test.js +110 -1
  29. package/src/core/queueStore.test.js +21 -0
  30. package/src/core/runtimeLog.js +119 -0
  31. package/src/core/runtimeLog.test.js +84 -0
  32. package/src/core/wikirc.js +22 -0
  33. package/src/core/wikirc.test.js +49 -1
  34. package/src/core/workflow.js +14 -3
  35. package/src/graph/graphAggregator.js +5 -0
  36. package/src/graph/graphPatch.js +8 -0
  37. package/src/graph/graphSnapshot.js +14 -0
  38. package/src/graph/graphVisibilityPolicy.js +40 -0
  39. package/src/graph/runGraphProjector.js +87 -0
  40. package/src/graph/runGraphProjector.test.js +111 -0
  41. package/src/orchestrator/agentRegistry.js +172 -0
  42. package/src/orchestrator/agentRegistry.test.js +114 -0
  43. package/src/orchestrator/approvalPolicy.js +126 -0
  44. package/src/orchestrator/approvalPolicy.test.js +78 -0
  45. package/src/orchestrator/assignmentManager.js +80 -0
  46. package/src/orchestrator/attemptManager.js +168 -0
  47. package/src/orchestrator/attemptManager.test.js +160 -0
  48. package/src/orchestrator/budgetManager.js +127 -0
  49. package/src/orchestrator/capabilityRegistry.js +64 -0
  50. package/src/orchestrator/capabilityRegistry.test.js +59 -0
  51. package/src/orchestrator/capabilityResolver.js +151 -0
  52. package/src/orchestrator/capabilityResolver.test.js +127 -0
  53. package/src/orchestrator/dependencyResolver.js +112 -0
  54. package/src/orchestrator/dispatcher.js +302 -0
  55. package/src/orchestrator/lockManager.js +51 -0
  56. package/src/orchestrator/planIntegrator.js +268 -0
  57. package/src/orchestrator/planIntegrator.test.js +213 -0
  58. package/src/orchestrator/planValidator.js +534 -0
  59. package/src/orchestrator/planValidator.test.js +262 -0
  60. package/src/orchestrator/resultAggregator.js +248 -0
  61. package/src/orchestrator/resultAggregator.test.js +211 -0
  62. package/src/orchestrator/scheduler.js +103 -0
  63. package/src/orchestrator/scheduler.test.js +138 -0
  64. package/src/runtime/approvals.js +130 -1
  65. package/src/runtime/donna-contract.test.js +293 -30
  66. package/src/runtime/recoveryManager.js +176 -0
  67. package/src/runtime/recoveryManager.test.js +162 -0
  68. package/src/runtime/runner.e2e.test.js +98 -12
  69. package/src/runtime/runner.js +261 -227
  70. package/src/runtime/runner.test.js +249 -452
  71. package/src/runtime/server.js +137 -20
  72. package/src/runtime/server.test.js +137 -7
  73. package/src/runtime/store.js +837 -2
  74. package/src/runtime/store.test.js +245 -4
  75. package/src/runtime/supervisor.js +33 -1
  76. package/src/runtime/supervisor.test.js +47 -1
  77. package/src/shell/RightPane.tsx +8 -5
  78. package/src/shell/repl.js +4 -9
  79. package/src/shell/repl.test.js +25 -4
  80. package/src/shell/tui.tsx +1 -0
  81. package/src/shell/useSession.ts +38 -10
@@ -1,7 +1,8 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
+ import { createAgentGraph } from '../agent/graph.js';
3
4
  import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
4
- import { runRuntimeAgenticWorkflow } from './runner.js';
5
+ import { runRuntimeAgenticWorkflow, runRuntimeParallelPlan } from './runner.js';
5
6
  import { startRuntimeServer } from './server.js';
6
7
 
7
8
  // Executable version of plan-0.11.5-hotfix-final.md §3 ("La recette — le seul
@@ -140,6 +141,123 @@ test('Recipe #3 — "où en est le dernier build ?" reads status, starts no new
140
141
  assert.equal(types.includes('plan_set'), false);
141
142
  });
142
143
 
144
+ test('CME setup and source configuration stay outside export orchestration', async () => {
145
+ const originalFetch = globalThis.fetch;
146
+ const calledTools = [];
147
+ globalThis.fetch = async (_url, options = {}) => {
148
+ const body = JSON.parse(String(options.body ?? '{}'));
149
+ calledTools.push(body.params?.name);
150
+ return {
151
+ ok: true,
152
+ status: 200,
153
+ headers: { get: () => null },
154
+ text: async () => JSON.stringify({ result: { content: [{ type: 'text', text: '{"ok":true}' }] } }),
155
+ };
156
+ };
157
+ let turn = 0;
158
+ const session = baseSession({
159
+ commands: ['status'],
160
+ workspace: 'demo-workspace',
161
+ mcp: {
162
+ cme: {
163
+ status: 'connected',
164
+ url: 'http://127.0.0.1:3010/mcp/',
165
+ tools: [
166
+ { name: 'cme_setup', inputSchema: { type: 'object', additionalProperties: true } },
167
+ { name: 'cme_source_add', inputSchema: { type: 'object', additionalProperties: true } },
168
+ { name: 'cme_export_run', inputSchema: { type: 'object', additionalProperties: true } },
169
+ ],
170
+ },
171
+ },
172
+ llm: {
173
+ async completeWithTools() {
174
+ turn += 1;
175
+ if (turn === 1) {
176
+ return {
177
+ content: null,
178
+ message: { role: 'assistant', content: null },
179
+ tool_calls: [
180
+ {
181
+ id: 'setup',
182
+ type: 'function',
183
+ function: {
184
+ name: 'cme__cme_setup',
185
+ arguments: JSON.stringify({ workspace: 'demo-workspace', base_url: 'https://confluence.example', username: 'user@example.com', pat: 'token' }),
186
+ },
187
+ },
188
+ {
189
+ id: 'source',
190
+ type: 'function',
191
+ function: {
192
+ name: 'cme__cme_source_add',
193
+ arguments: JSON.stringify({ workspace: 'demo-workspace', name: 'docs', base_url: 'https://confluence.example', space: 'DOC' }),
194
+ },
195
+ },
196
+ ],
197
+ };
198
+ }
199
+ return {
200
+ content: 'CME est configuré.',
201
+ message: { role: 'assistant', content: 'CME est configuré.' },
202
+ tool_calls: null,
203
+ };
204
+ },
205
+ },
206
+ });
207
+
208
+ try {
209
+ const result = await runRuntimeAgenticWorkflow(createAgentGraph(), session, 'configure cme', {
210
+ runId: 'cme-config',
211
+ timeoutMs: 1000,
212
+ maxTurns: 2,
213
+ evaluate: false,
214
+ });
215
+
216
+ assert.equal(result.ok, true);
217
+ assert.deepEqual(calledTools, ['cme_setup', 'cme_source_add']);
218
+ assert.equal(calledTools.includes('cme_export_run'), false);
219
+ assert.equal((session.headlessPlan ?? []).some((step) => step.requiredCapability === 'external-source.export'), false);
220
+ assert.equal((session.headlessPlan ?? []).some((step) => step.operation === 'export'), false);
221
+ } finally {
222
+ globalThis.fetch = originalFetch;
223
+ }
224
+ });
225
+
226
+ test('CME export is dispatched only from an approved DAG task', async () => {
227
+ let executeCalls = 0;
228
+ const session = baseSession({
229
+ workspace: 'demo-workspace',
230
+ mcp: {
231
+ cme: {
232
+ status: 'connected',
233
+ tools: [{ name: 'agent_execute' }, { name: 'agent_status' }, { name: 'agent_cancel' }],
234
+ },
235
+ },
236
+ agentRegistrySnapshot: [cmeAgent()],
237
+ wikircConfig: { capabilityRouting: {} },
238
+ approvals: [],
239
+ headlessPlan: [plannedCmeExportTask({ status: 'waiting_approval' })],
240
+ });
241
+ const callTool = async (_mcp, _serverName, toolName) => {
242
+ if (toolName === 'agent_execute') executeCalls += 1;
243
+ throw new Error(`unexpected tool: ${toolName}`);
244
+ };
245
+
246
+ const result = await runRuntimeParallelPlan({ invoke: async () => assert.fail('no child loop') }, session, 'export cme', {
247
+ runId: 'cme-export-approval',
248
+ timeoutMs: 100,
249
+ maxTurns: 1,
250
+ callTool,
251
+ dispatcherPollIntervalMs: 1,
252
+ });
253
+
254
+ assert.equal(result.ok, false);
255
+ assert.equal(result.stalled, true);
256
+ assert.equal(result.reason, 'awaiting_approval');
257
+ assert.equal(executeCalls, 0);
258
+ assert.equal(session.headlessPlan[0].status, 'waiting_approval');
259
+ });
260
+
143
261
  function buildSingleTaskAgent({ taskId, description, finalResponse }) {
144
262
  let turn = 0;
145
263
  return {
@@ -209,41 +327,65 @@ test('Recipe #5 — "construis le livrable X": plan posed, job runs, progress, d
209
327
  });
210
328
 
211
329
  function buildTwoParallelTasksAgent() {
212
- const started = [];
213
- const release = {};
214
330
  let parentTurnDone = false;
215
331
  return {
216
- started,
217
- release,
218
- async invoke({ input, session: turnSession }) {
219
- const taskMatch = input.match(/Task id: (\w+)/);
220
- if (!taskMatch) {
221
- if (!parentTurnDone) {
222
- parentTurnDone = true;
223
- dispatchAgentEvent(turnSession, createAgentEvent('plan_set', {
224
- origin: 'tool',
225
- payload: {
226
- steps: [
227
- { step: 1, id: 'x', description: 'Construire X', status: 'pending', dependsOn: [] },
228
- { step: 2, id: 'y', description: 'Construire Y', status: 'pending', dependsOn: [] },
229
- ],
230
- },
231
- }));
232
- return { response: 'Je construis X et Y en parallèle.' };
233
- }
234
- return { response: 'X et Y sont construits.' };
332
+ async invoke({ session: turnSession }) {
333
+ if (!parentTurnDone) {
334
+ parentTurnDone = true;
335
+ dispatchAgentEvent(turnSession, createAgentEvent('plan_set', {
336
+ origin: 'tool',
337
+ payload: {
338
+ steps: [
339
+ plannedDoctorTask('x', []),
340
+ plannedDoctorTask('y', []),
341
+ ],
342
+ },
343
+ }));
344
+ return { response: 'Je construis X et Y en parallèle.' };
235
345
  }
236
- const id = taskMatch[1];
237
- started.push(id);
238
- await new Promise((resolve) => { release[id] = resolve; });
239
- return { response: `${id} construit.` };
346
+ assert.fail('contractual tasks must be dispatched through agent tools, not child Donna turns');
240
347
  },
241
348
  };
242
349
  }
243
350
 
244
351
  test('Recipe #6 — "construis X et Y": two tasks run in parallel, converge, done', async () => {
245
- const session = baseSession();
352
+ const completed = new Set();
353
+ const jobs = new Map();
354
+ const executeOrder = [];
355
+ const session = baseSession({
356
+ workspace: 'demo-workspace',
357
+ mcp: {
358
+ production: {
359
+ status: 'connected',
360
+ tools: [{ name: 'agent_execute' }, { name: 'agent_status' }, { name: 'agent_cancel' }],
361
+ },
362
+ },
363
+ agentRegistrySnapshot: [productionAgent()],
364
+ wikircConfig: { capabilityRouting: {} },
365
+ });
246
366
  const agent = buildTwoParallelTasksAgent();
367
+ const callTool = async (_mcp, _serverName, toolName, args) => {
368
+ if (toolName === 'agent_execute') {
369
+ executeOrder.push(args.taskId);
370
+ const jobId = `job-${args.taskId}`;
371
+ jobs.set(jobId, { jobId, taskId: args.taskId });
372
+ return toolResult({ accepted: true, jobId, status: 'queued' });
373
+ }
374
+ if (toolName === 'agent_status') {
375
+ const job = jobs.get(args.jobId);
376
+ const done = completed.has(job.taskId);
377
+ return toolResult({
378
+ jobId: job.jobId,
379
+ taskId: job.taskId,
380
+ operation: 'doctor',
381
+ status: done ? 'done' : 'running',
382
+ progress: { percent: done ? 100 : 50 },
383
+ ...(done ? { result: { status: 'succeeded', outputRefs: [], metrics: { durationMs: 1 } } } : {}),
384
+ });
385
+ }
386
+ if (toolName === 'agent_cancel') return toolResult({ ok: true });
387
+ throw new Error(`unexpected tool: ${toolName}`);
388
+ };
247
389
 
248
390
  const running = runRuntimeAgenticWorkflow(agent, session, 'construis X et Y', {
249
391
  runId: 'recipe-6',
@@ -251,15 +393,17 @@ test('Recipe #6 — "construis X et Y": two tasks run in parallel, converge, don
251
393
  maxTurns: 3,
252
394
  maxReplans: 1,
253
395
  evaluate: false,
396
+ callTool,
397
+ dispatcherPollIntervalMs: 1,
254
398
  });
255
399
 
256
- await waitFor(() => agent.started.includes('x') && agent.started.includes('y'));
400
+ await waitFor(() => executeOrder.includes('x') && executeOrder.includes('y'));
257
401
  assert.deepEqual(
258
402
  session.headlessPlan.filter((step) => ['x', 'y'].includes(step.id)).map((step) => step.status),
259
403
  ['running', 'running'],
260
404
  );
261
- agent.release.x();
262
- agent.release.y();
405
+ completed.add('x');
406
+ completed.add('y');
263
407
 
264
408
  const result = await running;
265
409
 
@@ -267,6 +411,125 @@ test('Recipe #6 — "construis X et Y": two tasks run in parallel, converge, don
267
411
  assert.deepEqual(session.headlessPlan.map((step) => step.status), ['done', 'done']);
268
412
  });
269
413
 
414
+ function plannedDoctorTask(id, dependsOn) {
415
+ return {
416
+ step: id === 'x' ? 1 : 2,
417
+ id,
418
+ label: `Construire ${id.toUpperCase()}`,
419
+ description: `Construire ${id.toUpperCase()}`,
420
+ status: 'pending',
421
+ dependsOn,
422
+ requiredCapability: 'workspace.diagnose',
423
+ operation: 'doctor',
424
+ arguments: {},
425
+ parallelizable: true,
426
+ inputRefs: [],
427
+ expectedOutputRefs: [],
428
+ locks: [],
429
+ requiresApproval: false,
430
+ idempotencyKey: null,
431
+ progressWeight: 1,
432
+ outputRefs: [],
433
+ };
434
+ }
435
+
436
+ function plannedCmeExportTask(overrides = {}) {
437
+ return {
438
+ step: 1,
439
+ id: 'cme-export',
440
+ label: 'Export CME',
441
+ description: 'Export CME',
442
+ status: 'pending',
443
+ dependsOn: [],
444
+ requiredCapability: 'external-source.export',
445
+ operation: 'export',
446
+ arguments: { source_name: 'docs' },
447
+ parallelizable: false,
448
+ inputRefs: [],
449
+ expectedOutputRefs: [{ type: 'directory', ref: 'raw/untracked' }],
450
+ locks: ['external-source:docs'],
451
+ requiresApproval: true,
452
+ approvalClass: 'external-source',
453
+ idempotencyKey: 'idem-cme-export',
454
+ progressWeight: 1,
455
+ outputRefs: [],
456
+ ...overrides,
457
+ };
458
+ }
459
+
460
+ function cmeAgent() {
461
+ return {
462
+ serverName: 'cme',
463
+ agentInstanceId: 'cme-main',
464
+ health: 'available',
465
+ description: {
466
+ contractVersion: '1',
467
+ agentType: 'cme',
468
+ agentInstanceId: 'cme-main',
469
+ displayName: 'CME',
470
+ capabilities: [{
471
+ id: 'external-source.export',
472
+ version: '1',
473
+ description: 'Export external source',
474
+ inputSchema: { type: 'object', additionalProperties: true },
475
+ outputSchema: { type: 'object', additionalProperties: true },
476
+ supportedOperations: ['export'],
477
+ mutationClass: 'external-source',
478
+ defaultRequiresApproval: true,
479
+ }],
480
+ orchestration: {
481
+ canPlan: false,
482
+ canExpandPlan: false,
483
+ canExecute: true,
484
+ canCancel: true,
485
+ canResume: false,
486
+ singleTaskOnly: true,
487
+ supportsIdempotency: true,
488
+ supportsParallelWorkers: false,
489
+ },
490
+ limits: { recommendedConcurrency: 1, maxConcurrency: 1, maxTaskDurationMs: 2000 },
491
+ health: { status: 'available' },
492
+ },
493
+ };
494
+ }
495
+
496
+ function productionAgent() {
497
+ return {
498
+ serverName: 'production',
499
+ agentInstanceId: 'production-main',
500
+ health: 'available',
501
+ description: {
502
+ contractVersion: '1',
503
+ agentType: 'production',
504
+ agentInstanceId: 'production-main',
505
+ displayName: 'Production',
506
+ capabilities: [{
507
+ id: 'workspace.diagnose',
508
+ version: '1',
509
+ description: 'Diagnose workspace',
510
+ inputSchema: { type: 'object', additionalProperties: true },
511
+ outputSchema: { type: 'object', additionalProperties: true },
512
+ supportedOperations: ['doctor'],
513
+ }],
514
+ orchestration: {
515
+ canPlan: true,
516
+ canExpandPlan: false,
517
+ canExecute: true,
518
+ canCancel: true,
519
+ canResume: false,
520
+ supportsIdempotency: false,
521
+ supportsParallelWorkers: true,
522
+ },
523
+ limits: { recommendedConcurrency: 2, maxConcurrency: 2, maxTaskDurationMs: 2000 },
524
+ health: { status: 'available' },
525
+ },
526
+ };
527
+ }
528
+
529
+ function toolResult(payload) {
530
+ return { content: [{ type: 'text', text: JSON.stringify(payload) }] };
531
+ }
532
+
270
533
  test('Recipe #7 — "où en es-tu ?" during an active run: status in conversation, run continues, no new run', async (t) => {
271
534
  const session = { workspace: 'juno', controlQueue: [] };
272
535
  let runCount = 0;
@@ -0,0 +1,176 @@
1
+ import { parseJsonText } from '../core/activity.js';
2
+ import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
3
+ import { formatMcpToolResult, callMcpTool as defaultCallMcpTool } from '../core/mcp.js';
4
+ import { accept as acceptResult } from '../orchestrator/resultAggregator.js';
5
+
6
+ const ACTIVE_TASK_STATUSES = new Set(['running', 'queued', 'starting', 'assigned']);
7
+ const TERMINAL_STATUSES = new Set(['done', 'failed', 'cancelled', 'canceled', 'completed', 'complete', 'success', 'succeeded']);
8
+
9
+ export async function recoverActiveRuns({
10
+ store,
11
+ session,
12
+ workspace = null,
13
+ callTool = defaultCallMcpTool,
14
+ resultAggregator = acceptResult,
15
+ } = {}) {
16
+ if (!store) throw new Error('recoveryManager requires store.');
17
+ if (!session) throw new Error('recoveryManager requires session.');
18
+ const runs = store.listRecoverableRuns?.({ workspace }) ?? [];
19
+ const recovered = [];
20
+ const rescheduled = [];
21
+ const interrupted = [];
22
+ const errors = [];
23
+
24
+ for (const run of runs) {
25
+ const tasks = store.listTasks?.({ runId: run.id }) ?? [];
26
+ const activeTasks = tasks.filter((task) => ACTIVE_TASK_STATUSES.has(String(task.status ?? '').toLowerCase()));
27
+ for (const task of activeTasks) {
28
+ try {
29
+ const outcome = await recoverTask({ store, session, run, task, callTool, resultAggregator });
30
+ if (outcome?.status === 'recovered') recovered.push(outcome);
31
+ else if (outcome?.status === 'rescheduled') rescheduled.push(outcome);
32
+ else if (outcome?.status === 'interrupted') interrupted.push(outcome);
33
+ } catch (error) {
34
+ errors.push({ runId: run.id, taskId: task.id, error: error instanceof Error ? error.message : String(error) });
35
+ }
36
+ }
37
+ }
38
+
39
+ return {
40
+ ok: errors.length === 0,
41
+ recovered,
42
+ rescheduled,
43
+ interrupted,
44
+ errors,
45
+ };
46
+ }
47
+
48
+ async function recoverTask({ store, session, run, task, callTool, resultAggregator }) {
49
+ const attempt = latestAttempt(store.listTaskAttempts?.({ taskId: task.id }) ?? []);
50
+ const assignment = latestAssignment(store.listTaskAssignments?.({ taskId: task.id }) ?? [], attempt?.attemptId);
51
+ if (!attempt?.jobId || !assignment?.agentInstanceId) {
52
+ return interruptTask({ store, session, run, task, reason: 'missing attempt job or assignment' });
53
+ }
54
+
55
+ const agent = agentFor(session, assignment.agentInstanceId);
56
+ const serverName = agent?.serverName ?? assignment.agentId ?? assignment.agentInstanceId;
57
+ const statusTool = toolNameFor(session, serverName, 'agent_status');
58
+ const status = parseToolPayload(await callTool(session.mcp, serverName, statusTool, { jobId: attempt.jobId }));
59
+
60
+ if (isTerminal(status?.status ?? status?.result?.status)) {
61
+ const result = taskResultFromStatus(task, assignment, attempt.jobId, status, attempt);
62
+ await resultAggregator(result, {
63
+ session,
64
+ runId: run.id,
65
+ task,
66
+ assignment: {
67
+ agentInstanceId: assignment.agentInstanceId,
68
+ serverName,
69
+ agent,
70
+ },
71
+ store,
72
+ registry: session.capabilityRegistry ?? null,
73
+ workspaceConfig: session.wikircConfig ?? session.wikirc?.config ?? {},
74
+ callTool,
75
+ });
76
+ return { status: 'recovered', runId: run.id, taskId: task.id, jobId: attempt.jobId };
77
+ }
78
+
79
+ if (task.idempotencyKey) {
80
+ dispatch(session, store, 'plan_step_updated', {
81
+ origin: 'recovery_manager',
82
+ runId: run.id,
83
+ taskId: task.id,
84
+ workspace: run.workspace ?? workspaceFromSession(session),
85
+ payload: {
86
+ taskId: task.id,
87
+ status: 'pending',
88
+ recovery: {
89
+ reason: 'active_job_requeued_by_idempotency',
90
+ jobId: attempt.jobId,
91
+ idempotencyKey: task.idempotencyKey,
92
+ agentInstanceId: assignment.agentInstanceId,
93
+ },
94
+ },
95
+ });
96
+ return { status: 'rescheduled', runId: run.id, taskId: task.id, jobId: attempt.jobId, idempotencyKey: task.idempotencyKey };
97
+ }
98
+
99
+ return interruptTask({ store, session, run, task, reason: 'active job is non-terminal and task has no idempotencyKey' });
100
+ }
101
+
102
+ function interruptTask({ store, session, run, task, reason }) {
103
+ dispatch(session, store, 'runtime_log', {
104
+ origin: 'recovery_manager',
105
+ runId: run.id,
106
+ taskId: task.id,
107
+ workspace: run.workspace ?? workspaceFromSession(session),
108
+ payload: { message: `recovery: ${task.id} interrupted (${reason})` },
109
+ });
110
+ return { status: 'interrupted', runId: run.id, taskId: task.id, reason };
111
+ }
112
+
113
+ function latestAttempt(attempts) {
114
+ return [...attempts].sort((a, b) => String(b.startedAt ?? b.finishedAt ?? b.attemptId).localeCompare(String(a.startedAt ?? a.finishedAt ?? a.attemptId)))[0] ?? null;
115
+ }
116
+
117
+ function latestAssignment(assignments, attemptId) {
118
+ if (attemptId) {
119
+ const matched = assignments.find((assignment) => assignment.attemptId === attemptId);
120
+ if (matched) return matched;
121
+ }
122
+ return [...assignments].sort((a, b) => String(b.assignedAt ?? b.attemptId).localeCompare(String(a.assignedAt ?? a.attemptId)))[0] ?? null;
123
+ }
124
+
125
+ function isTerminal(status) {
126
+ return TERMINAL_STATUSES.has(String(status ?? '').toLowerCase());
127
+ }
128
+
129
+ function parseToolPayload(result) {
130
+ if (result && typeof result === 'object' && !Array.isArray(result) && !Array.isArray(result.content)) return result;
131
+ return parseJsonText(formatMcpToolResult(result)) ?? {};
132
+ }
133
+
134
+ function taskResultFromStatus(task, assignment, jobId, statusPayload, attempt) {
135
+ const result = statusPayload?.result ?? {};
136
+ const status = String(result.status ?? statusPayload?.status ?? '').toLowerCase();
137
+ const ok = ['succeeded', 'success', 'done', 'complete', 'completed'].includes(status);
138
+ return {
139
+ ok,
140
+ taskId: task.id,
141
+ attemptId: statusPayload?.attemptId ?? attempt?.attemptId ?? null,
142
+ jobId,
143
+ agentInstanceId: assignment.agentInstanceId,
144
+ status: result.status ?? statusPayload?.status,
145
+ outputRefs: Array.isArray(result.outputRefs) ? result.outputRefs : [],
146
+ metrics: result.metrics ?? {},
147
+ error: result.error ?? null,
148
+ rawStatus: statusPayload,
149
+ };
150
+ }
151
+
152
+ function agentFor(session, agentInstanceId) {
153
+ return [
154
+ ...(session.agentRegistrySnapshot ?? []),
155
+ ...(session.agents ?? []),
156
+ ].find((agent) => agent?.agentInstanceId === agentInstanceId) ?? null;
157
+ }
158
+
159
+ function toolNameFor(session, serverName, baseName) {
160
+ const tools = session.mcp?.[serverName]?.tools ?? [];
161
+ const names = tools.map((tool) => String(tool.name ?? '')).filter(Boolean);
162
+ return names.find((name) => name === baseName)
163
+ ?? names.find((name) => name === `${serverName}__${baseName}`)
164
+ ?? names.find((name) => name.endsWith(`__${baseName}`))
165
+ ?? baseName;
166
+ }
167
+
168
+ function dispatch(session, store, type, event) {
169
+ const dispatched = dispatchAgentEvent(session, createAgentEvent(type, event));
170
+ store?.persistEvent?.(dispatched);
171
+ return dispatched;
172
+ }
173
+
174
+ function workspaceFromSession(session) {
175
+ return session.workspace ?? session._currentRunIdentity?.workspace ?? null;
176
+ }