agent-relay 12.2.6 → 12.3.0
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/README.md +66 -0
- package/dist/cli/commands/core.d.ts +13 -1
- package/dist/cli/commands/core.d.ts.map +1 -1
- package/dist/cli/commands/core.js +3 -1
- package/dist/cli/commands/core.js.map +1 -1
- package/dist/cli/commands/node.d.ts +6 -0
- package/dist/cli/commands/node.d.ts.map +1 -1
- package/dist/cli/commands/node.js +72 -0
- package/dist/cli/commands/node.js.map +1 -1
- package/dist/cli/lib/broker-lifecycle.d.ts +21 -1
- package/dist/cli/lib/broker-lifecycle.d.ts.map +1 -1
- package/dist/cli/lib/broker-lifecycle.js +354 -4
- package/dist/cli/lib/broker-lifecycle.js.map +1 -1
- package/dist/cli/lib/client-factory.d.ts +4 -0
- package/dist/cli/lib/client-factory.d.ts.map +1 -1
- package/dist/cli/lib/client-factory.js +3 -1
- package/dist/cli/lib/client-factory.js.map +1 -1
- package/dist/cli/lib/node-claim.d.ts +460 -0
- package/dist/cli/lib/node-claim.d.ts.map +1 -0
- package/dist/cli/lib/node-claim.js +1338 -0
- package/dist/cli/lib/node-claim.js.map +1 -0
- package/dist/index.cjs +1 -1
- package/package.json +10 -10
|
@@ -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
|
-
|
|
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
|
}
|