@yaag/runtime 0.1.2 → 0.2.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.
Files changed (44) hide show
  1. package/package.json +3 -3
  2. package/src/agent.ts +11 -3
  3. package/src/ask-contract-identity.ts +23 -5
  4. package/src/ask-exchange-events.ts +14 -1
  5. package/src/ask-exchange-options.ts +7 -2
  6. package/src/ask-exchange.ts +198 -26
  7. package/src/ask-hash.ts +7 -3
  8. package/src/ask-limit.ts +27 -11
  9. package/src/ask-output-steering.ts +1 -3
  10. package/src/ask-output.ts +20 -4
  11. package/src/ask-turn.ts +11 -0
  12. package/src/cassette-loader.ts +24 -0
  13. package/src/cassette-replay.ts +76 -7
  14. package/src/cassette-schema.ts +17 -2
  15. package/src/cassette.ts +10 -9
  16. package/src/checkpoint-flush.ts +101 -0
  17. package/src/connection.ts +20 -0
  18. package/src/define-agent.ts +11 -5
  19. package/src/errors.ts +11 -2
  20. package/src/events.ts +29 -3
  21. package/src/fake-transport.ts +55 -0
  22. package/src/frame-queue.ts +5 -0
  23. package/src/index.ts +11 -1
  24. package/src/model-resolution.ts +119 -0
  25. package/src/model-suffix.ts +24 -0
  26. package/src/pi-state.ts +63 -8
  27. package/src/recording-transport.ts +8 -8
  28. package/src/replay-divergence.ts +1 -0
  29. package/src/replay-transport.ts +4 -0
  30. package/src/report-result-extension.ts +128 -0
  31. package/src/report-result-output.ts +73 -0
  32. package/src/report-result-steering.ts +83 -0
  33. package/src/report-result.ts +122 -0
  34. package/src/resume-transport.ts +26 -8
  35. package/src/run-checkpoint.ts +93 -41
  36. package/src/run.ts +86 -32
  37. package/src/spawn.ts +29 -4
  38. package/src/stall-watchdog.ts +193 -0
  39. package/src/summary-agent.ts +11 -2
  40. package/src/summary.ts +23 -2
  41. package/src/thinking-level.ts +32 -0
  42. package/src/transport.ts +43 -8
  43. package/src/types.ts +50 -14
  44. package/src/validation-errors.ts +10 -4
package/src/run.ts CHANGED
@@ -1,9 +1,15 @@
1
+ import { join } from "node:path";
1
2
  import type { TSchema } from "typebox";
2
3
  import type { Agent } from "./agent.ts";
3
4
  import { validateArgs } from "./args-validation.ts";
4
- import { type Cassette, CassetteCollector } from "./cassette.ts";
5
- import { loadCassette } from "./cassette-loader.ts";
6
- import { cleanStaleCheckpointTemp, resolveCheckpointDirectory } from "./checkpoint-dir.ts";
5
+ import { type Cassette, CassetteCollector, type CassetteSink } from "./cassette.ts";
6
+ import { assertReplayable, interruptedResumeWarning, loadCassette } from "./cassette-loader.ts";
7
+ import {
8
+ checkpointFileName,
9
+ cleanStaleCheckpointTemp,
10
+ resolveCheckpointDirectory,
11
+ } from "./checkpoint-dir.ts";
12
+ import { createCheckpointFlusher, flushingSink } from "./checkpoint-flush.ts";
7
13
  import { type OrchestrationProgram, programDefinition } from "./define-run.ts";
8
14
  import { isYaagError, YaagError } from "./errors.ts";
9
15
  import type { EventSink, LifecycleEvent, RunOutcome, StampedEventSink } from "./events.ts";
@@ -11,7 +17,12 @@ import { liveTransport } from "./live-transport.ts";
11
17
  import { recordingTransport } from "./recording-transport.ts";
12
18
  import { replayTransport } from "./replay-transport.ts";
13
19
  import { resumeTransport } from "./resume-transport.ts";
14
- import { publishRunCheckpoint, writeRecordingDiagnostic } from "./run-checkpoint.ts";
20
+ import {
21
+ publishRunCheckpoint,
22
+ type RunCheckpointResult,
23
+ resolveRunIdentity,
24
+ writeRecordingDiagnostic,
25
+ } from "./run-checkpoint.ts";
15
26
  import type { RunContext } from "./run-context.ts";
