@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.
- package/README.md +32 -7
- package/agents.docker-compose.yml +3 -0
- package/docker-compose.yml +1 -0
- package/package.json +3 -2
- package/src/activity/activityAggregator.js +109 -0
- package/src/activity/activityAggregator.test.js +60 -0
- package/src/activity/activityDeduplicator.js +50 -0
- package/src/activity/progressCalculator.js +61 -0
- package/src/activity/runSynthesis.js +15 -0
- package/src/agent/graph.js +65 -30
- package/src/agent/graph.test.js +28 -5
- package/src/cli/wiki-manager.js +22 -0
- package/src/contracts/schemas.js +249 -13
- package/src/contracts/schemas.test.js +145 -0
- package/src/core/activity.js +8 -5
- package/src/core/agentEvents.js +260 -23
- package/src/core/agentEvents.test.js +53 -0
- package/src/core/compose.js +6 -1
- package/src/core/dockerCompose.test.js +12 -0
- package/src/core/documentIntake.js +33 -38
- package/src/core/documentIntake.test.js +25 -0
- package/src/core/env.js +11 -1
- package/src/core/jobQueue.js +26 -2
- package/src/core/mcp.js +1 -1
- package/src/core/plan.js +46 -4
- package/src/core/plan.test.js +16 -1
- package/src/core/planPatch.js +64 -3
- package/src/core/planPatch.test.js +110 -1
- package/src/core/queueStore.test.js +21 -0
- package/src/core/runtimeLog.js +119 -0
- package/src/core/runtimeLog.test.js +84 -0
- package/src/core/wikirc.js +22 -0
- package/src/core/wikirc.test.js +49 -1
- package/src/core/workflow.js +14 -3
- package/src/graph/graphAggregator.js +5 -0
- package/src/graph/graphPatch.js +8 -0
- package/src/graph/graphSnapshot.js +14 -0
- package/src/graph/graphVisibilityPolicy.js +40 -0
- package/src/graph/runGraphProjector.js +87 -0
- package/src/graph/runGraphProjector.test.js +111 -0
- package/src/orchestrator/agentRegistry.js +172 -0
- package/src/orchestrator/agentRegistry.test.js +114 -0
- package/src/orchestrator/approvalPolicy.js +126 -0
- package/src/orchestrator/approvalPolicy.test.js +78 -0
- package/src/orchestrator/assignmentManager.js +80 -0
- package/src/orchestrator/attemptManager.js +168 -0
- package/src/orchestrator/attemptManager.test.js +160 -0
- package/src/orchestrator/budgetManager.js +127 -0
- package/src/orchestrator/capabilityRegistry.js +64 -0
- package/src/orchestrator/capabilityRegistry.test.js +59 -0
- package/src/orchestrator/capabilityResolver.js +151 -0
- package/src/orchestrator/capabilityResolver.test.js +127 -0
- package/src/orchestrator/dependencyResolver.js +112 -0
- package/src/orchestrator/dispatcher.js +302 -0
- package/src/orchestrator/lockManager.js +51 -0
- package/src/orchestrator/planIntegrator.js +268 -0
- package/src/orchestrator/planIntegrator.test.js +213 -0
- package/src/orchestrator/planValidator.js +534 -0
- package/src/orchestrator/planValidator.test.js +262 -0
- package/src/orchestrator/resultAggregator.js +248 -0
- package/src/orchestrator/resultAggregator.test.js +211 -0
- package/src/orchestrator/scheduler.js +103 -0
- package/src/orchestrator/scheduler.test.js +138 -0
- package/src/runtime/approvals.js +130 -1
- package/src/runtime/donna-contract.test.js +293 -30
- package/src/runtime/recoveryManager.js +176 -0
- package/src/runtime/recoveryManager.test.js +162 -0
- package/src/runtime/runner.e2e.test.js +98 -12
- package/src/runtime/runner.js +261 -227
- package/src/runtime/runner.test.js +249 -452
- package/src/runtime/server.js +137 -20
- package/src/runtime/server.test.js +137 -7
- package/src/runtime/store.js +837 -2
- package/src/runtime/store.test.js +245 -4
- package/src/runtime/supervisor.js +33 -1
- package/src/runtime/supervisor.test.js +47 -1
- package/src/shell/RightPane.tsx +8 -5
- package/src/shell/repl.js +4 -9
- package/src/shell/repl.test.js +25 -4
- package/src/shell/tui.tsx +1 -0
- 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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
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
|
|
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(() =>
|
|
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
|
-
|
|
262
|
-
|
|
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
|
+
}
|