ai-runtime-engine 2.8.0 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,53 @@ All notable changes to `ai-runtime` are documented here. The format follows
5
5
  Versioning](https://semver.org/). Development history and rationale live in
6
6
  [docs/DECISIONS.md](docs/DECISIONS.md) and [docs/PROGRESS.md](docs/PROGRESS.md).
7
7
 
8
+ ## [2.9.0] — 2026-09-05
9
+
10
+ Agent work becomes VISIBLE. Concurrent agent steps render as live lanes in the interactive terminal,
11
+ `/agents` lists and stops them, `/status` surfaces work that is unfinished, and the lifecycle events
12
+ they all read from are finally declared rather than cast. Additive: with `runtime.agents` absent no
13
+ agent event is ever emitted, so the lane region never opens and run output is identical to 2.8.0.
14
+
15
+ ### Added
16
+
17
+ - **Agent lifecycle event arms** — `agent.task.started`, `agent.task.progress`, `agent.task.completed`
18
+ on `RuntimeEvent`. Metadata only: ids, states and counts. `findings` is a COUNT, because a finding's
19
+ claim is agent-authored text and an event is the one surface a host may forward anywhere.
20
+ - **`agent.task.progress` per inner wave**, plus one at plan end — bounded at waves + 1 per task, never
21
+ per inner step.
22
+ - **A multi-lane display** (`src/cli/interactive/lanes.ts`) — one live row per concurrent agent, pure
23
+ and offline-testable: terminal width, colour and the animation tick are all parameters. Piped output,
24
+ `NO_COLOR` and dumb terminals get append-only transition lines instead of cursor movement.
25
+ - **`/agents`** — list agent tasks with progress, spend and finding counts; **`/agents stop <id>`**
26
+ stops one, and reports every outcome including the ones that change nothing.
27
+ - **`/status`** now names unfinished agent work, and says which task is waiting for an answer.
28
+ - **`AgentTaskView`** + `agentTaskView()` — the public read shape. The record's persistence internals
29
+ (inner plan, inner observations, raw findings, diagnostics) stay internal.
30
+
31
+ ### Changed
32
+
33
+ - **The event union's evolution contract is now documented**: a new arm is additive at runtime but
34
+ breaks an exhaustive `switch` at COMPILE time. Consumers must carry a default case. Arms added this
35
+ way are announced here.
36
+ - `AgentTaskView` (2.8.0) — the read RESULT of `parseAgentTasks` — is renamed `AgentTasksRead`, freeing
37
+ the name for the per-task view above. 2.8.0 was never published, so no released consumer names it.
38
+
39
+ ### Fixed
40
+
41
+ - **The agent events were a type lie.** Removing the `as never` cast made them *look* typed, but a
42
+ single object literal with a union-typed `type` compiles while carrying properties from every
43
+ constituent — so `agent.task.started` shipped four fields it does not declare. The emit now narrows to
44
+ one arm per branch, and a key-set test covers what the type system still cannot.
45
+ - **`runId` was missing, then wrong.** The events carried none at all, and the fix left
46
+ `orchestrateRunners` with a positional tail whose resume call site stamped the execution id instead.
47
+ It is now a required field of an options object.
48
+ - **`/agents stop` was cosmetic.** `AGENT_RESUMABLE` excludes `cancelled`, so a resume offered no record
49
+ for the stopped step and simply re-ran the agent — a second paid planning call after a human asked it
50
+ to stop. It also wedged an execution permanently when the stopped task held the pending slot, and
51
+ overwrote a concurrent writer's work.
52
+ - `/quit` was reachable but absent from `/help`, alongside `/agents`. A test now asserts every command
53
+ is documented.
54
+
8
55
  ## [2.8.0] — 2026-09-05
9
56
 
10
57
  Agent work now **survives a crash**. Until this release an agent's progress existed only in memory: the
@@ -750,6 +797,7 @@ Initial release: the provider-agnostic AI **router** — capability-based routin
750
797
  scoring, evidence validation, fallback, health tracking, learning-based scoring, multi-model verification,
751
798
  budgets, MCP tools, OpenAPI-based adapter generation, and the `AI` class + CLI.
752
799
 
800
+ [2.9.0]: https://github.com/pavankhandelwal21/ai-runtime/releases/tag/v2.9.0
753
801
  [2.8.0]: https://github.com/pavankhandelwal21/ai-runtime/releases/tag/v2.8.0
754
802
  [2.7.0]: https://github.com/pavankhandelwal21/ai-runtime/releases/tag/v2.7.0
755
803
  [2.6.0]: https://github.com/pavankhandelwal21/ai-runtime/releases/tag/v2.6.0
@@ -92,6 +92,43 @@ export interface AgentTaskRecord {
92
92
  export declare const AGENT_TERMINAL: ReadonlySet<AgentTaskState>;
93
93
  /** States a persisted task may be resumed from. Exactly the complement of AGENT_TERMINAL. */
94
94
  export declare const AGENT_RESUMABLE: ReadonlySet<AgentTaskState>;
95
+ /**
96
+ * The PUBLIC read shape of an agent task (Phase 3.6) — what a host or a status line needs to render
97
+ * progress, and nothing else.
98
+ *
99
+ * It exists so the record's internals are not frozen into the public API. `innerPlan`,
100
+ * `innerObservations`, `innerCheckpoint`, raw `findings` and `diagnostics` are all persistence detail:
101
+ * they change as the phase evolves, they carry agent-authored content, and a host that reached into
102
+ * them would break on the next schema version. This shape is counts, ids and states — safe to freeze.
103
+ */
104
+ export interface AgentTaskView {
105
+ agentTaskId: string;
106
+ agentId: string;
107
+ stepId: string;
108
+ state: AgentTaskState;
109
+ innerSteps: {
110
+ total: number;
111
+ succeeded: number;
112
+ };
113
+ /** What the step reserved from the run's call budget, and what it has actually spent. */
114
+ callsReserved: number;
115
+ callsUsed: number;
116
+ toolCallsUsed: number;
117
+ /** How many findings were ADMITTED — never the findings themselves. */
118
+ findings: number;
119
+ /** Why a non-terminal task stopped, when it was stopped by something outside itself. */
120
+ interruption?: {
121
+ kind: 'crash' | 'pause' | 'parent-cancel';
122
+ at: number;
123
+ };
124
+ /** The question this task is waiting on, clamped. Present only while it is waiting. */
125
+ question?: string;
126
+ startedAt?: number;
127
+ endedAt?: number;
128
+ updatedAt: number;
129
+ }
130
+ /** Project a record onto the public view. The ONE place that mapping lives. */
131
+ export declare function agentTaskView(record: AgentTaskRecord): AgentTaskView;
95
132
  /** One row per agent-task state. Read this table; never re-derive a projection at a call site. */
96
133
  export interface ProjectionRow {
97
134
  step: PlanStepStatus;
@@ -9,6 +9,7 @@
9
9
  * normal state and records WHY in `interruption`, so the reason is auditable without growing the
10
10
  * lifecycle.
11
11
  */
12
+ import { flattenClamp } from '../util/flatten.js';
12
13
  /** Terminal agent-task states: reached once, never left. A commit may not move a task out of one. */
13
14
  export const AGENT_TERMINAL = new Set(['completed', 'failed', 'cancelled']);
14
15
  /** States a persisted task may be resumed from. Exactly the complement of AGENT_TERMINAL. */
@@ -20,6 +21,27 @@ export const AGENT_RESUMABLE = new Set([
20
21
  'waiting_for_clarification',
21
22
  'paused',
22
23
  ]);
24
+ /** Project a record onto the public view. The ONE place that mapping lives. */
25
+ export function agentTaskView(record) {
26
+ return {
27
+ agentTaskId: record.agentTaskId,
28
+ agentId: record.agentId,
29
+ stepId: record.stepId,
30
+ state: record.state,
31
+ innerSteps: record.innerSteps,
32
+ callsReserved: record.callsReserved,
33
+ callsUsed: record.callsUsed,
34
+ toolCallsUsed: record.toolCallsUsed,
35
+ findings: record.findings.length,
36
+ ...(record.interruption ? { interruption: { kind: record.interruption.kind, at: record.interruption.at } } : {}),
37
+ // The ONE untrusted string in this shape: model-authored, read back from a file some other
38
+ // version wrote, and rendered in a terminal. `.slice()` truncates but strips nothing.
39
+ ...(record.pendingInner ? { question: flattenClamp(record.pendingInner.question, 240) } : {}),
40
+ ...(record.startedAt !== undefined ? { startedAt: record.startedAt } : {}),
41
+ ...(record.endedAt !== undefined ? { endedAt: record.endedAt } : {}),
42
+ updatedAt: record.updatedAt,
43
+ };
44
+ }
23
45
  export const AGENT_TASK_PROJECTION = {
24
46
  created: { step: 'pending', exec: 'running', reachableIn34: true },
25
47
  queued: { step: 'pending', exec: 'running', reachableIn34: true },
@@ -55,8 +55,11 @@ export interface AgentWorkerDeps {
55
55
  ref?: ArtifactRef;
56
56
  unavailable: boolean;
57
57
  };
58
+ /** Lifecycle notifications. `agent.task.progress` fires once per inner WAVE — never per inner step:
59
+ * the inner executor runs one step at a time, so per-step would emit one event per step and a chatty
60
+ * agent would push every other event out of the emitter's ring buffer. */
58
61
  emit?: (event: {
59
- type: 'agent.task.started' | 'agent.task.completed';
62
+ type: 'agent.task.started' | 'agent.task.progress' | 'agent.task.completed';
60
63
  record: AgentTaskRecord;
61
64
  }) => void;
62
65
  parentSignal?: AbortSignal;
@@ -66,6 +69,11 @@ export interface AgentWorkerDeps {
66
69
  executionId?: string;
67
70
  planVersion: number;
68
71
  };
72
+ /** Phase 3.6: hands the caller a way to stop THIS task specifically, for as long as it is running.
73
+ * Without it the only lever is the run's own controller, which stops every agent at once. */
74
+ registerAbort?: (agentTaskId: string, abort: () => void) => void;
75
+ /** Phase 3.6: called when the task is no longer running, so the abort handle is not kept forever. */
76
+ releaseAbort?: (agentTaskId: string) => void;
69
77
  /** Phase 3.5: called whenever the record MATERIALLY changes, so inner progress reaches disk while the
70
78
  * agent is still running. Without a seam inside the inner run, everything between `running` and
71
79
  * `finish()` — the inner plan, every completed inner step, every inner model call — is lost to a
@@ -120,6 +120,7 @@ export async function runAgentTask(step, envelope, definition, deps) {
120
120
  const skillResults = [];
121
121
  const finish = (state, failure) => {
122
122
  deps.parentSignal?.removeEventListener('abort', onParentAbort);
123
+ deps.releaseAbort?.(record.agentTaskId);
123
124
  record.state = state;
124
125
  record.endedAt = deps.clock.now();
125
126
  record.updatedAt = record.endedAt;
@@ -165,6 +166,7 @@ export async function runAgentTask(step, envelope, definition, deps) {
165
166
  record.state = 'running';
166
167
  record.startedAt = deps.clock.now();
167
168
  record.updatedAt = record.startedAt;
169
+ deps.registerAbort?.(record.agentTaskId, () => child.abort());
168
170
  deps.emit?.({ type: 'agent.task.started', record });
169
171
  deps.onRecord?.(record);
170
172
  // (6) THE TOOL SEAM - defense in depth behind narrowEnvelope. Always a structured denial, never a
@@ -261,7 +263,15 @@ export async function runAgentTask(step, envelope, definition, deps) {
261
263
  record.callsUsed = priorCalls + calls;
262
264
  record.toolCallsUsed = priorToolCalls + toolCalls;
263
265
  record.updatedAt = deps.clock.now();
266
+ record.innerSteps = { total: snap.plan.steps.length, succeeded: record.innerCompletedSteps.length };
264
267
  deps.onRecord?.(record);
268
+ // One event per WAVE, plus one at the end. `wave-partition` fires BEFORE the wave runs, so on
269
+ // its own it reports the count from before — for a single-wave inner plan (the common case)
270
+ // that means the only progress event says 0/N, which is exactly what `started` already said,
271
+ // and a display would read 0/N until the task simply finished. `plan-end` fires once and is the
272
+ // only snapshot carrying the final count. Still not per-step: waves + 1 events per task.
273
+ if (snap.at === 'wave-partition' || snap.at === 'plan-end')
274
+ deps.emit?.({ type: 'agent.task.progress', record });
265
275
  },
266
276
  runSkill: async (id, i) => {
267
277
  const out = await deps.runSkill(id, i, { permissions: envelope.permissions, signal: child.signal, ai: metered });
package/dist/cli/cli.js CHANGED
@@ -21,7 +21,7 @@ import { mcpCommand, mcpAddCommand, mcpRemoveCommand, mcpEnableCommand, mcpTestC
21
21
  import { startRepl } from './interactive/repl.js';
22
22
  import { printError } from './render.js';
23
23
  const program = new Command();
24
- program.name('ai-runtime').description('Universal, provider-agnostic AI Runtime & Orchestration Platform').version('2.8.0');
24
+ program.name('ai-runtime').description('Universal, provider-agnostic AI Runtime & Orchestration Platform').version('2.9.0');
25
25
  const configOpt = ['-c, --config <path>', 'path to an ai-runtime config file'];
26
26
  // Bare `ai-runtime` (no subcommand) opens the interactive terminal. `allowExcessArguments(false)` keeps
27
27
  // a mistyped subcommand (e.g. `ai-runtime porviders`) failing fast instead of silently opening the REPL.
@@ -13,6 +13,7 @@ import { print, printChunk, printError } from '../render.js';
13
13
  import { summarizeWorkspace } from '../../runtime/workspace/workspace.js';
14
14
  import { ReplSession, SLASH_COMMANDS } from './session.js';
15
15
  import { makeCompleter } from './complete.js';
16
+ import { LaneSet, laneLines, frameDiff, frameRows } from './lanes.js';
16
17
  import { colorEnabled, bold, cyan, dim, gray, SPINNER_FRAMES, statusLine, clearLine } from './ansi.js';
17
18
  function banner(rt, colors) {
18
19
  const ws = rt.workspaceInfo();
@@ -96,6 +97,22 @@ export async function startRepl(configPath) {
96
97
  };
97
98
  let streamedThisRun = false;
98
99
  let spinner;
100
+ // Phase 3.6: concurrent agent steps render as live lanes. The region opens on the first agent event —
101
+ // long after the spinner has been stopped by the very first event of the run — so the two never own
102
+ // the cursor at the same time. With agents disabled no agent event is ever emitted, so the region
103
+ // never opens and output is byte-identical.
104
+ const lanes = new LaneSet();
105
+ let laneRows = 0;
106
+ let laneTick = 0;
107
+ // A late event — one arriving after the run has already returned — must not open a region on top of
108
+ // the prompt and leave `laneRows` set for the NEXT run's cursor arithmetic to walk into.
109
+ let runInFlight = false;
110
+ const closeLanes = () => {
111
+ if (laneRows > 0)
112
+ printChunk(frameDiff(laneRows, [], colors));
113
+ laneRows = 0;
114
+ lanes.clear();
115
+ };
99
116
  // Live progress + token streaming. Events are already redacted; a throwing observer can't break a run.
100
117
  rt.on((e) => {
101
118
  spinner?.stop(); // any event means work has started producing output — drop the spinner first
@@ -104,6 +121,30 @@ export async function startRepl(configPath) {
104
121
  streamedThisRun = true;
105
122
  return;
106
123
  }
124
+ const lane = lanes.observe(e);
125
+ if (lane) {
126
+ if (!runInFlight)
127
+ return; // see `runInFlight`
128
+ laneTick += 1;
129
+ if (colors) {
130
+ // The region must fit the viewport: `cursorUp` saturates at row 0, so a frame taller than the
131
+ // pane can never walk back to its own top and would redraw itself downward forever.
132
+ const budget = Math.max(1, (process.stdout.rows || 24) - 2);
133
+ const all = lanes.list();
134
+ const shown = all.length > budget ? all.slice(0, budget - 1) : all;
135
+ const lines = laneLines(shown, { cols: process.stdout.columns || 80, colors, tick: laneTick });
136
+ if (all.length > shown.length)
137
+ lines.push(dim(` … ${all.length - shown.length} more agent task(s)`, colors));
138
+ printChunk(frameDiff(laneRows, lines, colors));
139
+ laneRows = frameRows(lines);
140
+ }
141
+ else if (e.type !== 'agent.task.progress') {
142
+ // Piped, NO_COLOR or a dumb terminal: cursor games would be garbage in a log file, so report
143
+ // the transitions append-only instead. Progress ticks are dropped — in a log they are noise.
144
+ print(` · agent ${lane.agentId} @ ${lane.stepId}: ${lane.state}`);
145
+ }
146
+ return;
147
+ }
107
148
  const line = progressLine(e, colors);
108
149
  if (line)
109
150
  print(line);
@@ -125,18 +166,29 @@ export async function startRepl(configPath) {
125
166
  let result;
126
167
  streamedThisRun = false;
127
168
  spinner = startSpinner(colors);
169
+ runInFlight = true;
170
+ // Readline keeps echoing keypresses while the handler awaits, and the cursor sits inside the lane
171
+ // region — so a keystroke mid-run writes into the frame and its clear-to-end-of-screen erases the
172
+ // rows below it. Pausing buffers the input instead; it is resumed on both exits below.
173
+ rl.pause();
128
174
  try {
129
175
  result = await session.handle(line);
130
176
  }
131
177
  catch (err) {
178
+ runInFlight = false;
179
+ rl.resume();
132
180
  spinner.stop();
181
+ closeLanes();
133
182
  if (streamedThisRun)
134
183
  process.stdout.write('\n');
135
184
  printError(`error: ${err instanceof Error ? err.message : String(err)}`);
136
185
  rl.prompt();
137
186
  continue;
138
187
  }
188
+ runInFlight = false;
189
+ rl.resume();
139
190
  spinner.stop();
191
+ closeLanes(); // hand the cursor back before any result line is printed
140
192
  if (streamedThisRun)
141
193
  process.stdout.write('\n'); // close the streamed line before printing result lines
142
194
  if (result.clear)
@@ -11,7 +11,10 @@ export interface HandleResult {
11
11
  clear?: boolean;
12
12
  }
13
13
  /** Top-level slash commands, for REPL tab-completion (Phase 21b). Kept in sync with the `handle` dispatch. */
14
- export declare const SLASH_COMMANDS: readonly ["help", "status", "info", "doctor", "cleanup", "mode", "compare", "models", "config", "providers", "tools", "capabilities", "mcp", "skills", "memory", "conversations", "executions", "resume", "resume-execution", "pause", "cancel", "approve", "deny", "learning", "feedback", "permissions", "budget", "stream", "dry-run", "clear", "exit", "quit"];
14
+ export declare const SLASH_COMMANDS: readonly ["help", "status", "info", "doctor", "cleanup", "mode", "compare", "models", "config", "providers", "tools", "capabilities", "mcp", "skills", "memory", "conversations", "executions", "agents", "resume", "resume-execution", "pause", "cancel", "approve", "deny", "learning", "feedback", "permissions", "budget", "stream", "dry-run", "clear", "exit", "quit"];
15
+ /** Exported so a test can prove every reachable command is documented — the three touch points below
16
+ * are synced by hand, and `/agents` shipped tab-completable but absent from this list. */
17
+ export declare const HELP: string[];
15
18
  export declare class ReplSession {
16
19
  private readonly runtime;
17
20
  private mode;
@@ -36,6 +39,10 @@ export declare class ReplSession {
36
39
  private learning;
37
40
  private permissions;
38
41
  private conversationsList;
42
+ /** Agent tasks across this project's executions, newest first. */
43
+ private agentsList;
44
+ /** Stop one agent task. Every outcome is reported — a stop that looks like nothing happened is a bug. */
45
+ private agentStop;
39
46
  private executionsList;
40
47
  private resumeExecution;
41
48
  private resume;
@@ -13,13 +13,17 @@ import { actionCapabilityRows, renderActionCapabilities, renderCapabilityGaps }
13
13
  import { renderMcpList, renderMcpDetail } from '../commands/mcp.js';
14
14
  import { candidatesFrom, deriveCapabilitiesOffline } from '../../runtime/planning/deriveCapabilities.js';
15
15
  import { displaySafe } from '../render.js';
16
+ import { AGENT_TERMINAL } from '../../agents/task.js';
17
+ import { TERMINAL as EXEC_TERMINAL } from '../../executions/execution.js';
16
18
  /** Top-level slash commands, for REPL tab-completion (Phase 21b). Kept in sync with the `handle` dispatch. */
17
19
  export const SLASH_COMMANDS = [
18
20
  'help', 'status', 'info', 'doctor', 'cleanup', 'mode', 'compare', 'models', 'config', 'providers', 'tools', 'capabilities', 'mcp',
19
- 'skills', 'memory', 'conversations', 'executions', 'resume', 'resume-execution', 'pause', 'cancel', 'approve',
21
+ 'skills', 'memory', 'conversations', 'executions', 'agents', 'resume', 'resume-execution', 'pause', 'cancel', 'approve',
20
22
  'deny', 'learning', 'feedback', 'permissions', 'budget', 'stream', 'dry-run', 'clear', 'exit', 'quit',
21
23
  ];
22
- const HELP = [
24
+ /** Exported so a test can prove every reachable command is documented — the three touch points below
25
+ * are synced by hand, and `/agents` shipped tab-completable but absent from this list. */
26
+ export const HELP = [
23
27
  'Commands:',
24
28
  ' /help show this help',
25
29
  ' /status workspace, mode, provider count',
@@ -49,6 +53,8 @@ const HELP = [
49
53
  ' /conversations list recent conversations',
50
54
  ' /resume <id> resume a conversation',
51
55
  ' /executions list persisted executions',
56
+ ' /agents list agent tasks (state, progress, spend, findings)',
57
+ ' /agents stop <id> stop one agent task',
52
58
  ' /resume-execution <id> resume an execution',
53
59
  ' /approve <id> approve an execution waiting for approval, then continue',
54
60
  ' /deny <id> deny an execution waiting for approval (cancels it)',
@@ -59,7 +65,7 @@ const HELP = [
59
65
  ' /stream toggle token-by-token streaming of answers',
60
66
  ' /budget show the call/cost budget (AI_MAX_CALLS / AI_MAX_COST_USD)',
61
67
  ' /clear clear the screen',
62
- ' /exit leave the session',
68
+ ' /exit, /quit leave the session',
63
69
  '',
64
70
  'Anything else is sent to the runtime as a request.',
65
71
  ];
@@ -209,6 +215,8 @@ export class ReplSession {
209
215
  return this.resume(args[0]);
210
216
  case 'executions':
211
217
  return this.executionsList();
218
+ case 'agents':
219
+ return args[0] === 'stop' ? this.agentStop(args[1]) : this.agentsList();
212
220
  case 'resume-execution':
213
221
  return this.resumeExecution(args[0]);
214
222
  case 'approve':
@@ -327,6 +335,32 @@ export class ReplSession {
327
335
  return { lines: ['no conversations yet.'] };
328
336
  return { lines: ['Recent conversations:', ...list.map((c, i) => ` ${i + 1}. ${c.id} ${c.title} (${c.turns} turns)`)] };
329
337
  }
338
+ /** Agent tasks across this project's executions, newest first. */
339
+ agentsList() {
340
+ if (!this.runtime.executionStore.enabled)
341
+ return { lines: ['agent tasks are not persisted (stateless mode).'] };
342
+ const tasks = this.runtime.agentTasks().slice(0, 12);
343
+ if (!tasks.length)
344
+ return { lines: ['no agent tasks yet.', '(agents run when `runtime.agents.enabled` is set and a plan delegates to one)'] };
345
+ // A waiting task's question goes on its own line: it is the one thing the user must read to act.
346
+ const lines = tasks.flatMap((t) => {
347
+ const steps = `${t.innerSteps.succeeded}/${t.innerSteps.total}`;
348
+ const spend = `${t.callsUsed}/${t.callsReserved} call(s), ${t.toolCallsUsed} tool call(s)`;
349
+ const why = t.interruption ? ` (${t.interruption.kind})` : '';
350
+ // `stepId` is planner-authored and `question` is model-authored: both go through displaySafe,
351
+ // like every other untrusted string this file renders.
352
+ const row = ` ${t.agentTaskId} [${t.state}] ${t.agentId} @ ${displaySafe(t.stepId, 40)} ${steps} inner step(s), ${spend}, ${t.findings} finding(s)${why}`;
353
+ return t.question ? [row, ` ? ${displaySafe(t.question, 200)}`] : [row];
354
+ });
355
+ return { lines: ['Agent tasks:', ...lines, '', 'stop one with /agents stop <agent-task-id>'] };
356
+ }
357
+ /** Stop one agent task. Every outcome is reported — a stop that looks like nothing happened is a bug. */
358
+ agentStop(id) {
359
+ if (!id)
360
+ return { lines: ['usage: /agents stop <agent-task-id>'] };
361
+ const r = this.runtime.stopAgentTask(id);
362
+ return { lines: [`${r.ok ? 'stopped' : 'not stopped'} ${id}: ${r.reason}${r.state ? ` (state: ${r.state})` : ''}`] };
363
+ }
330
364
  executionsList() {
331
365
  if (!this.runtime.executionStore.enabled)
332
366
  return { lines: ['executions are disabled (stateless mode).'] };
@@ -362,13 +396,31 @@ export class ReplSession {
362
396
  status() {
363
397
  const ws = this.runtime.workspaceInfo();
364
398
  const providers = this.runtime.ai.providers();
365
- return {
366
- lines: [
367
- `workspace: ${ws?.name ?? '(none)'}${ws?.git.branch ? ` @ ${ws.git.branch}` : ''}`,
368
- `mode: ${this.mode}`,
369
- `providers: ${providers.length} configured (${providers.filter((p) => p.enabled).length} enabled)`,
370
- ],
371
- };
399
+ const lines = [
400
+ `workspace: ${ws?.name ?? '(none)'}${ws?.git.branch ? ` @ ${ws.git.branch}` : ''}`,
401
+ `mode: ${this.mode}`,
402
+ `providers: ${providers.length} configured (${providers.filter((p) => p.enabled).length} enabled)`,
403
+ ];
404
+ // Phase 3.6: agent work is the one thing that can be UNFINISHED and invisible — a task left waiting
405
+ // or interrupted holds its execution up, so /status names it rather than leaving the user to think
406
+ // to run /agents. Absent entirely when nothing has ever delegated, so the flag-off output is
407
+ // unchanged.
408
+ // Only executions that are themselves still going: a non-terminal task on a finished execution is
409
+ // stale bookkeeping, and reporting it as "unfinished work" forever would train the user to ignore
410
+ // this line — which is the one line that has to be trusted when something IS waiting.
411
+ const live = this.runtime.executionStore.enabled ? this.runtime.executions().filter((e) => !EXEC_TERMINAL.has(e.status)) : [];
412
+ const tasks = live.flatMap((e) => this.runtime.agentTasks(e.id));
413
+ if (tasks.length) {
414
+ const unfinished = tasks.filter((t) => !AGENT_TERMINAL.has(t.state));
415
+ const waiting = unfinished.filter((t) => t.state === 'waiting_for_clarification' || t.state === 'waiting_for_input');
416
+ lines.push(`agents: ${tasks.length} task(s), ${unfinished.length} unfinished${waiting.length ? `, ${waiting.length} waiting for an answer` : ''}`);
417
+ for (const t of unfinished.slice(0, 3)) {
418
+ lines.push(` ${t.agentTaskId} [${t.state}] ${displaySafe(t.agentId, 24)} @ ${displaySafe(t.stepId, 16)} ${t.innerSteps.succeeded}/${t.innerSteps.total} step(s)`);
419
+ }
420
+ if (unfinished.length > 3)
421
+ lines.push(` … ${unfinished.length - 3} more (/agents)`);
422
+ }
423
+ return { lines };
372
424
  }
373
425
  setOrShowMode(next) {
374
426
  if (!next)
@@ -35,11 +35,11 @@ export declare const persistedAgentTask: z.ZodObject<{
35
35
  detail: z.ZodOptional<z.ZodString>;
36
36
  }, "strict", z.ZodTypeAny, {
37
37
  at: number;
38
- kind: "pause" | "crash" | "parent-cancel";
38
+ kind: "crash" | "pause" | "parent-cancel";
39
39
  detail?: string | undefined;
40
40
  }, {
41
41
  at: number;
42
- kind: "pause" | "crash" | "parent-cancel";
42
+ kind: "crash" | "pause" | "parent-cancel";
43
43
  detail?: string | undefined;
44
44
  }>>;
45
45
  provenance: z.ZodObject<{
@@ -361,42 +361,14 @@ export declare const persistedAgentTask: z.ZodObject<{
361
361
  }, "strict", z.ZodTypeAny, {
362
362
  v: 1;
363
363
  state: "running" | "failed" | "completed" | "waiting_for_clarification" | "cancelled" | "paused" | "waiting_for_input" | "created" | "queued";
364
- createdAt: number;
365
- updatedAt: number;
366
364
  stepId: string;
367
365
  agentTaskId: string;
368
366
  callsUsed: number;
369
- envelopeHash: string;
370
367
  agentId: string;
371
- provenance: {
372
- planVersion: number;
373
- executionId?: string | undefined;
374
- };
375
- agentDefHash: string;
376
- stepInputHash: string;
377
- attempt: number;
378
- innerCompletedSteps: string[];
379
- innerObservations: z.objectOutputType<{
380
- stepId: z.ZodString;
381
- skill: z.ZodOptional<z.ZodString>;
382
- tool: z.ZodOptional<z.ZodString>;
383
- agent: z.ZodOptional<z.ZodString>;
384
- agentTaskId: z.ZodOptional<z.ZodString>;
385
- ok: z.ZodBoolean;
386
- output: z.ZodOptional<z.ZodString>;
387
- error: z.ZodOptional<z.ZodString>;
388
- code: z.ZodOptional<z.ZodString>;
389
- data: z.ZodOptional<z.ZodUnknown>;
390
- artifacts: z.ZodOptional<z.ZodArray<z.ZodUnknown, "many">>;
391
- callsUsed: z.ZodOptional<z.ZodNumber>;
392
- }, z.ZodTypeAny, "passthrough">[];
393
- innerObservationsOmitted: number;
394
368
  innerSteps: {
395
369
  succeeded: number;
396
370
  total: number;
397
371
  };
398
- callsReserved: number;
399
- callsRefunded: number;
400
372
  toolCallsUsed: number;
401
373
  findings: z.objectOutputType<{
402
374
  id: z.ZodString;
@@ -440,7 +412,42 @@ export declare const persistedAgentTask: z.ZodObject<{
440
412
  supersededBy: z.ZodOptional<z.ZodString>;
441
413
  createdAt: z.ZodNumber;
442
414
  }, z.ZodTypeAny, "passthrough">[];
415
+ createdAt: number;
416
+ updatedAt: number;
417
+ envelopeHash: string;
418
+ provenance: {
419
+ planVersion: number;
420
+ executionId?: string | undefined;
421
+ };
422
+ agentDefHash: string;
423
+ stepInputHash: string;
424
+ attempt: number;
425
+ innerCompletedSteps: string[];
426
+ innerObservations: z.objectOutputType<{
427
+ stepId: z.ZodString;
428
+ skill: z.ZodOptional<z.ZodString>;
429
+ tool: z.ZodOptional<z.ZodString>;
430
+ agent: z.ZodOptional<z.ZodString>;
431
+ agentTaskId: z.ZodOptional<z.ZodString>;
432
+ ok: z.ZodBoolean;
433
+ output: z.ZodOptional<z.ZodString>;
434
+ error: z.ZodOptional<z.ZodString>;
435
+ code: z.ZodOptional<z.ZodString>;
436
+ data: z.ZodOptional<z.ZodUnknown>;
437
+ artifacts: z.ZodOptional<z.ZodArray<z.ZodUnknown, "many">>;
438
+ callsUsed: z.ZodOptional<z.ZodNumber>;
439
+ }, z.ZodTypeAny, "passthrough">[];
440
+ innerObservationsOmitted: number;
441
+ callsReserved: number;
442
+ callsRefunded: number;
443
443
  diagnostics: unknown[];
444
+ interruption?: {
445
+ at: number;
446
+ kind: "crash" | "pause" | "parent-cancel";
447
+ detail?: string | undefined;
448
+ } | undefined;
449
+ startedAt?: number | undefined;
450
+ endedAt?: number | undefined;
444
451
  innerPlan?: {
445
452
  id: string;
446
453
  version: number;
@@ -458,13 +465,6 @@ export declare const persistedAgentTask: z.ZodObject<{
458
465
  goal: string;
459
466
  reason?: string | undefined;
460
467
  } | undefined;
461
- startedAt?: number | undefined;
462
- endedAt?: number | undefined;
463
- interruption?: {
464
- at: number;
465
- kind: "pause" | "crash" | "parent-cancel";
466
- detail?: string | undefined;
467
- } | undefined;
468
468
  innerPlanVersion?: number | undefined;
469
469
  innerCheckpoint?: z.objectOutputType<{
470
470
  at: z.ZodNumber;
@@ -488,42 +488,14 @@ export declare const persistedAgentTask: z.ZodObject<{
488
488
  }, {
489
489
  v: 1;
490
490
  state: "running" | "failed" | "completed" | "waiting_for_clarification" | "cancelled" | "paused" | "waiting_for_input" | "created" | "queued";
491
- createdAt: number;
492
- updatedAt: number;
493
491
  stepId: string;
494
492
  agentTaskId: string;
495
493
  callsUsed: number;
496
- envelopeHash: string;
497
494
  agentId: string;
498
- provenance: {
499
- planVersion: number;
500
- executionId?: string | undefined;
501
- };
502
- agentDefHash: string;
503
- stepInputHash: string;
504
- attempt: number;
505
- innerCompletedSteps: string[];
506
- innerObservations: z.objectInputType<{
507
- stepId: z.ZodString;
508
- skill: z.ZodOptional<z.ZodString>;
509
- tool: z.ZodOptional<z.ZodString>;
510
- agent: z.ZodOptional<z.ZodString>;
511
- agentTaskId: z.ZodOptional<z.ZodString>;
512
- ok: z.ZodBoolean;
513
- output: z.ZodOptional<z.ZodString>;
514
- error: z.ZodOptional<z.ZodString>;
515
- code: z.ZodOptional<z.ZodString>;
516
- data: z.ZodOptional<z.ZodUnknown>;
517
- artifacts: z.ZodOptional<z.ZodArray<z.ZodUnknown, "many">>;
518
- callsUsed: z.ZodOptional<z.ZodNumber>;
519
- }, z.ZodTypeAny, "passthrough">[];
520
- innerObservationsOmitted: number;
521
495
  innerSteps: {
522
496
  succeeded: number;
523
497
  total: number;
524
498
  };
525
- callsReserved: number;
526
- callsRefunded: number;
527
499
  toolCallsUsed: number;
528
500
  findings: z.objectInputType<{
529
501
  id: z.ZodString;
@@ -567,7 +539,42 @@ export declare const persistedAgentTask: z.ZodObject<{
567
539
  supersededBy: z.ZodOptional<z.ZodString>;
568
540
  createdAt: z.ZodNumber;
569
541
  }, z.ZodTypeAny, "passthrough">[];
542
+ createdAt: number;
543
+ updatedAt: number;
544
+ envelopeHash: string;
545
+ provenance: {
546
+ planVersion: number;
547
+ executionId?: string | undefined;
548
+ };
549
+ agentDefHash: string;
550
+ stepInputHash: string;
551
+ attempt: number;
552
+ innerCompletedSteps: string[];
553
+ innerObservations: z.objectInputType<{
554
+ stepId: z.ZodString;
555
+ skill: z.ZodOptional<z.ZodString>;
556
+ tool: z.ZodOptional<z.ZodString>;
557
+ agent: z.ZodOptional<z.ZodString>;
558
+ agentTaskId: z.ZodOptional<z.ZodString>;
559
+ ok: z.ZodBoolean;
560
+ output: z.ZodOptional<z.ZodString>;
561
+ error: z.ZodOptional<z.ZodString>;
562
+ code: z.ZodOptional<z.ZodString>;
563
+ data: z.ZodOptional<z.ZodUnknown>;
564
+ artifacts: z.ZodOptional<z.ZodArray<z.ZodUnknown, "many">>;
565
+ callsUsed: z.ZodOptional<z.ZodNumber>;
566
+ }, z.ZodTypeAny, "passthrough">[];
567
+ innerObservationsOmitted: number;
568
+ callsReserved: number;
569
+ callsRefunded: number;
570
570
  diagnostics: unknown[];
571
+ interruption?: {
572
+ at: number;
573
+ kind: "crash" | "pause" | "parent-cancel";
574
+ detail?: string | undefined;
575
+ } | undefined;
576
+ startedAt?: number | undefined;
577
+ endedAt?: number | undefined;
571
578
  innerPlan?: {
572
579
  id: string;
573
580
  version: number;
@@ -585,13 +592,6 @@ export declare const persistedAgentTask: z.ZodObject<{
585
592
  goal: string;
586
593
  reason?: string | undefined;
587
594
  } | undefined;
588
- startedAt?: number | undefined;
589
- endedAt?: number | undefined;
590
- interruption?: {
591
- at: number;
592
- kind: "pause" | "crash" | "parent-cancel";
593
- detail?: string | undefined;
594
- } | undefined;
595
595
  innerPlanVersion?: number | undefined;
596
596
  innerCheckpoint?: z.objectInputType<{
597
597
  at: z.ZodNumber;
@@ -613,15 +613,16 @@ export declare const persistedAgentTask: z.ZodObject<{
613
613
  message: z.ZodString;
614
614
  }, z.ZodTypeAny, "passthrough"> | undefined;
615
615
  }>;
616
- export interface AgentTaskView {
616
+ /** The result of reading agent tasks back: what parsed, and how much did not. */
617
+ export interface AgentTasksRead {
617
618
  tasks: AgentTaskRecord[];
618
- /** Records that failed validation and were left out of the view (still on disk, untouched). */
619
+ /** Records that failed validation and were left out (still on disk, untouched). */
619
620
  dropped: number;
620
621
  }
621
622
  /**
622
623
  * Validate the agent tasks on an execution. Pure: it reads, it never writes. Callers that then persist
623
624
  * the execution are choosing to drop the unparseable records — reading alone never does.
624
625
  */
625
- export declare function parseAgentTasks(exec: Pick<Execution, 'agentTasks'>): AgentTaskView;
626
+ export declare function parseAgentTasks(exec: Pick<Execution, 'agentTasks'>): AgentTasksRead;
626
627
  /** One task by id, validated — the lookup every resume path uses. */
627
628
  export declare function findAgentTask(exec: Pick<Execution, 'agentTasks'>, agentTaskId: string): AgentTaskRecord | undefined;
package/dist/index.d.ts CHANGED
@@ -102,8 +102,9 @@ 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
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';
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';
107
108
  export type { ProgressSnapshot } from './orchestration/executor.js';
108
109
  export { mcpToolId } from './mcp/toolAdapter.js';
109
110
  export { MCP_PROTOCOL_VERSION } from './mcp/protocol.js';
package/dist/index.js CHANGED
@@ -80,7 +80,7 @@ export { PLAN_STEP_STATUSES } from './orchestration/plan.js';
80
80
  // trust records this version cannot parse. The state sets are exported with it because "is this task
81
81
  // finished?" must have one answer, not one per caller.
82
82
  export { parseAgentTasks, findAgentTask } from './executions/agentTasks.js';
83
- export { AGENT_TERMINAL, AGENT_RESUMABLE } from './agents/task.js';
83
+ export { AGENT_TERMINAL, AGENT_RESUMABLE, agentTaskView } from './agents/task.js';
84
84
  // ── MCP connectivity (Phase 3.2) — external servers as ordinary Runtime tools ──
85
85
  // The CONFIG + STATUS surface is public; the client, transports, and manager internals are not, so the
86
86
  // wire implementation stays free to change without a breaking release.
@@ -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
  }
@@ -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;
@@ -6,6 +6,10 @@
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 { systemClock } from '../util/clock.js';
11
15
  import { redact } from '../security/redact.js';
@@ -25,6 +25,7 @@ import type { SkillSource, LoadedSource } from '../skills/discovery.js';
25
25
  import type { AgentDefinition } from '../agents/definition.js';
26
26
  import { ExecutionStore } from '../executions/store.js';
27
27
  import type { Execution } from '../executions/execution.js';
28
+ import type { AgentTaskState, AgentTaskView } from '../agents/task.js';
28
29
  import { ArtifactStore } from '../artifacts/artifacts.js';
29
30
  import type { CompareInput } from '../comparison/comparator.js';
30
31
  import type { ComparisonResult } from '../comparison/comparison.js';
@@ -87,6 +88,8 @@ export declare class Runtime {
87
88
  /** Live runs, so a pause/cancel can abort the agent tasks actually in flight. Only ever populated
88
89
  * when agents are enabled, so pause/cancel are unchanged with the flag off. */
89
90
  private readonly liveRuns;
91
+ /** Phase 3.6: agent tasks running RIGHT NOW in this process, and how to stop each one on its own. */
92
+ private readonly liveAgentTasks;
90
93
  /** The config file's `budget:` ceilings, kept only so the 3.3 pre-pass can decline a model call. */
91
94
  private readonly _configBudget?;
92
95
  private readonly approval?;
@@ -285,6 +288,12 @@ export declare class Runtime {
285
288
  private runPersistence;
286
289
  /** Run `fn` while heartbeating the execution lease so a long run never lets the lease expire. */
287
290
  private withHeartbeat;
291
+ /**
292
+ * Build the OrchestrateInput. The optional half is an OBJECT, not a positional tail: this function
293
+ * grew to ten parameters and a caller that passed five of them silently got no sink, no signal and no
294
+ * provenance — a replan that persisted nothing and could not be cancelled. Named fields cannot be
295
+ * short-counted.
296
+ */
288
297
  private orchestrateInput;
289
298
  /** Record an EXECUTED orchestration outcome for learning. plan-only, dry-run, and waiting states are
290
299
  * skipped — no skill ran, so there is no success/failure to learn (recording them would teach noise). */
@@ -409,6 +418,27 @@ export declare class Runtime {
409
418
  * hand one step's completed inner work to a different step with the same id and a different input.
410
419
  */
411
420
  private agentResumeLookup;
421
+ /**
422
+ * Every agent task this Runtime can see, newest execution first (Phase 3.6). Live ones (running in
423
+ * this process) and persisted ones are the same list: a task's record IS its status, so there is no
424
+ * second source to disagree with.
425
+ */
426
+ agentTasks(executionId?: string): AgentTaskView[];
427
+ /**
428
+ * Stop one agent task. Four cases, and NONE of them is a silent no-op — a `stop` that appears to do
429
+ * nothing is indistinguishable from a bug:
430
+ *
431
+ * - running in this process: abort it, then let the worker record `cancelled` as it unwinds;
432
+ * - persisted and not finished (another process, or a dead one): write `cancelled` with a
433
+ * `parent-cancel` interruption, so the record stops claiming it is queued or running;
434
+ * - already finished: report that, and change nothing — a terminal state is sticky;
435
+ * - unknown id: say so.
436
+ */
437
+ stopAgentTask(agentTaskId: string): {
438
+ ok: boolean;
439
+ state?: AgentTaskState;
440
+ reason: string;
441
+ };
412
442
  /** Abort a run that is in flight, recording WHY so a task can tell a pause from a cancellation. */
413
443
  private abortLiveRun;
414
444
  /** Mark an execution paused (it can be resumed later). */
@@ -54,7 +54,7 @@ import { executePlan } from '../orchestration/executor.js';
54
54
  import { ExecutionStore } from '../executions/store.js';
55
55
  import { RESUMABLE, TERMINAL } from '../executions/execution.js';
56
56
  import { captureCheckpoint, reconcile } from '../executions/checkpoint.js';
57
- import { AGENT_TERMINAL, AGENT_RESUMABLE } from '../agents/task.js';
57
+ import { AGENT_TERMINAL, AGENT_RESUMABLE, agentTaskView } from '../agents/task.js';
58
58
  import { parseAgentTasks } from '../executions/agentTasks.js';
59
59
  import { stepIdentity } from '../agents/worker.js';
60
60
  import { hashOf } from '../util/hash.js';
@@ -159,6 +159,8 @@ export class Runtime {
159
159
  /** Live runs, so a pause/cancel can abort the agent tasks actually in flight. Only ever populated
160
160
  * when agents are enabled, so pause/cancel are unchanged with the flag off. */
161
161
  liveRuns = new Map();
162
+ /** Phase 3.6: agent tasks running RIGHT NOW in this process, and how to stop each one on its own. */
163
+ liveAgentTasks = new Map();
162
164
  /** The config file's `budget:` ceilings, kept only so the 3.3 pre-pass can decline a model call. */
163
165
  _configBudget;
164
166
  approval;
@@ -854,7 +856,14 @@ export class Runtime {
854
856
  // entire flag-off delta on this path.
855
857
  const planning = await this.capabilityPlanning(effectiveGoal, policy, routing);
856
858
  try {
857
- const outcome = await orchestrate(this.orchestrateInput(mode, effectiveGoal, policy, routing, partial, planning?.block, this.agentsEnabled ? controller.signal : undefined, { planVersion: 1 }));
859
+ const outcome = await orchestrate(this.orchestrateInput(mode, effectiveGoal, policy, {
860
+ ...(routing ? { routing } : {}),
861
+ ...(partial ? { partial } : {}),
862
+ ...(planning?.block ? { requiredCapabilities: planning.block } : {}),
863
+ ...(this.agentsEnabled ? { signal: controller.signal } : {}),
864
+ provenance: { planVersion: 1 },
865
+ runId,
866
+ }));
858
867
  this.recordOrchestration(mode, goal, outcome);
859
868
  return this.mapOutcome(outcome, resolution, runId, undefined, planning);
860
869
  }
@@ -887,7 +896,15 @@ export class Runtime {
887
896
  this.liveRuns.set(exec.id, { controller });
888
897
  // Phase 3.5: only when there is a store to write to — a stateless run keeps the 2.7.0 shape.
889
898
  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)));
899
+ const outcome = await this.withHeartbeat(exec.id, () => orchestrate(this.orchestrateInput(mode, effectiveGoal, policy, {
900
+ ...(routing ? { routing } : {}),
901
+ ...(partial ? { partial } : {}),
902
+ ...(planning?.block ? { requiredCapabilities: planning.block } : {}),
903
+ ...(this.agentsEnabled ? { signal: controller.signal } : {}),
904
+ provenance: { executionId: exec.id, planVersion: exec.planVersion },
905
+ ...(sink ? { sink } : {}),
906
+ runId,
907
+ })));
891
908
  exec.status = this.execStatus(outcome.status);
892
909
  exec.observations = outcome.observations.map(persistableObservation);
893
910
  if (outcome.plan) {
@@ -1035,7 +1052,14 @@ export class Runtime {
1035
1052
  clearInterval(timer);
1036
1053
  }
1037
1054
  }
1038
- orchestrateInput(mode, goal, policy, routing, partial, requiredCapabilities, signal, provenance, sink, agentResume) {
1055
+ /**
1056
+ * Build the OrchestrateInput. The optional half is an OBJECT, not a positional tail: this function
1057
+ * grew to ten parameters and a caller that passed five of them silently got no sink, no signal and no
1058
+ * provenance — a replan that persisted nothing and could not be cancelled. Named fields cannot be
1059
+ * short-counted.
1060
+ */
1061
+ orchestrateInput(mode, goal, policy, opts = {}) {
1062
+ const { routing, partial, requiredCapabilities, signal, provenance, sink, agentResume, runId } = opts;
1039
1063
  return {
1040
1064
  goal,
1041
1065
  mode,
@@ -1055,7 +1079,13 @@ export class Runtime {
1055
1079
  resolveGaps: (missing) => this.resolveMissingRefs(missing, policy),
1056
1080
  // Phase 3.4: ONE runner source. `agents`, `runAgent` and `reserve` ride along only when agents are
1057
1081
  // enabled AND a definition exists, so with the flag off this object is KEY-identical to 2.6.0.
1058
- ...this.orchestrateRunners(policy, signal, provenance, sink?.onRecord, agentResume),
1082
+ ...this.orchestrateRunners(policy, {
1083
+ runId: runId ?? 'unknown',
1084
+ ...(signal ? { signal } : {}),
1085
+ ...(provenance ? { provenance } : {}),
1086
+ ...(sink?.onRecord ? { onRecord: sink.onRecord } : {}),
1087
+ ...(agentResume ? { agentResume } : {}),
1088
+ }),
1059
1089
  // Phase 3.5: the commit points. Present ONLY when there is a store to commit to, so a stateless
1060
1090
  // Runtime builds an OrchestrateInput key-identical to 2.7.0.
1061
1091
  ...(sink ? { onPlan: sink.onPlan, onProgress: sink.onProgress } : {}),
@@ -1392,7 +1422,7 @@ export class Runtime {
1392
1422
  // path (whether the pause was approval, budget, or partial-progress) so an approved-but-over-budget
1393
1423
  // plan pauses for budget rather than silently exceeding it; a raised budget re-applies here.
1394
1424
  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 } : {}) }));
1425
+ const exe = await this.withHeartbeat(exec.id, () => executePlan(exec.plan, { ...this.orchestrateRunners(policy, { runId, signal: opts.signal ?? controller.signal, provenance: { executionId: exec.id, planVersion: exec.planVersion }, ...(sink?.onRecord ? { onRecord: sink.onRecord } : {}), agentResume: resumeLookup }), ...(sink ? { onProgress: sink.onProgress } : {}), maxParallelSteps: policy.maxParallelSteps ?? 2, ...(policy.limits ? { limits: policy.limits } : {}), skip: new Set(exec.completedSteps), ...(policy.maxCalls !== undefined ? { callBudget: policy.maxCalls } : {}), ...(opts.signal ? { signal: opts.signal } : {}) }));
1396
1426
  if (exe.stoppedForBudget) {
1397
1427
  const done = exe.plan.steps.filter((s) => s.status === 'succeeded').length;
1398
1428
  const total = exe.plan.steps.length;
@@ -1427,7 +1457,14 @@ export class Runtime {
1427
1457
  // this arm with no signal, no provenance, no sink and no resume lookup — so a replan persisted
1428
1458
  // no agent record AT ALL (onRecord is the only writer of `agentTasks`), could not be paused or
1429
1459
  // 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)));
1460
+ outcome = await this.withHeartbeat(exec.id, () => orchestrate(this.orchestrateInput('orchestrate', brief ? `${goal}\n\n${brief}` : goal, policy, {
1461
+ ...(this.effectiveRouting() ? { routing: this.effectiveRouting() } : {}),
1462
+ ...(budgetPaused ? { partial: true } : {}),
1463
+ ...(this.agentsEnabled ? { signal: controller.signal } : {}),
1464
+ provenance: { executionId: exec.id, planVersion: exec.planVersion },
1465
+ ...(sink ? { sink } : {}),
1466
+ runId,
1467
+ })));
1431
1468
  }
1432
1469
  exec.status = this.execStatus(outcome.status);
1433
1470
  exec.observations = [...observationsBefore, ...outcome.observations.map(persistableObservation)];
@@ -1464,7 +1501,8 @@ export class Runtime {
1464
1501
  * They used to drift: resume built its own pair with no agent runner, so a persisted plan containing
1465
1502
  * an agent step would have failed every one of those steps.
1466
1503
  */
1467
- orchestrateRunners(policy, signal, provenance, onRecord, agentResume) {
1504
+ orchestrateRunners(policy, opts) {
1505
+ const { runId, signal, provenance, onRecord, agentResume } = opts;
1468
1506
  const envelopes = policy ? this.agentEnvelopes(policy) : [];
1469
1507
  const byId = new Map(envelopes.map((e) => [e.agentId, e]));
1470
1508
  return {
@@ -1503,8 +1541,29 @@ export class Runtime {
1503
1541
  return { unavailable: true };
1504
1542
  }
1505
1543
  },
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 }),
1544
+ // METADATA ONLY, and ONE BRANCH PER ARM. Building a single object with a union-typed
1545
+ // `type` compiles while carrying properties from every constituent — TypeScript's
1546
+ // excess-property check against a union admits anything present in SOME arm — so
1547
+ // `agent.task.started` was shipping innerSteps/callsUsed/findings it does not declare.
1548
+ // Narrowing to a literal first makes an extra property a compile error again; the
1549
+ // key-set test covers what the type system still cannot.
1550
+ emit: (e) => {
1551
+ const base = { runId, agentTaskId: e.record.agentTaskId, agentId: e.record.agentId, stepId: e.record.stepId };
1552
+ if (e.type === 'agent.task.started') {
1553
+ this.emitter.emit({ type: 'agent.task.started', ...base, state: e.record.state });
1554
+ return;
1555
+ }
1556
+ if (e.type === 'agent.task.progress') {
1557
+ this.emitter.emit({ type: 'agent.task.progress', ...base, innerSteps: e.record.innerSteps, callsUsed: e.record.callsUsed, toolCallsUsed: e.record.toolCallsUsed });
1558
+ return;
1559
+ }
1560
+ // `findings` is a COUNT: a claim is agent-authored text and an event is the one
1561
+ // surface a host may forward anywhere, so no content crosses it.
1562
+ this.emitter.emit({ type: 'agent.task.completed', ...base, state: e.record.state, innerSteps: e.record.innerSteps, callsUsed: e.record.callsUsed, toolCallsUsed: e.record.toolCallsUsed, findings: e.record.findings.length });
1563
+ },
1507
1564
  ...(onRecord ? { onRecord } : {}),
1565
+ registerAbort: (id, abort) => this.liveAgentTasks.set(id, { abort, agentId: envelope.agentId, stepId: step.id, ...(provenance?.executionId ? { executionId: provenance.executionId } : {}) }),
1566
+ releaseAbort: (id) => this.liveAgentTasks.delete(id),
1508
1567
  ...(ctx.signal ?? signal ? { parentSignal: ctx.signal ?? signal } : {}),
1509
1568
  ...(provenance ? { provenance } : { provenance: { planVersion: 1 } }),
1510
1569
  abortReason: () => {
@@ -1631,6 +1690,110 @@ export class Runtime {
1631
1690
  return answer && record.agentTaskId === answeringId ? { record, answer } : { record };
1632
1691
  };
1633
1692
  }
1693
+ /**
1694
+ * Every agent task this Runtime can see, newest execution first (Phase 3.6). Live ones (running in
1695
+ * this process) and persisted ones are the same list: a task's record IS its status, so there is no
1696
+ * second source to disagree with.
1697
+ */
1698
+ agentTasks(executionId) {
1699
+ const executions = executionId ? this._executions.list().filter((e) => e.id === executionId) : this._executions.list();
1700
+ return executions.flatMap((e) => parseAgentTasks(e).tasks.map(agentTaskView));
1701
+ }
1702
+ /**
1703
+ * Stop one agent task. Four cases, and NONE of them is a silent no-op — a `stop` that appears to do
1704
+ * nothing is indistinguishable from a bug:
1705
+ *
1706
+ * - running in this process: abort it, then let the worker record `cancelled` as it unwinds;
1707
+ * - persisted and not finished (another process, or a dead one): write `cancelled` with a
1708
+ * `parent-cancel` interruption, so the record stops claiming it is queued or running;
1709
+ * - already finished: report that, and change nothing — a terminal state is sticky;
1710
+ * - unknown id: say so.
1711
+ */
1712
+ stopAgentTask(agentTaskId) {
1713
+ const live = this.liveAgentTasks.get(agentTaskId);
1714
+ if (live) {
1715
+ live.abort();
1716
+ // Deliberately no `state`: the abort is cooperative and the worker has not unwound yet, so
1717
+ // claiming `cancelled` here would report a state that has not happened.
1718
+ return { ok: true, reason: 'aborted a task running in this process' };
1719
+ }
1720
+ const owning = this._executions.list().find((e) => parseAgentTasks(e).tasks.some((t) => t.agentTaskId === agentTaskId));
1721
+ if (!owning)
1722
+ return { ok: false, reason: 'no such agent task' };
1723
+ // A run this process is executing owns its own record. Writing it from the side would fight the
1724
+ // run's sink, and TAKING ITS LEASE would be worse: the sink refuses a leaseless commit and treats
1725
+ // the refusal as a stop signal, so releasing here would abort the entire run to stop one task.
1726
+ if (this.liveRuns.has(owning.id)) {
1727
+ return { ok: false, reason: `that task belongs to a run in flight — /cancel ${owning.id} stops the whole run` };
1728
+ }
1729
+ // Claim the execution properly. A live lease held elsewhere means another process is mid-run: its
1730
+ // next commit would rewrite the file from its own memory and silently drop this edit, so refuse
1731
+ // rather than pretend.
1732
+ const acq = this._executions.acquire(owning.id);
1733
+ if (!acq.ok || !acq.execution)
1734
+ return { ok: false, reason: `the execution is busy elsewhere (${acq.reason ?? 'unavailable'})` };
1735
+ const exec = acq.execution;
1736
+ try {
1737
+ // Re-read EVERYTHING from the claimed copy. The listing was a snapshot: the task may have
1738
+ // finished since, and writing the snapshot back would revert its own counters and findings.
1739
+ const fresh = parseAgentTasks(exec).tasks.find((t) => t.agentTaskId === agentTaskId);
1740
+ if (!fresh)
1741
+ return { ok: false, reason: 'the task is no longer on the record' };
1742
+ if (AGENT_TERMINAL.has(fresh.state))
1743
+ return { ok: false, state: fresh.state, reason: `already ${fresh.state}` };
1744
+ const now = this.clock.now();
1745
+ const stopped = { ...fresh, state: 'cancelled', interruption: { kind: 'parent-cancel', at: now }, endedAt: now, updatedAt: now };
1746
+ // A cancelled task is not asking anything any more; leaving the question on it would keep
1747
+ // advertising a prompt nobody can answer.
1748
+ delete stopped.pendingInner;
1749
+ exec.agentTasks = (exec.agentTasks ?? []).map((entry) => (entry.agentTaskId === agentTaskId ? stopped : entry));
1750
+ // WITHOUT THIS THE COMMAND IS COSMETIC. `AGENT_RESUMABLE` excludes `cancelled`, so a resume
1751
+ // offers no record for this step and the executor runs the agent again from scratch — a second
1752
+ // paid planning call and the tools fired again, after a human asked it to stop.
1753
+ //
1754
+ // But mark the step done only when the CURRENT plan still contains that exact step. Step ids
1755
+ // recur across replans, so an id from a retired plan could name a completely different step,
1756
+ // and marking it done would silently skip work that was never even started.
1757
+ const stepStillThere = exec.plan?.steps.some((st) => st.id === stopped.stepId && stepIdentity(st) === stopped.stepInputHash);
1758
+ if (stepStillThere && !exec.completedSteps.includes(stopped.stepId))
1759
+ exec.completedSteps = [...exec.completedSteps, stopped.stepId];
1760
+ // A stopped task cannot answer, so a pending wait belonging to it would strand the execution:
1761
+ // resume early-returns on an unanswered inner clarification, and re-election only considers
1762
+ // tasks that are still waiting. Hand the slot to another waiter, or clear it.
1763
+ if (exec.pending?.kind === 'clarification' && exec.pending.agentTaskId === agentTaskId) {
1764
+ const next = this.electWaitingAgent(exec);
1765
+ if (next?.pendingInner)
1766
+ exec.pending = { kind: 'clarification', question: next.pendingInner.question, agentTaskId: next.agentTaskId };
1767
+ else
1768
+ delete exec.pending;
1769
+ }
1770
+ // `commit`, not `commitProgress`. The funnel's rules exist to stop an IN-FLIGHT RUN overwriting
1771
+ // a decision made while it was finishing — terminal is sticky, a pause is not the runner's to
1772
+ // erase. This is not a run: it holds the lease it just acquired, it changes one task record, and
1773
+ // it never touches `exec.status`, so a completed execution stays completed. Refusing here would
1774
+ // instead make a stale non-terminal task on a finished execution permanently unstoppable.
1775
+ if (!this._executions.commit(exec))
1776
+ return { ok: false, reason: 'another owner holds this execution' };
1777
+ this.emitter.emit({
1778
+ type: 'agent.task.completed',
1779
+ // A stop is out-of-band: it belongs to no run, so this id identifies the OPERATION. It is
1780
+ // deliberately fresh rather than an execution id borrowed to look like a run id.
1781
+ runId: nextRunId(),
1782
+ agentTaskId,
1783
+ agentId: stopped.agentId,
1784
+ stepId: stopped.stepId,
1785
+ state: 'cancelled',
1786
+ innerSteps: stopped.innerSteps,
1787
+ callsUsed: stopped.callsUsed,
1788
+ toolCallsUsed: stopped.toolCallsUsed,
1789
+ findings: stopped.findings.length,
1790
+ });
1791
+ return { ok: true, state: 'cancelled', reason: stepStillThere ? 'marked a persisted task cancelled' : 'marked a persisted task cancelled (its step is no longer in the plan)' };
1792
+ }
1793
+ finally {
1794
+ this._executions.release(owning.id);
1795
+ }
1796
+ }
1634
1797
  /** Abort a run that is in flight, recording WHY so a task can tell a pause from a cancellation. */
1635
1798
  abortLiveRun(id, reason) {
1636
1799
  const live = this.liveRuns.get(id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-runtime-engine",
3
- "version": "2.8.0",
3
+ "version": "2.9.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",