@phnx-labs/agents-cli 1.20.49 → 1.20.50
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 +7 -0
- package/README.md +3 -0
- package/dist/commands/doctor.js +133 -2
- package/dist/commands/teams.js +217 -70
- package/dist/lib/exec.js +12 -4
- package/dist/lib/hosts/passthrough.js +30 -1
- package/dist/lib/hosts/progress.d.ts +31 -0
- package/dist/lib/hosts/progress.js +35 -0
- package/dist/lib/hosts/remote-cmd.d.ts +1 -1
- package/dist/lib/hosts/remote-cmd.js +13 -3
- package/dist/lib/shims.d.ts +17 -0
- package/dist/lib/shims.js +29 -0
- package/dist/lib/teams/agents.d.ts +108 -1
- package/dist/lib/teams/agents.js +511 -11
- package/dist/lib/teams/api.d.ts +7 -1
- package/dist/lib/teams/api.js +5 -2
- package/dist/lib/teams/registry.d.ts +17 -0
- package/dist/lib/teams/registry.js +2 -0
- package/dist/lib/teams/remoteWorktree.d.ts +57 -0
- package/dist/lib/teams/remoteWorktree.js +213 -0
- package/dist/lib/teams/scheduler.d.ts +29 -0
- package/dist/lib/teams/scheduler.js +78 -0
- package/dist/lib/teams/supervisor.js +7 -0
- package/dist/lib/versions.d.ts +7 -0
- package/dist/lib/versions.js +42 -1
- package/package.json +1 -1
package/dist/lib/teams/agents.js
CHANGED
|
@@ -20,9 +20,20 @@ import { debug } from './debug.js';
|
|
|
20
20
|
import { setGeminiAutoUpdateDisabled, updateGeminiSettings } from '../gemini-settings.js';
|
|
21
21
|
import { getAgentsDir as getSystemAgentsDir, getShimsDir } from '../state.js';
|
|
22
22
|
import { AGENTS, getAccountInfo } from '../agents.js';
|
|
23
|
-
import { resolveVersion, isVersionInstalled } from '../versions.js';
|
|
23
|
+
import { resolveVersion, isVersionInstalled, verifyInstalledBinaryLaunches } from '../versions.js';
|
|
24
24
|
import { sanitizeProcessEnv } from '../secrets/bundles.js';
|
|
25
25
|
import { recordRunName } from '../session/run-names.js';
|
|
26
|
+
import { sshExec, shellQuote } from '../ssh-exec.js';
|
|
27
|
+
import { resolveHost } from '../hosts/registry.js';
|
|
28
|
+
import { sshTargetFor } from '../hosts/types.js';
|
|
29
|
+
import { dispatchAgentsCommand } from '../hosts/dispatch.js';
|
|
30
|
+
import { ensureHostReady } from '../hosts/ready.js';
|
|
31
|
+
import { remoteShellFor } from '../hosts/remote-cmd.js';
|
|
32
|
+
import { resolveRemoteOsSync } from '../hosts/remote-os.js';
|
|
33
|
+
import { pullRemoteLogDelta, REMOTE_MIRROR_MAX_BYTES } from '../hosts/progress.js';
|
|
34
|
+
import { createRemoteWorktree, ensureRemoteRepo } from './remoteWorktree.js';
|
|
35
|
+
import { getTeam } from './registry.js';
|
|
36
|
+
import { resolvePlacement } from './scheduler.js';
|
|
26
37
|
let lastMemoryWarnAt = 0;
|
|
27
38
|
// On macOS, os.freemem() returns only the truly-free pool and ignores the
|
|
28
39
|
// large inactive+purgeable cache the kernel will reclaim under pressure, so
|
|
@@ -392,6 +403,50 @@ export function resolveSignInAdvisory(installed, running, probeSignedIn) {
|
|
|
392
403
|
return { signedIn: null, running: false };
|
|
393
404
|
return { signedIn: running ? true : probeSignedIn, running };
|
|
394
405
|
}
|
|
406
|
+
/**
|
|
407
|
+
* Collect the same data `agents teams doctor` prints: per-agent install status,
|
|
408
|
+
* launch health, and advisory sign-in state. Kept in one place so `agents doctor
|
|
409
|
+
* --devices` can run it locally or compare it against remote JSON without
|
|
410
|
+
* duplicating the probe logic.
|
|
411
|
+
*/
|
|
412
|
+
export async function collectTeamsDoctorData() {
|
|
413
|
+
const info = checkAllClis();
|
|
414
|
+
// Deep integrity probe. `checkAllClis` reports presence (shim + stub guard),
|
|
415
|
+
// but a gutted native binary still passes that, so actually launch the default
|
|
416
|
+
// version and flip the agent to not-installed if it won't run.
|
|
417
|
+
await Promise.all(Object.entries(info).map(async ([name, entry]) => {
|
|
418
|
+
if (!entry.installed)
|
|
419
|
+
return;
|
|
420
|
+
const agent = name;
|
|
421
|
+
const version = resolveVersion(agent);
|
|
422
|
+
if (!version)
|
|
423
|
+
return;
|
|
424
|
+
const health = await verifyInstalledBinaryLaunches(agent, version);
|
|
425
|
+
if (!health.ok) {
|
|
426
|
+
entry.installed = false;
|
|
427
|
+
entry.path = null;
|
|
428
|
+
entry.error = `${AGENTS[agent]?.cliCommand ?? name}@${version} is installed but its binary won't launch`
|
|
429
|
+
+ `${health.detail ? ` (${health.detail})` : ''}. Repair: agents add ${agent}@${version}`;
|
|
430
|
+
}
|
|
431
|
+
}));
|
|
432
|
+
// Advisory enrichment only. Sign-in detection is unreliable, so it never
|
|
433
|
+
// changes the authoritative installed/ready column — it annotates. A running
|
|
434
|
+
// teammate overrides a negative probe.
|
|
435
|
+
const running = new Set();
|
|
436
|
+
try {
|
|
437
|
+
for (const a of await new AgentManager().listRunning())
|
|
438
|
+
running.add(a.agentType);
|
|
439
|
+
}
|
|
440
|
+
catch { /* no teams yet — leave running empty */ }
|
|
441
|
+
const result = {};
|
|
442
|
+
await Promise.all(Object.entries(info).map(async ([name, entry]) => {
|
|
443
|
+
const isRunning = running.has(name);
|
|
444
|
+
const probe = entry.installed && !isRunning ? await checkCliSignedIn(name) : false;
|
|
445
|
+
const auth = resolveSignInAdvisory(entry.installed, isRunning, probe);
|
|
446
|
+
result[name] = { ...entry, ...auth };
|
|
447
|
+
}));
|
|
448
|
+
return result;
|
|
449
|
+
}
|
|
395
450
|
let AGENTS_DIR = null;
|
|
396
451
|
/** Resolve and cache the base directory where teammate process data is stored. */
|
|
397
452
|
export async function getAgentsDir() {
|
|
@@ -456,6 +511,29 @@ export class AgentProcess {
|
|
|
456
511
|
// Worktree isolation: when non-null, this teammate runs in its own git worktree.
|
|
457
512
|
worktreeName = null;
|
|
458
513
|
worktreePath = null;
|
|
514
|
+
// Distributed teams: when hostName is non-null, this teammate runs on another
|
|
515
|
+
// machine over SSH (the "remote-host" backend), not as a local process. These
|
|
516
|
+
// are set post-construction (like startTime/pid) — placement config at add
|
|
517
|
+
// time (hostName/hostTarget/repoPath) and runtime handles at launch time
|
|
518
|
+
// (remotePid/remoteLog/remoteExit) — so the giant constructor stays untouched.
|
|
519
|
+
hostName = null;
|
|
520
|
+
hostTarget = null;
|
|
521
|
+
repoPath = null;
|
|
522
|
+
remotePid = null;
|
|
523
|
+
remoteLog = null;
|
|
524
|
+
remoteExit = null;
|
|
525
|
+
// Offset-tail cursor into the REMOTE log (bytes already pulled). Distinct from
|
|
526
|
+
// lastReadPos, which tracks the LOCAL mirror the parser consumes.
|
|
527
|
+
remoteLogOffset = 0;
|
|
528
|
+
// Per-wave batched-poll snapshot, refreshed each wave by the supervisor's
|
|
529
|
+
// one-ssh-per-host pre-pass (AgentManager.prefetchRemoteStatus) and read by
|
|
530
|
+
// isProcessAlive()/readNewEvents() so they skip their own SSH round-trip. It is
|
|
531
|
+
// set anew (and cleared for uncovered teammates) at the START of every prefetch,
|
|
532
|
+
// so it persists across BOTH poll passes within one wave (startReady's roster
|
|
533
|
+
// scan + the supervisor's listByTask) yet never carries into the next wave. Null
|
|
534
|
+
// outside a batched wave (e.g. a bare `teams status`), where a direct per-teammate
|
|
535
|
+
// SSH probe is the correctness fallback.
|
|
536
|
+
remotePollSnapshot = null;
|
|
459
537
|
eventsCache = [];
|
|
460
538
|
lastReadPos = 0;
|
|
461
539
|
baseDir = null;
|
|
@@ -607,7 +685,72 @@ export class AgentProcess {
|
|
|
607
685
|
}
|
|
608
686
|
return latest;
|
|
609
687
|
}
|
|
688
|
+
/**
|
|
689
|
+
* For a distributed (remote-host) teammate, pull NEW bytes of the host's log
|
|
690
|
+
* into the LOCAL mirror the parser consumes, advance the remote offset, and
|
|
691
|
+
* resolve terminal status from the remote `.exit` sentinel. Runs BEFORE the
|
|
692
|
+
* local read in readNewEvents(), so the existing stream-json parse path then
|
|
693
|
+
* runs unchanged over the freshly-mirrored bytes.
|
|
694
|
+
*
|
|
695
|
+
* Uses a per-wave batched snapshot (remotePollSnapshot) when the supervisor's
|
|
696
|
+
* one-ssh-per-host pre-pass populated it; otherwise falls back to its own
|
|
697
|
+
* round-trips so a bare `teams status`/`teams logs` is still correct.
|
|
698
|
+
*/
|
|
699
|
+
async syncRemoteMirror() {
|
|
700
|
+
if (!this.hostName || !this.hostTarget || !this.remoteLog)
|
|
701
|
+
return;
|
|
702
|
+
// Pull the new remote bytes and append them to the local mirror the parser
|
|
703
|
+
// reads. One offset-tail round-trip; nothing to write when the log is quiet.
|
|
704
|
+
const delta = pullRemoteLogDelta(this.hostTarget, {
|
|
705
|
+
remoteLog: this.remoteLog,
|
|
706
|
+
offset: this.remoteLogOffset,
|
|
707
|
+
});
|
|
708
|
+
if (delta && delta.bytes.length > 0) {
|
|
709
|
+
const stdoutPath = await this.getStdoutPath();
|
|
710
|
+
try {
|
|
711
|
+
await fs.appendFile(stdoutPath, delta.bytes);
|
|
712
|
+
this.remoteLogOffset = delta.newOffset;
|
|
713
|
+
}
|
|
714
|
+
catch {
|
|
715
|
+
// best-effort mirror — leave the offset unadvanced so we retry next poll
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
// Resolve terminal status from the remote `.exit` sentinel (mirror
|
|
719
|
+
// reapProcess). Prefer this wave's batched snapshot; else fetch the exit file
|
|
720
|
+
// directly. The snapshot is left in place (refreshed each wave by prefetch),
|
|
721
|
+
// so a second poll pass within the same wave reuses it.
|
|
722
|
+
let exit = null;
|
|
723
|
+
const snap = this.remotePollSnapshot;
|
|
724
|
+
if (snap) {
|
|
725
|
+
exit = snap.exit;
|
|
726
|
+
}
|
|
727
|
+
else if (this.remoteExit) {
|
|
728
|
+
// UNQUOTED so `$HOME` in the dispatch exit path expands on the remote shell.
|
|
729
|
+
const res = sshExec(this.hostTarget, `cat ${this.remoteExit} 2>/dev/null`, {
|
|
730
|
+
timeoutMs: 8000,
|
|
731
|
+
multiplex: true,
|
|
732
|
+
});
|
|
733
|
+
exit = res.code === 0 && res.stdout.trim() !== '' ? res.stdout.trim() : null;
|
|
734
|
+
}
|
|
735
|
+
// Only latch terminal on a PARSEABLE exit code. A `.exit` that exists but is
|
|
736
|
+
// momentarily empty (created, not yet written) or garbage must NOT force a
|
|
737
|
+
// spurious FAILED — leave the teammate RUNNING and let the next poll resolve
|
|
738
|
+
// it once the code lands. Matches the direct-cat guard above.
|
|
739
|
+
if (exit !== null && exit.trim() !== '' && this.status === AgentStatus.RUNNING) {
|
|
740
|
+
const code = Number.parseInt(exit.trim(), 10);
|
|
741
|
+
if (Number.isFinite(code)) {
|
|
742
|
+
this.status = code === 0 ? AgentStatus.COMPLETED : AgentStatus.FAILED;
|
|
743
|
+
if (!this.completedAt)
|
|
744
|
+
this.completedAt = new Date();
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
610
748
|
async readNewEvents() {
|
|
749
|
+
// Distributed teammate: mirror the host's new log bytes locally first, then
|
|
750
|
+
// fall through to the identical local read+parse below.
|
|
751
|
+
if (this.hostName) {
|
|
752
|
+
await this.syncRemoteMirror();
|
|
753
|
+
}
|
|
611
754
|
const stdoutPath = await this.getStdoutPath();
|
|
612
755
|
try {
|
|
613
756
|
const stats = await fs.stat(stdoutPath).catch(() => null);
|
|
@@ -663,6 +806,55 @@ export class AgentProcess {
|
|
|
663
806
|
catch (err) {
|
|
664
807
|
console.error(`Error reading events for agent ${this.agentId}:`, err);
|
|
665
808
|
}
|
|
809
|
+
// Distributed teammate: keep the orchestrator bounded across 10+ remote
|
|
810
|
+
// teammates. The parser has already consumed everything up to lastReadPos
|
|
811
|
+
// (status/digest updated), so both the on-disk mirror tail and the in-memory
|
|
812
|
+
// event backlog are safe to trim. The host keeps the full log.
|
|
813
|
+
if (this.hostName) {
|
|
814
|
+
await this.capMirrorToTail();
|
|
815
|
+
this.capEventsCache();
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
/**
|
|
819
|
+
* Truncate the local mirror to its trailing REMOTE_MIRROR_MAX_BYTES and reset
|
|
820
|
+
* lastReadPos to the new (smaller) size so the parser doesn't re-read the kept
|
|
821
|
+
* tail. Only trims when over the cap — a normal-length log is untouched.
|
|
822
|
+
*/
|
|
823
|
+
async capMirrorToTail() {
|
|
824
|
+
const stdoutPath = await this.getStdoutPath();
|
|
825
|
+
try {
|
|
826
|
+
const stats = await fs.stat(stdoutPath).catch(() => null);
|
|
827
|
+
if (!stats || stats.size <= REMOTE_MIRROR_MAX_BYTES)
|
|
828
|
+
return;
|
|
829
|
+
const keep = REMOTE_MIRROR_MAX_BYTES;
|
|
830
|
+
const fd = await fs.open(stdoutPath, 'r');
|
|
831
|
+
const buf = Buffer.alloc(keep);
|
|
832
|
+
const { bytesRead } = await fd.read(buf, 0, keep, stats.size - keep);
|
|
833
|
+
await fd.close();
|
|
834
|
+
await fs.writeFile(stdoutPath, buf.subarray(0, bytesRead));
|
|
835
|
+
// The parser consumed up to lastReadPos already; after truncation the file
|
|
836
|
+
// is `bytesRead` long, so clamp the cursor to the new EOF. It never needs
|
|
837
|
+
// to re-read the retained tail (events already cached).
|
|
838
|
+
this.lastReadPos = Math.min(this.lastReadPos, bytesRead);
|
|
839
|
+
}
|
|
840
|
+
catch {
|
|
841
|
+
// best-effort — a failed cap just leaves the mirror larger this wave
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
/** Cap on the in-memory event backlog kept per remote teammate. */
|
|
845
|
+
static REMOTE_EVENTS_MAX = 200;
|
|
846
|
+
/**
|
|
847
|
+
* Drop the oldest cached events for a remote teammate once past the cap. The
|
|
848
|
+
* status path only needs recent events (last N messages, recentToolCalls,
|
|
849
|
+
* terminal status) and the getDelta cursor filters by timestamp, so a bounded
|
|
850
|
+
* recent window preserves the digest while bounding the heap. Terminal status
|
|
851
|
+
* is already latched onto `this.status`, so trimming can't lose it.
|
|
852
|
+
*/
|
|
853
|
+
capEventsCache() {
|
|
854
|
+
const max = AgentProcess.REMOTE_EVENTS_MAX;
|
|
855
|
+
if (this.eventsCache.length > max) {
|
|
856
|
+
this.eventsCache = this.eventsCache.slice(-max);
|
|
857
|
+
}
|
|
666
858
|
}
|
|
667
859
|
async saveMeta() {
|
|
668
860
|
const agentDir = await this.getAgentDir();
|
|
@@ -697,6 +889,13 @@ export class AgentProcess {
|
|
|
697
889
|
cloud_branch: this.cloudBranch,
|
|
698
890
|
worktree_name: this.worktreeName,
|
|
699
891
|
worktree_path: this.worktreePath,
|
|
892
|
+
host_name: this.hostName,
|
|
893
|
+
host_target: this.hostTarget,
|
|
894
|
+
repo_path: this.repoPath,
|
|
895
|
+
remote_pid: this.remotePid,
|
|
896
|
+
remote_log: this.remoteLog,
|
|
897
|
+
remote_exit: this.remoteExit,
|
|
898
|
+
remote_log_offset: this.remoteLogOffset,
|
|
700
899
|
};
|
|
701
900
|
const metaPath = await this.getMetaPath();
|
|
702
901
|
await fs.writeFile(metaPath, JSON.stringify(meta, null, 2));
|
|
@@ -738,6 +937,15 @@ export class AgentProcess {
|
|
|
738
937
|
? meta.task_type
|
|
739
938
|
: null, meta.cloud_repo || null, meta.cloud_branch || null, meta.worktree_name || null, meta.worktree_path || null, meta.profile_name || null);
|
|
740
939
|
agent.startTime = typeof meta.start_time === 'string' ? meta.start_time : null;
|
|
940
|
+
// Distributed-team fields: set post-construction (like startTime) so the
|
|
941
|
+
// constructor signature stays fixed. Null on every pre-existing teammate.
|
|
942
|
+
agent.hostName = meta.host_name || null;
|
|
943
|
+
agent.hostTarget = meta.host_target || null;
|
|
944
|
+
agent.repoPath = meta.repo_path || null;
|
|
945
|
+
agent.remotePid = typeof meta.remote_pid === 'number' ? meta.remote_pid : null;
|
|
946
|
+
agent.remoteLog = meta.remote_log || null;
|
|
947
|
+
agent.remoteExit = meta.remote_exit || null;
|
|
948
|
+
agent.remoteLogOffset = typeof meta.remote_log_offset === 'number' ? meta.remote_log_offset : 0;
|
|
741
949
|
return agent;
|
|
742
950
|
}
|
|
743
951
|
catch {
|
|
@@ -745,6 +953,27 @@ export class AgentProcess {
|
|
|
745
953
|
}
|
|
746
954
|
}
|
|
747
955
|
isProcessAlive() {
|
|
956
|
+
// Distributed teammate: a local PID is meaningless. Alive = the remote `.exit`
|
|
957
|
+
// sentinel is absent AND `kill -0 <remotePid>` succeeds on the host, resolved
|
|
958
|
+
// in a single ssh round-trip. Prefer the supervisor's batched snapshot when
|
|
959
|
+
// present (consume it once so it can't go stale); otherwise probe directly.
|
|
960
|
+
if (this.hostName) {
|
|
961
|
+
// Prefer this wave's batched snapshot (persists across the wave's poll
|
|
962
|
+
// passes; the supervisor refreshes it each wave). Fall back to a direct
|
|
963
|
+
// probe outside a wave.
|
|
964
|
+
if (this.remotePollSnapshot)
|
|
965
|
+
return this.remotePollSnapshot.alive;
|
|
966
|
+
if (!this.hostTarget || !this.remotePid || !this.remoteExit)
|
|
967
|
+
return false;
|
|
968
|
+
// remoteExit is a dispatch `$HOME/.agents/.cache/hosts/<hex>.exit` path —
|
|
969
|
+
// interpolate UNQUOTED so `$HOME` expands (shellQuote would defeat it).
|
|
970
|
+
const probe = `test -f ${this.remoteExit} && echo DEAD || ` +
|
|
971
|
+
`(kill -0 ${this.remotePid} 2>/dev/null && echo ALIVE || echo DEAD)`;
|
|
972
|
+
const res = sshExec(this.hostTarget, probe, { timeoutMs: 8000, multiplex: true });
|
|
973
|
+
if (res.code === null)
|
|
974
|
+
return true; // transient ssh failure — don't reap early
|
|
975
|
+
return res.stdout.trim().endsWith('ALIVE');
|
|
976
|
+
}
|
|
748
977
|
if (!this.pid)
|
|
749
978
|
return false;
|
|
750
979
|
try {
|
|
@@ -768,6 +997,19 @@ export class AgentProcess {
|
|
|
768
997
|
}
|
|
769
998
|
async updateStatusFromProcess() {
|
|
770
999
|
if (!this.pid) {
|
|
1000
|
+
// Distributed (remote-host) teammates have no local PID by design; their
|
|
1001
|
+
// lifecycle lives on the host. readNewEvents() mirrors the remote log and
|
|
1002
|
+
// resolves terminal status from the remote `.exit` sentinel (see
|
|
1003
|
+
// syncRemoteMirror), so we just persist and return — never the local
|
|
1004
|
+
// "RUNNING without a PID is impossible" fail path below.
|
|
1005
|
+
if (this.hostName) {
|
|
1006
|
+
await this.readNewEvents();
|
|
1007
|
+
if (this.status !== AgentStatus.RUNNING && !this.completedAt) {
|
|
1008
|
+
this.completedAt = this.getLatestEventTime() || this.startedAt || new Date();
|
|
1009
|
+
}
|
|
1010
|
+
await this.saveMeta();
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
771
1013
|
await this.readNewEvents();
|
|
772
1014
|
// Cloud-backed teammates have no local PID by design; their lifecycle
|
|
773
1015
|
// is driven by the remote provider instead of a local process.
|
|
@@ -997,7 +1239,7 @@ export class AgentManager {
|
|
|
997
1239
|
}
|
|
998
1240
|
debug(`Loaded ${loadedCount} agents from disk`);
|
|
999
1241
|
}
|
|
1000
|
-
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) {
|
|
1242
|
+
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) {
|
|
1001
1243
|
await this.initialize();
|
|
1002
1244
|
const resolvedMode = resolveMode(mode, this.defaultMode);
|
|
1003
1245
|
// Enforce: teammate names are unique within a team.
|
|
@@ -1043,7 +1285,11 @@ export class AgentManager {
|
|
|
1043
1285
|
// local CLI for them (the pod has its own). The caller has already
|
|
1044
1286
|
// dispatched via the cloud provider and passed us the provider + session.
|
|
1045
1287
|
const isCloudBacked = Boolean(cloudProvider);
|
|
1046
|
-
|
|
1288
|
+
// Distributed teammates run on another machine over SSH — the agent CLI must
|
|
1289
|
+
// be present on the HOST (checked via ensureHostReady in the command), not
|
|
1290
|
+
// locally. So skip the local availability check for both remote backends.
|
|
1291
|
+
const isRemoteBacked = Boolean(hostName);
|
|
1292
|
+
if (!isCloudBacked && !isRemoteBacked) {
|
|
1047
1293
|
// Profile-backed teammates still spawn through `agents run`, which
|
|
1048
1294
|
// resolves the profile to its host harness — so the CLI we need to be
|
|
1049
1295
|
// present is the underlying agentType, not the profile name.
|
|
@@ -1060,6 +1306,12 @@ export class AgentManager {
|
|
|
1060
1306
|
? AgentStatus.PENDING
|
|
1061
1307
|
: AgentStatus.RUNNING;
|
|
1062
1308
|
const agent = new AgentProcess(agentId, taskName, agentType, prompt, resolvedCwd, resolvedMode, null, initialStatus, new Date(), null, this.agentsDir, parentSessionId, workspaceDir, cloudSessionId, cloudProvider, null, version, null, name, cleanAfter, effort, model, envOverrides && Object.keys(envOverrides).length > 0 ? envOverrides : null, taskType, cloudRepo, cloudBranch, worktreeName, worktreePath, profileName);
|
|
1309
|
+
// Distributed-team placement: set post-construction (like startTime), so the
|
|
1310
|
+
// giant constructor stays fixed. launchRemoteProcess() reads these to dispatch
|
|
1311
|
+
// over SSH and fills in the runtime handles (remotePid/remoteLog/remoteExit).
|
|
1312
|
+
agent.hostName = hostName;
|
|
1313
|
+
agent.hostTarget = hostTarget;
|
|
1314
|
+
agent.repoPath = repoPath;
|
|
1063
1315
|
const agentDir = await agent.getAgentDir();
|
|
1064
1316
|
try {
|
|
1065
1317
|
await fs.mkdir(agentDir, { recursive: true });
|
|
@@ -1086,8 +1338,20 @@ export class AgentManager {
|
|
|
1086
1338
|
await agent.saveMeta();
|
|
1087
1339
|
debug(`Cloud-backed ${agentType} teammate via ${cloudProvider} (session=${cloudSessionId})`);
|
|
1088
1340
|
}
|
|
1341
|
+
else if (isRemoteBacked) {
|
|
1342
|
+
// Distributed teammate that can run now (no unmet --after deps): dispatch
|
|
1343
|
+
// it onto its host over SSH instead of a local spawn.
|
|
1344
|
+
await this.launchRemoteProcess(agent);
|
|
1345
|
+
}
|
|
1089
1346
|
else {
|
|
1090
|
-
|
|
1347
|
+
// Unpinned + launching now: consult the pool scheduler before defaulting to
|
|
1348
|
+
// local, so an unpinned teammate on a --devices team auto-schedules even when
|
|
1349
|
+
// added without --after (it wouldn't pass through startReady otherwise).
|
|
1350
|
+
await this.maybeSchedulePlacement(agent, taskName);
|
|
1351
|
+
if (agent.hostName)
|
|
1352
|
+
await this.launchRemoteProcess(agent);
|
|
1353
|
+
else
|
|
1354
|
+
await this.launchProcess(agent);
|
|
1091
1355
|
}
|
|
1092
1356
|
await this.cleanupOldAgents();
|
|
1093
1357
|
return agent;
|
|
@@ -1149,6 +1413,182 @@ export class AgentManager {
|
|
|
1149
1413
|
}
|
|
1150
1414
|
debug(`Launched agent ${agent.agentId} with PID ${agent.pid}`);
|
|
1151
1415
|
}
|
|
1416
|
+
/**
|
|
1417
|
+
* Dispatch a distributed teammate onto its host over SSH — the remote-host
|
|
1418
|
+
* analog of launchProcess(). Symmetric to the cloud path: no local process; the
|
|
1419
|
+
* lifecycle lives on the host and is polled (isProcessAlive/readNewEvents over
|
|
1420
|
+
* SSH via the remote `.exit` sentinel + offset-tailed log).
|
|
1421
|
+
*
|
|
1422
|
+
* When the team uses worktrees (agent.worktreeName set), a git worktree is first
|
|
1423
|
+
* created ON THE HOST off the freshly-fetched default branch; the teammate runs
|
|
1424
|
+
* there. Otherwise it runs in the host repo path directly.
|
|
1425
|
+
*/
|
|
1426
|
+
async launchRemoteProcess(agent) {
|
|
1427
|
+
if (!agent.hostName || !agent.hostTarget || !agent.repoPath) {
|
|
1428
|
+
throw new Error(`Remote teammate ${agent.agentId} is missing host placement (host/target/repo).`);
|
|
1429
|
+
}
|
|
1430
|
+
// Re-resolve the device → Host at launch time (it may have moved / changed
|
|
1431
|
+
// address since `add` staged the teammate), matching how the command resolved
|
|
1432
|
+
// it. The target string on the agent stays the launch-time source of truth for
|
|
1433
|
+
// subsequent polling.
|
|
1434
|
+
const host = await resolveHost(agent.hostName);
|
|
1435
|
+
if (!host) {
|
|
1436
|
+
throw new Error(`Cannot launch remote teammate ${agent.agentId}: device "${agent.hostName}" no longer resolves.`);
|
|
1437
|
+
}
|
|
1438
|
+
// Ensure agents-cli is present + version-matched on the host; surface (not
|
|
1439
|
+
// fail on) an agent-not-installed warning like dispatch.ts does.
|
|
1440
|
+
try {
|
|
1441
|
+
const { warnings } = ensureHostReady(host, { agent: agent.agentType });
|
|
1442
|
+
for (const w of warnings)
|
|
1443
|
+
process.stderr.write(`[teams] warning: ${w}\n`);
|
|
1444
|
+
}
|
|
1445
|
+
catch (err) {
|
|
1446
|
+
throw new Error(`Host "${agent.hostName}" not ready for teammate ${agent.agentId}: ${err.message}`);
|
|
1447
|
+
}
|
|
1448
|
+
// Worktree isolation on the host, if the team enables it. createRemoteWorktree
|
|
1449
|
+
// fetches origin and branches off origin/<default>, returning the host path.
|
|
1450
|
+
let remoteCwd = agent.repoPath;
|
|
1451
|
+
if (agent.worktreeName) {
|
|
1452
|
+
const worktreePath = createRemoteWorktree(agent.hostTarget, agent.repoPath, agent.worktreeName);
|
|
1453
|
+
agent.worktreePath = worktreePath;
|
|
1454
|
+
remoteCwd = worktreePath;
|
|
1455
|
+
}
|
|
1456
|
+
// Same run argv the local path builds (shared buildRunArgv keeps the prompt
|
|
1457
|
+
// scaffolding + flags from drifting); dispatched non-blocking (follow:false)
|
|
1458
|
+
// — the supervisor polls the host, we don't block here.
|
|
1459
|
+
const effort = agent.effort ?? 'medium';
|
|
1460
|
+
const forwardedArgs = this.buildRunArgv(agent.agentType, agent.prompt, agent.mode, agent.model ?? null, effort, agent.version, agent.profileName);
|
|
1461
|
+
try {
|
|
1462
|
+
const { task } = await dispatchAgentsCommand(host, {
|
|
1463
|
+
forwardedArgs,
|
|
1464
|
+
remoteCwd,
|
|
1465
|
+
follow: false,
|
|
1466
|
+
});
|
|
1467
|
+
agent.remotePid = task.pid ?? null;
|
|
1468
|
+
agent.remoteLog = task.remoteLog ?? null;
|
|
1469
|
+
agent.remoteExit = task.remoteExit ?? null;
|
|
1470
|
+
agent.remoteLogOffset = 0;
|
|
1471
|
+
agent.status = AgentStatus.RUNNING;
|
|
1472
|
+
agent.startedAt = new Date();
|
|
1473
|
+
await agent.saveMeta();
|
|
1474
|
+
}
|
|
1475
|
+
catch (err) {
|
|
1476
|
+
console.error(`Failed to launch remote teammate ${agent.agentId} on ${agent.hostName}:`, err);
|
|
1477
|
+
throw new Error(`Failed to launch remote teammate: ${err.message}`);
|
|
1478
|
+
}
|
|
1479
|
+
debug(`Launched remote agent ${agent.agentId} on ${agent.hostName} (remote pid ${agent.remotePid})`);
|
|
1480
|
+
}
|
|
1481
|
+
/**
|
|
1482
|
+
* Resolve a scheduler-picked device to host placement fields on an unpinned
|
|
1483
|
+
* teammate at LAUNCH time (the same resolution `teams add --device` runs, minus
|
|
1484
|
+
* the fatal `die()` — a scheduling failure here is per-teammate, not per-add).
|
|
1485
|
+
* Sets hostName/hostTarget/repoPath + persists, so the subsequent
|
|
1486
|
+
* launchRemoteProcess dispatches over SSH. Mirrors the `add`-time pin path:
|
|
1487
|
+
* resolve device → reject Windows (POSIX-only) → ssh target → ensure the repo
|
|
1488
|
+
* is present on the host from the team's --repo (ensureRemoteRepo).
|
|
1489
|
+
*/
|
|
1490
|
+
async resolveScheduledPlacement(agent, device, taskName) {
|
|
1491
|
+
const host = await resolveHost(device);
|
|
1492
|
+
if (!host) {
|
|
1493
|
+
throw new Error(`Scheduler picked device "${device}" but it no longer resolves.`);
|
|
1494
|
+
}
|
|
1495
|
+
if (remoteShellFor(host.os ?? resolveRemoteOsSync(host.name)) === 'powershell') {
|
|
1496
|
+
throw new Error(`Scheduler picked Windows device "${host.name}", but distributed teammates are POSIX-only in v1.`);
|
|
1497
|
+
}
|
|
1498
|
+
const target = sshTargetFor(host);
|
|
1499
|
+
const teamMeta = await getTeam(taskName);
|
|
1500
|
+
const repoRoot = ensureRemoteRepo(target, teamMeta?.repo ?? '', taskName);
|
|
1501
|
+
agent.hostName = host.name;
|
|
1502
|
+
agent.hostTarget = target;
|
|
1503
|
+
agent.repoPath = repoRoot;
|
|
1504
|
+
await agent.saveMeta();
|
|
1505
|
+
}
|
|
1506
|
+
/**
|
|
1507
|
+
* Place an UNPINNED, non-cloud teammate onto the team pool via the cascade
|
|
1508
|
+
* (least-loaded), if the team declares one. A no-op for a pinned teammate
|
|
1509
|
+
* (hostName already set from `--device`), a cloud teammate, or a poolless team —
|
|
1510
|
+
* leaving hostName null so the local spawn runs unchanged. Shared by spawn()
|
|
1511
|
+
* (immediate add-launch) and startReady() (staged launch) so an unpinned pool
|
|
1512
|
+
* teammate schedules identically no matter how it was fired.
|
|
1513
|
+
*/
|
|
1514
|
+
async maybeSchedulePlacement(agent, taskName) {
|
|
1515
|
+
if (agent.hostName || agent.cloudProvider)
|
|
1516
|
+
return;
|
|
1517
|
+
const teamMeta = await getTeam(taskName);
|
|
1518
|
+
if (!teamMeta)
|
|
1519
|
+
return;
|
|
1520
|
+
const roster = await this.listByTask(taskName);
|
|
1521
|
+
const { device } = resolvePlacement(teamMeta, null, roster);
|
|
1522
|
+
if (device)
|
|
1523
|
+
await this.resolveScheduledPlacement(agent, device, taskName);
|
|
1524
|
+
}
|
|
1525
|
+
/**
|
|
1526
|
+
* One-ssh-per-host batched liveness/exit pre-pass for a team's remote teammates.
|
|
1527
|
+
* The supervisor calls this each wave BEFORE listByTask() so the per-teammate
|
|
1528
|
+
* isProcessAlive()/readNewEvents() consume a cached snapshot instead of each
|
|
1529
|
+
* issuing its own SSH handshake — avoiding N round-trips per wave at 10+ remote
|
|
1530
|
+
* teammates. Groups by hostTarget and, for each host, checks every teammate's
|
|
1531
|
+
* `.exit` + `kill -0` in a single ssh call over the shared ControlMaster socket.
|
|
1532
|
+
*/
|
|
1533
|
+
async prefetchRemoteStatus(taskName) {
|
|
1534
|
+
await this.initialize();
|
|
1535
|
+
// Read the in-memory roster directly — going through listByTask()/listAll()
|
|
1536
|
+
// would poll each teammate first (an SSH round-trip apiece), defeating the
|
|
1537
|
+
// batch. The caller (supervisor) has already rescanned from disk this wave.
|
|
1538
|
+
const remotes = Array.from(this.agents.values()).filter((a) => a.taskName === taskName && a.hostName);
|
|
1539
|
+
// Fresh snapshots each wave: clear stale ones first so a teammate that has
|
|
1540
|
+
// since finished (dropped from the RUNNING filter below) can't carry an old
|
|
1541
|
+
// ALIVE reading into this wave's poll.
|
|
1542
|
+
for (const a of remotes)
|
|
1543
|
+
a.remotePollSnapshot = null;
|
|
1544
|
+
const teammates = remotes.filter((a) => a.hostTarget && a.remotePid && a.remoteExit &&
|
|
1545
|
+
a.status === AgentStatus.RUNNING);
|
|
1546
|
+
if (teammates.length === 0)
|
|
1547
|
+
return;
|
|
1548
|
+
const byTarget = new Map();
|
|
1549
|
+
for (const a of teammates) {
|
|
1550
|
+
const arr = byTarget.get(a.hostTarget) || [];
|
|
1551
|
+
arr.push(a);
|
|
1552
|
+
byTarget.set(a.hostTarget, arr);
|
|
1553
|
+
}
|
|
1554
|
+
for (const [target, agents] of byTarget) {
|
|
1555
|
+
// Emit one line per teammate: "<agentId> ALIVE|DEAD <exitOrEmpty>". A single
|
|
1556
|
+
// round-trip over the multiplexed socket, regardless of teammate count.
|
|
1557
|
+
const parts = agents.map((a) => {
|
|
1558
|
+
const id = a.agentId;
|
|
1559
|
+
// remoteExit is a dispatch `$HOME/.agents/.cache/hosts/<hex>.exit` path —
|
|
1560
|
+
// interpolate UNQUOTED so `$HOME` expands (shellQuote would make `[ -f ]`
|
|
1561
|
+
// always miss, so a finished teammate would never resolve terminal).
|
|
1562
|
+
const exitFile = a.remoteExit;
|
|
1563
|
+
// exit code (if the sentinel exists) OR empty, then liveness.
|
|
1564
|
+
return (`printf '%s ' ${shellQuote(id)}; ` +
|
|
1565
|
+
`if [ -f ${exitFile} ]; then printf 'DEAD '; cat ${exitFile} 2>/dev/null | tr -d '\\n'; printf '\\n'; ` +
|
|
1566
|
+
`elif kill -0 ${a.remotePid} 2>/dev/null; then printf 'ALIVE\\n'; ` +
|
|
1567
|
+
`else printf 'DEAD\\n'; fi`);
|
|
1568
|
+
});
|
|
1569
|
+
const res = sshExec(target, parts.join('; '), { timeoutMs: 12000, multiplex: true });
|
|
1570
|
+
if (res.code === null)
|
|
1571
|
+
continue; // transient ssh failure — skip this wave, no snapshot
|
|
1572
|
+
const snapshots = new Map();
|
|
1573
|
+
for (const line of res.stdout.split('\n')) {
|
|
1574
|
+
const trimmed = line.trim();
|
|
1575
|
+
if (!trimmed)
|
|
1576
|
+
continue;
|
|
1577
|
+
const [id, state, exit] = trimmed.split(/\s+/);
|
|
1578
|
+
if (!id)
|
|
1579
|
+
continue;
|
|
1580
|
+
snapshots.set(id, {
|
|
1581
|
+
alive: state === 'ALIVE',
|
|
1582
|
+
exit: state === 'DEAD' ? (exit ?? '') : null,
|
|
1583
|
+
});
|
|
1584
|
+
}
|
|
1585
|
+
for (const a of agents) {
|
|
1586
|
+
const snap = snapshots.get(a.agentId);
|
|
1587
|
+
if (snap)
|
|
1588
|
+
a.remotePollSnapshot = snap;
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1152
1592
|
/**
|
|
1153
1593
|
* Fire any pending teammates in the given team whose `after` deps have all
|
|
1154
1594
|
* completed. Returns the list of teammates just launched. Repeatable:
|
|
@@ -1169,8 +1609,24 @@ export class AgentManager {
|
|
|
1169
1609
|
});
|
|
1170
1610
|
if (!depsReady)
|
|
1171
1611
|
continue;
|
|
1612
|
+
// Auto-scheduling: an UNPINNED teammate (no explicit --device at add time)
|
|
1613
|
+
// gets placed now via the pool cascade — same helper spawn() uses so the
|
|
1614
|
+
// immediate-add and staged paths agree. A null pick keeps hostName null →
|
|
1615
|
+
// local spawn, unchanged. Cloud teammates never schedule.
|
|
1616
|
+
try {
|
|
1617
|
+
await this.maybeSchedulePlacement(agent, taskName);
|
|
1618
|
+
}
|
|
1619
|
+
catch (err) {
|
|
1620
|
+
console.error(`Could not schedule ${agent.agentId} onto the team pool:`, err);
|
|
1621
|
+
continue;
|
|
1622
|
+
}
|
|
1172
1623
|
try {
|
|
1173
|
-
if (agent.
|
|
1624
|
+
if (agent.hostName) {
|
|
1625
|
+
// Distributed teammate: dispatch onto its host over SSH.
|
|
1626
|
+
await this.launchRemoteProcess(agent);
|
|
1627
|
+
launched.push(agent);
|
|
1628
|
+
}
|
|
1629
|
+
else if (agent.cloudProvider) {
|
|
1174
1630
|
if (!this.cloudDispatcher) {
|
|
1175
1631
|
console.error(`Cannot start cloud-backed teammate ${agent.agentId}: no dispatcher registered.`);
|
|
1176
1632
|
continue;
|
|
@@ -1200,7 +1656,20 @@ export class AgentManager {
|
|
|
1200
1656
|
* exec path (src/lib/exec.ts). The team runner just supplies prompt + mode
|
|
1201
1657
|
* and reads stream-json events off stdout.
|
|
1202
1658
|
*/
|
|
1203
|
-
|
|
1659
|
+
/**
|
|
1660
|
+
* Build the `agents run …` argv AFTER the `agents` binary — the flags + prompt
|
|
1661
|
+
* scaffolding shared by the LOCAL launch (buildCommand, which prefixes
|
|
1662
|
+
* process.execPath + the agents CLI path) and the REMOTE launch
|
|
1663
|
+
* (launchRemoteProcess, which prefixes `agents` on the host via dispatch). Kept
|
|
1664
|
+
* in one place so the PROMPT_SUFFIX / CLAUDE_PLAN_MODE_PREFIX scaffolding and the
|
|
1665
|
+
* flag set can never drift between the two backends.
|
|
1666
|
+
*
|
|
1667
|
+
* `cwd` is intentionally NOT emitted here: the local path passes it as
|
|
1668
|
+
* `--cwd`/`--add-dir` (below), while the remote path `cd`s into the host cwd
|
|
1669
|
+
* before invoking `agents`. `sessionId` is likewise local-only (the remote run
|
|
1670
|
+
* mints its own session on the host).
|
|
1671
|
+
*/
|
|
1672
|
+
buildRunArgv(agentType, prompt, mode, model, effort, version, profileName) {
|
|
1204
1673
|
// Compose the prompt: a plan-mode prefix for Claude (clarifying headless
|
|
1205
1674
|
// plan-mode restrictions) and a universal summary suffix. These are
|
|
1206
1675
|
// team-specific prompt scaffolding — `agents run` does not apply them.
|
|
@@ -1212,10 +1681,7 @@ export class AgentManager {
|
|
|
1212
1681
|
// host harness, version pin, and env injection in one place. Plain
|
|
1213
1682
|
// version pins only apply when no profile is selected.
|
|
1214
1683
|
const target = profileName ?? (version ? `${agentType}@${version}` : agentType);
|
|
1215
|
-
const
|
|
1216
|
-
const cmd = [
|
|
1217
|
-
process.execPath,
|
|
1218
|
-
agentsCli,
|
|
1684
|
+
const args = [
|
|
1219
1685
|
'run',
|
|
1220
1686
|
target,
|
|
1221
1687
|
fullPrompt,
|
|
@@ -1226,7 +1692,16 @@ export class AgentManager {
|
|
|
1226
1692
|
'--quiet',
|
|
1227
1693
|
];
|
|
1228
1694
|
if (model)
|
|
1229
|
-
|
|
1695
|
+
args.push('--model', model);
|
|
1696
|
+
return args;
|
|
1697
|
+
}
|
|
1698
|
+
buildCommand(agentType, prompt, mode, model, cwd = null, sessionId = null, effort = 'medium', version = null, profileName = null) {
|
|
1699
|
+
const agentsCli = process.argv[1];
|
|
1700
|
+
const cmd = [
|
|
1701
|
+
process.execPath,
|
|
1702
|
+
agentsCli,
|
|
1703
|
+
...this.buildRunArgv(agentType, prompt, mode, model, effort, version, profileName),
|
|
1704
|
+
];
|
|
1230
1705
|
if (cwd)
|
|
1231
1706
|
cmd.push('--cwd', cwd);
|
|
1232
1707
|
// Pin Claude's session UUID to our agent_id so its session file lands at
|
|
@@ -1339,6 +1814,31 @@ export class AgentManager {
|
|
|
1339
1814
|
if (!agent) {
|
|
1340
1815
|
return false;
|
|
1341
1816
|
}
|
|
1817
|
+
// Distributed teammate: no local PID — signal it over SSH. Try the process
|
|
1818
|
+
// GROUP first (negative pid, matching local `kill(-pid)`) to catch the
|
|
1819
|
+
// detached `agents run` and its children; but the remote launcher is
|
|
1820
|
+
// `nohup bash -lc … &` under a non-interactive shell where job control is off,
|
|
1821
|
+
// so `&` may NOT open a new group — fall back to signalling the wrapper pid
|
|
1822
|
+
// directly. Best-effort either way; the `.exit` sentinel is the durable
|
|
1823
|
+
// terminal-status source if a grandchild lingers.
|
|
1824
|
+
if (agent.hostName && agent.status === AgentStatus.RUNNING) {
|
|
1825
|
+
if (agent.hostTarget && agent.remotePid) {
|
|
1826
|
+
try {
|
|
1827
|
+
sshExec(agent.hostTarget, `kill -TERM -- -${agent.remotePid} 2>/dev/null || kill -TERM ${agent.remotePid} 2>/dev/null`, {
|
|
1828
|
+
timeoutMs: 10000,
|
|
1829
|
+
multiplex: true,
|
|
1830
|
+
});
|
|
1831
|
+
}
|
|
1832
|
+
catch {
|
|
1833
|
+
// best-effort — record the stop regardless
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
agent.status = AgentStatus.STOPPED;
|
|
1837
|
+
agent.completedAt = new Date();
|
|
1838
|
+
await agent.saveMeta();
|
|
1839
|
+
debug(`Stopped remote agent ${agentId} on ${agent.hostName}`);
|
|
1840
|
+
return true;
|
|
1841
|
+
}
|
|
1342
1842
|
if (agent.pid && agent.status === AgentStatus.RUNNING) {
|
|
1343
1843
|
// PID-reuse guard: if the PID we recorded at spawn no longer maps to
|
|
1344
1844
|
// our process (start-time mismatch), the OS has recycled it. Sending
|