@cursor/july 0.1.45 → 0.1.46

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 (145) 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.C2NFTb-h.js} +1 -1
  23. package/dist/docs/assets/chunks/@localSearchIndexroot.B-FxvuoI.js +1 -0
  24. package/dist/docs/assets/chunks/{VPLocalSearchBox.Cf6ghfFY.js → VPLocalSearchBox.o1_FJGip.js} +1 -1
  25. package/dist/docs/assets/chunks/{theme.B_5-VLe8.js → theme.BcGduE7g.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/server.d.ts.map +1 -1
  95. package/dist/internal/server.js +2 -2
  96. package/dist/internal/session-engine.d.ts +18 -17
  97. package/dist/internal/session-engine.d.ts.map +1 -1
  98. package/dist/internal/session-engine.js +60 -27
  99. package/dist/kv/memory-kv.d.ts +8 -0
  100. package/dist/kv/memory-kv.d.ts.map +1 -1
  101. package/dist/kv/memory-kv.js +8 -0
  102. package/dist/ledger/janitor.d.ts +60 -0
  103. package/dist/ledger/janitor.d.ts.map +1 -0
  104. package/dist/ledger/janitor.js +137 -0
  105. package/dist/ledger/session-ledger.d.ts +132 -43
  106. package/dist/ledger/session-ledger.d.ts.map +1 -1
  107. package/dist/ledger/session-ledger.js +282 -50
  108. package/dist/ledger.d.ts +9 -8
  109. package/dist/ledger.d.ts.map +1 -1
  110. package/dist/ledger.js +7 -7
  111. package/dist/playground/assets/{index-Dces8BDp.js → index-6wW6SAc4.js} +32 -32
  112. package/dist/playground/assets/index-CSRYDAFw.css +1 -0
  113. package/dist/playground/index.html +2 -2
  114. package/dist/storage-backends/cursor-hosted.d.ts +5 -0
  115. package/dist/storage-backends/cursor-hosted.d.ts.map +1 -1
  116. package/dist/storage-backends/cursor-hosted.js +6 -0
  117. package/dist/types.d.ts +19 -8
  118. package/dist/types.d.ts.map +1 -1
  119. package/docs/guides/github.md +23 -18
  120. package/docs/reference/agent-config.md +31 -36
  121. package/docs/reference/hooks.md +1 -1
  122. package/docs/storage.md +26 -9
  123. package/package.json +1 -1
  124. package/src/channels/github/api.ts +122 -0
  125. package/src/channels/github/cursor-account.ts +23 -0
  126. package/src/channels/github/defaults.ts +1 -1
  127. package/src/channels/github/github-channel.ts +5 -1
  128. package/src/channels/github/progress.ts +84 -19
  129. package/src/channels/github/state.ts +14 -0
  130. package/src/channels/github/types.ts +3 -3
  131. package/src/index.ts +1 -0
  132. package/src/internal/auth.ts +10 -0
  133. package/src/internal/durable-session-store.ts +66 -27
  134. package/src/internal/ledger-serving.ts +439 -148
  135. package/src/internal/server.ts +6 -2
  136. package/src/internal/session-engine.ts +95 -37
  137. package/src/kv/memory-kv.ts +8 -0
  138. package/src/ledger/janitor.ts +193 -0
  139. package/src/ledger/session-ledger.ts +372 -68
  140. package/src/ledger.ts +21 -6
  141. package/src/storage-backends/cursor-hosted.ts +17 -2
  142. package/src/types.ts +19 -8
  143. package/dist/docs/assets/chunks/@localSearchIndexroot.BMT0xM4I.js +0 -1
  144. package/dist/playground/assets/index-CZH9EFvO.css +0 -1
  145. /package/dist/docs/assets/{reference_hooks.md.4cbRRIIu.lean.js → reference_hooks.md.DJE5DXcT.lean.js} +0 -0
@@ -1,117 +1,173 @@
1
1
  /**
2
- * Ledger-mode serving seam (`serving: "ledger"` on `defineAgent`):
3
- * coordination only, no inbox. Every engine `send` acquires the session
4
- * lease before its turn runs and commits (head+1, lease released) after
5
- * it completes, so cross-pod mutual exclusion, generation fencing, and
6
- * the committed head sequence are real.
2
+ * Ledger-mode serving seam (`serving: "ledger"` on `defineAgent`): an event
3
+ * pump that routes every engine `send` through the KV session ledger
4
+ * instead of starting turns directly.
7
5
  *
8
- * **Durability trade-off (deliberate):** there is no durable mailbox.
9
- * An event that arrives while the lease is held does NOT park in the
10
- * store the delivering pod waits *in-process*: local concurrent sends
11
- * serialize on a per-session promise chain (no claim spinning), and a
12
- * lease held by another pod is waited out with bounded claim retries. A
13
- * crash therefore loses the in-flight event exactly as resident serving
14
- * does. A dead pod's lease simply expires (~30s TTL); the next delivery
15
- * claims at generation+1 no janitor is needed because nothing is
16
- * parked.
6
+ * Delivery: ensureSession (continuation key, as today) principal gate
7
+ * (an existing session rejects a sender that is not its principal BEFORE
8
+ * anything is parked; mirrors the resident `samePrincipal` check)
9
+ * appendEvent (payload + CAS the source ack point moves after this)
10
+ * claim drain run the drained turns through the resident engine path →
11
+ * commit (heartbeats while turns run) repeat while the commit reports
12
+ * pending. A deliver that finds the lease held walks away (the holder's
13
+ * commit sees the append); the janitor recovers sessions whose holder
14
+ * died. A foreign-principal envelope that reaches the mailbox anyway is
15
+ * consumed and committed away at drain (warn log), never replayed.
17
16
  *
18
- * Re-entrancy caveat (same shape as resident `waitForCompletion`): a
19
- * send issued from *inside* a running turn against the same session
20
- * chains behind that turn's serve, so awaiting its resolution from the
21
- * turn would deadlock. Fire-and-forget such sends.
17
+ * v1 scope: the mailbox envelope carries `message`, `auth`, `title`,
18
+ * `state`, `mode`, `purpose`, and the isolation-relevant per-send
19
+ * overrides `workspaceDir` and `cloud` the two options
20
+ * `resolveSessionRuntime` and harness-cwd selection read. Dropping
21
+ * those is a sandbox escape, not a feature gap: without them a drained
22
+ * GitHub/Slack turn falls back to the local runtime with the default
23
+ * harness cwd, so untrusted channel content drives tools on the serve
24
+ * host instead of the cloud VM / channel worktree. `coalesceSourceTs`
25
+ * (when present) is used as the drain-ordering `sourceTs`. Deferred
26
+ * (documented, not silently faked — deliver logs a warning naming what
27
+ * it dropped): `workspaceFiles` and per-turn `images` (content
28
+ * injection, and their base64 payloads can blow the mailbox blob past
29
+ * hosted KV value caps), `sdkAgentId` seeding, and `admission`
30
+ * semantics (both `"preempt"` and Slack `"coalesce"` nudge bookkeeping
31
+ * — ledger draining already serializes turns, but nothing is
32
+ * interrupted or coalesced through the mailbox).
22
33
  */
23
34
 
24
35
  import { randomBytes, randomUUID } from "node:crypto";
25
- import type { SessionLedger } from "../ledger/session-ledger.js";
36
+ import type {
37
+ JanitorClaimedSession,
38
+ LedgerJanitor,
39
+ } from "../ledger/janitor.js";
40
+ import { createLedgerJanitor } from "../ledger/janitor.js";
41
+ import type { LedgerLease, SessionLedger } from "../ledger/session-ledger.js";
26
42
  import {
27
43
  HEARTBEAT_INTERVAL_MS,
28
- LEASE_TTL_MS,
29
44
  LedgerFencedError,
30
45
  } from "../ledger/session-ledger.js";
31
- import type { ChannelSession, SendMessageOptions } from "../types.js";
46
+ import type {
47
+ AgentCloudOptions,
48
+ AuthContext,
49
+ ChannelSession,
50
+ JsonValue,
51
+ SendMessageOptions,
52
+ SessionPurpose,
53
+ } from "../types.js";
54
+ import { SessionAuthError, samePrincipal } from "./auth.js";
55
+ import { describeError } from "./describe-error.js";
32
56
  import { newContinuationKey } from "./ids.js";
33
57
 
34
- /** Wait between claim retries while another holder has the lease. */
35
- export const CLAIM_RETRY_DELAY_MS = 250;
36
- /**
37
- * Give up waiting for the lease after this long — two full lease TTLs,
38
- * so a dead holder's expiry (~30s) is always survived with margin.
39
- */
40
- export const CLAIM_WAIT_TIMEOUT_MS: number = 2 * LEASE_TTL_MS;
41
-
42
- /** The `send` options a ledger delivery carries to the resident turn. */
43
- export type LedgerDeliverOptions = SendMessageOptions & {
44
- mode?: "chat" | "task";
45
- };
46
-
47
- /** One delivery handed back to the engine's resident path. */
48
- export interface LedgerTurnRequest {
58
+ /** The JSON payload stored in `mailbox/<sessionId>/<eventId>`. */
59
+ export interface LedgerDeliveryEnvelope {
49
60
  channelId: string;
50
61
  continuationToken: string;
51
62
  message: string;
52
- options: LedgerDeliverOptions;
63
+ auth: AuthContext | null;
64
+ title?: string;
65
+ state?: JsonValue;
66
+ mode?: "chat" | "task";
67
+ purpose?: SessionPurpose;
68
+ /**
69
+ * Isolation-relevant per-send overrides, reapplied on the resident
70
+ * send at drain. Absent on envelopes parked by earlier builds — the
71
+ * drain then behaves as those builds did (agent-default runtime/cwd).
72
+ */
73
+ workspaceDir?: string;
74
+ cloud?: AgentCloudOptions;
53
75
  }
54
76
 
77
+ /** The `send` options the envelope can carry through the mailbox. */
78
+ export type LedgerDeliverOptions = SendMessageOptions & {
79
+ mode?: "chat" | "task";
80
+ };
81
+
55
82
  export interface LedgerServingOptions {
56
83
  ledger: SessionLedger;
57
84
  /**
58
- * Run one delivery as a resident-path turn (the engine's `send` with
59
- * the internal ledger-delivery flag). Must resolve once the turn is
60
- * admitted; the pump awaits `waitForCompletion` itself.
85
+ * Run one drained event as a resident-path turn (the engine's `send`
86
+ * with the internal `fromLedgerPump` flag). Must resolve once the turn
87
+ * is admitted; the pump awaits `waitForCompletion` itself.
61
88
  */
62
- deliverTurn: (request: LedgerTurnRequest) => Promise<ChannelSession>;
89
+ deliverTurn: (envelope: LedgerDeliveryEnvelope) => Promise<ChannelSession>;
90
+ /** Engine continuation lookup (local index + lazy storage restore). */
91
+ findSession: (
92
+ channelId: string,
93
+ continuationToken: string
94
+ ) => Promise<ChannelSession | undefined>;
63
95
  logger?: (line: string) => void;
64
- /** Lease heartbeat cadence while the turn runs (default 10s). */
96
+ /** Lease heartbeat cadence while turns run (default 10s). */
65
97
  heartbeatIntervalMs?: number;
66
- /** Claim retry cadence while the lease is held (default 250ms). */
67
- claimRetryDelayMs?: number;
68
- /** Give-up bound for lease waiting (default 2× lease TTL). */
69
- claimWaitTimeoutMs?: number;
98
+ /** Janitor scan cadence (default `DEFAULT_JANITOR_INTERVAL_MS`). */
99
+ janitorIntervalMs?: number;
70
100
  clock?: () => number;
71
- /** Injectable wait (tests use short real delays; keep them tiny). */
72
- sleep?: (ms: number) => Promise<void>;
73
101
  }
74
102
 
75
103
  export class LedgerServing {
76
104
  private readonly ledger: SessionLedger;
77
105
  private readonly deliverTurn: LedgerServingOptions["deliverTurn"];
106
+ private readonly findSession: LedgerServingOptions["findSession"];
78
107
  private readonly logger: (line: string) => void;
79
108
  private readonly heartbeatIntervalMs: number;
80
- private readonly claimRetryDelayMs: number;
81
- private readonly claimWaitTimeoutMs: number;
82
109
  private readonly clock: () => number;
83
- private readonly sleep: (ms: number) => Promise<void>;
84
- /**
85
- * Per-ledger-session in-process serialization: local concurrent sends
86
- * queue here instead of spinning claim retries against our own lease.
87
- */
88
- private readonly chains = new Map<string, Promise<unknown>>();
110
+ private readonly janitor: LedgerJanitor;
111
+ /** One pump per ledger session — the in-process face of the lease. */
112
+ private readonly pumps = new Map<string, Promise<void>>();
113
+ /** Fire-and-forget re-pump checks, awaited on close. */
114
+ private readonly background = new Set<Promise<void>>();
89
115
 
90
116
  constructor(options: LedgerServingOptions) {
91
117
  this.ledger = options.ledger;
92
118
  this.deliverTurn = options.deliverTurn;
119
+ this.findSession = options.findSession;
93
120
  this.logger = options.logger ?? (() => {});
94
121
  this.heartbeatIntervalMs =
95
122
  options.heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_MS;
96
- this.claimRetryDelayMs = options.claimRetryDelayMs ?? CLAIM_RETRY_DELAY_MS;
97
- this.claimWaitTimeoutMs =
98
- options.claimWaitTimeoutMs ?? CLAIM_WAIT_TIMEOUT_MS;
99
123
  this.clock = options.clock ?? Date.now;
100
- this.sleep =
101
- options.sleep ??
102
- ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
124
+ this.janitor = createLedgerJanitor({
125
+ ledger: options.ledger,
126
+ onSession: (claimed) => this.serveClaimed(claimed),
127
+ // Consumed mailbox blobs (full message envelopes) would otherwise
128
+ // accumulate forever; the default TTL (1h) is far beyond any lease
129
+ // or turn horizon, so only truly dead blobs are collected.
130
+ orphanSweep: {},
131
+ ...(options.janitorIntervalMs === undefined
132
+ ? {}
133
+ : { intervalMs: options.janitorIntervalMs }),
134
+ onError: (error, sessionId) => {
135
+ this.logger(
136
+ `[ledger] janitor error${sessionId === undefined ? "" : ` (session ${sessionId})`}: ${describeError(error)}`
137
+ );
138
+ },
139
+ clock: this.clock,
140
+ });
103
141
  }
104
142
 
105
- /** Settle every queued and in-flight delivery. */
143
+ /** Start the janitor loop (recovers expired-lease sessions). */
144
+ start(): void {
145
+ this.janitor.start();
146
+ }
147
+
148
+ /** Stop the janitor and settle in-flight pumps and re-pump checks. */
106
149
  async close(): Promise<void> {
107
- await Promise.allSettled([...this.chains.values()]);
150
+ // Awaiting stop() matters: an in-flight janitor pass can claim a
151
+ // session and register a pump after this method snapshots the maps.
152
+ await this.janitor.stop();
153
+ await Promise.allSettled([...this.pumps.values()]);
154
+ await Promise.allSettled([...this.background]);
108
155
  }
109
156
 
110
157
  /**
111
- * Ledger-mode `send`: resolve the session identity, wait for the
112
- * lease (in-process queue first, then cross-pod claim retries), run
113
- * the turn, commit. Resolves with the engine session handle after the
114
- * commit released the lease.
158
+ * Ledger-mode `send`: append the message to the session mailbox, then
159
+ * serve pending events (or walk away if another holder is already
160
+ * serving them). Resolves with the engine session handle once one
161
+ * exists the ack point is the durable append, not turn completion —
162
+ * so HTTP create/follow-up can return a session id while the holder is
163
+ * still in flight. When this process wins the claim, the pump keeps
164
+ * draining in the background; callers that need the turn to finish use
165
+ * `waitForCompletion` (and the lease releases on the pump's commit).
166
+ * When another holder is serving the session and no engine session
167
+ * exists on this pod, the delivery is still a success — the event is
168
+ * durably parked and the holder's commit (or the janitor) drains it —
169
+ * so the resolved handle is a parked stand-in carrying the durable
170
+ * session identity.
115
171
  */
116
172
  async deliver(
117
173
  channelId: string,
@@ -119,46 +175,277 @@ export class LedgerServing {
119
175
  options: LedgerDeliverOptions
120
176
  ): Promise<ChannelSession> {
121
177
  const continuationToken = options.continuationToken ?? newContinuationKey();
178
+ this.warnOnDroppedOptions(channelId, options);
122
179
  const ensured = await this.ledger.ensureSession(
123
180
  `${channelId}/${continuationToken}`
124
181
  );
125
- return this.enqueue(ensured.session.sessionId, () =>
126
- this.serveOne(ensured.session.sessionId, {
127
- channelId,
128
- continuationToken,
129
- message,
130
- options,
131
- })
132
- );
182
+ const ledgerSessionId = ensured.session.sessionId;
183
+ // Principal gate BEFORE the durable append: once an envelope is
184
+ // parked it WILL be drained, so a foreign sender must be rejected
185
+ // here — not on the resident path after the mailbox write. A new
186
+ // ledger session has no owner yet: the creating sender becomes it,
187
+ // exactly as on resident session creation.
188
+ if (!ensured.created) {
189
+ const existing = await this.findSession(channelId, continuationToken);
190
+ if (existing !== undefined) {
191
+ if (!samePrincipal(existing.info.auth, options.auth ?? null)) {
192
+ throw new SessionAuthError(existing.id);
193
+ }
194
+ }
195
+ // No handle: the engine session does not exist yet (the first
196
+ // parked event has not run — the lease holder creates it) or a
197
+ // cross-pod restore transiently failed. Park rather than fail
198
+ // closed: rejecting here would bounce the legitimate owner racing
199
+ // the session's first turn. This is not fail-open — the resident
200
+ // path re-checks the principal when the event drains, and the
201
+ // drain treats a SessionAuthError as consume-and-commit, so a
202
+ // foreign envelope that parks through this window costs one warn
203
+ // line and never actuates, bricks, or replays the session.
204
+ }
205
+ const envelope: LedgerDeliveryEnvelope = {
206
+ channelId,
207
+ continuationToken,
208
+ message,
209
+ auth: options.auth ?? null,
210
+ ...(options.title === undefined ? {} : { title: options.title }),
211
+ ...(options.state === undefined ? {} : { state: options.state }),
212
+ ...(options.mode === undefined ? {} : { mode: options.mode }),
213
+ ...(options.purpose === undefined ? {} : { purpose: options.purpose }),
214
+ ...(options.workspaceDir === undefined
215
+ ? {}
216
+ : { workspaceDir: options.workspaceDir }),
217
+ ...(options.cloud === undefined ? {} : { cloud: options.cloud }),
218
+ };
219
+ await this.ledger.appendEvent(ledgerSessionId, {
220
+ eventId: `evt_${randomBytes(12).toString("hex")}`,
221
+ envelope: envelope as unknown as JsonValue,
222
+ sourceTs: this.resolveSourceTs(options),
223
+ });
224
+ // Ack point: the event is durable (mailbox blob + ledger pending).
225
+ // Do not await the pump: awaiting turns here would block HTTP create
226
+ // until the first turn committed, so a client could never park a
227
+ // mid-turn follow-up and a second pod would see a released lease.
228
+ const serving = this.servePending(ledgerSessionId);
229
+ return this.resolveDeliverHandle({
230
+ channelId,
231
+ continuationToken,
232
+ ledgerSessionId,
233
+ envelope,
234
+ created: ensured.created,
235
+ serving,
236
+ });
133
237
  }
134
238
 
135
- /** Chain a delivery behind the session's in-flight ones. */
136
- private enqueue(
239
+ /**
240
+ * The stand-in handle for a delivery whose event is parked while
241
+ * another holder serves the session: real durable identity, no local
242
+ * turn. `waitForCompletion` resolves immediately — the parked event's
243
+ * turn runs on the holder, so there is nothing local to await (the
244
+ * same contract as a coalesced send, whose outcome is not its own
245
+ * message's turn). The event stream and re-keying live wherever the
246
+ * turn runs, so both fail loudly here.
247
+ */
248
+ private parkedSession(
137
249
  ledgerSessionId: string,
138
- run: () => Promise<ChannelSession>
139
- ): Promise<ChannelSession> {
140
- const prior = this.chains.get(ledgerSessionId) ?? Promise.resolve();
141
- const next = prior.then(
142
- () => run(),
143
- () => run()
144
- );
145
- this.chains.set(ledgerSessionId, next);
146
- void next
147
- .catch(() => {})
148
- .finally(() => {
149
- if (this.chains.get(ledgerSessionId) === next) {
150
- this.chains.delete(ledgerSessionId);
151
- }
250
+ envelope: LedgerDeliveryEnvelope,
251
+ isNew: boolean
252
+ ): ChannelSession {
253
+ const unavailable = (what: string): Error =>
254
+ new Error(
255
+ `ledger serving: ${what} is unavailable for session ${ledgerSessionId}: the parked event runs on the session's current lease holder, not this process`
256
+ );
257
+ return {
258
+ id: ledgerSessionId,
259
+ continuationToken: envelope.continuationToken,
260
+ isNew,
261
+ info: {
262
+ id: ledgerSessionId,
263
+ channelId: envelope.channelId,
264
+ mode: envelope.mode ?? "chat",
265
+ auth: envelope.auth,
266
+ ...(envelope.title === undefined ? {} : { title: envelope.title }),
267
+ ...(envelope.purpose === undefined
268
+ ? {}
269
+ : { purpose: envelope.purpose }),
270
+ continuationKey: envelope.continuationToken,
271
+ },
272
+ getEventStream: () => Promise.reject(unavailable("the event stream")),
273
+ waitForCompletion: async () => ({ status: "finished" }),
274
+ setContinuationToken: () =>
275
+ Promise.reject(unavailable("re-keying the continuation token")),
276
+ };
277
+ }
278
+
279
+ /**
280
+ * Return a handle as soon as the engine session exists, without waiting
281
+ * for the pump to commit. If the pump settles without creating a
282
+ * session (walk-away, or a throw before admit), surface that: rethrow
283
+ * a failed claimant turn, otherwise the parked stand-in.
284
+ */
285
+ private async resolveDeliverHandle(args: {
286
+ channelId: string;
287
+ continuationToken: string;
288
+ ledgerSessionId: string;
289
+ envelope: LedgerDeliveryEnvelope;
290
+ created: boolean;
291
+ serving: Promise<void>;
292
+ }): Promise<ChannelSession> {
293
+ const {
294
+ channelId,
295
+ continuationToken,
296
+ ledgerSessionId,
297
+ envelope,
298
+ created,
299
+ serving,
300
+ } = args;
301
+ for (;;) {
302
+ const handle = await this.findSession(channelId, continuationToken);
303
+ if (handle !== undefined) {
304
+ return handle;
305
+ }
306
+ const pump = this.pumps.get(ledgerSessionId);
307
+ if (pump !== undefined) {
308
+ await Promise.race([
309
+ new Promise<void>((resolve) => {
310
+ setTimeout(resolve, 15);
311
+ }),
312
+ pump.then(
313
+ () => undefined,
314
+ () => undefined
315
+ ),
316
+ ]);
317
+ continue;
318
+ }
319
+ const createdLate = await this.findSession(channelId, continuationToken);
320
+ if (createdLate !== undefined) {
321
+ return createdLate;
322
+ }
323
+ await serving;
324
+ this.logger(
325
+ `[ledger] deliver on channel "${channelId}": event parked for session ${ledgerSessionId} (continuation ${continuationToken}); another holder is serving it and no local session exists — its commit (or the janitor) drains the event`
326
+ );
327
+ return this.parkedSession(ledgerSessionId, envelope, created);
328
+ }
329
+ }
330
+
331
+ /**
332
+ * Drain ordering: prefer the real source time over this pod's receipt
333
+ * time. `coalesceSourceTs` is a Slack-style seconds string
334
+ * (`"1712345678.000100"`); normalize sub-epoch-ms values to ms so they
335
+ * stay comparable with the `Date.now()` fallback.
336
+ */
337
+ private resolveSourceTs(options: LedgerDeliverOptions): number {
338
+ if (options.coalesceSourceTs !== undefined) {
339
+ const parsed = Number(options.coalesceSourceTs);
340
+ if (Number.isFinite(parsed) && parsed > 0) {
341
+ return parsed < 1e11 ? Math.round(parsed * 1000) : parsed;
342
+ }
343
+ }
344
+ return this.clock();
345
+ }
346
+
347
+ /**
348
+ * One warning per delivery naming the send options the mailbox does not
349
+ * replay — better a loud line than an author silently losing images or
350
+ * admission semantics on a ledger-mode agent.
351
+ */
352
+ private warnOnDroppedOptions(
353
+ channelId: string,
354
+ options: LedgerDeliverOptions
355
+ ): void {
356
+ const dropped = (
357
+ ["images", "sdkAgentId", "admission", "workspaceFiles"] as const
358
+ ).filter((name) => options[name] !== undefined);
359
+ if (dropped.length > 0) {
360
+ this.logger(
361
+ `[ledger] deliver on channel "${channelId}": dropping send options not replayed through the mailbox: ${dropped.join(", ")}`
362
+ );
363
+ }
364
+ }
365
+
366
+ /**
367
+ * Serve a session's pending events. When a pump is already running the
368
+ * caller walks away (the holder's commit re-reads pending) — except
369
+ * that the holder's *final* commit may have released before this
370
+ * append landed, so a re-check is chained behind it. Deliver never
371
+ * awaits this promise for its full lifetime: awaiting a new pump would
372
+ * block HTTP until the turn committed, and awaiting a running pump
373
+ * would deadlock a send issued from inside one of its turns.
374
+ */
375
+ private servePending(ledgerSessionId: string): Promise<void> {
376
+ const running = this.pumps.get(ledgerSessionId);
377
+ if (running !== undefined) {
378
+ const recheck = running
379
+ .catch(() => {})
380
+ .then(() => this.repumpIfPending(ledgerSessionId))
381
+ .catch(() => {});
382
+ this.background.add(recheck);
383
+ void recheck.finally(() => {
384
+ this.background.delete(recheck);
152
385
  });
153
- return next;
386
+ return Promise.resolve();
387
+ }
388
+ return this.track(ledgerSessionId, () => this.runPump(ledgerSessionId));
389
+ }
390
+
391
+ /** Claim-or-defer entry used after an append and after a pump settles. */
392
+ private async runPump(ledgerSessionId: string): Promise<void> {
393
+ const claim = await this.ledger.claim(ledgerSessionId);
394
+ if (!claim.claimed) {
395
+ return;
396
+ }
397
+ await this.runClaimedTurns(ledgerSessionId, claim.lease);
398
+ }
399
+
400
+ /** Janitor hand-off: the claim already happened, just run the turns. */
401
+ private serveClaimed(claimed: JanitorClaimedSession): Promise<void> {
402
+ return this.track(claimed.sessionId, () =>
403
+ this.runClaimedTurns(claimed.sessionId, claimed.lease)
404
+ );
405
+ }
406
+
407
+ /**
408
+ * Re-pump after the previous holder settled, covering the release window
409
+ * where an append landed after that holder's final commit.
410
+ */
411
+ private async repumpIfPending(ledgerSessionId: string): Promise<void> {
412
+ if (this.pumps.has(ledgerSessionId)) {
413
+ return;
414
+ }
415
+ const snapshot = await this.ledger.readLedger(ledgerSessionId);
416
+ if (snapshot === undefined || snapshot.pending.length === 0) {
417
+ return;
418
+ }
419
+ if (snapshot.lease !== null && snapshot.lease.expiresAt > this.clock()) {
420
+ return;
421
+ }
422
+ await this.track(ledgerSessionId, () => this.runPump(ledgerSessionId));
154
423
  }
155
424
 
156
- /** claim (waiting) resident turn commit-release. */
157
- private async serveOne(
425
+ /** Register a pump in the per-session map for its lifetime. */
426
+ private track(
158
427
  ledgerSessionId: string,
159
- request: LedgerTurnRequest
160
- ): Promise<ChannelSession> {
161
- const lease = await this.claimWithWait(ledgerSessionId);
428
+ run: () => Promise<void>
429
+ ): Promise<void> {
430
+ const tracked: Promise<void> = run().finally(() => {
431
+ if (this.pumps.get(ledgerSessionId) === tracked) {
432
+ this.pumps.delete(ledgerSessionId);
433
+ }
434
+ });
435
+ this.pumps.set(ledgerSessionId, tracked);
436
+ return tracked;
437
+ }
438
+
439
+ /**
440
+ * The drain → turn → commit loop for a held lease. Heartbeats extend the
441
+ * lease while turns run; a fenced heartbeat (or fenced commit) means a
442
+ * new holder took over — discard without committing; the new holder
443
+ * re-drains (at-least-once actuation, exactly-once ledger apply).
444
+ */
445
+ private async runClaimedTurns(
446
+ ledgerSessionId: string,
447
+ lease: LedgerLease
448
+ ): Promise<void> {
162
449
  let fenced = false;
163
450
  const heartbeat = setInterval(() => {
164
451
  this.ledger.heartbeat(ledgerSessionId, lease.generation).then(
@@ -174,64 +461,68 @@ export class LedgerServing {
174
461
  }, this.heartbeatIntervalMs);
175
462
  heartbeat.unref?.();
176
463
  try {
177
- let handle: ChannelSession;
178
- try {
179
- handle = await this.deliverTurn(request);
180
- await handle.waitForCompletion();
181
- } catch (error) {
182
- // The turn never committed; free the session for the next
183
- // delivery instead of making it wait out the TTL. A fenced
184
- // holder's release is a no-op.
185
- await this.ledger
186
- .release(ledgerSessionId, lease.generation)
187
- .catch(() => {});
188
- throw error;
464
+ for (;;) {
465
+ if (fenced) {
466
+ return;
467
+ }
468
+ const { head, events } = await this.ledger.drain(ledgerSessionId);
469
+ if (events.length === 0) {
470
+ await this.ledger.release(ledgerSessionId, lease.generation);
471
+ return;
472
+ }
473
+ for (const event of events) {
474
+ if (fenced) {
475
+ return;
476
+ }
477
+ const envelope = event.envelope as unknown as LedgerDeliveryEnvelope;
478
+ try {
479
+ const session = await this.deliverTurn(envelope);
480
+ await session.waitForCompletion();
481
+ } catch (error) {
482
+ if (!(error instanceof SessionAuthError)) {
483
+ throw error;
484
+ }
485
+ // Defense in depth behind deliver's principal gate: a parked
486
+ // envelope from a foreign principal (gate raced the session's
487
+ // first turn, or a pre-gate build wrote it) is consumed and
488
+ // committed away like any drained event. Aborting the batch
489
+ // instead would replay the already-actuated turns before it
490
+ // and re-throw on every future drain — one poison event must
491
+ // cost a log line, never a stuck session.
492
+ this.logger(
493
+ `[ledger] session ${ledgerSessionId}: discarding parked event ${event.eventId} — its sender is not the session's principal (${describeError(error)})`
494
+ );
495
+ }
496
+ }
497
+ const commit = await this.ledger.commit(ledgerSessionId, {
498
+ drainedEventIds: events.map((event) => event.eventId),
499
+ generation: lease.generation,
500
+ expectedHead: head,
501
+ commitId: randomUUID(),
502
+ });
503
+ if (commit.leaseReleased) {
504
+ return;
505
+ }
189
506
  }
190
- if (fenced) {
191
- // A new holder took over mid-turn (our lease lapsed). Discard:
192
- // committing would fence anyway, and warm state is now stale.
507
+ } catch (error) {
508
+ if (error instanceof LedgerFencedError) {
193
509
  this.logger(
194
- `[ledger] session ${ledgerSessionId} fenced mid-turn (generation ${lease.generation}); discarding without commit`
195
- );
196
- throw new LedgerFencedError(
197
- `deliver(${ledgerSessionId}): fenced mid-turn at generation ${lease.generation}`
510
+ `[ledger] session ${ledgerSessionId} fenced mid-turn (generation ${lease.generation}); discarding the new holder re-drains`
198
511
  );
512
+ return;
199
513
  }
200
- await this.ledger.commit(ledgerSessionId, {
201
- generation: lease.generation,
202
- expectedHead: lease.head,
203
- commitId: randomUUID(),
204
- });
205
- return handle;
514
+ // The turn never committed; free the session for the next
515
+ // append-driven claim instead of making it wait out the lease TTL
516
+ // (and the janitor cadence). Best-effort: a fenced holder's
517
+ // release is a no-op, and the events stay pending either way.
518
+ await this.ledger
519
+ .release(ledgerSessionId, lease.generation)
520
+ .catch(() => {});
521
+ throw error;
206
522
  } finally {
207
523
  clearInterval(heartbeat);
208
524
  }
209
525
  }
210
-
211
- /**
212
- * Claim the lease, waiting out another holder with bounded retries.
213
- * The common waits are short: our own pod's deliveries never reach
214
- * here concurrently (the in-process chain serializes them), so a held
215
- * lease means another pod is serving — usually for one turn — or died
216
- * (expiry unblocks us at ~30s).
217
- */
218
- private async claimWithWait(
219
- ledgerSessionId: string
220
- ): Promise<{ generation: number; head: number }> {
221
- const start = this.clock();
222
- for (;;) {
223
- const claim = await this.ledger.claim(ledgerSessionId);
224
- if (claim.claimed) {
225
- return { generation: claim.lease.generation, head: claim.head };
226
- }
227
- if (this.clock() - start >= this.claimWaitTimeoutMs) {
228
- throw new Error(
229
- `ledger serving: session ${ledgerSessionId} is still leased by ${claim.holder} after ${this.claimWaitTimeoutMs}ms; giving up on this delivery`
230
- );
231
- }
232
- await this.sleep(this.claimRetryDelayMs);
233
- }
234
- }
235
526
  }
236
527
 
237
528
  /** Mint the lease holder id for this process. */