agent-relay 12.2.7 → 12.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { HarnessDriverClient } from '@agent-relay/harness-driver';
5
+ import { getBrokerBinaryPath } from '@agent-relay/harness-driver/broker-path';
5
6
  import { startServeNode } from '@agent-relay/fleet';
6
7
  import { createLogger } from '@agent-relay/utils';
7
8
  import { redactCredentialValues } from '@agent-relay/cloud/redact';
@@ -14,6 +15,7 @@ import { createTriggerSyncClient, resolveNodeCapacityHarnesses } from './fleet-s
14
15
  import { discoverNodeConfigPath, discoverPythonNodeConfigPath, loadNodeDefinition, } from './node-definition-loader.js';
15
16
  import { describeNodeDefinitionViaNode, descriptorCapacitySource, startNodeJsNodeProvider, } from './node-provider-child.js';
16
17
  import { describeError } from './describe-error.js';
18
+ import { acquireNodeClaim, adoptNodeClaim, closeNodeClaimHold, describeNodeClaimHolder, enrolledNodeIdForClaim, findLiveStateDirBroker, inspectNodeClaimHold, listHeldNodeClaims, normalizeClaimStateDir, openNodeClaimHold, recordSpawnedBrokerChild, releaseNodeClaim, releaseNodeClaimsForBroker, } from './node-claim.js';
17
19
  import { maskSecret } from './redact.js';
18
20
  import { startReflexCapture } from './reflex-capture.js';
19
21
  import { readProjectWorkspaceSession, resolveWorkspaceSelection, writeProjectWorkspaceKeyPreservingSession, } from './project-workspace-key.js';
