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.
@@ -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,11 @@ 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 { AgentTasksRead } from './executions/agentTasks.js';
106
+ export { AGENT_TERMINAL, AGENT_RESUMABLE, agentTaskView } from './agents/task.js';
107
+ export type { AgentTaskView } from './agents/task.js';
108
+ export type { ProgressSnapshot } from './orchestration/executor.js';
104
109
  export { mcpToolId } from './mcp/toolAdapter.js';
105
110
  export { MCP_PROTOCOL_VERSION } from './mcp/protocol.js';
106
111
  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, agentTaskView } 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>;
@@ -34,7 +34,11 @@ async function runStepInner(step, deps) {
34
34
  if (step.agent) {
35
35
  if (!deps.runAgent)
36
36
  return { stepId: step.id, agent: step.agent, ok: false, code: 'agent-not-enabled', error: 'agent execution is not enabled' };
37
- return deps.runAgent(step, { reservation: stepCalls(step, Number.POSITIVE_INFINITY, deps.reserve), ...(deps.signal ? { signal: deps.signal } : {}) });
37
+ // `return await`, NOT a bare return. In an async function a returned promise is ADOPTED, not
38
+ // caught, so a bare return here escapes this try/catch entirely: a throwing agent rejected the
39
+ // whole executePlan instead of failing its own step, and left its wave-mates running unawaited.
40
+ // The skill and tool branches above both await, which is why only agents had this hole.
41
+ return await deps.runAgent(step, { reservation: stepCalls(step, Number.POSITIVE_INFINITY, deps.reserve), ...(deps.signal ? { signal: deps.signal } : {}) });
38
42
  }
39
43
  return { stepId: step.id, ok: false, error: 'step names neither a skill nor a tool' };
40
44
  }
@@ -55,8 +59,20 @@ export async function executePlan(plan, deps) {
55
59
  // a step carrying `agent` can enter this set, so with agents disabled it is provably empty and both
56
60
  // branches below render exactly as 2.6.0.
57
61
  const cancelled = new Set();
62
+ // Phase 3.5: steps whose agent asked for input. They are NOT failures — the step stays `pending`, its
63
+ // dependents are neither run nor skipped, and the run stops scheduling so it can be resumed.
64
+ const waiting = new Set();
58
65
  let callsUsed = 0; // model calls = executed skill steps (tool steps are free)
59
66
  let stoppedForBudget = false;
67
+ // Phase 3.5: observations are handed to the sink as a DELTA, so it appends instead of de-duplicating.
68
+ let emitted = 0;
69
+ const fire = (at) => {
70
+ if (!deps.onProgress)
71
+ return;
72
+ const delta = observations.slice(emitted);
73
+ emitted = observations.length;
74
+ deps.onProgress({ at, plan, observations: delta, callsUsed, waiting: [...waiting] });
75
+ };
60
76
  for (const wave of executionWaves(plan.steps)) {
61
77
  // A step runs only if all its dependencies succeeded; otherwise it is skipped.
62
78
  const runnable = [];
@@ -89,12 +105,16 @@ export async function executePlan(plan, deps) {
89
105
  runnable.push(step);
90
106
  }
91
107
  }
108
+ // Every skipped/cancelled verdict for this wave is now decided and nothing is in flight: the
109
+ // cheapest honest moment to get the wave's shape on disk, and it precedes the budget stop below.
110
+ fire('wave-partition');
92
111
  // Phase 22 budget gate: if running this wave's skill steps would exceed the call budget, stop BEFORE
93
112
  // it (phase granularity). Remaining steps keep status 'pending' and can resume with a raised budget.
94
113
  if (deps.callBudget !== undefined) {
95
114
  const waveCalls = foldCalls(runnable, deps.callBudget - callsUsed, deps.reserve);
96
115
  if (waveCalls > 0 && callsUsed + waveCalls > deps.callBudget) {
97
116
  stoppedForBudget = true;
117
+ fire('budget-stop');
98
118
  break;
99
119
  }
100
120
  }
@@ -104,22 +124,43 @@ export async function executePlan(plan, deps) {
104
124
  const results = await Promise.all(batch.map((s) => runStep(s, deps, limiters)));
105
125
  results.forEach((obs, j) => {
106
126
  const step = batch[j];
107
- step.status = obs.ok ? 'succeeded' : 'failed';
127
+ // A waiting agent is the one non-terminal step outcome: it is neither a success nor a failure, so
128
+ // it must not mark the step `failed` — that would skip every dependent and make the branch
129
+ // unrecoverable on resume, which is the opposite of what waiting means.
130
+ const isWaiting = !obs.ok && obs.code === 'agent-waiting';
131
+ step.status = isWaiting ? 'pending' : obs.ok ? 'succeeded' : 'failed';
108
132
  if (step.skill)
109
133
  callsUsed += 1;
110
134
  // Charging what an agent actually SPENT is the refund: an aborted or cancelled task reports its
111
135
  // metered total and the unspent reservation is simply never charged. No ledger, no refund path.
112
136
  else if (step.agent)
113
137
  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);
138
+ if (isWaiting)
139
+ waiting.add(step.id);
140
+ else {
141
+ if (!obs.ok && obs.code === 'cancelled')
142
+ cancelled.add(step.id);
143
+ if (!obs.ok)
144
+ failedOrSkipped.add(step.id);
145
+ }
118
146
  observations.push(obs);
119
147
  });
148
+ fire('batch');
120
149
  }
