@dotdrelle/wiki-manager 0.12.0 → 0.12.7
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/package.json +2 -2
- package/src/activity/activityAggregator.js +34 -3
- package/src/activity/activityAggregator.test.js +32 -0
- package/src/agent/graph.js +307 -29
- package/src/agent/graph.test.js +404 -1
- package/src/cli/wiki-manager.js +24 -1
- package/src/commands/slash.js +74 -1
- package/src/commands/slash.test.js +36 -0
- package/src/contracts/schemas.test.js +2 -2
- package/src/core/activity.js +4 -0
- package/src/core/activity.test.js +9 -1
- package/src/core/agentEvents.js +30 -0
- package/src/core/agentEvents.test.js +42 -1
- package/src/core/buildInfo.js +58 -0
- package/src/core/buildInfo.json +4 -0
- package/src/core/buildInfo.test.js +14 -0
- package/src/core/mcp.js +50 -3
- package/src/core/mcp.test.js +94 -1
- package/src/core/runtimeLog.js +6 -1
- package/src/core/runtimeLog.test.js +3 -1
- package/src/runtime/client.js +22 -0
- package/src/runtime/controlMessages.js +43 -0
- package/src/runtime/controlMessages.test.js +21 -0
- package/src/runtime/donna-contract.test.js +3 -3
- package/src/runtime/recoveryManager.js +54 -0
- package/src/runtime/recoveryManager.test.js +73 -0
- package/src/runtime/runner.js +49 -13
- package/src/runtime/runner.test.js +196 -0
- package/src/runtime/server.js +73 -13
- package/src/runtime/server.test.js +284 -67
- package/src/runtime/store.js +48 -3
- package/src/runtime/store.test.js +56 -24
- package/src/runtime/supervisor.js +77 -1
- package/src/shell/RightPane.tsx +124 -31
- package/src/shell/SetupWizard.tsx +13 -1
- package/src/shell/repl.js +81 -11
- package/src/shell/repl.test.js +115 -2
- package/src/shell/tui.tsx +4 -1
- package/src/shell/useAgent.ts +37 -4
- package/src/shell/useSession.ts +51 -10
|
@@ -160,3 +160,76 @@ function recoverySession() {
|
|
|
160
160
|
}],
|
|
161
161
|
};
|
|
162
162
|
}
|
|
163
|
+
|
|
164
|
+
test('recoveryManager fails unresolvable-capability tasks and interrupts the run', async () => {
|
|
165
|
+
const { store, root, runId, taskId } = storeWithActiveTask();
|
|
166
|
+
const session = recoverySession();
|
|
167
|
+
// A registry that DOES know capabilities, but not the task's one: the plan
|
|
168
|
+
// came from a hallucinated capability (the 0.12.1 incident) — re-attaching
|
|
169
|
+
// it would recreate a forever-waiting queue on every boot.
|
|
170
|
+
session.agentRegistrySnapshot = [{
|
|
171
|
+
agentInstanceId: 'production-main',
|
|
172
|
+
serverName: 'production',
|
|
173
|
+
health: 'available',
|
|
174
|
+
description: {
|
|
175
|
+
agentType: 'production',
|
|
176
|
+
contractVersion: '1',
|
|
177
|
+
capabilities: [{ id: 'knowledge.pipeline', version: '1' }],
|
|
178
|
+
},
|
|
179
|
+
}];
|
|
180
|
+
store.hydrateSession(session, { workspace: 'docs' });
|
|
181
|
+
const statusCalls = [];
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
const result = await recoverActiveRuns({
|
|
185
|
+
store,
|
|
186
|
+
session,
|
|
187
|
+
workspace: 'docs',
|
|
188
|
+
callTool: async (_mcp, serverName, toolName, args) => {
|
|
189
|
+
statusCalls.push({ serverName, toolName, args });
|
|
190
|
+
return { content: [{ type: 'text', text: '{}' }] };
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
assert.equal(result.ok, true);
|
|
195
|
+
assert.equal(result.recovered.length, 0);
|
|
196
|
+
assert.equal(result.rescheduled.length, 0);
|
|
197
|
+
assert.equal(result.interrupted.length, 1);
|
|
198
|
+
assert.match(result.interrupted[0].reason, /unresolvable capability: document\.build/);
|
|
199
|
+
assert.deepEqual(statusCalls, [], 'no agent_status poll for an unresolvable task');
|
|
200
|
+
assert.equal(store.listTasks({ runId })[0].status, 'failed');
|
|
201
|
+
// The run must not come back as a zombie on the next boot.
|
|
202
|
+
assert.deepEqual(store.listRecoverableRuns({ workspace: 'docs' }), []);
|
|
203
|
+
} finally {
|
|
204
|
+
store.close();
|
|
205
|
+
rmSync(root, { recursive: true, force: true });
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('recoveryManager keeps recovering when the registry has no capability information', async () => {
|
|
210
|
+
const { store, root, runId, taskId } = storeWithActiveTask();
|
|
211
|
+
const session = recoverySession(); // snapshot without capability lists
|
|
212
|
+
store.hydrateSession(session, { workspace: 'docs' });
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
const result = await recoverActiveRuns({
|
|
216
|
+
store,
|
|
217
|
+
session,
|
|
218
|
+
workspace: 'docs',
|
|
219
|
+
callTool: async (_mcp, _serverName, _toolName, args) => ({
|
|
220
|
+
content: [{ type: 'text', text: JSON.stringify({
|
|
221
|
+
jobId: args.jobId,
|
|
222
|
+
taskId,
|
|
223
|
+
status: 'done',
|
|
224
|
+
result: { status: 'succeeded', outputRefs: [], metrics: {} },
|
|
225
|
+
}) }],
|
|
226
|
+
}),
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
assert.equal(result.recovered.length, 1, 'uninformative registry must not block recovery');
|
|
230
|
+
assert.equal(store.listTasks({ runId })[0].status, 'done');
|
|
231
|
+
} finally {
|
|
232
|
+
store.close();
|
|
233
|
+
rmSync(root, { recursive: true, force: true });
|
|
234
|
+
}
|
|
235
|
+
});
|
package/src/runtime/runner.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
2
|
-
import { sessionActivities, terminalFailures } from '../core/activity.js';
|
|
2
|
+
import { isCancelledStatus, sessionActivities, terminalFailures } from '../core/activity.js';
|
|
3
3
|
import { runAgenticLoop, throwIfAborted } from '../core/agentLoop.js';
|
|
4
|
-
import { formatPlanStatus } from '../core/plan.js';
|
|
4
|
+
import { formatPlanStatus, formatPlanStep } from '../core/plan.js';
|
|
5
5
|
import { readyPlanTasks, sanitizePlanForExecution } from '../core/planPatch.js';
|
|
6
6
|
import { createAssignmentManager } from '../orchestrator/assignmentManager.js';
|
|
7
7
|
import { createAttemptManager } from '../orchestrator/attemptManager.js';
|
|
@@ -134,6 +134,19 @@ export async function runRuntimeAgenticWorkflow(agent, session, input, {
|
|
|
134
134
|
continue;
|
|
135
135
|
}
|
|
136
136
|
}
|
|
137
|
+
if (!trigger && isCancelledOnlyLoopResult(result)) {
|
|
138
|
+
dispatchAgentEvent(session, createAgentEvent('run_cancelled', {
|
|
139
|
+
origin: 'runtime',
|
|
140
|
+
runId,
|
|
141
|
+
payload: {
|
|
142
|
+
runId,
|
|
143
|
+
cancelled: true,
|
|
144
|
+
message: 'Runtime run cancelled by user.',
|
|
145
|
+
},
|
|
146
|
+
}));
|
|
147
|
+
emitRuntimeLog(session, 'runtime: run ended by user cancellation (no replan)');
|
|
148
|
+
return { ok: false, result, cancelled: true };
|
|
149
|
+
}
|
|
137
150
|
dispatchAgentEvent(session, createAgentEvent('run_error', {
|
|
138
151
|
origin: 'runtime',
|
|
139
152
|
runId,
|
|
@@ -800,6 +813,7 @@ export async function replanRuntimeRun(session, input, trigger, {
|
|
|
800
813
|
'Given the original objective, current plan, and failure reason, return only the remaining steps required.',
|
|
801
814
|
'Do not include steps that are already done.',
|
|
802
815
|
'Return only JSON with this exact shape: {"steps":["..."]}.',
|
|
816
|
+
'Each step MUST be a plain string, not an object.',
|
|
803
817
|
].join('\n'),
|
|
804
818
|
tools: [],
|
|
805
819
|
messages: [{ role: 'user', content: buildReplanPrompt(input, session, trigger) }],
|
|
@@ -825,10 +839,12 @@ export async function replanRuntimeRun(session, input, trigger, {
|
|
|
825
839
|
steps: mergedSteps,
|
|
826
840
|
},
|
|
827
841
|
}));
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
842
|
+
// Every replan requires approval: deciding "is this step mutating?" with
|
|
843
|
+
// a verb regex was a safety judgement made by pattern-matching — a
|
|
844
|
+
// missed verb silently skipped the approval gate. Replans are rare
|
|
845
|
+
// (technical failures only) and re-executing work deserves a human OK.
|
|
846
|
+
session._runApprovalRequired = true;
|
|
847
|
+
session._runApprovalResolved = false;
|
|
832
848
|
return { ok: true, steps };
|
|
833
849
|
} catch (err) {
|
|
834
850
|
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
|
|
@@ -847,7 +863,12 @@ function replanTriggerFromLoopResult(result) {
|
|
|
847
863
|
};
|
|
848
864
|
}
|
|
849
865
|
const failures = terminalFailures(result.completed ?? []);
|
|
850
|
-
const
|
|
866
|
+
const technical = failures.filter((failure) => !isCancelledStatus(failure.status));
|
|
867
|
+
const cancelled = failures.filter((failure) => isCancelledStatus(failure.status));
|
|
868
|
+
if (cancelled.length > 0 && technical.length === 0 && (result.failures ?? []).every(isCancelledTaskFailure)) {
|
|
869
|
+
return null;
|
|
870
|
+
}
|
|
871
|
+
const failure = technical[0];
|
|
851
872
|
if (failure) {
|
|
852
873
|
return {
|
|
853
874
|
kind: 'activity_error',
|
|
@@ -859,7 +880,7 @@ function replanTriggerFromLoopResult(result) {
|
|
|
859
880
|
// The parallel scheduler can fail a task before it ever produces an
|
|
860
881
|
// _activity (e.g. a thrown error on the first turn) — that failure lives
|
|
861
882
|
// in result.failures, not in any activity, so it must be checked too.
|
|
862
|
-
const taskFailure = (result.failures ?? [])
|
|
883
|
+
const taskFailure = (result.failures ?? []).find((failure) => !isCancelledTaskFailure(failure));
|
|
863
884
|
if (!taskFailure) return null;
|
|
864
885
|
return {
|
|
865
886
|
kind: 'task_error',
|
|
@@ -869,6 +890,25 @@ function replanTriggerFromLoopResult(result) {
|
|
|
869
890
|
};
|
|
870
891
|
}
|
|
871
892
|
|
|
893
|
+
function isCancelledTaskFailure(failure) {
|
|
894
|
+
return failure?.cancelled === true
|
|
895
|
+
|| failure?.result?.cancelled === true
|
|
896
|
+
|| isCancelledStatus(failure?.status)
|
|
897
|
+
|| isCancelledStatus(failure?.result?.status);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function isCancelledOnlyLoopResult(result) {
|
|
901
|
+
const failures = terminalFailures(result.completed ?? []);
|
|
902
|
+
const technicalActivities = failures.filter((failure) => !isCancelledStatus(failure.status));
|
|
903
|
+
const cancelledActivities = failures.filter((failure) => isCancelledStatus(failure.status));
|
|
904
|
+
const taskFailures = result.failures ?? [];
|
|
905
|
+
const technicalTasks = taskFailures.filter((failure) => !isCancelledTaskFailure(failure));
|
|
906
|
+
const cancelledTasks = taskFailures.filter(isCancelledTaskFailure);
|
|
907
|
+
return technicalActivities.length === 0
|
|
908
|
+
&& technicalTasks.length === 0
|
|
909
|
+
&& (cancelledActivities.length > 0 || cancelledTasks.length > 0);
|
|
910
|
+
}
|
|
911
|
+
|
|
872
912
|
function runtimeLoopErrorMessage(result) {
|
|
873
913
|
if (result.timedOut) return 'Runtime agentic loop timed out.';
|
|
874
914
|
if (result.maxTurns) return 'Runtime agentic loop reached max turns.';
|
|
@@ -912,15 +952,11 @@ function buildReplannedRunPrompt(input, trigger, steps) {
|
|
|
912
952
|
function normalizeReplan(steps) {
|
|
913
953
|
if (!Array.isArray(steps)) return [];
|
|
914
954
|
return steps
|
|
915
|
-
.map((step) =>
|
|
955
|
+
.map((step) => formatPlanStep(step).trim())
|
|
916
956
|
.filter(Boolean)
|
|
917
957
|
.slice(0, 12);
|
|
918
958
|
}
|
|
919
959
|
|
|
920
|
-
function hasMutatingReplanStep(steps) {
|
|
921
|
-
return steps.some((step) => /\b(build|copy|ingest|import|export|polish|pipeline|write|create|delete|update|send|deploy|publish|generate|construire|copier|importer|exporter|publier|envoyer|supprimer|modifier|creer|générer|generer)\b/i.test(step));
|
|
922
|
-
}
|
|
923
|
-
|
|
924
960
|
function mergeReplanWithCompleted(plan, steps) {
|
|
925
961
|
const completed = (plan ?? [])
|
|
926
962
|
.filter((step) => step.status === 'done')
|
|
@@ -259,6 +259,30 @@ test('replanRuntimeRun preserves completed outputs when replacing remaining work
|
|
|
259
259
|
assert.deepEqual(session.headlessPlan[1].dependsOn, ['export']);
|
|
260
260
|
});
|
|
261
261
|
|
|
262
|
+
test('replanRuntimeRun normalizes object steps without leaking object strings', async () => {
|
|
263
|
+
const session = {
|
|
264
|
+
activities: {},
|
|
265
|
+
headlessPlan: [{ step: 1, id: 'done', description: 'Done', status: 'done' }],
|
|
266
|
+
llm: {
|
|
267
|
+
async completeWithTools({ system }) {
|
|
268
|
+
assert.match(system, /Each step MUST be a plain string, not an object\./);
|
|
269
|
+
return { content: '{"steps":["a",{"description":"b"},{"id":"c"},{},null]}' };
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const result = await replanRuntimeRun(session, 'Build deliverable', {
|
|
275
|
+
kind: 'activity_error',
|
|
276
|
+
reason: 'Build failed.',
|
|
277
|
+
}, { runId: 'run-replan-object-steps', replansLeft: 1 });
|
|
278
|
+
|
|
279
|
+
assert.equal(result.ok, true);
|
|
280
|
+
assert.deepEqual(result.steps, ['a', 'b', 'c']);
|
|
281
|
+
assert.deepEqual(session.agentProjection.replans[0].plan, ['a', 'b', 'c']);
|
|
282
|
+
assert.deepEqual(session.headlessPlan.slice(1).map((step) => step.description), ['a', 'b', 'c']);
|
|
283
|
+
assert.equal(JSON.stringify(session.headlessPlan).includes('[object Object]'), false);
|
|
284
|
+
});
|
|
285
|
+
|
|
262
286
|
test('replanRuntimeRun requires runtime approval for mutating replanned work', async () => {
|
|
263
287
|
const session = {
|
|
264
288
|
activities: {},
|
|
@@ -370,6 +394,154 @@ test('runRuntimeAgenticWorkflow replans after terminal activity error', async ()
|
|
|
370
394
|
}
|
|
371
395
|
});
|
|
372
396
|
|
|
397
|
+
test('runRuntimeAgenticWorkflow treats user-cancelled activity as terminal cancellation without replan', async () => {
|
|
398
|
+
const originalFetch = globalThis.fetch;
|
|
399
|
+
let pollAttempts = 0;
|
|
400
|
+
const events = [];
|
|
401
|
+
globalThis.fetch = async () => {
|
|
402
|
+
pollAttempts += 1;
|
|
403
|
+
return mcpActivityResponse({
|
|
404
|
+
id: 'job-cancelled',
|
|
405
|
+
source: 'production',
|
|
406
|
+
label: 'Production build',
|
|
407
|
+
status: 'cancelled',
|
|
408
|
+
terminal: true,
|
|
409
|
+
});
|
|
410
|
+
};
|
|
411
|
+
const session = {
|
|
412
|
+
mcp: {
|
|
413
|
+
production: {
|
|
414
|
+
status: 'connected',
|
|
415
|
+
url: 'http://127.0.0.1:3000/mcp/',
|
|
416
|
+
retry: { maxAttempts: 1, backoffMs: 0 },
|
|
417
|
+
},
|
|
418
|
+
},
|
|
419
|
+
activities: {},
|
|
420
|
+
headlessPlan: null,
|
|
421
|
+
llm: {
|
|
422
|
+
async completeWithTools() {
|
|
423
|
+
assert.fail('cancelled activity must not call evaluator or replanner');
|
|
424
|
+
},
|
|
425
|
+
},
|
|
426
|
+
_onAgentEvent: (event) => events.push(event),
|
|
427
|
+
};
|
|
428
|
+
const agent = {
|
|
429
|
+
async invoke({ session: turnSession }) {
|
|
430
|
+
dispatchAgentEvent(turnSession, createAgentEvent('activity_upserted', {
|
|
431
|
+
payload: {
|
|
432
|
+
activity: runningPollableActivity('job-cancelled', 'Production build'),
|
|
433
|
+
},
|
|
434
|
+
}));
|
|
435
|
+
return { response: 'Started build.' };
|
|
436
|
+
},
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
try {
|
|
440
|
+
const result = await runRuntimeAgenticWorkflow(agent, session, 'Build workspace', {
|
|
441
|
+
runId: 'run-cancelled-activity',
|
|
442
|
+
timeoutMs: 1000,
|
|
443
|
+
maxTurns: 1,
|
|
444
|
+
maxReplans: 1,
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
assert.equal(result.ok, false);
|
|
448
|
+
assert.equal(result.cancelled, true);
|
|
449
|
+
assert.equal(pollAttempts, 1);
|
|
450
|
+
assert.equal(events.some((event) => event.type === 'run_replanned'), false);
|
|
451
|
+
assert.ok(events.some((event) => event.type === 'run_cancelled' && event.payload?.cancelled === true));
|
|
452
|
+
assert.equal(session.agentProjection.status, 'cancelled');
|
|
453
|
+
assert.match(session.agentProjection.logs.join('\n'), /run ended by user cancellation \(no replan\)/);
|
|
454
|
+
} finally {
|
|
455
|
+
globalThis.fetch = originalFetch;
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
test('runRuntimeAgenticWorkflow replans from failed activity when another activity is cancelled', async () => {
|
|
460
|
+
const originalFetch = globalThis.fetch;
|
|
461
|
+
const events = [];
|
|
462
|
+
const pollStatuses = new Map([
|
|
463
|
+
['job-failed', {
|
|
464
|
+
id: 'job-failed',
|
|
465
|
+
source: 'production',
|
|
466
|
+
label: 'Production build',
|
|
467
|
+
status: 'failed',
|
|
468
|
+
terminal: true,
|
|
469
|
+
error: 'build failed',
|
|
470
|
+
}],
|
|
471
|
+
['job-cancelled', {
|
|
472
|
+
id: 'job-cancelled',
|
|
473
|
+
source: 'production',
|
|
474
|
+
label: 'Production cleanup',
|
|
475
|
+
status: 'cancelled',
|
|
476
|
+
terminal: true,
|
|
477
|
+
}],
|
|
478
|
+
]);
|
|
479
|
+
globalThis.fetch = async (_url, options = {}) => {
|
|
480
|
+
const body = JSON.parse(String(options.body ?? '{}'));
|
|
481
|
+
return mcpActivityResponse(pollStatuses.get(body.params?.arguments?.jobId));
|
|
482
|
+
};
|
|
483
|
+
const session = {
|
|
484
|
+
mcp: {
|
|
485
|
+
production: {
|
|
486
|
+
status: 'connected',
|
|
487
|
+
url: 'http://127.0.0.1:3000/mcp/',
|
|
488
|
+
retry: { maxAttempts: 1, backoffMs: 0 },
|
|
489
|
+
},
|
|
490
|
+
},
|
|
491
|
+
activities: {},
|
|
492
|
+
headlessPlan: null,
|
|
493
|
+
llm: {
|
|
494
|
+
async completeWithTools({ system }) {
|
|
495
|
+
if (/replanner/.test(system)) return { content: '{"steps":["Retry build"]}' };
|
|
496
|
+
return { content: '{"ok":true,"reason":"Build complete.","suggestedAction":null}' };
|
|
497
|
+
},
|
|
498
|
+
},
|
|
499
|
+
_onAgentEvent: (event) => events.push(event),
|
|
500
|
+
};
|
|
501
|
+
let turns = 0;
|
|
502
|
+
const agent = {
|
|
503
|
+
async invoke({ session: turnSession }) {
|
|
504
|
+
turns += 1;
|
|
505
|
+
if (turns === 1) {
|
|
506
|
+
dispatchAgentEvent(turnSession, createAgentEvent('activity_upserted', {
|
|
507
|
+
payload: { activity: runningPollableActivity('job-failed', 'Production build') },
|
|
508
|
+
}));
|
|
509
|
+
dispatchAgentEvent(turnSession, createAgentEvent('activity_upserted', {
|
|
510
|
+
payload: { activity: runningPollableActivity('job-cancelled', 'Production cleanup') },
|
|
511
|
+
}));
|
|
512
|
+
return { response: 'Started build.' };
|
|
513
|
+
}
|
|
514
|
+
const pending = turnSession.headlessPlan?.find((step) => step.status === 'pending');
|
|
515
|
+
if (pending) {
|
|
516
|
+
dispatchAgentEvent(turnSession, createAgentEvent('plan_step_updated', {
|
|
517
|
+
origin: 'tool',
|
|
518
|
+
payload: { taskId: pending.id, status: 'done' },
|
|
519
|
+
}));
|
|
520
|
+
}
|
|
521
|
+
return { response: 'Retry done.' };
|
|
522
|
+
},
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
try {
|
|
526
|
+
const result = await runRuntimeAgenticWorkflow(agent, session, 'Build workspace', {
|
|
527
|
+
runId: 'run-failed-and-cancelled',
|
|
528
|
+
timeoutMs: 1000,
|
|
529
|
+
maxTurns: 3,
|
|
530
|
+
maxReplans: 1,
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
assert.equal(result.ok, true);
|
|
534
|
+
assert.equal(turns, 2);
|
|
535
|
+
assert.equal(events.some((event) => event.type === 'run_cancelled'), false);
|
|
536
|
+
assert.ok(events.some((event) => event.type === 'run_replanned'));
|
|
537
|
+
assert.equal(session.agentProjection.replans[0].reason, 'Production build ended with failed: build failed');
|
|
538
|
+
assert.deepEqual(session.agentProjection.replans[0].plan, ['Retry build']);
|
|
539
|
+
assert.equal(session.agentProjection.status, 'done');
|
|
540
|
+
} finally {
|
|
541
|
+
globalThis.fetch = originalFetch;
|
|
542
|
+
}
|
|
543
|
+
});
|
|
544
|
+
|
|
373
545
|
test('runRuntimeAgenticWorkflow stops after replan budget is exhausted', async () => {
|
|
374
546
|
const session = {
|
|
375
547
|
activities: {},
|
|
@@ -707,6 +879,30 @@ function productionAgent() {
|
|
|
707
879
|
};
|
|
708
880
|
}
|
|
709
881
|
|
|
882
|
+
function runningPollableActivity(id, label) {
|
|
883
|
+
return {
|
|
884
|
+
id,
|
|
885
|
+
source: 'production',
|
|
886
|
+
label,
|
|
887
|
+
status: 'running',
|
|
888
|
+
terminal: false,
|
|
889
|
+
poll: { server: 'production', tool: 'production_job_status', args: { jobId: id }, intervalMs: 0 },
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function mcpActivityResponse(activity) {
|
|
894
|
+
return {
|
|
895
|
+
ok: true,
|
|
896
|
+
status: 200,
|
|
897
|
+
headers: { get: () => null },
|
|
898
|
+
text: async () => JSON.stringify({
|
|
899
|
+
result: {
|
|
900
|
+
content: [{ type: 'text', text: JSON.stringify({ _activity: activity }) }],
|
|
901
|
+
},
|
|
902
|
+
}),
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
|
|
710
906
|
function toolResult(payload) {
|
|
711
907
|
return { content: [{ type: 'text', text: JSON.stringify(payload) }] };
|
|
712
908
|
}
|
package/src/runtime/server.js
CHANGED
|
@@ -5,6 +5,7 @@ import { activeCacertPath } from '../core/cacert.js';
|
|
|
5
5
|
import { normalizePlanPatch, rebasePlanPatch } from '../core/planPatch.js';
|
|
6
6
|
import { validateContractInDev } from '../contracts/schemas.js';
|
|
7
7
|
import { runtimeTokenFromEnv } from './auth.js';
|
|
8
|
+
import { controlMessage } from './controlMessages.js';
|
|
8
9
|
|
|
9
10
|
export function startRuntimeServer({
|
|
10
11
|
host = '127.0.0.1',
|
|
@@ -241,15 +242,19 @@ export function startRuntimeServer({
|
|
|
241
242
|
}
|
|
242
243
|
validateContractInDev('runRequest', { ...body, input });
|
|
243
244
|
if (context.running) {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
245
|
+
// Blind enqueueing made every message typed during a run
|
|
246
|
+
// invisible until the run ended (serve UI symptom: "no result
|
|
247
|
+
// until the job finished or was stopped"). Classify instead:
|
|
248
|
+
// observe → immediate status explanation, cancel → abort,
|
|
249
|
+
// ambiguous action → explicit choices, explicit "later" intent
|
|
250
|
+
// (body.intent = 'enqueue') → enqueue as before.
|
|
251
|
+
const result = await handleControlMessage(context, store, input, {
|
|
252
|
+
intent: body.intent,
|
|
253
|
+
startNextControlRequest,
|
|
254
|
+
cancel,
|
|
255
|
+
approve,
|
|
252
256
|
});
|
|
257
|
+
sendJson(response, result.statusCode, result.body);
|
|
253
258
|
return;
|
|
254
259
|
}
|
|
255
260
|
const accepted = startRuntimeRun(context, body);
|
|
@@ -273,6 +278,16 @@ export function startRuntimeServer({
|
|
|
273
278
|
sendJson(response, 202, { cancelled: true, workspace: context.workspace ?? workspace ?? null });
|
|
274
279
|
return;
|
|
275
280
|
}
|
|
281
|
+
if (request.method === 'POST' && url.pathname === '/kill') {
|
|
282
|
+
const body = await readJson(request);
|
|
283
|
+
const workspace = workspaceFromBody(body) ?? workspaceFromUrl(url);
|
|
284
|
+
const runId = url.searchParams.get('runId') ?? body.runId ?? null;
|
|
285
|
+
const context = await resolveContext({ workspace });
|
|
286
|
+
const result = await killRuntimeRuns(context, { workspace, runId });
|
|
287
|
+
publishState(context.workspace ?? workspace ?? null, context);
|
|
288
|
+
sendJson(response, 202, result);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
276
291
|
if (request.method === 'POST' && url.pathname === '/shutdown') {
|
|
277
292
|
const workspace = workspaceFromUrl(url);
|
|
278
293
|
const context = await resolveContext({ workspace });
|
|
@@ -354,6 +369,23 @@ export function startRuntimeServer({
|
|
|
354
369
|
return { body, workspace, context };
|
|
355
370
|
}
|
|
356
371
|
|
|
372
|
+
async function killRuntimeRuns(context, { workspace = null, runId = null } = {}) {
|
|
373
|
+
const targetWorkspace = context?.workspace ?? workspace ?? null;
|
|
374
|
+
const targetRunId = runId ? String(runId) : null;
|
|
375
|
+
if (!targetRunId || targetRunId === context?.currentRunId) {
|
|
376
|
+
context?.currentAbortController?.abort();
|
|
377
|
+
await cancel?.(context);
|
|
378
|
+
}
|
|
379
|
+
const runs = typeof store.interruptRuns === 'function'
|
|
380
|
+
? store.interruptRuns({ workspace: targetWorkspace, runId: targetRunId, reason: 'Runtime run killed by user.' })
|
|
381
|
+
: 0;
|
|
382
|
+
const tasks = typeof store.cancelActiveTasksForInterruptedRuns === 'function'
|
|
383
|
+
? store.cancelActiveTasksForInterruptedRuns({ workspace: targetWorkspace, runId: targetRunId })
|
|
384
|
+
: 0;
|
|
385
|
+
const queued = cancelQueuedControlItems(context?.session, targetWorkspace);
|
|
386
|
+
return { killed: true, workspace: targetWorkspace, runId: targetRunId, runs, tasks, queued };
|
|
387
|
+
}
|
|
388
|
+
|
|
357
389
|
function startRuntimeRun(context, body, { controlItemId = null } = {}) {
|
|
358
390
|
const runId = randomUUID();
|
|
359
391
|
const runWorkspace = context.workspace ?? body.workspace ?? null;
|
|
@@ -471,6 +503,34 @@ function controlQueueFor(session) {
|
|
|
471
503
|
return Array.isArray(session?.controlQueue) ? session.controlQueue : [];
|
|
472
504
|
}
|
|
473
505
|
|
|
506
|
+
function cancelQueuedControlItems(session, workspace = null) {
|
|
507
|
+
const now = new Date().toISOString();
|
|
508
|
+
const counted = new Set();
|
|
509
|
+
let count = 0;
|
|
510
|
+
for (const queue of [controlQueueFor(session), session?.agentProjection?.controlQueue].filter(Array.isArray)) {
|
|
511
|
+
for (const item of queue) {
|
|
512
|
+
if (item.status !== 'queued') continue;
|
|
513
|
+
if (workspace && item.workspace && item.workspace !== workspace) continue;
|
|
514
|
+
item.status = 'cancelled';
|
|
515
|
+
item.finishedAt = now;
|
|
516
|
+
item.updatedAt = now;
|
|
517
|
+
const id = item.id ?? item.runId ?? `${item.input}:${item.createdAt}`;
|
|
518
|
+
if (!counted.has(id)) {
|
|
519
|
+
counted.add(id);
|
|
520
|
+
if (session) {
|
|
521
|
+
dispatchAgentEvent(session, createAgentEvent('control_cancelled', {
|
|
522
|
+
origin: 'runtime',
|
|
523
|
+
workspace: item.workspace ?? workspace ?? null,
|
|
524
|
+
payload: { id, finishedAt: now },
|
|
525
|
+
}));
|
|
526
|
+
}
|
|
527
|
+
count += 1;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
return count;
|
|
532
|
+
}
|
|
533
|
+
|
|
474
534
|
function readOnlyControlResponse(kind, classification, status, explanation, { accepted = true, extra = {} } = {}) {
|
|
475
535
|
return {
|
|
476
536
|
statusCode: 200,
|
|
@@ -522,7 +582,7 @@ async function handleControlMessage(context, store, input, { intent = null, star
|
|
|
522
582
|
classification,
|
|
523
583
|
proposal,
|
|
524
584
|
...controlStatus(context, store),
|
|
525
|
-
explanation:
|
|
585
|
+
explanation: controlMessage(context?.session, 'plan_patch_proposed'),
|
|
526
586
|
},
|
|
527
587
|
};
|
|
528
588
|
}
|
|
@@ -540,12 +600,12 @@ async function handleControlMessage(context, store, input, { intent = null, star
|
|
|
540
600
|
classification,
|
|
541
601
|
item,
|
|
542
602
|
...controlStatus(context, store),
|
|
543
|
-
explanation:
|
|
603
|
+
explanation: controlMessage(context?.session, 'queued_for_future_run'),
|
|
544
604
|
},
|
|
545
605
|
};
|
|
546
606
|
}
|
|
547
607
|
if (classification.kind === 'ambiguous') {
|
|
548
|
-
return readOnlyControlResponse('ambiguous', classification, status,
|
|
608
|
+
return readOnlyControlResponse('ambiguous', classification, status, controlMessage(context?.session, 'ambiguous_control'), {
|
|
549
609
|
accepted: false,
|
|
550
610
|
extra: {
|
|
551
611
|
choices: [
|
|
@@ -557,8 +617,8 @@ async function handleControlMessage(context, store, input, { intent = null, star
|
|
|
557
617
|
});
|
|
558
618
|
}
|
|
559
619
|
return readOnlyControlResponse('converse', classification, status, status.running
|
|
560
|
-
?
|
|
561
|
-
:
|
|
620
|
+
? controlMessage(context?.session, 'converse_while_running')
|
|
621
|
+
: controlMessage(context?.session, 'converse_while_idle'));
|
|
562
622
|
}
|
|
563
623
|
|
|
564
624
|
function enqueueControlRequest(context, input) {
|