@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 CHANGED
@@ -197,5 +197,6 @@ CORS_ORIGIN=https://magnus.example.com:3000
197
197
  # Server-driven agent upgrades (console "Upgrade agents", `magnus-server agents upgrade`).
198
198
  # MAGNUS_AGENT_UPGRADES=true
199
199
  # MAGNUS_AGENT_UPGRADE_CONCURRENCY=5
200
+ # MAGNUS_AGENT_UPGRADE_MAX_FAILURES=2
200
201
  # The retired git-checkout self-update endpoints (source installs only).
201
202
  # MAGNUS_LEGACY_GIT_SELF_UPDATE=false
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.1.7"
2
+ "version": "1.1.8"
3
3
  }
@@ -1,25 +1,48 @@
1
- /** `magnus-server agents upgrade`: the console's "Upgrade agents" action from a shell. */
1
+ /** `magnus-server agents upgrade`: the console's "Upgrade agents" action from a shell, and `agents doctor`, its check. */
2
+ import { upgradeRemedy } from '@hivemagnus/magnus-contracts';
2
3
  import { UsageError } from './args.js';
3
4
  export const AGENTS_USAGE = `Usage: magnus-server agents upgrade [options] [--env-file FILE]
5
+ magnus-server agents doctor [--nodes a,b] [--to X.Y.Z] [--heal] [--env-file FILE]
6
+ magnus-server agents fence NODE VALUE [--env-file FILE]
4
7
  magnus-server agents stop RUN_ID [--env-file FILE]
5
8
 
6
- Upgrades agents to the version bundled with this server (or --to), one canary
7
- first, then MAGNUS_AGENT_UPGRADE_CONCURRENCY at a time. Each agent installs the
8
- exact version beside the running one and switches back by itself if the new
9
- version does not report a heartbeat in time. A revert, a release problem or an
10
- agent that goes silent stops the run; an agent that refuses before changing
11
- anything (busy, enrollment, unreachable) is reported as refused and the run
12
- goes on.
9
+ upgrade: moves agents to the version bundled with this server (or --to). Every
10
+ selected node is checked first and blocked nodes are left out with the reason.
11
+ One canary goes first, then MAGNUS_AGENT_UPGRADE_CONCURRENCY at a time. Each
12
+ agent installs the exact version beside the running one and switches back by
13
+ itself if the new version does not report a heartbeat in time.
14
+
15
+ Only evidence about the release stops a run: an integrity or source failure at
16
+ once, a failure on the canary, ambiguous answers from canaries with nothing
17
+ confirmed, and after that MAGNUS_AGENT_UPGRADE_MAX_FAILURES reverts or failed
18
+ installs. A problem of one node (busy, enrollment, unreachable,
19
+ a lost reply) is recorded and the run goes on; transient ones are tried again at
20
+ the end. The summary lists each node needing attention, with what to do.
21
+
22
+ Exit status: 0 succeeded, 2 partial (some nodes need attention), 1 otherwise.
13
23
 
14
24
  Options:
15
25
  --to X.Y.Z Agent version (default: the one bundled with this server)
16
26
  --integrity sha512-… npm integrity of that version (default: read from the registry)
17
27
  --nodes a,b Only these nodes (names, host names or ids)
18
28
  --canary NODE Upgrade this node first (default: the first eligible one)
29
+ --max-failures N Release failures that stop this run (default: MAGNUS_AGENT_UPGRADE_MAX_FAILURES)
19
30
  --allow-prerelease Required for a prerelease version such as 1.2.0-rc.1
20
31
  --retry-reverted Also offer nodes that already reverted this version
21
- --retry RUN_ID Retry the failed, refused, skipped and interrupted nodes of a run
22
- --dry-run Show which agents would be upgraded, and why others would not
32
+ --retry RUN_ID Retry the nodes of a run that were not upgraded
33
+ --acknowledge-suspicion With --retry: also retry older agents after a run that stopped on their ambiguous answers
34
+ --dry-run Show the full per-node report and start nothing
35
+
36
+ doctor: the same read-only check for every node: what each agent reports about
37
+ its layout, launcher, disk, npm, ledger, fences and enrollment, and what to do
38
+ about each problem. --heal also repairs what the server owns: it moves a fence
39
+ generation up to what the agent signs that it holds, and settles leases stuck
40
+ unknown when the agent's ledger proves they never ran. Exit status 1 when a node
41
+ is blocked.
42
+
43
+ fence: moves the server's fence generation for a node to VALUE + 1. Only ever up,
44
+ and audited. For agents older than 1.1.8, which do not say what fence they hold:
45
+ read it from .magnus/command-receipts/fence-*.json on the node.
23
46
 
24
47
  Ctrl-C asks the run to stop after the nodes in progress; a second Ctrl-C leaves
25
48
  at once, and the run is then shown as interrupted.
@@ -38,9 +61,19 @@ export function parseAgentsArgs(args) {
38
61
  throw new UsageError('agents stop takes one RUN_ID');
39
62
  return { stop: id, dryRun: false };
40
63
  }
41
- if (sub !== 'upgrade')
64
+ if (sub === 'fence') {
65
+ const [node, value, ...extra] = rest;
66
+ if (node === '--help' || node === '-h')
67
+ return 'help';
68
+ if (!node || !value || node.startsWith('--') || extra.length)
69
+ throw new UsageError('agents fence takes NODE and VALUE');
70
+ if (!/^[1-9][0-9]{0,18}$/.test(value))
71
+ throw new UsageError('agents fence: VALUE must be a positive decimal');
72
+ return { fence: { node, value }, dryRun: false };
73
+ }
74
+ if (sub !== 'upgrade' && sub !== 'doctor')
42
75
  throw new UsageError(`unknown agents subcommand "${sub}"`);
43
- const options = { dryRun: false };
76
+ const options = { dryRun: false, ...(sub === 'doctor' ? { doctor: true } : {}) };
44
77
  for (let index = 0; index < rest.length; index++) {
45
78
  const arg = rest[index];
46
79
  const [name, inline] = arg.includes('=') ? [arg.slice(0, arg.indexOf('=')), arg.slice(arg.indexOf('=') + 1)] : [arg, undefined];
@@ -64,8 +97,18 @@ export function parseAgentsArgs(args) {
64
97
  options.allowPrerelease = true;
65
98
  else if (name === '--retry-reverted')
66
99
  options.retryReverted = true;
100
+ else if (name === '--acknowledge-suspicion')
101
+ options.acknowledgeSuspicion = true;
67
102
  else if (name === '--dry-run')
68
103
  options.dryRun = true;
104
+ else if (name === '--heal' && sub === 'doctor')
105
+ options.heal = true;
106
+ else if (name === '--max-failures') {
107
+ const budget = Number(value());
108
+ if (!Number.isInteger(budget) || budget < 1 || budget > 1000)
109
+ throw new UsageError('--max-failures must be a whole number from 1 to 1000');
110
+ options.maxFailures = budget;
111
+ }
69
112
  else if (name === '--help' || name === '-h')
70
113
  return 'help';
71
114
  else
@@ -77,6 +120,34 @@ export function parseAgentsArgs(args) {
77
120
  throw new UsageError('--retry reuses the version, integrity and nodes of that run; drop --to, --integrity, --nodes and --dry-run');
78
121
  return options;
79
122
  }
123
+ const remedyLines = (item) => [
124
+ ` ${item.name}: ${item.code}${item.unknownCode ? ' [unrecognised code]' : ''}${item.detail ? ` (${item.detail})` : ''}`,
125
+ ` cause: ${item.cause}`,
126
+ ` remedy: ${item.remedy}`,
127
+ ];
128
+ /** What one node's check found, in the order someone would act on it. */
129
+ export function doctorLines(report) {
130
+ const lines = [`Target magnus-agent ${report.version}${report.release.verified ? '' : ` (not verified on the registry: ${report.release.reason ?? 'unknown'})`}`];
131
+ for (const node of report.nodes) {
132
+ const preflight = node.preflight;
133
+ const verdict = node.eligible ? 'ready' : node.code && upgradeRemedy(node.code).informational ? 'not applicable' : 'blocked';
134
+ const facts = preflight?.report ? [preflight.report.layout, preflight.report.disk ? `${Math.floor(preflight.report.disk.freeBytes / (1024 * 1024))} MiB free` : null,
135
+ preflight.report.npm?.version ? `npm ${preflight.report.npm.version}` : null].filter(Boolean).join(', ') : `preflight ${preflight?.status ?? 'not run'}${preflight?.detail ? `: ${preflight.detail}` : ''}`;
136
+ lines.push(`${node.name} ${node.version} ${verdict} (${facts})`);
137
+ if (!node.eligible && node.code)
138
+ lines.push(...remedyLines({ name: 'reason', code: node.code, detail: node.reason, cause: upgradeRemedy(node.code).cause, remedy: upgradeRemedy(node.code).remedy }));
139
+ for (const problem of preflight?.problems ?? []) {
140
+ if (problem.code === node.code)
141
+ continue;
142
+ const remedy = upgradeRemedy(problem.code);
143
+ lines.push(...remedyLines({ name: problem.blocking ? 'blocks' : 'note', code: problem.code, detail: problem.detail, cause: remedy.cause, remedy: remedy.remedy }));
144
+ }
145
+ for (const healed of preflight?.healed ?? [])
146
+ lines.push(` repaired: ${healed}`);
147
+ }
148
+ lines.push(`${report.eligible} ready, ${report.blocked} blocked, ${report.nodes.length - report.eligible - report.blocked} not applicable.`);
149
+ return lines;
150
+ }
80
151
  const nodeLine = (node) => ` ${node.name}: ${node.state}${node.detail ? ` (${node.code ? `${node.code}: ` : ''}${node.detail})` : node.code ? ` (${node.code})` : ''}`;
81
152
  export async function commandAgents(args) {
82
153
  const options = parseAgentsArgs(args);
@@ -93,6 +164,21 @@ export async function commandAgents(args) {
93
164
  console.log(`Asked run ${run.id} to stop; it stops before its next batch.`);
94
165
  return 0;
95
166
  }
167
+ if (options.fence) {
168
+ const row = await getDb().node.findFirst({ where: { OR: [{ id: options.fence.node }, { name: options.fence.node }, { hostname: options.fence.node }] }, select: { id: true, name: true } });
169
+ if (!row) {
170
+ console.error(`Unknown node: ${options.fence.node}`);
171
+ return 1;
172
+ }
173
+ const { advanceCommandFence } = await import('../services/command-fence-repair.js');
174
+ const moved = await advanceCommandFence({ nodeId: row.id, resourceKey: `node:${row.id}`, highWater: options.fence.value, source: 'operator', actorId: 'system:magnus-server-cli' });
175
+ if (moved.status === 'rejected') {
176
+ console.error(`${moved.code}: node ${row.name} ${moved.detail}`);
177
+ return 1;
178
+ }
179
+ console.log(moved.status === 'advanced' ? `${row.name}: fence generation moved from ${moved.from} to ${moved.to}.` : `${row.name}: the fence generation is already past ${options.fence.value}; nothing changed.`);
180
+ return 0;
181
+ }
96
182
  const resolveNodes = async (names) => {
97
183
  const rows = await getDb().node.findMany({
98
184
  where: { OR: [{ id: { in: names } }, { name: { in: names } }, { hostname: { in: names } }] },
@@ -116,6 +202,12 @@ export async function commandAgents(args) {
116
202
  canaryNodeId = resolved.byName(options.canary);
117
203
  }
118
204
  const deps = service.defaultDeps();
205
+ if (options.doctor) {
206
+ const report = await service.checkUpgrade({ version: options.to, integrity: options.integrity, nodeIds, heal: options.heal ?? false, deps, allowRetryReverted: options.retryReverted });
207
+ for (const line of doctorLines(report))
208
+ console.log(line);
209
+ return report.blocked ? 1 : 0;
210
+ }
119
211
  const seen = new Map();
120
212
  const save = deps.save;
121
213
  deps.save = async (run) => {
@@ -131,27 +223,47 @@ export async function commandAgents(args) {
131
223
  let started;
132
224
  if (options.retry) {
133
225
  started = await service.retryUpgradeRun(options.retry, { actorId: 'system:magnus-server-cli', deps, canaryNodeId,
134
- allowRetryReverted: options.retryReverted, allowPrerelease: options.allowPrerelease });
226
+ allowRetryReverted: options.retryReverted, allowPrerelease: options.allowPrerelease, acknowledgeSuspicion: options.acknowledgeSuspicion, ...(options.maxFailures ? { maxFailures: options.maxFailures } : {}) });
135
227
  console.log(`Retrying run ${options.retry} as ${started.run.id}: ${started.run.nodes.length} agent(s) to ${started.run.version}`);
136
228
  }
137
229
  else {
138
230
  const release = await service.resolveAgentRelease({ version: options.to, integrity: options.integrity });
139
- const candidates = await service.listUpgradeCandidates(release.version, nodeIds, { allowRetryReverted: options.retryReverted });
140
231
  console.log(`magnus-agent ${release.version} (${release.integrity.slice(0, 19)}…)`);
141
- for (const candidate of candidates) {
142
- console.log(` ${candidate.eligible ? '→' : '·'} ${candidate.name.padEnd(28)} ${candidate.version.padEnd(12)} ${candidate.eligible ? 'will upgrade' : candidate.reason}`);
143
- }
144
- const eligible = candidates.filter(candidate => candidate.eligible);
232
+ const showCandidates = (candidates) => {
233
+ for (const candidate of candidates) {
234
+ console.log(` ${candidate.eligible ? '→' : '·'} ${candidate.name.padEnd(28)} ${candidate.version.padEnd(12)} ${candidate.eligible ? 'will upgrade' : candidate.reason}`);
235
+ }
236
+ };
145
237
  if (options.dryRun) {
146
- console.log(`Dry run: ${eligible.length} of ${candidates.length} agent(s) would be upgraded.`);
238
+ const report = await service.checkUpgrade({ version: release.version, integrity: release.integrity, nodeIds, deps, everyOnlineNode: false, allowRetryReverted: options.retryReverted });
239
+ showCandidates(report.nodes);
240
+ for (const node of report.nodes) {
241
+ if (!node.eligible && node.code && !upgradeRemedy(node.code).informational)
242
+ console.log(` ${node.name}: ${node.code}: ${node.remedy}`);
243
+ if (node.preflight?.status === 'unavailable')
244
+ console.log(` ${node.name}: preflight unavailable (${node.preflight.detail ?? 'older agent'}); heartbeat data was used`);
245
+ }
246
+ console.log(`Dry run: ${report.eligible} of ${report.nodes.length} agent(s) would be upgraded.`);
147
247
  return 0;
148
248
  }
149
- if (eligible.length === 0) {
249
+ try {
250
+ started = await service.startUpgradeRun({ version: release.version, integrity: release.integrity, nodeIds, canaryNodeId, allowPrerelease: options.allowPrerelease,
251
+ allowRetryReverted: options.retryReverted, actorId: 'system:magnus-server-cli', deps, ...(options.maxFailures ? { maxFailures: options.maxFailures } : {}) });
252
+ }
253
+ catch (error) {
254
+ if (error.code !== 'NO_ELIGIBLE_AGENTS')
255
+ throw error;
256
+ const candidates = await service.listUpgradeCandidates(release.version, nodeIds, { allowRetryReverted: options.retryReverted });
257
+ showCandidates(candidates);
150
258
  console.log('Nothing to upgrade.');
151
259
  return candidates.some(candidate => candidate.reason !== 'already on this version') ? 1 : 0;
152
260
  }
153
- started = await service.startUpgradeRun({ version: release.version, integrity: release.integrity, nodeIds: eligible.map(node => node.nodeId),
154
- canaryNodeId, allowPrerelease: options.allowPrerelease, allowRetryReverted: options.retryReverted, actorId: 'system:magnus-server-cli', deps });
261
+ showCandidates(started.candidates);
262
+ for (const candidate of started.candidates)
263
+ for (const healed of candidate.preflight?.healed ?? [])
264
+ console.log(` ${candidate.name}: repaired: ${healed}`);
265
+ for (const warning of started.run.warnings ?? [])
266
+ console.warn(` ${warning.name}: ${warning.code}: ${warning.detail}`);
155
267
  }
156
268
  let interrupts = 0;
157
269
  const onSigint = () => {
@@ -168,9 +280,17 @@ export async function commandAgents(args) {
168
280
  for (const node of run.excluded ?? [])
169
281
  if (options.nodes || options.retry)
170
282
  console.log(` ${node.name}: not upgraded (${node.reason})`);
171
- console.log(`${run.status === 'succeeded' ? 'Done' : run.status === 'stopped' ? 'Stopped' : 'Failed'}: ${run.message ?? run.status}`);
172
- console.log(`Run ${run.id}${run.nodes.every(node => node.state === 'confirmed') ? '' : `; retry the rest with: magnus-server agents upgrade --retry ${run.id}`}`);
173
- return run.status === 'succeeded' ? 0 : 1;
283
+ if (run.suspectedRelease)
284
+ console.log(`Suspected release problem: ${run.suspectedRelease}`);
285
+ if (run.attention?.length) {
286
+ console.log(`Needs attention (${run.attention.length}):`);
287
+ for (const item of run.attention)
288
+ for (const line of remedyLines(item))
289
+ console.log(line);
290
+ }
291
+ console.log(`${run.status === 'succeeded' ? 'Done' : run.status === 'partial' ? 'Partial' : run.status === 'stopped' ? 'Stopped' : 'Failed'}: ${run.message ?? run.status}`);
292
+ console.log(`Run ${run.id}${run.nodes.every(node => node.state === 'confirmed') && !run.attention?.length ? '' : `; retry the rest with: magnus-server agents upgrade --retry ${run.id}`}`);
293
+ return run.status === 'succeeded' ? 0 : run.status === 'partial' ? 2 : 1;
174
294
  }
175
295
  finally {
176
296
  process.off('SIGINT', onSigint);
package/dist/cli/args.js CHANGED
@@ -11,7 +11,7 @@ Commands:
11
11
  restore FILE Replace the database with a dump (stop the server first; asks unless --yes)
12
12
  upgrade Upgrade a \`magnus init\` project (runs \`magnus upgrade\`; see --help there)
13
13
  rollback Switch a \`magnus init\` project back to the previous version
14
- agents Agent fleet commands: \`agents upgrade [--to X.Y.Z] [--nodes a,b] [--dry-run]\`
14
+ agents Agent fleet commands: \`agents upgrade [--to X.Y.Z] [--nodes a,b] [--dry-run]\`, \`agents doctor [--nodes a,b]\`
15
15
  env-schema Print the documented environment variables
16
16
  help Show this help
17
17
  version Print the version
@@ -111,6 +111,7 @@ export const ENV_CATALOG = [
111
111
  { name: 'MAGNUS_UPDATE_REGISTRY', group: 'upgrades', default: 'https://registry.npmjs.org', description: 'npm registry used by the update check.' },
112
112
  { name: 'MAGNUS_AGENT_UPGRADES', group: 'upgrades', default: 'true', description: 'Allow server-driven agent upgrades (console action, API and `magnus-server agents upgrade`). Agents must also allow it (MAGNUS_AGENT_SELF_UPGRADE).' },
113
113
  { name: 'MAGNUS_AGENT_UPGRADE_CONCURRENCY', group: 'upgrades', default: '5', description: 'Agents upgraded in parallel after the first (canary) agent confirms. Maximum 50.' },
114
+ { name: 'MAGNUS_AGENT_UPGRADE_MAX_FAILURES', group: 'upgrades', default: '2', description: 'Failures pointing at the release (revert, failed install, trial that never confirms) that stop an agent upgrade run after its canary. Problems of one node never stop a run. A run can override it.' },
114
115
  { name: 'MAGNUS_LEGACY_GIT_SELF_UPDATE', group: 'upgrades', default: 'false', description: 'true: keep the retired git-checkout self-update endpoints (/api/v1/self-update/apply and /restart) for source installs. npm installs use `magnus upgrade`.' },
115
116
  { name: 'NODE_EXTRA_CA_CERTS', group: 'upgrades', description: 'Extra CA bundle (PEM) trusted when the server calls agents, for agent certificates from a private CA. Node reads it at process start: set it in the unit; `magnus-server agents` also honours it from --env-file.' },
116
117
  { name: 'MAGNUS_PROJECT_DIR', group: 'upgrades', description: 'Managed project created by `magnus init` (holds magnus-project.json). `magnus-server upgrade` finds it from the install path when unset.' },
@@ -24,6 +24,7 @@ export function envSwitch(value, fallback) {
24
24
  }
25
25
  export function upgradeSettings(env = process.env) {
26
26
  const concurrency = Number.parseInt(env['MAGNUS_AGENT_UPGRADE_CONCURRENCY'] ?? '', 10);
27
+ const maxFailures = Number.parseInt(env['MAGNUS_AGENT_UPGRADE_MAX_FAILURES'] ?? '', 10);
27
28
  return {
28
29
  autoMigrate: envSwitch(env['MAGNUS_AUTO_MIGRATE'], false),
29
30
  autoMigrateBackup: envSwitch(env['MAGNUS_AUTO_MIGRATE_BACKUP'], true),
@@ -32,6 +33,7 @@ export function upgradeSettings(env = process.env) {
32
33
  updateRegistry: (env['MAGNUS_UPDATE_REGISTRY']?.trim() || 'https://registry.npmjs.org').replace(/\/+$/, ''),
33
34
  agentUpgrades: envSwitch(env['MAGNUS_AGENT_UPGRADES'], true),
34
35
  agentUpgradeConcurrency: Number.isInteger(concurrency) && concurrency > 0 ? Math.min(concurrency, 50) : 5,
36
+ agentUpgradeMaxFailures: Number.isInteger(maxFailures) && maxFailures > 0 ? Math.min(maxFailures, 1000) : 2,
35
37
  legacyGitSelfUpdate: envSwitch(env['MAGNUS_LEGACY_GIT_SELF_UPDATE'], false),
36
38
  };
37
39
  }
package/dist/index.js CHANGED
@@ -24,6 +24,7 @@ const { startRolloutReconciler, stopRolloutReconciler } = await import('./servic
24
24
  const { startApplicationProbes, stopApplicationProbes } = await import('./services/application-probes.js');
25
25
  const { startNodeIssueMonitor } = await import('./services/node-issue-monitor.js');
26
26
  const { stopNodeIssueMonitor } = await import('./services/node-issue-monitor.js');
27
+ const { startUpgradeReadinessMonitor, stopUpgradeReadinessMonitor } = await import('./services/agent-upgrade-readiness.js');
27
28
  const { startLogRetention } = await import('./services/log-retention.js');
28
29
  const { stopLogRetention } = await import('./services/log-retention.js');
29
30
  const { startHealthRetention } = await import('./services/health-retention.js');
@@ -58,6 +59,7 @@ const { CONTROL_API_VERSION } = await import('./config/version.js');
58
59
  function startBackgroundServices(listenPort) {
59
60
  writeRuntimeFile({ version: CONTROL_API_VERSION, port: listenPort, host: listener.hostname });
60
61
  startNodeIssueMonitor();
62
+ startUpgradeReadinessMonitor();
61
63
  startApplicationProbes();
62
64
  startLogRetention();
63
65
  startHealthRetention();
@@ -130,6 +132,7 @@ async function shutdown(signal) {
130
132
  await Promise.race([(async () => {
131
133
  stopRolloutReconciler();
132
134
  stopNodeIssueMonitor();
135
+ stopUpgradeReadinessMonitor();
133
136
  stopApplicationProbes();
134
137
  stopLogRetention();
135
138
  stopHealthRetention();
@@ -655,7 +655,7 @@ const paths = {
655
655
  get: {
656
656
  tags: ['Nodes'],
657
657
  summary: 'List nodes',
658
- description: 'Returns all fleet nodes with agent info and health metadata. Supports pagination and status filtering.',
658
+ description: 'Returns all fleet nodes with agent info and health metadata. Supports pagination and status filtering. Each node has `upgradeReadiness`: `status` (ok, not-ready, or unknown for agents that do not report it) and `problems` with a code, a detail, a cause and a remedy, such as an enrollment stuck draining, a marker the server does not share, a fence ahead of the server, an unresolved ledger, low disk or missing npm.',
659
659
  security: apiSecretAuth,
660
660
  parameters: [
661
661
  qp('page', 'Page number (1-based)', { type: 'integer' }),
@@ -2156,7 +2156,7 @@ const paths = {
2156
2156
  get: {
2157
2157
  tags: ['Agent upgrades'],
2158
2158
  summary: 'Which agents can take a version',
2159
- description: 'For every node: its agent version and whether it can upgrade itself to `version` (default: the agent version bundled with this server) now, or why not: offline, not set up with `magnus-agent install-service`, MAGNUS_AGENT_SELF_UPGRADE=false, another major version, already current, already reverted this version, a closed release admission fence, a resource enrollment that is not complete (for example "stuck in draining since <date>"), held participant work or command leases, or an agent whose sticky managed-enrollment marker the server does not share. `release.verified` is false when the registry does not have the version.',
2159
+ description: 'For every node: its agent version and whether it can upgrade itself to `version` (default: the agent version bundled with this server) now, or why not: offline, not set up with `magnus-agent install-service`, MAGNUS_AGENT_SELF_UPGRADE=false, another major version, already current, already reverted this version, a closed release admission fence, a resource enrollment that is not complete (for example "stuck in draining since <date>"), held participant work or command leases, or an agent whose sticky managed-enrollment marker the server does not share. Each blocked node has a `code` from the upgrade problem catalogue. `preflight` is the latest result of the agent check for this version, when one is recent, and only for ADMIN. `release.verified` is false when the registry does not have the version.',
2160
2160
  security: apiSecretAuth,
2161
2161
  parameters: [qp('version', 'Target agent version (X.Y.Z)'), qp('allowRetryReverted', '`true` to offer nodes that already reverted this version')],
2162
2162
  responses: { 200: success('Eligibility', { type: 'object', properties: {
@@ -2164,7 +2164,7 @@ const paths = {
2164
2164
  prerelease: { type: 'boolean' },
2165
2165
  release: { type: 'object', properties: { verified: { type: 'boolean' }, integrity: { type: ['string', 'null'] }, reason: { type: ['string', 'null'] } } },
2166
2166
  nodes: { type: 'array', items: { type: 'object', properties: { nodeId: { type: 'string' }, name: { type: 'string' },
2167
- version: { type: 'string' }, eligible: { type: 'boolean' }, reason: { type: ['string', 'null'] } } } },
2167
+ version: { type: 'string' }, eligible: { type: 'boolean' }, reason: { type: ['string', 'null'] }, code: { type: ['string', 'null'] }, preflight: { type: ['object', 'null'], description: 'status: ok, warning, blocked, unavailable (older agent) or unreachable; problems: code, detail, blocking' } } } },
2168
2168
  latestRun: { type: ['object', 'null'] },
2169
2169
  } }), 400: err400, 401: err401 },
2170
2170
  },
@@ -2173,7 +2173,7 @@ const paths = {
2173
2173
  post: {
2174
2174
  tags: ['Agent upgrades'],
2175
2175
  summary: 'Upgrade agents',
2176
- description: 'Starts an upgrade run (ADMIN, MAGNUS_AGENT_UPGRADES=true): one canary, then MAGNUS_AGENT_UPGRADE_CONCURRENCY agents at a time. Each agent installs the exact version and npm integrity and switches back by itself if the new version does not report a heartbeat in time. A revert, a release problem (integrity, install failure) or an agent that goes silent after the request stops the run. An agent that refuses before changing anything is recorded as `refused` and the run goes on; a refused canary hands the role to the next node. Only one run at a time. Selected nodes that are not eligible are listed in `excluded`; unknown node ids are a 400.',
2176
+ description: 'Starts an upgrade run (ADMIN, MAGNUS_AGENT_UPGRADES=true): every selected node is checked first, then one canary, then MAGNUS_AGENT_UPGRADE_CONCURRENCY agents at a time. Each agent installs the exact version and npm integrity and switches back by itself if the new version does not report a heartbeat in time. With `dryRun` it returns 200 with the full per-node report and starts nothing. Only evidence about the release stops a run: an integrity or source failure at once, a release failure on the canary, `maxFailures` ambiguous canaries with nothing confirmed (at least two, reported as suspect but unproven in `suspectedRelease`; nodes that cannot report their fence go last and do not count while another may still confirm), and after the canary `maxFailures` reverts, failed installs or trials that never confirm. A problem of one node is recorded and the run goes on: a refused canary hands the role to the next node, transient refusals are tried again at the end of the wave, and a node whose reply was lost is watched by request id (`watching`, then `ambiguous`) without holding a batch slot past its window. The final status is `succeeded`, `partial` (node-level problems, nothing against the release), `failed` (release evidence), `stopped` or `interrupted`, and `attention` lists each node with a code, a cause and a remedy; a run is `succeeded` only if every selected node is confirmed or has an informational reason. `warnings` lists what the check found that does not stop a node. Only one run at a time. Selected nodes that are not eligible are listed in `excluded`; unknown node ids are a 400.',
2177
2177
  security: apiSecretAuth,
2178
2178
  requestBody: { required: false, content: { 'application/json': { schema: { type: 'object', properties: {
2179
2179
  version: { type: 'string', description: 'Agent version (default: the one bundled with this server)' },
@@ -2182,8 +2182,10 @@ const paths = {
2182
2182
  canaryNodeId: { type: 'string', description: 'Upgrade this node first' },
2183
2183
  allowPrerelease: { type: 'boolean', description: 'Required for a prerelease version' },
2184
2184
  allowRetryReverted: { type: 'boolean', description: 'Also upgrade nodes that already reverted this version' },
2185
+ dryRun: { type: 'boolean', description: 'Report on every selected node and start nothing' },
2186
+ maxFailures: { type: 'integer', minimum: 1, maximum: 1000, description: 'Release failures that stop this run (default MAGNUS_AGENT_UPGRADE_MAX_FAILURES)' },
2185
2187
  } } } } },
2186
- responses: { 202: success('Run started'), 400: err400, 401: err401, 403: err403, 409: err409 },
2188
+ responses: { 200: success('Dry run report'), 202: success('Run started'), 400: err400, 401: err401, 403: err403, 409: err409 },
2187
2189
  },
2188
2190
  },
2189
2191
  '/agents/upgrade/runs/latest': {
@@ -2218,11 +2220,11 @@ const paths = {
2218
2220
  post: {
2219
2221
  tags: ['Agent upgrades'],
2220
2222
  summary: 'Retry an upgrade run',
2221
- description: 'ADMIN. Starts a new run with the same version and integrity for the failed, refused, skipped and interrupted nodes of a finished run (reverted nodes only with allowRetryReverted).',
2223
+ description: 'ADMIN. Starts a new run with the same version and integrity for the nodes of a finished run that were not upgraded: failed, refused, skipped, ambiguous and interrupted ones, and those the check held back (reverted nodes only with allowRetryReverted).',
2222
2224
  security: apiSecretAuth,
2223
2225
  parameters: [pp('id', 'Run ID')],
2224
2226
  requestBody: { required: false, content: { 'application/json': { schema: { type: 'object', properties: {
2225
- canaryNodeId: { type: 'string' }, allowRetryReverted: { type: 'boolean' },
2227
+ canaryNodeId: { type: 'string' }, allowRetryReverted: { type: 'boolean' }, maxFailures: { type: 'integer', minimum: 1, maximum: 1000 }, acknowledgeSuspicion: { type: 'boolean', description: 'After a run that stopped on ambiguous answers from older agents, include older agents again; without this they are held back with RETRY_HELD_SUSPECTED' },
2226
2228
  } } } } },
2227
2229
  responses: { 202: success('Run started'), 400: err400, 401: err401, 403: err403, 404: err404, 409: err409 },
2228
2230
  },
@@ -4,12 +4,13 @@ import { authMiddleware, getUser, requireRole } from '../middleware/auth.js';
4
4
  import { HttpError } from '../middleware/error-handler.js';
5
5
  import { upgradeSettings } from '../config/features.js';
6
6
  import { AGENT_VERSION } from '../config/version.js';
7
- import { getUpgradeRun, isPrerelease, latestUpgradeRun, listUpgradeCandidates, retryUpgradeRun, startUpgradeRun, stopUpgradeRun, verifyAgentRelease } from '../services/agent-upgrade-service.js';
7
+ import { checkUpgrade, getUpgradeRun, isPrerelease, latestUpgradeRun, listUpgradeCandidates, retryUpgradeRun, startUpgradeRun, stopUpgradeRun, verifyAgentRelease, withCachedPreflight } from '../services/agent-upgrade-service.js';
8
8
  /** /api/v1/agents/upgrade: which agents can take a version, and fleet upgrade runs. */
9
9
  export const agentUpgradesRouter = new Hono();
10
10
  agentUpgradesRouter.use('*', authMiddleware);
11
11
  const version = z.string().regex(/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/);
12
12
  const nodeId = z.string().min(1).max(128);
13
+ const maxFailures = z.number().int().min(1).max(1000);
13
14
  agentUpgradesRouter.get('/eligibility', async (c) => {
14
15
  const target = version.safeParse(c.req.query('version') ?? AGENT_VERSION);
15
16
  if (!target.success)
@@ -21,7 +22,9 @@ agentUpgradesRouter.get('/eligibility', async (c) => {
21
22
  return c.json({ success: true, data: {
22
23
  version: target.data, serverVersion: AGENT_VERSION, upgradesEnabled: upgradeSettings().agentUpgrades,
23
24
  disabledReason: upgradeSettings().agentUpgrades ? null : 'MAGNUS_AGENT_UPGRADES=false on the server',
24
- prerelease: isPrerelease(target.data), release, nodes, latestRun,
25
+ prerelease: isPrerelease(target.data), release,
26
+ // Reports hold paths, fences and command ids: only the role that can run upgrades sees them.
27
+ nodes: getUser(c).role === 'ADMIN' ? withCachedPreflight(nodes, target.data) : nodes.map(({ preflight: _preflight, ...node }) => node), latestRun,
25
28
  } });
26
29
  });
27
30
  const startSchema = z.object({
@@ -31,6 +34,9 @@ const startSchema = z.object({
31
34
  canaryNodeId: nodeId.optional(),
32
35
  allowPrerelease: z.boolean().optional(),
33
36
  allowRetryReverted: z.boolean().optional(),
37
+ /** Report on every selected node and start nothing: the console's Check action. */
38
+ dryRun: z.boolean().optional(),
39
+ maxFailures: maxFailures.optional(),
34
40
  }).strict();
35
41
  const parseBody = async (schema, raw) => {
36
42
  const parsed = schema.safeParse(raw);
@@ -40,7 +46,10 @@ const parseBody = async (schema, raw) => {
40
46
  };
41
47
  agentUpgradesRouter.post('/', requireRole('ADMIN'), async (c) => {
42
48
  const body = await parseBody(startSchema, await c.req.json().catch(() => ({})));
43
- const { run } = await startUpgradeRun({ ...body, actorId: getUser(c).userId });
49
+ const { dryRun, ...options } = body;
50
+ if (dryRun)
51
+ return c.json({ success: true, data: { dryRun: true, ...await checkUpgrade(options) } });
52
+ const { run } = await startUpgradeRun({ ...options, actorId: getUser(c).userId });
44
53
  return c.json({ success: true, data: run }, 202);
45
54
  });
46
55
  agentUpgradesRouter.get('/runs/latest', async (c) => c.json({ success: true, data: await latestUpgradeRun() }));
@@ -54,6 +63,8 @@ agentUpgradesRouter.post('/runs/:id/stop', requireRole('ADMIN'), async (c) => c.
54
63
  const retrySchema = z.object({
55
64
  canaryNodeId: nodeId.optional(),
56
65
  allowRetryReverted: z.boolean().optional(),
66
+ maxFailures: maxFailures.optional(),
67
+ acknowledgeSuspicion: z.boolean().optional(),
57
68
  }).strict();
58
69
  agentUpgradesRouter.post('/runs/:id/retry', requireRole('ADMIN'), async (c) => {
59
70
  const body = await parseBody(retrySchema, await c.req.json().catch(() => ({})));
@@ -17,6 +17,7 @@ import { getNodeBaseUrl } from '../utils/node-url.js';
17
17
  import { getNodeManagerSecret } from '../utils/env.js';
18
18
  import { ensureControlHostNode } from '../services/control-host-node.js';
19
19
  import { withReadRetry } from '../db/read-retry.js';
20
+ import { readinessOfNodes } from '../services/agent-upgrade-readiness.js';
20
21
  import { parseTailscaleNodeKeyPosture, summarizeTailscaleNodeKeys, TAILSCALE_RENEWAL_WARNING_DAYS, } from '../services/tailscale-node-key-service.js';
21
22
  function parseSystemUpdatePosture(value) {
22
23
  try {
@@ -116,9 +117,10 @@ nodesRouter.get('/', async (c) => {
116
117
  ]));
117
118
  // Strip token from API response — never expose node secrets to the frontend
118
119
  const safeNodes = nodes.map(({ token: _token, ...rest }) => rest);
120
+ const readiness = await readinessOfNodes(safeNodes);
119
121
  return c.json({
120
122
  success: true,
121
- data: safeNodes,
123
+ data: safeNodes.map(node => ({ ...node, upgradeReadiness: readiness.get(node.id) ?? null })),
122
124
  meta: { total, page, pageSize, hasMore: page * pageSize < total },
123
125
  });
124
126
  });
@@ -494,7 +496,8 @@ nodesRouter.get('/:nodeId', async (c) => {
494
496
  if (!node)
495
497
  throw new HttpError(404, 'NOT_FOUND', 'Node not found');
496
498
  const { token: _token, ...safeNode } = node;
497
- return c.json({ success: true, data: safeNode });
499
+ const readiness = await readinessOfNodes([safeNode]);
500
+ return c.json({ success: true, data: { ...safeNode, upgradeReadiness: readiness.get(safeNode.id) ?? null } });
498
501
  });
499
502
  // ── Register a new node ──
500
503
  nodesRouter.post('/register', requireRole('ADMIN', 'OPERATOR'), async (c) => {
@@ -5,11 +5,13 @@ import { COMMAND_COMPLETION_REGISTRY, COMMAND_COMPLETION_REGISTRY_SHA256, comman
5
5
  import { commandReconciliationSchema } from '../schemas/command-reconciliation.js';
6
6
  import { admitResourceCommand, markResourceDispatched, failResourceCommand, settleResourceResult, settleResourceReconciliation } from '../services/command-resource-leases.js';
7
7
  import { commandFenceSchema, commandResultSchema } from '../schemas/node-responses.js';
8
+ import { advanceCommandFence } from '../services/command-fence-repair.js';
8
9
  import { commandRequestDigest } from '../security/command-request.js';
9
10
  import ky, { HTTPError } from 'ky';
10
11
  import { HttpError } from '../middleware/error-handler.js';
11
12
  import { createHmac, timingSafeEqual } from 'node:crypto';
12
13
  import { z } from 'zod';
14
+ import { agentUpgradePreflightSchema } from '@hivemagnus/magnus-contracts';
13
15
  import { RESULT_SIGNING_PREVIOUS_SECRET, RESULT_SIGNING_SECRET } from '../utils/env.js';
14
16
  import { correlateSkillPublicationNoEffect } from '../services/skill-publication-settlement.js';
15
17
  import { commandResultSigningPayload, verifyCommandResultSignature, } from '../security/command-result-signature.js';
@@ -125,10 +127,52 @@ export function isConnectError(error) {
125
127
  function errorText(error) {
126
128
  return (error instanceof Error ? error.message : String(error)).slice(0, 1000);
127
129
  }
130
+ const certainRefusalOf = (error) => error?.certainRefusal;
131
+ /** The signed refusal in an agent's error reply. An older agent, or a reply without one, says nothing about execution. */
132
+ export function parseAgentRefusal(error) {
133
+ if (!(error instanceof HTTPError))
134
+ return null;
135
+ const body = (error.data ?? null);
136
+ const refusal = body?.error?.refusal;
137
+ if (body?.error?.executed !== false || !refusal || typeof refusal.commandId !== 'string' || typeof refusal.requestSha256 !== 'string'
138
+ || typeof refusal.code !== 'string' || typeof refusal.signature !== 'string' || !(refusal.highWater === null || typeof refusal.highWater === 'string')
139
+ || typeof refusal.nodeFqdn !== 'string' || typeof refusal.leaseId !== 'string')
140
+ return null;
141
+ return { commandId: refusal.commandId, requestSha256: refusal.requestSha256, code: refusal.code, highWater: refusal.highWater, nodeFqdn: refusal.nodeFqdn, leaseId: refusal.leaseId, signature: refusal.signature,
142
+ message: typeof body.error.message === 'string' ? body.error.message.slice(0, 1000) : refusal.code };
143
+ }
144
+ /**
145
+ * Close the lease of a dispatch that threw. A verified refusal settles it as
146
+ * not-dispatched, and a STALE_FENCE moves the server's fence past the agent's
147
+ * high-water so the next request can pass. Everything else stays `unknown`.
148
+ */
149
+ async function settleFailedDispatch(admission, actorId, error) {
150
+ const refusal = parseAgentRefusal(error);
151
+ const settled = await failResourceCommand(admission, refusal ?? undefined);
152
+ if (!refusal || !settled)
153
+ return;
154
+ let code = refusal.code;
155
+ let { message } = refusal;
156
+ const resourceKey = admission.payload.coordination?.resourceKey;
157
+ if (code === 'STALE_FENCE' && refusal.highWater && resourceKey) {
158
+ try {
159
+ const advanced = await advanceCommandFence({ nodeId: admission.nodeId, resourceKey, highWater: refusal.highWater, source: 'stale-fence-reply', actorId });
160
+ if (advanced.status === 'rejected')
161
+ ({ code, detail: message } = advanced);
162
+ }
163
+ catch (repairError) {
164
+ console.warn(`[resource-leases] fence of ${admission.nodeId} could not be advanced: ${repairError instanceof Error ? repairError.message : String(repairError)}`);
165
+ }
166
+ }
167
+ Object.assign(error, { certainRefusal: { code, message } });
168
+ }
128
169
  /** Sort an upgradeAgent failure by whether the request can have reached the agent. */
129
170
  export function classifyAgentRequestError(error, dispatched) {
171
+ const refused = certainRefusalOf(error);
172
+ if (refused)
173
+ return new NotDispatchedError(refused.code, refused.message, error);
130
174
  if (!dispatched)
131
- return error instanceof NotDispatchedError ? error : new NotDispatchedError(error instanceof HttpError ? error.code : error instanceof AgentResponseError && error.code ? error.code : 'NOT_DISPATCHED', errorText(error), error);
175
+ return error instanceof NotDispatchedError ? error : new NotDispatchedError(error instanceof HttpError ? error.code : error instanceof AgentResponseError && error.code ? error.code : isConnectError(error) ? 'AGENT_UNREACHABLE' : 'NOT_DISPATCHED', errorText(error), error);
132
176
  if (error instanceof AgentResponseError)
133
177
  return error;
134
178
  if (error instanceof HTTPError)
@@ -225,7 +269,7 @@ export class NodeApiClient {
225
269
  acknowledgedAt: new Date() } };
226
270
  }
227
271
  catch (error) {
228
- await failResourceCommand(admission);
272
+ await settleFailedDispatch(admission, this.resourceContext.actorId, error);
229
273
  throw error;
230
274
  }
231
275
  }
@@ -324,7 +368,7 @@ export class NodeApiClient {
324
368
  }
325
369
  catch (error) {
326
370
  if (admission)
327
- await failResourceCommand(admission);
371
+ await settleFailedDispatch(admission, this.resourceContext.actorId, error);
328
372
  throw error;
329
373
  }
330
374
  }
@@ -506,6 +550,41 @@ export class NodeApiClient {
506
550
  throw classifyAgentRequestError(error, dispatched);
507
551
  }
508
552
  }
553
+ /**
554
+ * The agent's read-only upgrade report: its GET needs no lease or claim.
555
+ * `unavailable` is an agent older than 1.1.8 (404) or a report that does not
556
+ * parse; neither says anything against the node.
557
+ */
558
+ async getUpgradePreflight(query, timeoutMs = 5_000) {
559
+ let body;
560
+ try {
561
+ body = await this.api.get('agent-upgrade/preflight', { timeout: timeoutMs, retry: 0,
562
+ searchParams: { ...(query.target ? { target: query.target } : {}), ...(query.engines ? { engines: query.engines } : {}), ...(query.nonce ? { nonce: query.nonce } : {}) } }).json();
563
+ }
564
+ catch (error) {
565
+ if (error instanceof HTTPError && error.response.status === 404)
566
+ return { status: 'unavailable', detail: 'this agent has no preflight check' };
567
+ if (error instanceof SyntaxError)
568
+ return { status: 'unavailable', detail: 'the preflight answer is not JSON' };
569
+ return { status: 'unreachable', detail: errorText(error) };
570
+ }
571
+ const parsed = agentUpgradePreflightSchema.safeParse(body?.data);
572
+ return parsed.success ? { status: 'ok', report: parsed.data } : { status: 'unavailable', detail: 'the preflight answer does not parse' };
573
+ }
574
+ /** Signed account of what the agent's ledger holds for a command; null from an agent without this route. */
575
+ async getCommandTrace(commandId, nonce, resourceKey, timeoutMs = 5_000) {
576
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(commandId))
577
+ throw new Error('Invalid command identifier');
578
+ try {
579
+ const response = await this.api.get(`commands/receipts/${encodeURIComponent(commandId)}/trace`, { timeout: timeoutMs, retry: 0, searchParams: { nonce, resourceKey } }).json();
580
+ return response.success ? response.data : null;
581
+ }
582
+ catch (error) {
583
+ if (error instanceof HTTPError && error.response.status === 404)
584
+ return null;
585
+ throw error;
586
+ }
587
+ }
509
588
  // ── Ping ──
510
589
  async ping() {
511
590
  return this.api.get('ping').json();