@phnx-labs/agents-cli 1.21.3 → 1.22.1
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 +136 -0
- package/README.md +32 -3
- package/dist/bin/agents +0 -0
- package/dist/commands/computer-actions.js +1 -0
- package/dist/commands/doctor.js +34 -2
- package/dist/commands/exec.d.ts +27 -0
- package/dist/commands/exec.js +123 -6
- package/dist/commands/models.js +36 -1
- package/dist/commands/projects.js +120 -62
- package/dist/commands/sessions-backfill.d.ts +32 -0
- package/dist/commands/sessions-backfill.js +186 -0
- package/dist/commands/sessions.d.ts +17 -1
- package/dist/commands/sessions.js +317 -18
- package/dist/commands/teams.js +1 -1
- package/dist/commands/worktree.d.ts +3 -3
- package/dist/commands/worktree.js +35 -4
- package/dist/lib/app-bundle-install.d.ts +17 -0
- package/dist/lib/app-bundle-install.js +94 -0
- package/dist/lib/daemon.d.ts +5 -1
- package/dist/lib/daemon.js +63 -14
- package/dist/lib/devices/doctor-findings.js +12 -4
- package/dist/lib/devices/doctor-overview-cache.d.ts +45 -0
- package/dist/lib/devices/doctor-overview-cache.js +168 -0
- package/dist/lib/devices/fleet.js +7 -2
- package/dist/lib/devices/resolve-target.d.ts +6 -0
- package/dist/lib/devices/resolve-target.js +9 -3
- package/dist/lib/devices/self-host.d.ts +9 -0
- package/dist/lib/devices/self-host.js +61 -0
- package/dist/lib/exec.js +39 -8
- package/dist/lib/hosts/dispatch.d.ts +12 -0
- package/dist/lib/hosts/dispatch.js +23 -6
- package/dist/lib/hosts/passthrough.js +8 -6
- package/dist/lib/hosts/reconnect.d.ts +38 -0
- package/dist/lib/hosts/reconnect.js +85 -4
- package/dist/lib/hosts/run-target.js +14 -2
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/install-menubar.js +27 -23
- package/dist/lib/model-tiers.d.ts +54 -0
- package/dist/lib/model-tiers.js +229 -0
- package/dist/lib/models.d.ts +3 -0
- package/dist/lib/models.js +44 -7
- package/dist/lib/pricing/prices.json +16 -1
- package/dist/lib/project-focus.d.ts +42 -0
- package/dist/lib/project-focus.js +80 -0
- package/dist/lib/project-schedule.d.ts +75 -0
- package/dist/lib/project-schedule.js +110 -0
- package/dist/lib/project-status.d.ts +7 -0
- package/dist/lib/project-status.js +9 -0
- package/dist/lib/projects.d.ts +11 -2
- package/dist/lib/projects.js +57 -9
- package/dist/lib/redact.d.ts +2 -0
- package/dist/lib/redact.js +22 -0
- package/dist/lib/remote-agents-json.d.ts +2 -0
- package/dist/lib/remote-agents-json.js +3 -3
- package/dist/lib/rotate.d.ts +84 -1
- package/dist/lib/rotate.js +155 -5
- package/dist/lib/runner.d.ts +4 -2
- package/dist/lib/runner.js +13 -4
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/install-helper.js +28 -31
- package/dist/lib/session/bash-command.js +60 -9
- package/dist/lib/session/db.d.ts +7 -1
- package/dist/lib/session/db.js +301 -32
- package/dist/lib/session/discover.d.ts +40 -7
- package/dist/lib/session/discover.js +144 -83
- package/dist/lib/session/parse.d.ts +8 -1
- package/dist/lib/session/parse.js +83 -32
- package/dist/lib/session/remote-list.d.ts +71 -0
- package/dist/lib/session/remote-list.js +410 -2
- package/dist/lib/session/shell-programs.d.ts +15 -0
- package/dist/lib/session/shell-programs.js +359 -0
- package/dist/lib/session/tool-calls.d.ts +88 -0
- package/dist/lib/session/tool-calls.js +612 -0
- package/dist/lib/session/tool-index.d.ts +100 -0
- package/dist/lib/session/tool-index.js +773 -0
- package/dist/lib/session/tool-store.d.ts +15 -0
- package/dist/lib/session/tool-store.js +198 -0
- package/dist/lib/session/types.d.ts +7 -0
- package/dist/lib/state.d.ts +10 -1
- package/dist/lib/state.js +11 -2
- package/dist/lib/teams/remoteWorktree.d.ts +3 -4
- package/dist/lib/teams/remoteWorktree.js +3 -4
- package/dist/lib/teams/worktree.d.ts +11 -1
- package/dist/lib/teams/worktree.js +42 -4
- package/dist/lib/types.d.ts +17 -0
- package/dist/lib/types.js +17 -0
- package/dist/lib/versions.js +69 -22
- package/package.json +3 -1
|
@@ -54,6 +54,13 @@ export interface ProjectSessionRollup {
|
|
|
54
54
|
* with the full definition list to show zero-agent projects.
|
|
55
55
|
*/
|
|
56
56
|
export declare function rollupSessionsByProject(defs: ProjectDef[], sessions: ActiveSession[]): Map<string, ProjectSessionRollup>;
|
|
57
|
+
/**
|
|
58
|
+
* True when a session's status means it is over. Exported so the card can keep
|
|
59
|
+
* the `agents` roster to live sessions: the headline and the `dead` row already
|
|
60
|
+
* separate the two, and a roster that reads `crashed ×25` beside `23 live`
|
|
61
|
+
* makes the reader distrust both numbers.
|
|
62
|
+
*/
|
|
63
|
+
export declare function isDeadStatus(status: string): boolean;
|
|
57
64
|
/** Live vs finished sessions on a project. */
|
|
58
65
|
export interface LiveDeadSplit {
|
|
59
66
|
live: number;
|
|
@@ -93,6 +93,15 @@ export function rollupSessionsByProject(defs, sessions) {
|
|
|
93
93
|
* that are running unattended.
|
|
94
94
|
*/
|
|
95
95
|
const DEAD_STATUSES = new Set(['closed', 'crashed']);
|
|
96
|
+
/**
|
|
97
|
+
* True when a session's status means it is over. Exported so the card can keep
|
|
98
|
+
* the `agents` roster to live sessions: the headline and the `dead` row already
|
|
99
|
+
* separate the two, and a roster that reads `crashed ×25` beside `23 live`
|
|
100
|
+
* makes the reader distrust both numbers.
|
|
101
|
+
*/
|
|
102
|
+
export function isDeadStatus(status) {
|
|
103
|
+
return DEAD_STATUSES.has(status);
|
|
104
|
+
}
|
|
96
105
|
/**
|
|
97
106
|
* Split a rollup's sessions into what is working and what is wreckage.
|
|
98
107
|
*
|
package/dist/lib/projects.d.ts
CHANGED
|
@@ -5,8 +5,17 @@
|
|
|
5
5
|
* directory by pure convention (`<projectRoot>/<slug>`, see `project-root.ts`).
|
|
6
6
|
* This module adds editable definitions on top: one YAML file per project under
|
|
7
7
|
* `~/.agents/projects/<name>.yaml`, sitting beside the existing `routines/`,
|
|
8
|
-
* `monitors/`, and `teams/` dirs in the user repo
|
|
9
|
-
*
|
|
8
|
+
* `monitors/`, and `teams/` dirs in the user repo.
|
|
9
|
+
*
|
|
10
|
+
* That location makes definitions SYNCABLE, not automatically synced: they ride
|
|
11
|
+
* the user repo only once they are committed to it, via `agents repo push user`
|
|
12
|
+
* (`agents push` was removed). Until then the directory is untracked, and a
|
|
13
|
+
* reconcile that cleans the working tree deletes it — observed twice on one
|
|
14
|
+
* machine, taking four definitions with it each time. The recovery is an
|
|
15
|
+
* orphaned `chore(local): save …-sync drift` commit, which is not a guarantee:
|
|
16
|
+
* unreachable objects are collected. Say "commit them" rather than "for free".
|
|
17
|
+
*
|
|
18
|
+
* A defined project can name itself
|
|
10
19
|
* independently of its folder, bind more than one repo, pin a monorepo subpath,
|
|
11
20
|
* describe context subdirectories an agent should start from, carry a Linear
|
|
12
21
|
* link and external integrations, and set an explicit default path.
|
package/dist/lib/projects.js
CHANGED
|
@@ -5,8 +5,17 @@
|
|
|
5
5
|
* directory by pure convention (`<projectRoot>/<slug>`, see `project-root.ts`).
|
|
6
6
|
* This module adds editable definitions on top: one YAML file per project under
|
|
7
7
|
* `~/.agents/projects/<name>.yaml`, sitting beside the existing `routines/`,
|
|
8
|
-
* `monitors/`, and `teams/` dirs in the user repo
|
|
9
|
-
*
|
|
8
|
+
* `monitors/`, and `teams/` dirs in the user repo.
|
|
9
|
+
*
|
|
10
|
+
* That location makes definitions SYNCABLE, not automatically synced: they ride
|
|
11
|
+
* the user repo only once they are committed to it, via `agents repo push user`
|
|
12
|
+
* (`agents push` was removed). Until then the directory is untracked, and a
|
|
13
|
+
* reconcile that cleans the working tree deletes it — observed twice on one
|
|
14
|
+
* machine, taking four definitions with it each time. The recovery is an
|
|
15
|
+
* orphaned `chore(local): save …-sync drift` commit, which is not a guarantee:
|
|
16
|
+
* unreachable objects are collected. Say "commit them" rather than "for free".
|
|
17
|
+
*
|
|
18
|
+
* A defined project can name itself
|
|
10
19
|
* independently of its folder, bind more than one repo, pin a monorepo subpath,
|
|
11
20
|
* describe context subdirectories an agent should start from, carry a Linear
|
|
12
21
|
* link and external integrations, and set an explicit default path.
|
|
@@ -235,11 +244,40 @@ export function projectBasePath(def, forRemote) {
|
|
|
235
244
|
}
|
|
236
245
|
function projectRootsAbs(defs) {
|
|
237
246
|
const out = [];
|
|
238
|
-
|
|
239
|
-
const raw = def.root ?? def.defaultPath;
|
|
247
|
+
const push = (name, raw) => {
|
|
240
248
|
if (!raw)
|
|
241
|
-
|
|
242
|
-
out.push({ name
|
|
249
|
+
return;
|
|
250
|
+
out.push({ name, abs: path.resolve(expandLocalHome(raw)) });
|
|
251
|
+
};
|
|
252
|
+
for (const def of defs) {
|
|
253
|
+
// `root` says where the CHECKOUT is; `defaultPath` says which work is this
|
|
254
|
+
// project's. For a monorepo subproject those differ, and only the narrower
|
|
255
|
+
// one is a membership claim — a project scoped to `rush/apps/cli` does not
|
|
256
|
+
// own `rush/apps/web`.
|
|
257
|
+
//
|
|
258
|
+
// The old `root ?? defaultPath` collapsed such a subproject onto the
|
|
259
|
+
// monorepo root, the same path its umbrella anchors at, so the longest-match
|
|
260
|
+
// tiebreak below had nothing to separate them and a session in
|
|
261
|
+
// `rush/apps/cli` counted toward whichever definition was listed first.
|
|
262
|
+
const rootAbs = def.root ? path.resolve(expandLocalHome(def.root)) : undefined;
|
|
263
|
+
const defaultAbs = def.defaultPath ? path.resolve(expandLocalHome(def.defaultPath)) : undefined;
|
|
264
|
+
const narrowed = !!(rootAbs && defaultAbs && defaultAbs !== rootAbs && isUnder(defaultAbs, rootAbs));
|
|
265
|
+
// A narrowed project's root is a WEAK claim: it still covers the rest of the
|
|
266
|
+
// checkout when nobody else wants it, but yields to any project that claims
|
|
267
|
+
// a path outright. Dropping it entirely regressed the single-project case —
|
|
268
|
+
// `add foo --root ~/src/foo --path apps/web` stopped attributing work
|
|
269
|
+
// anywhere else in its own repo, and `--path` means where agents START, not
|
|
270
|
+
// which work counts.
|
|
271
|
+
if (rootAbs)
|
|
272
|
+
out.push({ name: def.name, abs: rootAbs, weak: narrowed });
|
|
273
|
+
if (defaultAbs && defaultAbs !== rootAbs)
|
|
274
|
+
out.push({ name: def.name, abs: defaultAbs });
|
|
275
|
+
for (const r of def.repos ?? []) {
|
|
276
|
+
push(def.name, r.path);
|
|
277
|
+
// A repo pinned to a monorepo subpath anchors at that subpath too.
|
|
278
|
+
if (r.path && r.subpath)
|
|
279
|
+
push(def.name, path.join(expandLocalHome(r.path), r.subpath));
|
|
280
|
+
}
|
|
243
281
|
}
|
|
244
282
|
return out;
|
|
245
283
|
}
|
|
@@ -269,13 +307,23 @@ export function projectNameForCwd(cwd, defs) {
|
|
|
269
307
|
const abs = path.resolve(expandLocalHome(cwd));
|
|
270
308
|
let best;
|
|
271
309
|
let bestLen = -1;
|
|
272
|
-
|
|
273
|
-
|
|
310
|
+
let weakBest;
|
|
311
|
+
let weakLen = -1;
|
|
312
|
+
for (const { name, abs: root, weak } of projectRootsAbs(defs)) {
|
|
313
|
+
if (!isUnder(abs, root))
|
|
314
|
+
continue;
|
|
315
|
+
if (weak) {
|
|
316
|
+
if (root.length > weakLen) {
|
|
317
|
+
weakBest = name;
|
|
318
|
+
weakLen = root.length;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
else if (root.length > bestLen) {
|
|
274
322
|
best = name;
|
|
275
323
|
bestLen = root.length;
|
|
276
324
|
}
|
|
277
325
|
}
|
|
278
|
-
return best;
|
|
326
|
+
return best ?? weakBest;
|
|
279
327
|
}
|
|
280
328
|
/**
|
|
281
329
|
* The canonical project label for a cwd, for every surface that buckets work by
|
package/dist/lib/redact.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared redaction helpers for text that may be exported or logged.
|
|
3
3
|
*/
|
|
4
|
+
/** Remove terminal control sequences from untrusted text before storage or display. */
|
|
5
|
+
export declare function sanitizeForTerminal(text: string): string;
|
|
4
6
|
/**
|
|
5
7
|
* Scrub secrets from `text`. Two passes: format-based patterns (above), then a
|
|
6
8
|
* value-aware pass that masks any `knownValues` verbatim — a credential we
|
package/dist/lib/redact.js
CHANGED
|
@@ -20,9 +20,31 @@ const SECRET_PATTERNS = [
|
|
|
20
20
|
[/\bxapp-[A-Za-z0-9-]{10,}\b/g, '[REDACTED_SLACK_TOKEN]'],
|
|
21
21
|
[/\bnpm_[A-Za-z0-9]{36}\b/g, '[REDACTED_NPM_TOKEN]'],
|
|
22
22
|
[/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, '[REDACTED_JWT]'],
|
|
23
|
+
// Headers frequently appear inside shell arguments. Consume a quoted header
|
|
24
|
+
// as one unit so cookie attributes after a space do not survive redaction.
|
|
25
|
+
[/(^|\s)(["'])(Cookie|Set-Cookie|Authorization|Proxy-Authorization)\s*:\s*.*?\2/gi, '$1$2$3: [REDACTED]$2'],
|
|
26
|
+
[/(^|\s)(["'])(Cookie|Set-Cookie|Authorization|Proxy-Authorization)\s*:\s*(?:(?!\2).)*$/gi, '$1$2$3: [REDACTED]'],
|
|
23
27
|
[/Bearer\s+\S+/gi, 'Bearer [REDACTED]'],
|
|
28
|
+
[/\b((?:Cookie|Set-Cookie)\s*:\s*)\S+/gi, '$1[REDACTED]'],
|
|
29
|
+
[/\b(Authorization\s*:\s*)(?!Bearer\s+\[REDACTED\])\S+(?:\s+\S+)?/gi, '$1[REDACTED]'],
|
|
30
|
+
// Structured secret fields can arrive as raw JSON output rather than an
|
|
31
|
+
// argument object, so the object walker alone is not sufficient.
|
|
32
|
+
[/(^[,{\s]|["'])([A-Z0-9_-]*(?:TOKEN|KEY|SECRET|PASSWORD|AUTHORIZATION|COOKIE)[A-Z0-9_-]*["']?\s*:\s*)(["'][^"']*["']|[^,}\]\s]+)/gim, '$1$2"[REDACTED]"'],
|
|
33
|
+
[/(\s--?(?:password|token|secret|api[_-]?key)(?:=|\s+))(["']?)[^\s"']+\2/gi, '$1[REDACTED]'],
|
|
34
|
+
[/(\s--user(?:=|\s+))(["']?)[^\s"']+\2/gi, '$1[REDACTED]'],
|
|
35
|
+
[/(\s-u\s+)(["']?)[^\s"']+\2/g, '$1[REDACTED]'],
|
|
36
|
+
[/(\s--proxy-user(?:=|\s+))(["']?)[^\s"']+\2/gi, '$1[REDACTED]'],
|
|
37
|
+
[/(\s-U\s+)(["']?)[^\s"']+\2/g, '$1[REDACTED]'],
|
|
38
|
+
[/(https?:\/\/)[^/\s:@]+:[^@\s/]+@/gi, '$1[REDACTED]@'],
|
|
24
39
|
[/\b([A-Z0-9_]*(?:TOKEN|KEY|SECRET|PASSWORD)[A-Z0-9_]*)=("[^"]*"|'[^']*'|\S+)/gi, '$1=[REDACTED]'],
|
|
25
40
|
];
|
|
41
|
+
const TERMINAL_ESCAPE_REGEX = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x9d[^\x07\x9c]*(?:\x07|\x9c)|\x1b\[[0-?]*[ -/]*[@-~]|\x9b[0-?]*[ -/]*[@-~]|\x1b[@-_]|[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g;
|
|
42
|
+
/** Remove terminal control sequences from untrusted text before storage or display. */
|
|
43
|
+
export function sanitizeForTerminal(text) {
|
|
44
|
+
if (!text)
|
|
45
|
+
return text;
|
|
46
|
+
return text.replace(TERMINAL_ESCAPE_REGEX, '');
|
|
47
|
+
}
|
|
26
48
|
/** Env vars whose NAME marks their VALUE as a credential worth masking literally. */
|
|
27
49
|
const SECRET_ENV_NAME = /(?:TOKEN|KEY|SECRET|PASSWORD)/i;
|
|
28
50
|
/** Don't literal-mask trivially short values — they collide with ordinary text. */
|
|
@@ -10,6 +10,8 @@ export interface RemoteAgentsJsonOptions<T> {
|
|
|
10
10
|
* printing a line per offline box above its output. Never a silent drop.
|
|
11
11
|
*/
|
|
12
12
|
quiet?: boolean;
|
|
13
|
+
/** Per-peer deadline. Long-running maintenance commands override 12 seconds. */
|
|
14
|
+
timeoutMs?: number;
|
|
13
15
|
}
|
|
14
16
|
export interface RemoteAgentsJsonParseResult<T> {
|
|
15
17
|
items: T[];
|
|
@@ -32,7 +32,7 @@ export function remoteAgentsJsonCommand(args, noFanoutEnv, os) {
|
|
|
32
32
|
const inner = `${noFanoutEnv}=1 agents ${args.map(shellQuote).join(' ')}`;
|
|
33
33
|
return `bash -lc ${shellQuote(inner)}`;
|
|
34
34
|
}
|
|
35
|
-
function sshCapture(target, remoteCmd) {
|
|
35
|
+
function sshCapture(target, remoteCmd, timeoutMs) {
|
|
36
36
|
assertValidSshTarget(target);
|
|
37
37
|
return new Promise((resolve) => {
|
|
38
38
|
const args = [...SSH_OPTS, ...controlOpts(), target, remoteCmd];
|
|
@@ -49,7 +49,7 @@ function sshCapture(target, remoteCmd) {
|
|
|
49
49
|
const timer = setTimeout(() => {
|
|
50
50
|
child.kill('SIGKILL');
|
|
51
51
|
done(null);
|
|
52
|
-
},
|
|
52
|
+
}, timeoutMs);
|
|
53
53
|
child.stdout.on('data', (data) => { stdout += data.toString(); });
|
|
54
54
|
child.on('error', () => done(null));
|
|
55
55
|
child.on('close', (code) => done(code));
|
|
@@ -102,7 +102,7 @@ export async function gatherRemoteAgentsJson(options) {
|
|
|
102
102
|
const parseFailed = [];
|
|
103
103
|
const results = await Promise.all(targets.map(async (target) => {
|
|
104
104
|
const command = remoteAgentsJsonCommand(options.args, options.noFanoutEnv, target.os);
|
|
105
|
-
const result = await sshCapture(target.target, command);
|
|
105
|
+
const result = await sshCapture(target.target, command, options.timeoutMs ?? REMOTE_TIMEOUT_MS);
|
|
106
106
|
if (result.code !== 0) {
|
|
107
107
|
skipped.push(target.name);
|
|
108
108
|
if (!options.quiet) {
|
package/dist/lib/rotate.d.ts
CHANGED
|
@@ -174,7 +174,81 @@ export declare function capacityWeight(usedPercent: number | null, minutesToLimi
|
|
|
174
174
|
* usage headroom.
|
|
175
175
|
*/
|
|
176
176
|
export declare function pickAvailableCandidate(candidates: RotateCandidate[], preferredVersion?: string | null, nowMs?: number): RotateResult | null;
|
|
177
|
+
/**
|
|
178
|
+
* Per-harness routing summary for `agents run auto` — the cross-harness layer
|
|
179
|
+
* that sits above `pickBalancedCandidate` (which is strictly per-harness).
|
|
180
|
+
*/
|
|
181
|
+
export interface HarnessSummary {
|
|
182
|
+
agent: AgentId;
|
|
183
|
+
/** Every installed account slot probed for this harness. */
|
|
184
|
+
candidates: RotateCandidate[];
|
|
185
|
+
/** Healthy accounts after identity dedupe, sorted by headroom. */
|
|
186
|
+
healthy: RotateCandidate[];
|
|
187
|
+
/** The account this harness would route to (best verified headroom). Null when the harness is excluded. */
|
|
188
|
+
best: RotateCandidate | null;
|
|
189
|
+
/** Routing used% of `best` (max across non-session windows); null when unknown. */
|
|
190
|
+
bestUsedPercent: number | null;
|
|
191
|
+
/** Why the harness was excluded, e.g. ['2 rate_limited', '1 signed_out']. Empty when healthy. */
|
|
192
|
+
exclusionReasons: string[];
|
|
193
|
+
}
|
|
194
|
+
export interface HarnessPickResult {
|
|
195
|
+
/** The harness picked for this run. */
|
|
196
|
+
picked: HarnessSummary;
|
|
197
|
+
/** Harnesses with ≥1 healthy account (including the picked one). */
|
|
198
|
+
healthy: HarnessSummary[];
|
|
199
|
+
/** Harnesses with zero healthy accounts — excluded, not down-weighted. */
|
|
200
|
+
excluded: HarnessSummary[];
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Classify every harness's candidates into healthy (with a representative
|
|
204
|
+
* best account) vs excluded (with per-reason counts). Pure — the pick and the
|
|
205
|
+
* zero-healthy error message both read this, so they can never disagree.
|
|
206
|
+
*
|
|
207
|
+
* Health uses the exact account-layer gate (`isRotationEligible`: signed in
|
|
208
|
+
* AND not maxed on ANY blocking window, weekly included). The representative
|
|
209
|
+
* best account honors `preferVerified`: confirmed headroom beats apparent
|
|
210
|
+
* headroom, the same freshness rule the account layer routes on.
|
|
211
|
+
*/
|
|
212
|
+
export declare function classifyHarnessCandidates(byHarness: ReadonlyMap<AgentId, RotateCandidate[]>, nowMs?: number): HarnessSummary[];
|
|
213
|
+
/**
|
|
214
|
+
* Pick a harness for `agents run auto` using weighted random by best-account
|
|
215
|
+
* headroom (RUSH-2132).
|
|
216
|
+
*
|
|
217
|
+
* A harness's capacity is `100 − min(routingUsed% across its healthy accounts)`
|
|
218
|
+
* — its best account's headroom. The pick reuses `weightedRandomByCapacity` on
|
|
219
|
+
* the representative best accounts, so host/harness/account layers all share
|
|
220
|
+
* one sampling behavior. Harnesses with zero healthy accounts are EXCLUDED,
|
|
221
|
+
* not down-weighted. Returns null when no harness has any healthy account;
|
|
222
|
+
* call `classifyHarnessCandidates` for the exclusion detail to message with.
|
|
223
|
+
*/
|
|
224
|
+
export declare function pickHarnessWeighted(byHarness: ReadonlyMap<AgentId, RotateCandidate[]>, nowMs?: number): HarnessPickResult | null;
|
|
225
|
+
/** One-line banner naming the auto-picked harness and why (headroom). */
|
|
226
|
+
export declare function formatHarnessPickBanner(result: HarnessPickResult): string;
|
|
227
|
+
/**
|
|
228
|
+
* The earliest FUTURE window reset across these candidates' usage snapshots —
|
|
229
|
+
* when the first exhausted account becomes usable again. Null when no snapshot
|
|
230
|
+
* carries a reset timestamp.
|
|
231
|
+
*/
|
|
232
|
+
export declare function earliestResetAcross(candidates: RotateCandidate[], nowMs?: number): Date | null;
|
|
233
|
+
/**
|
|
234
|
+
* The zero-healthy-account error (RUSH-2132). EXACT contract — the Factory
|
|
235
|
+
* watchdog tail-detects this text: it must contain the literal `no healthy`
|
|
236
|
+
* and `resets <time>` (parsed for the rotate cooldown). Do not deviate.
|
|
237
|
+
*/
|
|
238
|
+
export declare function formatNoHealthyAccountError(agent: AgentId, strategy: RunStrategy, excluded: RotateCandidate[], nowMs?: number): string;
|
|
239
|
+
/**
|
|
240
|
+
* The zero-healthy-harness error for `agents run auto` — names each harness's
|
|
241
|
+
* exclusion reason plus the earliest reset across all snapshots.
|
|
242
|
+
*/
|
|
243
|
+
export declare function formatNoHealthyHarnessError(summaries: HarnessSummary[], nowMs?: number): string;
|
|
177
244
|
export declare function collectRunCandidates(agent: AgentId): Promise<RotateCandidate[]>;
|
|
245
|
+
/**
|
|
246
|
+
* Collect run candidates for every harness with ≥1 installed version — the
|
|
247
|
+
* probe `agents run auto` routes on (the same per-harness account probe
|
|
248
|
+
* `agents view` aggregates). Harnesses with nothing installed are absent from
|
|
249
|
+
* the map: not a candidate at all, rather than an excluded one.
|
|
250
|
+
*/
|
|
251
|
+
export declare function collectHarnessCandidates(agentIds?: AgentId[]): Promise<Map<AgentId, RotateCandidate[]>>;
|
|
178
252
|
/**
|
|
179
253
|
* Resolve an account identity to the installed version slot that holds it, over
|
|
180
254
|
* an already-collected candidate list. Pure — no I/O — so it is unit-tested
|
|
@@ -207,9 +281,18 @@ export declare function resolveAccountVersion(agent: AgentId, account: string):
|
|
|
207
281
|
export declare function selectBalancedVersion(agent: AgentId): Promise<RotateResult | null>;
|
|
208
282
|
/** Select the configured version if available, otherwise another available version. */
|
|
209
283
|
export declare function selectAvailableVersion(agent: AgentId, preferredVersion?: string | null): Promise<RotateResult | null>;
|
|
210
|
-
export declare function resolveRunVersion(agent: AgentId, strategy: RunStrategy, cwd?: string): Promise<{
|
|
284
|
+
export declare function resolveRunVersion(agent: AgentId, strategy: RunStrategy, cwd?: string, collect?: (agent: AgentId) => Promise<RotateCandidate[]>): Promise<{
|
|
211
285
|
version: string | null;
|
|
212
286
|
rotation: RotateResult | null;
|
|
287
|
+
/**
|
|
288
|
+
* Set when a non-pinned strategy found ZERO healthy candidates among the
|
|
289
|
+
* installed versions: the full excluded set, so callers fail loud with
|
|
290
|
+
* per-account reasons instead of launching the exhausted pinned default
|
|
291
|
+
* (RUSH-2132). Undefined for pinned, for successful picks, and when no
|
|
292
|
+
* version is installed at all (the pre-existing not-installed path — there
|
|
293
|
+
* is no account to be "unhealthy").
|
|
294
|
+
*/
|
|
295
|
+
exhausted?: RotateCandidate[];
|
|
213
296
|
}>;
|
|
214
297
|
/**
|
|
215
298
|
* Cap on the number of healthy accounts a single run will re-dispatch through
|
package/dist/lib/rotate.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import * as fs from 'fs';
|
|
8
8
|
import * as path from 'path';
|
|
9
|
-
import { accountDisplayLabel, getAccountInfo } from './agents.js';
|
|
9
|
+
import { accountDisplayLabel, getAccountInfo, ALL_AGENT_IDS } from './agents.js';
|
|
10
10
|
import { readMeta, writeMeta, getHelpersDir } from './state.js';
|
|
11
11
|
import { listInstalledVersions, getVersionHomePath, resolveVersion } from './versions.js';
|
|
12
12
|
import { getProjectRunConfigs } from './run-config.js';
|
|
@@ -342,6 +342,136 @@ export function pickAvailableCandidate(candidates, preferredVersion, nowMs = Dat
|
|
|
342
342
|
: undefined;
|
|
343
343
|
return { picked: preferred ?? bestVerified, healthy: sorted, excluded, usageUnverified };
|
|
344
344
|
}
|
|
345
|
+
/**
|
|
346
|
+
* Classify every harness's candidates into healthy (with a representative
|
|
347
|
+
* best account) vs excluded (with per-reason counts). Pure — the pick and the
|
|
348
|
+
* zero-healthy error message both read this, so they can never disagree.
|
|
349
|
+
*
|
|
350
|
+
* Health uses the exact account-layer gate (`isRotationEligible`: signed in
|
|
351
|
+
* AND not maxed on ANY blocking window, weekly included). The representative
|
|
352
|
+
* best account honors `preferVerified`: confirmed headroom beats apparent
|
|
353
|
+
* headroom, the same freshness rule the account layer routes on.
|
|
354
|
+
*/
|
|
355
|
+
export function classifyHarnessCandidates(byHarness, nowMs = Date.now()) {
|
|
356
|
+
const summaries = [];
|
|
357
|
+
for (const [agent, candidates] of byHarness) {
|
|
358
|
+
const eligible = candidates.filter(isRotationEligible);
|
|
359
|
+
if (eligible.length === 0) {
|
|
360
|
+
const counts = new Map();
|
|
361
|
+
for (const c of candidates) {
|
|
362
|
+
const readiness = readinessFromCandidate(c);
|
|
363
|
+
const reason = readiness.ready ? 'ineligible' : readiness.reason;
|
|
364
|
+
counts.set(reason, (counts.get(reason) ?? 0) + 1);
|
|
365
|
+
}
|
|
366
|
+
summaries.push({
|
|
367
|
+
agent,
|
|
368
|
+
candidates,
|
|
369
|
+
healthy: [],
|
|
370
|
+
best: null,
|
|
371
|
+
bestUsedPercent: null,
|
|
372
|
+
exclusionReasons: [...counts.entries()].map(([reason, n]) => `${n} ${reason}`),
|
|
373
|
+
});
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
const sorted = dedupeAndSortCandidates(eligible);
|
|
377
|
+
const { picked: best } = preferVerified(sorted, nowMs, (from) => from[0]);
|
|
378
|
+
summaries.push({
|
|
379
|
+
agent,
|
|
380
|
+
candidates,
|
|
381
|
+
healthy: sorted,
|
|
382
|
+
best,
|
|
383
|
+
bestUsedPercent: getRoutingUsedPercent(best.usageSnapshot),
|
|
384
|
+
exclusionReasons: [],
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
return summaries;
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Pick a harness for `agents run auto` using weighted random by best-account
|
|
391
|
+
* headroom (RUSH-2132).
|
|
392
|
+
*
|
|
393
|
+
* A harness's capacity is `100 − min(routingUsed% across its healthy accounts)`
|
|
394
|
+
* — its best account's headroom. The pick reuses `weightedRandomByCapacity` on
|
|
395
|
+
* the representative best accounts, so host/harness/account layers all share
|
|
396
|
+
* one sampling behavior. Harnesses with zero healthy accounts are EXCLUDED,
|
|
397
|
+
* not down-weighted. Returns null when no harness has any healthy account;
|
|
398
|
+
* call `classifyHarnessCandidates` for the exclusion detail to message with.
|
|
399
|
+
*/
|
|
400
|
+
export function pickHarnessWeighted(byHarness, nowMs = Date.now()) {
|
|
401
|
+
const summaries = classifyHarnessCandidates(byHarness, nowMs);
|
|
402
|
+
const healthy = summaries.filter((s) => s.best !== null);
|
|
403
|
+
const excluded = summaries.filter((s) => s.best === null);
|
|
404
|
+
if (healthy.length === 0)
|
|
405
|
+
return null;
|
|
406
|
+
const pickedBest = weightedRandomByCapacity(healthy.map((s) => s.best));
|
|
407
|
+
const picked = healthy.find((s) => s.best === pickedBest);
|
|
408
|
+
return { picked, healthy, excluded };
|
|
409
|
+
}
|
|
410
|
+
/** One-line banner naming the auto-picked harness and why (headroom). */
|
|
411
|
+
export function formatHarnessPickBanner(result) {
|
|
412
|
+
const { picked, healthy, excluded } = result;
|
|
413
|
+
const headroom = picked.bestUsedPercent === null
|
|
414
|
+
? 'best account headroom unknown'
|
|
415
|
+
: `best account ${Math.max(0, Math.round(100 - picked.bestUsedPercent))}% headroom`;
|
|
416
|
+
const ratio = `${healthy.length} of ${healthy.length + excluded.length} harnesses healthy`;
|
|
417
|
+
return `[agents] auto picked ${picked.agent} (${headroom}, ${ratio})`;
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* The earliest FUTURE window reset across these candidates' usage snapshots —
|
|
421
|
+
* when the first exhausted account becomes usable again. Null when no snapshot
|
|
422
|
+
* carries a reset timestamp.
|
|
423
|
+
*/
|
|
424
|
+
export function earliestResetAcross(candidates, nowMs = Date.now()) {
|
|
425
|
+
let earliest = null;
|
|
426
|
+
for (const c of candidates) {
|
|
427
|
+
for (const window of c.usageSnapshot?.windows ?? []) {
|
|
428
|
+
const t = window.resetsAt?.getTime();
|
|
429
|
+
if (t != null && t > nowMs && (earliest === null || t < earliest)) {
|
|
430
|
+
earliest = t;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return earliest === null ? null : new Date(earliest);
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* The `resets <summary>` fragment both zero-healthy errors share. ISO 8601 so
|
|
438
|
+
* a watchdog can parse the cooldown straight off the line; `unknown` when no
|
|
439
|
+
* snapshot carries a reset (a parser falls back to its default cooldown).
|
|
440
|
+
*/
|
|
441
|
+
function formatResetSummary(reset) {
|
|
442
|
+
return reset ? reset.toISOString() : 'unknown (no reset timestamps in any snapshot)';
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* The zero-healthy-account error (RUSH-2132). EXACT contract — the Factory
|
|
446
|
+
* watchdog tail-detects this text: it must contain the literal `no healthy`
|
|
447
|
+
* and `resets <time>` (parsed for the rotate cooldown). Do not deviate.
|
|
448
|
+
*/
|
|
449
|
+
export function formatNoHealthyAccountError(agent, strategy, excluded, nowMs = Date.now()) {
|
|
450
|
+
const excludedStr = excluded.length === 0
|
|
451
|
+
? 'no installed versions'
|
|
452
|
+
: excluded.map((c) => {
|
|
453
|
+
const readiness = readinessFromCandidate(c);
|
|
454
|
+
const reason = readiness.ready ? 'ineligible' : readiness.reason;
|
|
455
|
+
return `${c.version} (${reason})`;
|
|
456
|
+
}).join(', ');
|
|
457
|
+
const resetSummary = formatResetSummary(earliestResetAcross(excluded, nowMs));
|
|
458
|
+
return `agents: no healthy ${agent} account under strategy '${strategy}' — excluded: ${excludedStr}; earliest window resets ${resetSummary}. Use --strategy pinned to force the default.`;
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* The zero-healthy-harness error for `agents run auto` — names each harness's
|
|
462
|
+
* exclusion reason plus the earliest reset across all snapshots.
|
|
463
|
+
*/
|
|
464
|
+
export function formatNoHealthyHarnessError(summaries, nowMs = Date.now()) {
|
|
465
|
+
const excludedStr = summaries.length === 0
|
|
466
|
+
? 'no installed harnesses'
|
|
467
|
+
: summaries.map((s) => {
|
|
468
|
+
const n = s.candidates.length;
|
|
469
|
+
const detail = s.exclusionReasons.length > 0 ? s.exclusionReasons.join(', ') : 'no accounts signed in';
|
|
470
|
+
return `${s.agent} (${n} account${n === 1 ? '' : 's'}: ${detail})`;
|
|
471
|
+
}).join(', ');
|
|
472
|
+
const resetSummary = formatResetSummary(earliestResetAcross(summaries.flatMap((s) => s.candidates), nowMs));
|
|
473
|
+
return `agents: no healthy harness for 'run auto' — excluded: ${excludedStr}; earliest window resets ${resetSummary}. Sign in an account or wait for a window to reset.`;
|
|
474
|
+
}
|
|
345
475
|
export async function collectRunCandidates(agent) {
|
|
346
476
|
const versions = listInstalledVersions(agent);
|
|
347
477
|
const rows = await Promise.all(versions.map(async (version) => {
|
|
@@ -400,6 +530,25 @@ export async function collectRunCandidates(agent) {
|
|
|
400
530
|
};
|
|
401
531
|
});
|
|
402
532
|
}
|
|
533
|
+
/**
|
|
534
|
+
* Collect run candidates for every harness with ≥1 installed version — the
|
|
535
|
+
* probe `agents run auto` routes on (the same per-harness account probe
|
|
536
|
+
* `agents view` aggregates). Harnesses with nothing installed are absent from
|
|
537
|
+
* the map: not a candidate at all, rather than an excluded one.
|
|
538
|
+
*/
|
|
539
|
+
export async function collectHarnessCandidates(agentIds = ALL_AGENT_IDS) {
|
|
540
|
+
const entries = await Promise.all(agentIds.map(async (agent) => {
|
|
541
|
+
if (listInstalledVersions(agent).length === 0)
|
|
542
|
+
return null;
|
|
543
|
+
return [agent, await collectRunCandidates(agent)];
|
|
544
|
+
}));
|
|
545
|
+
const byHarness = new Map();
|
|
546
|
+
for (const entry of entries) {
|
|
547
|
+
if (entry)
|
|
548
|
+
byHarness.set(entry[0], entry[1]);
|
|
549
|
+
}
|
|
550
|
+
return byHarness;
|
|
551
|
+
}
|
|
403
552
|
/**
|
|
404
553
|
* Resolve an account identity to the installed version slot that holds it, over
|
|
405
554
|
* an already-collected candidate list. Pure — no I/O — so it is unit-tested
|
|
@@ -478,14 +627,15 @@ function readRotationStamp(agent) {
|
|
|
478
627
|
catch { /* missing or corrupt — treat as no stamp */ }
|
|
479
628
|
return null;
|
|
480
629
|
}
|
|
481
|
-
export async function resolveRunVersion(agent, strategy, cwd = process.cwd()) {
|
|
630
|
+
export async function resolveRunVersion(agent, strategy, cwd = process.cwd(), collect = collectRunCandidates) {
|
|
482
631
|
const fallback = resolveVersion(agent, cwd);
|
|
483
632
|
if (strategy === 'pinned') {
|
|
484
633
|
return { version: fallback, rotation: null };
|
|
485
634
|
}
|
|
635
|
+
const candidates = await collect(agent);
|
|
486
636
|
const rotation = strategy === 'available'
|
|
487
|
-
?
|
|
488
|
-
:
|
|
637
|
+
? pickAvailableCandidate(candidates, fallback)
|
|
638
|
+
: pickBalancedCandidate(candidates);
|
|
489
639
|
if (rotation) {
|
|
490
640
|
// `available` is sticky to the pinned default when healthy. Use the 60s
|
|
491
641
|
// anti-collision stamp to nudge parallel callers off the same version.
|
|
@@ -503,7 +653,7 @@ export async function resolveRunVersion(agent, strategy, cwd = process.cwd()) {
|
|
|
503
653
|
emit('rotation.resolved', { module: 'rotate', agent, version: rotation.picked.version, strategy, healthy: rotation.healthy.length, excluded: rotation.excluded.length });
|
|
504
654
|
return { version: rotation.picked.version, rotation };
|
|
505
655
|
}
|
|
506
|
-
return { version: fallback, rotation: null };
|
|
656
|
+
return { version: fallback, rotation: null, exhausted: candidates.length > 0 ? candidates : undefined };
|
|
507
657
|
}
|
|
508
658
|
/**
|
|
509
659
|
* Cap on the number of healthy accounts a single run will re-dispatch through
|
package/dist/lib/runner.d.ts
CHANGED
|
@@ -17,7 +17,7 @@ import type { JobConfig, RunMeta } from './routines.js';
|
|
|
17
17
|
import type { AgentId } from './types.js';
|
|
18
18
|
import { type FallbackEntry } from './exec.js';
|
|
19
19
|
import type { LoopDeps } from './loop.js';
|
|
20
|
-
import { type RotateResult } from './rotate.js';
|
|
20
|
+
import { resolveRunVersion, type RotateResult } from './rotate.js';
|
|
21
21
|
/** Result of a completed job execution, including metadata and optional report. */
|
|
22
22
|
export interface RunResult {
|
|
23
23
|
meta: RunMeta;
|
|
@@ -49,7 +49,9 @@ export interface RoutineLaunchPlan {
|
|
|
49
49
|
*
|
|
50
50
|
* Workflows are left alone — `agents run <workflow>` owns selection.
|
|
51
51
|
*/
|
|
52
|
-
export declare function resolveRoutineLaunch(config: JobConfig, cwd?: string
|
|
52
|
+
export declare function resolveRoutineLaunch(config: JobConfig, cwd?: string, deps?: {
|
|
53
|
+
resolveRunVersion?: typeof resolveRunVersion;
|
|
54
|
+
}): Promise<RoutineLaunchPlan>;
|
|
53
55
|
/**
|
|
54
56
|
* Rewrite `cmd[0]` to the absolute binary for `agent@version` when installed.
|
|
55
57
|
* Bypasses the bare-name shim so a sandboxed HOME / missing default pin cannot
|
package/dist/lib/runner.js
CHANGED
|
@@ -29,7 +29,7 @@ import { reconcileTask as reconcileHostTask } from './hosts/reconcile.js';
|
|
|
29
29
|
import { backgroundSpawnOptions } from './platform/process.js';
|
|
30
30
|
import { walkForFiles } from './fs-walk.js';
|
|
31
31
|
import { getBinaryPath, isVersionInstalled, resolveVersion } from './versions.js';
|
|
32
|
-
import { getConfiguredRunStrategy, resolveRunVersion, resolveAccountVersion, rotationFailoverChain, readinessFromCandidate, } from './rotate.js';
|
|
32
|
+
import { getConfiguredRunStrategy, resolveRunVersion, resolveAccountVersion, rotationFailoverChain, readinessFromCandidate, formatNoHealthyAccountError, } from './rotate.js';
|
|
33
33
|
import { readAuthHealth, isDeadVerdict } from './auth-health.js';
|
|
34
34
|
import { machineId } from './machine-id.js';
|
|
35
35
|
import { isSelfUpdatingAgent } from './agents.js';
|
|
@@ -330,7 +330,7 @@ function readCommandExitCode(runDir) {
|
|
|
330
330
|
*
|
|
331
331
|
* Workflows are left alone — `agents run <workflow>` owns selection.
|
|
332
332
|
*/
|
|
333
|
-
export async function resolveRoutineLaunch(config, cwd = process.cwd()) {
|
|
333
|
+
export async function resolveRoutineLaunch(config, cwd = process.cwd(), deps = {}) {
|
|
334
334
|
if (config.workflow) {
|
|
335
335
|
return { chain: [], rotation: null, pinned: false };
|
|
336
336
|
}
|
|
@@ -364,10 +364,12 @@ export async function resolveRoutineLaunch(config, cwd = process.cwd()) {
|
|
|
364
364
|
const strategy = getConfiguredRunStrategy(agent, cwd);
|
|
365
365
|
let version;
|
|
366
366
|
let rotation = null;
|
|
367
|
+
let exhausted;
|
|
367
368
|
try {
|
|
368
|
-
const resolved = await resolveRunVersion(agent, strategy, cwd);
|
|
369
|
+
const resolved = await (deps.resolveRunVersion ?? resolveRunVersion)(agent, strategy, cwd);
|
|
369
370
|
version = resolved.version ?? undefined;
|
|
370
371
|
rotation = resolved.rotation;
|
|
372
|
+
exhausted = resolved.exhausted;
|
|
371
373
|
if (rotation) {
|
|
372
374
|
const label = rotation.picked.email
|
|
373
375
|
? `${rotation.picked.email} · ${agent}@${rotation.picked.version}`
|
|
@@ -385,7 +387,7 @@ export async function resolveRoutineLaunch(config, cwd = process.cwd()) {
|
|
|
385
387
|
process.stderr.write(`[agents] routine ${config.name}: skipped ${reasons}\n`);
|
|
386
388
|
}
|
|
387
389
|
}
|
|
388
|
-
else if (!version) {
|
|
390
|
+
else if (!version && !exhausted) {
|
|
389
391
|
process.stderr.write(`[agents] routine ${config.name}: strategy ${strategy} found no usable ${agent} version; ` +
|
|
390
392
|
`falling back to default pin\n`);
|
|
391
393
|
}
|
|
@@ -393,6 +395,13 @@ export async function resolveRoutineLaunch(config, cwd = process.cwd()) {
|
|
|
393
395
|
catch (err) {
|
|
394
396
|
process.stderr.write(`[agents] routine ${config.name}: strategy ${strategy} skipped: ${err.message}\n`);
|
|
395
397
|
}
|
|
398
|
+
// Zero healthy accounts is NOT a "fall back to the default pin" case — that
|
|
399
|
+
// pin is exactly the exhausted account an unattended routine would hammer
|
|
400
|
+
// every tick (RUSH-2132). Throwing fails the job run (nonzero), and the
|
|
401
|
+
// message text is the contract the Factory watchdog tail-detects.
|
|
402
|
+
if (exhausted) {
|
|
403
|
+
throw new Error(formatNoHealthyAccountError(agent, strategy, exhausted));
|
|
404
|
+
}
|
|
396
405
|
if (!version) {
|
|
397
406
|
version = resolveVersion(agent, cwd) ?? undefined;
|
|
398
407
|
}
|
|
Binary file
|
|
Binary file
|