agent-relay 12.0.0 → 12.1.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.
Files changed (38) hide show
  1. package/README.md +64 -0
  2. package/dist/cli/agent-relay-mcp.d.ts.map +1 -1
  3. package/dist/cli/agent-relay-mcp.js +177 -28
  4. package/dist/cli/agent-relay-mcp.js.map +1 -1
  5. package/dist/cli/commands/core.d.ts +8 -0
  6. package/dist/cli/commands/core.d.ts.map +1 -1
  7. package/dist/cli/commands/core.js +4 -0
  8. package/dist/cli/commands/core.js.map +1 -1
  9. package/dist/cli/commands/fleet-agent.d.ts +5 -1
  10. package/dist/cli/commands/fleet-agent.d.ts.map +1 -1
  11. package/dist/cli/commands/fleet-agent.js +114 -26
  12. package/dist/cli/commands/fleet-agent.js.map +1 -1
  13. package/dist/cli/commands/fleet.d.ts.map +1 -1
  14. package/dist/cli/commands/fleet.js +93 -13
  15. package/dist/cli/commands/fleet.js.map +1 -1
  16. package/dist/cli/commands/node.js +4 -0
  17. package/dist/cli/commands/node.js.map +1 -1
  18. package/dist/cli/commands/status.d.ts +1 -1
  19. package/dist/cli/commands/status.d.ts.map +1 -1
  20. package/dist/cli/commands/status.js +7 -3
  21. package/dist/cli/commands/status.js.map +1 -1
  22. package/dist/cli/lib/broker-lifecycle.d.ts +2 -0
  23. package/dist/cli/lib/broker-lifecycle.d.ts.map +1 -1
  24. package/dist/cli/lib/broker-lifecycle.js +222 -153
  25. package/dist/cli/lib/broker-lifecycle.js.map +1 -1
  26. package/dist/cli/lib/broker-process-identity.d.ts +30 -0
  27. package/dist/cli/lib/broker-process-identity.d.ts.map +1 -0
  28. package/dist/cli/lib/broker-process-identity.js +229 -0
  29. package/dist/cli/lib/broker-process-identity.js.map +1 -0
  30. package/dist/cli/lib/sdk-command.d.ts.map +1 -1
  31. package/dist/cli/lib/sdk-command.js +20 -2
  32. package/dist/cli/lib/sdk-command.js.map +1 -1
  33. package/dist/cli/lib/spawn-lifecycle.d.ts +12 -0
  34. package/dist/cli/lib/spawn-lifecycle.d.ts.map +1 -0
  35. package/dist/cli/lib/spawn-lifecycle.js +89 -0
  36. package/dist/cli/lib/spawn-lifecycle.js.map +1 -0
  37. package/dist/index.cjs +156 -23
  38. package/package.json +9 -9
@@ -5,6 +5,7 @@ import { HarnessDriverClient } from '@agent-relay/harness-driver';
5
5
  import { startServeNode } from '@agent-relay/fleet';
6
6
  import { createLogger } from '@agent-relay/utils';
7
7
  import { redactCredentialValues } from '@agent-relay/cloud/redact';
8
+ import { brokerIdentityPath, matchesBrokerIdentity, persistBrokerIdentity, readBrokerIdentities, removeBrokerIdentity, } from './broker-process-identity.js';
8
9
  import { track } from '../telemetry/index.js';
9
10
  import { buildBundledAgentRelayMcpCommand, isBundledBunEntrypointPath } from './agent-relay-mcp-command.js';
10
11
  import { errorClassName } from './telemetry-helpers.js';
@@ -747,67 +748,9 @@ function isProcessRunning(pid, deps) {
747
748
  deps.killProcess(pid, 0);
748
749
  return true;
749
750
  }
