@cursor/july 0.1.45 → 0.1.47

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 (149) hide show
  1. package/AGENTS.md +4 -2
  2. package/README.md +3 -1
  3. package/dist/channels/github/api.d.ts +25 -0
  4. package/dist/channels/github/api.d.ts.map +1 -1
  5. package/dist/channels/github/api.js +48 -0
  6. package/dist/channels/github/cursor-account.d.ts +7 -0
  7. package/dist/channels/github/cursor-account.d.ts.map +1 -1
  8. package/dist/channels/github/cursor-account.js +18 -0
  9. package/dist/channels/github/defaults.d.ts +1 -1
  10. package/dist/channels/github/defaults.js +1 -1
  11. package/dist/channels/github/github-channel.d.ts.map +1 -1
  12. package/dist/channels/github/github-channel.js +2 -2
  13. package/dist/channels/github/progress.d.ts +6 -5
  14. package/dist/channels/github/progress.d.ts.map +1 -1
  15. package/dist/channels/github/progress.js +58 -16
  16. package/dist/channels/github/state.d.ts +11 -0
  17. package/dist/channels/github/state.d.ts.map +1 -1
  18. package/dist/channels/github/state.js +1 -1
  19. package/dist/channels/github/types.d.ts +3 -3
  20. package/dist/docs/404.html +2 -2
  21. package/dist/docs/ab.html +3 -3
  22. package/dist/docs/assets/{app.fHnNXEJ6.js → app.IEXEim5m.js} +1 -1
  23. package/dist/docs/assets/chunks/@localSearchIndexroot.D7w61u6Y.js +1 -0
  24. package/dist/docs/assets/chunks/{VPLocalSearchBox.Cf6ghfFY.js → VPLocalSearchBox.78s6nqsH.js} +1 -1
  25. package/dist/docs/assets/chunks/{theme.B_5-VLe8.js → theme.DroGdz_Q.js} +2 -2
  26. package/dist/docs/assets/{guides_github.md.DSg0j9qj.js → guides_github.md.B8QKtIpw.js} +7 -6
  27. package/dist/docs/assets/{guides_github.md.DSg0j9qj.lean.js → guides_github.md.B8QKtIpw.lean.js} +1 -1
  28. package/dist/docs/assets/{reference_agent-config.md.XRpxctsA.js → reference_agent-config.md.DHkkvcEZ.js} +4 -4
  29. package/dist/docs/assets/{reference_agent-config.md.XRpxctsA.lean.js → reference_agent-config.md.DHkkvcEZ.lean.js} +1 -1
  30. package/dist/docs/assets/{reference_hooks.md.4cbRRIIu.js → reference_hooks.md.DJE5DXcT.js} +1 -1
  31. package/dist/docs/assets/{storage.md.DcGSnIxX.js → storage.md.CJYd5YmN.js} +2 -2
  32. package/dist/docs/assets/{storage.md.DcGSnIxX.lean.js → storage.md.CJYd5YmN.lean.js} +1 -1
  33. package/dist/docs/building-with-agents.html +3 -3
  34. package/dist/docs/concepts.html +3 -3
  35. package/dist/docs/deployment.html +3 -3
  36. package/dist/docs/evals.html +3 -3
  37. package/dist/docs/example-agents/approval-buddy.html +3 -3
  38. package/dist/docs/example-agents/benny.html +3 -3
  39. package/dist/docs/example-agents/bugbot.html +3 -3
  40. package/dist/docs/example-agents/codebase-wiki.html +3 -3
  41. package/dist/docs/example-agents/codeowners-review.html +3 -3
  42. package/dist/docs/example-agents/concierge.html +3 -3
  43. package/dist/docs/example-agents/fsd.html +3 -3
  44. package/dist/docs/example-agents/index.html +3 -3
  45. package/dist/docs/example-agents/knowledge-base.html +3 -3
  46. package/dist/docs/example-agents/oncall.html +3 -3
  47. package/dist/docs/example-agents/security-reviewer.html +3 -3
  48. package/dist/docs/example-agents/slack-agent.html +3 -3
  49. package/dist/docs/example-agents/weather-agent.html +3 -3
  50. package/dist/docs/guides/agent-to-agent.html +3 -3
  51. package/dist/docs/guides/cloud-runtime.html +3 -3
  52. package/dist/docs/guides/github.html +10 -9
  53. package/dist/docs/guides/human-in-the-loop.html +3 -3
  54. package/dist/docs/guides/mcp-oauth.html +3 -3
  55. package/dist/docs/guides/opentelemetry.html +3 -3
  56. package/dist/docs/guides/slack.html +3 -3
  57. package/dist/docs/guides/webhooks.html +3 -3
  58. package/dist/docs/hashmap.json +1 -1
  59. package/dist/docs/hillclimbing.html +3 -3
  60. package/dist/docs/index.html +3 -3
  61. package/dist/docs/quickstart.html +3 -3
  62. package/dist/docs/reference/agent-config.html +6 -6
  63. package/dist/docs/reference/artifacts.html +3 -3
  64. package/dist/docs/reference/channels.html +3 -3
  65. package/dist/docs/reference/cli.html +3 -3
  66. package/dist/docs/reference/connections.html +3 -3
  67. package/dist/docs/reference/hooks.html +5 -5
  68. package/dist/docs/reference/http-api.html +3 -3
  69. package/dist/docs/reference/instructions.html +3 -3
  70. package/dist/docs/reference/playground.html +3 -3
  71. package/dist/docs/reference/project-layout.html +3 -3
  72. package/dist/docs/reference/prompt.html +3 -3
  73. package/dist/docs/reference/schedules.html +3 -3
  74. package/dist/docs/reference/sessions.html +3 -3
  75. package/dist/docs/reference/skills.html +3 -3
  76. package/dist/docs/reference/subagents.html +3 -3
  77. package/dist/docs/reference/tools.html +3 -3
  78. package/dist/docs/scaffolding-agents.html +3 -3
  79. package/dist/docs/storage.html +5 -5
  80. package/dist/docs/templates/triage.html +3 -3
  81. package/dist/docs/troubleshooting.html +3 -3
  82. package/dist/index.d.ts +1 -1
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.js +1 -1
  85. package/dist/internal/auth.d.ts +4 -0
  86. package/dist/internal/auth.d.ts.map +1 -1
  87. package/dist/internal/auth.js +7 -0
  88. package/dist/internal/durable-session-store.d.ts +35 -23
  89. package/dist/internal/durable-session-store.d.ts.map +1 -1
  90. package/dist/internal/durable-session-store.js +71 -26
  91. package/dist/internal/ledger-serving.d.ts +135 -67
  92. package/dist/internal/ledger-serving.d.ts.map +1 -1
  93. package/dist/internal/ledger-serving.js +326 -107
  94. package/dist/internal/sdk-runner.d.ts +21 -0
  95. package/dist/internal/sdk-runner.d.ts.map +1 -1
  96. package/dist/internal/sdk-runner.js +34 -1
  97. package/dist/internal/server.d.ts.map +1 -1
  98. package/dist/internal/server.js +9 -4
  99. package/dist/internal/session-engine.d.ts +18 -17
  100. package/dist/internal/session-engine.d.ts.map +1 -1
  101. package/dist/internal/session-engine.js +60 -27
  102. package/dist/kv/memory-kv.d.ts +8 -0
  103. package/dist/kv/memory-kv.d.ts.map +1 -1
  104. package/dist/kv/memory-kv.js +8 -0
  105. package/dist/ledger/janitor.d.ts +60 -0
  106. package/dist/ledger/janitor.d.ts.map +1 -0
  107. package/dist/ledger/janitor.js +137 -0
  108. package/dist/ledger/session-ledger.d.ts +132 -43
  109. package/dist/ledger/session-ledger.d.ts.map +1 -1
  110. package/dist/ledger/session-ledger.js +282 -50
  111. package/dist/ledger.d.ts +9 -8
  112. package/dist/ledger.d.ts.map +1 -1
  113. package/dist/ledger.js +7 -7
  114. package/dist/playground/assets/{index-Dces8BDp.js → index-BgUHfe3L.js} +32 -32
  115. package/dist/playground/assets/index-uFaeqVZS.css +1 -0
  116. package/dist/playground/index.html +2 -2
  117. package/dist/storage-backends/cursor-hosted.d.ts +5 -0
  118. package/dist/storage-backends/cursor-hosted.d.ts.map +1 -1
  119. package/dist/storage-backends/cursor-hosted.js +6 -0
  120. package/dist/types.d.ts +19 -8
  121. package/dist/types.d.ts.map +1 -1
  122. package/docs/guides/github.md +23 -18
  123. package/docs/reference/agent-config.md +31 -36
  124. package/docs/reference/hooks.md +1 -1
  125. package/docs/storage.md +26 -9
  126. package/package.json +1 -1
  127. package/src/channels/github/api.ts +122 -0
  128. package/src/channels/github/cursor-account.ts +23 -0
  129. package/src/channels/github/defaults.ts +1 -1
  130. package/src/channels/github/github-channel.ts +5 -1
  131. package/src/channels/github/progress.ts +84 -19
  132. package/src/channels/github/state.ts +14 -0
  133. package/src/channels/github/types.ts +3 -3
  134. package/src/index.ts +1 -0
  135. package/src/internal/auth.ts +10 -0
  136. package/src/internal/durable-session-store.ts +66 -27
  137. package/src/internal/ledger-serving.ts +439 -148
  138. package/src/internal/sdk-runner.ts +46 -1
  139. package/src/internal/server.ts +17 -3
  140. package/src/internal/session-engine.ts +95 -37
  141. package/src/kv/memory-kv.ts +8 -0
  142. package/src/ledger/janitor.ts +193 -0
  143. package/src/ledger/session-ledger.ts +372 -68
  144. package/src/ledger.ts +21 -6
  145. package/src/storage-backends/cursor-hosted.ts +17 -2
  146. package/src/types.ts +19 -8
  147. package/dist/docs/assets/chunks/@localSearchIndexroot.BMT0xM4I.js +0 -1
  148. package/dist/playground/assets/index-CZH9EFvO.css +0 -1
  149. /package/dist/docs/assets/{reference_hooks.md.4cbRRIIu.lean.js → reference_hooks.md.DJE5DXcT.lean.js} +0 -0
