@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.
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-B-tf5vQn.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 -81
  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-BfPVmr1X.mjs +0 -875
  58. package/dist/sqlite-BUWnS0so.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
package/dist/testing.mjs CHANGED
@@ -1,8 +1,8 @@
1
- import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionId, SessionLogOffset, SessionSeq, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from "@deepseek-ai/dsh-session";
1
+ import { SessionAlreadyExistsError, SessionAlreadyOwnedError, SessionFormatUnsupportedError, SessionHandleClosedError, SessionPersistenceNotFoundError, SessionReadOnlyError } from "@deepseek-ai/dsh-session-persistence";
2
+ import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset, SessionSeq } from "@deepseek-ai/dsh-session";
2
3
  import { SettingsProvider } from "@deepseek-ai/dsh-settings";
3
- import { MessageId, ToolCallId, createMessage, createUserMessage, freezeMessage } from "@deepseek-ai/dsh-llm";
4
+ import { MessageId, createUserMessage, freezeMessage } from "@deepseek-ai/dsh-llm";
4
5
  import { Context } from "@deepseek-ai/cordis";
5
- import { scopeTarget } from "@deepseek-ai/dsh-scope";
6
6
  //#region src/testing/helpers.ts
7
7
  var EmptySettings = class extends SettingsProvider {
8
8
  constructor(ctx) {
@@ -5116,7 +5116,7 @@ function manageArtifactAttachment(attachment) {
5116
5116
  if (attachment.body != null) attachment.bodyEncoding ??= "base64";
5117
5117
  }
5118
5118
  //#endregion
5119
- //#region ../../node_modules/.pnpm/vitest@4.1.11_@opentelemetry+api@1.9.1_@types+node@26.4.1_happy-dom@20.13.2_vite@8.2.2__685d385505d44d75e67f1bad705abef7/node_modules/vitest/dist/chunks/utils.BX5Fg8C4.js
5119
+ //#region ../../node_modules/.pnpm/vitest@4.1.11_@opentelemetry+api@1.9.1_@types+node@26.5.0_happy-dom@20.13.2_vite@8.2.2__9bde3b9553ba3388013323e9f29b314e/node_modules/vitest/dist/chunks/utils.BX5Fg8C4.js
5120
5120
  const NAME_WORKER_STATE = "__vitest_worker__";
5121
5121
  function getWorkerState() {
5122
5122
  const workerState = globalThis[NAME_WORKER_STATE];
@@ -10019,10 +10019,10 @@ function offsetToLineNumber(source, offset) {
10019
10019
  return line + 1;
10020
10020
  }
10021
10021
  //#endregion
10022
- //#region ../../node_modules/.pnpm/vitest@4.1.11_@opentelemetry+api@1.9.1_@types+node@26.4.1_happy-dom@20.13.2_vite@8.2.2__685d385505d44d75e67f1bad705abef7/node_modules/vitest/dist/chunks/_commonjsHelpers.D26ty3Ew.js
10022
+ //#region ../../node_modules/.pnpm/vitest@4.1.11_@opentelemetry+api@1.9.1_@types+node@26.5.0_happy-dom@20.13.2_vite@8.2.2__9bde3b9553ba3388013323e9f29b314e/node_modules/vitest/dist/chunks/_commonjsHelpers.D26ty3Ew.js
10023
10023
  var commonjsGlobal = typeof globalThis !== "undefined" ? globalThis : typeof window !== "undefined" ? window : typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : {};
10024
10024
  //#endregion
10025
- //#region ../../node_modules/.pnpm/vitest@4.1.11_@opentelemetry+api@1.9.1_@types+node@26.4.1_happy-dom@20.13.2_vite@8.2.2__685d385505d44d75e67f1bad705abef7/node_modules/vitest/dist/chunks/rpc.MzXet3jl.js
10025
+ //#region ../../node_modules/.pnpm/vitest@4.1.11_@opentelemetry+api@1.9.1_@types+node@26.5.0_happy-dom@20.13.2_vite@8.2.2__9bde3b9553ba3388013323e9f29b314e/node_modules/vitest/dist/chunks/rpc.MzXet3jl.js
10026
10026
  const RealDate = Date;
10027
10027
  let now = null;
10028
10028
  var MockDate = class MockDate extends RealDate {
@@ -10987,7 +10987,7 @@ function raceWith(promise, other) {
10987
10987
  }))]);
10988
10988
  }
10989
10989
  //#endregion
10990
- //#region ../../node_modules/.pnpm/vitest@4.1.11_@opentelemetry+api@1.9.1_@types+node@26.4.1_happy-dom@20.13.2_vite@8.2.2__685d385505d44d75e67f1bad705abef7/node_modules/vitest/dist/chunks/test.DNmyFkvJ.js
10990
+ //#region ../../node_modules/.pnpm/vitest@4.1.11_@opentelemetry+api@1.9.1_@types+node@26.5.0_happy-dom@20.13.2_vite@8.2.2__9bde3b9553ba3388013323e9f29b314e/node_modules/vitest/dist/chunks/test.DNmyFkvJ.js
10991
10991
  var fakeTimersSrc = {};
10992
10992
  var global$1;
10993
10993
  var hasRequiredGlobal;
@@ -13675,6 +13675,7 @@ Object.defineProperty(globalThis, GLOBAL_EXPECT, {
13675
13675
  });
13676
13676
  //#endregion
13677
13677
  //#region src/testing/contract.ts
13678
+ /** Build a minimal {@link SessionHeader} for a session id. */
13678
13679
  function meta(id, cwd) {
13679
13680
  return {
13680
13681
  version: SESSION_FORMAT_VERSION,
@@ -13684,6 +13685,7 @@ function meta(id, cwd) {
13684
13685
  ...cwd === void 0 ? {} : { cwd }
13685
13686
  };
13686
13687
  }
13688
+ /** A well-formed one-turn event log (contiguous seqs from 0). */
13687
13689
  function oneTurnLog() {
13688
13690
  return [
13689
13691
  {
@@ -13735,7 +13737,45 @@ function oneTurnLog() {
13735
13737
  provider: "mock",
13736
13738
  model: "mock"
13737
13739
  }
13738
- })
13740
+ }),
13741
+ stream: [
13742
+ {
13743
+ type: "chunk",
13744
+ time: 3,
13745
+ chunk: {
13746
+ type: "block-start",
13747
+ index: 0,
13748
+ blockType: "text"
13749
+ }
13750
+ },
13751
+ {
13752
+ type: "text-chunks",
13753
+ time0: 3,
13754
+ index: 0,
13755
+ dt: [],
13756
+ texts: ["hello"]
13757
+ },
13758
+ {
13759
+ type: "chunk",
13760
+ time: 4,
13761
+ chunk: {
13762
+ type: "block-end",
13763
+ index: 0,
13764
+ block: {
13765
+ type: "text",
13766
+ text: "hello"
13767
+ }
13768
+ }
13769
+ },
13770
+ {
13771
+ type: "chunk",
13772
+ time: 4,
13773
+ chunk: {
13774
+ type: "finish",
13775
+ reason: { kind: "stop" }
13776
+ }
13777
+ }
13778
+ ]
13739
13779
  },
13740
13780
  surfaceOp: "append"
13741
13781
  },
@@ -13759,6 +13799,23 @@ function oneTurnLog() {
13759
13799
  }
13760
13800
  ];
13761
13801
  }
13802
+ /** A contiguous second-turn batch continuing {@link oneTurnLog}. */
13803
+ function secondTurn(startSeq = 6) {
13804
+ return [{
13805
+ type: "turn/start",
13806
+ seq: SessionSeq(startSeq),
13807
+ time: 9,
13808
+ data: { turn: 2 }
13809
+ }, {
13810
+ type: "turn/end",
13811
+ seq: SessionSeq(startSeq + 1),
13812
+ time: 10,
13813
+ data: {
13814
+ turn: 2,
13815
+ reason: { kind: "completed" }
13816
+ }
13817
+ }];
13818
+ }
13762
13819
  function appendLog(session, events) {
13763
13820
  for (const e of events) {
13764
13821
  const se = e;
@@ -13771,123 +13828,115 @@ function appendLog(session, events) {
13771
13828
  } else session.append(e.type, e.data);
13772
13829
  }
13773
13830
  }