16
27
  import { liveSkillProbe, type SkillProbeFactory } from "./skill-probe.ts";
17
28
  import { skillRestrictionTransport } from "./skill-restriction-transport.ts";
@@ -72,26 +83,28 @@ export async function executeRun<Args, Result>(
72
83
  } catch (error) {
73
84
  writeRecordingDiagnostic(error);
74
85
  }
75
- const replay = options.replay === undefined ? null : await loadCassette(options.replay);
76
- const resume = options.resume === undefined ? null : await loadCassette(options.resume);
86
+ const replay = await loadReplay(options.replay);
87
+ const resume = await loadResume(options.resume);
77
88
  const definition = programDefinition(program);
78
- // Collection is universal so every Run can checkpoint. Only pause, stop and
79
- // --record flush (ADR-0021). That memory is potentially unbounded over
80
- // arbitrarily many Asks yaag has no Run token budget (ADR-0012/0017), and
81
- // this ADR does not pretend one exists.
89
+ // Collection is universal so every Run can checkpoint, and every Run flushes
90
+ // that collection at each Ask boundary (ADR-0031). Only a stopped Run and a
91
+ // --record Run keep the artifact once the Run settles (ADR-0021). That memory
92
+ // is potentially unbounded over arbitrarily many Asks — yaag has no Run token
93
+ // budget (ADR-0012/0017), and these ADRs do not pretend one exists.
82
94
  const collector = new CassetteCollector();
83
95
  const args: unknown = Object.hasOwn(options, "args") ? options.args : {};
96
+ // Fixed here, so every Ask-boundary flush and the settlement write one file,
97
+ // and so run_start can name it (ADR-0031).
98
+ const destination = options.record ?? join(checkpointDir, checkpointFileName());
99
+ const identity = await resolveRunIdentity(options.programFile, args);
100
+ const checkpoint = { collector, record: options.record, destination, identity };
84
101
  try {
85
102
  assertArgs<Args>(definition.args, args);
86
103
  } catch (error) {
87
- await publishRunCheckpoint({
88
- outcome: "failed",
89
- collector,
90
- record: options.record,
91
- checkpointDir,
92
- programFile: options.programFile,
93
- args,
94
- });
104
+ // The result is discarded on purpose: this rejection precedes run_start, and
105
+ // no event may precede an ARGS_INVALID rejection, so a lost Checkpoint here
106
+ // stays a stderr diagnostic (ticket 04).
107
+ await publishRunCheckpoint({ outcome: "failed", ...checkpoint });
95
108
  throw error;
96
109
  }
97
110
 
@@ -105,7 +118,13 @@ export async function executeRun<Args, Result>(
105
118
  summary = applyEvent(summary, event);
106
119
  sink(event);
107
120
  };
108
- const factory = selectFactory({ options, collector, replay, resume });
121
+ const flusher = createCheckpointFlusher(checkpoint);
122
+ const factory = selectFactory({
123
+ options,
124
+ collector: flushingSink(collector, flusher),
125
+ replay,
126
+ resume,
127
+ });
109
128
  const agents: Agent[] = [];
110
129
  const startedAt = Date.now();
111
130
  const spawnGate = makeSpawn({
@@ -117,7 +136,7 @@ export async function executeRun<Args, Result>(
117
136
  });
118
137
 
119
138
  const ctx: RunContext<Args> = { args, spawn: spawnGate.spawn };
120
- emit({ type: "run_start", program: definition.name ?? "program" });
139
+ emit({ type: "run_start", program: definition.name ?? "program", artifact: destination });
121
140
 
122
141
  // The program's settlement is held rather than rethrown from a `finally`, so
123
142
  // publication can displace it when the checkpoint fails (ADR-0021).
@@ -133,27 +152,40 @@ export async function executeRun<Args, Result>(
133
152
  }
134
153
  spawnGate.close();
135
154
  await reapAll({ agents, emit });
155
+ // No flush may land after the settled artifact, or an acknowledged Run would
156
+ // be overwritten by an `interrupted` one.
157
+ await flusher.quiesce();
136
158
  // run_end is the acknowledgement: nothing may report an outcome before the
137
159
  // checkpoint's destination-directory fsync has landed (ADR-0021).
138
- const failure = await publishRunCheckpoint({
139
- outcome,
140
- collector,
141
- record: options.record,
142
- checkpointDir,
143
- programFile: options.programFile,
144
- args,
145
- });
160
+ const publication = await publishRunCheckpoint({ outcome, ...checkpoint });
146
161
  emitRunEnd({
147
162
  emit,
148
- outcome: failure === null ? outcome : "failed",
149
163
  durationMs: Date.now() - startedAt,
150
164
  summary,
165
+ ...endingFrom(publication, outcome),
151
166
  });
152
- if (failure !== null) throw failure;
167
+ if (publication.kind === "displaced") throw publication.error;
153
168
  if (!settled.ok) throw settled.error;
154
169
  return settled.value;
155
170
  }
