ai-runtime-engine 2.7.0 → 2.9.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/CHANGELOG.md +108 -0
- package/dist/agents/roles.d.ts +36 -0
- package/dist/agents/roles.js +44 -0
- package/dist/agents/synthesize.d.ts +44 -0
- package/dist/agents/synthesize.js +60 -0
- package/dist/agents/task.d.ts +95 -6
- package/dist/agents/task.js +40 -2
- package/dist/agents/worker.d.ts +32 -1
- package/dist/agents/worker.js +150 -19
- package/dist/cli/cli.js +1 -1
- package/dist/cli/interactive/lanes.d.ts +69 -0
- package/dist/cli/interactive/lanes.js +181 -0
- package/dist/cli/interactive/repl.js +52 -0
- package/dist/cli/interactive/session.d.ts +8 -1
- package/dist/cli/interactive/session.js +62 -10
- package/dist/executions/agentTasks.d.ts +628 -0
- package/dist/executions/agentTasks.js +149 -0
- package/dist/executions/checkpoint.d.ts +5 -1
- package/dist/executions/checkpoint.js +13 -1
- package/dist/executions/execution.d.ts +23 -0
- package/dist/executions/store.d.ts +37 -0
- package/dist/executions/store.js +33 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +7 -0
- package/dist/orchestration/executor.d.ts +26 -1
- package/dist/orchestration/executor.js +50 -9
- package/dist/orchestration/orchestrator.d.ts +6 -0
- package/dist/orchestration/orchestrator.js +18 -1
- package/dist/runtime/events.d.ts +44 -0
- package/dist/runtime/events.js +4 -0
- package/dist/runtime/runtime.d.ts +90 -0
- package/dist/runtime/runtime.js +510 -20
- package/dist/security/redact.js +22 -10
- package/dist/util/hash.d.ts +19 -0
- package/dist/util/hash.js +39 -0
- package/package.json +1 -1
package/dist/runtime/runtime.js
CHANGED
|
@@ -31,6 +31,8 @@ import { TokenEstimator } from '../context/tokens.js';
|
|
|
31
31
|
import { ToolRegistry } from '../tools/registry.js';
|
|
32
32
|
import { resolvePermissions, clampMcpPermissions } from '../tools/permissions.js';
|
|
33
33
|
import { flattenClamp } from '../util/flatten.js';
|
|
34
|
+
import { wrapUntrusted } from '../tools/untrusted.js';
|
|
35
|
+
import { redact } from '../security/redact.js';
|
|
34
36
|
import { McpManager } from '../mcp/manager.js';
|
|
35
37
|
import { mcpTool, mcpToolId } from '../mcp/toolAdapter.js';
|
|
36
38
|
import { StaticMcpSource } from '../mcp/mcp.js';
|
|
@@ -52,6 +54,34 @@ import { executePlan } from '../orchestration/executor.js';
|
|
|
52
54
|
import { ExecutionStore } from '../executions/store.js';
|
|
53
55
|
import { RESUMABLE, TERMINAL } from '../executions/execution.js';
|
|
54
56
|
import { captureCheckpoint, reconcile } from '../executions/checkpoint.js';
|
|
57
|
+
import { AGENT_TERMINAL, AGENT_RESUMABLE, agentTaskView } from '../agents/task.js';
|
|
58
|
+
import { parseAgentTasks } from '../executions/agentTasks.js';
|
|
59
|
+
import { stepIdentity } from '../agents/worker.js';
|
|
60
|
+
import { hashOf } from '../util/hash.js';
|
|
61
|
+
/** Checkpoints kept per execution. Only the newest is ever read; the rest are audit trail, and the
|
|
62
|
+
* record is rewritten on every commit, so this is a file-size bound (Phase 3.5). */
|
|
63
|
+
const CHECKPOINTS_MAX = 5;
|
|
64
|
+
/** Findings carried into a replan prompt. Bounded because they are rendered into a model call. */
|
|
65
|
+
const FINDINGS_BRIEF_MAX = 10;
|
|
66
|
+
/**
|
|
67
|
+
* What an observation looks like ON DISK (Phase 3.5).
|
|
68
|
+
*
|
|
69
|
+
* Two jobs, both at the persistence boundary. REDACTION: an observation carries tool output, which is
|
|
70
|
+
* arbitrary text from outside the process — a tool that prints a token would otherwise write it into
|
|
71
|
+
* the execution file verbatim, where it survives every later read. Every other egress (logs, telemetry,
|
|
72
|
+
* CLI) already redacts; the store did not, and Phase 3.5 writes far more of this content far more often.
|
|
73
|
+
* SLIMMING: findings already live on the agent task record in the same file, so keeping only their ids
|
|
74
|
+
* here stops every finding being stored twice and rewritten on every commit.
|
|
75
|
+
*
|
|
76
|
+
* The live observation is untouched — callers still receive full findings from the run itself.
|
|
77
|
+
*/
|
|
78
|
+
function persistableObservation(obs) {
|
|
79
|
+
const data = obs.data;
|
|
80
|
+
const slimmed = data && Array.isArray(data.findings)
|
|
81
|
+
? { ...obs, data: { ...(({ findings: _drop, ...rest }) => rest)(data), findingIds: data.findings.map((f) => f?.id).filter((id) => typeof id === 'string') } }
|
|
82
|
+
: obs;
|
|
83
|
+
return redact(slimmed);
|
|
84
|
+
}
|
|
55
85
|
import { ArtifactStore } from '../artifacts/artifacts.js';
|
|
56
86
|
import { compare } from '../comparison/comparator.js';
|
|
57
87
|
import { renderComparison } from '../comparison/render.js';
|
|
@@ -129,6 +159,8 @@ export class Runtime {
|
|
|
129
159
|
/** Live runs, so a pause/cancel can abort the agent tasks actually in flight. Only ever populated
|
|
130
160
|
* when agents are enabled, so pause/cancel are unchanged with the flag off. */
|
|
131
161
|
liveRuns = new Map();
|
|
162
|
+
/** Phase 3.6: agent tasks running RIGHT NOW in this process, and how to stop each one on its own. */
|
|
163
|
+
liveAgentTasks = new Map();
|
|
132
164
|
/** The config file's `budget:` ceilings, kept only so the 3.3 pre-pass can decline a model call. */
|
|
133
165
|
_configBudget;
|
|
134
166
|
approval;
|
|
@@ -824,7 +856,14 @@ export class Runtime {
|
|
|
824
856
|
// entire flag-off delta on this path.
|
|
825
857
|
const planning = await this.capabilityPlanning(effectiveGoal, policy, routing);
|
|
826
858
|
try {
|
|
827
|
-
const outcome = await orchestrate(this.orchestrateInput(mode, effectiveGoal, policy,
|
|
859
|
+
const outcome = await orchestrate(this.orchestrateInput(mode, effectiveGoal, policy, {
|
|
860
|
+
...(routing ? { routing } : {}),
|
|
861
|
+
...(partial ? { partial } : {}),
|
|
862
|
+
...(planning?.block ? { requiredCapabilities: planning.block } : {}),
|
|
863
|
+
...(this.agentsEnabled ? { signal: controller.signal } : {}),
|
|
864
|
+
provenance: { planVersion: 1 },
|
|
865
|
+
runId,
|
|
866
|
+
}));
|
|
828
867
|
this.recordOrchestration(mode, goal, outcome);
|
|
829
868
|
return this.mapOutcome(outcome, resolution, runId, undefined, planning);
|
|
830
869
|
}
|
|
@@ -855,9 +894,19 @@ export class Runtime {
|
|
|
855
894
|
// Renew the lease while the (possibly long) run is in flight so it can't expire mid-run.
|
|
856
895
|
if (this.agentsEnabled)
|
|
857
896
|
this.liveRuns.set(exec.id, { controller });
|
|
858
|
-
|
|
897
|
+
// Phase 3.5: only when there is a store to write to — a stateless run keeps the 2.7.0 shape.
|
|
898
|
+
const sink = this._executions.enabled ? this.runPersistence(exec, controller) : undefined;
|
|
899
|
+
const outcome = await this.withHeartbeat(exec.id, () => orchestrate(this.orchestrateInput(mode, effectiveGoal, policy, {
|
|
900
|
+
...(routing ? { routing } : {}),
|
|
901
|
+
...(partial ? { partial } : {}),
|
|
902
|
+
...(planning?.block ? { requiredCapabilities: planning.block } : {}),
|
|
903
|
+
...(this.agentsEnabled ? { signal: controller.signal } : {}),
|
|
904
|
+
provenance: { executionId: exec.id, planVersion: exec.planVersion },
|
|
905
|
+
...(sink ? { sink } : {}),
|
|
906
|
+
runId,
|
|
907
|
+
})));
|
|
859
908
|
exec.status = this.execStatus(outcome.status);
|
|
860
|
-
exec.observations = outcome.observations;
|
|
909
|
+
exec.observations = outcome.observations.map(persistableObservation);
|
|
861
910
|
if (outcome.plan) {
|
|
862
911
|
exec.plan = outcome.plan;
|
|
863
912
|
exec.planVersion = outcome.plan.version;
|
|
@@ -865,12 +914,19 @@ export class Runtime {
|
|
|
865
914
|
}
|
|
866
915
|
if (outcome.status === 'waiting_for_approval')
|
|
867
916
|
exec.pending = { kind: 'approval', action: goal };
|
|
868
|
-
else if (outcome.status === 'waiting_for_clarification' && outcome.clarification)
|
|
869
|
-
|
|
917
|
+
else if (outcome.status === 'waiting_for_clarification' && outcome.clarification) {
|
|
918
|
+
// First-wins: if an AGENT is what is waiting, the pending slot records which one, so the answer
|
|
919
|
+
// is routed into that task's inner resume instead of being appended to the outer goal.
|
|
920
|
+
const waiter = this.electWaitingAgent(exec);
|
|
921
|
+
exec.pending = { kind: 'clarification', question: outcome.clarification, ...(waiter ? { agentTaskId: waiter.agentTaskId } : {}) };
|
|
922
|
+
}
|
|
870
923
|
else if (outcome.status === 'waiting_for_budget' && outcome.budget)
|
|
871
924
|
exec.pending = { kind: 'budget', budget: outcome.budget };
|
|
872
|
-
exec.checkpoints.push(captureCheckpoint({ root: this.workspaceRoot, ...(exec.plan ? { plan: exec.plan } : {}), skills: this.skills(), completedSteps: exec.completedSteps }));
|
|
873
|
-
|
|
925
|
+
exec.checkpoints.push(captureCheckpoint({ root: this.workspaceRoot, ...(exec.plan ? { plan: exec.plan } : {}), skills: this.skills(), completedSteps: exec.completedSteps, mcpTools: this.mcpToolHashes(exec.plan) }));
|
|
926
|
+
if (sink)
|
|
927
|
+
sink.finalize();
|
|
928
|
+
else
|
|
929
|
+
this._executions.commit(exec); // ownership-checked: never clobber a newer owner
|
|
874
930
|
this.recordOrchestration(mode, goal, outcome);
|
|
875
931
|
return this.mapOutcome(outcome, resolution, runId, exec.id, planning);
|
|
876
932
|
}
|
|
@@ -880,6 +936,110 @@ export class Runtime {
|
|
|
880
936
|
this._executions.release(exec.id);
|
|
881
937
|
}
|
|
882
938
|
}
|
|
939
|
+
/**
|
|
940
|
+
* THE mid-run persistence sink (Phase 3.5) — the only thing that writes an execution while it runs.
|
|
941
|
+
*
|
|
942
|
+
* Invariant 18 says everything needed for resume is on disk before the next wave starts, and the
|
|
943
|
+
* non-obvious part is WHAT that includes. Committing the plan, the completed steps and the agent
|
|
944
|
+
* records is not enough: the resume gate is `!recon.drifted && !!exec.plan`, and `recon` defaults to
|
|
945
|
+
* DRIFTED whenever `checkpoints` is empty. Since checkpoints were captured only after orchestration
|
|
946
|
+
* returned, a crash mid-run always drifted, always replanned, and re-ran every completed agent task —
|
|
947
|
+
* the exact thing this phase exists to prevent. So the sink captures a checkpoint too.
|
|
948
|
+
*
|
|
949
|
+
* Every refusal from the funnel ABORTS the run. A refused commit means another owner now owns this
|
|
950
|
+
* execution's fate; carrying on would call the same tools and burn the same model calls twice while
|
|
951
|
+
* that owner re-runs the identical steps, and every result would be discarded at the end anyway.
|
|
952
|
+
*/
|
|
953
|
+
runPersistence(exec, controller) {
|
|
954
|
+
// Fixed for each executePlan call: its `callsUsed` is cumulative for that call, so the pool must be
|
|
955
|
+
// `priorCalls + thisCall`, and `priorCalls` advances only when the call ends. Adding a per-fire delta
|
|
956
|
+
// instead would let the total go BACKWARDS across replan iterations and over-grant the resume pool.
|
|
957
|
+
let priorCalls = exec.callsUsed ?? 0;
|
|
958
|
+
let lastCheckpointed = '';
|
|
959
|
+
let stopped = false;
|
|
960
|
+
const commit = () => {
|
|
961
|
+
if (stopped)
|
|
962
|
+
return;
|
|
963
|
+
const outcome = this._executions.commitProgress(exec);
|
|
964
|
+
if (outcome === 'ok')
|
|
965
|
+
return;
|
|
966
|
+
// Stop trying to write, and stop the run itself.
|
|
967
|
+
stopped = true;
|
|
968
|
+
this.abortLiveRun(exec.id, outcome === 'paused' ? 'pause' : 'parent-cancel');
|
|
969
|
+
controller.abort();
|
|
970
|
+
};
|
|
971
|
+
/** Capture only when the completed set moved: `captureCheckpoint` hashes files and shells git. */
|
|
972
|
+
const checkpointIfMoved = () => {
|
|
973
|
+
const key = exec.completedSteps.join('\u0000');
|
|
974
|
+
if (key === lastCheckpointed)
|
|
975
|
+
return;
|
|
976
|
+
lastCheckpointed = key;
|
|
977
|
+
exec.checkpoints.push(captureCheckpoint({ root: this.workspaceRoot, ...(exec.plan ? { plan: exec.plan } : {}), skills: this.skills(), completedSteps: exec.completedSteps, mcpTools: this.mcpToolHashes(exec.plan) }));
|
|
978
|
+
if (exec.checkpoints.length > CHECKPOINTS_MAX)
|
|
979
|
+
exec.checkpoints.splice(0, exec.checkpoints.length - CHECKPOINTS_MAX);
|
|
980
|
+
};
|
|
981
|
+
return {
|
|
982
|
+
/** The plan is settled and nothing has run: a crash in wave 1 must still resume against a plan. */
|
|
983
|
+
onPlan: (plan) => {
|
|
984
|
+
exec.plan = plan;
|
|
985
|
+
exec.planVersion = plan.version;
|
|
986
|
+
exec.completedSteps = plan.steps.filter((st) => st.status === 'succeeded').map((st) => st.id);
|
|
987
|
+
checkpointIfMoved();
|
|
988
|
+
commit();
|
|
989
|
+
},
|
|
990
|
+
onProgress: (snap) => {
|
|
991
|
+
exec.plan = snap.plan;
|
|
992
|
+
exec.completedSteps = snap.plan.steps.filter((st) => st.status === 'succeeded').map((st) => st.id);
|
|
993
|
+
if (snap.observations.length)
|
|
994
|
+
exec.observations = [...exec.observations, ...snap.observations.map(persistableObservation)];
|
|
995
|
+
exec.callsUsed = priorCalls + snap.callsUsed;
|
|
996
|
+
checkpointIfMoved();
|
|
997
|
+
commit();
|
|
998
|
+
// One executePlan call is over; its spend is now part of the floor for the next one.
|
|
999
|
+
if (snap.at === 'plan-end')
|
|
1000
|
+
priorCalls = exec.callsUsed ?? priorCalls;
|
|
1001
|
+
},
|
|
1002
|
+
onRecord: (record) => {
|
|
1003
|
+
const tasks = (exec.agentTasks ??= []);
|
|
1004
|
+
const at = tasks.findIndex((t) => t.agentTaskId === record.agentTaskId);
|
|
1005
|
+
// TERMINAL is sticky for a TASK too. A late write from an aborted worker must not reopen a task
|
|
1006
|
+
// that already completed, failed or was cancelled.
|
|
1007
|
+
if (at >= 0 && AGENT_TERMINAL.has(tasks[at].state) && !AGENT_TERMINAL.has(record.state))
|
|
1008
|
+
return;
|
|
1009
|
+
// Redacted at the boundary: findings, inner observations and diagnostics all carry text that
|
|
1010
|
+
// came from tools and models, and this record is about to become a durable file.
|
|
1011
|
+
// The inner workspace fingerprint. The OUTER checkpoint cannot stand in for it: `planPaths`
|
|
1012
|
+
// reads the outer plan's step inputs, so files an agent touched through its own inner steps are
|
|
1013
|
+
// invisible to it. Captured here because the worker has no workspace root — it is deliberately
|
|
1014
|
+
// not given one.
|
|
1015
|
+
if (record.innerPlan) {
|
|
1016
|
+
record.innerCheckpoint = captureCheckpoint({ root: this.workspaceRoot, plan: record.innerPlan, skills: this.skills(), completedSteps: record.innerCompletedSteps });
|
|
1017
|
+
}
|
|
1018
|
+
const snapshot = redact({ ...record });
|
|
1019
|
+
if (at >= 0)
|
|
1020
|
+
tasks[at] = snapshot;
|
|
1021
|
+
else
|
|
1022
|
+
tasks.push(snapshot);
|
|
1023
|
+
// A task that FINISHED means its step succeeded. Waiting for the batch commit to record that
|
|
1024
|
+
// leaves a window where a crash finds a `completed` record — which is not resumable, so no
|
|
1025
|
+
// record is offered — and a step not in `completedSteps`, so the agent is simply re-run: a
|
|
1026
|
+
// second paid planning call, the tools fired twice, and two records for one step.
|
|
1027
|
+
if (record.state === 'completed' && !exec.completedSteps.includes(record.stepId)) {
|
|
1028
|
+
exec.completedSteps = [...exec.completedSteps, record.stepId];
|
|
1029
|
+
checkpointIfMoved();
|
|
1030
|
+
}
|
|
1031
|
+
commit();
|
|
1032
|
+
},
|
|
1033
|
+
/**
|
|
1034
|
+
* The terminal write. It goes through the SAME funnel as every mid-run commit, so a pause or a
|
|
1035
|
+
* cancel that landed while the run was finishing is not overwritten by its result: the plain
|
|
1036
|
+
* `commit()` only refuses a live FOREIGN lease, and pause/cancel release the lease as this very
|
|
1037
|
+
* owner — so nothing stopped the final write from resurrecting a cancelled run as `completed`.
|
|
1038
|
+
*/
|
|
1039
|
+
finalize: () => (stopped ? 'terminal' : this._executions.commitProgress(exec)),
|
|
1040
|
+
stopped: () => stopped,
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
883
1043
|
/** Run `fn` while heartbeating the execution lease so a long run never lets the lease expire. */
|
|
884
1044
|
async withHeartbeat(id, fn) {
|
|
885
1045
|
const timer = setInterval(() => this._executions.heartbeat(id), this._executions.heartbeatMs);
|
|
@@ -892,7 +1052,14 @@ export class Runtime {
|
|
|
892
1052
|
clearInterval(timer);
|
|
893
1053
|
}
|
|
894
1054
|
}
|
|
895
|
-
|
|
1055
|
+
/**
|
|
1056
|
+
* Build the OrchestrateInput. The optional half is an OBJECT, not a positional tail: this function
|
|
1057
|
+
* grew to ten parameters and a caller that passed five of them silently got no sink, no signal and no
|
|
1058
|
+
* provenance — a replan that persisted nothing and could not be cancelled. Named fields cannot be
|
|
1059
|
+
* short-counted.
|
|
1060
|
+
*/
|
|
1061
|
+
orchestrateInput(mode, goal, policy, opts = {}) {
|
|
1062
|
+
const { routing, partial, requiredCapabilities, signal, provenance, sink, agentResume, runId } = opts;
|
|
896
1063
|
return {
|
|
897
1064
|
goal,
|
|
898
1065
|
mode,
|
|
@@ -912,7 +1079,16 @@ export class Runtime {
|
|
|
912
1079
|
resolveGaps: (missing) => this.resolveMissingRefs(missing, policy),
|
|
913
1080
|
// Phase 3.4: ONE runner source. `agents`, `runAgent` and `reserve` ride along only when agents are
|
|
914
1081
|
// enabled AND a definition exists, so with the flag off this object is KEY-identical to 2.6.0.
|
|
915
|
-
...this.orchestrateRunners(policy,
|
|
1082
|
+
...this.orchestrateRunners(policy, {
|
|
1083
|
+
runId: runId ?? 'unknown',
|
|
1084
|
+
...(signal ? { signal } : {}),
|
|
1085
|
+
...(provenance ? { provenance } : {}),
|
|
1086
|
+
...(sink?.onRecord ? { onRecord: sink.onRecord } : {}),
|
|
1087
|
+
...(agentResume ? { agentResume } : {}),
|
|
1088
|
+
}),
|
|
1089
|
+
// Phase 3.5: the commit points. Present ONLY when there is a store to commit to, so a stateless
|
|
1090
|
+
// Runtime builds an OrchestrateInput key-identical to 2.7.0.
|
|
1091
|
+
...(sink ? { onPlan: sink.onPlan, onProgress: sink.onProgress } : {}),
|
|
916
1092
|
...(signal ? { signal } : {}),
|
|
917
1093
|
};
|
|
918
1094
|
}
|
|
@@ -1180,6 +1356,13 @@ export class Runtime {
|
|
|
1180
1356
|
return { ok: false, runId, mode: resolution, status: 'failed', response: { text: `cannot resume ${id}: ${acq.reason ?? 'unavailable'}` }, artifacts: [] };
|
|
1181
1357
|
}
|
|
1182
1358
|
const exec = acq.execution;
|
|
1359
|
+
// A resumed run is a live run: pause/cancel must be able to abort it, and a refused commit must be
|
|
1360
|
+
// able to stop it — both of which need a controller registered under this execution's id.
|
|
1361
|
+
const controller = new AbortController();
|
|
1362
|
+
// What was on the record BEFORE this resume. The terminal write rebuilds from here rather than
|
|
1363
|
+
// appending: the sink has already been appending this run's observations as they happened, so
|
|
1364
|
+
// appending the outcome's copy too would store every step of a resumed run twice.
|
|
1365
|
+
const observationsBefore = [...exec.observations];
|
|
1183
1366
|
try {
|
|
1184
1367
|
if (TERMINAL.has(exec.status))
|
|
1185
1368
|
return this.resultFromExecution(exec, resolution, runId);
|
|
@@ -1198,12 +1381,28 @@ export class Runtime {
|
|
|
1198
1381
|
return this.resultFromExecution(exec, resolution, runId);
|
|
1199
1382
|
}
|
|
1200
1383
|
}
|
|
1384
|
+
// An INNER wait needs its answer, exactly as an approval needs a decision. Without this gate the
|
|
1385
|
+
// ordinary `resume-execution <id>` (the CLI makes --answer optional) falls through to the replan
|
|
1386
|
+
// branch, which replaces the plan and orphans every sibling agent's completed work — destroying
|
|
1387
|
+
// progress in the one situation the wait exists to protect.
|
|
1388
|
+
if (exec.pending?.kind === 'clarification' && exec.pending.agentTaskId && !opts.clarificationAnswer) {
|
|
1389
|
+
return this.resultFromExecution(exec, resolution, runId);
|
|
1390
|
+
}
|
|
1391
|
+
// Phase 3.5: whoever was mid-flight when this execution stopped is gone. Put those records back in
|
|
1392
|
+
// `queued` with the reason BEFORE anything is scheduled against them.
|
|
1393
|
+
if (this.reconcileAgentTasks(exec, 'crash') > 0)
|
|
1394
|
+
this._executions.commit(exec);
|
|
1201
1395
|
// Reconcile against the checkpoint — drift forces a replan rather than a blind continue.
|
|
1202
1396
|
const checkpoint = exec.checkpoints[exec.checkpoints.length - 1];
|
|
1203
|
-
const recon = checkpoint ? reconcile(checkpoint, this.workspaceRoot, this.skills()) : { drifted: true, reasons: ['no checkpoint'] };
|
|
1204
|
-
|
|
1397
|
+
const recon = checkpoint ? reconcile(checkpoint, this.workspaceRoot, this.skills(), this.mcpToolHashes(exec.plan)) : { drifted: true, reasons: ['no checkpoint'] };
|
|
1398
|
+
// An INNER answer belongs to one agent task, never to the outer goal: appending it here would
|
|
1399
|
+
// replan the whole plan and discard every sibling agent's completed work.
|
|
1400
|
+
const innerAnswered = exec.pending?.kind === 'clarification' && !!exec.pending.agentTaskId && !!opts.clarificationAnswer;
|
|
1401
|
+
const goal = exec.pending?.kind === 'clarification' && opts.clarificationAnswer && !innerAnswered ? `${exec.goal}\n\nClarification: ${opts.clarificationAnswer}` : exec.goal;
|
|
1205
1402
|
// Re-read the budget from env so raising AI_MAX_CALLS before resuming actually takes effect (Phase 22).
|
|
1206
1403
|
const policy = resolvePolicy({ mode: 'orchestrate', overrides: { autonomy: 'autonomous', approval: 'none', ...(this.configPermissions ? { permissions: this.configPermissions } : {}) }, settings: this.settingsValue, env: { maxCostUsd: numFromEnv(this.env, 'AI_MAX_COST_USD'), maxCalls: numFromEnv(this.env, 'AI_MAX_CALLS') } });
|
|
1404
|
+
if (this.agentsEnabled)
|
|
1405
|
+
this.liveRuns.set(exec.id, { controller });
|
|
1207
1406
|
// A resumed run is never re-DERIVED — no surprise second model call on work already approved and
|
|
1208
1407
|
// mid-flight — but it does get the FREE post-plan check: a grant may have changed since it started.
|
|
1209
1408
|
const planning = this.settingsValue.capabilities?.planning
|
|
@@ -1212,18 +1411,37 @@ export class Runtime {
|
|
|
1212
1411
|
// Continue (skip completed steps) for an approved plan, a budget pause, or partial progress.
|
|
1213
1412
|
const approvedNow = exec.pending?.kind === 'approval';
|
|
1214
1413
|
const budgetPaused = exec.pending?.kind === 'budget';
|
|
1215
|
-
|
|
1414
|
+
// `innerAnswered` is its own arm: the plan is intact and one agent step needs re-running with its
|
|
1415
|
+
// answer. The partial-progress arm cannot cover it — a run where every agent asked before doing
|
|
1416
|
+
// anything has NO completed steps, which is precisely the two-waiting-agents case.
|
|
1417
|
+
const canContinue = !recon.drifted && !!exec.plan && (approvedNow || budgetPaused || innerAnswered || (exec.completedSteps.length > 0 && !exec.pending));
|
|
1418
|
+
const sink = this._executions.enabled ? this.runPersistence(exec, controller) : undefined;
|
|
1216
1419
|
let outcome;
|
|
1217
1420
|
if (canContinue && exec.plan) {
|
|
1218
1421
|
// Continue the plan, skipping completed steps. The call budget is ALWAYS enforced on the continue
|
|
1219
1422
|
// path (whether the pause was approval, budget, or partial-progress) so an approved-but-over-budget
|
|
1220
1423
|
// plan pauses for budget rather than silently exceeding it; a raised budget re-applies here.
|
|
1221
|
-
const
|
|
1424
|
+
const resumeLookup = this.agentResumeLookup(exec, opts.clarificationAnswer);
|
|
1425
|
+
const exe = await this.withHeartbeat(exec.id, () => executePlan(exec.plan, { ...this.orchestrateRunners(policy, { runId, signal: opts.signal ?? controller.signal, provenance: { executionId: exec.id, planVersion: exec.planVersion }, ...(sink?.onRecord ? { onRecord: sink.onRecord } : {}), agentResume: resumeLookup }), ...(sink ? { onProgress: sink.onProgress } : {}), maxParallelSteps: policy.maxParallelSteps ?? 2, ...(policy.limits ? { limits: policy.limits } : {}), skip: new Set(exec.completedSteps), ...(policy.maxCalls !== undefined ? { callBudget: policy.maxCalls } : {}), ...(opts.signal ? { signal: opts.signal } : {}) }));
|
|
1222
1426
|
if (exe.stoppedForBudget) {
|
|
1223
1427
|
const done = exe.plan.steps.filter((s) => s.status === 'succeeded').length;
|
|
1224
1428
|
const total = exe.plan.steps.length;
|
|
1225
1429
|
outcome = { status: 'waiting_for_budget', plan: exe.plan, planHistory: [exe.plan], observations: exe.observations, budget: { estCalls: foldCalls(exe.plan.steps, policy.maxCalls), maxCalls: policy.maxCalls, completedSteps: done, totalSteps: total }, summary: `Ran ${done} of ${total} step(s) within the ${policy.maxCalls}-call budget. Raise the budget (AI_MAX_CALLS) and resume to continue.` };
|
|
1226
1430
|
}
|
|
1431
|
+
else if (exe.waiting?.length) {
|
|
1432
|
+
// Answering one agent can simply reveal the next one. Reporting `failed` here (the pre-3.5
|
|
1433
|
+
// shape, which read `ok` alone) would mark a perfectly resumable run as dead and lie in the
|
|
1434
|
+
// summary while doing it.
|
|
1435
|
+
const asked = exe.observations.find((o) => o.code === 'agent-waiting');
|
|
1436
|
+
outcome = {
|
|
1437
|
+
status: 'waiting_for_clarification',
|
|
1438
|
+
plan: exe.plan,
|
|
1439
|
+
planHistory: [exe.plan],
|
|
1440
|
+
observations: exe.observations,
|
|
1441
|
+
clarification: asked?.error ?? 'an agent needs more information to continue',
|
|
1442
|
+
summary: `Still waiting: ${exe.waiting.length} agent step(s) need an answer.`,
|
|
1443
|
+
};
|
|
1444
|
+
}
|
|
1227
1445
|
else {
|
|
1228
1446
|
outcome = { status: exe.ok ? 'completed' : 'failed', plan: exe.plan, planHistory: [exe.plan], observations: exe.observations, summary: exe.ok ? `resumed and completed "${exec.goal}"` : `resume did not complete "${exec.goal}"` };
|
|
1229
1447
|
}
|
|
@@ -1232,25 +1450,49 @@ export class Runtime {
|
|
|
1232
1450
|
// Drift, or a pending clarification answer, or no partial progress → replan from the goal. Re-resolve
|
|
1233
1451
|
// routing so env/config excludes (and learned prefer) still apply. A budget-paused replan stays
|
|
1234
1452
|
// partial so it keeps running-what-fits instead of reverting to notify-and-wait.
|
|
1235
|
-
|
|
1453
|
+
// Drift replaces the PLAN, not the facts. What the agents proved before the drift is carried
|
|
1454
|
+
// into the new planning context rather than silently orphaned.
|
|
1455
|
+
const brief = this.findingsBrief(exec);
|
|
1456
|
+
// The full argument list. Passing five positionals to a ten-parameter function silently left
|
|
1457
|
+
// this arm with no signal, no provenance, no sink and no resume lookup — so a replan persisted
|
|
1458
|
+
// no agent record AT ALL (onRecord is the only writer of `agentTasks`), could not be paused or
|
|
1459
|
+
// cancelled, and elected `pending` from stale records.
|
|
1460
|
+
outcome = await this.withHeartbeat(exec.id, () => orchestrate(this.orchestrateInput('orchestrate', brief ? `${goal}\n\n${brief}` : goal, policy, {
|
|
1461
|
+
...(this.effectiveRouting() ? { routing: this.effectiveRouting() } : {}),
|
|
1462
|
+
...(budgetPaused ? { partial: true } : {}),
|
|
1463
|
+
...(this.agentsEnabled ? { signal: controller.signal } : {}),
|
|
1464
|
+
provenance: { executionId: exec.id, planVersion: exec.planVersion },
|
|
1465
|
+
...(sink ? { sink } : {}),
|
|
1466
|
+
runId,
|
|
1467
|
+
})));
|
|
1236
1468
|
}
|
|
1237
1469
|
exec.status = this.execStatus(outcome.status);
|
|
1238
|
-
exec.observations = [...
|
|
1470
|
+
exec.observations = [...observationsBefore, ...outcome.observations.map(persistableObservation)];
|
|
1239
1471
|
if (outcome.plan) {
|
|
1240
1472
|
exec.plan = outcome.plan;
|
|
1241
1473
|
exec.planVersion = outcome.plan.version;
|
|
1242
1474
|
exec.completedSteps = outcome.plan.steps.filter((s) => s.status === 'succeeded').map((s) => s.id);
|
|
1243
1475
|
}
|
|
1244
|
-
// Preserve a fresh budget pause; otherwise the pending state is resolved
|
|
1476
|
+
// Preserve a fresh budget pause; otherwise the pending state is resolved — except that answering
|
|
1477
|
+
// one agent's question may simply have revealed the NEXT one (rediscovery: the records are the
|
|
1478
|
+
// source of truth, so a second waiting task is re-elected rather than queued somewhere).
|
|
1245
1479
|
if (outcome.status === 'waiting_for_budget' && outcome.budget)
|
|
1246
1480
|
exec.pending = { kind: 'budget', budget: outcome.budget };
|
|
1481
|
+
else if (outcome.status === 'waiting_for_clarification' && outcome.clarification) {
|
|
1482
|
+
const waiter = this.electWaitingAgent(exec);
|
|
1483
|
+
exec.pending = { kind: 'clarification', question: outcome.clarification, ...(waiter ? { agentTaskId: waiter.agentTaskId } : {}) };
|
|
1484
|
+
}
|
|
1247
1485
|
else
|
|
1248
1486
|
delete exec.pending;
|
|
1249
|
-
exec.checkpoints.push(captureCheckpoint({ root: this.workspaceRoot, ...(exec.plan ? { plan: exec.plan } : {}), skills: this.skills(), completedSteps: exec.completedSteps }));
|
|
1250
|
-
|
|
1487
|
+
exec.checkpoints.push(captureCheckpoint({ root: this.workspaceRoot, ...(exec.plan ? { plan: exec.plan } : {}), skills: this.skills(), completedSteps: exec.completedSteps, mcpTools: this.mcpToolHashes(exec.plan) }));
|
|
1488
|
+
if (sink)
|
|
1489
|
+
sink.finalize();
|
|
1490
|
+
else
|
|
1491
|
+
this._executions.commit(exec);
|
|
1251
1492
|
return this.mapOutcome(outcome, resolution, runId, exec.id, planning);
|
|
1252
1493
|
}
|
|
1253
1494
|
finally {
|
|
1495
|
+
this.liveRuns.delete(exec.id);
|
|
1254
1496
|
this._executions.release(id);
|
|
1255
1497
|
}
|
|
1256
1498
|
}
|
|
@@ -1259,7 +1501,8 @@ export class Runtime {
|
|
|
1259
1501
|
* They used to drift: resume built its own pair with no agent runner, so a persisted plan containing
|
|
1260
1502
|
* an agent step would have failed every one of those steps.
|
|
1261
1503
|
*/
|
|
1262
|
-
orchestrateRunners(policy,
|
|
1504
|
+
orchestrateRunners(policy, opts) {
|
|
1505
|
+
const { runId, signal, provenance, onRecord, agentResume } = opts;
|
|
1263
1506
|
const envelopes = policy ? this.agentEnvelopes(policy) : [];
|
|
1264
1507
|
const byId = new Map(envelopes.map((e) => [e.agentId, e]));
|
|
1265
1508
|
return {
|
|
@@ -1275,7 +1518,11 @@ export class Runtime {
|
|
|
1275
1518
|
if (!envelope || !definition)
|
|
1276
1519
|
return { stepId: step.id, ok: false, code: 'agent-not-enabled', error: `no agent definition '${step.agent ?? ''}'` };
|
|
1277
1520
|
const innerSkills = this._skills.list().filter((sk) => envelope.skills.includes(sk.id));
|
|
1521
|
+
// Phase 3.5: continue a persisted task for THIS step, if one exists.
|
|
1522
|
+
const prior = agentResume?.(step);
|
|
1278
1523
|
const out = await runAgentTask(step, envelope, definition, {
|
|
1524
|
+
...(prior?.record ? { resume: prior.record } : {}),
|
|
1525
|
+
...(prior?.answer ? { resumeAnswer: prior.answer } : {}),
|
|
1279
1526
|
ai: this._ai,
|
|
1280
1527
|
clock: this.clock,
|
|
1281
1528
|
skills: innerSkills,
|
|
@@ -1294,7 +1541,29 @@ export class Runtime {
|
|
|
1294
1541
|
return { unavailable: true };
|
|
1295
1542
|
}
|
|
1296
1543
|
},
|
|
1297
|
-
|
|
1544
|
+
// METADATA ONLY, and ONE BRANCH PER ARM. Building a single object with a union-typed
|
|
1545
|
+
// `type` compiles while carrying properties from every constituent — TypeScript's
|
|
1546
|
+
// excess-property check against a union admits anything present in SOME arm — so
|
|
1547
|
+
// `agent.task.started` was shipping innerSteps/callsUsed/findings it does not declare.
|
|
1548
|
+
// Narrowing to a literal first makes an extra property a compile error again; the
|
|
1549
|
+
// key-set test covers what the type system still cannot.
|
|
1550
|
+
emit: (e) => {
|
|
1551
|
+
const base = { runId, agentTaskId: e.record.agentTaskId, agentId: e.record.agentId, stepId: e.record.stepId };
|
|
1552
|
+
if (e.type === 'agent.task.started') {
|
|
1553
|
+
this.emitter.emit({ type: 'agent.task.started', ...base, state: e.record.state });
|
|
1554
|
+
return;
|
|
1555
|
+
}
|
|
1556
|
+
if (e.type === 'agent.task.progress') {
|
|
1557
|
+
this.emitter.emit({ type: 'agent.task.progress', ...base, innerSteps: e.record.innerSteps, callsUsed: e.record.callsUsed, toolCallsUsed: e.record.toolCallsUsed });
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1560
|
+
// `findings` is a COUNT: a claim is agent-authored text and an event is the one
|
|
1561
|
+
// surface a host may forward anywhere, so no content crosses it.
|
|
1562
|
+
this.emitter.emit({ type: 'agent.task.completed', ...base, state: e.record.state, innerSteps: e.record.innerSteps, callsUsed: e.record.callsUsed, toolCallsUsed: e.record.toolCallsUsed, findings: e.record.findings.length });
|
|
1563
|
+
},
|
|
1564
|
+
...(onRecord ? { onRecord } : {}),
|
|
1565
|
+
registerAbort: (id, abort) => this.liveAgentTasks.set(id, { abort, agentId: envelope.agentId, stepId: step.id, ...(provenance?.executionId ? { executionId: provenance.executionId } : {}) }),
|
|
1566
|
+
releaseAbort: (id) => this.liveAgentTasks.delete(id),
|
|
1298
1567
|
...(ctx.signal ?? signal ? { parentSignal: ctx.signal ?? signal } : {}),
|
|
1299
1568
|
...(provenance ? { provenance } : { provenance: { planVersion: 1 } }),
|
|
1300
1569
|
abortReason: () => {
|
|
@@ -1310,6 +1579,221 @@ export class Runtime {
|
|
|
1310
1579
|
: {}),
|
|
1311
1580
|
};
|
|
1312
1581
|
}
|
|
1582
|
+
/**
|
|
1583
|
+
* A bounded, fenced brief of what the agents have already established (Phase 3.5).
|
|
1584
|
+
*
|
|
1585
|
+
* On drift the plan is thrown away and the goal is replanned — but validated findings are facts about
|
|
1586
|
+
* the WORKSPACE, not about the plan's structure, so discarding them silently would make the run redo
|
|
1587
|
+
* work it had already proved. They are agent-authored text, so they cross a prompt boundary fenced,
|
|
1588
|
+
* exactly like every other untrusted string.
|
|
1589
|
+
*/
|
|
1590
|
+
findingsBrief(exec) {
|
|
1591
|
+
const active = parseAgentTasks(exec)
|
|
1592
|
+
.tasks.flatMap((t) => t.findings)
|
|
1593
|
+
.filter((f) => f.status === 'active')
|
|
1594
|
+
.slice(0, FINDINGS_BRIEF_MAX);
|
|
1595
|
+
if (!active.length)
|
|
1596
|
+
return undefined;
|
|
1597
|
+
const lines = active.map((f) => `- [${flattenClamp(f.type, 40)}] ${flattenClamp(f.subject ?? '', 60)}: ${flattenClamp(f.claim, 160)}`);
|
|
1598
|
+
return wrapUntrusted('prior-findings', `Already established by earlier agent work:\n${lines.join('\n')}`);
|
|
1599
|
+
}
|
|
1600
|
+
/**
|
|
1601
|
+
* Fingerprint the MCP tools a plan actually references (Phase 3.5). An MCP server is remote and
|
|
1602
|
+
* mutable: while a plan sits paused it can change a tool's input schema, change what it does, or drop
|
|
1603
|
+
* it entirely — and the plan would then be resumed against a tool that is no longer the tool it was
|
|
1604
|
+
* planned for. Hashing the live declaration makes that visible as ordinary drift.
|
|
1605
|
+
*
|
|
1606
|
+
* Non-MCP tools are deliberately absent: they are in-tree code covered by the config/skill hashes.
|
|
1607
|
+
*/
|
|
1608
|
+
mcpToolHashes(plan) {
|
|
1609
|
+
const out = {};
|
|
1610
|
+
if (!plan)
|
|
1611
|
+
return out;
|
|
1612
|
+
for (const step of plan.steps) {
|
|
1613
|
+
if (!step.tool || !step.tool.startsWith('mcp:') || out[step.tool])
|
|
1614
|
+
continue;
|
|
1615
|
+
const tool = this._tools.get(step.tool);
|
|
1616
|
+
// A missing tool hashes to a sentinel rather than being skipped — "gone" must be comparable, or a
|
|
1617
|
+
// removed server would silently look like no drift at all.
|
|
1618
|
+
out[step.tool] = tool ? hashOf({ description: tool.description, parameters: tool.parameters ?? null }) : 'absent';
|
|
1619
|
+
}
|
|
1620
|
+
return out;
|
|
1621
|
+
}
|
|
1622
|
+
/**
|
|
1623
|
+
* Crash / pause / cancel reconciliation (Phase 3.5). A record left `running` or `created` describes a
|
|
1624
|
+
* worker that no longer exists — the process died, or the parent stopped it — so it goes back to
|
|
1625
|
+
* `queued` with an auditable reason. An interruption is deliberately NOT a state: the lifecycle does
|
|
1626
|
+
* not grow, only the explanation does.
|
|
1627
|
+
*
|
|
1628
|
+
* This runs on the RESUME path and inside pause/cancel. Resume alone is not enough: `cancelExecution`
|
|
1629
|
+
* writes a terminal status and resume early-returns on terminal, so a cancelled execution's `running`
|
|
1630
|
+
* records would stay `running` on disk forever, unstamped and unexplained.
|
|
1631
|
+
*
|
|
1632
|
+
* Records that fail validation are preserved in place, never dropped — reconciling is not a licence to
|
|
1633
|
+
* delete what this version could not parse.
|
|
1634
|
+
*/
|
|
1635
|
+
reconcileAgentTasks(exec, kind) {
|
|
1636
|
+
const raw = exec.agentTasks;
|
|
1637
|
+
if (!Array.isArray(raw) || raw.length === 0)
|
|
1638
|
+
return 0;
|
|
1639
|
+
const { tasks, dropped } = parseAgentTasks(exec);
|
|
1640
|
+
const now = this.clock.now();
|
|
1641
|
+
const fixed = new Map();
|
|
1642
|
+
for (const t of tasks) {
|
|
1643
|
+
if (t.state !== 'running' && t.state !== 'created')
|
|
1644
|
+
continue;
|
|
1645
|
+
fixed.set(t.agentTaskId, { ...t, state: 'queued', interruption: { kind, at: now }, updatedAt: now });
|
|
1646
|
+
}
|
|
1647
|
+
if (dropped)
|
|
1648
|
+
exec.agentTasksDropped = dropped;
|
|
1649
|
+
if (fixed.size === 0)
|
|
1650
|
+
return 0;
|
|
1651
|
+
exec.agentTasks = raw.map((entry) => fixed.get(entry.agentTaskId ?? '') ?? entry);
|
|
1652
|
+
return fixed.size;
|
|
1653
|
+
}
|
|
1654
|
+
/**
|
|
1655
|
+
* FIRST-WINS PENDING. `Execution.pending` is a single slot but two agents can be waiting at once, so
|
|
1656
|
+
* the earliest-created waiting task claims it. The others are not lost: once this one is answered and
|
|
1657
|
+
* the run continues, the next resume re-elects whichever task is still waiting — rediscovery, rather
|
|
1658
|
+
* than a queue that has to be kept in sync with the records that are already the source of truth.
|
|
1659
|
+
*/
|
|
1660
|
+
electWaitingAgent(exec) {
|
|
1661
|
+
return parseAgentTasks(exec).tasks.find((t) => t.state === 'waiting_for_clarification' && t.pendingInner);
|
|
1662
|
+
}
|
|
1663
|
+
/**
|
|
1664
|
+
* Which persisted task, if any, a plan step should CONTINUE. Bound by step-input hash, never by step
|
|
1665
|
+
* id: plan step ids (`s1`, `auto1`) are model-authored and recur across replans, so an id match would
|
|
1666
|
+
* hand one step's completed inner work to a different step with the same id and a different input.
|
|
1667
|
+
*/
|
|
1668
|
+
agentResumeLookup(exec, answer) {
|
|
1669
|
+
const tasks = parseAgentTasks(exec).tasks.filter((t) => AGENT_RESUMABLE.has(t.state));
|
|
1670
|
+
const answeringId = exec.pending?.agentTaskId;
|
|
1671
|
+
// A plan may legitimately contain two steps with identical identity (same agent, same input) — a
|
|
1672
|
+
// retry, or genuinely duplicated work. Without claiming, both would resolve to the SAME record and
|
|
1673
|
+
// the second would resume, and then overwrite, the first's inner work.
|
|
1674
|
+
const claimed = new Set();
|
|
1675
|
+
return (step) => {
|
|
1676
|
+
if (!step.agent)
|
|
1677
|
+
return undefined;
|
|
1678
|
+
const hash = stepIdentity(step);
|
|
1679
|
+
const record = tasks.find((t) => t.agentId === step.agent && t.stepInputHash === hash && !claimed.has(t.agentTaskId));
|
|
1680
|
+
if (!record)
|
|
1681
|
+
return undefined;
|
|
1682
|
+
claimed.add(record.agentTaskId);
|
|
1683
|
+
// If the files this agent worked on changed while the run was stopped, its completed inner steps
|
|
1684
|
+
// are no longer safe to skip — the same rule the outer plan already lives by.
|
|
1685
|
+
if (record.innerCheckpoint && reconcile(record.innerCheckpoint, this.workspaceRoot, this.skills()).drifted) {
|
|
1686
|
+
const { innerPlan: _drop, ...rest } = record;
|
|
1687
|
+
return { record: { ...rest, innerCompletedSteps: [], innerObservations: [], innerObservationsOmitted: 0 } };
|
|
1688
|
+
}
|
|
1689
|
+
// The answer belongs to exactly ONE task — the one that asked.
|
|
1690
|
+
return answer && record.agentTaskId === answeringId ? { record, answer } : { record };
|
|
1691
|
+
};
|
|
1692
|
+
}
|
|
1693
|
+
/**
|
|
1694
|
+
* Every agent task this Runtime can see, newest execution first (Phase 3.6). Live ones (running in
|
|
1695
|
+
* this process) and persisted ones are the same list: a task's record IS its status, so there is no
|
|
1696
|
+
* second source to disagree with.
|
|
1697
|
+
*/
|
|
1698
|
+
agentTasks(executionId) {
|
|
1699
|
+
const executions = executionId ? this._executions.list().filter((e) => e.id === executionId) : this._executions.list();
|
|
1700
|
+
return executions.flatMap((e) => parseAgentTasks(e).tasks.map(agentTaskView));
|
|
1701
|
+
}
|
|
1702
|
+
/**
|
|
1703
|
+
* Stop one agent task. Four cases, and NONE of them is a silent no-op — a `stop` that appears to do
|
|
1704
|
+
* nothing is indistinguishable from a bug:
|
|
1705
|
+
*
|
|
1706
|
+
* - running in this process: abort it, then let the worker record `cancelled` as it unwinds;
|
|
1707
|
+
* - persisted and not finished (another process, or a dead one): write `cancelled` with a
|
|
1708
|
+
* `parent-cancel` interruption, so the record stops claiming it is queued or running;
|
|
1709
|
+
* - already finished: report that, and change nothing — a terminal state is sticky;
|
|
1710
|
+
* - unknown id: say so.
|
|
1711
|
+
*/
|
|
1712
|
+
stopAgentTask(agentTaskId) {
|
|
1713
|
+
const live = this.liveAgentTasks.get(agentTaskId);
|
|
1714
|
+
if (live) {
|
|
1715
|
+
live.abort();
|
|
1716
|
+
// Deliberately no `state`: the abort is cooperative and the worker has not unwound yet, so
|
|
1717
|
+
// claiming `cancelled` here would report a state that has not happened.
|
|
1718
|
+
return { ok: true, reason: 'aborted a task running in this process' };
|
|
1719
|
+
}
|
|
1720
|
+
const owning = this._executions.list().find((e) => parseAgentTasks(e).tasks.some((t) => t.agentTaskId === agentTaskId));
|
|
1721
|
+
if (!owning)
|
|
1722
|
+
return { ok: false, reason: 'no such agent task' };
|
|
1723
|
+
// A run this process is executing owns its own record. Writing it from the side would fight the
|
|
1724
|
+
// run's sink, and TAKING ITS LEASE would be worse: the sink refuses a leaseless commit and treats
|
|
1725
|
+
// the refusal as a stop signal, so releasing here would abort the entire run to stop one task.
|
|
1726
|
+
if (this.liveRuns.has(owning.id)) {
|
|
1727
|
+
return { ok: false, reason: `that task belongs to a run in flight — /cancel ${owning.id} stops the whole run` };
|
|
1728
|
+
}
|
|
1729
|
+
// Claim the execution properly. A live lease held elsewhere means another process is mid-run: its
|
|
1730
|
+
// next commit would rewrite the file from its own memory and silently drop this edit, so refuse
|
|
1731
|
+
// rather than pretend.
|
|
1732
|
+
const acq = this._executions.acquire(owning.id);
|
|
1733
|
+
if (!acq.ok || !acq.execution)
|
|
1734
|
+
return { ok: false, reason: `the execution is busy elsewhere (${acq.reason ?? 'unavailable'})` };
|
|
1735
|
+
const exec = acq.execution;
|
|
1736
|
+
try {
|
|
1737
|
+
// Re-read EVERYTHING from the claimed copy. The listing was a snapshot: the task may have
|
|
1738
|
+
// finished since, and writing the snapshot back would revert its own counters and findings.
|
|
1739
|
+
const fresh = parseAgentTasks(exec).tasks.find((t) => t.agentTaskId === agentTaskId);
|
|
1740
|
+
if (!fresh)
|
|
1741
|
+
return { ok: false, reason: 'the task is no longer on the record' };
|
|
1742
|
+
if (AGENT_TERMINAL.has(fresh.state))
|
|
1743
|
+
return { ok: false, state: fresh.state, reason: `already ${fresh.state}` };
|
|
1744
|
+
const now = this.clock.now();
|
|
1745
|
+
const stopped = { ...fresh, state: 'cancelled', interruption: { kind: 'parent-cancel', at: now }, endedAt: now, updatedAt: now };
|
|
1746
|
+
// A cancelled task is not asking anything any more; leaving the question on it would keep
|
|
1747
|
+
// advertising a prompt nobody can answer.
|
|
1748
|
+
delete stopped.pendingInner;
|
|
1749
|
+
exec.agentTasks = (exec.agentTasks ?? []).map((entry) => (entry.agentTaskId === agentTaskId ? stopped : entry));
|
|
1750
|
+
// WITHOUT THIS THE COMMAND IS COSMETIC. `AGENT_RESUMABLE` excludes `cancelled`, so a resume
|
|
1751
|
+
// offers no record for this step and the executor runs the agent again from scratch — a second
|
|
1752
|
+
// paid planning call and the tools fired again, after a human asked it to stop.
|
|
1753
|
+
//
|
|
1754
|
+
// But mark the step done only when the CURRENT plan still contains that exact step. Step ids
|
|
1755
|
+
// recur across replans, so an id from a retired plan could name a completely different step,
|
|
1756
|
+
// and marking it done would silently skip work that was never even started.
|
|
1757
|
+
const stepStillThere = exec.plan?.steps.some((st) => st.id === stopped.stepId && stepIdentity(st) === stopped.stepInputHash);
|
|
1758
|
+
if (stepStillThere && !exec.completedSteps.includes(stopped.stepId))
|
|
1759
|
+
exec.completedSteps = [...exec.completedSteps, stopped.stepId];
|
|
1760
|
+
// A stopped task cannot answer, so a pending wait belonging to it would strand the execution:
|
|
1761
|
+
// resume early-returns on an unanswered inner clarification, and re-election only considers
|
|
1762
|
+
// tasks that are still waiting. Hand the slot to another waiter, or clear it.
|
|
1763
|
+
if (exec.pending?.kind === 'clarification' && exec.pending.agentTaskId === agentTaskId) {
|
|
1764
|
+
const next = this.electWaitingAgent(exec);
|
|
1765
|
+
if (next?.pendingInner)
|
|
1766
|
+
exec.pending = { kind: 'clarification', question: next.pendingInner.question, agentTaskId: next.agentTaskId };
|
|
1767
|
+
else
|
|
1768
|
+
delete exec.pending;
|
|
1769
|
+
}
|
|
1770
|
+
// `commit`, not `commitProgress`. The funnel's rules exist to stop an IN-FLIGHT RUN overwriting
|
|
1771
|
+
// a decision made while it was finishing — terminal is sticky, a pause is not the runner's to
|
|
1772
|
+
// erase. This is not a run: it holds the lease it just acquired, it changes one task record, and
|
|
1773
|
+
// it never touches `exec.status`, so a completed execution stays completed. Refusing here would
|
|
1774
|
+
// instead make a stale non-terminal task on a finished execution permanently unstoppable.
|
|
1775
|
+
if (!this._executions.commit(exec))
|
|
1776
|
+
return { ok: false, reason: 'another owner holds this execution' };
|
|
1777
|
+
this.emitter.emit({
|
|
1778
|
+
type: 'agent.task.completed',
|
|
1779
|
+
// A stop is out-of-band: it belongs to no run, so this id identifies the OPERATION. It is
|
|
1780
|
+
// deliberately fresh rather than an execution id borrowed to look like a run id.
|
|
1781
|
+
runId: nextRunId(),
|
|
1782
|
+
agentTaskId,
|
|
1783
|
+
agentId: stopped.agentId,
|
|
1784
|
+
stepId: stopped.stepId,
|
|
1785
|
+
state: 'cancelled',
|
|
1786
|
+
innerSteps: stopped.innerSteps,
|
|
1787
|
+
callsUsed: stopped.callsUsed,
|
|
1788
|
+
toolCallsUsed: stopped.toolCallsUsed,
|
|
1789
|
+
findings: stopped.findings.length,
|
|
1790
|
+
});
|
|
1791
|
+
return { ok: true, state: 'cancelled', reason: stepStillThere ? 'marked a persisted task cancelled' : 'marked a persisted task cancelled (its step is no longer in the plan)' };
|
|
1792
|
+
}
|
|
1793
|
+
finally {
|
|
1794
|
+
this._executions.release(owning.id);
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1313
1797
|
/** Abort a run that is in flight, recording WHY so a task can tell a pause from a cancellation. */
|
|
1314
1798
|
abortLiveRun(id, reason) {
|
|
1315
1799
|
const live = this.liveRuns.get(id);
|
|
@@ -1327,6 +1811,9 @@ export class Runtime {
|
|
|
1327
1811
|
// Phase 3.4: abort whatever is actually in flight, so a pause stops live agent work instead of only
|
|
1328
1812
|
// flipping a stored status. A no-op with agents disabled — `liveRuns` is empty then.
|
|
1329
1813
|
this.abortLiveRun(id, 'pause');
|
|
1814
|
+
// Phase 3.5: stamp whatever was mid-flight. Doing it here rather than only on resume is what stops a
|
|
1815
|
+
// record sitting at `running` with no worker behind it.
|
|
1816
|
+
this.reconcileAgentTasks(exec, 'pause');
|
|
1330
1817
|
this._executions.save(exec);
|
|
1331
1818
|
this._executions.release(id);
|
|
1332
1819
|
return true;
|
|
@@ -1338,6 +1825,9 @@ export class Runtime {
|
|
|
1338
1825
|
return false;
|
|
1339
1826
|
exec.status = 'cancelled';
|
|
1340
1827
|
this.abortLiveRun(id, 'parent-cancel'); // parent cancelled ⇒ every live agent controller aborted
|
|
1828
|
+
// Cancel is TERMINAL and resume early-returns on terminal, so if this did not reconcile here those
|
|
1829
|
+
// `running` records would stay `running` on disk forever, unstamped and unexplained.
|
|
1830
|
+
this.reconcileAgentTasks(exec, 'parent-cancel');
|
|
1341
1831
|
this._executions.save(exec);
|
|
1342
1832
|
this._executions.release(id);
|
|
1343
1833
|
return true;
|