@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.
- package/.env.example +1 -0
- package/agent-version.json +1 -1
- package/dist/cli/agents.js +145 -25
- package/dist/cli/args.js +1 -1
- package/dist/cli/env-catalog.js +1 -0
- package/dist/config/features.js +2 -0
- package/dist/index.js +3 -0
- package/dist/openapi/spec.js +9 -7
- package/dist/routes/agent-upgrades.js +14 -3
- package/dist/routes/nodes.js +5 -2
- package/dist/sdk/node-api-client.js +82 -3
- package/dist/security/command-result-signature.js +22 -0
- package/dist/services/agent-upgrade-preflight.js +162 -0
- package/dist/services/agent-upgrade-readiness.js +203 -0
- package/dist/services/agent-upgrade-service.js +511 -115
- package/dist/services/command-fence-repair.js +72 -0
- package/dist/services/command-resource-leases.js +135 -8
- package/dist/services/resource-enrollment.js +10 -4
- package/package.json +3 -3
|
@@ -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,
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
209
|
+
if (!entry)
|
|
168
210
|
return;
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
entry.
|
|
172
|
-
|
|
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
|
|
231
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
if (
|
|
458
|
-
return { kind: '
|
|
459
|
-
|
|
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
|
|
545
|
+
let verdict;
|
|
475
546
|
try {
|
|
476
|
-
|
|
547
|
+
verdict = await deps.check(entry.nodeId, run);
|
|
477
548
|
}
|
|
478
549
|
catch (error) {
|
|
479
|
-
|
|
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
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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('
|
|
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
|
|
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
|
-
*
|
|
555
|
-
*
|
|
556
|
-
*
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
|
584
|
-
|
|
585
|
-
|
|
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
|
-
|
|
588
|
-
|
|
589
|
-
if (
|
|
590
|
-
return
|
|
821
|
+
await readLateReports();
|
|
822
|
+
const stop = evidenceStop(canaryPhase && confirmed().length === 0);
|
|
823
|
+
if (stop)
|
|
824
|
+
return stop;
|
|
591
825
|
}
|
|
592
|
-
|
|
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
|
|
595
|
-
|
|
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
|
-
|
|
598
|
-
if (
|
|
599
|
-
return
|
|
857
|
+
ended = await drain();
|
|
858
|
+
if (ended)
|
|
859
|
+
return ended;
|
|
600
860
|
}
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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: '
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
698
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|