@morlay/session-rdb 0.0.15 → 0.0.16-alpha.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 (65) hide show
  1. package/README.md +11 -13
  2. package/dist/artifact.d.mts +6 -16
  3. package/dist/artifact.mjs +3 -3
  4. package/dist/{import-DFed8DBt.mjs → import-CKrzjXVB.mjs} +73 -83
  5. package/dist/import.d.mts +2 -3
  6. package/dist/import.mjs +2 -2
  7. package/dist/index-CgGDHQSK.d.mts +225 -0
  8. package/dist/index.d.mts +3 -3
  9. package/dist/index.mjs +694 -169
  10. package/dist/log-DTJsxMud.mjs +314 -0
  11. package/dist/{schema-CFXZURX7.d.mts → schema-COK7-wRV.d.mts} +3 -15
  12. package/dist/sqlite-DCy4VTD8.mjs +698 -0
  13. package/dist/storage.d.mts +2 -7
  14. package/dist/storage.mjs +2 -3
  15. package/dist/testing.d.mts +30 -1
  16. package/dist/testing.mjs +676 -1991
  17. package/drizzle/postgres/20260908072805_v2_initial/migration.sql +58 -0
  18. package/drizzle/postgres/20260908072805_v2_initial/snapshot.json +686 -0
  19. package/drizzle/postgres/20260908072806_v3_drop_original_seq/migration.sql +1 -0
  20. package/drizzle/postgres/20260908072806_v3_drop_original_seq/snapshot.json +673 -0
  21. package/drizzle/sqlite/20260908072751_v2_initial/migration.sql +53 -0
  22. package/drizzle/sqlite/20260908072751_v2_initial/snapshot.json +492 -0
  23. package/drizzle/sqlite/20260908072752_v3_drop_original_seq/migration.sql +6 -0
  24. package/drizzle/sqlite/20260908072752_v3_drop_original_seq/snapshot.json +513 -0
  25. package/package.json +17 -12
  26. package/src/adapters/index.ts +10 -2
  27. package/src/adapters/to-postgres.ts +19 -15
  28. package/src/adapters/to-sqlite.ts +20 -14
  29. package/src/{entities → adapters}/types.ts +7 -8
  30. package/src/backend.ts +0 -7
  31. package/src/branch.ts +29 -110
  32. package/src/drizzle/postgres-v2.ts +11 -0
  33. package/src/drizzle/postgres-v3.ts +11 -0
  34. package/src/drizzle/sqlite-v2.ts +10 -0
  35. package/src/drizzle/sqlite-v3.ts +11 -0
  36. package/src/entities/index.ts +2 -30
  37. package/src/entities/v2/index.ts +20 -0
  38. package/src/entities/v2/session-events.ts +11 -0
  39. package/src/entities/v3/events.ts +27 -0
  40. package/src/entities/v3/index.ts +27 -0
  41. package/src/entities/v3/persistence-state.ts +10 -0
  42. package/src/entities/v3/schema-meta.ts +9 -0
  43. package/src/entities/v3/session-events.ts +26 -0
  44. package/src/entities/v3/sessions.ts +20 -0
  45. package/src/import.ts +65 -57
  46. package/src/index.ts +868 -223
  47. package/src/invariant.ts +0 -2
  48. package/src/legacy.ts +67 -0
  49. package/src/log.ts +41 -87
  50. package/src/postgres.ts +75 -93
  51. package/src/schema.ts +3 -14
  52. package/src/sqlite.ts +59 -46
  53. package/src/testing/contract.ts +460 -375
  54. package/src/testing/coordinator-contract.ts +108 -1374
  55. package/src/testing.ts +1 -6
  56. package/dist/index-uUvn6gYp.d.mts +0 -111
  57. package/dist/schema-vwzwEXNE.mjs +0 -878
  58. package/dist/sqlite-CG_Qcx5C.mjs +0 -356
  59. package/src/adapters/ddl.ts +0 -77
  60. package/src/entities/events.ts +0 -27
  61. package/src/entities/persistence-state.ts +0 -10
  62. package/src/entities/schema-meta.ts +0 -9
  63. package/src/entities/session-events.ts +0 -29
  64. package/src/entities/sessions.ts +0 -20
  65. package/src/migrate.ts +0 -137
@@ -1,10 +1,8 @@
1
1
  import { createUserMessage } from "@deepseek-ai/dsh-llm";
2
2
 
3
- import { describe, expect, it, vi } from "vitest";
3
+ import { describe, expect, it } from "vitest";
4
4
  import { Context, type Fiber } from "@deepseek-ai/cordis";