@@ -1,12 +1,13 @@
1
1
  /**
2
- * Session ledger — coordination only (no inbox).
2
+ * Session ledger — KV coordination with a durable inbox.
3
3
  *
4
- * Two key families, driven with CAS over a {@link SessionLedgerKv};
5
- * agents never touch these keys directly:
4
+ * Three key families, all driven with `get` + CAS on a
5
+ * {@link SessionLedgerKv}; agents never touch these keys directly:
6
6
  *
7
7
  * ```
8
- * session/<continuationKey> identity, created with put-if-absent
9
- * ledger/<sessionId> head + lease — every mutation one CAS
8
+ * session/<continuationKey> identity, created with put-if-absent
9
+ * ledger/<sessionId> head + pending + lease — every mutation one CAS
10
+ * mailbox/<sessionId>/<eventId> immutable payload blobs, plain put
10
11
  * ```
11
12
  *
12
13
  * The keys are deliberately bare: namespacing (per agent, per
@@ -15,21 +16,21 @@
15
16
  * where it constructs the handle (see `durable-session-store.ts`), the
16
17
  * same boundary where `host.kv` gets its agent scoping.
17
18
  *
18
- * This is a coordination ledger: session identity, one writer per
19
- * session (leases with generation fencing), and a fenced,
20
- * ambiguity-safe commit sequence (`head`). It deliberately has **no
21
- * durable inbox** no pending list, no payload blobs, no append/drain,
22
- * no janitor. An event that arrives while the lease is held waits
23
- * *in-process* on the delivering pod; a crash loses that in-flight
24
- * event exactly as resident serving does. What ledger mode adds over
25
- * resident is cross-pod mutual exclusion, zombie fencing, and the
26
- * committed head sequence. A dead pod needs no janitor: its lease
27
- * simply expires (~30s) and the next delivery claims at generation+1.
19
+ * The guarantee: **exactly-once ledger apply, at-least-once actuation.**
20
+ * A commit is one CAS carrying the read version, the expected `head`, and
21
+ * the holder's `generation` one committed transition per turn, atomic by
22
+ * construction. What the CAS cannot fence (a zombie's plain `host.kv`
23
+ * writes, external tool calls) is a stated bound, not this module's job.
28
24
  *
29
- * The commit is one CAS carrying the read version, the expected `head`,
30
- * and the holder's `generation` one committed transition per turn,
31
- * atomic by construction. Commit always releases the lease: with no
32
- * pending list there is nothing to keep it for.
25
+ * The inbox makes events durable before their turn runs: `appendEvent`
26
+ * writes the payload blob, then one CAS indexes the eventId into
27
+ * `pending` that CAS is the ack point. A delivery that finds the lease
28
+ * held walks away; the holder's commit sees the appended id in the same
29
+ * CAS value, keeps the lease, and drains again. Sessions whose holder
30
+ * died with events pending are recovered by the janitor
31
+ * (`./janitor.js`), which is also why the store must implement
32
+ * `listKeys`: the inbox brings the prefix scans (janitor + orphan
33
+ * sweep) that the coordination-only ledger did not need.
33
34
  *
34
35
  * Clocks: lease expiry compares `expiresAt` against {@link
35
36
  * SessionLedgerOptions.clock} (default `Date.now`). Self-hosted N-pod
@@ -46,26 +47,36 @@ import type { HostKvCasApi, JsonObject, JsonValue } from "../types.js";
46
47
  // acquisition.
47
48
  export const LEASE_TTL_MS = 30_000;
48
49
  export const HEARTBEAT_INTERVAL_MS = 10_000;
50
+ /** Unreferenced mailbox blobs older than this are sweepable. */
51
+ export const DEFAULT_ORPHAN_TTL_MS: number = 60 * 60_000;
49
52
 