750
- catch {
751
- return false;
752
- }
753
- }
754
- function parsePsAuxLine(line) {
755
- const fields = line.trim().split(/\s+/);
756
- if (fields.length < 11 || fields[0] === 'USER') {
757
- return null;
758
- }
759
- const pid = Number.parseInt(fields[1], 10);
760
- if (Number.isNaN(pid) || pid <= 0) {
761
- return null;
762
- }
763
- return {
764
- pid,
765
- command: fields.slice(10).join(' '),
766
- };
767
- }
768
- function commandExecutableBasename(command) {
769
- const executable = command.trim().split(/\s+/)[0] ?? '';
770
- return path.basename(executable.replace(/^["']|["']$/g, ''));
771
- }
772
- function isBrokerExecutableCommand(command) {
773
- const basename = commandExecutableBasename(command);
774
- return basename === 'agent-relay-broker' || basename.startsWith('agent-relay-broker-');
775
- }
776
- function isAttachedBrokerCliCommand(command) {
777
- if (command.includes('agent-relay-mcp')) {
778
- return false;
779
- }
780
- // The attached `up` process holds the broker. Skip the transient
781
- // `up --background` launcher, which exits as soon as the child is ready.
782
- if (!/(?:^|\s)up(?:\s|$)/.test(command) || /(?:^|\s)--background(?:\s|=|$)/.test(command)) {
783
- return false;
784
- }
785
- return /(?:^|\s)(?:\S*agent-relay(?:\.js)?|\S*agent-relay-[^\s]+)(?:\s|$)/.test(command);
786
- }
787
- function isBrokerProcessCommand(command) {
788
- return isBrokerExecutableCommand(command) || isAttachedBrokerCliCommand(command);
789
- }
790
- function escapeRegExp(value) {
791
- return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
792
- }
793
- function commandHasBrokerName(command, brokerName) {
794
- const escapedName = escapeRegExp(brokerName);
795
- return new RegExp(`(?:^|\\s)--name(?:\\s+|=)${escapedName}(?:\\s|$)`).test(command);
796
- }
797
- function commandHasProjectRoot(command, projectRoot) {
798
- const escapedRoot = escapeRegExp(path.resolve(projectRoot));
799
- return new RegExp(`(?:^|\\s|=|["'])${escapedRoot}(?:$|\\s|["']|${escapeRegExp(path.sep)})`).test(command);
800
- }
801
- async function processCwdMatchesProjectRoot(processInfo, projectRoot, deps) {
802
- try {
803
- const cwdDetails = await deps.execCommand(`lsof -nP -a -p ${processInfo.pid} -d cwd -Fn`);
804
- return cwdDetails.stdout
805
- .split('\n')
806
- .filter((line) => line.startsWith('n'))
807
- .some((line) => path.resolve(line.slice(1)) === projectRoot);
808
- }
809
- catch {
810
- return false;
751
+ catch (error) {
752
+ // Permission denial proves no exit and must never authorize record deletion.
753
+ return error?.code === 'EPERM';
811
754
  }
812
755
  }
813
756
  async function terminateProcess(pid, deps, force) {
@@ -829,64 +772,64 @@ async function terminateProcess(pid, deps, force) {
829
772
  }
830
773
  return waitForProcessExit(pid, 500, deps);
831
774
  }
832
- async function killOrphanedBrokerProcesses(projectRoot, deps, options) {
833
- let matchedCount = 0;
834
- let killedCount = 0;
835
- try {
836
- const resolvedProjectRoot = path.resolve(projectRoot);
837
- const brokerName = path.basename(resolvedProjectRoot) || 'project';
838
- const candidates = [];
839
- try {
840
- const processList = await deps.execCommand('ps aux');
841
- const relayProcesses = processList.stdout
842
- .split('\n')
843
- .map(parsePsAuxLine)
844
- .filter((process) => process !== null)
845
- .filter((process) => isBrokerProcessCommand(process.command));
846
- const matchedPids = new Set();
847
- for (const processInfo of relayProcesses) {
848
- if (commandHasProjectRoot(processInfo.command, resolvedProjectRoot)) {
849
- candidates.push(processInfo);
850
- matchedPids.add(processInfo.pid);
851
- }
852
- }
853
- for (const processInfo of relayProcesses) {
854
- if (matchedPids.has(processInfo.pid)) {
855
- continue;
856
- }
857
- const cwdMatches = await processCwdMatchesProjectRoot(processInfo, resolvedProjectRoot, deps);
858
- if (!cwdMatches)
859
- continue;
860
- if (isBrokerExecutableCommand(processInfo.command) &&
861
- !commandHasBrokerName(processInfo.command, brokerName)) {
862
- continue;
863
- }
864
- candidates.push(processInfo);
865
- matchedPids.add(processInfo.pid);
775
+ async function killOrphanedBrokerProcesses(paths, deps, options) {
776
+ const identities = readBrokerIdentities(paths, deps);
777
+ if (!identities) {
778
+ deps.warn(`Broker identities could not be read in ${path.join(paths.projectRoot, '.agentworkforce', 'relay')}. State retained; inspect the records and verify process ownership before manual recovery.`);
779
+ return { matchedCount: 1, killedCount: 0 };
780
+ }
781
+ const brokerName = options?.brokerName?.trim() ||
782
+ deps.env.AGENT_RELAY_BROKER_NAME?.trim() ||
783
+ path.basename(paths.projectRoot) ||
784
+ 'project';
785
+ const result = { matchedCount: 0, killedCount: 0 };
786
+ for (const identity of identities) {
787
+ if (options?.matchBrokerName && identity.brokerName !== brokerName)
788
+ continue;
789
+ if (!isProcessRunning(identity.pid, deps)) {
790
+ if (options?.matchBrokerName) {
791
+ result.matchedCount++;
792
+ deps.warn(`Recorded broker has already exited; its identity was retained because no matched exit was observed. Verify ownership before removing ${brokerIdentityPath(paths, deps, identity.brokerName)} and restarting.`);
866
793
  }
794
+ continue;
867
795
  }
868
- catch {
869
- // Expected if ps is unavailable; fall through to no matches.
870
- }
871
- for (const { pid } of candidates) {
872
- if (pid === deps.pid) {
873
- continue;
874
- }
875
- matchedCount += 1;
876
- deps.warn(`Killing orphaned broker process (pid: ${pid})`);
877
- const killed = await terminateProcess(pid, deps, options?.force === true);
878
- if (killed) {
879
- killedCount += 1;
880
- }
881
- else if (options?.force === true) {
882
- deps.warn(`Broker orphan process may still be running (pid: ${pid})`);
883
- }
796
+ result.matchedCount++;
797
+ if (await stopRecordedBroker(paths, identity, deps, options?.force === true))
798
+ result.killedCount++;
799
+ }
800
+ return result;
801
+ }
802
+ async function stopRecordedBroker(paths, identity, deps, force) {
803
+ // Check the persisted instance immediately before EVERY signal, including
804
+ // escalation after a wait. Never rediscover ownership from rendered argv.
805
+ if (!(await matchesBrokerIdentity(identity, paths, deps))) {
806
+ deps.warn(`Broker identity could not be verified (pid: ${identity.pid}). State retained; verify process ownership before stopping it manually. If the recorded broker has exited, remove its stale identity file: ${brokerIdentityPath(paths, deps, identity.brokerName)}`);
807
+ return false;
808
+ }
809
+ deps.warn(`Killing orphaned broker process (pid: ${identity.pid})`);
810
+ try {
811
+ deps.killProcess(identity.pid, 'SIGTERM');
812
+ // Native graceful shutdown may drain identity/presence work for 3.5s.
813
+ let exited = await waitForProcessExit(identity.pid, force ? 500 : 5000, deps);
814
+ if (!exited && force && (await matchesBrokerIdentity(identity, paths, deps))) {
815
+ deps.killProcess(identity.pid, 'SIGKILL');
816
+ exited = await waitForProcessExit(identity.pid, 500, deps);
817
+ }
818
+ if (exited) {
819
+ removeBrokerIdentity(paths, identity, deps);
820
+ return true;
884
821
  }
885
822
  }
886
- catch {
887
- // Best-effort orphan cleanup.
823
+ catch (error) {
824
+ if (error?.code === 'ESRCH') {
825
+ // The kernel observed absence after this exact instance was verified.
826
+ removeBrokerIdentity(paths, identity, deps);
827
+ return true;
828
+ }
829
+ // Retain the record when a matched exit was not observed.
888
830
  }
889
- return { matchedCount, killedCount };
831
+ deps.warn(`Broker orphan process may still be running (pid: ${identity.pid})`);
832
+ return false;
890
833
  }
891
834
  function ensureBundledAgentRelayMcpCommand(deps) {
892
835
  if (deps.env.AGENT_RELAY_MCP_COMMAND?.trim()) {
@@ -907,24 +850,30 @@ async function waitForProcessExit(pid, timeoutMs, deps) {
907
850
  }
908
851
  return false;
909
852
  }
910
- async function recoverHalfStartedBroker(paths, deps) {
853
+ async function recoverHalfStartedBroker(paths, deps, brokerName) {
911
854
  deps.fs.mkdirSync(paths.dataDir, { recursive: true });
912
855
  const readiness = await waitForBrokerReadiness(paths, deps, 0, true);
913
856
  if (readiness.state === 'running') {
914
857
  return 'running';
915
858
  }
916
859
  if (readiness.state === 'starting') {
917
- deps.warn(`Broker process is running but the API is not ready; killing half-started broker (pid: ${readiness.conn.pid}).`);
918
- const stopped = await terminateProcess(readiness.conn.pid, deps, true);
860
+ deps.warn(`Broker process is running but the API is not ready; verifying half-started broker ownership (pid: ${readiness.conn.pid}).`);
861
+ const identities = readBrokerIdentities(paths, deps);
862
+ const identity = identities?.find((record) => record.pid === readiness.conn.pid);
863
+ const stopped = identity && (await stopRecordedBroker(paths, identity, deps, true));
919
864
  if (!stopped) {
920
865
  deps.error(`Failed to stop half-started broker process (pid: ${readiness.conn.pid}). ` +
921
- 'Run `agent-relay down --force` to retry cleanup, or remove `.agentworkforce/relay/` after stopping the process.');
866
+ `Verify process ownership manually before stopping it; inspect identities in ${path.join(paths.projectRoot, '.agentworkforce', 'relay')}. Connection metadata alone cannot authorize a signal.`);
922
867
  return 'blocked';
923
868
  }
924
869
  cleanupBrokerFiles(paths, deps);
925
870
  return 'recovered';
926
871
  }
927
- const orphanCleanup = await killOrphanedBrokerProcesses(paths.projectRoot, deps, { force: true });
872
+ const orphanCleanup = await killOrphanedBrokerProcesses(paths, deps, {
873
+ force: true,
874
+ brokerName,
875
+ matchBrokerName: true,
876
+ });
928
877
  if (orphanCleanup.matchedCount > 0) {
929
878
  if (orphanCleanup.killedCount < orphanCleanup.matchedCount) {
930
879
  deps.error('Failed to stop all half-started broker processes. ' +
@@ -934,20 +883,29 @@ async function recoverHalfStartedBroker(paths, deps) {
934
883
  cleanupBrokerFiles(paths, deps);
935
884
  return 'recovered';
936
885
  }
937
- cleanupBrokerFiles(paths, deps);
938
886
  return 'clear';
939
887
  }
940
888
  function cleanupBrokerFiles(paths, deps) {
889
+ // A concurrent replacement or another named broker still owns discovery
890
+ // state. Unreadable records are likewise not permission to remove it.
891
+ if (readBrokerIdentities(paths, deps)?.length !== 0)
892
+ return;
893
+ // A replacement can publish connection.json before its identity is captured.
894
+ const connection = readBrokerConnectionFromFs(deps.fs, paths.dataDir);
895
+ if (connection?.pid && isProcessRunning(connection.pid, deps))
896
+ return;
941
897
  const runtimePath = path.join(paths.dataDir, 'runtime.json');
942
898
  const relaySockPath = path.join(paths.dataDir, 'relay.sock');
943
899
  safeUnlink(path.join(paths.dataDir, CONNECTION_FILENAME), deps);
944
900
  safeUnlink(relaySockPath, deps);
945
901
  safeUnlink(runtimePath, deps);
946
902
  safeUnlink(backgroundStartErrorPath(paths.dataDir), deps);
947
- // Clean up lock files and legacy pid files
903
+ // A lock pathname may still belong to an unverifiable or differently named
904
+ // live broker. Reusing the existing inode preserves its flock protection.
905
+ // Only legacy pid files are disposable here.
948
906
  try {
949
907
  for (const file of deps.fs.readdirSync(paths.dataDir)) {
950
- if (file.startsWith('broker-') && (file.endsWith('.lock') || file.endsWith('.pid'))) {
908
+ if (file.startsWith('broker-') && file.endsWith('.pid')) {
951
909
  safeUnlink(path.join(paths.dataDir, file), deps);
952
910
  continue;
953
911
  }
@@ -1135,9 +1093,20 @@ export async function waitForNodeDelivery(relay, deps, waitMs = NODE_DELIVERY_RE
1135
1093
  await deps.sleep(Math.min(STATUS_POLL_INTERVAL_MS, Math.max(0, deadline - deps.now())));
1136
1094
  }
1137
1095
  }
1138
- async function shutdownUpResources(relay, dataDir, deps) {
1096
+ async function shutdownUpResources(relay, paths, deps, observedChildExit) {
1097
+ // The SDK clears its child handle on exit/shutdown, so preserve the owned
1098
+ // PID before awaiting shutdown or use the exact observed-child snapshot.
1099
+ const brokerPid = observedChildExit?.pid ?? relay.brokerPid;
1100
+ const identity = readBrokerIdentities(paths, deps)?.find((record) => record.pid === brokerPid);
1101
+ const owned = identity !== undefined && (await matchesBrokerIdentity(identity, paths, deps));
1139
1102
  await relay.shutdown().catch(() => undefined);
1140
- safeUnlink(path.join(dataDir, CONNECTION_FILENAME), deps);
1103
+ if (observedChildExit && observedChildExit.pid === brokerPid)
1104
+ removeBrokerIdentity(paths, observedChildExit, deps);
1105
+ if (identity && owned && !isProcessRunning(identity.pid, deps))
1106
+ removeBrokerIdentity(paths, identity, deps);
1107
+ if (brokerPid && readBrokerPid(paths.dataDir, deps) === brokerPid) {
1108
+ safeUnlink(path.join(paths.dataDir, CONNECTION_FILENAME), deps);
1109
+ }
1141
1110
  }
1142
1111
  // eslint-disable-next-line complexity
1143
1112
  /**
@@ -1249,7 +1218,23 @@ function recordWorkspaceBindingSource(selection, deps, overrideSource) {
1249
1218
  deps.env[WORKSPACE_BINDING_SOURCE_ENV] = source;
1250
1219
  return source;
1251
1220
  }
1221
+ function resolveBrokerName(options, deps, projectRoot) {
1222
+ return (options.brokerName?.trim() ||
1223
+ deps.env.AGENT_RELAY_BROKER_NAME?.trim() ||
1224
+ path.basename(projectRoot) ||
1225
+ 'project');
1226
+ }
1252
1227
  export async function runUpCommand(options, deps) {
1228
+ if (!['darwin', 'linux'].includes(process.platform)) {
1229
+ deps.error(`Broker lifecycle identity verification is supported only on macOS and Linux; refusing to start on ${process.platform}.`);
1230
+ deps.exit(1);
1231
+ return;
1232
+ }
1233
+ const localOnly = options.localOnly || deps.env.AGENT_RELAY_LOCAL_ONLY === '1';
1234
+ if (localOnly) {
1235
+ deps.env.AGENT_RELAY_LOCAL_ONLY = '1';
1236
+ deps.warn('DEGRADED — LOCAL ONLY: fleet routing, worker presence, remote delivery and remote attachment are disabled.');
1237
+ }
1253
1238
  ensureBundledAgentRelayMcpCommand(deps);
1254
1239
  const paths = deps.getProjectPaths();
1255
1240
  // The stable, default project data dir (`.agentworkforce/relay/`) captured
@@ -1293,7 +1278,7 @@ export async function runUpCommand(options, deps) {
1293
1278
  deps.env.RELAY_API_KEY = options.workspaceKey;
1294
1279
  }
1295
1280
  if (options.background) {
1296
- const preflight = await recoverHalfStartedBroker(paths, deps);
1281
+ const preflight = await recoverHalfStartedBroker(paths, deps, options.brokerName);
1297
1282
  if (preflight === 'running') {
1298
1283
  const pid = readBrokerPid(paths.dataDir, deps);
1299
1284
  deps.error(pid
@@ -1358,17 +1343,20 @@ export async function runUpCommand(options, deps) {
1358
1343
  }
1359
1344
  for (const cleanupPid of cleanupPids) {
1360
1345
  deps.warn(`Cleaning up failed broker start (pid: ${cleanupPid})`);
1361
- const stopped = await terminateProcess(cleanupPid, deps, true);
1346
+ const identity = readBrokerIdentities(paths, deps)?.find((record) => record.pid === cleanupPid);
1347
+ const stopped = cleanupPid === child.pid
1348
+ ? await terminateProcess(cleanupPid, deps, true)
1349
+ : identity && (await stopRecordedBroker(paths, identity, deps, true));
1362
1350
  if (!stopped) {
1363
1351
  deps.error(`Failed to stop half-started broker process (pid: ${cleanupPid}). ` +
1364
- 'Run `agent-relay down --force` to retry cleanup, or remove `.agentworkforce/relay/` after stopping the process.');
1352
+ `Verify process ownership before stopping it manually; inspect identities in ${path.join(paths.projectRoot, '.agentworkforce', 'relay')}.`);
1365
1353
  }
1366
1354
  }
1367
1355
  cleanupBrokerFiles(paths, deps);
1368
1356
  deps.exit(1);
1369
1357
  return;
1370
1358
  }
1371
- const enrolledNodeToken = deps.env.RELAY_NODE_TOKEN?.trim();
1359
+ const enrolledNodeToken = localOnly ? undefined : deps.env.RELAY_NODE_TOKEN?.trim();
1372
1360
  const enrolledNodeId = enrolledNodeToken ? deps.env.RELAY_NODE_ID?.trim() : undefined;
1373
1361
  let enrollmentFailureReason;
1374
1362
  if (enrolledNodeToken && !enrolledNodeId) {
@@ -1394,11 +1382,14 @@ export async function runUpCommand(options, deps) {
1394
1382
  let allStopped = true;
1395
1383
  for (const cleanupPid of cleanupPids) {
1396
1384
  deps.warn(`Cleaning up failed broker start (pid: ${cleanupPid})`);
1397
- const stopped = await terminateProcess(cleanupPid, deps, true);
1385
+ const identity = readBrokerIdentities(paths, deps)?.find((record) => record.pid === cleanupPid);
1386
+ const stopped = cleanupPid === child.pid
1387
+ ? await terminateProcess(cleanupPid, deps, true)
1388
+ : identity && (await stopRecordedBroker(paths, identity, deps, true));
1398
1389
  if (!stopped) {
1399
1390
  allStopped = false;
1400
1391
  deps.error(`Failed to stop broker process after Cloud enrollment startup failed (pid: ${cleanupPid}). ` +
1401
- 'Run `agent-relay down --force` to retry cleanup.');
1392
+ `Verify process ownership before stopping it manually; inspect identities in ${path.join(paths.projectRoot, '.agentworkforce', 'relay')}.`);
1402
1393
  }
1403
1394
  }
1404
1395
  if (allStopped) {
@@ -1407,6 +1398,21 @@ export async function runUpCommand(options, deps) {
1407
1398
  deps.exit(1);
1408
1399
  return;
1409
1400
  }
1401
+ const identityDeadline = deps.now() + DETACHED_START_READY_TIMEOUT_MS;
1402
+ let identityReady = false;
1403
+ while (deps.now() < identityDeadline && isProcessRunning(readiness.conn.pid, deps)) {
1404
+ const record = readBrokerIdentities(paths, deps)?.find((entry) => entry.pid === readiness.conn.pid);
1405
+ if (record && (await matchesBrokerIdentity(record, paths, deps))) {
1406
+ identityReady = true;
1407
+ break;
1408
+ }
1409
+ await deps.sleep(100);
1410
+ }
1411
+ if (!identityReady) {
1412
+ deps.error(`Broker API is ready but process identity was not confirmed (pid: ${readiness.conn.pid}). State retained; verify ownership manually and inspect identities in ${path.join(paths.projectRoot, '.agentworkforce', 'relay')}.`);
1413
+ deps.exit(1);
1414
+ return;
1415
+ }
1410
1416
  deps.log('Broker started.');
1411
1417
  deps.log(`Broker PID: ${readiness.conn.pid}`);
1412
1418
  deps.log('Stop with: agent-relay down');
@@ -1417,12 +1423,22 @@ export async function runUpCommand(options, deps) {
1417
1423
  const basePort = resolveBrokerBasePort(deps);
1418
1424
  deps.fs.mkdirSync(paths.dataDir, { recursive: true });
1419
1425
  const existingPid = readBrokerPid(paths.dataDir, deps);
1426
+ const brokerName = resolveBrokerName(options, deps, paths.projectRoot);
1420
1427
  let relay = null;
1421
1428
  let nodeProviders;
1422
1429
  let reflexCapture;
1423
1430
  let shuttingDown = false;
1424
1431
  let sigintCount = 0;
1425
1432
  let shutdownPromise;
1433
+ let stopWatchingBrokerExit;
1434
+ let managedIdentity;
1435
+ let ownedBrokerExited = false;
1436
+ let rejectBrokerExit;
1437
+ const brokerExit = new Promise((_resolve, reject) => {
1438
+ rejectBrokerExit = reject;
1439
+ });
1440
+ // The child can exit during startup before holdOpen begins racing this promise.
1441
+ void brokerExit.catch(() => undefined);
1426
1442
  const shutdownOnce = async () => {
1427
1443
  if (!shutdownPromise) {
1428
1444
  shuttingDown = true;
@@ -1433,7 +1449,7 @@ export async function runUpCommand(options, deps) {
1433
1449
  shutdownPromise = (async () => {
1434
1450
  await reflexCapture?.stop();
1435
1451
  await nodeProviders?.stop();
1436
- await shutdownUpResources(relay, paths.dataDir, deps);
1452
+ await shutdownUpResources(relay, paths, deps, ownedBrokerExited ? managedIdentity : undefined);
1437
1453
  })();
1438
1454
  }
1439
1455
  }
@@ -1483,7 +1499,7 @@ export async function runUpCommand(options, deps) {
1483
1499
  applyNodeLogEnv(options, deps);
1484
1500
  // Resolved BEFORE the broker starts so an explicit bad --config fails
1485
1501
  // fast instead of tearing down a broker that just came up.
1486
- const nodePlan = await resolveNodeDefinitionForUp(paths, options, deps);
1502
+ const nodePlan = localOnly ? undefined : await resolveNodeDefinitionForUp(paths, options, deps);
1487
1503
  const teamsConfig = deps.loadTeamsConfig(paths.projectRoot);
1488
1504
  // The broker advertises spawn:<harness> capacity for this set. A pre-set
1489
1505
  // AGENT_RELAY_NODE_HARNESSES is the operator's authoritative declaration of the
@@ -1491,11 +1507,17 @@ export async function runUpCommand(options, deps) {
1491
1507
  // the project's runnable harnesses (built-in defaults plus teams.json clis and
1492
1508
  // any spawn:<harness> definitions) and passes it to the broker before it registers.
1493
1509
  deps.env.AGENT_RELAY_NODE_HARNESSES = resolveNodeCapacityHarnesses(deps.env.AGENT_RELAY_NODE_HARNESSES, teamsConfig, planCapacitySource(nodePlan));
1494
- // Kill any orphaned broker processes for this project that lost their PID
1495
- // files (e.g. user deleted .agentworkforce/relay/ while broker was running).
1510
+ // Recover a broker whose discovery files were lost only while its persisted
1511
+ // process identity and original runtime lock still prove ownership.
1496
1512
  vlog(deps, options.verbose, 'Checking for orphaned broker processes...');
1497
- await killOrphanedBrokerProcesses(paths.projectRoot, deps);
1498
- const started = await startBrokerWithPortFallback(paths, basePort, deps, options.brokerName, options.verbose,
1513
+ const orphanCleanup = await killOrphanedBrokerProcesses(paths, deps, {
1514
+ brokerName,
1515
+ matchBrokerName: true,
1516
+ });
1517
+ if (orphanCleanup.matchedCount > orphanCleanup.killedCount) {
1518
+ throw new Error('Could not verify orphan broker exit; retained its state for a later cleanup.');
1519
+ }
1520
+ const started = await startBrokerWithPortFallback(paths, basePort, deps, brokerName, options.verbose,
1499
1521
  // Assign `relay` as soon as the broker child process exists, not only
1500
1522
  // once the handshake/status-check retries above also succeed. A
1501
1523
  // SIGTERM/SIGINT arriving during that check window otherwise finds
@@ -1512,6 +1534,16 @@ export async function runUpCommand(options, deps) {
1512
1534
  throw err;
1513
1535
  });
1514
1536
  relay = started.relay;
1537
+ stopWatchingBrokerExit = relay.onBrokerExit?.(() => {
1538
+ ownedBrokerExited = true;
1539
+ if (!shuttingDown)
1540
+ rejectBrokerExit(new Error('Broker exited; stopping the node supervisor.'));
1541
+ });
1542
+ if (relay.brokerPid)
1543
+ managedIdentity = await persistBrokerIdentity(paths, relay.brokerPid, brokerName, deps);
1544
+ if (!managedIdentity) {
1545
+ 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.');
1546
+ }
1515
1547
  try {
1516
1548
  writeBrokerBindingSource(paths.dataDir, workspaceBindingSource, deps);
1517
1549
  }
@@ -1528,7 +1560,10 @@ export async function runUpCommand(options, deps) {
1528
1560
  const joinedWorkspaceId = relay.workspaceId ?? 'unknown';
1529
1561
  // The multi-workspace session always joins a configured membership; it
1530
1562
  // never mints a new workspace the way an unresolved single key does.
1531
- if (workspaceSelection || joinsMultiWorkspaceSession) {
1563
+ if (localOnly) {
1564
+ deps.log('Workspace: local only; configured credentials are used only for delivery-record reconciliation');
1565
+ }
1566
+ else if (workspaceSelection || joinsMultiWorkspaceSession) {
1532
1567
  deps.log(`Workspace: joined ${joinedWorkspaceId}`);
1533
1568
  }
1534
1569
  else {
@@ -1560,7 +1595,9 @@ export async function runUpCommand(options, deps) {
1560
1595
  // can't be written (read-only dir, etc.).
1561
1596
  }
1562
1597
  vlog(deps, options.verbose, 'Starting node capability providers (if any)...');
1563
- nodeProviders = await startNodeCapabilityProviders(paths, relay, options, deps, nodePlan);
1598
+ nodeProviders = localOnly
1599
+ ? undefined
1600
+ : await startNodeCapabilityProviders(paths, relay, options, deps, nodePlan);
1564
1601
  // When Reflex is enabled, periodically sync + push local session history to
1565
1602
  // relayhistory-cloud in-process via the ai-hist-native addon (no subprocess).
1566
1603
  // No-op when disabled or the addon isn't available.
@@ -1571,7 +1608,9 @@ export async function runUpCommand(options, deps) {
1571
1608
  // Node delivery can't connect until the broker mints its node token, which
1572
1609
  // now happens in the background after `Broker started.`. Budget for that
1573
1610
  // mint window plus the connect so a slow mint doesn't abort auto-spawn.
1574
- const delivery = await waitForNodeDelivery(relay, deps, NODE_TOKEN_WAIT_MS + NODE_DELIVERY_READY_TIMEOUT_MS);
1611
+ const delivery = localOnly
1612
+ ? { ready: true, status: null }
1613
+ : await waitForNodeDelivery(relay, deps, NODE_TOKEN_WAIT_MS + NODE_DELIVERY_READY_TIMEOUT_MS);
1575
1614
  if (!delivery.ready) {
1576
1615
  deps.error('Refusing to auto-spawn agents because broker node delivery is not connected.');
1577
1616
  deps.error(`Node delivery: ${formatNodeDeliveryStatus(delivery.status)}`);
@@ -1594,7 +1633,7 @@ export async function runUpCommand(options, deps) {
1594
1633
  else if (options.spawn === true && !teamsConfig) {
1595
1634
  deps.warn('Warning: --spawn specified but no teams.json found');
1596
1635
  }
1597
- const holdOpen = deps.holdOpen();
1636
+ const holdOpen = Promise.race([deps.holdOpen(), brokerExit]);
1598
1637
  if (nodeProviders?.done) {
1599
1638
  await Promise.race([holdOpen, nodeProviders.done]);
1600
1639
  }
@@ -1622,6 +1661,7 @@ export async function runUpCommand(options, deps) {
1622
1661
  deps.exit(1);
1623
1662
  }
1624
1663
  finally {
1664
+ stopWatchingBrokerExit?.();
1625
1665
  crashGuard.dispose();
1626
1666
  }
1627
1667
  }
@@ -1681,7 +1721,16 @@ export async function runDownCommand(options, deps) {
1681
1721
  const conn = readBrokerConnectionFromFs(deps.fs, paths.dataDir);
1682
1722
  if (!conn) {
1683
1723
  if (options.force) {
1684
- await killOrphanedBrokerProcesses(paths.projectRoot, deps, { force: true });
1724
+ const result = await killOrphanedBrokerProcesses(paths, deps, { force: true });
1725
+ if (result.matchedCount > result.killedCount) {
1726
+ deps.error('Could not verify broker exit; retained its state for a later cleanup.');
1727
+ deps.exit(1);
1728
+ return;
1729
+ }
1730
+ if (result.matchedCount === 0) {
1731
+ deps.log('No verified orphan broker found; retained existing state.');
1732
+ return;
1733
+ }
1685
1734
  cleanupBrokerFiles(paths, deps);
1686
1735
  deps.log('Cleaned up (was not running)');
1687
1736
  }
@@ -1701,40 +1750,52 @@ export async function runDownCommand(options, deps) {
1701
1750
  deps.log('Cleaned up stale state (process was not running)');
1702
1751
  return;
1703
1752
  }
1753
+ const identities = readBrokerIdentities(paths, deps);
1754
+ const identity = identities?.find((record) => record.pid === pid);
1755
+ if (!identity || !(await matchesBrokerIdentity(identity, paths, deps))) {
1756
+ deps.error(`Broker identity could not be verified (pid: ${pid}); retained its state. Verify ownership before stopping the process manually; inspect identities in ${path.join(paths.projectRoot, '.agentworkforce', 'relay')}. Connection metadata alone cannot authorize a signal.`);
1757
+ deps.exit(1);
1758
+ return;
1759
+ }
1704
1760
  try {
1705
1761
  deps.log(`Stopping broker (pid: ${pid})...`);
1706
1762
  deps.killProcess(pid, 'SIGTERM');
1707
- const exited = await waitForProcessExit(pid, timeout, deps);
1763
+ let exited = await waitForProcessExit(pid, timeout, deps);
1708
1764
  if (!exited) {
1709
1765
  // eslint-disable-next-line max-depth
1710
1766
  if (options.force) {
1711
1767
  deps.log('Graceful shutdown timed out, forcing...');
1712
- // eslint-disable-next-line max-depth
1713
- try {
1714
- deps.killProcess(pid, 'SIGKILL');
1715
- await waitForProcessExit(pid, 2000, deps);
1716
- }
1717
- catch {
1718
- // Ignore kill errors.
1768
+ if (identity && !(await matchesBrokerIdentity(identity, paths, deps))) {
1769
+ throw new Error('Broker identity changed before escalation; retained its state.');
1719
1770
  }
1771
+ deps.killProcess(pid, 'SIGKILL');
1772
+ exited = await waitForProcessExit(pid, 2000, deps);
1720
1773
  }
1721
1774
  else {
1722
1775
  deps.log(`Graceful shutdown timed out after ${timeout}ms. Use --force to kill.`);
1723
1776
  return;
1724
1777
  }
1725
1778
  }
1779
+ if (!exited) {
1780
+ throw new Error('Could not verify broker exit; retained its state for a later cleanup.');
1781
+ }
1782
+ if (identity)
1783
+ removeBrokerIdentity(paths, identity, deps);
1726
1784
  cleanupBrokerFiles(paths, deps);
1727
1785
  deps.log('Stopped');
1786
+ return;
1728
1787
  }
1729
1788
  catch (err) {
1730
1789
  const withCode = err;
1731
1790
  if (withCode.code === 'ESRCH') {
1791
+ removeBrokerIdentity(paths, identity, deps);
1732
1792
  cleanupBrokerFiles(paths, deps);
1733
1793
  deps.log('Cleaned up stale state');
1734
1794
  return;
1735
1795
  }
1736
1796
  deps.error(`Error stopping broker: ${toErrorMessage(err)}`);
1737
1797
  }
1798
+ deps.exit(1);
1738
1799
  }
1739
1800
  export async function runStatusCommand(deps, options) {
1740
1801
  const paths = deps.getProjectPaths();
@@ -1762,16 +1823,24 @@ export async function runStatusCommand(deps, options) {
1762
1823
  deps.exit(1);
1763
1824
  return;
1764
1825
  }
1765
- deps.log('Status: RUNNING');
1766
- deps.log('Mode: broker (stdio)');
1826
+ const statusDetails = readiness.statusDetails ?? (waitMs > 0 ? null : await readBrokerStatusDetails(readiness.conn));
1827
+ const localOnly = statusDetails?.status.mode === 'local_only' || readiness.conn.operation_mode === 'local_only';
1828
+ deps.log(localOnly ? 'Status: DEGRADED (LOCAL ONLY)' : 'Status: RUNNING');
1829
+ deps.log(localOnly ? 'Mode: local only' : 'Mode: broker (stdio)');
1830
+ if (localOnly) {
1831
+ deps.warn('Cross-machine routing, worker presence, remote delivery and remote attachment: DISABLED');
1832
+ const reconciliation = statusDetails?.status.degraded?.reconciliation;
1833
+ if (reconciliation) {
1834
+ deps.log(`Reconciliation: ${reconciliation.connected ? 'connected (audit only)' : reconciliation.configured ? 'disconnected' : 'not configured'}; pending records: ${reconciliation.pending_records}`);
1835
+ }
1836
+ }
1767
1837
  deps.log(`PID: ${readiness.conn.pid}`);
1768
1838
  deps.log(`Project: ${paths.projectRoot}`);
1769
1839
  const source = workspaceBindingSource(readiness.conn.workspace_source);
1770
1840
  deps.log(source
1771
1841
  ? `Workspace source: ${workspaceBindingSourceLabel(source)}`
1772
1842
  : 'Workspace source: unknown (startup provenance was not recorded)');
1773
- // Query the running broker for additional status info
1774
- const statusDetails = readiness.statusDetails ?? (waitMs > 0 ? null : await readBrokerStatusDetails(readiness.conn));
1843
+ // Additional runtime details use the same bounded snapshot as the mode above.
1775
1844
  if (!statusDetails || statusDetails.session === null) {
1776
1845
  deps.warn('Broker API details unavailable (request failed or exceeded the 2s limit).');
1777
1846
  }