@nylorun/runtime 0.22.0-beta → 0.23.0-beta

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 (49) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +17 -3
  3. package/dist/adapters/execution/restate.d.ts +15 -3
  4. package/dist/adapters/execution/restate.js +77 -83
  5. package/dist/adapters/streams/s2.js +1 -13
  6. package/dist/core/flow-host.d.ts +1 -19
  7. package/dist/core/flow-host.js +8 -37
  8. package/dist/execution/types.d.ts +10 -8
  9. package/dist/execution/types.js +5 -7
  10. package/dist/host/execution.d.ts +8 -6
  11. package/dist/host/main.js +10 -16
  12. package/dist/host/stack-config.d.ts +6 -0
  13. package/dist/host/stack-config.js +6 -0
  14. package/dist/infra/execution.d.ts +18 -1
  15. package/dist/infra/execution.js +28 -4
  16. package/dist/infra/index.d.ts +1 -1
  17. package/dist/management-openapi.json +1 -1
  18. package/dist/openapi.json +1 -1
  19. package/dist/retry.d.ts +21 -0
  20. package/dist/retry.js +34 -0
  21. package/dist/sandbox/join.js +6 -7
  22. package/dist/sandbox/pods/reconcile.js +3 -3
  23. package/dist/store/postgres/control.js +8 -1
  24. package/dist/store/postgres/drizzle/0018_wake_outbox_retries.sql +3 -0
  25. package/dist/store/postgres/drizzle/0019_sandbox_signal_outbox.sql +18 -0
  26. package/dist/store/postgres/drizzle/meta/_journal.json +14 -0
  27. package/dist/store/postgres/schema.d.ts +263 -2
  28. package/dist/store/postgres/schema.js +34 -3
  29. package/dist/store/postgres/store.js +165 -52
  30. package/dist/store/types.d.ts +96 -39
  31. package/dist/streams/relay/core.js +1 -13
  32. package/dist/tenant/advance.js +6 -7
  33. package/dist/tenant/commands.js +1 -2
  34. package/dist/tenant/context.d.ts +19 -11
  35. package/dist/tenant/control.d.ts +8 -1
  36. package/dist/tenant/control.js +2 -0
  37. package/dist/tenant/runtime.js +23 -3
  38. package/dist/tenant/sandboxes.d.ts +2 -1
  39. package/dist/tenant/sandboxes.js +23 -19
  40. package/dist/tenant/scheduler.d.ts +7 -0
  41. package/dist/tenant/scheduler.js +38 -7
  42. package/dist/tenant/session-streams.d.ts +0 -1
  43. package/dist/tenant/session-streams.js +1 -13
  44. package/dist/tenant/streams.js +14 -29
  45. package/dist/tenant/sweep.d.ts +39 -12
  46. package/dist/tenant/sweep.js +157 -58
  47. package/dist/version.d.ts +1 -1
  48. package/dist/version.js +1 -1
  49. package/package.json +2 -1
