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.
@@ -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, routing, partial, planning?.block, this.agentsEnabled ? controller.signal : undefined, { planVersion: 1 }));
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
- const outcome = await this.withHeartbeat(exec.id, () => orchestrate(this.orchestrateInput(mode, effectiveGoal, policy, routing, partial, planning?.block, this.agentsEnabled ? controller.signal : undefined, { executionId: exec.id, planVersion: exec.planVersion })));
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
- exec.pending = { kind: 'clarification', question: outcome.clarification };
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
- this._executions.commit(exec); // ownership-checked: never clobber a newer owner
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
- orchestrateInput(mode, goal, policy, routing, partial, requiredCapabilities, signal, provenance) {
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, signal, provenance),
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
- const goal = exec.pending?.kind === 'clarification' && opts.clarificationAnswer ? `${exec.goal}\n\nClarification: ${opts.clarificationAnswer}` : exec.goal;
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
- const canContinue = !recon.drifted && !!exec.plan && (approvedNow || budgetPaused || (exec.completedSteps.length > 0 && !exec.pending));
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 exe = await this.withHeartbeat(exec.id, () => executePlan(exec.plan, { ...this.orchestrateRunners(policy, opts.signal, { executionId: exec.id, planVersion: exec.planVersion }), 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 } : {}) }));
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
- outcome = await this.withHeartbeat(exec.id, () => orchestrate(this.orchestrateInput('orchestrate', goal, policy, this.effectiveRouting(), budgetPaused)));
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 = [...exec.observations, ...outcome.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
- this._executions.commit(exec);
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, signal, provenance) {
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
- emit: (e) => this.emitter.emit({ type: e.type, agentTaskId: e.record.agentTaskId, agentId: e.record.agentId, stepId: e.record.stepId, state: e.record.state, innerSteps: e.record.innerSteps, callsUsed: e.record.callsUsed, toolCallsUsed: e.record.toolCallsUsed, findings: e.record.findings.length }),
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;