@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,107 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.50
4
+
5
+ ### Fixed
6
+
7
+ - **Teams now persist observed teammate failure evidence and keep independent DAG branches moving.** Placement, local/remote launch, cloud dispatch, dependency, and process-exit failures carry a stable code, sanitized message, exit code, retryability, and observation time in `teams status` text/JSON. A runnable node with a durable placement failure fails independently; a node blocked only by pool capacity or load stays pending with retryable evidence so a later wave can launch it. Descendants name failed or missing `--after` blockers instead of spinning until `--max-waves`. `teams start` (no `--watch`) reports teammates that failed during the wave in a `Failed this wave` section (JSON: `failed[]` with evidence) and exits non-zero when a wave produced only failures. Because a failed launch now keeps its record (evidence) instead of deleting it, re-running the identical `teams add --name <name>` after a failure requires `teams remove <team> <name>` first.
8
+ Successfully resuming a failed teammate clears the prior attempt's evidence as
9
+ the replacement enters `running`; a replacement that fails to launch restores
10
+ the original terminal state and evidence intact.
11
+
12
+ - **Register the Phoenix session with Prix after sync (PHNX-3257).** A successful
13
+ (non-dry-run) `agents traces sync` now fire-and-forget POSTs the Phoenix bearer
14
+ to `api.prix.dev/api/v1/traces/link`, so the Prix web console can mint a token
15
+ and serve live trajectories instead of fixtures. Scoped to the managed Phoenix
16
+ backend only — a BYO/self-hosted `AGENTS_TRACES_WRITE_TOKEN` is never sent to
17
+ Prix. Source: `cli/src/lib/traces/sync.ts`.
18
+
19
+ - **Ship `traces-daily-sync` routine (PHNX-3258).** Daily `agents traces sync` at 02:00 UTC
20
+ to accumulate `bucketHistory` for drift signals; requires a device pin
21
+ (`agents routines devices traces-daily-sync --set <name>`) before it fires.
22
+ Source: `cli/routines/traces-daily-sync.yml`.
23
+
24
+ - **`--strategy balanced` now spreads across your provider accounts, not just
25
+ native logins (RUSH-3182).** A setup-token or API-key account added with
26
+ `agents accounts add` is now a first-class balancing candidate for every harness
27
+ its provider can authenticate — claude, codex, grok, cursor, opencode (a harness
28
+ with only a native login and no provider adapter, like kimi, keeps balancing its
29
+ native logins). Before, balanced only rotated across accounts that sat in a version
30
+ home, so a worker's shared setup-tokens never participated and `--account`
31
+ couldn't select them. The run path folds those provider accounts into the
32
+ candidate list and injects the picked one through the existing `--account` path
33
+ (a setup-token still authenticates via `CLAUDE_CODE_OAUTH_TOKEN`); the other
34
+ candidate consumers — the watchdog, session recovery, teams placement — keep the
35
+ native-only list, so nothing else changes. On a box dominated by verified native
36
+ logins a usage-less provider account is deprioritized until the daemon fetches
37
+ its usage; on a worker (setup-token majority) it participates immediately.
38
+ Source: `cli/src/lib/accounting/account-pool.ts`,
39
+ `cli/src/lib/accounting/account-pool-collect.ts`,
40
+ `cli/src/lib/accounting/rotate.ts`, `cli/src/commands/exec.ts`.
41
+
42
+ - **The attestation producer skips the helper manifest by default too (RUSH-3216).**
43
+ `release.sh` gained `--with-helpers` so an ordinary release does no helper work, but
44
+ `release-attestation-produce.sh` kept the same unconditional
45
+ `release-manifest.sh` verification — so the coupling survived one step upstream. Found
46
+ live: a one-line **comment** fix in `native/computer-mac/scripts/build.sh` (an
47
+ `apps/cli/` → `cli/` path in prose) changed that helper's input digest and aborted an
48
+ otherwise-clean 1.22.49 attestation, for a helper the tarball no longer ships and the
49
+ CLI resolves from its own tag. The producer now takes the same `--with-helpers` flag,
50
+ default off. Source: `cli/scripts/release-attestation-produce.sh`.
51
+
52
+ - **`scripts/publish-computer-win.sh` — a publish path for the Windows helper (RUSH-3228).**
53
+ Its release now triggers on `computer-win/v<x.y.z>` rather than the CLI's `v*` tag, but
54
+ nothing in the repo cut such a tag, so the trigger would have been dead — trading a
55
+ wasteful 165 MB rebuild on every CLI release for no rebuild at all. The tag *is* the
56
+ publish action (`release-exe` builds, smokes on a real windows-latest runner, and
57
+ uploads the exe + sha256), so a mis-shaped tag is a silent no-op: the script refuses a
58
+ `v`-prefixed or non-semver version, refuses an existing tag because the upload uses
59
+ `--clobber` and an installed CLI may already pin it, and is dry-run by default.
60
+ Symmetric with `publish-computer-helper-mac.sh`. Source: `cli/scripts/publish-computer-win.sh`.
61
+
62
+ - **`scripts/test.sh --shard <n>` — fan the suite across n fleet workers (RUSH-3230).**
63
+ Uses vitest's own `--shard=i/n`, drawing workers from the same auto pool a single
64
+ `--device auto` run uses, so `role=worker`/`role=personal` marks govern the fan-out too.
65
+ This is the change that moves release time, and the reason is arithmetic: a measured full
66
+ run is 3,079s of CPU at 11.5× parallelism on one box, so wall equals CPU/workers (269s) —
67
+ the suite is **throughput-bound**, not bound by any single slow file. Adding boxes divides
68
+ the CPU: 3 ≈ 93s, 6 ≈ 47s, 9 ≈ 31s. Shards run concurrently and every one is waited on
69
+ before reporting, so a failure in one does not hide the others. Requires `agents` ≥ 1.22.49
70
+ for `devices pick --json`; an older CLI fails naming the version and the fix rather than
71
+ passing through a commander error. Source: `cli/scripts/test.sh`.
72
+ - **`scripts/test.sh --devices a,b,c` — name the shard workers explicitly (RUSH-3230).**
73
+ Pins the fan-out to known-idle boxes instead of auto-picking, and skips the
74
+ `devices pick --json` dependency, so sharding also works from a machine whose installed
75
+ CLI predates 1.22.49. Source: `cli/scripts/test.sh`.
76
+ - **`--shard` fails loud on a count below 2 and on a conflicting target flag (RUSH-3230).**
77
+ `--shard 0` previously passed the numeric check, ran zero shards, and still printed
78
+ `All 0 shards passed.` with exit 0 — a false green that reported success having run no
79
+ tests. It now requires at least 2 (a single worker is `--device auto`). Separately, `MODE`
80
+ was last-write-wins with no cross-flag validation, so `--shard 6 --device box` silently
81
+ dropped one of the two purely on argument order; conflicting target flags now die naming
82
+ both. Source: `cli/scripts/test.sh`.
83
+ - **`--devices` obeys the same 2-worker floor, and is validated before prerequisites (RUSH-3230).**
84
+ `--devices onebox` derived the shard count from the list length and skipped the floor
85
+ entirely, running a one-shard fan-out. The list is now resolved and checked immediately
86
+ after argument parsing rather than inside the dispatch branch, so a bad invocation reports
87
+ its own problem instead of dying on a missing `rsync` first. Source: `cli/scripts/test.sh`.
88
+
89
+ - **Split `ssh.device-config.test.ts`, the second-slowest file (RUSH-3230).** 18 subprocess
90
+ tests in one file at ~44s locally (151s on a loaded worker) — 8.4s *per test*. Now three
91
+ files along its existing `describe` boundaries (per-device config / fleet-wide defaults +
92
+ role + describe / retired-subcommand tombstones) over a shared
93
+ `device-config-test-harness.ts`: **45s → 19s wall, all 18 tests still passing**, none
94
+ changed, skipped, or dropped. Source: `cli/src/commands/device-config-test-harness.ts`.
95
+
96
+ - **Split `daemon.test.ts`, the suite's floor (RUSH-3230).** It was 35 subprocess tests in
97
+ one file at ~53s locally (159s on a loaded worker) — the slowest file in the repo, and
98
+ therefore the whole suite's floor, because vitest parallelises across *files* and runs
99
+ one file's tests sequentially in a single worker. Now three files by theme (command
100
+ surface / services + webhooks / doctor + logs) over a shared
101
+ `daemon-test-harness.ts`, so they run concurrently: **53.5s → 26s wall, all 35 tests
102
+ still passing**. No test was changed, skipped, or dropped. Source:
103
+ `cli/src/commands/daemon-test-harness.ts`.
104
+
3
105
  ## 1.22.49
