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.
- package/README.md +64 -0
- package/dist/cli/agent-relay-mcp.d.ts.map +1 -1
- package/dist/cli/agent-relay-mcp.js +177 -28
- package/dist/cli/agent-relay-mcp.js.map +1 -1
- package/dist/cli/commands/core.d.ts +8 -0
- package/dist/cli/commands/core.d.ts.map +1 -1
- package/dist/cli/commands/core.js +4 -0
- package/dist/cli/commands/core.js.map +1 -1
- package/dist/cli/commands/fleet-agent.d.ts +5 -1
- package/dist/cli/commands/fleet-agent.d.ts.map +1 -1
- package/dist/cli/commands/fleet-agent.js +114 -26
- package/dist/cli/commands/fleet-agent.js.map +1 -1
- package/dist/cli/commands/fleet.d.ts.map +1 -1
- package/dist/cli/commands/fleet.js +93 -13
- package/dist/cli/commands/fleet.js.map +1 -1
- package/dist/cli/commands/node.js +4 -0
- package/dist/cli/commands/node.js.map +1 -1
- package/dist/cli/commands/status.d.ts +1 -1
- package/dist/cli/commands/status.d.ts.map +1 -1
- package/dist/cli/commands/status.js +7 -3
- package/dist/cli/commands/status.js.map +1 -1
- package/dist/cli/lib/broker-lifecycle.d.ts +2 -0
- package/dist/cli/lib/broker-lifecycle.d.ts.map +1 -1
- package/dist/cli/lib/broker-lifecycle.js +222 -153
- package/dist/cli/lib/broker-lifecycle.js.map +1 -1
- package/dist/cli/lib/broker-process-identity.d.ts +30 -0
- package/dist/cli/lib/broker-process-identity.d.ts.map +1 -0
- package/dist/cli/lib/broker-process-identity.js +229 -0
- package/dist/cli/lib/broker-process-identity.js.map +1 -0
- package/dist/cli/lib/sdk-command.d.ts.map +1 -1
- package/dist/cli/lib/sdk-command.js +20 -2
- package/dist/cli/lib/sdk-command.js.map +1 -1
- package/dist/cli/lib/spawn-lifecycle.d.ts +12 -0
- package/dist/cli/lib/spawn-lifecycle.d.ts.map +1 -0
- package/dist/cli/lib/spawn-lifecycle.js +89 -0
- package/dist/cli/lib/spawn-lifecycle.js.map +1 -0
- package/dist/index.cjs +156 -23
- 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
|
-
|
|
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(
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
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
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
918
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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-') &&
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
1495
|
-
//
|
|
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
|
|
1498
|
-
|
|
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 (
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1713
|
-
|
|
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
|
-
|
|
1766
|
-
|
|
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
|
-
//
|
|
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
|
}
|