@hivemagnus/magnus-server 1.1.7 → 1.1.8

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,20 +1,29 @@
1
1
  /**
2
2
  * Server-driven agent upgrades (MAGNUS_AGENT_UPGRADES). The server pins one
3
- * exact version and its npm integrity, asks one canary agent to stage it,
4
- * waits until that agent reports the version on trial as confirmed, then
5
- * upgrades the rest a few at a time. A revert, a release problem or a node
6
- * that goes silent after the request stops the run; a node that refuses
7
- * before anything changed (busy, enrollment, unreachable) is recorded as
8
- * `refused` and the wave goes on. Agents decide for themselves whether they
9
- * can upgrade (MAGNUS_AGENT_SELF_UPGRADE, launcher layout) and report it with
10
- * every heartbeat, so the console can grey out the ones that cannot.
3
+ * exact version and its npm integrity, checks every selected node before
4
+ * touching any of them, asks one canary agent to stage it, waits until that
5
+ * agent reports the version on trial as confirmed, then upgrades the rest a
6
+ * few at a time.
7
+ *
8
+ * Only evidence about the RELEASE stops a wave: an integrity or source failure
9
+ * at once, a revert, failed install or unconfirmed trial on the canary, and
10
+ * after the canary as many of those as MAGNUS_AGENT_UPGRADE_MAX_FAILURES.
11
+ * Whatever is true of one node (refusals, busy, unreachable, enrollment,
12
+ * ambiguous transport) is recorded with a code and a remedy, and the wave goes
13
+ * on. Transient refusals are tried again at the end of the wave. Ambiguous
14
+ * nodes are watched by request id without holding a batch slot past their
15
+ * window, and are never retried automatically.
16
+ *
17
+ * Agents decide for themselves whether they can upgrade
18
+ * (MAGNUS_AGENT_SELF_UPGRADE, launcher layout) and report it with every
19
+ * heartbeat, so the console can grey out the ones that cannot.
11
20
  *
12
21
  * Eligibility uses the same read-only predicates as resource admission
13
22
  * (admission fence, enrollment, held work and leases, fresh health), so a node
14
23
  * the console offers is one that dispatch will not refuse for those reasons.
15
24
  */
16
25
  import { randomUUID } from 'node:crypto';
17
- import { AGENT_SELF_UPGRADE_PROTOCOL, agentSelfUpgradeStatusSchema, checkUpgradeAllowed, compareVersions, isVersion, parseVersion, } from '@hivemagnus/magnus-contracts';
26
+ import { AGENT_SELF_UPGRADE_PROTOCOL, agentSelfUpgradeStatusSchema, checkUpgradeAllowed, compareVersions, isVersion, parseVersion, upgradeRemedy, } from '@hivemagnus/magnus-contracts';
18
27
  import { upgradeSettings } from '../config/features.js';
19
28
  import { AGENT_VERSION } from '../config/version.js';
20
29
  import { getDb } from '../db/client.js';