50
- const DEFAULT_MAX_CAS_ATTEMPTS = 16;
53
+ // Sized against the two-pod simulation running at this default (no
54
+ // override): 64 exhausts appendEvent under a hot session's worst-case
55
+ // interleaving, 128 passes with margin. Exhaustion mid-lease stalls the
56
+ // session for a lease TTL, so the budget errs generous — the backoff
57
+ // keeps late attempts cheap.
58
+ const DEFAULT_MAX_CAS_ATTEMPTS = 128;
51
59
 
52
60
  /**
53
- * Exactly the KV surface the coordination-only ledger uses. `listKeys`
54
- * is deliberately absent: nothing here scans, so a store must not be
55
- * rejected for lacking it.
61
+ * The KV surface the ledger uses — the full CAS-capable contract. The
62
+ * coordination half needs only `getWithVersion` / `putIfAbsent` /
63
+ * `putIfVersion`; the inbox adds plain `get`/`put`/`delete` for mailbox
64
+ * payload blobs and `listKeys` for the janitor scan and the orphan
65
+ * sweep.
56
66
  */
57
- export type SessionLedgerKv = Pick<
58
- HostKvCasApi,
59
- "getWithVersion" | "putIfAbsent" | "putIfVersion"
60
- >;
67
+ export type SessionLedgerKv = HostKvCasApi;
61
68
 