@@ -387,10 +389,25 @@ export async function startBrokerWithPortFallback(paths, basePort, deps, brokerN
387
389
  * function returns -- a signal arriving during the status check would
388
390
  * otherwise find no handle to shut down and leak the broker child.
389
391
  */
390
- onCandidateReady) {
392
+ onCandidateReady,
393
+ /**
394
+ * Descriptors every spawn attempt must hand the broker child. `node up`
395
+ * passes its node claim's hold descriptor, which the child inherits across
396
+ * `fork` — so the claim reads as held from the instant a broker exists,
397
+ * rather than from the moment one of them manages to write something down.
398
+ */
399
+ inheritFds = [],
400
+ /**
401
+ * Invoked with the broker child's pid in the same turn `spawn()` returns it
402
+ * — before the handshake, and before a launcher script can `exec` into the
403
+ * real broker. `node up` writes it onto the node claim, so the process the
404
+ * inherited descriptor fences stays identifiable whatever executable it ends
405
+ * up running.
406
+ */
407
+ onBrokerSpawn) {
391
408
  if (basePort === 0) {
392
409
  vlog(deps, verbose, 'Asking the OS to assign the broker API port...');
393
- const candidate = await deps.createRelay(paths.projectRoot, 0, brokerName, verbose);
410
+ const candidate = await deps.createRelay(paths.projectRoot, 0, brokerName, verbose, inheritFds, onBrokerSpawn);
394
411
  onCandidateReady?.(candidate);
395
412
  try {
396
413
  await getBrokerStatusWithRetry(candidate, deps, verbose);
@@ -418,7 +435,7 @@ onCandidateReady) {
418
435
  const apiPort = await resolveApiPortWithFallback(startApiPort, MAX_API_PORT_ATTEMPTS, deps);
419
436
  vlog(deps, verbose, `API port resolved: ${apiPort}`);
420
437
  vlog(deps, verbose, 'Creating broker client (spawns broker process, waits for handshake)...');
421
- const candidate = await deps.createRelay(paths.projectRoot, apiPort, brokerName, verbose);
438
+ const candidate = await deps.createRelay(paths.projectRoot, apiPort, brokerName, verbose, inheritFds, onBrokerSpawn);
422
439
  onCandidateReady?.(candidate);
423
440
  vlog(deps, verbose, 'Broker client created. Checking broker status...');
424
441
  try {
@@ -1229,6 +1246,193 @@ function resolveBrokerName(options, deps, projectRoot) {
1229
1246
  path.basename(projectRoot) ||
1230
1247
  'project');
1231
1248
  }
1249
+ /**
1250
+ * Take machine-local ownership of the enrolled node id this start will register
1251
+ * as, if any — BEFORE anything that can register is spawned.
1252
+ *
1253
+ * The broker queues `node.register` from its own initialization
1254
+ * (`crates/broker/src/runtime/init.rs`), so by the time the CLI can verify the
1255
+ * child and write a claim the engine may already have moved the node's delivery
1256
+ * socket. Claiming after the spawn therefore could not deliver the guarantee it
1257
+ * was written for: a loser that is correctly refused had already evicted the
1258
+ * incumbent. The reservation names this supervising CLI, is exclusive against
1259
+ * every other start on the machine, and is handed to the broker by
1260
+ * {@link adoptEnrolledNodeClaim} once a verified process owns the state dir.
1261
+ *
1262
+ * `node up` also refuses a conflicting node id in its own preflight so the
1263
+ * operator gets remedies instead of a startup failure; this is what guards the
1264
+ * plain `up` / `local up` aliases, which have no preflight, and what serializes
1265
+ * two starts that raced past one.
1266
+ *
1267
+ * @throws NodeClaimConflictError when a live local broker holds the node id.
1268
+ * @throws NodeClaimContentionError when exclusion could not be established.
1269
+ */
1270
+ /**
1271
+ * The executable this start will run as its broker.
1272
+ *
1273
+ * Recorded in the claim so a later start recognises that process by executable
1274
+ * identity rather than by its filename: `AGENT_RELAY_BIN` /
1275
+ * `BROKER_BINARY_PATH` let a supported deployment run the broker under any
1276
+ * name, and such a broker orphaned by a dead supervisor used to read as an
1277
+ * unrelated process — the "node id free" verdict that evicts a live broker.
1278
+ *
1279
+ * Resolution mirrors `getBrokerBinaryPath`, which reads the override from the
1280
+ * real environment; `deps.env` is consulted first so a start whose environment
1281
+ * was overridden records the binary it is actually going to spawn.
1282
+ */
1283
+ function resolveBrokerBinary(deps) {
1284
+ const override = (deps.env.BROKER_BINARY_PATH ?? deps.env.AGENT_RELAY_BIN)?.trim();
1285
+ if (override) {
1286
+ const resolved = path.resolve(override);
1287
+ if (fs.existsSync(resolved))
1288
+ return resolved;
1289
+ }
1290
+ try {
1291
+ return getBrokerBinaryPath() ?? undefined;
1292
+ }
1293
+ catch {
1294
+ return undefined;
1295
+ }
1296
+ }
1297
+ async function reserveEnrolledNode(paths, options, deps, localOnly) {
1298
+ const nodeId = localOnly ? undefined : enrolledNodeIdForClaim(deps.env);
1299
+ if (!nodeId) {
1300
+ return undefined;
1301
+ }
1302
+ const brokerBinary = resolveBrokerBinary(deps);
1303
+ return acquireNodeClaim({
1304
+ nodeId,
1305
+ pid: deps.pid,
1306
+ stateDir: paths.dataDir,
1307
+ ...(brokerBinary ? { brokerBinary } : {}),
1308
+ status: 'reserved',
1309
+ force: options.force === true,
1310
+ env: deps.env,
1311
+ killProcess: deps.killProcess,
1312
+ execCommand: deps.execCommand,
1313
+ });
1314
+ }
1315
+ /**
1316
+ * Move the reservation onto the verified broker process.
1317
+ *
1318
+ * Conditional on the reservation still being the claim on disk: a `--force`
1319
+ * takeover that landed while this broker was starting has already won the node
1320
+ * id, and overwriting it would put two brokers back on one delivery socket.
1321
+ * Losing here fails startup, which tears this broker down.
1322
+ */
1323
+ async function adoptEnrolledNodeClaim(reservation, broker, deps) {
1324
+ return adoptNodeClaim({
1325
+ reservation,
1326
+ pid: broker.pid,
1327
+ ...(broker.apiPort !== undefined ? { apiPort: broker.apiPort } : {}),
1328
+ brokerName: broker.brokerName,
1329
+ env: deps.env,
1330
+ killProcess: deps.killProcess,
1331
+ execCommand: deps.execCommand,
1332
+ });
1333
+ }
1334
+ /** Polls before giving up on observing a shut-down broker actually exit. */
1335
+ const NODE_CLAIM_EXIT_POLL_ATTEMPTS = 50;
1336
+ const NODE_CLAIM_EXIT_POLL_MS = 100;
1337
+ /**
1338
+ * Wait for a claimed pid to disappear.
1339
+ *
1340
+ * Bounded by attempts rather than a deadline read from `deps.now()`: a frozen
1341
+ * clock must not turn this into an unbounded loop.
1342
+ */
1343
+ async function waitForClaimedProcessExit(pid, deps) {
1344
+ for (let attempt = 0; attempt < NODE_CLAIM_EXIT_POLL_ATTEMPTS; attempt += 1) {
1345
+ if (!isProcessRunning(pid, deps)) {
1346
+ return true;
1347
+ }
1348
+ await deps.sleep(NODE_CLAIM_EXIT_POLL_MS);
1349
+ }
1350
+ return !isProcessRunning(pid, deps);
1351
+ }
1352
+ /**
1353
+ * Polls before giving up on a fenced child that has not dropped the claim's
1354
+ * hold descriptor. Shorter than the pid wait above: every attempt runs `lsof`,
1355
+ * and a holder this start never captured is either exiting right now or is not
1356
+ * going to.
1357
+ */
1358
+ const NODE_CLAIM_HOLD_POLL_ATTEMPTS = 10;
1359
+ /**
1360
+ * Wait for every holder of the claim's hold descriptor other than this
1361
+ * supervisor to exit.
1362
+ *
1363
+ * This is the release-side half of the spawn-to-publication fence. `claim.pid`,
1364
+ * `supervisor_pid` and `spawnedPids` only ever name a child some part of this
1365
+ * start managed to capture, and `onCandidateReady` cannot fire until
1366
+ * `createRelay` RESOLVES: a spawn that rejects (a handshake that never
1367
+ * completed, a startup SIGTERM the child outlived) leaves a broker child that no
1368
+ * pid anywhere names and no `connection.json` either. Releasing then would
1369
+ * tombstone the claim and unlink the hold file out from under a live process
1370
+ * that is still fenced by it, and the next start would read the node id as free
1371
+ * while that child can still register — the eviction this lane exists to
1372
+ * prevent.
1373
+ *
1374
+ * This process's own descriptor is excluded: it is dropped only after the
1375
+ * release decision, and it is never evidence that somebody else owns the node.
1376
+ */
1377
+ async function waitForClaimHoldRelease(claim, deps) {
1378
+ const claimDeps = { env: deps.env, killProcess: deps.killProcess, execCommand: deps.execCommand };
1379
+ // `deps.pid` is the supervisor of record; `process.pid` is who actually holds
1380
+ // the descriptor. They are the same process in production and can differ in
1381
+ // tests, so neither may read as a rival holder.
1382
+ const selfPids = new Set([deps.pid, process.pid]);
1383
+ let status = await inspectNodeClaimHold(claim, claimDeps, selfPids);
1384
+ for (let attempt = 0; status.held && attempt < NODE_CLAIM_HOLD_POLL_ATTEMPTS; attempt += 1) {
1385
+ await deps.sleep(NODE_CLAIM_EXIT_POLL_MS);
1386
+ status = await inspectNodeClaimHold(claim, claimDeps, selfPids);
1387
+ }
1388
+ return status;
1389
+ }
1390
+ /**
1391
+ * Release the node claim only once every process it protects is provably gone.
1392
+ *
1393
+ * "shutdown returned" is not evidence of exit: `shutdownUpResources` swallows
1394
+ * shutdown errors, and the SDK's `waitForExit` gives up after its timeout.
1395
+ * Releasing on that signal alone opened the node id while the old broker still
1396
+ * held its node-control socket — the eviction this claim exists to prevent.
1397
+ * Keeping a claim costs nothing: once the pids really die it reads as stale and
1398
+ * the next start takes it over, and `node down` releases it by verified pid.
1399
+ *
1400
+ * Before adoption the claim names only this supervising CLI, so its pids alone
1401
+ * do not cover the broker this start spawned. `spawnedPids` carries every child
1402
+ * pid this start ever saw — kept across the failure paths that clear `relay` —
1403
+ * the claim's hold descriptor answers for a child that was never captured at
1404
+ * all, and the state dir's own connection file is consulted last, for a broker
1405
+ * that published but was never fenced by this start.
1406
+ *
1407
+ * @returns Whether the claim was released.
1408
+ */
1409
+ async function releaseNodeClaimAfterExit(claim, deps, spawnedPids = []) {
1410
+ const protectedPids = [...new Set([claim.pid, claim.supervisor_pid, ...spawnedPids])].filter((pid) => typeof pid === 'number' && pid > 0 && pid !== deps.pid);
1411
+ for (const pid of protectedPids) {
1412
+ if (!(await waitForClaimedProcessExit(pid, deps))) {
1413
+ deps.warn(`Broker pid ${pid} is still running after shutdown; keeping this machine's claim on node ${claim.node_id}. ` +
1414
+ `Stop it with: agent-relay node down --state-dir ${claim.state_dir} --force`);
1415
+ return false;
1416
+ }
1417
+ }
1418
+ // Checked before the connection file, exactly as `inspectNodeClaim` orders
1419
+ // them: the descriptor is the only evidence that covers a child's whole life,
1420
+ // including the window before it has published anything at all.
1421
+ const fence = await waitForClaimHoldRelease(claim, deps);
1422
+ if (fence.held) {
1423
+ deps.warn(`${fence.reason}; keeping this machine's claim on node ${claim.node_id}. ` +
1424
+ `Stop it with: agent-relay node down --state-dir ${claim.state_dir} --force`);
1425
+ return false;
1426
+ }
1427
+ const occupant = await findLiveStateDirBroker(claim.state_dir, { env: deps.env, killProcess: deps.killProcess, execCommand: deps.execCommand }, { binary: claim.broker_binary, object: claim.broker_executable });
1428
+ if (occupant) {
1429
+ deps.warn(`A broker (pid ${occupant.pid}) is still serving ${claim.state_dir}; keeping this machine's claim on node ${claim.node_id}. ` +
1430
+ `Stop it with: agent-relay node down --state-dir ${claim.state_dir} --force`);
1431
+ return false;
1432
+ }
1433
+ await releaseNodeClaim(claim, deps.env);
1434
+ return true;
1435
+ }
1232
1436
  export async function runUpCommand(options, deps) {
1233
1437
  if (!['darwin', 'linux'].includes(process.platform)) {
1234
1438
  deps.error(`Broker lifecycle identity verification is supported only on macOS and Linux; refusing to start on ${process.platform}.`);
@@ -1437,6 +1641,33 @@ export async function runUpCommand(options, deps) {
1437
1641
  let shutdownPromise;
1438
1642
  let stopWatchingBrokerExit;
1439
1643
  let managedIdentity;
1644
+ /** Machine-global claim on this broker's enrolled node id, once registered. */
1645
+ let nodeClaim;
1646
+ /**
1647
+ * Descriptor on the claim's hold file, opened before the broker is spawned
1648
+ * and inherited by it.
1649
+ *
1650
+ * This is the ownership fence that does not depend on either process living
1651
+ * long enough to write anything down. A supervisor SIGKILLed between the
1652
+ * spawn and the broker's first write used to leave a reservation whose pids
1653
+ * were all dead and a state dir with no `connection.json`: a competing start
1654
+ * read that as stale, began a second broker, and the orphaned child then
1655
+ * registered against the same node id with nobody left to adopt or stop it.
1656
+ * The child inherits this descriptor across `fork`, so from the instant it
1657
+ * exists the kernel answers for it, and it stops answering the instant it
1658
+ * dies.
1659
+ */
1660
+ let nodeClaimHoldFd;
1661
+ /**
1662
+ * Every broker pid this start spawned, remembered independently of `relay`.
1663
+ *
1664
+ * The startup failure paths null `relay` out (to avoid a double `shutdown()`)
1665
+ * while the child can still be alive — a `shutdown()` that rejected, a status
1666
+ * check that failed with cleanup failing too. Releasing the node claim then
1667
+ * would open the node id for a broker that is still registered, so the pids
1668
+ * stay here and every release waits on them.
1669
+ */
1670
+ const spawnedBrokerPids = new Set();
1440
1671
  let ownedBrokerExited = false;
1441
1672
  let rejectBrokerExit;
1442
1673
  const brokerExit = new Promise((_resolve, reject) => {
@@ -1448,13 +1679,33 @@ export async function runUpCommand(options, deps) {
1448
1679
  if (!shutdownPromise) {
1449
1680
  shuttingDown = true;
1450
1681
  if (relay === null) {
1451
- shutdownPromise = Promise.resolve();
1682
+ // A reservation taken before the broker spawned protects nothing once
1683
+ // this start gives up. Drop it here so the node id frees immediately
1684
+ // instead of staying held until this supervisor's pid disappears.
1685
+ shutdownPromise = (async () => {
1686
+ if (nodeClaim && (await releaseNodeClaimAfterExit(nodeClaim, deps, spawnedBrokerPids))) {
1687
+ nodeClaim = undefined;
1688
+ }
1689
+ // Dropped last: while this descriptor is open, this start is still
1690
+ // one of the live processes the claim's hold answers for.
1691
+ closeNodeClaimHold(nodeClaimHoldFd);
1692
+ nodeClaimHoldFd = undefined;
1693
+ })();
1452
1694
  }
1453
1695
  else {
1454
1696
  shutdownPromise = (async () => {
1455
1697
  await reflexCapture?.stop();
1456
1698
  await nodeProviders?.stop();
1457
1699
  await shutdownUpResources(relay, paths, deps, ownedBrokerExited ? managedIdentity : undefined);
1700
+ // Released only after the broker's exit is OBSERVED: dropping the
1701
+ // claim while its node-control socket is still connected would wave a
1702
+ // second `node up` straight through to evict it, and shutdown can
1703
+ // return without the process being gone.
1704
+ if (nodeClaim && (await releaseNodeClaimAfterExit(nodeClaim, deps, spawnedBrokerPids))) {
1705
+ nodeClaim = undefined;
1706
+ }
1707
+ closeNodeClaimHold(nodeClaimHoldFd);
1708
+ nodeClaimHoldFd = undefined;
1458
1709
  })();
1459
1710
  }
1460
1711
  }
@@ -1522,6 +1773,19 @@ export async function runUpCommand(options, deps) {
1522
1773
  if (orphanCleanup.matchedCount > orphanCleanup.killedCount) {
1523
1774
  throw new Error('Could not verify orphan broker exit; retained its state for a later cleanup.');
1524
1775
  }
1776
+ // Ownership is taken BEFORE the broker exists, because the broker registers
1777
+ // with the engine on its own initialization path. A start that loses here
1778
+ // has spawned nothing, so it cannot have moved the node's delivery socket.
1779
+ nodeClaim = await reserveEnrolledNode(paths, options, deps, localOnly);
1780
+ if (nodeClaim) {
1781
+ // Opened BEFORE the spawn: a fence established afterwards would leave
1782
+ // exactly the window it exists to close. A failure here throws, so the
1783
+ // spawn below is unreachable without the fence in place -- warning and
1784
+ // continuing would have started a broker whose claim silently loses its
1785
+ // crash safety, which is the failure this whole path exists to prevent.
1786
+ // The outer catch releases the reservation on the way out.
1787
+ nodeClaimHoldFd = openNodeClaimHold(nodeClaim, deps.env);
1788
+ }
1525
1789
  const started = await startBrokerWithPortFallback(paths, basePort, deps, brokerName, options.verbose,
1526
1790
  // Assign `relay` as soon as the broker child process exists, not only
1527
1791
  // once the handshake/status-check retries above also succeed. A
@@ -1530,6 +1794,26 @@ export async function runUpCommand(options, deps) {
1530
1794
  // child instead of shutting it down.
1531
1795
  (candidate) => {
1532
1796
  relay = candidate;
1797
+ // Remembered here and never cleared: the catch below nulls `relay` out
1798
+ // while this process can still be running, and the node claim must not
1799
+ // be released while it is.
1800
+ if (typeof candidate.brokerPid === 'number' && candidate.brokerPid > 0) {
1801
+ spawnedBrokerPids.add(candidate.brokerPid);
1802
+ }
1803
+ }, nodeClaimHoldFd === undefined ? [] : [nodeClaimHoldFd],
1804
+ // Written to the claim in the same turn the child exists, because the
1805
+ // executable identity recorded before the spawn describes the file this
1806
+ // start MEANT to run, not the one the process ends up mapping: a shebang
1807
+ // launcher runs as its interpreter, and a launcher that `exec`s the real
1808
+ // broker maps that instead. Both kept their pid, and without it such a
1809
+ // live, fenced child read as an unrelated process and the next start took
1810
+ // its node id. Tracked for the release path too, which must not free a
1811
+ // claim while a child this start spawned can still register under it.
1812
+ (pid) => {
1813
+ if (pid > 0)
1814
+ spawnedBrokerPids.add(pid);
1815
+ if (nodeClaim)
1816
+ nodeClaim = recordSpawnedBrokerChild(nodeClaim, pid, deps.env);
1533
1817
  }).catch((err) => {
1534
1818
  // On failure, `startBrokerWithPortFallback` has already shut down any
1535
1819
  // candidate it created before rethrowing. Clear the early handle too
@@ -1549,6 +1833,12 @@ export async function runUpCommand(options, deps) {
1549
1833
  if (!managedIdentity) {
1550
1834
  throw new Error('Could not persist a verified broker process identity. Startup was stopped; ensure ps and lsof are available, process inspection is permitted, and the project identity directory is writable.');
1551
1835
  }
1836
+ // Hand the reservation to the verified broker process that now owns the
1837
+ // state dir, so the claim survives this supervisor and `node down` can
1838
+ // release it by the pid it verifies.
1839
+ if (nodeClaim) {
1840
+ nodeClaim = await adoptEnrolledNodeClaim(nodeClaim, { pid: managedIdentity.pid, brokerName, apiPort: started.apiPort }, deps);
1841
+ }
1552
1842
  try {
1553
1843
  writeBrokerBindingSource(paths.dataDir, workspaceBindingSource, deps);
1554
1844
  }
@@ -1670,6 +1960,38 @@ export async function runUpCommand(options, deps) {
1670
1960
  crashGuard.dispose();
1671
1961
  }
1672
1962
  }
1963
+ /**
1964
+ * Point `down` at live brokers serving other state directories.
1965
+ *
1966
+ * `down` resolves its state dir from the cwd (or `--state-dir`), so running it
1967
+ * from the wrong directory reports "Not running" while the broker it was meant
1968
+ * to stop keeps serving. The machine-global node claims are the one index that
1969
+ * spans state dirs, so use them to name the live brokers instead.
1970
+ */
1971
+ async function reportNodeClaimsElsewhere(paths, deps) {
1972
+ let held;
1973
+ try {
1974
+ held = await listHeldNodeClaims({
1975
+ env: deps.env,
1976
+ killProcess: deps.killProcess,
1977
+ execCommand: deps.execCommand,
1978
+ });
1979
+ }
1980
+ catch {
1981
+ // Diagnostics only — an unreadable claims directory changes nothing here.
1982
+ return;
1983
+ }
1984
+ const stateDir = normalizeClaimStateDir(paths.dataDir);
1985
+ const elsewhere = held.filter((claim) => claim.state_dir !== stateDir);
1986
+ if (elsewhere.length === 0) {
1987
+ return;
1988
+ }
1989
+ deps.log('Live relay nodes on this machine are serving other state directories:');
1990
+ for (const claim of elsewhere) {
1991
+ deps.log(` node ${claim.node_id} — ${describeNodeClaimHolder(claim)}`);
1992
+ }
1993
+ deps.log('Stop one with: agent-relay node down --state-dir <state dir>');
1994
+ }
1673
1995
  // eslint-disable-next-line complexity, max-depth
1674
1996
  export async function runDownCommand(options, deps) {
1675
1997
  const paths = deps.getProjectPaths();
@@ -1734,6 +2056,7 @@ export async function runDownCommand(options, deps) {
1734
2056
  }
1735
2057
  if (result.matchedCount === 0) {
1736
2058
  deps.log('No verified orphan broker found; retained existing state.');
2059
+ await reportNodeClaimsElsewhere(paths, deps);
1737
2060
  return;
1738
2061
  }
1739
2062
  cleanupBrokerFiles(paths, deps);
@@ -1741,6 +2064,7 @@ export async function runDownCommand(options, deps) {
1741
2064
  }
1742
2065
  else {
1743
2066
  deps.log('Not running');
2067
+ await reportNodeClaimsElsewhere(paths, deps);
1744
2068
  }
1745
2069
  return;
1746
2070
  }
@@ -1752,6 +2076,13 @@ export async function runDownCommand(options, deps) {
1752
2076
  }
1753
2077
  if (!isProcessRunning(pid, deps)) {
1754
2078
  cleanupBrokerFiles(paths, deps);
2079
+ await releaseNodeClaimsForBroker({
2080
+ pid,
2081
+ stateDir: paths.dataDir,
2082
+ env: deps.env,
2083
+ killProcess: deps.killProcess,
2084
+ execCommand: deps.execCommand,
2085
+ });
1755
2086
  deps.log('Cleaned up stale state (process was not running)');
1756
2087
  return;
1757
2088
  }
@@ -1787,6 +2118,18 @@ export async function runDownCommand(options, deps) {
1787
2118
  if (identity)
1788
2119
  removeBrokerIdentity(paths, identity, deps);
1789
2120
  cleanupBrokerFiles(paths, deps);
2121
+ // The supervising CLI releases its own claim on a graceful exit, but `down`
2122
+ // can outlive it (or kill it with --force), so release by the pid and state
2123
+ // dir it just verified. Only a claim naming both is removed.
2124
+ for (const claim of await releaseNodeClaimsForBroker({
2125
+ pid,
2126
+ stateDir: paths.dataDir,
2127
+ env: deps.env,
2128
+ killProcess: deps.killProcess,
2129
+ execCommand: deps.execCommand,
2130
+ })) {
2131
+ deps.log(`Released this machine's claim on node ${claim.node_id}.`);
2132
+ }
1790
2133
  deps.log('Stopped');
1791
2134
  return;
1792
2135
  }
@@ -1795,6 +2138,13 @@ export async function runDownCommand(options, deps) {
1795
2138
  if (withCode.code === 'ESRCH') {
1796
2139
  removeBrokerIdentity(paths, identity, deps);
1797
2140
  cleanupBrokerFiles(paths, deps);
2141
+ await releaseNodeClaimsForBroker({
2142
+ pid,
2143
+ stateDir: paths.dataDir,
2144
+ env: deps.env,
2145
+ killProcess: deps.killProcess,
2146
+ execCommand: deps.execCommand,
2147
+ });
1798
2148
  deps.log('Cleaned up stale state');
1799
2149
  return;
1800
2150
  }