@hivemagnus/magnus-server 1.1.2 → 1.1.4

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.
@@ -2,29 +2,54 @@
2
2
  * Server-driven agent upgrades (MAGNUS_AGENT_UPGRADES). The server pins one
3
3
  * exact version and its npm integrity, asks one canary agent to stage it,
4
4
  * waits until that agent reports the version on trial as confirmed, then
5
- * upgrades the rest a few at a time. The first revert or failure stops the
6
- * run. Agents decide for themselves whether they can upgrade
7
- * (MAGNUS_AGENT_SELF_UPGRADE, launcher layout) and report it with every
8
- * heartbeat, so the console can grey out the ones that cannot.
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.
11
+ *
12
+ * Eligibility uses the same read-only predicates as resource admission
13
+ * (admission fence, enrollment, held work and leases, fresh health), so a node
14
+ * the console offers is one that dispatch will not refuse for those reasons.
9
15
  */
10
16
  import { randomUUID } from 'node:crypto';
11
- import { AGENT_SELF_UPGRADE_PROTOCOL, agentSelfUpgradeStatusSchema, checkUpgradeAllowed, compareVersions, isVersion, } from '@hivemagnus/magnus-contracts';
17
+ import { AGENT_SELF_UPGRADE_PROTOCOL, agentSelfUpgradeStatusSchema, checkUpgradeAllowed, compareVersions, isVersion, parseVersion, } from '@hivemagnus/magnus-contracts';
12
18
  import { upgradeSettings } from '../config/features.js';
13
19
  import { AGENT_VERSION } from '../config/version.js';
14
20
  import { getDb } from '../db/client.js';
15
21
  import { HttpError } from '../middleware/error-handler.js';
16
- import { NodeApiClient } from '../sdk/node-api-client.js';
22
+ import { AgentResponseError, DispatchOutcomeUnknownError, NodeApiClient, NotDispatchedError } from '../sdk/node-api-client.js';
17
23
  import { getNodeManagerSecret } from '../utils/env.js';
18
24
  import { getNodeBaseUrl } from '../utils/node-url.js';
19
25
  import { SENTINEL_NODE_IDS } from '../utils/sentinel-nodes.js';
20
26
  import { logAuditEvent } from './audit-service.js';
27
+ import { HELD_RESOURCE_STATES, hasFreshOnlineHealth, legacyAdmissionPossible, resourceLeasesRequested } from './command-resource-leases.js';
28
+ import { enrollmentIsComplete } from './resource-enrollment.js';
29
+ import { stickyEnrollmentCause } from './resource-ownership.js';
21
30
  export const AGENT_PACKAGE = '@hivemagnus/magnus-agent';
22
- /** Heartbeats are 30 s apart; older than this and the node counts as offline for upgrades. */
23
- const FRESH_HEARTBEAT_MS = 2 * 60_000;
24
- /** Install time plus the agent's confirmation window (default 180 s), with margin. */
25
- const CONFIRM_TIMEOUT_MS = 20 * 60_000;
31
+ const HEARTBEAT_MS = 30_000;
32
+ /** MAGNUS_AGENT_UPGRADE_CONFIRM_SECONDS default, for agents that do not report theirs. */
33
+ const DEFAULT_CONFIRM_SECONDS = 180;
34
+ /** Restart and first heartbeat of the new version, on top of the trial window. */
35
+ const CONFIRM_MARGIN_MS = 2 * 60_000;
36
+ /** A request whose reply was lost may still be installing. */
37
+ const UNKNOWN_DISPATCH_MARGIN_MS = 10 * 60_000;
26
38
  const POLL_MS = 5_000;
27
39
  const RUN_TTL_SECONDS = 7 * 24 * 60 * 60;