62
69
  /** Runtime probe for {@link SessionLedgerKv} on stores of unknown shape. */
63
70
  export function supportsSessionLedgerKv(kv: object): kv is SessionLedgerKv {
64
71
  const candidate = kv as Partial<SessionLedgerKv>;
65
72
  return (
73
+ typeof candidate.get === "function" &&
74
+ typeof candidate.put === "function" &&
75
+ typeof candidate.delete === "function" &&
66
76
  typeof candidate.getWithVersion === "function" &&
67
77
  typeof candidate.putIfAbsent === "function" &&
68
- typeof candidate.putIfVersion === "function"
78
+ typeof candidate.putIfVersion === "function" &&
79
+ typeof candidate.listKeys === "function"
69
80
  );
70
81
  }
71
82
 
@@ -83,38 +94,68 @@ export interface LedgerLease {
83
94
  }
84
95
 
85
96
  /**
86
- * `ledger/<sessionId>` value: `{ sessionId, head, lease, generation,
87
- * lastCommitId }`. `sessionId` lets a future scan address sessions
88
- * without inverting digested key segments; `generation` is a monotonic
89
- * acquisition counter that must survive lease release, or a zombie of a
90
- * cleanly released lease could reuse its generation; `lastCommitId` is
91
- * the ambiguous-commit recovery token.
97
+ * `ledger/<sessionId>` value: `{ sessionId, head, pending, lease,
98
+ * generation, lastCommitId }`. `sessionId` lets the janitor's prefix
99
+ * scan address sessions without inverting digested key segments;
100
+ * `generation` is a monotonic acquisition counter that must survive
101
+ * lease release, or a zombie of a cleanly released lease could reuse
102
+ * its generation; `lastCommitId` is the ambiguous-commit recovery
103
+ * token.
92
104
  */
93
105
  export interface LedgerSnapshot {
94
106
  sessionId: string;
95
107
  head: number;
108
+ pending: string[];
96
109
  lease: LedgerLease | null;
97
110
  generation: number;
98
111
  lastCommitId: string | null;
99
112
  }
100
113
 
114
+ /** One event handed to {@link SessionLedger.appendEvent}. */
115
+ export interface LedgerEventInput {
116
+ eventId: string;
117
+ envelope: JsonValue;
118
+ hookContext?: JsonValue;
119
+ /** Source-assigned timestamp used for drain ordering. */
120
+ sourceTs: number;
121
+ }
122
+
123
+ /** One drained mailbox event. */
124
+ export interface LedgerEvent {
125
+ eventId: string;
126
+ envelope: JsonValue;
127
+ hookContext: JsonValue | null;
128
+ sourceTs: number;
129
+ /** Pod-clock write time; drives the orphan sweep TTL. */
130
+ storedAt: number;
131
+ }
132
+
101
133
  export type EnsureSessionResult = {
102
134
  created: boolean;
103
135
  session: SessionIdentity;
104
136
  };
105
137
 
106
138
  export type ClaimResult =
107
- | { claimed: true; lease: LedgerLease; head: number }
139
+ | { claimed: true; lease: LedgerLease; head: number; pending: string[] }
108
140
  | { claimed: false; reason: "held"; holder: string; expiresAt: number };
109
141
 
110
142
  export type HeartbeatResult =
111
143
  | { ok: true; expiresAt: number }
112
144
  | { ok: false; reason: "not-found" | "fenced" };
113
145
 
