@phnx-labs/agents-cli 1.22.49 → 1.22.51
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 +229 -0
- package/README.md +10 -0
- package/dist/bootstrap.js +1 -1
- package/dist/commands/accounts.js +1 -13
- package/dist/commands/browser.d.ts +10 -0
- package/dist/commands/browser.js +77 -2
- package/dist/commands/config.js +20 -0
- package/dist/commands/daemon-test-harness.d.ts +42 -0
- package/dist/commands/daemon-test-harness.js +90 -0
- package/dist/commands/device-config-test-harness.d.ts +11 -0
- package/dist/commands/device-config-test-harness.js +59 -0
- package/dist/commands/exec.js +24 -1
- package/dist/commands/feedback.js +1 -1
- package/dist/commands/menubar.js +6 -4
- package/dist/commands/share.js +9 -4
- package/dist/commands/teams.d.ts +12 -0
- package/dist/commands/teams.js +44 -67
- package/dist/commands/traces.js +36 -4
- package/dist/lib/account-provider-registry.d.ts +9 -0
- package/dist/lib/account-provider-registry.js +20 -0
- package/dist/lib/accounting/account-pool-collect.d.ts +34 -0
- package/dist/lib/accounting/account-pool-collect.js +71 -0
- package/dist/lib/accounting/account-pool.d.ts +46 -0
- package/dist/lib/accounting/account-pool.js +33 -0
- package/dist/lib/accounting/capacity.d.ts +24 -0
- package/dist/lib/accounting/capacity.js +30 -0
- package/dist/lib/accounting/rotate.d.ts +11 -18
- package/dist/lib/accounting/rotate.js +6 -24
- package/dist/lib/claude-statusline.d.ts +9 -0
- package/dist/lib/claude-statusline.js +45 -4
- package/dist/lib/computer/ssh-tunnel.d.ts +7 -6
- package/dist/lib/computer/ssh-tunnel.js +13 -8
- package/dist/lib/config-keys.d.ts +4 -3
- package/dist/lib/config-keys.js +9 -2
- package/dist/lib/device-config.js +23 -0
- package/dist/lib/factory/snapshot.d.ts +1 -1
- package/dist/lib/factory/snapshot.js +1 -1
- package/dist/lib/helper-download.d.ts +12 -2
- package/dist/lib/helper-download.js +12 -2
- package/dist/lib/installations/migrate.js +4 -4
- package/dist/lib/installations/store.js +6 -3
- package/dist/lib/installations/versions.js +48 -46
- package/dist/lib/menubar/install-menubar.d.ts +71 -8
- package/dist/lib/menubar/install-menubar.js +183 -24
- package/dist/lib/share/publish.d.ts +8 -3
- package/dist/lib/share/publish.js +6 -3
- package/dist/lib/share/worker-template.js +239 -22
- package/dist/lib/star-nudge.d.ts +2 -2
- package/dist/lib/star-nudge.js +2 -2
- package/dist/lib/state.d.ts +1 -1
- package/dist/lib/state.js +2 -2
- package/dist/lib/teams/agents.d.ts +14 -12
- package/dist/lib/teams/agents.js +188 -73
- package/dist/lib/teams/api.d.ts +6 -1
- package/dist/lib/teams/api.js +2 -0
- package/dist/lib/teams/scheduler.d.ts +7 -0
- package/dist/lib/teams/scheduler.js +26 -8
- package/dist/lib/traces/sync.d.ts +30 -0
- package/dist/lib/traces/sync.js +103 -10
- package/package.json +3 -3
package/dist/lib/teams/agents.js
CHANGED
|
@@ -35,9 +35,10 @@ import { pullRemoteLogDelta, REMOTE_MIRROR_MAX_BYTES } from '../hosts/progress.j
|
|
|
35
35
|
import { createRemoteWorktree, ensureRemoteRepo } from './remoteWorktree.js';
|
|
36
36
|
import { getTeam, isTeamDisbanded } from './registry.js';
|
|
37
37
|
import { atomicWriteJsonSync } from '../fs-atomic.js';
|
|
38
|
-
import { resolvePlacement, classifyExclusions, NoViableDeviceError } from './scheduler.js';
|
|
38
|
+
import { resolvePlacement, classifyExclusions, isTransientPlacementBlock, NoViableDeviceError, } from './scheduler.js';
|
|
39
39
|
import { probePoolSignals } from './placement-probe.js';
|
|
40
40
|
import { readMaxConcurrentCaps } from '../device-config.js';
|
|
41
|
+
import { redactSecrets, sanitizeForTerminal } from '../redact.js';
|
|
41
42
|
import chalk from 'chalk';
|
|
42
43
|
let lastMemoryWarnAt = 0;
|
|
43
44
|
// On macOS, os.freemem() returns only the truly-free pool and ignores the
|
|
@@ -148,6 +149,9 @@ export const TERMINAL_STATUSES = new Set([
|
|
|
148
149
|
export function isTerminalStatus(status) {
|
|
149
150
|
return TERMINAL_STATUSES.has(status);
|
|
150
151
|
}
|
|
152
|
+
function safeFailureMessage(message) {
|
|
153
|
+
return redactSecrets(sanitizeForTerminal(message)).replace(/\s+/g, ' ').trim().slice(0, 500);
|
|
154
|
+
}
|
|
151
155
|
/**
|
|
152
156
|
* The per-teammate shell that emits `<id> <ALIVE|EXITED|GONE> <codeOrEmpty>`.
|
|
153
157
|
* Shared by the batched prefetch (many teammates, one round-trip) and the
|
|
@@ -546,6 +550,7 @@ export class AgentProcess {
|
|
|
546
550
|
remotePid = null;
|
|
547
551
|
remoteLog = null;
|
|
548
552
|
remoteExit = null;
|
|
553
|
+
failure = null;
|
|
549
554
|
// Offset-tail cursor into the REMOTE log (bytes already pulled). Distinct from
|
|
550
555
|
// lastReadPos, which tracks the LOCAL mirror the parser consumes.
|
|
551
556
|
remoteLogOffset = 0;
|
|
@@ -621,6 +626,7 @@ export class AgentProcess {
|
|
|
621
626
|
cloud_session_id: this.cloudSessionId,
|
|
622
627
|
cloud_repo: this.cloudRepo,
|
|
623
628
|
cloud_branch: this.cloudBranch,
|
|
629
|
+
failure: this.failure,
|
|
624
630
|
agent_dir: await this.getAgentDir(),
|
|
625
631
|
cwd: this.cwd,
|
|
626
632
|
};
|
|
@@ -668,6 +674,7 @@ export class AgentProcess {
|
|
|
668
674
|
task_type: this.taskType,
|
|
669
675
|
cloud_repo: this.cloudRepo,
|
|
670
676
|
cloud_branch: this.cloudBranch,
|
|
677
|
+
failure: this.failure,
|
|
671
678
|
};
|
|
672
679
|
}
|
|
673
680
|
duration() {
|
|
@@ -767,6 +774,13 @@ export class AgentProcess {
|
|
|
767
774
|
const code = Number.parseInt(snap.exit.trim(), 10);
|
|
768
775
|
if (Number.isFinite(code)) {
|
|
769
776
|
this.status = code === 0 ? AgentStatus.COMPLETED : AgentStatus.FAILED;
|
|
777
|
+
if (code !== 0) {
|
|
778
|
+
this.failure = {
|
|
779
|
+
stage: 'execution', code: 'remote-process-exit-nonzero',
|
|
780
|
+
message: `Remote teammate process exited with code ${code}.`, exit_code: code,
|
|
781
|
+
retryable: false, observed_at: new Date().toISOString(),
|
|
782
|
+
};
|
|
783
|
+
}
|
|
770
784
|
if (!this.completedAt)
|
|
771
785
|
this.completedAt = new Date();
|
|
772
786
|
return;
|
|
@@ -780,6 +794,11 @@ export class AgentProcess {
|
|
|
780
794
|
// RUNNING above precisely so this branch does not misfire on that race.
|
|
781
795
|
if (this.status === AgentStatus.RUNNING && !snap.alive && !snap.exitFilePresent) {
|
|
782
796
|
this.status = AgentStatus.FAILED;
|
|
797
|
+
this.failure = {
|
|
798
|
+
stage: 'execution', code: 'remote-process-gone',
|
|
799
|
+
message: 'Remote teammate process disappeared before recording an exit code.', exit_code: null,
|
|
800
|
+
retryable: true, observed_at: new Date().toISOString(),
|
|
801
|
+
};
|
|
783
802
|
if (!this.completedAt)
|
|
784
803
|
this.completedAt = this.getLatestEventTime() || this.startedAt || new Date();
|
|
785
804
|
}
|
|
@@ -870,6 +889,11 @@ export class AgentProcess {
|
|
|
870
889
|
}
|
|
871
890
|
else if (event.status === 'error') {
|
|
872
891
|
this.status = AgentStatus.FAILED;
|
|
892
|
+
this.failure = {
|
|
893
|
+
stage: 'execution', code: 'harness-reported-error',
|
|
894
|
+
message: 'The harness reported a terminal error.', exit_code: null,
|
|
895
|
+
retryable: false, observed_at: new Date().toISOString(),
|
|
896
|
+
};
|
|
873
897
|
this.completedAt = event.timestamp ? new Date(event.timestamp) : new Date();
|
|
874
898
|
}
|
|
875
899
|
}
|
|
@@ -992,6 +1016,7 @@ export class AgentProcess {
|
|
|
992
1016
|
remote_log: this.remoteLog,
|
|
993
1017
|
remote_exit: this.remoteExit,
|
|
994
1018
|
remote_log_offset: this.remoteLogOffset,
|
|
1019
|
+
failure: this.failure,
|
|
995
1020
|
};
|
|
996
1021
|
const metaPath = await this.getMetaPath();
|
|
997
1022
|
atomicWriteJsonSync(metaPath, meta);
|
|
@@ -1088,6 +1113,16 @@ export class AgentProcess {
|
|
|
1088
1113
|
agent.remoteLog = meta.remote_log || null;
|
|
1089
1114
|
agent.remoteExit = meta.remote_exit || null;
|
|
1090
1115
|
agent.remoteLogOffset = typeof meta.remote_log_offset === 'number' ? meta.remote_log_offset : 0;
|
|
1116
|
+
agent.failure = meta.failure && typeof meta.failure === 'object'
|
|
1117
|
+
? {
|
|
1118
|
+
stage: meta.failure.stage,
|
|
1119
|
+
code: String(meta.failure.code),
|
|
1120
|
+
message: safeFailureMessage(String(meta.failure.message ?? '')),
|
|
1121
|
+
exit_code: typeof meta.failure.exit_code === 'number' ? meta.failure.exit_code : null,
|
|
1122
|
+
retryable: Boolean(meta.failure.retryable),
|
|
1123
|
+
observed_at: String(meta.failure.observed_at),
|
|
1124
|
+
}
|
|
1125
|
+
: null;
|
|
1091
1126
|
// The team's project. Absent on every teammate added before `--project`.
|
|
1092
1127
|
agent.project = typeof meta.project === 'string' ? meta.project : null;
|
|
1093
1128
|
return agent;
|
|
@@ -1258,6 +1293,11 @@ export class AgentProcess {
|
|
|
1258
1293
|
const fallbackCompletion = this.getLatestEventTime() || this.startedAt || new Date();
|
|
1259
1294
|
if (this.status === AgentStatus.RUNNING) {
|
|
1260
1295
|
this.status = AgentStatus.FAILED;
|
|
1296
|
+
this.failure = {
|
|
1297
|
+
stage: 'spawn', code: 'process-identity-missing',
|
|
1298
|
+
message: 'The teammate was marked running without a process identity.', exit_code: null,
|
|
1299
|
+
retryable: true, observed_at: new Date().toISOString(),
|
|
1300
|
+
};
|
|
1261
1301
|
this.completedAt = fallbackCompletion;
|
|
1262
1302
|
}
|
|
1263
1303
|
await this.saveMeta();
|
|
@@ -1275,12 +1315,22 @@ export class AgentProcess {
|
|
|
1275
1315
|
return;
|
|
1276
1316
|
}
|
|
1277
1317
|
if (this.status === AgentStatus.RUNNING) {
|
|
1278
|
-
const
|
|
1318
|
+
const exit = await this.reapProcess();
|
|
1279
1319
|
await this.readNewEvents();
|
|
1280
1320
|
if (this.status === AgentStatus.RUNNING) {
|
|
1281
1321
|
const fallbackCompletion = this.getLatestEventTime() || this.startedAt || new Date();
|
|
1282
|
-
if (
|
|
1322
|
+
if (exit !== null && exit.code !== 0) {
|
|
1283
1323
|
this.status = AgentStatus.FAILED;
|
|
1324
|
+
this.failure = {
|
|
1325
|
+
stage: 'execution',
|
|
1326
|
+
code: exit.sentinelPresent ? 'process-exit-nonzero' : 'process-exit-unrecorded',
|
|
1327
|
+
message: exit.sentinelPresent
|
|
1328
|
+
? `Teammate process exited with code ${exit.code}.`
|
|
1329
|
+
: 'Teammate process disappeared before recording an exit code.',
|
|
1330
|
+
exit_code: exit.sentinelPresent ? exit.code : null,
|
|
1331
|
+
retryable: !exit.sentinelPresent,
|
|
1332
|
+
observed_at: new Date().toISOString(),
|
|
1333
|
+
};
|
|
1284
1334
|
}
|
|
1285
1335
|
else {
|
|
1286
1336
|
this.status = AgentStatus.COMPLETED;
|
|
@@ -1322,11 +1372,11 @@ export class AgentProcess {
|
|
|
1322
1372
|
try {
|
|
1323
1373
|
const raw = (await fs.readFile(await this.getExitCodePath(), 'utf-8')).trim();
|
|
1324
1374
|
const code = Number.parseInt(raw, 10);
|
|
1325
|
-
return Number.isNaN(code) ? 1 : code;
|
|
1375
|
+
return { code: Number.isNaN(code) ? 1 : code, sentinelPresent: true };
|
|
1326
1376
|
}
|
|
1327
1377
|
catch {
|
|
1328
1378
|
// No sentinel: the shell died before recording $? (killed mid-run).
|
|
1329
|
-
return 1;
|
|
1379
|
+
return { code: 1, sentinelPresent: false };
|
|
1330
1380
|
}
|
|
1331
1381
|
}
|
|
1332
1382
|
}
|
|
@@ -1485,6 +1535,26 @@ export class AgentManager {
|
|
|
1485
1535
|
registerAgent(agent) {
|
|
1486
1536
|
this.agents.set(agent.agentId, agent);
|
|
1487
1537
|
}
|
|
1538
|
+
async failAgent(agent, failure) {
|
|
1539
|
+
agent.failure = {
|
|
1540
|
+
...failure,
|
|
1541
|
+
message: safeFailureMessage(failure.message),
|
|
1542
|
+
observed_at: new Date().toISOString(),
|
|
1543
|
+
};
|
|
1544
|
+
agent.status = AgentStatus.FAILED;
|
|
1545
|
+
agent.completedAt = new Date();
|
|
1546
|
+
await agent.saveMeta();
|
|
1547
|
+
}
|
|
1548
|
+
async deferAgent(agent, failure) {
|
|
1549
|
+
agent.failure = {
|
|
1550
|
+
...failure,
|
|
1551
|
+
message: safeFailureMessage(failure.message),
|
|
1552
|
+
observed_at: new Date().toISOString(),
|
|
1553
|
+
};
|
|
1554
|
+
agent.status = AgentStatus.PENDING;
|
|
1555
|
+
agent.completedAt = null;
|
|
1556
|
+
await agent.saveMeta();
|
|
1557
|
+
}
|
|
1488
1558
|
/**
|
|
1489
1559
|
* Scan the agents dir for meta.json files not already in the in-memory
|
|
1490
1560
|
* cache and load them. Needed when another process (e.g. a Planner
|
|
@@ -1761,7 +1831,7 @@ export class AgentManager {
|
|
|
1761
1831
|
// --session-id so it's also Claude's session id (unified identity).
|
|
1762
1832
|
const agentId = randomUUID();
|
|
1763
1833
|
const isStaged = cleanAfter.length > 0;
|
|
1764
|
-
const initialStatus = isStaged || !isCloudBacked
|
|
1834
|
+
const initialStatus = isStaged || !isCloudBacked || !cloudSessionId
|
|
1765
1835
|
? AgentStatus.PENDING
|
|
1766
1836
|
: AgentStatus.RUNNING;
|
|
1767
1837
|
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);
|
|
@@ -1796,10 +1866,29 @@ export class AgentManager {
|
|
|
1796
1866
|
debug(`Staged ${agentType} teammate '${name}' in team '${taskName}' (after: ${cleanAfter.join(', ')})`);
|
|
1797
1867
|
}
|
|
1798
1868
|
else if (isCloudBacked) {
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1869
|
+
if (cloudSessionId) {
|
|
1870
|
+
// Compatibility path for API callers that already dispatched remotely.
|
|
1871
|
+
await agent.saveMeta();
|
|
1872
|
+
debug(`Cloud-backed ${agentType} teammate via ${cloudProvider} (session=${cloudSessionId})`);
|
|
1873
|
+
}
|
|
1874
|
+
else {
|
|
1875
|
+
try {
|
|
1876
|
+
if (!this.cloudDispatcher)
|
|
1877
|
+
throw new Error('No cloud dispatcher registered.');
|
|
1878
|
+
const dispatched = await this.cloudDispatcher(agent);
|
|
1879
|
+
agent.cloudSessionId = dispatched.cloudSessionId;
|
|
1880
|
+
agent.status = AgentStatus.RUNNING;
|
|
1881
|
+
agent.startedAt = new Date();
|
|
1882
|
+
await agent.saveMeta();
|
|
1883
|
+
}
|
|
1884
|
+
catch (err) {
|
|
1885
|
+
await this.failAgent(agent, {
|
|
1886
|
+
stage: 'cloud', code: 'cloud-dispatch-failed', message: err.message,
|
|
1887
|
+
exit_code: null, retryable: true,
|
|
1888
|
+
});
|
|
1889
|
+
throw err;
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1803
1892
|
}
|
|
1804
1893
|
else if (isRemoteBacked) {
|
|
1805
1894
|
// Distributed teammate that can run now (no unmet --after deps): dispatch
|
|
@@ -1810,7 +1899,27 @@ export class AgentManager {
|
|
|
1810
1899
|
// Unpinned + launching now: consult the pool scheduler before defaulting to
|
|
1811
1900
|
// local, so an unpinned teammate on a --devices team auto-schedules even when
|
|
1812
1901
|
// added without --after (it wouldn't pass through startReady otherwise).
|
|
1813
|
-
|
|
1902
|
+
try {
|
|
1903
|
+
await this.maybeSchedulePlacement(agent, taskName);
|
|
1904
|
+
}
|
|
1905
|
+
catch (err) {
|
|
1906
|
+
if (isTransientPlacementBlock(err)) {
|
|
1907
|
+
await this.deferAgent(agent, {
|
|
1908
|
+
stage: 'placement', code: 'placement-capacity-wait', message: err.message,
|
|
1909
|
+
exit_code: null, retryable: true,
|
|
1910
|
+
});
|
|
1911
|
+
await this.cleanupOldAgents();
|
|
1912
|
+
return agent;
|
|
1913
|
+
}
|
|
1914
|
+
await this.failAgent(agent, {
|
|
1915
|
+
stage: 'placement',
|
|
1916
|
+
code: err instanceof NoViableDeviceError ? 'no-viable-device' : 'placement-failed',
|
|
1917
|
+
message: err.message,
|
|
1918
|
+
exit_code: null,
|
|
1919
|
+
retryable: !(err instanceof NoViableDeviceError),
|
|
1920
|
+
});
|
|
1921
|
+
throw err;
|
|
1922
|
+
}
|
|
1814
1923
|
if (agent.hostName)
|
|
1815
1924
|
await this.launchRemoteProcess(agent);
|
|
1816
1925
|
else
|
|
@@ -1878,6 +1987,7 @@ export class AgentManager {
|
|
|
1878
1987
|
const resume = { id: agent.remoteSessionId ?? agent.agentId, message };
|
|
1879
1988
|
const priorRuntime = {
|
|
1880
1989
|
status: agent.status,
|
|
1990
|
+
failure: agent.failure,
|
|
1881
1991
|
completedAt: agent.completedAt,
|
|
1882
1992
|
pid: agent.pid,
|
|
1883
1993
|
startTime: agent.startTime,
|
|
@@ -1894,6 +2004,13 @@ export class AgentManager {
|
|
|
1894
2004
|
// that happens, restore the stopped lifecycle state and keep its existing
|
|
1895
2005
|
// metadata/log directory intact so the user can retry.
|
|
1896
2006
|
agent.status = AgentStatus.RUNNING;
|
|
2007
|
+
// Failure evidence describes the CURRENT attempt. Once a resume has
|
|
2008
|
+
// successfully launched, retaining the prior terminal attempt's failure on
|
|
2009
|
+
// a RUNNING (and eventually COMPLETED) teammate is false state. Clear it
|
|
2010
|
+
// before either launcher persists RUNNING; priorRuntime restores it if the
|
|
2011
|
+
// replacement launch fails, so a failed resume never erases the evidence
|
|
2012
|
+
// needed to diagnose and retry the original attempt.
|
|
2013
|
+
agent.failure = null;
|
|
1897
2014
|
agent.completedAt = null;
|
|
1898
2015
|
try {
|
|
1899
2016
|
if (agent.hostName) {
|
|
@@ -1992,11 +2109,15 @@ export class AgentManager {
|
|
|
1992
2109
|
await terminateSpawnedProcess(childProcess.pid);
|
|
1993
2110
|
if (resumeLog)
|
|
1994
2111
|
await rollbackResumeLogTransaction(resumeLog);
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2112
|
+
if (!resume) {
|
|
2113
|
+
await this.failAgent(agent, {
|
|
2114
|
+
stage: 'spawn',
|
|
2115
|
+
code: 'local-spawn-failed',
|
|
2116
|
+
message: err.message,
|
|
2117
|
+
exit_code: null,
|
|
2118
|
+
retryable: true,
|
|
2119
|
+
});
|
|
2120
|
+
}
|
|
2000
2121
|
console.error(`Failed to spawn agent ${agent.agentId}:`, err);
|
|
2001
2122
|
throw new Error(`Failed to spawn agent: ${err.message}`);
|
|
2002
2123
|
}
|
|
@@ -2118,6 +2239,15 @@ export class AgentManager {
|
|
|
2118
2239
|
}
|
|
2119
2240
|
}
|
|
2120
2241
|
console.error(`Failed to launch remote teammate ${agent.agentId} on ${agent.hostName}:`, err);
|
|
2242
|
+
if (!resume) {
|
|
2243
|
+
await this.failAgent(agent, {
|
|
2244
|
+
stage: 'spawn',
|
|
2245
|
+
code: 'remote-launch-failed',
|
|
2246
|
+
message: err.message,
|
|
2247
|
+
exit_code: null,
|
|
2248
|
+
retryable: true,
|
|
2249
|
+
});
|
|
2250
|
+
}
|
|
2121
2251
|
if (cleanupError) {
|
|
2122
2252
|
throw new Error(`Failed to launch remote teammate: ${err.message}; cleanup failed: ${cleanupError.message}`, { cause: err });
|
|
2123
2253
|
}
|
|
@@ -2200,49 +2330,6 @@ export class AgentManager {
|
|
|
2200
2330
|
placementAgentLabel(agent) {
|
|
2201
2331
|
return agent.version ? `${agent.agentType}@${agent.version}` : String(agent.agentType);
|
|
2202
2332
|
}
|
|
2203
|
-
/**
|
|
2204
|
-
* Pre-flight the team pool at `teams start` (RUSH-2002): probe the pool and
|
|
2205
|
-
* confirm each distinct agent among the pending, unpinned, pooled teammates
|
|
2206
|
-
* has at least one viable device. Returns the first {@link NoViableDeviceError}
|
|
2207
|
-
* so the command can fail loud BEFORE launching a wave, rather than silently
|
|
2208
|
-
* leaving teammates stranded pending. A poolless team (no `--devices`) or a
|
|
2209
|
-
* probe that could not gather positive evidence returns null — fail-loud fires
|
|
2210
|
-
* only on proof that no device can run the agent, never on a probe miss.
|
|
2211
|
-
*/
|
|
2212
|
-
async preflightPlacement(taskName) {
|
|
2213
|
-
await this.initialize();
|
|
2214
|
-
const teamMeta = await getTeam(taskName);
|
|
2215
|
-
const pool = teamMeta?.devices ?? [];
|
|
2216
|
-
if (!teamMeta || pool.length === 0)
|
|
2217
|
-
return null;
|
|
2218
|
-
const roster = await this.listByTask(taskName);
|
|
2219
|
-
const pending = roster.filter((a) => a.status === AgentStatus.PENDING && !a.hostName && !a.cloudProvider);
|
|
2220
|
-
if (pending.length === 0)
|
|
2221
|
-
return null;
|
|
2222
|
-
const maxConcurrent = pool.length > 1 ? readMaxConcurrentCaps(pool) : undefined;
|
|
2223
|
-
// One representative teammate per distinct agent type — the signal + gate is
|
|
2224
|
-
// per harness, not per teammate.
|
|
2225
|
-
const byAgent = new Map();
|
|
2226
|
-
for (const a of pending)
|
|
2227
|
-
if (!byAgent.has(a.agentType))
|
|
2228
|
-
byAgent.set(a.agentType, a);
|
|
2229
|
-
for (const [, rep] of byAgent) {
|
|
2230
|
-
const signals = await probePoolSignals(pool, rep.agentType, { now: Date.now() });
|
|
2231
|
-
try {
|
|
2232
|
-
resolvePlacement(teamMeta, null, roster, {
|
|
2233
|
-
maxConcurrent,
|
|
2234
|
-
signals,
|
|
2235
|
-
agentLabel: this.placementAgentLabel(rep),
|
|
2236
|
-
});
|
|
2237
|
-
}
|
|
2238
|
-
catch (err) {
|
|
2239
|
-
if (err instanceof NoViableDeviceError)
|
|
2240
|
-
return err;
|
|
2241
|
-
throw err;
|
|
2242
|
-
}
|
|
2243
|
-
}
|
|
2244
|
-
return null;
|
|
2245
|
-
}
|
|
2246
2333
|
/**
|
|
2247
2334
|
* One-ssh-per-host batched liveness/exit pre-pass for a team's remote teammates.
|
|
2248
2335
|
* The supervisor calls this each wave BEFORE listByTask() so the per-teammate
|
|
@@ -2319,6 +2406,20 @@ export class AgentManager {
|
|
|
2319
2406
|
for (const agent of teammates) {
|
|
2320
2407
|
if (agent.status !== AgentStatus.PENDING)
|
|
2321
2408
|
continue;
|
|
2409
|
+
const blockers = agent.after
|
|
2410
|
+
.map((depName) => ({ depName, dep: byName.get(depName) }))
|
|
2411
|
+
.filter(({ dep }) => !dep || (isTerminalStatus(dep.status) && dep.status !== AgentStatus.COMPLETED));
|
|
2412
|
+
if (blockers.length > 0) {
|
|
2413
|
+
const names = blockers.map(({ depName, dep }) => `${depName} (${dep?.status ?? 'missing'})`);
|
|
2414
|
+
await this.failAgent(agent, {
|
|
2415
|
+
stage: 'dependency',
|
|
2416
|
+
code: 'dependency-failed',
|
|
2417
|
+
message: `Blocked by dependency: ${names.join(', ')}.`,
|
|
2418
|
+
exit_code: null,
|
|
2419
|
+
retryable: false,
|
|
2420
|
+
});
|
|
2421
|
+
continue;
|
|
2422
|
+
}
|
|
2322
2423
|
const depsReady = agent.after.every((depName) => {
|
|
2323
2424
|
const dep = byName.get(depName);
|
|
2324
2425
|
return dep && dep.status === AgentStatus.COMPLETED;
|
|
@@ -2333,9 +2434,21 @@ export class AgentManager {
|
|
|
2333
2434
|
await this.maybeSchedulePlacement(agent, taskName, { probe: true });
|
|
2334
2435
|
}
|
|
2335
2436
|
catch (err) {
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2437
|
+
if (isTransientPlacementBlock(err)) {
|
|
2438
|
+
await this.deferAgent(agent, {
|
|
2439
|
+
stage: 'placement', code: 'placement-capacity-wait', message: err.message,
|
|
2440
|
+
exit_code: null, retryable: true,
|
|
2441
|
+
});
|
|
2442
|
+
console.error(`Placement deferred for ${agent.agentId}; the pool may free up on a later wave:`, err);
|
|
2443
|
+
continue;
|
|
2444
|
+
}
|
|
2445
|
+
await this.failAgent(agent, {
|
|
2446
|
+
stage: 'placement',
|
|
2447
|
+
code: err instanceof NoViableDeviceError ? 'no-viable-device' : 'placement-failed',
|
|
2448
|
+
message: err.message,
|
|
2449
|
+
exit_code: null,
|
|
2450
|
+
retryable: !(err instanceof NoViableDeviceError),
|
|
2451
|
+
});
|
|
2339
2452
|
console.error(`Could not schedule ${agent.agentId} onto the team pool:`, err);
|
|
2340
2453
|
continue;
|
|
2341
2454
|
}
|
|
@@ -2347,7 +2460,12 @@ export class AgentManager {
|
|
|
2347
2460
|
}
|
|
2348
2461
|
else if (agent.cloudProvider) {
|
|
2349
2462
|
if (!this.cloudDispatcher) {
|
|
2350
|
-
|
|
2463
|
+
const message = `Cannot start cloud-backed teammate ${agent.agentId}: no dispatcher registered.`;
|
|
2464
|
+
await this.failAgent(agent, {
|
|
2465
|
+
stage: 'cloud', code: 'cloud-dispatcher-missing', message,
|
|
2466
|
+
exit_code: null, retryable: false,
|
|
2467
|
+
});
|
|
2468
|
+
console.error(message);
|
|
2351
2469
|
continue;
|
|
2352
2470
|
}
|
|
2353
2471
|
const { cloudSessionId } = await this.cloudDispatcher(agent);
|
|
@@ -2363,6 +2481,13 @@ export class AgentManager {
|
|
|
2363
2481
|
}
|
|
2364
2482
|
}
|
|
2365
2483
|
catch (err) {
|
|
2484
|
+
await this.failAgent(agent, {
|
|
2485
|
+
stage: agent.cloudProvider ? 'cloud' : 'spawn',
|
|
2486
|
+
code: agent.cloudProvider ? 'cloud-dispatch-failed' : agent.hostName ? 'remote-launch-failed' : 'local-spawn-failed',
|
|
2487
|
+
message: err.message,
|
|
2488
|
+
exit_code: null,
|
|
2489
|
+
retryable: true,
|
|
2490
|
+
});
|
|
2366
2491
|
console.error(`Could not launch ${agent.agentId}:`, err);
|
|
2367
2492
|
}
|
|
2368
2493
|
}
|
|
@@ -2657,16 +2782,6 @@ export class AgentManager {
|
|
|
2657
2782
|
}
|
|
2658
2783
|
return false;
|
|
2659
2784
|
}
|
|
2660
|
-
async cleanupPartialAgent(agent) {
|
|
2661
|
-
this.agents.delete(agent.agentId);
|
|
2662
|
-
try {
|
|
2663
|
-
const agentDir = await agent.getAgentDir();
|
|
2664
|
-
await fs.rm(agentDir, { recursive: true });
|
|
2665
|
-
}
|
|
2666
|
-
catch (err) {
|
|
2667
|
-
console.warn(`Failed to clean up agent directory:`, err);
|
|
2668
|
-
}
|
|
2669
|
-
}
|
|
2670
2785
|
async cleanupOldAgents() {
|
|
2671
2786
|
// listCompleted() is terminal-only (isTerminalStatus), so a pending or
|
|
2672
2787
|
// running teammate is never a reap candidate — retention can only delete a
|
package/dist/lib/teams/api.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AgentManager, type TaskType } from './agents.js';
|
|
1
|
+
import { AgentManager, type TaskType, type TeammateFailure } from './agents.js';
|
|
2
2
|
import { AgentType } from './parsers.js';
|
|
3
3
|
export interface ToolCallDetail {
|
|
4
4
|
tool: string;
|
|
@@ -22,6 +22,8 @@ export interface SpawnResult {
|
|
|
22
22
|
cloud_session_id?: string | null;
|
|
23
23
|
/** Device name the teammate runs on for a distributed (--on) teammate; null for local. */
|
|
24
24
|
host?: string | null;
|
|
25
|
+
/** Sanitized evidence observed at the lifecycle boundary that failed. */
|
|
26
|
+
failure?: TeammateFailure | null;
|
|
25
27
|
}
|
|
26
28
|
/** Detailed status of a single teammate, including file ops, commands, and a cursor for delta polling. */
|
|
27
29
|
export interface AgentStatusDetail {
|
|
@@ -60,6 +62,8 @@ export interface AgentStatusDetail {
|
|
|
60
62
|
task_type?: TaskType | null;
|
|
61
63
|
/** Device name the teammate runs on for a distributed (--on) teammate; null for local. */
|
|
62
64
|
host?: string | null;
|
|
65
|
+
/** Sanitized evidence observed at the lifecycle boundary that failed. */
|
|
66
|
+
failure?: TeammateFailure | null;
|
|
63
67
|
}
|
|
64
68
|
/** Aggregated status of all teammates in a task, with per-status counts and a global cursor. */
|
|
65
69
|
export interface TaskStatusResult {
|
|
@@ -121,6 +125,7 @@ export interface AgentStatusSummary {
|
|
|
121
125
|
last_messages: string[];
|
|
122
126
|
/** Device name for a distributed (--on) teammate; null for local. */
|
|
123
127
|
host: string | null;
|
|
128
|
+
failure: TeammateFailure | null;
|
|
124
129
|
/** ISO timestamp — feed back via --since for delta polling. */
|
|
125
130
|
cursor: string;
|
|
126
131
|
}
|
package/dist/lib/teams/api.js
CHANGED
|
@@ -125,6 +125,7 @@ export function toAgentStatusSummary(detail) {
|
|
|
125
125
|
.slice(-SUMMARY_MAX_MESSAGES)
|
|
126
126
|
.map((m) => trimMessage(m)),
|
|
127
127
|
host: detail.host ?? null,
|
|
128
|
+
failure: detail.failure ?? null,
|
|
128
129
|
cursor: detail.cursor,
|
|
129
130
|
};
|
|
130
131
|
}
|
|
@@ -253,6 +254,7 @@ parentSessionId) {
|
|
|
253
254
|
after: agent.after,
|
|
254
255
|
task_type: agent.taskType,
|
|
255
256
|
host: agent.hostName,
|
|
257
|
+
failure: agent.failure,
|
|
256
258
|
mode: agent.mode,
|
|
257
259
|
cloud_session_id: agent.cloudSessionId,
|
|
258
260
|
cloud_provider: agent.cloudProvider,
|
|
@@ -68,6 +68,13 @@ export declare class NoViableDeviceError extends Error {
|
|
|
68
68
|
readonly agentLabel?: string;
|
|
69
69
|
constructor(excluded: ExcludedDevice[], agentLabel?: string);
|
|
70
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* Whether a failed placement can become viable without changing the team.
|
|
73
|
+
* Capacity and load are time-varying; a pool with at least one such exclusion
|
|
74
|
+
* must remain schedulable so a later supervisor wave can retry it. A pool that
|
|
75
|
+
* is only missing the requested harness is a durable configuration failure.
|
|
76
|
+
*/
|
|
77
|
+
export declare function isTransientPlacementBlock(error: unknown): error is NoViableDeviceError;
|
|
71
78
|
/** Build the user-facing fail-loud message from the per-device reasons. */
|
|
72
79
|
export declare function formatNoViableMessage(excluded: ExcludedDevice[], agentLabel?: string): string;
|
|
73
80
|
/**
|
|
@@ -50,9 +50,20 @@ export class NoViableDeviceError extends Error {
|
|
|
50
50
|
this.agentLabel = agentLabel;
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Whether a failed placement can become viable without changing the team.
|
|
55
|
+
* Capacity and load are time-varying; a pool with at least one such exclusion
|
|
56
|
+
* must remain schedulable so a later supervisor wave can retry it. A pool that
|
|
57
|
+
* is only missing the requested harness is a durable configuration failure.
|
|
58
|
+
*/
|
|
59
|
+
export function isTransientPlacementBlock(error) {
|
|
60
|
+
return error instanceof NoViableDeviceError
|
|
61
|
+
&& error.excluded.some((entry) => entry.reason === 'capped' || entry.reason === 'overloaded');
|
|
62
|
+
}
|
|
53
63
|
/** Build the user-facing fail-loud message from the per-device reasons. */
|
|
54
64
|
export function formatNoViableMessage(excluded, agentLabel) {
|
|
55
65
|
const anyNotInstalled = excluded.some((e) => e.reason === 'not-installed');
|
|
66
|
+
const allCapped = excluded.length > 0 && excluded.every((e) => e.reason === 'capped');
|
|
56
67
|
const agent = agentLabel ?? 'the requested agent';
|
|
57
68
|
const perDevice = excluded
|
|
58
69
|
.map((e) => (e.detail ? `${e.device} (${e.reason} ${e.detail})` : `${e.device} (${e.reason})`))
|
|
@@ -61,14 +72,21 @@ export function formatNoViableMessage(excluded, agentLabel) {
|
|
|
61
72
|
// ticket specifies; otherwise summarize the mixed reasons.
|
|
62
73
|
const head = anyNotInstalled
|
|
63
74
|
? `No device in the team pool can run ${agent}.`
|
|
64
|
-
:
|
|
75
|
+
: allCapped
|
|
76
|
+
? `Every device in the pool is at its agents.max-concurrent cap:`
|
|
77
|
+
: `No viable device in the team pool for ${agent}.`;
|
|
65
78
|
const anyUnreachable = excluded.some((e) => e.reason === 'unreachable');
|
|
66
79
|
const hint = anyNotInstalled
|
|
67
80
|
? " Run 'agents devices ping' to see which devices have the agent installed + signed in, or add the agent to a pool device."
|
|
68
81
|
: anyUnreachable
|
|
69
82
|
? " Add a device to the pool, raise a cap, or wait for the pool to free up / come back online ('agents devices ping')."
|
|
70
|
-
:
|
|
71
|
-
|
|
83
|
+
: allCapped
|
|
84
|
+
? " Raise a cap with 'agents devices config <name> agents.max-concurrent N' or add a device to the pool."
|
|
85
|
+
: ' Add a device to the pool, raise a cap, or bring an overloaded box under load.';
|
|
86
|
+
const reasons = allCapped
|
|
87
|
+
? excluded.map((e) => `${e.device} (${e.detail})`).join(', ')
|
|
88
|
+
: perDevice;
|
|
89
|
+
return `${head} ${reasons}.${hint}`;
|
|
72
90
|
}
|
|
73
91
|
/** True when `device` names the local machine (case-insensitive). */
|
|
74
92
|
function isLocalDevice(device) {
|
|
@@ -128,11 +146,11 @@ export function pickLeastLoaded(devices, roster, maxConcurrent) {
|
|
|
128
146
|
const capped = new Set(maxConcurrent ? cappedDevices(devices, roster, maxConcurrent).map((c) => c.device) : []);
|
|
129
147
|
const eligible = devices.filter((d) => !capped.has(d));
|
|
130
148
|
if (eligible.length === 0) {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
149
|
+
throw new NoViableDeviceError(devices.map((device) => ({
|
|
150
|
+
device,
|
|
151
|
+
reason: 'capped',
|
|
152
|
+
detail: `${load.get(device) ?? 0}/${maxConcurrent[device]}`,
|
|
153
|
+
})));
|
|
136
154
|
}
|
|
137
155
|
// Iterate the pool in declared order so the first device wins ties.
|
|
138
156
|
let best = eligible[0];
|
|
@@ -9,6 +9,15 @@
|
|
|
9
9
|
* Sync gate: `sessions.db` `file_mtime_ms` is the source of truth. A ledger
|
|
10
10
|
* at `getRuntimeStateDir()/traces-sync.json` records the last sync timestamp
|
|
11
11
|
* per device so re-runs skip unchanged sessions.
|
|
12
|
+
*
|
|
13
|
+
* Retry ledger: the mtime watermark alone cannot retry failures — a later-mtime
|
|
14
|
+
* success advances it past an earlier failed row, which the next run's
|
|
15
|
+
* `file_mtime_ms > watermark` filter then skips forever. So the same ledger also
|
|
16
|
+
* records the identity + typed evidence of each failed session (`failures`), and
|
|
17
|
+
* the row query unions those retry-worthy ids back in regardless of the watermark
|
|
18
|
+
* (PHNX-3267). Failures are typed so a gone transcript (`transcript-unavailable`,
|
|
19
|
+
* expected history, aged out after a TTL and never re-read) is distinguished from a
|
|
20
|
+
* real parse/upload failure (retried until it resolves).
|
|
12
21
|
*/
|
|
13
22
|
import { type SessionTrajectory } from '../session/trajectory.js';
|
|
14
23
|
import { type BucketStats, type DriftSignal, type TraceFailureCause, type TraceTopicGroup } from './classify.js';
|
|
@@ -30,7 +39,14 @@ export interface SyncOpts {
|
|
|
30
39
|
export interface SyncResult {
|
|
31
40
|
uploaded: number;
|
|
32
41
|
skipped: number;
|
|
42
|
+
/** Total failures = transcriptUnavailable + parseFailed + uploadFailed. Kept for callers. */
|
|
33
43
|
errors: number;
|
|
44
|
+
/** The transcript file the row points at is gone/unreadable — expected history, not retried each run. */
|
|
45
|
+
transcriptUnavailable: number;
|
|
46
|
+
/** The transcript exists but could not be parsed into a trajectory — retried until it parses. */
|
|
47
|
+
parseFailed: number;
|
|
48
|
+
/** Parsed fine but the upload PUT failed (network/5xx) — retried on the next sync. */
|
|
49
|
+
uploadFailed: number;
|
|
34
50
|
}
|
|
35
51
|
/** Push derived, redacted trajectories for this device to the traces store. */
|
|
36
52
|
export declare function syncTraces(opts?: SyncOpts): Promise<SyncResult>;
|
|
@@ -107,8 +123,22 @@ export interface SessionDetail {
|
|
|
107
123
|
* written to R2. `repo` is the cwd basename only.
|
|
108
124
|
*/
|
|
109
125
|
export declare function buildSessionDetail(traj: SessionTrajectory): SessionDetail;
|
|
126
|
+
/** How a session failed to sync. Only parse/upload failures are re-queried every run. */
|
|
127
|
+
export type SyncFailureKind = 'transcript-unavailable' | 'parse-failed' | 'upload-failed';
|
|
128
|
+
export interface SyncFailure {
|
|
129
|
+
id: string;
|
|
130
|
+
mtimeMs: number;
|
|
131
|
+
kind: SyncFailureKind;
|
|
132
|
+
/** Bounded, redacted first line of the underlying error — actionable evidence. */
|
|
133
|
+
detail: string;
|
|
134
|
+
/** Epoch ms first recorded, so a permanently-unavailable transcript can be aged out. */
|
|
135
|
+
firstSeen: number;
|
|
136
|
+
attempts: number;
|
|
137
|
+
}
|
|
110
138
|
interface SyncLedger {
|
|
111
139
|
lastSyncMtime?: number;
|
|
140
|
+
/** Failed session identities, keyed by id, that survive across syncs. */
|
|
141
|
+
failures?: SyncFailure[];
|
|
112
142
|
}
|
|
113
143
|
export declare function readSyncLedger(): SyncLedger;
|
|
114
144
|
export declare function writeSyncLedger(ledger: SyncLedger): void;
|