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.
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Persisted agent tasks (Phase 3.5) — the schema, and the ONE way anything reads them back.
3
+ *
4
+ * An `Execution` is a JSON file in the user's home. Invariant 21 says persisted agent state is
5
+ * schema-validated on read, and the reason is concrete: a record's `innerPlan` is EXECUTED on resume and
6
+ * its hashes decide whether completed work may be reused, so a malformed record is not a display problem
7
+ * — it is an execution problem. Validation is fail-closed per record: anything that does not parse is
8
+ * dropped whole and counted, never coerced or half-read.
9
+ *
10
+ * READING IS NOT WRITING. `parseAgentTasks` returns a filtered VIEW and never touches the stored record,
11
+ * so merely listing or acquiring an execution cannot destroy tasks it could not parse. The dropped count
12
+ * travels with the view so a silently shrinking list is visible instead of inferred.
13
+ */
14
+ import { z } from 'zod';
15
+ /** Structural only. A persisted inner plan still faces `validatePlan` against the envelope before it runs. */
16
+ const planStep = z
17
+ .object({
18
+ id: z.string().min(1),
19
+ description: z.string(),
20
+ skill: z.string().optional(),
21
+ tool: z.string().optional(),
22
+ agent: z.string().optional(),
23
+ motivatedBy: z.array(z.string()).optional(),
24
+ input: z.unknown().optional(),
25
+ dependsOn: z.array(z.string()).optional(),
26
+ status: z.enum(['pending', 'running', 'succeeded', 'failed', 'skipped']),
27
+ })
28
+ .strict();
29
+ const executionPlan = z.object({ id: z.string(), goal: z.string(), version: z.number(), steps: z.array(planStep), reason: z.string().optional() }).strict();
30
+ const observation = z
31
+ .object({
32
+ stepId: z.string(),
33
+ skill: z.string().optional(),
34
+ tool: z.string().optional(),
35
+ agent: z.string().optional(),
36
+ agentTaskId: z.string().optional(),
37
+ ok: z.boolean(),
38
+ output: z.string().optional(),
39
+ error: z.string().optional(),
40
+ code: z.string().optional(),
41
+ data: z.unknown().optional(),
42
+ artifacts: z.array(z.unknown()).optional(),
43
+ callsUsed: z.number().optional(),
44
+ })
45
+ .passthrough();
46
+ const evidence = z.object({ kind: z.string(), ref: z.string().optional(), detail: z.string().optional(), stepId: z.string().optional() }).passthrough();
47
+ const finding = z
48
+ .object({
49
+ id: z.string().min(1),
50
+ agentId: z.string(),
51
+ agentTaskId: z.string(),
52
+ type: z.string(),
53
+ subject: z.string().optional(),
54
+ claim: z.string(),
55
+ verdict: z.string().optional(),
56
+ executionCoverage: z.number().min(0).max(1),
57
+ confidence: z.number().min(0).max(1),
58
+ evidence: z.array(evidence),
59
+ artifacts: z.array(z.unknown()),
60
+ sourceSteps: z.array(z.string()),
61
+ provenance: z.object({ executionId: z.string().optional(), planVersion: z.number() }).passthrough(),
62
+ status: z.enum(['active', 'superseded', 'contradicted']),
63
+ supersededBy: z.string().optional(),
64
+ createdAt: z.number(),
65
+ })
66
+ .passthrough();
67
+ const checkpoint = z
68
+ .object({
69
+ at: z.number(),
70
+ planVersion: z.number(),
71
+ gitHead: z.string().optional(),
72
+ fileHashes: z.record(z.string(), z.string()),
73
+ configHash: z.string().optional(),
74
+ skillVersions: z.record(z.string(), z.string()),
75
+ completedSteps: z.array(z.string()),
76
+ mcpTools: z.record(z.string(), z.string()).optional(),
77
+ })
78
+ .passthrough();
79
+ /** A counter read back into arithmetic: a whole number, never negative, never absurd. */
80
+ const count = z.number().int().min(0).max(1_000_000);
81
+ /** Bounds on the per-record collections, so one task cannot make an execution file unreadable. */
82
+ const FINDINGS_MAX = 200;
83
+ const DIAGNOSTICS_MAX = 200;
84
+ const AGENT_STATES = ['created', 'queued', 'running', 'completed', 'failed', 'cancelled', 'waiting_for_input', 'waiting_for_clarification', 'paused'];
85
+ /**
86
+ * The record schema. `v` is a LITERAL: a record written by a future, wider version is dropped rather
87
+ * than parsed as this shape — reinterpreting an unknown layout is how a resume executes something its
88
+ * author never wrote.
89
+ */
90
+ export const persistedAgentTask = z
91
+ .object({
92
+ v: z.literal(1),
93
+ agentTaskId: z.string().min(1),
94
+ agentId: z.string().min(1),
95
+ stepId: z.string().min(1),
96
+ state: z.enum(AGENT_STATES),
97
+ createdAt: z.number(),
98
+ updatedAt: z.number(),
99
+ startedAt: z.number().optional(),
100
+ endedAt: z.number().optional(),
101
+ interruption: z.object({ kind: z.enum(['crash', 'pause', 'parent-cancel']), at: z.number(), detail: z.string().optional() }).strict().optional(),
102
+ provenance: z.object({ executionId: z.string().optional(), planVersion: z.number() }).strict(),
103
+ agentDefHash: z.string().min(1),
104
+ envelopeHash: z.string().min(1),
105
+ stepInputHash: z.string().min(1),
106
+ attempt: z.number().int().min(1),
107
+ innerPlanVersion: z.number().optional(),
108
+ innerPlan: executionPlan.optional(),
109
+ innerCompletedSteps: z.array(z.string()),
110
+ innerObservations: z.array(observation),
111
+ innerObservationsOmitted: z.number().int().min(0),
112
+ innerCheckpoint: checkpoint.optional(),
113
+ pendingInner: z.object({ kind: z.literal('clarification'), question: z.string(), at: z.number() }).strict().optional(),
114
+ // Bounded and non-negative. These are read back into budget arithmetic and into rendered status
115
+ // lines, so a tampered or corrupted file must not be able to inject a negative or absurd count.
116
+ innerSteps: z.object({ total: count, succeeded: count }).strict(),
117
+ callsReserved: count,
118
+ callsUsed: count,
119
+ callsRefunded: count,
120
+ toolCallsUsed: count,
121
+ findings: z.array(finding).max(FINDINGS_MAX),
122
+ diagnostics: z.array(z.unknown()).max(DIAGNOSTICS_MAX),
123
+ failure: z.object({ code: z.string(), message: z.string() }).passthrough().optional(),
124
+ })
125
+ .strict();
126
+ /**
127
+ * Validate the agent tasks on an execution. Pure: it reads, it never writes. Callers that then persist
128
+ * the execution are choosing to drop the unparseable records — reading alone never does.
129
+ */
130
+ export function parseAgentTasks(exec) {
131
+ const raw = exec.agentTasks;
132
+ if (!Array.isArray(raw))
133
+ return { tasks: [], dropped: 0 };
134
+ const tasks = [];
135
+ let dropped = 0;
136
+ for (const entry of raw) {
137
+ const parsed = persistedAgentTask.safeParse(entry);
138
+ if (!parsed.success) {
139
+ dropped += 1;
140
+ continue;
141
+ }
142
+ tasks.push(parsed.data);
143
+ }
144
+ return { tasks, dropped };
145
+ }
146
+ /** One task by id, validated — the lookup every resume path uses. */
147
+ export function findAgentTask(exec, agentTaskId) {
148
+ return parseAgentTasks(exec).tasks.find((t) => t.agentTaskId === agentTaskId);
149
+ }
@@ -16,6 +16,10 @@ export interface CaptureInput {
16
16
  skills: Skill[];
17
17
  completedSteps: string[];
18
18
  clock?: Clock;
19
+ /** Phase 3.5: plan-referenced MCP tool id → a hash of its live declaration. An MCP server is remote
20
+ * and mutable: it can change a tool's schema, or drop it, while a plan sits paused. That is drift in
21
+ * exactly the same sense as an edited file, and it was invisible before. */
22
+ mcpTools?: Record<string, string>;
19
23
  }
