@nylorun/runtime 0.46.2-beta → 0.47.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,35 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.47.1-beta
4
+
5
+ ### Patch Changes
6
+
7
+ - 3a8a080: Pods on Agent Substrate (preview): a crash that an idle suspend found now makes the Agent `failing`, and its `attention` names the crash (`compute crashed: Crashed: …`). Before, the crash appeared only in the Agent's events, and the Agent showed as sleeping. The entry stays until a turn starts the pod again, which reverts the sandbox to its last suspend.
8
+
9
+ ## 0.47.0-beta
10
+
11
+ ### Minor Changes
12
+
13
+ - fe118db: The Tenant's sandbox settings round-trip, and `engine.images` comes back (#588).
14
+
15
+ - **Breaking (`@nylorun/runtime`, `@nylorun/core`, `@nylorun/admin`): `config` is the settings as stored.** `GET` and `PUT /v1/tenant/sandbox` (`admin.settings.sandbox`) answer `config` as the Tenant set it, with no defaults, in exactly the shape `PUT` takes (`limits.resources.memory: "8GiB"`), so `put(get().config)` changes nothing and saves no default as a setting. The settings in force, every default filled in and sizes in MiB (`memoryMiB`), are `effective`, which is what `config` was. Before, `PUT` refused what `GET` answered (`unrecognized_keys "memoryMiB"`). Read the settings in force from `effective` (`view.effective.limits.resources.memoryMiB`), and build a `PUT` body from `view.config`. `TenantSandboxViewSchema` has `config: TenantEnvironmentsConfigSchema` and `effective: EffectiveSandboxConfigSchema`.
16
+ - **`engine.images` is answered.** A `PUT` with `engine.images` was stored and used, but left out of its answer and of the next `GET`. Both now carry it, in `config` as set and in `effective` (`[]` when unset).
17
+ - `@nylorun/studio`: Edit compute settings puts the stored settings with only the fields it changed, so it no longer erases `engine.images` set through the API, keeps `placement`, and saves no default as a setting; clearing idle, stop grace or most Agents puts back the default. The Compute tab lists the engine images.
18
+ - `nylorun`: `nylorun tenant settings sandbox put` takes what `get` printed and puts back its `config`. `nylorun doctor sandbox` reads the limits in force from `effective`, or from `config` on an older Runtime.
19
+
20
+ ### Patch Changes
21
+
22
+ - fe118db: An A2A `SendMessage` with `returnImmediately` to a busy context answers `TASK_STATE_SUBMITTED` (#586), and `CancelTask` on a queued task answers `TASK_STATE_CANCELED` (#585), instead of `TaskNotFoundError` while the session's stream still lags behind the record. Both replies now wait, up to 5 s, for the stream to hold the request's own write, and a queued task's `GetTask` answers `SUBMITTED` from its queue row until then.
23
+ - 71c6741: Pods on Agent Substrate (preview): a crashed sandbox is now recorded in the Agent's events. A running pod whose sandbox crashed is `failed` at once, rather than after the 5-minute ready deadline. A crash that an idle suspend finds is also recorded, where before the Agent showed only `agent.suspended`. In both cases `agent.failed` gives Substrate's crash message and says that what the Agent wrote since its last suspend is lost. The next turn reverts the sandbox to that suspend, as before. The sandboxes service now reports the crash's own message in place of the whole crash record.
24
+ - fe118db: A message's `removeTools` turns a Runtime plugin's tool off by its own name too: `memory` now does what `nylorun__memory` does, so the name events and `check --explain` showed no longer leaves the tool on without a word (#589). `nylorun check --explain` lists a plugin's tools by the names the model reads (`nylorun__memory`, `nylorun__update_plan`), the ones `removeTools` and tool policy take, and its memory note says the index is pinned again at a turn after a write from elsewhere (#590, #591).
25
+ - Pin core to the tested release.
26
+ - Pin harness to the tested release.
27
+ - Updated dependencies [fe118db]
28
+ - Updated dependencies [fe118db]
29
+ - Updated dependencies
30
+ - @nylorun/core@0.41.0-beta
31
+ - @nylorun/harness@0.36.1-beta
32
+
3
33
  ## 0.46.2-beta
4
34
 
5
35
  ### Patch Changes
@@ -44,7 +44,7 @@ import { A2aError, a2aFail, checkVersion, jsonRpcError, jsonRpcResult, parseCrea
44
44
  import { configView, kickPushes, pushEnabled, pushPolicy, savePushConfig, } from "./push.js";
45
45
  import { ENDED, SETTLED_EVENTS, buildTask, callerTraceId, contextSessionId, droppedTask, endsQueued, newContextId, parseTaskId, pendingInteraction, pendingInteractions, submittedTask, taskIdOf, } from "./tasks.js";
46
46
  import { currentModelDescriptor } from "../../model/descriptor.js";
47
- import { decodeCursor } from "../../record/cursor.js";
47
+ import { decodeCursor, encodeCursor } from "../../record/cursor.js";
48
48
  import { accessOf } from "../../tenant/auth.js";
49
49
  import { cancelQueuedMessage, command } from "../../tenant/commands.js";
50
50
  import { loadSession, reachesAgent, } from "../../tenant/context.js";
@@ -218,7 +218,8 @@ async function indexedTask(caller, row, session, options = {}) {
218
218
  * index keeps the queued messages each turn took) the task is that turn's, under the id the
219
219
  * caller knows. A queued message that failed or was cancelled before a turn took it (Host feature
220
220
  * `queue-recovery`) is `FAILED` or `CANCELED`, from the event that ended it. Only a message still
221
- * in the queue, or no longer in it and taken by no turn, is looked for in the session's history.
221
+ * in the queue, or no longer in it and taken by no turn, is looked for in the session's history;
222
+ * one still in the queue is `SUBMITTED` from its row while the stream lags behind the record.
222
223
  */
223
224
  async function ownedTask(caller, taskId, historyLength) {
224
225
  const ids = parseTaskId(taskId) ?? taskNotFound(taskId);
@@ -229,8 +230,9 @@ async function ownedTask(caller, taskId, historyLength) {
229
230
  if (turn)
230
231
  return { turn };
231
232
  // Still queued, or taken: the queue is read first, so a message taken meanwhile is found.
232
- if ((await t.queuedMessages(sessionId)).some((row) => row.id === ids.turnId))
233
- return { queued: true };
233
+ const row = (await t.queuedMessages(sessionId)).find((row) => row.id === ids.turnId);
234
+ if (row)
235
+ return { row };
234
236
  const taken = await t.turnOfQueuedMessage(sessionId, ids.turnId);
235
237
  return taken ? { turn: taken } : {};
236
238
  });
@@ -242,8 +244,10 @@ async function ownedTask(caller, taskId, historyLength) {
242
244
  const owned = { session, sessionId, contextId: ids.contextId, turnId: ids.turnId, events: [] };
243
245
  if (dropped)
244
246
  return { ...owned, task: droppedTask(ids.contextId, taskId, dropped), dropped };
245
- if (queued && found.queued)
246
- return { ...owned, task: submittedTask(ids.contextId, taskId, queued), queued };
247
+ if (found.row) {
248
+ const since = queued?.time ?? found.row.createdAt;
249
+ return { ...owned, task: submittedTask(ids.contextId, taskId, since), queued: queued ?? found.row };
250
+ }
247
251
  }
248
252
  const row = found.turn ?? taskNotFound(taskId);
249
253
  const built = (await indexedTask(caller, { ...row, a2aContextId: ids.contextId }, session, {
@@ -361,7 +365,10 @@ async function sendMessage(caller, params, signal) {
361
365
  nextSeq: decodeCursor(sessionId, accepted.cursor),
362
366
  ...(accepted.turnId !== undefined ? { traceId: await sendTraceId(caller, sessionId, accepted.turnId) } : {}),
363
367
  });
364
- if (!send.returnImmediately)
368
+ // The task is read from the stream: it must hold the message this request wrote.
369
+ if (send.returnImmediately)
370
+ await caughtUp(caller.ctx, sessionId, accepted.cursor, signal);
371
+ else
365
372
  await settled(caller.ctx, sessionId, accepted, signal);
366
373
  const { task } = await ownedTask(caller, taskId, send.historyLength);
367
374
  return { task };
@@ -509,17 +516,30 @@ function replyTarget(owned) {
509
516
  /**
510
517
  * `CancelTask`. A queued task (`SUBMITTED`) leaves its context's queue and is `CANCELED`
511
518
  * (`queue.cancelled`, pushed), whether a task runs in the context or not (Host feature
512
- * `queue-recovery`); one a turn took meanwhile is cancelled as that turn. A running or waiting
513
- * task's turn is cancelled. An ended task is not cancelable, unless it is `CANCELED` already.
519
+ * `queue-recovery`); one a turn took meanwhile is cancelled as that turn. The answer waits for
520
+ * the stream to hold the `queue.cancelled` it wrote (`caughtUp`). A running or waiting task's turn
521
+ * is cancelled. An ended task is not cancelable, unless it is `CANCELED` already.
514
522
  */
515
523
  async function cancelTask(caller, taskId, again = true) {
516
524
  const owned = await ownedTask(caller, taskId);
517
525
  if (owned.queued) {
518
526
  const cancelled = await cancelQueuedMessage(caller.ctx, owned.sessionId, owned.turnId, caller.scope, "Canceled by the A2A client");
519
527
  // Gone from the queue meanwhile: a turn took it, or it failed. Answer as the task is now.
520
- if (!cancelled && again)
521
- return cancelTask(caller, taskId, false);
522
- return (await ownedTask(caller, taskId)).task;
528
+ if (!cancelled)
529
+ return again ? cancelTask(caller, taskId, false) : (await ownedTask(caller, taskId)).task;
530
+ await caughtUp(caller.ctx, owned.sessionId, cancelled.cursor);
531
+ const after = await ownedTask(caller, taskId).catch((error) => {
532
+ if (error instanceof A2aError && error.kind === "taskNotFound")
533
+ return undefined;
534
+ throw error;
535
+ });
536
+ // The stream still lags behind `queue.cancelled`: the cancel committed, so it is CANCELED.
537
+ return (after?.task ?? {
538
+ id: taskId,
539
+ contextId: owned.contextId,
540
+ status: { state: "TASK_STATE_CANCELED", timestamp: new Date().toISOString() },
541
+ history: [],
542
+ });
523
543
  }
524
544
  if (owned.session.activeTurnId !== owned.turnId) {
525
545
  if (owned.task.status.state === "TASK_STATE_CANCELED")
@@ -577,7 +597,9 @@ async function createPushConfig(caller, params) {
577
597
  requirePush(caller);
578
598
  const { taskId, config } = parseCreatePushParams(params);
579
599
  const owned = await ownedTask(caller, taskId);
580
- const last = owned.dropped ?? owned.queued ?? owned.events.at(-1);
600
+ // A queued task the stream has not caught up with is followed from the session's start.
601
+ const queued = owned.queued && "type" in owned.queued ? owned.queued : undefined;
602
+ const last = owned.dropped ?? queued ?? owned.events.at(-1);
581
603
  const ended = owned.dropped !== undefined || owned.events.some((event) => ENDED.has(event.type));
582
604
  const configId = config.id ?? createHash("sha256").update(`${config.url}\u0000${config.token ?? ""}`).digest("hex").slice(0, 16);
583
605
  return registerPush(caller, { ...config, id: configId }, {
@@ -622,6 +644,40 @@ async function deletePushConfig(caller, params) {
622
644
  await caller.ctx.store.tx((t) => t.deletePushConfig(owned.sessionId, owned.task.id, ref.id));
623
645
  return {};
624
646
  }
647
+ /** How long a reply waits for the session's stream to hold the request's own write. */
648
+ const OWN_WRITE_WAIT_MS = 5_000;
649
+ /**
650
+ * Waits until the session's stream holds the event at `cursor`, which this request wrote: the
651
+ * relay copies it from the record a little after the commit, and replies are read from the
652
+ * stream. Ends after `OWN_WRITE_WAIT_MS`, when the caller hangs up, or when the feed ends; the
653
+ * reply then says what the stream and the store say.
654
+ */
655
+ async function caughtUp(ctx, sessionId, cursor, signal) {
656
+ const seq = decodeCursor(sessionId, cursor);
657
+ const stop = new AbortController();
658
+ const timer = setTimeout(() => stop.abort(), OWN_WRITE_WAIT_MS);
659
+ timer.unref();
660
+ const hangup = () => stop.abort();
661
+ signal?.addEventListener("abort", hangup, { once: true });
662
+ if (signal?.aborted)
663
+ stop.abort();
664
+ try {
665
+ // The feed yields what follows a cursor: start from the event before.
666
+ const before = seq > 0 ? encodeCursor(sessionId, seq - 1) : undefined;
667
+ for await (const event of observeSession(ctx, sessionId, before, { signal: stop.signal }))
668
+ if (event.seq >= seq)
669
+ return;
670
+ }
671
+ catch (error) {
672
+ // A session or streams gone: the reply says so.
673
+ if (!(error instanceof HttpError))
674
+ throw error;
675
+ }
676
+ finally {
677
+ clearTimeout(timer);
678
+ signal?.removeEventListener("abort", hangup);
679
+ }
680
+ }
625
681
  /**
626
682
  * Waits until the task's turn ends, pauses or has an uncertain effect, the caller hangs up, or
627
683
  * the Tenant's Runtime limit `a2aWaitMs` passes: after it, the task is returned as it is
@@ -121,10 +121,10 @@ export interface BuildTaskInput {
121
121
  */
122
122
  export declare function buildTask(input: BuildTaskInput): Task | undefined;
123
123
  /**
124
- * A queued message's task while it waits (profile v2 §1): `SUBMITTED`, from its `message.queued`
125
- * event, until a turn takes it.
124
+ * A queued message's task while it waits (profile v2 §1): `SUBMITTED` since `queuedAt` (its
125
+ * `message.queued` event's time, or its queue row's), until a turn takes it.
126
126
  */
127
- export declare function submittedTask(contextId: string, taskId: string, queued: LiveEvent): Task;
127
+ export declare function submittedTask(contextId: string, taskId: string, queuedAt: string): Task;
128
128
  /**
129
129
  * A queued message's task that ended without a turn (Host feature `queue-recovery`), from the
130
130
  * `queue.failed` or `queue.cancelled` event that names it: `FAILED`, saying why it never started,
@@ -488,14 +488,14 @@ export function buildTask(input) {
488
488
  };
489
489
  }
490
490
  /**
491
- * A queued message's task while it waits (profile v2 §1): `SUBMITTED`, from its `message.queued`
492
- * event, until a turn takes it.
491
+ * A queued message's task while it waits (profile v2 §1): `SUBMITTED` since `queuedAt` (its
492
+ * `message.queued` event's time, or its queue row's), until a turn takes it.
493
493
  */
494
- export function submittedTask(contextId, taskId, queued) {
494
+ export function submittedTask(contextId, taskId, queuedAt) {
495
495
  return {
496
496
  id: taskId,
497
497
  contextId,
498
- status: { state: "TASK_STATE_SUBMITTED", timestamp: queued.time },
498
+ status: { state: "TASK_STATE_SUBMITTED", timestamp: queuedAt },
499
499
  history: [],
500
500
  };
501
501
  }
@@ -15896,6 +15896,33 @@ export declare const TenantSandboxView: z.ZodObject<{
15896
15896
  }, z.core.$strict>>;
15897
15897
  defaultImage: z.ZodString;
15898
15898
  config: z.ZodObject<{
15899
+ limits: z.ZodOptional<z.ZodObject<{
15900
+ network: z.ZodOptional<z.ZodArray<z.ZodString>>;
15901
+ resources: z.ZodOptional<z.ZodObject<{
15902
+ cpus: z.ZodOptional<z.ZodNumber>;
15903
+ memory: z.ZodOptional<z.ZodString>;
15904
+ }, z.core.$strict>>;
15905
+ defaultResources: z.ZodOptional<z.ZodObject<{
15906
+ cpus: z.ZodOptional<z.ZodNumber>;
15907
+ memory: z.ZodOptional<z.ZodString>;
15908
+ }, z.core.$strict>>;
15909
+ idle: z.ZodOptional<z.ZodString>;
15910
+ environments: z.ZodOptional<z.ZodNumber>;
15911
+ }, z.core.$strict>>;
15912
+ lifecycle: z.ZodOptional<z.ZodObject<{
15913
+ stopGrace: z.ZodOptional<z.ZodString>;
15914
+ }, z.core.$strict>>;
15915
+ engine: z.ZodOptional<z.ZodObject<{
15916
+ images: z.ZodOptional<z.ZodArray<z.ZodString>>;
15917
+ }, z.core.$strict>>;
15918
+ placement: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
15919
+ hosts: z.ZodArray<z.ZodEnum<{
15920
+ "harness-container": "harness-container";
15921
+ sandbox: "sandbox";
15922
+ }>>;
15923
+ }, z.core.$strict>>>;
15924
+ }, z.core.$strict>;
15925
+ effective: z.ZodObject<{
15899
15926
  limits: z.ZodObject<{
15900
15927
  network: z.ZodArray<z.ZodString>;
15901
15928
  resources: z.ZodObject<{
@@ -15918,6 +15945,9 @@ export declare const TenantSandboxView: z.ZodObject<{
15918
15945
  sandbox: "sandbox";
15919
15946
  }>>;
15920
15947
  }, z.core.$strict>>>;
15948
+ engine: z.ZodOptional<z.ZodObject<{
15949
+ images: z.ZodArray<z.ZodString>;
15950
+ }, z.core.$strict>>;
15921
15951
  }, z.core.$strict>;
15922
15952
  cluster: z.ZodOptional<z.ZodNullable<z.ZodObject<{
15923
15953
  namespace: z.ZodString;
@@ -50,15 +50,21 @@ const budgetView = (row) => ({
50
50
  ...(row.limitUsd !== null ? { limitUsd: row.limitUsd } : {}),
51
51
  ...(row.limitTokens !== null ? { limitTokens: row.limitTokens } : {}),
52
52
  });
53
- /** `GET /v1/tenant/sandbox`: the backend report and the configuration with defaults applied. */
53
+ /**
54
+ * `GET /v1/tenant/sandbox`: the backend report, the settings as stored (`config`, what `PUT`
55
+ * takes, so a client can put it back changed) and the configuration in force (`effective`).
56
+ */
54
57
  async function sandboxView(ctx) {
55
58
  const config = await ctx.store.tx((t) => readSandboxConfig(t));
56
59
  return {
57
60
  ...(await ctx.sandbox.report()),
58
- config: effectiveSandboxConfig(config),
61
+ config,
62
+ effective: effectiveSandboxConfig(config),
59
63
  cluster: await clusterOf(ctx),
60
64
  };
61
65
  }
66
+ /** What the sandbox routes answer, for their descriptions. */
67
+ const SANDBOX_VIEW_TEXT = "`config` is the Tenant's settings as stored: only what it set, in exactly the shape `PUT` takes, so a client changes one setting by putting `config` back with that change. `effective` is the configuration in force: `config` with every default filled in, sizes in MiB (`memoryMiB`).";
62
68
  /** The Tenant's cluster for pod sandboxes (F7.2), from the sandboxes service; null without one. */
63
69
  async function clusterOf(ctx) {
64
70
  if (!ctx.pods)
@@ -224,16 +230,21 @@ export function tenantRoutes(api) {
224
230
  path: "/v1/tenant/sandbox",
225
231
  tags: ["Tenant"],
226
232
  summary: "Get the Tenant's sandbox backend and Agent compute settings",
227
- responses: { 200: json(TenantSandboxView, "The backend in use and the configuration in force") },
233
+ description: SANDBOX_VIEW_TEXT,
234
+ responses: {
235
+ 200: json(TenantSandboxView, "The backend in use, the settings as stored and the configuration in force"),
236
+ },
228
237
  }, async (c) => jsonResponse(200, await sandboxView(c.env.tenant)));
229
238
  tenantRoute(api, MANAGEMENT, {
230
239
  method: "put",
231
240
  path: "/v1/tenant/sandbox",
232
241
  tags: ["Tenant"],
233
242
  summary: "Set the Tenant's Agent compute settings",
234
- description: "The limits every Agent's runtime must fit, the pods' stop grace and where harnesses may run (Tenant setting `environments.config`).",
243
+ description: `Replaces the Tenant's settings (Tenant setting \`environments.config\`): the limits every Agent's runtime must fit, the pods' stop grace, the images that carry the engine (\`engine.images\`) and where harnesses may run. A setting left out takes its default. Answers like \`GET\`: ${SANDBOX_VIEW_TEXT}`,
235
244
  request: { body: body(PutTenantEnvironmentsRequest) },
236
- responses: { 200: json(TenantSandboxView, "The configuration now in force") },
245
+ responses: {
246
+ 200: json(TenantSandboxView, "The settings now stored and the configuration now in force"),
247
+ },
237
248
  }, async (c) => {
238
249
  const ctx = c.env.tenant;
239
250
  const { requestId: _requestId, ...config } = PutTenantEnvironmentsRequestSchema.parse(await readJson(c));
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.2.0",
3
3
  "info": {
4
4
  "title": "Nylorun Management API",
5
- "version": "0.46.2-beta",
5
+ "version": "0.47.1-beta",
6
6
  "x-nylorun-protocol": {
7
7
  "min": 16,
8
8
  "max": 18,
@@ -2144,6 +2144,104 @@
2144
2144
  "type": "string"
2145
2145
  },
2146
2146
  "config": {
2147
+ "type": "object",
2148
+ "properties": {
2149
+ "limits": {
2150
+ "type": "object",
2151
+ "properties": {
2152
+ "network": {
2153
+ "type": "array",
2154
+ "items": {
2155
+ "type": "string"
2156
+ }
2157
+ },
2158
+ "resources": {
2159
+ "type": "object",
2160
+ "properties": {
2161
+ "cpus": {
2162
+ "type": "integer",
2163
+ "minimum": 1
2164
+ },
2165
+ "memory": {
2166
+ "type": "string"
2167
+ }
2168
+ },
2169
+ "additionalProperties": false
2170
+ },
2171
+ "defaultResources": {
2172
+ "type": "object",
2173
+ "properties": {
2174
+ "cpus": {
2175
+ "type": "integer",
2176
+ "minimum": 1
2177
+ },
2178
+ "memory": {
2179
+ "type": "string"
2180
+ }
2181
+ },
2182
+ "additionalProperties": false
2183
+ },
2184
+ "idle": {
2185
+ "type": "string"
2186
+ },
2187
+ "environments": {
2188
+ "type": "integer",
2189
+ "minimum": 0
2190
+ }
2191
+ },
2192
+ "additionalProperties": false
2193
+ },
2194
+ "lifecycle": {
2195
+ "type": "object",
2196
+ "properties": {
2197
+ "stopGrace": {
2198
+ "type": "string"
2199
+ }
2200
+ },
2201
+ "additionalProperties": false
2202
+ },
2203
+ "engine": {
2204
+ "type": "object",
2205
+ "properties": {
2206
+ "images": {
2207
+ "type": "array",
2208
+ "items": {
2209
+ "type": "string",
2210
+ "minLength": 1,
2211
+ "maxLength": 256,
2212
+ "pattern": "^[a-z0-9]([a-z0-9._:/-]*[a-z0-9])?$"
2213
+ },
2214
+ "maxItems": 32
2215
+ }
2216
+ },
2217
+ "additionalProperties": false
2218
+ },
2219
+ "placement": {
2220
+ "type": "object",
2221
+ "additionalProperties": {
2222
+ "type": "object",
2223
+ "properties": {
2224
+ "hosts": {
2225
+ "type": "array",
2226
+ "items": {
2227
+ "type": "string",
2228
+ "enum": [
2229
+ "harness-container",
2230
+ "sandbox"
2231
+ ]
2232
+ }
2233
+ }
2234
+ },
2235
+ "required": [
2236
+ "hosts"
2237
+ ],
2238
+ "additionalProperties": false
2239
+ }
2240
+ }
2241
+ },
2242
+ "additionalProperties": false
2243
+ },
2244
+ "effective": {
2147
2245
  "type": "object",
2148
2246
  "properties": {
2149
2247
  "limits": {
@@ -2236,6 +2334,21 @@
2236
2334
  ],
2237
2335
  "additionalProperties": false
2238
2336
  }
2337
+ },
2338
+ "engine": {
2339
+ "type": "object",
2340
+ "properties": {
2341
+ "images": {
2342
+ "type": "array",
2343
+ "items": {
2344
+ "type": "string"
2345
+ }
2346
+ }
2347
+ },
2348
+ "required": [
2349
+ "images"
2350
+ ],
2351
+ "additionalProperties": false
2239
2352
  }
2240
2353
  },
2241
2354
  "required": [
@@ -2300,7 +2413,8 @@
2300
2413
  "reason",
2301
2414
  "probes",
2302
2415
  "defaultImage",
2303
- "config"
2416
+ "config",
2417
+ "effective"
2304
2418
  ],
2305
2419
  "additionalProperties": false
2306
2420
  },
@@ -6841,6 +6955,7 @@
6841
6955
  "Settings"
6842
6956
  ],
6843
6957
  "summary": "Get the Tenant's sandbox backend and Agent compute settings",
6958
+ "description": "`config` is the Tenant's settings as stored: only what it set, in exactly the shape `PUT` takes, so a client changes one setting by putting `config` back with that change. `effective` is the configuration in force: `config` with every default filled in, sizes in MiB (`memoryMiB`).",
6844
6959
  "security": [
6845
6960
  {
6846
6961
  "managementKey": []
@@ -6864,7 +6979,7 @@
6864
6979
  ],
6865
6980
  "responses": {
6866
6981
  "200": {
6867
- "description": "The backend in use and the configuration in force",
6982
+ "description": "The backend in use, the settings as stored and the configuration in force",
6868
6983
  "content": {
6869
6984
  "application/json": {
6870
6985
  "schema": {
@@ -6940,7 +7055,7 @@
6940
7055
  "Settings"
6941
7056
  ],
6942
7057
  "summary": "Set the Tenant's Agent compute settings",
6943
- "description": "The limits every Agent's runtime must fit, the pods' stop grace and where harnesses may run (Tenant setting `environments.config`).",
7058
+ "description": "Replaces the Tenant's settings (Tenant setting `environments.config`): the limits every Agent's runtime must fit, the pods' stop grace, the images that carry the engine (`engine.images`) and where harnesses may run. A setting left out takes its default. Answers like `GET`: `config` is the Tenant's settings as stored: only what it set, in exactly the shape `PUT` takes, so a client changes one setting by putting `config` back with that change. `effective` is the configuration in force: `config` with every default filled in, sizes in MiB (`memoryMiB`).",
6944
7059
  "security": [
6945
7060
  {
6946
7061
  "managementKey": []
@@ -6974,7 +7089,7 @@
6974
7089
  },
6975
7090
  "responses": {
6976
7091
  "200": {
6977
- "description": "The configuration now in force",
7092
+ "description": "The settings now stored and the configuration now in force",
6978
7093
  "content": {
6979
7094
  "application/json": {
6980
7095
  "schema": {
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.46.2-beta",
5
+ "version": "0.47.1-beta",
6
6
  "x-nylorun-protocol": {
7
7
  "min": 16,
8
8
  "max": 18,
@@ -21,6 +21,10 @@
21
21
  * ran on for days. A timer that fires during a turn looks again one idle period later.
22
22
  * Becoming ready moves `lastActiveAt` to that moment, so a slow start doesn't use up the
23
23
  * idle period before the pod can take a turn.
24
+ * - **Crashed** (Agent Substrate): a running pod whose sandbox crashed is `failed` at once
25
+ * (`agent.failed`, saying what the Agent lost); a crash an idle suspend finds is recorded the
26
+ * same way. The next turn start asks for `Running`, and the sandboxes service reverts the
27
+ * sandbox to its last suspend before resuming it.
24
28
  * - **Lost**: an incarnation that has run (a pod joined) whose Sandbox is gone or whose volume
25
29
  * is missing is `lost` (`agent.lost`): its host epoch moves, its Sandbox is deleted, and
26
30
  * only a reset brings the Environment back. agent-sandbox recreates a deleted claim empty, so
@@ -58,6 +62,14 @@ export declare function repositoryOf(reference: string): string;
58
62
  export declare function imageDigestOf(imageID: string | undefined, requested: string): string | undefined;
59
63
  /** A setup failure's `reason` starts with this (`sandbox/setup.ts`); it is sticky. */
60
64
  export declare const SETUP_FAILED = "setup:";
65
+ /** A crash's `reason` (see `crashReasonOf`) starts with this; it stays until a turn starts the pod. */
66
+ export declare const CRASHED = "Crashed: ";
67
+ /**
68
+ * A sandbox that crashed (Agent Substrate: its worker went away, or a wake failed) lost what its
69
+ * Agent wrote since its last suspend; the next start reverts it to that suspend. The reason
70
+ * `agent.failed` records, or undefined when the sandbox has not crashed.
71
+ */
72
+ export declare function crashReasonOf(status: PodStatus): string | undefined;
61
73
  /** The lifecycle settings (D36), read each time a decision is made. */
62
74
  export interface PodLifecycleConfig {
63
75
  readonly idleMs: number;
@@ -36,6 +36,19 @@ export function imageDigestOf(imageID, requested) {
36
36
  }
37
37
  /** A setup failure's `reason` starts with this (`sandbox/setup.ts`); it is sticky. */
38
38
  export const SETUP_FAILED = "setup:";
39
+ /** A crash's `reason` (see `crashReasonOf`) starts with this; it stays until a turn starts the pod. */
40
+ export const CRASHED = "Crashed: ";
41
+ /**
42
+ * A sandbox that crashed (Agent Substrate: its worker went away, or a wake failed) lost what its
43
+ * Agent wrote since its last suspend; the next start reverts it to that suspend. The reason
44
+ * `agent.failed` records, or undefined when the sandbox has not crashed.
45
+ */
46
+ export function crashReasonOf(status) {
47
+ if (status.reason !== "Crashed")
48
+ return undefined;
49
+ const what = status.message?.trim() || "the sandbox stopped";
50
+ return `${CRASHED}${what}. What the Agent wrote since its last suspend is lost; its next turn starts it from that suspend.`;
51
+ }
39
52
  /** The service's operation id of the row's current spec. */
40
53
  export function opIdOf(pod) {
41
54
  return `${pod.k8sName}.r${pod.rev}`;
@@ -195,9 +208,19 @@ export function decide(facts) {
195
208
  const startedAt = Date.parse(pod.startedAt ?? row.updatedAt);
196
209
  if (pod.observed === "failed")
197
210
  return done();
211
+ // A pod that was running and crashed will not come back by itself: failed now, not at the
212
+ // ready deadline. One still starting is left to its wake, which reverts a crashed sandbox.
213
+ const crash = crashReasonOf(status);
214
+ if (crash !== undefined && pod.observed === "running") {
215
+ patch.observed = "failed";
216
+ patch.reason = crash;
217
+ events.push(event("agent.failed", { reason: crash }));
218
+ return done();
219
+ }
198
220
  if (now - startedAt >= readyDeadlineMs) {
199
- const reason = [status.reason, status.message].filter(Boolean).join(": ") ||
200
- `The pod was not ready within ${Math.round(readyDeadlineMs / 1000)} s`;
221
+ const reason = crash ??
222
+ ([status.reason, status.message].filter(Boolean).join(": ") ||
223
+ `The pod was not ready within ${Math.round(readyDeadlineMs / 1000)} s`);
201
224
  patch.observed = "failed";
202
225
  patch.reason = reason;
203
226
  events.push(event("agent.failed", { reason }));
@@ -212,6 +235,13 @@ export function decide(facts) {
212
235
  if (status.suspended || !status.podPhase) {
213
236
  if (pod.observed !== "suspended")
214
237
  patch.observed = "suspended";
238
+ // A suspend can find the sandbox already crashed (the idle timer fired before anything saw
239
+ // the crash): record the crash once, so its owner learns what it lost.
240
+ const crash = crashReasonOf(status);
241
+ if (crash !== undefined && pod.reason !== crash) {
242
+ patch.reason = crash;
243
+ events.push(event("agent.failed", { reason: crash }));
244
+ }
215
245
  return done();
216
246
  }
217
247
  again(pollMs);
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * A Tenant's Environment settings, stored as Tenant setting `environments.config`: the limits
3
- * every Environment's recipe must fit, the pods' stop grace and the harness placement. Unset
4
- * fields take the defaults below.
3
+ * every Environment's recipe must fit, the pods' stop grace, the images that carry the engine and
4
+ * the harness placement. Unset fields take the defaults below.
5
5
  */
6
6
  import { type PlacementHost, type TenantEnvironmentsConfig } from "@nylorun/core/contracts";
7
7
  import { DEFAULT_SANDBOX_IDLE } from "./policy.js";
@@ -36,6 +36,10 @@ export interface EffectiveSandboxConfig {
36
36
  readonly placement: Readonly<Record<string, {
37
37
  readonly hosts: readonly PlacementHost[];
38
38
  }>>;
39
+ /** Pods: the image repositories that carry this Runtime's engine (ADR-99). */
40
+ readonly engine: {
41
+ readonly images: readonly string[];
42
+ };
39
43
  }
40
44
  interface SettingsReader {
41
45
  getSetting(key: string): Promise<string | undefined>;
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * A Tenant's Environment settings, stored as Tenant setting `environments.config`: the limits
3
- * every Environment's recipe must fit, the pods' stop grace and the harness placement. Unset
4
- * fields take the defaults below.
3
+ * every Environment's recipe must fit, the pods' stop grace, the images that carry the engine and
4
+ * the harness placement. Unset fields take the defaults below.
5
5
  */
6
6
  import { TenantEnvironmentsConfigSchema, } from "@nylorun/core/contracts";
7
7
  import { parseSandboxSize } from "@nylorun/core/define";
@@ -42,6 +42,7 @@ export function effectiveSandboxConfig(config) {
42
42
  },
43
43
  lifecycle: { stopGrace: config.lifecycle?.stopGrace ?? "10s" },
44
44
  placement: config.placement ?? { "*": { hosts: ["harness-container", "sandbox"] } },
45
+ engine: { images: config.engine?.images ?? [] },
45
46
  };
46
47
  }
47
48
  /** The stored configuration, or `{}` when the Tenant has none. */
@@ -55,7 +55,7 @@ import { sandboxWorkspaceKey } from "../sandbox/records.js";
55
55
  import { effectiveSandboxConfig, readSandboxConfig } from "../sandbox/tenant-config.js";
56
56
  import { podName } from "../sandbox/pods/name.js";
57
57
  import { podLifecycleConfig } from "../sandbox/pods/spec.js";
58
- import { SETUP_FAILED } from "../sandbox/pods/lifecycle.js";
58
+ import { CRASHED, SETUP_FAILED } from "../sandbox/pods/lifecycle.js";
59
59
  import { requirePods } from "../sandbox/placement.js";
60
60
  import { deleteDiskArtifact, forkSeedOf, runPendingSeed, snapshotEnvironment } from "../artifacts/disk.js";
61
61
  import { manifestChanges, manifestRecipeRequest } from "./agent-manifests.js";
@@ -128,6 +128,10 @@ export function stateOf(agent, sessions, unsetSecrets = []) {
128
128
  const pod = agent.pod;
129
129
  if (pod && (pod.observed === "failed" || pod.observed === "lost") && pod.desired !== "deleted")
130
130
  attention.push({ state: "failing", reason: `compute ${pod.observed}${pod.reason ? `: ${pod.reason}` : ""}` });
131
+ // A crash an idle suspend found leaves the pod suspended, not failed: what the Agent wrote since
132
+ // its last suspend is gone all the same, so it is named until a turn starts the pod again.
133
+ else if (pod && pod.reason?.startsWith(CRASHED) && pod.desired !== "deleted")
134
+ attention.push({ state: "failing", reason: `compute crashed: ${pod.reason}` });
131
135
  // A start on a new version failed and went back (track T3): until a start on a new one succeeds.
132
136
  if (agent.failedVersion !== undefined)
133
137
  attention.push({ state: "failing", reason: `start_failed: version ${agent.failedVersion} did not start` });
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const RUNTIME_VERSION = "0.46.2-beta";
1
+ export declare const RUNTIME_VERSION = "0.47.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.46.2-beta";
3
+ export const RUNTIME_VERSION = "0.47.1-beta";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nylorun/runtime",
3
- "version": "0.46.2-beta",
3
+ "version": "0.47.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.5",
66
66
  "@hono/zod-openapi": "^1.6.3",
67
67
  "@modelcontextprotocol/sdk": "^1.32.1",
68
- "@nylorun/core": "0.40.0-beta",
69
- "@nylorun/harness": "0.36.0-beta",
68
+ "@nylorun/core": "0.41.0-beta",
69
+ "@nylorun/harness": "0.36.1-beta",
70
70
  "@restatedev/restate-sdk": "1.17.3",
71
71
  "@restatedev/restate-sdk-clients": "1.17.3",
72
72
  "@s2-dev/streamstore": "0.28.0",