ai-runtime-engine 2.7.0 → 2.8.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 } 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';
@@ -855,9 +885,11 @@ export class Runtime {
855
885
  // Renew the lease while the (possibly long) run is in flight so it can't expire mid-run.
856
886
  if (this.agentsEnabled)
857
887
  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 })));
888
+ // Phase 3.5: only when there is a store to write to a stateless run keeps the 2.7.0 shape.
889
+ const sink = this._executions.enabled ? this.runPersistence(exec, controller) : undefined;
890
+ 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 }, sink)));
859
891
  exec.status = this.execStatus(outcome.status);
860
- exec.observations = outcome.observations;
892
+ exec.observations = outcome.observations.map(persistableObservation);
861
893
  if (outcome.plan) {
862
894
  exec.plan = outcome.plan;
863
895
  exec.planVersion = outcome.plan.version;
@@ -865,12 +897,19 @@ export class Runtime {
865
897
  }
866
898
  if (outcome.status === 'waiting_for_approval')
867
899
  exec.pending = { kind: 'approval', action: goal };
868
- else if (outcome.status === 'waiting_for_clarification' && outcome.clarification)
869
- exec.pending = { kind: 'clarification', question: outcome.clarification };
900
+ else if (outcome.status === 'waiting_for_clarification' && outcome.clarification) {
901
+ // First-wins: if an AGENT is what is waiting, the pending slot records which one, so the answer
902
+ // is routed into that task's inner resume instead of being appended to the outer goal.
903
+ const waiter = this.electWaitingAgent(exec);
904
+ exec.pending = { kind: 'clarification', question: outcome.clarification, ...(waiter ? { agentTaskId: waiter.agentTaskId } : {}) };
905
+ }
870
906
  else if (outcome.status === 'waiting_for_budget' && outcome.budget)
871
907
  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
908
+ exec.checkpoints.push(captureCheckpoint({ root: this.workspaceRoot, ...(exec.plan ? { plan: exec.plan } : {}), skills: this.skills(), completedSteps: exec.completedSteps, mcpTools: this.mcpToolHashes(exec.plan) }));
909
+ if (sink)
910
+ sink.finalize();
911
+ else
912
+ this._executions.commit(exec); // ownership-checked: never clobber a newer owner
874
913
  this.recordOrchestration(mode, goal, outcome);
875
914
  return this.mapOutcome(outcome, resolution, runId, exec.id, planning);
876
915
  }
@@ -880,6 +919,110 @@ export class Runtime {
880
919
  this._executions.release(exec.id);
881
920
  }
882
921
  }
