@phnx-labs/agents-cli 1.22.49 → 1.22.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.
Files changed (35) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +10 -0
  3. package/dist/commands/accounts.js +1 -13
  4. package/dist/commands/daemon-test-harness.d.ts +42 -0
  5. package/dist/commands/daemon-test-harness.js +90 -0
  6. package/dist/commands/device-config-test-harness.d.ts +11 -0
  7. package/dist/commands/device-config-test-harness.js +59 -0
  8. package/dist/commands/exec.js +24 -1
  9. package/dist/commands/share.js +9 -4
  10. package/dist/commands/teams.d.ts +12 -0
  11. package/dist/commands/teams.js +44 -67
  12. package/dist/commands/traces.js +4 -0
  13. package/dist/lib/account-provider-registry.d.ts +9 -0
  14. package/dist/lib/account-provider-registry.js +20 -0
  15. package/dist/lib/accounting/account-pool-collect.d.ts +34 -0
  16. package/dist/lib/accounting/account-pool-collect.js +71 -0
  17. package/dist/lib/accounting/account-pool.d.ts +46 -0
  18. package/dist/lib/accounting/account-pool.js +33 -0
  19. package/dist/lib/accounting/capacity.d.ts +24 -0
  20. package/dist/lib/accounting/capacity.js +30 -0
  21. package/dist/lib/accounting/rotate.d.ts +11 -18
  22. package/dist/lib/accounting/rotate.js +6 -24
  23. package/dist/lib/installations/store.js +6 -3
  24. package/dist/lib/installations/versions.js +48 -46
  25. package/dist/lib/share/publish.d.ts +8 -3
  26. package/dist/lib/share/publish.js +6 -3
  27. package/dist/lib/share/worker-template.js +239 -22
  28. package/dist/lib/teams/agents.d.ts +14 -12
  29. package/dist/lib/teams/agents.js +188 -73
  30. package/dist/lib/teams/api.d.ts +6 -1
  31. package/dist/lib/teams/api.js +2 -0
  32. package/dist/lib/teams/scheduler.d.ts +7 -0
  33. package/dist/lib/teams/scheduler.js +26 -8
  34. package/dist/lib/traces/sync.js +12 -0
  35. package/package.json +1 -1
@@ -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 exitCode = await this.reapProcess();
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 (exitCode !== null && exitCode !== 0) {
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
- // Cloud-backed teammate: the provider already dispatched a remote task.
1800
- // No local process to launch; status polling walks the provider instead.
1801
- await agent.saveMeta();
1802
- debug(`Cloud-backed ${agentType} teammate via ${cloudProvider} (session=${cloudSessionId})`);
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
- await this.maybeSchedulePlacement(agent, taskName);
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
- // Fresh spawns own a newly-created directory, so a failed launch removes
1996
- // that partial record. A resume reuses an existing teammate: its caller
1997
- // restores the prior terminal state and preserves the directory for retry.
1998
- if (!resume)
1999
- await this.cleanupPartialAgent(agent);
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
- // A NoViableDeviceError means the pool cannot host this teammate right
2337
- // now — leave it PENDING (never a silent local fallback) and surface the
2338
- // reason; a transient device loss is retried next wave.
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
- console.error(`Cannot start cloud-backed teammate ${agent.agentId}: no dispatcher registered.`);
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
@@ -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
  }
@@ -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
- : `No viable device in the team pool for ${agent}.`;
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
- : ' Add a device to the pool, raise a cap, or bring an overloaded box under load.';
71
- return `${head} ${perDevice}.${hint}`;
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
- const detail = devices
132
- .map((d) => `${d} (${load.get(d) ?? 0}/${maxConcurrent[d]})`)
133
- .join(', ');
134
- throw new Error(`Every device in the pool is at its agents.max-concurrent cap: ${detail}. ` +
135
- `Raise a cap with 'agents devices config <name> agents.max-concurrent N' or add a device to the pool.`);
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];
@@ -115,6 +115,18 @@ export async function syncTraces(opts = {}) {
115
115
  // A dry-run never advances the incremental watermark: it is a read-only export.
116
116
  if (!dryRun) {
117
117
  writeSyncLedger({ lastSyncMtime: maxSuccessMtime });
118
+ // Register the Phoenix session with Prix so the console can fetch live data
119
+ // (PHNX-3257). Fire-and-forget — a link failure must not block sync.
120
+ // Managed backend only: the BYO path's token is a static write token that
121
+ // bypasses Phoenix auth (resolveTracesBackend's userId: 'byo' sentinel) —
122
+ // it is not a Phoenix identity credential and must never be sent to Prix.
123
+ if (backend && backend.userId !== 'byo') {
124
+ fetch('https://api.prix.dev/api/v1/traces/link', {
125
+ method: 'POST',
126
+ headers: { Authorization: `Bearer ${backend.token}` },
127
+ signal: AbortSignal.timeout(8_000),
128
+ }).catch(() => { });
129
+ }
118
130
  }
119
131
  return { uploaded, skipped, errors };
120
132
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.22.49",
3
+ "version": "1.22.50",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",