@nylorun/runtime 0.21.1-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 (84) hide show
  1. package/CHANGELOG.md +32 -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/sandbox/local.js +1 -1
  6. package/dist/adapters/sandbox/virtual.js +1 -1
  7. package/dist/adapters/streams/s2.js +1 -13
  8. package/dist/api/components.d.ts +42 -2
  9. package/dist/core/flow-host.d.ts +1 -19
  10. package/dist/core/flow-host.js +8 -37
  11. package/dist/execution/types.d.ts +10 -8
  12. package/dist/execution/types.js +5 -7
  13. package/dist/gates/egress-credentials.d.ts +48 -0
  14. package/dist/gates/egress-credentials.js +147 -0
  15. package/dist/gates/egress.d.ts +3 -0
  16. package/dist/gates/egress.js +14 -1
  17. package/dist/gates/tenant-vaults.d.ts +4 -0
  18. package/dist/gates/tenant-vaults.js +2 -0
  19. package/dist/harness/calls.d.ts +2 -0
  20. package/dist/harness/calls.js +1 -0
  21. package/dist/harness/executors.js +1 -0
  22. package/dist/harness/main.js +4 -1
  23. package/dist/harness/pod.d.ts +2 -0
  24. package/dist/harness/pod.js +36 -1
  25. package/dist/harness-api/start.d.ts +2 -0
  26. package/dist/harness-api/start.js +4 -2
  27. package/dist/harness-api/ws-server.js +2 -2
  28. package/dist/host/execution.d.ts +8 -6
  29. package/dist/host/gates.js +11 -0
  30. package/dist/host/main.js +10 -16
  31. package/dist/host/stack-config.d.ts +6 -0
  32. package/dist/host/stack-config.js +6 -0
  33. package/dist/infra/execution.d.ts +18 -1
  34. package/dist/infra/execution.js +28 -4
  35. package/dist/infra/index.d.ts +1 -1
  36. package/dist/keys/egress-ca.d.ts +15 -0
  37. package/dist/keys/egress-ca.js +66 -0
  38. package/dist/keys/keys.d.ts +8 -1
  39. package/dist/keys/keys.js +6 -0
  40. package/dist/keys/x509.d.ts +29 -0
  41. package/dist/keys/x509.js +160 -0
  42. package/dist/management-openapi.json +195 -9
  43. package/dist/openapi.json +2 -1
  44. package/dist/retry.d.ts +21 -0
  45. package/dist/retry.js +34 -0
  46. package/dist/sandbox/join.d.ts +2 -0
  47. package/dist/sandbox/join.js +14 -10
  48. package/dist/sandbox/manager.d.ts +2 -0
  49. package/dist/sandbox/manager.js +1 -1
  50. package/dist/sandbox/pods/reconcile.js +3 -3
  51. package/dist/sandbox/tools.d.ts +3 -1
  52. package/dist/sandbox/tools.js +4 -2
  53. package/dist/sandbox/types.d.ts +5 -0
  54. package/dist/store/postgres/control.js +8 -1
  55. package/dist/store/postgres/drizzle/0018_wake_outbox_retries.sql +3 -0
  56. package/dist/store/postgres/drizzle/0019_sandbox_signal_outbox.sql +18 -0
  57. package/dist/store/postgres/drizzle/meta/_journal.json +14 -0
  58. package/dist/store/postgres/schema.d.ts +265 -4
  59. package/dist/store/postgres/schema.js +35 -4
  60. package/dist/store/postgres/store.js +165 -52
  61. package/dist/store/types.d.ts +96 -39
  62. package/dist/streams/relay/core.js +1 -13
  63. package/dist/tenant/advance.js +6 -7
  64. package/dist/tenant/commands.js +1 -2
  65. package/dist/tenant/context.d.ts +19 -11
  66. package/dist/tenant/control.d.ts +8 -1
  67. package/dist/tenant/control.js +2 -0
  68. package/dist/tenant/runtime.js +23 -3
  69. package/dist/tenant/sandboxes.d.ts +2 -1
  70. package/dist/tenant/sandboxes.js +23 -19
  71. package/dist/tenant/scheduler.d.ts +7 -0
  72. package/dist/tenant/scheduler.js +38 -7
  73. package/dist/tenant/session-streams.d.ts +0 -1
  74. package/dist/tenant/session-streams.js +1 -13
  75. package/dist/tenant/sessions.js +26 -0
  76. package/dist/tenant/streams.js +14 -29
  77. package/dist/tenant/sweep.d.ts +39 -12
  78. package/dist/tenant/sweep.js +157 -58
  79. package/dist/tenant/transcript.d.ts +8 -8
  80. package/dist/vault/service.d.ts +34 -0
  81. package/dist/vault/service.js +148 -15
  82. package/dist/version.d.ts +1 -1
  83. package/dist/version.js +1 -1
  84. package/package.json +4 -3