922
+ /**
923
+ * THE mid-run persistence sink (Phase 3.5) — the only thing that writes an execution while it runs.
924
+ *
925
+ * Invariant 18 says everything needed for resume is on disk before the next wave starts, and the
926
+ * non-obvious part is WHAT that includes. Committing the plan, the completed steps and the agent
927
+ * records is not enough: the resume gate is `!recon.drifted && !!exec.plan`, and `recon` defaults to
928
+ * DRIFTED whenever `checkpoints` is empty. Since checkpoints were captured only after orchestration
929
+ * returned, a crash mid-run always drifted, always replanned, and re-ran every completed agent task —
930
+ * the exact thing this phase exists to prevent. So the sink captures a checkpoint too.
931
+ *
932
+ * Every refusal from the funnel ABORTS the run. A refused commit means another owner now owns this
933
+ * execution's fate; carrying on would call the same tools and burn the same model calls twice while
934
+ * that owner re-runs the identical steps, and every result would be discarded at the end anyway.
935
+ */
936
+ runPersistence(exec, controller) {
937
+ // Fixed for each executePlan call: its `callsUsed` is cumulative for that call, so the pool must be
938
+ // `priorCalls + thisCall`, and `priorCalls` advances only when the call ends. Adding a per-fire delta
939
+ // instead would let the total go BACKWARDS across replan iterations and over-grant the resume pool.
940
+ let priorCalls = exec.callsUsed ?? 0;
941
+ let lastCheckpointed = '';
942
+ let stopped = false;
943
+ const commit = () => {
944
+ if (stopped)
945
+ return;
946
+ const outcome = this._executions.commitProgress(exec);
947
+ if (outcome === 'ok')
948
+ return;
949
+ // Stop trying to write, and stop the run itself.
950
+ stopped = true;
951
+ this.abortLiveRun(exec.id, outcome === 'paused' ? 'pause' : 'parent-cancel');
952
+ controller.abort();
953
+ };
954
+ /** Capture only when the completed set moved: `captureCheckpoint` hashes files and shells git. */
955
+ const checkpointIfMoved = () => {
956
+ const key = exec.completedSteps.join('\u0000');
957
+ if (key === lastCheckpointed)
958
+ return;
959
+ lastCheckpointed = key;
960
+ exec.checkpoints.push(captureCheckpoint({ root: this.workspaceRoot, ...(exec.plan ? { plan: exec.plan } : {}), skills: this.skills(), completedSteps: exec.completedSteps, mcpTools: this.mcpToolHashes(exec.plan) }));
961
+ if (exec.checkpoints.length > CHECKPOINTS_MAX)
962
+ exec.checkpoints.splice(0, exec.checkpoints.length - CHECKPOINTS_MAX);
963
+ };
964
+ return {
965
+ /** The plan is settled and nothing has run: a crash in wave 1 must still resume against a plan. */
966
+ onPlan: (plan) => {
967
+ exec.plan = plan;
968
+ exec.planVersion = plan.version;
969
+ exec.completedSteps = plan.steps.filter((st) => st.status === 'succeeded').map((st) => st.id);
970
+ checkpointIfMoved();
971
+ commit();
972
+ },
973
+ onProgress: (snap) => {
974
+ exec.plan = snap.plan;
975
+ exec.completedSteps = snap.plan.steps.filter((st) => st.status === 'succeeded').map((st) => st.id);
976
+ if (snap.observations.length)
977
+ exec.observations = [...exec.observations, ...snap.observations.map(persistableObservation)];
978
+ exec.callsUsed = priorCalls + snap.callsUsed;
979
+ checkpointIfMoved();
980
+ commit();
981
+ // One executePlan call is over; its spend is now part of the floor for the next one.
982
+ if (snap.at === 'plan-end')
983
+ priorCalls = exec.callsUsed ?? priorCalls;
984
+ },
985
+ onRecord: (record) => {
986
+ const tasks = (exec.agentTasks ??= []);
987
+ const at = tasks.findIndex((t) => t.agentTaskId === record.agentTaskId);
988
+ // TERMINAL is sticky for a TASK too. A late write from an aborted worker must not reopen a task
989
+ // that already completed, failed or was cancelled.
990
+ if (at >= 0 && AGENT_TERMINAL.has(tasks[at].state) && !AGENT_TERMINAL.has(record.state))
991
+ return;
992
+ // Redacted at the boundary: findings, inner observations and diagnostics all carry text that
993
+ // came from tools and models, and this record is about to become a durable file.
994
+ // The inner workspace fingerprint. The OUTER checkpoint cannot stand in for it: `planPaths`
995
+ // reads the outer plan's step inputs, so files an agent touched through its own inner steps are
996
+ // invisible to it. Captured here because the worker has no workspace root — it is deliberately
997
+ // not given one.
998
+ if (record.innerPlan) {
999
+ record.innerCheckpoint = captureCheckpoint({ root: this.workspaceRoot, plan: record.innerPlan, skills: this.skills(), completedSteps: record.innerCompletedSteps });
1000
+ }
1001
+ const snapshot = redact({ ...record });
1002
+ if (at >= 0)
1003
+ tasks[at] = snapshot;
1004
+ else
1005
+ tasks.push(snapshot);
1006
+ // A task that FINISHED means its step succeeded. Waiting for the batch commit to record that
1007
+ // leaves a window where a crash finds a `completed` record — which is not resumable, so no
1008
+ // record is offered — and a step not in `completedSteps`, so the agent is simply re-run: a
1009
+ // second paid planning call, the tools fired twice, and two records for one step.
1010
+ if (record.state === 'completed' && !exec.completedSteps.includes(record.stepId)) {
1011
+ exec.completedSteps = [...exec.completedSteps, record.stepId];
1012
+ checkpointIfMoved();
1013
+ }
1014
+ commit();
1015
+ },
1016
+ /**
1017
+ * The terminal write. It goes through the SAME funnel as every mid-run commit, so a pause or a
1018
+ * cancel that landed while the run was finishing is not overwritten by its result: the plain
1019
+ * `commit()` only refuses a live FOREIGN lease, and pause/cancel release the lease as this very
1020
+ * owner — so nothing stopped the final write from resurrecting a cancelled run as `completed`.
1021
+ */
1022
+ finalize: () => (stopped ? 'terminal' : this._executions.commitProgress(exec)),
1023
+ stopped: () => stopped,
1024
+ };
1025
+ }
883
1026
  /** Run `fn` while heartbeating the execution lease so a long run never lets the lease expire. */