4
106
 
5
107
  - **Check CLI benchmark numbers into `docs/benchmarks.md` (RUSH-2385).** Commander-bootstrap and audit-hook means from the yosemite-s1 vitest bench (PR #2349) plus pointers to OPT-01/OPT-02 live in git. Linear is not the ledger. Source: `cli/docs/benchmarks.md`.
package/README.md CHANGED
@@ -741,6 +741,16 @@ Teammates run detached -- close your terminal, they keep working. Check in with
741
741
 
742
742
  Team state is observable via `agents teams list --json` / `agents teams status --json` (compact by default; add `--verbose` for the full per-teammate shape). External tools join it with `sessions --json` (teammates get `isTeamOrigin: true`) and `cloud list --json` (for `--cloud` teammates) to build a unified fleet view. See [docs/observability.md](cli/docs/observability.md).
743
743
 
744
+ Placement, spawn, dependency, and non-zero-exit failures are persisted as sanitized
745
+ evidence on the teammate record and shown by `teams status`. A failed root does not stop
746
+ independent DAG branches; descendants name the failed or missing `--after` dependency
747
+ that blocked them instead of remaining pending indefinitely. Capacity- or load-blocked
748
+ placement stays pending with retryable evidence and is reconsidered on the next wave.
749
+ `teams start` reports
750
+ teammates that failed during the wave (`Failed this wave` / JSON `failed[]`) and exits
751
+ non-zero when a wave produced only failures. A failed teammate keeps its record as
752
+ evidence, so re-adding the same name needs `teams remove <team> <name>` first.
753
+
744
754
  ---
745
755
 
746
756
  ## Cloud
@@ -18,7 +18,7 @@ import { resolveRemoteOsSync } from '../lib/hosts/remote-os.js';
18
18
  import { runDevicesAccounts } from './ssh.js';
19
19
  import { discoverNativeAccounts } from '../lib/account-catalog.js';
20
20
  import { readAndResolveBundleEnv } from '../lib/secrets/bundles.js';
21
- import { getAccountProvider, listAccountProviders } from '../lib/account-provider-registry.js';
21
+ import { getAccountProvider, listAccountProviders, providerAuthenticatesHarness } from '../lib/account-provider-registry.js';
22
22
  import { accountBindings, addAccount, addNativeAccount, bindAccount, findAccount, findUnifiedAccount, inspectAccount, labelNativeAccount, listNativeAccounts, readAccountRegistry, removeAccount, renameAccount, setAccountSecret, unbindAccount } from '../lib/account-registry.js';
23
23
  function cleanCommandError(command, err) {
24
24
  command.error(err instanceof Error ? err.message : String(err), { exitCode: 1, code: 'accounts.error' });
@@ -288,18 +288,6 @@ async function pickLabelIdentity(agent, identities) {
288
288
  throw err;
289
289
  }
290
290
  }
291
- function providerAuthenticatesHarness(provider, auth, agent) {
292
- try {
293
- getAccountProvider(provider).envFor(agent, auth);
294
- return true;
295
- }
296
- catch (err) {
297
- const message = err instanceof Error ? err.message : String(err);
298
- if (message.includes('cannot authenticate'))
299
- return false;
300
- throw err;
301
- }
302
- }
303
291
  /** Every account (native + provider) registered/usable for this harness, oldest-first by name. */
304
292
  function accountsForHarness(agent) {
305
293
  const meta = readMeta();
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Shared harness for the `agents daemon` CLI subprocess suites (RUSH-2354).
3
+ *
4
+ * Every test spawns the real CLI against an isolated mkdtemp HOME with no
5
+ * daemon running — no mocks. Modeled on routines.test.ts.
6
+ *
7
+ * EXTRACTED so the suite can live in several files. `daemon.test.ts` was 35
8
+ * tests in ONE file at 159s — the slowest file in the repo and therefore the
9
+ * SUITE'S FLOOR, because vitest parallelises across files and runs the tests
10
+ * inside one file sequentially in a single worker. Splitting the tests across
11
+ * files lets them run concurrently; sharing the harness is what makes that
12
+ * possible without duplicating the spawn plumbing.
13
+ */
14
+ import { spawnSync, type ChildProcess } from 'child_process';
15
+ export declare const REPO_ROOT: string;
16
+ export declare const TSX_IMPORT: string;
17
+ export declare const CLI_ENTRYPOINT: string;
18
+ /**
19
+ * win32: subprocess CLI + process-group signals / path spawn assumptions
20
+ * (RUSH-2215). Exported as a PREDICATE, not as a pre-bound `describe.skip` —
21
+ * vitest's suite type is not nameable across a module boundary (TS4023), so
22
+ * each suite builds its own `describe` from this.
23
+ */
24
+ export declare const DAEMON_TESTS_SUPPORTED: boolean;
25
+ /** Provision an isolated HOME with just enough scaffolding for the CLI to boot. */
26
+ export declare function makeHome(): string;
27
+ /** Run `agents daemon <args>` against an isolated HOME — no daemon process ever started. */
28
+ export declare function run(home: string, args: string[]): ReturnType<typeof spawnSync>;
29
+ /**
30
+ * Spawn a real, long-lived process whose command line ends in `__daemon-run`
31
+ * (so `isDaemonRunProcess`'s `ps` check accepts it — see
32
+ * `lib/daemon.test.ts`'s "reaps a live __daemon-run registrant" test, same
33
+ * technique) and register it in `home`'s OWN instance registry, exactly the
34
+ * marker `registerDaemonInstance` would write. A real live process, not a
35
+ * mock — `agents daemon status` reads it through the actual registry +
36
+ * `ps`-liveness path, the same one the reaper and `stopDaemon`'s postcondition
37
+ * use.
38
+ */
39
+ export declare function spawnFakeRegisteredDaemon(home: string): Promise<ChildProcess>;
40
+ /** Register a pid in `home`'s instance registry — the scope stale/duplicate reporting uses. */
41
+ export declare function registerInstance(home: string, pid: number): void;
42
+ export declare function killFakeDaemon(child: ChildProcess): void;
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Shared harness for the `agents daemon` CLI subprocess suites (RUSH-2354).
3
+ *
4
+ * Every test spawns the real CLI against an isolated mkdtemp HOME with no
5
+ * daemon running — no mocks. Modeled on routines.test.ts.
6
+ *
7
+ * EXTRACTED so the suite can live in several files. `daemon.test.ts` was 35
8
+ * tests in ONE file at 159s — the slowest file in the repo and therefore the
9
+ * SUITE'S FLOOR, because vitest parallelises across files and runs the tests
10
+ * inside one file sequentially in a single worker. Splitting the tests across
11
+ * files lets them run concurrently; sharing the harness is what makes that
12
+ * possible without duplicating the spawn plumbing.
13
+ */
14
+ import { spawnSync, spawn } from 'child_process';
15
+ import * as fs from 'fs';
16
+ import * as os from 'os';
17
+ import * as path from 'path';
18
+ import { fileURLToPath, pathToFileURL } from 'url';
19
+ import { createRequire } from 'module';
20
+ export const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
21
+ const require = createRequire(import.meta.url);
22
+ export const TSX_IMPORT = pathToFileURL(require.resolve('tsx')).href;
23
+ export const CLI_ENTRYPOINT = path.join(REPO_ROOT, 'src', 'index.ts');
24
+ /**
25
+ * win32: subprocess CLI + process-group signals / path spawn assumptions
26
+ * (RUSH-2215). Exported as a PREDICATE, not as a pre-bound `describe.skip` —
27
+ * vitest's suite type is not nameable across a module boundary (TS4023), so
28
+ * each suite builds its own `describe` from this.
29
+ */
30
+ export const DAEMON_TESTS_SUPPORTED = process.platform !== 'win32';
31
+ /** Provision an isolated HOME with just enough scaffolding for the CLI to boot. */
32
+ export function makeHome() {
33
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'agents-daemon-test-'));
34
+ fs.mkdirSync(path.join(home, '.agents', '.system', '.git'), { recursive: true });
35
+ fs.writeFileSync(path.join(home, '.agents', 'agents.yaml'), 'agents: {}\n');
36
+ return home;
37
+ }
38
+ /** Run `agents daemon <args>` against an isolated HOME — no daemon process ever started. */
39
+ export function run(home, args) {
40
+ return spawnSync('node', ['--import', TSX_IMPORT, CLI_ENTRYPOINT, 'daemon', ...args], {
41
+ cwd: REPO_ROOT,
42
+ env: {
43
+ ...process.env,
44
+ HOME: home,
45
+ USERPROFILE: home,
46
+ AGENTS_SKIP_MIGRATION: '1',
47
+ AGENTS_NO_AUTOPULL: '1',
48
+ AGENTS_CLI_DISABLE_AUTO_UPDATE: '1',
49
+ AGENTS_DAEMON_DIR: path.join(home, '.agents', '.cache', 'helpers', 'daemon'),
50
+ },
51
+ encoding: 'utf-8',
52
+ timeout: 30_000,
53
+ });
54
+ }
55
+ /**
56
+ * Spawn a real, long-lived process whose command line ends in `__daemon-run`
57
+ * (so `isDaemonRunProcess`'s `ps` check accepts it — see
58
+ * `lib/daemon.test.ts`'s "reaps a live __daemon-run registrant" test, same
59
+ * technique) and register it in `home`'s OWN instance registry, exactly the
60
+ * marker `registerDaemonInstance` would write. A real live process, not a
61
+ * mock — `agents daemon status` reads it through the actual registry +
62
+ * `ps`-liveness path, the same one the reaper and `stopDaemon`'s postcondition
63
+ * use.
64
+ */
65
+ export async function spawnFakeRegisteredDaemon(home) {
66
+ const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1e9)', '__daemon-run'], {
67
+ stdio: 'ignore',
68
+ });
69
+ // Give the exec a moment to land before `ps` (read by the status command's
70
+ // isDaemonRunProcess check) is asked to see its real argv — mirrors
71
+ // lib/daemon.test.ts's identical fake-daemon technique.
72
+ await new Promise((r) => setTimeout(r, 150));
73
+ const instancesDir = path.join(home, '.agents', '.cache', 'helpers', 'daemon', 'instances');
74
+ fs.mkdirSync(instancesDir, { recursive: true });
75
+ fs.writeFileSync(path.join(instancesDir, String(child.pid)), '__daemon-run', 'utf-8');
76
+ return child;
77
+ }
78
+ /** Register a pid in `home`'s instance registry — the scope stale/duplicate reporting uses. */
79
+ export function registerInstance(home, pid) {
80
+ const dir = path.join(home, '.agents', '.cache', 'helpers', 'daemon', 'instances');
81
+ fs.mkdirSync(dir, { recursive: true });
82
+ fs.writeFileSync(path.join(dir, String(pid)), '__daemon-run', 'utf-8');
83
+ }
84
+ export function killFakeDaemon(child) {
85
+ try {
86
+ if (child.pid)
87
+ process.kill(child.pid, 'SIGKILL');
88
+ }
89
+ catch { /* already gone */ }
90
+ }
@@ -0,0 +1,11 @@
1
+ export declare const REPO_ROOT: string;
2
+ export declare const INDEX: string;
3
+ export declare function guardedHome(): void;
4
+ export declare function run(args: string[], extraEnv?: Record<string, string>): {
5
+ stdout: string;
6
+ stderr: string;
7
+ status: number | null;
8
+ };
9
+ export declare function centralDoc(): string;
10
+ export declare function deviceDoc(name: string): string;
11
+ export declare function addDevice(name: string, target?: string): void;
@@ -0,0 +1,59 @@
1
+ import { afterEach, expect } from 'vitest';
2
+ import { spawnSync } from 'child_process';
3
+ import * as fs from 'fs';
4
+ import * as os from 'os';
5
+ import * as path from 'path';
6
+ import { fileURLToPath } from 'url';
7
+ // End-to-end tests for the unified `agents devices config` surface and the
8
+ // retired-subcommand tombstones (configure / note / set / set-interactive /
9
+ // enable / disable / prefer / unprefer). Spawns the REAL CLI against a
10
+ // throwaway HOME (same pattern as ssh.test.ts) — no mocking; the assertions
11
+ // read the actual files the commands wrote: per-device docs under
12
+ // devices/<name>/agents.yaml (device layer) and central agents.yaml
13
+ // fleet.defaults.config (fleet layer).
14
+ export const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
15
+ export const INDEX = path.join(REPO_ROOT, 'src', 'index.ts');
16
+ let testHome = '';
17
+ afterEach(() => {
18
+ if (testHome)
19
+ fs.rmSync(testHome, { recursive: true, force: true });
20
+ testHome = '';
21
+ });
22
+ export function guardedHome() {
23
+ testHome = fs.mkdtempSync(path.join(os.tmpdir(), 'agents-devices-config-home-'));
24
+ const systemDir = path.join(testHome, '.agents', '.system');
25
+ fs.mkdirSync(path.join(systemDir, '.git'), { recursive: true });
26
+ fs.writeFileSync(path.join(systemDir, '.update-check'), JSON.stringify({ lastCheck: 4102444800000, latestVersion: '0.0.0' }));
27
+ }
28
+ export function run(args, extraEnv = {}) {
29
+ const r = spawnSync('bun', [INDEX, ...args], {
30
+ encoding: 'utf-8',
31
+ env: {
32
+ ...process.env,
33
+ HOME: testHome,
34
+ // os.homedir() reads USERPROFILE on Windows, so HOME alone leaves the
35
+ // spawned CLI resolving the real profile ('agents-cli is not set up').
36
+ USERPROFILE: testHome,
37
+ AGENTS_NO_UPDATE_CHECK: '1',
38
+ AGENTS_NO_USAGE_TRACK: '1',
39
+ // Default identity is mac-mini so machine-local keys (scheduler, tmux,
40
+ // browser consent) can be set in these tests. Override per-call to act
41
+ // as a different box.
42
+ AGENTS_SYNC_MACHINE_ID: 'mac-mini',
43
+ ...extraEnv,
44
+ },
45
+ });
46
+ return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', status: r.status };
47
+ }
48
+ export function centralDoc() {
49
+ const p = path.join(testHome, '.agents', 'agents.yaml');
50
+ return fs.existsSync(p) ? fs.readFileSync(p, 'utf-8') : '';
51
+ }
52
+ export function deviceDoc(name) {
53
+ const p = path.join(testHome, '.agents', 'devices', name, 'agents.yaml');
54
+ return fs.existsSync(p) ? fs.readFileSync(p, 'utf-8') : '';
55
+ }
56
+ export function addDevice(name, target = 'muqsit@192.0.2.1') {
57
+ const r = run(['devices', 'add', name, target]);
58
+ expect(r.status, r.stderr).toBe(0);
59
+ }
@@ -2377,7 +2377,13 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2377
2377
  }