@@ -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
- }
@@ -42,6 +42,7 @@
42
42
  import { runDurable, runFlowDurable, } from "@nylorun/harness/run";
43
43
  import { aggregateWaits, cancelSiblingWork, isWorkflowManifest, wakeLinkedWorkflow, } from "../core/flow-host.js";
44
44
  import { isOwnershipLost, ownedTx } from "../store/ownership.js";
45
+ import { endAdvance } from "./scheduler.js";
45
46
  import { isGateToolEffect, recoversModelCalls, recoversToolCalls } from "./effects.js";
46
47
  import { slimModelEffects } from "./slim.js";
47
48
  import { applyUpdates, leanState, transcriptShadow, withTranscript, } from "./history.js";
@@ -169,10 +170,7 @@ export async function advance(ctx, id, signal) {
169
170
  heartbeat.stop();
170
171
  dropRunGrant(ctx, lease);
171
172
  signal.removeEventListener("abort", forward);
172
- if (ctx.work.running.get(id) === controller) {
173
- ctx.work.running.delete(id);
174
- ctx.work.runningTurns.delete(id);
175
- }
173
+ endAdvance(ctx.work, id, controller);
176
174
  // Best effort: a release that fails leaves a lease that simply expires.
177
175
  if (release)
178
176
  await ctx.store
@@ -395,7 +393,6 @@ async function wakeWorkflowOf(ctx, t, agent, turnId, type, payload) {
395
393
  error: type === "turn.failed"
396
394
  ? String(payload.message ?? "")
397
395
  : undefined,
398
- schedule: ctx.wake,
399
396
  });
400
397
  else if (type === "turn.cancelled")
401
398
  await wakeLinkedWorkflow({
@@ -404,7 +401,6 @@ async function wakeWorkflowOf(ctx, t, agent, turnId, type, payload) {
404
401
  turnId,
405
402
  cancelled: true,
406
403
  error: "Agent turn was cancelled",
407
- schedule: ctx.wake,
408
404
  });
409
405
  }
410
406
  /**
@@ -473,7 +469,10 @@ async function settle(ctx, lease, s, result, updates = [], cursor) {
473
469
  await slimModelEffects(t, id, s.activeTurnId);
474
470
  await t.put("sessions", id, current);
475
471
  const segment = finished.segment + 1;
476
- await t.wake(id, { reason: "rollover", dedupeKey: `rollover:${s.activeTurnId}:${segment}` }, ctx.wake);
472
+ await t.wake(id, {
473
+ reason: "rollover",
474
+ dedupeKey: `rollover:${s.activeTurnId}:${segment}`,
475
+ });
477
476
  return siblings;
478
477
  }
479
478
  const flow = isWorkflowManifest(current.manifest);
@@ -231,7 +231,7 @@ export async function command(ctx, id, input, scope) {
231
231
  reason: command.type,
232
232
  dedupeKey: `${command.type}:${s.activeTurnId}:${cause}`,
233
233
  };
234
- await t.wake(id, commandWake, ctx.wake);
234
+ await t.wake(id, commandWake);
235
235
  event = await t.event(id, s.activeTurnId, `command.${command.type}`, command);
236
236
  }
237
237
  await t.put("sessions", id, s);
@@ -244,7 +244,6 @@ export async function command(ctx, id, input, scope) {
244
244
  turnId: cancelledTurnId,
245
245
  cancelled: true,
246
246
  error: "Agent turn was cancelled",
247
- schedule: ctx.wake,
248
247
  });
249
248
  const response = {
250
249
  status: "accepted",