@morlay/session-rdb 0.0.14 → 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.
- package/README.md +11 -13
- package/dist/artifact.d.mts +6 -16
- package/dist/artifact.mjs +3 -3
- package/dist/{import-B-tf5vQn.mjs → import-CKrzjXVB.mjs} +73 -83
- package/dist/import.d.mts +2 -3
- package/dist/import.mjs +2 -2
- package/dist/index-CgGDHQSK.d.mts +225 -0
- package/dist/index.d.mts +3 -3
- package/dist/index.mjs +694 -169
- package/dist/log-DTJsxMud.mjs +314 -0
- package/dist/{schema-CFXZURX7.d.mts → schema-COK7-wRV.d.mts} +3 -15
- package/dist/sqlite-DCy4VTD8.mjs +698 -0
- package/dist/storage.d.mts +2 -7
- package/dist/storage.mjs +2 -3
- package/dist/testing.d.mts +30 -1
- package/dist/testing.mjs +676 -1991
- package/drizzle/postgres/20260908072805_v2_initial/migration.sql +58 -0
- package/drizzle/postgres/20260908072805_v2_initial/snapshot.json +686 -0
- package/drizzle/postgres/20260908072806_v3_drop_original_seq/migration.sql +1 -0
- package/drizzle/postgres/20260908072806_v3_drop_original_seq/snapshot.json +673 -0
- package/drizzle/sqlite/20260908072751_v2_initial/migration.sql +53 -0
- package/drizzle/sqlite/20260908072751_v2_initial/snapshot.json +492 -0
- package/drizzle/sqlite/20260908072752_v3_drop_original_seq/migration.sql +6 -0
- package/drizzle/sqlite/20260908072752_v3_drop_original_seq/snapshot.json +513 -0
- package/package.json +17 -12
- package/src/adapters/index.ts +10 -2
- package/src/adapters/to-postgres.ts +19 -15
- package/src/adapters/to-sqlite.ts +20 -14
- package/src/{entities → adapters}/types.ts +7 -8
- package/src/backend.ts +0 -7
- package/src/branch.ts +29 -110
- package/src/drizzle/postgres-v2.ts +11 -0
- package/src/drizzle/postgres-v3.ts +11 -0
- package/src/drizzle/sqlite-v2.ts +10 -0
- package/src/drizzle/sqlite-v3.ts +11 -0
- package/src/entities/index.ts +2 -30
- package/src/entities/v2/index.ts +20 -0
- package/src/entities/v2/session-events.ts +11 -0
- package/src/entities/v3/events.ts +27 -0
- package/src/entities/v3/index.ts +27 -0
- package/src/entities/v3/persistence-state.ts +10 -0
- package/src/entities/v3/schema-meta.ts +9 -0
- package/src/entities/v3/session-events.ts +26 -0
- package/src/entities/v3/sessions.ts +20 -0
- package/src/import.ts +65 -57
- package/src/index.ts +868 -223
- package/src/invariant.ts +0 -2
- package/src/legacy.ts +67 -0
- package/src/log.ts +41 -81
- package/src/postgres.ts +75 -93
- package/src/schema.ts +3 -14
- package/src/sqlite.ts +59 -46
- package/src/testing/contract.ts +460 -375
- package/src/testing/coordinator-contract.ts +108 -1374
- package/src/testing.ts +1 -6
- package/dist/index-uUvn6gYp.d.mts +0 -111
- package/dist/schema-BfPVmr1X.mjs +0 -875
- package/dist/sqlite-BUWnS0so.mjs +0 -356
- package/src/adapters/ddl.ts +0 -77
- package/src/entities/events.ts +0 -27
- package/src/entities/persistence-state.ts +0 -10
- package/src/entities/schema-meta.ts +0 -9
- package/src/entities/session-events.ts +0 -29
- package/src/entities/sessions.ts +0 -20
- 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
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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(`
|
|
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
|
-
|
|
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
|
|
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
|
|
400
|
-
|
|
401
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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("
|
|
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
|
-
//
|
|
1255
|
-
|
|
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
|
-
|
|
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(
|
|
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("
|
|
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
|
-
//
|
|
1340
|
-
//
|
|
1341
|
-
|
|
1342
|
-
await ctx.
|
|
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
|
-
|
|
327
|
+
firstSession = inner.sessions.create(SessionId("abandoned"), { meta: { cwd: WORK } });
|
|
1352
328
|
},
|
|
1353
329
|
{ inject: ["sessions"] },
|
|
1354
330
|
),
|
|
1355
331
|
);
|
|
1356
|
-
await
|
|
1357
|
-
|
|
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
|
-
|
|
1398
|
-
|
|
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
|
-
|
|
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(
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
expect(loaded.
|
|
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
|
|
358
|
+
const { ctx, fiber } = await freshCtx(fix);
|
|
1504
359
|
try {
|
|
1505
|
-
const m = meta("
|
|
1506
|
-
await
|
|
1507
|
-
await
|
|
1508
|
-
|
|
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
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1550
|
-
|
|
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 =
|
|
1595
|
-
await ctx.sessionPersistence.create(m);
|
|
1596
|
-
await ctx.sessionPersistence.
|
|
1597
|
-
await
|
|
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
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
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
|
-
|
|
1627
|
-
|
|
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
|
-
|
|
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 (
|
|
1658
|
-
|
|
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
|
|
1669
|
-
await
|
|
1670
|
-
|
|
1671
|
-
await
|
|
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(
|
|
1711
|
-
time:
|
|
1712
|
-
data: { turn:
|
|
444
|
+
seq: SessionSeq(7),
|
|
445
|
+
time: 8,
|
|
446
|
+
data: { turn: 2, reason: { kind: "completed" } },
|
|
1713
447
|
},
|
|
1714
448
|
]);
|
|
1715
|
-
|
|
1716
|
-
|
|
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
|
|
452
|
+
await fiber.dispose();
|
|
1719
453
|
await fix.cleanup();
|
|
1720
454
|
}
|
|
1721
455
|
});
|