@nylorun/runtime 0.50.0-beta → 0.51.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,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.51.1-beta
4
+
5
+ ### Patch Changes
6
+
7
+ - 8451138: Pods on Agent Substrate (preview): a drain is recorded as soon as the moved pod's engine connects again. After suspend before drain, the restored engine redials with its host token (no join), and nothing asked the Runtime to reconcile the pod, so `agent.suspended` with reason `drain` waited for the next turn or idle timer (E13 on kind: not within two minutes). A pod engine's connection now asks for its sandbox's reconcile.
8
+
9
+ ## 0.51.0-beta
10
+
11
+ ### Minor Changes
12
+
13
+ - 874cc83: Pods on Agent Substrate (preview): suspend before drain, following Substrate's eviction contract. A worker pod that stops (a drain, a GKE node upgrade or scale-down, a WorkerPool rollout, a deleted pod) sends each of its actors SIGTERM and gives them 30 minutes to be suspended; until now the engine exited on that signal, so the actor crashed and went back to its last suspend, losing everything since.
14
+
15
+ - `@nylorun/runtime`: on Substrate (`NYLORUN_SANDBOX_SUBSTRATE=1`, which the sandboxes service's template sets), the engine logs `harness_worker_stopping` on SIGTERM and keeps running, with its runs. The sandboxes service watches worker pods (label `ate.dev/worker-pool`) and, when one turns Terminating, suspends each of the Tenant's actors on it synchronously (eight at a time, trying again for up to 29 minutes), records `drainedAt`, and resumes a sandbox whose request is `Running` on another worker. While it moves, its status reason is `Moving`, and the pod lifecycle waits instead of failing it at the ready deadline. Each drain is recorded once, as `agent.suspended` with reason `drain` (the pod row's `drained_at`, store migration 0048, nullable, run at start). Not covered: a spot reclaim or a node that dies, which still crash and revert. An actor created by an earlier release keeps its engine (#643) until it is reset.
16
+ - `@nylorun/core`: `agent.suspended`'s `reason` may be `drain`.
17
+ - `nylorun`: `local sandbox enable --backend substrate` lets the sandboxes service read pods cluster-wide (`get`, `list`, `watch`), for the worker pods it watches.
18
+
19
+ ### Patch Changes
20
+
21
+ - Pin core to the tested release.
22
+ - Pin harness to the tested release.
23
+ - Updated dependencies [874cc83]
24
+ - Updated dependencies
25
+ - @nylorun/core@0.44.0-beta
26
+ - @nylorun/harness@0.36.4-beta
27
+
3
28
  ## 0.50.0-beta
4
29
 
5
30
  ### Minor Changes
@@ -14,7 +14,9 @@
14
14
  * pod's join token instead of a harness token (`pod.ts`), runs its disk's setup on a new volume
15
15
  * (`setup.ts`), and runs its sandbox's tools in the pod itself (the `local` backend), with egress
16
16
  * through egress-gate. SIGTERM (tini forwards
17
- * it; the pod's grace is 10 s by default) gives its runs back and exits.
17
+ * it; the pod's grace is 10 s by default) gives its runs back and exits. On Agent Substrate
18
+ * (`NYLORUN_SANDBOX_SUBSTRATE=1`), SIGTERM means the worker pod is stopping: the engine logs it and
19
+ * keeps running, so the sandboxes service can suspend the actor within Substrate's 30 minutes.
18
20
  */
19
21
  import { existsSync, mkdirSync } from "node:fs";
20
22
  import { createServer } from "node:http";
@@ -106,8 +108,18 @@ export async function runHarness(stack, baseline) {
106
108
  });
107
109
  logger.info("harness_ready", { health: `http://127.0.0.1:${config.healthPort}/health` });
108
110
  let stopping = false;
111
+ let workerStopping = false;
109
112
  for (const signal of ["SIGINT", "SIGTERM"])
110
113
  process.on(signal, () => {
114
+ // On Agent Substrate, SIGTERM means the worker pod is stopping: the actor has 30 minutes to
115
+ // be suspended, and the sandboxes service suspends it. A process that exits can't be, so the
116
+ // engine keeps running, and keeps its runs (Substrate's eviction contract).
117
+ if (signal === "SIGTERM" && config.pod?.substrate) {
118
+ if (!workerStopping)
119
+ logger.info("harness_worker_stopping", { sandboxId: config.pod.sandboxId });
120
+ workerStopping = true;
121
+ return;
122
+ }
111
123
  if (stopping)
112
124
  return;
113
125
  stopping = true;
@@ -169,6 +169,12 @@ export interface PodHostConfig {
169
169
  * for the hosts whose keys Substrate's egress adds.
170
170
  */
171
171
  egressCaFile?: string;
172
+ /**
173
+ * `NYLORUN_SANDBOX_SUBSTRATE=1`: the pod is an Agent Substrate actor. A stopping worker sends
174
+ * it SIGTERM and gives it 30 minutes to be suspended, which the sandboxes service does; the
175
+ * engine stays up instead of exiting (suspend before drain).
176
+ */
177
+ substrate?: boolean;
172
178
  /** Addresses the pod's NetworkPolicy must block, probed before the engine starts. */
173
179
  blocked: BlockedAddress[];
174
180
  }
@@ -647,6 +647,7 @@ function parsePodHost(env, url) {
647
647
  httpUrl: origin,
648
648
  ...(egress ? { egressProxy: egress.replace(/\/+$/, "") } : {}),
649
649
  ...(egressCa ? { egressCaFile: egressCa } : {}),
650
+ ...(read(env, "NYLORUN_SANDBOX_SUBSTRATE") === "1" ? { substrate: true } : {}),
650
651
  blocked: blockedAddresses(env),
651
652
  },
652
653
  };
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.2.0",
3
3
  "info": {
4
4
  "title": "Nylorun Management API",
5
- "version": "0.50.0-beta",
5
+ "version": "0.51.1-beta",
6
6
  "x-nylorun-protocol": {
7
7
  "min": 16,
8
8
  "max": 18,
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.50.0-beta",
5
+ "version": "0.51.1-beta",
6
6
  "x-nylorun-protocol": {
7
7
  "min": 16,
8
8
  "max": 18,
@@ -63,6 +63,11 @@ export interface PodStatus {
63
63
  readonly shutdownTime?: string;
64
64
  readonly reason?: string;
65
65
  readonly message?: string;
66
+ /**
67
+ * Agent Substrate: when the sandbox was last suspended because its worker stopped. While it
68
+ * moves to another worker, `reason` is `Moving`.
69
+ */
70
+ readonly drainedAt?: string;
66
71
  /** With `?wait=`: whether the state was reached before the timeout. */
67
72
  readonly met?: boolean;
68
73
  }
@@ -25,6 +25,10 @@
25
25
  * (`agent.failed`, saying what the Agent lost); a crash an idle suspend finds is recorded the
26
26
  * same way. The next turn start asks for `Running`, and the sandboxes service reverts the
27
27
  * sandbox to its last suspend before resuming it.
28
+ * - **Drained** (Agent Substrate): a worker that stops gives its actors 30 minutes to be
29
+ * suspended. The sandboxes service suspends each, records `drainedAt` and resumes it on
30
+ * another worker; a new `drainedAt` is `agent.suspended` (reason `drain`), once, and a pod
31
+ * that is `Moving` waits rather than fail at the ready deadline.
28
32
  * - **Lost**: an incarnation that has run (a pod joined) whose Sandbox is gone or whose volume
29
33
  * is missing is `lost` (`agent.lost`): its host epoch moves, its Sandbox is deleted, and
30
34
  * only a reset brings the Environment back. agent-sandbox recreates a deleted claim empty, so
@@ -60,6 +64,11 @@ export declare function repositoryOf(reference: string): string;
60
64
  * the node made up for an image loaded onto it, which no registry serves.
61
65
  */
62
66
  export declare function imageDigestOf(imageID: string | undefined, requested: string): string | undefined;
67
+ /**
68
+ * The sandboxes service's reason for a sandbox suspended because its Agent Substrate worker
69
+ * stopped, until it runs on another one.
70
+ */
71
+ export declare const MOVING = "Moving";
63
72
  /** A setup failure's `reason` starts with this (`sandbox/setup.ts`); it is sticky. */
64
73
  export declare const SETUP_FAILED = "setup:";
65
74
  /** A crash's `reason` (see `crashReasonOf`) starts with this; it stays until a turn starts the pod. */
@@ -34,6 +34,11 @@ export function imageDigestOf(imageID, requested) {
34
34
  return undefined;
35
35
  return repositoryOf(id) === repositoryOf(requested) ? id : undefined;
36
36
  }
37
+ /**
38
+ * The sandboxes service's reason for a sandbox suspended because its Agent Substrate worker
39
+ * stopped, until it runs on another one.
40
+ */
41
+ export const MOVING = "Moving";
37
42
  /** A setup failure's `reason` starts with this (`sandbox/setup.ts`); it is sticky. */
38
43
  export const SETUP_FAILED = "setup:";
39
44
  /** A crash's `reason` (see `crashReasonOf`) starts with this; it stays until a turn starts the pod. */
@@ -158,6 +163,12 @@ export function decide(facts) {
158
163
  again(pollMs);
159
164
  return done();
160
165
  }
166
+ // Agent Substrate: its worker stopped, and the sandboxes service suspended it with everything it
167
+ // held and resumes it on another worker (suspend before drain). Recorded once per drain.
168
+ if (status.drainedAt !== undefined && status.drainedAt !== pod.drainedAt) {
169
+ patch.drainedAt = status.drainedAt;
170
+ events.push(event("agent.suspended", { reason: "drain" }));
171
+ }
161
172
  const opId = opIdOf({ k8sName: pod.k8sName, rev });
162
173
  if (desired === "suspended" && !status.exists) {
163
174
  if (pod.observed !== "suspended")
@@ -217,6 +228,11 @@ export function decide(facts) {
217
228
  events.push(event("agent.failed", { reason: crash }));
218
229
  return done();
219
230
  }
231
+ // On its way to another worker after a drain: neither failed nor starting.
232
+ if (status.reason === MOVING) {
233
+ again(pollMs);
234
+ return done();
235
+ }
220
236
  if (now - startedAt >= readyDeadlineMs) {
221
237
  const reason = crash ??
222
238
  ([status.reason, status.message].filter(Boolean).join(": ") ||
@@ -0,0 +1 @@
1
+ ALTER TABLE "nylorun"."agents" ADD COLUMN "drained_at" text;
@@ -337,6 +337,13 @@
337
337
  "when": 1794630124577,
338
338
  "tag": "0047_model_usage_attempts",
339
339
  "breakpoints": true
340
+ },
341
+ {
342
+ "idx": 48,
343
+ "version": "7",
344
+ "when": 1794635246579,
345
+ "tag": "0048_sandbox_pod_drained_at",
346
+ "breakpoints": true
340
347
  }
341
348
  ]
342
349
  }
@@ -1321,6 +1321,23 @@ export declare const agents: import("drizzle-orm/pg-core").PgTableWithColumns<{
1321
1321
  }, {}, {
1322
1322
  pgColumnBuilderBrand: 'PgCustomColumnBuilderBrand';
1323
1323
  }>;
1324
+ drainedAt: import("drizzle-orm/pg-core").PgColumn<{
1325
+ name: "drainedAt";
1326
+ tableName: "agents";
1327
+ dataType: "string";
1328
+ columnType: "PgText";
1329
+ data: string;
1330
+ driverParam: string;
1331
+ notNull: false;
1332
+ hasDefault: false;
1333
+ isPrimaryKey: false;
1334
+ isAutoincrement: false;
1335
+ hasRuntimeDefault: false;
1336
+ enumValues: [string, ...string[]];
1337
+ baseColumn: never;
1338
+ identity: undefined;
1339
+ generated: undefined;
1340
+ }, {}, {}>;
1324
1341
  diskOp: import("drizzle-orm/pg-core").PgColumn<{
1325
1342
  name: "diskOp";
1326
1343
  tableName: "agents";
@@ -173,6 +173,8 @@ export const agents = nylorun.table("agents", {
173
173
  imageDigest: textC(),
174
174
  /** The engine the image carries (ADR-99): `{ version, digest? }`, from a join of its engine. */
175
175
  engineImage: jsonText(),
176
+ /** When its Agent Substrate worker last stopped under it (the sandboxes service's `drainedAt`). */
177
+ drainedAt: text(),
176
178
  /**
177
179
  * The disk lease (R2d step 3): `{ kind, version?, startedAt, until }` while a snapshot, a
178
180
  * restore or a seed copies the disk; turns are refused until it ends or `until` passes.
@@ -284,6 +284,7 @@ function environmentOf(row) {
284
284
  ...(row.volumeId !== null ? { volumeId: row.volumeId } : {}),
285
285
  ...(row.imageDigest !== null ? { imageDigest: row.imageDigest } : {}),
286
286
  ...(row.engineImage ? { engineImage: row.engineImage } : {}),
287
+ ...(row.drainedAt !== null ? { drainedAt: row.drainedAt } : {}),
287
288
  },
288
289
  }
289
290
  : {}),
@@ -319,6 +320,7 @@ function podColumns(patch) {
319
320
  volumeId: patch.volumeId,
320
321
  imageDigest: patch.imageDigest,
321
322
  engineImage: patch.engineImage,
323
+ drainedAt: patch.drainedAt,
322
324
  };
323
325
  return Object.fromEntries(Object.entries(columns).filter(([, value]) => value !== undefined));
324
326
  }
@@ -269,6 +269,11 @@ export interface SandboxPodState {
269
269
  version: string;
270
270
  digest?: string;
271
271
  };
272
+ /**
273
+ * When the sandbox was last suspended because its Agent Substrate worker stopped (the sandboxes
274
+ * service's `drainedAt`), so each drain is recorded once (`agent.suspended`, reason `drain`).
275
+ */
276
+ drainedAt?: string;
272
277
  }
273
278
  /** A change to a pod's columns: `null` clears an optional one. */
274
279
  export type SandboxPodPatch = {
@@ -392,7 +392,20 @@ export class TenantRuntime {
392
392
  return this.ctx.onSweep(hook);
393
393
  }
394
394
  attachHarness(channel, peer) {
395
- return this.ctx.harness.attach(channel, peer);
395
+ const detach = this.ctx.harness.attach(channel, peer);
396
+ // A pod's engine that (re)connects asks for its sandbox's reconcile: after a drain (Agent
397
+ // Substrate's eviction contract) the restored engine redials with its host token and no join,
398
+ // and this is what lets the Runtime see the move (`drainedAt`, `agent.suspended` reason
399
+ // `drain`) at once rather than at the next turn or idle timer.
400
+ const sandboxId = peer.host?.sandboxId;
401
+ if (sandboxId !== undefined)
402
+ void this.ctx.store
403
+ .tx((t) => t.signalSandbox(sandboxId, { kind: "reconcile" }))
404
+ .catch((error) => this.ctx.config.logger.warn("sandbox reconcile after connect failed", {
405
+ sandboxId,
406
+ error: error instanceof Error ? error.message : String(error),
407
+ }));
408
+ return detach;
396
409
  }
397
410
  harnessStatus() {
398
411
  return harnessStatusOf(this.ctx);
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const RUNTIME_VERSION = "0.50.0-beta";
1
+ export declare const RUNTIME_VERSION = "0.51.1-beta";
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Kept in step with package.json by runtime/server/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.50.0-beta";
3
+ export const RUNTIME_VERSION = "0.51.1-beta";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nylorun/runtime",
3
- "version": "0.50.0-beta",
3
+ "version": "0.51.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",
@@ -61,39 +61,39 @@
61
61
  "dependencies": {
62
62
  "@ag-ui/core": "~1.0.4",
63
63
  "@asteasolutions/zod-to-openapi": "^9.1.0",
64
- "@earendil-works/pi-ai": "1.3.0",
65
- "@hono/node-server": "^2.1.5",
64
+ "@earendil-works/pi-ai": "1.5.2",
65
+ "@hono/node-server": "^2.1.8",
66
66
  "@hono/zod-openapi": "^1.6.3",
67
- "@modelcontextprotocol/sdk": "^1.32.1",
68
- "@nylorun/core": "0.43.0-beta",
69
- "@nylorun/harness": "0.36.3-beta",
70
- "@restatedev/restate-sdk": "1.17.3",
71
- "@restatedev/restate-sdk-clients": "1.17.3",
72
- "@s2-dev/streamstore": "0.28.0",
67
+ "@modelcontextprotocol/sdk": "^1.32.2",
68
+ "@nylorun/core": "0.44.0-beta",
69
+ "@nylorun/harness": "0.36.4-beta",
70
+ "@restatedev/restate-sdk": "1.18.3",
71
+ "@restatedev/restate-sdk-clients": "1.18.3",
72
+ "@s2-dev/streamstore": "0.30.0",
73
73
  "aws4fetch": "1.0.20",
74
74
  "croner": "^9.1.0",
75
75
  "drizzle-orm": "^0.45.4",
76
- "hono": "^4.13.13",
77
- "jose": "^6.2.12",
76
+ "hono": "^4.13.18",
77
+ "jose": "^6.2.17",
78
78
  "just-bash": "3.6.0",
79
79
  "pg": "8.23.1",
80
- "pg-logical-replication": "2.5.0",
80
+ "pg-logical-replication": "2.6.0",
81
81
  "postgres": "^3.4.9",
82
- "ws": "^8.22.0",
82
+ "ws": "^8.22.2",
83
83
  "yaml": "^2.9.1",
84
84
  "zod": "^4.6.7"
85
85
  },
86
86
  "devDependencies": {
87
87
  "@a2a-js/sdk": "~1.3.0",
88
88
  "@ag-ui/client": "1.0.4",
89
- "@scalar/openapi-parser": "^0.29.13",
90
- "@types/node": "^26.6.5",
89
+ "@scalar/openapi-parser": "^0.29.21",
90
+ "@types/node": "^26.6.6",
91
91
  "@types/pg": "^8.23.1",
92
92
  "@types/ws": "^8.18.2",
93
93
  "@typescript/native": "npm:typescript@^7.0.2",
94
94
  "drizzle-kit": "^0.31.11",
95
95
  "esbuild": "^0.28.2",
96
96
  "typescript": "npm:@typescript/typescript6@^6.0.2",
97
- "vitest": "^5.0.4"
97
+ "vitest": "^5.1.0"
98
98
  }
99
99
  }