5
- import { scopeTarget } from "@deepseek-ai/dsh-scope";
6
5
  import SessionStore, {
7
- SESSION_FORMAT_VERSION,
8
6
  Session,
9
7
  SessionId,
10
8
  SessionLogOffset,
@@ -22,238 +20,21 @@ export interface CoordinatorFixture {
22
20
  }
23
21
 
24
22
  const WORK = "/w";
25
- const OTHER = "/other";
26
23
 
27
24
  function send(session: Session, events: readonly SessionEvent[]): void {
28
25
  appendLog(session, events);
29
26
  }
30
27
 
31
- function legacyMessageLog(): SessionEvent[] {
32
- return [
33
- { type: "turn/start", seq: SessionSeq(0), time: 1, data: { turn: 1 } },
34
- {
35
- type: "user/message",
36
- seq: SessionSeq(1),
37
- time: 2,
38
- data: { content: [{ type: "text", text: "hi" }], source: { kind: "user" } },
39
- surfaceOp: "append",
40
- },
41
- { type: "step/start", seq: SessionSeq(2), time: 3, data: { turn: 1, step: 1 } },
42
- {
43
- type: "assistant/message",
44
- seq: SessionSeq(3),
45
- time: 4,
46
- data: {
47
- turn: 1,
48
- step: 1,
49
- content: [{ type: "tool-call", id: "call-1", name: "read", arguments: "{}" }],
50
- provenance: { provider: "mock", model: "mock" },
51
- },
52
- surfaceOp: "append",
53
- },
54
- {
55
- type: "tool/call",
56
- seq: SessionSeq(4),
57
- time: 5,
58
- data: { turn: 1, step: 1, callId: "call-1", name: "read", arguments: "{}" },
59
- },
60
- {
61
- type: "tool/result",
62
- seq: SessionSeq(5),
63
- time: 6,
64
- data: {
65
- turn: 1,
66
- step: 1,
67
- callId: "call-1",
68
- content: [{ type: "text", text: "full result" }],
69
- isError: false,
70
- },
71
- sourceEventSeqs: [4],
72
- surfaceOp: "append",
73
- },
74
- {
75
- type: "tool/result",
76
- seq: SessionSeq(6),
77
- time: 8,
78
- data: {
79
- turn: 1,
80
- step: 1,
81
- callId: "call-1",
82
- content: [{ type: "text", text: "pruned" }],
83
- isError: false,
84
- },
85
- sourceEventSeqs: [5],
86
- surfaceOp: { op: "replace", start: 5, end: 5 },
87
- },
88
- { type: "step/end", seq: SessionSeq(7), time: 9, data: { turn: 1, step: 1 } },
89
- {
90
- type: "turn/end",
91
- seq: SessionSeq(8),
92
- time: 10,
93
- data: { turn: 1, reason: { kind: "completed" } },
94
- },
95
- ] as unknown as SessionEvent[];
96
- }
97
-
98
- function preReactLoopLog(): SessionEvent[] {
99
- const prompt = createUserMessage({
100
- content: [{ type: "text", text: "old prompt" }],
101
- source: { kind: "user" },
102
- });
103
- const steering = createUserMessage({
104
- content: [{ type: "text", text: "old steering" }],
105
- source: { kind: "user" },
106
- });
107
- return [
108
- {
109
- type: "turn/start",
110
- seq: SessionSeq(0),
111
- time: 1,
112
- data: { turn: 1, trigger: { kind: "message", source: { kind: "user" } } },
113
- },
114
- { type: "user/message", seq: SessionSeq(1), time: 2, data: prompt, surfaceOp: "append" },
115
- { type: "step/start", seq: SessionSeq(2), time: 3, data: { turn: 1, step: 1 } },
116
- {
117
- type: "steering/message",
118
- seq: SessionSeq(3),
119
- time: 4,
120
- data: { turn: 1, message: steering },
121
- surfaceOp: "append",
122
- },
123
- { type: "step/end", seq: SessionSeq(4), time: 5, data: { turn: 1, step: 1 } },
124
- {
125
- type: "turn/end",
126
- seq: SessionSeq(5),
127
- time: 6,
128
- data: { turn: 1, reason: { kind: "completed" } },
129
- },
130
- {
131
- type: "turn/start",
132
- seq: SessionSeq(6),
133
- time: 7,
134
- data: { turn: 2, trigger: { kind: "retry" } },
135
- },
136
- { type: "step/start", seq: SessionSeq(7), time: 8, data: { turn: 2, step: 1 } },
137
- { type: "step/end", seq: SessionSeq(8), time: 9, data: { turn: 2, step: 1 } },
138
- {
139
- type: "turn/end",
140
- seq: SessionSeq(9),
141
- time: 10,
142
- data: {
143
- turn: 2,
144
- reason: {
145
- kind: "error",
146
- step: 1,
147
- failure: { message: "old provider failure", code: "SERVER" },
148
- },
149
- },
150
- },
151
- {
152
- type: "turn/start",
153
- seq: SessionSeq(10),
154
- time: 11,
155
- data: { turn: 3, trigger: { kind: "message", source: { kind: "user" } } },
156
- },
157
- {
158
- type: "turn/end",
159
- seq: SessionSeq(11),
160
- time: 12,
161
- data: { turn: 3, reason: { kind: "aborted" } },
162
- },
163
- {
164
- type: "turn/start",
165
- seq: SessionSeq(12),
166
- time: 13,
167
- data: { turn: 4, trigger: { kind: "message", source: { kind: "user" } } },
168
- },
169
- {
170
- type: "turn/end",
171
- seq: SessionSeq(13),
172
- time: 14,
173
- data: { turn: 4, reason: { kind: "disposed" } },
174
- },
175
- {
176
- type: "turn/start",
177
- seq: SessionSeq(14),
178
- time: 15,
179
- data: { turn: 5, trigger: { kind: "message", source: { kind: "user" } } },
180
- },
181
- { type: "step/start", seq: SessionSeq(15), time: 16, data: { turn: 5, step: 1 } },
182
- { type: "step/end", seq: SessionSeq(16), time: 17, data: { turn: 5, step: 1 } },
183
- {
184
- type: "turn/end",
185
- seq: SessionSeq(17),
186
- time: 18,
187
- data: { turn: 5, reason: { kind: "error", step: 1, message: "old thrown value" } },
188
- },
189
- {
190
- type: "turn/start",
191
- seq: SessionSeq(18),
192
- time: 19,
193
- data: { turn: 6, trigger: { kind: "message", source: { kind: "user" } } },
194
- },
195
- {
196
- type: "turn/end",
197
- seq: SessionSeq(19),
198
- time: 20,
199
- data: {
200
- turn: 6,
201
- reason: {
202
- kind: "error",
203
- step: 0,
204
- failure: {
205
- message: "old detailed provider failure",
206
- code: "RATE_LIMIT",
207
- status: 429,
208
- providerRetryAfterMs: 1000,
209
- requestId: "request-1",
210
- },
211
- },
212
- },
213
- },
214
- {
215
- type: "turn/start",
216
- seq: SessionSeq(20),
217
- time: 21,
218
- data: { turn: 7, trigger: { kind: "message", source: { kind: "user" } } },
219
- },
220
- {
221
- type: "turn/end",
222
- seq: SessionSeq(21),
223
- time: 22,
224
- data: {
225
- turn: 7,
226
- reason: { kind: "error", step: 0, message: "old coded error", code: "CODED" },
227
- },
228
- },
229
- ] as unknown as Array<SessionEvent>;
230
- }
231
-
232
- async function liveSessionInFiber(
233
- ctx: Context,
234
- id: string,
235
- cwd: string | undefined,
236
- ): Promise<Session> {
237
- let session!: Session;
238
- await ctx.plugin(
239
- Object.assign(
240
- (inner: Context) => {
241
- session = inner.sessions.create(
242
- SessionId(id),
243
- cwd !== undefined ? { meta: { cwd } } : undefined,
244
- );
245
- },
246
- { inject: ["sessions"] },
247
- ),
248
- );
249
- return session;
250
- }
251
-
28
+ /**
29
+ * RDB 特有行为契约:live 会话驱动持久化、seed 边界、fork/resume、HMR
30
+ * dispose drain、冲突拒绝、torn-tail。coordinator 内部状态(ownerless /
31
+ * per-id chain / legacy 迁移)已由新版 handle 模型承担,不再单独测试。
32
+ */
252
33
  export function runCoordinatorContract(
253
34
  name: string,
254
35
  makeFixture: () => Promise<CoordinatorFixture>,
255
36
  ): void {
256
- describe(`PersistenceCoordinator orchestration: ${name}`, () => {
37
+ describe(`SessionPersistenceRdb orchestration: ${name}`, () => {
257
38
  async function freshCtx(fix: CoordinatorFixture): Promise<{ ctx: Context; fiber: Fiber }> {
258
39
  const ctx = new Context();
259
40
  await ctx.plugin(SessionStore);
@@ -261,7 +42,18 @@ export function runCoordinatorContract(
261
42
  return { ctx, fiber };
262
43
  }
263
44
 
264
- // --- write path: live session → flush → reload ---
45
+ async function readAll(
46
+ ctx: Context,
47
+ id: SessionId,
48
+ ): Promise<{ meta: Session["header"]; events: readonly SessionEvent[] }> {
49
+ const handle = await ctx.sessionPersistence.open(id, "read");
50
+ try {
51
+ const { events } = await handle.read();
52
+ return { meta: handle.header, events };
53
+ } finally {
54
+ await handle.close();
55
+ }
56
+ }
265
57
 
266
58
  it("persists a live session driven through the store, surviving reload", async () => {
267
59
  const fix = await makeFixture();
@@ -271,7 +63,7 @@ export function runCoordinatorContract(
271
63
  send(session, oneTurnLog());
272
64
  await ctx.sessions.flush(session);
273
65
 
274
- const loaded = await ctx.sessionPersistence.load(SessionId("live"));
66
+ const loaded = await readAll(ctx, SessionId("live"));
275
67
  expect(loaded.events).toHaveLength(6);
276
68
  expect(loaded.meta.cwd).toBe(WORK);
277
69
  } finally {
@@ -280,102 +72,7 @@ export function runCoordinatorContract(
280
72
  }
281
73
  });
282
74
 
283
- it("rejects crash-repair load while a live session owns the persisted prefix", async () => {
284
- const fix = await makeFixture();
285
- const { ctx, fiber } = await freshCtx(fix);
286
- let session!: Session;
287
- const sessionFiber = await ctx.plugin(
288
- Object.assign(
289
- (inner: Context) => {
290
- session = inner.sessions.create(SessionId("live-load"), { meta: { cwd: WORK } });
291
- },
292
- { inject: ["sessions"] },
293
- ),
294
- );
295
- try {
296
- session.append("turn/start", { turn: 1 });
297
- await ctx.sessions.flush(session);
298
-
299
- await expect(ctx.sessionPersistence.load(session.id)).rejects.toThrow(
300
- `cannot load session "${session.id}" while its live turn is open`,
301
- );
302
-
303
- send(session, oneTurnLog().slice(1));
304
- await ctx.sessions.flush(session);
305
- await sessionFiber.dispose();
306
-
307
- await vi.waitFor(async () => {
308
- const loaded = await ctx.sessionPersistence.load(session.id);
309
- expect(loaded.events.map((event) => event.type)).toEqual(
310
- oneTurnLog().map((event) => event.type),
311
- );
312
- expect(loaded.events.at(-1)).toMatchObject({
313
- type: "turn/end",
314
- data: { reason: { kind: "completed" } },
315
- });
316
- });
317
- } finally {
318
- await sessionFiber.dispose();
319
- await fiber.dispose();
320
- await fix.cleanup();
321
- }
322
- });
323
-
324
- it("rechecks live ownership after a cold load enters the per-id chain", async () => {
325
- const fix = await makeFixture();
326
- const { ctx, fiber } = await freshCtx(fix);
327
- try {
328
- const id = SessionId("queued-load-live-race");
329
- const header = meta(id, WORK);
330
- const start: SessionEvent = {
331
- type: "turn/start",
332
- seq: SessionSeq(0),
333
- time: 1,
334
- data: { turn: 1 },
335
- };
336
- await ctx.sessionPersistence.create(header);
337
- await ctx.sessionPersistence.append(id, [start]);
338
-
339
- const loading = ctx.sessionPersistence.load(id);
340
- const live = ctx.sessions.create(id, { seed: [start], meta: header });
341
- await expect(loading).rejects.toThrow(/live turn is open/);
342
-
343
- live.append("turn/end", { turn: 1, reason: { kind: "completed" } });
344
- await ctx.sessions.flush(live);
345
- const loaded = await ctx.sessionPersistence.load(id);
346
- // The constructor's end-seed event persisted between the stored
347
- // turn/start and the turn/end appended live.
348
- expect(loaded.events.map((event) => event.type)).toEqual([
349
- "turn/start",
350
- "session/end-seed",
351
- "turn/end",
352
- ]);
353
- expect(loaded.events.at(-1)).toMatchObject({
354
- type: "turn/end",
355
- data: { reason: { kind: "completed" } },
356
- });
357
- } finally {
358
- await fiber.dispose();
359
- await fix.cleanup();
360
- }
361
- });
362
-
363
- it("does not load an unmaterialized empty live session", async () => {
364
- const fix = await makeFixture();
365
- const { ctx, fiber } = await freshCtx(fix);
366
- try {
367
- const session = ctx.sessions.create(SessionId("empty-live"), { meta: { cwd: WORK } });
368
- await expect(ctx.sessionPersistence.load(session.id)).rejects.toThrow(/not found/);
369
- } finally {
370
- await fiber.dispose();
371
- await fix.cleanup();
372
- }
373
- });
374
-
375
75
  it("round-trips the seed boundary (inheritedEventCount) through persistence", async () => {
376
- // A forked child records how many leading events were inherited via the seed; the
377
- // boundary must survive a reload (so a resume/replay can tell the inherited prefix from
378
- // the child's own events). JSONL stores it in the header; SQLite uses `seed_length`.
379
76
  const fix = await makeFixture();
380
77
  const { ctx, fiber } = await freshCtx(fix);
381
78
  try {
@@ -396,9 +93,13 @@ export function runCoordinatorContract(
396
93
  await ctx.sessions.flush(session);
397
94
  await sessionFiber.dispose();
398
95
 
399
- const loaded = await ctx.sessionPersistence.load(SessionId("forked-child"));
400
- expect(loaded.meta.isSeeded).toBe(true);
401
- expect(loaded.inheritedEventCount).toBe(3);
96
+ const handle = await ctx.sessionPersistence.open(SessionId("forked-child"), "read");
97
+ try {
98
+ expect(handle.header.isSeeded).toBe(true);
99
+ expect(handle.inheritedEventCount).toBe(3);
100
+ } finally {
101
+ await handle.close();
102
+ }
402
103
  } finally {
403
104
  await fiber.dispose();
404
105
  await fix.cleanup();
@@ -406,9 +107,6 @@ export function runCoordinatorContract(
406
107
  });
407
108
 
408
109
  it("round-trips the delegation depth through persistence", async () => {
409
- // A subagent child's recursion budget lives in its header; a reload that
410
- // dropped it would reset the child to top-level and un-bound maxDepth
411
- // (JSONL stores it in the header line; SQLite uses `delegation_depth`).
412
110
  const fix = await makeFixture();
413
111
  const { ctx, fiber } = await freshCtx(fix);
414
112
  try {
@@ -427,7 +125,7 @@ export function runCoordinatorContract(
427
125
  await ctx.parallel("session/flush", session);
428
126
  await sessionFiber.dispose();
429
127
 
430
- const loaded = await ctx.sessionPersistence.load(SessionId("delegated-child"));
128
+ const loaded = await readAll(ctx, SessionId("delegated-child"));
431
129
  expect(loaded.meta.delegationDepth).toBe(2);
432
130
  } finally {
433
131
  await fiber.dispose();
@@ -455,7 +153,7 @@ export function runCoordinatorContract(
455
153
  session.append("turn/end", { turn: 1, reason: { kind: "completed" } });
456
154
  await ctx.sessions.flush(session);
457
155
 
458
- const loaded = await ctx.sessionPersistence.load(SessionId("mutate"));
156
+ const loaded = await readAll(ctx, SessionId("mutate"));
459
157
  const message = loaded.events.find((event) => event.type === "user/message");
460
158
  expect(
461
159
  message?.type === "user/message" && (message.data.content[0] as { text: string }).text,
@@ -466,469 +164,6 @@ export function runCoordinatorContract(
466
164
  }
467
165
  });
468
166
 
469
- it("load and inspect return immutable identified-message snapshots", async () => {
470
- const fix = await makeFixture();
471
- const { ctx, fiber } = await freshCtx(fix);
472
- try {
473
- const id = SessionId("immutable-read");
474
- const session = ctx.sessions.create(id, { meta: { cwd: WORK } });
475
- send(session, oneTurnLog());
476
- await ctx.sessions.flush(session);
477
-
478
- for (const snapshot of [
479
- await ctx.sessionPersistence.load(id),
480
- await ctx.sessionPersistence.inspect(id),
481
- ]) {
482
- const event = snapshot.events.find((candidate) => candidate.type === "user/message");
483
- if (event?.type !== "user/message") throw new Error("fixture lacks user/message");
484
- expect(Object.isFrozen(event.data)).toBe(true);
485
- expect(Object.isFrozen(event.data.content)).toBe(true);
486
- expect(() => {
487
- (event.data as { id: string }).id = "rewritten";
488
- }).toThrow(TypeError);
489
- expect(() => {
490
- (event.data.content[0] as { type: "text"; text: string }).text = "rewritten";
491
- }).toThrow(TypeError);
492
- }
493
- } finally {
494
- await fiber.dispose();
495
- await fix.cleanup();
496
- }
497
- });
498
-
499
- it("loads pre-identity message logs into resumable current sessions", async () => {
500
- const fix = await makeFixture();
501
- const { ctx, fiber } = await freshCtx(fix);
502
- try {
503
- const id = SessionId("legacy-message-load");
504
- await ctx.sessionPersistence.create(meta(id, WORK));
505
- await ctx.sessionPersistence.append(id, legacyMessageLog());
506
-
507
- for (const snapshot of [
508
- await ctx.sessionPersistence.inspect(id),
509
- await ctx.sessionPersistence.load(id),
510
- ]) {
511
- const messages: { id: string }[] = [];
512
- for (const event of snapshot.events) {
513
- if (event.type === "user/message") messages.push(event.data);
514
- else if (event.type === "assistant/message" || event.type === "tool/result")
515
- messages.push(event.data.message);
516
- }
517
- expect(messages.map((message) => message.id)).toEqual([
518
- `legacy-message:${id}:1`,
519
- `legacy-message:${id}:3`,
520
- `legacy-message:${id}:5`,
521
- `legacy-message:${id}:5`,
522
- ]);
523
- expect(messages.every((message) => Object.isFrozen(message))).toBe(true);
524
-
525
- const resumed = Session.create(
526
- id,
527
- snapshot.events,
528
- snapshot.meta,
529
- snapshot.inheritedEventCount,
530
- );
531
- expect(resumed.deriveMessages().map((message) => message.id)).toEqual([
532
- `legacy-message:${id}:1`,
533
- `legacy-message:${id}:3`,
534
- `legacy-message:${id}:5`,
535
- ]);
536
- }
537
-
538
- const replacementSuffix = await ctx.sessionPersistence.readFrom(id, SessionLogOffset(6));
539
- expect(replacementSuffix.events[0]).toMatchObject({
540
- type: "tool/result",
541
- seq: SessionSeq(6),
542
- data: { message: { id: `legacy-message:${id}:5` } },
543
- });
544
- } finally {
545
- await fiber.dispose();
546
- await fix.cleanup();
547
- }
548
- });
549
-
550
- it("loads pre-react-loop session logs into resumable current sessions", async () => {
551
- const fix = await makeFixture();
552
- const { ctx, fiber } = await freshCtx(fix);
553
- try {
554
- const id = SessionId("pre-react-loop-load");
555
- const log = preReactLoopLog();
556
- const legacySteering = log[3] as unknown as { data: { message: { id: string } } };
557
- await ctx.sessionPersistence.create(meta(id, WORK));
558
- await ctx.sessionPersistence.append(id, log);
559
-
560
- const snapshots = [
561
- await ctx.sessionPersistence.inspect(id),
562
- await ctx.sessionPersistence.readFrom(id, SessionLogOffset(0)),
563
- await ctx.sessionPersistence.load(id),
564
- ];
565
- for (const snapshot of snapshots) {
566
- expect(
567
- snapshot.events.some((event) => (event.type as string) === "steering/message"),
568
- ).toBe(false);
569
- expect(
570
- snapshot.events
571
- .filter((event) => event.type === "turn/start")
572
- .map((event) => event.data),
573
- ).toEqual([
574
- { turn: 1 },
575
- { turn: 2 },
576
- { turn: 3 },
577
- { turn: 4 },
578
- { turn: 5 },
579
- { turn: 6 },
580
- { turn: 7 },
581
- ]);
582
- expect(
583
- snapshot.events.filter((event) => event.type === "turn/end").map((event) => event.data),
584
- ).toEqual([
585
- { turn: 1, reason: { kind: "completed" } },
586
- {
587
- turn: 2,
588
- reason: { kind: "error", error: { message: "old provider failure", code: "SERVER" } },
589
- },
590
- { turn: 3, reason: { kind: "aborted", reason: { kind: "legacy" } } },
591
- { turn: 4, reason: { kind: "aborted", reason: { kind: "disposed" } } },
592
- {
593
- turn: 5,
594
- reason: { kind: "error", error: { message: "old thrown value", code: "UNKNOWN" } },
595
- },
596
- {
597
- turn: 6,
598
- reason: {
599
- kind: "error",
600
- error: {
601
- message: "old detailed provider failure",
602
- code: "RATE_LIMIT",
603
- status: 429,
604
- providerRetryAfterMs: 1000,
605
- requestId: "request-1",
606
- },
607
- },
608
- },
609
- {
610
- turn: 7,
611
- reason: { kind: "error", error: { message: "old coded error", code: "CODED" } },
612
- },
613
- ]);
614
-
615
- const resumed = Session.create(
616
- id,
617
- snapshot.events,
618
- snapshot.meta,
619
- snapshot.inheritedEventCount,
620
- );
621
- expect(resumed.deriveMessages().map((message) => message.content)).toEqual([
622
- [{ type: "text", text: "old prompt" }],
623
- [{ type: "text", text: "old steering" }],
624
- ]);
625
- }
626
-
627
- const suffix = await ctx.sessionPersistence.readFrom(id, SessionLogOffset(3));
628
- expect(suffix.events[0]).toMatchObject({
629
- type: "user/message",
630
- seq: SessionSeq(3),
631
- data: { id: legacySteering.data.message.id },
632
- });
633
- expect(
634
- suffix.events
635
- .filter((event) => event.type === "turn/end")
636
- .every((event) => !Object.hasOwn(event.data, "step")),
637
- ).toBe(true);
638
-
639
- const flatId = SessionId("pre-react-loop-flat-steering");
640
- await ctx.sessionPersistence.create(meta(flatId, WORK));
641
- await ctx.sessionPersistence.append(flatId, [
642
- {
643
- type: "steering/message",
644
- seq: SessionSeq(0),
645
- time: 1,
646
- data: {
647
- turn: 1,
648
- content: [{ type: "text", text: "flat steering" }],
649
- source: { kind: "user" },
650
- },
651
- surfaceOp: "append",
652
- } as unknown as SessionEvent,
653
- ]);
654
- expect((await ctx.sessionPersistence.inspect(flatId)).events[0]).toMatchObject({
655
- type: "user/message",
656
- data: {
657
- id: `legacy-message:${flatId}:0`,
658
- role: "user",
659
- content: [{ type: "text", text: "flat steering" }],
660
- },
661
- });
662
-
663
- const extendedId = SessionId("current-extended-turn-end");
664
- await ctx.sessionPersistence.create(meta(extendedId, WORK));
665
- await ctx.sessionPersistence.append(extendedId, [
666
- { type: "turn/start", seq: SessionSeq(0), time: 1, data: { turn: 1 } },
667
- {
668
- type: "turn/end",
669
- seq: SessionSeq(1),
670
- time: 2,
671
- data: { turn: 1, reason: { kind: "extension-reason" } },
672
- } as unknown as SessionEvent,
673
- ]);
674
- expect((await ctx.sessionPersistence.inspect(extendedId)).events[1]).toMatchObject({
675
- type: "turn/end",
676
- data: { reason: { kind: "extension-reason" } },
677
- });
678
- } finally {
679
- await fiber.dispose();
680
- await fix.cleanup();
681
- }
682
- });
683
-
684
- it("rejects malformed persisted message events before returning them", async () => {
685
- const fix = await makeFixture();
686
- const { ctx, fiber } = await freshCtx(fix);
687
- try {
688
- const id = SessionId("invalid-message-read");
689
- await ctx.sessionPersistence.create(meta(id, WORK));
690
- await ctx.sessionPersistence.append(id, [
691
- {
692
- type: "user/message",
693
- seq: SessionSeq(0),
694
- time: 1,
695
- surfaceOp: "append",
696
- data: {
697
- id: "wrong-role",
698
- role: "assistant",
699
- content: [{ type: "text", text: "wrong" }],
700
- source: { kind: "user" },
701
- },
702
- } as unknown as SessionEvent,
703
- ]);
704
-
705
- await expect(ctx.sessionPersistence.inspect(id)).rejects.toThrow(
706
- 'message must have role "user"',
707
- );
708
- await expect(ctx.sessionPersistence.load(id)).rejects.toThrow(
709
- 'message must have role "user"',
710
- );
711
-
712
- const malformedLegacy: { id: string; event: SessionEvent; message: string }[] = [
713
- {
714
- id: "invalid-old-turn-start",
715
- event: {
716
- type: "turn/start",
717
- seq: SessionSeq(0),
718
- time: 1,
719
- data: { turn: 1, trigger: null },
720
- } as unknown as SessionEvent,
721
- message: "malformed pre-react-loop turn/start",
722
- },
723
- {
724
- id: "invalid-old-steering",
725
- event: {
726
- type: "steering/message",
727
- seq: SessionSeq(0),
728
- time: 1,
729
- surfaceOp: "append",
730
- data: { turn: 1, content: [], source: { kind: "user" }, extra: true },
731
- } as unknown as SessionEvent,
732
- message: "malformed pre-react-loop steering/message",
733
- },
734
- {
735
- id: "invalid-old-steering-data",
736
- event: {
737
- type: "steering/message",
738
- seq: SessionSeq(0),
739
- time: 1,
740
- surfaceOp: "append",
741
- data: null,
742
- } as unknown as SessionEvent,
743
- message: "malformed pre-react-loop steering/message",
744
- },
745
- {
746
- id: "invalid-old-turn-end",
747
- event: {
748
- type: "turn/end",
749
- seq: SessionSeq(0),
750
- time: 1,
751
- data: { turn: 1, reason: { kind: "completed", extra: true } },
752
- } as unknown as SessionEvent,
753
- message: "malformed pre-react-loop turn/end",
754
- },
755
- {
756
- id: "invalid-old-turn-end-reason",
757
- event: {
758
- type: "turn/end",
759
- seq: SessionSeq(0),
760
- time: 1,
761
- data: { turn: 1, reason: null },
762
- } as unknown as SessionEvent,
763
- message: "malformed pre-react-loop turn/end",
764
- },
765
- {
766
- id: "unsupported-intermediate-turn-end-step",
767
- event: {
768
- type: "turn/end",
769
- seq: SessionSeq(0),
770
- time: 1,
771
- data: { turn: 1, step: 1, reason: { kind: "completed" } },
772
- } as unknown as SessionEvent,
773
- message: "malformed pre-react-loop turn/end",
774
- },
775
- {
776
- id: "invalid-old-turn-end-aborted",
777
- event: {
778
- type: "turn/end",
779
- seq: SessionSeq(0),
780
- time: 1,
781
- data: { turn: 1, reason: { kind: "aborted", extra: true } },
782
- } as unknown as SessionEvent,
783
- message: "malformed pre-react-loop turn/end",
784
- },
785
- {
786
- id: "invalid-old-turn-end-disposed",
787
- event: {
788
- type: "turn/end",
789
- seq: SessionSeq(0),
790
- time: 1,
791
- data: { turn: 1, reason: { kind: "disposed", extra: true } },
792
- } as unknown as SessionEvent,
793
- message: "malformed pre-react-loop turn/end",
794
- },
795
- {
796
- id: "invalid-old-turn-end-error-step",
797
- event: {
798
- type: "turn/end",
799
- seq: SessionSeq(0),
800
- time: 1,
801
- data: { turn: 1, reason: { kind: "error", step: -1, message: "bad step" } },
802
- } as unknown as SessionEvent,
803
- message: "malformed pre-react-loop turn/end",
804
- },
805
- {
806
- id: "invalid-old-turn-end-error-code",
807
- event: {
808
- type: "turn/end",
809
- seq: SessionSeq(0),
810
- time: 1,
811
- data: { turn: 1, reason: { kind: "error", step: 0, message: "bad code", code: 1 } },
812
- } as unknown as SessionEvent,
813
- message: "malformed pre-react-loop turn/end",
814
- },
815
- ];
816
- for (const malformed of malformedLegacy) {
817
- const malformedId = SessionId(malformed.id);
818
- await ctx.sessionPersistence.create(meta(malformedId, WORK));
819
- await ctx.sessionPersistence.append(malformedId, [malformed.event]);
820
- await expect(ctx.sessionPersistence.inspect(malformedId)).rejects.toThrow(
821
- malformed.message,
822
- );
823
- await expect(
824
- ctx.sessionPersistence.readFrom(malformedId, SessionLogOffset(0)),
825
- ).rejects.toThrow(malformed.message);
826
- }
827
-
828
- for (const type of ["tool/result"] as const) {
829
- const malformedId = SessionId(`invalid-${type}`);
830
- await ctx.sessionPersistence.create(meta(malformedId, WORK));
831
- await ctx.sessionPersistence.append(malformedId, [
832
- {
833
- type,
834
- seq: SessionSeq(0),
835
- time: 1,
836
- surfaceOp: "append",
837
- data: { message: null },
838
- } as unknown as SessionEvent,
839
- ]);
840
- await expect(ctx.sessionPersistence.inspect(malformedId)).rejects.toThrow(
841
- "lacks an identified message",
842
- );
843
- }
844
-
845
- const pluginId = SessionId("non-object-plugin-event");
846
- await ctx.sessionPersistence.create(meta(pluginId, WORK));
847
- await ctx.sessionPersistence.append(pluginId, [
848
- {
849
- type: "plugin/test",
850
- seq: SessionSeq(0),
851
- time: 1,
852
- data: null,
853
- // An ignorable event's loss cannot affect reconstruction (envelope
854
- // contract on SessionEvent.ignorable), so this backend drops it at
855
- // write time instead of persisting a marker for it.
856
- ignorable: true,
857
- } as unknown as SessionEvent,
858
- ]);
859
- await expect(ctx.sessionPersistence.inspect(pluginId)).rejects.toThrow(/not found/);
860
- await expect(
861
- ctx.sessionPersistence.readFrom(pluginId, SessionLogOffset(0)),
862
- ).rejects.toThrow(/not found/);
863
-
864
- // Contrast: an unknown type WITHOUT the ignorable marker is a REQUIRED
865
- // event — it is persisted (loss would affect reconstruction) and the
866
- // coordinator refuses to interpret the log on read.
867
- const requiredId = SessionId("non-object-required-plugin-event");
868
- await ctx.sessionPersistence.create(meta(requiredId, WORK));
869
- await ctx.sessionPersistence.append(requiredId, [
870
- {
871
- type: "plugin/required",
872
- seq: SessionSeq(0),
873
- time: 1,
874
- data: null,
875
- } as unknown as SessionEvent,
876
- ]);
877
- await expect(ctx.sessionPersistence.inspect(requiredId)).rejects.toThrow(
878
- /unknown to this harness/,
879
- );
880
-
881
- for (const type of ["user/message", "assistant/message"] as const) {
882
- const missingContentId = SessionId(`invalid-${type}-without-content`);
883
- await ctx.sessionPersistence.create(meta(missingContentId, WORK));
884
- await ctx.sessionPersistence.append(missingContentId, [
885
- {
886
- type,
887
- seq: SessionSeq(0),
888
- time: 1,
889
- surfaceOp: "append",
890
- data: {},
891
- } as unknown as SessionEvent,
892
- ]);
893
- await expect(
894
- ctx.sessionPersistence.readFrom(missingContentId, SessionLogOffset(0)),
895
- ).rejects.toThrow("lacks an identified message");
896
- }
897
- } finally {
898
- await fiber.dispose();
899
- await fix.cleanup();
900
- }
901
- });
902
-
903
- it("append snapshots the batch: mutating the caller array/events after the call is ignored", async () => {
904
- const fix = await makeFixture();
905
- const { ctx, fiber } = await freshCtx(fix);
906
- try {
907
- const m = meta("snapshot", WORK);
908
- await ctx.sessionPersistence.create(m);
909
- const events = structuredClone(oneTurnLog()); // seqs 0..5
910
- const userMsg = events[1]; // the user/message event
911
- const p = ctx.sessionPersistence.append(m.id, events);
912
- // Mutate the caller's array AND an event object after the call but before
913
- // the queued op runs: the snapshot taken at call time must shield the copy.
914
- events.push({ type: "turn/start", seq: SessionSeq(6), time: 99, data: { turn: 2 } });
915
- if (userMsg?.type === "user/message") {
916
- (userMsg.data as { content: unknown[] }).content = [{ type: "text", text: "MUTATED" }];
917
- }
918
- await p;
919
- const loaded = await ctx.sessionPersistence.load(m.id);
920
- expect(loaded.events.map((e) => e.seq)).toEqual([0, 1, 2, 3, 4, 5]); // not 0..6
921
- const persisted = JSON.stringify(loaded.events);
922
- expect(persisted).toContain("hi"); // original content
923
- expect(persisted).not.toContain("MUTATED");
924
- } finally {
925
- await fiber.dispose();
926
- await fix.cleanup();
927
- }
928
- });
929
-
930
- // --- fork / resume ---
931
-
932
167
  it("fork: a seeded new session persists its seed once (no double-write on a no-op flush)", async () => {
933
168
  const fix = await makeFixture();
934
169
  const { ctx, fiber } = await freshCtx(fix);
@@ -937,14 +172,14 @@ export function runCoordinatorContract(
937
172
  // A fork: a brand-new id whose seed came from elsewhere.
938
173
  const forked = ctx.sessions.create(SessionId("forked"), { seed, meta: { cwd: WORK } });
939
174
  await ctx.sessions.flush(forked); // onCreated persisted the seed
940
- const loaded = await ctx.sessionPersistence.load(SessionId("forked"));
175
+ const loaded = await readAll(ctx, SessionId("forked"));
941
176
  // Fork is where the marker earns its keep: the inherited prefix may
942
177
  // carry a bracket the still-running parent owns.
943
178
  expect(loaded.events.slice(0, seed.length)).toEqual(seed);
944
179
  expect(loaded.events.at(-1)).toMatchObject({ type: "session/end-seed", seq: seed.length });
945
180
  // A flush with no NEW events must not double-write.
946
181
  await ctx.sessions.flush(forked);
947
- const reloaded = await ctx.sessionPersistence.load(SessionId("forked"));
182
+ const reloaded = await readAll(ctx, SessionId("forked"));
948
183
  expect(reloaded.events).toEqual(loaded.events);
949
184
  } finally {
950
185
  await fiber.dispose();
@@ -953,7 +188,6 @@ export function runCoordinatorContract(
953
188
  });
954
189
 
955
190
  it("resume: a re-created session seeded with the loaded log does not re-append its seed and continues the seq", async () => {
956
- // Separate backend lifecycles distinguish persisted-seed adoption from an in-memory continuation.
957
191
  const fix = await makeFixture();
958
192
  const first = await freshCtx(fix);
959
193
  try {
@@ -966,7 +200,7 @@ export function runCoordinatorContract(
966
200
 
967
201
  const second = await freshCtx(fix);
968
202
  try {
969
- const loaded = await second.ctx.sessionPersistence.load(SessionId("resumed"));
203
+ const loaded = await readAll(second.ctx, SessionId("resumed"));
970
204
  const s2 = second.ctx.sessions.create(SessionId("resumed"), {
971
205
  seed: loaded.events,
972
206
  meta: { cwd: WORK },
@@ -976,7 +210,7 @@ export function runCoordinatorContract(
976
210
  s2.append("turn/end", { turn: 2, reason: { kind: "completed" } });
977
211
  await second.ctx.sessions.flush(s2);
978
212
 
979
- const reloaded = await second.ctx.sessionPersistence.load(SessionId("resumed"));
213
+ const reloaded = await readAll(second.ctx, SessionId("resumed"));
980
214
  // 0-5 the resumed seed, 6 end-seed, 7-8 the new turn.
981
215
  expect(reloaded.events.map((e) => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8]);
982
216
  expect(reloaded.events[6]).toMatchObject({ type: "session/end-seed" });
@@ -986,8 +220,6 @@ export function runCoordinatorContract(
986
220
  }
987
221
  });
988
222
 
989
- // --- HMR ---
990
-
991
223
  it("HMR: applying the plugin seeds existing live sessions", async () => {
992
224
  const fix = await makeFixture();
993
225
  const ctx = new Context();
@@ -1009,7 +241,7 @@ export function runCoordinatorContract(
1009
241
  try {
1010
242
  // The plugin seeded it on apply; a subsequent flush persists its events.
1011
243
  await ctx.sessions.flush(session);
1012
- const loaded = await ctx.sessionPersistence.load(SessionId("pre-existing"));
244
+ const loaded = await readAll(ctx, SessionId("pre-existing"));
1013
245
  expect(loaded.events.length).toBeGreaterThanOrEqual(2);
1014
246
  } finally {
1015
247
  await fiber.dispose();
@@ -1022,7 +254,7 @@ export function runCoordinatorContract(
1022
254
  const ctx = new Context();
1023
255
  await ctx.plugin(SessionStore);
1024
256
  const fiber = await fix.mount(ctx);
1025
- const session = await liveSessionInFiber(ctx, "drain", WORK);
257
+ const session = ctx.sessions.create(SessionId("drain"), { meta: { cwd: WORK } });
1026
258
  session.append("turn/start", { turn: 1 });
1027
259
  session.append(
1028
260
  "user/message",
@@ -1039,7 +271,7 @@ export function runCoordinatorContract(
1039
271
  // A fresh backend instance reads what the disposed one drained.
1040
272
  const second = await freshCtx(fix);
1041
273
  try {
1042
- const loaded = await second.ctx.sessionPersistence.load(SessionId("drain"));
274
+ const loaded = await readAll(second.ctx, SessionId("drain"));
1043
275
  expect(loaded.events.length).toBeGreaterThanOrEqual(2);
1044
276
  } finally {
1045
277
  await second.fiber.dispose();
@@ -1047,473 +279,92 @@ export function runCoordinatorContract(
1047
279
  }
1048
280
  });
1049
281
 
1050
- it("HMR: reloading the backend adopts a still-live, already-materialized session", async () => {
1051
- const fix = await makeFixture();
1052
- const ctx = new Context();
1053
- await ctx.plugin(SessionStore);
1054
- // The session lives in its OWN fiber so it survives the backend reload.
1055
- const session = await liveSessionInFiber(ctx, "hmr-adopt", WORK);
1056
- try {
1057
- // Backend instance 1 materializes the session.
1058
- const backend1 = await fix.mount(ctx);
1059
- session.append("turn/start", { turn: 1 });
1060
- session.append(
1061
- "user/message",
1062
- createUserMessage({
1063
- content: [{ type: "text", text: "hi" }],
1064
- source: { kind: "user" },
1065
- }),
1066
- { surfaceOp: "append" },
1067
- );
1068
- session.append("turn/end", { turn: 1, reason: { kind: "completed" } });
1069
- await ctx.sessions.flush(session);
1070
-
1071
- // Hot-reload: dispose instance 1, mount instance 2 over the same storage while the
1072
- // session stays live. The new instance has no coordinator state but must adopt the
1073
- // materialized prefix, then persist another turn rather than rejecting it as a collision.
1074
- await backend1.dispose();
1075
- await fix.mount(ctx);
1076
- session.append("turn/start", { turn: 2 });
1077
- session.append(
1078
- "user/message",
1079
- createUserMessage({
1080
- content: [{ type: "text", text: "again" }],
1081
- source: { kind: "user" },
1082
- }),
1083
- { surfaceOp: "append" },
1084
- );
1085
- session.append("turn/end", { turn: 2, reason: { kind: "completed" } });
1086
- await expect(ctx.sessions.flush(session)).resolves.not.toThrow();
1087
-
1088
- const loaded = await ctx.sessionPersistence.load(SessionId("hmr-adopt"));
1089
- expect(loaded.events.filter((e) => e.type === "turn/start")).toHaveLength(2);
1090
- } finally {
1091
- await ctx.fiber.dispose();
1092
- await fix.cleanup();
1093
- }
1094
- });
1095
-
1096
- it("HMR: adoption persists the live SUFFIX that was ahead of the stored prefix", async () => {
1097
- const fix = await makeFixture();
1098
- const ctx = new Context();
1099
- await ctx.plugin(SessionStore);
1100
- const session = await liveSessionInFiber(ctx, "hmr-suffix", WORK);
1101
- try {
1102
- // Instance 1 flushes turn 1.
1103
- const backend1 = await fix.mount(ctx);
1104
- session.append("turn/start", { turn: 1 });
1105
- session.append("turn/end", { turn: 1, reason: { kind: "completed" } });
1106
- await ctx.sessions.flush(session);
1107
-
1108
- // Append turn 2 to the LIVE session, then dispose instance 1 WITHOUT
1109
- // flushing turn 2: it is now ONLY in the live session's events; the new
1110
- // backend never buffered it via session/event.
1111
- await backend1.dispose();
1112
- session.append("turn/start", { turn: 2 });
1113
- session.append("turn/end", { turn: 2, reason: { kind: "completed" } });
1114
-
1115
- // Instance 2 adopts the stored prefix (turn 1) and MUST also persist the
1116
- // live suffix (turn 2) carried in the session's events.
1117
- await fix.mount(ctx);
1118
- await ctx.sessions.flush(session);
1119
- const loaded = await ctx.sessionPersistence.load(SessionId("hmr-suffix"));
1120
- expect(loaded.events.map((e) => e.seq)).toEqual([0, 1, 2, 3]);
1121
- expect(loaded.events.filter((e) => e.type === "turn/start")).toHaveLength(2);
1122
- } finally {
1123
- await ctx.fiber.dispose();
1124
- await fix.cleanup();
1125
- }
1126
- });
1127
-
1128
- it("HMR adoption does NOT crash-repair an active open turn as interrupted (truncate without closers)", async () => {
1129
- const fix = await makeFixture();
1130
- const ctx = new Context();
1131
- await ctx.plugin(SessionStore);
1132
- const session = await liveSessionInFiber(ctx, "hmr-open", WORK);
1133
- try {
1134
- const first = await fix.mount(ctx);
1135
- session.append("turn/start", { turn: 1 });
1136
- session.append("step/start", { turn: 1, step: 1 });
1137
- await ctx.sessions.flush(session);
1138
-
1139
- // Crash-tail a torn fragment past the (open) committed turn, then reload.
1140
- await first.dispose();
1141
- if (fix.corruptTail) await fix.corruptTail(SessionId("hmr-open"), WORK);
1142
- const second = await fix.mount(ctx);
1143
- // The live session is still the authority: it appends the REAL step/turn
1144
- // end. Adoption must truncate the torn tail but NOT synthesize closers.
1145
- session.append("step/end", { turn: 1, step: 1 });
1146
- session.append("turn/end", { turn: 1, reason: { kind: "completed" } });
1147
- await ctx.sessions.flush(session);
1148
-
1149
- const loaded = await ctx.sessionPersistence.load(SessionId("hmr-open"));
1150
- expect(loaded.events.map((e) => e.type)).toEqual([
1151
- "turn/start",
1152
- "step/start",
1153
- "step/end",
1154
- "turn/end",
1155
- ]);
1156
- expect(loaded.events.at(-1)).toMatchObject({
1157
- type: "turn/end",
1158
- data: { reason: { kind: "completed" } },
1159
- });
1160
- await second.dispose();
1161
- } finally {
1162
- await ctx.fiber.dispose();
1163
- await fix.cleanup();
1164
- }
1165
- });
1166
-
1167
- // --- collision / id reuse ---
1168
-
1169
282
  it("a NEW live session colliding on a persisted id is rejected, not silently adopted", async () => {
1170
- const fix = await makeFixture();
1171
- const first = await freshCtx(fix);
1172
- try {
1173
- const s1 = first.ctx.sessions.create(SessionId("collide"), { meta: { cwd: WORK } });
1174
- send(s1, oneTurnLog());
1175
- await first.ctx.sessions.flush(s1);
1176
- } finally {
1177
- await first.fiber.dispose();
1178
- }
1179
-
1180
- // A fresh backend + a NEW live session with the same id but NO explicit resume. onCreated
1181
- // treats it as new; create() rejects because a log already exists, and `flush()` surfaces
1182
- // that initialization rejection.
1183
- const second = await freshCtx(fix);
1184
- try {
1185
- const s2 = second.ctx.sessions.create(SessionId("collide"), { meta: { cwd: WORK } });
1186
- s2.append("turn/start", { turn: 1 });
1187
- await expect(second.ctx.sessions.flush(s2)).rejects.toThrow(
1188
- /already has a persisted log|id collision/,
1189
- );
1190
- } finally {
1191
- await second.fiber.dispose();
1192
- await fix.cleanup();
1193
- }
1194
- });
1195
-
1196
- it("an abandoned lazy session (never materialized) releases its id for reuse", async () => {
1197
- const fix = await makeFixture();
1198
- const { ctx, fiber } = await freshCtx(fix);
1199
- try {
1200
- // A live session created then disposed BEFORE its first append: cursor 0,
1201
- // never materialized. A new live session reusing the id must reclaim it.
1202
- let firstSession!: Session;
1203
- const firstFiber = await ctx.plugin(
1204
- Object.assign(
1205
- (inner: Context) => {
1206
- firstSession = inner.sessions.create(SessionId("abandoned"), { meta: { cwd: WORK } });
1207
- },
1208
- { inject: ["sessions"] },
1209
- ),
1210
- );
1211
- await ctx.sessions.flush(firstSession); // register the lazy state
1212
- await firstFiber.dispose(); // disposed before any append → never materialized
1213
-
1214
- let reuse!: Session;
1215
- await ctx.plugin(
1216
- Object.assign(
1217
- (inner: Context) => {
1218
- reuse = inner.sessions.create(SessionId("abandoned"), { meta: { cwd: WORK } });
1219
- },
1220
- { inject: ["sessions"] },
1221
- ),
1222
- );
1223
- await expect(ctx.sessions.flush(reuse)).resolves.toBe(true);
1224
- reuse.append("turn/start", { turn: 1 });
1225
- reuse.append("turn/end", { turn: 1, reason: { kind: "completed" } });
1226
- await ctx.sessions.flush(reuse);
1227
- const loaded = await ctx.sessionPersistence.load(SessionId("abandoned"));
1228
- expect(loaded.events.map((e) => e.seq)).toEqual([0, 1]);
1229
- } finally {
1230
- await fiber.dispose();
1231
- await fix.cleanup();
1232
- }
1233
- });
1234
-
1235
- it("session disposal drains buffered events before retiring ownership", async () => {
1236
283
  const fix = await makeFixture();
1237
284
  const { ctx, fiber } = await freshCtx(fix);
1238
285
  try {
286
+ // Persist a session with cwd WORK.
1239
287
  let first!: Session;
1240
288
  const firstFiber = await ctx.plugin(
1241
289
  Object.assign(
1242
290
  (inner: Context) => {
1243
- first = inner.sessions.create(SessionId("buffered"), { meta: { cwd: WORK } });
291
+ first = inner.sessions.create(SessionId("collide"), { meta: { cwd: WORK } });
1244
292
  },
1245
293
  { inject: ["sessions"] },
1246
294
  ),
1247
295
  );
296
+ send(first, oneTurnLog());
1248
297
  await ctx.sessions.flush(first);
1249
- // Append a turn but do NOT flush — events sit in the write-behind buffer.
1250
- first.append("turn/start", { turn: 1 });
1251
- first.append("turn/end", { turn: 1, reason: { kind: "completed" } });
1252
298
  await firstFiber.dispose();
1253
299
 
1254
- // Disposal is an observe-only notification. Poll storage rather than
1255
- // assuming the owning fiber awaits the coordinator's detached drain.
1256
- await vi.waitFor(async () => {
1257
- expect((await ctx.sessionPersistence.list()).map((meta) => meta.id)).toContain(
1258
- SessionId("buffered"),
1259
- );
1260
- });
1261
- expect(
1262
- (await ctx.sessionPersistence.load(SessionId("buffered"))).events.map(
1263
- (event) => event.seq,
1264
- ),
1265
- ).toEqual([0, 1]);
1266
-
1267
- let reuse!: Session;
300
+ // A NEW live session with the same id but a DIFFERENT cwd must be rejected.
301
+ let second!: Session;
1268
302
  await ctx.plugin(
1269
303
  Object.assign(
1270
304
  (inner: Context) => {
1271
- reuse = inner.sessions.create(SessionId("buffered"), { meta: { cwd: WORK } });
305
+ second = inner.sessions.create(SessionId("collide"), { meta: { cwd: "/other" } });
1272
306
  },
1273
307
  { inject: ["sessions"] },
1274
308
  ),
1275
309
  );
1276
- await expect(ctx.sessions.flush(reuse)).rejects.toThrow(/persisted log|id collision/);
1277
- } finally {
1278
- await fiber.dispose();
1279
- await fix.cleanup();
1280
- }
1281
- });
1282
-
1283
- it("initFor is idempotent: re-emitting session/created does not re-initialize", async () => {
1284
- const fix = await makeFixture();
1285
- const { ctx, fiber } = await freshCtx(fix);
1286
- try {
1287
- const session = ctx.sessions.create(SessionId("idem"), { meta: { cwd: WORK } });
1288
- session.append("turn/start", { turn: 1 });
1289
- session.append(
1290
- "user/message",
1291
- createUserMessage({
1292
- content: [{ type: "text", text: "x" }],
1293
- source: { kind: "user" },
1294
- }),
1295
- { surfaceOp: "append" },
1296
- );
1297
- session.append("turn/end", { turn: 1, reason: { kind: "completed" } });
1298
- await ctx.sessions.flush(session);
1299
- // Re-emit session/created for the SAME live session (idempotent initFor).
1300
- ctx.emit(scopeTarget(session, undefined), "session/created", session);
1301
- await ctx.sessions.flush(session);
1302
- const loaded = await ctx.sessionPersistence.load(SessionId("idem"));
1303
- expect(loaded.events).toHaveLength(3); // not doubled
1304
- } finally {
1305
- await fiber.dispose();
1306
- await fix.cleanup();
1307
- }
1308
- });
1309
-
1310
- // --- ownerless-state claim (public create()/load() then a live session arrives) ---
1311
-
1312
- it("a live session claims cursor-0 ownerless state created via the public API and persists its seed", async () => {
1313
- const fix = await makeFixture();
1314
- const { ctx, fiber } = await freshCtx(fix);
1315
- try {
1316
- // create() registers ownerless state with cursor 0 (lazy, nothing persisted).
1317
- await ctx.sessionPersistence.create(meta("lazy-claim", WORK));
1318
- // A live session with that id arrives and claims it (cursor 0 matches
1319
- // trivially), persisting its seed.
1320
- const live = ctx.sessions.create(SessionId("lazy-claim"), {
1321
- seed: oneTurnLog(),
1322
- meta: { cwd: WORK },
1323
- });
1324
- await expect(ctx.sessions.flush(live)).resolves.toBe(true);
1325
- const loaded = await ctx.sessionPersistence.load(SessionId("lazy-claim"));
1326
- // Seeded 0-5 plus the constructor's end-seed event at 6.
1327
- expect(loaded.events.map((e) => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6]);
1328
- expect(loaded.events.at(-1)).toMatchObject({ type: "session/end-seed" });
310
+ await expect(ctx.sessions.flush(second)).rejects.toThrow(/different cwd/);
1329
311
  } finally {
1330
312
  await fiber.dispose();
1331
313
  await fix.cleanup();
1332
314
  }
1333
315
  });
1334
316
 
1335
- it("a fresh session reusing a previously-loaded id is rejected (ownerless guard)", async () => {
317
+ it("an abandoned lazy session (never materialized) releases its id for reuse", async () => {
1336
318
  const fix = await makeFixture();
1337
319
  const { ctx, fiber } = await freshCtx(fix);
1338
320
  try {
1339
- // Materialize a log, then load() it WITHOUT a live session — ownerless
1340
- // state, cursor at the persisted length.
1341
- await ctx.sessionPersistence.create(meta("preview", WORK));
1342
- await ctx.sessionPersistence.append(SessionId("preview"), oneTurnLog());
1343
- await ctx.sessionPersistence.load(SessionId("preview"));
1344
-
1345
- // A FRESH (empty-seed) live session reusing that id must be rejected: its
1346
- // seq 0..cursor-1 events would otherwise be filtered as already-persisted.
1347
- let fresh!: Session;
1348
- await ctx.plugin(
321
+ // A live session created then disposed BEFORE its first append: never
322
+ // materialized. A new live session reusing the id must reclaim it.
323
+ let firstSession!: Session;
324
+ const firstFiber = await ctx.plugin(
1349
325
  Object.assign(
1350
326
  (inner: Context) => {
1351
- fresh = inner.sessions.create(SessionId("preview"), { meta: { cwd: WORK } });
327
+ firstSession = inner.sessions.create(SessionId("abandoned"), { meta: { cwd: WORK } });
1352
328
  },
1353
329
  { inject: ["sessions"] },
1354
330
  ),
1355
331
  );
1356
- await expect(ctx.sessions.flush(fresh)).rejects.toThrow(
1357
- /do not match this live session|already has a persisted log|id collision/,
1358
- );
1359
- } finally {
1360
- await fiber.dispose();
1361
- await fix.cleanup();
1362
- }
1363
- });
1364
-
1365
- it("a live session whose complete seed matches loaded ownerless state claims it without appending", async () => {
1366
- const fix = await makeFixture();
1367
- const { ctx, fiber } = await freshCtx(fix);
1368
- try {
1369
- const id = SessionId("claim-exact");
1370
- const completeSeed = [
1371
- ...oneTurnLog(),
1372
- { type: "session/end-seed", seq: SessionSeq(6), time: 7, data: {} },
1373
- ] as SessionEvent[];
1374
- await ctx.sessionPersistence.create(meta(id, WORK));
1375
- await ctx.sessionPersistence.append(id, completeSeed);
1376
- const { events } = await ctx.sessionPersistence.load(id);
1377
-
1378
- const live = ctx.sessions.create(id, { seed: events, meta: { cwd: WORK } });
1379
- await expect(ctx.sessions.flush(live)).resolves.toBe(true);
1380
- expect((await ctx.sessionPersistence.load(id)).events).toEqual(events);
1381
- } finally {
1382
- await fiber.dispose();
1383
- await fix.cleanup();
1384
- }
1385
- });
1386
-
1387
- it("a live session whose seed matches the loaded prefix claims ownerless state and persists the suffix", async () => {
1388
- const fix = await makeFixture();
1389
- const { ctx, fiber } = await freshCtx(fix);
1390
- try {
1391
- // Materialize and load (ownerless, cursor = 6).
1392
- const storedMeta = meta("claim", WORK);
1393
- await ctx.sessionPersistence.create(storedMeta);
1394
- await ctx.sessionPersistence.append(SessionId("claim"), oneTurnLog());
1395
- const { events, meta: durableMeta } = await ctx.sessionPersistence.load(SessionId("claim"));
332
+ await ctx.sessions.flush(firstSession); // register the lazy state
333
+ await firstFiber.dispose(); // disposed before any append never materialized
1396
334
 
1397
- // A live session SEEDED with the loaded log PLUS a new turn claims the
1398
- // ownerless state and persists only the suffix.
1399
- let cont!: Session;
1400
- const contFiber = await ctx.plugin(
335
+ let reuse!: Session;
336
+ await ctx.plugin(
1401
337
  Object.assign(
1402
338
  (inner: Context) => {
1403
- cont = inner.sessions.create(SessionId("claim"), {
1404
- seed: [
1405
- ...events,
1406
- { type: "turn/start", seq: SessionSeq(6), time: 7, data: { turn: 2 } },
1407
- {
1408
- type: "turn/end",
1409
- seq: SessionSeq(7),
1410
- time: 8,
1411
- data: { turn: 2, reason: { kind: "completed" } },
1412
- },
1413
- ],
1414
- meta: { cwd: WORK, createdAt: 2000 },
1415
- });
339
+ reuse = inner.sessions.create(SessionId("abandoned"), { meta: { cwd: WORK } });
1416
340
  },
1417
341
  { inject: ["sessions"] },
1418
342
  ),
1419
343
  );
1420
- await ctx.sessions.flush(cont);
1421
- const loaded = await ctx.sessionPersistence.load(SessionId("claim"));
1422
- // 6-7 the claimed suffix; 8 end-seed after the whole seed.
1423
- expect(loaded.events.map((e) => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8]);
1424
- expect(loaded.events.at(-1)).toMatchObject({ type: "session/end-seed" });
1425
- expect(loaded.meta).toEqual(durableMeta);
1426
- expect(loaded.meta.createdAt).toBe(1000);
1427
-
1428
- await contFiber.dispose();
1429
- await vi.waitFor(async () => {
1430
- expect((await ctx.sessionPersistence.load(SessionId("claim"))).meta).toEqual(durableMeta);
1431
- });
1432
- } finally {
1433
- await fiber.dispose();
1434
- await fix.cleanup();
1435
- }
1436
- });
1437
-
1438
- it("a live session at a DIFFERENT cwd cannot claim cursor-0 ownerless state (cwd scope)", async () => {
1439
- const fix = await makeFixture();
1440
- const { ctx, fiber } = await freshCtx(fix);
1441
- try {
1442
- // create() registers ownerless state at cwd /a (cursor 0 — claims would
1443
- // otherwise match trivially on the seed).
1444
- await ctx.sessionPersistence.create(meta("wrong-cwd-claim", OTHER));
1445
- // A live session reusing the id but at cwd WORK must NOT claim it — the
1446
- // cwd scope is the fence (without it, WORK events would append under the
1447
- // OTHER header). Rejected as a collision.
1448
- const live = ctx.sessions.create(SessionId("wrong-cwd-claim"), {
1449
- seed: oneTurnLog(),
1450
- meta: { cwd: WORK },
1451
- });
1452
- await expect(ctx.sessions.flush(live)).rejects.toThrow(/different cwd|id collision/);
1453
- } finally {
1454
- await fiber.dispose();
1455
- await fix.cleanup();
1456
- }
1457
- });
1458
-
1459
- it("a live session at a DIFFERENT cwd cannot claim loaded-prefix ownerless state (cwd scope)", async () => {
1460
- const fix = await makeFixture();
1461
- const { ctx, fiber } = await freshCtx(fix);
1462
- try {
1463
- // Materialize + load at cwd OTHER (ownerless, cursor = 6).
1464
- await ctx.sessionPersistence.create(meta("wrong-cwd-load", OTHER));
1465
- await ctx.sessionPersistence.append(SessionId("wrong-cwd-load"), oneTurnLog());
1466
- const { events } = await ctx.sessionPersistence.load(SessionId("wrong-cwd-load"));
1467
- // A live session whose SEED matches the loaded prefix but whose cwd is
1468
- // WORK must still be rejected — the cwd guard runs before the seed check.
1469
- const live = ctx.sessions.create(SessionId("wrong-cwd-load"), {
1470
- seed: events,
1471
- meta: { cwd: WORK },
1472
- });
1473
- await expect(ctx.sessions.flush(live)).rejects.toThrow(/different cwd|id collision/);
1474
- } finally {
1475
- await fiber.dispose();
1476
- await fix.cleanup();
1477
- }
1478
- });
1479
-
1480
- it("a no-cwd ownerless state cannot be claimed by a live session WITH a cwd (cwd scope, undefined side)", async () => {
1481
- const fix = await makeFixture();
1482
- const { ctx, fiber } = await freshCtx(fix);
1483
- try {
1484
- // Ownerless state created WITHOUT a cwd (the `_no-cwd` project directory).
1485
- await ctx.sessionPersistence.create(meta("no-cwd-state"));
1486
- // A live session reusing the id but WITH cwd WORK is a cwd mismatch
1487
- // (undefined vs WORK) and must be rejected.
1488
- const live = ctx.sessions.create(SessionId("no-cwd-state"), {
1489
- seed: oneTurnLog(),
1490
- meta: { cwd: WORK },
1491
- });
1492
- await expect(ctx.sessions.flush(live)).rejects.toThrow(/different cwd|id collision/);
344
+ await expect(ctx.sessions.flush(reuse)).resolves.toBe(true);
345
+ reuse.append("turn/start", { turn: 1 });
346
+ reuse.append("turn/end", { turn: 1, reason: { kind: "completed" } });
347
+ await ctx.sessions.flush(reuse);
348
+ const loaded = await readAll(ctx, SessionId("abandoned"));
349
+ expect(loaded.events.map((e) => e.seq)).toEqual([0, 1]);
1493
350
  } finally {
1494
351
  await fiber.dispose();
1495
352
  await fix.cleanup();
1496
353
  }
1497
354
  });
1498
355
 
1499
- // --- append adopts a storage-only session (fresh instance, no prior create/load) ---
1500
-
1501
356
  it("append adopts a storage-only session (fresh instance) and continues the seq", async () => {
1502
357
  const fix = await makeFixture();
1503
- const first = await freshCtx(fix);
358
+ const { ctx, fiber } = await freshCtx(fix);
1504
359
  try {
1505
- const m = meta("adopt-append", WORK);
1506
- await first.ctx.sessionPersistence.create(m);
1507
- await first.ctx.sessionPersistence.append(m.id, oneTurnLog());
1508
- } finally {
1509
- await first.fiber.dispose();
1510
- }
360
+ const m = meta("storage-only", WORK);
361
+ const handle = await ctx.sessionPersistence.create(m);
362
+ await handle.append(oneTurnLog());
363
+ await handle.close();
1511
364
 
1512
- // A fresh instance appends a second turn WITHOUT a prior create/load: append
1513
- // must adopt the stored session (cursor = stored length) and continue.
1514
- const second = await freshCtx(fix);
1515
- try {
1516
- await second.ctx.sessionPersistence.append(SessionId("adopt-append"), [
365
+ // A fresh backend instance appends through a write handle.
366
+ const writer = await ctx.sessionPersistence.open(SessionId("storage-only"), "write");
367
+ await writer.append([
1517
368
  { type: "turn/start", seq: SessionSeq(6), time: 7, data: { turn: 2 } },
1518
369
  {
1519
370
  type: "turn/end",
@@ -1522,38 +373,10 @@ export function runCoordinatorContract(
1522
373
  data: { turn: 2, reason: { kind: "completed" } },
1523
374
  },
1524
375
  ]);
1525
- const loaded = await second.ctx.sessionPersistence.load(SessionId("adopt-append"));
1526
- expect(loaded.events.map((e) => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]);
1527
- } finally {
1528
- await second.fiber.dispose();
1529
- await fix.cleanup();
1530
- }
1531
- });
1532
-
1533
- // --- small public-API edges that the coordinator owns uniformly ---
1534
-
1535
- it("append of an empty batch is a no-op (stays lazy)", async () => {
1536
- const fix = await makeFixture();
1537
- const { ctx, fiber } = await freshCtx(fix);
1538
- try {
1539
- const m = meta("empty-batch", WORK);
1540
- await ctx.sessionPersistence.create(m);
1541
- await ctx.sessionPersistence.append(m.id, []);
1542
- expect((await ctx.sessionPersistence.list()).map((h) => h.id)).not.toContain(m.id);
1543
- } finally {
1544
- await fiber.dispose();
1545
- await fix.cleanup();
1546
- }
1547
- });
376
+ await writer.close();
1548
377
 
1549
- it("load and inspect reject a missing session", async () => {
1550
- const fix = await makeFixture();
1551
- const { ctx, fiber } = await freshCtx(fix);
1552
- try {
1553
- await expect(ctx.sessionPersistence.load(SessionId("nope"))).rejects.toThrow(/not found/);
1554
- await expect(ctx.sessionPersistence.inspect(SessionId("nope"))).rejects.toThrow(
1555
- /not found/,
1556
- );
378
+ const loaded = await readAll(ctx, SessionId("storage-only"));
379
+ expect(loaded.events.map((e) => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]);
1557
380
  } finally {
1558
381
  await fiber.dispose();
1559
382
  await fix.cleanup();
@@ -1561,40 +384,15 @@ export function runCoordinatorContract(
1561
384
  });
1562
385
 
1563
386
  it("create rejects a duplicate id (in memory and on a persisted log)", async () => {
1564
- const fix = await makeFixture();
1565
- const first = await freshCtx(fix);
1566
- try {
1567
- const m = meta("dup", WORK);
1568
- await first.ctx.sessionPersistence.create(m);
1569
- // Same in-memory state.
1570
- await expect(first.ctx.sessionPersistence.create(m)).rejects.toThrow(
1571
- /already exists in this backend/,
1572
- );
1573
- await first.ctx.sessionPersistence.append(m.id, oneTurnLog());
1574
- } finally {
1575
- await first.fiber.dispose();
1576
- }
1577
-
1578
- // A fresh instance over the same storage sees the persisted log.
1579
- const second = await freshCtx(fix);
1580
- try {
1581
- await expect(second.ctx.sessionPersistence.create(meta("dup", WORK))).rejects.toThrow(
1582
- /already has a persisted log on disk/,
1583
- );
1584
- } finally {
1585
- await second.fiber.dispose();
1586
- await fix.cleanup();
1587
- }
1588
- });
1589
-
1590
- it("rejects an unknown format version on load (assertVersion)", async () => {
1591
387
  const fix = await makeFixture();
1592
388
  const { ctx, fiber } = await freshCtx(fix);
1593
389
  try {
1594
- const m = { version: 99, id: SessionId("v99"), createdAt: 1, cwd: WORK, isSeeded: false };
1595
- await ctx.sessionPersistence.create(m);
1596
- await ctx.sessionPersistence.append(m.id, oneTurnLog());
1597
- await expect(ctx.sessionPersistence.load(m.id)).rejects.toThrow(/log format/);
390
+ const m = meta("dup-id", WORK);
391
+ const first = await ctx.sessionPersistence.create(m);
392
+ await expect(ctx.sessionPersistence.create(m)).rejects.toThrow(/already exists/);
393
+ await first.append(oneTurnLog());
394
+ await first.close();
395
+ await expect(ctx.sessionPersistence.create(m)).rejects.toThrow(/already exists/);
1598
396
  } finally {
1599
397
  await fiber.dispose();
1600
398
  await fix.cleanup();
@@ -1605,117 +403,53 @@ export function runCoordinatorContract(
1605
403
  const fix = await makeFixture();
1606
404
  const { ctx, fiber } = await freshCtx(fix);
1607
405
  try {
1608
- const m = {
1609
- version: SESSION_FORMAT_VERSION,
1610
- id: SessionId("forked-child"),
1611
- createdAt: 1,
1612
- cwd: WORK,
1613
- parentSession: SessionId("the-parent"),
1614
- isSeeded: false,
1615
- };
1616
- await ctx.sessionPersistence.create(m);
1617
- await ctx.sessionPersistence.append(m.id, oneTurnLog());
1618
- const loaded = await ctx.sessionPersistence.load(m.id);
1619
- expect(loaded.meta.parentSession).toBe("the-parent");
1620
- } finally {
1621
- await fiber.dispose();
1622
- await fix.cleanup();
1623
- }
1624
- });
406
+ const m = meta("lineage", WORK);
407
+ const handle = await ctx.sessionPersistence.create({
408
+ ...m,
409
+ parentSession: SessionId("parent"),
410
+ });
411
+ await handle.append(oneTurnLog());
412
+ await handle.close();
1625
413
 
1626
- it("flush before init resolves uses cursor 0", async () => {
1627
- const fix = await makeFixture();
1628
- const { ctx, fiber } = await freshCtx(fix);
1629
- try {
1630
- // Append directly to a live session and flush IMMEDIATELY, before the
1631
- // async onCreated init has necessarily set state (exercises the
1632
- // state-undefined cursor path).
1633
- const session = ctx.sessions.create(SessionId("flush-nostate"), { meta: { cwd: WORK } });
1634
- session.append("turn/start", { turn: 1 });
1635
- session.append(
1636
- "user/message",
1637
- createUserMessage({
1638
- content: [{ type: "text", text: "q" }],
1639
- source: { kind: "user" },
1640
- }),
1641
- { surfaceOp: "append" },
1642
- );
1643
- session.append("turn/end", { turn: 1, reason: { kind: "completed" } });
1644
- await ctx.sessions.flush(session);
1645
- const loaded = await ctx.sessionPersistence.load(SessionId("flush-nostate"));
1646
- expect(loaded.events).toHaveLength(3);
414
+ const loaded = await readAll(ctx, SessionId("lineage"));
415
+ expect(loaded.meta.parentSession).toBe(SessionId("parent"));
1647
416
  } finally {
1648
417
  await fiber.dispose();
1649
418
  await fix.cleanup();
1650
419
  }
1651
420
  });
1652
421
 
1653
- // --- crash-tail repair THROUGH the coordinator (real storage torn tail) ---
1654
-
1655
- it("torn-tail load: a never-committed tail is truncated and the open turn closed during load (commitRepair w/ tornMarker)", async () => {
422
+ it("torn-tail: a never-committed tail is truncated by the write path", async () => {
1656
423
  const fix = await makeFixture();
1657
- if (!fix.corruptTail) {
1658
- // A memory-style store has no torn tails (every write is atomic in RAM),
1659
- // so there is no tornMarker path to exercise. Assert that explicitly
1660
- // instead of silently skipping, then bail.
1661
- expect(fix.corruptTail).toBeUndefined();
1662
- await fix.cleanup();
1663
- return;
1664
- }
1665
- const first = await freshCtx(fix);
424
+ if (fix.corruptTail === undefined) return;
425
+ const { ctx, fiber } = await freshCtx(fix);
1666
426
  try {
1667
427
  const m = meta("torn", WORK);
1668
- await first.ctx.sessionPersistence.create(m);
1669
- await first.ctx.sessionPersistence.append(m.id, oneTurnLog()); // committed 0..5 (balanced)
1670
- // A second turn whose real events are durable but never closed (open turn).
1671
- await first.ctx.sessionPersistence.append(m.id, [
428
+ const creator = await ctx.sessionPersistence.create(m);
429
+ await creator.append(oneTurnLog());
430
+ await creator.close();
431
+ await fix.corruptTail(SessionId("torn"), WORK);
432
+
433
+ // A reader serves only the committed prefix.
434
+ const reader = await ctx.sessionPersistence.open(SessionId("torn"), "read");
435
+ expect((await reader.read()).events).toEqual(oneTurnLog());
436
+ await reader.close();
437
+
438
+ // A write open + first append durably truncates the torn tail.
439
+ const writer = await ctx.sessionPersistence.open(SessionId("torn"), "write");
440
+ await writer.append([
1672
441
  { type: "turn/start", seq: SessionSeq(6), time: 7, data: { turn: 2 } },
1673
- { type: "step/start", seq: SessionSeq(7), time: 8, data: { turn: 2, step: 1 } },
1674
- ]);
1675
- } finally {
1676
- await first.fiber.dispose();
1677
- }
1678
- // Inject a torn fragment past the committed region (never-committed tail).
1679
- await fix.corruptTail(SessionId("torn"), WORK);
1680
-
1681
- // A FRESH instance loads: the torn tail is truncated (tornMarker !==
1682
- // undefined) AND the open turn 2 is closed with synthetic step/end +
1683
- // turn/end {interrupted} — commitRepair runs with BOTH a torn marker and
1684
- // closers. The preserved real events (0..7) are never truncated.
1685
- const second = await freshCtx(fix);
1686
- try {
1687
- const loaded = await second.ctx.sessionPersistence.load(SessionId("torn"));
1688
- expect(loaded.events.map((e) => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
1689
- expect(loaded.events.map((e) => e.type)).toEqual([
1690
- "turn/start",
1691
- "user/message",
1692
- "step/start",
1693
- "assistant/message",
1694
- "step/end",
1695
- "turn/end", // turn 1
1696
- "turn/start",
1697
- "step/start",
1698
- "step/end",
1699
- "turn/end", // turn 2: real + synthetic closers
1700
- ]);
1701
- const last = loaded.events.at(-1)!;
1702
- expect(last.type === "turn/end" && last.data.reason).toEqual({ kind: "interrupted" });
1703
-
1704
- // The repair is durable: the next append continues at the balanced length
1705
- // (seq 10) and a reload round-trips identically.
1706
- await second.ctx.sessionPersistence.append(SessionId("torn"), [
1707
- { type: "turn/start", seq: SessionSeq(10), time: 9, data: { turn: 3 } },
1708
442
  {
1709
443
  type: "turn/end",
1710
- seq: SessionSeq(11),
1711
- time: 10,
1712
- data: { turn: 3, reason: { kind: "completed" } },
444
+ seq: SessionSeq(7),
445
+ time: 8,
446
+ data: { turn: 2, reason: { kind: "completed" } },
1713
447
  },
1714
448
  ]);
1715
- const reloaded = await second.ctx.sessionPersistence.load(SessionId("torn"));
1716
- expect(reloaded.events.map((e) => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]);
449
+ expect((await writer.read()).events.map((e) => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]);
450
+ await writer.close();
1717
451
  } finally {
1718
- await second.fiber.dispose();
452
+ await fiber.dispose();
1719
453
  await fix.cleanup();
1720
454
  }
1721
455
  });