150
+ // Something is waiting for a human. Scheduling further waves would run work whose inputs may change
151
+ // once the answer arrives, so the run stops here and resumes when it is answered.
152
+ if (waiting.size > 0)
153
+ break;
121
154
  }
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 } : {}) };
155
+ // Success only if every step succeeded AND we didn't stop early for budget or for an answer.
156
+ const ok = !stoppedForBudget && waiting.size === 0 && plan.steps.every((s) => s.status === 'succeeded');
157
+ fire('plan-end');
158
+ return {
159
+ ok,
160
+ plan,
161
+ observations,
162
+ callsUsed,
163
+ ...(stoppedForBudget ? { stoppedForBudget: true } : {}),
164
+ ...(waiting.size > 0 ? { waiting: [...waiting] } : {}),
165
+ };
125
166
  }
@@ -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.
@@ -6,10 +6,15 @@
6
6
  * Token-streaming extension point: `response.delta` is reserved here (content-bearing, redacted like
7
7
  * everything else). Provider token streaming is NOT implemented in 1.0 — no delta is ever emitted yet —
8
8
  * but declaring the arm keeps the host/emitter architecture ready for it without a breaking change.
9
+ *
10
+ * EVOLUTION CONTRACT: this union GROWS. A new arm is additive at runtime — an existing consumer keeps
11
+ * receiving the events it knows — but it breaks an exhaustive `switch` at COMPILE time. Consumers must
12
+ * carry a default case. Arms added this way are announced in the CHANGELOG.
9
13
  */
10
14
  import type { Clock } from '../util/clock.js';
11
15
  import type { ErrorCategory } from '../types.js';
12
16
  import type { ExecutableMode, ModeSource, RuntimeMode, RuntimeStatus } from './types.js';
17
+ import type { AgentTaskState } from '../agents/task.js';
13
18
  export type RuntimeEvent = {
14
19
  type: 'runtime.started';
15
20
  ts: number;
@@ -54,6 +59,45 @@ export type RuntimeEvent = {
54
59
  ts: number;
55
60
  runId: string;
56
61
  text: string;
62
+ } | {
63
+ type: 'agent.task.started';
64
+ ts: number;
65
+ runId: string;
66
+ agentTaskId: string;
67
+ agentId: string;
68
+ stepId: string;
69
+ state: AgentTaskState;
70
+ } | {
71
+ type: 'agent.task.progress';
72
+ ts: number;
73
+ runId: string;
74
+ agentTaskId: string;
75
+ agentId: string;
76
+ stepId: string;
77
+ /** Per inner WAVE, never per inner step: a chatty agent would otherwise flood the ring buffer
78
+ * and push every other event out of it. */
79
+ innerSteps: {
80
+ total: number;
81
+ succeeded: number;
82
+ };
83
+ callsUsed: number;
84
+ toolCallsUsed: number;
85
+ } | {
86
+ type: 'agent.task.completed';
87
+ ts: number;
88
+ runId: string;
89
+ agentTaskId: string;
90
+ agentId: string;
91
+ stepId: string;
92
+ state: AgentTaskState;
93
+ innerSteps: {
94
+ total: number;
95
+ succeeded: number;
96
+ };
97
+ callsUsed: number;
98
+ toolCallsUsed: number;
99
+ /** How many findings were ADMITTED. Never the findings themselves. */
100
+ findings: number;
57
101
  };
58
102
  /** Distributive Omit so an event can be emitted without pre-stamping `ts`. */
59
103
  type WithoutTs<T> = T extends unknown ? Omit<T, 'ts'> : never;