40
+ /** The owner renews its lease every LEASE_MS / 3; a lease this old means the owner is gone. */
41
+ const LEASE_MS = 90_000;
42
+ /** Runs written by servers without leases count as abandoned after this long. */
43
+ const UNLEASED_RUN_MS = 2 * 60 * 60_000;
44
+ const MAX_LOCAL_RUNS = 20;
45
+ /** This process, as the owner of the runs it executes. */
46
+ 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']);
28
53
  function parseMetadata(raw) {
29
54
  try {
30
55
  const parsed = JSON.parse(raw ?? '{}');
@@ -34,50 +59,121 @@ function parseMetadata(raw) {
34
59
  return {};
35
60
  }
36
61
  }
62
+ const parseWarnings = new Map();
63
+ /**
64
+ * Parse a self-upgrade report leniently: a malformed `upgrade` entry is
65
+ * dropped instead of the whole report. Failures are logged (once an hour per source).
66
+ */
67
+ export function parseSelfUpgradeStatus(value, source) {
68
+ if (value === undefined || value === null)
69
+ return null;
70
+ const parsed = agentSelfUpgradeStatusSchema.safeParse(value);
71
+ if (parsed.success)
72
+ return parsed.data;
73
+ const partial = typeof value === 'object' && !Array.isArray(value)
74
+ ? agentSelfUpgradeStatusSchema.safeParse({ ...value, upgrade: null }) : null;
75
+ if (source && Date.now() - (parseWarnings.get(source) ?? 0) > 60 * 60_000) {
76
+ parseWarnings.set(source, Date.now());
77
+ console.warn(`[agent-upgrade] ${source} sent a self-upgrade report that does not parse (${parsed.error.issues.slice(0, 3)
78
+ .map(issue => `${issue.path.join('.') || '<root>'}: ${issue.message}`).join('; ')})${partial?.success ? '; kept it without its upgrade entry' : '; ignored it'}`);
79
+ }
80
+ return partial?.success ? partial.data : null;
81
+ }
37
82
  export function storedSelfUpgrade(metadataRaw) {
38
83
  const value = parseMetadata(metadataRaw)['agentSelfUpgrade'];
39
- const parsed = agentSelfUpgradeStatusSchema.safeParse(value);
40
- if (!parsed.success)
84
+ const parsed = parseSelfUpgradeStatus(value);
85
+ if (!parsed)
41
86
  return null;
42
87
  const extra = value;
43
- return { ...parsed.data, nodeVersion: typeof extra.nodeVersion === 'string' ? extra.nodeVersion : null,
88
+ return { ...parsed, nodeVersion: typeof extra.nodeVersion === 'string' ? extra.nodeVersion : null,
44
89
  observedAt: typeof extra.observedAt === 'string' ? extra.observedAt : new Date(0).toISOString() };
45
90
  }
91
+ const AGENT_STATUSES = new Set(['RUNNING', 'STOPPED', 'ERROR']);
92
+ const priorStatus = (metadata) => {
93
+ const value = metadata['agentStatusBeforeUpgrade'];
94
+ return typeof value === 'string' && AGENT_STATUSES.has(value) ? value : 'RUNNING';
95
+ };
96
+ /** End UPDATING (node went offline, or the agent stopped reporting an upgrade) and restore the status it had before. */
97
+ export async function clearAgentUpdating(nodeId) {
98
+ const db = getDb();
99
+ const agent = await db.agent.findUnique({ where: { nodeId }, select: { status: true } });
100
+ if (agent?.status !== 'UPDATING')
101
+ return;
102
+ const row = await db.node.findUnique({ where: { id: nodeId }, select: { metadata: true } });
103
+ const metadata = parseMetadata(row?.metadata);
104
+ await db.agent.updateMany({ where: { nodeId, status: 'UPDATING' }, data: { status: priorStatus(metadata) } });
105
+ if ('agentStatusBeforeUpgrade' in metadata) {
106
+ delete metadata['agentStatusBeforeUpgrade'];
107
+ await db.node.update({ where: { id: nodeId }, data: { metadata: JSON.stringify(metadata) } });
108
+ }
109
+ }
46
110
  /**
47
111
  * Store the self-upgrade report from a heartbeat, keep Agent.status in step
48
- * (UPDATING while a version is staged or on trial) and audit each revert once.
112
+ * (UPDATING while a version is staged or on trial, then the status it had
113
+ * before), audit each confirmation and revert once, and record outcomes that
114
+ * arrive after their run stopped watching.
49
115
  */
50
- export async function recordSelfUpgradeReport(node, body) {
51
- const parsed = agentSelfUpgradeStatusSchema.safeParse(body.selfUpgrade);
52
- if (!parsed.success)
116
+ export async function recordSelfUpgradeReport(node, body, store = defaultRunStore()) {
117
+ const parsed = parseSelfUpgradeStatus(body.selfUpgrade, node.name);
118
+ if (!parsed) {
119
+ await clearAgentUpdating(node.id);
53
120
  return;
121
+ }
54
122
  const db = getDb();
55
123
  const row = await db.node.findUnique({ where: { id: node.id }, select: { metadata: true } });
56
124
  const metadata = parseMetadata(row?.metadata);
57
125
  const previous = storedSelfUpgrade(row?.metadata);
58
126
  const nodeVersion = typeof body.nodeVersion === 'string' ? body.nodeVersion.slice(0, 32) : null;
59
- const next = { ...parsed.data, nodeVersion, observedAt: new Date().toISOString() };
60
- const unchanged = previous && JSON.stringify({ ...previous, observedAt: '' }) === JSON.stringify({ ...next, observedAt: '' })
61
- && Date.now() - Date.parse(previous.observedAt) < 10 * 60_000;
62
- if (!unchanged) {
63
- await db.node.update({ where: { id: node.id }, data: { metadata: JSON.stringify({ ...metadata, agentSelfUpgrade: next, nodeVersion }) } });
64
- }
65
- const state = parsed.data.upgrade?.state;
127
+ const next = { ...parsed, nodeVersion, observedAt: new Date().toISOString() };
128
+ const nextMetadata = { ...metadata, agentSelfUpgrade: next, nodeVersion };
129
+ let changed = !(previous && JSON.stringify({ ...previous, observedAt: '' }) === JSON.stringify({ ...next, observedAt: '' })
130
+ && Date.now() - Date.parse(previous.observedAt) < 10 * 60_000);
131
+ const state = parsed.upgrade?.state;
66
132
  const updating = state === 'staged' || state === 'trial';
67
- await db.agent.updateMany({
68
- where: { nodeId: node.id, status: updating ? { not: 'UPDATING' } : 'UPDATING' },
69
- data: { status: updating ? 'UPDATING' : 'RUNNING' },
70
- });
71
- const upgrade = parsed.data.upgrade;
72
- if (upgrade && (upgrade.state === 'reverted' || upgrade.state === 'confirmed')
73
- && upgrade.requestId && previous?.upgrade?.requestId === upgrade.requestId && previous.upgrade.state !== upgrade.state) {
74
- await logAuditEvent({
75
- action: upgrade.state === 'reverted' ? 'agent.upgrade_reverted' : 'agent.upgrade',
76
- actorId: 'system:agent-upgrade', actorType: 'system', targetType: 'node', targetId: node.id,
77
- details: { node: node.name, from: upgrade.from, to: upgrade.to, requestId: upgrade.requestId, result: upgrade.state, reason: upgrade.reason ?? null },
78
- }).catch(() => undefined);
133
+ const agent = await db.agent.findUnique({ where: { nodeId: node.id }, select: { status: true } });
134
+ if (agent && updating && agent.status !== 'UPDATING') {
135
+ nextMetadata['agentStatusBeforeUpgrade'] = agent.status;
136
+ changed = true;
137
+ await db.agent.updateMany({ where: { nodeId: node.id, status: { not: 'UPDATING' } }, data: { status: 'UPDATING' } });
138
+ }
139
+ else if (agent && !updating && agent.status === 'UPDATING') {
140
+ delete nextMetadata['agentStatusBeforeUpgrade'];
141
+ changed = true;
142
+ await db.agent.updateMany({ where: { nodeId: node.id, status: 'UPDATING' }, data: { status: priorStatus(metadata) } });
79
143
  }
144
+ if (changed)
145
+ await db.node.update({ where: { id: node.id }, data: { metadata: JSON.stringify(nextMetadata) } });
146
+ const upgrade = parsed.upgrade;
147
+ if (upgrade && (upgrade.state === 'reverted' || upgrade.state === 'confirmed' || upgrade.state === 'failed') && upgrade.requestId
148
+ && !(previous?.upgrade?.requestId === upgrade.requestId && previous.upgrade.state === upgrade.state)) {
149
+ const runId = await store.runForRequest(upgrade.requestId).catch(() => null);
150
+ if (upgrade.state !== 'failed') {
151
+ await logAuditEvent({
152
+ action: upgrade.state === 'reverted' ? 'agent.upgrade.node_reverted' : 'agent.upgrade.node_confirmed',
153
+ actorId: 'system:agent-upgrade', actorType: 'system', targetType: 'node', targetId: node.id,
154
+ details: { node: node.name, from: upgrade.from, to: upgrade.to, requestId: upgrade.requestId, runId, result: upgrade.state, reason: upgrade.reason ?? null },
155
+ }).catch(() => undefined);
156
+ }
157
+ if (runId)
158
+ await recordLateOutcome(store, runId, upgrade.requestId, upgrade.state, upgrade.reason ?? null).catch(() => undefined);
159
+ }
160
+ }
161
+ /** A report for a node whose run already ended without knowing its outcome. */
162
+ 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
166
+ const entry = run.nodes.find(node => node.requestId === requestId);
167
+ if (!entry || !(entry.awaitingReport || entry.state === 'requested'))
168
+ 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;
173
+ run.summary = summarizeRun(run);
174
+ await store.save(run);
80
175
  }
176
+ export const isPrerelease = (version) => (parseVersion(version)?.prerelease.length ?? 0) > 0;
81
177
  /** The exact version (default: the one this server ships) and its npm integrity. */
82
178
  export async function resolveAgentRelease(options = {}) {
83
179
  const version = options.version ?? AGENT_VERSION;
@@ -92,7 +188,7 @@ export async function resolveAgentRelease(options = {}) {
92
188
  const url = `${registry}/${AGENT_PACKAGE.replace('/', '%2f')}/${version}`;
93
189
  let response;
94
190
  try {
95
- response = await (options.fetchImpl ?? fetch)(url, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(15_000) });
191
+ response = await (options.fetchImpl ?? fetch)(url, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(options.timeoutMs ?? 15_000) });
96
192
  }
97
193
  catch (error) {
98
194
  throw new HttpError(502, 'REGISTRY_UNREACHABLE', `Cannot read ${AGENT_PACKAGE}@${version} from ${registry}: ${error instanceof Error ? error.message : String(error)}. Pass the integrity explicitly for offline installs.`);
@@ -107,14 +203,34 @@ export async function resolveAgentRelease(options = {}) {
107
203
  throw new HttpError(502, 'REGISTRY_ERROR', `${AGENT_PACKAGE}@${version} has no sha512 integrity`);
108
204
  return { version, integrity };
109
205
  }
110
- /** Pure, for tests: why one node can or cannot take `target` now. */
111
- export function classifyCandidate(row, target, now = Date.now()) {
206
+ const verifications = new Map();
207
+ /** Whether the registry has `version` (cached ten minutes); eligibility shows unpublished targets as unverified. */
208
+ export async function verifyAgentRelease(version, fetchImpl) {
209
+ const cached = verifications.get(version);
210
+ if (cached && Date.now() - cached.at < 10 * 60_000)
211
+ return cached.value;
212
+ let value;
213
+ try {
214
+ const release = await resolveAgentRelease({ version, fetchImpl, timeoutMs: 5_000 });
215
+ value = { verified: true, integrity: release.integrity, reason: null };
216
+ }
217
+ catch (error) {
218
+ value = { verified: false, integrity: null, reason: error instanceof Error ? error.message : String(error) };
219
+ }
220
+ verifications.set(version, { at: Date.now(), value });
221
+ return value;
222
+ }
223
+ const day = (value) => value ? new Date(value).toISOString().slice(0, 10) : 'an unknown date';
224
+ const ENROLLMENT_DOC = 'docs/COMMAND_RESOURCE_LEASES.md';
225
+ /** Pure, for tests: why one node can or cannot take `target` now. Mirrors the dispatch preconditions. */
226
+ export function classifyCandidate(row, target, options = {}) {
227
+ const now = options.now ?? Date.now();
112
228
  const version = row.agent?.version ?? 'unknown';
113
229
  const report = storedSelfUpgrade(row.metadata);
114
230
  const base = { nodeId: row.id, name: row.name, version, upgrade: report?.upgrade ?? null, layout: report?.layout ?? null };
115
231
  const no = (reason) => ({ ...base, eligible: false, reason });
116
- if (row.status !== 'ONLINE' || !row.lastHeartbeatAt || now - row.lastHeartbeatAt.getTime() > FRESH_HEARTBEAT_MS)
117
- return no('offline or no recent heartbeat');
232
+ if (!hasFreshOnlineHealth(row, now))
233
+ return no(`offline or no recent heartbeat${row.lastHeartbeatAt ? ` (last ${row.lastHeartbeatAt.toISOString()})` : ''}`);
118
234
  if (!isVersion(version))
119
235
  return no('agent version unknown');
120
236
  if (compareVersions(version, target) === 0)
@@ -128,166 +244,510 @@ export function classifyCandidate(row, target, now = Date.now()) {
128
244
  return no('agent self-upgrade protocol too old');
129
245
  if (!report.enabled)
130
246
  return no(report.reason ?? 'self-upgrade disabled on this node');
131
- if (report.upgrade && (report.upgrade.state === 'staged' || report.upgrade.state === 'trial'))
132
- return no(`an upgrade to ${report.upgrade.to} is in progress`);
247
+ 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
+ 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`);
252
+ if (row.admissionFence)
253
+ return no(`release ${row.admissionFence.releaseId} closed admission to this node at ${row.admissionFence.closedAt.toISOString()}; release the fence first`);
254
+ const enrollment = row.resourceEnrollment ?? null;
255
+ const hostname = row.hostname ?? '';
256
+ const marker = report.managedEnrollment;
257
+ if (marker && (!enrollmentIsComplete(enrollment, marker.nodeFqdn) || marker.nodeId !== row.id || marker.nodeFqdn !== hostname)) {
258
+ const server = enrollment ? `a ${enrollment.state} enrollment for ${enrollment.nodeFqdn}` : 'no enrollment row';
259
+ const cause = stickyEnrollmentCause(enrollment, { lease: Boolean(row.leaseHistory), managedCron: Boolean(row.managedCronHistory),
260
+ 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}`
262
+ + `${cause ? ` (the server answers this agent as enrolled because of ${cause})` : ''}; `
263
+ + `complete the node's resource enrollment on the server (${ENROLLMENT_DOC}), or upgrade this agent by hand on the node`);
264
+ }
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
+ if (managed) {
268
+ if (!enrollmentIsComplete(enrollment, hostname)) {
269
+ 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})`);
271
+ 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`);
273
+ 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})`);
276
+ }
277
+ if (row.heldWork)
278
+ return no(`${row.heldWork} participant work item(s) still held on this node; reconcile them first`);
279
+ if (row.heldLeases)
280
+ return no(`${row.heldLeases} command lease(s) unsettled on this node (reserved, running or unknown); reconcile them first`);
281
+ }
133
282
  return { ...base, eligible: true, reason: null };
134
283
  }
135
- export async function listUpgradeCandidates(target, nodeIds) {
136
- const rows = await getDb().node.findMany({
137
- where: { id: { notIn: SENTINEL_NODE_IDS, ...(nodeIds?.length ? { in: [...nodeIds] } : {}) }, status: { not: 'DECOMMISSIONED' } },
138
- select: { id: true, name: true, status: true, lastHeartbeatAt: true, metadata: true, agent: { select: { version: true } } },
284
+ /** Candidates for `target`; `nodeIds: []` selects nothing (never the whole fleet). */
285
+ export async function listUpgradeCandidates(target, nodeIds, options = {}) {
286
+ if (nodeIds && nodeIds.length === 0)
287
+ return [];
288
+ const db = getDb();
289
+ const rows = await db.node.findMany({
290
+ where: { id: { notIn: SENTINEL_NODE_IDS, ...(nodeIds ? { in: [...nodeIds] } : {}) }, status: { not: 'DECOMMISSIONED' } },
291
+ select: { id: true, name: true, hostname: true, status: true, lastHeartbeatAt: true, metadata: true, agent: { select: { version: true } },
292
+ resourceEnrollment: { select: { state: true, nodeFqdn: true, startedAt: true, updatedAt: true } },
293
+ admissionFence: { select: { releaseId: true, closedAt: true } } },
139
294
  orderBy: { name: 'asc' },
140
295
  });
141
- return rows.map(row => classifyCandidate(row, target));
296
+ const ids = rows.map(row => row.id);
297
+ const [leases, work, crons] = ids.length ? await Promise.all([
298
+ db.commandResourceLease.groupBy({ by: ['nodeId', 'state'], where: { nodeId: { in: ids } }, _count: { _all: true } }),
299
+ db.resourceParticipantWork.groupBy({ by: ['nodeId', 'state'], where: { nodeId: { in: ids } }, _count: { _all: true } }),
300
+ db.managedCronDefinition.groupBy({ by: ['nodeId'], where: { nodeId: { in: ids } }, _count: { _all: true } }),
301
+ ]) : [[], [], []];
302
+ const held = (items, nodeId) => items.filter(item => item.nodeId === nodeId && HELD_RESOURCE_STATES.includes(item.state)).reduce((sum, item) => sum + item._count._all, 0);
303
+ return rows.map(row => classifyCandidate({ ...row,
304
+ leaseHistory: leases.some(lease => lease.nodeId === row.id),
305
+ heldLeases: held(leases, row.id),
306
+ heldWork: held(work, row.id),
307
+ participantHistory: work.some(item => item.nodeId === row.id),
308
+ managedCronHistory: crons.some(item => item.nodeId === row.id),
309
+ }, target, options));
142
310
  }
143
- const runs = new Map();
144
- async function saveRun(run) {
145
- runs.set(run.id, run);
146
- try {
147
- const { getRedis } = await import('../queue/redis.js');
148
- await getRedis().set(`agent-upgrade-run:${run.id}`, JSON.stringify(run), 'EX', RUN_TTL_SECONDS);
149
- await getRedis().set('agent-upgrade-run:latest', run.id, 'EX', RUN_TTL_SECONDS);
150
- }
151
- catch {
152
- // in-memory only
153
- }
154
- }
155
- export async function getUpgradeRun(id) {
156
- const local = runs.get(id);
157
- if (local)
158
- return local;
159
- try {
160
- const { getRedis } = await import('../queue/redis.js');
161
- const raw = await getRedis().get(`agent-upgrade-run:${id}`);
162
- return raw ? JSON.parse(raw) : null;
163
- }
164
- catch {
165
- return null;
166
- }
311
+ /** In-process store: tests, and the fallback without Redis. Keeps the newest MAX_LOCAL_RUNS runs. */
312
+ export function memoryRunStore() {
313
+ const runs = new Map();
314
+ const stops = new Map();
315
+ const requests = new Map();
316
+ let held = null;
317
+ let latestId = null;
318
+ return {
319
+ async save(run) {
320
+ runs.delete(run.id);
321
+ runs.set(run.id, run);
322
+ latestId = run.id;
323
+ for (const [id, stored] of runs) {
324
+ if (runs.size <= MAX_LOCAL_RUNS)
325
+ break;
326
+ if (stored.status !== 'running') {
327
+ runs.delete(id);
328
+ stops.delete(id);
329
+ for (const node of stored.nodes)
330
+ requests.delete(node.requestId);
331
+ }
332
+ }
333
+ },
334
+ async get(id) { return runs.get(id) ?? null; },
335
+ async latest() { return latestId ? runs.get(latestId) ?? null : null; },
336
+ async lock(token) {
337
+ if (held && held.until > Date.now() && held.token !== token)
338
+ return false;
339
+ held = { token, until: Date.now() + LEASE_MS };
340
+ return true;
341
+ },
342
+ async renew(token) {
343
+ if (held?.token !== token)
344
+ return false;
345
+ held.until = Date.now() + LEASE_MS;
346
+ return true;
347
+ },
348
+ async unlock(token) { if (held?.token === token)
349
+ held = null; },
350
+ async requestStop(id, actorId) { stops.set(id, actorId); },
351
+ async stopRequested(id) { return stops.get(id) ?? null; },
352
+ async index(run) { for (const node of run.nodes)
353
+ requests.set(node.requestId, run.id); },
354
+ async runForRequest(requestId) { return requests.get(requestId) ?? null; },
355
+ };
167
356
  }
168
- export async function latestUpgradeRun() {
169
- try {
170
- const { getRedis } = await import('../queue/redis.js');
171
- const id = await getRedis().get('agent-upgrade-run:latest');
172
- return id ? getUpgradeRun(id) : null;
173
- }
174
- catch {
175
- return [...runs.values()].at(-1) ?? null;
176
- }
357
+ const LOCK_KEY = 'agent-upgrade-run:lock';
358
+ const RELEASE_IF_OWNED = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
359
+ const RENEW_IF_OWNED = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('pexpire', KEYS[1], ARGV[2]) else return 0 end";
360
+ let sharedStore = null;
361
+ /** Redis-backed store; every call falls back to this process's memory when Redis is unavailable. */
362
+ export function defaultRunStore() {
363
+ if (sharedStore)
364
+ return sharedStore;
365
+ const memory = memoryRunStore();
366
+ const redis = async () => (await import('../queue/redis.js')).getRedis();
367
+ const either = async (remote, local) => {
368
+ try {
369
+ return await remote();
370
+ }
371
+ catch {
372
+ return local();
373
+ }
374
+ };
375
+ sharedStore = {
376
+ async save(run) {
377
+ await memory.save(run);
378
+ await either(async () => {
379
+ const client = await redis();
380
+ await client.set(`agent-upgrade-run:${run.id}`, JSON.stringify(run), 'EX', RUN_TTL_SECONDS);
381
+ await client.set('agent-upgrade-run:latest', run.id, 'EX', RUN_TTL_SECONDS);
382
+ }, async () => undefined);
383
+ },
384
+ get: id => either(async () => {
385
+ const raw = await (await redis()).get(`agent-upgrade-run:${id}`);
386
+ return raw ? JSON.parse(raw) : memory.get(id);
387
+ }, () => memory.get(id)),
388
+ latest: () => either(async () => {
389
+ const id = await (await redis()).get('agent-upgrade-run:latest');
390
+ return id ? sharedStore.get(id) : memory.latest();
391
+ }, () => memory.latest()),
392
+ lock: token => either(async () => await (await redis()).set(LOCK_KEY, token, 'PX', LEASE_MS, 'NX') === 'OK', () => memory.lock(token)),
393
+ renew: token => either(async () => Number(await (await redis()).eval(RENEW_IF_OWNED, 1, LOCK_KEY, token, String(LEASE_MS))) === 1, () => memory.renew(token)),
394
+ unlock: token => either(async () => { await (await redis()).eval(RELEASE_IF_OWNED, 1, LOCK_KEY, token); }, () => memory.unlock(token)),
395
+ async requestStop(id, actorId) {
396
+ await memory.requestStop(id, actorId);
397
+ await either(async () => { await (await redis()).set(`agent-upgrade-run:${id}:stop`, actorId, 'EX', RUN_TTL_SECONDS); }, async () => undefined);
398
+ },
399
+ stopRequested: id => either(async () => await (await redis()).get(`agent-upgrade-run:${id}:stop`) ?? memory.stopRequested(id), () => memory.stopRequested(id)),
400
+ async index(run) {
401
+ await memory.index(run);
402
+ await either(async () => {
403
+ const pipeline = (await redis()).pipeline();
404
+ for (const node of run.nodes)
405
+ pipeline.set(`agent-upgrade-request:${node.requestId}`, run.id, 'EX', RUN_TTL_SECONDS);
406
+ await pipeline.exec();
407
+ }, async () => undefined);
408
+ },
409
+ runForRequest: requestId => either(async () => await (await redis()).get(`agent-upgrade-request:${requestId}`) ?? memory.runForRequest(requestId), () => memory.runForRequest(requestId)),
410
+ };
411
+ return sharedStore;
177
412
  }
413
+ /** Runs this process is executing right now. */
414
+ const executing = new Map();
178
415
  function clientFor(node) {
179
416
  return new NodeApiClient({ baseUrl: getNodeBaseUrl(node.ipAddress, node.port), resourceNodeId: node.id, nodeToken: getNodeManagerSecret(),
180
- timeout: 15 * 60_000 });
417
+ resourceActorId: 'system:agent-upgrade', timeout: 15 * 60_000 });
418
+ }
419
+ /** Trial window the agent reported (or the default), two heartbeats, and the restart margin. */
420
+ export function confirmWindowMs(report, fallbackMs = DEFAULT_CONFIRM_SECONDS * 1000 + 2 * HEARTBEAT_MS + CONFIRM_MARGIN_MS) {
421
+ return report?.confirmSeconds ? report.confirmSeconds * 1000 + 2 * HEARTBEAT_MS + CONFIRM_MARGIN_MS : fallbackMs;
181
422
  }
182
- export function defaultDeps() {
423
+ export function defaultDeps(store = defaultRunStore()) {
183
424
  return {
184
425
  request: async (nodeId, body) => {
185
- const node = await getDb().node.findUniqueOrThrow({ where: { id: nodeId }, select: { id: true, ipAddress: true, port: true } });
426
+ const node = await getDb().node.findUnique({ where: { id: nodeId }, select: { id: true, ipAddress: true, port: true } });
427
+ if (!node)
428
+ throw new NotDispatchedError('NODE_NOT_FOUND', 'node is no longer in the inventory');
186
429
  await clientFor(node).upgradeAgent(body);
187
430
  },
188
431
  observe: async (nodeId) => {
189
- const row = await getDb().node.findUnique({ where: { id: nodeId }, select: { metadata: true, agent: { select: { version: true } } } });
190
- return { version: row?.agent?.version ?? null, report: storedSelfUpgrade(row?.metadata) };
432
+ const row = await getDb().node.findUnique({ where: { id: nodeId }, select: { metadata: true, lastHeartbeatAt: true, agent: { select: { version: true } } } });
433
+ return { version: row?.agent?.version ?? null, report: storedSelfUpgrade(row?.metadata), lastHeartbeatAt: row?.lastHeartbeatAt ?? null };
434
+ },
435
+ check: async (nodeId, run) => {
436
+ 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';
191
438
  },
192
439
  sleep: ms => new Promise(resolve => setTimeout(resolve, ms)),
193
440
  now: () => Date.now(),
194
- save: saveRun,
195
- confirmTimeoutMs: CONFIRM_TIMEOUT_MS,
441
+ save: run => store.save(run),
442
+ stopRequested: async (runId) => await store.stopRequested(runId).catch(() => null),
443
+ confirmTimeoutMs: confirmWindowMs(null),
196
444
  pollMs: POLL_MS,
197
445
  };
198
446
  }
447
+ /** Whether a failed request left the node untouched (refused), hit a release problem (failed), or may have run (unknown). */
448
+ export function classifyDispatchFailure(error) {
449
+ const message = (error instanceof AgentResponseError ? error.agentMessage ?? error.message : error instanceof Error ? error.message : String(error)).slice(0, 1000);
450
+ const code = error?.code;
451
+ if (error instanceof NotDispatchedError || error?.dispatched === false)
452
+ return { kind: 'refused', code: typeof code === 'string' ? code : 'NOT_DISPATCHED', message };
453
+ if (error instanceof AgentResponseError) {
454
+ 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}` };
460
+ }
461
+ if (error instanceof DispatchOutcomeUnknownError)
462
+ return { kind: 'unknown', code: error.code, message };
463
+ return { kind: 'unknown', code: typeof code === 'string' ? code : 'MUTATION_OUTCOME_UNKNOWN', message };
464
+ }
465
+ 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);
199
466
  /** Ask one node to upgrade and wait for its confirmed (or reverted) report. */