146
+ export interface DrainResult {
147
+ /** `head` at read time — pass as `expectedHead` to the matching commit. */
148
+ head: number;
149
+ /** Pending events sorted by `sourceTs`, then `eventId`. */
150
+ events: LedgerEvent[];
151
+ }
152
+
114
153
  export interface CommitArgs {
154
+ /** Event ids this turn actually consumed. Only these leave `pending`. */
155
+ drainedEventIds: string[];
115
156
  /** The holder's lease generation (fences zombies). */
116
157
  generation: number;
117
- /** `head` observed at claim time (fences concurrent turns). */
158
+ /** `head` observed at drain time (fences concurrent turns). */
118
159
  expectedHead: number;
119
160
  /** Unique per commit attempt; enables ambiguous-outcome recovery. */
120
161
  commitId: string;
@@ -122,18 +163,30 @@ export interface CommitArgs {
122
163
 
123
164
  export interface CommitResult {
124
165
  head: number;
166
+ /** Events that arrived mid-turn; non-empty ⇒ the lease was kept. */
167
+ pending: string[];
125
168
  /**
126
169
  * Whether this holder's lease is gone — for the caller's purposes, not
127
170
  * the record's: `true` also when the re-read of an ambiguous commit
128
171
  * finds a *new claimant's* lease, since we no longer hold the session
129
172
  * either way and continuing the turn loop would duplicate the new
130
- * holder's work. On this coordination-only stage every commit
131
- * releases, so this is always `true`; it exists so callers already
132
- * branch on it before the inbox makes keep-the-lease outcomes real.
173
+ * holder's work.
133
174
  */
134
175
  leaseReleased: boolean;
135
176
  }
136
177
 
178
+ export interface SweepOrphansArgs {
179
+ /** Limit the sweep to one session (default: all mailbox blobs). */
180
+ sessionId?: string;
181
+ /** Age gate (default {@link DEFAULT_ORPHAN_TTL_MS}). */
182
+ olderThanMs?: number;
183
+ }
184
+
185
+ export interface SweepOrphansResult {
186
+ /** Deleted blob ids as `<sessionId>/<eventId>`. */
187
+ deleted: string[];
188
+ }
189
+
137
190
  /**
138
191
  * The turn was fenced: a stale generation, a moved head, or a missing
139
192
  * ledger. The caller must abort the turn and discard warm state.
@@ -160,7 +213,7 @@ export interface SessionLedgerOptions {
160
213
  podId: string;
161
214
  /** Lease TTL (default {@link LEASE_TTL_MS}). */
162
215
  leaseTtlMs?: number;
163
- /** Time source for lease expiry (default `Date.now`). */
216
+ /** Time source for lease expiry and blob ages (default `Date.now`). */
164
217
  clock?: () => number;
165
218
  /** Max attempts per CAS loop (default 16). */
166
219
  maxCasAttempts?: number;
@@ -175,7 +228,7 @@ export interface SessionLedgerOptions {
175
228
 
176
229
  async function defaultBackoff(attempt: number): Promise<void> {
177
230
  // The common case is a single conflicting writer (one heartbeat or one
178
- // claim); retrying immediately wins the next round. Sleep only when
231
+ // append); retrying immediately wins the next round. Sleep only when
179
232
  // contention persists.
180
233
  if (attempt < 2) {
181
234
  return;
@@ -185,7 +238,7 @@ async function defaultBackoff(attempt: number): Promise<void> {
185
238
  await new Promise((resolve) => setTimeout(resolve, waitMs));
186
239
  }
187
240
 
188
- /** Drives the `session/` and `ledger/` key families over a CAS KV. */
241
+ /** Drives the three ledger key families over a CAS-capable KV. */
189
242
  export class SessionLedger {
190
243
  private readonly kv: SessionLedgerKv;
191
244
  private readonly podId: string;
@@ -213,9 +266,29 @@ export class SessionLedger {
213
266
  return `ledger/${keySegment(sessionId)}`;
214
267
  }
215
268
 
269
+ private mailboxKey(sessionId: string, eventId: string): string {
270
+ return `${this.mailboxPrefix(sessionId)}${keySegment(eventId)}`;
271
+ }
272
+
273
+ private mailboxPrefix(sessionId: string): string {
274
+ return `mailbox/${keySegment(sessionId)}/`;
275
+ }
276
+
277
+ private emptyLedger(sessionId: string): LedgerSnapshot {
278
+ return {
279
+ sessionId,
280
+ head: 0,
281
+ pending: [],
282
+ lease: null,
283
+ generation: 0,
284
+ lastCommitId: null,
285
+ };
286
+ }
287
+
216
288
  /**
217
289
  * Create-or-read the session identity for a continuation key. The create
218
- * race has one winner (put-if-absent); losers read the winner's identity.
290
+ * race has one winner (put-if-absent); losers read the winner's identity
291
+ * and append there.
219
292
  */
220
293
  async ensureSession(
221
294
  continuationKey: string,
@@ -252,25 +325,82 @@ export class SessionLedger {
252
325
  throw new LedgerCasExhaustedError("ensureSession", this.maxCasAttempts);
253
326
  }
254
327
 
328
+ /**
329
+ * Make one event durable: payload blob first, then one CAS indexing its
330
+ * id into `pending`. The blob must land before the index — a pending id
331
+ * with no payload would break drain, while the reverse (blob without
332
+ * index, i.e. a crash between the two writes) is a sweepable orphan.
333
+ * Idempotent by `eventId`: `pending` is a set, so redelivery re-runs are
334
+ * no-ops. The CAS in step two is the ack point — callers ack the source
335
+ * only after this resolves.
336
+ */
337
+ async appendEvent(
338
+ sessionId: string,
339
+ event: LedgerEventInput
340
+ ): Promise<{ appended: boolean }> {
341
+ const blob: JsonObject = {
342
+ sessionId,
343
+ eventId: event.eventId,
344
+ envelope: event.envelope,
345
+ hookContext: event.hookContext ?? null,
346
+ sourceTs: event.sourceTs,
347
+ storedAt: this.clock(),
348
+ };
349
+ await this.kv.put(this.mailboxKey(sessionId, event.eventId), blob);
350
+
351
+ const key = this.ledgerKey(sessionId);
352
+ for (let attempt = 0; attempt < this.maxCasAttempts; attempt++) {
353
+ const entry = await this.kv.getWithVersion(key);
354
+ if (entry === undefined) {
355
+ const initial: LedgerSnapshot = {
356
+ ...this.emptyLedger(sessionId),
357
+ pending: [event.eventId],
358
+ };
359
+ const result = await this.kv.putIfAbsent(key, toJson(initial));
360
+ if (result.ok) {
361
+ return { appended: true };
362
+ }
363
+ await this.backoff(attempt);
364
+ continue;
365
+ }
366
+ const ledger = parseLedgerSnapshot(entry.value);
367
+ if (ledger.pending.includes(event.eventId)) {
368
+ return { appended: false };
369
+ }
370
+ const next: LedgerSnapshot = {
371
+ ...ledger,
372
+ pending: [...ledger.pending, event.eventId],
373
+ };
374
+ const result = await this.kv.putIfVersion(
375
+ key,
376
+ toJson(next),
377
+ entry.version
378
+ );
379
+ if (result.ok) {
380
+ return { appended: true };
381
+ }
382
+ await this.backoff(attempt);
383
+ }
384
+ throw new LedgerCasExhaustedError("appendEvent", this.maxCasAttempts);
385
+ }
386
+
255
387
  /**
256
388
  * Try to take the session lease, creating the ledger record on first
257
389
  * contact. Succeeds only when the lease is null or expired; the CAS
258
390
  * bumps the acquisition generation, fencing every prior holder. A
259
- * losing caller gets `reason: "held"` and decides whether to wait.
391
+ * losing caller walks away the holder's commit sees any appended
392
+ * events in the same CAS value, and the janitor recovers sessions
393
+ * whose holder died.
260
394
  */
261
395
  async claim(sessionId: string): Promise<ClaimResult> {
262
396
  const key = this.ledgerKey(sessionId);
263
397
  for (let attempt = 0; attempt < this.maxCasAttempts; attempt++) {
264
398
  const entry = await this.kv.getWithVersion(key);
265
399
  if (entry === undefined) {
266
- const initial: LedgerSnapshot = {
267
- sessionId,
268
- head: 0,
269
- lease: null,
270
- generation: 0,
271
- lastCommitId: null,
272
- };
273
- const result = await this.kv.putIfAbsent(key, toJson(initial));
400
+ const result = await this.kv.putIfAbsent(
401
+ key,
402
+ toJson(this.emptyLedger(sessionId))
403
+ );
274
404
  // Won or lost, the record now exists — loop back and claim it.
275
405
  if (!result.ok) {
276
406
  await this.backoff(attempt);
@@ -303,7 +433,7 @@ export class SessionLedger {
303
433
  entry.version
304
434
  );
305
435
  if (result.ok) {
306
- return { claimed: true, lease, head: next.head };
436
+ return { claimed: true, lease, head: next.head, pending: next.pending };
307
437
  }
308
438
  await this.backoff(attempt);
309
439
  }
@@ -346,16 +476,47 @@ export class SessionLedger {
346
476
  if (result.ok) {
347
477
  return { ok: true, expiresAt };
348
478
  }
349
- // Version conflict here is a concurrent writer, not a lost lease —
479
+ // Version conflict here is a concurrent append, not a lost lease —
350
480
  // the re-read revalidates the generation before trying again.
351
481
  await this.backoff(attempt);
352
482
  }
353
483
  throw new LedgerCasExhaustedError("heartbeat", this.maxCasAttempts);
354
484
  }
355
485
 
486
+ /**
487
+ * Read the pending mailbox: fetch each indexed payload blob and return
488
+ * them sorted by `sourceTs`, then `eventId` (mailbox order ≠ source
489
+ * order is a stated non-guarantee; this is the drain order). The
490
+ * returned `head` is the `expectedHead` for the matching commit.
491
+ */
492
+ async drain(sessionId: string): Promise<DrainResult> {
493
+ const entry = await this.kv.getWithVersion(this.ledgerKey(sessionId));
494
+ if (entry === undefined) {
495
+ return { head: 0, events: [] };
496
+ }
497
+ const ledger = parseLedgerSnapshot(entry.value);
498
+ const events: LedgerEvent[] = [];
499
+ for (const eventId of ledger.pending) {
500
+ const blob = await this.kv.get(this.mailboxKey(sessionId, eventId));
501
+ // Append order (blob before index) makes a missing blob impossible;
502
+ // skip defensively rather than wedge the session on corruption.
503
+ if (blob === undefined) {
504
+ continue;
505
+ }
506
+ events.push(parseLedgerEvent(blob));
507
+ }
508
+ events.sort(
509
+ (a, b) => a.sourceTs - b.sourceTs || compareStrings(a.eventId, b.eventId)
510
+ );
511
+ return { head: ledger.head, events };
512
+ }
513
+
356
514
  /**
357
515
  * Commit one turn. The single CAS is the commit point: it bumps `head`,
358
- * releases the lease, and records `commitId` atomically.
516
+ * removes ONLY the drained ids from `pending`, releases the lease iff
517
+ * nothing is left pending, and records `commitId` — atomically. A
518
+ * non-empty result `pending` means events landed mid-turn: the lease was
519
+ * kept and the caller drains again.
359
520
  *
360
521
  * Ambiguous outcomes (the KV threw and we never saw the CAS result) are
361
522
  * resolved by re-reading, never by blind-retrying: `head` advanced to
@@ -365,6 +526,7 @@ export class SessionLedger {
365
526
  */
366
527
  async commit(sessionId: string, args: CommitArgs): Promise<CommitResult> {
367
528
  const key = this.ledgerKey(sessionId);
529
+ const drained = new Set(args.drainedEventIds);
368
530
  for (let attempt = 0; attempt < this.maxCasAttempts; attempt++) {
369
531
  const entry = await this.kv.getWithVersion(key);
370
532
  if (entry === undefined) {
@@ -379,15 +541,19 @@ export class SessionLedger {
379
541
  ) {
380
542
  // A previous attempt landed even though its result was lost. The
381
543
  // lease is released *for us* unless the observed lease is exactly
382
- // ours: a non-null lease here belongs to a claimant who took the
383
- // session after our releasing write, and reporting it as "still
384
- // held" would keep the caller's turn loop alive alongside the new
385
- // holder's.
544
+ // ours (our commit kept it for a non-empty pending): any other
545
+ // non-null lease belongs to a claimant who took the session after
546
+ // our releasing write, and reporting it as "still held" would
547
+ // keep the caller's turn loop alive alongside the new holder's.
386
548
  const stillOurs =
387
549
  ledger.lease !== null &&
388
550
  ledger.lease.holder === this.podId &&
389
551
  ledger.lease.generation === args.generation;
390
- return { head: ledger.head, leaseReleased: !stillOurs };
552
+ return {
553
+ head: ledger.head,
554
+ pending: ledger.pending,
555
+ leaseReleased: !stillOurs,
556
+ };
391
557
  }
392
558
  if (ledger.head !== args.expectedHead) {
393
559
  throw new LedgerFencedError(
@@ -403,10 +569,13 @@ export class SessionLedger {
403
569
  `commit(${sessionId}): lease generation ${args.generation} is stale`
404
570
  );
405
571
  }
572
+ const pending = ledger.pending.filter((id) => !drained.has(id));
573
+ const leaseReleased = pending.length === 0;
406
574
  const next: LedgerSnapshot = {
407
575
  ...ledger,
408
576
  head: args.expectedHead + 1,
409
- lease: null,
577
+ pending,
578
+ lease: leaseReleased ? null : ledger.lease,
410
579
  lastCommitId: args.commitId,
411
580
  };
412
581
  let result: Awaited<ReturnType<SessionLedgerKv["putIfVersion"]>>;
@@ -419,20 +588,21 @@ export class SessionLedger {
419
588
  continue;
420
589
  }
421
590
  if (result.ok) {
422
- return { head: next.head, leaseReleased: true };
591
+ return { head: next.head, pending, leaseReleased };
423
592
  }
424
- // Version conflict: a concurrent writer moved the record. Re-read
425
- // and retry — the fencing predicates re-run against the fresh value.
593
+ // Version conflict: a concurrent append. Re-read and retry — the
594
+ // fencing predicates re-run against the fresh value.
426
595
  await this.backoff(attempt);
427
596
  }
428
597
  throw new LedgerCasExhaustedError("commit", this.maxCasAttempts);
429
598
  }
430
599
 
431
600
  /**
432
- * Release a held lease without committing — graceful shutdown, or a
433
- * turn that failed before its commit. Fenced by generation: a stale
434
- * holder's release is a no-op (the session has moved on). `head` and
435
- * the acquisition counter are untouched, so prior holders stay fenced.
601
+ * Release a held lease without committing — graceful shutdown, a turn
602
+ * that failed before its commit, or a claim that found nothing to
603
+ * drain. Fenced by generation: a stale holder's release is a no-op
604
+ * (the session has moved on). `head` and the acquisition counter are
605
+ * untouched, so prior holders stay fenced.
436
606
  */
437
607
  async release(
438
608
  sessionId: string,
@@ -471,6 +641,92 @@ export class SessionLedger {
471
641
  const entry = await this.kv.getWithVersion(this.ledgerKey(sessionId));
472
642
  return entry === undefined ? undefined : parseLedgerSnapshot(entry.value);
473
643
  }
644
+
645
+ /**
646
+ * All ledgers under this prefix (the janitor's scan). An unparseable
647
+ * record is skipped — reported through `onSkip` — never thrown: one
648
+ * corrupt value must degrade that one session, not abort the scan and
649
+ * kill the whole agent's recovery pass.
650
+ */
651
+ async listLedgers(
652
+ onSkip?: (key: string, error: unknown) => void
653
+ ): Promise<LedgerSnapshot[]> {
654
+ const keys = await this.kv.listKeys("ledger/");
655
+ const ledgers: LedgerSnapshot[] = [];
656
+ for (const key of keys) {
657
+ const entry = await this.kv.getWithVersion(key);
658
+ if (entry === undefined) {
659
+ continue;
660
+ }
661
+ try {
662
+ ledgers.push(parseLedgerSnapshot(entry.value));
663
+ } catch (error) {
664
+ onSkip?.(key, error);
665
+ }
666
+ }
667
+ return ledgers;
668
+ }
669
+
670
+ /**
671
+ * Delete mailbox blobs that are (a) not referenced by their session's
672
+ * `pending` and (b) older than the TTL. Covers both true orphans (a
673
+ * crash between the payload put and the ledger CAS) and consumed blobs
674
+ * whose ids a commit already removed. Membership is checked against a
675
+ * ledger read taken *after* listing the blobs, so a blob whose append
676
+ * CAS is in flight is never swept: its id is either already in
677
+ * `pending`, or the blob is younger than any sane TTL.
678
+ */
679
+ async sweepOrphans(args?: SweepOrphansArgs): Promise<SweepOrphansResult> {
680
+ const olderThanMs = args?.olderThanMs ?? DEFAULT_ORPHAN_TTL_MS;
681
+ const prefix =
682
+ args?.sessionId === undefined
683
+ ? "mailbox/"
684
+ : this.mailboxPrefix(args.sessionId);
685
+ const keys = await this.kv.listKeys(prefix);
686
+ const now = this.clock();
687
+
688
+ // Blob contents carry sessionId/eventId (key segments may be digested
689
+ // and are not invertible), so group by reading each blob.
690
+ const bySession = new Map<
691
+ string,
692
+ Array<{ key: string; event: LedgerEvent }>
693
+ >();
694
+ for (const key of keys) {
695
+ const value = await this.kv.get(key);
696
+ if (value === undefined) {
697
+ continue;
698
+ }
699
+ const event = parseLedgerEvent(value);
700
+ const sessionId = readBlobSessionId(value);
701
+ const group = bySession.get(sessionId) ?? [];
702
+ group.push({ key, event });
703
+ bySession.set(sessionId, group);
704
+ }
705
+
706
+ const deleted: string[] = [];
707
+ for (const [sessionId, blobs] of bySession) {
708
+ const ledger = await this.readLedger(sessionId);
709
+ const pending = new Set(ledger?.pending ?? []);
710
+ for (const { key, event } of blobs) {
711
+ if (pending.has(event.eventId)) {
712
+ continue;
713
+ }
714
+ if (event.storedAt + olderThanMs > now) {
715
+ continue;
716
+ }
717
+ await this.kv.delete(key);
718
+ deleted.push(`${sessionId}/${event.eventId}`);
719
+ }
720
+ }
721
+ return { deleted };
722
+ }
723
+ }
724
+
725
+ function compareStrings(a: string, b: string): number {
726
+ if (a < b) {
727
+ return -1;
728
+ }
729
+ return a > b ? 1 : 0;
474
730
  }
475
731
 
476
732
  function toJson(value: SessionIdentity | LedgerSnapshot): JsonValue {
@@ -505,12 +761,32 @@ function parseLedgerSnapshot(value: JsonValue): LedgerSnapshot {
505
761
  return {
506
762
  sessionId,
507
763
  head,
764
+ pending: parsePending(record.pending),
508
765
  lease: parseLease(lease),
509
766
  generation,
510
767
  lastCommitId: typeof lastCommitId === "string" ? lastCommitId : null,
511
768
  };
512
769
  }
513
770
 
771
+ /**
772
+ * Records written by the pre-inbox ledger have no `pending` field; read
773
+ * them as an empty inbox instead of rejecting them — one legacy record
774
+ * must not brick `claim`/`readLedger` (or a whole `listLedgers` scan)
775
+ * after an upgrade. Every write from the inbox on includes the field.
776
+ */
777
+ function parsePending(value: JsonValue | undefined): string[] {
778
+ if (value === undefined) {
779
+ return [];
780
+ }
781
+ if (
782
+ !Array.isArray(value) ||
783
+ !value.every((id): id is string => typeof id === "string")
784
+ ) {
785
+ throw new Error("session ledger: malformed ledger value");
786
+ }
787
+ return value;
788
+ }
789
+
514
790
  function parseLease(value: JsonValue | undefined): LedgerLease | null {
515
791
  if (value === null || value === undefined) {
516
792
  return null;
@@ -527,6 +803,34 @@ function parseLease(value: JsonValue | undefined): LedgerLease | null {
527
803
  return { holder, generation, expiresAt };
528
804
  }
529
805
 
806
+ function parseLedgerEvent(value: JsonValue): LedgerEvent {
807
+ const record = asRecord(value, "mailbox blob");
808
+ const { eventId, sourceTs, storedAt } = record;
809
+ if (
810
+ typeof eventId !== "string" ||
811
+ typeof sourceTs !== "number" ||
812
+ typeof storedAt !== "number"
813
+ ) {
814
+ throw new Error("session ledger: malformed mailbox blob");
815
+ }
816
+ return {
817
+ eventId,
818
+ envelope: record.envelope ?? null,
819
+ hookContext: record.hookContext ?? null,
820
+ sourceTs,
821
+ storedAt,
822
+ };
823
+ }
824
+
825
+ function readBlobSessionId(value: JsonValue): string {
826
+ const record = asRecord(value, "mailbox blob");
827
+ const sessionId = record.sessionId;
828
+ if (typeof sessionId !== "string") {
829
+ throw new Error("session ledger: mailbox blob is missing sessionId");
830
+ }
831
+ return sessionId;
832
+ }
833
+
530
834
  function asRecord(value: JsonValue, what: string): JsonObject {
531
835
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
532
836
  throw new Error(`session ledger: ${what} value is not an object`);