20
24
  export declare function captureCheckpoint(input: CaptureInput): Checkpoint;
21
25
  export interface Reconciliation {
@@ -23,4 +27,4 @@ export interface Reconciliation {
23
27
  reasons: string[];
24
28
  }
25
29
  /** Recompute the fingerprint and compare to a checkpoint. Any difference is drift. */
26
- export declare function reconcile(checkpoint: Checkpoint, root: string, skills: Skill[]): Reconciliation;
30
+ export declare function reconcile(checkpoint: Checkpoint, root: string, skills: Skill[], mcpTools?: Record<string, string>): Reconciliation;
@@ -85,10 +85,11 @@ export function captureCheckpoint(input) {
85
85
  ...(cfg !== undefined ? { configHash: cfg } : {}),
86
86
  skillVersions,
87
87
  completedSteps: [...input.completedSteps],
88
+ ...(input.mcpTools && Object.keys(input.mcpTools).length ? { mcpTools: input.mcpTools } : {}),
88
89
  };
89
90
  }
90
91
  /** Recompute the fingerprint and compare to a checkpoint. Any difference is drift. */
91
- export function reconcile(checkpoint, root, skills) {
92
+ export function reconcile(checkpoint, root, skills, mcpTools) {
92
93
  const reasons = [];
93
94
  // Compare directly so an ADDITION (absent at capture, present now — e.g. git initialized, config
94
95
  // added) also counts as drift, not just a change to something already captured.
@@ -110,5 +111,16 @@ export function reconcile(checkpoint, root, skills) {
110
111
  else if (cur !== ver)
111
112
  reasons.push(`skill ${id} version changed (${ver} → ${cur})`);
112
113
  }
114
+ // Phase 3.5: an absent `mcpTools` means the checkpoint predates this dimension — "nothing to
115
+ // compare", never "everything changed". A tool that vanished is drift: the plan references it.
116
+ if (checkpoint.mcpTools) {
117
+ for (const [id, hash] of Object.entries(checkpoint.mcpTools)) {
118
+ const cur = mcpTools?.[id];
119
+ if (cur === undefined)
120
+ reasons.push(`MCP tool ${id} is no longer available`);
121
+ else if (cur !== hash)
122
+ reasons.push(`MCP tool ${id} changed shape`);
123
+ }
124
+ }
113
125
  return { drifted: reasons.length > 0, reasons };
114
126
  }
@@ -7,6 +7,7 @@
7
7
  import type { ExecutionPlan } from '../orchestration/plan.js';
8
8
  import type { StepObservation } from '../orchestration/executor.js';
9
9
  import type { ArtifactRef } from '../runtime/types.js';
10
+ import type { AgentTaskRecord } from '../agents/task.js';
10
11
  export type ExecutionStatus = 'planned' | 'running' | 'paused' | 'waiting_for_input' | 'waiting_for_clarification' | 'completed' | 'failed' | 'cancelled';
11
12
  /** A time-boxed claim on an execution by one process. */
12
13
  export interface Lease {
@@ -23,6 +24,10 @@ export interface Checkpoint {
23
24
  configHash?: string;
24
25
  skillVersions: Record<string, string>;
25
26
  completedSteps: string[];
27
+ /** Phase 3.5: plan-referenced MCP tool id → a hash of its declaration at plan time. A server that
28
+ * changed a tool's shape under a paused plan is drift, exactly like an edited file. Absent on every
29
+ * pre-3.5 checkpoint, which reconcile must read as "nothing to compare", never as "everything changed". */
30
+ mcpTools?: Record<string, string>;
26
31
  }
27
32
  /** A plan's estimated vs available model-call budget (Phase 22), carried on a budget-paused execution. */
28
33
  export interface BudgetInfo {
@@ -40,6 +45,10 @@ export interface PendingInput {
40
45
  question?: string;
41
46
  action?: string;
42
47
  budget?: BudgetInfo;
48
+ /** Phase 3.5: set when the wait belongs to an AGENT's inner run. The answer is then routed into that
49
+ * task's inner resume — appending it to the OUTER goal would replan and destroy every sibling's
50
+ * progress. Absent ⇒ the wait is the outer run's, which is the pre-3.5 behaviour unchanged. */
51
+ agentTaskId?: string;
43
52
  }
44
53
  export interface Execution {
45
54
  id: string;
@@ -57,6 +66,20 @@ export interface Execution {
57
66
  createdAt: number;
58
67
  updatedAt: number;
59
68
  lease?: Lease;
69
+ /** Phase 3.5: budgeted step-calls charged across every run AND resume of this execution — the unit
70
+ * `foldCalls` counts, NOT provider requests (planning calls are deliberately outside the pool).
71
+ *
72
+ * ACCOUNTING, NOT A CAP. It is deliberately not subtracted from `maxCalls` on resume: `AI_MAX_CALLS`
73
+ * bounds a RUN, and "raise it and resume" is how a partially-budgeted plan makes progress (Phase 22).
74
+ * Treating it as a cumulative task ceiling would make `AI_MAX_CALLS=1` un-resumable — the first run
75
+ * spends the whole allowance and every resume gets a budget of zero. See DECISIONS D132.
76
+ * Optional because records written before 3.5 do not have it: always read `?? 0`. */
77
+ callsUsed?: number;
78
+ /** Phase 3.5: the agent tasks this execution owns, in creation order. Schema-validated on read. */
79
+ agentTasks?: AgentTaskRecord[];
80
+ /** Phase 3.5, diagnostic: how many persisted agent tasks failed validation on the last read. Counted
81
+ * so a silently shrinking list is visible; the dropped records are never rendered as content. */
82
+ agentTasksDropped?: number;
60
83
  }
61
84
  export declare const RESUMABLE: ReadonlySet<ExecutionStatus>;
62
85
  export declare const TERMINAL: ReadonlySet<ExecutionStatus>;
@@ -21,6 +21,26 @@ export interface AcquireResult {
21
21
  execution?: Execution;
22
22
  reason?: string;
23
23
  }
24
+ /**
25
+ * Why a mid-run progress commit was accepted or refused (Phase 3.5).
26
+ *
27
+ * Every refusal means the same thing to the caller: SOMEONE ELSE now owns this execution's fate, and
28
+ * this process must stop — not retry, not carry on discarding writes. A run that keeps executing after a
29
+ * refused commit doubles every tool side effect and every model call while another owner re-runs the
30
+ * same steps, so the caller is required to abort. That is why this is an enum and not a boolean: a
31
+ * boolean invites an ignored return value at the call site, which is exactly the bug.
32
+ */
33
+ export type CommitOutcome = 'ok'
34
+ /** The record is gone from disk (deleted under us). */
35
+ | 'missing'
36
+ /** Another owner holds the lease, or ours is gone — writing would clobber a live run. */
37
+ | 'foreign-lease'
38
+ /** The lease was released out from under us. Never write leaseless: an unleased record is claimable. */
39
+ | 'released'
40
+ /** Disk reached a terminal status. TERMINAL IS STICKY — a snapshot from before it may not undo it. */
41
+ | 'terminal'
42
+ /** Someone paused us. `paused` is not terminal, but this owner cannot produce it, so it is not ours to overwrite. */
43
+ | 'paused';
24
44
  export declare class ExecutionStore {
25
45
  private readonly area;
26
46
  readonly owner: string;
@@ -42,6 +62,23 @@ export declare class ExecutionStore {
42
62
  /** Claim an execution. Fails if a DIFFERENT owner holds a live lease; steals an expired one. Re-reads
43
63
  * after writing to detect losing a concurrent steal (best-effort — no OS lock). */
44
64
  acquire(id: string): AcquireResult;
65
+ /**
66
+ * THE mid-run write (Phase 3.5). Every incremental commit point goes through here and nothing else
67
+ * writes an execution while a run is in flight, so all four ordering rules live in one place:
68
+ *
69
+ * 1. TERMINAL IS STICKY. A commit carries a snapshot taken before it was built; if the record reached
70
+ * `completed`/`failed`/`cancelled` in the meantime, that verdict stands. Without this, a cancel is
71
+ * undone by the next in-flight wave's commit milliseconds later.
72
+ * 2. `paused` IS NOT OURS TO OVERWRITE. A running owner never writes `paused`, so finding it on disk
73
+ * means someone else did — and the pre-3.5 behaviour (clobber it at end of run) is far worse here,
74
+ * because commits are frequent: the pause would be erased within one batch, before any heartbeat
75
+ * tick could observe it.
76
+ * 3. NEVER WRITE LEASELESS. An unleased record is claimable by any process; writing one back without
77
+ * a lease invites a second owner to acquire and re-run everything this owner is still doing.
78
+ * 4. THE DISK LEASE WINS. Lease bookkeeping belongs to acquire/heartbeat; a progress commit carries a
79
+ * possibly-stale copy and must not push it back.
80
+ */
81
+ commitProgress(exec: Execution): CommitOutcome;
45
82
  /** Write only if we still hold the lease (or it's free/expired) — never clobber a live different owner. */
46
83
  commit(exec: Execution): boolean;
47
84
  /** Renew this owner's lease. Returns false if we no longer hold it. */
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { hostname } from 'node:os';
11
11
  import { systemClock } from '../util/clock.js';
12
+ import { TERMINAL } from './execution.js';
12
13
  export class ExecutionStore {
13
14
  area;
14
15
  owner;
@@ -85,6 +86,38 @@ export class ExecutionStore {
85
86
  return { ok: false, reason: 'lost the acquire race' };
86
87
  return { ok: true, execution: confirmed };
87
88
  }
89
+ /**
90
+ * THE mid-run write (Phase 3.5). Every incremental commit point goes through here and nothing else
91
+ * writes an execution while a run is in flight, so all four ordering rules live in one place:
92
+ *
93
+ * 1. TERMINAL IS STICKY. A commit carries a snapshot taken before it was built; if the record reached
94
+ * `completed`/`failed`/`cancelled` in the meantime, that verdict stands. Without this, a cancel is
95
+ * undone by the next in-flight wave's commit milliseconds later.
96
+ * 2. `paused` IS NOT OURS TO OVERWRITE. A running owner never writes `paused`, so finding it on disk
97
+ * means someone else did — and the pre-3.5 behaviour (clobber it at end of run) is far worse here,
98
+ * because commits are frequent: the pause would be erased within one batch, before any heartbeat
99
+ * tick could observe it.
100
+ * 3. NEVER WRITE LEASELESS. An unleased record is claimable by any process; writing one back without
101
+ * a lease invites a second owner to acquire and re-run everything this owner is still doing.
102
+ * 4. THE DISK LEASE WINS. Lease bookkeeping belongs to acquire/heartbeat; a progress commit carries a
103
+ * possibly-stale copy and must not push it back.
104
+ */
105
+ commitProgress(exec) {
106
+ const disk = this.get(exec.id);
107
+ if (!disk)
108
+ return 'missing';
109
+ if (TERMINAL.has(disk.status))
110
+ return 'terminal';
111
+ if (disk.status === 'paused' && exec.status !== 'paused')
112
+ return 'paused';
113
+ if (!disk.lease)
114
+ return 'released';
115
+ if (disk.lease.owner !== this.owner)
116
+ return 'foreign-lease';
117
+ exec.lease = disk.lease;
118
+ this.save(exec);
119
+ return 'ok';
120
+ }
88
121
  /** Write only if we still hold the lease (or it's free/expired) — never clobber a live different owner. */
89
122
  commit(exec) {
90
123
  const disk = this.get(exec.id);
package/dist/index.d.ts CHANGED
@@ -101,6 +101,10 @@ export type { Finding, FindingEvidence, FindingEvidenceKind, FindingStatus } fro
101
101
  export type { AdmissionRejection, AdmissionResult } from './agents/admit.js';
102
102
  export type { StepObservationCode } from './orchestration/executor.js';
103
103
  export { PLAN_STEP_STATUSES } from './orchestration/plan.js';
104
+ export { parseAgentTasks, findAgentTask } from './executions/agentTasks.js';
105
+ export type { AgentTaskView } from './executions/agentTasks.js';
106
+ export { AGENT_TERMINAL, AGENT_RESUMABLE } from './agents/task.js';
107
+ export type { ProgressSnapshot } from './orchestration/executor.js';
104
108
  export { mcpToolId } from './mcp/toolAdapter.js';
105
109
  export { MCP_PROTOCOL_VERSION } from './mcp/protocol.js';
106
110
  export type { McpServerConfig, McpServerState, McpServerStatus, McpTransportKind } from './mcp/manager.js';
package/dist/index.js CHANGED
@@ -74,6 +74,13 @@ export { deriveCapabilities, deriveCapabilitiesOffline, candidatesFrom, candidat
74
74
  export { ActionCapabilityRegistry, capabilityReportFrom } from './capabilities/registry.js';
75
75
  export { CURATED_VOCABULARY, curated, isCurated } from './capabilities/vocabulary.js';
76
76
  export { PLAN_STEP_STATUSES } from './orchestration/plan.js';
77
+ // ── Agent persistence (Phase 3.5) — reading back what a run left on disk ──
78
+ // `parseAgentTasks` is exported because it is the ONLY correct way to read agent tasks off an
79
+ // Execution: the raw array is unvalidated JSON from a file, and a host that reads it directly would
80
+ // trust records this version cannot parse. The state sets are exported with it because "is this task
81
+ // finished?" must have one answer, not one per caller.
82
+ export { parseAgentTasks, findAgentTask } from './executions/agentTasks.js';
83
+ export { AGENT_TERMINAL, AGENT_RESUMABLE } from './agents/task.js';
77
84
  // ── MCP connectivity (Phase 3.2) — external servers as ordinary Runtime tools ──
78
85
  // The CONFIG + STATUS surface is public; the client, transports, and manager internals are not, so the
79
86
  // wire implementation stays free to change without a breaking release.
@@ -10,7 +10,10 @@ import type { ExecutionPlan, PlanStep } from './plan.js';
10
10
  import type { ReserveAgentCalls } from './budget.js';
11
11
  import type { ArtifactRef } from '../runtime/types.js';
12
12
  /** Why a step ended the way it did, when the reason is not simply "the skill/tool said so". */
13
- export type StepObservationCode = 'cancelled' | 'agent-failed' | 'agent-timeout' | 'agent-call-budget' | 'agent-tool-budget' | 'finding-contract' | 'agent-not-enabled';
13
+ export type StepObservationCode = 'cancelled' | 'agent-failed' | 'agent-timeout' | 'agent-call-budget' | 'agent-tool-budget' | 'finding-contract' | 'agent-not-enabled'
14
+ /** Phase 3.5: the agent's inner run needs an answer. NOT a failure — the step stays `pending` so a
15
+ * resume re-runs it rather than skipping its dependents. */
16
+ | 'agent-waiting';
14
17
  export interface StepObservation {
15
18
  stepId: string;
16
19
  skill?: string;
@@ -31,6 +34,22 @@ export interface StepObservation {
31
34
  /** Phase 3.4, agent steps only: METERED inner model calls. Charging the actual is the refund. */
32
35
  callsUsed?: number;
33
36
  }
37
+ /**
38
+ * A quiescent moment in `executePlan`, handed to `ExecuteDeps.onProgress` (Phase 3.5).
39
+ *
40
+ * Every fire point is between awaits — no `Promise.all` is in flight and every semaphore slot has been
41
+ * released — so the plan's step statuses are consistent and the snapshot describes a state the run could
42
+ * legitimately be resumed from. `observations` is a DELTA (what appeared since the previous fire), so a
43
+ * sink appends rather than de-duplicating; `callsUsed` is cumulative for this `executePlan` call.
44
+ */
45
+ export interface ProgressSnapshot {
46
+ at: 'wave-partition' | 'batch' | 'budget-stop' | 'plan-end';
47
+ plan: ExecutionPlan;
48
+ observations: StepObservation[];
49
+ callsUsed: number;
50
+ /** Step ids whose agent is waiting for input. Non-empty ⇒ the run is resumable, NOT failed. */
51
+ waiting: string[];
52
+ }
34
53
  export interface ExecuteResult {
35
54
  ok: boolean;
36
55
  plan: ExecutionPlan;
@@ -38,6 +57,8 @@ export interface ExecuteResult {
38
57
  /** Phase 22: true when execution paused BEFORE a wave because running it would exceed `callBudget`.
39
58
  * Not a failure — the completed steps stand and the rest can resume with a raised budget. */
40
59
  stoppedForBudget?: boolean;
60
+ /** Phase 3.5: steps left waiting for input. Non-empty ⇒ resumable; callers must not report `failed`. */
61
+ waiting?: string[];
41
62
  /** Model calls (skill steps) actually executed this run. */
42
63
  callsUsed?: number;
43
64
  }
@@ -68,6 +89,10 @@ export interface ExecuteDeps {
68
89
  }) => Promise<StepObservation>;
69
90
  /** Phase 3.4: what an agent step reserves. The SAME function the pre-flight estimate uses. */
70
91
  reserve?: ReserveAgentCalls;
92
+ /** Phase 3.5: called at each quiescent point so progress reaches disk BEFORE the next wave starts
93
+ * (invariant 18). Synchronous and must not throw — the executor does not own persistence policy and
94
+ * will not try to recover from a sink that fails. */
95
+ onProgress?: (snapshot: ProgressSnapshot) => void;
71
96
  }
72
97
  /** Execute a plan. Runs DAG waves; within a wave, batches of at most maxParallelSteps run concurrently. */
73
98
  export declare function executePlan(plan: ExecutionPlan, deps: ExecuteDeps): Promise<ExecuteResult>;
@@ -55,8 +55,20 @@ export async function executePlan(plan, deps) {
55
55
  // a step carrying `agent` can enter this set, so with agents disabled it is provably empty and both
56
56
  // branches below render exactly as 2.6.0.
57
57
  const cancelled = new Set();
58
+ // Phase 3.5: steps whose agent asked for input. They are NOT failures — the step stays `pending`, its
59
+ // dependents are neither run nor skipped, and the run stops scheduling so it can be resumed.
60
+ const waiting = new Set();
58
61
  let callsUsed = 0; // model calls = executed skill steps (tool steps are free)
59
62
  let stoppedForBudget = false;
63
+ // Phase 3.5: observations are handed to the sink as a DELTA, so it appends instead of de-duplicating.
64
+ let emitted = 0;
65
+ const fire = (at) => {
66
+ if (!deps.onProgress)
67
+ return;
68
+ const delta = observations.slice(emitted);
69
+ emitted = observations.length;
70
+ deps.onProgress({ at, plan, observations: delta, callsUsed, waiting: [...waiting] });
71
+ };
60
72
  for (const wave of executionWaves(plan.steps)) {
61
73
  // A step runs only if all its dependencies succeeded; otherwise it is skipped.
62
74
  const runnable = [];
@@ -89,12 +101,16 @@ export async function executePlan(plan, deps) {
89
101
  runnable.push(step);
90
102
  }
91
103
  }
104
+ // Every skipped/cancelled verdict for this wave is now decided and nothing is in flight: the
105
+ // cheapest honest moment to get the wave's shape on disk, and it precedes the budget stop below.
106
+ fire('wave-partition');
92
107
  // Phase 22 budget gate: if running this wave's skill steps would exceed the call budget, stop BEFORE
93
108
  // it (phase granularity). Remaining steps keep status 'pending' and can resume with a raised budget.
94
109
  if (deps.callBudget !== undefined) {
95
110
  const waveCalls = foldCalls(runnable, deps.callBudget - callsUsed, deps.reserve);
96
111
  if (waveCalls > 0 && callsUsed + waveCalls > deps.callBudget) {
97
112
  stoppedForBudget = true;
113
+ fire('budget-stop');
98
114
  break;
99
115
  }
100
116
  }
@@ -104,22 +120,43 @@ export async function executePlan(plan, deps) {
104
120
  const results = await Promise.all(batch.map((s) => runStep(s, deps, limiters)));
105
121
  results.forEach((obs, j) => {
106
122
  const step = batch[j];
107
- step.status = obs.ok ? 'succeeded' : 'failed';
123
+ // A waiting agent is the one non-terminal step outcome: it is neither a success nor a failure, so
124
+ // it must not mark the step `failed` — that would skip every dependent and make the branch
125
+ // unrecoverable on resume, which is the opposite of what waiting means.
126
+ const isWaiting = !obs.ok && obs.code === 'agent-waiting';
127
+ step.status = isWaiting ? 'pending' : obs.ok ? 'succeeded' : 'failed';
108
128
  if (step.skill)
109
129
  callsUsed += 1;
110
130
  // Charging what an agent actually SPENT is the refund: an aborted or cancelled task reports its
111
131
  // metered total and the unspent reservation is simply never charged. No ledger, no refund path.
112
132
  else if (step.agent)
113
133
  callsUsed += obs.callsUsed ?? stepCalls(step, Number.POSITIVE_INFINITY, deps.reserve);
114
- if (!obs.ok && obs.code === 'cancelled')
115
- cancelled.add(step.id);
116
- if (!obs.ok)
117
- failedOrSkipped.add(step.id);
134
+ if (isWaiting)
135
+ waiting.add(step.id);
136
+ else {
137
+ if (!obs.ok && obs.code === 'cancelled')
138
+ cancelled.add(step.id);
139
+ if (!obs.ok)
140
+ failedOrSkipped.add(step.id);
141
+ }
118
142
  observations.push(obs);
119
143
  });
144
+ fire('batch');
120
145
  }
146
+ // Something is waiting for a human. Scheduling further waves would run work whose inputs may change
147
+ // once the answer arrives, so the run stops here and resumes when it is answered.
148
+ if (waiting.size > 0)
149
+ break;
121
150
  }
122
- // Success only if every step succeeded AND we didn't stop early for budget.
123
- const ok = !stoppedForBudget && plan.steps.every((s) => s.status === 'succeeded');
124
- return { ok, plan, observations, callsUsed, ...(stoppedForBudget ? { stoppedForBudget: true } : {}) };
151
+ // Success only if every step succeeded AND we didn't stop early for budget or for an answer.
152
+ const ok = !stoppedForBudget && waiting.size === 0 && plan.steps.every((s) => s.status === 'succeeded');
153
+ fire('plan-end');
154
+ return {
155
+ ok,
156
+ plan,
157
+ observations,
158
+ callsUsed,
159
+ ...(stoppedForBudget ? { stoppedForBudget: true } : {}),
160
+ ...(waiting.size > 0 ? { waiting: [...waiting] } : {}),
161
+ };
125
162
  }
@@ -54,6 +54,12 @@ export interface OrchestrateInput {
54
54
  runAgent?: ExecuteDeps['runAgent'];
55
55
  /** Phase 3.1: maps unregistered plan references to structured gaps. */
56
56
  resolveGaps?: PlannerInput['resolveGaps'];
57
+ /** Phase 3.5: the plan is settled — approved if approval was required, and inside the call budget.
58
+ * Fired BEFORE the first step runs, so a crash during wave 1 still resumes against a real plan
59
+ * instead of replanning from the bare goal. */
60
+ onPlan?: (plan: ExecutionPlan) => void;
61
+ /** Phase 3.5: forwarded verbatim to `executePlan` — the per-wave/per-batch commit points. */
62
+ onProgress?: ExecuteDeps['onProgress'];
57
63
  }
58
64
  export interface OrchestrateOutcome {
59
65
  status: OrchestrationStatus;
@@ -119,7 +119,10 @@ export async function orchestrate(input) {
119
119
  summary: `This task looks like ~${estCalls} model call(s), but the budget is ${maxCalls}. Raise the budget (AI_MAX_CALLS or maxCalls) and re-run, or run the phases that fit with --partial (then resume as you raise it).`,
120
120
  };
121
121
  }
122
- const exec = await executePlan(plan, { runSkill: input.runSkill, runTool: input.runTool, ...(input.runAgent ? { runAgent: input.runAgent } : {}), ...(reserve ? { reserve } : {}), ...(input.policy.maxParallelSteps ? { maxParallelSteps: input.policy.maxParallelSteps } : {}), ...(input.policy.limits ? { limits: input.policy.limits } : {}), ...(input.signal ? { signal: input.signal } : {}), ...(maxCalls !== undefined ? { callBudget: maxCalls } : {}) });
122
+ // Settled: approved (if required) and within budget. Everything a resume needs about the PLAN is
123
+ // known, and nothing has run yet.
124
+ input.onPlan?.(plan);
125
+ const exec = await executePlan(plan, { ...(input.onProgress ? { onProgress: input.onProgress } : {}), runSkill: input.runSkill, runTool: input.runTool, ...(input.runAgent ? { runAgent: input.runAgent } : {}), ...(reserve ? { reserve } : {}), ...(input.policy.maxParallelSteps ? { maxParallelSteps: input.policy.maxParallelSteps } : {}), ...(input.policy.limits ? { limits: input.policy.limits } : {}), ...(input.signal ? { signal: input.signal } : {}), ...(maxCalls !== undefined ? { callBudget: maxCalls } : {}) });
123
126
  allObservations.push(...exec.observations);
124
127
  if (exec.stoppedForBudget) {
125
128
  const done = plan.steps.filter((s) => s.status === 'succeeded').length;
@@ -132,6 +135,20 @@ export async function orchestrate(input) {
132
135
  summary: `Ran ${done} of ${plan.steps.length} step(s) within the ${maxCalls}-call budget. Raise the budget (AI_MAX_CALLS) and resume to continue.`,
133
136
  };
134
137
  }
138
+ // An agent is waiting for a human. This MUST precede the failure branch: replanning here would
139
+ // discard the plan the waiting task belongs to and orphan every sibling agent's completed work.
140
+ if (exec.waiting?.length) {
141
+ const asked = exec.observations.find((o) => o.code === 'agent-waiting');
142
+ const done = plan.steps.filter((st) => st.status === 'succeeded').length;
143
+ return {
144
+ status: 'waiting_for_clarification',
145
+ plan,
146
+ planHistory,
147
+ observations: [...allObservations],
148
+ clarification: asked?.error ?? 'an agent needs more information to continue',
149
+ summary: `Paused after ${done} of ${plan.steps.length} step(s): an agent needs an answer. Resume with it to continue.`,
150
+ };
151
+ }
135
152
  if (exec.ok)
136
153
  return { status: 'completed', plan, planHistory, observations: [...allObservations], summary: `completed "${plan.goal}" in ${plan.steps.length} step(s)` };
137
154
  // Failed: gather evidence and replan (orchestrate only) or stop.
@@ -268,6 +268,21 @@ export declare class Runtime {
268
268
  executions(): Execution[];
269
269
  /** plan/execute/orchestrate/agent/debug: run the orchestrator and persist a resumable Execution. */
270
270
  private runOrchestration;
271
+ /**
272
+ * THE mid-run persistence sink (Phase 3.5) — the only thing that writes an execution while it runs.
273
+ *
274
+ * Invariant 18 says everything needed for resume is on disk before the next wave starts, and the
275
+ * non-obvious part is WHAT that includes. Committing the plan, the completed steps and the agent
276
+ * records is not enough: the resume gate is `!recon.drifted && !!exec.plan`, and `recon` defaults to
277
+ * DRIFTED whenever `checkpoints` is empty. Since checkpoints were captured only after orchestration
278
+ * returned, a crash mid-run always drifted, always replanned, and re-ran every completed agent task —
279
+ * the exact thing this phase exists to prevent. So the sink captures a checkpoint too.
280
+ *
281
+ * Every refusal from the funnel ABORTS the run. A refused commit means another owner now owns this
282
+ * execution's fate; carrying on would call the same tools and burn the same model calls twice while
283
+ * that owner re-runs the identical steps, and every result would be discarded at the end anyway.
284
+ */
285
+ private runPersistence;
271
286
  /** Run `fn` while heartbeating the execution lease so a long run never lets the lease expire. */
272
287
  private withHeartbeat;
273
288
  private orchestrateInput;
@@ -349,6 +364,51 @@ export declare class Runtime {
349
364
  * an agent step would have failed every one of those steps.
350
365
  */
351
366
  private orchestrateRunners;
367
+ /**
368
+ * A bounded, fenced brief of what the agents have already established (Phase 3.5).
369
+ *
370
+ * On drift the plan is thrown away and the goal is replanned — but validated findings are facts about
371
+ * the WORKSPACE, not about the plan's structure, so discarding them silently would make the run redo
372
+ * work it had already proved. They are agent-authored text, so they cross a prompt boundary fenced,
373
+ * exactly like every other untrusted string.
374
+ */
375
+ private findingsBrief;
376
+ /**
377
+ * Fingerprint the MCP tools a plan actually references (Phase 3.5). An MCP server is remote and
378
+ * mutable: while a plan sits paused it can change a tool's input schema, change what it does, or drop
379
+ * it entirely — and the plan would then be resumed against a tool that is no longer the tool it was
380
+ * planned for. Hashing the live declaration makes that visible as ordinary drift.
381
+ *
382
+ * Non-MCP tools are deliberately absent: they are in-tree code covered by the config/skill hashes.
383
+ */
384
+ private mcpToolHashes;
385
+ /**
386
+ * Crash / pause / cancel reconciliation (Phase 3.5). A record left `running` or `created` describes a
387
+ * worker that no longer exists — the process died, or the parent stopped it — so it goes back to
388
+ * `queued` with an auditable reason. An interruption is deliberately NOT a state: the lifecycle does
389
+ * not grow, only the explanation does.
390
+ *
391
+ * This runs on the RESUME path and inside pause/cancel. Resume alone is not enough: `cancelExecution`
392
+ * writes a terminal status and resume early-returns on terminal, so a cancelled execution's `running`
393
+ * records would stay `running` on disk forever, unstamped and unexplained.
394
+ *
395
+ * Records that fail validation are preserved in place, never dropped — reconciling is not a licence to
396
+ * delete what this version could not parse.
397
+ */
398
+ private reconcileAgentTasks;
399
+ /**
400
+ * FIRST-WINS PENDING. `Execution.pending` is a single slot but two agents can be waiting at once, so
401
+ * the earliest-created waiting task claims it. The others are not lost: once this one is answered and
402
+ * the run continues, the next resume re-elects whichever task is still waiting — rediscovery, rather
403
+ * than a queue that has to be kept in sync with the records that are already the source of truth.
404
+ */
405
+ private electWaitingAgent;
406
+ /**
407
+ * Which persisted task, if any, a plan step should CONTINUE. Bound by step-input hash, never by step
408
+ * id: plan step ids (`s1`, `auto1`) are model-authored and recur across replans, so an id match would
409
+ * hand one step's completed inner work to a different step with the same id and a different input.
410
+ */
411
+ private agentResumeLookup;
352
412
  /** Abort a run that is in flight, recording WHY so a task can tell a pause from a cancellation. */
353
413
  private abortLiveRun;
354
414
  /** Mark an execution paused (it can be resumed later). */