@nylorun/runtime 0.21.0-beta → 0.21.1-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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.21.1-beta
4
+
5
+ ### Patch Changes
6
+
7
+ - 070674d: **One source of truth for the manifest and Harness API schemas.**
8
+
9
+ - `@nylorun/core`: `Agent.from` (`agentFrom`) checks a manifest with `AgentManifestSchema`, the Runtime's own check, instead of a validator of its own, and refuses with `agent.build-failed` and the schema's messages (each prefixed with its path, such as `capabilities.0.tools.0.name: …`). It now refuses what the Runtime refuses at registration and it let through, such as unknown fields (it dropped them) or a tool or MCP server name that not every model provider accepts. The schema names a top-level `model` or `schemaVersion` and a capability's `model` as before, and the skill whose `files` are wrong (`Skill 'triage' files must include SKILL.md`). The rebuilt manifest is a frozen copy; the object passed in is no longer frozen. `AgentManifestSchema` and `WorkflowManifestSchema` are no longer force-cast to their types: the build checks that each accepts exactly `AgentManifest` and `WorkflowManifest`. `@nylorun/core/contracts` exports `McpServerManifestSchema`, `HEADER_NAME_PATTERN` and `LOOPBACK_HOSTS`. The Harness API's types (`@nylorun/core/harness-api`) are inferred from the schemas that validate its frames, so they match them: `TurnStart.manifest`, `TurnStart.checkpoint` and `RunRouting.rootManifest` are `object`, `WorkspaceCall.tool` is a sandbox tool name, `ABORT_REASONS` is a tuple, and properties are no longer `readonly` (arrays that were stay so).
10
+ - `@nylorun/harness`: `runDurable` checks the manifest once, when it rebuilds the agent, instead of twice. The unpublished, stale `schemas/manifest.schema.json` (manifest `schemaVersion` 2) is gone; `z.toJSONSchema(AgentManifestSchema)` is the manifest's JSON Schema.
11
+ - `@nylorun/agents`: a plugin's MCP servers are checked with core's `McpServerManifestSchema`, header-name pattern and loopback hosts. A server whose key is not a name every model provider accepts is skipped with a `plugin.mcp-server-skipped` warning, since the Runtime would refuse the manifest declaring it.
12
+ - `@nylorun/runtime`: the stack configuration reads its four listeners (`NYLORUN_LISTEN_*`, `NYLORUN_GATES_LISTEN_*`, `NYLORUN_HARNESS_LISTEN_*`, `NYLORUN_EGRESS_LISTEN_*`) with one parser; the variables and their errors are unchanged.
13
+
14
+ - 34d1a81: A commit can no longer lose its wake. A transaction that asks for an advance writes the wake to the Session Store's wake outbox (`Tx.wake`, migration `0017_wake_outbox` adds `nylorun.wakes`) and sends it to Durable Session Execution after commit, with an idempotency key (the wake's dedupe key, or one made from its row), deleting the row once Restate accepted it. A send that fails, or that a crash between the commit and the send cut off, is sent again by the Tenant sweep under the same key: on a process's first pass for any row, then for rows older than 2 s. Restate deduplicates the repeat, so a wake whose answer was lost runs one advance. The sweep's orphan scan no longer delivers lost wakes: it runs on a process's first pass and then once a minute, as the backstop for what the execution itself lost (Restate state wiped, an invocation killed, an in-process execution's queue). Restate owns when a session runs (wakes, one advance per session, retries, timers); the Session Store owns what happened (checkpoint, effect journal, events, the lease's epoch and the outbox), as `src/CONTEXT.md` and `adapters/execution/restate.ts` now describe. A Runtime of the previous beta refuses the migrated database (`schema-too-new`); while processes of both run, the old ones send wakes as before.
15
+ - Pin core to the tested release.
16
+ - Pin harness to the tested release.
17
+ - Updated dependencies [417f336]
18
+ - Updated dependencies [6fecd4c]
19
+ - Updated dependencies [7780b2f]
20
+ - Updated dependencies [070674d]
21
+ - Updated dependencies
22
+ - @nylorun/core@0.18.0-beta
23
+ - @nylorun/harness@0.25.0-beta
24
+
3
25
  ## 0.21.0-beta
4
26
 
5
27
  ### Major Changes
package/README.md CHANGED
@@ -42,8 +42,10 @@ whose key the admin key derives (the only derived key). Management keys come fro
42
42
  rotates and deletes the Tenant's application keys (`PUT /v1/tenant/keys/{keyId}`); a rotated
43
43
  or deleted key stops authenticating at once. A database written by a Runtime that kept several Tenants in one
44
44
  database (`tenant_<id>` schemas), or by a pre-release build of one Tenant per database
45
- (`schema_version` tables), is refused: this release starts fresh on a new database. Restate runs one advance of a session at a time and holds the Tenant's sweep
46
- timer (Durable Session Execution); every session's events are relayed from the record to
45
+ (`schema_version` tables), is refused: this release starts fresh on a new database. Restate runs one advance of a session at a time, delivers its wakes and holds
46
+ its timers and the Tenant's sweep (Durable Session Execution), and journals no effect:
47
+ checkpoints, effects, events and the wake outbox are the Session Store's, and a wake is
48
+ written to the outbox in the transaction that causes it, so a commit never loses it; every session's events are relayed from the record to
47
49
  its own S2 stream, which history and SSE read (Durable Streams). One image runs every **service**,
48
50
  and `--service` picks what a process runs: `core` serves the Runtime API, the
49
51
  Management API and SSE and runs the stream relay, `loop` runs advances (the Worker), and
@@ -25,7 +25,32 @@
25
25
  * What a run answers arms its timers and its retry.
26
26
  *
27
27
  * Wakes, sandbox reconciles, timers and sweep arming go through the ingress as one-way
28
- * sends, so an API node can call them without ever calling `start`.
28
+ * sends, so an API node can call them without ever calling `start`. A wake a commit asked for
29
+ * comes from the Session Store's wake outbox (`Tx.wake`) with an idempotency key, so a send
30
+ * repeated after its answer was lost causes no second advance.
31
+ *
32
+ * ## What Restate owns, and what the Session Store owns
33
+ *
34
+ * Restate owns when a session runs: it delivers each wake at least once, deduped by its
35
+ * key for `dedupeRetentionMs`; runs at most one invocation per key at a time; retries an
36
+ * invocation whose attempt failed, so an advance whose Worker died runs again and takes the
37
+ * session over (§11.4); and keeps every timer: the busy re-wake, `NylorunTimer`,
38
+ * `NylorunSandbox` and the `NylorunTenant` sweep chain. The Session Store (Postgres) owns
39
+ * what happened: the checkpoint, the effect journal (`harness-api/record.ts`), the events
40
+ * (the Record), and the wake outbox that hands each committed wake to Restate. Restate
41
+ * journals no effect: an effect's intent and outcome commit with the session's events in
42
+ * one transaction, under the advance's lease, and remote harnesses and pod engines write
43
+ * them too, over the Harness API.
44
+ *
45
+ * Two Session Store mechanisms overlap Restate on purpose:
46
+ *
47
+ * - **The lease and its epoch** (§10.6). Restate's one invocation per key holds for an
48
+ * attempt, not for the code it started: an attempt that ends (abort timeout, a dropped
49
+ * connection) is retried while the previous attempt's handler may still run on its Worker,
50
+ * and a remote harness or pod engine may still hold the run. The epoch fences their writes,
51
+ * and `busy` answers the overlap.
52
+ * - **The advance deadline** (`tenant/worker.ts`). Restate's abort timeout does not stop a
53
+ * running handler, so the Worker bounds each advance itself, below `timeouts`.
29
54
  */
30
55
  import { createServer } from "node:http2";
31
56
  import * as restate from "@restatedev/restate-sdk";
@@ -1,8 +1,7 @@
1
1
  import type { EffectOutcome } from "@nylorun/core/contracts";
2
2
  import { type JsonValue, type WorkflowManifest } from "@nylorun/core/define";
3
3
  import type { HostEffect } from "@nylorun/harness/run";
4
- import type { Wake } from "../execution/types.js";
5
- import type { EffectDoc, Tx } from "../store/types.js";
4
+ import type { EffectDoc, Tx, WakeDelivery } from "../store/types.js";
6
5
  import { type FlowLimits } from "./limits.js";
7
6
  /** Deterministic agent session id: derive(workflowSessionId, path, …parts). */
8
7
  export declare function deriveSessionId(workflowSessionId: string, path: string, ...parts: string[]): string;
@@ -99,7 +98,7 @@ export type CascadeCancelPlan = {
99
98
  readonly agentSessionIds: string[];
100
99
  };
101
100
  /** Wakes a session after commit (`DurableExecution.wake` through the Tenant context). */
102
- type Schedule = (sessionId: string, wake: Wake) => void | Promise<void>;
101
+ type Schedule = WakeDelivery;
103
102
  /** Effect documents as the flow host writes them. */
104
103
  export type FlowEffect = EffectDoc & {
105
104
  agentSessionId?: string;
@@ -4,7 +4,8 @@
4
4
  * Every function that touches state takes the caller's transaction `t: Tx` and
5
5
  * is async. Nothing here publishes, notifies or schedules directly (seam rule
6
6
  * 1): events go through `t.event(...)`, which the store publishes after
7
- * commit, and wakes go through `t.afterCommit(() => schedule(id, wake))`. Functions
7
+ * commit, and wakes go through `t.wake(id, wake, schedule)`, the wake outbox, which
8
+ * calls `schedule` after commit. Functions
8
9
  * that rewrite a session-scoped document lock that session first with
9
10
  * `t.lockSession` (a no-op when the caller already holds it).
10
11
  *
@@ -35,8 +36,9 @@
35
36
  * - `foreignInteractionConflict({ t, workflowSessionId, interactionId }): Promise<{ status: 409; message; ownerSessionId } | undefined>`
36
37
  * - `wakeForQueuedEffects({ t, workflowSessionId, turnId, limits, schedule }): Promise<boolean>`
37
38
  *
38
- * `schedule: (sessionId, wake) => void | Promise<void>` runs after commit, never inside `t`;
39
- * `wake` carries the reason (`linked`, `flow`) and, where the cause has one, a dedupe key.
39
+ * `schedule` (a `WakeDelivery`, the Tenant's `ctx.wake`) runs after commit, never inside `t`;
40
+ * `wake` carries the reason (`linked`, `flow`) and, where the cause has one, a dedupe key. A
41
+ * wake `schedule` does not deliver stays in the outbox for the Tenant sweep (`Tx.wake`).
40
42
  *
41
43
  * An `agent` effect settles only from the linked turn it started. A linked agent session is
42
44
  * reused by every iteration of a Loop (its id derives from the workflow and the path), so its
@@ -138,8 +140,9 @@ export async function recordVerdict(t, request, value) {
138
140
  ...(verdict.data !== undefined ? { data: verdict.data } : {}),
139
141
  });
140
142
  }
141
- function scheduleAfterCommit(t, schedule, id, wake) {
142
- t.afterCommit(() => schedule(id, wake));
143
+ /** Writes the wake to the outbox; `schedule` delivers it after commit. */
144
+ function requestWake(t, schedule, id, wake) {
145
+ return t.wake(id, wake, schedule);
143
146
  }
144
147
  /** Active agent turns + tool nodes in flight for one workflow turn. */
145
148
  export async function countActiveFlowWork(t, workflowSessionId, turnId) {
@@ -242,7 +245,7 @@ export async function wakeLinkedWorkflow(input) {
242
245
  await settleAgentEffect({ t, effect, outcome });
243
246
  workflow.status = "runnable";
244
247
  await t.put("sessions", workflow.id, workflow);
245
- scheduleAfterCommit(t, input.schedule, workflow.id, {
248
+ await requestWake(t, input.schedule, workflow.id, {
246
249
  reason: "linked",
247
250
  dedupeKey: `linked:${link.turnId}:${link.effectId}`,
248
251
  });
@@ -471,7 +474,7 @@ export async function wakeForQueuedEffects(input) {
471
474
  workflow.status = "runnable";
472
475
  await t.put("sessions", workflow.id, workflow);
473
476
  }
474
- scheduleAfterCommit(t, input.schedule, input.workflowSessionId, {
477
+ await requestWake(t, input.schedule, input.workflowSessionId, {
475
478
  reason: "flow",
476
479
  });
477
480
  return true;
@@ -3,7 +3,10 @@
3
3
  *
4
4
  * Durable Session Execution decides when to look at a session again. It never
5
5
  * holds session state: every outcome lives in the Session Store, and `advance`
6
- * is safe to repeat (§10.1). Restate is the supported implementation
6
+ * is safe to repeat (§10.1). It owns the schedule (wake delivery, one advance per
7
+ * key, retries, timers, the sweep chain); the Session Store owns the record (the
8
+ * checkpoint, the effect journal, the events) and the wake outbox that bridges the
9
+ * two. Restate is the supported implementation
7
10
  * (`adapters/execution/restate.ts`); `execution/memory.ts` is the in-process
8
11
  * implementation used by unit tests, `startEphemeralRuntime` and a Host
9
12
  * started without Restate endpoints.
@@ -15,11 +18,14 @@
15
18
  * Different keys may run concurrently.
16
19
  * - **At least once after a wake.** When `wake` resolves, an `advance` for the
17
20
  * key will start after that point (possibly merged with other wakes). Wakes
18
- * are sent after the Session Store transaction that caused them commits
19
- * (`Tx.afterCommit`), never inside it.
21
+ * are sent after the Session Store transaction that caused them commits, never
22
+ * inside it: the transaction writes the wake to its outbox (`Tx.wake`), and the
23
+ * wake is sent after commit, or by the Tenant sweep when that send failed or a
24
+ * crash cut it off. A wake may therefore be sent more than once, always with the
25
+ * same `dedupeKey`.
20
26
  * - **Dedupe.** Wakes carrying the same `dedupeKey` for the same key cause at
21
27
  * most one advance for that cause (within the implementation's retention
22
- * window, at least 24 hours for Restate).
28
+ * window, at least 24 hours for Restate). Every wake from the outbox has one.
23
29
  * - **Busy re-wake.** An advance that returns `busy` is re-run for the same key
24
30
  * after `retryAfterMs`.
25
31
  * - **Retries on infrastructure errors.** A handler that throws is retried with
@@ -27,7 +33,9 @@
27
33
  * becomes `uncertain` is settled in the Session Store and `advance` returns
28
34
  * `done`.
29
35
  * - **Sweeps.** `armSweep` arms one self-re-arming sweep per Tenant; arming an
30
- * armed Tenant is a no-op. The sweep re-wakes orphaned `runnable` sessions.
36
+ * armed Tenant is a no-op. The sweep sends the outbox's undelivered wakes and,
37
+ * as a backstop for what an implementation lost, re-wakes orphaned sessions
38
+ * (`tenant/sweep.ts`).
31
39
  * - **One reconcile per sandbox at a time** (F7.2). `sandbox` runs
32
40
  * `WorkerHandlers.sandbox` for the key `<tenantId>:<sandboxId>` at least once
33
41
  * after a `reconcile` resolves, never overlapping another run for the key. A
@@ -3,7 +3,10 @@
3
3
  *
4
4
  * Durable Session Execution decides when to look at a session again. It never
5
5
  * holds session state: every outcome lives in the Session Store, and `advance`
6
- * is safe to repeat (§10.1). Restate is the supported implementation
6
+ * is safe to repeat (§10.1). It owns the schedule (wake delivery, one advance per
7
+ * key, retries, timers, the sweep chain); the Session Store owns the record (the
8
+ * checkpoint, the effect journal, the events) and the wake outbox that bridges the
9
+ * two. Restate is the supported implementation
7
10
  * (`adapters/execution/restate.ts`); `execution/memory.ts` is the in-process
8
11
  * implementation used by unit tests, `startEphemeralRuntime` and a Host
9
12
  * started without Restate endpoints.
@@ -15,11 +18,14 @@
15
18
  * Different keys may run concurrently.
16
19
  * - **At least once after a wake.** When `wake` resolves, an `advance` for the
17
20
  * key will start after that point (possibly merged with other wakes). Wakes
18
- * are sent after the Session Store transaction that caused them commits
19
- * (`Tx.afterCommit`), never inside it.
21
+ * are sent after the Session Store transaction that caused them commits, never
22
+ * inside it: the transaction writes the wake to its outbox (`Tx.wake`), and the
23
+ * wake is sent after commit, or by the Tenant sweep when that send failed or a
24
+ * crash cut it off. A wake may therefore be sent more than once, always with the
25
+ * same `dedupeKey`.
20
26
  * - **Dedupe.** Wakes carrying the same `dedupeKey` for the same key cause at
21
27
  * most one advance for that cause (within the implementation's retention
22
- * window, at least 24 hours for Restate).
28
+ * window, at least 24 hours for Restate). Every wake from the outbox has one.
23
29
  * - **Busy re-wake.** An advance that returns `busy` is re-run for the same key
24
30
  * after `retryAfterMs`.
25
31
  * - **Retries on infrastructure errors.** A handler that throws is retried with
@@ -27,7 +33,9 @@
27
33
  * becomes `uncertain` is settled in the Session Store and `advance` returns
28
34
  * `done`.
29
35
  * - **Sweeps.** `armSweep` arms one self-re-arming sweep per Tenant; arming an
30
- * armed Tenant is a no-op. The sweep re-wakes orphaned `runnable` sessions.
36
+ * armed Tenant is a no-op. The sweep sends the outbox's undelivered wakes and,
37
+ * as a backstop for what an implementation lost, re-wakes orphaned sessions
38
+ * (`tenant/sweep.ts`).
31
39
  * - **One reconcile per sandbox at a time** (F7.2). `sandbox` runs
32
40
  * `WorkerHandlers.sandbox` for the key `<tenantId>:<sandboxId>` at least once
33
41
  * after a `reconcile` resolves, never overlapping another run for the key. A
@@ -1,7 +1,7 @@
1
1
  import type { ResourceServerConfig } from "../tenant/resource-server.js";
2
2
  import type { Logger, TenantModule } from "../tenant/types.js";
3
3
  import type { HostConfigFile } from "./config.js";
4
- import type { ContainerListen } from "./stack-config.js";
4
+ import { type ContainerListen } from "./stack-config.js";
5
5
  export interface CreateHostOptions {
6
6
  hostRoot: string;
7
7
  module: TenantModule;
@@ -1,7 +1,8 @@
1
1
  import { createServer, } from "node:http";
2
2
  import { getRequestListener, RequestError } from "@hono/node-server";
3
3
  import { createHostApp } from "./app.js";
4
- import { bindListener as bind, EXIT_NON_LOOPBACK, headerValue, HostListenError, isAllowedRequestHost, isLoopbackHost, pathnameIsLogged, redactRoutePath, rejectedResponse, sendRejected, } from "./http.js";
4
+ import { isLoopbackHost } from "./stack-config.js";
5
+ import { bindListener as bind, EXIT_NON_LOOPBACK, headerValue, HostListenError, isAllowedRequestHost, pathnameIsLogged, redactRoutePath, rejectedResponse, sendRejected, } from "./http.js";
5
6
  export function createHost(options) {
6
7
  const { module, config, logger, coreVersion } = options;
7
8
  const pid = options.pid ?? process.pid;
@@ -54,4 +54,3 @@ export declare function adminKeyMatches(presented: string | undefined, adminKey:
54
54
  export declare function headerValue(incoming: IncomingMessage, name: string): string | undefined;
55
55
  /** A JSON request body, capped at `limit` bytes (413); `undefined` when there is none. */
56
56
  export declare function readJsonBody(request: Request, limit?: number): Promise<unknown>;
57
- export declare function isLoopbackHost(host: string): boolean;
package/dist/host/http.js CHANGED
@@ -191,9 +191,3 @@ export async function readJsonBody(request, limit = 1024 * 1024) {
191
191
  throw Object.assign(new Error("Invalid JSON"), { status: 400 });
192
192
  }
193
193
  }
194
- export function isLoopbackHost(host) {
195
- const normalized = host.trim().toLowerCase();
196
- return (normalized === "127.0.0.1" ||
197
- normalized === "::1" ||
198
- normalized === "localhost");
199
- }
@@ -237,6 +237,8 @@ export declare function parseServices(argv: readonly string[]): ServiceSelection
237
237
  * IPv6 literals must be bracketed (`[::1]:4000`).
238
238
  */
239
239
  export declare function normalizeAllowedHost(name: string, entry: string): string;
240
+ /** A loopback bind address: `127.0.0.1`, `::1` or `localhost`. */
241
+ export declare function isLoopbackHost(host: string): boolean;
240
242
  /** Parse the stack configuration; throws `StackConfigError` naming the variable. */
241
243
  export declare function parseStackConfig(env: EnvSnapshot, argv: readonly string[]): StackConfig;
242
244
  /** Endpoint summary for logs: which endpoints are set, never their values. */
@@ -179,9 +179,10 @@ export function normalizeAllowedHost(name, entry) {
179
179
  function loopbackForms(port) {
180
180
  return [`localhost:${port}`, `127.0.0.1:${port}`, `[::1]:${port}`];
181
181
  }
182
- function isLoopbackAddress(host) {
183
- const value = host.toLowerCase();
184
- return value === "127.0.0.1" || value === "::1" || value === "localhost";
182
+ /** A loopback bind address: `127.0.0.1`, `::1` or `localhost`. */
183
+ export function isLoopbackHost(host) {
184
+ const normalized = host.trim().toLowerCase();
185
+ return normalized === "127.0.0.1" || normalized === "::1" || normalized === "localhost";
185
186
  }
186
187
  function parseUrl(env, name, protocols) {
187
188
  const raw = read(env, name);
@@ -198,32 +199,37 @@ function parseUrl(env, name, protocols) {
198
199
  throw new StackConfigError(`${name} must use ${protocols.map((p) => p.replace(/:$/, "")).join(" or ")}; got ${url.protocol.replace(/:$/, "")}`);
199
200
  return raw;
200
201
  }
201
- function parseListen(env) {
202
- const rawHost = read(env, "NYLORUN_LISTEN_HOST");
203
- const rawPort = read(env, "NYLORUN_LISTEN_PORT");
204
- const rawAllowed = read(env, "NYLORUN_ALLOWED_HOSTS");
205
- if (rawHost === undefined && rawPort === undefined && rawAllowed === undefined)
206
- return undefined;
207
- const host = rawHost ?? DEFAULT_CONTAINER_LISTEN_HOST;
202
+ function parseListener(env, prefix, defaultPort, hosts) {
203
+ const hostName = `${prefix}LISTEN_HOST`;
204
+ const host = read(env, hostName) ?? DEFAULT_CONTAINER_LISTEN_HOST;
208
205
  if (/\s|\//.test(host))
209
- throw new StackConfigError(`NYLORUN_LISTEN_HOST is not an address: ${host}`);
210
- const port = rawPort === undefined
211
- ? DEFAULT_CONTAINER_LISTEN_PORT
212
- : parsePort("NYLORUN_LISTEN_PORT", rawPort);
213
- const explicit = rawAllowed === undefined
214
- ? []
215
- : rawAllowed
216
- .split(",")
217
- .map((entry) => entry.trim())
218
- .filter((entry) => entry !== "")
219
- .map((entry) => normalizeAllowedHost("NYLORUN_ALLOWED_HOSTS", entry));
220
- if (explicit.length === 0 && !isLoopbackAddress(host))
221
- throw new StackConfigError(`NYLORUN_ALLOWED_HOSTS is required when NYLORUN_LISTEN_HOST is ${host}: list the Host headers clients send, e.g. runtime:${port},localhost:<published port>`);
206
+ throw new StackConfigError(`${hostName} is not an address: ${host}`);
207
+ const portName = `${prefix}LISTEN_PORT`;
208
+ const rawPort = read(env, portName);
209
+ const port = rawPort === undefined ? defaultPort : parsePort(portName, rawPort);
210
+ if (!hosts)
211
+ return { host, port };
212
+ const allowedName = `${prefix}ALLOWED_HOSTS`;
213
+ const explicit = (read(env, allowedName) ?? "")
214
+ .split(",")
215
+ .map((entry) => entry.trim())
216
+ .filter((entry) => entry !== "")
217
+ .map((entry) => normalizeAllowedHost(allowedName, entry));
218
+ if (explicit.length === 0 && !isLoopbackHost(host))
219
+ throw new StackConfigError(`${allowedName} is required when ${hostName} is ${host}: list the Host headers ${hosts.senders}, e.g. ${hosts.example(port)}`);
222
220
  // Loopback forms of the listen port serve probes from inside the container
223
221
  // (health checks). A browser on the Docker host never sends them: it sends
224
222
  // the published port.
225
- const allowedHosts = [...new Set([...explicit, ...loopbackForms(port)])];
226
- return { host, port, allowedHosts };
223
+ return { host, port, allowedHosts: [...new Set([...explicit, ...loopbackForms(port)])] };
224
+ }
225
+ /** The API's listener (`NYLORUN_LISTEN_*`, `NYLORUN_ALLOWED_HOSTS`): none when none is set. */
226
+ function parseListen(env) {
227
+ if (["NYLORUN_LISTEN_HOST", "NYLORUN_LISTEN_PORT", "NYLORUN_ALLOWED_HOSTS"].every((name) => read(env, name) === undefined))
228
+ return undefined;
229
+ return parseListener(env, "NYLORUN_", DEFAULT_CONTAINER_LISTEN_PORT, {
230
+ senders: "clients send",
231
+ example: (port) => `runtime:${port},localhost:<published port>`,
232
+ });
227
233
  }
228
234
  const IDENTITY_KEY = /^publickeyv1_[1-9A-HJ-NP-Za-km-z]{32,64}$/;
229
235
  function parseIdentityKeys(env) {
@@ -398,30 +404,14 @@ function parseSandboxes(env) {
398
404
  * (required with `NYLORUN_HARNESS=remote`; optional when only sandbox pods connect).
399
405
  */
400
406
  function parseHarnessListener(env, tokenRequired = true) {
401
- const host = read(env, "NYLORUN_HARNESS_LISTEN_HOST") ?? DEFAULT_CONTAINER_LISTEN_HOST;
402
- if (/\s|\//.test(host))
403
- throw new StackConfigError(`NYLORUN_HARNESS_LISTEN_HOST is not an address: ${host}`);
404
- const rawPort = read(env, "NYLORUN_HARNESS_LISTEN_PORT");
405
- const port = rawPort === undefined
406
- ? DEFAULT_HARNESS_LISTEN_PORT
407
- : parsePort("NYLORUN_HARNESS_LISTEN_PORT", rawPort);
408
- const rawAllowed = read(env, "NYLORUN_HARNESS_ALLOWED_HOSTS");
409
- const explicit = rawAllowed === undefined
410
- ? []
411
- : rawAllowed
412
- .split(",")
413
- .map((entry) => entry.trim())
414
- .filter((entry) => entry !== "")
415
- .map((entry) => normalizeAllowedHost("NYLORUN_HARNESS_ALLOWED_HOSTS", entry));
416
- if (explicit.length === 0 && !isLoopbackAddress(host))
417
- throw new StackConfigError(`NYLORUN_HARNESS_ALLOWED_HOSTS is required when NYLORUN_HARNESS_LISTEN_HOST is ${host}: list the Host headers harnesses send, e.g. runtime:${port}`);
407
+ const listen = parseListener(env, "NYLORUN_HARNESS_", DEFAULT_HARNESS_LISTEN_PORT, {
408
+ senders: "harnesses send",
409
+ example: (port) => `runtime:${port}`,
410
+ });
418
411
  const token = tokenRequired || read(env, "NYLORUN_HARNESS_TOKEN") !== undefined
419
412
  ? parseHarnessToken(env, "with NYLORUN_HARNESS=remote: the credential harnesses present")
420
413
  : undefined;
421
- return {
422
- listen: { host, port, allowedHosts: [...new Set([...explicit, ...loopbackForms(port)])] },
423
- ...(token ? { token } : {}),
424
- };
414
+ return { listen, ...(token ? { token } : {}) };
425
415
  }
426
416
  /** What a harness process must not hold: it runs next to agent code. */
427
417
  const HARNESS_REFUSED = ["NYLORUN_DATABASE_URL", "NYLORUN_GATES_TOKEN", "NYLORUN_KEYS_URL"];
@@ -491,43 +481,22 @@ function parsePodHost(env, url) {
491
481
  }
492
482
  /** `StackConfig.gates` from `NYLORUN_GATES_*`. */
493
483
  function parseGates(env) {
494
- const host = read(env, "NYLORUN_GATES_LISTEN_HOST") ?? DEFAULT_CONTAINER_LISTEN_HOST;
495
- if (/\s|\//.test(host))
496
- throw new StackConfigError(`NYLORUN_GATES_LISTEN_HOST is not an address: ${host}`);
497
- const rawPort = read(env, "NYLORUN_GATES_LISTEN_PORT");
498
- const port = rawPort === undefined
499
- ? DEFAULT_GATES_LISTEN_PORT
500
- : parsePort("NYLORUN_GATES_LISTEN_PORT", rawPort);
501
- const rawAllowed = read(env, "NYLORUN_GATES_ALLOWED_HOSTS");
502
- const explicit = rawAllowed === undefined
503
- ? []
504
- : rawAllowed
505
- .split(",")
506
- .map((entry) => entry.trim())
507
- .filter((entry) => entry !== "")
508
- .map((entry) => normalizeAllowedHost("NYLORUN_GATES_ALLOWED_HOSTS", entry));
509
- if (explicit.length === 0 && !isLoopbackAddress(host))
510
- throw new StackConfigError(`NYLORUN_GATES_ALLOWED_HOSTS is required when NYLORUN_GATES_LISTEN_HOST is ${host}: list the Host headers the loop sends, e.g. gateway:${port}`);
484
+ const listen = parseListener(env, "NYLORUN_GATES_", DEFAULT_GATES_LISTEN_PORT, {
485
+ senders: "the loop sends",
486
+ example: (port) => `gateway:${port}`,
487
+ });
511
488
  const token = read(env, "NYLORUN_GATES_TOKEN");
512
489
  if (token === undefined)
513
490
  throw new StackConfigError("NYLORUN_GATES_TOKEN is required for --service gates: the token the loop presents (`nylorun start` sets it)");
514
491
  if (!GATES_TOKEN.test(token))
515
492
  throw new StackConfigError("NYLORUN_GATES_TOKEN must be at least 32 bytes as hex");
516
- return {
517
- listen: { host, port, allowedHosts: [...new Set([...explicit, ...loopbackForms(port)])] },
518
- token,
519
- };
493
+ return { listen, token };
520
494
  }
521
495
  /** The prefix of the credential resolver's variables (removed in protocol 10). */
522
496
  const REMOVED_RESOLVER_PREFIX = "NYLORUN_RESOLVER_";
523
497
  /** `StackConfig.egress` from `NYLORUN_EGRESS_LISTEN_*`. CONNECT has no Host header to check. */
524
498
  function parseEgress(env) {
525
- const host = read(env, "NYLORUN_EGRESS_LISTEN_HOST") ?? DEFAULT_CONTAINER_LISTEN_HOST;
526
- if (/\s|\//.test(host))
527
- throw new StackConfigError(`NYLORUN_EGRESS_LISTEN_HOST is not an address: ${host}`);
528
- const rawPort = read(env, "NYLORUN_EGRESS_LISTEN_PORT");
529
- const port = rawPort === undefined ? DEFAULT_EGRESS_LISTEN_PORT : parsePort("NYLORUN_EGRESS_LISTEN_PORT", rawPort);
530
- return { listen: { host, port } };
499
+ return { listen: parseListener(env, "NYLORUN_EGRESS_", DEFAULT_EGRESS_LISTEN_PORT) };
531
500
  }
532
501
  /** `StackConfig.modelGate` from `NYLORUN_GATES_URL` and `NYLORUN_GATES_TOKEN`. */
533
502
  function parseModelGate(env) {
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.2.0",
3
3
  "info": {
4
4
  "title": "Nylorun Management API",
5
- "version": "0.21.0-beta",
5
+ "version": "0.21.1-beta",
6
6
  "x-nylorun-protocol": {
7
7
  "min": 4,
8
8
  "max": 10,
package/dist/openapi.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.2.0",
3
3
  "info": {
4
4
  "title": "Nylorun Runtime API",
5
- "version": "0.21.0-beta",
5
+ "version": "0.21.1-beta",
6
6
  "x-nylorun-protocol": {
7
7
  "min": 4,
8
8
  "max": 10,
@@ -0,0 +1,9 @@
1
+ CREATE TABLE "nylorun"."wakes" (
2
+ "id" text COLLATE "C" PRIMARY KEY NOT NULL,
3
+ "session_id" text COLLATE "C" NOT NULL,
4
+ "reason" text NOT NULL,
5
+ "dedupe_key" text,
6
+ "created_at" timestamp with time zone DEFAULT clock_timestamp() NOT NULL
7
+ );
8
+ --> statement-breakpoint
9
+ CREATE INDEX "wakes_created_at" ON "nylorun"."wakes" USING btree ("created_at");
@@ -120,6 +120,13 @@
120
120
  "when": 1791395759051,
121
121
  "tag": "0016_mcp_oauth_removed",
122
122
  "breakpoints": true
123
+ },
124
+ {
125
+ "idx": 17,
126
+ "version": "7",
127
+ "when": 1791455573196,
128
+ "tag": "0017_wake_outbox",
129
+ "breakpoints": true
123
130
  }
124
131
  ]
125
132
  }
@@ -3340,6 +3340,109 @@ export declare const controlSignals: import("drizzle-orm/pg-core").PgTableWithCo
3340
3340
  };
3341
3341
  dialect: 'pg';
3342
3342
  }>;
3343
+ /**
3344
+ * The wake outbox (architecture §12.3): one row per wake a committed transaction asked for
3345
+ * (`Tx.wake`), written in that transaction, so a commit never loses its wake. The row is
3346
+ * deleted once Durable Session Execution has accepted the wake; until then the Tenant sweep
3347
+ * delivers it again, with the same idempotency key (`dedupe_key`, or `wake:<id>` without
3348
+ * one). `created_at` is the insert's own clock (`clock_timestamp()`).
3349
+ */
3350
+ export declare const wakes: import("drizzle-orm/pg-core").PgTableWithColumns<{
3351
+ name: "wakes";
3352
+ schema: "nylorun";
3353
+ columns: {
3354
+ id: import("drizzle-orm/pg-core").PgColumn<{
3355
+ name: "id";
3356
+ tableName: "wakes";
3357
+ dataType: "custom";
3358
+ columnType: "PgCustomColumn";
3359
+ data: string;
3360
+ driverParam: string;
3361
+ notNull: true;
3362
+ hasDefault: false;
3363
+ isPrimaryKey: true;
3364
+ isAutoincrement: false;
3365
+ hasRuntimeDefault: false;
3366
+ enumValues: undefined;
3367
+ baseColumn: never;
3368
+ identity: undefined;
3369
+ generated: undefined;
3370
+ }, {}, {
3371
+ pgColumnBuilderBrand: 'PgCustomColumnBuilderBrand';
3372
+ }>;
3373
+ sessionId: import("drizzle-orm/pg-core").PgColumn<{
3374
+ name: "sessionId";
3375
+ tableName: "wakes";
3376
+ dataType: "custom";
3377
+ columnType: "PgCustomColumn";
3378
+ data: string;
3379
+ driverParam: string;
3380
+ notNull: true;
3381
+ hasDefault: false;
3382
+ isPrimaryKey: false;
3383
+ isAutoincrement: false;
3384
+ hasRuntimeDefault: false;
3385
+ enumValues: undefined;
3386
+ baseColumn: never;
3387
+ identity: undefined;
3388
+ generated: undefined;
3389
+ }, {}, {
3390
+ pgColumnBuilderBrand: 'PgCustomColumnBuilderBrand';
3391
+ }>;
3392
+ reason: import("drizzle-orm/pg-core").PgColumn<{
3393
+ name: "reason";
3394
+ tableName: "wakes";
3395
+ dataType: "string";
3396
+ columnType: "PgText";
3397
+ data: string;
3398
+ driverParam: string;
3399
+ notNull: true;
3400
+ hasDefault: false;
3401
+ isPrimaryKey: false;
3402
+ isAutoincrement: false;
3403
+ hasRuntimeDefault: false;
3404
+ enumValues: [string, ...string[]];
3405
+ baseColumn: never;
3406
+ identity: undefined;
3407
+ generated: undefined;
3408
+ }, {}, {}>;
3409
+ dedupeKey: import("drizzle-orm/pg-core").PgColumn<{
3410
+ name: "dedupeKey";
3411
+ tableName: "wakes";
3412
+ dataType: "string";
3413
+ columnType: "PgText";
3414
+ data: string;
3415
+ driverParam: string;
3416
+ notNull: false;
3417
+ hasDefault: false;
3418
+ isPrimaryKey: false;
3419
+ isAutoincrement: false;
3420
+ hasRuntimeDefault: false;
3421
+ enumValues: [string, ...string[]];
3422
+ baseColumn: never;
3423
+ identity: undefined;
3424
+ generated: undefined;
3425
+ }, {}, {}>;
3426
+ createdAt: import("drizzle-orm/pg-core").PgColumn<{
3427
+ name: "createdAt";
3428
+ tableName: "wakes";
3429
+ dataType: "date";
3430
+ columnType: "PgTimestamp";
3431
+ data: Date;
3432
+ driverParam: string;
3433
+ notNull: true;
3434
+ hasDefault: true;
3435
+ isPrimaryKey: false;
3436
+ isAutoincrement: false;
3437
+ hasRuntimeDefault: false;
3438
+ enumValues: undefined;
3439
+ baseColumn: never;
3440
+ identity: undefined;
3441
+ generated: undefined;
3442
+ }, {}, {}>;
3443
+ };
3444
+ dialect: 'pg';
3445
+ }>;
3343
3446
  /**
3344
3447
  * Each sandbox's lifecycle stream (`nylorun.sandbox-event/1`), numbered from 0 per sandbox id.
3345
3448
  * Written only by the record module; not published to the stream relay.
@@ -421,6 +421,22 @@ export const controlSignals = nylorun.table("control_signals", {
421
421
  index("control_signals_created_at").on(t.createdAt),
422
422
  check("control_signals_kind_check", sql `${t.kind} IN ('session.cancel', 'sessions.reset')`),
423
423
  ]);
424
+ /**
425
+ * The wake outbox (architecture §12.3): one row per wake a committed transaction asked for
426
+ * (`Tx.wake`), written in that transaction, so a commit never loses its wake. The row is
427
+ * deleted once Durable Session Execution has accepted the wake; until then the Tenant sweep
428
+ * 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()`).
430
+ */
431
+ export const wakes = nylorun.table("wakes", {
432
+ id: textC().primaryKey(),
433
+ sessionId: textC().notNull(),
434
+ reason: text().notNull(),
435
+ dedupeKey: text(),
436
+ createdAt: timestamp({ withTimezone: true, mode: "date" })
437
+ .notNull()
438
+ .default(sql `clock_timestamp()`),
439
+ }, (t) => [index("wakes_created_at").on(t.createdAt)]);
424
440
  // ---------------------------------------------------------------------------
425
441
  // nylorun_streams: the record
426
442
  /**
@@ -49,6 +49,7 @@
49
49
  * `timestamptz`.
50
50
  */
51
51
  import { AsyncLocalStorage } from "node:async_hooks";
52
+ import { randomUUID } from "node:crypto";
52
53
  import { and, count, eq, getTableColumns, gte, inArray, isNotNull, isNull, lt, lte, ne, or, sql, } from "drizzle-orm";
53
54
  import { appendEvent, appendSandboxEvent } from "../../record/index.js";
54
55
  import { OwnershipLostError, PrincipalRoleConflict } from "../ownership.js";
@@ -57,7 +58,7 @@ import { database, driverError } from "./db.js";
57
58
  import { expectedSchemaVersion, readSchemaVersion } from "./migrate.js";
58
59
  import { createPostgresRecordReader } from "./record.js";
59
60
  import { postgresRecordWriter, postgresSandboxRecordWriter } from "./record-writer.js";
60
- 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, } 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, wakes, } from "./schema.js";
61
62
  export function createPostgresSessionStore(options) {
62
63
  return new PostgresSessionStore(options);
63
64
  }
@@ -110,7 +111,7 @@ class PostgresSessionStore {
110
111
  let t;
111
112
  // Wrapped so `begin` does not treat an array result as queries to await.
112
113
  const run = this.db.transaction(async (db) => {
113
- t = new PostgresTx(db, this.tenantId, this.now);
114
+ t = new PostgresTx(db, this.tenantId, this.now, this.deleteWake);
114
115
  try {
115
116
  return { value: await this.active.run(this, () => fn(t)) };
116
117
  }
@@ -153,6 +154,10 @@ class PostgresSessionStore {
153
154
  }
154
155
  return result.value;
155
156
  }
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
+ };
156
161
  onCommit(listener) {
157
162
  this.listeners.add(listener);
158
163
  return () => this.listeners.delete(listener);
@@ -256,21 +261,27 @@ const SIGNING_KEY_STAMP = {
256
261
  };
257
262
  /** The audit columns a row has (`ord` only orders them). */
258
263
  const { ord: _ord, ...AUDIT } = getTableColumns(vaultAudit);
259
- const SESSION_TABLES = [sessions, commands, effects, links];
264
+ 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. */
266
+ function outboxWake(id, reason, dedupeKey) {
267
+ return { reason, dedupeKey: dedupeKey ?? `wake:${id}` };
268
+ }
260
269
  /** The inserted row's `column` (`ON CONFLICT … DO UPDATE`). */
261
270
  const excluded = (column) => sql.raw(`excluded.${column}`);
262
271
  class PostgresTx {
263
272
  db;
264
273
  tenantId;
265
274
  now;
275
+ deleteWake;
266
276
  closed = false;
267
277
  events = [];
268
278
  generations = [];
269
279
  callbacks = [];
270
- constructor(db, tenantId, now) {
280
+ constructor(db, tenantId, now, deleteWake) {
271
281
  this.db = db;
272
282
  this.tenantId = tenantId;
273
283
  this.now = now;
284
+ this.deleteWake = deleteWake;
274
285
  }
275
286
  check() {
276
287
  if (this.closed)
@@ -345,6 +356,45 @@ class PostgresTx {
345
356
  this.check();
346
357
  this.callbacks.push(fn);
347
358
  }
359
+ // --- wake outbox ---------------------------------------------------------
360
+ async wake(sessionId, wake, deliver) {
361
+ this.check();
362
+ const id = randomUUID();
363
+ await this.db.insert(wakes).values({
364
+ id,
365
+ sessionId,
366
+ reason: wake.reason,
367
+ dedupeKey: wake.dedupeKey ?? null,
368
+ });
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);
375
+ });
376
+ }
377
+ async pendingWakes(before, limit) {
378
+ this.check();
379
+ const rows = await this.db
380
+ .select()
381
+ .from(wakes)
382
+ .where(lt(wakes.createdAt, before))
383
+ .orderBy(wakes.createdAt, wakes.id)
384
+ .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
+ }));
391
+ }
392
+ async deleteWakes(ids) {
393
+ this.check();
394
+ if (ids.length === 0)
395
+ return;
396
+ await this.db.delete(wakes).where(inArray(wakes.id, [...ids]));
397
+ }
348
398
  // --- ownership -----------------------------------------------------------
349
399
  async takeOwnership(sessionId, claim) {
350
400
  this.check();
@@ -25,16 +25,18 @@
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. Wakes go
29
- * through `afterCommit`, and events are delivered to commit listeners after
30
- * commit (seam rule 1 and 2).
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).
31
31
  * 5. **No nested transactions.** Calling `store.tx` from inside `fn` rejects.
32
32
  * A `Tx` must not be used after its `tx` call settles.
33
33
  * 6. **Post-commit order.** After a commit, the store first calls every commit
34
34
  * listener once with the transaction's events (in allocation order), then runs the `afterCommit` callbacks in registration
35
35
  * order and awaits them. Listener and callback failures are reported to the
36
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.
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`).
38
40
  * 7. **Documents are values.** `get` and queries return fresh copies; mutating
39
41
  * them changes nothing until `put`.
40
42
  * 8. **Ownership columns are store-managed.** `owner`, `epoch` and
@@ -56,6 +58,7 @@
56
58
  */
57
59
  import type { KeyRole } from "@nylorun/core/compatibility";
58
60
  import type { RecordReader } from "../streams/relay/types.js";
61
+ import type { Wake } from "../execution/types.js";
59
62
  import type { EventPayload, EventType, LiveEvent, SandboxEvent, SandboxEventPayload, SandboxEventType, SandboxKind, SandboxPodDesired, SandboxPodObserved, SessionEventOf } from "@nylorun/core/contracts";
60
63
  import type { SandboxManifest } from "@nylorun/core/define";
61
64
  import type { HostEffect } from "@nylorun/harness/run";
@@ -271,6 +274,24 @@ export interface SessionStore {
271
274
  followSignals(onSignal: (signal: ControlSignal) => void, options?: FollowSignalsOptions): Promise<SignalFollower>;
272
275
  close(): Promise<void>;
273
276
  }
277
+ /** A wake as the outbox delivers it: its `dedupeKey` is the request's idempotency key. */
278
+ export type OutboxWake = Wake & {
279
+ dedupeKey: string;
280
+ };
281
+ /**
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.
285
+ */
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;
290
+ sessionId: string;
291
+ wake: OutboxWake;
292
+ /** ISO time it was written. */
293
+ createdAt: string;
294
+ }
274
295
  /**
275
296
  * A signal on the control bus (D21, D48): one process tells the others with the Tenant open.
276
297
  * Signals are not events: they are never recorded in a session's log or streamed, and a
@@ -345,8 +366,28 @@ export interface Tx {
345
366
  * cursor is final.
346
367
  */
347
368
  event<T extends EventType>(sessionId: string, turnId: string | null, type: T, payload: EventPayload<T>): Promise<SessionEventOf<T>>;
348
- /** Runs `fn` after a successful commit (never on rollback). Used for wakes. */
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
+ */
349
374
  afterCommit(fn: () => void | Promise<void>): void;
375
+ /**
376
+ * Asks for an advance of `sessionId` once this transaction commits. The request is a row
377
+ * 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.
385
+ */
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[]>;
389
+ /** Deletes delivered outbox requests. */
390
+ deleteWakes(ids: readonly string[]): Promise<void>;
350
391
  /**
351
392
  * Locks the session and takes ownership when it has no owner or the owner's
352
393
  * lease ended at or before `now`; then increments `epoch` and sets the lease
@@ -473,10 +473,7 @@ async function settle(ctx, lease, s, result, updates = [], cursor) {
473
473
  await slimModelEffects(t, id, s.activeTurnId);
474
474
  await t.put("sessions", id, current);
475
475
  const segment = finished.segment + 1;
476
- t.afterCommit(() => ctx.wake(id, {
477
- reason: "rollover",
478
- dedupeKey: `rollover:${s.activeTurnId}:${segment}`,
479
- }));
476
+ await t.wake(id, { reason: "rollover", dedupeKey: `rollover:${s.activeTurnId}:${segment}` }, ctx.wake);
480
477
  return siblings;
481
478
  }
482
479
  const flow = isWorkflowManifest(current.manifest);
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * The session command service: `message`, `approve`/`respond` and `cancel`,
3
3
  * with per-session idempotency keys. A command locks its session and commits its state
4
- * change, events and wakes in one transaction (the store publishes the events and runs the
5
- * `afterCommit` wakes), then aborts a cancelled advance and cascades workflow cancels to the
6
- * linked agent sessions, one transaction each.
4
+ * change, events and wakes in one transaction (the store publishes the events and delivers the
5
+ * wakes from its outbox after commit, `Tx.wake`), then aborts a cancelled advance and cascades
6
+ * workflow cancels to the linked agent sessions, one transaction each.
7
7
  *
8
- * Wakes (`ctx.wake`, architecture §12.3) carry the command type as the reason and a dedupe
8
+ * Wakes (`t.wake`, architecture §12.3) carry the command type as the reason and a dedupe
9
9
  * key naming the cause: `<type>:<turnId>:<segment>` for `message`, `approve` and `respond`
10
10
  * (every accepted one writes a new checkpoint segment), except that a workflow's `approve` and
11
11
  * `respond` name `<type>:<turnId>:<interactionId>`: a flow resumes in the same segment.
@@ -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
- t.afterCommit(() => ctx.wake(id, commandWake));
234
+ await t.wake(id, commandWake, ctx.wake);
235
235
  event = await t.event(id, s.activeTurnId, `command.${command.type}`, command);
236
236
  }
237
237
  await t.put("sessions", id, s);
@@ -5,9 +5,10 @@
5
5
  *
6
6
  * Business code changes state only inside `ctx.store.tx(async (t) => …)` and follows the
7
7
  * seam rules: events through `t.event(...)` (the relay appends them to Durable Streams after
8
- * commit), and advances through `t.afterCommit(() => ctx.wake(id, { reason, dedupeKey }))`. It never publishes or notifies
9
- * itself; `runtime.ts` wires the streams with `wireStreams()`. No external I/O runs inside a
10
- * tx.
8
+ * commit), and advances through `await t.wake(id, { reason, dedupeKey }, ctx.wake)`, which
9
+ * writes the wake to the outbox in the transaction and delivers it after commit. It never
10
+ * publishes or notifies itself; `runtime.ts` wires the streams with `wireStreams()`. No
11
+ * external I/O runs inside a tx.
11
12
  *
12
13
  * `wake` goes to `DurableExecution.wake`, which calls the Tenant's `advance` (`advance.ts`)
13
14
  * under ownership (§10.6); `abortLocal` aborts an advance running on this process.
@@ -211,11 +212,13 @@ export interface TenantContext {
211
212
  /** How long an advance's ownership lease lasts; the heartbeat renews it. */
212
213
  readonly ownerLeaseMs: number;
213
214
  /**
214
- * Seam: request an advance of a session (`DurableExecution.wake`). Call it from
215
- * `t.afterCommit`, never inside a tx. Dropped while the Tenant is closing; the sweep
216
- * re-wakes anything left runnable.
215
+ * Seam: hand a wake to `DurableExecution.wake`, outside any tx. Business code passes it to
216
+ * `t.wake` (the outbox), which calls it after commit; the sweep calls it for outbox rows
217
+ * left undelivered and for orphaned sessions. Resolves `true` once the execution accepted
218
+ * the wake, `false` while the Tenant is closing: the wake is not sent, and its outbox row
219
+ * stays for the next sweep.
217
220
  */
218
- wake(sessionId: string, wake: Wake): Promise<void>;
221
+ wake(sessionId: string, wake: Wake): Promise<boolean>;
219
222
  /**
220
223
  * Seam: abort the advance of a session running in this process, if any. With `turnId`, only
221
224
  * an advance of that turn (a cancel signal names the turn it cancelled).
@@ -261,8 +261,9 @@ export class TenantRuntime {
261
261
  ownerLeaseMs: config.ownerLeaseMs ?? DEFAULT_OWNER_LEASE_MS,
262
262
  wake: async (sessionId, wake) => {
263
263
  if (ctx.closing || ctx.closed)
264
- return;
264
+ return false;
265
265
  await execution.wake(config.tenantId, sessionId, wake);
266
+ return true;
266
267
  },
267
268
  abortLocal: (sessionId, turnId) => abortLocal(ctx, sessionId, "cancel", turnId),
268
269
  ...(hooks.pods ? { pods: hooks.pods } : {}),
@@ -1,5 +1,19 @@
1
1
  import type { TenantContext } from "./context.js";
2
+ /**
3
+ * How old a wake outbox row is before the sweep delivers it: longer than a delivery after
4
+ * commit takes, so the sweep seldom sends a wake its commit is still sending. Sending one
5
+ * twice is harmless either way (the idempotency key).
6
+ */
7
+ export declare const WAKE_GRACE_MS = 2000;
8
+ /** How often the orphan scan runs after the first pass of a Tenant opened on this process. */
9
+ export declare const ORPHAN_SCAN_MS = 60000;
2
10
  export declare function sweep(ctx: TenantContext): Promise<void>;
11
+ /**
12
+ * Deliver the wake outbox rows written before `before` (oldest first, one batch) and delete
13
+ * the delivered ones. A row the Tenant declines (it is closing) stays. Returns the ids of the
14
+ * rows delivered.
15
+ */
16
+ export declare function deliverPendingWakes(ctx: Pick<TenantContext, "store" | "wake">, before?: Date): Promise<string[]>;
3
17
  /** Settle pending workflow `agent` effects whose linked turn already finished. */
4
18
  export declare function reconcileLinkedAgents(ctx: Pick<TenantContext, "store" | "wake">): Promise<void>;
5
19
  /** Wake `running`/`runnable` sessions without a live owner. Returns the ids woken. */
@@ -1,21 +1,32 @@
1
1
  /**
2
2
  * The Tenant sweep (architecture §12.3, "Timers"): one self-re-arming pass per Tenant, armed
3
3
  * with `DurableExecution.armSweep` when the Tenant opens. It replaces every in-process timer
4
- * and startup scan the Tenant runtime used to keep:
4
+ * and startup scan the Tenant runtime used to keep. It reconciles the Session Store with what
5
+ * Durable Session Execution was asked to do; it never decides when a session advances:
5
6
  *
6
- * 1. **Linked agents.** Pending workflow `agent` effects whose linked turn already settled
7
+ * 1. **Wakes.** Wake outbox rows (`Tx.wake`) are delivered again and deleted: a delivery
8
+ * after commit that failed, or that a crash between the commit and the send cut off. The
9
+ * first pass on this process takes every row, so a previous process's are not left
10
+ * waiting; later passes take the rows older than `WAKE_GRACE_MS`. Each row carries the
11
+ * idempotency key it was first sent with, so a wake the execution did accept before the
12
+ * crash, or one still being sent after its commit, causes no second advance.
13
+ * 2. **Linked agents.** Pending workflow `agent` effects whose linked turn already settled
7
14
  * are completed and the workflow is woken (a crash between the two, or data written by an
8
- * older Runtime). One transaction per effect, child session locked before its workflow.
9
- * 2. **Orphaned sessions.** `running` or `runnable` sessions with no owner, or an owner whose
10
- * lease expired, are woken with reason `recover`: a wake lost between commit and send, or
11
- * a Worker that died mid-advance (the advance then takes over, §11.4).
12
- * 3. **Sandboxes.** Idle sandboxes are stopped, records of compute no longer held are marked
15
+ * older Runtime).
16
+ * 3. **Orphaned sessions.** On this process's first pass for the Tenant, then at most every
17
+ * `ORPHAN_SCAN_MS`: `running` or `runnable` sessions with no owner, or an owner whose lease
18
+ * expired, are woken with reason `recover`. Neither a lost wake (step 1) nor a Worker that
19
+ * died mid-advance (the execution retries that advance, which takes over, §11.4) needs it:
20
+ * it is the backstop for what the execution itself lost or ended without an advance, such
21
+ * as an in-process execution's queue when its process stopped, an advance for a Tenant not
22
+ * open on its Worker, an invocation an operator killed, or Restate's state wiped (§17.11).
23
+ * 4. **Sandboxes.** Idle sandboxes are stopped, records of compute no longer held are marked
13
24
  * stopped, and sandboxes whose session or sandbox resource is gone are removed, by the
14
25
  * workspace capability (`harness-api/workspace.ts`: here, or in the harness that serves
15
26
  * workspaces). Idle MCP connections of the in-process harness are closed.
16
- * 4. **Control signals.** Signals on the control bus older than `SIGNAL_RETENTION_MS` are
27
+ * 5. **Control signals.** Signals on the control bus older than `SIGNAL_RETENTION_MS` are
17
28
  * deleted: followers read back only the last two minutes (`store/postgres/control.ts`).
18
- * 5. **Hooks.** Callbacks registered with `ctx.onSweep`.
29
+ * 6. **Hooks.** Callbacks registered with `ctx.onSweep`.
19
30
  *
20
31
  * Every step runs one transaction per session it changes (a linked agent and its workflow
21
32
  * share one, child first), so the sweep follows the lock order in `store/types.ts`. A failing
@@ -26,13 +37,41 @@ import { pendingAgentEffects, reconcilePendingAgentEffect, } from "../core/flow-
26
37
  const BATCH = 100;
27
38
  /** How long a control signal is kept after it was written. */
28
39
  const SIGNAL_RETENTION_MS = 60 * 60 * 1000;
40
+ /**
41
+ * How old a wake outbox row is before the sweep delivers it: longer than a delivery after
42
+ * commit takes, so the sweep seldom sends a wake its commit is still sending. Sending one
43
+ * twice is harmless either way (the idempotency key).
44
+ */
45
+ export const WAKE_GRACE_MS = 2000;
46
+ /** How often the orphan scan runs after the first pass of a Tenant opened on this process. */
47
+ export const ORPHAN_SCAN_MS = 60_000;
48
+ /** Each open Tenant's sweep state on this process: when its orphan scan last ran. */
49
+ const passes = new WeakMap();
29
50
  export async function sweep(ctx) {
30
51
  if (ctx.closing || ctx.closed)
31
52
  return;
32
53
  const now = new Date();
54
+ const first = !passes.has(ctx);
55
+ const pass = passes.get(ctx) ?? {};
56
+ passes.set(ctx, pass);
57
+ const scanOrphans = pass.orphansAt === undefined || now.getTime() - pass.orphansAt >= ORPHAN_SCAN_MS;
33
58
  const steps = [
59
+ [
60
+ "wakes",
61
+ () => deliverPendingWakes(ctx, first ? now : new Date(now.getTime() - WAKE_GRACE_MS)),
62
+ ],
34
63
  ["linked", () => reconcileLinkedAgents(ctx)],
35
- ["orphans", () => wakeOrphanedSessions(ctx, now)],
64
+ ...(scanOrphans
65
+ ? [
66
+ [
67
+ "orphans",
68
+ async () => {
69
+ await wakeOrphanedSessions(ctx, now);
70
+ pass.orphansAt = now.getTime();
71
+ },
72
+ ],
73
+ ]
74
+ : []),
36
75
  [
37
76
  "sandboxes",
38
77
  () => ctx.sandbox.sweep({
@@ -68,6 +107,26 @@ export async function sweep(ctx) {
68
107
  if (failure)
69
108
  throw failure.error;
70
109
  }
110
+ /**
111
+ * Deliver the wake outbox rows written before `before` (oldest first, one batch) and delete
112
+ * the delivered ones. A row the Tenant declines (it is closing) stays. Returns the ids of the
113
+ * rows delivered.
114
+ */
115
+ export async function deliverPendingWakes(ctx, before = new Date()) {
116
+ const pending = await ctx.store.tx((t) => t.pendingWakes(before, BATCH));
117
+ const delivered = [];
118
+ try {
119
+ for (const row of pending)
120
+ if ((await ctx.wake(row.sessionId, row.wake)) !== false)
121
+ delivered.push(row.id);
122
+ }
123
+ finally {
124
+ // Delivered ones go even when a later one fails, so they are not sent again.
125
+ if (delivered.length > 0)
126
+ await ctx.store.tx((t) => t.deleteWakes(delivered));
127
+ }
128
+ return delivered;
129
+ }
71
130
  /** Settle pending workflow `agent` effects whose linked turn already finished. */
72
131
  export async function reconcileLinkedAgents(ctx) {
73
132
  const { store } = ctx;
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const RUNTIME_VERSION = "0.21.0-beta";
1
+ export declare const RUNTIME_VERSION = "0.21.1-beta";
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Kept in step with package.json by runtime/scripts/check-package.mjs, which runs in npm run check.
2
2
  // release:prepare also rewrites this when it bumps @nylorun/runtime.
3
- export const RUNTIME_VERSION = "0.21.0-beta";
3
+ export const RUNTIME_VERSION = "0.21.1-beta";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nylorun/runtime",
3
- "version": "0.21.0-beta",
3
+ "version": "0.21.1-beta",
4
4
  "description": "Nylorun Runtime: the agent execution Host on Postgres, Restate and S2, and model providers.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -65,8 +65,8 @@
65
65
  "@hono/node-server": "^2.1.3",
66
66
  "@hono/zod-openapi": "^1.6.3",
67
67
  "@modelcontextprotocol/sdk": "^1.31.0",
68
- "@nylorun/core": "0.17.0-beta",
69
- "@nylorun/harness": "0.24.0-beta",
68
+ "@nylorun/core": "0.18.0-beta",
69
+ "@nylorun/harness": "0.25.0-beta",
70
70
  "@restatedev/restate-sdk": "1.17.2",
71
71
  "@s2-dev/streamstore": "0.28.0",
72
72
  "aws4fetch": "1.0.20",