156
171
 
172
+ /** Loads a strict-replay source, which must be a settled Cassette (ADR-0031). */
173
+ async function loadReplay(path: string | undefined): Promise<Cassette | null> {
174
+ if (path === undefined) return null;
175
+ const cassette = await loadCassette(path);
176
+ assertReplayable(cassette, path);
177
+ return cassette;
178
+ }
179
+
180
+ /** Loads a resume source, warning once when it came from a hard death (ADR-0031). */
181
+ async function loadResume(path: string | undefined): Promise<Cassette | null> {
182
+ if (path === undefined) return null;
183
+ const cassette = await loadCassette(path);
184
+ const warning = interruptedResumeWarning(cassette, path);
185
+ if (warning !== null) writeRecordingDiagnostic(warning);
186
+ return cassette;
187
+ }
188
+
157
189
  /** What the Orchestration Program body produced, before the Run acknowledges it. */
158
190
  type Settlement<Result> =
159
191
  | { readonly ok: true; readonly value: Result }
@@ -182,7 +214,8 @@ function assertArgs<Args>(schema: TSchema | undefined, value: unknown): asserts
182
214
  */
183
215
  export interface FactorySelection {
184
216
  readonly options: RunOptions;
185
- readonly collector: CassetteCollector;
217
+ /** Receives every frame; wrapped so Ask boundaries flush the Checkpoint (ADR-0031). */
218
+ readonly collector: CassetteSink;
186
219
  readonly replay: Cassette | null;
187
220
  readonly resume: Cassette | null;
188
221
  }
@@ -257,12 +290,33 @@ interface RunEndOptions {
257
290
  readonly durationMs: number;
258
291
  /** Settled fold: every Agent is reaped and the checkpoint is published by now. */
259
292
  readonly summary: RunSummary;
293
+ /** Publication error text when a requested Checkpoint was lost (ticket 04). */
294
+ readonly checkpointLost?: string;
295
+ }
296
+
297
+ /**
298
+ * Folds one publication result into how the Run ends. A displaced publication
299
+ * takes the outcome; a lost Checkpoint keeps it and reports the loss instead.
300
+ */
301
+ function endingFrom(
302
+ publication: RunCheckpointResult,
303
+ outcome: RunOutcome,
304
+ ): { readonly outcome: RunOutcome; readonly checkpointLost?: string } {
305
+ switch (publication.kind) {
306
+ case "published":
307
+ return { outcome };
308
+ case "lost":
309
+ return { outcome, checkpointLost: publication.message };
310
+ case "displaced":
311
+ return { outcome: "failed" };
312
+ }
260
313
  }
261
314
 
262
315
  /** Emits the Run's acknowledgement, the last Lifecycle Event of every Run. */
