@yaag/runtime 0.10.0 → 0.11.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.
@@ -1,29 +1,13 @@
1
- import { resolve } from "node:path";
2
1
  import type { ConfigExtension } from "../config/index.ts";
3
2
  import { YaagError } from "../errors.ts";
4
3
  import type { EventSink } from "../events.ts";
5
- import {
6
- ModelErrorHistory,
7
- type ModelSelection,
8
- normalizeModelResolution,
9
- resolveModel,
10
- resolveRecordedModel,
11
- } from "../model/index.ts";
12
4
  import type { RunContext } from "../run/index.ts";
13
- import type { AgentTransport, TransportFactory, TransportStartup } from "../transport/index.ts";
14
- import type {
15
- AskOptions,
16
- Handle,
17
- ResolvedSpawnOptions,
18
- SpawnOptions,
19
- SpawnOverrides,
20
- } from "../types.ts";
21
- import { Agent } from "./agent.ts";
22
- import { uniqueAgentName } from "./agent-names.ts";
5
+ import type { TransportFactory } from "../transport/index.ts";
6
+ import type { Handle, SpawnOptions, SpawnOverrides } from "../types.ts";
7
+ import type { Agent } from "./agent.ts";
23
8
  import { type AgentDefinition, agentDefinitionConfig, isAgentDefinition } from "./define-agent.ts";
24
- import { resolveSpawnExtensions } from "./spawn-extensions.ts";
25
- import { resolveParent } from "./spawn-parent.ts";
26
- import { openRequest, withSelection } from "./spawn-request.ts";
9
+ import { type ForkSpawner, forkRequest } from "./fork.ts";
10
+ import { type OpenAgentContext, type OpenAgentRequest, openAgent } from "./spawn-open.ts";
27
11
 
28
12
  /** Dependencies for one Run's Agent-spawn gate. */