@@ -406,7 +406,8 @@ export const definitionFileUses = nylorun.table("definition_file_uses", {
406
406
  * written in the transaction that commits what they announce and notified on
407
407
  * `nylorun_control` at commit (`control.ts`). A row outlives its notification so a process
408
408
  * whose listener was down catches up; the Tenant sweep deletes old ones. `created_at` is the
409
- * insert's own clock (`clock_timestamp()`), not its transaction's start.
409
+ * insert's own clock (`clock_timestamp()`), not its transaction's start. `sandbox_id` and
410
+ * `epoch` are a `host.revoked` signal's.
410
411
  */
411
412
  export const controlSignals = nylorun.table("control_signals", {
412
413
  id: bigint({ mode: "number" }).primaryKey().generatedAlwaysAsIdentity(),
@@ -417,16 +418,20 @@ export const controlSignals = nylorun.table("control_signals", {
417
418
  createdAt: timestamp({ withTimezone: true, mode: "string" })
418
419
  .notNull()
419
420
  .default(sql `clock_timestamp()`),
421
+ sandboxId: textC(),
422
+ epoch: integer(),
420
423
  }, (t) => [
421
424
  index("control_signals_created_at").on(t.createdAt),
422
- check("control_signals_kind_check", sql `${t.kind} IN ('session.cancel', 'sessions.reset')`),
425
+ check("control_signals_kind_check", sql `${t.kind} IN ('session.cancel', 'sessions.reset', 'host.revoked')`),
423
426
  ]);
424
427
  /**
425
428
  * The wake outbox (architecture §12.3): one row per wake a committed transaction asked for
426
429
  * (`Tx.wake`), written in that transaction, so a commit never loses its wake. The row is
427
430
  * deleted once Durable Session Execution has accepted the wake; until then the Tenant sweep
428
431
  * delivers it again, with the same idempotency key (`dedupe_key`, or `wake:<id>` without
429
- * one). `created_at` is the insert's own clock (`clock_timestamp()`).
432
+ * one). `created_at` is the insert's own clock (`clock_timestamp()`). `attempts` counts the
433
+ * sweep's failed deliveries, `retry_at` is when it tries the row again, and a row
434
+ * `parked_at` is never tried again: it stays for an operator (`Tx.failOutbox`).
430
435
  */
431
436
  export const wakes = nylorun.table("wakes", {
432
437
  id: textC().primaryKey(),
@@ -436,7 +441,33 @@ export const wakes = nylorun.table("wakes", {
436
441
  createdAt: timestamp({ withTimezone: true, mode: "date" })
437
442
  .notNull()
438
443
  .default(sql `clock_timestamp()`),
444
+ attempts: integer().notNull().default(0),
445
+ retryAt: timestamp({ withTimezone: true, mode: "date" }),
446
+ parkedAt: timestamp({ withTimezone: true, mode: "date" }),
439
447
  }, (t) => [index("wakes_created_at").on(t.createdAt)]);
448
+ /**
449
+ * The sandbox signal outbox (F7.2): one row per pod sandbox reconcile or timer a committed
450
+ * transaction asked for (`Tx.signalSandbox`), kept and delivered as the wake outbox's rows are
451
+ * (`attempts`, `retry_at`, `parked_at`). A reconcile is sent with the idempotency key
452
+ * `signal:<id>`; a timer (`kind` `arm`) is its `timer` at `at` (ms since the epoch). Not in
453
+ * `wakes`, so a Runtime that delivers only wakes never reads one.
454
+ */
455
+ export const sandboxSignals = nylorun.table("sandbox_signals", {
456
+ id: textC().primaryKey(),
457
+ sandboxId: textC().notNull(),
458
+ kind: text().notNull(),
459
+ timer: text(),
460
+ at: bigint({ mode: "number" }),
461
+ createdAt: timestamp({ withTimezone: true, mode: "date" })
462
+ .notNull()
463
+ .default(sql `clock_timestamp()`),
464
+ attempts: integer().notNull().default(0),
465
+ retryAt: timestamp({ withTimezone: true, mode: "date" }),
466
+ parkedAt: timestamp({ withTimezone: true, mode: "date" }),
467
+ }, (t) => [
468
+ index("sandbox_signals_created_at").on(t.createdAt),
469
+ check("sandbox_signals_kind_check", sql `${t.kind} IN ('reconcile', 'arm')`),
470
+ ]);
440
471
  // ---------------------------------------------------------------------------
441
472
  // nylorun_streams: the record
442
473
  /**
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * Every `tx` is one READ COMMITTED transaction on a pooled connection (Drizzle's
17
17
  * `transaction` on postgres.js `begin`). Tables are addressed by schema-qualified names,
18
- * never through `search_path`. Commit listeners and `afterCommit` callbacks run after `COMMIT`
18
+ * never through `search_path`. Commit listeners and outbox deliveries run after `COMMIT`
19
19
  * returns and never reject the committed `tx`. Nested `tx` calls are detected with
20
20
  * `AsyncLocalStorage` and rejected, and a `Tx` rejects every call once its callback settles.
21
21
  * A failed statement rejects `tx` with the driver's error (`driverError`), not Drizzle's
@@ -50,7 +50,7 @@
50
50
  */
51
51
  import { AsyncLocalStorage } from "node:async_hooks";
52
52
  import { randomUUID } from "node:crypto";
53
- import { and, count, eq, getTableColumns, gte, inArray, isNotNull, isNull, lt, lte, ne, or, sql, } from "drizzle-orm";
53
+ import { and, count, eq, getTableColumns, gte, gt, inArray, isNotNull, isNull, lt, lte, ne, or, sql, } from "drizzle-orm";
54
54
  import { appendEvent, appendSandboxEvent } from "../../record/index.js";
55
55
  import { OwnershipLostError, PrincipalRoleConflict } from "../ownership.js";
56
56
  import { followControlSignals, pruneSignals, writeSignal } from "./control.js";
@@ -58,7 +58,7 @@ import { database, driverError } from "./db.js";
58
58
  import { expectedSchemaVersion, readSchemaVersion } from "./migrate.js";
59
59
  import { createPostgresRecordReader } from "./record.js";
60
60
  import { postgresRecordWriter, postgresSandboxRecordWriter } from "./record-writer.js";
61
- import { artifacts, artifactVersions, artifactContent, definitionFiles, definitionFileUses, commands, definitions, effects, links, modelBudgets, modelUsage, principals, sandboxes, sandboxEvents, sandboxResources, sessionEvents, sessionLogHeads, sessions, toJson, toolCrossings, signingKeys, tenant, tenantSettings, vaultAudit, vaultCredentials, vaultIdempotency, vaults, wakes, } from "./schema.js";
61
+ import { artifacts, artifactVersions, artifactContent, definitionFiles, definitionFileUses, commands, definitions, effects, links, modelBudgets, modelUsage, principals, sandboxes, sandboxEvents, sandboxResources, sessionEvents, sessionLogHeads, sessions, toJson, toolCrossings, signingKeys, tenant, tenantSettings, vaultAudit, vaultCredentials, vaultIdempotency, vaults, sandboxSignals, wakes, } from "./schema.js";
62
62
  export function createPostgresSessionStore(options) {
63
63
  return new PostgresSessionStore(options);
64
64
  }
@@ -88,6 +88,9 @@ class PostgresSessionStore {
88
88
  active = new AsyncLocalStorage();
89
89
  listeners = new Set();
90
90
  inflight = new Set();
91
+ /** Deliveries of committed outbox requests still running (`deliverTo`). */
92
+ deliveries = new Set();
93
+ deliver;
91
94
  now;
92
95
  onError;
93
96
  closed = false;
@@ -111,7 +114,7 @@ class PostgresSessionStore {
111
114
  let t;
112
115
  // Wrapped so `begin` does not treat an array result as queries to await.
113
116
  const run = this.db.transaction(async (db) => {
114
- t = new PostgresTx(db, this.tenantId, this.now, this.deleteWake);
117
+ t = new PostgresTx(db, this.tenantId, this.now);
115
118
  try {
116
119
  return { value: await this.active.run(this, () => fn(t)) };
117
120
  }
@@ -144,20 +147,51 @@ class PostgresSessionStore {
144
147
  }
145
148
  }
146
149
  }
147
- for (const callback of t.callbacks) {
148
- try {
149
- await callback();
150
- }
151
- catch (error) {
152
- this.onError(error);
153
- }
154
- }
150
+ if (t.outbox.length > 0)
151
+ this.send(t.outbox);
155
152
  return result.value;
156
153
  }
157
- /** Deletes a delivered outbox request, outside any transaction (`Tx.wake`). */
158
- deleteWake = async (id) => {
159
- await this.db.delete(wakes).where(eq(wakes.id, id));
160
- };
154
+ deliverTo(deliver) {
155
+ this.deliver = deliver;
156
+ }
157
+ async delivered() {
158
+ while (this.deliveries.size > 0)
159
+ await Promise.allSettled([...this.deliveries]);
160
+ }
161
+ /**
162
+ * Hands a commit's outbox requests to the delivery, one after the other, without the caller
163
+ * waiting: a delivered one's row is deleted, and one that was not is due at the sweep's next
164
+ * pass.
165
+ */
166
+ send(committed) {
167
+ const deliver = this.deliver;
168
+ if (!deliver)
169
+ return;
170
+ const run = (async () => {
171
+ for (const { kind, id, request } of committed) {
172
+ let sent = false;
173
+ try {
174
+ sent = await deliver(request);
175
+ }
176
+ catch (error) {
177
+ this.onError(error);
178
+ }
179
+ const table = OUTBOX[kind];
180
+ try {
181
+ if (sent === false)
182
+ await this.db.update(table).set({ retryAt: sql `clock_timestamp()` }).where(eq(table.id, id));
183
+ else
184
+ await this.db.delete(table).where(eq(table.id, id));
185
+ }
186
+ catch (error) {
187
+ // The row stays as it was: the sweep sends it again, under the same key.
188
+ this.onError(error);
189
+ }
190
+ }
191
+ })();
192
+ this.deliveries.add(run);
193
+ void run.finally(() => this.deliveries.delete(run));
194
+ }
161
195
  onCommit(listener) {
162
196
  this.listeners.add(listener);
163
197
  return () => this.listeners.delete(listener);
@@ -186,10 +220,14 @@ class PostgresSessionStore {
186
220
  throw new Error("SessionStore is closed");
187
221
  return followControlSignals(this.sql, this.db, onSignal, options);
188
222
  }
189
- /** Rejects new transactions and waits for running ones. Does not end the pool. */
223
+ /**
224
+ * Rejects new transactions and waits for running ones, and for the deliveries of committed
225
+ * outbox requests. Does not end the pool.
226
+ */
190
227
  async close() {
191
228
  this.closed = true;
192
229
  await Promise.allSettled([...this.inflight]);
230
+ await this.delivered();
193
231
  }
194
232
  }
195
233
  function storedSession(row) {
@@ -262,26 +300,49 @@ const SIGNING_KEY_STAMP = {
262
300
  /** The audit columns a row has (`ord` only orders them). */
263
301
  const { ord: _ord, ...AUDIT } = getTableColumns(vaultAudit);
264
302
  const SESSION_TABLES = [sessions, commands, effects, links, wakes];
265
- /** An outbox row as it is delivered: the wake's own dedupe key, or one made from the row id. */
303
+ /**
304
+ * The outbox's tables, by the kind of request each holds. Their delivery columns (`id`,
305
+ * `created_at`, `attempts`, `retry_at`, `parked_at`) are the same.
306
+ */
307
+ const OUTBOX = {
308
+ wake: wakes,
309
+ sandbox: sandboxSignals,
310
+ };
311
+ /** A wake outbox row as it is delivered: its own dedupe key, or one made from the row id. */
266
312
  function outboxWake(id, reason, dedupeKey) {
267
313
  return { reason, dedupeKey: dedupeKey ?? `wake:${id}` };
268
314
  }
315
+ /** A sandbox signal outbox row as it is delivered: a reconcile is keyed by the row. */
316
+ function outboxSignal(id, row) {
317
+ return row.kind === "arm"
318
+ ? { kind: "arm", timer: row.timer, at: row.at }
319
+ : { kind: "reconcile", key: `signal:${id}` };
320
+ }
321
+ /** When an outbox row of `table` is due, after a grace of `graceMs` (the database's clock). */
322
+ function dueAt(table, graceMs) {
323
+ const grace = sql `make_interval(secs => ${Math.max(0, graceMs) / 1000})`;
324
+ return sql `coalesce(${table.retryAt}, ${table.createdAt} + ${grace})`;
325
+ }
326
+ /** Due rows of `table`, soonest first: their epoch seconds, to merge two tables' rows. */
327
+ const DUE = (table, graceMs) => ({
328
+ due: dueAt(table, graceMs),
329
+ dueSeconds: sql `extract(epoch from ${dueAt(table, graceMs)})::float8`,
330
+ });
269
331
  /** The inserted row's `column` (`ON CONFLICT … DO UPDATE`). */
270
332
  const excluded = (column) => sql.raw(`excluded.${column}`);
271
333
  class PostgresTx {
272
334
  db;
273
335
  tenantId;
274
336
  now;
275
- deleteWake;
276
337
  closed = false;
277
338
  events = [];
278
339
  generations = [];
279
- callbacks = [];
280
- constructor(db, tenantId, now, deleteWake) {
340
+ /** The outbox requests this transaction wrote, delivered after it commits. */
341
+ outbox = [];
342
+ constructor(db, tenantId, now) {
281
343
  this.db = db;
282
344
  this.tenantId = tenantId;
283
345
  this.now = now;
284
- this.deleteWake = deleteWake;
285
346
  }
286
347
  check() {
287
348
  if (this.closed)
@@ -352,12 +413,8 @@ class PostgresTx {
352
413
  this.generations.push(generation);
353
414
  return structuredClone(event);
354
415
  }
355
- afterCommit(fn) {
356
- this.check();
357
- this.callbacks.push(fn);
358
- }
359
416
  // --- wake outbox ---------------------------------------------------------
360
- async wake(sessionId, wake, deliver) {
417
+ async wake(sessionId, wake) {
361
418
  this.check();
362
419
  const id = randomUUID();
363
420
  await this.db.insert(wakes).values({
@@ -366,34 +423,90 @@ class PostgresTx {
366
423
  reason: wake.reason,
367
424
  dedupeKey: wake.dedupeKey ?? null,
368
425
  });
369
- const delivered = outboxWake(id, wake.reason, wake.dedupeKey ?? null);
370
- // A rejection goes to the store's error hook (as every callback's), and the row stays.
371
- this.callbacks.push(async () => {
372
- if ((await deliver(sessionId, delivered)) === false)
373
- return;
374
- await this.deleteWake(id);
426
+ this.outbox.push({
427
+ kind: "wake",
428
+ id,
429
+ request: { kind: "wake", sessionId, wake: outboxWake(id, wake.reason, wake.dedupeKey ?? null) },
375
430
  });
376
431
  }
377
- async pendingWakes(before, limit) {
432
+ async signalSandbox(sandboxId, signal) {
378
433
  this.check();
379
- const rows = await this.db
380
- .select()
434
+ const id = randomUUID();
435
+ const row = {
436
+ kind: signal.kind,
437
+ timer: signal.kind === "arm" ? signal.timer : null,
438
+ at: signal.kind === "arm" ? signal.at : null,
439
+ };
440
+ await this.db.insert(sandboxSignals).values({ id, sandboxId, ...row });
441
+ this.outbox.push({
442
+ kind: "sandbox",
443
+ id,
444
+ request: { kind: "sandbox", sandboxId, signal: outboxSignal(id, row) },
445
+ });
446
+ }
447
+ async pendingOutbox(graceMs, limit) {
448
+ this.check();
449
+ // Compared on the database's clock, which wrote `created_at` and `retry_at`.
450
+ const now = sql `clock_timestamp()`;
451
+ const wake = DUE(wakes, graceMs);
452
+ const signal = DUE(OUTBOX.sandbox, graceMs);
453
+ const wakeRows = await this.db
454
+ .select({ ...getTableColumns(wakes), dueSeconds: wake.dueSeconds })
381
455
  .from(wakes)
382
- .where(lt(wakes.createdAt, before))
383
- .orderBy(wakes.createdAt, wakes.id)
456
+ .where(and(isNull(wakes.parkedAt), lte(wake.due, now)))
457
+ .orderBy(wake.due, wakes.id)
384
458
  .limit(limit);
385
- return rows.map((row) => ({
386
- id: row.id,
387
- sessionId: row.sessionId,
388
- wake: outboxWake(row.id, row.reason, row.dedupeKey),
389
- createdAt: row.createdAt.toISOString(),
390
- }));
459
+ const signalRows = await this.db
460
+ .select({ ...getTableColumns(sandboxSignals), dueSeconds: signal.dueSeconds })
461
+ .from(sandboxSignals)
462
+ .where(and(isNull(sandboxSignals.parkedAt), lte(signal.due, now)))
463
+ .orderBy(signal.due, sandboxSignals.id)
464
+ .limit(limit);
465
+ const rows = [
466
+ ...wakeRows.map((row) => ({
467
+ kind: "wake",
468
+ id: row.id,
469
+ request: {
470
+ kind: "wake",
471
+ sessionId: row.sessionId,
472
+ wake: outboxWake(row.id, row.reason, row.dedupeKey),
473
+ },
474
+ createdAt: row.createdAt.toISOString(),
475
+ attempts: row.attempts,
476
+ due: row.dueSeconds,
477
+ })),
478
+ ...signalRows.map((row) => ({
479
+ kind: "sandbox",
480
+ id: row.id,
481
+ request: { kind: "sandbox", sandboxId: row.sandboxId, signal: outboxSignal(row.id, row) },
482
+ createdAt: row.createdAt.toISOString(),
483
+ attempts: row.attempts,
484
+ due: row.dueSeconds,
485
+ })),
486
+ ];
487
+ rows.sort((a, b) => a.due - b.due || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
488
+ return rows.slice(0, limit).map(({ due: _due, ...row }) => row);
489
+ }
490
+ async deleteOutbox(rows) {
491
+ this.check();
492
+ for (const kind of ["wake", "sandbox"]) {
493
+ const ids = rows.filter((row) => row.kind === kind).map((row) => row.id);
494
+ if (ids.length > 0)
495
+ await this.db.delete(OUTBOX[kind]).where(inArray(OUTBOX[kind].id, ids));
496
+ }
391
497
  }
392
- async deleteWakes(ids) {
498
+ async failOutbox(row, retry) {
393
499
  this.check();
394
- if (ids.length === 0)
395
- return;
396
- await this.db.delete(wakes).where(inArray(wakes.id, [...ids]));
500
+ const table = OUTBOX[row.kind];
501
+ const secs = Math.max(0, retry.retryInMs) / 1000;
502
+ await this.db
503
+ .update(table)
504
+ .set({
505
+ attempts: sql `${table.attempts} + 1`,
506
+ retryAt: sql `clock_timestamp() + make_interval(secs => ${secs})`,
507
+ ...(retry.park ? { parkedAt: sql `clock_timestamp()` } : {}),
508
+ })
509
+ .where(eq(table.id, row.id));
397
510
  }
398
511
  // --- ownership -----------------------------------------------------------
399
512
  async takeOwnership(sessionId, claim) {
@@ -470,12 +583,11 @@ class PostgresTx {
470
583
  .orderBy(sessions.id);
471
584
  return rows.map((row) => storedSession(row));
472
585
  }
473
- async orphanedSessions(now, limit) {
586
+ async orphanedSessions(now, limit, after) {
474
587
  this.check();
475
588
  const rows = await this.sessions()
476
- .where(and(inArray(sessions.status, OPEN_SESSION), or(isNull(sessions.owner), isNull(sessions.ownerExpiresAt), lte(sessions.ownerExpiresAt, now))))
477
- // Never-owned sessions first, then the longest expired.
478
- .orderBy(sql `CASE WHEN ${sessions.owner} IS NULL THEN NULL ELSE ${sessions.ownerExpiresAt} END ASC NULLS FIRST`, sessions.id)
589
+ .where(and(inArray(sessions.status, OPEN_SESSION), or(isNull(sessions.owner), isNull(sessions.ownerExpiresAt), lte(sessions.ownerExpiresAt, now)), after === undefined ? undefined : gt(sessions.id, after)))
590
+ .orderBy(sessions.id)
479
591
  .limit(limit);
480
592
  return rows.map((row) => storedSession(row));
481
593
  }
@@ -1120,6 +1232,7 @@ class PostgresTx {
1120
1232
  await db.delete(sandboxes);
1121
1233
  await db.delete(sandboxResources);
1122
1234
  await db.delete(sandboxEvents);
1235
+ await db.delete(sandboxSignals);
1123
1236
  }
1124
1237
  if (scope === "all") {
1125
1238
  await db.delete(definitions);
@@ -10,8 +10,8 @@
10
10
  * 1. **One transaction per `tx` call, READ COMMITTED or stronger.** Nothing a
11
11
  * transaction wrote is visible to others before it commits, and nothing is
12
12
  * kept when `fn` throws: document writes, event sequences, record rows,
13
- * and `afterCommit` callbacks are all discarded, and `tx` rejects with the
14
- * error `fn` threw.
13
+ * and outbox requests are all discarded, and `tx` rejects with the error `fn`
14
+ * threw.
15
15
  * 2. **Session-scoped writes lock the session row first.** `lockSession` takes
16
16
  * a row lock (`SELECT … FOR UPDATE`) held until the transaction ends. Effect
17
17
  * intent and outcome, checkpoint settlement and event
@@ -25,18 +25,22 @@
25
25
  * generation, `streams/basin.ts`), and the cursor is
26
26
  * `base64url("<sessionId>:<seq>")` (see `record/cursor.ts`).
27
27
  * 4. **No external I/O inside `fn`.** No model, tool, MCP, sandbox, Restate or
28
- * S2 call, and no `fetch`, runs inside a transaction. A wake is written in the
29
- * transaction (`Tx.wake`, the wake outbox) and delivered after commit, and events
30
- * are delivered to commit listeners after commit (seam rule 1 and 2).
28
+ * S2 call, and no `fetch`, runs inside a transaction. What a transaction asks of
29
+ * Durable Session Execution (a wake, a pod sandbox's reconcile or timer) is written
30
+ * to the outbox in the transaction (`Tx.wake`, `Tx.signalSandbox`) and delivered
31
+ * after commit; what it tells the other processes goes on the control bus
32
+ * (`Tx.signal`); and events are delivered to commit listeners after commit (seam
33
+ * rule 1 and 2).
31
34
  * 5. **No nested transactions.** Calling `store.tx` from inside `fn` rejects.
32
35
  * A `Tx` must not be used after its `tx` call settles.
33
36
  * 6. **Post-commit order.** After a commit, the store first calls every commit
34
- * listener once with the transaction's events (in allocation order), then runs the `afterCommit` callbacks in registration
35
- * order and awaits them. Listener and callback failures are reported to the
36
- * store's error hook; they never reject `tx`, because the commit stands.
37
- * Recovery from a lost post-commit step is the Tenant sweep's job: a wake's
38
- * delivery is a callback too, and its outbox row is what the sweep delivers
39
- * again (`Tx.wake`).
37
+ * listener once with the transaction's events (in allocation order), then hands
38
+ * the transaction's outbox requests to the delivery (`deliverTo`), in the order
39
+ * they were written, without the caller waiting. Listener and delivery failures
40
+ * are reported to the store's error hook; they never reject `tx`, because the
41
+ * commit stands. A request whose delivery fails, or that a crash cuts off, stays
42
+ * in the outbox, which the Tenant sweep delivers again. Nothing else runs after
43
+ * commit: a step that is not written down is lost to a crash.
40
44
  * 7. **Documents are values.** `get` and queries return fresh copies; mutating
41
45
  * them changes nothing until `put`.
42
46
  * 8. **Ownership columns are store-managed.** `owner`, `epoch` and
@@ -58,7 +62,7 @@
58
62
  */
59
63
  import type { KeyRole } from "@nylorun/core/compatibility";
60
64
  import type { RecordReader } from "../streams/relay/types.js";
61
- import type { Wake } from "../execution/types.js";
65
+ import type { SandboxSignal, Wake } from "../execution/types.js";
62
66
  import type { EventPayload, EventType, LiveEvent, SandboxEvent, SandboxEventPayload, SandboxEventType, SandboxKind, SandboxPodDesired, SandboxPodObserved, SessionEventOf } from "@nylorun/core/contracts";
63
67
  import type { SandboxManifest } from "@nylorun/core/define";
64
68
  import type { HostEffect } from "@nylorun/harness/run";
@@ -272,6 +276,14 @@ export interface SessionStore {
272
276
  * throw; what it throws goes to `onError`, as do listener failures.
273
277
  */
274
278
  followSignals(onSignal: (signal: ControlSignal) => void, options?: FollowSignalsOptions): Promise<SignalFollower>;
279
+ /**
280
+ * Sets where committed outbox requests go (`Tx.wake`, `Tx.signalSandbox`): after each
281
+ * commit the store hands them to `deliver` without waiting for it, so a slow execution
282
+ * never holds up the caller. Without one, they wait in the outbox for the Tenant sweep.
283
+ */
284
+ deliverTo(deliver: OutboxDelivery | undefined): void;
285
+ /** Resolves once every delivery started so far has settled (`close` waits for them too). */
286
+ delivered(): Promise<void>;
275
287
  close(): Promise<void>;
276
288
  }
277
289
  /** A wake as the outbox delivers it: its `dedupeKey` is the request's idempotency key. */
@@ -279,18 +291,38 @@ export type OutboxWake = Wake & {
279
291
  dedupeKey: string;
280
292
  };
281
293
  /**
282
- * Hands an outbox request to Durable Session Execution (`Tx.wake`). Resolving means the
283
- * execution accepted it; `false` means it was not delivered and stays in the outbox, as a
284
- * rejection does, without an error.
294
+ * What a committed transaction asked of Durable Session Execution, as the outbox delivers it:
295
+ * a wake (`Tx.wake`), or a pod sandbox's reconcile or timer (`Tx.signalSandbox`; a reconcile
296
+ * carries its idempotency key, `signal:<row id>`).
285
297
  */
286
- export type WakeDelivery = (sessionId: string, wake: OutboxWake) => boolean | void | Promise<boolean | void>;
287
- /** A wake outbox request not yet delivered (`Tx.pendingWakes`). */
288
- export interface PendingWake {
289
- id: string;
298
+ export type OutboxRequest = {
299
+ kind: "wake";
290
300
  sessionId: string;
291
301
  wake: OutboxWake;
302
+ } | {
303
+ kind: "sandbox";
304
+ sandboxId: string;
305
+ signal: SandboxSignal;
306
+ };
307
+ /**
308
+ * Hands an outbox request to Durable Session Execution (`SessionStore.deliverTo`). Resolving
309
+ * means the execution accepted it; `false` means it was not delivered and stays in the
310
+ * outbox, due at the sweep's next pass, as after a rejection, which also goes to the store's
311
+ * error hook.
312
+ */
313
+ export type OutboxDelivery = (request: OutboxRequest) => boolean | void | Promise<boolean | void>;
314
+ /** An outbox row: which table (`kind`) and its id. */
315
+ export type OutboxRef = {
316
+ kind: OutboxRequest["kind"];
317
+ id: string;
318
+ };
319
+ /** An outbox request not yet delivered (`Tx.pendingOutbox`). */
320
+ export interface PendingRequest extends OutboxRef {
321
+ request: OutboxRequest;
292
322
  /** ISO time it was written. */
293
323
  createdAt: string;
324
+ /** The sweep's failed deliveries of it so far (`failOutbox`). */
325
+ attempts: number;
294
326
  }
295
327
  /**
296
328
  * A signal on the control bus (D21, D48): one process tells the others with the Tenant open.
@@ -307,6 +339,15 @@ export type ControlSignal = {
307
339
  /** The Tenant's sessions were reset and it moved to basin generation `generation`. */
308
340
  type: "sessions.reset";
309
341
  generation: number;
342
+ } | {
343
+ /**
344
+ * Pod sandbox `sandboxId`'s host epoch moved to `epoch` (a join, a stop, a reset, a
345
+ * deletion, a loss): every process closes its connections hosting it at an older epoch
346
+ * (`HarnessApiServer.revokeHost`).
347
+ */
348
+ type: "host.revoked";
349
+ sandboxId: string;
350
+ epoch: number;
310
351
  };
311
352
  export interface FollowSignalsOptions {
312
353
  /** How often recent signals are read back. Default 5 s. */
@@ -327,7 +368,7 @@ export interface SessionStoreOptions {
327
368
  tenantId: string;
328
369
  /** Clock for event `time`. Defaults to `() => new Date()`. */
329
370
  now?: () => Date;
330
- /** Receives listener and `afterCommit` failures, which never reject `tx`. */
371
+ /** Receives listener and outbox delivery failures, which never reject `tx`. */
331
372
  onError?: (error: unknown) => void;
332
373
  }
333
374
  /** The transaction handle. Valid only inside its `tx` callback. */
@@ -366,28 +407,43 @@ export interface Tx {
366
407
  * cursor is final.
367
408
  */
368
409
  event<T extends EventType>(sessionId: string, turnId: string | null, type: T, payload: EventPayload<T>): Promise<SessionEventOf<T>>;
369
- /**
370
- * Runs `fn` after a successful commit (never on rollback): a sandbox signal or a host
371
- * revocation. Nothing writes it down, so a crash after commit loses it. A wake goes
372
- * through `wake`, which does.
373
- */
374
- afterCommit(fn: () => void | Promise<void>): void;
375
410
  /**
376
411
  * Asks for an advance of `sessionId` once this transaction commits. The request is a row
377
412
  * of the wake outbox written in this transaction, so it commits, or rolls back, with what
378
- * caused it: a commit never loses its wake. After commit, in `afterCommit` order, the
379
- * store calls `deliver` (`DurableExecution.wake`, through the Tenant) and deletes the row
380
- * once it resolves to anything but `false`. A request `deliver` declines (`false`) or
381
- * rejects, or one a crash cut off, stays in the outbox until the Tenant sweep delivers it
382
- * (`pendingWakes`, `deleteWakes`). `deliver` gets the request's idempotency key as
383
- * `dedupeKey`: the wake's own, or `wake:<row id>` without one, so a request delivered
384
- * twice still causes one advance.
413
+ * caused it: a commit never loses its wake. After commit the store hands it to the
414
+ * delivery (`SessionStore.deliverTo`, `DurableExecution.wake` through the Tenant) without
415
+ * waiting, and deletes the row once that resolves to anything but `false`. A request the
416
+ * delivery declines or rejects is due at the sweep's next pass; one a crash cut off, or
417
+ * still being sent, is due once it is older than the sweep's grace (`pendingOutbox`). The
418
+ * delivery gets the request's idempotency key as `dedupeKey`: the wake's own, or
419
+ * `wake:<row id>` without one, so a request delivered twice still causes one advance.
420
+ */
421
+ wake(sessionId: string, wake: Wake): Promise<void>;
422
+ /**
423
+ * Asks for a pod sandbox's reconcile, or sets one of its timers, once this transaction
424
+ * commits (`DurableExecution.sandbox`): a row of the sandbox signal outbox, delivered as a
425
+ * wake's is. A reconcile is delivered with the key `signal:<row id>`, so one delivered
426
+ * twice runs once.
427
+ */
428
+ signalSandbox(sandboxId: string, signal: SandboxSignal): Promise<void>;
429
+ /**
430
+ * Outbox requests that are due, by the database's clock: written more than `graceMs` ago,
431
+ * or whose delivery failed and whose retry time has come (`failOutbox`), and not parked.
432
+ * Oldest first, a retried one by its retry time, so a request that keeps failing never
433
+ * holds the head of the queue.
385
434
  */
386
- wake(sessionId: string, wake: Wake, deliver: WakeDelivery): Promise<void>;
387
- /** Outbox requests written before `before`, oldest first. */
388
- pendingWakes(before: Date, limit: number): Promise<PendingWake[]>;
435
+ pendingOutbox(graceMs: number, limit: number): Promise<PendingRequest[]>;
389
436
  /** Deletes delivered outbox requests. */
390
- deleteWakes(ids: readonly string[]): Promise<void>;
437
+ deleteOutbox(rows: readonly OutboxRef[]): Promise<void>;
438
+ /**
439
+ * Records a failed delivery of outbox request `row`: one more attempt, due again `retryInMs`
440
+ * from now (the database's clock). With `park` it is never due again: it stays in the
441
+ * outbox, with when it was parked, for an operator to send again or delete.
442
+ */
443
+ failOutbox(row: OutboxRef, retry: {
444
+ retryInMs: number;
445
+ park: boolean;
446
+ }): Promise<void>;
391
447
  /**
392
448
  * Locks the session and takes ownership when it has no owner or the owner's
393
449
  * lease ended at or before `now`; then increments `epoch` and sets the lease
@@ -416,9 +472,10 @@ export interface Tx {
416
472
  sessionsWithStatus<T extends SessionDoc = SessionDoc>(statuses: readonly (SessionStatus | (string & {}))[]): Promise<StoredSession<T>[]>;
417
473
  /**
418
474
  * Sessions `running` or `runnable` with no owner or an owner whose lease
419
- * ended at or before `now`, oldest lease first. The sweep re-wakes these.
475
+ * ended at or before `now`, by id, the first `limit` after `after` (a page:
476
+ * pass the last id of the previous one). The sweep re-wakes these.
420
477
  */
421
- orphanedSessions<T extends SessionDoc = SessionDoc>(now: Date, limit: number): Promise<StoredSession<T>[]>;
478
+ orphanedSessions<T extends SessionDoc = SessionDoc>(now: Date, limit: number, after?: string): Promise<StoredSession<T>[]>;
422
479
  /** Sessions, optionally of one agent and one owner (`ownerUserId`), by id. */
423
480
  listSessions<T extends SessionDoc = SessionDoc>(filter?: {
424
481
  agentId?: string;
@@ -22,6 +22,7 @@
22
22
  *
23
23
  * The relay never writes the record and never deletes from S2.
24
24
  */
25
+ import { sleep } from "../../retry.js";
25
26
  import { basinOf } from "../basin.js";
26
27
  import { sessionStream } from "../types.js";
27
28
  const RETRY_MIN_MS = 50;
@@ -351,16 +352,3 @@ export function createStreamRelay(options) {
351
352
  };
352
353
  }
353
354
  const messageOf = (error) => (error instanceof Error ? error.message : String(error));
354
- function sleep(ms, signal) {
355
- return new Promise((resolve) => {
356
- if (signal.aborted)
357
- return resolve();
358
- const done = () => {
359
- clearTimeout(timer);
360
- signal.removeEventListener("abort", done);
361
- resolve();
362
- };
363
- const timer = setTimeout(done, ms);
364
- signal.addEventListener("abort", done, { once: true });
365
- });
366
- }