@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.
- package/docker-compose.yml +1 -1
- package/mcp.endpoints.example.json +7 -0
- package/package.json +2 -2
- package/src/agent/graph.js +354 -143
- package/src/agent/graph.test.js +516 -54
- package/src/agent/llm.js +5 -5
- package/src/cli/wiki-manager.js +234 -6
- package/src/cli/wiki-manager.test.js +28 -0
- package/src/commands/slash.js +32 -11
- package/src/commands/slash.test.js +9 -1
- package/src/core/agentEvents.js +7 -1
- package/src/core/agentEvents.test.js +13 -1
- package/src/core/buildInfo.json +2 -2
- package/src/core/mcp.js +46 -4
- package/src/core/skills.js +0 -28
- package/src/core/toolLoop.js +56 -0
- package/src/core/toolLoop.test.js +88 -0
- package/src/orchestrator/capabilityRegistry.js +14 -0
- package/src/orchestrator/capabilityRegistry.test.js +12 -1
- package/src/orchestrator/dependencyResolver.js +10 -1
- package/src/orchestrator/dispatcher.js +34 -3
- package/src/orchestrator/dispatcher.test.js +34 -0
- package/src/orchestrator/objectiveResolver.js +79 -0
- package/src/orchestrator/objectiveResolver.test.js +50 -0
- package/src/orchestrator/scheduler.test.js +25 -0
- package/src/runtime/client.js +32 -1
- package/src/runtime/lifecycle.js +32 -2
- package/src/runtime/recoveryManager.js +14 -7
- package/src/runtime/runner.js +112 -13
- package/src/runtime/runner.test.js +64 -1
- package/src/runtime/server.js +47 -3
- package/src/runtime/supervisor.js +4 -1
- package/src/runtime/supervisor.test.js +49 -0
- package/src/shell/repl.js +134 -55
- package/src/shell/repl.test.js +151 -12
- package/src/shell/useSession.ts +15 -3
package/src/runtime/runner.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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 = [];
|
package/src/runtime/server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|