29
13
  export interface SpawnDependencies {
@@ -51,130 +35,30 @@ export interface SpawnGate {
51
35
  export function makeSpawn(deps: SpawnDependencies): SpawnGate {
52
36
  const taken = new Set<string>();
53
37
  let closed = false;
38
+ // Declared before use: every Agent this gate opens is handed the same fork
39
+ // spawner, so a fork is gated and named exactly like a spawn.
40
+ const forkSpawner: ForkSpawner = async (source, overrides) => {
41
+ if (closed) throw new YaagError("RUN_CLOSED", "run has already settled");
42
+ const request = forkRequest(source, overrides);
43
+ const handle = await openAgent(context, {
44
+ spawnOptions: request.spawnOptions,
45
+ parent: request.parent,
46
+ askDefaults: undefined,
47
+ definitionName: undefined,
48
+ fork: request.fork,
49
+ });
50
+ if (request.compact !== undefined && request.compact !== false) {
51
+ await handle.compact(typeof request.compact === "string" ? request.compact : undefined);
52
+ }
53
+ return handle;
54
+ };
55
+ const context: OpenAgentContext = { ...deps, taken, forkSpawner };
54
56
  const spawn: RunContext["spawn"] = async (
55
57
  definitionOrOptions: SpawnOptions | AgentDefinition = {},
56
58
  overrides?: SpawnOverrides,
57
59
  ): Promise<Handle> => {
58
60
  if (closed) throw new YaagError("RUN_CLOSED", "run has already settled");
59
- const request = resolveRequest(definitionOrOptions, overrides);
60
- const cwd = resolve(request.spawnOptions.cwd ?? process.cwd());
61
- const name = uniqueAgentName(request.spawnOptions.name, deps.agents.length, taken);
62
- // Resolved before any transport work, so a bad Parent Link costs nothing.
63
- const parent = resolveParent(request.parent, deps.agents);
64
- const resolution = normalizeModelResolution(request.spawnOptions);
65
- // One history per Agent: its spawn loop and every mid-Ask fallback loop
66
- // append to it, so a resolver sees every candidate that already failed.
67
- const history = new ModelErrorHistory();
68
- try {
69
- // Extension paths do not vary per candidate, so a bad path fails once, generically.
70
- const extensions = await resolveSpawnExtensions({
71
- configExtensions: deps.configExtensions,
72
- declared: request.spawnOptions.extensions,
73
- useConfigExtensions: request.spawnOptions.configExtensions !== false,
74
- ...(deps.programFile === undefined ? {} : { programFile: deps.programFile }),
75
- projectRoot: cwd,
76
- }).catch((error: unknown) => {
77
- if (error instanceof YaagError) throw error;
78
- throw new YaagError("SPAWN_FAILED", `agent "${name}": ${String(error)}`, name);
79
- });
80
- // One local for both open sites, so the launch arguments and the recorded
81
- // identity cannot drift apart.
82
- const extensionFields =
83
- extensions === undefined
84
- ? {}
85
- : {
86
- resolvedExtensionPaths: extensions.resolvedPaths,
87
- declaredExtensions: extensions.declared,
88
- };
89
- const attempt = async (
90
- selection: ModelSelection,
91
- ): Promise<{ opened: OpenedTransport; spawnOptions: ResolvedSpawnOptions }> => {
92
- const settled = withSelection(request.spawnOptions, selection);
93
- return {
94
- opened: await openTransport({
95
- factory: deps.factory,
96
- name,
97
- cwd,
98
- spawnOptions: settled,
99
- ...extensionFields,
100
- ...(parent === undefined ? {} : { parent }),
101
- sessionDir: deps.sessionDir,
102
- }),
103
- spawnOptions: settled,
104
- };
105
- };
106
- // The peek must stay in the same synchronous block as the first open: a
107
- // Cassette-backed factory claims Agents in open order (ADR-0013).
108
- const recorded = deps.factory.recordedSpawn?.(
109
- openRequest({
110
- name,
111
- cwd,
112
- spawnOptions: request.spawnOptions,
113
- ...extensionFields,
114
- ...(parent === undefined ? {} : { parent }),
115
- sessionDir: deps.sessionDir,
116
- }),
117
- );
118
- // A Cassette-backed spawn adopts the recorded resolved selection and skips
119
- // the loop, so a replayed Run emits no spawn-time fallback (ADR-0039).
120
- const adopted =
121
- recorded === undefined ? undefined : resolveRecordedModel({ resolution, recorded });
122
- // The adopted outcome carries the attempts the recording already spent, so
123
- // a later mid-Ask fallback re-resolves from that attempt index (ADR-0039).
124
- for (const skipped of adopted?.skipped ?? []) {
125
- history.record(skipped.reason, skipped.failedModel);
126
- }
127
- const { opened, spawnOptions } =
128
- adopted === undefined
129
- ? await resolveModel({
130
- resolution,
131
- agent: name,
132
- history,
133
- onFallback: (fallback) => {
134
- deps.emit({ type: "model_fallback", agent: name, ...fallback });
135
- },
136
- attempt,
137
- })
138
- : await attempt(adopted.selection);
139
- const resolvedCwd = opened.startup.worktree?.cwd ?? cwd;
140
- const branch = opened.startup.worktree?.branch;
141
- const sessionFile = opened.startup.sessionFile;
142
- const agent = new Agent({
143
- name,
144
- cwd: resolvedCwd,
145
- branch,
146
- transport: opened.transport,
147
- emit: deps.emit,
148
- spawnOptions,
149
- // An Agent that named no candidate inherits pi's default model, so a
150
- // failing Ask has nothing to fall back from and stays an Ask failure.
151
- ...(spawnOptions.model === undefined
152
- ? {}
153
- : { modelFallback: { resolution, history, candidate: spawnOptions.model } }),
154
- ...(request.askDefaults === undefined ? {} : { askDefaults: request.askDefaults }),
155
- ...(request.definitionName === undefined ? {} : { definitionName: request.definitionName }),
156
- });
157
- deps.agents.push(agent);
158
- deps.emit({
159
- type: "agent_spawn",
160
- agent: name,
161
- model: agent.model,
162
- cwd: resolvedCwd,
163
- ...(branch === undefined ? {} : { branch }),
164
- ...(sessionFile === undefined ? {} : { sessionFile }),
165
- ...(parent === undefined ? {} : { parent }),
166
- });
167
- return agent;
168
- } catch (error) {
169
- if (request.definitionName !== undefined && isSpawnFailure(error)) {
170
- throw new YaagError(
171
- "SPAWN_FAILED",
172
- `definition "${request.definitionName}": ${error.message}`,
173
- name,
174
- );
175
- }
176
- throw error;
177
- }
61
+ return openAgent(context, resolveRequest(definitionOrOptions, overrides));
178
62
  };
179
63
  return {
180
64
  spawn,
@@ -195,20 +79,10 @@ interface ValidatedOverrides {
195
79
  readonly parent: unknown;
196
80
  }
197
81
 
198
- interface SpawnRequest {
199
- /** Never carries `parent`: a Handle must not reach the recorded options. */
200
- readonly spawnOptions: SpawnOptions;
201
- /** The raw Parent Link option, validated by `resolveParent`. */
202
- readonly parent: unknown;
203
- readonly askDefaults: AskOptions | undefined;
204
- /** Definition identity stays separate from a topology-overridden Agent name. */
205
- readonly definitionName: string | undefined;
206
- }
207
-
208
82
  function resolveRequest(
209
83
  definitionOrOptions: SpawnOptions | AgentDefinition,
210
84
  overrides: SpawnOverrides | undefined,
211
- ): SpawnRequest {
85
+ ): OpenAgentRequest {
212
86
  if (!isAgentDefinition(definitionOrOptions)) {
213
87
  const { parent, ...spawnOptions } = definitionOrOptions;
214
88
  return { spawnOptions, parent, askDefaults: undefined, definitionName: undefined };
@@ -284,49 +158,3 @@ function validateOverrides(overrides: unknown): ValidatedOverrides {
284
158
  }
285
159
  return { topology, parent };
286
160
  }
287
-
288
- function isSpawnFailure(error: unknown): error is YaagError {
289
- return error instanceof YaagError && error.code === "SPAWN_FAILED";
290
- }
291
-
292
- interface OpenTransportOptions {
293
- readonly factory: TransportFactory;
294
- readonly name: string;
295
- readonly cwd: string;
296
- readonly spawnOptions: ResolvedSpawnOptions;
297
- readonly resolvedExtensionPaths?: readonly string[];
298
- readonly declaredExtensions?: readonly string[];
299
- readonly parent?: string;
300
- readonly sessionDir: string | undefined;
301
- }
302
-
303
- interface OpenedTransport {
304
- readonly transport: AgentTransport;
305
- readonly startup: TransportStartup;
306
- }
307
-
308
- async function openTransport(options: OpenTransportOptions): Promise<OpenedTransport> {
309
- const startup: TransportStartup = {};
310
- try {
311
- const transport = await options.factory.open(
312
- openRequest({
313
- name: options.name,
314
- cwd: options.cwd,
315
- spawnOptions: options.spawnOptions,
316
- ...(options.resolvedExtensionPaths === undefined
317
- ? {}
318
- : { resolvedExtensionPaths: options.resolvedExtensionPaths }),
319
- ...(options.declaredExtensions === undefined
320
- ? {}
321
- : { declaredExtensions: options.declaredExtensions }),
322
- ...(options.parent === undefined ? {} : { parent: options.parent }),
323
- sessionDir: options.sessionDir,
324
- }),
325
- (report) => Object.assign(startup, report),
326
- );
327
- return { transport, startup };
328
- } catch (error) {
329
- if (error instanceof YaagError) throw error;
330
- throw new YaagError("SPAWN_FAILED", `agent "${options.name}": ${String(error)}`, options.name);
331
- }
332
- }
@@ -1,6 +1,13 @@
1
1
  import type { ReportedResult } from "../ask-contract/index.ts";
2
2
  import { isReportResultCommandFrame, ReportResultCall } from "../ask-contract/index.ts";
3
- import type { AgentStats, AskMarker, AskPlayback, Frame } from "../transport/index.ts";
3
+ import type {
4
+ AgentStats,
5
+ AskMarker,
6
+ AskPlayback,
7
+ CompactionMarker,
8
+ CompactionPlayback,
9
+ Frame,
10
+ } from "../transport/index.ts";
4
11
  import { FrameQueue } from "../transport/index.ts";
5
12
  import type { CassetteAgent } from "./cassette.ts";
6
13
 
@@ -92,6 +99,31 @@ export class CassetteReplay {
92
99
  };
93
100
  }
94
101
 
102
+ /**
103
+ * Moves playback to a compaction its caller has already identity-checked.
104
+ *
105
+ * A compaction lives between two Asks, and `beginAsk` never switches the
106
+ * cursors back to the Agent-level streams, so the recorded compaction owns
107
+ * its own streams (ADR-0043).
108
+ */
109
+ beginCompaction(marker: CompactionMarker): CompactionPlayback {
110
+ const compaction = this.#agent.compactions?.[marker.index];
111
+ if (!compaction) throw new Error("unreachable replay compaction cursor");
112
+ this.#sentCursor = 0;
113
+ this.#receivedCursor = 0;
114
+ this.#sent = compaction.sentFrames;
115
+ this.#received = compaction.receivedFrames;
116
+ this.#openAttempt(0);
117
+ return {
118
+ result: compaction.result ?? {
119
+ tokensBefore: null,
120
+ tokensAfter: null,
121
+ tokens: null,
122
+ cost: null,
123
+ },
124
+ };
125
+ }
126
+
95
127
  /** Ends the stream cleanly, allowing a caller to switch to another source. */
96
128
  finish(): void {
97
129
  this.#queue.end();
@@ -39,6 +39,9 @@ const CassetteSpawnSchema = Type.Object({
39
39
  worktree: Type.Optional(Type.Literal(true)),
40
40
  declaredExtensions: Type.Optional(StringArray),
41
41
  parent: Type.Optional(Type.String()),
42
+ origin: Type.Optional(Type.Literal("fork")),
43
+ forkOf: Type.Optional(Type.String()),
44
+ forkAsks: Type.Optional(Type.Number()),
42
45
  });
43
46
 
44
47
  const ContextSpawnSchema = Type.Object({
@@ -119,6 +122,22 @@ const TokenBreakdownSchema = Type.Object({
119
122
  total: Type.Number(),
120
123
  });
121
124
 
125
+ const CompactionResultSchema = Type.Object({
126
+ tokensBefore: Type.Union([Type.Null(), Type.Number()]),
127
+ tokensAfter: Type.Union([Type.Null(), Type.Number()]),
128
+ tokens: Type.Union([Type.Null(), TokenBreakdownSchema]),
129
+ cost: Type.Union([Type.Null(), Type.Number()]),
130
+ });
131
+
132
+ const CassetteCompactionSchema = Type.Object({
133
+ index: Type.Number(),
134
+ hash: Type.String(),
135
+ afterAsks: Type.Number(),
136
+ result: Type.Optional(CompactionResultSchema),
137
+ sentFrames: Type.Array(FrameSchema),
138
+ receivedFrames: Type.Array(FrameSchema),
139
+ });
140
+
122
141
  const CassetteAgentSchema = Type.Object({
123
142
  spawn: CassetteSpawnSchema,
124
143
  model: Type.String(),
@@ -128,6 +147,7 @@ const CassetteAgentSchema = Type.Object({
128
147
  sentFrames: Type.Array(FrameSchema),
129
148
  receivedFrames: Type.Array(FrameSchema),
130
149
  asks: Type.Array(CassetteAskSchema),
150
+ compactions: Type.Optional(Type.Array(CassetteCompactionSchema)),
131
151
  stats: Type.Object({
132
152
  tokens: Type.Union([Type.Null(), TokenBreakdownSchema]),
133
153
  cost: Type.Union([Type.Null(), Type.Number()]),
@@ -7,6 +7,8 @@ import type {
7
7
  AskInvalidOutputPlayback,
8
8
  AskMarker,
9
9
  AskMarkerContext,
10
+ CompactionMarker,
11
+ CompactionResult,
10
12
  Frame,
11
13
  OpenOptions,
12
14
  WorktreeResolution,
@@ -60,9 +62,23 @@ export interface CassetteAgent {
60
62
  readonly sentFrames: readonly Frame[];
61
63
  readonly receivedFrames: readonly Frame[];
62
64
  readonly asks: readonly CassetteAsk[];
65
+ /** Compactions between this Agent's Asks; absent for an Agent that compacted none. */
66
+ readonly compactions?: readonly CassetteCompaction[];
63
67
  readonly stats: AgentStats;
64
68
  }
65
69
 
70
+ /** The frames attributed to one compaction marker (ADR-0043). */
71
+ export interface CassetteCompaction {
72
+ readonly index: number;
73
+ readonly hash: string;
74
+ /** Settled Asks of this Agent at the compaction point. */
75
+ readonly afterAsks: number;
76
+ /** What the recorded compaction reported; absent when the exchange failed. */
77
+ readonly result?: CompactionResult;
78
+ readonly sentFrames: readonly Frame[];
79
+ readonly receivedFrames: readonly Frame[];
80
+ }
81
+
66
82
  /** Behavioural identity supplied when opening a recorded Agent. */
67
83
  export interface CassetteSpawn {
68
84
  readonly name: string;
@@ -93,6 +109,12 @@ export interface CassetteSpawn {
93
109
  * Part of spawn identity; absent for a root Agent and for older Cassettes.
94
110
  */
95
111
  readonly parent?: string;
112
+ /** How the Agent came to be; absent means an ordinary spawn. Spawn identity. */
113
+ readonly origin?: "fork";
114
+ /** Name of the Agent this one was forked from. Spawn identity. */
115
+ readonly forkOf?: string;
116
+ /** Settled Asks of the fork source at the fork point. Spawn identity. */
117
+ readonly forkAsks?: number;
96
118
  }
97
119
 
98
120
  /** The frames attributed to one Ask marker. */
@@ -142,6 +164,8 @@ export interface CassetteRecorder {
142
164
  received(frame: Frame): void;
143
165
  beginAsk(marker: AskMarker): void;
144
166
  finishAsk(completion: AskCompletion): void;
167
+ beginCompaction(marker: CompactionMarker): void;
168
+ finishCompaction(result: CompactionResult | undefined): void;
145
169
  closed(stats: AgentStats): void;
146
170
  }
147
171
 
@@ -161,6 +185,15 @@ interface MutableAsk {
161
185
  recovered?: true;
162
186
  }
163
187
 
188
+ interface MutableCompaction {
189
+ readonly index: number;
190
+ readonly hash: string;
191
+ readonly afterAsks: number;
192
+ readonly sentFrames: Frame[];
193
+ readonly receivedFrames: Frame[];
194
+ result?: CompactionResult;
195
+ }
196
+
164
197
  interface MutableAgent {
165
198
  readonly spawn: CassetteSpawn;
166
199
  readonly model: string;
@@ -170,8 +203,10 @@ interface MutableAgent {
170
203
  readonly sentFrames: Frame[];
171
204
  readonly receivedFrames: Frame[];
172
205
  readonly asks: MutableAsk[];
206
+ readonly compactions: MutableCompaction[];
173
207
  stats: AgentStats;
174
- active: MutableAsk | null;
208
+ /** The slot later frames belong to: one Ask, one compaction, or the Agent. */
209
+ active: MutableAsk | MutableCompaction | null;
175
210
  }
176
211
 
177
212
  /** Collects a Cassette in memory; `executeRun` serializes it at Run settlement. */
@@ -194,6 +229,7 @@ export class CassetteCollector implements CassetteSink {
194
229
  sentFrames: [],
195
230
  receivedFrames: [],
196
231
  asks: [],
232
+ compactions: [],
197
233
  stats: { tokens: null, cost: null },
198
234
  active: null,
199
235
  };
@@ -211,11 +247,24 @@ export class CassetteCollector implements CassetteSink {
211
247
  agent.active = ask;
212
248
  },
213
249
  finishAsk: (completion): void => {
214
- if (!agent?.active) return;
215
- if (completion.limit !== undefined) agent.active.limit = completion.limit;
216
- if (completion.stalled !== undefined) agent.active.stalled = completion.stalled;
217
- if (completion.invalidOutput !== undefined) agent.active.outcome = completion.invalidOutput;
218
- if (completion.recovered === true) agent.active.recovered = true;
250
+ const ask = activeAsk(agent);
251
+ if (!ask) return;
252
+ if (completion.limit !== undefined) ask.limit = completion.limit;
253
+ if (completion.stalled !== undefined) ask.stalled = completion.stalled;
254
+ if (completion.invalidOutput !== undefined) ask.outcome = completion.invalidOutput;
255
+ if (completion.recovered === true) ask.recovered = true;
256
+ },
257
+ beginCompaction: (marker): void => {
258
+ if (!agent) return;
259
+ const compaction = { ...marker, sentFrames: [], receivedFrames: [] };
260
+ agent.compactions.push(compaction);
261
+ agent.active = compaction;
262
+ },
263
+ finishCompaction: (result): void => {
264
+ const compaction = agent?.compactions.at(-1);
265
+ if (compaction !== undefined && result !== undefined) compaction.result = result;
266
+ // Later frames belong to the Agent again, not to the compaction.
267
+ if (agent) agent.active = null;
219
268
  },
220
269
  closed: (stats): void => {
221
270
  if (agent) agent.stats = stats;
@@ -239,6 +288,7 @@ export class CassetteCollector implements CassetteSink {
239
288
  sentFrames,
240
289
  receivedFrames,
241
290
  asks,
291
+ compactions,
242
292
  stats,
243
293
  }) => ({
244
294
  spawn,
@@ -249,6 +299,9 @@ export class CassetteCollector implements CassetteSink {
249
299
  sentFrames,
250
300
  receivedFrames,
251
301
  asks,
302
+ // Absent for an Agent that compacted nothing, so every Cassette
303
+ // recorded before ADR-0043 keeps its byte-identical shape.
304
+ ...(compactions.length === 0 ? {} : { compactions }),
252
305
  stats,
253
306
  }),
254
307
  ),
@@ -278,10 +331,20 @@ function spawnIdentity(options: OpenOptions): CassetteSpawn {
278
331
  ? {}
279
332
  : { declaredExtensions: [...options.declaredExtensions] }),
280
333
  ...(options.parent === undefined ? {} : { parent: options.parent }),
334
+ ...(options.origin === undefined ? {} : { origin: options.origin }),
335
+ ...(options.forkOf === undefined ? {} : { forkOf: options.forkOf }),
336
+ ...(options.forkAsks === undefined ? {} : { forkAsks: options.forkAsks }),
281
337
  ...(options.worktree === true ? { worktree: true } : {}),
282
338
  };
283
339
  }
284
340
 
341
+ /** The Ask that later frames belong to, or nothing when a compaction owns them. */
342
+ function activeAsk(agent: MutableAgent | null): MutableAsk | null {
343
+ const active = agent?.active;
344
+ if (!active || "afterAsks" in active) return null;
345
+ return active;
346
+ }
347
+
285
348
  function append(
286
349
  agent: MutableAgent | null,
287
350
  direction: "sentFrames" | "receivedFrames",
@@ -10,6 +10,7 @@ export {
10
10
  type CassetteArtifact,
11
11
  type CassetteAsk,
12
12
  CassetteCollector,
13
+ type CassetteCompaction,
13
14
  type CassetteGit,
14
15
  type CassetteRun,
15
16
  type CassetteSink,
@@ -4,6 +4,9 @@ import type {
4
4
  AskCompletion,
5
5
  AskMarker,
6
6
  AskPlayback,
7
+ CompactionMarker,
8
+ CompactionPlayback,
9
+ CompactionResult,
7
10
  Frame,
8
11
  OpenOptions,
9
12
  RecordedSpawnSelection,
@@ -87,6 +90,16 @@ class RecordingTransport implements AgentTransport {
87
90
  this.#inner.finishAsk(completion);
88
91
  }
89
92
 
93
+ beginCompaction(marker: CompactionMarker): CompactionPlayback | undefined {
94
+ this.#recorder.beginCompaction(marker);
95
+ return this.#inner.beginCompaction(marker);
96
+ }
97
+
98
+ finishCompaction(result: CompactionResult | undefined): void {
99
+ this.#recorder.finishCompaction(result);
100
+ this.#inner.finishCompaction(result);
101
+ }
102
+
90
103
  recordedExtractionPolicy(index: number): string | undefined {
91
104
  return this.#inner.recordedExtractionPolicy?.(index);
92
105
  }
@@ -2,6 +2,7 @@ import { YaagError } from "../errors.ts";
2
2
  import type {
3
3
  AskMarker,
4
4
  AskMarkerContext,
5
+ CompactionMarker,
5
6
  OpenOptions,
6
7
  RecordedSpawnSelection,
7
8
  } from "../transport/index.ts";
@@ -9,7 +10,13 @@ import type { CassetteAgent, CassetteAsk, CassetteSpawn } from "./cassette.ts";
9
10
 
10
11
  /** A pure description of the first strict replay identity mismatch. */
11
12
  export interface ReplayMismatch {
12
- readonly kind: "unexpected-spawn" | "spawn-options" | "changed-ask" | "extra-ask";
13
+ readonly kind:
14
+ | "unexpected-spawn"
15
+ | "spawn-options"
16
+ | "changed-ask"
17
+ | "extra-ask"
18
+ | "changed-compaction"
19
+ | "extra-compaction";
13
20
  readonly agent: string;
14
21
  readonly index?: number;
15
22
  readonly expectedHash: string;
@@ -71,6 +78,32 @@ export const replayMismatch = {
71
78
  }),
72
79
  };
73
80
  },
81
+
82
+ compaction(
83
+ agent: CassetteAgent,
84
+ cursor: number,
85
+ actual: CompactionMarker,
86
+ ): ReplayMismatch | null {
87
+ const expected = agent.compactions?.[cursor];
88
+ if (!expected) {
89
+ return {
90
+ kind: "extra-compaction",
91
+ agent: agent.spawn.name,
92
+ index: actual.index,
93
+ expectedHash: "<none>",
94
+ actualHash: actual.hash,
95
+ };
96
+ }
97
+ return expected.index === actual.index && expected.hash === actual.hash
98
+ ? null
99
+ : {
100
+ kind: "changed-compaction",
101
+ agent: agent.spawn.name,
102
+ index: actual.index,
103
+ expectedHash: expected.hash,
104
+ actualHash: actual.hash,
105
+ };
106
+ },
74
107
  };
75
108
 
76
109
  /** Where a user reads what a Divergence means and what to do about it. */
@@ -86,7 +119,8 @@ export function strictReplay(mismatch: ReplayMismatch): never {
86
119
  mismatch.agent,
87
120
  );
88
121
  }
89
- const at = mismatch.index === undefined ? "spawn" : `Ask ${mismatch.index}`;
122
+ const at =
123
+ compactionAt(mismatch) ?? (mismatch.index === undefined ? "spawn" : `Ask ${mismatch.index}`);
90
124
  const changed =
91
125
  mismatch.kind === "spawn-options" && mismatch.changedFields !== undefined
92
126
  ? ` changed ${mismatch.changedFields.join(", ")};`
@@ -98,6 +132,14 @@ export function strictReplay(mismatch: ReplayMismatch): never {
98
132
  );
99
133
  }
100
134
 
135
+ /** Names the compaction seam of a compaction mismatch, or nothing for other kinds. */
136
+ function compactionAt(mismatch: ReplayMismatch): string | undefined {
137
+ if (mismatch.kind !== "changed-compaction" && mismatch.kind !== "extra-compaction") {
138
+ return undefined;
139
+ }
140
+ return `compaction #${mismatch.index}`;
141
+ }
142
+
101
143
  /** The identity fields a changed spawn altered, for the Divergence report (ADR-0039). */
102
144
  function spawnChangedFields(expected: CassetteSpawn, actual: OpenOptions): readonly string[] {
103
145
  return changedAmong(SPAWN_OPEN_FIELDS, expected, actual);
@@ -150,6 +192,9 @@ const SPAWN_OPEN_FIELDS = [
150
192
  ...SPAWN_IDENTITY_FIELDS,
151
193
  "declaredExtensions",
152
194
  "parent",
195
+ "origin",
196
+ "forkOf",
197
+ "forkAsks",
153
198
  ] as const;
154
199
 
155
200
  /** The listed fields whose canonical JSON differs between two identity records. */
@@ -222,6 +267,10 @@ function spawnHash(options: CassetteSpawn | OpenOptions): string {
222
267
  ? {}
223
268
  : { declaredExtensions: options.declaredExtensions }),
224
269
  ...(options.parent === undefined ? {} : { parent: options.parent }),
270
+ // Conditional, so a Cassette recorded before forking keeps its hash.
271
+ ...(options.origin === undefined ? {} : { origin: options.origin }),
272
+ ...(options.forkOf === undefined ? {} : { forkOf: options.forkOf }),
273
+ ...(options.forkAsks === undefined ? {} : { forkAsks: options.forkAsks }),
225
274
  }),
226
275
  );
227
276
  return hasher.digest("hex");
@@ -3,6 +3,8 @@ import type {
3
3
  AgentTransport,
4
4
  AskMarker,
5
5
  AskPlayback,
6
+ CompactionMarker,
7
+ CompactionPlayback,
6
8
  Frame,
7
9
  OpenOptions,
8
10
  RecordedSpawnSelection,
@@ -30,7 +32,14 @@ export function replayTransport(cassette: Cassette): TransportFactory {
30
32
  const mismatch = replayMismatch.spawn(agent, options);
31
33
  if (mismatch) strictReplay(mismatch);
32
34
  spawnCursor += 1;
33
- if (agent.worktree !== undefined) observeStartup?.({ worktree: agent.worktree });
35
+ // The recorded session file rides along, so a replayed Agent can be
36
+ // forked exactly where the recording forked it (ADR-0044).
37
+ if (agent.worktree !== undefined || agent.sessionFile !== undefined) {
38
+ observeStartup?.({
39
+ ...(agent.worktree === undefined ? {} : { worktree: agent.worktree }),
40
+ ...(agent.sessionFile === undefined ? {} : { sessionFile: agent.sessionFile }),
41
+ });
42
+ }
34
43
  return new ReplayTransport(new CassetteReplay(agent));
35
44
  },
36
45
 
@@ -46,6 +55,7 @@ class ReplayTransport implements AgentTransport {
46
55
  readonly model: string;
47
56
  readonly #replay: CassetteReplay;
48
57
  #askCursor = 0;
58
+ #compactionCursor = 0;
49
59
  #closed: Promise<AgentStats> | null = null;
50
60
 
51
61
  constructor(replay: CassetteReplay) {
@@ -72,6 +82,17 @@ class ReplayTransport implements AgentTransport {
72
82
  // A completed replay does not alter the recorded Cassette.
73
83
  }
74
84
 
85
+ beginCompaction(marker: CompactionMarker): CompactionPlayback {
86
+ const mismatch = replayMismatch.compaction(this.#replay.agent, this.#compactionCursor, marker);
87
+ if (mismatch) strictReplay(mismatch);
88
+ this.#compactionCursor += 1;
89
+ return this.#replay.beginCompaction(marker);
90
+ }
91
+
92
+ finishCompaction(): void {
93
+ // A completed replay does not alter the recorded Cassette.
94
+ }
95
+
75
96
  recordedExtractionPolicy(index: number): string | undefined {
76
97
  return this.#replay.agent.asks[index]?.extractionPolicy;
77
98
  }