@forgezero/agent 0.1.31 → 0.1.32

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.
@@ -1,6 +1,6 @@
1
1
  import type { NodeKeyPair } from '@forgezero/runtime/identity';
2
2
  import { type AgentRelease } from './agent-update';
3
- import { type AgentUpdateResponse } from './agent-update-helper';
3
+ import { type AgentUpdateReceipt, type AgentUpdateResponse } from './agent-update-helper';
4
4
  export interface AgentObservation {
5
5
  version: string;
6
6
  os: {
@@ -9,12 +9,18 @@ export interface AgentObservation {
9
9
  };
10
10
  architecture: string;
11
11
  mode: 'attested' | 'enrolled';
12
+ update?: AgentUpdateReceipt;
12
13
  }
13
14
  export interface AgentHeartbeatResponse {
14
15
  ok: true;
15
16
  nodeKey: string;
16
17
  intervalSeconds: number;
17
18
  desiredAgentRelease?: AgentRelease;
19
+ desiredAgentUpdate?: {
20
+ attemptId: string;
21
+ leaseExpiresAtTs: number;
22
+ release: AgentRelease;
23
+ };
18
24
  }
19
25
  export interface AgentHeartbeatOptions {
20
26
  apiUrl: string;
@@ -26,11 +32,13 @@ export interface AgentHeartbeatOptions {
26
32
  fetch?: (input: URL, init: RequestInit) => Promise<Response>;
27
33
  requestTimeoutMs?: number;
28
34
  observation?: () => AgentObservation;
35
+ receiptPath?: string;
36
+ now?: () => number;
29
37
  /** Stop new lifecycle/deploy claims and await current jobs before replacement. */
30
38
  prepareUpdate?: (release: AgentRelease) => Promise<void>;
31
39
  /** Restart the drained current process if staging is refused before activation. */
32
40
  recoverUpdate?: (cause: unknown) => Promise<void> | void;
33
- applyUpdate?: (release: AgentRelease, currentVersion: string) => Promise<AgentUpdateResponse>;
41
+ applyUpdate?: (release: AgentRelease, currentVersion: string, attemptId: string) => Promise<AgentUpdateResponse>;
34
42
  setTimer?: (callback: () => void, ms: number) => unknown;
35
43
  clearTimer?: (handle: unknown) => void;
36
44
  onEvent?: (event: string, detail?: unknown) => void;
@@ -2,8 +2,11 @@
2
2
  import { createHash, timingSafeEqual, randomUUID } from "node:crypto";
3
3
  import {
4
4
  chmodSync,
5
+ closeSync,
5
6
  existsSync,
7
+ fsyncSync,
6
8
  mkdirSync,
9
+ openSync,
7
10
  readFileSync,
8
11
  readlinkSync,
9
12
  renameSync,
@@ -17,6 +20,25 @@ var DEFAULT_AGENT_UPDATE_SOCKET = "/run/forgezero-update/helper.sock";
17
20
  var MAX_AGENT_TARBALL_BYTES = 32 * 1024 * 1024;
18
21
  var VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
19
22
  var REGISTRY = "registry.npmjs.org";
23
+ var syncPath = (path) => {
24
+ const descriptor = openSync(path, "r");
25
+ try {
26
+ fsyncSync(descriptor);
27
+ } finally {
28
+ closeSync(descriptor);
29
+ }
30
+ };
31
+ var syncReleaseDirectory = (directory) => {
32
+ for (const path of [
33
+ join(directory, "package.json"),
34
+ join(directory, "dist", "fz-agent.js"),
35
+ join(directory, "dist", "fz.js"),
36
+ join(directory, "dist"),
37
+ directory,
38
+ dirname(directory)
39
+ ])
40
+ syncPath(path);
41
+ };
20
42
  function validateAgentRelease(release) {
21
43
  if (release?.package !== "@forgezero/agent")
22
44
  throw new Error("agent update package is fixed");
@@ -135,16 +157,24 @@ async function stageAgentRelease(releaseInput, options) {
135
157
  ]
136
158
  }, "agent update extraction");
137
159
  await validateReleaseDirectory(unpacked, release, run);
138
- if (!existsSync(finalDirectory))
160
+ if (!existsSync(finalDirectory)) {
139
161
  renameSync(unpacked, finalDirectory);
140
- else
162
+ syncReleaseDirectory(finalDirectory);
163
+ } else
141
164
  await validateReleaseDirectory(finalDirectory, release, run);
142
165
  if (!existsSync(currentLink)) {
143
166
  throw new Error("agent update requires an active immutable release to roll back to");
144
167
  }
145
168
  const previousTarget = readlinkSync(currentLink);
169
+ if (previousTarget !== join("versions", options.currentVersion)) {
170
+ throw new Error("agent update current release does not match the running version");
171
+ }
172
+ if (!existsSync(join(root, previousTarget))) {
173
+ throw new Error("agent update rollback release is missing");
174
+ }
146
175
  return {
147
176
  version: release.version,
177
+ fromVersion: options.currentVersion,
148
178
  directory: finalDirectory,
149
179
  previousTarget,
150
180
  nextTarget: join("versions", release.version),
@@ -159,6 +189,7 @@ function selectAgentRelease(staged) {
159
189
  try {
160
190
  symlinkSync(staged.nextTarget, next);
161
191
  renameSync(next, staged.currentLink);
192
+ syncPath(dirname(staged.currentLink));
162
193
  } finally {
163
194
  rmSync(next, { force: true });
164
195
  }
@@ -168,25 +199,188 @@ function restoreAgentRelease(staged) {
168
199
  try {
169
200
  symlinkSync(staged.previousTarget, next);
170
201
  renameSync(next, staged.currentLink);
202
+ syncPath(dirname(staged.currentLink));
171
203
  } finally {
172
204
  rmSync(next, { force: true });
173
205
  }
174
206
  }
175
207
 
176
208
  // src/agent-update-helper.ts
177
- import { chmodSync as chmodSync2, existsSync as existsSync2, mkdirSync as mkdirSync2, renameSync as renameSync2, unlinkSync, writeFileSync as writeFileSync2 } from "node:fs";
209
+ import { randomUUID as randomUUID2 } from "node:crypto";
210
+ import {
211
+ chmodSync as chmodSync2,
212
+ closeSync as closeSync2,
213
+ existsSync as existsSync2,
214
+ fsyncSync as fsyncSync2,
215
+ mkdirSync as mkdirSync2,
216
+ openSync as openSync2,
217
+ readFileSync as readFileSync2,
218
+ renameSync as renameSync2,
219
+ rmSync as rmSync2,
220
+ unlinkSync,
221
+ writeFileSync as writeFileSync2
222
+ } from "node:fs";
178
223
  import { connect, createServer } from "node:net";
179
- import { dirname as dirname2 } from "node:path";
224
+ import { dirname as dirname2, join as join2, resolve as resolve2 } from "node:path";
180
225
  import { DEFAULT_SOCKET } from "@forgezero/vault";
181
226
  var AGENT_UPDATE_GROUP = "forgezero-update";
182
227
  var AGENT_UPDATE_HELPER_UNIT_PATH = "/etc/systemd/system/forgezero-agent-update-helper.service";
183
- var AGENT_UPDATE_RECEIPT = "/var/lib/forgezero/agent-update.json";
228
+ var AGENT_UPDATE_JOURNAL = "/var/lib/forgezero/agent-update.json";
229
+ var AGENT_UPDATE_RECEIPT = "/var/lib/forgezero/agent-update-receipt.json";
184
230
  var MAX_REQUEST_BYTES = 8 * 1024;
185
231
  var COMPUTE_HELPER_UNITS = [
186
232
  "forgezero-deploy-runner.service",
187
233
  "forgezero-lifecycle-helper.service",
188
234
  "forgezero-software-helper.service"
189
235
  ];
236
+ var VERSION2 = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
237
+ var ATTEMPT_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
238
+ var REASON_CODE = /^[A-Z][A-Z0-9_]{0,63}$/;
239
+ var MAX_REASON_BYTES = 512;
240
+ var UPDATE_RETRY_BASE_MS = 5 * 60000;
241
+ var UPDATE_RETRY_MAX_MS = 24 * 60 * 60000;
242
+ var boundedMessage = (value) => {
243
+ let message = value.replace(/[\r\n]+/g, " ").trim();
244
+ while (Buffer.byteLength(message, "utf8") > MAX_REASON_BYTES)
245
+ message = message.slice(0, -1);
246
+ return message;
247
+ };
248
+ var reason = (code, message) => ({
249
+ code: REASON_CODE.test(code) ? code : "UPDATE_FAILED",
250
+ message: boundedMessage(message) || "Agent update failed"
251
+ });
252
+ var validTime = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
253
+ function validateReceipt(value) {
254
+ if (!value || typeof value !== "object")
255
+ throw new Error("Agent update receipt is malformed");
256
+ const receipt = value;
257
+ if (!receipt.attemptId || !ATTEMPT_ID.test(receipt.attemptId))
258
+ throw new Error("Agent update attempt ID is invalid");
259
+ if (!receipt.fromVersion || !VERSION2.test(receipt.fromVersion))
260
+ throw new Error("Agent update source version is invalid");
261
+ if (!receipt.targetVersion || !VERSION2.test(receipt.targetVersion))
262
+ throw new Error("Agent update target version is invalid");
263
+ if (!["activating", "active", "rolled-back", "failed"].includes(receipt.outcome ?? "")) {
264
+ throw new Error("Agent update outcome is invalid");
265
+ }
266
+ if (!validTime(receipt.startedAtTs) || !validTime(receipt.updatedAtTs)) {
267
+ throw new Error("Agent update timestamps are invalid");
268
+ }
269
+ if (receipt.retryAfterTs !== undefined && !validTime(receipt.retryAfterTs)) {
270
+ throw new Error("Agent update retry timestamp is invalid");
271
+ }
272
+ if (receipt.rollbackHealthy !== undefined && typeof receipt.rollbackHealthy !== "boolean") {
273
+ throw new Error("Agent update rollback health is invalid");
274
+ }
275
+ if (receipt.reason && (!REASON_CODE.test(receipt.reason.code) || typeof receipt.reason.message !== "string" || Buffer.byteLength(receipt.reason.message, "utf8") > MAX_REASON_BYTES))
276
+ throw new Error("Agent update failure reason is invalid");
277
+ return {
278
+ attemptId: receipt.attemptId,
279
+ fromVersion: receipt.fromVersion,
280
+ targetVersion: receipt.targetVersion,
281
+ outcome: receipt.outcome,
282
+ startedAtTs: receipt.startedAtTs,
283
+ updatedAtTs: receipt.updatedAtTs,
284
+ ...receipt.retryAfterTs === undefined ? {} : { retryAfterTs: receipt.retryAfterTs },
285
+ ...receipt.rollbackHealthy === undefined ? {} : { rollbackHealthy: receipt.rollbackHealthy },
286
+ ...receipt.reason === undefined ? {} : { reason: receipt.reason }
287
+ };
288
+ }
289
+ function validateJournal(value, root) {
290
+ if (!value || typeof value !== "object")
291
+ throw new Error("Agent update journal is malformed");
292
+ const legacy = value;
293
+ if (legacy.schemaVersion === undefined) {
294
+ if (typeof legacy.version === "string" && VERSION2.test(legacy.version) && legacy.outcome === "active" && validTime(legacy.updatedAtTs) && Object.keys(value).every((key) => ["version", "outcome", "updatedAtTs"].includes(key)))
295
+ return;
296
+ throw new Error("Agent update legacy receipt is malformed");
297
+ }
298
+ const journal = value;
299
+ const receipt = validateReceipt(journal);
300
+ if (journal.schemaVersion !== 1)
301
+ throw new Error("Agent update journal schema is unsupported");
302
+ if (journal.target !== "compute" && journal.target !== "metal")
303
+ throw new Error("Agent update target is invalid");
304
+ if (!Number.isSafeInteger(journal.failureCount) || (journal.failureCount ?? -1) < 0) {
305
+ throw new Error("Agent update failure count is invalid");
306
+ }
307
+ const releaseRoot = resolve2(root);
308
+ if (journal.currentLink !== join2(releaseRoot, "current"))
309
+ throw new Error("Agent update current link is invalid");
310
+ if (journal.previousTarget !== join2("versions", receipt.fromVersion)) {
311
+ throw new Error("Agent update rollback target is invalid");
312
+ }
313
+ if (journal.nextTarget !== join2("versions", receipt.targetVersion)) {
314
+ throw new Error("Agent update next target is invalid");
315
+ }
316
+ return journal;
317
+ }
318
+ function readJournal(path, root) {
319
+ if (!existsSync2(path))
320
+ return;
321
+ return validateJournal(JSON.parse(readFileSync2(path, "utf8")), root);
322
+ }
323
+ function writeAtomic(path, value, mode) {
324
+ mkdirSync2(dirname2(path), { recursive: true, mode: 493 });
325
+ const next = `${path}.${randomUUID2()}.next`;
326
+ let file;
327
+ try {
328
+ file = openSync2(next, "wx", mode);
329
+ writeFileSync2(file, `${JSON.stringify(value)}
330
+ `);
331
+ fsyncSync2(file);
332
+ closeSync2(file);
333
+ file = undefined;
334
+ renameSync2(next, path);
335
+ const directory = openSync2(dirname2(path), "r");
336
+ try {
337
+ fsyncSync2(directory);
338
+ } finally {
339
+ closeSync2(directory);
340
+ }
341
+ } finally {
342
+ if (file !== undefined)
343
+ closeSync2(file);
344
+ rmSync2(next, { force: true });
345
+ }
346
+ }
347
+ var publicReceipt = (journal) => {
348
+ const {
349
+ attemptId,
350
+ fromVersion,
351
+ targetVersion,
352
+ outcome,
353
+ startedAtTs,
354
+ updatedAtTs,
355
+ retryAfterTs,
356
+ rollbackHealthy,
357
+ reason: failureReason
358
+ } = journal;
359
+ return {
360
+ attemptId,
361
+ fromVersion,
362
+ targetVersion,
363
+ outcome,
364
+ startedAtTs,
365
+ updatedAtTs,
366
+ ...retryAfterTs === undefined ? {} : { retryAfterTs },
367
+ ...rollbackHealthy === undefined ? {} : { rollbackHealthy },
368
+ ...failureReason === undefined ? {} : { reason: failureReason }
369
+ };
370
+ };
371
+ function writeUpdateState(journalPath, receiptPath, journal) {
372
+ writeAtomic(journalPath, journal, 384);
373
+ writeAtomic(receiptPath, publicReceipt(journal), 416);
374
+ }
375
+ function readAgentUpdateReceipt(path = AGENT_UPDATE_RECEIPT) {
376
+ try {
377
+ if (!existsSync2(path))
378
+ return;
379
+ return validateReceipt(JSON.parse(readFileSync2(path, "utf8")));
380
+ } catch {
381
+ return;
382
+ }
383
+ }
190
384
  var runCommand = async (input) => {
191
385
  const child = Bun.spawn([input.command, ...input.args], {
192
386
  cwd: input.cwd,
@@ -202,8 +396,27 @@ var runCommand = async (input) => {
202
396
  return { exitCode, output: `${stdout}${stderr}` };
203
397
  };
204
398
  var runOk = async (run, command2, args) => (await run({ command: command2, args })).exitCode === 0;
399
+ var retryAfter = (now, failures) => now + Math.min(UPDATE_RETRY_MAX_MS, UPDATE_RETRY_BASE_MS * 2 ** Math.min(16, Math.max(0, failures - 1)));
400
+ var stagedFromJournal = (journal, root) => ({
401
+ version: journal.targetVersion,
402
+ fromVersion: journal.fromVersion,
403
+ directory: join2(resolve2(root), journal.nextTarget),
404
+ previousTarget: journal.previousTarget,
405
+ nextTarget: journal.nextTarget,
406
+ currentLink: journal.currentLink
407
+ });
408
+ var restartAgent = async (target, run) => {
409
+ const helpers = target === "compute" ? COMPUTE_HELPER_UNITS : ["forgezero-metal-helper.service"];
410
+ for (const unit of helpers)
411
+ await run({ command: "/usr/bin/systemctl", args: ["try-restart", unit] });
412
+ const service = target === "compute" ? "forgezero-agent.service" : "forgezero-metal-agent.service";
413
+ if (!await runOk(run, "/usr/bin/systemctl", ["restart", service])) {
414
+ throw new Error(`systemd could not restart ${service}`);
415
+ }
416
+ };
417
+ var targetProbe = (target, run) => target === "compute" ? () => probeAgentSocket() : async () => await runOk(run, "/usr/bin/systemctl", ["is-active", "--quiet", "forgezero-metal-agent.service"]) && await runOk(run, "/usr/bin/systemctl", ["is-active", "--quiet", "forgezero-metal-helper.service"]);
205
418
  function probeAgentSocket(socketPath = DEFAULT_SOCKET, timeoutMs = 5000) {
206
- return new Promise((resolve2) => {
419
+ return new Promise((resolve3) => {
207
420
  const socket = connect(socketPath);
208
421
  let settled = false;
209
422
  let buffer = "";
@@ -213,7 +426,7 @@ function probeAgentSocket(socketPath = DEFAULT_SOCKET, timeoutMs = 5000) {
213
426
  settled = true;
214
427
  clearTimeout(timer);
215
428
  socket.destroy();
216
- resolve2(value);
429
+ resolve3(value);
217
430
  };
218
431
  const timer = setTimeout(() => finish(false), timeoutMs);
219
432
  socket.on("connect", () => socket.write(`{"op":"identity"}
@@ -237,55 +450,136 @@ function probeAgentSocket(socketPath = DEFAULT_SOCKET, timeoutMs = 5000) {
237
450
  async function activateAgentRelease(staged, options = {}) {
238
451
  const run = options.run ?? runCommand;
239
452
  const target = options.target ?? "compute";
240
- const probe = options.probe ?? (target === "compute" ? () => probeAgentSocket() : async () => await runOk(run, "/usr/bin/systemctl", ["is-active", "--quiet", "forgezero-metal-agent.service"]) && await runOk(run, "/usr/bin/systemctl", ["is-active", "--quiet", "forgezero-metal-helper.service"]));
241
- const restart = async () => {
242
- const helpers = target === "compute" ? COMPUTE_HELPER_UNITS : ["forgezero-metal-helper.service"];
243
- for (const unit of helpers) {
244
- await run({ command: "/usr/bin/systemctl", args: ["try-restart", unit] });
245
- }
246
- const service = target === "compute" ? "forgezero-agent.service" : "forgezero-metal-agent.service";
247
- const restarted = await runOk(run, "/usr/bin/systemctl", ["restart", service]);
248
- if (!restarted)
249
- throw new Error(`systemd could not restart ${service}`);
453
+ const probe = options.probe ?? targetProbe(target, run);
454
+ const now = options.now ?? Date.now;
455
+ const journalPath = options.journalPath ?? AGENT_UPDATE_JOURNAL;
456
+ const receiptPath = options.receiptPath ?? AGENT_UPDATE_RECEIPT;
457
+ const previous = readJournal(journalPath, dirname2(staged.currentLink));
458
+ const attemptId = options.attemptId ?? randomUUID2();
459
+ if (!ATTEMPT_ID.test(attemptId))
460
+ throw new Error("Agent update attempt ID is invalid");
461
+ const startedAtTs = now();
462
+ const failureCount = previous?.targetVersion === staged.version ? previous.failureCount : 0;
463
+ let journal = {
464
+ schemaVersion: 1,
465
+ attemptId,
466
+ target,
467
+ fromVersion: staged.fromVersion,
468
+ targetVersion: staged.version,
469
+ outcome: "activating",
470
+ startedAtTs,
471
+ updatedAtTs: startedAtTs,
472
+ currentLink: staged.currentLink,
473
+ previousTarget: staged.previousTarget,
474
+ nextTarget: staged.nextTarget,
475
+ failureCount
250
476
  };
251
- let selected = false;
477
+ writeUpdateState(journalPath, receiptPath, journal);
478
+ let selectionAttempted = false;
252
479
  try {
480
+ selectionAttempted = true;
253
481
  selectAgentRelease(staged);
254
- selected = true;
255
- await restart();
482
+ await restartAgent(target, run);
256
483
  if (!await probe())
257
484
  throw new Error("the replacement Agent did not answer its retained Vault socket");
258
- const receipt = options.receiptPath ?? AGENT_UPDATE_RECEIPT;
259
- mkdirSync2(dirname2(receipt), { recursive: true, mode: 493 });
260
- const next = `${receipt}.next`;
261
- writeFileSync2(next, JSON.stringify({
262
- version: staged.version,
263
- outcome: "active",
264
- updatedAtTs: (options.now ?? Date.now)()
265
- }) + `
266
- `, { mode: 420 });
267
- renameSync2(next, receipt);
485
+ journal = { ...journal, outcome: "active", updatedAtTs: now(), rollbackHealthy: undefined };
486
+ writeUpdateState(journalPath, receiptPath, journal);
268
487
  run({
269
488
  command: "/usr/bin/systemctl",
270
489
  args: ["try-restart", "--no-block", "forgezero-agent-update-helper.service"]
271
490
  });
272
491
  return { ok: true, version: staged.version };
273
492
  } catch (cause) {
274
- const reason = cause instanceof Error ? cause.message : String(cause);
275
- if (selected) {
276
- restoreAgentRelease(staged);
277
- await restart();
493
+ const message = cause instanceof Error ? cause.message : String(cause);
494
+ let rollbackHealthy = false;
495
+ let restored = false;
496
+ if (selectionAttempted) {
497
+ try {
498
+ restoreAgentRelease(staged);
499
+ restored = true;
500
+ await restartAgent(target, run);
501
+ rollbackHealthy = await probe();
502
+ } catch {
503
+ rollbackHealthy = false;
504
+ }
278
505
  }
279
- return { ok: false, rolledBack: selected, reason };
506
+ const failures = failureCount + 1;
507
+ const updatedAtTs = now();
508
+ journal = {
509
+ ...journal,
510
+ outcome: rollbackHealthy ? "rolled-back" : "failed",
511
+ updatedAtTs,
512
+ retryAfterTs: retryAfter(updatedAtTs, failures),
513
+ rollbackHealthy,
514
+ reason: reason(rollbackHealthy ? "REPLACEMENT_UNHEALTHY" : "ROLLBACK_UNHEALTHY", rollbackHealthy ? message : `${message}; the restored Agent did not pass its health probe`),
515
+ failureCount: failures
516
+ };
517
+ writeUpdateState(journalPath, receiptPath, journal);
518
+ return { ok: false, rolledBack: restored, rollbackHealthy, reason: message };
280
519
  }
281
520
  }
521
+ async function recoverInterruptedAgentUpdate(options = {}) {
522
+ const root = resolve2(options.root ?? DEFAULT_AGENT_RELEASE_ROOT);
523
+ const journalPath = options.journalPath ?? AGENT_UPDATE_JOURNAL;
524
+ const receiptPath = options.receiptPath ?? AGENT_UPDATE_RECEIPT;
525
+ const journal = readJournal(journalPath, root);
526
+ if (!journal)
527
+ return;
528
+ if (journal.outcome !== "activating") {
529
+ writeAtomic(receiptPath, publicReceipt(journal), 416);
530
+ return publicReceipt(journal);
531
+ }
532
+ const staged = stagedFromJournal(journal, root);
533
+ if (!existsSync2(join2(root, journal.previousTarget))) {
534
+ throw new Error("Agent update rollback release is missing");
535
+ }
536
+ const run = options.run ?? runCommand;
537
+ const probe = options.probe ?? targetProbe(journal.target, run);
538
+ restoreAgentRelease(staged);
539
+ let rollbackHealthy = false;
540
+ let failureMessage = "activation was interrupted before its health verdict became durable";
541
+ try {
542
+ await restartAgent(journal.target, run);
543
+ rollbackHealthy = await probe();
544
+ } catch (cause) {
545
+ failureMessage = `${failureMessage}; ${cause instanceof Error ? cause.message : String(cause)}`;
546
+ }
547
+ const failures = journal.failureCount + 1;
548
+ const updatedAtTs = (options.now ?? Date.now)();
549
+ const recovered = {
550
+ ...journal,
551
+ outcome: rollbackHealthy ? "rolled-back" : "failed",
552
+ updatedAtTs,
553
+ retryAfterTs: retryAfter(updatedAtTs, failures),
554
+ rollbackHealthy,
555
+ reason: reason(rollbackHealthy ? "ACTIVATION_INTERRUPTED" : "ROLLBACK_UNHEALTHY", failureMessage),
556
+ failureCount: failures
557
+ };
558
+ writeUpdateState(journalPath, receiptPath, recovered);
559
+ return readAgentUpdateReceipt(receiptPath);
560
+ }
282
561
  function startAgentUpdateHelper(options = {}) {
283
562
  const socketPath = options.socketPath ?? DEFAULT_AGENT_UPDATE_SOCKET;
284
563
  if (existsSync2(socketPath))
285
564
  unlinkSync(socketPath);
286
565
  mkdirSync2(dirname2(socketPath), { recursive: true, mode: 488 });
287
566
  const setTimer = options.setTimer ?? ((callback, ms) => setTimeout(callback, ms));
288
- const activate = options.activate ?? ((staged, target) => activateAgentRelease(staged, { target }));
567
+ const receiptPath = options.receiptPath ?? AGENT_UPDATE_RECEIPT;
568
+ const journalPath = options.journalPath ?? AGENT_UPDATE_JOURNAL;
569
+ const releaseRoot = options.root ?? DEFAULT_AGENT_RELEASE_ROOT;
570
+ const activate = options.activate ?? ((staged, target, attemptId) => activateAgentRelease(staged, { target, attemptId, journalPath, receiptPath, now: options.now }));
571
+ let busy = true;
572
+ let blocked;
573
+ (options.recover ?? (() => recoverInterruptedAgentUpdate({
574
+ root: releaseRoot,
575
+ journalPath,
576
+ receiptPath,
577
+ now: options.now
578
+ })))().catch((cause) => {
579
+ blocked = cause instanceof Error ? cause.message : String(cause);
580
+ }).finally(() => {
581
+ busy = false;
582
+ });
289
583
  const server = createServer((socket) => {
290
584
  let buffer = "";
291
585
  socket.on("data", (chunk) => {
@@ -301,22 +595,42 @@ function startAgentUpdateHelper(options = {}) {
301
595
  return;
302
596
  const line = buffer.slice(0, newline);
303
597
  buffer = "";
598
+ let ownsBusy = false;
304
599
  Promise.resolve().then(() => JSON.parse(line)).then(async (request) => {
600
+ if (blocked)
601
+ throw new Error(`update journal needs operator recovery: ${blocked}`);
602
+ if (busy)
603
+ throw new Error("another Agent update or recovery is already active");
305
604
  if (request.op !== "apply")
306
605
  throw new Error("unknown update operation");
307
606
  if (request.target !== "compute" && request.target !== "metal") {
308
607
  throw new Error("agent update target is invalid");
309
608
  }
609
+ const attemptId = request.attemptId ?? randomUUID2();
610
+ if (!ATTEMPT_ID.test(attemptId))
611
+ throw new Error("Agent update attempt ID is invalid");
612
+ const prior = readJournal(journalPath, releaseRoot);
613
+ const now = (options.now ?? Date.now)();
614
+ if (prior?.targetVersion === request.release.version && (prior.outcome === "rolled-back" || prior.outcome === "failed") && (prior.retryAfterTs ?? 0) > now)
615
+ throw new Error(`Agent update ${request.release.version} is quarantined until ${prior.retryAfterTs}`);
616
+ busy = true;
617
+ ownsBusy = true;
310
618
  const staged = await stageAgentRelease(request.release, {
311
619
  currentVersion: request.currentVersion,
312
- root: options.root ?? DEFAULT_AGENT_RELEASE_ROOT
620
+ root: releaseRoot
313
621
  });
314
- const response = { ok: true, status: "staged", version: staged.version };
622
+ const response = { ok: true, status: "staged", version: staged.version, attemptId };
315
623
  socket.end(`${JSON.stringify(response)}
316
- `, () => {
317
- setTimer(() => void activate(staged, request.target), 100);
318
- });
624
+ `);
625
+ setTimer(() => void activate(staged, request.target, attemptId).catch((cause) => {
626
+ blocked = cause instanceof Error ? cause.message : String(cause);
627
+ }).finally(() => {
628
+ busy = false;
629
+ }), 100);
630
+ ownsBusy = false;
319
631
  }).catch((cause) => {
632
+ if (ownsBusy)
633
+ busy = false;
320
634
  const response = {
321
635
  ok: false,
322
636
  error: { code: "UPDATE_REFUSED", message: cause instanceof Error ? cause.message : String(cause) }
@@ -331,7 +645,7 @@ function startAgentUpdateHelper(options = {}) {
331
645
  return server;
332
646
  }
333
647
  function requestAgentUpdate(request, socketPath = DEFAULT_AGENT_UPDATE_SOCKET, timeoutMs = 90000) {
334
- return new Promise((resolve2, reject) => {
648
+ return new Promise((resolve3, reject) => {
335
649
  const socket = connect(socketPath, () => socket.write(`${JSON.stringify(request)}
336
650
  `));
337
651
  let buffer = "";
@@ -347,7 +661,7 @@ function requestAgentUpdate(request, socketPath = DEFAULT_AGENT_UPDATE_SOCKET, t
347
661
  return;
348
662
  socket.end();
349
663
  try {
350
- resolve2(JSON.parse(buffer.slice(0, newline)));
664
+ resolve3(JSON.parse(buffer.slice(0, newline)));
351
665
  } catch (cause) {
352
666
  reject(cause);
353
667
  }
@@ -357,7 +671,7 @@ function requestAgentUpdate(request, socketPath = DEFAULT_AGENT_UPDATE_SOCKET, t
357
671
  }
358
672
 
359
673
  // src/agent-heartbeat.ts
360
- import { readFileSync as readFileSync2 } from "node:fs";
674
+ import { readFileSync as readFileSync3 } from "node:fs";
361
675
 
362
676
  // src/signed-node-http.ts
363
677
  import {
@@ -418,8 +732,8 @@ async function postSignedNode(options, path, body) {
418
732
  const payload = await response.json().catch(() => null);
419
733
  if (!response.ok) {
420
734
  const failure = payload;
421
- const reason = failure ? failure.error?.message ?? failure.message : undefined;
422
- throw new SignedNodeHttpError(response.status, reason || `signed node request returned HTTP ${response.status}`);
735
+ const reason2 = failure ? failure.error?.message ?? failure.message : undefined;
736
+ throw new SignedNodeHttpError(response.status, reason2 || `signed node request returned HTTP ${response.status}`);
423
737
  }
424
738
  try {
425
739
  return await openResponse(recipient.secretKey, signature, payload);
@@ -429,11 +743,11 @@ async function postSignedNode(options, path, body) {
429
743
  }
430
744
 
431
745
  // src/version.ts
432
- var VERSION2 = "0.1.31";
746
+ var VERSION3 = "0.1.32";
433
747
 
434
748
  // src/agent-heartbeat.ts
435
749
  var unquote = (value) => value.replace(/^['"]|['"]$/g, "");
436
- function observeAgentHost(version = VERSION2, mode = "enrolled", osRelease = readFileSync2("/etc/os-release", "utf8"), architecture = process.arch) {
750
+ function observeAgentHost(version = VERSION3, mode = "enrolled", osRelease = readFileSync3("/etc/os-release", "utf8"), architecture = process.arch) {
437
751
  const values = Object.fromEntries(osRelease.split(`
438
752
  `).flatMap((line) => {
439
753
  const separator = line.indexOf("=");
@@ -447,23 +761,51 @@ function observeAgentHost(version = VERSION2, mode = "enrolled", osRelease = rea
447
761
  };
448
762
  }
449
763
  async function heartbeatAgentOnce(options) {
450
- const observation = (options.observation ?? (() => observeAgentHost(options.version ?? VERSION2, options.mode)))();
764
+ let observation = (options.observation ?? (() => observeAgentHost(options.version ?? VERSION3, options.mode)))();
765
+ const update = observation.update ?? readAgentUpdateReceipt(options.receiptPath ?? AGENT_UPDATE_RECEIPT);
766
+ if (update)
767
+ observation = { ...observation, update };
451
768
  const response = await postSignedNode(options, "v1/node/heartbeat", observation);
452
- if (response.desiredAgentRelease) {
453
- const release = validateAgentRelease(response.desiredAgentRelease);
769
+ const desired = response.desiredAgentUpdate ?? (response.desiredAgentRelease ? {
770
+ attemptId: `legacy:${response.desiredAgentRelease.version}`,
771
+ leaseExpiresAtTs: Number.MAX_SAFE_INTEGER,
772
+ release: response.desiredAgentRelease
773
+ } : undefined);
774
+ if (desired) {
775
+ const release = validateAgentRelease(desired.release);
776
+ const now = (options.now ?? Date.now)();
777
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(desired.attemptId)) {
778
+ throw new Error("agent update attempt ID is invalid");
779
+ }
780
+ if (!Number.isSafeInteger(desired.leaseExpiresAtTs) || desired.leaseExpiresAtTs <= now) {
781
+ options.onEvent?.("update-lease-expired", { attemptId: desired.attemptId, to: release.version });
782
+ return response;
783
+ }
454
784
  if (compareVersions(release.version, observation.version) > 0) {
785
+ if (update?.targetVersion === release.version && (update.outcome === "rolled-back" || update.outcome === "failed") && (update.retryAfterTs ?? 0) > now) {
786
+ options.onEvent?.("update-quarantined", {
787
+ attemptId: desired.attemptId,
788
+ to: release.version,
789
+ retryAfterTs: update.retryAfterTs
790
+ });
791
+ return response;
792
+ }
455
793
  let prepared = false;
456
794
  try {
457
795
  await options.prepareUpdate?.(release);
458
796
  prepared = true;
459
- const applied = await (options.applyUpdate ?? ((next, current) => requestAgentUpdate({
797
+ const applied = await (options.applyUpdate ?? ((next, current, attemptId) => requestAgentUpdate({
460
798
  op: "apply",
461
799
  target: options.updateTarget ?? "compute",
462
800
  release: next,
463
- currentVersion: current
464
- })))(release, observation.version);
801
+ currentVersion: current,
802
+ attemptId
803
+ })))(release, observation.version, desired.attemptId);
465
804
  if (!applied.ok)
466
805
  throw new Error(`agent update refused: ${applied.error.message}`);
806
+ if (applied.attemptId !== desired.attemptId) {
807
+ throw new Error("agent update helper returned the wrong rollout attempt");
808
+ }
467
809
  options.onEvent?.("update-staged", { from: observation.version, to: release.version });
468
810
  } catch (cause) {
469
811
  if (prepared)