263
- function emitRunEnd({ emit, outcome, durationMs, summary }: RunEndOptions): void {
316
+ function emitRunEnd({ emit, outcome, durationMs, summary, checkpointLost }: RunEndOptions): void {
264
317
  emit({
265
318
  type: "run_end",
319
+ ...(checkpointLost === undefined ? {} : { checkpointLost }),
266
320
  ok: outcome === "completed",
267
321
  outcome,
268
322
  durationMs,
package/src/spawn.ts CHANGED
@@ -5,9 +5,16 @@ import { type AgentDefinition, agentDefinitionConfig, isAgentDefinition } from "
5
5
  import { YaagError } from "./errors.ts";
6
6
  import type { EventSink } from "./events.ts";
7
7
  import { resolveExtensionPaths } from "./extension-paths.ts";
8
+ import { normalizeModelResolution } from "./model-resolution.ts";
8
9
  import type { RunContext } from "./run-context.ts";
9
10
  import type { AgentTransport, TransportFactory, TransportStartup } from "./transport.ts";
10
- import type { AskOptions, Handle, SpawnOptions, SpawnOverrides } from "./types.ts";
11
+ import type {
12
+ AskOptions,
13
+ Handle,
14
+ ResolvedSpawnOptions,
15
+ SpawnOptions,
16
+ SpawnOverrides,
17
+ } from "./types.ts";
11
18
 
12
19
  /** Dependencies for one Run's Agent-spawn gate. */
13
20
  export interface SpawnDependencies {
@@ -41,12 +48,13 @@ export function makeSpawn(deps: SpawnDependencies): SpawnGate {
41
48
  const request = resolveRequest(definitionOrOptions, overrides);
42
49
  const cwd = resolve(request.spawnOptions.cwd ?? process.cwd());
43
50
  const name = uniqueAgentName(request.spawnOptions.name, deps.agents.length, taken);
51
+ const spawnOptions = resolveSpawnSelection(request.spawnOptions, name);
44
52
  try {
45
53
  const opened = await openTransport({
46
54
  factory: deps.factory,
47
55
  name,
48
56
  cwd,
49
- spawnOptions: request.spawnOptions,
57
+ spawnOptions,
50
58
  sessionDir: deps.sessionDir,
51
59
  programFile: deps.programFile,
52
60
  });
@@ -59,7 +67,7 @@ export function makeSpawn(deps: SpawnDependencies): SpawnGate {
59
67
  branch,
60
68
  transport: opened.transport,
61
69
  emit: deps.emit,
62
- spawnOptions: request.spawnOptions,
70
+ spawnOptions,
63
71
  ...(request.askDefaults === undefined ? {} : { askDefaults: request.askDefaults }),
64
72
  ...(request.definitionName === undefined ? {} : { definitionName: request.definitionName }),
65
73
  });
@@ -92,6 +100,23 @@ export function makeSpawn(deps: SpawnDependencies): SpawnGate {
92
100
  };
93
101
  }
94
102
 
103
+ /**
104
+ * Attempt-0 shim for Model Resolution: one selection, no retry loop.
105
+ * Ticket 02 replaces this with the fallback loop and MODEL_RESOLUTION_FAILED.
106
+ */
107
+ function resolveSpawnSelection(options: SpawnOptions, name: string): ResolvedSpawnOptions {
108
+ const selection = normalizeModelResolution(options).resolve([]);
109
+ if (selection === undefined) {
110
+ throw new YaagError("SPAWN_FAILED", `agent "${name}": no model candidate to try`, name);
111
+ }
112
+ const { model: _model, thinking: _thinking, ...rest } = options;
113
+ return {
114
+ ...rest,
115
+ ...(selection.model === undefined ? {} : { model: selection.model }),
116
+ ...(selection.thinking === undefined ? {} : { thinking: selection.thinking }),
117
+ };
118
+ }
119
+
95
120
  type MutableSpawnOverrides = { -readonly [Key in keyof SpawnOverrides]: SpawnOverrides[Key] };
96
121
 
97
122
  interface SpawnRequest {
@@ -180,7 +205,7 @@ interface OpenTransportOptions {
180
205
  readonly factory: TransportFactory;
181
206
  readonly name: string;
182
207
  readonly cwd: string;
183
- readonly spawnOptions: SpawnOptions;
208
+ readonly spawnOptions: ResolvedSpawnOptions;
184
209
  readonly sessionDir: string | undefined;
185
210
  readonly programFile: string | undefined;
186
211
  }
@@ -0,0 +1,193 @@
1
+ import type { CommandResponse } from "./connection.ts";
2
+ import type { AskStalledOutcome } from "./errors.ts";
3
+ import { readAgentProgress } from "./pi-state.ts";
4
+ import type { Frame } from "./transport.ts";
5
+
6
+ /** Silence allowed on a live Ask before the Stall Watchdog probes the Agent. */
7
+ export const DEFAULT_STALL_MS = 600_000;
8
+ /** Time allowed for the `get_state` probe to answer before yaag kills the Agent. */
9
+ export const STALL_PROBE_SETTLE_MS = 45_000;
10
+
11
+ /** Why an Ask settled: normal frames, watchdog recovery, or a stall rejection. */
12
+ export type SettlementCause = "normal" | "recovered" | "stalled";
13
+
14
+ /** Rejection carried by {@link StallWatchdog.failed} for both stall outcomes. */
15
+ export class StallSignal extends Error {
16
+ readonly outcome: AskStalledOutcome;
17
+
18
+ constructor(outcome: AskStalledOutcome) {
19
+ super(
20
+ `no frame for ${outcome.idleMs}ms and the state probe ${
21
+ outcome.destructive ? "did not answer" : "reported work in progress"
22
+ }`,
23
+ );
24
+ this.name = "StallSignal";
25
+ this.outcome = outcome;
26
+ }
27
+ }
28
+
29
+ export interface StallWatchdogOptions {
30
+ /** Silence, in milliseconds, that arms the probe. */
31
+ readonly stallMs: number;
32
+ /** Bounded wait for the probe response; defaults to 45s. */
33
+ readonly probeSettleMs?: number;
34
+ /** Sends one command to the Agent and resolves with its response. */
35
+ readonly command: (frame: Frame) => Promise<CommandResponse>;
36
+ /** True when this Ask already observed a terminal assistant `message_end`. */
37
+ readonly terminal: () => boolean;
38
+ /** Settles the Ask from the observed terminal state. */
39
+ readonly recover: () => void;
40
+ }
41
+
42
+ /**
43
+ * Default liveness guard for every live Ask (ADR-0029).
44
+ *
45
+ * It measures silence, not wall time: any Agent frame resets the timer, so a
46
+ * legitimately long Ask is never punished. On expiry it sends one `get_state`
47
+ * probe and takes one of three exits:
48
+ * - the probe answers, the Agent is not working, and this Ask already saw a
49
+ * terminal assistant `message_end` — the Ask settles as `recovered`;
50
+ * - the probe answers and the Agent is still working — the Ask fails with a
51
+ * recoverable `ASK_STALLED` and the Agent stays alive;
52
+ * - the probe stays silent — the Ask fails with a destructive `ASK_STALLED`
53
+ * and the Agent is killed.
54
+ */
55
+ export class StallWatchdog {
56
+ readonly #stallMs: number;
57
+ readonly #probeSettleMs: number;
58
+ readonly #command: (frame: Frame) => Promise<CommandResponse>;
59
+ readonly #terminal: () => boolean;
60
+ readonly #recover: () => void;
61
+ readonly #failure: Promise<never>;
62
+ #reject: (reason: unknown) => void = () => {};
63
+ #stallTimer: ReturnType<typeof setTimeout> | undefined;
64
+ #probeTimer: ReturnType<typeof setTimeout> | undefined;
65
+ #tripped = false;
66
+ #done = false;
67
+ #cause: SettlementCause = "normal";
68
+ #result: AskStalledOutcome | null = null;
69
+
70
+ constructor(options: StallWatchdogOptions) {
71
+ this.#stallMs = options.stallMs;
72
+ this.#probeSettleMs = options.probeSettleMs ?? STALL_PROBE_SETTLE_MS;
73
+ this.#command = options.command;
74
+ this.#terminal = options.terminal;
75
+ this.#recover = options.recover;
76
+ this.#failure = new Promise<never>((_resolve, reject) => {
77
+ this.#reject = reject;
78
+ });
79
+ // The race may settle before the Ask starts awaiting it.
80
+ void this.#failure.catch(() => {});
81
+ }
82
+
83
+ /** Arms the silence timer immediately before the prompt command is sent. */
84
+ start(): void {
85
+ this.#arm();
86
+ }
87
+
88
+ /** Resets the silence timer. Frames that arrive after the probe are ignored. */
89
+ observe(_frame: Frame): void {
90
+ if (this.#done || this.#tripped) return;
91
+ this.#arm();
92
+ }
93
+
94
+ /**
95
+ * Disarms the watchdog for a settled turn, so the commands that follow
96
+ * settlement — `get_last_assistant_text` and output steering — can never be
97
+ * mistaken for Agent silence. Idempotent.
98
+ */
99
+ settled(): void {
100
+ if (this.#tripped) return;
101
+ this.cleanup();
102
+ }
103
+
104
+ /** Re-arms the timer for a further effort in the same Ask. Inert after a trip. */
105
+ rearm(): void {
106
+ if (this.#done || this.#tripped) return;
107
+ this.#arm();
108
+ }
109
+
110
+ /** Cancels every timer once the Ask leaves. Idempotent. */
111
+ cleanup(): void {
112
+ clearTimeout(this.#stallTimer);
113
+ clearTimeout(this.#probeTimer);
114
+ this.#stallTimer = undefined;
115
+ this.#probeTimer = undefined;
116
+ }
117
+
118
+ /** How this Ask settled, as reported in its `ask_end` Lifecycle Event. */
119
+ get cause(): SettlementCause {
120
+ return this.#cause;
121
+ }
122
+
123
+ /** The stalled outcome, once the probe protocol resolved either way. */
124
+ get result(): AskStalledOutcome | null {
125
+ return this.#result;
126
+ }
127
+
128
+ /** Rejects with a {@link StallSignal} on both stall exits. */
129
+ get failed(): Promise<never> {
130
+ return this.#failure;
131
+ }
132
+
133
+ #arm(): void {
134
+ clearTimeout(this.#stallTimer);
135
+ this.#stallTimer = setTimeout(() => this.#trip(), this.#stallMs);
136
+ }
137
+
138
+ #trip(): void {
139
+ if (this.#done || this.#tripped) return;
140
+ this.#tripped = true;
141
+ clearTimeout(this.#stallTimer);
142
+ this.#stallTimer = undefined;
143
+ this.#probeTimer = setTimeout(() => this.#fail(true), this.#probeSettleMs);
144
+ void this.#probe();
145
+ }
146
+
147
+ async #probe(): Promise<void> {
148
+ let response: CommandResponse;
149
+ try {
150
+ response = await this.#command({ type: "get_state" });
151
+ } catch {
152
+ // A dead Agent or a refused probe is silence: the kill timer decides.
153
+ return;
154
+ }
155
+ if (this.#done) return;
156
+ const progress = readAgentProgress(response.data);
157
+ // Unreadable state is treated as work in progress: yaag never invents a
158
+ // settlement it did not observe.
159
+ if (progress !== null && !progress.working && this.#terminal()) {
160
+ this.#done = true;
161
+ this.#cause = "recovered";
162
+ this.cleanup();
163
+ this.#recover();
164
+ return;
165
+ }
166
+ this.#fail(false, progress ?? undefined);
167
+ }
168
+
169
+ #fail(destructive: boolean, state?: AskStalledOutcome["state"]): void {
170
+ if (this.#done) return;
171
+ this.#done = true;
172
+ this.#cause = "stalled";
173
+ this.#result = {
174
+ idleMs: this.#stallMs,
175
+ destructive,
176
+ ...(state === undefined ? {} : { state }),
177
+ };
178
+ this.cleanup();
179
+ this.#reject(new StallSignal(this.#result));
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Resolves the effective stall budget.
185
+ *
186
+ * `false` and any value that is not above zero disable the watchdog for one
187
+ * Ask. An absent value takes the 10-minute default.
188
+ */
189
+ export function stallBudgetMs(stallMs: number | false | undefined): number | null {
190
+ if (stallMs === false) return null;
191
+ if (stallMs === undefined) return DEFAULT_STALL_MS;
192
+ return stallMs > 0 ? stallMs : null;
193
+ }
@@ -184,11 +184,19 @@ export function setActivity(
184
184
  * Folds an Ask settlement into the idle arm, retaining the settled Ask identity.
185
185
  * Older stamped events are ignored and legacy unstamped events use stream order.
186
186
  */
187
+ /** One Ask settlement as the fold sees it. */
188
+ export interface AskSettlement {
189
+ readonly index: number;
190
+ /** A recovered settlement makes this Agent's accounting a floor, not a total. */
191
+ readonly recovered: boolean;
192
+ }
193
+
187
194
  export function endAsk(
188
195
  current: AgentRecord | undefined,
189
- index: number,
196
+ settlement: AskSettlement,
190
197
  at: number | null,
191
198
  ): AgentRecord {
199
+ const { index, recovered } = settlement;
192
200
  const agent = current ?? placeholderAgent();
193
201
  if (
194
202
  !canChangeAskState(agent, at) ||
@@ -202,6 +210,7 @@ export function endAsk(
202
210
  state: "idle",
203
211
  askIndex: index,
204
212
  activity: null,
213
+ incomplete: agent.incomplete || recovered,
205
214
  stateChangedAt: at,
206
215
  };
207
216
  }
@@ -268,7 +277,7 @@ export function totalsFromAgents(agents: Readonly<Record<string, AgentRecord>>):
268
277
  ? null
269
278
  : records.reduce(addTokens, zeroTokens()),
270
279
  incomplete: records.some(
271
- (agent) => agent.state === "exited" && (agent.incomplete || agent.cost === null),
280
+ (agent) => agent.incomplete || (agent.state === "exited" && agent.cost === null),
272
281
  ),
273
282
  };
274
283
  }
package/src/summary.ts CHANGED
@@ -31,6 +31,11 @@ export type RunState = "running" | "ended";
31
31
  interface RunSummaryBase {
32
32
  readonly program: string;
33
33
  readonly startedAt: number | null;
34
+ /**
35
+ * Path of the Run's Checkpoint, as `run_start` named it (ADR-0031). It is
36
+ * null for a Run whose CLI is older than that event field.
37
+ */
38
+ readonly artifact: string | null;
34
39
  readonly agents: Readonly<Record<string, AgentInfo>>;
35
40
  readonly asksStarted: number;
36
41
  readonly asksSettled: number;
@@ -55,6 +60,11 @@ export interface EndedRunSummary extends RunSummaryBase {
55
60
  readonly ok: boolean;
56
61
  /** The `run_end.at` event fact used to reject strictly older stamped endings. */
57
62
  readonly endedAt: number | null;
63
+ /**
64
+ * The Run asked for a Checkpoint with `--record`, failed, and lost the
65
+ * artifact too. Carries the publication error text (ticket 04).
66
+ */
67
+ readonly checkpointLost?: string;
58
68
  }
59
69
 
60
70
  /**
@@ -82,6 +92,7 @@ export function initialSummary(): RunningRunSummary {
82
92
  runState: "running",
83
93
  outcome: null,
84
94
  startedAt: null,
95
+ artifact: null,
85
96
  agents: {},
86
97
  asksStarted: 0,
87
98
  asksSettled: 0,
@@ -111,7 +122,12 @@ export function applyEvent(
111
122
  const at = "at" in event ? event.at : null;
112
123
  switch (event.type) {
113
124
  case "run_start":
114
- return { ...summary, program: event.program, startedAt: at };
125
+ return {
126
+ ...summary,
127
+ program: event.program,
128
+ startedAt: at,
129
+ artifact: event.artifact ?? null,
130
+ };
115
131
  case "agent_spawn":
116
132
  return withAgent(summary, event.agent, spawnAgent(summary.agents[event.agent], event, at));
117
133
  case "ask_start":
@@ -158,6 +174,7 @@ function endRun(
158
174
  endedAt: at,
159
175
  durationMs: event.durationMs,
160
176
  worstFrameGapMs: event.worstFrameGapMs,
177
+ ...(event.checkpointLost === undefined ? {} : { checkpointLost: event.checkpointLost }),
161
178
  };
162
179
  }
163
180
 
@@ -195,7 +212,11 @@ function settleAsk(
195
212
  const next = withAgent(
196
213
  { ...summary, asksSettled: summary.asksSettled + 1 },
197
214
  event.agent,
198
- endAsk(summary.agents[event.agent], event.index, at),
215
+ endAsk(
216
+ summary.agents[event.agent],
217
+ { index: event.index, recovered: event.cause === "recovered" },
218
+ at,
219
+ ),
199
220
  );
200
221
  return {
201
222
  ...next,
@@ -0,0 +1,32 @@
1
+ /**
2
+ * pi's supported thinking levels.
3
+ *
4
+ * Lives in its own leaf module so both `types.ts` and the Model Resolution
5
+ * modules can depend on it without forming an import cycle.
6
+ */
7
+ export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
8
+
9
+ /**
10
+ * The closed level list, authored as a total record so that adding a level to
11
+ * `ThinkingLevel` breaks this build instead of silently omitting the level.
12
+ * Authority: pi `docs/rpc.md` "Levels:".
13
+ */
14
+ const levels: Record<ThinkingLevel, true> = {
15
+ off: true,
16
+ minimal: true,
17
+ low: true,
18
+ medium: true,
19
+ high: true,
20
+ xhigh: true,
21
+ max: true,
22
+ };
23
+
24
+ /** Every thinking level pi accepts, in documented order. */
25
+ export const THINKING_LEVELS: readonly ThinkingLevel[] = Object.freeze(
26
+ Object.keys(levels) as ThinkingLevel[],
27
+ );
28
+
29
+ /** True only for one of pi's thinking levels; case-sensitive and total. */
30
+ export function isThinkingLevel(value: unknown): value is ThinkingLevel {
31
+ return typeof value === "string" && Object.hasOwn(levels, value);
32
+ }