884
1027
  async withHeartbeat(id, fn) {
885
1028
  const timer = setInterval(() => this._executions.heartbeat(id), this._executions.heartbeatMs);
@@ -892,7 +1035,7 @@ export class Runtime {
892
1035
  clearInterval(timer);
893
1036
  }
894
1037
  }
895
- orchestrateInput(mode, goal, policy, routing, partial, requiredCapabilities, signal, provenance) {
1038
+ orchestrateInput(mode, goal, policy, routing, partial, requiredCapabilities, signal, provenance, sink, agentResume) {
896
1039
  return {
897
1040
  goal,
898
1041
  mode,
@@ -912,7 +1055,10 @@ export class Runtime {
912
1055
  resolveGaps: (missing) => this.resolveMissingRefs(missing, policy),
913
1056
  // Phase 3.4: ONE runner source. `agents`, `runAgent` and `reserve` ride along only when agents are
914
1057
  // 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),
1058
+ ...this.orchestrateRunners(policy, signal, provenance, sink?.onRecord, agentResume),
1059
+ // Phase 3.5: the commit points. Present ONLY when there is a store to commit to, so a stateless
1060
+ // Runtime builds an OrchestrateInput key-identical to 2.7.0.
1061
+ ...(sink ? { onPlan: sink.onPlan, onProgress: sink.onProgress } : {}),
916
1062
  ...(signal ? { signal } : {}),
917
1063
  };
918
1064
  }
@@ -1180,6 +1326,13 @@ export class Runtime {
1180
1326
  return { ok: false, runId, mode: resolution, status: 'failed', response: { text: `cannot resume ${id}: ${acq.reason ?? 'unavailable'}` }, artifacts: [] };
1181
1327
  }
1182
1328
  const exec = acq.execution;
1329
+ // A resumed run is a live run: pause/cancel must be able to abort it, and a refused commit must be
1330
+ // able to stop it — both of which need a controller registered under this execution's id.
1331
+ const controller = new AbortController();
1332
+ // What was on the record BEFORE this resume. The terminal write rebuilds from here rather than
1333
+ // appending: the sink has already been appending this run's observations as they happened, so
1334
+ // appending the outcome's copy too would store every step of a resumed run twice.
1335
+ const observationsBefore = [...exec.observations];
1183
1336
  try {
1184
1337
  if (TERMINAL.has(exec.status))
1185
1338
  return this.resultFromExecution(exec, resolution, runId);
@@ -1198,12 +1351,28 @@ export class Runtime {
1198
1351
  return this.resultFromExecution(exec, resolution, runId);
1199
1352
  }
1200
1353
  }
1354
+ // An INNER wait needs its answer, exactly as an approval needs a decision. Without this gate the
1355
+ // ordinary `resume-execution <id>` (the CLI makes --answer optional) falls through to the replan
1356
+ // branch, which replaces the plan and orphans every sibling agent's completed work — destroying
1357
+ // progress in the one situation the wait exists to protect.
1358
+ if (exec.pending?.kind === 'clarification' && exec.pending.agentTaskId && !opts.clarificationAnswer) {
1359
+ return this.resultFromExecution(exec, resolution, runId);
1360
+ }
1361
+ // Phase 3.5: whoever was mid-flight when this execution stopped is gone. Put those records back in
1362
+ // `queued` with the reason BEFORE anything is scheduled against them.
1363
+ if (this.reconcileAgentTasks(exec, 'crash') > 0)
1364
+ this._executions.commit(exec);
1201
1365
  // Reconcile against the checkpoint — drift forces a replan rather than a blind continue.
1202
1366
  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;
1367
+ const recon = checkpoint ? reconcile(checkpoint, this.workspaceRoot, this.skills(), this.mcpToolHashes(exec.plan)) : { drifted: true, reasons: ['no checkpoint'] };
1368
+ // An INNER answer belongs to one agent task, never to the outer goal: appending it here would
1369
+ // replan the whole plan and discard every sibling agent's completed work.
1370
+ const innerAnswered = exec.pending?.kind === 'clarification' && !!exec.pending.agentTaskId && !!opts.clarificationAnswer;
1371
+ const goal = exec.pending?.kind === 'clarification' && opts.clarificationAnswer && !innerAnswered ? `${exec.goal}\n\nClarification: ${opts.clarificationAnswer}` : exec.goal;
1205
1372
  // Re-read the budget from env so raising AI_MAX_CALLS before resuming actually takes effect (Phase 22).
1206
1373
  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') } });
1374
+ if (this.agentsEnabled)
1375
+ this.liveRuns.set(exec.id, { controller });
1207
1376
  // A resumed run is never re-DERIVED — no surprise second model call on work already approved and
1208
1377
  // mid-flight — but it does get the FREE post-plan check: a grant may have changed since it started.
1209
1378
  const planning = this.settingsValue.capabilities?.planning
@@ -1212,18 +1381,37 @@ export class Runtime {
1212
1381
  // Continue (skip completed steps) for an approved plan, a budget pause, or partial progress.
1213
1382
  const approvedNow = exec.pending?.kind === 'approval';
1214
1383
  const budgetPaused = exec.pending?.kind === 'budget';
1215
- const canContinue = !recon.drifted && !!exec.plan && (approvedNow || budgetPaused || (exec.completedSteps.length > 0 && !exec.pending));
1384
+ // `innerAnswered` is its own arm: the plan is intact and one agent step needs re-running with its
1385
+ // answer. The partial-progress arm cannot cover it — a run where every agent asked before doing
1386
+ // anything has NO completed steps, which is precisely the two-waiting-agents case.
1387
+ const canContinue = !recon.drifted && !!exec.plan && (approvedNow || budgetPaused || innerAnswered || (exec.completedSteps.length > 0 && !exec.pending));
1388
+ const sink = this._executions.enabled ? this.runPersistence(exec, controller) : undefined;
1216
1389
  let outcome;
1217
1390
  if (canContinue && exec.plan) {
1218
1391
  // Continue the plan, skipping completed steps. The call budget is ALWAYS enforced on the continue
1219
1392
  // path (whether the pause was approval, budget, or partial-progress) so an approved-but-over-budget
1220
1393
  // 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 } : {}) }));
1394
+ const resumeLookup = this.agentResumeLookup(exec, opts.clarificationAnswer);
1395
+ const exe = await this.withHeartbeat(exec.id, () => executePlan(exec.plan, { ...this.orchestrateRunners(policy, opts.signal ?? controller.signal, { executionId: exec.id, planVersion: exec.planVersion }, sink?.onRecord, 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
1396
  if (exe.stoppedForBudget) {
1223
1397
  const done = exe.plan.steps.filter((s) => s.status === 'succeeded').length;
1224
1398
  const total = exe.plan.steps.length;
1225
1399
  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
1400
  }
1401
+ else if (exe.waiting?.length) {
1402
+ // Answering one agent can simply reveal the next one. Reporting `failed` here (the pre-3.5
1403
+ // shape, which read `ok` alone) would mark a perfectly resumable run as dead and lie in the
1404
+ // summary while doing it.
1405
+ const asked = exe.observations.find((o) => o.code === 'agent-waiting');
1406
+ outcome = {
1407
+ status: 'waiting_for_clarification',
1408
+ plan: exe.plan,
1409
+ planHistory: [exe.plan],
1410
+ observations: exe.observations,
1411
+ clarification: asked?.error ?? 'an agent needs more information to continue',
1412
+ summary: `Still waiting: ${exe.waiting.length} agent step(s) need an answer.`,
1413
+ };
1414
+ }
1227
1415
  else {
1228
1416
  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
1417
  }
@@ -1232,25 +1420,42 @@ export class Runtime {
1232
1420
  // Drift, or a pending clarification answer, or no partial progress → replan from the goal. Re-resolve
1233
1421
  // routing so env/config excludes (and learned prefer) still apply. A budget-paused replan stays
1234
1422
  // 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)));
1423
+ // Drift replaces the PLAN, not the facts. What the agents proved before the drift is carried
1424
+ // into the new planning context rather than silently orphaned.
1425
+ const brief = this.findingsBrief(exec);
1426
+ // The full argument list. Passing five positionals to a ten-parameter function silently left
1427
+ // this arm with no signal, no provenance, no sink and no resume lookup — so a replan persisted
1428
+ // no agent record AT ALL (onRecord is the only writer of `agentTasks`), could not be paused or
1429
+ // cancelled, and elected `pending` from stale records.
1430
+ outcome = await this.withHeartbeat(exec.id, () => orchestrate(this.orchestrateInput('orchestrate', brief ? `${goal}\n\n${brief}` : goal, policy, this.effectiveRouting(), budgetPaused, undefined, this.agentsEnabled ? controller.signal : undefined, { executionId: exec.id, planVersion: exec.planVersion }, sink)));
1236
1431
  }
1237
1432
  exec.status = this.execStatus(outcome.status);
1238
- exec.observations = [...exec.observations, ...outcome.observations];
1433
+ exec.observations = [...observationsBefore, ...outcome.observations.map(persistableObservation)];
1239
1434
  if (outcome.plan) {
1240
1435
  exec.plan = outcome.plan;
1241
1436
  exec.planVersion = outcome.plan.version;
1242
1437
  exec.completedSteps = outcome.plan.steps.filter((s) => s.status === 'succeeded').map((s) => s.id);
1243
1438
  }
1244
- // Preserve a fresh budget pause; otherwise the pending state is resolved.
1439
+ // Preserve a fresh budget pause; otherwise the pending state is resolved — except that answering
1440
+ // one agent's question may simply have revealed the NEXT one (rediscovery: the records are the
1441
+ // source of truth, so a second waiting task is re-elected rather than queued somewhere).
1245
1442
  if (outcome.status === 'waiting_for_budget' && outcome.budget)
1246
1443
  exec.pending = { kind: 'budget', budget: outcome.budget };
1444
+ else if (outcome.status === 'waiting_for_clarification' && outcome.clarification) {
1445
+ const waiter = this.electWaitingAgent(exec);
1446
+ exec.pending = { kind: 'clarification', question: outcome.clarification, ...(waiter ? { agentTaskId: waiter.agentTaskId } : {}) };
1447
+ }
1247
1448
  else
1248
1449
  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);
1450
+ exec.checkpoints.push(captureCheckpoint({ root: this.workspaceRoot, ...(exec.plan ? { plan: exec.plan } : {}), skills: this.skills(), completedSteps: exec.completedSteps, mcpTools: this.mcpToolHashes(exec.plan) }));
1451
+ if (sink)
1452
+ sink.finalize();
1453
+ else
1454
+ this._executions.commit(exec);
1251
1455
  return this.mapOutcome(outcome, resolution, runId, exec.id, planning);
1252
1456
  }
1253
1457
  finally {
1458
+ this.liveRuns.delete(exec.id);
1254
1459
  this._executions.release(id);
1255
1460
  }
1256
1461
  }
@@ -1259,7 +1464,7 @@ export class Runtime {
1259
1464
  * They used to drift: resume built its own pair with no agent runner, so a persisted plan containing
1260
1465
  * an agent step would have failed every one of those steps.
1261
1466
  */
1262
- orchestrateRunners(policy, signal, provenance) {
1467
+ orchestrateRunners(policy, signal, provenance, onRecord, agentResume) {
1263
1468
  const envelopes = policy ? this.agentEnvelopes(policy) : [];
1264
1469
  const byId = new Map(envelopes.map((e) => [e.agentId, e]));
1265
1470
  return {
@@ -1275,7 +1480,11 @@ export class Runtime {
1275
1480
  if (!envelope || !definition)
1276
1481
  return { stepId: step.id, ok: false, code: 'agent-not-enabled', error: `no agent definition '${step.agent ?? ''}'` };
1277
1482
  const innerSkills = this._skills.list().filter((sk) => envelope.skills.includes(sk.id));
1483
+ // Phase 3.5: continue a persisted task for THIS step, if one exists.
1484
+ const prior = agentResume?.(step);
1278
1485
  const out = await runAgentTask(step, envelope, definition, {
1486
+ ...(prior?.record ? { resume: prior.record } : {}),
1487
+ ...(prior?.answer ? { resumeAnswer: prior.answer } : {}),
1279
1488
  ai: this._ai,
1280
1489
  clock: this.clock,
1281
1490
  skills: innerSkills,
@@ -1295,6 +1504,7 @@ export class Runtime {
1295
1504
  }
1296
1505
  },
1297
1506
  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 }),
1507
+ ...(onRecord ? { onRecord } : {}),
1298
1508
  ...(ctx.signal ?? signal ? { parentSignal: ctx.signal ?? signal } : {}),
1299
1509
  ...(provenance ? { provenance } : { provenance: { planVersion: 1 } }),
1300
1510
  abortReason: () => {
@@ -1310,6 +1520,117 @@ export class Runtime {
1310
1520
  : {}),
1311
1521
  };
1312
1522
  }
1523
+ /**
1524
+ * A bounded, fenced brief of what the agents have already established (Phase 3.5).
1525
+ *
1526
+ * On drift the plan is thrown away and the goal is replanned — but validated findings are facts about
1527
+ * the WORKSPACE, not about the plan's structure, so discarding them silently would make the run redo
1528
+ * work it had already proved. They are agent-authored text, so they cross a prompt boundary fenced,
1529
+ * exactly like every other untrusted string.
1530
+ */
1531
+ findingsBrief(exec) {
1532
+ const active = parseAgentTasks(exec)
1533
+ .tasks.flatMap((t) => t.findings)
1534
+ .filter((f) => f.status === 'active')
1535
+ .slice(0, FINDINGS_BRIEF_MAX);
1536
+ if (!active.length)
1537
+ return undefined;
1538
+ const lines = active.map((f) => `- [${flattenClamp(f.type, 40)}] ${flattenClamp(f.subject ?? '', 60)}: ${flattenClamp(f.claim, 160)}`);
1539
+ return wrapUntrusted('prior-findings', `Already established by earlier agent work:\n${lines.join('\n')}`);
1540
+ }
1541
+ /**
1542
+ * Fingerprint the MCP tools a plan actually references (Phase 3.5). An MCP server is remote and
1543
+ * mutable: while a plan sits paused it can change a tool's input schema, change what it does, or drop
1544
+ * it entirely — and the plan would then be resumed against a tool that is no longer the tool it was
1545
+ * planned for. Hashing the live declaration makes that visible as ordinary drift.
1546
+ *
1547
+ * Non-MCP tools are deliberately absent: they are in-tree code covered by the config/skill hashes.
1548
+ */
1549
+ mcpToolHashes(plan) {
1550
+ const out = {};
1551
+ if (!plan)
1552
+ return out;
1553
+ for (const step of plan.steps) {
1554
+ if (!step.tool || !step.tool.startsWith('mcp:') || out[step.tool])
1555
+ continue;
1556
+ const tool = this._tools.get(step.tool);
1557
+ // A missing tool hashes to a sentinel rather than being skipped — "gone" must be comparable, or a
1558
+ // removed server would silently look like no drift at all.
1559
+ out[step.tool] = tool ? hashOf({ description: tool.description, parameters: tool.parameters ?? null }) : 'absent';
1560
+ }
1561
+ return out;
1562
+ }
1563
+ /**
1564
+ * Crash / pause / cancel reconciliation (Phase 3.5). A record left `running` or `created` describes a
1565
+ * worker that no longer exists — the process died, or the parent stopped it — so it goes back to
1566
+ * `queued` with an auditable reason. An interruption is deliberately NOT a state: the lifecycle does
1567
+ * not grow, only the explanation does.
1568
+ *
1569
+ * This runs on the RESUME path and inside pause/cancel. Resume alone is not enough: `cancelExecution`
1570
+ * writes a terminal status and resume early-returns on terminal, so a cancelled execution's `running`
1571
+ * records would stay `running` on disk forever, unstamped and unexplained.
1572
+ *
1573
+ * Records that fail validation are preserved in place, never dropped — reconciling is not a licence to
1574
+ * delete what this version could not parse.
1575
+ */
1576
+ reconcileAgentTasks(exec, kind) {
1577
+ const raw = exec.agentTasks;
1578
+ if (!Array.isArray(raw) || raw.length === 0)
1579
+ return 0;
1580
+ const { tasks, dropped } = parseAgentTasks(exec);
1581
+ const now = this.clock.now();
1582
+ const fixed = new Map();
1583
+ for (const t of tasks) {
1584
+ if (t.state !== 'running' && t.state !== 'created')
1585
+ continue;
1586
+ fixed.set(t.agentTaskId, { ...t, state: 'queued', interruption: { kind, at: now }, updatedAt: now });
1587
+ }
1588
+ if (dropped)
1589
+ exec.agentTasksDropped = dropped;
1590
+ if (fixed.size === 0)
1591
+ return 0;
1592
+ exec.agentTasks = raw.map((entry) => fixed.get(entry.agentTaskId ?? '') ?? entry);
1593
+ return fixed.size;
1594
+ }
1595
+ /**
1596
+ * FIRST-WINS PENDING. `Execution.pending` is a single slot but two agents can be waiting at once, so
1597
+ * the earliest-created waiting task claims it. The others are not lost: once this one is answered and
1598
+ * the run continues, the next resume re-elects whichever task is still waiting — rediscovery, rather
1599
+ * than a queue that has to be kept in sync with the records that are already the source of truth.
1600
+ */
1601
+ electWaitingAgent(exec) {
1602
+ return parseAgentTasks(exec).tasks.find((t) => t.state === 'waiting_for_clarification' && t.pendingInner);
1603
+ }
1604
+ /**
1605
+ * Which persisted task, if any, a plan step should CONTINUE. Bound by step-input hash, never by step
1606
+ * id: plan step ids (`s1`, `auto1`) are model-authored and recur across replans, so an id match would
1607
+ * hand one step's completed inner work to a different step with the same id and a different input.
1608
+ */
1609
+ agentResumeLookup(exec, answer) {
1610
+ const tasks = parseAgentTasks(exec).tasks.filter((t) => AGENT_RESUMABLE.has(t.state));
1611
+ const answeringId = exec.pending?.agentTaskId;
1612
+ // A plan may legitimately contain two steps with identical identity (same agent, same input) — a
1613
+ // retry, or genuinely duplicated work. Without claiming, both would resolve to the SAME record and
1614
+ // the second would resume, and then overwrite, the first's inner work.
1615
+ const claimed = new Set();
1616
+ return (step) => {
1617
+ if (!step.agent)
1618
+ return undefined;
1619
+ const hash = stepIdentity(step);
1620
+ const record = tasks.find((t) => t.agentId === step.agent && t.stepInputHash === hash && !claimed.has(t.agentTaskId));
1621
+ if (!record)
1622
+ return undefined;
1623
+ claimed.add(record.agentTaskId);
1624
+ // If the files this agent worked on changed while the run was stopped, its completed inner steps
1625
+ // are no longer safe to skip — the same rule the outer plan already lives by.
1626
+ if (record.innerCheckpoint && reconcile(record.innerCheckpoint, this.workspaceRoot, this.skills()).drifted) {
1627
+ const { innerPlan: _drop, ...rest } = record;
1628
+ return { record: { ...rest, innerCompletedSteps: [], innerObservations: [], innerObservationsOmitted: 0 } };
1629
+ }
1630
+ // The answer belongs to exactly ONE task — the one that asked.
1631
+ return answer && record.agentTaskId === answeringId ? { record, answer } : { record };
1632
+ };
1633
+ }
1313
1634
  /** Abort a run that is in flight, recording WHY so a task can tell a pause from a cancellation. */
1314
1635
  abortLiveRun(id, reason) {
1315
1636
  const live = this.liveRuns.get(id);
@@ -1327,6 +1648,9 @@ export class Runtime {
1327
1648
  // Phase 3.4: abort whatever is actually in flight, so a pause stops live agent work instead of only
1328
1649
  // flipping a stored status. A no-op with agents disabled — `liveRuns` is empty then.
1329
1650
  this.abortLiveRun(id, 'pause');
1651
+ // Phase 3.5: stamp whatever was mid-flight. Doing it here rather than only on resume is what stops a
1652
+ // record sitting at `running` with no worker behind it.
1653
+ this.reconcileAgentTasks(exec, 'pause');
1330
1654
  this._executions.save(exec);
1331
1655
  this._executions.release(id);
1332
1656
  return true;
@@ -1338,6 +1662,9 @@ export class Runtime {
1338
1662
  return false;
1339
1663
  exec.status = 'cancelled';
1340
1664
  this.abortLiveRun(id, 'parent-cancel'); // parent cancelled ⇒ every live agent controller aborted
1665
+ // Cancel is TERMINAL and resume early-returns on terminal, so if this did not reconcile here those
1666
+ // `running` records would stay `running` on disk forever, unstamped and unexplained.
1667
+ this.reconcileAgentTasks(exec, 'parent-cancel');
1341
1668
  this._executions.save(exec);
1342
1669
  this._executions.release(id);
1343
1670
  return true;
@@ -37,21 +37,33 @@ export function redactString(input) {
37
37
  }
38
38
  /** Deep-redact any value, returning a scrubbed clone. Non-plain objects are stringified defensively. */
39
39
  export function redact(value) {
40
- return redactValue(value, new WeakSet());
40
+ return redactValue(value, new Set());
41
41
  }
42
- function redactValue(value, seen) {
42
+ /**
43
+ * The `ancestors` set holds the objects on the CURRENT PATH, and each is removed on the way back out.
44
+ * A global visited-set would be wrong: it cannot tell a cycle from a DAG, so the second appearance of a
45
+ * merely SHARED object becomes the string `'[circular]'`. That is not cosmetic — a shared array turning
46
+ * into a string changes the shape of redacted data, and anything that then validates it (the persisted
47
+ * agent-task schema does) rejects the whole record. Only a genuine cycle reports `[circular]`.
48
+ */
49
+ function redactValue(value, ancestors) {
43
50
  if (typeof value === 'string')
44
51
  return redactString(value);
45
52
  if (value === null || typeof value !== 'object')
46
53
  return value;
47
- if (seen.has(value))
54
+ if (ancestors.has(value))
48
55
  return '[circular]';
49
- seen.add(value);
50
- if (Array.isArray(value))
51
- return value.map((v) => redactValue(v, seen));
52
- const out = {};
53
- for (const [k, v] of Object.entries(value)) {
54
- out[k] = redactValue(v, seen);
56
+ ancestors.add(value);
57
+ try {
58
+ if (Array.isArray(value))
59
+ return value.map((v) => redactValue(v, ancestors));
60
+ const out = {};
61
+ for (const [k, v] of Object.entries(value)) {
62
+ out[k] = redactValue(v, ancestors);
63
+ }
64
+ return out;
65
+ }
66
+ finally {
67
+ ancestors.delete(value);
55
68
  }
56
- return out;
57
69
  }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * One short content hash, shared by everything that needs to ask "is this the same thing it was?".
3
+ *
4
+ * Phase 3.5 resume decisions turn on identity comparisons — is this the same agent definition, the same
5
+ * narrowed envelope, the same step input, the same MCP tool declaration — and each of those was about to
6
+ * grow its own hashing. They must agree, so there is exactly one implementation and one canonical JSON
7
+ * form: keys sorted at every depth, so `{a,b}` and `{b,a}` are the same object, which is the whole point
8
+ * when the thing being hashed came back from `JSON.parse`.
9
+ */
10
+ /** 16 hex chars — the same width `checkpoint.ts` uses for file hashes. Identity, never security. */
11
+ export declare function hash16(text: string): string;
12
+ /**
13
+ * Stable JSON: object keys sorted at every depth, `undefined` dropped. Arrays keep their order (order is
14
+ * meaning in a plan's steps). Cycles are impossible in the persisted shapes this serves, and a stray one
15
+ * throws rather than hashing something arbitrary.
16
+ */
17
+ export declare function canonicalJson(value: unknown): string;
18
+ /** `hash16(canonicalJson(value))` — the form every identity check in Phase 3.5 uses. */
19
+ export declare function hashOf(value: unknown): string;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * One short content hash, shared by everything that needs to ask "is this the same thing it was?".
3
+ *
4
+ * Phase 3.5 resume decisions turn on identity comparisons — is this the same agent definition, the same
5
+ * narrowed envelope, the same step input, the same MCP tool declaration — and each of those was about to
6
+ * grow its own hashing. They must agree, so there is exactly one implementation and one canonical JSON
7
+ * form: keys sorted at every depth, so `{a,b}` and `{b,a}` are the same object, which is the whole point
8
+ * when the thing being hashed came back from `JSON.parse`.
9
+ */
10
+ import { createHash } from 'node:crypto';
11
+ /** 16 hex chars — the same width `checkpoint.ts` uses for file hashes. Identity, never security. */
12
+ export function hash16(text) {
13
+ return createHash('sha256').update(text).digest('hex').slice(0, 16);
14
+ }
15
+ /**
16
+ * Stable JSON: object keys sorted at every depth, `undefined` dropped. Arrays keep their order (order is
17
+ * meaning in a plan's steps). Cycles are impossible in the persisted shapes this serves, and a stray one
18
+ * throws rather than hashing something arbitrary.
19
+ */
20
+ export function canonicalJson(value) {
21
+ const walk = (v) => {
22
+ if (v === null || typeof v !== 'object')
23
+ return v;
24
+ if (Array.isArray(v))
25
+ return v.map(walk);
26
+ const out = {};
27
+ for (const k of Object.keys(v).sort()) {
28
+ const child = v[k];
29
+ if (child !== undefined)
30
+ out[k] = walk(child);
31
+ }
32
+ return out;
33
+ };
34
+ return JSON.stringify(walk(value)) ?? 'null';
35
+ }
36
+ /** `hash16(canonicalJson(value))` — the form every identity check in Phase 3.5 uses. */
37
+ export function hashOf(value) {
38
+ return hash16(canonicalJson(value));
39
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-runtime-engine",
3
- "version": "2.7.0",
3
+ "version": "2.8.0",
4
4
  "description": "AI Runtime \u2014 a provider-agnostic AI runtime and orchestration platform. Point it at whatever AI providers you have; it routes each task to the best available model. Ships the `ai-runtime` CLI and the `Runtime`/`AI` library API.",
5
5
  "type": "module",
6
6
  "license": "ISC",