13831
+ /**
13832
+ * Run the backend-agnostic handle contract suite. `make()` MUST return a
13833
+ * fresh backend over fresh, empty storage each call.
13834
+ * @param name - suite label, e.g. `jsonl-none` / `sqlite`.
13835
+ * @param make - factory producing one fresh {@link ContractBackend} per test.
13836
+ */
13774
13837
  function runPersistenceContract(name, make) {
13775
13838
  describe(`SessionPersistence contract: ${name}`, () => {
13776
- it("round-trips a session: create + append load returns identical meta and byte-identical events", async () => {
13839
+ it("round-trips through one write handle: append, self-read, offset/length defaults", async () => {
13777
13840
  const { persistence, dispose } = await make();
13778
13841
  try {
13779
- const m = meta("s1", "/work");
13842
+ const m = meta("round-trip", "/work");
13780
13843
  const log = oneTurnLog();
13781
- await persistence.create(m);
13782
- await persistence.append(m.id, log);
13783
- const loaded = await persistence.load(m.id);
13784
- globalExpect(loaded.meta).toMatchObject({
13785
- version: SESSION_FORMAT_VERSION,
13786
- id: m.id,
13787
- cwd: "/work"
13788
- });
13789
- globalExpect(loaded.events).toEqual(log);
13844
+ const handle = await persistence.create(m);
13845
+ globalExpect(handle.access).toBe("write");
13846
+ globalExpect(handle.id).toBe(m.id);
13847
+ globalExpect(handle.header).toMatchObject(m);
13848
+ await handle.append(log);
13849
+ await handle.append([]);
13850
+ const full = await handle.read();
13851
+ globalExpect(full.events).toEqual(log);
13852
+ globalExpect((await handle.read(3)).events).toEqual(log.slice(3));
13853
+ globalExpect((await handle.read(0, 2)).events).toEqual(log.slice(0, 2));
13854
+ globalExpect((await handle.read(1, 3)).events).toEqual(log.slice(1, 4));
13855
+ globalExpect((await handle.read(log.length)).events).toEqual([]);
13856
+ globalExpect((await handle.read(log.length + 100)).events).toEqual([]);
13857
+ await handle.flush();
13858
+ await handle.close();
13790
13859
  } finally {
13791
13860
  await dispose();
13792
13861
  }
13793
13862
  });
13794
- it("rejects a fractional creation timestamp without reserving its session id", async () => {
13863
+ it("read rejects negative or fractional offsets and lengths", async () => {
13795
13864
  const { persistence, dispose } = await make();
13796
13865
  try {
13797
- const m = {
13798
- ...meta("fractional-created-at"),
13799
- createdAt: 1.5
13800
- };
13801
- await globalExpect(persistence.create(m)).rejects.toThrow("session metadata createdAt must be a non-negative safe integer");
13802
- const valid = meta("fractional-created-at");
13803
- await persistence.create(valid);
13804
- await persistence.append(valid.id, oneTurnLog());
13805
- globalExpect((await persistence.load(valid.id)).meta.createdAt).toBe(valid.createdAt);
13866
+ const handle = await persistence.create(meta("read-args"));
13867
+ await globalExpect(handle.read(-1)).rejects.toThrow(/non-negative safe integer/);
13868
+ await globalExpect(handle.read(1.5)).rejects.toThrow(/non-negative safe integer/);
13869
+ await globalExpect(handle.read(0, -1)).rejects.toThrow(/non-negative safe integer/);
13870
+ await handle.close();
13806
13871
  } finally {
13807
13872
  await dispose();
13808
13873
  }
13809
13874
  });
13810
- it("crash recovery: load preserves an interrupted (unclosed) turn and closes it with turn/end {interrupted}", async () => {
13875
+ it("duplicate create rejects against a live pending session and allows the id after an erasing close", async () => {
13811
13876
  const { persistence, dispose } = await make();
13812
13877
  try {
13813
- const m = meta("interrupted");
13814
- await persistence.create(m);
13815
- await persistence.append(m.id, oneTurnLog());
13816
- await persistence.append(m.id, [{
13817
- type: "turn/start",
13818
- seq: SessionSeq(6),
13819
- time: 7,
13820
- data: { turn: 2 }
13821
- }, {
13822
- type: "step/start",
13823
- seq: SessionSeq(7),
13824
- time: 8,
13825
- data: {
13826
- turn: 2,
13827
- step: 1
13828
- }
13829
- }]);
13830
- const beforeRepair = (await persistence.listSnapshots()).find((snapshot) => snapshot.header.id === m.id)?.revision;
13831
- const inspected = await persistence.inspect(m.id);
13832
- const afterInspect = (await persistence.listSnapshots()).find((snapshot) => snapshot.header.id === m.id)?.revision;
13833
- globalExpect(afterInspect).toBe(beforeRepair);
13834
- globalExpect(inspected.events.map((e) => e.type)).toEqual([
13835
- "turn/start",
13836
- "user/message",
13837
- "step/start",
13838
- "assistant/message",
13839
- "step/end",
13840
- "turn/end",
13841
- "turn/start",
13842
- "step/start",
13843
- "step/end",
13844
- "turn/end"
13845
- ]);
13846
- const loaded = await persistence.load(m.id);
13847
- const afterRepair = (await persistence.listSnapshots()).find((snapshot) => snapshot.header.id === m.id)?.revision;
13848
- globalExpect(afterRepair).not.toBe(beforeRepair);
13849
- globalExpect(loaded.events.map((e) => e.type)).toEqual([
13850
- "turn/start",
13851
- "user/message",
13852
- "step/start",
13853
- "assistant/message",
13854
- "step/end",
13855
- "turn/end",
13856
- "turn/start",
13857
- "step/start",
13858
- "step/end",
13859
- "turn/end"
13860
- ]);
13861
- globalExpect(loaded.events.map((e) => e.seq)).toEqual([
13862
- 0,
13863
- 1,
13864
- 2,
13865
- 3,
13866
- 4,
13867
- 5,
13868
- 6,
13869
- 7,
13870
- 8,
13871
- 9
13872
- ]);
13873
- const last = loaded.events.at(-1);
13874
- globalExpect(last.type === "turn/end" && last.data.reason).toEqual({ kind: "interrupted" });
13875
- await persistence.append(m.id, [{
13876
- type: "turn/start",
13877
- seq: SessionSeq(10),
13878
- time: 9,
13879
- data: { turn: 3 }
13880
- }, {
13881
- type: "turn/end",
13882
- seq: SessionSeq(11),
13883
- time: 10,
13884
- data: {
13885
- turn: 3,
13886
- reason: { kind: "completed" }
13887
- }
13888
- }]);
13889
- const reloaded = await persistence.load(m.id);
13890
- globalExpect(reloaded.events.map((e) => e.seq)).toEqual([
13878
+ const first = await persistence.create(meta("dup-pending"));
13879
+ await globalExpect(persistence.create(meta("dup-pending"))).rejects.toBeInstanceOf(SessionAlreadyExistsError);
13880
+ await first.close();
13881
+ await (await persistence.create(meta("dup-pending"))).close();
13882
+ } finally {
13883
+ await dispose();
13884
+ }
13885
+ });
13886
+ it("concurrent duplicate creates: one wins, the loser rejects SessionAlreadyExistsError", async () => {
13887
+ const { persistence, dispose } = await make();
13888
+ try {
13889
+ const m = meta("dup-race");
13890
+ const results = await Promise.allSettled([persistence.create(m), persistence.create(m)]);
13891
+ const winners = results.filter((r) => r.status === "fulfilled");
13892
+ const losers = results.filter((r) => r.status === "rejected");
13893
+ globalExpect(winners).toHaveLength(1);
13894
+ globalExpect(losers).toHaveLength(1);
13895
+ globalExpect(losers[0].reason).toBeInstanceOf(SessionAlreadyExistsError);
13896
+ await winners[0].value.close();
13897
+ } finally {
13898
+ await dispose();
13899
+ }
13900
+ });
13901
+ it("duplicate create rejects against a materialized artifact seen by a fresh instance", async () => {
13902
+ const backend = await make();
13903
+ try {
13904
+ if (backend.reopen === void 0) return;
13905
+ const m = meta("dup-stored", "/work");
13906
+ const handle = await backend.persistence.create(m);
13907
+ await handle.append(oneTurnLog());
13908
+ await handle.close();
13909
+ const reopened = await backend.reopen();
13910
+ try {
13911
+ await globalExpect(reopened.persistence.create(meta("dup-stored", "/work"))).rejects.toBeInstanceOf(SessionAlreadyExistsError);
13912
+ } finally {
13913
+ await reopened.dispose();
13914
+ }
13915
+ } finally {
13916
+ await backend.dispose();
13917
+ }
13918
+ });
13919
+ it("open of an absent session rejects with SessionPersistenceNotFoundError for both accesses", async () => {
13920
+ const { persistence, dispose } = await make();
13921
+ try {
13922
+ await globalExpect(persistence.open(SessionId("absent"), "read")).rejects.toBeInstanceOf(SessionPersistenceNotFoundError);
13923
+ await globalExpect(persistence.open(SessionId("absent"), "write")).rejects.toBeInstanceOf(SessionPersistenceNotFoundError);
13924
+ } finally {
13925
+ await dispose();
13926
+ }
13927
+ });
13928
+ it("write ownership is single-holder per instance and released by close", async () => {
13929
+ const { persistence, dispose } = await make();
13930
+ try {
13931
+ const m = meta("owned");
13932
+ const creator = await persistence.create(m);
13933
+ await globalExpect(persistence.open(m.id, "write")).rejects.toBeInstanceOf(SessionAlreadyOwnedError);
13934
+ await creator.append(oneTurnLog());
13935
+ await globalExpect(persistence.open(m.id, "write")).rejects.toBeInstanceOf(SessionAlreadyOwnedError);
13936
+ await creator.close();
13937
+ const writer = await persistence.open(m.id, "write");
13938
+ await writer.append(secondTurn());
13939
+ globalExpect((await writer.read()).events.map((e) => e.seq)).toEqual([
13891
13940
  0,
13892
13941
  1,
13893
13942
  2,
@@ -13895,331 +13944,371 @@ function runPersistenceContract(name, make) {
13895
13944
  4,
13896
13945
  5,
13897
13946
  6,
13898
- 7,
13899
- 8,
13900
- 9,
13901
- 10,
13902
- 11
13947
+ 7
13903
13948
  ]);
13949
+ await writer.close();
13904
13950
  } finally {
13905
13951
  await dispose();
13906
13952
  }
13907
13953
  });
13908
- it("crash recovery: an unstarted assistant tool request gets a retryable synthetic result", async () => {
13954
+ it("a read handle refuses append and flush with SessionReadOnlyError", async () => {
13909
13955
  const { persistence, dispose } = await make();
13910
13956
  try {
13911
- const m = meta("interrupted-toolcall");
13912
- await persistence.create(m);
13913
- await persistence.append(m.id, oneTurnLog());
13914
- await persistence.append(m.id, [
13915
- {
13916
- type: "turn/start",
13917
- seq: SessionSeq(6),
13918
- time: 7,
13919
- data: { turn: 2 }
13920
- },
13921
- {
13922
- type: "step/start",
13923
- seq: SessionSeq(7),
13924
- time: 8,
13925
- data: {
13926
- turn: 2,
13927
- step: 1
13928
- }
13929
- },
13930
- {
13931
- type: "assistant/message",
13932
- seq: SessionSeq(8),
13933
- time: 9,
13934
- data: {
13935
- turn: 2,
13936
- step: 1,
13937
- message: createMessage({
13938
- role: "assistant",
13939
- content: [{
13940
- type: "tool-call",
13941
- id: ToolCallId("call-x"),
13942
- name: "bash",
13943
- arguments: "{}"
13944
- }],
13945
- source: {
13946
- kind: "model",
13947
- provider: "mock",
13948
- model: "mock"
13949
- }
13950
- })
13951
- },
13952
- surfaceOp: "append"
13953
- }
13954
- ]);
13955
- const loaded = await persistence.load(m.id);
13956
- globalExpect(loaded.events.map((e) => e.type)).toEqual([
13957
- "turn/start",
13958
- "user/message",
13959
- "step/start",
13960
- "assistant/message",
13961
- "step/end",
13962
- "turn/end",
13963
- "turn/start",
13964
- "step/start",
13965
- "assistant/message",
13966
- "tool/result",
13967
- "step/end",
13968
- "turn/end"
13969
- ]);
13970
- const synthetic = loaded.events.find((e) => e.type === "tool/result");
13971
- globalExpect(synthetic?.type === "tool/result" && synthetic.data).toMatchObject({
13972
- message: {
13973
- source: {
13974
- kind: "tool",
13975
- callId: ToolCallId("call-x")
13976
- },
13977
- content: [{
13978
- type: "tool-result",
13979
- toolCallId: ToolCallId("call-x"),
13980
- isError: true
13981
- }]
13982
- },
13983
- error: { code: TOOL_NOT_STARTED }
13984
- });
13985
- const call = loaded.events.findLast((e) => e.type === "assistant/message");
13986
- const callId = call?.type === "assistant/message" && call.data.message.content.find((b) => b.type === "tool-call");
13987
- globalExpect(callId && callId.type === "tool-call" && callId.id).toBe(ToolCallId("call-x"));
13957
+ const m = meta("read-only");
13958
+ const writer = await persistence.create(m);
13959
+ await writer.append(oneTurnLog());
13960
+ await writer.close();
13961
+ const reader = await persistence.open(m.id, "read");
13962
+ globalExpect(reader.access).toBe("read");
13963
+ await globalExpect(reader.append(secondTurn())).rejects.toBeInstanceOf(SessionReadOnlyError);
13964
+ await globalExpect(reader.flush()).rejects.toBeInstanceOf(SessionReadOnlyError);
13965
+ globalExpect((await reader.read()).events).toEqual(oneTurnLog());
13966
+ await reader.close();
13988
13967
  } finally {
13989
13968
  await dispose();
13990
13969
  }
13991
13970
  });
13992
- it("crash recovery: a recorded tool call with no result tells the model to assess retry risk", async () => {
13971
+ it("operations on a closed handle reject; close is idempotent; asyncDispose releases ownership", async () => {
13993
13972
  const { persistence, dispose } = await make();
13994
13973
  try {
13995
- const m = meta("unknown-tool-outcome");
13996
- await persistence.create(m);
13997
- await persistence.append(m.id, [
13998
- {
13999
- type: "turn/start",
14000
- seq: SessionSeq(0),
14001
- time: 1,
14002
- data: { turn: 1 }
14003
- },
14004
- {
14005
- type: "step/start",
14006
- seq: SessionSeq(1),
14007
- time: 2,
14008
- data: {
14009
- turn: 1,
14010
- step: 1
14011
- }
14012
- },
14013
- {
14014
- type: "assistant/message",
14015
- seq: SessionSeq(2),
14016
- time: 3,
14017
- data: {
14018
- turn: 1,
14019
- step: 1,
14020
- message: createMessage({
14021
- role: "assistant",
14022
- content: [{
14023
- type: "tool-call",
14024
- id: ToolCallId("call-risk"),
14025
- name: "write",
14026
- arguments: "{}"
14027
- }],
14028
- source: {
14029
- kind: "model",
14030
- provider: "mock",
14031
- model: "mock"
14032
- }
14033
- })
14034
- },
14035
- surfaceOp: "append"
14036
- },
14037
- {
14038
- type: "tool/call",
14039
- seq: SessionSeq(3),
14040
- time: 4,
14041
- data: {
14042
- turn: 1,
14043
- step: 1,
14044
- callId: ToolCallId("call-risk"),
14045
- name: "write",
14046
- arguments: "{}"
14047
- }
14048
- }
14049
- ]);
14050
- const loaded = await persistence.load(m.id);
14051
- const synthetic = loaded.events.find((e) => e.type === "tool/result");
14052
- globalExpect(synthetic?.type === "tool/result" && synthetic.data.error).toEqual({
14053
- name: "ToolOutcomeUnknownError",
14054
- code: TOOL_OUTCOME_UNKNOWN
14055
- });
14056
- if (synthetic?.type !== "tool/result" || synthetic.data.message.content[0].content[0]?.type !== "text") throw new Error("expected a text tool result");
14057
- globalExpect(synthetic.data.message.content[0].content[0].text).toContain("retry only if the operation is read-only or idempotent");
14058
- globalExpect(synthetic.data.message.content[0].content[0].text).toContain("if it may have side effects, first verify external state or ask the user");
14059
- const resumedResult = Session.create(m.id, loaded.events, loaded.meta, loaded.inheritedEventCount).deriveMessages().find((message) => message.content.some((block) => block.type === "tool-result"));
14060
- globalExpect(resumedResult?.content[0]).toMatchObject({
14061
- type: "tool-result",
14062
- toolCallId: ToolCallId("call-risk"),
14063
- isError: true
14064
- });
13974
+ const m = meta("closed");
13975
+ const handle = await persistence.create(m);
13976
+ await handle.append(oneTurnLog());
13977
+ await handle.close();
13978
+ await handle.close();
13979
+ await globalExpect(handle.read()).rejects.toBeInstanceOf(SessionHandleClosedError);
13980
+ await globalExpect(handle.append(secondTurn())).rejects.toBeInstanceOf(SessionHandleClosedError);
13981
+ await globalExpect(handle.flush()).rejects.toBeInstanceOf(SessionHandleClosedError);
13982
+ {
13983
+ await using writer = await persistence.open(m.id, "write");
13984
+ await writer.append(secondTurn());
13985
+ }
13986
+ await (await persistence.open(m.id, "write")).close();
14065
13987
  } finally {
14066
13988
  await dispose();
14067
13989
  }
14068
13990
  });
14069
- it("list() excludes a created-but-never-appended (zero-event) session", async () => {
14070
- const { persistence, dispose } = await make();
13991
+ it("service-level flush materializes every active write handle and counts a closing one as flushed", async () => {
13992
+ const backend = await make();
13993
+ try {
13994
+ const materialized = await backend.persistence.create(meta("flush-all"));
13995
+ const closing = (await backend.persistence.create(meta("flush-all-closing"))).close();
13996
+ await backend.persistence.flush();
13997
+ await closing;
13998
+ if (backend.reopen !== void 0) {
13999
+ const reopened = await backend.reopen();
14000
+ try {
14001
+ globalExpect(await reopened.persistence.stat(SessionId("flush-all"))).toBeDefined();
14002
+ globalExpect(await reopened.persistence.stat(SessionId("flush-all-closing"))).toBeUndefined();
14003
+ } finally {
14004
+ await reopened.dispose();
14005
+ }
14006
+ }
14007
+ await materialized.close();
14008
+ } finally {
14009
+ await backend.dispose();
14010
+ }
14011
+ });
14012
+ it("a created-but-unappended session is visible to this instance and invisible to a fresh one", async () => {
14013
+ const backend = await make();
14071
14014
  try {
14072
- await persistence.create(meta("empty"));
14073
- globalExpect((await persistence.list()).map((m) => m.id)).not.toContain(SessionId("empty"));
14074
- globalExpect((await persistence.listSnapshots()).map((snapshot) => snapshot.header.id)).not.toContain(SessionId("empty"));
14015
+ const m = meta("lazy", "/work");
14016
+ const creator = await backend.persistence.create(m);
14017
+ globalExpect((await creator.read()).events).toEqual([]);
14018
+ const snapshot = await backend.persistence.stat(m.id);
14019
+ globalExpect(snapshot?.header).toMatchObject(m);
14020
+ globalExpect((await backend.persistence.list()).map((s) => s.header.id)).toContain(m.id);
14021
+ const reader = await backend.persistence.open(m.id, "read");
14022
+ globalExpect((await reader.read()).events).toEqual([]);
14023
+ await reader.close();
14024
+ if (backend.reopen !== void 0) {
14025
+ const reopened = await backend.reopen();
14026
+ try {
14027
+ globalExpect(await reopened.persistence.stat(m.id)).toBeUndefined();
14028
+ globalExpect((await reopened.persistence.list()).map((s) => s.header.id)).not.toContain(m.id);
14029
+ await globalExpect(reopened.persistence.open(m.id, "read")).rejects.toBeInstanceOf(SessionPersistenceNotFoundError);
14030
+ } finally {
14031
+ await reopened.dispose();
14032
+ }
14033
+ }
14034
+ await creator.close();
14075
14035
  } finally {
14076
- await dispose();
14036
+ await backend.dispose();
14077
14037
  }
14078
14038
  });
14079
- it("rejects pre-aborted observation reads with the exact cancellation reason", async () => {
14039
+ it("close without an append erases the created session from this instance", async () => {
14080
14040
  const { persistence, dispose } = await make();
14081
14041
  try {
14082
- const reason = /* @__PURE__ */ new Error("persistence observation cancelled");
14083
- const controller = new AbortController();
14084
- await globalExpect(persistence.listSnapshots(controller.signal)).resolves.toEqual([]);
14085
- controller.abort(reason);
14086
- await globalExpect(persistence.list(controller.signal)).rejects.toBe(reason);
14087
- await globalExpect(persistence.listSnapshots(controller.signal)).rejects.toBe(reason);
14088
- await globalExpect(persistence.inspect(SessionId("cancelled-inspect"), controller.signal)).rejects.toBe(reason);
14089
- await globalExpect(persistence.readFrom(SessionId("cancelled-read-from"), SessionLogOffset(0), controller.signal)).rejects.toBe(reason);
14042
+ const m = meta("never-was");
14043
+ await (await persistence.create(m)).close();
14044
+ globalExpect(await persistence.stat(m.id)).toBeUndefined();
14045
+ globalExpect((await persistence.list()).map((s) => s.header.id)).not.toContain(m.id);
14046
+ await globalExpect(persistence.open(m.id, "read")).rejects.toBeInstanceOf(SessionPersistenceNotFoundError);
14090
14047
  } finally {
14091
14048
  await dispose();
14092
14049
  }
14093
14050
  });
14094
- it("readFrom returns exactly the stored suffix from the requested seq, without mutating the log", async () => {
14095
- const { persistence, dispose } = await make();
14051
+ it("flush materializes an empty session durably for a fresh instance", async () => {
14052
+ const backend = await make();
14096
14053
  try {
14097
- const m = meta("read-from", "/work");
14098
- const log = oneTurnLog();
14099
- await persistence.create(m);
14100
- await persistence.append(m.id, log);
14101
- const whole = await persistence.readFrom(m.id, SessionLogOffset(0));
14102
- globalExpect(whole.meta).toMatchObject({
14103
- id: m.id,
14104
- cwd: "/work"
14105
- });
14106
- globalExpect(whole.events).toEqual(log);
14107
- const suffix = await persistence.readFrom(m.id, SessionLogOffset(3));
14108
- globalExpect(suffix.events).toEqual(log.slice(3));
14109
- globalExpect(suffix.events[0]?.seq).toBe(3);
14110
- await globalExpect(persistence.readFrom(m.id, SessionLogOffset(log.length))).resolves.toMatchObject({ events: [] });
14111
- await globalExpect(persistence.readFrom(m.id, SessionLogOffset(log.length + 100))).resolves.toMatchObject({ events: [] });
14112
- await persistence.append(m.id, [{
14113
- type: "turn/start",
14114
- seq: SessionSeq(6),
14115
- time: 7,
14116
- data: { turn: 2 }
14117
- }]);
14118
- const tail = await persistence.readFrom(m.id, SessionLogOffset(6));
14119
- globalExpect(tail.events.map((event) => event.type)).toEqual(["turn/start"]);
14120
- await globalExpect(persistence.readFrom(SessionId("absent-read-from"), SessionLogOffset(0))).rejects.toThrow("not found");
14121
- await globalExpect(persistence.readFrom(m.id, -1)).rejects.toThrow("non-negative safe integer");
14122
- await globalExpect(persistence.readFrom(m.id, 1.5)).rejects.toThrow("non-negative safe integer");
14054
+ if (backend.reopen === void 0) return;
14055
+ const m = meta("durable-empty", "/work");
14056
+ const creator = await backend.persistence.create(m);
14057
+ await creator.flush();
14058
+ await creator.close();
14059
+ const reopened = await backend.reopen();
14060
+ try {
14061
+ globalExpect((await reopened.persistence.list()).map((s) => s.header.id)).toContain(m.id);
14062
+ globalExpect((await reopened.persistence.stat(m.id))?.header).toMatchObject(m);
14063
+ const reader = await reopened.persistence.open(m.id, "read");
14064
+ globalExpect((await reader.read()).events).toEqual([]);
14065
+ await reader.close();
14066
+ } finally {
14067
+ await reopened.dispose();
14068
+ }
14123
14069
  } finally {
14124
- await dispose();
14070
+ await backend.dispose();
14125
14071
  }
14126
14072
  });
14127
- it("lists stable lightweight revisions that change after an append", async () => {
14073
+ it("freshness: reads started after an append resolves observe that prefix on any handle", async () => {
14128
14074
  const { persistence, dispose } = await make();
14129
14075
  try {
14130
- const m = meta("s2");
14131
- await persistence.create(m);
14132
- await persistence.append(m.id, oneTurnLog());
14133
- globalExpect((await persistence.list()).map((x) => x.id)).toContain(m.id);
14134
- const first = (await persistence.listSnapshots()).find((snapshot) => snapshot.header.id === m.id);
14135
- const repeated = (await persistence.listSnapshots()).find((snapshot) => snapshot.header.id === m.id);
14136
- globalExpect(first).toBeDefined();
14137
- globalExpect(repeated?.revision).toBe(first?.revision);
14138
- await persistence.append(m.id, [{
14139
- type: "turn/start",
14140
- seq: SessionSeq(6),
14141
- time: 7,
14142
- data: { turn: 2 }
14143
- }]);
14144
- const changed = (await persistence.listSnapshots()).find((snapshot) => snapshot.header.id === m.id);
14145
- globalExpect(changed?.revision).not.toBe(first?.revision);
14076
+ const m = meta("fresh");
14077
+ const writer = await persistence.create(m);
14078
+ await writer.append(oneTurnLog());
14079
+ const before = await persistence.open(m.id, "read");
14080
+ globalExpect((await before.read()).events).toEqual(oneTurnLog());
14081
+ await writer.append(secondTurn());
14082
+ globalExpect((await before.read()).events.map((e) => e.seq)).toEqual([
14083
+ 0,
14084
+ 1,
14085
+ 2,
14086
+ 3,
14087
+ 4,
14088
+ 5,
14089
+ 6,
14090
+ 7
14091
+ ]);
14092
+ const after = await persistence.open(m.id, "read");
14093
+ globalExpect((await after.read()).events.map((e) => e.seq)).toEqual([
14094
+ 0,
14095
+ 1,
14096
+ 2,
14097
+ 3,
14098
+ 4,
14099
+ 5,
14100
+ 6,
14101
+ 7
14102
+ ]);
14103
+ await before.close();
14104
+ await after.close();
14105
+ await writer.close();
14146
14106
  } finally {
14147
14107
  await dispose();
14148
14108
  }
14149
14109
  });
14150
- it("append rejects a batch whose first seq does not match the stored next-seq", async () => {
14110
+ it("a fresh instance continues the stored log at the committed next-seq", async () => {
14111
+ const backend = await make();
14112
+ try {
14113
+ if (backend.reopen === void 0) return;
14114
+ const m = meta("continue", "/work");
14115
+ const creator = await backend.persistence.create(m);
14116
+ await creator.append(oneTurnLog());
14117
+ await creator.close();
14118
+ const reopened = await backend.reopen();
14119
+ try {
14120
+ const writer = await reopened.persistence.open(m.id, "write");
14121
+ await writer.append(secondTurn());
14122
+ globalExpect((await writer.read()).events.map((e) => e.seq)).toEqual([
14123
+ 0,
14124
+ 1,
14125
+ 2,
14126
+ 3,
14127
+ 4,
14128
+ 5,
14129
+ 6,
14130
+ 7
14131
+ ]);
14132
+ await writer.close();
14133
+ } finally {
14134
+ await reopened.dispose();
14135
+ }
14136
+ } finally {
14137
+ await backend.dispose();
14138
+ }
14139
+ });
14140
+ it("append rejects a batch that does not contiguously continue the log, naming the expected seq", async () => {
14151
14141
  const { persistence, dispose } = await make();
14152
14142
  try {
14153
- const m = meta("s3");
14154
- await persistence.create(m);
14155
- await persistence.append(m.id, oneTurnLog());
14156
- const restated = oneTurnLog();
14157
- await globalExpect(persistence.append(m.id, restated)).rejects.toThrow();
14143
+ const m = meta("contiguity");
14144
+ const handle = await persistence.create(m);
14145
+ await handle.append(oneTurnLog());
14146
+ await globalExpect(handle.append(oneTurnLog())).rejects.toThrow(/expected 6/);
14147
+ const gapped = [{
14148
+ type: "turn/start",
14149
+ seq: SessionSeq(6),
14150
+ time: 9,
14151
+ data: { turn: 2 }
14152
+ }, {
14153
+ type: "turn/end",
14154
+ seq: SessionSeq(8),
14155
+ time: 10,
14156
+ data: {
14157
+ turn: 2,
14158
+ reason: { kind: "completed" }
14159
+ }
14160
+ }];
14161
+ await globalExpect(handle.append(gapped)).rejects.toThrow(/expected 7/);
14162
+ globalExpect((await handle.read()).events.map((e) => e.seq)).toEqual([
14163
+ 0,
14164
+ 1,
14165
+ 2,
14166
+ 3,
14167
+ 4,
14168
+ 5
14169
+ ]);
14170
+ await handle.close();
14158
14171
  } finally {
14159
14172
  await dispose();
14160
14173
  }
14161
14174
  });
14162
- it("append rejects a mid-batch seq gap", async () => {
14175
+ it("append rejects non-JSON-serializable event data without storing anything", async () => {
14163
14176
  const { persistence, dispose } = await make();
14164
14177
  try {
14165
- const m = meta("s4");
14166
- await persistence.create(m);
14167
- const gapped = [{
14178
+ const m = meta("non-json");
14179
+ const handle = await persistence.create(m);
14180
+ const bad = (extra) => [{
14168
14181
  type: "turn/start",
14169
14182
  seq: SessionSeq(0),
14170
14183
  time: 1,
14171
- data: { turn: 1 }
14172
- }, {
14173
- type: "step/start",
14174
- seq: SessionSeq(2),
14175
- time: 2,
14176
14184
  data: {
14177
14185
  turn: 1,
14178
- step: 1
14186
+ extra
14179
14187
  }
14180
14188
  }];
14181
- await globalExpect(persistence.append(m.id, gapped)).rejects.toThrow();
14189
+ await globalExpect(handle.append(bad(1n))).rejects.toThrow(TypeError);
14190
+ await globalExpect(handle.append(bad(1n))).rejects.toThrow(/losslessly JSON-serializable/);
14191
+ await globalExpect(handle.append(bad(void 0))).rejects.toThrow(/losslessly JSON-serializable/);
14192
+ await handle.append(oneTurnLog());
14193
+ globalExpect((await handle.read()).events).toEqual(oneTurnLog());
14194
+ await handle.close();
14182
14195
  } finally {
14183
14196
  await dispose();
14184
14197
  }
14185
14198
  });
14186
- it("append rejects non-JSON-serializable event data, naming the event type", async () => {
14199
+ it("vocabulary fail-closed: an unknown event type refuses append and stored reads", async () => {
14187
14200
  const { persistence, dispose } = await make();
14188
14201
  try {
14189
- const cyclic = {
14190
- type: "text",
14191
- text: "x"
14192
- };
14193
- cyclic["self"] = cyclic;
14194
- const badValues = [
14195
- 1n,
14196
- void 0,
14197
- Infinity,
14198
- () => 0,
14199
- Symbol("s"),
14200
- /* @__PURE__ */ new Map(),
14201
- cyclic
14202
- ];
14203
- for (const [i, bad] of badValues.entries()) {
14204
- const mi = meta(`s5-${i}`);
14205
- await persistence.create(mi);
14206
- const events = [{
14207
- type: "user/message",
14208
- seq: SessionSeq(0),
14209
- time: 1,
14202
+ const m = meta("foreign-vocabulary");
14203
+ const handle = await persistence.create(m);
14204
+ await handle.append(oneTurnLog());
14205
+ await globalExpect(handle.append([{
14206
+ type: "mystery/event",
14207
+ seq: SessionSeq(6),
14208
+ time: 7,
14209
+ data: { payload: true }
14210
+ }])).rejects.toBeInstanceOf(SessionFormatUnsupportedError);
14211
+ await handle.close();
14212
+ const reader = await persistence.open(m.id, "read");
14213
+ try {
14214
+ const { events } = await reader.read();
14215
+ globalExpect(events).toHaveLength(6);
14216
+ } finally {
14217
+ await reader.close();
14218
+ }
14219
+ const writer = await persistence.open(m.id, "write");
14220
+ try {
14221
+ await writer.append([{
14222
+ type: "turn/end",
14223
+ seq: SessionSeq(6),
14224
+ time: 7,
14210
14225
  data: {
14211
- id: MessageId(`invalid-json-${i}`),
14212
- role: "user",
14213
- content: [{
14214
- type: "text",
14215
- text: "x"
14216
- }],
14217
- source: { kind: "user" },
14218
- extra: bad
14226
+ turn: 2,
14227
+ reason: { kind: "completed" }
14219
14228
  }
14220
- }];
14221
- await globalExpect(persistence.append(mi.id, events)).rejects.toThrow(/losslessly JSON-serializable/);
14229
+ }]);
14230
+ } finally {
14231
+ await writer.close();
14232
+ }
14233
+ } finally {
14234
+ await dispose();
14235
+ }
14236
+ });
14237
+ it("a torn physical tail is never served and is durably truncated by the write path", async () => {
14238
+ const backend = await make();
14239
+ try {
14240
+ if (backend.reopen === void 0 || backend.corruptTail === void 0) return;
14241
+ const m = meta("torn", "/work");
14242
+ const creator = await backend.persistence.create(m);
14243
+ await creator.append(oneTurnLog());
14244
+ await creator.close();
14245
+ await backend.corruptTail(m.id, m.cwd);
14246
+ const readerInstance = await backend.reopen();
14247
+ try {
14248
+ const reader = await readerInstance.persistence.open(m.id, "read");
14249
+ globalExpect((await reader.read()).events).toEqual(oneTurnLog());
14250
+ await reader.close();
14251
+ const writer = await readerInstance.persistence.open(m.id, "write");
14252
+ await writer.append(secondTurn());
14253
+ globalExpect((await writer.read()).events.map((e) => e.seq)).toEqual([
14254
+ 0,
14255
+ 1,
14256
+ 2,
14257
+ 3,
14258
+ 4,
14259
+ 5,
14260
+ 6,
14261
+ 7
14262
+ ]);
14263
+ await writer.close();
14264
+ } finally {
14265
+ await readerInstance.dispose();
14266
+ }
14267
+ const verifyInstance = await backend.reopen();
14268
+ try {
14269
+ const verify = await verifyInstance.persistence.open(m.id, "read");
14270
+ globalExpect((await verify.read()).events.map((e) => e.seq)).toEqual([
14271
+ 0,
14272
+ 1,
14273
+ 2,
14274
+ 3,
14275
+ 4,
14276
+ 5,
14277
+ 6,
14278
+ 7
14279
+ ]);
14280
+ await verify.close();
14281
+ } finally {
14282
+ await verifyInstance.dispose();
14222
14283
  }
14284
+ } finally {
14285
+ await backend.dispose();
14286
+ }
14287
+ });
14288
+ it("stat and list agree on stable revisions that change after an append", async () => {
14289
+ const { persistence, dispose } = await make();
14290
+ try {
14291
+ const m = meta("revisions", "/work");
14292
+ const writer = await persistence.create(m);
14293
+ await writer.append(oneTurnLog());
14294
+ const statFirst = await persistence.stat(m.id);
14295
+ const statAgain = await persistence.stat(m.id);
14296
+ const listFirst = (await persistence.list()).find((s) => s.header.id === m.id);
14297
+ globalExpect(statFirst).toBeDefined();
14298
+ globalExpect(statAgain?.revision).toBe(statFirst?.revision);
14299
+ globalExpect(listFirst?.revision).toBe(statFirst?.revision);
14300
+ await writer.append(secondTurn());
14301
+ const statChanged = await persistence.stat(m.id);
14302
+ globalExpect(statChanged?.revision).not.toBe(statFirst?.revision);
14303
+ const listChanged = (await persistence.list()).find((s) => s.header.id === m.id);
14304
+ globalExpect(listChanged?.revision).toBe(statChanged?.revision);
14305
+ const reader = await persistence.open(m.id, "read");
14306
+ globalExpect(statChanged?.header).toEqual(reader.header);
14307
+ globalExpect(listChanged?.header).toEqual(reader.header);
14308
+ globalExpect(statChanged?.header).toMatchObject(m);
14309
+ await reader.close();
14310
+ await writer.close();
14311
+ globalExpect(await persistence.stat(SessionId("absent-stat"))).toBeUndefined();
14223
14312
  } finally {
14224
14313
  await dispose();
14225
14314
  }
@@ -14229,398 +14318,16 @@ function runPersistenceContract(name, make) {
14229
14318
  //#endregion
14230
14319
  //#region src/testing/coordinator-contract.ts
14231
14320
  const WORK = "/w";
14232
- const OTHER = "/other";
14233
14321
  function send(session, events) {
14234
14322
  appendLog(session, events);
14235
14323
  }
14236
- function legacyMessageLog() {
14237
- return [
14238
- {
14239
- type: "turn/start",
14240
- seq: SessionSeq(0),
14241
- time: 1,
14242
- data: { turn: 1 }
14243
- },
14244
- {
14245
- type: "user/message",
14246
- seq: SessionSeq(1),
14247
- time: 2,
14248
- data: {
14249
- content: [{
14250
- type: "text",
14251
- text: "hi"
14252
- }],
14253
- source: { kind: "user" }
14254
- },
14255
- surfaceOp: "append"
14256
- },
14257
- {
14258
- type: "step/start",
14259
- seq: SessionSeq(2),
14260
- time: 3,
14261
- data: {
14262
- turn: 1,
14263
- step: 1
14264
- }
14265
- },
14266
- {
14267
- type: "assistant/message",
14268
- seq: SessionSeq(3),
14269
- time: 4,
14270
- data: {
14271
- turn: 1,
14272
- step: 1,
14273
- content: [{
14274
- type: "tool-call",
14275
- id: "call-1",
14276
- name: "read",
14277
- arguments: "{}"
14278
- }],
14279
- provenance: {
14280
- provider: "mock",
14281
- model: "mock"
14282
- }
14283
- },
14284
- surfaceOp: "append"
14285
- },
14286
- {
14287
- type: "tool/call",
14288
- seq: SessionSeq(4),
14289
- time: 5,
14290
- data: {
14291
- turn: 1,
14292
- step: 1,
14293
- callId: "call-1",
14294
- name: "read",
14295
- arguments: "{}"
14296
- }
14297
- },
14298
- {
14299
- type: "tool/result",
14300
- seq: SessionSeq(5),
14301
- time: 6,
14302
- data: {
14303
- turn: 1,
14304
- step: 1,
14305
- callId: "call-1",
14306
- content: [{
14307
- type: "text",
14308
- text: "full result"
14309
- }],
14310
- isError: false
14311
- },
14312
- sourceEventSeqs: [4],
14313
- surfaceOp: "append"
14314
- },
14315
- {
14316
- type: "tool/result",
14317
- seq: SessionSeq(6),
14318
- time: 8,
14319
- data: {
14320
- turn: 1,
14321
- step: 1,
14322
- callId: "call-1",
14323
- content: [{
14324
- type: "text",
14325
- text: "pruned"
14326
- }],
14327
- isError: false
14328
- },
14329
- sourceEventSeqs: [5],
14330
- surfaceOp: {
14331
- op: "replace",
14332
- start: 5,
14333
- end: 5
14334
- }
14335
- },
14336
- {
14337
- type: "step/end",
14338
- seq: SessionSeq(7),
14339
- time: 9,
14340
- data: {
14341
- turn: 1,
14342
- step: 1
14343
- }
14344
- },
14345
- {
14346
- type: "turn/end",
14347
- seq: SessionSeq(8),
14348
- time: 10,
14349
- data: {
14350
- turn: 1,
14351
- reason: { kind: "completed" }
14352
- }
14353
- }
14354
- ];
14355
- }
14356
- function preReactLoopLog() {
14357
- const prompt = createUserMessage({
14358
- content: [{
14359
- type: "text",
14360
- text: "old prompt"
14361
- }],
14362
- source: { kind: "user" }
14363
- });
14364
- const steering = createUserMessage({
14365
- content: [{
14366
- type: "text",
14367
- text: "old steering"
14368
- }],
14369
- source: { kind: "user" }
14370
- });
14371
- return [
14372
- {
14373
- type: "turn/start",
14374
- seq: SessionSeq(0),
14375
- time: 1,
14376
- data: {
14377
- turn: 1,
14378
- trigger: {
14379
- kind: "message",
14380
- source: { kind: "user" }
14381
- }
14382
- }
14383
- },
14384
- {
14385
- type: "user/message",
14386
- seq: SessionSeq(1),
14387
- time: 2,
14388
- data: prompt,
14389
- surfaceOp: "append"
14390
- },
14391
- {
14392
- type: "step/start",
14393
- seq: SessionSeq(2),
14394
- time: 3,
14395
- data: {
14396
- turn: 1,
14397
- step: 1
14398
- }
14399
- },
14400
- {
14401
- type: "steering/message",
14402
- seq: SessionSeq(3),
14403
- time: 4,
14404
- data: {
14405
- turn: 1,
14406
- message: steering
14407
- },
14408
- surfaceOp: "append"
14409
- },
14410
- {
14411
- type: "step/end",
14412
- seq: SessionSeq(4),
14413
- time: 5,
14414
- data: {
14415
- turn: 1,
14416
- step: 1
14417
- }
14418
- },
14419
- {
14420
- type: "turn/end",
14421
- seq: SessionSeq(5),
14422
- time: 6,
14423
- data: {
14424
- turn: 1,
14425
- reason: { kind: "completed" }
14426
- }
14427
- },
14428
- {
14429
- type: "turn/start",
14430
- seq: SessionSeq(6),
14431
- time: 7,
14432
- data: {
14433
- turn: 2,
14434
- trigger: { kind: "retry" }
14435
- }
14436
- },
14437
- {
14438
- type: "step/start",
14439
- seq: SessionSeq(7),
14440
- time: 8,
14441
- data: {
14442
- turn: 2,
14443
- step: 1
14444
- }
14445
- },
14446
- {
14447
- type: "step/end",
14448
- seq: SessionSeq(8),
14449
- time: 9,
14450
- data: {
14451
- turn: 2,
14452
- step: 1
14453
- }
14454
- },
14455
- {
14456
- type: "turn/end",
14457
- seq: SessionSeq(9),
14458
- time: 10,
14459
- data: {
14460
- turn: 2,
14461
- reason: {
14462
- kind: "error",
14463
- step: 1,
14464
- failure: {
14465
- message: "old provider failure",
14466
- code: "SERVER"
14467
- }
14468
- }
14469
- }
14470
- },
14471
- {
14472
- type: "turn/start",
14473
- seq: SessionSeq(10),
14474
- time: 11,
14475
- data: {
14476
- turn: 3,
14477
- trigger: {
14478
- kind: "message",
14479
- source: { kind: "user" }
14480
- }
14481
- }
14482
- },
14483
- {
14484
- type: "turn/end",
14485
- seq: SessionSeq(11),
14486
- time: 12,
14487
- data: {
14488
- turn: 3,
14489
- reason: { kind: "aborted" }
14490
- }
14491
- },
14492
- {
14493
- type: "turn/start",
14494
- seq: SessionSeq(12),
14495
- time: 13,
14496
- data: {
14497
- turn: 4,
14498
- trigger: {
14499
- kind: "message",
14500
- source: { kind: "user" }
14501
- }
14502
- }
14503
- },
14504
- {
14505
- type: "turn/end",
14506
- seq: SessionSeq(13),
14507
- time: 14,
14508
- data: {
14509
- turn: 4,
14510
- reason: { kind: "disposed" }
14511
- }
14512
- },
14513
- {
14514
- type: "turn/start",
14515
- seq: SessionSeq(14),
14516
- time: 15,
14517
- data: {
14518
- turn: 5,
14519
- trigger: {
14520
- kind: "message",
14521
- source: { kind: "user" }
14522
- }
14523
- }
14524
- },
14525
- {
14526
- type: "step/start",
14527
- seq: SessionSeq(15),
14528
- time: 16,
14529
- data: {
14530
- turn: 5,
14531
- step: 1
14532
- }
14533
- },
14534
- {
14535
- type: "step/end",
14536
- seq: SessionSeq(16),
14537
- time: 17,
14538
- data: {
14539
- turn: 5,
14540
- step: 1
14541
- }
14542
- },
14543
- {
14544
- type: "turn/end",
14545
- seq: SessionSeq(17),
14546
- time: 18,
14547
- data: {
14548
- turn: 5,
14549
- reason: {
14550
- kind: "error",
14551
- step: 1,
14552
- message: "old thrown value"
14553
- }
14554
- }
14555
- },
14556
- {
14557
- type: "turn/start",
14558
- seq: SessionSeq(18),
14559
- time: 19,
14560
- data: {
14561
- turn: 6,
14562
- trigger: {
14563
- kind: "message",
14564
- source: { kind: "user" }
14565
- }
14566
- }
14567
- },
14568
- {
14569
- type: "turn/end",
14570
- seq: SessionSeq(19),
14571
- time: 20,
14572
- data: {
14573
- turn: 6,
14574
- reason: {
14575
- kind: "error",
14576
- step: 0,
14577
- failure: {
14578
- message: "old detailed provider failure",
14579
- code: "RATE_LIMIT",
14580
- status: 429,
14581
- providerRetryAfterMs: 1e3,
14582
- requestId: "request-1"
14583
- }
14584
- }
14585
- }
14586
- },
14587
- {
14588
- type: "turn/start",
14589
- seq: SessionSeq(20),
14590
- time: 21,
14591
- data: {
14592
- turn: 7,
14593
- trigger: {
14594
- kind: "message",
14595
- source: { kind: "user" }
14596
- }
14597
- }
14598
- },
14599
- {
14600
- type: "turn/end",
14601
- seq: SessionSeq(21),
14602
- time: 22,
14603
- data: {
14604
- turn: 7,
14605
- reason: {
14606
- kind: "error",
14607
- step: 0,
14608
- message: "old coded error",
14609
- code: "CODED"
14610
- }
14611
- }
14612
- }
14613
- ];
14614
- }
14615
- async function liveSessionInFiber(ctx, id, cwd) {
14616
- let session;
14617
- await ctx.plugin(Object.assign((inner) => {
14618
- session = inner.sessions.create(SessionId(id), cwd !== void 0 ? { meta: { cwd } } : void 0);
14619
- }, { inject: ["sessions"] }));
14620
- return session;
14621
- }
14324
+ /**
14325
+ * RDB 特有行为契约:live 会话驱动持久化、seed 边界、fork/resume、HMR
14326
+ * dispose drain、冲突拒绝、torn-tail。coordinator 内部状态(ownerless /
14327
+ * per-id chain / legacy 迁移)已由新版 handle 模型承担,不再单独测试。
14328
+ */
14622
14329
  function runCoordinatorContract(name, makeFixture) {
14623
- describe(`PersistenceCoordinator orchestration: ${name}`, () => {
14330
+ describe(`SessionPersistenceRdb orchestration: ${name}`, () => {
14624
14331
  async function freshCtx(fix) {
14625
14332
  const ctx = new Context();
14626
14333
  await ctx.plugin(SessionStore);
@@ -14629,6 +14336,18 @@ function runCoordinatorContract(name, makeFixture) {
14629
14336
  fiber: await fix.mount(ctx)
14630
14337
  };
14631
14338
  }
14339
+ async function readAll(ctx, id) {
14340
+ const handle = await ctx.sessionPersistence.open(id, "read");
14341
+ try {
14342
+ const { events } = await handle.read();
14343
+ return {
14344
+ meta: handle.header,
14345
+ events
14346
+ };
14347
+ } finally {
14348
+ await handle.close();
14349
+ }
14350
+ }
14632
14351
  it("persists a live session driven through the store, surviving reload", async () => {
14633
14352
  const fix = await makeFixture();
14634
14353
  const { ctx, fiber } = await freshCtx(fix);
@@ -14636,7 +14355,7 @@ function runCoordinatorContract(name, makeFixture) {
14636
14355
  const session = ctx.sessions.create(SessionId("live"), { meta: { cwd: WORK } });
14637
14356
  send(session, oneTurnLog());
14638
14357
  await ctx.sessions.flush(session);
14639
- const loaded = await ctx.sessionPersistence.load(SessionId("live"));
14358
+ const loaded = await readAll(ctx, SessionId("live"));
14640
14359
  globalExpect(loaded.events).toHaveLength(6);
14641
14360
  globalExpect(loaded.meta.cwd).toBe(WORK);
14642
14361
  } finally {
@@ -14644,106 +14363,31 @@ function runCoordinatorContract(name, makeFixture) {
14644
14363
  await fix.cleanup();
14645
14364
  }
14646
14365
  });
14647
- it("rejects crash-repair load while a live session owns the persisted prefix", async () => {
14366
+ it("round-trips the seed boundary (inheritedEventCount) through persistence", async () => {
14648
14367
  const fix = await makeFixture();
14649
14368
  const { ctx, fiber } = await freshCtx(fix);
14650
- let session;
14651
- const sessionFiber = await ctx.plugin(Object.assign((inner) => {
14652
- session = inner.sessions.create(SessionId("live-load"), { meta: { cwd: WORK } });
14653
- }, { inject: ["sessions"] }));
14654
14369
  try {
14655
- session.append("turn/start", { turn: 1 });
14656
- await ctx.sessions.flush(session);
14657
- await globalExpect(ctx.sessionPersistence.load(session.id)).rejects.toThrow(`cannot load session "${session.id}" while its live turn is open`);
14658
- send(session, oneTurnLog().slice(1));
14370
+ let session;
14371
+ const sessionFiber = await ctx.plugin(Object.assign((inner) => {
14372
+ session = inner.sessions.create(SessionId("forked-child"), {
14373
+ seed: oneTurnLog().slice(0, 3),
14374
+ meta: {
14375
+ cwd: WORK,
14376
+ isSeeded: true
14377
+ },
14378
+ inheritedEventCount: SessionLogOffset(3)
14379
+ });
14380
+ }, { inject: ["sessions"] }));
14381
+ send(session, oneTurnLog().slice(3));
14659
14382
  await ctx.sessions.flush(session);
14660
14383
  await sessionFiber.dispose();
14661
- await vi.waitFor(async () => {
14662
- const loaded = await ctx.sessionPersistence.load(session.id);
14663
- globalExpect(loaded.events.map((event) => event.type)).toEqual(oneTurnLog().map((event) => event.type));
14664
- globalExpect(loaded.events.at(-1)).toMatchObject({
14665
- type: "turn/end",
14666
- data: { reason: { kind: "completed" } }
14667
- });
14668
- });
14669
- } finally {
14670
- await sessionFiber.dispose();
14671
- await fiber.dispose();
14672
- await fix.cleanup();
14673
- }
14674
- });
14675
- it("rechecks live ownership after a cold load enters the per-id chain", async () => {
14676
- const fix = await makeFixture();
14677
- const { ctx, fiber } = await freshCtx(fix);
14678
- try {
14679
- const id = SessionId("queued-load-live-race");
14680
- const header = meta(id, WORK);
14681
- const start = {
14682
- type: "turn/start",
14683
- seq: SessionSeq(0),
14684
- time: 1,
14685
- data: { turn: 1 }
14686
- };
14687
- await ctx.sessionPersistence.create(header);
14688
- await ctx.sessionPersistence.append(id, [start]);
14689
- const loading = ctx.sessionPersistence.load(id);
14690
- const live = ctx.sessions.create(id, {
14691
- seed: [start],
14692
- meta: header
14693
- });
14694
- await globalExpect(loading).rejects.toThrow(/live turn is open/);
14695
- live.append("turn/end", {
14696
- turn: 1,
14697
- reason: { kind: "completed" }
14698
- });
14699
- await ctx.sessions.flush(live);
14700
- const loaded = await ctx.sessionPersistence.load(id);
14701
- globalExpect(loaded.events.map((event) => event.type)).toEqual([
14702
- "turn/start",
14703
- "session/end-seed",
14704
- "turn/end"
14705
- ]);
14706
- globalExpect(loaded.events.at(-1)).toMatchObject({
14707
- type: "turn/end",
14708
- data: { reason: { kind: "completed" } }
14709
- });
14710
- } finally {
14711
- await fiber.dispose();
14712
- await fix.cleanup();
14713
- }
14714
- });
14715
- it("does not load an unmaterialized empty live session", async () => {
14716
- const fix = await makeFixture();
14717
- const { ctx, fiber } = await freshCtx(fix);
14718
- try {
14719
- const session = ctx.sessions.create(SessionId("empty-live"), { meta: { cwd: WORK } });
14720
- await globalExpect(ctx.sessionPersistence.load(session.id)).rejects.toThrow(/not found/);
14721
- } finally {
14722
- await fiber.dispose();
14723
- await fix.cleanup();
14724
- }
14725
- });
14726
- it("round-trips the seed boundary (inheritedEventCount) through persistence", async () => {
14727
- const fix = await makeFixture();
14728
- const { ctx, fiber } = await freshCtx(fix);
14729
- try {
14730
- let session;
14731
- const sessionFiber = await ctx.plugin(Object.assign((inner) => {
14732
- session = inner.sessions.create(SessionId("forked-child"), {
14733
- seed: oneTurnLog().slice(0, 3),
14734
- meta: {
14735
- cwd: WORK,
14736
- isSeeded: true
14737
- },
14738
- inheritedEventCount: SessionLogOffset(3)
14739
- });
14740
- }, { inject: ["sessions"] }));
14741
- send(session, oneTurnLog().slice(3));
14742
- await ctx.sessions.flush(session);
14743
- await sessionFiber.dispose();
14744
- const loaded = await ctx.sessionPersistence.load(SessionId("forked-child"));
14745
- globalExpect(loaded.meta.isSeeded).toBe(true);
14746
- globalExpect(loaded.inheritedEventCount).toBe(3);
14384
+ const handle = await ctx.sessionPersistence.open(SessionId("forked-child"), "read");
14385
+ try {
14386
+ globalExpect(handle.header.isSeeded).toBe(true);
14387
+ globalExpect(handle.inheritedEventCount).toBe(3);
14388
+ } finally {
14389
+ await handle.close();
14390
+ }
14747
14391
  } finally {
14748
14392
  await fiber.dispose();
14749
14393
  await fix.cleanup();
@@ -14753,986 +14397,102 @@ function runCoordinatorContract(name, makeFixture) {
14753
14397
  const fix = await makeFixture();
14754
14398
  const { ctx, fiber } = await freshCtx(fix);
14755
14399
  try {
14756
- let session;
14757
- const sessionFiber = await ctx.plugin(Object.assign((inner) => {
14758
- session = inner.sessions.create(SessionId("delegated-child"), { meta: {
14759
- cwd: WORK,
14760
- parentSession: SessionId("root"),
14761
- delegationDepth: 2
14762
- } });
14763
- }, { inject: ["sessions"] }));
14764
- send(session, oneTurnLog());
14765
- await ctx.parallel("session/flush", session);
14766
- await sessionFiber.dispose();
14767
- const loaded = await ctx.sessionPersistence.load(SessionId("delegated-child"));
14768
- globalExpect(loaded.meta.delegationDepth).toBe(2);
14769
- } finally {
14770
- await fiber.dispose();
14771
- await fix.cleanup();
14772
- }
14773
- });
14774
- it("source-frozen events cannot be mutated after buffering and persist unchanged", async () => {
14775
- const fix = await makeFixture();
14776
- const { ctx, fiber } = await freshCtx(fix);
14777
- try {
14778
- const session = ctx.sessions.create(SessionId("mutate"), { meta: { cwd: WORK } });
14779
- session.append("turn/start", { turn: 1 });
14780
- const ev = session.append("user/message", createUserMessage({
14781
- content: [{
14782
- type: "text",
14783
- text: "original"
14784
- }],
14785
- source: { kind: "user" }
14786
- }), { surfaceOp: "append" });
14787
- globalExpect(() => {
14788
- ev.data.content[0].text = "HACKED";
14789
- }).toThrow(TypeError);
14790
- session.append("turn/end", {
14791
- turn: 1,
14792
- reason: { kind: "completed" }
14793
- });
14794
- await ctx.sessions.flush(session);
14795
- const message = (await ctx.sessionPersistence.load(SessionId("mutate"))).events.find((event) => event.type === "user/message");
14796
- globalExpect(message?.type === "user/message" && message.data.content[0].text).toBe("original");
14797
- } finally {
14798
- await fiber.dispose();
14799
- await fix.cleanup();
14800
- }
14801
- });
14802
- it("load and inspect return immutable identified-message snapshots", async () => {
14803
- const fix = await makeFixture();
14804
- const { ctx, fiber } = await freshCtx(fix);
14805
- try {
14806
- const id = SessionId("immutable-read");
14807
- const session = ctx.sessions.create(id, { meta: { cwd: WORK } });
14808
- send(session, oneTurnLog());
14809
- await ctx.sessions.flush(session);
14810
- for (const snapshot of [await ctx.sessionPersistence.load(id), await ctx.sessionPersistence.inspect(id)]) {
14811
- const event = snapshot.events.find((candidate) => candidate.type === "user/message");
14812
- if (event?.type !== "user/message") throw new Error("fixture lacks user/message");
14813
- globalExpect(Object.isFrozen(event.data)).toBe(true);
14814
- globalExpect(Object.isFrozen(event.data.content)).toBe(true);
14815
- globalExpect(() => {
14816
- event.data.id = "rewritten";
14817
- }).toThrow(TypeError);
14818
- globalExpect(() => {
14819
- event.data.content[0].text = "rewritten";
14820
- }).toThrow(TypeError);
14821
- }
14822
- } finally {
14823
- await fiber.dispose();
14824
- await fix.cleanup();
14825
- }
14826
- });
14827
- it("loads pre-identity message logs into resumable current sessions", async () => {
14828
- const fix = await makeFixture();
14829
- const { ctx, fiber } = await freshCtx(fix);
14830
- try {
14831
- const id = SessionId("legacy-message-load");
14832
- await ctx.sessionPersistence.create(meta(id, WORK));
14833
- await ctx.sessionPersistence.append(id, legacyMessageLog());
14834
- for (const snapshot of [await ctx.sessionPersistence.inspect(id), await ctx.sessionPersistence.load(id)]) {
14835
- const messages = [];
14836
- for (const event of snapshot.events) if (event.type === "user/message") messages.push(event.data);
14837
- else if (event.type === "assistant/message" || event.type === "tool/result") messages.push(event.data.message);
14838
- globalExpect(messages.map((message) => message.id)).toEqual([
14839
- `legacy-message:${id}:1`,
14840
- `legacy-message:${id}:3`,
14841
- `legacy-message:${id}:5`,
14842
- `legacy-message:${id}:5`
14843
- ]);
14844
- globalExpect(messages.every((message) => Object.isFrozen(message))).toBe(true);
14845
- const resumed = Session.create(id, snapshot.events, snapshot.meta, snapshot.inheritedEventCount);
14846
- globalExpect(resumed.deriveMessages().map((message) => message.id)).toEqual([
14847
- `legacy-message:${id}:1`,
14848
- `legacy-message:${id}:3`,
14849
- `legacy-message:${id}:5`
14850
- ]);
14851
- }
14852
- const replacementSuffix = await ctx.sessionPersistence.readFrom(id, SessionLogOffset(6));
14853
- globalExpect(replacementSuffix.events[0]).toMatchObject({
14854
- type: "tool/result",
14855
- seq: SessionSeq(6),
14856
- data: { message: { id: `legacy-message:${id}:5` } }
14857
- });
14858
- } finally {
14859
- await fiber.dispose();
14860
- await fix.cleanup();
14861
- }
14862
- });
14863
- it("loads pre-react-loop session logs into resumable current sessions", async () => {
14864
- const fix = await makeFixture();
14865
- const { ctx, fiber } = await freshCtx(fix);
14866
- try {
14867
- const id = SessionId("pre-react-loop-load");
14868
- const log = preReactLoopLog();
14869
- const legacySteering = log[3];
14870
- await ctx.sessionPersistence.create(meta(id, WORK));
14871
- await ctx.sessionPersistence.append(id, log);
14872
- const snapshots = [
14873
- await ctx.sessionPersistence.inspect(id),
14874
- await ctx.sessionPersistence.readFrom(id, SessionLogOffset(0)),
14875
- await ctx.sessionPersistence.load(id)
14876
- ];
14877
- for (const snapshot of snapshots) {
14878
- globalExpect(snapshot.events.some((event) => event.type === "steering/message")).toBe(false);
14879
- globalExpect(snapshot.events.filter((event) => event.type === "turn/start").map((event) => event.data)).toEqual([
14880
- { turn: 1 },
14881
- { turn: 2 },
14882
- { turn: 3 },
14883
- { turn: 4 },
14884
- { turn: 5 },
14885
- { turn: 6 },
14886
- { turn: 7 }
14887
- ]);
14888
- globalExpect(snapshot.events.filter((event) => event.type === "turn/end").map((event) => event.data)).toEqual([
14889
- {
14890
- turn: 1,
14891
- reason: { kind: "completed" }
14892
- },
14893
- {
14894
- turn: 2,
14895
- reason: {
14896
- kind: "error",
14897
- error: {
14898
- message: "old provider failure",
14899
- code: "SERVER"
14900
- }
14901
- }
14902
- },
14903
- {
14904
- turn: 3,
14905
- reason: {
14906
- kind: "aborted",
14907
- reason: { kind: "legacy" }
14908
- }
14909
- },
14910
- {
14911
- turn: 4,
14912
- reason: {
14913
- kind: "aborted",
14914
- reason: { kind: "disposed" }
14915
- }
14916
- },
14917
- {
14918
- turn: 5,
14919
- reason: {
14920
- kind: "error",
14921
- error: {
14922
- message: "old thrown value",
14923
- code: "UNKNOWN"
14924
- }
14925
- }
14926
- },
14927
- {
14928
- turn: 6,
14929
- reason: {
14930
- kind: "error",
14931
- error: {
14932
- message: "old detailed provider failure",
14933
- code: "RATE_LIMIT",
14934
- status: 429,
14935
- providerRetryAfterMs: 1e3,
14936
- requestId: "request-1"
14937
- }
14938
- }
14939
- },
14940
- {
14941
- turn: 7,
14942
- reason: {
14943
- kind: "error",
14944
- error: {
14945
- message: "old coded error",
14946
- code: "CODED"
14947
- }
14948
- }
14949
- }
14950
- ]);
14951
- const resumed = Session.create(id, snapshot.events, snapshot.meta, snapshot.inheritedEventCount);
14952
- globalExpect(resumed.deriveMessages().map((message) => message.content)).toEqual([[{
14953
- type: "text",
14954
- text: "old prompt"
14955
- }], [{
14956
- type: "text",
14957
- text: "old steering"
14958
- }]]);
14959
- }
14960
- const suffix = await ctx.sessionPersistence.readFrom(id, SessionLogOffset(3));
14961
- globalExpect(suffix.events[0]).toMatchObject({
14962
- type: "user/message",
14963
- seq: SessionSeq(3),
14964
- data: { id: legacySteering.data.message.id }
14965
- });
14966
- globalExpect(suffix.events.filter((event) => event.type === "turn/end").every((event) => !Object.hasOwn(event.data, "step"))).toBe(true);
14967
- const flatId = SessionId("pre-react-loop-flat-steering");
14968
- await ctx.sessionPersistence.create(meta(flatId, WORK));
14969
- await ctx.sessionPersistence.append(flatId, [{
14970
- type: "steering/message",
14971
- seq: SessionSeq(0),
14972
- time: 1,
14973
- data: {
14974
- turn: 1,
14975
- content: [{
14976
- type: "text",
14977
- text: "flat steering"
14978
- }],
14979
- source: { kind: "user" }
14980
- },
14981
- surfaceOp: "append"
14982
- }]);
14983
- globalExpect((await ctx.sessionPersistence.inspect(flatId)).events[0]).toMatchObject({
14984
- type: "user/message",
14985
- data: {
14986
- id: `legacy-message:${flatId}:0`,
14987
- role: "user",
14988
- content: [{
14989
- type: "text",
14990
- text: "flat steering"
14991
- }]
14992
- }
14993
- });
14994
- const extendedId = SessionId("current-extended-turn-end");
14995
- await ctx.sessionPersistence.create(meta(extendedId, WORK));
14996
- await ctx.sessionPersistence.append(extendedId, [{
14997
- type: "turn/start",
14998
- seq: SessionSeq(0),
14999
- time: 1,
15000
- data: { turn: 1 }
15001
- }, {
15002
- type: "turn/end",
15003
- seq: SessionSeq(1),
15004
- time: 2,
15005
- data: {
15006
- turn: 1,
15007
- reason: { kind: "extension-reason" }
15008
- }
15009
- }]);
15010
- globalExpect((await ctx.sessionPersistence.inspect(extendedId)).events[1]).toMatchObject({
15011
- type: "turn/end",
15012
- data: { reason: { kind: "extension-reason" } }
15013
- });
15014
- } finally {
15015
- await fiber.dispose();
15016
- await fix.cleanup();
15017
- }
15018
- });
15019
- it("rejects malformed persisted message events before returning them", async () => {
15020
- const fix = await makeFixture();
15021
- const { ctx, fiber } = await freshCtx(fix);
15022
- try {
15023
- const id = SessionId("invalid-message-read");
15024
- await ctx.sessionPersistence.create(meta(id, WORK));
15025
- await ctx.sessionPersistence.append(id, [{
15026
- type: "user/message",
15027
- seq: SessionSeq(0),
15028
- time: 1,
15029
- surfaceOp: "append",
15030
- data: {
15031
- id: "wrong-role",
15032
- role: "assistant",
15033
- content: [{
15034
- type: "text",
15035
- text: "wrong"
15036
- }],
15037
- source: { kind: "user" }
15038
- }
15039
- }]);
15040
- await globalExpect(ctx.sessionPersistence.inspect(id)).rejects.toThrow("message must have role \"user\"");
15041
- await globalExpect(ctx.sessionPersistence.load(id)).rejects.toThrow("message must have role \"user\"");
15042
- const malformedLegacy = [
15043
- {
15044
- id: "invalid-old-turn-start",
15045
- event: {
15046
- type: "turn/start",
15047
- seq: SessionSeq(0),
15048
- time: 1,
15049
- data: {
15050
- turn: 1,
15051
- trigger: null
15052
- }
15053
- },
15054
- message: "malformed pre-react-loop turn/start"
15055
- },
15056
- {
15057
- id: "invalid-old-steering",
15058
- event: {
15059
- type: "steering/message",
15060
- seq: SessionSeq(0),
15061
- time: 1,
15062
- surfaceOp: "append",
15063
- data: {
15064
- turn: 1,
15065
- content: [],
15066
- source: { kind: "user" },
15067
- extra: true
15068
- }
15069
- },
15070
- message: "malformed pre-react-loop steering/message"
15071
- },
15072
- {
15073
- id: "invalid-old-steering-data",
15074
- event: {
15075
- type: "steering/message",
15076
- seq: SessionSeq(0),
15077
- time: 1,
15078
- surfaceOp: "append",
15079
- data: null
15080
- },
15081
- message: "malformed pre-react-loop steering/message"
15082
- },
15083
- {
15084
- id: "invalid-old-turn-end",
15085
- event: {
15086
- type: "turn/end",
15087
- seq: SessionSeq(0),
15088
- time: 1,
15089
- data: {
15090
- turn: 1,
15091
- reason: {
15092
- kind: "completed",
15093
- extra: true
15094
- }
15095
- }
15096
- },
15097
- message: "malformed pre-react-loop turn/end"
15098
- },
15099
- {
15100
- id: "invalid-old-turn-end-reason",
15101
- event: {
15102
- type: "turn/end",
15103
- seq: SessionSeq(0),
15104
- time: 1,
15105
- data: {
15106
- turn: 1,
15107
- reason: null
15108
- }
15109
- },
15110
- message: "malformed pre-react-loop turn/end"
15111
- },
15112
- {
15113
- id: "unsupported-intermediate-turn-end-step",
15114
- event: {
15115
- type: "turn/end",
15116
- seq: SessionSeq(0),
15117
- time: 1,
15118
- data: {
15119
- turn: 1,
15120
- step: 1,
15121
- reason: { kind: "completed" }
15122
- }
15123
- },
15124
- message: "malformed pre-react-loop turn/end"
15125
- },
15126
- {
15127
- id: "invalid-old-turn-end-aborted",
15128
- event: {
15129
- type: "turn/end",
15130
- seq: SessionSeq(0),
15131
- time: 1,
15132
- data: {
15133
- turn: 1,
15134
- reason: {
15135
- kind: "aborted",
15136
- extra: true
15137
- }
15138
- }
15139
- },
15140
- message: "malformed pre-react-loop turn/end"
15141
- },
15142
- {
15143
- id: "invalid-old-turn-end-disposed",
15144
- event: {
15145
- type: "turn/end",
15146
- seq: SessionSeq(0),
15147
- time: 1,
15148
- data: {
15149
- turn: 1,
15150
- reason: {
15151
- kind: "disposed",
15152
- extra: true
15153
- }
15154
- }
15155
- },
15156
- message: "malformed pre-react-loop turn/end"
15157
- },
15158
- {
15159
- id: "invalid-old-turn-end-error-step",
15160
- event: {
15161
- type: "turn/end",
15162
- seq: SessionSeq(0),
15163
- time: 1,
15164
- data: {
15165
- turn: 1,
15166
- reason: {
15167
- kind: "error",
15168
- step: -1,
15169
- message: "bad step"
15170
- }
15171
- }
15172
- },
15173
- message: "malformed pre-react-loop turn/end"
15174
- },
15175
- {
15176
- id: "invalid-old-turn-end-error-code",
15177
- event: {
15178
- type: "turn/end",
15179
- seq: SessionSeq(0),
15180
- time: 1,
15181
- data: {
15182
- turn: 1,
15183
- reason: {
15184
- kind: "error",
15185
- step: 0,
15186
- message: "bad code",
15187
- code: 1
15188
- }
15189
- }
15190
- },
15191
- message: "malformed pre-react-loop turn/end"
15192
- }
15193
- ];
15194
- for (const malformed of malformedLegacy) {
15195
- const malformedId = SessionId(malformed.id);
15196
- await ctx.sessionPersistence.create(meta(malformedId, WORK));
15197
- await ctx.sessionPersistence.append(malformedId, [malformed.event]);
15198
- await globalExpect(ctx.sessionPersistence.inspect(malformedId)).rejects.toThrow(malformed.message);
15199
- await globalExpect(ctx.sessionPersistence.readFrom(malformedId, SessionLogOffset(0))).rejects.toThrow(malformed.message);
15200
- }
15201
- for (const type of ["tool/result"]) {
15202
- const malformedId = SessionId(`invalid-${type}`);
15203
- await ctx.sessionPersistence.create(meta(malformedId, WORK));
15204
- await ctx.sessionPersistence.append(malformedId, [{
15205
- type,
15206
- seq: SessionSeq(0),
15207
- time: 1,
15208
- surfaceOp: "append",
15209
- data: { message: null }
15210
- }]);
15211
- await globalExpect(ctx.sessionPersistence.inspect(malformedId)).rejects.toThrow("lacks an identified message");
15212
- }
15213
- const pluginId = SessionId("non-object-plugin-event");
15214
- await ctx.sessionPersistence.create(meta(pluginId, WORK));
15215
- await ctx.sessionPersistence.append(pluginId, [{
15216
- type: "plugin/test",
15217
- seq: SessionSeq(0),
15218
- time: 1,
15219
- data: null,
15220
- ignorable: true
15221
- }]);
15222
- await globalExpect(ctx.sessionPersistence.inspect(pluginId)).rejects.toThrow(/not found/);
15223
- await globalExpect(ctx.sessionPersistence.readFrom(pluginId, SessionLogOffset(0))).rejects.toThrow(/not found/);
15224
- const requiredId = SessionId("non-object-required-plugin-event");
15225
- await ctx.sessionPersistence.create(meta(requiredId, WORK));
15226
- await ctx.sessionPersistence.append(requiredId, [{
15227
- type: "plugin/required",
15228
- seq: SessionSeq(0),
15229
- time: 1,
15230
- data: null
15231
- }]);
15232
- await globalExpect(ctx.sessionPersistence.inspect(requiredId)).rejects.toThrow(/unknown to this harness/);
15233
- for (const type of ["user/message", "assistant/message"]) {
15234
- const missingContentId = SessionId(`invalid-${type}-without-content`);
15235
- await ctx.sessionPersistence.create(meta(missingContentId, WORK));
15236
- await ctx.sessionPersistence.append(missingContentId, [{
15237
- type,
15238
- seq: SessionSeq(0),
15239
- time: 1,
15240
- surfaceOp: "append",
15241
- data: {}
15242
- }]);
15243
- await globalExpect(ctx.sessionPersistence.readFrom(missingContentId, SessionLogOffset(0))).rejects.toThrow("lacks an identified message");
15244
- }
15245
- } finally {
15246
- await fiber.dispose();
15247
- await fix.cleanup();
15248
- }
15249
- });
15250
- it("append snapshots the batch: mutating the caller array/events after the call is ignored", async () => {
15251
- const fix = await makeFixture();
15252
- const { ctx, fiber } = await freshCtx(fix);
15253
- try {
15254
- const m = meta("snapshot", WORK);
15255
- await ctx.sessionPersistence.create(m);
15256
- const events = structuredClone(oneTurnLog());
15257
- const userMsg = events[1];
15258
- const p = ctx.sessionPersistence.append(m.id, events);
15259
- events.push({
15260
- type: "turn/start",
15261
- seq: SessionSeq(6),
15262
- time: 99,
15263
- data: { turn: 2 }
15264
- });
15265
- if (userMsg?.type === "user/message") userMsg.data.content = [{
15266
- type: "text",
15267
- text: "MUTATED"
15268
- }];
15269
- await p;
15270
- const loaded = await ctx.sessionPersistence.load(m.id);
15271
- globalExpect(loaded.events.map((e) => e.seq)).toEqual([
15272
- 0,
15273
- 1,
15274
- 2,
15275
- 3,
15276
- 4,
15277
- 5
15278
- ]);
15279
- const persisted = JSON.stringify(loaded.events);
15280
- globalExpect(persisted).toContain("hi");
15281
- globalExpect(persisted).not.toContain("MUTATED");
15282
- } finally {
15283
- await fiber.dispose();
15284
- await fix.cleanup();
15285
- }
15286
- });
15287
- it("fork: a seeded new session persists its seed once (no double-write on a no-op flush)", async () => {
15288
- const fix = await makeFixture();
15289
- const { ctx, fiber } = await freshCtx(fix);
15290
- try {
15291
- const seed = oneTurnLog();
15292
- const forked = ctx.sessions.create(SessionId("forked"), {
15293
- seed,
15294
- meta: { cwd: WORK }
15295
- });
15296
- await ctx.sessions.flush(forked);
15297
- const loaded = await ctx.sessionPersistence.load(SessionId("forked"));
15298
- globalExpect(loaded.events.slice(0, seed.length)).toEqual(seed);
15299
- globalExpect(loaded.events.at(-1)).toMatchObject({
15300
- type: "session/end-seed",
15301
- seq: seed.length
15302
- });
15303
- await ctx.sessions.flush(forked);
15304
- const reloaded = await ctx.sessionPersistence.load(SessionId("forked"));
15305
- globalExpect(reloaded.events).toEqual(loaded.events);
15306
- } finally {
15307
- await fiber.dispose();
15308
- await fix.cleanup();
15309
- }
15310
- });
15311
- it("resume: a re-created session seeded with the loaded log does not re-append its seed and continues the seq", async () => {
15312
- const fix = await makeFixture();
15313
- const first = await freshCtx(fix);
15314
- try {
15315
- const s1 = first.ctx.sessions.create(SessionId("resumed"), { meta: { cwd: WORK } });
15316
- send(s1, oneTurnLog());
15317
- await first.ctx.sessions.flush(s1);
15318
- } finally {
15319
- await first.fiber.dispose();
15320
- }
15321
- const second = await freshCtx(fix);
15322
- try {
15323
- const loaded = await second.ctx.sessionPersistence.load(SessionId("resumed"));
15324
- const s2 = second.ctx.sessions.create(SessionId("resumed"), {
15325
- seed: loaded.events,
15326
- meta: { cwd: WORK }
15327
- });
15328
- await second.ctx.sessions.flush(s2);
15329
- s2.append("turn/start", { turn: 2 });
15330
- s2.append("turn/end", {
15331
- turn: 2,
15332
- reason: { kind: "completed" }
15333
- });
15334
- await second.ctx.sessions.flush(s2);
15335
- const reloaded = await second.ctx.sessionPersistence.load(SessionId("resumed"));
15336
- globalExpect(reloaded.events.map((e) => e.seq)).toEqual([
15337
- 0,
15338
- 1,
15339
- 2,
15340
- 3,
15341
- 4,
15342
- 5,
15343
- 6,
15344
- 7,
15345
- 8
15346
- ]);
15347
- globalExpect(reloaded.events[6]).toMatchObject({ type: "session/end-seed" });
15348
- } finally {
15349
- await second.fiber.dispose();
15350
- await fix.cleanup();
15351
- }
15352
- });
15353
- it("HMR: applying the plugin seeds existing live sessions", async () => {
15354
- const fix = await makeFixture();
15355
- const ctx = new Context();
15356
- await ctx.plugin(SessionStore);
15357
- const session = ctx.sessions.create(SessionId("pre-existing"), { meta: { cwd: WORK } });
15358
- session.append("turn/start", { turn: 1 });
15359
- session.append("user/message", createUserMessage({
15360
- content: [{
15361
- type: "text",
15362
- text: "hi"
15363
- }],
15364
- source: { kind: "user" }
15365
- }), { surfaceOp: "append" });
15366
- session.append("turn/end", {
15367
- turn: 1,
15368
- reason: { kind: "completed" }
15369
- });
15370
- const fiber = await fix.mount(ctx);
15371
- try {
15372
- await ctx.sessions.flush(session);
15373
- const loaded = await ctx.sessionPersistence.load(SessionId("pre-existing"));
15374
- globalExpect(loaded.events.length).toBeGreaterThanOrEqual(2);
15375
- } finally {
15376
- await fiber.dispose();
15377
- await fix.cleanup();
15378
- }
15379
- });
15380
- it("HMR: dispose drains remaining buffers", async () => {
15381
- const fix = await makeFixture();
15382
- const ctx = new Context();
15383
- await ctx.plugin(SessionStore);
15384
- const fiber = await fix.mount(ctx);
15385
- const session = await liveSessionInFiber(ctx, "drain", WORK);
15386
- session.append("turn/start", { turn: 1 });
15387
- session.append("user/message", createUserMessage({
15388
- content: [{
15389
- type: "text",
15390
- text: "buffered"
15391
- }],
15392
- source: { kind: "user" }
15393
- }), { surfaceOp: "append" });
15394
- session.append("turn/end", {
15395
- turn: 1,
15396
- reason: { kind: "completed" }
15397
- });
15398
- await fiber.dispose();
15399
- const second = await freshCtx(fix);
15400
- try {
15401
- const loaded = await second.ctx.sessionPersistence.load(SessionId("drain"));
15402
- globalExpect(loaded.events.length).toBeGreaterThanOrEqual(2);
15403
- } finally {
15404
- await second.fiber.dispose();
15405
- await fix.cleanup();
15406
- }
15407
- });
15408
- it("HMR: reloading the backend adopts a still-live, already-materialized session", async () => {
15409
- const fix = await makeFixture();
15410
- const ctx = new Context();
15411
- await ctx.plugin(SessionStore);
15412
- const session = await liveSessionInFiber(ctx, "hmr-adopt", WORK);
15413
- try {
15414
- const backend1 = await fix.mount(ctx);
15415
- session.append("turn/start", { turn: 1 });
15416
- session.append("user/message", createUserMessage({
15417
- content: [{
15418
- type: "text",
15419
- text: "hi"
15420
- }],
15421
- source: { kind: "user" }
15422
- }), { surfaceOp: "append" });
15423
- session.append("turn/end", {
15424
- turn: 1,
15425
- reason: { kind: "completed" }
15426
- });
15427
- await ctx.sessions.flush(session);
15428
- await backend1.dispose();
15429
- await fix.mount(ctx);
15430
- session.append("turn/start", { turn: 2 });
15431
- session.append("user/message", createUserMessage({
15432
- content: [{
15433
- type: "text",
15434
- text: "again"
15435
- }],
15436
- source: { kind: "user" }
15437
- }), { surfaceOp: "append" });
15438
- session.append("turn/end", {
15439
- turn: 2,
15440
- reason: { kind: "completed" }
15441
- });
15442
- await globalExpect(ctx.sessions.flush(session)).resolves.not.toThrow();
15443
- const loaded = await ctx.sessionPersistence.load(SessionId("hmr-adopt"));
15444
- globalExpect(loaded.events.filter((e) => e.type === "turn/start")).toHaveLength(2);
15445
- } finally {
15446
- await ctx.fiber.dispose();
15447
- await fix.cleanup();
15448
- }
15449
- });
15450
- it("HMR: adoption persists the live SUFFIX that was ahead of the stored prefix", async () => {
15451
- const fix = await makeFixture();
15452
- const ctx = new Context();
15453
- await ctx.plugin(SessionStore);
15454
- const session = await liveSessionInFiber(ctx, "hmr-suffix", WORK);
15455
- try {
15456
- const backend1 = await fix.mount(ctx);
15457
- session.append("turn/start", { turn: 1 });
15458
- session.append("turn/end", {
15459
- turn: 1,
15460
- reason: { kind: "completed" }
15461
- });
15462
- await ctx.sessions.flush(session);
15463
- await backend1.dispose();
15464
- session.append("turn/start", { turn: 2 });
15465
- session.append("turn/end", {
15466
- turn: 2,
15467
- reason: { kind: "completed" }
15468
- });
15469
- await fix.mount(ctx);
15470
- await ctx.sessions.flush(session);
15471
- const loaded = await ctx.sessionPersistence.load(SessionId("hmr-suffix"));
15472
- globalExpect(loaded.events.map((e) => e.seq)).toEqual([
15473
- 0,
15474
- 1,
15475
- 2,
15476
- 3
15477
- ]);
15478
- globalExpect(loaded.events.filter((e) => e.type === "turn/start")).toHaveLength(2);
15479
- } finally {
15480
- await ctx.fiber.dispose();
15481
- await fix.cleanup();
15482
- }
15483
- });
15484
- it("HMR adoption does NOT crash-repair an active open turn as interrupted (truncate without closers)", async () => {
15485
- const fix = await makeFixture();
15486
- const ctx = new Context();
15487
- await ctx.plugin(SessionStore);
15488
- const session = await liveSessionInFiber(ctx, "hmr-open", WORK);
15489
- try {
15490
- const first = await fix.mount(ctx);
15491
- session.append("turn/start", { turn: 1 });
15492
- session.append("step/start", {
15493
- turn: 1,
15494
- step: 1
15495
- });
15496
- await ctx.sessions.flush(session);
15497
- await first.dispose();
15498
- if (fix.corruptTail) await fix.corruptTail(SessionId("hmr-open"), WORK);
15499
- const second = await fix.mount(ctx);
15500
- session.append("step/end", {
15501
- turn: 1,
15502
- step: 1
15503
- });
15504
- session.append("turn/end", {
15505
- turn: 1,
15506
- reason: { kind: "completed" }
15507
- });
15508
- await ctx.sessions.flush(session);
15509
- const loaded = await ctx.sessionPersistence.load(SessionId("hmr-open"));
15510
- globalExpect(loaded.events.map((e) => e.type)).toEqual([
15511
- "turn/start",
15512
- "step/start",
15513
- "step/end",
15514
- "turn/end"
15515
- ]);
15516
- globalExpect(loaded.events.at(-1)).toMatchObject({
15517
- type: "turn/end",
15518
- data: { reason: { kind: "completed" } }
15519
- });
15520
- await second.dispose();
15521
- } finally {
15522
- await ctx.fiber.dispose();
15523
- await fix.cleanup();
15524
- }
15525
- });
15526
- it("a NEW live session colliding on a persisted id is rejected, not silently adopted", async () => {
15527
- const fix = await makeFixture();
15528
- const first = await freshCtx(fix);
15529
- try {
15530
- const s1 = first.ctx.sessions.create(SessionId("collide"), { meta: { cwd: WORK } });
15531
- send(s1, oneTurnLog());
15532
- await first.ctx.sessions.flush(s1);
15533
- } finally {
15534
- await first.fiber.dispose();
15535
- }
15536
- const second = await freshCtx(fix);
15537
- try {
15538
- const s2 = second.ctx.sessions.create(SessionId("collide"), { meta: { cwd: WORK } });
15539
- s2.append("turn/start", { turn: 1 });
15540
- await globalExpect(second.ctx.sessions.flush(s2)).rejects.toThrow(/already has a persisted log|id collision/);
15541
- } finally {
15542
- await second.fiber.dispose();
15543
- await fix.cleanup();
15544
- }
15545
- });
15546
- it("an abandoned lazy session (never materialized) releases its id for reuse", async () => {
15547
- const fix = await makeFixture();
15548
- const { ctx, fiber } = await freshCtx(fix);
15549
- try {
15550
- let firstSession;
15551
- const firstFiber = await ctx.plugin(Object.assign((inner) => {
15552
- firstSession = inner.sessions.create(SessionId("abandoned"), { meta: { cwd: WORK } });
15553
- }, { inject: ["sessions"] }));
15554
- await ctx.sessions.flush(firstSession);
15555
- await firstFiber.dispose();
15556
- let reuse;
15557
- await ctx.plugin(Object.assign((inner) => {
15558
- reuse = inner.sessions.create(SessionId("abandoned"), { meta: { cwd: WORK } });
15559
- }, { inject: ["sessions"] }));
15560
- await globalExpect(ctx.sessions.flush(reuse)).resolves.toBe(true);
15561
- reuse.append("turn/start", { turn: 1 });
15562
- reuse.append("turn/end", {
15563
- turn: 1,
15564
- reason: { kind: "completed" }
15565
- });
15566
- await ctx.sessions.flush(reuse);
15567
- const loaded = await ctx.sessionPersistence.load(SessionId("abandoned"));
15568
- globalExpect(loaded.events.map((e) => e.seq)).toEqual([0, 1]);
15569
- } finally {
15570
- await fiber.dispose();
15571
- await fix.cleanup();
15572
- }
15573
- });
15574
- it("session disposal drains buffered events before retiring ownership", async () => {
15575
- const fix = await makeFixture();
15576
- const { ctx, fiber } = await freshCtx(fix);
15577
- try {
15578
- let first;
15579
- const firstFiber = await ctx.plugin(Object.assign((inner) => {
15580
- first = inner.sessions.create(SessionId("buffered"), { meta: { cwd: WORK } });
15581
- }, { inject: ["sessions"] }));
15582
- await ctx.sessions.flush(first);
15583
- first.append("turn/start", { turn: 1 });
15584
- first.append("turn/end", {
15585
- turn: 1,
15586
- reason: { kind: "completed" }
15587
- });
15588
- await firstFiber.dispose();
15589
- await vi.waitFor(async () => {
15590
- globalExpect((await ctx.sessionPersistence.list()).map((meta) => meta.id)).toContain(SessionId("buffered"));
15591
- });
15592
- globalExpect((await ctx.sessionPersistence.load(SessionId("buffered"))).events.map((event) => event.seq)).toEqual([0, 1]);
15593
- let reuse;
15594
- await ctx.plugin(Object.assign((inner) => {
15595
- reuse = inner.sessions.create(SessionId("buffered"), { meta: { cwd: WORK } });
14400
+ let session;
14401
+ const sessionFiber = await ctx.plugin(Object.assign((inner) => {
14402
+ session = inner.sessions.create(SessionId("delegated-child"), { meta: {
14403
+ cwd: WORK,
14404
+ parentSession: SessionId("root"),
14405
+ delegationDepth: 2
14406
+ } });
15596
14407
  }, { inject: ["sessions"] }));
15597
- await globalExpect(ctx.sessions.flush(reuse)).rejects.toThrow(/persisted log|id collision/);
14408
+ send(session, oneTurnLog());
14409
+ await ctx.parallel("session/flush", session);
14410
+ await sessionFiber.dispose();
14411
+ const loaded = await readAll(ctx, SessionId("delegated-child"));
14412
+ globalExpect(loaded.meta.delegationDepth).toBe(2);
15598
14413
  } finally {
15599
14414
  await fiber.dispose();
15600
14415
  await fix.cleanup();
15601
14416
  }
15602
14417
  });
15603
- it("initFor is idempotent: re-emitting session/created does not re-initialize", async () => {
14418
+ it("source-frozen events cannot be mutated after buffering and persist unchanged", async () => {
15604
14419
  const fix = await makeFixture();
15605
14420
  const { ctx, fiber } = await freshCtx(fix);
15606
14421
  try {
15607
- const session = ctx.sessions.create(SessionId("idem"), { meta: { cwd: WORK } });
14422
+ const session = ctx.sessions.create(SessionId("mutate"), { meta: { cwd: WORK } });
15608
14423
  session.append("turn/start", { turn: 1 });
15609
- session.append("user/message", createUserMessage({
14424
+ const ev = session.append("user/message", createUserMessage({
15610
14425
  content: [{
15611
14426
  type: "text",
15612
- text: "x"
14427
+ text: "original"
15613
14428
  }],
15614
14429
  source: { kind: "user" }
15615
14430
  }), { surfaceOp: "append" });
14431
+ globalExpect(() => {
14432
+ ev.data.content[0].text = "HACKED";
14433
+ }).toThrow(TypeError);
15616
14434
  session.append("turn/end", {
15617
14435
  turn: 1,
15618
14436
  reason: { kind: "completed" }
15619
14437
  });
15620
14438
  await ctx.sessions.flush(session);
15621
- ctx.emit(scopeTarget(session, void 0), "session/created", session);
15622
- await ctx.sessions.flush(session);
15623
- const loaded = await ctx.sessionPersistence.load(SessionId("idem"));
15624
- globalExpect(loaded.events).toHaveLength(3);
14439
+ const message = (await readAll(ctx, SessionId("mutate"))).events.find((event) => event.type === "user/message");
14440
+ globalExpect(message?.type === "user/message" && message.data.content[0].text).toBe("original");
15625
14441
  } finally {
15626
14442
  await fiber.dispose();
15627
14443
  await fix.cleanup();
15628
14444
  }
15629
14445
  });
15630
- it("a live session claims cursor-0 ownerless state created via the public API and persists its seed", async () => {
14446
+ it("fork: a seeded new session persists its seed once (no double-write on a no-op flush)", async () => {
15631
14447
  const fix = await makeFixture();
15632
14448
  const { ctx, fiber } = await freshCtx(fix);
15633
14449
  try {
15634
- await ctx.sessionPersistence.create(meta("lazy-claim", WORK));
15635
- const live = ctx.sessions.create(SessionId("lazy-claim"), {
15636
- seed: oneTurnLog(),
14450
+ const seed = oneTurnLog();
14451
+ const forked = ctx.sessions.create(SessionId("forked"), {
14452
+ seed,
15637
14453
  meta: { cwd: WORK }
15638
14454
  });
15639
- await globalExpect(ctx.sessions.flush(live)).resolves.toBe(true);
15640
- const loaded = await ctx.sessionPersistence.load(SessionId("lazy-claim"));
15641
- globalExpect(loaded.events.map((e) => e.seq)).toEqual([
15642
- 0,
15643
- 1,
15644
- 2,
15645
- 3,
15646
- 4,
15647
- 5,
15648
- 6
15649
- ]);
15650
- globalExpect(loaded.events.at(-1)).toMatchObject({ type: "session/end-seed" });
14455
+ await ctx.sessions.flush(forked);
14456
+ const loaded = await readAll(ctx, SessionId("forked"));
14457
+ globalExpect(loaded.events.slice(0, seed.length)).toEqual(seed);
14458
+ globalExpect(loaded.events.at(-1)).toMatchObject({
14459
+ type: "session/end-seed",
14460
+ seq: seed.length
14461
+ });
14462
+ await ctx.sessions.flush(forked);
14463
+ const reloaded = await readAll(ctx, SessionId("forked"));
14464
+ globalExpect(reloaded.events).toEqual(loaded.events);
15651
14465
  } finally {
15652
14466
  await fiber.dispose();
15653
14467
  await fix.cleanup();
15654
14468
  }
15655
14469
  });
15656
- it("a fresh session reusing a previously-loaded id is rejected (ownerless guard)", async () => {
14470
+ it("resume: a re-created session seeded with the loaded log does not re-append its seed and continues the seq", async () => {
15657
14471
  const fix = await makeFixture();
15658
- const { ctx, fiber } = await freshCtx(fix);
14472
+ const first = await freshCtx(fix);
15659
14473
  try {
15660
- await ctx.sessionPersistence.create(meta("preview", WORK));
15661
- await ctx.sessionPersistence.append(SessionId("preview"), oneTurnLog());
15662
- await ctx.sessionPersistence.load(SessionId("preview"));
15663
- let fresh;
15664
- await ctx.plugin(Object.assign((inner) => {
15665
- fresh = inner.sessions.create(SessionId("preview"), { meta: { cwd: WORK } });
15666
- }, { inject: ["sessions"] }));
15667
- await globalExpect(ctx.sessions.flush(fresh)).rejects.toThrow(/do not match this live session|already has a persisted log|id collision/);
14474
+ const s1 = first.ctx.sessions.create(SessionId("resumed"), { meta: { cwd: WORK } });
14475
+ send(s1, oneTurnLog());
14476
+ await first.ctx.sessions.flush(s1);
15668
14477
  } finally {
15669
- await fiber.dispose();
15670
- await fix.cleanup();
14478
+ await first.fiber.dispose();
15671
14479
  }
15672
- });
15673
- it("a live session whose complete seed matches loaded ownerless state claims it without appending", async () => {
15674
- const fix = await makeFixture();
15675
- const { ctx, fiber } = await freshCtx(fix);
14480
+ const second = await freshCtx(fix);
15676
14481
  try {
15677
- const id = SessionId("claim-exact");
15678
- const completeSeed = [...oneTurnLog(), {
15679
- type: "session/end-seed",
15680
- seq: SessionSeq(6),
15681
- time: 7,
15682
- data: {}
15683
- }];
15684
- await ctx.sessionPersistence.create(meta(id, WORK));
15685
- await ctx.sessionPersistence.append(id, completeSeed);
15686
- const { events } = await ctx.sessionPersistence.load(id);
15687
- const live = ctx.sessions.create(id, {
15688
- seed: events,
14482
+ const loaded = await readAll(second.ctx, SessionId("resumed"));
14483
+ const s2 = second.ctx.sessions.create(SessionId("resumed"), {
14484
+ seed: loaded.events,
15689
14485
  meta: { cwd: WORK }
15690
14486
  });
15691
- await globalExpect(ctx.sessions.flush(live)).resolves.toBe(true);
15692
- globalExpect((await ctx.sessionPersistence.load(id)).events).toEqual(events);
15693
- } finally {
15694
- await fiber.dispose();
15695
- await fix.cleanup();
15696
- }
15697
- });
15698
- it("a live session whose seed matches the loaded prefix claims ownerless state and persists the suffix", async () => {
15699
- const fix = await makeFixture();
15700
- const { ctx, fiber } = await freshCtx(fix);
15701
- try {
15702
- const storedMeta = meta("claim", WORK);
15703
- await ctx.sessionPersistence.create(storedMeta);
15704
- await ctx.sessionPersistence.append(SessionId("claim"), oneTurnLog());
15705
- const { events, meta: durableMeta } = await ctx.sessionPersistence.load(SessionId("claim"));
15706
- let cont;
15707
- const contFiber = await ctx.plugin(Object.assign((inner) => {
15708
- cont = inner.sessions.create(SessionId("claim"), {
15709
- seed: [
15710
- ...events,
15711
- {
15712
- type: "turn/start",
15713
- seq: SessionSeq(6),
15714
- time: 7,
15715
- data: { turn: 2 }
15716
- },
15717
- {
15718
- type: "turn/end",
15719
- seq: SessionSeq(7),
15720
- time: 8,
15721
- data: {
15722
- turn: 2,
15723
- reason: { kind: "completed" }
15724
- }
15725
- }
15726
- ],
15727
- meta: {
15728
- cwd: WORK,
15729
- createdAt: 2e3
15730
- }
15731
- });
15732
- }, { inject: ["sessions"] }));
15733
- await ctx.sessions.flush(cont);
15734
- const loaded = await ctx.sessionPersistence.load(SessionId("claim"));
15735
- globalExpect(loaded.events.map((e) => e.seq)).toEqual([
14487
+ await second.ctx.sessions.flush(s2);
14488
+ s2.append("turn/start", { turn: 2 });
14489
+ s2.append("turn/end", {
14490
+ turn: 2,
14491
+ reason: { kind: "completed" }
14492
+ });
14493
+ await second.ctx.sessions.flush(s2);
14494
+ const reloaded = await readAll(second.ctx, SessionId("resumed"));
14495
+ globalExpect(reloaded.events.map((e) => e.seq)).toEqual([
15736
14496
  0,
15737
14497
  1,
15738
14498
  2,
@@ -15743,60 +14503,111 @@ function runCoordinatorContract(name, makeFixture) {
15743
14503
  7,
15744
14504
  8
15745
14505
  ]);
15746
- globalExpect(loaded.events.at(-1)).toMatchObject({ type: "session/end-seed" });
15747
- globalExpect(loaded.meta).toEqual(durableMeta);
15748
- globalExpect(loaded.meta.createdAt).toBe(1e3);
15749
- await contFiber.dispose();
15750
- await vi.waitFor(async () => {
15751
- globalExpect((await ctx.sessionPersistence.load(SessionId("claim"))).meta).toEqual(durableMeta);
15752
- });
14506
+ globalExpect(reloaded.events[6]).toMatchObject({ type: "session/end-seed" });
15753
14507
  } finally {
15754
- await fiber.dispose();
14508
+ await second.fiber.dispose();
15755
14509
  await fix.cleanup();
15756
14510
  }
15757
14511
  });
15758
- it("a live session at a DIFFERENT cwd cannot claim cursor-0 ownerless state (cwd scope)", async () => {
14512
+ it("HMR: applying the plugin seeds existing live sessions", async () => {
15759
14513
  const fix = await makeFixture();
15760
- const { ctx, fiber } = await freshCtx(fix);
14514
+ const ctx = new Context();
14515
+ await ctx.plugin(SessionStore);
14516
+ const session = ctx.sessions.create(SessionId("pre-existing"), { meta: { cwd: WORK } });
14517
+ session.append("turn/start", { turn: 1 });
14518
+ session.append("user/message", createUserMessage({
14519
+ content: [{
14520
+ type: "text",
14521
+ text: "hi"
14522
+ }],
14523
+ source: { kind: "user" }
14524
+ }), { surfaceOp: "append" });
14525
+ session.append("turn/end", {
14526
+ turn: 1,
14527
+ reason: { kind: "completed" }
14528
+ });
14529
+ const fiber = await fix.mount(ctx);
15761
14530
  try {
15762
- await ctx.sessionPersistence.create(meta("wrong-cwd-claim", OTHER));
15763
- const live = ctx.sessions.create(SessionId("wrong-cwd-claim"), {
15764
- seed: oneTurnLog(),
15765
- meta: { cwd: WORK }
15766
- });
15767
- await globalExpect(ctx.sessions.flush(live)).rejects.toThrow(/different cwd|id collision/);
14531
+ await ctx.sessions.flush(session);
14532
+ const loaded = await readAll(ctx, SessionId("pre-existing"));
14533
+ globalExpect(loaded.events.length).toBeGreaterThanOrEqual(2);
15768
14534
  } finally {
15769
14535
  await fiber.dispose();
15770
14536
  await fix.cleanup();
15771
14537
  }
15772
14538
  });
15773
- it("a live session at a DIFFERENT cwd cannot claim loaded-prefix ownerless state (cwd scope)", async () => {
14539
+ it("HMR: dispose drains remaining buffers", async () => {
14540
+ const fix = await makeFixture();
14541
+ const ctx = new Context();
14542
+ await ctx.plugin(SessionStore);
14543
+ const fiber = await fix.mount(ctx);
14544
+ const session = ctx.sessions.create(SessionId("drain"), { meta: { cwd: WORK } });
14545
+ session.append("turn/start", { turn: 1 });
14546
+ session.append("user/message", createUserMessage({
14547
+ content: [{
14548
+ type: "text",
14549
+ text: "buffered"
14550
+ }],
14551
+ source: { kind: "user" }
14552
+ }), { surfaceOp: "append" });
14553
+ session.append("turn/end", {
14554
+ turn: 1,
14555
+ reason: { kind: "completed" }
14556
+ });
14557
+ await fiber.dispose();
14558
+ const second = await freshCtx(fix);
14559
+ try {
14560
+ const loaded = await readAll(second.ctx, SessionId("drain"));
14561
+ globalExpect(loaded.events.length).toBeGreaterThanOrEqual(2);
14562
+ } finally {
14563
+ await second.fiber.dispose();
14564
+ await fix.cleanup();
14565
+ }
14566
+ });
14567
+ it("a NEW live session colliding on a persisted id is rejected, not silently adopted", async () => {
15774
14568
  const fix = await makeFixture();
15775
14569
  const { ctx, fiber } = await freshCtx(fix);
15776
14570
  try {
15777
- await ctx.sessionPersistence.create(meta("wrong-cwd-load", OTHER));
15778
- await ctx.sessionPersistence.append(SessionId("wrong-cwd-load"), oneTurnLog());
15779
- const { events } = await ctx.sessionPersistence.load(SessionId("wrong-cwd-load"));
15780
- const live = ctx.sessions.create(SessionId("wrong-cwd-load"), {
15781
- seed: events,
15782
- meta: { cwd: WORK }
15783
- });
15784
- await globalExpect(ctx.sessions.flush(live)).rejects.toThrow(/different cwd|id collision/);
14571
+ let first;
14572
+ const firstFiber = await ctx.plugin(Object.assign((inner) => {
14573
+ first = inner.sessions.create(SessionId("collide"), { meta: { cwd: WORK } });
14574
+ }, { inject: ["sessions"] }));
14575
+ send(first, oneTurnLog());
14576
+ await ctx.sessions.flush(first);
14577
+ await firstFiber.dispose();
14578
+ let second;
14579
+ await ctx.plugin(Object.assign((inner) => {
14580
+ second = inner.sessions.create(SessionId("collide"), { meta: { cwd: "/other" } });
14581
+ }, { inject: ["sessions"] }));
14582
+ await globalExpect(ctx.sessions.flush(second)).rejects.toThrow(/different cwd/);
15785
14583
  } finally {
15786
14584
  await fiber.dispose();
15787
14585
  await fix.cleanup();
15788
14586
  }
15789
14587
  });
15790
- it("a no-cwd ownerless state cannot be claimed by a live session WITH a cwd (cwd scope, undefined side)", async () => {
14588
+ it("an abandoned lazy session (never materialized) releases its id for reuse", async () => {
15791
14589
  const fix = await makeFixture();
15792
14590
  const { ctx, fiber } = await freshCtx(fix);
15793
14591
  try {
15794
- await ctx.sessionPersistence.create(meta("no-cwd-state"));
15795
- const live = ctx.sessions.create(SessionId("no-cwd-state"), {
15796
- seed: oneTurnLog(),
15797
- meta: { cwd: WORK }
14592
+ let firstSession;
14593
+ const firstFiber = await ctx.plugin(Object.assign((inner) => {
14594
+ firstSession = inner.sessions.create(SessionId("abandoned"), { meta: { cwd: WORK } });
14595
+ }, { inject: ["sessions"] }));
14596
+ await ctx.sessions.flush(firstSession);
14597
+ await firstFiber.dispose();
14598
+ let reuse;
14599
+ await ctx.plugin(Object.assign((inner) => {
14600
+ reuse = inner.sessions.create(SessionId("abandoned"), { meta: { cwd: WORK } });
14601
+ }, { inject: ["sessions"] }));
14602
+ await globalExpect(ctx.sessions.flush(reuse)).resolves.toBe(true);
14603
+ reuse.append("turn/start", { turn: 1 });
14604
+ reuse.append("turn/end", {
14605
+ turn: 1,
14606
+ reason: { kind: "completed" }
15798
14607
  });
15799
- await globalExpect(ctx.sessions.flush(live)).rejects.toThrow(/different cwd|id collision/);
14608
+ await ctx.sessions.flush(reuse);
14609
+ const loaded = await readAll(ctx, SessionId("abandoned"));
14610
+ globalExpect(loaded.events.map((e) => e.seq)).toEqual([0, 1]);
15800
14611
  } finally {
15801
14612
  await fiber.dispose();
15802
14613
  await fix.cleanup();
@@ -15804,17 +14615,14 @@ function runCoordinatorContract(name, makeFixture) {
15804
14615
  });
15805
14616
  it("append adopts a storage-only session (fresh instance) and continues the seq", async () => {
15806
14617
  const fix = await makeFixture();
15807
- const first = await freshCtx(fix);
15808
- try {
15809
- const m = meta("adopt-append", WORK);
15810
- await first.ctx.sessionPersistence.create(m);
15811
- await first.ctx.sessionPersistence.append(m.id, oneTurnLog());
15812
- } finally {
15813
- await first.fiber.dispose();
15814
- }
15815
- const second = await freshCtx(fix);
14618
+ const { ctx, fiber } = await freshCtx(fix);
15816
14619
  try {
15817
- await second.ctx.sessionPersistence.append(SessionId("adopt-append"), [{
14620
+ const m = meta("storage-only", WORK);
14621
+ const handle = await ctx.sessionPersistence.create(m);
14622
+ await handle.append(oneTurnLog());
14623
+ await handle.close();
14624
+ const writer = await ctx.sessionPersistence.open(SessionId("storage-only"), "write");
14625
+ await writer.append([{
15818
14626
  type: "turn/start",
15819
14627
  seq: SessionSeq(6),
15820
14628
  time: 7,
@@ -15828,7 +14636,8 @@ function runCoordinatorContract(name, makeFixture) {
15828
14636
  reason: { kind: "completed" }
15829
14637
  }
15830
14638
  }]);
15831
- const loaded = await second.ctx.sessionPersistence.load(SessionId("adopt-append"));
14639
+ await writer.close();
14640
+ const loaded = await readAll(ctx, SessionId("storage-only"));
15832
14641
  globalExpect(loaded.events.map((e) => e.seq)).toEqual([
15833
14642
  0,
15834
14643
  1,
@@ -15839,68 +14648,21 @@ function runCoordinatorContract(name, makeFixture) {
15839
14648
  6,
15840
14649
  7
15841
14650
  ]);
15842
- } finally {
15843
- await second.fiber.dispose();
15844
- await fix.cleanup();
15845
- }
15846
- });
15847
- it("append of an empty batch is a no-op (stays lazy)", async () => {
15848
- const fix = await makeFixture();
15849
- const { ctx, fiber } = await freshCtx(fix);
15850
- try {
15851
- const m = meta("empty-batch", WORK);
15852
- await ctx.sessionPersistence.create(m);
15853
- await ctx.sessionPersistence.append(m.id, []);
15854
- globalExpect((await ctx.sessionPersistence.list()).map((h) => h.id)).not.toContain(m.id);
15855
- } finally {
15856
- await fiber.dispose();
15857
- await fix.cleanup();
15858
- }
15859
- });
15860
- it("load and inspect reject a missing session", async () => {
15861
- const fix = await makeFixture();
15862
- const { ctx, fiber } = await freshCtx(fix);
15863
- try {
15864
- await globalExpect(ctx.sessionPersistence.load(SessionId("nope"))).rejects.toThrow(/not found/);
15865
- await globalExpect(ctx.sessionPersistence.inspect(SessionId("nope"))).rejects.toThrow(/not found/);
15866
14651
  } finally {
15867
14652
  await fiber.dispose();
15868
14653
  await fix.cleanup();
15869
14654
  }
15870
14655
  });
15871
14656
  it("create rejects a duplicate id (in memory and on a persisted log)", async () => {
15872
- const fix = await makeFixture();
15873
- const first = await freshCtx(fix);
15874
- try {
15875
- const m = meta("dup", WORK);
15876
- await first.ctx.sessionPersistence.create(m);
15877
- await globalExpect(first.ctx.sessionPersistence.create(m)).rejects.toThrow(/already exists in this backend/);
15878
- await first.ctx.sessionPersistence.append(m.id, oneTurnLog());
15879
- } finally {
15880
- await first.fiber.dispose();
15881
- }
15882
- const second = await freshCtx(fix);
15883
- try {
15884
- await globalExpect(second.ctx.sessionPersistence.create(meta("dup", WORK))).rejects.toThrow(/already has a persisted log on disk/);
15885
- } finally {
15886
- await second.fiber.dispose();
15887
- await fix.cleanup();
15888
- }
15889
- });
15890
- it("rejects an unknown format version on load (assertVersion)", async () => {
15891
14657
  const fix = await makeFixture();
15892
14658
  const { ctx, fiber } = await freshCtx(fix);
15893
14659
  try {
15894
- const m = {
15895
- version: 99,
15896
- id: SessionId("v99"),
15897
- createdAt: 1,
15898
- cwd: WORK,
15899
- isSeeded: false
15900
- };
15901
- await ctx.sessionPersistence.create(m);
15902
- await ctx.sessionPersistence.append(m.id, oneTurnLog());
15903
- await globalExpect(ctx.sessionPersistence.load(m.id)).rejects.toThrow(/log format/);
14660
+ const m = meta("dup-id", WORK);
14661
+ const first = await ctx.sessionPersistence.create(m);
14662
+ await globalExpect(ctx.sessionPersistence.create(m)).rejects.toThrow(/already exists/);
14663
+ await first.append(oneTurnLog());
14664
+ await first.close();
14665
+ await globalExpect(ctx.sessionPersistence.create(m)).rejects.toThrow(/already exists/);
15904
14666
  } finally {
15905
14667
  await fiber.dispose();
15906
14668
  await fix.cleanup();
@@ -15910,123 +14672,49 @@ function runCoordinatorContract(name, makeFixture) {
15910
14672
  const fix = await makeFixture();
15911
14673
  const { ctx, fiber } = await freshCtx(fix);
15912
14674
  try {
15913
- const m = {
15914
- version: SESSION_FORMAT_VERSION,
15915
- id: SessionId("forked-child"),
15916
- createdAt: 1,
15917
- cwd: WORK,
15918
- parentSession: SessionId("the-parent"),
15919
- isSeeded: false
15920
- };
15921
- await ctx.sessionPersistence.create(m);
15922
- await ctx.sessionPersistence.append(m.id, oneTurnLog());
15923
- const loaded = await ctx.sessionPersistence.load(m.id);
15924
- globalExpect(loaded.meta.parentSession).toBe("the-parent");
15925
- } finally {
15926
- await fiber.dispose();
15927
- await fix.cleanup();
15928
- }
15929
- });
15930
- it("flush before init resolves uses cursor 0", async () => {
15931
- const fix = await makeFixture();
15932
- const { ctx, fiber } = await freshCtx(fix);
15933
- try {
15934
- const session = ctx.sessions.create(SessionId("flush-nostate"), { meta: { cwd: WORK } });
15935
- session.append("turn/start", { turn: 1 });
15936
- session.append("user/message", createUserMessage({
15937
- content: [{
15938
- type: "text",
15939
- text: "q"
15940
- }],
15941
- source: { kind: "user" }
15942
- }), { surfaceOp: "append" });
15943
- session.append("turn/end", {
15944
- turn: 1,
15945
- reason: { kind: "completed" }
14675
+ const m = meta("lineage", WORK);
14676
+ const handle = await ctx.sessionPersistence.create({
14677
+ ...m,
14678
+ parentSession: SessionId("parent")
15946
14679
  });
15947
- await ctx.sessions.flush(session);
15948
- const loaded = await ctx.sessionPersistence.load(SessionId("flush-nostate"));
15949
- globalExpect(loaded.events).toHaveLength(3);
14680
+ await handle.append(oneTurnLog());
14681
+ await handle.close();
14682
+ const loaded = await readAll(ctx, SessionId("lineage"));
14683
+ globalExpect(loaded.meta.parentSession).toBe(SessionId("parent"));
15950
14684
  } finally {
15951
14685
  await fiber.dispose();
15952
14686
  await fix.cleanup();
15953
14687
  }
15954
14688
  });
15955
- it("torn-tail load: a never-committed tail is truncated and the open turn closed during load (commitRepair w/ tornMarker)", async () => {
14689
+ it("torn-tail: a never-committed tail is truncated by the write path", async () => {
15956
14690
  const fix = await makeFixture();
15957
- if (!fix.corruptTail) {
15958
- globalExpect(fix.corruptTail).toBeUndefined();
15959
- await fix.cleanup();
15960
- return;
15961
- }
15962
- const first = await freshCtx(fix);
14691
+ if (fix.corruptTail === void 0) return;
14692
+ const { ctx, fiber } = await freshCtx(fix);
15963
14693
  try {
15964
14694
  const m = meta("torn", WORK);
15965
- await first.ctx.sessionPersistence.create(m);
15966
- await first.ctx.sessionPersistence.append(m.id, oneTurnLog());
15967
- await first.ctx.sessionPersistence.append(m.id, [{
14695
+ const creator = await ctx.sessionPersistence.create(m);
14696
+ await creator.append(oneTurnLog());
14697
+ await creator.close();
14698
+ await fix.corruptTail(SessionId("torn"), WORK);
14699
+ const reader = await ctx.sessionPersistence.open(SessionId("torn"), "read");
14700
+ globalExpect((await reader.read()).events).toEqual(oneTurnLog());
14701
+ await reader.close();
14702
+ const writer = await ctx.sessionPersistence.open(SessionId("torn"), "write");
14703
+ await writer.append([{
15968
14704
  type: "turn/start",
15969
14705
  seq: SessionSeq(6),
15970
14706
  time: 7,
15971
14707
  data: { turn: 2 }
15972
14708
  }, {
15973
- type: "step/start",
14709
+ type: "turn/end",
15974
14710
  seq: SessionSeq(7),
15975
14711
  time: 8,
15976
14712
  data: {
15977
14713
  turn: 2,
15978
- step: 1
15979
- }
15980
- }]);
15981
- } finally {
15982
- await first.fiber.dispose();
15983
- }
15984
- await fix.corruptTail(SessionId("torn"), WORK);
15985
- const second = await freshCtx(fix);
15986
- try {
15987
- const loaded = await second.ctx.sessionPersistence.load(SessionId("torn"));
15988
- globalExpect(loaded.events.map((e) => e.seq)).toEqual([
15989
- 0,
15990
- 1,
15991
- 2,
15992
- 3,
15993
- 4,
15994
- 5,
15995
- 6,
15996
- 7,
15997
- 8,
15998
- 9
15999
- ]);
16000
- globalExpect(loaded.events.map((e) => e.type)).toEqual([
16001
- "turn/start",
16002
- "user/message",
16003
- "step/start",
16004
- "assistant/message",
16005
- "step/end",
16006
- "turn/end",
16007
- "turn/start",
16008
- "step/start",
16009
- "step/end",
16010
- "turn/end"
16011
- ]);
16012
- const last = loaded.events.at(-1);
16013
- globalExpect(last.type === "turn/end" && last.data.reason).toEqual({ kind: "interrupted" });
16014
- await second.ctx.sessionPersistence.append(SessionId("torn"), [{
16015
- type: "turn/start",
16016
- seq: SessionSeq(10),
16017
- time: 9,
16018
- data: { turn: 3 }
16019
- }, {
16020
- type: "turn/end",
16021
- seq: SessionSeq(11),
16022
- time: 10,
16023
- data: {
16024
- turn: 3,
16025
14714
  reason: { kind: "completed" }
16026
14715
  }
16027
14716
  }]);
16028
- const reloaded = await second.ctx.sessionPersistence.load(SessionId("torn"));
16029
- globalExpect(reloaded.events.map((e) => e.seq)).toEqual([
14717
+ globalExpect((await writer.read()).events.map((e) => e.seq)).toEqual([
16030
14718
  0,
16031
14719
  1,
16032
14720
  2,
@@ -16034,14 +14722,11 @@ function runCoordinatorContract(name, makeFixture) {
16034
14722
  4,
16035
14723
  5,
16036
14724
  6,
16037
- 7,
16038
- 8,
16039
- 9,
16040
- 10,
16041
- 11
14725
+ 7
16042
14726
  ]);
14727
+ await writer.close();
16043
14728
  } finally {
16044
- await second.fiber.dispose();
14729
+ await fiber.dispose();
16045
14730
  await fix.cleanup();
16046
14731
  }
16047
14732
  });