2378
2378
  else {
2379
2379
  try {
2380
- const resolved = await resolveRunVersion(agent, strategy, cwd);
2380
+ // Account-centric candidate list: native version-home logins PLUS
2381
+ // provider accounts (setup-token / API-key) that can auth this agent,
2382
+ // so `--strategy balanced` spreads across ALL accounts, not just the
2383
+ // ones sitting in a version home (RUSH-3182). Run-path only — the
2384
+ // picker's other callers keep the native-only collector.
2385
+ const { collectRunCandidatesForRun } = await import('../lib/accounting/account-pool-collect.js');
2386
+ const resolved = await resolveRunVersion(agent, strategy, cwd, collectRunCandidatesForRun);
2381
2387
  if (resolved.exhausted) {
2382
2388
  // Zero healthy accounts splits two ways, and conflating them is what
2383
2389
  // stranded a logged-out harness with no way in at all (RUSH-2334):
@@ -2428,6 +2434,23 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2428
2434
  else if (resolved.version) {
2429
2435
  version = resolved.version;
2430
2436
  rotationResult = resolved.rotation;
2437
+ // A balanced/available pick of a PROVIDER account (setup-token /
2438
+ // API-key) carries `providerAccount`. Resolve its env through the
2439
+ // same `resolveSpawnAccount` path an explicit `--account` uses, so
2440
+ // exec injects the credential; a native pick has no providerAccount
2441
+ // and runs from its own version home unchanged (RUSH-3182).
2442
+ const pickedProviderAccount = resolved.rotation?.picked.providerAccount;
2443
+ if (pickedProviderAccount) {
2444
+ try {
2445
+ const picked = resolveSpawnAccount(pickedProviderAccount, agent, resolved.version, readMeta(), { useDefault: false });
2446
+ if (picked?.kind === 'provider')
2447
+ accountEnv = picked.env;
2448
+ }
2449
+ catch (err) {
2450
+ console.error(chalk.red(err.message));
2451
+ process.exit(1);
2452
+ }
2453
+ }
2431
2454
  if (resolved.rotation && !options.quiet) {
2432
2455
  const banner = formatRotationBanner(resolved.rotation, strategy);
2433
2456
  process.stderr.write(chalk.gray(banner + '\n'));
@@ -36,6 +36,9 @@ export function formatSharePublishResult(result, json = false) {
36
36
  if (visibility === 'unlisted') {
37
37
  lines.push(chalk.dim(' visibility: unlisted (noindex, hidden from gallery)'));
38
38
  }
39
+ else if (visibility === 'me' || visibility === 'org') {
40
+ lines.push(chalk.dim(` visibility: ${visibility} (login required, hidden from gallery)`));
41
+ }
39
42
  else {
40
43
  lines.push(chalk.dim(` visibility: ${visibility}`));
41
44
  }
@@ -436,8 +439,8 @@ export function registerShareCommands(artifactsCmd) {
436
439
  .option('--slug <slug>', 'custom URL slug under your namespace (default: <feature>-<16hex>)')
437
440
  .option('--github-user <user>', 'GitHub username for the share namespace (default: resolved from gh/git config; ignored on the managed endpoint)')
438
441
  .option('--expire <spec>', "auto-expire (default 30d). e.g. 12h, 30d, 2026-08-01, or 'never'")
439
- .addOption(new Option('--visibility <level>', 'public | unlisted (default public). unlisted is a capability URL: GET still works, hidden from the gallery, X-Robots-Tag: noindex')
440
- .choices(['public', 'unlisted'])
442
+ .addOption(new Option('--visibility <level>', 'public | unlisted | me | org (default public). unlisted is a capability URL; me/org require a Phoenix session and are hidden from the gallery')
443
+ .choices(['public', 'unlisted', 'me', 'org'])
441
444
  .default('public'))
442
445
  .addOption(new Option('--unlisted', 'hidden alias of --visibility unlisted').hideHelp())
443
446
  .addOption(new Option('--private', 'hidden alias of --visibility unlisted').hideHelp())
@@ -525,8 +528,10 @@ ${SHARE_DELETE_EXAMPLES}
525
528
  a permanent link. --visibility unlisted (hidden aliases: --unlisted / --private)
526
529
  hides the page from the public gallery and agents artifacts share list; the
527
530
  direct URL is still world-readable (unlisted, not secret) and GET sends
528
- X-Robots-Tag: noindex. A pre-publish scan refuses emails and credential-shaped
529
- strings unless --force is passed.
531
+ X-Robots-Tag: noindex. --visibility me requires a Phoenix session and is only
532
+ visible to the signed-in owner; --visibility org requires the same and is
533
+ visible to members of the same Phoenix organization. A pre-publish scan
534
+ refuses emails and credential-shaped strings unless --force is passed.
530
535
 
531
536
  Every publish carries provenance auto-captured from the exec env/git/clock
532
537
  (agent, session, host, repo, date) — never invented, only sent when present —
@@ -7,9 +7,11 @@
7
7
  */
8
8
  import type { Command } from 'commander';
9
9
  import { AgentManager, AgentStatus, type TaskType } from '../lib/teams/agents.js';
10
+ import type { DispatchOptions } from '../lib/cloud/types.js';
10
11
  import { type TaskInfo } from '../lib/teams/api.js';
11
12
  import { type TeamRow } from './teams-picker.js';
12
13
  import { type TeamSpawner } from '../lib/session/db.js';
14
+ import type { AgentProcess } from '../lib/teams/agents.js';
13
15
  type TeamRegistry = Record<string, {
14
16
  created_at: string;
15
17
  description?: string;
@@ -106,6 +108,16 @@ export declare function staleRepoError(params: {
106
108
  * the teammate itself so we don't need the original --cloud CLI args.
107
109
  */
108
110
  export declare function wireCloudDispatcher(mgr: AgentManager): void;
111
+ export declare function cloudDispatchOptions(agent: Pick<AgentProcess, 'prompt' | 'agentType' | 'cloudRepo' | 'cloudBranch' | 'model'>): DispatchOptions;
112
+ /**
113
+ * Single-wave start used by `teams start` without --watch. startReady()
114
+ * terminalizes a placement/spawn/cloud/dependency failure to FAILED with
115
+ * evidence rather than leaving it PENDING, so a teammate that failed this wave
116
+ * appears in neither `launched` nor `still_pending` — it must be diffed against
117
+ * the pre-wave roster and reported explicitly, or the wave reads as a success.
118
+ * A wave that only produced failures sets a non-zero exit code.
119
+ */
120
+ export declare function runOneWave(mgr: AgentManager, team: string, json: boolean): Promise<void>;
109
121
  export declare function buildTeamRowsFromSnapshots(registry: TeamRegistry, agents: TeamListAgentSnapshot[],
110
122
  /** team name -> the session that spawned it (see teamSpawners). */
111
123
  spawners?: Map<string, TeamSpawner>): {
@@ -364,17 +364,21 @@ export function wireCloudDispatcher(mgr) {
364
364
  throw new Error(`Teammate ${a.agentId} has no cloud provider set`);
365
365
  }
366
366
  const prov = resolveProvider(a.cloudProvider);
367
- const dispatchOpts = {
368
- prompt: a.prompt,
369
- agent: a.agentType,
370
- repo: a.cloudRepo ?? undefined,
371
- branch: a.cloudBranch ?? undefined,
372
- model: a.model ?? undefined,
373
- };
367
+ const dispatchOpts = cloudDispatchOptions(a);
374
368
  const cloudTask = await prov.dispatch(dispatchOpts);
375
369
  return { cloudSessionId: cloudTask.id };
376
370
  });
377
371
  }
372
+ export function cloudDispatchOptions(agent) {
373
+ return {
374
+ prompt: agent.prompt,
375
+ agent: agent.agentType,
376
+ repo: agent.cloudRepo ?? undefined,
377
+ branch: agent.cloudBranch ?? undefined,
378
+ model: agent.model ?? undefined,
379
+ env: shareRuntimeEnv(),
380
+ };
381
+ }
378
382
  /**
379
383
  * Advisory: warn once per agent type of any still-pending (staged `--after`)
380
384
  * teammate whose CLI may not be signed in. Warn-only — never blocks `start`.
@@ -446,20 +450,32 @@ async function warnUnsignedTeammates(mgr, team) {
446
450
  }
447
451
  }
448
452
  }
449
- /** Single-wave start used by `teams start` without --watch. */
450
- async function runOneWave(mgr, team, json) {
453
+ /**
454
+ * Single-wave start used by `teams start` without --watch. startReady()
455
+ * terminalizes a placement/spawn/cloud/dependency failure to FAILED with
456
+ * evidence rather than leaving it PENDING, so a teammate that failed this wave
457
+ * appears in neither `launched` nor `still_pending` — it must be diffed against
458
+ * the pre-wave roster and reported explicitly, or the wave reads as a success.
459
+ * A wave that only produced failures sets a non-zero exit code.
460
+ */
461
+ export async function runOneWave(mgr, team, json) {
462
+ const preWavePending = new Set((await mgr.listByTask(team)).filter((a) => a.status === 'pending').map((a) => a.agentId));
451
463
  const launched = await mgr.startReady(team);
452
464
  const all = await mgr.listByTask(team);
453
465
  const stillPending = all.filter((a) => a.status === 'pending');
466
+ const failed = all.filter((a) => a.status === 'failed' && preWavePending.has(a.agentId));
467
+ if (failed.length > 0 && launched.length === 0)
468
+ process.exitCode = 1;
454
469
  if (json) {
455
470
  console.log(JSON.stringify({
456
471
  team,
457
472
  launched: launched.map((a) => ({ agent_id: a.agentId, name: a.name, after: a.after })),
458
473
  still_pending: stillPending.map((a) => ({ agent_id: a.agentId, name: a.name, after: a.after })),
474
+ failed: failed.map((a) => ({ agent_id: a.agentId, name: a.name, after: a.after, failure: a.failure })),
459
475
  }, null, 2));
460
476
  return;
461
477
  }
462
- if (launched.length === 0 && stillPending.length === 0) {
478
+ if (launched.length === 0 && stillPending.length === 0 && failed.length === 0) {
463
479
  console.log(chalk.gray(`No pending teammates in team ${team}.`));
464
480
  return;
465
481
  }
@@ -471,6 +487,16 @@ async function runOneWave(mgr, team, json) {
471
487
  console.log(` ${chalk.cyan(h)} ${who}`);
472
488
  }
473
489
  }
490
+ if (failed.length > 0) {
491
+ if (launched.length > 0)
492
+ console.log();
493
+ console.log(chalk.red(`Failed this wave (${failed.length}):`));
494
+ for (const a of failed) {
495
+ const h = a.name || shortId(a.agentId);
496
+ const why = a.failure ? `${a.failure.code}: ${a.failure.message}` : 'no failure evidence recorded';
497
+ console.log(` ${chalk.red(h)} ${why}`);
498
+ }
499
+ }
474
500
  if (stillPending.length > 0) {
475
501
  console.log();
476
502
  console.log(chalk.gray(`Still pending (${stillPending.length}):`));
@@ -710,6 +736,9 @@ function printAgentDetail(a, session) {
710
736
  if (a.host) {
711
737
  console.log(` ${chalk.gray('host ')} ${chalk.cyan(a.host)}`);
712
738
  }
739
+ if (a.failure) {
740
+ console.log(` ${chalk.red('failure ')} ${a.failure.code}: ${a.failure.message}`);
741
+ }
713
742
  // If the agent's internal session id differs from ours (non-Claude), show
714
743
  // it as a hint for `agents sessions <id>`.
715
744
  if (a.remote_session_id && a.remote_session_id !== a.agent_id) {
@@ -798,6 +827,9 @@ function printAgentSummary(s) {
798
827
  const tools = chalk.gray(` · ${s.tool_count} tools`);
799
828
  const hostBadge = s.host ? chalk.gray(' · on ') + chalk.cyan(s.host) : '';
800
829
  console.log(` ${chalk.cyan(handle.padEnd(14))} ${ident.padEnd(11)} ${label}${duration}${tools}${hostBadge}${errBadge}`);
830
+ if (s.failure) {
831
+ console.log(` ${chalk.red('failure ')} ${s.failure.code}: ${s.failure.message}`);
832
+ }
801
833
  // Files: counts + basenames. Read is count only.
802
834
  const fileLines = [];
803
835
  const renderCat = (label, cat) => {
@@ -1856,36 +1888,8 @@ export function registerTeamsCommands(program) {
1856
1888
  return { cloudSessionId: cloudTask.id };
1857
1889
  });
1858
1890
  }
1859
- let cloudSessionId = null;
1891
+ const cloudSessionId = null;
1860
1892
  const isStaged = after.length > 0;
1861
- if (cloudProviderId && !isStaged) {
1862
- // Ready to run now: dispatch to the cloud provider before registering
1863
- // the teammate so we have the remote session id up front.
1864
- //
1865
- // resolveProvider() and shareRuntimeEnv() are INSIDE the try on purpose:
1866
- // both read config (the cloud registry; `agents.yaml`'s share block) and
1867
- // both throw on a malformed one, and by this point the worktree already
1868
- // exists — leaving them outside stranded the branch with no catch at all.
1869
- try {
1870
- const prov = resolveProvider(cloudProviderId);
1871
- const dispatchOpts = {
1872
- prompt: effectiveTask,
1873
- agent,
1874
- repo: opts.repo,
1875
- branch: opts.branch,
1876
- model: opts.model,
1877
- env: shareRuntimeEnv(),
1878
- };
1879
- const cloudTask = await prov.dispatch(dispatchOpts);
1880
- cloudSessionId = cloudTask.id;
1881
- }
1882
- catch (err) {
1883
- // Same orphan class as a failed spawn: this add already created its
1884
- // worktree, so a dispatch failure must not strand the branch either.
1885
- await tearDownCreatedWorktree();
1886
- dieFriction('teams', 'cloud-dispatch-failed', `Cloud dispatch failed: ${err.message}`);
1887
- }
1888
- }
1889
1893
  try {
1890
1894
  const result = await handleSpawn(mgr, team, agent, effectiveTask, cwd, opts.mode, opts.effort, null, cwd, version, opts.name ?? null, after, opts.model ?? null, envOverrides ?? null, taskType, cloudProviderId, cloudSessionId, opts.repo ?? null, opts.branch ?? null, worktreeName, worktreePath, profileName, hostName, hostTarget, hostRepoPath, teamMeta?.project ?? null);
1891
1895
  emit('teams.add', { module: 'teams', team, agent, name: result.name, agent_id: result.agent_id, status: result.status });
@@ -2057,7 +2061,7 @@ export function registerTeamsCommands(program) {
2057
2061
  });
2058
2062
  // start — fire any staged teammates whose --after deps have all completed
2059
2063
  addHostOption(teams.command('start [team]'))
2060
- .description('Launch any pending teammates whose --after dependencies are satisfied. Use --watch to keep draining the DAG as teammates finish and as new tasks are added mid-flight.')
2064
+ .description('Launch ready teammates independently. Failed launch/placement and blocked --after dependencies persist in status; --watch keeps draining viable DAG branches.')
2061
2065
  .option('--json', 'Output machine-readable JSON')
2062
2066
  .option('--watch', 'Keep running: poll every --interval seconds, fire new waves, exit when the DAG drains.')
2063
2067
  .option('--interval <seconds>', 'Seconds between waves in --watch mode (default 8)', '8')
@@ -2094,33 +2098,6 @@ export function registerTeamsCommands(program) {
2094
2098
  await warnUnsignedTeammates(mgr, team);
2095
2099
  await warnThrottledTeammates(mgr, team);
2096
2100
  }
2097
- // Health-/harness-aware pre-flight (RUSH-2002): if the team declares a
2098
- // device pool and no device in it can run a pending teammate's agent, fail
2099
- // loud here rather than launching a wave that strands teammates pending.
2100
- // --force downgrades it to a warning so the user can still try.
2101
- const placementError = await mgr.preflightPlacement(team);
2102
- if (placementError) {
2103
- if (isJsonMode(opts)) {
2104
- console.log(JSON.stringify({
2105
- team,
2106
- error: 'no-viable-device',
2107
- message: placementError.message,
2108
- excluded: placementError.excluded,
2109
- }));
2110
- if (!opts.force) {
2111
- process.exitCode = 1;
2112
- return;
2113
- }
2114
- }
2115
- else if (opts.force) {
2116
- console.error(chalk.yellow(`⚠ ${placementError.message}\n Continuing anyway (--force).`));
2117
- }
2118
- else {
2119
- console.error(chalk.red(placementError.message));
2120
- process.exitCode = 1;
2121
- return;
2122
- }
2123
- }
2124
2101
  emit('teams.start', { module: 'teams', team, watch: Boolean(opts.watch) });
2125
2102
  if (!opts.watch) {
2126
2103
  await runOneWave(mgr, team, Boolean(opts.json));