@@ -23,6 +32,7 @@ import { AgentResponseError, DispatchOutcomeUnknownError, NodeApiClient, NotDisp
23
32
  import { getNodeManagerSecret } from '../utils/env.js';
24
33
  import { getNodeBaseUrl } from '../utils/node-url.js';
25
34
  import { SENTINEL_NODE_IDS } from '../utils/sentinel-nodes.js';
35
+ import { CERTAIN_REFUSALS_SINCE, healNode, mapLimit, PREFLIGHT_CONCURRENCY, preflightNode, preflightStatus, selfUpgradeOffCode } from './agent-upgrade-preflight.js';
26
36
  import { logAuditEvent } from './audit-service.js';
27
37
  import { HELD_RESOURCE_STATES, hasFreshOnlineHealth, legacyAdmissionPossible, resourceLeasesRequested } from './command-resource-leases.js';
28
38
  import { enrollmentIsComplete } from './resource-enrollment.js';
@@ -36,29 +46,39 @@ const CONFIRM_MARGIN_MS = 2 * 60_000;
36
46
  /** A request whose reply was lost may still be installing. */
37
47
  const UNKNOWN_DISPATCH_MARGIN_MS = 10 * 60_000;
38
48
  const POLL_MS = 5_000;
49
+ /** MAGNUS_AGENT_UPGRADE_MAX_FAILURES default, for runs stored without their own budget. */
50
+ const DEFAULT_MAX_FAILURES = 2;
51
+ const LEASE_HELD_NOTE = 'not tried again: the lease of this request is still held';
39
52
  const RUN_TTL_SECONDS = 7 * 24 * 60 * 60;
40
53
  /** The owner renews its lease every LEASE_MS / 3; a lease this old means the owner is gone. */
41
54
  const LEASE_MS = 90_000;
42
55
  /** Runs written by servers without leases count as abandoned after this long. */
43
56
  const UNLEASED_RUN_MS = 2 * 60 * 60_000;
44
57
  const MAX_LOCAL_RUNS = 20;
58
+ /** Waits before each end-of-wave retry of transient refusals; so at most two more attempts per node. */
59
+ const AUTO_RETRY_BACKOFF_MS = [30_000, 120_000];
60
+ /** A preflight result this old is not shown as current in the eligibility list. */
61
+ const PREFLIGHT_CACHE_MS = 10 * 60_000;
45
62
  /** This process, as the owner of the runs it executes. */
46
63
  export const INSTANCE_ID = randomUUID();
47
- /** Agent error codes that mean the node was not touched. */
48
- export const UNTOUCHED_CODES = new Set(['SELF_UPGRADE_BUSY', 'SELF_UPGRADE_CURRENT', 'SELF_UPGRADE_UNAVAILABLE', 'SELF_UPGRADE_PACKAGE',
49
- 'SELF_UPGRADE_REFUSED', 'SELF_UPGRADE_LAUNCHER', 'SELF_UPGRADE_ENGINE', 'SELF_UPGRADE_TIMEOUT',
50
- 'RESOURCE_BUSY', 'UNMANAGED_MUTATION', 'ENROLLMENT_AUTHORITY_UNAVAILABLE', 'RESOURCE_CLAIM_INVALID']);
51
- /** Agent error codes that point at the release itself: the wave stops. */
52
- export const RELEASE_PROBLEM_CODES = new Set(['SELF_UPGRADE_INTEGRITY', 'SELF_UPGRADE_FAILED', 'SELF_UPGRADE_SOURCE']);
53
64
  function parseMetadata(raw) {
54
65
  try {
55
66
  const parsed = JSON.parse(raw ?? '{}');
56
67
  return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
57
68
  }
58
- catch {
69
+ catch (error) {
70
+ console.warn(`[agent-upgrade] node metadata is not JSON: ${error instanceof Error ? error.message : String(error)}`);
59
71
  return {};
60
72
  }
61
73
  }
74
+ const onceWarnings = new Map();
75
+ /** The same message at most once an hour, so a Redis outage does not flood the log. */
76
+ function warnOnce(message) {
77
+ if (Date.now() - (onceWarnings.get(message) ?? 0) < 60 * 60_000)
78
+ return;
79
+ onceWarnings.set(message, Date.now());
80
+ console.warn(`[agent-upgrade] ${message}`);
81
+ }
62
82
  const parseWarnings = new Map();
63
83
  /**
64
84
  * Parse a self-upgrade report leniently: a malformed `upgrade` entry is
@@ -126,7 +146,9 @@ export async function recordSelfUpgradeReport(node, body, store = defaultRunStor
126
146
  const nodeVersion = typeof body.nodeVersion === 'string' ? body.nodeVersion.slice(0, 32) : null;
127
147
  const next = { ...parsed, nodeVersion, observedAt: new Date().toISOString() };
128
148
  const nextMetadata = { ...metadata, agentSelfUpgrade: next, nodeVersion };
129
- let changed = !(previous && JSON.stringify({ ...previous, observedAt: '' }) === JSON.stringify({ ...next, observedAt: '' })
149
+ // Free disk moves with every report; only crossing the threshold is a change worth a write.
150
+ const comparable = (value) => JSON.stringify({ ...value, observedAt: '', disk: value.disk?.low });
151
+ let changed = !(previous && comparable(previous) === comparable(next)
130
152
  && Date.now() - Date.parse(previous.observedAt) < 10 * 60_000);
131
153
  const state = parsed.upgrade?.state;
132
154
  const updating = state === 'staged' || state === 'trial';
@@ -158,19 +180,48 @@ export async function recordSelfUpgradeReport(node, body, store = defaultRunStor
158
180
  await recordLateOutcome(store, runId, upgrade.requestId, upgrade.state, upgrade.reason ?? null).catch(() => undefined);
159
181
  }
160
182
  }
161
- /** A report for a node whose run already ended without knowing its outcome. */
183
+ function applyLateOutcome(entry, state, reason) {
184
+ entry.state = state;
185
+ entry.code = state === 'confirmed' ? null : state === 'reverted' ? 'SELF_UPGRADE_REVERTED' : 'SELF_UPGRADE_FAILED';
186
+ entry.failureClass = state === 'confirmed' ? null : 'release';
187
+ entry.detail = `${state === 'confirmed' ? 'confirmed' : state === 'reverted' ? `reverted: ${reason ?? 'the agent switched back'}` : `failed: ${reason ?? 'the agent reported a failure'}`} (reported after the run stopped watching)`;
188
+ entry.awaitingReport = false;
189
+ }
190
+ /** A report for a node nobody watches any more: its run ended, or its watch timed out while the run goes on. */
162
191
  async function recordLateOutcome(store, runId, requestId, state, reason) {
163
- const run = await store.get(runId);
164
- if (!run || run.status === 'running')
165
- return; // the owner is watching and records it itself
192
+ // A read-modify-write of the stored run: one at a time per run here. Two servers can still overlap; the owner re-reads reports.
193
+ const previous = lateOutcomeQueue.get(runId) ?? Promise.resolve();
194
+ const turn = previous.catch(() => undefined).then(() => applyLateReport(store, runId, requestId, state, reason));
195
+ const tail = turn.finally(() => { if (lateOutcomeQueue.get(runId) === tail)
196
+ lateOutcomeQueue.delete(runId); });
197
+ lateOutcomeQueue.set(runId, tail);
198
+ return tail;
199
+ }
200
+ const lateOutcomeQueue = new Map();
201
+ async function applyLateReport(store, runId, requestId, state, reason) {
202
+ const live = executing.get(runId)?.run;
203
+ const run = live ?? await store.get(runId);
204
+ if (!run)
205
+ return;
206
+ if (run.status === 'running' && !live)
207
+ return; // another server owns it and records it itself
166
208
  const entry = run.nodes.find(node => node.requestId === requestId);
167
- if (!entry || !(entry.awaitingReport || entry.state === 'requested'))
209
+ if (!entry)
168
210
  return;
169
- entry.state = state;
170
- entry.code = state === 'confirmed' ? null : entry.code;
171
- entry.detail = `${state === 'confirmed' ? 'confirmed' : state === 'reverted' ? `reverted: ${reason ?? 'the agent switched back'}` : `failed: ${reason ?? 'the agent reported a failure'}`} (reported after the run ended)`;
172
- entry.awaitingReport = false;
211
+ // In a live run a node still being asked is its watcher's to record. Anything that gave up waiting is open,
212
+ // whatever state it ended in: a timed-out trial that confirms later must stop counting against the budget.
213
+ if (!(entry.awaitingReport || (run.status !== 'running' && (entry.state === 'requested' || entry.state === 'watching'))))
214
+ return;
215
+ applyLateOutcome(entry, state, reason);
216
+ if (run.status === 'running')
217
+ return; // the owner counts it at its next decision
173
218
  run.summary = summarizeRun(run);
219
+ run.attention = attentionList(run);
220
+ run.suspectedRelease = releaseSuspicion(run);
221
+ if (run.status === 'succeeded' || run.status === 'partial' || run.status === 'failed') {
222
+ run.status = finalStatus(run);
223
+ run.message = `${run.status === 'succeeded' ? 'all nodes upgraded after late reports' : 'updated by a late report'} (${run.summary})`;
224
+ }
174
225
  await store.save(run);
175
226
  }
176
227
  export const isPrerelease = (version) => (parseVersion(version)?.prerelease.length ?? 0) > 0;
@@ -201,7 +252,7 @@ export async function resolveAgentRelease(options = {}) {
201
252
  const integrity = document.dist?.integrity;
202
253
  if (!integrity?.startsWith('sha512-'))
203
254
  throw new HttpError(502, 'REGISTRY_ERROR', `${AGENT_PACKAGE}@${version} has no sha512 integrity`);
204
- return { version, integrity };
255
+ return { version, integrity, engines: typeof document.engines?.node === 'string' ? document.engines.node : null };
205
256
  }
206
257
  const verifications = new Map();
207
258
  /** Whether the registry has `version` (cached ten minutes); eligibility shows unpublished targets as unverified. */
@@ -227,30 +278,34 @@ export function classifyCandidate(row, target, options = {}) {
227
278
  const now = options.now ?? Date.now();
228
279
  const version = row.agent?.version ?? 'unknown';
229
280
  const report = storedSelfUpgrade(row.metadata);
230
- const base = { nodeId: row.id, name: row.name, version, upgrade: report?.upgrade ?? null, layout: report?.layout ?? null };
231
- const no = (reason) => ({ ...base, eligible: false, reason });
281
+ const enrollmentState = row.resourceEnrollment ?? null;
282
+ // Same rule as admission: off the legacy path once leases are on, enrollment started, or any lease exists.
283
+ const managed = !legacyAdmissionPossible(enrollmentState, options.leasesRequested ?? resourceLeasesRequested()) || Boolean(row.leaseHistory);
284
+ const base = { nodeId: row.id, name: row.name, version, upgrade: report?.upgrade ?? null, layout: report?.layout ?? null, managed, leaseHistory: Boolean(row.leaseHistory), heldLeases: row.heldLeases ?? 0 };
285
+ const no = (code, reason) => ({ ...base, eligible: false, reason, code });
232
286
  if (!hasFreshOnlineHealth(row, now))
233
- return no(`offline or no recent heartbeat${row.lastHeartbeatAt ? ` (last ${row.lastHeartbeatAt.toISOString()})` : ''}`);
287
+ return no('NODE_OFFLINE', `offline or no recent heartbeat${row.lastHeartbeatAt ? ` (last ${row.lastHeartbeatAt.toISOString()})` : ''}`);
234
288
  if (!isVersion(version))
235
- return no('agent version unknown');
289
+ return no('VERSION_UNKNOWN', 'agent version unknown');
290
+ // A node on trial reports the new version already, and can still switch back.
291
+ if (report?.upgrade && (report.upgrade.state === 'staged' || report.upgrade.state === 'trial'))
292
+ return no('UPGRADE_IN_PROGRESS', `an upgrade to ${report.upgrade.to} is in progress`);
236
293
  if (compareVersions(version, target) === 0)
237
- return no('already on this version');
294
+ return no('ALREADY_CURRENT', 'already on this version');
238
295
  const decision = checkUpgradeAllowed(version, target);
239
296
  if (!decision.allowed)
240
- return no(decision.kind === 'downgrade' ? `runs ${version}, newer than ${target}` : `${version} -> ${target} crosses a major version; upgrade this node by hand`);
297
+ return decision.kind === 'downgrade' ? no('DOWNGRADE', `runs ${version}, newer than ${target}`) : no('MAJOR_VERSION', `${version} -> ${target} crosses a major version; upgrade this node by hand`);
241
298
  if (!report)
242
- return no(`magnus-agent ${version} cannot upgrade itself (needs 1.1.0 or later and \`sudo magnus-agent install-service\`)`);
299
+ return no('NOT_SELF_UPGRADABLE', `magnus-agent ${version} cannot upgrade itself (needs 1.1.0 or later and \`sudo magnus-agent install-service\`)`);
243
300
  if (report.protocol < AGENT_SELF_UPGRADE_PROTOCOL)
244
- return no('agent self-upgrade protocol too old');
301
+ return no('PROTOCOL_TOO_OLD', 'agent self-upgrade protocol too old');
245
302
  if (!report.enabled)
246
- return no(report.reason ?? 'self-upgrade disabled on this node');
303
+ return no(selfUpgradeOffCode(report), report.reason ?? 'self-upgrade disabled on this node');
247
304
  const upgrade = report.upgrade;
248
- if (upgrade && (upgrade.state === 'staged' || upgrade.state === 'trial'))
249
- return no(`an upgrade to ${upgrade.to} is in progress`);
250
305
  if (upgrade?.state === 'reverted' && upgrade.to === target && !options.allowRetryReverted)
251
- return no(`reverted ${target} at ${upgrade.updatedAt ?? report.observedAt}: ${upgrade.reason ?? 'no reason given'}; try again only with allowRetryReverted`);
306
+ return no('ALREADY_REVERTED', `reverted ${target} at ${upgrade.updatedAt ?? report.observedAt}: ${upgrade.reason ?? 'no reason given'}; try again only with allowRetryReverted`);
252
307
  if (row.admissionFence)
253
- return no(`release ${row.admissionFence.releaseId} closed admission to this node at ${row.admissionFence.closedAt.toISOString()}; release the fence first`);
308
+ return no('ADMISSION_FENCE_CLOSED', `release ${row.admissionFence.releaseId} closed admission to this node at ${row.admissionFence.closedAt.toISOString()}; release the fence first`);
254
309
  const enrollment = row.resourceEnrollment ?? null;
255
310
  const hostname = row.hostname ?? '';
256
311
  const marker = report.managedEnrollment;
@@ -258,28 +313,26 @@ export function classifyCandidate(row, target, options = {}) {
258
313
  const server = enrollment ? `a ${enrollment.state} enrollment for ${enrollment.nodeFqdn}` : 'no enrollment row';
259
314
  const cause = stickyEnrollmentCause(enrollment, { lease: Boolean(row.leaseHistory), managedCron: Boolean(row.managedCronHistory),
260
315
  participantWork: Boolean(row.participantHistory) });
261
- return no(`agent and server disagree about resource enrollment: the agent holds a managed-enrollment marker for ${marker.nodeFqdn} but the server has ${server}`
316
+ return no('ENROLLMENT_MARKER_MISMATCH', `agent and server disagree about resource enrollment: the agent holds a managed-enrollment marker for ${marker.nodeFqdn} but the server has ${server}`
262
317
  + `${cause ? ` (the server answers this agent as enrolled because of ${cause})` : ''}; `
263
318
  + `complete the node's resource enrollment on the server (${ENROLLMENT_DOC}), or upgrade this agent by hand on the node`);
264
319
  }
265
- // Same rule as admission: off the legacy path once leases are on, enrollment started, or any lease exists.
266
- const managed = !legacyAdmissionPossible(enrollment, options.leasesRequested ?? resourceLeasesRequested()) || Boolean(row.leaseHistory);
267
320
  if (managed) {
268
321
  if (!enrollmentIsComplete(enrollment, hostname)) {
269
322
  if (!enrollment)
270
- return no(`resource leases apply to this node (${row.leaseHistory ? 'it has lease history' : 'MAGNUS_RESOURCE_LEASES_ENABLED=true'}) but it is not enrolled; enroll it first (${ENROLLMENT_DOC})`);
323
+ return no('ENROLLMENT_MISSING', `resource leases apply to this node (${row.leaseHistory ? 'it has lease history' : 'MAGNUS_RESOURCE_LEASES_ENABLED=true'}) but it is not enrolled; enroll it first (${ENROLLMENT_DOC})`);
271
324
  if (enrollment.state === 'draining')
272
- return no(`resource enrollment stuck in draining since ${day(enrollment.startedAt ?? enrollment.updatedAt)}; finish the drain (POST /api/v1/resource-leases/enrollment/${row.id}/complete) first`);
325
+ return no('ENROLLMENT_DRAINING', `resource enrollment stuck in draining since ${day(enrollment.startedAt ?? enrollment.updatedAt)}; finish the drain (POST /api/v1/resource-leases/enrollment/${row.id}/complete) first`);
273
326
  if (enrollment.state === 'enrolled')
274
- return no(`resource enrollment is for ${enrollment.nodeFqdn} but the inventory host name is ${hostname || 'unknown'}; fix the inventory host name first`);
275
- return no(`resource enrollment is ${enrollment.state} while resource leases apply to this node; enroll it first (${ENROLLMENT_DOC})`);
327
+ return no('ENROLLMENT_HOST_MISMATCH', `resource enrollment is for ${enrollment.nodeFqdn} but the inventory host name is ${hostname || 'unknown'}; fix the inventory host name first`);
328
+ return no('ENROLLMENT_INCOMPLETE', `resource enrollment is ${enrollment.state} while resource leases apply to this node; enroll it first (${ENROLLMENT_DOC})`);
276
329
  }
277
330
  if (row.heldWork)
278
- return no(`${row.heldWork} participant work item(s) still held on this node; reconcile them first`);
331
+ return no('WORK_HELD', `${row.heldWork} participant work item(s) still held on this node; reconcile them first`);
279
332
  if (row.heldLeases)
280
- return no(`${row.heldLeases} command lease(s) unsettled on this node (reserved, running or unknown); reconcile them first`);
333
+ return no('LEASE_HELD', `${row.heldLeases} command lease(s) unsettled on this node (reserved, running or unknown); reconcile them first`);
281
334
  }
282
- return { ...base, eligible: true, reason: null };
335
+ return { ...base, eligible: true, reason: null, code: null };
283
336
  }
284
337
  /** Candidates for `target`; `nodeIds: []` selects nothing (never the whole fleet). */
285
338
  export async function listUpgradeCandidates(target, nodeIds, options = {}) {
@@ -368,7 +421,8 @@ export function defaultRunStore() {
368
421
  try {
369
422
  return await remote();
370
423
  }
371
- catch {
424
+ catch (error) {
425
+ warnOnce(`run store: ${error instanceof Error ? error.message : String(error)}`);
372
426
  return local();
373
427
  }
374
428
  };
@@ -434,7 +488,9 @@ export function defaultDeps(store = defaultRunStore()) {
434
488
  },
435
489
  check: async (nodeId, run) => {
436
490
  const [candidate] = await listUpgradeCandidates(run.version, [nodeId], { allowRetryReverted: run.allowRetryReverted });
437
- return candidate ? candidate.eligible ? null : candidate.reason : 'node is no longer in the inventory';
491
+ if (!candidate)
492
+ return { reason: 'node is no longer in the inventory', code: 'NODE_NOT_FOUND' };
493
+ return candidate.eligible ? null : { reason: candidate.reason ?? 'not eligible', code: candidate.code };
438
494
  },
439
495
  sleep: ms => new Promise(resolve => setTimeout(resolve, ms)),
440
496
  now: () => Date.now(),
@@ -442,6 +498,19 @@ export function defaultDeps(store = defaultRunStore()) {
442
498
  stopRequested: async (runId) => await store.stopRequested(runId).catch(() => null),
443
499
  confirmTimeoutMs: confirmWindowMs(null),
444
500
  pollMs: POLL_MS,
501
+ index: run => store.index(run),
502
+ leaseSettled: async (_nodeId, requestId) => {
503
+ const lease = await getDb().commandResourceLease.findUnique({ where: { commandId: `agent-upgrade-${requestId}` }, select: { state: true } });
504
+ return !lease || lease.state === 'released';
505
+ },
506
+ settleLostReply: async (nodeId, requestId) => {
507
+ const node = await getDb().node.findUnique({ where: { id: nodeId }, select: { id: true, ipAddress: true, port: true } });
508
+ // Settles through the signed result of the receipt; a receipt without one changes nothing.
509
+ if (node)
510
+ await clientFor(node).getCommandReceipt(`agent-upgrade-${requestId}`);
511
+ },
512
+ preflight: (target, query) => preflightNode(target, query),
513
+ heal: (target, preflight) => healNode(target, preflight),
445
514
  };
446
515
  }
447
516
  /** Whether a failed request left the node untouched (refused), hit a release problem (failed), or may have run (unknown). */
@@ -452,37 +521,45 @@ export function classifyDispatchFailure(error) {
452
521
  return { kind: 'refused', code: typeof code === 'string' ? code : 'NOT_DISPATCHED', message };
453
522
  if (error instanceof AgentResponseError) {
454
523
  const agentCode = error.code ?? `HTTP_${error.agentStatus}`;
455
- if (agentCode === 'MUTATION_OUTCOME_UNKNOWN')
456
- return { kind: 'unknown', code: agentCode, message };
457
- if (UNTOUCHED_CODES.has(agentCode))
458
- return { kind: 'refused', code: agentCode, message };
459
- return { kind: 'failed', code: agentCode, message: RELEASE_PROBLEM_CODES.has(agentCode) ? message : `agent answered HTTP ${error.agentStatus}: ${message}` };
524
+ const remedy = upgradeRemedy(agentCode);
525
+ // A 5xx with no code of ours says nothing about how far the request got.
526
+ if (remedy.ambiguous || (!error.code && error.agentStatus >= 500))
527
+ return { kind: 'unknown', code: agentCode === `HTTP_${error.agentStatus}` ? 'MUTATION_OUTCOME_UNKNOWN' : agentCode, message, answered: true };
528
+ if (remedy.class === 'release')
529
+ return { kind: 'failed', code: agentCode, message };
530
+ return { kind: 'refused', code: agentCode, message };
460
531
  }
461
532
  if (error instanceof DispatchOutcomeUnknownError)
462
533
  return { kind: 'unknown', code: error.code, message };
463
534
  return { kind: 'unknown', code: typeof code === 'string' ? code : 'MUTATION_OUTCOME_UNKNOWN', message };
464
535
  }
465
536
  const audit = (action, run, details, targetType = 'fleet', targetId = run.id) => logAuditEvent({ action, actorId: run.actorId, targetType, targetId, details: { runId: run.id, version: run.version, ...details } }).catch(() => undefined);
466
- /** Ask one node to upgrade and wait for its confirmed (or reverted) report. */
467
- async function upgradeOne(run, entry, deps) {
537
+ /** Ask one node to upgrade and wait for its confirmed (or reverted) report. An ambiguous node holds its slot only for its window. */
538
+ async function upgradeOne(run, entry, deps, context) {
468
539
  const iso = () => new Date(deps.now()).toISOString();
469
540
  const end = (state, detail, code = null, awaitingReport = false) => {
470
- Object.assign(entry, { state, detail: detail?.slice(0, 1000) ?? null, code, finishedAt: iso(), awaitingReport });
541
+ Object.assign(entry, { state, detail: detail?.slice(0, 1000) ?? null, code, finishedAt: iso(), awaitingReport, failureClass: code ? upgradeRemedy(code).class : null });
471
542
  if (state === 'refused' || state === 'failed' || (state === 'confirmed' && detail?.startsWith('confirmed by version')))
472
543
  void audit(`agent.upgrade.node_${state}`, run, { node: entry.name, requestId: entry.requestId, code, detail }, 'node', entry.nodeId);
473
544
  };
474
- let reason;
545
+ let verdict;
475
546
  try {
476
- reason = await deps.check(entry.nodeId, run);
547
+ verdict = await deps.check(entry.nodeId, run);
477
548
  }
478
549
  catch (error) {
479
- reason = `eligibility check failed: ${error instanceof Error ? error.message : String(error)}`;
550
+ verdict = { reason: `eligibility check failed: ${error instanceof Error ? error.message : String(error)}`, code: 'CHECK_FAILED' };
551
+ }
552
+ if (verdict) {
553
+ const skipped = typeof verdict === 'string' ? { reason: verdict, code: 'NOT_ELIGIBLE' } : { reason: verdict.reason, code: verdict.code ?? 'NOT_ELIGIBLE' };
554
+ // A retry that cannot go ahead says so, and the node keeps what its real attempt ended as.
555
+ const prior = context.retrying.get(entry.nodeId);
556
+ if (prior && prior.state === 'refused')
557
+ return void Object.assign(entry, { ...prior, detail: `${prior.detail ?? ''}; not tried again: ${skipped.reason}`.slice(0, 1000), failureClass: prior.code ? upgradeRemedy(prior.code).class : null, finishedAt: iso() });
558
+ return void Object.assign(entry, { state: 'skipped', detail: skipped.reason, code: skipped.code, failureClass: upgradeRemedy(skipped.code).class, finishedAt: iso() });
480
559
  }
481
- if (reason)
482
- return void Object.assign(entry, { state: 'skipped', detail: reason, finishedAt: iso() });
483
560
  entry.state = 'requested';
561
+ entry.attempts = (entry.attempts ?? 0) + 1;
484
562
  await deps.save(run);
485
- const startedAt = deps.now();
486
563
  let unknown = null;
487
564
  try {
488
565
  await deps.request(entry.nodeId, { version: run.version, integrity: run.integrity, requestId: entry.requestId, packageName: AGENT_PACKAGE });
@@ -495,13 +572,27 @@ async function upgradeOne(run, entry, deps) {
495
572
  return end('failed', failure.message, failure.code);
496
573
  unknown = failure;
497
574
  }
575
+ // Staging can take minutes before the reply: the trial and its window only start once the request has ended.
576
+ const startedAt = deps.now();
498
577
  entry.dispatchedAt = iso();
578
+ let freeSlot = () => undefined;
579
+ const slot = new Promise(resolve => { freeSlot = resolve; });
580
+ const watch = watchNode(run, entry, deps, context, end, startedAt, unknown, freeSlot).catch((error) => {
581
+ end(unknown ? 'ambiguous' : 'failed', `watching this node failed: ${error instanceof Error ? error.message : String(error)}`, unknown?.code ?? 'CONFIRM_TIMEOUT', true);
582
+ }).finally(() => { freeSlot(); context.watchers.delete(watch); });
583
+ context.watchers.add(watch);
584
+ await slot;
585
+ }
586
+ /** Follow one dispatched node until it reports. `freeSlot` runs once an ambiguous node has had its window. */
587
+ async function watchNode(run, entry, deps, context, end, startedAt, unknown, freeSlot) {
499
588
  let windowMs = deps.confirmTimeoutMs;
500
589
  let versionSince = null;
501
590
  let lastHeartbeat = null;
502
591
  const deadline = () => Math.max(startedAt + windowMs + (unknown ? UNKNOWN_DISPATCH_MARGIN_MS : 0), versionSince === null ? 0 : versionSince + windowMs + deps.pollMs);
503
- while (deps.now() < deadline()) {
592
+ while (deps.now() < deadline() && !context.closed) {
504
593
  await deps.sleep(deps.pollMs);
594
+ if (context.closed)
595
+ return;
505
596
  let observation;
506
597
  try {
507
598
  observation = await deps.observe(entry.nodeId);
@@ -516,8 +607,13 @@ async function upgradeOne(run, entry, deps) {
516
607
  windowMs = confirmWindowMs(report);
517
608
  const upgrade = report?.upgrade;
518
609
  if (upgrade?.requestId === entry.requestId) {
519
- if (upgrade.state === 'confirmed')
610
+ if (upgrade.state === 'confirmed') {
611
+ if (unknown)
612
+ await deps.settleLostReply?.(entry.nodeId, entry.requestId).catch((error) => {
613
+ console.warn(`[agent-upgrade] lease of ${entry.name} stays held: ${error instanceof Error ? error.message : String(error)}`);
614
+ });
520
615
  return end('confirmed', unknown ? 'confirmed (the reply to the request was lost)' : null);
616
+ }
521
617
  if (upgrade.state === 'reverted')
522
618
  return end('reverted', upgrade.reason ?? 'the agent switched back to its previous version', 'SELF_UPGRADE_REVERTED');
523
619
  if (upgrade.state === 'failed')
@@ -532,76 +628,255 @@ async function upgradeOne(run, entry, deps) {
532
628
  else {
533
629
  versionSince = null;
534
630
  }
535
- // Silent for longer than the agent's own trial window: it will not confirm, even if the extended deadline is not up.
536
- if (lastHeartbeat && deps.now() - lastHeartbeat.getTime() > windowMs)
537
- return end('failed', `no heartbeat since ${lastHeartbeat.toISOString()}`, 'AGENT_SILENT', true);
631
+ if (unknown) {
632
+ // Silent past its window: it will not report now. This is not AGENT_SILENT, which counts against the release:
633
+ // nobody knows the request arrived. It ends the watch early, and it counts as ambiguous towards a suspicion.
634
+ if (lastHeartbeat && deps.now() - Math.max(lastHeartbeat.getTime(), startedAt) > windowMs) {
635
+ return end('ambiguous', `outcome unknown (${unknown.message}); no heartbeat since ${lastHeartbeat.toISOString()}`, 'AGENT_SILENT_AFTER_LOST_REPLY', true);
636
+ }
637
+ // Past the window the wave goes on without this node; its reports are still read here. A first canary whose reply
638
+ // was lost may still be staging and then restarting, so nothing else is touched until its whole watch is over. An
639
+ // agent that ANSWERED uncertain has finished with the request, and a node reporting a trial is still going.
640
+ const inFlight = (!unknown.answered && !run.nodes.some(node => node.state === 'confirmed')) || upgrade?.requestId === entry.requestId && (upgrade.state === 'staged' || upgrade.state === 'trial');
641
+ if (!inFlight && deps.now() - startedAt >= windowMs && entry.state === 'requested') {
642
+ Object.assign(entry, { state: 'watching', code: unknown.code, failureClass: 'node', awaitingReport: true,
643
+ detail: `outcome unknown (${unknown.message}); still watching this request's reports` });
644
+ freeSlot();
645
+ await deps.save(run);
646
+ }
647
+ continue;
648
+ }
538
649
  }
650
+ if (context.closed)
651
+ return;
539
652
  const minutes = Math.round((deadline() - startedAt) / 60_000);
540
653
  if (unknown)
541
- return end('failed', `outcome unknown (${unknown.message}); no report for this request within ${minutes} min`, unknown.code, true);
654
+ return end('ambiguous', `outcome unknown (${unknown.message}); no report for this request within ${minutes} min`, unknown.code, true);
655
+ // No heartbeat since the reply: the new version never came up, as opposed to one that runs and does not confirm.
656
+ if (lastHeartbeat && lastHeartbeat.getTime() <= startedAt)
657
+ return end('failed', `no heartbeat since ${lastHeartbeat.toISOString()}`, 'AGENT_SILENT', true);
542
658
  end('failed', `no confirmation within ${minutes} min`, 'CONFIRM_TIMEOUT', true);
543
659
  }
544
660
  /** "N confirmed, N refused, N failed, N skipped" and each refusal reason. */
545
661
  export function summarizeRun(run) {
546
662
  const count = (state) => run.nodes.filter(node => node.state === state).length;
547
663
  const reverted = count('reverted');
664
+ const ambiguous = count('ambiguous') + count('watching');
548
665
  const refused = run.nodes.filter(node => node.state === 'refused');
549
- return `${count('confirmed')} confirmed, ${refused.length} refused, ${count('failed')} failed, ${reverted ? `${reverted} reverted, ` : ''}${count('skipped')} skipped`
666
+ return `${count('confirmed')} confirmed, ${refused.length} refused, ${count('failed')} failed, ${reverted ? `${reverted} reverted, ` : ''}${ambiguous ? `${ambiguous} unknown, ` : ''}${count('skipped')} skipped`
550
667
  + (refused.length ? `; refused: ${refused.map(node => `${node.name} (${node.code ? `${node.code}: ` : ''}${node.detail ?? 'no reason given'})`).join('; ')}` : '');
551
668
  }
552
- const halts = (node) => node.state === 'failed' || node.state === 'reverted';
669
+ const isReleaseFailure = (node) => (node.state === 'failed' || node.state === 'reverted') && upgradeRemedy(node.code).class === 'release';
670
+ /** An expected state: a refusal or skip whose code says the node needs nobody. */
671
+ const isExpected = (node) => (node.state === 'refused' || node.state === 'skipped') && Boolean(node.code && upgradeRemedy(node.code).informational);
672
+ /** Every selected node someone has to look at: everything not confirmed, unless its code says it is expected. */
673
+ export function attentionList(run) {
674
+ const item = (node, state, code, detail) => {
675
+ const remedy = upgradeRemedy(code);
676
+ return { nodeId: node.nodeId, name: node.name, state, code, failureClass: remedy.class, detail, cause: remedy.cause, remedy: remedy.remedy, ...(remedy.unknown ? { unknownCode: true } : {}) };
677
+ };
678
+ return [
679
+ ...run.nodes.filter(node => node.state !== 'confirmed' && !isExpected(node)).map(node => item(node, node.state, node.code ?? 'NOT_REACHED', node.detail)),
680
+ ...(run.excluded ?? []).filter(node => !(node.code && upgradeRemedy(node.code).informational)).map(node => item(node, 'excluded', node.code ?? 'NOT_ELIGIBLE', node.reason)),
681
+ ];
682
+ }
683
+ /** One old agent with a stale fence looks exactly like this, so a suspicion always takes more than one node. */
684
+ const suspicionThreshold = (run) => Math.max(run.maxFailures ?? DEFAULT_MAX_FAILURES, 2);
685
+ const isCountedAmbiguous = (node) => (node.state === 'watching' || node.state === 'ambiguous') && node.code !== 'RUN_INTERRUPTED';
686
+ /**
687
+ * Ambiguous canaries with nothing confirmed, the only signal a fleet before 1.1.8
688
+ * gives about a bad release. A node that cannot report its fence has another
689
+ * explanation for its ambiguity, so it does not count while any other node may
690
+ * still confirm; those nodes go last in the queue for this reason. Once only they
691
+ * are left, they count: the walk stays bounded, at the cost that a fleet of nothing
692
+ * but stale old agents reads as suspected too, and the message says that.
693
+ */
694
+ function ambiguousSuspicion(run) {
695
+ if (run.nodes.some(node => node.state === 'confirmed'))
696
+ return null;
697
+ const ambiguous = run.nodes.filter(isCountedAmbiguous);
698
+ const otherStillPending = run.nodes.some(node => node.state === 'pending' && !node.fenceUnreported);
699
+ const counted = otherStillPending ? ambiguous.filter(node => !node.fenceUnreported) : ambiguous;
700
+ if (counted.length < suspicionThreshold(run))
701
+ return null;
702
+ const staleFence = counted.some(node => node.fenceUnreported) ? '. These agents cannot report their fence, so each may instead hold a stale fence: read it on the node and run `magnus-server agents fence NODE VALUE`' : '';
703
+ return `the release is suspect but unproven: ${counted.length} canaries in a row answered without a usable outcome (${[...new Set(counted.map(node => node.code))].join(', ')}) and no node has confirmed; read the agent log on ${counted.slice(0, 3).map(node => node.name).join(', ')}${staleFence}`;
704
+ }
705
+ /**
706
+ * With nothing confirmed, nodes that all behave the same point at what they have
707
+ * in common. Not proof: the message says so, and a node that confirms clears it.
708
+ * A refusal counts only when its code can point at the release at all.
709
+ */
710
+ export function releaseSuspicion(run) {
711
+ if (run.nodes.some(node => node.state === 'confirmed'))
712
+ return null;
713
+ const ambiguous = ambiguousSuspicion(run);
714
+ if (ambiguous)
715
+ return ambiguous;
716
+ const threshold = suspicionThreshold(run);
717
+ const refusals = new Map();
718
+ for (const node of run.nodes) {
719
+ if (node.state !== 'refused' || !node.code)
720
+ continue;
721
+ if (upgradeRemedy(node.code).suspectable)
722
+ refusals.set(node.code, [...refusals.get(node.code) ?? [], node]);
723
+ }
724
+ for (const [code, nodes] of refusals)
725
+ if (nodes.length >= threshold)
726
+ return `a release problem is suspected: ${nodes.length} nodes refused with ${code} and none has confirmed`;
727
+ return null;
728
+ }
553
729
  /**
554
- * Canary first, then batches of `concurrency`. A refused canary hands the
555
- * role to the next node. Stops at the first revert or failure, or between
556
- * batches when someone asked to stop.
730
+ * Evidence against the release decides first; then whether any node still needs
731
+ * someone. `succeeded` means every selected node is confirmed or has an
732
+ * informational reason; finish and late reports both come through here.
733
+ */
734
+ export function finalStatus(run) {
735
+ if (run.nodes.some(isReleaseFailure) || releaseSuspicion(run))
736
+ return 'failed';
737
+ return attentionList(run).length ? 'partial' : 'succeeded';
738
+ }
739
+ /**
740
+ * Canary first, then batches of `concurrency`. Until a node confirms, nodes go
741
+ * one at a time: any release failure stops the run, and a node-level problem
742
+ * hands the canary role to the next node. After that the run stops only at an
743
+ * integrity or source failure, or when release failures reach the budget.
744
+ * Transient refusals are tried again at the end, and the run ends once the
745
+ * nodes it still watches have reported or run out of time.
557
746
  */
558
747
  export async function executeUpgradeRun(run, deps) {
748
+ const context = { watchers: new Set(), closed: false, retrying: new Map() };
749
+ const budget = run.maxFailures ?? DEFAULT_MAX_FAILURES;
559
750
  const finish = async (status, message) => {
560
- for (const node of run.nodes)
751
+ context.closed = true;
752
+ const at = new Date(deps.now()).toISOString();
753
+ for (const node of run.nodes) {
561
754
  if (node.state === 'pending')
562
- Object.assign(node, { state: 'skipped', detail: status === 'stopped' ? 'run stopped' : 'not reached' });
755
+ Object.assign(node, { state: 'skipped', detail: status === 'stopped' ? 'run stopped' : 'not reached', code: status === 'stopped' ? 'RUN_STOPPED' : 'NOT_REACHED' });
756
+ if (node.state === 'watching')
757
+ Object.assign(node, { state: 'ambiguous', finishedAt: at, detail: `${node.detail ?? 'outcome unknown'}; a later report is recorded here` });
758
+ }
563
759
  run.status = status;
564
760
  run.summary = summarizeRun(run);
761
+ run.attention = attentionList(run);
762
+ run.suspectedRelease = releaseSuspicion(run);
565
763
  run.message = `${message} (${run.summary})`;
566
- run.finishedAt = new Date(deps.now()).toISOString();
764
+ run.finishedAt = at;
567
765
  await deps.save(run);
568
766
  await audit(status === 'stopped' ? 'agent.upgrade_run.stopped' : 'agent.upgrade_run.finished', run, { status, summary: run.summary,
569
- stoppedBy: run.stopRequestedBy ?? null });
767
+ stoppedBy: run.stopRequestedBy ?? null, attention: run.attention.map(node => `${node.name}: ${node.code}`).slice(0, 50) });
570
768
  return run;
571
769
  };
572
770
  const pending = () => run.nodes.filter(node => node.state === 'pending');
771
+ const confirmed = () => run.nodes.filter(node => node.state === 'confirmed');
573
772
  const stopped = async () => {
574
773
  const actor = await deps.stopRequested(run.id).catch(() => null);
575
774
  if (actor)
576
775
  run.stopRequestedBy = actor;
577
776
  return actor;
578
777
  };
579
- try {
580
- let canary;
581
- while ((canary = pending()[0])) {
778
+ /** A node that gave up waiting may have reported since: the stored report is the same on every server. */
779
+ const readLateReports = async () => {
780
+ for (const node of run.nodes.filter(entry => entry.awaitingReport && entry.state !== 'watching')) {
781
+ const upgrade = await deps.observe(node.nodeId).then(observation => observation.report?.upgrade, () => null);
782
+ if (upgrade?.requestId !== node.requestId)
783
+ continue;
784
+ if (upgrade.state === 'confirmed' || upgrade.state === 'reverted' || upgrade.state === 'failed')
785
+ applyLateOutcome(node, upgrade.state, upgrade.reason ?? null);
786
+ }
787
+ };
788
+ const leaseSettled = (node) => (deps.leaseSettled?.(node.nodeId, node.requestId) ?? Promise.resolve(true)).catch((error) => {
789
+ console.warn(`[agent-upgrade] lease of ${node.name} could not be read: ${error instanceof Error ? error.message : String(error)}`);
790
+ return false;
791
+ });
792
+ const stoppedByOperator = () => finish('stopped', `stopped by ${run.stopRequestedBy}`);
793
+ const describe = (node) => `${node.name} (${node.code ?? node.state}: ${node.detail ?? ''})`;
794
+ /** What the evidence so far says: null, or why the run ends now. */
795
+ const evidenceStop = (canaryPhase) => {
796
+ const failures = run.nodes.filter(isReleaseFailure);
797
+ const fatal = failures.find(node => upgradeRemedy(node.code).stopsRun);
798
+ if (fatal)
799
+ return finish('failed', `${describe(fatal)}: every node gets the same artifact, so the run stopped`);
800
+ if (canaryPhase && failures[0])
801
+ return finish('failed', `canary ${failures[0].name} did not confirm (${failures[0].code}: ${failures[0].detail ?? ''}); the other nodes were not touched`);
802
+ // Ambiguity stops the wave; identical refusals do not, since each of those nodes is known to be untouched.
803
+ const suspected = ambiguousSuspicion(run);
804
+ if (suspected)
805
+ return finish('failed', suspected);
806
+ if (failures.length >= budget)
807
+ return finish('failed', `${failures.length} release failure(s) reached the budget of ${budget}: ${failures.slice(0, 5).map(describe).join('; ')}; remaining nodes were skipped`);
808
+ return null;
809
+ };
810
+ /** Work through the pending nodes; a returned promise means the run ended. */
811
+ const drain = async () => {
812
+ for (let queue = pending(); queue.length; queue = pending()) {
582
813
  if (await stopped())
583
- return finish('stopped', `stopped by ${run.stopRequestedBy}`);
584
- run.canaryNodeId = canary.nodeId;
585
- await upgradeOne(run, canary, deps);
814
+ return stoppedByOperator();
815
+ const canaryPhase = confirmed().length === 0;
816
+ const batch = queue.slice(0, canaryPhase ? 1 : run.concurrency);
817
+ if (canaryPhase)
818
+ run.canaryNodeId = batch[0].nodeId;
819
+ await Promise.all(batch.map(node => upgradeOne(run, node, deps, context)));
586
820
  await deps.save(run);
587
- if (canary.state === 'confirmed')
588
- break;
589
- if (halts(canary))
590
- return finish('failed', `canary ${canary.name} did not confirm (${canary.state}: ${canary.detail ?? ''}); the other nodes were not touched`);
821
+ await readLateReports();
822
+ const stop = evidenceStop(canaryPhase && confirmed().length === 0);
823
+ if (stop)
824
+ return stop;
591
825
  }
592
- for (let batch = pending().slice(0, run.concurrency); batch.length; batch = pending().slice(0, run.concurrency)) {
826
+ return null;
827
+ };
828
+ try {
829
+ if (!run.nodes.length)
830
+ return await finish(finalStatus(run), 'no eligible nodes');
831
+ let ended = await drain();
832
+ if (ended)
833
+ return ended;
834
+ // Refusals that clear by themselves, once or twice, with a pause. Never anything ambiguous or release-class.
835
+ for (const wait of deps.retryBackoffMs ?? AUTO_RETRY_BACKOFF_MS) {
836
+ const transient = run.nodes.filter(node => (node.state === 'refused' || node.state === 'skipped') && node.code && upgradeRemedy(node.code).transient);
837
+ // A lease still held makes a retry pointless: admission would refuse it, and hide why with LEASE_HELD.
838
+ const again = [];
839
+ for (const node of transient) {
840
+ if (await leaseSettled(node))
841
+ again.push(node);
842
+ else if (!node.detail?.includes(LEASE_HELD_NOTE))
843
+ node.detail = `${node.detail ?? ''}; ${LEASE_HELD_NOTE}`.slice(0, 1000);
844
+ }
845
+ if (!again.length)
846
+ break;
847
+ await deps.sleep(wait);
593
848
  if (await stopped())
594
- return finish('stopped', `stopped by ${run.stopRequestedBy}`);
595
- await Promise.all(batch.map(node => upgradeOne(run, node, deps)));
849
+ return await stoppedByOperator();
850
+ // A command id owns one lease for good, so each attempt is a new request.
851
+ for (const node of again) {
852
+ context.retrying.set(node.nodeId, { state: node.state, code: node.code ?? null, detail: node.detail });
853
+ Object.assign(node, { state: 'pending', requestId: randomUUID(), detail: null, code: null, failureClass: null, finishedAt: null, dispatchedAt: null });
854
+ }
855
+ await deps.index?.(run);
596
856
  await deps.save(run);
597
- const bad = batch.find(halts);
598
- if (bad)
599
- return finish('failed', `${bad.name} did not confirm (${bad.state}: ${bad.detail ?? ''}); remaining nodes were skipped`);
857
+ ended = await drain();
858
+ if (ended)
859
+ return ended;
600
860
  }
601
- const confirmed = run.nodes.filter(node => node.state === 'confirmed').length;
602
- if (!run.nodes.length)
603
- return finish('succeeded', 'no eligible nodes');
604
- return confirmed ? finish('succeeded', `${confirmed} agent(s) upgraded to ${run.version}`) : finish('failed', 'no agent was upgraded');
861
+ // Nodes whose reply was lost keep their chance to report; their late outcomes still count.
862
+ while (context.watchers.size) {
863
+ await deps.sleep(deps.pollMs);
864
+ if (await stopped())
865
+ return await stoppedByOperator();
866
+ await readLateReports();
867
+ const stop = evidenceStop(confirmed().length === 0);
868
+ if (stop)
869
+ return await stop;
870
+ }
871
+ await readLateReports();
872
+ const upgraded = confirmed().length;
873
+ const status = finalStatus(run);
874
+ const suspicion = releaseSuspicion(run);
875
+ return await finish(status, status === 'succeeded' ? `${upgraded} agent(s) upgraded to ${run.version}`
876
+ : suspicion ? suspicion
877
+ : status === 'failed' ? `${run.nodes.filter(isReleaseFailure).length} node(s) failed in a way that points at the release; ${upgraded} agent(s) upgraded`
878
+ : upgraded ? `${upgraded} agent(s) upgraded to ${run.version}; ${run.attention?.length ?? attentionList(run).length} node(s) need attention`
879
+ : `no agent was upgraded; ${attentionList(run).length} node(s) need attention, and nothing points at the release`);
605
880
  }
606
881
  catch (error) {
607
882
  return finish('failed', `run aborted: ${error instanceof Error ? error.message : String(error)}`);
@@ -621,24 +896,25 @@ export async function reconcileInterruptedRun(run, observe, save, now = Date.now
621
896
  const at = new Date(now).toISOString();
622
897
  for (const node of run.nodes) {
623
898
  if (node.state === 'pending')
624
- Object.assign(node, { state: 'skipped', detail: 'run interrupted before this node', finishedAt: at });
625
- if (node.state !== 'requested')
899
+ Object.assign(node, { state: 'skipped', detail: 'run interrupted before this node', code: 'NOT_REACHED', finishedAt: at });
900
+ if (node.state !== 'requested' && node.state !== 'watching')
626
901
  continue;
627
902
  const upgrade = await observe(node.nodeId).then(observation => observation.report?.upgrade, () => null);
628
903
  const mine = upgrade?.requestId === node.requestId ? upgrade : null;
629
904
  if (mine?.state === 'confirmed')
630
- Object.assign(node, { state: 'confirmed', detail: 'confirmed (recorded after the run was interrupted)', code: null });
905
+ Object.assign(node, { state: 'confirmed', detail: 'confirmed (recorded after the run was interrupted)', code: null, failureClass: null, awaitingReport: false });
631
906
  else if (mine?.state === 'reverted')
632
- Object.assign(node, { state: 'reverted', detail: mine.reason ?? 'the agent switched back', code: 'SELF_UPGRADE_REVERTED' });
907
+ Object.assign(node, { state: 'reverted', detail: mine.reason ?? 'the agent switched back', code: 'SELF_UPGRADE_REVERTED', failureClass: 'release', awaitingReport: false });
633
908
  else if (mine?.state === 'failed')
634
- Object.assign(node, { state: 'failed', detail: mine.reason ?? 'the agent reported a failure', code: 'SELF_UPGRADE_FAILED' });
909
+ Object.assign(node, { state: 'failed', detail: mine.reason ?? 'the agent reported a failure', code: 'SELF_UPGRADE_FAILED', failureClass: 'release', awaitingReport: false });
635
910
  else
636
- Object.assign(node, { state: 'failed', code: 'RUN_INTERRUPTED', awaitingReport: true,
911
+ Object.assign(node, { state: 'ambiguous', code: 'RUN_INTERRUPTED', failureClass: 'node', awaitingReport: true,
637
912
  detail: `the server stopped watching${mine ? ` while the agent reported ${mine.state}` : ''}; a later report for this request is recorded here` });
638
913
  node.finishedAt = at;
639
914
  }
640
915
  run.status = 'interrupted';
641
916
  run.summary = summarizeRun(run);
917
+ run.attention = attentionList(run);
642
918
  run.message = `interrupted: the server that ran it stopped renewing its lease (restart or interrupted CLI) (${run.summary})`;
643
919
  run.finishedAt = at;
644
920
  await save(run);
@@ -653,6 +929,100 @@ export async function latestUpgradeRun(store = defaultRunStore()) {
653
929
  const run = await store.latest().catch(() => null);
654
930
  return run ? getUpgradeRun(run.id, store) : null;
655
931
  }
932
+ const latestPreflights = new Map();
933
+ /** The newest result per node for this version, for the eligibility list. Older ones say nothing about now. */
934
+ export function withCachedPreflight(candidates, version, now = Date.now()) {
935
+ return candidates.map(candidate => {
936
+ const cached = latestPreflights.get(candidate.nodeId);
937
+ return cached?.version === version && now - Date.parse(cached.preflight.checkedAt) < PREFLIGHT_CACHE_MS ? { ...candidate, preflight: cached.preflight } : candidate;
938
+ });
939
+ }
940
+ /**
941
+ * Who can take `release` now: the read-only admission predicates, then each
942
+ * agent's preflight report. A blocked node carries its reason and code, and the
943
+ * others proceed. Nothing here takes a lease; without `heal` it changes nothing.
944
+ */
945
+ export async function checkCandidates(release, nodeIds, deps, options = {}) {
946
+ const list = () => listUpgradeCandidates(release.version, nodeIds, { allowRetryReverted: options.allowRetryReverted });
947
+ let candidates = await list();
948
+ const ask = deps.preflight;
949
+ if (!ask)
950
+ return candidates;
951
+ // A held lease earns its heal attempt whichever earlier code the node stopped at, including on one already on the target.
952
+ const askable = (candidate) => candidate.code !== 'NODE_OFFLINE'
953
+ && (options.everyOnlineNode || candidate.eligible || Boolean(candidate.heldLeases));
954
+ const rows = candidates.filter(askable);
955
+ const hostnames = await hostnamesOf(rows.map(candidate => candidate.nodeId));
956
+ const target = (candidate) => ({ nodeId: candidate.nodeId, hostname: hostnames.get(candidate.nodeId) ?? '', managed: candidate.managed ?? false });
957
+ const query = { target: release.version, engines: release.engines ?? null };
958
+ const results = new Map(await mapLimit(rows, PREFLIGHT_CONCURRENCY, async (candidate) => {
959
+ let preflight = await ask(target(candidate), query).catch((error) => ({ status: 'unreachable', checkedAt: new Date().toISOString(), problems: [],
960
+ detail: error instanceof Error ? error.message : String(error) }));
961
+ // It has handled managed requests, and cannot say whether its fence would refuse this one.
962
+ if (preflight.status === 'unavailable' && candidate.leaseHistory && !preflight.problems.some(problem => problem.code === 'FENCE_UNREPORTED'))
963
+ preflight = { ...preflight, problems: [...preflight.problems, { code: 'FENCE_UNREPORTED', blocking: false,
964
+ detail: `${candidate.name} cannot report its fence and has lease history; if its fence is ahead, run \`magnus-server agents fence ${candidate.name} VALUE\` before the wave` }] };
965
+ if (options.heal && deps.heal && (preflight.report || candidate.heldLeases)) {
966
+ const repaired = await deps.heal(target(candidate), preflight).catch((error) => {
967
+ console.warn(`[agent-upgrade] repair of ${candidate.name} failed: ${error instanceof Error ? error.message : String(error)}`);
968
+ return null;
969
+ });
970
+ if (repaired)
971
+ preflight = { ...preflight, problems: repaired.problems, status: preflight.report ? preflightStatus(repaired.problems) : preflight.status,
972
+ ...(repaired.healed.length ? { healed: repaired.healed } : {}) };
973
+ }
974
+ latestPreflights.set(candidate.nodeId, { version: release.version, preflight });
975
+ return [candidate.nodeId, preflight];
976
+ }));
977
+ if ([...results.values()].some(preflight => preflight.healed?.length))
978
+ candidates = await list();
979
+ return candidates.map(candidate => {
980
+ const preflight = results.get(candidate.nodeId);
981
+ if (!preflight)
982
+ return candidate;
983
+ const blocker = preflight.problems.find(problem => problem.blocking);
984
+ return { ...candidate, preflight, ...(candidate.eligible && blocker ? { eligible: false, reason: blocker.detail, code: blocker.code } : {}) };
985
+ });
986
+ }
987
+ async function hostnamesOf(ids) {
988
+ if (!ids.length)
989
+ return new Map();
990
+ const rows = await getDb().node.findMany({ where: { id: { in: ids } }, select: { id: true, hostname: true } });
991
+ return new Map(rows.map(row => [row.id, row.hostname ?? '']));
992
+ }
993
+ /**
994
+ * 0: 1.1.8 or later, which says what it refused. 1: older, with no lease history.
995
+ * 2: older with lease history, which cannot say whether its fence would refuse this.
996
+ */
997
+ function agentTier(candidate) {
998
+ if (isVersion(candidate.version) && compareVersions(candidate.version, CERTAIN_REFUSALS_SINCE) >= 0)
999
+ return 0;
1000
+ return candidate.preflight?.problems.some(problem => problem.code === 'FENCE_UNREPORTED') ? 2 : 1;
1001
+ }
1002
+ /** The dry run behind `dryRun`, the console's Check and `agents doctor`: the full per-node report, before anything starts. */
1003
+ export async function checkUpgrade(options) {
1004
+ const version = options.version ?? AGENT_VERSION;
1005
+ if (!isVersion(version))
1006
+ throw new HttpError(400, 'INVALID_VERSION', `${version} is not an X.Y.Z version`);
1007
+ let release;
1008
+ let engines = null;
1009
+ try {
1010
+ const resolved = await resolveAgentRelease({ version, integrity: options.integrity });
1011
+ release = { verified: true, integrity: resolved.integrity, reason: null };
1012
+ engines = resolved.engines ?? null;
1013
+ }
1014
+ catch (error) {
1015
+ // The nodes can still be judged without knowing the release's engines.
1016
+ release = { verified: false, integrity: null, reason: error instanceof Error ? error.message : String(error) };
1017
+ }
1018
+ const deps = options.deps ?? defaultDeps();
1019
+ const candidates = await checkCandidates({ version, engines }, options.nodeIds, deps, { everyOnlineNode: options.everyOnlineNode ?? true, heal: options.heal ?? false, allowRetryReverted: options.allowRetryReverted });
1020
+ const nodes = candidates.map(candidate => {
1021
+ const remedy = candidate.code ? upgradeRemedy(candidate.code) : null;
1022
+ return { ...candidate, cause: remedy?.cause ?? null, remedy: remedy?.remedy ?? null };
1023
+ });
1024
+ return { version, release, eligible: nodes.filter(node => node.eligible).length, blocked: nodes.filter(node => !node.eligible && node.code && !upgradeRemedy(node.code).informational).length, nodes };
1025
+ }
656
1026
  /** Validate, take the fleet-wide run lock, create the run and start it in the background. */
657
1027
  export async function startUpgradeRun(options) {
658
1028
  const settings = upgradeSettings();
@@ -673,11 +1043,27 @@ export async function startUpgradeRun(options) {
673
1043
  if (running?.status === 'running')
674
1044
  throw new HttpError(409, 'AGENT_UPGRADE_RUNNING', `Upgrade run ${running.id} is still in progress`);
675
1045
  const release = await resolveAgentRelease({ version, integrity: options.integrity });
676
- const candidates = await listUpgradeCandidates(release.version, options.nodeIds, { allowRetryReverted: options.allowRetryReverted });
1046
+ const baseDeps = options.deps ?? defaultDeps(store);
1047
+ const deps = { ...baseDeps, index: baseDeps.index ?? (next => store.index(next)) };
1048
+ // Checking a large fleet can outlast the lock's TTL.
1049
+ const holdLock = setInterval(() => void store.renew(token).catch(() => false), LEASE_MS / 3);
1050
+ holdLock.unref?.();
1051
+ let candidates;
1052
+ try {
1053
+ candidates = await checkCandidates(release, options.nodeIds, deps, { heal: true, allowRetryReverted: options.allowRetryReverted });
1054
+ }
1055
+ finally {
1056
+ clearInterval(holdLock);
1057
+ }
677
1058
  const unknown = (options.nodeIds ?? []).filter(nodeId => !candidates.some(candidate => candidate.nodeId === nodeId));
678
1059
  if (unknown.length)
679
1060
  throw new HttpError(400, 'UNKNOWN_NODES', `Unknown or decommissioned node(s): ${unknown.slice(0, 20).join(', ')}`);
680
- const eligible = candidates.filter(candidate => candidate.eligible);
1061
+ if (options.holdOldAgents)
1062
+ candidates = candidates.map(candidate => candidate.eligible && agentTier(candidate) > 0 ? { ...candidate, eligible: false, code: 'RETRY_HELD_SUSPECTED',
1063
+ reason: 'left out of this retry: the run before it stopped on ambiguous answers from older agents, and another attempt can strand another lease; retry with acknowledgeSuspicion to include it' } : candidate);
1064
+ // Who goes first decides who can stop the wave: those that can answer plainly, then those that can only answer uncertain.
1065
+ const eligible = candidates.filter(candidate => candidate.eligible).map((candidate, index) => ({ candidate, index }))
1066
+ .sort((a, b) => agentTier(a.candidate) - agentTier(b.candidate) || a.index - b.index).map(entry => entry.candidate);
681
1067
  if (options.canaryNodeId && !eligible.some(candidate => candidate.nodeId === options.canaryNodeId)) {
682
1068
  const canary = candidates.find(candidate => candidate.nodeId === options.canaryNodeId);
683
1069
  throw new HttpError(400, 'INVALID_CANARY', canary ? `Canary ${canary.name} cannot be upgraded now: ${canary.reason}` : 'canaryNodeId is not among the selected nodes');
@@ -688,14 +1074,17 @@ export async function startUpgradeRun(options) {
688
1074
  }
689
1075
  if (options.canaryNodeId)
690
1076
  eligible.unshift(...eligible.splice(eligible.findIndex(candidate => candidate.nodeId === options.canaryNodeId), 1));
691
- const deps = options.deps ?? defaultDeps(store);
692
1077
  const run = {
693
1078
  id, version: release.version, integrity: release.integrity, actorId: options.actorId, status: 'running',
694
1079
  startedAt: new Date(deps.now()).toISOString(), finishedAt: null, concurrency: settings.agentUpgradeConcurrency, message: null, summary: null,
695
1080
  canaryNodeId: eligible[0].nodeId, owner: INSTANCE_ID, leaseExpiresAt: new Date(deps.now() + LEASE_MS).toISOString(), stopRequestedBy: null,
696
1081
  retryOf: options.retryOf ?? null, allowRetryReverted: options.allowRetryReverted ?? false,
697
- excluded: candidates.filter(candidate => !candidate.eligible).map(candidate => ({ nodeId: candidate.nodeId, name: candidate.name, reason: candidate.reason ?? 'not eligible' })),
698
- nodes: eligible.map(candidate => ({ nodeId: candidate.nodeId, name: candidate.name, from: candidate.version, state: 'pending', requestId: randomUUID(), detail: null })),
1082
+ maxFailures: options.maxFailures ?? settings.agentUpgradeMaxFailures,
1083
+ warnings: candidates.flatMap(candidate => (candidate.preflight?.problems ?? []).filter(problem => problem.code === 'FENCE_UNREPORTED')
1084
+ .map(problem => ({ nodeId: candidate.nodeId, name: candidate.name, code: problem.code, detail: problem.detail }))),
1085
+ excluded: candidates.filter(candidate => !candidate.eligible).map(candidate => ({ nodeId: candidate.nodeId, name: candidate.name, reason: candidate.reason ?? 'not eligible', code: candidate.code })),
1086
+ nodes: eligible.map(candidate => ({ nodeId: candidate.nodeId, name: candidate.name, from: candidate.version, state: 'pending', requestId: randomUUID(), detail: null,
1087
+ ...(agentTier(candidate) === 2 ? { fenceUnreported: true } : {}) })),
699
1088
  };
700
1089
  await store.index(run);
701
1090
  await deps.save(run);
@@ -743,11 +1132,18 @@ export async function retryUpgradeRun(id, options) {
743
1132
  throw new HttpError(404, 'NOT_FOUND', 'Upgrade run not found');
744
1133
  if (previous.status === 'running')
745
1134
  throw new HttpError(409, 'AGENT_UPGRADE_RUNNING', `Upgrade run ${id} is still in progress`);
746
- const retry = new Set(['failed', 'refused', 'skipped', 'requested', ...options.allowRetryReverted ? ['reverted'] : []]);
747
- const nodeIds = previous.nodes.filter(node => retry.has(node.state)).map(node => node.nodeId);
1135
+ const retry = new Set(['failed', 'refused', 'skipped', 'requested', 'watching', 'ambiguous', ...options.allowRetryReverted ? ['reverted'] : []]);
1136
+ // Nodes the preflight held back come back too: what blocked them may be repaired now.
1137
+ const nodeIds = [...previous.nodes.filter(node => retry.has(node.state)).map(node => node.nodeId),
1138
+ ...(previous.excluded ?? []).filter(node => node.code && !upgradeRemedy(node.code).informational).map(node => node.nodeId)];
748
1139
  if (!nodeIds.length)
749
1140
  throw new HttpError(409, 'NOTHING_TO_RETRY', `Run ${id} has no failed, refused, skipped or interrupted nodes${options.allowRetryReverted ? '' : ' (reverted nodes need allowRetryReverted)'}`);
750
- return startUpgradeRun({ ...options, store, version: previous.version, integrity: previous.integrity, nodeIds, retryOf: previous.id,
1141
+ // Retrying a run that stopped on ambiguity must not walk the next old agents into the same stranded lease: someone has to look first.
1142
+ const stoppedOnAmbiguity = Boolean(previous.suspectedRelease && previous.nodes.some(isCountedAmbiguous) && !previous.nodes.some(node => node.state === 'confirmed'));
1143
+ const holdOldAgents = !options.acknowledgeSuspicion && (stoppedOnAmbiguity || Boolean(previous.excluded?.some(node => node.code === 'RETRY_HELD_SUSPECTED')));
1144
+ const { acknowledgeSuspicion: _acknowledged, ...startOptions } = options;
1145
+ return startUpgradeRun({ ...startOptions, store, holdOldAgents, version: previous.version, integrity: previous.integrity, nodeIds, retryOf: previous.id,
1146
+ maxFailures: options.maxFailures ?? previous.maxFailures,
751
1147
  allowPrerelease: options.allowPrerelease ?? isPrerelease(previous.version) });
752
1148
  }
753
1149
  //# sourceMappingURL=agent-upgrade-service.js.map