@phnx-labs/agents-cli 1.22.33 → 1.22.35
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/CHANGELOG.md +55 -0
- package/README.md +9 -1
- package/dist/bin/agents +0 -0
- package/dist/commands/accounts.js +3 -3
- package/dist/commands/browser-sessions-picker.d.ts +16 -0
- package/dist/commands/browser-sessions-picker.js +179 -0
- package/dist/commands/browser.js +9 -4
- package/dist/commands/hosts.js +1 -5
- package/dist/commands/inspect.js +174 -41
- package/dist/commands/message.d.ts +6 -1
- package/dist/commands/message.js +60 -3
- package/dist/commands/sessions.d.ts +54 -4
- package/dist/commands/sessions.js +252 -46
- package/dist/commands/share.d.ts +18 -0
- package/dist/commands/share.js +108 -0
- package/dist/commands/ssh.js +17 -5
- package/dist/commands/teams.d.ts +28 -0
- package/dist/commands/teams.js +148 -13
- package/dist/commands/upgrade.d.ts +7 -0
- package/dist/commands/upgrade.js +10 -0
- package/dist/commands/watchdog.d.ts +2 -0
- package/dist/commands/watchdog.js +112 -27
- package/dist/index.js +51 -59
- package/dist/lib/agents.js +6 -0
- package/dist/lib/browser/sessions-list.d.ts +81 -0
- package/dist/lib/browser/sessions-list.js +179 -4
- package/dist/lib/codex-policy.d.ts +9 -1
- package/dist/lib/codex-policy.js +17 -2
- package/dist/lib/daemon.js +45 -5
- package/dist/lib/devices/connect.d.ts +33 -0
- package/dist/lib/devices/connect.js +61 -3
- package/dist/lib/devices/doctor-findings.d.ts +4 -2
- package/dist/lib/devices/doctor-findings.js +4 -2
- package/dist/lib/exec.js +65 -8
- package/dist/lib/help.d.ts +3 -2
- package/dist/lib/help.js +4 -0
- package/dist/lib/hosts/dispatch.d.ts +2 -32
- package/dist/lib/hosts/dispatch.js +6 -61
- package/dist/lib/hosts/tasks.d.ts +7 -0
- package/dist/lib/hosts/tasks.js +9 -0
- package/dist/lib/mailbox-target.d.ts +27 -0
- package/dist/lib/mailbox-target.js +21 -0
- package/dist/lib/mcp.d.ts +10 -0
- package/dist/lib/mcp.js +21 -2
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/migrate.d.ts +11 -0
- package/dist/lib/migrate.js +40 -0
- package/dist/lib/project-key.d.ts +17 -0
- package/dist/lib/project-key.js +26 -0
- package/dist/lib/project-root.d.ts +47 -0
- package/dist/lib/project-root.js +68 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/agent.d.ts +9 -2
- package/dist/lib/secrets/agent.js +52 -7
- package/dist/lib/secrets/reaper.d.ts +24 -3
- package/dist/lib/secrets/reaper.js +55 -6
- package/dist/lib/session/discover.js +12 -0
- package/dist/lib/session/render.d.ts +2 -0
- package/dist/lib/session/render.js +1 -1
- package/dist/lib/share/delete.d.ts +93 -0
- package/dist/lib/share/delete.js +127 -0
- package/dist/lib/shims.js +48 -4
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +11 -3
- package/dist/lib/startup/root-command.d.ts +3 -0
- package/dist/lib/startup/root-command.js +10 -0
- package/dist/lib/teams/agents.d.ts +136 -6
- package/dist/lib/teams/agents.js +324 -58
- package/dist/lib/teams/worktree.d.ts +39 -2
- package/dist/lib/teams/worktree.js +60 -4
- package/dist/lib/types.d.ts +11 -0
- package/dist/lib/versions.js +2 -2
- package/dist/lib/watchdog/history.d.ts +20 -0
- package/dist/lib/watchdog/history.js +46 -0
- package/dist/lib/watchdog/log.d.ts +16 -1
- package/dist/lib/watchdog/log.js +82 -2
- package/dist/lib/watchdog/runner.d.ts +12 -0
- package/dist/lib/watchdog/runner.js +20 -0
- package/package.json +1 -1
package/dist/lib/teams/agents.js
CHANGED
|
@@ -129,6 +129,46 @@ export var AgentStatus;
|
|
|
129
129
|
AgentStatus["FAILED"] = "failed";
|
|
130
130
|
AgentStatus["STOPPED"] = "stopped";
|
|
131
131
|
})(AgentStatus || (AgentStatus = {}));
|
|
132
|
+
/**
|
|
133
|
+
* The statuses a teammate can never leave — its process has run and finished
|
|
134
|
+
* (or been stopped). Everything else (pending, running) is still live work.
|
|
135
|
+
*
|
|
136
|
+
* This is the ONLY set that retention (cleanupOldAgents) may reap: a `pending`
|
|
137
|
+
* teammate has not launched yet and a `running` one is doing work, so deleting
|
|
138
|
+
* either is data loss. Treating "not running" as "completed" was the RUSH-2356
|
|
139
|
+
* bug — it swept live `pending` `--after` teammates past the 50-record cap.
|
|
140
|
+
*/
|
|
141
|
+
export const TERMINAL_STATUSES = new Set([
|
|
142
|
+
AgentStatus.COMPLETED,
|
|
143
|
+
AgentStatus.FAILED,
|
|
144
|
+
AgentStatus.STOPPED,
|
|
145
|
+
]);
|
|
146
|
+
/** True when a teammate has reached a terminal (completed/failed/stopped) status. */
|
|
147
|
+
export function isTerminalStatus(status) {
|
|
148
|
+
return TERMINAL_STATUSES.has(status);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* The per-teammate shell that emits `<id> <ALIVE|EXITED|GONE> <codeOrEmpty>`.
|
|
152
|
+
* Shared by the batched prefetch (many teammates, one round-trip) and the
|
|
153
|
+
* direct single-teammate probe, so both classify liveness identically.
|
|
154
|
+
* `exitFile` is interpolated UNQUOTED so `$HOME` in the dispatch path expands on
|
|
155
|
+
* the remote shell (shellQuote would defeat the `[ -f ]` test).
|
|
156
|
+
*/
|
|
157
|
+
export function remoteLivenessSnippet(id, exitFile, pid) {
|
|
158
|
+
return (`printf '%s ' ${shellQuote(id)}; ` +
|
|
159
|
+
`if [ -f ${exitFile} ]; then printf 'EXITED '; cat ${exitFile} 2>/dev/null | tr -d '\\n'; printf '\\n'; ` +
|
|
160
|
+
`elif kill -0 ${pid} 2>/dev/null; then printf 'ALIVE\\n'; ` +
|
|
161
|
+
`else printf 'GONE\\n'; fi`);
|
|
162
|
+
}
|
|
163
|
+
/** Parse one `<STATE> <codeOrEmpty>` reading into a snapshot. */
|
|
164
|
+
export function parseRemoteLivenessState(state, code) {
|
|
165
|
+
if (state === 'ALIVE')
|
|
166
|
+
return { alive: true, exit: null, exitFilePresent: false };
|
|
167
|
+
if (state === 'EXITED')
|
|
168
|
+
return { alive: false, exit: code ?? '', exitFilePresent: true };
|
|
169
|
+
// GONE (or an unrecognised token): process not alive, no sentinel recorded.
|
|
170
|
+
return { alive: false, exit: null, exitFilePresent: false };
|
|
171
|
+
}
|
|
132
172
|
export const VALID_TASK_TYPES = [
|
|
133
173
|
'plan', 'implement', 'test', 'review', 'bugfix', 'docs',
|
|
134
174
|
];
|
|
@@ -705,36 +745,60 @@ export class AgentProcess {
|
|
|
705
745
|
// best-effort mirror — leave the offset unadvanced so we retry next poll
|
|
706
746
|
}
|
|
707
747
|
}
|
|
708
|
-
// Resolve terminal status from the
|
|
709
|
-
//
|
|
710
|
-
//
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
if (snap) {
|
|
715
|
-
exit = snap.exit;
|
|
716
|
-
}
|
|
717
|
-
else if (this.remoteExit) {
|
|
718
|
-
// UNQUOTED so `$HOME` in the dispatch exit path expands on the remote shell.
|
|
719
|
-
const res = sshExec(this.hostTarget, `cat ${this.remoteExit} 2>/dev/null`, {
|
|
720
|
-
timeoutMs: 8000,
|
|
721
|
-
multiplex: true,
|
|
722
|
-
extraSshArgs: this.hostIdentityFile ? ['-i', this.hostIdentityFile, '-o', 'IdentitiesOnly=yes'] : [],
|
|
723
|
-
});
|
|
724
|
-
exit = res.code === 0 && res.stdout.trim() !== '' ? res.stdout.trim() : null;
|
|
725
|
-
}
|
|
748
|
+
// Resolve terminal status from the host. Prefer this wave's batched snapshot;
|
|
749
|
+
// else probe this teammate directly. The snapshot is left in place (refreshed
|
|
750
|
+
// each wave by prefetch), so a second poll pass within the same wave reuses it.
|
|
751
|
+
const snap = this.remotePollSnapshot ?? (await this.probeRemoteLiveness());
|
|
752
|
+
if (!snap)
|
|
753
|
+
return; // transient ssh failure — leave RUNNING, retry next poll
|
|
726
754
|
// Only latch terminal on a PARSEABLE exit code. A `.exit` that exists but is
|
|
727
755
|
// momentarily empty (created, not yet written) or garbage must NOT force a
|
|
728
756
|
// spurious FAILED — leave the teammate RUNNING and let the next poll resolve
|
|
729
|
-
// it once the code lands.
|
|
730
|
-
if (exit !== null && exit.trim() !== '' && this.status === AgentStatus.RUNNING) {
|
|
731
|
-
const code = Number.parseInt(exit.trim(), 10);
|
|
757
|
+
// it once the code lands.
|
|
758
|
+
if (snap.exit !== null && snap.exit.trim() !== '' && this.status === AgentStatus.RUNNING) {
|
|
759
|
+
const code = Number.parseInt(snap.exit.trim(), 10);
|
|
732
760
|
if (Number.isFinite(code)) {
|
|
733
761
|
this.status = code === 0 ? AgentStatus.COMPLETED : AgentStatus.FAILED;
|
|
734
762
|
if (!this.completedAt)
|
|
735
763
|
this.completedAt = new Date();
|
|
764
|
+
return;
|
|
736
765
|
}
|
|
737
766
|
}
|
|
767
|
+
// No exit code resolved it. If the remote process is GONE with NO sentinel at
|
|
768
|
+
// all, the wrapper died before recording `$?` (killed, box lost, OOM) — it can
|
|
769
|
+
// never write a code, so this teammate is FAILED, not "running forever"
|
|
770
|
+
// (RUSH-2366). This is the remote analog of reapProcess()'s "sentinel absent
|
|
771
|
+
// -> 1 -> FAILED". An EXITED-but-empty `.exit` (wrapper mid-write) is left
|
|
772
|
+
// RUNNING above precisely so this branch does not misfire on that race.
|
|
773
|
+
if (this.status === AgentStatus.RUNNING && !snap.alive && !snap.exitFilePresent) {
|
|
774
|
+
this.status = AgentStatus.FAILED;
|
|
775
|
+
if (!this.completedAt)
|
|
776
|
+
this.completedAt = this.getLatestEventTime() || this.startedAt || new Date();
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* One-shot direct liveness probe for a single remote teammate — the fallback
|
|
781
|
+
* used outside a batched supervisor wave (a bare `teams status`, `mgr.get()`
|
|
782
|
+
* for `teams resume`). Returns null on a transient ssh failure so the caller
|
|
783
|
+
* leaves the teammate RUNNING rather than reaping it on a dropped connection.
|
|
784
|
+
*/
|
|
785
|
+
async probeRemoteLiveness() {
|
|
786
|
+
if (!this.hostTarget || !this.remotePid || !this.remoteExit)
|
|
787
|
+
return null;
|
|
788
|
+
const res = sshExec(this.hostTarget, remoteLivenessSnippet(this.agentId, this.remoteExit, this.remotePid), {
|
|
789
|
+
timeoutMs: 8000,
|
|
790
|
+
multiplex: true,
|
|
791
|
+
extraSshArgs: this.hostIdentityFile ? ['-i', this.hostIdentityFile, '-o', 'IdentitiesOnly=yes'] : [],
|
|
792
|
+
});
|
|
793
|
+
if (res.code === null)
|
|
794
|
+
return null; // transient ssh failure — don't reap early
|
|
795
|
+
const trimmed = res.stdout.trim();
|
|
796
|
+
if (!trimmed)
|
|
797
|
+
return null;
|
|
798
|
+
const [, state, code] = trimmed.split(/\s+/);
|
|
799
|
+
if (!state)
|
|
800
|
+
return null;
|
|
801
|
+
return parseRemoteLivenessState(state, code);
|
|
738
802
|
}
|
|
739
803
|
/** Reset the local stdout cursor for a newly truncated resume log. */
|
|
740
804
|
resetLogReadPosition() {
|
|
@@ -1015,12 +1079,64 @@ export class AgentProcess {
|
|
|
1015
1079
|
}
|
|
1016
1080
|
return true;
|
|
1017
1081
|
}
|
|
1082
|
+
/**
|
|
1083
|
+
* Read just the persisted status + completion time from meta.json, without
|
|
1084
|
+
* reconstructing the whole teammate. Returns null when there is no readable
|
|
1085
|
+
* record on disk. Used to detect that ANOTHER process (a `teams stop`, a
|
|
1086
|
+
* sibling supervisor) has already moved this teammate to a terminal status.
|
|
1087
|
+
*/
|
|
1088
|
+
async readDiskStatus() {
|
|
1089
|
+
let raw;
|
|
1090
|
+
try {
|
|
1091
|
+
raw = await fs.readFile(await this.getMetaPath(), 'utf-8');
|
|
1092
|
+
}
|
|
1093
|
+
catch {
|
|
1094
|
+
return null;
|
|
1095
|
+
}
|
|
1096
|
+
try {
|
|
1097
|
+
const meta = JSON.parse(raw);
|
|
1098
|
+
const validStatuses = Object.values(AgentStatus);
|
|
1099
|
+
const status = validStatuses.includes(meta.status)
|
|
1100
|
+
? meta.status
|
|
1101
|
+
: AgentStatus.RUNNING;
|
|
1102
|
+
const completedAt = meta.completed_at ? new Date(meta.completed_at) : null;
|
|
1103
|
+
return { status, completedAt };
|
|
1104
|
+
}
|
|
1105
|
+
catch {
|
|
1106
|
+
return null;
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
/**
|
|
1110
|
+
* If this in-memory teammate is still non-terminal but disk already shows a
|
|
1111
|
+
* terminal status, adopt the disk state. Returns true when it did.
|
|
1112
|
+
*
|
|
1113
|
+
* This is the guard against the stale-manager race (RUSH-2366): a long-lived
|
|
1114
|
+
* supervisor holding a teammate as `running` must never re-persist that stale
|
|
1115
|
+
* `running` over a `stopped`/`failed`/`completed` another process just wrote
|
|
1116
|
+
* (e.g. an explicit `teams stop` in a separate CLI invocation). A terminal
|
|
1117
|
+
* status is a one-way latch, so disk-terminal always wins over memory-running.
|
|
1118
|
+
*/
|
|
1119
|
+
async adoptDiskTerminalIfNewer() {
|
|
1120
|
+
if (isTerminalStatus(this.status))
|
|
1121
|
+
return false;
|
|
1122
|
+
const disk = await this.readDiskStatus();
|
|
1123
|
+
if (!disk || !isTerminalStatus(disk.status))
|
|
1124
|
+
return false;
|
|
1125
|
+
this.status = disk.status;
|
|
1126
|
+
this.completedAt = disk.completedAt ?? this.completedAt ?? new Date();
|
|
1127
|
+
return true;
|
|
1128
|
+
}
|
|
1018
1129
|
/**
|
|
1019
1130
|
* @param opts.skipRemote A `--local` caller (RUSH-2118): a distributed
|
|
1020
1131
|
* teammate is never dialed — its in-memory state (already loaded from
|
|
1021
1132
|
* meta.json) stands as-is, no ssh, no re-save.
|
|
1022
1133
|
*/
|
|
1023
1134
|
async updateStatusFromProcess(opts = {}) {
|
|
1135
|
+
// Stale-manager guard (RUSH-2366): if disk has already latched this teammate
|
|
1136
|
+
// terminal, adopt that and stop — a poll of a process that no longer exists
|
|
1137
|
+
// must not re-persist `running` over the newer on-disk terminal status.
|
|
1138
|
+
if (await this.adoptDiskTerminalIfNewer())
|
|
1139
|
+
return;
|
|
1024
1140
|
if (!this.pid) {
|
|
1025
1141
|
// Distributed (remote-host) teammates have no local PID by design; their
|
|
1026
1142
|
// lifecycle lives on the host. readNewEvents() mirrors the remote log and
|
|
@@ -1030,6 +1146,15 @@ export class AgentProcess {
|
|
|
1030
1146
|
if (this.hostName) {
|
|
1031
1147
|
if (opts.skipRemote)
|
|
1032
1148
|
return;
|
|
1149
|
+
// Staged (--after) distributed teammates also have hostName set but no
|
|
1150
|
+
// PID yet (RUSH-2356 sibling bug): without this guard the `!== RUNNING`
|
|
1151
|
+
// fallback below stamps a completedAt on a teammate that hasn't even
|
|
1152
|
+
// launched, which the age-based reap in loadExistingAgents() would
|
|
1153
|
+
// later delete outright once it aged past cleanupAgeDays. Leave it
|
|
1154
|
+
// alone until startReady() launches it — matches the local-only guard
|
|
1155
|
+
// further below.
|
|
1156
|
+
if (this.status === AgentStatus.PENDING)
|
|
1157
|
+
return;
|
|
1033
1158
|
await this.readNewEvents();
|
|
1034
1159
|
if (this.status !== AgentStatus.RUNNING && !this.completedAt) {
|
|
1035
1160
|
this.completedAt = this.getLatestEventTime() || this.startedAt || new Date();
|
|
@@ -1041,6 +1166,10 @@ export class AgentProcess {
|
|
|
1041
1166
|
// Cloud-backed teammates have no local PID by design; their lifecycle
|
|
1042
1167
|
// is driven by the remote provider instead of a local process.
|
|
1043
1168
|
if (this.cloudProvider) {
|
|
1169
|
+
// Same staged-teammate guard as the hostName branch above — a staged
|
|
1170
|
+
// cloud teammate is PENDING with no PID until its deps resolve.
|
|
1171
|
+
if (this.status === AgentStatus.PENDING)
|
|
1172
|
+
return;
|
|
1044
1173
|
if (!this.completedAt && this.status !== AgentStatus.RUNNING) {
|
|
1045
1174
|
const fallbackCompletion = this.getLatestEventTime() || this.startedAt || new Date();
|
|
1046
1175
|
this.completedAt = fallbackCompletion;
|
|
@@ -1207,6 +1336,13 @@ export class AgentManager {
|
|
|
1207
1336
|
*/
|
|
1208
1337
|
localOnly;
|
|
1209
1338
|
constructorAgentsDir = null;
|
|
1339
|
+
/**
|
|
1340
|
+
* One-shot memo of the last `validateAddPreconditions` result, so the
|
|
1341
|
+
* command-layer pre-worktree call and spawn()'s own call don't each pay a
|
|
1342
|
+
* full `listAll()` status refresh (a round of SSH probes on a `--device`
|
|
1343
|
+
* team). Consumed by the first matching call — see that method.
|
|
1344
|
+
*/
|
|
1345
|
+
validatedAdd = null;
|
|
1210
1346
|
constructor(maxAgents = 50, agentsDir = null, defaultMode = null, filterByCwd = null, cleanupAgeDays = 7, localOnly = false) {
|
|
1211
1347
|
this.maxAgents = maxAgents;
|
|
1212
1348
|
this.constructorAgentsDir = agentsDir;
|
|
@@ -1251,8 +1387,12 @@ export class AgentManager {
|
|
|
1251
1387
|
* manager is alive — the supervisor loop calls this each wave so
|
|
1252
1388
|
* dynamically-added teammates get picked up.
|
|
1253
1389
|
*
|
|
1254
|
-
*
|
|
1255
|
-
*
|
|
1390
|
+
* For a teammate ALREADY cached, refreshes it only when disk has latched it
|
|
1391
|
+
* terminal while the cache still holds it non-terminal — the case where
|
|
1392
|
+
* another process (e.g. `agents teams stop` in a separate CLI invocation)
|
|
1393
|
+
* moved it to `stopped`/`failed` and this long-lived manager would otherwise
|
|
1394
|
+
* never see it and re-persist a stale `running` (RUSH-2366). A still-live
|
|
1395
|
+
* cached teammate is left untouched; updateStatusFromProcess() owns that path.
|
|
1256
1396
|
*/
|
|
1257
1397
|
async rescanFromDisk() {
|
|
1258
1398
|
await this.initialize();
|
|
@@ -1265,12 +1405,23 @@ export class AgentManager {
|
|
|
1265
1405
|
const entries = await fs.readdir(this.agentsDir);
|
|
1266
1406
|
let added = 0;
|
|
1267
1407
|
for (const entry of entries) {
|
|
1268
|
-
if (this.agents.has(entry))
|
|
1269
|
-
continue;
|
|
1270
1408
|
const agentDir = path.join(this.agentsDir, entry);
|
|
1271
1409
|
const stat = await fs.stat(agentDir).catch(() => null);
|
|
1272
1410
|
if (!stat || !stat.isDirectory())
|
|
1273
1411
|
continue;
|
|
1412
|
+
const cached = this.agents.get(entry);
|
|
1413
|
+
if (cached) {
|
|
1414
|
+
// Adopt a disk-terminal status the cache hasn't seen; never overwrite a
|
|
1415
|
+
// cached teammate that is still live with a stale disk read. Terminal is
|
|
1416
|
+
// a one-way latch, so this can only move a teammate forward.
|
|
1417
|
+
if (!isTerminalStatus(cached.status)) {
|
|
1418
|
+
const fresh = await AgentProcess.loadFromDisk(entry, this.agentsDir);
|
|
1419
|
+
if (fresh && isTerminalStatus(fresh.status)) {
|
|
1420
|
+
this.agents.set(entry, fresh);
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
continue;
|
|
1424
|
+
}
|
|
1274
1425
|
const agent = await AgentProcess.loadFromDisk(entry, this.agentsDir);
|
|
1275
1426
|
if (!agent)
|
|
1276
1427
|
continue;
|
|
@@ -1302,7 +1453,13 @@ export class AgentManager {
|
|
|
1302
1453
|
const agent = await AgentProcess.loadFromDisk(agentId, this.agentsDir);
|
|
1303
1454
|
if (!agent)
|
|
1304
1455
|
continue;
|
|
1305
|
-
|
|
1456
|
+
// Age-based reap is a SECOND retention mechanism, independent of
|
|
1457
|
+
// cleanupOldAgents()'s cap-based one — and must obey the same invariant
|
|
1458
|
+
// (RUSH-2356): a non-terminal teammate is never a reap candidate,
|
|
1459
|
+
// however old its (possibly spuriously stamped) completedAt is. Belt and
|
|
1460
|
+
// suspenders alongside the PENDING guards above that stop completedAt
|
|
1461
|
+
// from getting set on a staged teammate in the first place.
|
|
1462
|
+
if (agent.completedAt && agent.completedAt < cutoffDate && isTerminalStatus(agent.status)) {
|
|
1306
1463
|
try {
|
|
1307
1464
|
await fs.rm(agentDir, { recursive: true });
|
|
1308
1465
|
cleanedOld++;
|
|
@@ -1331,23 +1488,38 @@ export class AgentManager {
|
|
|
1331
1488
|
}
|
|
1332
1489
|
debug(`Loaded ${loadedCount} agents from disk`);
|
|
1333
1490
|
}
|
|
1334
|
-
|
|
1491
|
+
/**
|
|
1492
|
+
* Validate an add's name uniqueness and `--after` dependency graph, without
|
|
1493
|
+
* any side effects. Throws a user-facing error on: a duplicate name, `--after`
|
|
1494
|
+
* without `--name`, an unknown dependency, or a cycle. Returns the cleaned
|
|
1495
|
+
* (whitespace-filtered) `after` list.
|
|
1496
|
+
*
|
|
1497
|
+
* Extracted from spawn() so the command layer can run it BEFORE creating a
|
|
1498
|
+
* worktree — a rejected add must not leave an orphan `agents/<name>` branch
|
|
1499
|
+
* that then breaks the retry with `fatal: a branch ... already exists`
|
|
1500
|
+
* (RUSH-2356). spawn() calls it too, so validation lives in exactly one place.
|
|
1501
|
+
*
|
|
1502
|
+
* The result is cached for exactly ONE subsequent call with the same
|
|
1503
|
+
* arguments, which spawn() then consumes. `listByTask()` → `listAll()`
|
|
1504
|
+
* refreshes every sibling's status, and on a `--device` team that is a full
|
|
1505
|
+
* round of SSH liveness probes — running it twice per `teams add` would
|
|
1506
|
+
* double that cost for no gain, since the second pass reads the same snapshot
|
|
1507
|
+
* and cannot catch anything the first missed. The cache is single-use so any
|
|
1508
|
+
* later spawn (a `teams start --watch` supervisor launching staged teammates)
|
|
1509
|
+
* still validates against fresh state and still rejects a duplicate name.
|
|
1510
|
+
*/
|
|
1511
|
+
async validateAddPreconditions(taskName, name, after) {
|
|
1335
1512
|
await this.initialize();
|
|
1336
|
-
const
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
// session; a team started outside any agent simply carries none.
|
|
1342
|
-
if (!parentSessionId) {
|
|
1343
|
-
parentSessionId = process.env.AGENTS_SESSION_ID ?? null;
|
|
1513
|
+
const key = JSON.stringify([taskName, name, after]);
|
|
1514
|
+
if (this.validatedAdd?.key === key) {
|
|
1515
|
+
const cached = this.validatedAdd.cleanAfter;
|
|
1516
|
+
this.validatedAdd = null; // single use
|
|
1517
|
+
return cached;
|
|
1344
1518
|
}
|
|
1345
|
-
// Enforce: teammate names are unique within a team.
|
|
1346
1519
|
const siblings = await this.listByTask(taskName);
|
|
1347
1520
|
if (name && siblings.some((a) => a.name === name)) {
|
|
1348
1521
|
throw new Error(`Team '${taskName}' already has a teammate named '${name}'. Pick another name or leave --name off.`);
|
|
1349
1522
|
}
|
|
1350
|
-
// --- dependency validation ---
|
|
1351
1523
|
const cleanAfter = after.filter((s) => s && s.trim());
|
|
1352
1524
|
if (cleanAfter.length > 0) {
|
|
1353
1525
|
if (!name) {
|
|
@@ -1369,6 +1541,88 @@ export class AgentManager {
|
|
|
1369
1541
|
}
|
|
1370
1542
|
}
|
|
1371
1543
|
}
|
|
1544
|
+
this.validatedAdd = { key, cleanAfter };
|
|
1545
|
+
return cleanAfter;
|
|
1546
|
+
}
|
|
1547
|
+
/**
|
|
1548
|
+
* Does any LIVE teammate — in any team — already own `worktreeName`?
|
|
1549
|
+
*
|
|
1550
|
+
* A RAW disk scan: no status probing, no cache, no `listAll()`. The caller is
|
|
1551
|
+
* the `teams add` failure path, where the manager's own status refresh can be
|
|
1552
|
+
* the very thing that threw (`cleanupOldAgents()` → `listAll()` →
|
|
1553
|
+
* `updateStatusFromProcess()` runs AFTER the staged record is saved), so a
|
|
1554
|
+
* check that re-entered that machinery would throw again and answer nothing.
|
|
1555
|
+
*
|
|
1556
|
+
* `teams add` asks this before removing a worktree, to tell an ORPHAN from
|
|
1557
|
+
* someone's live checkout (RUSH-2356). Two deliberate scoping choices:
|
|
1558
|
+
*
|
|
1559
|
+
* - **Any team, not just the one being added to.** Worktree names are global
|
|
1560
|
+
* to the repo but records are per-team, so a same-named worktree owned by
|
|
1561
|
+
* another team's teammate must also block the removal.
|
|
1562
|
+
* - **Non-terminal records only.** A completed/failed/stopped teammate's
|
|
1563
|
+
* worktree was already cleaned up at `teams stop`, and its record lingers
|
|
1564
|
+
* until retention reaps it — counting those would leave a genuine orphan
|
|
1565
|
+
* branch stranded forever, which is the bug this all exists to fix.
|
|
1566
|
+
* - **Fails CLOSED.** This guards a `git worktree remove --force`, so the two
|
|
1567
|
+
* errors are not symmetric: a false "claimed" strands an orphan branch that
|
|
1568
|
+
* a human can delete, while a false "unclaimed" deletes a live agent's
|
|
1569
|
+
* checkout and its uncommitted work. Only `ENOENT` proves absence — no
|
|
1570
|
+
* agents dir means no records, and a record with no `meta.json` is not a
|
|
1571
|
+
* record. Any other failure (EACCES, EIO, half-written or invalid JSON,
|
|
1572
|
+
* a race with a writer) means we could not READ the records, which is not
|
|
1573
|
+
* the same as there being none, so it answers `true`. This is deliberate
|
|
1574
|
+
* asymmetry, not defensive coding: the caller acts destructively on `false`.
|
|
1575
|
+
*/
|
|
1576
|
+
async isWorktreeClaimed(worktreeName) {
|
|
1577
|
+
const base = this.agentsDir ?? (await getAgentsDir());
|
|
1578
|
+
let entries;
|
|
1579
|
+
try {
|
|
1580
|
+
entries = await fs.readdir(base);
|
|
1581
|
+
}
|
|
1582
|
+
catch (err) {
|
|
1583
|
+
// ENOENT is the only error that PROVES nothing claims the worktree: there
|
|
1584
|
+
// are no records at all. Every other failure (EACCES, EIO, a transient
|
|
1585
|
+
// races with a writer) means we could not read the records, which is not
|
|
1586
|
+
// the same as there being none — fail closed.
|
|
1587
|
+
if (err?.code === 'ENOENT')
|
|
1588
|
+
return false;
|
|
1589
|
+
return true;
|
|
1590
|
+
}
|
|
1591
|
+
for (const entry of entries) {
|
|
1592
|
+
try {
|
|
1593
|
+
const raw = await fs.readFile(path.join(base, entry, 'meta.json'), 'utf-8');
|
|
1594
|
+
const meta = JSON.parse(raw);
|
|
1595
|
+
if (meta?.worktree_name !== worktreeName)
|
|
1596
|
+
continue;
|
|
1597
|
+
if (!isTerminalStatus(meta?.status))
|
|
1598
|
+
return true;
|
|
1599
|
+
}
|
|
1600
|
+
catch (err) {
|
|
1601
|
+
// A record without a meta.json is not a record — skip it. Anything else
|
|
1602
|
+
// (unreadable, half-written, invalid JSON) may be the very record that
|
|
1603
|
+
// claims this worktree, and we cannot tell. Fail closed.
|
|
1604
|
+
if (err?.code === 'ENOENT')
|
|
1605
|
+
continue;
|
|
1606
|
+
return true;
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
return false;
|
|
1610
|
+
}
|
|
1611
|
+
async spawn(taskName, agentType, prompt, cwd = null, mode = null, effort = 'medium', parentSessionId = null, workspaceDir = null, version = null, name = null, after = [], model = null, envOverrides = null, taskType = null, cloudProvider = null, cloudSessionId = null, cloudRepo = null, cloudBranch = null, worktreeName = null, worktreePath = null, profileName = null, hostName = null, hostTarget = null, repoPath = null) {
|
|
1612
|
+
await this.initialize();
|
|
1613
|
+
const resolvedMode = resolveMode(mode, this.defaultMode);
|
|
1614
|
+
// Lineage (RUSH-2019): when the caller didn't name a parent, inherit the
|
|
1615
|
+
// orchestrator's own session id from its env (exec.ts stamps AGENTS_SESSION_ID
|
|
1616
|
+
// onto every agent process). A team spawned from inside a running agent then
|
|
1617
|
+
// records which session created it, so the spawn chain traces back to a parent
|
|
1618
|
+
// session; a team started outside any agent simply carries none.
|
|
1619
|
+
if (!parentSessionId) {
|
|
1620
|
+
parentSessionId = process.env.AGENTS_SESSION_ID ?? null;
|
|
1621
|
+
}
|
|
1622
|
+
// Validate name uniqueness + --after deps. Throws on any violation. The
|
|
1623
|
+
// command layer calls this BEFORE creating a worktree so a rejected add
|
|
1624
|
+
// never leaves an orphan `agents/<name>` branch behind (RUSH-2356).
|
|
1625
|
+
const cleanAfter = await this.validateAddPreconditions(taskName, name, after);
|
|
1372
1626
|
// Resolve and validate cwd
|
|
1373
1627
|
let resolvedCwd = null;
|
|
1374
1628
|
if (cwd !== null) {
|
|
@@ -1454,6 +1708,18 @@ export class AgentManager {
|
|
|
1454
1708
|
await this.launchProcess(agent);
|
|
1455
1709
|
}
|
|
1456
1710
|
await this.cleanupOldAgents();
|
|
1711
|
+
// Postcondition: the teammate MUST be durably on disk before we report
|
|
1712
|
+
// success. saveMeta() ran above and cleanupOldAgents() can no longer reap a
|
|
1713
|
+
// non-terminal record, but a failed write (full disk, permissions) or any
|
|
1714
|
+
// future retention regression would otherwise let `teams add` print a full
|
|
1715
|
+
// success block for a teammate that does not exist — the RUSH-2356
|
|
1716
|
+
// silent-success class. Assert the outcome, not the exit code.
|
|
1717
|
+
const persisted = await AgentProcess.loadFromDisk(agentId, this.agentsDir);
|
|
1718
|
+
if (!persisted) {
|
|
1719
|
+
this.agents.delete(agentId);
|
|
1720
|
+
throw new Error(`Teammate '${name ?? agentId}' was not durably persisted to disk after add ` +
|
|
1721
|
+
`(no meta.json under ${this.agentsDir}/${agentId}). The add did not take effect.`);
|
|
1722
|
+
}
|
|
1457
1723
|
return agent;
|
|
1458
1724
|
}
|
|
1459
1725
|
/**
|
|
@@ -1890,20 +2156,12 @@ export class AgentManager {
|
|
|
1890
2156
|
byTarget.set(key, group);
|
|
1891
2157
|
}
|
|
1892
2158
|
for (const { target, agents } of byTarget.values()) {
|
|
1893
|
-
// Emit one line per teammate: "<agentId> ALIVE|
|
|
1894
|
-
// round-trip over the multiplexed socket, regardless of teammate
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
// always miss, so a finished teammate would never resolve terminal).
|
|
1900
|
-
const exitFile = a.remoteExit;
|
|
1901
|
-
// exit code (if the sentinel exists) OR empty, then liveness.
|
|
1902
|
-
return (`printf '%s ' ${shellQuote(id)}; ` +
|
|
1903
|
-
`if [ -f ${exitFile} ]; then printf 'DEAD '; cat ${exitFile} 2>/dev/null | tr -d '\\n'; printf '\\n'; ` +
|
|
1904
|
-
`elif kill -0 ${a.remotePid} 2>/dev/null; then printf 'ALIVE\\n'; ` +
|
|
1905
|
-
`else printf 'DEAD\\n'; fi`);
|
|
1906
|
-
});
|
|
2159
|
+
// Emit one line per teammate: "<agentId> <ALIVE|EXITED|GONE> <codeOrEmpty>".
|
|
2160
|
+
// A single round-trip over the multiplexed socket, regardless of teammate
|
|
2161
|
+
// count. GONE (process gone, no `.exit`) is kept distinct from EXITED so a
|
|
2162
|
+
// teammate killed without recording `$?` resolves terminal instead of
|
|
2163
|
+
// reporting RUNNING forever (RUSH-2366).
|
|
2164
|
+
const parts = agents.map((a) => remoteLivenessSnippet(a.agentId, a.remoteExit, a.remotePid));
|
|
1907
2165
|
const identityFile = agents[0]?.hostIdentityFile;
|
|
1908
2166
|
const res = sshExec(target, parts.join('; '), {
|
|
1909
2167
|
timeoutMs: 12000,
|
|
@@ -1917,13 +2175,10 @@ export class AgentManager {
|
|
|
1917
2175
|
const trimmed = line.trim();
|
|
1918
2176
|
if (!trimmed)
|
|
1919
2177
|
continue;
|
|
1920
|
-
const [id, state,
|
|
1921
|
-
if (!id)
|
|
2178
|
+
const [id, state, code] = trimmed.split(/\s+/);
|
|
2179
|
+
if (!id || !state)
|
|
1922
2180
|
continue;
|
|
1923
|
-
snapshots.set(id,
|
|
1924
|
-
alive: state === 'ALIVE',
|
|
1925
|
-
exit: state === 'DEAD' ? (exit ?? '') : null,
|
|
1926
|
-
});
|
|
2181
|
+
snapshots.set(id, parseRemoteLivenessState(state, code));
|
|
1927
2182
|
}
|
|
1928
2183
|
for (const a of agents) {
|
|
1929
2184
|
const snap = snapshots.get(a.agentId);
|
|
@@ -2137,9 +2392,16 @@ export class AgentManager {
|
|
|
2137
2392
|
const all = await this.listAll();
|
|
2138
2393
|
return all.filter(a => a.status === AgentStatus.RUNNING);
|
|
2139
2394
|
}
|
|
2395
|
+
/**
|
|
2396
|
+
* Teammates that have reached a terminal status (completed/failed/stopped) —
|
|
2397
|
+
* the ONLY records retention may reap. A `pending` teammate has not launched
|
|
2398
|
+
* and a `running` one is working, so neither is "completed"; classifying them
|
|
2399
|
+
* as such let cleanupOldAgents sweep live `pending` `--after` teammates past
|
|
2400
|
+
* the cap (RUSH-2356). Filter on `isTerminalStatus`, never `!== RUNNING`.
|
|
2401
|
+
*/
|
|
2140
2402
|
async listCompleted() {
|
|
2141
2403
|
const all = await this.listAll();
|
|
2142
|
-
return all.filter(a => a.status
|
|
2404
|
+
return all.filter(a => isTerminalStatus(a.status));
|
|
2143
2405
|
}
|
|
2144
2406
|
async listByTask(taskName) {
|
|
2145
2407
|
const all = await this.listAll();
|
|
@@ -2236,6 +2498,10 @@ export class AgentManager {
|
|
|
2236
2498
|
}
|
|
2237
2499
|
}
|
|
2238
2500
|
async cleanupOldAgents() {
|
|
2501
|
+
// listCompleted() is terminal-only (isTerminalStatus), so a pending or
|
|
2502
|
+
// running teammate is never a reap candidate — retention can only delete a
|
|
2503
|
+
// record whose process has finished. (RUSH-2356: the old `!== RUNNING`
|
|
2504
|
+
// filter reaped live `pending` `--after` teammates.)
|
|
2239
2505
|
const completed = await this.listCompleted();
|
|
2240
2506
|
if (completed.length > this.maxAgents) {
|
|
2241
2507
|
completed.sort((a, b) => {
|
|
@@ -34,7 +34,16 @@ export declare function localDefaultBranch(gitRoot: string): Promise<string>;
|
|
|
34
34
|
* code. Matches `createRemoteWorktree` — local and remote isolation share one
|
|
35
35
|
* base policy.
|
|
36
36
|
*
|
|
37
|
-
*
|
|
37
|
+
* Resolves the placement root with {@link getMainRepoRoot}, NOT the local
|
|
38
|
+
* `getGitRoot`/`--show-toplevel`, on purpose: when `repoDir` (the caller's
|
|
39
|
+
* ambient cwd) is itself inside another teammate's linked worktree,
|
|
40
|
+
* `--show-toplevel` returns THAT worktree's own root, so the new worktree
|
|
41
|
+
* lands nested inside it (observed: `.../worktrees/A/.agents/worktrees/B`,
|
|
42
|
+
* destroyed along with A's cleanup). `getMainRepoRoot` follows
|
|
43
|
+
* `--git-common-dir`, which always points at the primary checkout's `.git`
|
|
44
|
+
* regardless of which worktree the caller is standing in.
|
|
45
|
+
*
|
|
46
|
+
* @param repoDir - Directory inside the git repository (main checkout or any linked worktree)
|
|
38
47
|
* @param worktreeName - Name for the worktree (used in path and branch)
|
|
39
48
|
* @returns The absolute path to the created worktree
|
|
40
49
|
*/
|
|
@@ -42,7 +51,12 @@ export declare function createWorktree(repoDir: string, worktreeName: string): P
|
|
|
42
51
|
/**
|
|
43
52
|
* Remove a git worktree and optionally its branch.
|
|
44
53
|
*
|
|
45
|
-
*
|
|
54
|
+
* Resolves via {@link getMainRepoRoot}, same as {@link createWorktree} — a
|
|
55
|
+
* caller standing inside a DIFFERENT linked worktree (e.g. a `teams stop`
|
|
56
|
+
* run from within another teammate's checkout) must still target the main
|
|
57
|
+
* repo's `.agents/worktrees/<name>`, not `--show-toplevel`'s local answer.
|
|
58
|
+
*
|
|
59
|
+
* @param repoDir - Directory inside the git repository (main checkout or any linked worktree)
|
|
46
60
|
* @param worktreeName - Name of the worktree to remove
|
|
47
61
|
* @param deleteBranch - Whether to delete the associated branch
|
|
48
62
|
*/
|
|
@@ -55,3 +69,26 @@ export declare function getWorktreePath(gitRoot: string, worktreeName: string):
|
|
|
55
69
|
* Get the branch name for a worktree.
|
|
56
70
|
*/
|
|
57
71
|
export declare function getWorktreeBranch(worktreeName: string): string;
|
|
72
|
+
/**
|
|
73
|
+
* Does the CHECKOUT DIRECTORY for this worktree name exist?
|
|
74
|
+
*
|
|
75
|
+
* Narrower than {@link worktreeExists}, and the difference is load-bearing:
|
|
76
|
+
* `teams add` only ever cleans up after a failed create when there is NO
|
|
77
|
+
* checkout — i.e. when all that can be left is a dangling `agents/<name>`
|
|
78
|
+
* branch ref. That keeps the cleanup incapable of deleting anybody's files,
|
|
79
|
+
* including a concurrent add's freshly-created worktree, whatever the
|
|
80
|
+
* pre-flight probe saw a `git fetch` ago. (RUSH-2356)
|
|
81
|
+
*/
|
|
82
|
+
export declare function worktreeCheckoutExists(repoDir: string, worktreeName: string): Promise<boolean>;
|
|
83
|
+
/**
|
|
84
|
+
* Does anything already exist under this worktree name — the checkout directory
|
|
85
|
+
* or its `agents/<name>` branch?
|
|
86
|
+
*
|
|
87
|
+
* Answered from git and the filesystem, never from teammate records: it is asked
|
|
88
|
+
* by `teams add` immediately BEFORE {@link createWorktree}, so that if the create
|
|
89
|
+
* fails the command can tell "the branch ref I half-created" (safe to remove)
|
|
90
|
+
* from "something that was already here" (never remove — `teams stop` deliberately
|
|
91
|
+
* KEEPS a worktree holding uncommitted changes, and its teammate record is
|
|
92
|
+
* terminal by then, so no record-based check can protect it). (RUSH-2356)
|
|
93
|
+
*/
|
|
94
|
+
export declare function worktreeExists(repoDir: string, worktreeName: string): Promise<boolean>;
|