200
467
  async function upgradeOne(run, entry, deps) {
468
+ const iso = () => new Date(deps.now()).toISOString();
469
+ const end = (state, detail, code = null, awaitingReport = false) => {
470
+ Object.assign(entry, { state, detail: detail?.slice(0, 1000) ?? null, code, finishedAt: iso(), awaitingReport });
471
+ if (state === 'refused' || state === 'failed' || (state === 'confirmed' && detail?.startsWith('confirmed by version')))
472
+ void audit(`agent.upgrade.node_${state}`, run, { node: entry.name, requestId: entry.requestId, code, detail }, 'node', entry.nodeId);
473
+ };
474
+ let reason;
475
+ try {
476
+ reason = await deps.check(entry.nodeId, run);
477
+ }
478
+ catch (error) {
479
+ reason = `eligibility check failed: ${error instanceof Error ? error.message : String(error)}`;
480
+ }
481
+ if (reason)
482
+ return void Object.assign(entry, { state: 'skipped', detail: reason, finishedAt: iso() });
201
483
  entry.state = 'requested';
202
484
  await deps.save(run);
485
+ const startedAt = deps.now();
486
+ let unknown = null;
203
487
  try {
204
488
  await deps.request(entry.nodeId, { version: run.version, integrity: run.integrity, requestId: entry.requestId, packageName: AGENT_PACKAGE });
205
489
  }
206
490
  catch (error) {
207
- entry.state = 'failed';
208
- entry.detail = (error instanceof Error ? error.message : String(error)).slice(0, 500);
209
- return;
491
+ const failure = classifyDispatchFailure(error);
492
+ if (failure.kind === 'refused')
493
+ return end('refused', failure.message, failure.code);
494
+ if (failure.kind === 'failed')
495
+ return end('failed', failure.message, failure.code);
496
+ unknown = failure;
210
497
  }
211
- const deadline = deps.now() + deps.confirmTimeoutMs;
212
- while (deps.now() < deadline) {
498
+ entry.dispatchedAt = iso();
499
+ let windowMs = deps.confirmTimeoutMs;
500
+ let versionSince = null;
501
+ let lastHeartbeat = null;
502
+ const deadline = () => Math.max(startedAt + windowMs + (unknown ? UNKNOWN_DISPATCH_MARGIN_MS : 0), versionSince === null ? 0 : versionSince + windowMs + deps.pollMs);
503
+ while (deps.now() < deadline()) {
213
504
  await deps.sleep(deps.pollMs);
214
- const { version, report } = await deps.observe(entry.nodeId);
505
+ let observation;
506
+ try {
507
+ observation = await deps.observe(entry.nodeId);
508
+ }
509
+ catch (error) {
510
+ console.warn(`[agent-upgrade] could not read the state of ${entry.name}: ${error instanceof Error ? error.message : String(error)}`);
511
+ continue;
512
+ }
513
+ const { version, report } = observation;
514
+ lastHeartbeat = observation.lastHeartbeatAt ?? lastHeartbeat;
515
+ if (report?.confirmSeconds)
516
+ windowMs = confirmWindowMs(report);
215
517
  const upgrade = report?.upgrade;
216
- if (upgrade?.requestId === entry.requestId && upgrade.state === 'reverted') {
217
- entry.state = 'reverted';
218
- entry.detail = upgrade.reason ?? 'the agent switched back to its previous version';
219
- return;
518
+ if (upgrade?.requestId === entry.requestId) {
519
+ if (upgrade.state === 'confirmed')
520
+ return end('confirmed', unknown ? 'confirmed (the reply to the request was lost)' : null);
521
+ if (upgrade.state === 'reverted')
522
+ return end('reverted', upgrade.reason ?? 'the agent switched back to its previous version', 'SELF_UPGRADE_REVERTED');
523
+ if (upgrade.state === 'failed')
524
+ return end('failed', upgrade.reason ?? 'the agent reported the upgrade as failed', 'SELF_UPGRADE_FAILED');
525
+ }
526
+ else if (!upgrade?.requestId && version === run.version) {
527
+ // Agents that do not report request ids: the version has to hold for one full trial window.
528
+ versionSince ??= deps.now();
529
+ if (deps.now() - versionSince >= windowMs)
530
+ return end('confirmed', 'confirmed by version (the agent does not report upgrade requests)');
220
531
  }
221
- if (version === run.version && (upgrade?.requestId !== entry.requestId || upgrade.state === 'confirmed')) {
222
- entry.state = 'confirmed';
223
- entry.detail = null;
224
- return;
532
+ else {
533
+ versionSince = null;
225
534
  }
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);
226
538
  }
227
- entry.state = 'failed';
228
- entry.detail = `no confirmation within ${Math.round(deps.confirmTimeoutMs / 60_000)} min`;
539
+ const minutes = Math.round((deadline() - startedAt) / 60_000);
540
+ if (unknown)
541
+ return end('failed', `outcome unknown (${unknown.message}); no report for this request within ${minutes} min`, unknown.code, true);
542
+ end('failed', `no confirmation within ${minutes} min`, 'CONFIRM_TIMEOUT', true);
543
+ }
544
+ /** "N confirmed, N refused, N failed, N skipped" and each refusal reason. */
545
+ export function summarizeRun(run) {
546
+ const count = (state) => run.nodes.filter(node => node.state === state).length;
547
+ const reverted = count('reverted');
548
+ 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`
550
+ + (refused.length ? `; refused: ${refused.map(node => `${node.name} (${node.code ? `${node.code}: ` : ''}${node.detail ?? 'no reason given'})`).join('; ')}` : '');
229
551
  }
230
- /** Canary first, then batches of `concurrency`. Stops at the first node that does not confirm. */
552
+ const halts = (node) => node.state === 'failed' || node.state === 'reverted';
553
+ /**
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.
557
+ */
231
558
  export async function executeUpgradeRun(run, deps) {
232
- const queue = run.nodes.filter(node => node.state === 'pending');
233
- const [canary, ...rest] = queue;
234
559
  const finish = async (status, message) => {
235
560
  for (const node of run.nodes)
236
561
  if (node.state === 'pending')
237
- node.state = 'skipped';
562
+ Object.assign(node, { state: 'skipped', detail: status === 'stopped' ? 'run stopped' : 'not reached' });
238
563
  run.status = status;
239
- run.message = message;
564
+ run.summary = summarizeRun(run);
565
+ run.message = `${message} (${run.summary})`;
240
566
  run.finishedAt = new Date(deps.now()).toISOString();
241
567
  await deps.save(run);
568
+ await audit(status === 'stopped' ? 'agent.upgrade_run.stopped' : 'agent.upgrade_run.finished', run, { status, summary: run.summary,
569
+ stoppedBy: run.stopRequestedBy ?? null });
242
570
  return run;
243
571
  };
244
- if (!canary)
245
- return finish('succeeded', 'no eligible nodes');
246
- await upgradeOne(run, canary, deps);
247
- await deps.save(run);
248
- if (canary.state !== 'confirmed')
249
- return finish('failed', `canary ${canary.name} did not confirm (${canary.state}: ${canary.detail ?? ''}); the other nodes were not touched`);
250
- for (let index = 0; index < rest.length; index += run.concurrency) {
251
- const batch = rest.slice(index, index + run.concurrency);
252
- await Promise.all(batch.map(node => upgradeOne(run, node, deps)));
253
- await deps.save(run);
254
- const bad = batch.find(node => node.state !== 'confirmed');
255
- if (bad)
256
- return finish('failed', `${bad.name} did not confirm (${bad.state}: ${bad.detail ?? ''}); remaining nodes were skipped`);
572
+ const pending = () => run.nodes.filter(node => node.state === 'pending');
573
+ const stopped = async () => {
574
+ const actor = await deps.stopRequested(run.id).catch(() => null);
575
+ if (actor)
576
+ run.stopRequestedBy = actor;
577
+ return actor;
578
+ };
579
+ try {
580
+ let canary;
581
+ while ((canary = pending()[0])) {
582
+ if (await stopped())
583
+ return finish('stopped', `stopped by ${run.stopRequestedBy}`);
584
+ run.canaryNodeId = canary.nodeId;
585
+ await upgradeOne(run, canary, deps);
586
+ 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`);
591
+ }
592
+ for (let batch = pending().slice(0, run.concurrency); batch.length; batch = pending().slice(0, run.concurrency)) {
593
+ if (await stopped())
594
+ return finish('stopped', `stopped by ${run.stopRequestedBy}`);
595
+ await Promise.all(batch.map(node => upgradeOne(run, node, deps)));
596
+ 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`);
600
+ }
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');
605
+ }
606
+ catch (error) {
607
+ return finish('failed', `run aborted: ${error instanceof Error ? error.message : String(error)}`);
257
608
  }
258
- return finish('succeeded', `${run.nodes.filter(node => node.state === 'confirmed').length} agent(s) upgraded to ${run.version}`);
259
609
  }
260
- /** Validate, create the run and start it in the background. */
610
+ // ── Interrupted runs ──
611
+ const leaseExpired = (run, now = Date.now()) => run.status === 'running' && !executing.has(run.id)
612
+ && (run.leaseExpiresAt ? Date.parse(run.leaseExpiresAt) : Date.parse(run.startedAt) + UNLEASED_RUN_MS) < now;
613
+ /**
614
+ * The owner stopped renewing (server restart, CLI interrupted): settle the
615
+ * requested nodes from their stored heartbeat reports, skip the pending ones
616
+ * and mark the run `interrupted`. Unresolved nodes keep `awaitingReport`.
617
+ */
618
+ export async function reconcileInterruptedRun(run, observe, save, now = Date.now()) {
619
+ if (!leaseExpired(run, now))
620
+ return run;
621
+ const at = new Date(now).toISOString();
622
+ for (const node of run.nodes) {
623
+ if (node.state === 'pending')
624
+ Object.assign(node, { state: 'skipped', detail: 'run interrupted before this node', finishedAt: at });
625
+ if (node.state !== 'requested')
626
+ continue;
627
+ const upgrade = await observe(node.nodeId).then(observation => observation.report?.upgrade, () => null);
628
+ const mine = upgrade?.requestId === node.requestId ? upgrade : null;
629
+ if (mine?.state === 'confirmed')
630
+ Object.assign(node, { state: 'confirmed', detail: 'confirmed (recorded after the run was interrupted)', code: null });
631
+ else if (mine?.state === 'reverted')
632
+ Object.assign(node, { state: 'reverted', detail: mine.reason ?? 'the agent switched back', code: 'SELF_UPGRADE_REVERTED' });
633
+ else if (mine?.state === 'failed')
634
+ Object.assign(node, { state: 'failed', detail: mine.reason ?? 'the agent reported a failure', code: 'SELF_UPGRADE_FAILED' });
635
+ else
636
+ Object.assign(node, { state: 'failed', code: 'RUN_INTERRUPTED', awaitingReport: true,
637
+ detail: `the server stopped watching${mine ? ` while the agent reported ${mine.state}` : ''}; a later report for this request is recorded here` });
638
+ node.finishedAt = at;
639
+ }
640
+ run.status = 'interrupted';
641
+ run.summary = summarizeRun(run);
642
+ run.message = `interrupted: the server that ran it stopped renewing its lease (restart or interrupted CLI) (${run.summary})`;
643
+ run.finishedAt = at;
644
+ await save(run);
645
+ await audit('agent.upgrade_run.finished', run, { status: 'interrupted', summary: run.summary });
646
+ return run;
647
+ }
648
+ export async function getUpgradeRun(id, store = defaultRunStore()) {
649
+ const run = executing.get(id)?.run ?? await store.get(id).catch(() => null);
650
+ return run ? reconcileInterruptedRun(run, defaultDeps(store).observe, saved => store.save(saved)) : null;
651
+ }
652
+ export async function latestUpgradeRun(store = defaultRunStore()) {
653
+ const run = await store.latest().catch(() => null);
654
+ return run ? getUpgradeRun(run.id, store) : null;
655
+ }
656
+ /** Validate, take the fleet-wide run lock, create the run and start it in the background. */
261
657
  export async function startUpgradeRun(options) {
262
658
  const settings = upgradeSettings();
263
659
  if (!settings.agentUpgrades)
264
660
  throw new HttpError(403, 'AGENT_UPGRADES_DISABLED', 'Server-driven agent upgrades are off (MAGNUS_AGENT_UPGRADES=false)');
265
- const release = await resolveAgentRelease({ version: options.version, integrity: options.integrity });
266
- const candidates = await listUpgradeCandidates(release.version, options.nodeIds);
267
- const eligible = candidates.filter(candidate => candidate.eligible);
268
- if (eligible.length === 0) {
269
- const reasons = candidates.slice(0, 5).map(candidate => `${candidate.name}: ${candidate.reason}`).join('; ');
270
- throw new HttpError(409, 'NO_ELIGIBLE_AGENTS', `No selected agent can be upgraded now${reasons ? ` (${reasons})` : ''}`);
271
- }
272
- const running = await latestUpgradeRun();
273
- if (running?.status === 'running')
274
- throw new HttpError(409, 'AGENT_UPGRADE_RUNNING', `Upgrade run ${running.id} is still in progress`);
275
- const run = {
276
- id: randomUUID(), version: release.version, integrity: release.integrity, actorId: options.actorId, status: 'running',
277
- startedAt: new Date().toISOString(), finishedAt: null, concurrency: settings.agentUpgradeConcurrency, message: null,
278
- nodes: eligible.map(candidate => ({ nodeId: candidate.nodeId, name: candidate.name, from: candidate.version, state: 'pending', requestId: randomUUID(), detail: null })),
279
- };
280
- const deps = options.deps ?? defaultDeps();
281
- await deps.save(run);
282
- await logAuditEvent({ action: 'agent.upgrade', actorId: options.actorId, targetType: 'fleet', targetId: run.id,
283
- details: { version: run.version, nodes: run.nodes.map(node => node.name) } }).catch(() => undefined);
284
- const done = executeUpgradeRun(run, deps).catch(async (error) => {
285
- run.status = 'failed';
286
- run.message = error instanceof Error ? error.message : String(error);
287
- run.finishedAt = new Date().toISOString();
661
+ const version = options.version ?? AGENT_VERSION;
662
+ if (isVersion(version) && isPrerelease(version) && !options.allowPrerelease)
663
+ throw new HttpError(400, 'PRERELEASE_NOT_ALLOWED', `${version} is a prerelease; pass allowPrerelease to install it`);
664
+ if (options.nodeIds && options.nodeIds.length === 0)
665
+ throw new HttpError(400, 'NO_NODES_SELECTED', 'nodeIds is empty; leave it out to upgrade every eligible agent');
666
+ const store = options.store ?? defaultRunStore();
667
+ const id = randomUUID();
668
+ const token = `${INSTANCE_ID}:${id}`;
669
+ if (!await store.lock(token))
670
+ throw new HttpError(409, 'AGENT_UPGRADE_RUNNING', 'Another agent upgrade run is starting or in progress');
671
+ try {
672
+ const running = await latestUpgradeRun(store);
673
+ if (running?.status === 'running')
674
+ throw new HttpError(409, 'AGENT_UPGRADE_RUNNING', `Upgrade run ${running.id} is still in progress`);
675
+ const release = await resolveAgentRelease({ version, integrity: options.integrity });
676
+ const candidates = await listUpgradeCandidates(release.version, options.nodeIds, { allowRetryReverted: options.allowRetryReverted });
677
+ const unknown = (options.nodeIds ?? []).filter(nodeId => !candidates.some(candidate => candidate.nodeId === nodeId));
678
+ if (unknown.length)
679
+ throw new HttpError(400, 'UNKNOWN_NODES', `Unknown or decommissioned node(s): ${unknown.slice(0, 20).join(', ')}`);
680
+ const eligible = candidates.filter(candidate => candidate.eligible);
681
+ if (options.canaryNodeId && !eligible.some(candidate => candidate.nodeId === options.canaryNodeId)) {
682
+ const canary = candidates.find(candidate => candidate.nodeId === options.canaryNodeId);
683
+ throw new HttpError(400, 'INVALID_CANARY', canary ? `Canary ${canary.name} cannot be upgraded now: ${canary.reason}` : 'canaryNodeId is not among the selected nodes');
684
+ }
685
+ if (eligible.length === 0) {
686
+ const reasons = candidates.slice(0, 5).map(candidate => `${candidate.name}: ${candidate.reason}`).join('; ');
687
+ throw new HttpError(409, 'NO_ELIGIBLE_AGENTS', `No selected agent can be upgraded now${reasons ? ` (${reasons})` : ''}`);
688
+ }
689
+ if (options.canaryNodeId)
690
+ eligible.unshift(...eligible.splice(eligible.findIndex(candidate => candidate.nodeId === options.canaryNodeId), 1));
691
+ const deps = options.deps ?? defaultDeps(store);
692
+ const run = {
693
+ id, version: release.version, integrity: release.integrity, actorId: options.actorId, status: 'running',
694
+ startedAt: new Date(deps.now()).toISOString(), finishedAt: null, concurrency: settings.agentUpgradeConcurrency, message: null, summary: null,
695
+ canaryNodeId: eligible[0].nodeId, owner: INSTANCE_ID, leaseExpiresAt: new Date(deps.now() + LEASE_MS).toISOString(), stopRequestedBy: null,
696
+ 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 })),
699
+ };
700
+ await store.index(run);
288
701
  await deps.save(run);
289
- return run;
290
- });
291
- return { run, candidates, done };
702
+ await audit('agent.upgrade_run.started', run, { nodes: run.nodes.map(node => node.name), canary: run.nodes[0].name, retryOf: run.retryOf,
703
+ excluded: run.excluded.map(node => `${node.name}: ${node.reason}`).slice(0, 50) });
704
+ // Renew the lease while this process runs it; a reader that finds it expired marks the run interrupted.
705
+ const renew = async () => {
706
+ run.leaseExpiresAt = new Date(Date.now() + LEASE_MS).toISOString();
707
+ if (!await store.renew(token).catch(() => false))
708
+ console.warn(`[agent-upgrade] run ${run.id}: could not renew the run lock`);
709
+ await deps.save(run).catch(() => undefined);
710
+ };
711
+ const timer = setInterval(() => void renew(), LEASE_MS / 3);
712
+ timer.unref?.();
713
+ executing.set(run.id, { run, timer });
714
+ const done = executeUpgradeRun(run, deps).finally(async () => {
715
+ clearInterval(timer);
716
+ executing.delete(run.id);
717
+ await store.unlock(token).catch(() => undefined);
718
+ });
719
+ return { run, candidates, done };
720
+ }
721
+ catch (error) {
722
+ await store.unlock(token).catch(() => undefined);
723
+ throw error;
724
+ }
725
+ }
726
+ /** Ask a run to stop; the owner honours it before the next batch. A run nobody owns any more is closed as interrupted. */
727
+ export async function stopUpgradeRun(id, actorId, store = defaultRunStore()) {
728
+ const run = await getUpgradeRun(id, store);
729
+ if (!run)
730
+ throw new HttpError(404, 'NOT_FOUND', 'Upgrade run not found');
731
+ if (run.status !== 'running')
732
+ throw new HttpError(409, 'RUN_NOT_RUNNING', `Upgrade run ${id} already ended (${run.status})`);
733
+ await store.requestStop(id, actorId);
734
+ run.stopRequestedBy = actorId;
735
+ await logAuditEvent({ action: 'agent.upgrade_run.stop_requested', actorId, targetType: 'fleet', targetId: id, details: { runId: id } }).catch(() => undefined);
736
+ return run;
737
+ }
738
+ /** Retry the nodes a finished run did not upgrade, with the same version and integrity. */
739
+ export async function retryUpgradeRun(id, options) {
740
+ const store = options.store ?? defaultRunStore();
741
+ const previous = await getUpgradeRun(id, store);
742
+ if (!previous)
743
+ throw new HttpError(404, 'NOT_FOUND', 'Upgrade run not found');
744
+ if (previous.status === 'running')
745
+ 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);
748
+ if (!nodeIds.length)
749
+ 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,
751
+ allowPrerelease: options.allowPrerelease ?? isPrerelease(previous.version) });
292
752
  }
293
753
  //# sourceMappingURL=agent-upgrade-service.js.map