@nylorun/runtime 0.46.0-beta → 0.46.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,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.46.1-beta
4
+
5
+ ### Patch Changes
6
+
7
+ - 3a9933c: A pod's engine rejoins at once after an Agent Substrate restore. When the process resumes from a memory snapshot, the engine notices the jump in the wall clock (a 100 ms timer finding more than 1.5 s gone), drops the connection attempt the freeze caught in flight, its host-token renew or join included, and dials again without a backoff pause. Before, it waited for TCP to resend the packet lost with the old network: 1 s, or 3 s on a second loss, which the Phase 1 exit test measured in 4 of 10 wakes. Only pod engines watch for this (`harness_clock_jumped` in their log); an open connection is never dropped by it.
8
+
3
9
  ## 0.46.0-beta
4
10
 
5
11
  ### Minor Changes
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Notices that the process was frozen and resumed, as an Agent Substrate actor is by a memory
3
+ * snapshot's restore: a timer that should fire every `intervalMs` finds the wall clock moved on
4
+ * by more than `jumpMs` beyond that. A pod's engine uses it to drop a connection attempt the
5
+ * freeze caught in flight (its packets went with the old network, so it would wait for TCP to
6
+ * send them again, 1 s and then 3 s; P1.8's third run) and to dial again at once.
7
+ *
8
+ * An event loop held up as long (a long GC pause) reads as a jump too; the caller only redials,
9
+ * so that costs one connection attempt, never a working connection.
10
+ */
11
+ export interface ClockJumpWatch {
12
+ stop(): void;
13
+ }
14
+ export declare function watchClockJumps(onJump: (jumpedMs: number) => void, options?: {
15
+ intervalMs?: number;
16
+ jumpMs?: number;
17
+ now?: () => number;
18
+ }): ClockJumpWatch;
@@ -0,0 +1,15 @@
1
+ export function watchClockJumps(onJump, options = {}) {
2
+ const intervalMs = options.intervalMs ?? 100;
3
+ const jumpMs = options.jumpMs ?? 1_500;
4
+ const now = options.now ?? Date.now;
5
+ let last = now();
6
+ const timer = setInterval(() => {
7
+ const at = now();
8
+ const late = at - last - intervalMs;
9
+ last = at;
10
+ if (late > jumpMs)
11
+ onJump(late);
12
+ }, intervalMs);
13
+ timer.unref();
14
+ return { stop: () => clearInterval(timer) };
15
+ }
@@ -70,13 +70,15 @@ export async function runHarness(stack, baseline) {
70
70
  if (!pod.afterSeed)
71
71
  await prepare();
72
72
  }
73
- const token = pod ? () => pod.token() : config.token;
73
+ const token = pod ? (signal) => pod.token(signal) : config.token;
74
74
  if (token === undefined)
75
75
  throw new Error("--service harness needs NYLORUN_HARNESS_TOKEN");
76
76
  const runTokens = harnessRunTokens();
77
77
  const service = startHarnessService({
78
78
  url: config.url,
79
79
  token,
80
+ // A pod's engine may be frozen and resumed (an Agent Substrate restore): redial at once.
81
+ ...(pod ? { redialOnClockJump: true } : {}),
80
82
  ...(pod
81
83
  ? { sandboxBackends: [localBackend({ env: baseline, proxyEnv: () => pod.proxyEnv(), harnessRoot: config.root })] }
82
84
  : {}),
@@ -23,8 +23,11 @@ export interface DiskSetupReport {
23
23
  readonly output?: string;
24
24
  }
25
25
  export interface PodHost {
26
- /** A current host token, for the next connection. Throws when neither renewal nor join works. */
27
- token(): Promise<string>;
26
+ /**
27
+ * A current host token, for the next connection. Throws when neither renewal nor join works,
28
+ * or when `signal` drops the attempt.
29
+ */
30
+ token(signal?: AbortSignal): Promise<string>;
28
31
  /** `HTTPS_PROXY` and friends with the current egress token; none before the first join. */
29
32
  proxyEnv(): Record<string, string>;
30
33
  /** The host epoch of the tokens held. */
@@ -38,7 +38,8 @@ export function podHost(config, logger, options = {}) {
38
38
  let afterSeed = false;
39
39
  /** The CA in the bundle on disk, once one is written. */
40
40
  let bundled;
41
- const post = async (path, init) => {
41
+ const post = async (path, init, signal) => {
42
+ const timeout = AbortSignal.timeout(hostTuning().harness.podJoinTimeoutMs);
42
43
  const response = await call(`${config.httpUrl}/nylorun/harness/v1/host/${path}`, {
43
44
  method: "POST",
44
45
  headers: {
@@ -48,7 +49,7 @@ export function podHost(config, logger, options = {}) {
48
49
  ...(path === "join" && imageEngine ? { [IMAGE_ENGINE_HEADER]: RUNTIME_VERSION } : {}),
49
50
  },
50
51
  ...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }),
51
- signal: AbortSignal.timeout(hostTuning().harness.podJoinTimeoutMs),
52
+ signal: signal ? AbortSignal.any([timeout, signal]) : timeout,
52
53
  });
53
54
  const text = await response.text();
54
55
  if (response.status === 401 || response.status === 400)
@@ -97,7 +98,7 @@ export function podHost(config, logger, options = {}) {
97
98
  return id;
98
99
  }
99
100
  };
100
- const join = async () => {
101
+ const join = async (signal) => {
101
102
  const joinToken = (await readFile(config.joinFile, "utf8")).trim();
102
103
  const volume = await volumeId();
103
104
  held = await post("join", {
@@ -107,7 +108,7 @@ export function podHost(config, logger, options = {}) {
107
108
  joinToken,
108
109
  ...(volume === undefined ? {} : { volumeId: volume }),
109
110
  },
110
- });
111
+ }, signal);
111
112
  logger.info("sandbox_host_joined", { sandboxId: config.sandboxId, epoch: held.epoch });
112
113
  return held;
113
114
  };
@@ -122,11 +123,11 @@ export function podHost(config, logger, options = {}) {
122
123
  signal: AbortSignal.timeout(hostTuning().harness.podRequestTimeoutMs),
123
124
  });
124
125
  };
125
- const renew = async () => {
126
+ const renew = async (signal) => {
126
127
  if (!held)
127
128
  return undefined;
128
129
  try {
129
- held = await post("renew", { bearer: held.hostToken });
130
+ held = await post("renew", { bearer: held.hostToken }, signal);
130
131
  return held;
131
132
  }
132
133
  catch (error) {
@@ -143,8 +144,8 @@ export function podHost(config, logger, options = {}) {
143
144
  }, options.renewMs ?? hostTuning().harness.podRenewMs);
144
145
  timer.unref();
145
146
  return {
146
- async token() {
147
- const current = (await renew()) ?? (await join());
147
+ async token(signal) {
148
+ const current = (await renew(signal)) ?? (await join(signal));
148
149
  return current.hostToken;
149
150
  },
150
151
  proxyEnv() {
@@ -7,6 +7,7 @@ import { type EngineTuning } from "../tuning.js";
7
7
  export interface HarnessServiceOptions {
8
8
  readonly url: string;
9
9
  readonly token: HarnessClientOptions["token"];
10
+ readonly redialOnClockJump?: boolean;
10
11
  /** Where workspaces (and their records) live. */
11
12
  readonly paths: {
12
13
  readonly sandboxes: string;
@@ -124,6 +124,7 @@ export function startHarnessService(options) {
124
124
  client = connectHarness({
125
125
  url: options.url,
126
126
  token: options.token,
127
+ ...(options.redialOnClockJump ? { redialOnClockJump: true } : {}),
127
128
  logger,
128
129
  executors,
129
130
  serve,
@@ -5,18 +5,24 @@
5
5
  * core's `workspace.*` requests (`serve`). When the connection is lost its runs stop (core gives
6
6
  * their segments to the next advance, which resumes them by replay) and the client connects
7
7
  * again after a jittered backoff (`harness.reconnect` in the Host tuning, 0.5 to 10 s).
8
+ *
9
+ * A pod's engine (`redialOnClockJump`) also watches for a freeze and resume (an Agent Substrate
10
+ * restore): it drops the connection attempt in flight, token request included, and dials again
11
+ * at once, instead of waiting for TCP to resend what the freeze lost.
8
12
  */
9
13
  import { type HarnessChannel, type RequestHandler } from "@nylorun/core/harness-api";
10
14
  import { type HarnessOptions } from "@nylorun/harness/api";
11
15
  import { type WsPortOptions } from "./ws-port.js";
16
+ import { watchClockJumps } from "./clock-jump.js";
12
17
  export interface HarnessClientOptions extends Omit<HarnessOptions, "channel" | "logger"> {
13
18
  /** The Harness API, e.g. `ws://runtime:4200/nylorun/harness/v1` (`NYLORUN_HARNESS_URL`). */
14
19
  readonly url: string;
15
20
  /**
16
21
  * `NYLORUN_HARNESS_TOKEN`, or, for a pod sandbox's engine (F7.2), a function that answers
17
- * a current host token before each connection (it renews, or joins again).
22
+ * a current host token before each connection (it renews, or joins again). The signal aborts
23
+ * it when the attempt is dropped.
18
24
  */
19
- readonly token: string | (() => Promise<string>);
25
+ readonly token: string | ((signal?: AbortSignal) => Promise<string>);
20
26
  readonly logger: {
21
27
  info(message: string, fields?: Record<string, unknown>): void;
22
28
  warn(message: string, fields?: Record<string, unknown>): void;
@@ -29,6 +35,13 @@ export interface HarnessClientOptions extends Omit<HarnessOptions, "channel" | "
29
35
  readonly maxMs: number;
30
36
  };
31
37
  readonly liveness?: Omit<WsPortOptions, "ping">;
38
+ /**
39
+ * Drop an attempt in flight and dial again at once when the process was frozen and resumed
40
+ * (`clock-jump.ts`): a pod's engine, which Agent Substrate suspends to a memory snapshot.
41
+ */
42
+ readonly redialOnClockJump?: boolean;
43
+ /** The clock and timings `redialOnClockJump` watches with (tests set them). */
44
+ readonly clockJumps?: Parameters<typeof watchClockJumps>[1];
32
45
  }
33
46
  export interface HarnessClient {
34
47
  /** Resolves once the first connection's `hello` is answered. */
@@ -5,6 +5,10 @@
5
5
  * core's `workspace.*` requests (`serve`). When the connection is lost its runs stop (core gives
6
6
  * their segments to the next advance, which resumes them by replay) and the client connects
7
7
  * again after a jittered backoff (`harness.reconnect` in the Host tuning, 0.5 to 10 s).
8
+ *
9
+ * A pod's engine (`redialOnClockJump`) also watches for a freeze and resume (an Agent Substrate
10
+ * restore): it drops the connection attempt in flight, token request included, and dials again
11
+ * at once, instead of waiting for TCP to resend what the freeze lost.
8
12
  */
9
13
  import { createChannel } from "@nylorun/core/harness-api";
10
14
  import { createHarness } from "@nylorun/harness/api";
@@ -12,6 +16,7 @@ import { WebSocket } from "ws";
12
16
  import { HARNESS_API_HEADER, HARNESS_API_TRANSPORT, HARNESS_MAX_PAYLOAD } from "@nylorun/core/harness-api";
13
17
  import { wsPort } from "./ws-port.js";
14
18
  import { hostTuning } from "../tuning.js";
19
+ import { watchClockJumps } from "./clock-jump.js";
15
20
  export function connectHarness(options) {
16
21
  const { logger } = options;
17
22
  const tuning = hostTuning().harness;
@@ -20,30 +25,49 @@ export function connectHarness(options) {
20
25
  let connected = false;
21
26
  let current;
22
27
  let wake;
28
+ /** The connection attempt in flight (token, then handshake), until it opens or fails. */
29
+ let attempting;
30
+ let attempt = 0;
23
31
  let markReady;
24
32
  const ready = new Promise((resolve) => (markReady = resolve));
25
33
  const open = async () => {
26
- const token = typeof options.token === "string" ? options.token : await options.token();
27
- return new Promise((resolve, reject) => {
28
- const socket = new WebSocket(options.url, {
29
- headers: { authorization: `Bearer ${token}`, [HARNESS_API_HEADER]: HARNESS_API_TRANSPORT },
30
- maxPayload: HARNESS_MAX_PAYLOAD,
31
- handshakeTimeout: tuning.handshakeTimeoutMs,
32
- });
33
- socket.once("open", () => {
34
- socket.removeAllListeners("error");
35
- resolve(socket);
36
- });
37
- socket.once("unexpected-response", (_request, response) => {
38
- socket.removeAllListeners("error");
39
- socket.on("error", () => undefined);
40
- reject(new Error(`The Harness API refused the connection: ${response.statusCode ?? 0}`));
41
- response.resume();
42
- socket.terminate();
43
- });
44
- socket.once("error", reject);
45
- });
34
+ const controller = new AbortController();
35
+ attempting = controller;
36
+ try {
37
+ const token = typeof options.token === "string" ? options.token : await options.token(controller.signal);
38
+ controller.signal.throwIfAborted();
39
+ return await dial(token, controller.signal);
40
+ }
41
+ finally {
42
+ if (attempting === controller)
43
+ attempting = undefined;
44
+ }
46
45
  };
46
+ const dial = (token, signal) => new Promise((resolve, reject) => {
47
+ const socket = new WebSocket(options.url, {
48
+ headers: { authorization: `Bearer ${token}`, [HARNESS_API_HEADER]: HARNESS_API_TRANSPORT },
49
+ maxPayload: HARNESS_MAX_PAYLOAD,
50
+ handshakeTimeout: tuning.handshakeTimeoutMs,
51
+ });
52
+ socket.once("open", () => {
53
+ socket.removeAllListeners("error");
54
+ resolve(socket);
55
+ });
56
+ socket.once("unexpected-response", (_request, response) => {
57
+ socket.removeAllListeners("error");
58
+ socket.on("error", () => undefined);
59
+ reject(new Error(`The Harness API refused the connection: ${response.statusCode ?? 0}`));
60
+ response.resume();
61
+ socket.terminate();
62
+ });
63
+ socket.once("error", reject);
64
+ signal.addEventListener("abort", () => {
65
+ socket.removeAllListeners("error");
66
+ socket.on("error", () => undefined);
67
+ socket.terminate();
68
+ reject(signal.reason instanceof Error ? signal.reason : new Error("connection attempt dropped"));
69
+ }, { once: true });
70
+ });
47
71
  const pause = (ms) => new Promise((resolve) => {
48
72
  const timer = setTimeout(done, ms);
49
73
  timer.unref();
@@ -81,8 +105,16 @@ export function connectHarness(options) {
81
105
  current = undefined;
82
106
  }
83
107
  };
108
+ const watch = options.redialOnClockJump
109
+ ? watchClockJumps((jumpedMs) => {
110
+ const inFlight = attempting !== undefined;
111
+ logger.info("harness_clock_jumped", { jumpedMs, redial: inFlight || wake !== undefined });
112
+ attempt = 0;
113
+ attempting?.abort(new Error(`the process resumed after ${jumpedMs} ms`));
114
+ wake?.();
115
+ }, options.clockJumps)
116
+ : undefined;
84
117
  const loop = (async () => {
85
- let attempt = 0;
86
118
  while (!stopped) {
87
119
  const started = Date.now();
88
120
  try {
@@ -113,6 +145,8 @@ export function connectHarness(options) {
113
145
  channel: () => current?.channel,
114
146
  async stop(waitMs = tuning.stopMs) {
115
147
  stopped = true;
148
+ watch?.stop();
149
+ attempting?.abort(new Error("harness stopped"));
116
150
  wake?.();
117
151
  const live = current;
118
152
  if (live) {
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.2.0",
3
3
  "info": {
4
4
  "title": "Nylorun Management API",
5
- "version": "0.46.0-beta",
5
+ "version": "0.46.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.46.0-beta",
5
+ "version": "0.46.1-beta",
6
6
  "x-nylorun-protocol": {
7
7
  "min": 16,
8
8
  "max": 18,
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const RUNTIME_VERSION = "0.46.0-beta";
1
+ export declare const RUNTIME_VERSION = "0.46.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.0-beta";
3
+ export const RUNTIME_VERSION = "0.46.1-beta";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nylorun/runtime",
3
- "version": "0.46.0-beta",
3
+ "version": "0.46.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",