claude-yes 1.165.0 → 1.166.0
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/dist/SUPPORTED_CLIS-ClQgRKQu.js +8 -0
- package/dist/{SUPPORTED_CLIS-H7IIw10E.js → SUPPORTED_CLIS-Xem8nliL.js} +2 -2
- package/dist/cli.js +14 -5
- package/dist/index.js +2 -2
- package/dist/{remotes-DkNuq417.js → remotes-ClT1bq16.js} +1 -1
- package/dist/{remotes-DBCvpp3B.js → remotes-T6nf0t3K.js} +2 -2
- package/dist/{schedule-BobQwIBS.js → schedule-CwAX3y8s.js} +5 -5
- package/dist/{serve-DzOWMI_8.js → serve-DeJBMba0.js} +12 -9
- package/dist/{setup-tVfp7djj.js → setup-hWG3ZBG3.js} +3 -3
- package/dist/{share-Bq_tDXQU.js → share-BjqQBWM-.js} +1 -1
- package/dist/spawnGate-B_VDMXYL.js +107 -0
- package/dist/spawnGate-UH73I2le.js +5 -0
- package/dist/{subcommands-DtwxPMYe.js → subcommands-BY0mIDo9.js} +7 -7
- package/dist/{subcommands-CIFWi9vq.js → subcommands-BnsjTNpy.js} +2 -2
- package/dist/{tray-BMzpUSfa.js → tray-CZarCA2Q.js} +1 -1
- package/dist/{ts-jDEwTsVt.js → ts-CXxZiZKE.js} +2 -2
- package/dist/{versionChecker-C0UJyFcN.js → versionChecker-Dv5DhRs3.js} +2 -2
- package/dist/{webrtcRemote-Ccdzmuc-.js → webrtcRemote-GAgF5K45.js} +1 -1
- package/dist/{workspaceConfig-B0Q9-q2B.js → workspaceConfig-D3OH7and.js} +41 -2
- package/package.json +1 -1
- package/ts/cli.ts +21 -0
- package/ts/serve.ts +8 -0
- package/ts/spawnGate.spec.ts +155 -0
- package/ts/spawnGate.ts +123 -0
- package/ts/workspaceConfig.spec.ts +85 -0
- package/ts/workspaceConfig.ts +64 -0
- package/dist/SUPPORTED_CLIS-dBgLp3mi.js +0 -8
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import "./ts-CXxZiZKE.js";
|
|
2
|
+
import "./logger-CDIsZ-Pp.js";
|
|
3
|
+
import "./versionChecker-Dv5DhRs3.js";
|
|
4
|
+
import "./pidStore-BfoBWUjc.js";
|
|
5
|
+
import "./globalPidIndex-DlmmJlO8.js";
|
|
6
|
+
import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-Xem8nliL.js";
|
|
7
|
+
|
|
8
|
+
export { SUPPORTED_CLIS };
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { t as CLIS_CONFIG } from "./ts-
|
|
1
|
+
import { t as CLIS_CONFIG } from "./ts-CXxZiZKE.js";
|
|
2
2
|
|
|
3
3
|
//#region ts/SUPPORTED_CLIS.ts
|
|
4
4
|
const SUPPORTED_CLIS = Object.keys(CLIS_CONFIG);
|
|
5
5
|
|
|
6
6
|
//#endregion
|
|
7
7
|
export { SUPPORTED_CLIS as t };
|
|
8
|
-
//# sourceMappingURL=SUPPORTED_CLIS-
|
|
8
|
+
//# sourceMappingURL=SUPPORTED_CLIS-Xem8nliL.js.map
|
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { t as invokedCliName } from "./invokedCli-uqM2YYA7.js";
|
|
3
3
|
import { n as logger } from "./logger-CDIsZ-Pp.js";
|
|
4
|
-
import { i as versionString, n as displayVersion, r as getInstalledPackage, t as checkAndAutoUpdate } from "./versionChecker-
|
|
4
|
+
import { i as versionString, n as displayVersion, r as getInstalledPackage, t as checkAndAutoUpdate } from "./versionChecker-Dv5DhRs3.js";
|
|
5
5
|
import { argv } from "process";
|
|
6
6
|
import { execFileSync, spawn } from "child_process";
|
|
7
7
|
import ms from "ms";
|
|
@@ -481,7 +481,7 @@ function buildRustArgs(argv, cliFromScript, supportedClis) {
|
|
|
481
481
|
const rawArg = process.argv[2];
|
|
482
482
|
const managerCommands = !invokedCliName(process.argv);
|
|
483
483
|
const isHelpFlag = rawArg === "-h" || rawArg === "--help";
|
|
484
|
-
const { isSubcommand, runSubcommand, cmdHelp } = await import("./subcommands-
|
|
484
|
+
const { isSubcommand, runSubcommand, cmdHelp } = await import("./subcommands-BnsjTNpy.js");
|
|
485
485
|
if (isHelpFlag && process.argv.length === 3) {
|
|
486
486
|
cmdHelp(managerCommands);
|
|
487
487
|
process.exit(0);
|
|
@@ -495,14 +495,23 @@ await checkAndAutoUpdate();
|
|
|
495
495
|
logger.info(versionString());
|
|
496
496
|
const config = parseCliArgs(process.argv);
|
|
497
497
|
if (config.tray) {
|
|
498
|
-
const { startTray } = await import("./tray-
|
|
498
|
+
const { startTray } = await import("./tray-CZarCA2Q.js");
|
|
499
499
|
await startTray();
|
|
500
500
|
await new Promise(() => {});
|
|
501
501
|
}
|
|
502
502
|
{
|
|
503
|
-
const { ensureTray } = await import("./tray-
|
|
503
|
+
const { ensureTray } = await import("./tray-CZarCA2Q.js");
|
|
504
504
|
ensureTray();
|
|
505
505
|
}
|
|
506
|
+
if (!config.showVersion && !config.appendPrompt && !config.tray) {
|
|
507
|
+
const { waitForSpawnCapacity } = await import("./spawnGate-UH73I2le.js");
|
|
508
|
+
await waitForSpawnCapacity({
|
|
509
|
+
onWait: (reason, waitedMs) => {
|
|
510
|
+
if (config.verbose || waitedMs === 0) console.error(`[agent-yes] spawn gate: ${reason} — waiting…`);
|
|
511
|
+
},
|
|
512
|
+
onProceedAnyway: (reason, waitedMs) => console.error(`[agent-yes] spawn gate: ${reason} — waited ${Math.round(waitedMs / 1e3)}s, proceeding anyway`)
|
|
513
|
+
});
|
|
514
|
+
}
|
|
506
515
|
if (config.useRust) {
|
|
507
516
|
let rustBinary;
|
|
508
517
|
try {
|
|
@@ -514,7 +523,7 @@ if (config.useRust) {
|
|
|
514
523
|
}
|
|
515
524
|
}
|
|
516
525
|
if (rustBinary) {
|
|
517
|
-
const { SUPPORTED_CLIS } = await import("./SUPPORTED_CLIS-
|
|
526
|
+
const { SUPPORTED_CLIS } = await import("./SUPPORTED_CLIS-ClQgRKQu.js");
|
|
518
527
|
const rustArgs = buildRustArgs(process.argv, config.cli, SUPPORTED_CLIS);
|
|
519
528
|
if (config.verbose) {
|
|
520
529
|
console.log(`[rust] Using binary: ${rustBinary}`);
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { a as removeControlCharacters, i as AgentContext, n as agentYes, r as config, t as CLIS_CONFIG } from "./ts-
|
|
1
|
+
import { a as removeControlCharacters, i as AgentContext, n as agentYes, r as config, t as CLIS_CONFIG } from "./ts-CXxZiZKE.js";
|
|
2
2
|
import "./logger-CDIsZ-Pp.js";
|
|
3
|
-
import "./versionChecker-
|
|
3
|
+
import "./versionChecker-Dv5DhRs3.js";
|
|
4
4
|
import "./pidStore-BfoBWUjc.js";
|
|
5
5
|
import "./globalPidIndex-DlmmJlO8.js";
|
|
6
6
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import "./e2e-Bfw7qL9O.js";
|
|
2
2
|
import "./webrtcLink-CBZkZ-LT.js";
|
|
3
|
-
import { a as resolveRemoteSpec, i as readRemotes, n as deleteRemoteAlias, o as writeRemoteAlias, r as parseDirectRemoteSpec, t as cmdRemote } from "./remotes-
|
|
3
|
+
import { a as resolveRemoteSpec, i as readRemotes, n as deleteRemoteAlias, o as writeRemoteAlias, r as parseDirectRemoteSpec, t as cmdRemote } from "./remotes-T6nf0t3K.js";
|
|
4
4
|
|
|
5
5
|
export { cmdRemote };
|
|
@@ -86,7 +86,7 @@ async function resolveRemoteSpec(spec) {
|
|
|
86
86
|
* The bridge lives for the rest of the process (torn down on `process.exit`).
|
|
87
87
|
*/
|
|
88
88
|
async function resolveWebrtc(link, keyword) {
|
|
89
|
-
const { startWebrtcBridge } = await import("./webrtcRemote-
|
|
89
|
+
const { startWebrtcBridge } = await import("./webrtcRemote-GAgF5K45.js");
|
|
90
90
|
const bridge = await startWebrtcBridge(link);
|
|
91
91
|
return {
|
|
92
92
|
url: bridge.baseUrl,
|
|
@@ -173,4 +173,4 @@ async function cmdRemote(rest) {
|
|
|
173
173
|
|
|
174
174
|
//#endregion
|
|
175
175
|
export { resolveRemoteSpec as a, readRemotes as i, deleteRemoteAlias as n, writeRemoteAlias as o, parseDirectRemoteSpec as r, cmdRemote as t };
|
|
176
|
-
//# sourceMappingURL=remotes-
|
|
176
|
+
//# sourceMappingURL=remotes-T6nf0t3K.js.map
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import "./ts-
|
|
1
|
+
import "./ts-CXxZiZKE.js";
|
|
2
2
|
import "./logger-CDIsZ-Pp.js";
|
|
3
|
-
import "./versionChecker-
|
|
3
|
+
import "./versionChecker-Dv5DhRs3.js";
|
|
4
4
|
import "./pidStore-BfoBWUjc.js";
|
|
5
5
|
import "./globalPidIndex-DlmmJlO8.js";
|
|
6
|
-
import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-
|
|
7
|
-
import {
|
|
6
|
+
import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-Xem8nliL.js";
|
|
7
|
+
import { l as resolveSpawnCwd } from "./workspaceConfig-D3OH7and.js";
|
|
8
8
|
import { createHash } from "node:crypto";
|
|
9
9
|
|
|
10
10
|
//#region ts/oxmgrService.ts
|
|
@@ -141,4 +141,4 @@ async function cmdSchedule(rest) {
|
|
|
141
141
|
|
|
142
142
|
//#endregion
|
|
143
143
|
export { cmdSchedule };
|
|
144
|
-
//# sourceMappingURL=schedule-
|
|
144
|
+
//# sourceMappingURL=schedule-CwAX3y8s.js.map
|
|
@@ -1,16 +1,17 @@
|
|
|
1
|
-
import "./ts-
|
|
1
|
+
import "./ts-CXxZiZKE.js";
|
|
2
2
|
import "./logger-CDIsZ-Pp.js";
|
|
3
|
-
import { r as getInstalledPackage } from "./versionChecker-
|
|
3
|
+
import { r as getInstalledPackage } from "./versionChecker-Dv5DhRs3.js";
|
|
4
4
|
import "./pidStore-BfoBWUjc.js";
|
|
5
5
|
import { a as updateGlobalPidStatus } from "./globalPidIndex-DlmmJlO8.js";
|
|
6
6
|
import { t as pgidForWrapper } from "./reaper-C-eWAxIj.js";
|
|
7
7
|
import "./configShared-CEyhl0WH.js";
|
|
8
|
-
import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-
|
|
8
|
+
import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-Xem8nliL.js";
|
|
9
9
|
import "./e2e-Bfw7qL9O.js";
|
|
10
10
|
import "./webrtcLink-CBZkZ-LT.js";
|
|
11
|
-
import "./remotes-
|
|
12
|
-
import {
|
|
13
|
-
import { A as writeToIpc, D as snapshotStatus, S as resolveOne, b as renderRawLog, c as extractNeedsInput, h as listRecords, i as controlCodeFromName, l as extractTaskCounts, o as deriveLiveStatus, v as readNotes, y as readPtysize } from "./subcommands-
|
|
11
|
+
import "./remotes-T6nf0t3K.js";
|
|
12
|
+
import { c as isProvisionAllowed, i as getSpawnHook, l as resolveSpawnCwd, r as getProvisionRoot, s as hasSpawnHook } from "./workspaceConfig-D3OH7and.js";
|
|
13
|
+
import { A as writeToIpc, D as snapshotStatus, S as resolveOne, b as renderRawLog, c as extractNeedsInput, h as listRecords, i as controlCodeFromName, l as extractTaskCounts, o as deriveLiveStatus, v as readNotes, y as readPtysize } from "./subcommands-BY0mIDo9.js";
|
|
14
|
+
import { r as spawnRejectionReason } from "./spawnGate-B_VDMXYL.js";
|
|
14
15
|
import yargs from "yargs";
|
|
15
16
|
import { mkdir, open, readFile, stat, unlink, writeFile } from "fs/promises";
|
|
16
17
|
import { homedir, hostname, userInfo } from "os";
|
|
@@ -307,7 +308,7 @@ async function cmdServeDaemon(sub, args) {
|
|
|
307
308
|
let shareLink = null;
|
|
308
309
|
let shareLinkMinted = false;
|
|
309
310
|
if (webrtcDaemon) try {
|
|
310
|
-
const { loadOrCreateShareRoom, shareLinkFromRoomUrl } = await import("./share-
|
|
311
|
+
const { loadOrCreateShareRoom, shareLinkFromRoomUrl } = await import("./share-BjqQBWM-.js");
|
|
311
312
|
const explicit = explicitWebrtcUrl(effArgs);
|
|
312
313
|
shareLink = shareLinkFromRoomUrl(explicit ?? await loadOrCreateShareRoom());
|
|
313
314
|
shareLinkMinted = !explicit;
|
|
@@ -1169,6 +1170,8 @@ Options:
|
|
|
1169
1170
|
const cli = String(body.cli ?? "claude");
|
|
1170
1171
|
if (!SUPPORTED_CLIS.includes(cli)) return new Response(`unsupported cli: ${cli}`, { status: 400 });
|
|
1171
1172
|
const prompt = String(body.prompt ?? "");
|
|
1173
|
+
const reject = await spawnRejectionReason();
|
|
1174
|
+
if (reject) return new Response(reject, { status: 429 });
|
|
1172
1175
|
let cwd;
|
|
1173
1176
|
let provisioned = null;
|
|
1174
1177
|
const fork = body.fork && typeof body.fork.fromCwd === "string" && body.fork.fromCwd.trim() && typeof body.fork.branch === "string" && body.fork.branch.trim() ? {
|
|
@@ -1383,7 +1386,7 @@ Options:
|
|
|
1383
1386
|
const webrtcVal = argv.webrtc ?? argv.share;
|
|
1384
1387
|
const explicitUrl = typeof webrtcVal === "string" && webrtcVal.startsWith("webrtc://") ? webrtcVal : void 0;
|
|
1385
1388
|
try {
|
|
1386
|
-
const { startShare, loadOrCreateShareRoom } = await import("./share-
|
|
1389
|
+
const { startShare, loadOrCreateShareRoom } = await import("./share-BjqQBWM-.js");
|
|
1387
1390
|
const linkFile = path.join(process.env.AGENT_YES_HOME ?? path.join(homedir(), ".agent-yes"), ".share-link");
|
|
1388
1391
|
const announce = async (room, link, rotated) => {
|
|
1389
1392
|
const lead = rotated ? "the room was rejected by signaling (stale generation) — rotated to a fresh link" : "shared over WebRTC — open this link (the token is eaten from the URL on open)";
|
|
@@ -1442,4 +1445,4 @@ Options:
|
|
|
1442
1445
|
|
|
1443
1446
|
//#endregion
|
|
1444
1447
|
export { cmdServe };
|
|
1445
|
-
//# sourceMappingURL=serve-
|
|
1448
|
+
//# sourceMappingURL=serve-DeJBMba0.js.map
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { o as getWorkspaceRoot, u as setWorkspaceRoot } from "./workspaceConfig-D3OH7and.js";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import { stdin, stdout } from "node:process";
|
|
4
4
|
import { createInterface } from "node:readline/promises";
|
|
@@ -32,7 +32,7 @@ async function cmdSetup(rest) {
|
|
|
32
32
|
if (!existsSync(abs)) process.stderr.write(` note: that directory doesn't exist yet — create it, or agents spawned there will fail\n`);
|
|
33
33
|
if (noShare) return 0;
|
|
34
34
|
process.stdout.write(`\nsharing this machine to agent-yes.com…\n`);
|
|
35
|
-
const { cmdServe } = await import("./serve-
|
|
35
|
+
const { cmdServe } = await import("./serve-DeJBMba0.js");
|
|
36
36
|
return cmdServe([
|
|
37
37
|
"install",
|
|
38
38
|
"--share",
|
|
@@ -42,4 +42,4 @@ async function cmdSetup(rest) {
|
|
|
42
42
|
|
|
43
43
|
//#endregion
|
|
44
44
|
export { cmdSetup };
|
|
45
|
-
//# sourceMappingURL=setup-
|
|
45
|
+
//# sourceMappingURL=setup-hWG3ZBG3.js.map
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { i as readGlobalPids } from "./globalPidIndex-DlmmJlO8.js";
|
|
2
|
+
import { a as getSpawnWaitMs, n as getMinFreeMb, t as getMaxAgents } from "./workspaceConfig-D3OH7and.js";
|
|
3
|
+
import { readFile } from "fs/promises";
|
|
4
|
+
|
|
5
|
+
//#region ts/spawnGate.ts
|
|
6
|
+
/**
|
|
7
|
+
* Spawn admission control — keep an unbounded fan-out of agents from exhausting
|
|
8
|
+
* host RAM and tripping the kernel OOM-killer (which, on a box with no cgroup
|
|
9
|
+
* memory limit, hangs the whole machine). Two opt-in limits:
|
|
10
|
+
* - {@link getMaxAgents} — max concurrently-live agents
|
|
11
|
+
* - {@link getMinFreeMb} — minimum free memory before admitting a spawn
|
|
12
|
+
*
|
|
13
|
+
* Two entry points share one instantaneous check ({@link spawnRejectionReason}):
|
|
14
|
+
* - the `ay serve` daemon HARD-REJECTS (`/api/spawn` → 429) so the caller retries;
|
|
15
|
+
* - the CLI path BLOCKS-AND-WAITS ({@link waitForSpawnCapacity}) with φ-backoff,
|
|
16
|
+
* failing open after a timeout so recursive `ay <cli>` spawns get spaced out
|
|
17
|
+
* (the actual cause of the burst storms) without ever deadlocking a workflow.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* System available memory in MB. Prefers Linux `/proc/meminfo` `MemAvailable`
|
|
21
|
+
* (the kernel's own estimate of what's reclaimable without swapping — far more
|
|
22
|
+
* accurate than free RAM), falling back to `os.freemem()` on other platforms or
|
|
23
|
+
* if the file can't be parsed. Returns null when nothing usable is available.
|
|
24
|
+
*/
|
|
25
|
+
async function memAvailableMb() {
|
|
26
|
+
try {
|
|
27
|
+
const txt = await readFile("/proc/meminfo", "utf-8");
|
|
28
|
+
const m = /^MemAvailable:\s+(\d+)\s*kB/m.exec(txt);
|
|
29
|
+
if (m) return Math.floor(Number(m[1]) / 1024);
|
|
30
|
+
} catch {}
|
|
31
|
+
try {
|
|
32
|
+
const { freemem } = await import("os");
|
|
33
|
+
return Math.floor(freemem() / (1024 * 1024));
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** True when at least one spawn limit is configured (else the gate is a no-op). */
|
|
39
|
+
function spawnGateEnabled() {
|
|
40
|
+
return getMaxAgents() !== void 0 || getMinFreeMb() !== void 0;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Instantaneous capacity check. Returns a human-readable reason string when a
|
|
44
|
+
* new spawn should be held back (cap reached / memory too low), or null when
|
|
45
|
+
* there's capacity. Both limits are opt-in — unset means no check.
|
|
46
|
+
*
|
|
47
|
+
* NOTE — the `maxAgents` count is BEST-EFFORT, not a hard barrier: the check is
|
|
48
|
+
* non-atomic (check here → register in `pids.jsonl` later, in another process),
|
|
49
|
+
* so a simultaneous burst can briefly overshoot the cap before the new wrappers
|
|
50
|
+
* appear in the live count. That's acceptable because (a) the CLI path's
|
|
51
|
+
* φ-backoff desynchronizes retries, spreading a burst out, and (b) `minFreeMb`
|
|
52
|
+
* is the HARD OOM guard — it's re-evaluated against live `/proc/meminfo` on every
|
|
53
|
+
* attempt, so once RAM actually drops, further spawns are held regardless of the
|
|
54
|
+
* count. Exact admission would need a cross-process reservation+lock (a TTL'd
|
|
55
|
+
* reservation file); deliberately deferred to avoid stale reservations wedging
|
|
56
|
+
* spawns, which would be worse than a transient overshoot.
|
|
57
|
+
*/
|
|
58
|
+
async function spawnRejectionReason() {
|
|
59
|
+
const maxAgents = getMaxAgents();
|
|
60
|
+
if (maxAgents !== void 0) {
|
|
61
|
+
const live = (await readGlobalPids({ liveOnly: true })).length;
|
|
62
|
+
if (live >= maxAgents) return `too many agents running (${live}/${maxAgents}) — this agent wasn't started. Please wait for one to finish and try again. (host: raise "maxAgents" in ~/.agent-yes/config.json or AGENT_YES_MAX_AGENTS)`;
|
|
63
|
+
}
|
|
64
|
+
const minFreeMb = getMinFreeMb();
|
|
65
|
+
if (minFreeMb !== void 0) {
|
|
66
|
+
const avail = await memAvailableMb();
|
|
67
|
+
if (avail !== null && avail < minFreeMb) return `the host is low on memory (${avail}MB free, needs ${minFreeMb}MB) — this agent wasn't started, to avoid crashing the machine. Please try again in a moment. (host: adjust "minFreeMb" in ~/.agent-yes/config.json or AGENT_YES_MIN_FREE_MB)`;
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
const PHI = 1.618;
|
|
72
|
+
const BACKOFF_CAP_MS = 6e4;
|
|
73
|
+
const BACKOFF_BASE_MS = 1e3;
|
|
74
|
+
/**
|
|
75
|
+
* Block until there's spawn capacity, polling with φ-backoff (1s × φⁿ, capped at
|
|
76
|
+
* 60s — there is no OS event for "memory freed", so polling is unavoidable here).
|
|
77
|
+
* Fails open after {@link getSpawnWaitMs} ms (default 10 min): on timeout it logs
|
|
78
|
+
* and proceeds, so a set of mutually-waiting recursive spawns can never deadlock
|
|
79
|
+
* permanently. A no-op (returns immediately) when no limit is configured, so it
|
|
80
|
+
* adds ~zero overhead to the spawn hot path for users who haven't opted in.
|
|
81
|
+
*
|
|
82
|
+
* `sleep`/`now` are injectable for tests. `onWait` fires once per backoff cycle.
|
|
83
|
+
*/
|
|
84
|
+
async function waitForSpawnCapacity(opts) {
|
|
85
|
+
if (!spawnGateEnabled()) return;
|
|
86
|
+
const maxWaitMs = opts?.maxWaitMs ?? getSpawnWaitMs();
|
|
87
|
+
const now = opts?.now ?? (() => performance.now());
|
|
88
|
+
const sleep = opts?.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
89
|
+
const start = now();
|
|
90
|
+
let delay = BACKOFF_BASE_MS;
|
|
91
|
+
for (;;) {
|
|
92
|
+
const reason = await spawnRejectionReason();
|
|
93
|
+
if (!reason) return;
|
|
94
|
+
const waited = now() - start;
|
|
95
|
+
if (waited >= maxWaitMs) {
|
|
96
|
+
opts?.onProceedAnyway?.(reason, waited);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
opts?.onWait?.(reason, waited);
|
|
100
|
+
await sleep(Math.min(delay, BACKOFF_CAP_MS, maxWaitMs - waited));
|
|
101
|
+
delay = Math.min(delay * PHI, BACKOFF_CAP_MS);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
//#endregion
|
|
106
|
+
export { waitForSpawnCapacity as i, spawnGateEnabled as n, spawnRejectionReason as r, memAvailableMb as t };
|
|
107
|
+
//# sourceMappingURL=spawnGate-B_VDMXYL.js.map
|
|
@@ -3,7 +3,7 @@ import { t as agentYesHome } from "./agentYesHome-_eJa3DaX.js";
|
|
|
3
3
|
import { a as updateGlobalPidStatus, i as readGlobalPids } from "./globalPidIndex-DlmmJlO8.js";
|
|
4
4
|
import { t as loadSharedCliDefaults } from "./configShared-CEyhl0WH.js";
|
|
5
5
|
import { n as isWebrtcSpec } from "./webrtcLink-CBZkZ-LT.js";
|
|
6
|
-
import { a as resolveRemoteSpec, i as readRemotes } from "./remotes-
|
|
6
|
+
import { a as resolveRemoteSpec, i as readRemotes } from "./remotes-T6nf0t3K.js";
|
|
7
7
|
import ms from "ms";
|
|
8
8
|
import yargs from "yargs";
|
|
9
9
|
import { appendFile, mkdir, open, readFile, stat, writeFile } from "fs/promises";
|
|
@@ -597,19 +597,19 @@ async function runSubcommand(argv) {
|
|
|
597
597
|
case "restart": return await cmdRestart(rest);
|
|
598
598
|
case "note": return await cmdNote(rest);
|
|
599
599
|
case "serve": {
|
|
600
|
-
const { cmdServe } = await import("./serve-
|
|
600
|
+
const { cmdServe } = await import("./serve-DeJBMba0.js");
|
|
601
601
|
return cmdServe(rest);
|
|
602
602
|
}
|
|
603
603
|
case "setup": {
|
|
604
|
-
const { cmdSetup } = await import("./setup-
|
|
604
|
+
const { cmdSetup } = await import("./setup-hWG3ZBG3.js");
|
|
605
605
|
return cmdSetup(rest);
|
|
606
606
|
}
|
|
607
607
|
case "schedule": {
|
|
608
|
-
const { cmdSchedule } = await import("./schedule-
|
|
608
|
+
const { cmdSchedule } = await import("./schedule-CwAX3y8s.js");
|
|
609
609
|
return cmdSchedule(rest);
|
|
610
610
|
}
|
|
611
611
|
case "remote": {
|
|
612
|
-
const { cmdRemote } = await import("./remotes-
|
|
612
|
+
const { cmdRemote } = await import("./remotes-ClT1bq16.js");
|
|
613
613
|
return cmdRemote(rest);
|
|
614
614
|
}
|
|
615
615
|
case "reap":
|
|
@@ -982,7 +982,7 @@ async function fetchRemoteRecordsRaw(url, token, opts) {
|
|
|
982
982
|
let base = url;
|
|
983
983
|
let bearer = token;
|
|
984
984
|
if (isWebrtcSpec(url)) {
|
|
985
|
-
const { startWebrtcBridge } = await import("./webrtcRemote-
|
|
985
|
+
const { startWebrtcBridge } = await import("./webrtcRemote-GAgF5K45.js");
|
|
986
986
|
bridge = await startWebrtcBridge(url);
|
|
987
987
|
base = bridge.baseUrl;
|
|
988
988
|
bearer = bridge.token;
|
|
@@ -3005,4 +3005,4 @@ async function cmdResultSet(rest) {
|
|
|
3005
3005
|
|
|
3006
3006
|
//#endregion
|
|
3007
3007
|
export { writeToIpc as A, resolveReadWindow as C, snapshotStatus as D, runSubcommand as E, stopTipForCli as O, resolveOne as S, restartHintLines as T, menuSelectKeys as _, cursorAbs as a, renderRawLog as b, extractNeedsInput as c, isAgentStuck as d, isExitRequest as f, matchKeyword as g, listRecords as h, controlCodeFromName as i, writeKeysPaced as k, extractTaskCounts as l, isSubcommand as m, READ_PAGE_DEFAULT as n, deriveLiveStatus as o, isPidAlive as p, cmdHelp as r, extractMenu as s, GRACEFUL_EXIT_COMMANDS as t, finalizedLines as u, readNotes as v, resolveResumeArgs as w, renderRawLogLines as x, readPtysize as y };
|
|
3008
|
-
//# sourceMappingURL=subcommands-
|
|
3008
|
+
//# sourceMappingURL=subcommands-BY0mIDo9.js.map
|
|
@@ -3,7 +3,7 @@ import "./globalPidIndex-DlmmJlO8.js";
|
|
|
3
3
|
import "./configShared-CEyhl0WH.js";
|
|
4
4
|
import "./e2e-Bfw7qL9O.js";
|
|
5
5
|
import "./webrtcLink-CBZkZ-LT.js";
|
|
6
|
-
import "./remotes-
|
|
7
|
-
import { A as writeToIpc, C as resolveReadWindow, D as snapshotStatus, E as runSubcommand, O as stopTipForCli, S as resolveOne, T as restartHintLines, _ as menuSelectKeys, a as cursorAbs, b as renderRawLog, c as extractNeedsInput, d as isAgentStuck, f as isExitRequest, g as matchKeyword, h as listRecords, i as controlCodeFromName, k as writeKeysPaced, l as extractTaskCounts, m as isSubcommand, n as READ_PAGE_DEFAULT, o as deriveLiveStatus, p as isPidAlive, r as cmdHelp, s as extractMenu, t as GRACEFUL_EXIT_COMMANDS, u as finalizedLines, v as readNotes, w as resolveResumeArgs, x as renderRawLogLines, y as readPtysize } from "./subcommands-
|
|
6
|
+
import "./remotes-T6nf0t3K.js";
|
|
7
|
+
import { A as writeToIpc, C as resolveReadWindow, D as snapshotStatus, E as runSubcommand, O as stopTipForCli, S as resolveOne, T as restartHintLines, _ as menuSelectKeys, a as cursorAbs, b as renderRawLog, c as extractNeedsInput, d as isAgentStuck, f as isExitRequest, g as matchKeyword, h as listRecords, i as controlCodeFromName, k as writeKeysPaced, l as extractTaskCounts, m as isSubcommand, n as READ_PAGE_DEFAULT, o as deriveLiveStatus, p as isPidAlive, r as cmdHelp, s as extractMenu, t as GRACEFUL_EXIT_COMMANDS, u as finalizedLines, v as readNotes, w as resolveResumeArgs, x as renderRawLogLines, y as readPtysize } from "./subcommands-BY0mIDo9.js";
|
|
8
8
|
|
|
9
9
|
export { cmdHelp, isSubcommand, runSubcommand };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { n as logger, t as addTransport } from "./logger-CDIsZ-Pp.js";
|
|
2
|
-
import { r as getInstalledPackage } from "./versionChecker-
|
|
2
|
+
import { r as getInstalledPackage } from "./versionChecker-Dv5DhRs3.js";
|
|
3
3
|
import { t as agentYesHome } from "./agentYesHome-_eJa3DaX.js";
|
|
4
4
|
import { i as shouldUseLock, r as releaseLock, t as acquireLock } from "./runningLock-V4qvXgAw.js";
|
|
5
5
|
import { t as PidStore } from "./pidStore-BfoBWUjc.js";
|
|
@@ -1803,4 +1803,4 @@ function sleep(ms) {
|
|
|
1803
1803
|
|
|
1804
1804
|
//#endregion
|
|
1805
1805
|
export { removeControlCharacters as a, AgentContext as i, agentYes as n, config as r, CLIS_CONFIG as t };
|
|
1806
|
-
//# sourceMappingURL=ts-
|
|
1806
|
+
//# sourceMappingURL=ts-CXxZiZKE.js.map
|
|
@@ -7,7 +7,7 @@ import { fileURLToPath } from "url";
|
|
|
7
7
|
|
|
8
8
|
//#region package.json
|
|
9
9
|
var name = "claude-yes";
|
|
10
|
-
var version = "1.
|
|
10
|
+
var version = "1.166.0";
|
|
11
11
|
|
|
12
12
|
//#endregion
|
|
13
13
|
//#region ts/versionChecker.ts
|
|
@@ -215,4 +215,4 @@ async function displayVersion() {
|
|
|
215
215
|
|
|
216
216
|
//#endregion
|
|
217
217
|
export { versionString as i, displayVersion as n, getInstalledPackage as r, checkAndAutoUpdate as t };
|
|
218
|
-
//# sourceMappingURL=versionChecker-
|
|
218
|
+
//# sourceMappingURL=versionChecker-Dv5DhRs3.js.map
|
|
@@ -96,6 +96,45 @@ function getSpawnHook() {
|
|
|
96
96
|
function hasSpawnHook() {
|
|
97
97
|
return getSpawnHook() !== null;
|
|
98
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* Cap on concurrently-live agents admitted via `/api/spawn`. Env
|
|
101
|
+
* `AGENT_YES_MAX_AGENTS` overrides the config `maxAgents`. A non-positive,
|
|
102
|
+
* missing, or unparseable value means **unlimited** (returns undefined), which
|
|
103
|
+
* preserves the historical no-cap behavior. Exists to stop an unbounded fan-out
|
|
104
|
+
* of agents from exhausting host RAM and tripping the OOM-killer.
|
|
105
|
+
*/
|
|
106
|
+
function getMaxAgents() {
|
|
107
|
+
const raw = process.env.AGENT_YES_MAX_AGENTS?.trim();
|
|
108
|
+
const n = raw !== void 0 && raw !== "" ? Number(raw) : readConfig().maxAgents;
|
|
109
|
+
const v = typeof n === "number" && Number.isFinite(n) ? Math.floor(n) : NaN;
|
|
110
|
+
return v > 0 ? v : void 0;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Minimum system MemAvailable (MB) required to admit a new spawn. Env
|
|
114
|
+
* `AGENT_YES_MIN_FREE_MB` overrides config `minFreeMb`. Non-positive/unset =
|
|
115
|
+
* no floor (undefined). Complements {@link getMaxAgents}: a count cap alone
|
|
116
|
+
* can't stop OOM when individual agents are large, so we also refuse to spawn
|
|
117
|
+
* when free memory is already low.
|
|
118
|
+
*/
|
|
119
|
+
function getMinFreeMb() {
|
|
120
|
+
const raw = process.env.AGENT_YES_MIN_FREE_MB?.trim();
|
|
121
|
+
const n = raw !== void 0 && raw !== "" ? Number(raw) : readConfig().minFreeMb;
|
|
122
|
+
const v = typeof n === "number" && Number.isFinite(n) ? Math.floor(n) : NaN;
|
|
123
|
+
return v > 0 ? v : void 0;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Max time (ms) a CLI spawn blocks waiting for capacity before failing open.
|
|
127
|
+
* Env `AGENT_YES_SPAWN_WAIT_MS` overrides config `spawnWaitMs`. A non-negative
|
|
128
|
+
* finite value is used as-is (0 = don't wait, check once then proceed); anything
|
|
129
|
+
* missing/garbage falls back to the 10-minute default. Bounded fail-open is
|
|
130
|
+
* deliberate: recursive spawns must never deadlock permanently on each other.
|
|
131
|
+
*/
|
|
132
|
+
function getSpawnWaitMs() {
|
|
133
|
+
const DEFAULT = 6e5;
|
|
134
|
+
const raw = process.env.AGENT_YES_SPAWN_WAIT_MS?.trim();
|
|
135
|
+
const n = raw !== void 0 && raw !== "" ? Number(raw) : readConfig().spawnWaitMs;
|
|
136
|
+
return typeof n === "number" && Number.isFinite(n) && n >= 0 ? Math.floor(n) : DEFAULT;
|
|
137
|
+
}
|
|
99
138
|
/** Persist the workspace root, tilde-expanded and resolved to an absolute path. */
|
|
100
139
|
function setWorkspaceRoot(dir) {
|
|
101
140
|
const abs = path.resolve(expandTilde(dir));
|
|
@@ -122,5 +161,5 @@ function resolveSpawnCwd(input) {
|
|
|
122
161
|
}
|
|
123
162
|
|
|
124
163
|
//#endregion
|
|
125
|
-
export {
|
|
126
|
-
//# sourceMappingURL=workspaceConfig-
|
|
164
|
+
export { getSpawnWaitMs as a, isProvisionAllowed as c, getSpawnHook as i, resolveSpawnCwd as l, getMinFreeMb as n, getWorkspaceRoot as o, getProvisionRoot as r, hasSpawnHook as s, getMaxAgents as t, setWorkspaceRoot as u };
|
|
165
|
+
//# sourceMappingURL=workspaceConfig-D3OH7and.js.map
|
package/package.json
CHANGED
package/ts/cli.ts
CHANGED
|
@@ -62,6 +62,27 @@ if (config.tray) {
|
|
|
62
62
|
ensureTray(); // fire-and-forget, don't await
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
// Spawn admission control — applies to a REAL agent launch only (subcommands,
|
|
66
|
+
// --version, --tray already handled/exited above; --append-prompt / --version
|
|
67
|
+
// below are not launches). Sits BEFORE the --rust dispatch so it gates BOTH the
|
|
68
|
+
// Rust and the TypeScript runtimes. Blocks with φ-backoff until there's capacity
|
|
69
|
+
// (or fails open after a timeout) so a burst of recursive `ay <cli>` spawns gets
|
|
70
|
+
// spaced out instead of storming the host into the OOM-killer. No-op (instant)
|
|
71
|
+
// unless maxAgents/minFreeMb is configured — see ts/spawnGate.ts.
|
|
72
|
+
if (!config.showVersion && !config.appendPrompt && !config.tray) {
|
|
73
|
+
const { waitForSpawnCapacity } = await import("./spawnGate.ts");
|
|
74
|
+
await waitForSpawnCapacity({
|
|
75
|
+
onWait: (reason, waitedMs) => {
|
|
76
|
+
if (config.verbose || waitedMs === 0)
|
|
77
|
+
console.error(`[agent-yes] spawn gate: ${reason} — waiting…`);
|
|
78
|
+
},
|
|
79
|
+
onProceedAnyway: (reason, waitedMs) =>
|
|
80
|
+
console.error(
|
|
81
|
+
`[agent-yes] spawn gate: ${reason} — waited ${Math.round(waitedMs / 1000)}s, proceeding anyway`,
|
|
82
|
+
),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
65
86
|
// Handle --rust: spawn the Rust binary instead, fall back to TypeScript if unavailable
|
|
66
87
|
if (config.useRust) {
|
|
67
88
|
let rustBinary: string | undefined;
|
package/ts/serve.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
type CommonOpts,
|
|
22
22
|
} from "./subcommands.ts";
|
|
23
23
|
import { updateGlobalPidStatus } from "./globalPidIndex.ts";
|
|
24
|
+
import { spawnRejectionReason } from "./spawnGate.ts";
|
|
24
25
|
import { pgidForWrapper } from "./reaper.ts";
|
|
25
26
|
import { SUPPORTED_CLIS } from "./SUPPORTED_CLIS.ts";
|
|
26
27
|
import { getInstalledPackage } from "./versionChecker.ts";
|
|
@@ -1705,6 +1706,13 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
1705
1706
|
return new Response(`unsupported cli: ${cli}`, { status: 400 });
|
|
1706
1707
|
const prompt = String(body.prompt ?? "");
|
|
1707
1708
|
|
|
1709
|
+
// Admission control BEFORE any provisioning (clone/worktree) so a capped
|
|
1710
|
+
// request fails fast without doing expensive work. Enforces the optional
|
|
1711
|
+
// concurrency cap + memory floor that keep a fan-out of agents from
|
|
1712
|
+
// driving the host into the OOM-killer. 429 = back-pressure, retry later.
|
|
1713
|
+
const reject = await spawnRejectionReason();
|
|
1714
|
+
if (reject) return new Response(reject, { status: 429 });
|
|
1715
|
+
|
|
1708
1716
|
// Resolve the working directory. A `from` source is provisioned (clone /
|
|
1709
1717
|
// worktree) through codehost/provision; a plain `cwd` is resolved to the
|
|
1710
1718
|
// workspace root and mkdir-p'd so a missing dir no longer ENOENTs.
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "fs";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import {
|
|
6
|
+
memAvailableMb,
|
|
7
|
+
spawnGateEnabled,
|
|
8
|
+
spawnRejectionReason,
|
|
9
|
+
waitForSpawnCapacity,
|
|
10
|
+
} from "./spawnGate.ts";
|
|
11
|
+
|
|
12
|
+
// Build a minimal live pid record. Using the test runner's own pid guarantees
|
|
13
|
+
// `isProcessAlive` (process.kill(pid, 0)) succeeds, so the record counts as live.
|
|
14
|
+
const liveRecord = (pid: number) =>
|
|
15
|
+
JSON.stringify({
|
|
16
|
+
pid,
|
|
17
|
+
cli: "claude",
|
|
18
|
+
prompt: null,
|
|
19
|
+
cwd: "/tmp",
|
|
20
|
+
log_file: null,
|
|
21
|
+
status: "active",
|
|
22
|
+
exit_code: null,
|
|
23
|
+
exit_reason: null,
|
|
24
|
+
started_at: 0,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("spawnGate", () => {
|
|
28
|
+
let original: string | undefined;
|
|
29
|
+
let tmp: string;
|
|
30
|
+
const writePids = (lines: string[]) =>
|
|
31
|
+
writeFileSync(path.join(tmp, "pids.jsonl"), lines.join("\n") + "\n");
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
original = process.env.AGENT_YES_HOME;
|
|
35
|
+
tmp = mkdtempSync(path.join(tmpdir(), "ay-gate-"));
|
|
36
|
+
process.env.AGENT_YES_HOME = tmp;
|
|
37
|
+
});
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
if (original === undefined) delete process.env.AGENT_YES_HOME;
|
|
40
|
+
else process.env.AGENT_YES_HOME = original;
|
|
41
|
+
delete process.env.AGENT_YES_MAX_AGENTS;
|
|
42
|
+
delete process.env.AGENT_YES_MIN_FREE_MB;
|
|
43
|
+
delete process.env.AGENT_YES_SPAWN_WAIT_MS;
|
|
44
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("memAvailableMb", () => {
|
|
48
|
+
it("returns a positive number on this host", async () => {
|
|
49
|
+
const mb = await memAvailableMb();
|
|
50
|
+
expect(mb).not.toBeNull();
|
|
51
|
+
expect(mb!).toBeGreaterThan(0);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("spawnGateEnabled", () => {
|
|
56
|
+
it("is false when nothing is configured", () => {
|
|
57
|
+
expect(spawnGateEnabled()).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
it("is true when a cap or floor is set", () => {
|
|
60
|
+
process.env.AGENT_YES_MAX_AGENTS = "5";
|
|
61
|
+
expect(spawnGateEnabled()).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("spawnRejectionReason", () => {
|
|
66
|
+
it("returns null when no limits are configured", async () => {
|
|
67
|
+
expect(await spawnRejectionReason()).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("rejects when live agents reach the cap", async () => {
|
|
71
|
+
writePids([liveRecord(process.pid)]);
|
|
72
|
+
process.env.AGENT_YES_MAX_AGENTS = "1";
|
|
73
|
+
expect(await spawnRejectionReason()).toMatch(/too many agents running \(1\/1\)/);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("admits when live agents are under the cap", async () => {
|
|
77
|
+
writePids([liveRecord(process.pid)]);
|
|
78
|
+
process.env.AGENT_YES_MAX_AGENTS = "5";
|
|
79
|
+
expect(await spawnRejectionReason()).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("ignores dead pids when counting live agents", async () => {
|
|
83
|
+
// pid 2^31-1 is effectively never a live process → not counted.
|
|
84
|
+
writePids([liveRecord(2147483646)]);
|
|
85
|
+
process.env.AGENT_YES_MAX_AGENTS = "1";
|
|
86
|
+
expect(await spawnRejectionReason()).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("rejects when free memory is below the floor", async () => {
|
|
90
|
+
process.env.AGENT_YES_MIN_FREE_MB = String(Number.MAX_SAFE_INTEGER);
|
|
91
|
+
expect(await spawnRejectionReason()).toMatch(/host is low on memory/);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("admits when free memory is above the floor", async () => {
|
|
95
|
+
process.env.AGENT_YES_MIN_FREE_MB = "1";
|
|
96
|
+
expect(await spawnRejectionReason()).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("waitForSpawnCapacity", () => {
|
|
101
|
+
it("returns immediately (no sleep) when the gate is disabled", async () => {
|
|
102
|
+
const sleeps: number[] = [];
|
|
103
|
+
await waitForSpawnCapacity({ sleep: async (ms) => void sleeps.push(ms) });
|
|
104
|
+
expect(sleeps).toEqual([]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("backs off with φ growth and fails open at the deadline", async () => {
|
|
108
|
+
writePids([liveRecord(process.pid)]);
|
|
109
|
+
process.env.AGENT_YES_MAX_AGENTS = "1"; // always over → never admits
|
|
110
|
+
|
|
111
|
+
let t = 0;
|
|
112
|
+
const sleeps: number[] = [];
|
|
113
|
+
let proceededAfter: number | null = null;
|
|
114
|
+
await waitForSpawnCapacity({
|
|
115
|
+
maxWaitMs: 5000,
|
|
116
|
+
now: () => t,
|
|
117
|
+
sleep: async (ms) => {
|
|
118
|
+
sleeps.push(ms);
|
|
119
|
+
t += ms;
|
|
120
|
+
},
|
|
121
|
+
onProceedAnyway: (_r, waited) => {
|
|
122
|
+
proceededAfter = waited;
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// 1000, then ×φ (1618), then clamped to the remaining 2382ms before deadline.
|
|
127
|
+
expect(sleeps[0]).toBe(1000);
|
|
128
|
+
expect(sleeps[1]).toBe(1618);
|
|
129
|
+
expect(sleeps.reduce((a, b) => a + b, 0)).toBe(5000);
|
|
130
|
+
expect(proceededAfter).toBe(5000);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("admits as soon as capacity frees, without proceeding-anyway", async () => {
|
|
134
|
+
writePids([liveRecord(process.pid)]);
|
|
135
|
+
process.env.AGENT_YES_MAX_AGENTS = "1";
|
|
136
|
+
|
|
137
|
+
const sleeps: number[] = [];
|
|
138
|
+
let proceeded = false;
|
|
139
|
+
await waitForSpawnCapacity({
|
|
140
|
+
maxWaitMs: 600_000,
|
|
141
|
+
sleep: async (ms) => {
|
|
142
|
+
sleeps.push(ms);
|
|
143
|
+
// free up capacity after two backoff cycles
|
|
144
|
+
if (sleeps.length >= 2) process.env.AGENT_YES_MAX_AGENTS = "10";
|
|
145
|
+
},
|
|
146
|
+
onProceedAnyway: () => {
|
|
147
|
+
proceeded = true;
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
expect(sleeps.length).toBe(2);
|
|
152
|
+
expect(proceeded).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
});
|
package/ts/spawnGate.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spawn admission control — keep an unbounded fan-out of agents from exhausting
|
|
3
|
+
* host RAM and tripping the kernel OOM-killer (which, on a box with no cgroup
|
|
4
|
+
* memory limit, hangs the whole machine). Two opt-in limits:
|
|
5
|
+
* - {@link getMaxAgents} — max concurrently-live agents
|
|
6
|
+
* - {@link getMinFreeMb} — minimum free memory before admitting a spawn
|
|
7
|
+
*
|
|
8
|
+
* Two entry points share one instantaneous check ({@link spawnRejectionReason}):
|
|
9
|
+
* - the `ay serve` daemon HARD-REJECTS (`/api/spawn` → 429) so the caller retries;
|
|
10
|
+
* - the CLI path BLOCKS-AND-WAITS ({@link waitForSpawnCapacity}) with φ-backoff,
|
|
11
|
+
* failing open after a timeout so recursive `ay <cli>` spawns get spaced out
|
|
12
|
+
* (the actual cause of the burst storms) without ever deadlocking a workflow.
|
|
13
|
+
*/
|
|
14
|
+
import { readFile } from "fs/promises";
|
|
15
|
+
import { readGlobalPids } from "./globalPidIndex.ts";
|
|
16
|
+
import { getMaxAgents, getMinFreeMb, getSpawnWaitMs } from "./workspaceConfig.ts";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* System available memory in MB. Prefers Linux `/proc/meminfo` `MemAvailable`
|
|
20
|
+
* (the kernel's own estimate of what's reclaimable without swapping — far more
|
|
21
|
+
* accurate than free RAM), falling back to `os.freemem()` on other platforms or
|
|
22
|
+
* if the file can't be parsed. Returns null when nothing usable is available.
|
|
23
|
+
*/
|
|
24
|
+
export async function memAvailableMb(): Promise<number | null> {
|
|
25
|
+
try {
|
|
26
|
+
const txt = await readFile("/proc/meminfo", "utf-8");
|
|
27
|
+
const m = /^MemAvailable:\s+(\d+)\s*kB/m.exec(txt);
|
|
28
|
+
if (m) return Math.floor(Number(m[1]) / 1024);
|
|
29
|
+
} catch {
|
|
30
|
+
/* not Linux / unreadable — fall through */
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
const { freemem } = await import("os");
|
|
34
|
+
return Math.floor(freemem() / (1024 * 1024));
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** True when at least one spawn limit is configured (else the gate is a no-op). */
|
|
41
|
+
export function spawnGateEnabled(): boolean {
|
|
42
|
+
return getMaxAgents() !== undefined || getMinFreeMb() !== undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Instantaneous capacity check. Returns a human-readable reason string when a
|
|
47
|
+
* new spawn should be held back (cap reached / memory too low), or null when
|
|
48
|
+
* there's capacity. Both limits are opt-in — unset means no check.
|
|
49
|
+
*
|
|
50
|
+
* NOTE — the `maxAgents` count is BEST-EFFORT, not a hard barrier: the check is
|
|
51
|
+
* non-atomic (check here → register in `pids.jsonl` later, in another process),
|
|
52
|
+
* so a simultaneous burst can briefly overshoot the cap before the new wrappers
|
|
53
|
+
* appear in the live count. That's acceptable because (a) the CLI path's
|
|
54
|
+
* φ-backoff desynchronizes retries, spreading a burst out, and (b) `minFreeMb`
|
|
55
|
+
* is the HARD OOM guard — it's re-evaluated against live `/proc/meminfo` on every
|
|
56
|
+
* attempt, so once RAM actually drops, further spawns are held regardless of the
|
|
57
|
+
* count. Exact admission would need a cross-process reservation+lock (a TTL'd
|
|
58
|
+
* reservation file); deliberately deferred to avoid stale reservations wedging
|
|
59
|
+
* spawns, which would be worse than a transient overshoot.
|
|
60
|
+
*/
|
|
61
|
+
export async function spawnRejectionReason(): Promise<string | null> {
|
|
62
|
+
const maxAgents = getMaxAgents();
|
|
63
|
+
if (maxAgents !== undefined) {
|
|
64
|
+
// Live agents already running (this wrapper hasn't registered itself yet),
|
|
65
|
+
// so "live >= cap" admits exactly `maxAgents` concurrent agents.
|
|
66
|
+
const live = (await readGlobalPids({ liveOnly: true })).length;
|
|
67
|
+
if (live >= maxAgents)
|
|
68
|
+
// Phrased for the end user who clicked "Spawn" (the console shows this
|
|
69
|
+
// string verbatim), with a host-admin hint appended in parentheses.
|
|
70
|
+
return `too many agents running (${live}/${maxAgents}) — this agent wasn't started. Please wait for one to finish and try again. (host: raise "maxAgents" in ~/.agent-yes/config.json or AGENT_YES_MAX_AGENTS)`;
|
|
71
|
+
}
|
|
72
|
+
const minFreeMb = getMinFreeMb();
|
|
73
|
+
if (minFreeMb !== undefined) {
|
|
74
|
+
const avail = await memAvailableMb();
|
|
75
|
+
if (avail !== null && avail < minFreeMb)
|
|
76
|
+
return `the host is low on memory (${avail}MB free, needs ${minFreeMb}MB) — this agent wasn't started, to avoid crashing the machine. Please try again in a moment. (host: adjust "minFreeMb" in ~/.agent-yes/config.json or AGENT_YES_MIN_FREE_MB)`;
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const PHI = 1.618; // golden-ratio backoff base (snomiao global pref)
|
|
82
|
+
const BACKOFF_CAP_MS = 60_000;
|
|
83
|
+
const BACKOFF_BASE_MS = 1_000;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Block until there's spawn capacity, polling with φ-backoff (1s × φⁿ, capped at
|
|
87
|
+
* 60s — there is no OS event for "memory freed", so polling is unavoidable here).
|
|
88
|
+
* Fails open after {@link getSpawnWaitMs} ms (default 10 min): on timeout it logs
|
|
89
|
+
* and proceeds, so a set of mutually-waiting recursive spawns can never deadlock
|
|
90
|
+
* permanently. A no-op (returns immediately) when no limit is configured, so it
|
|
91
|
+
* adds ~zero overhead to the spawn hot path for users who haven't opted in.
|
|
92
|
+
*
|
|
93
|
+
* `sleep`/`now` are injectable for tests. `onWait` fires once per backoff cycle.
|
|
94
|
+
*/
|
|
95
|
+
export async function waitForSpawnCapacity(opts?: {
|
|
96
|
+
maxWaitMs?: number;
|
|
97
|
+
onWait?: (reason: string, waitedMs: number) => void;
|
|
98
|
+
onProceedAnyway?: (reason: string, waitedMs: number) => void;
|
|
99
|
+
sleep?: (ms: number) => Promise<void>;
|
|
100
|
+
now?: () => number;
|
|
101
|
+
}): Promise<void> {
|
|
102
|
+
if (!spawnGateEnabled()) return; // fast path: nothing configured
|
|
103
|
+
const maxWaitMs = opts?.maxWaitMs ?? getSpawnWaitMs();
|
|
104
|
+
// Monotonic clock for the deadline — `Date.now()` can step backward (NTP /
|
|
105
|
+
// suspend) and stretch the fail-open wait far past `maxWaitMs`.
|
|
106
|
+
const now = opts?.now ?? (() => performance.now());
|
|
107
|
+
const sleep = opts?.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
|
|
108
|
+
const start = now();
|
|
109
|
+
let delay = BACKOFF_BASE_MS;
|
|
110
|
+
for (;;) {
|
|
111
|
+
const reason = await spawnRejectionReason();
|
|
112
|
+
if (!reason) return; // capacity available — admit
|
|
113
|
+
const waited = now() - start;
|
|
114
|
+
if (waited >= maxWaitMs) {
|
|
115
|
+
opts?.onProceedAnyway?.(reason, waited);
|
|
116
|
+
return; // fail open — never deadlock a workflow
|
|
117
|
+
}
|
|
118
|
+
opts?.onWait?.(reason, waited);
|
|
119
|
+
// Don't oversleep past the deadline.
|
|
120
|
+
await sleep(Math.min(delay, BACKOFF_CAP_MS, maxWaitMs - waited));
|
|
121
|
+
delay = Math.min(delay * PHI, BACKOFF_CAP_MS);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -4,6 +4,9 @@ import { homedir, tmpdir } from "os";
|
|
|
4
4
|
import path from "path";
|
|
5
5
|
import {
|
|
6
6
|
expandTilde,
|
|
7
|
+
getMaxAgents,
|
|
8
|
+
getMinFreeMb,
|
|
9
|
+
getSpawnWaitMs,
|
|
7
10
|
getProvisionAllowlist,
|
|
8
11
|
getProvisionRoot,
|
|
9
12
|
getSpawnHook,
|
|
@@ -28,6 +31,9 @@ describe("workspaceConfig", () => {
|
|
|
28
31
|
delete process.env.CODEHOST_WS_ROOT;
|
|
29
32
|
delete process.env.CODEHOST_PROVISION_ALLOWLIST;
|
|
30
33
|
delete process.env.AGENT_YES_SPAWN_HOOK;
|
|
34
|
+
delete process.env.AGENT_YES_MAX_AGENTS;
|
|
35
|
+
delete process.env.AGENT_YES_MIN_FREE_MB;
|
|
36
|
+
delete process.env.AGENT_YES_SPAWN_WAIT_MS;
|
|
31
37
|
rmSync(tmp, { recursive: true, force: true });
|
|
32
38
|
});
|
|
33
39
|
|
|
@@ -146,6 +152,85 @@ describe("workspaceConfig", () => {
|
|
|
146
152
|
});
|
|
147
153
|
});
|
|
148
154
|
|
|
155
|
+
describe("getMaxAgents", () => {
|
|
156
|
+
it("is undefined (unlimited) when neither env nor config is set", () => {
|
|
157
|
+
expect(getMaxAgents()).toBeUndefined();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("reads a positive integer from config", () => {
|
|
161
|
+
writeConfig({ maxAgents: 8 });
|
|
162
|
+
expect(getMaxAgents()).toBe(8);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("env AGENT_YES_MAX_AGENTS overrides config", () => {
|
|
166
|
+
writeConfig({ maxAgents: 8 });
|
|
167
|
+
process.env.AGENT_YES_MAX_AGENTS = "3";
|
|
168
|
+
expect(getMaxAgents()).toBe(3);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("floors a fractional value", () => {
|
|
172
|
+
process.env.AGENT_YES_MAX_AGENTS = "4.9";
|
|
173
|
+
expect(getMaxAgents()).toBe(4);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("treats 0, negative, and garbage as unlimited (undefined)", () => {
|
|
177
|
+
writeConfig({ maxAgents: 0 });
|
|
178
|
+
expect(getMaxAgents()).toBeUndefined();
|
|
179
|
+
process.env.AGENT_YES_MAX_AGENTS = "-5";
|
|
180
|
+
expect(getMaxAgents()).toBeUndefined();
|
|
181
|
+
process.env.AGENT_YES_MAX_AGENTS = "lots";
|
|
182
|
+
expect(getMaxAgents()).toBeUndefined();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("treats a fractional value < 1 as unlimited, not a 0 hard-cap", () => {
|
|
186
|
+
// Regression: 0.5 must NOT floor to 0 (which would reject every spawn).
|
|
187
|
+
process.env.AGENT_YES_MAX_AGENTS = "0.5";
|
|
188
|
+
expect(getMaxAgents()).toBeUndefined();
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe("getMinFreeMb", () => {
|
|
193
|
+
it("is undefined (no floor) when unset", () => {
|
|
194
|
+
expect(getMinFreeMb()).toBeUndefined();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("reads config and lets env override", () => {
|
|
198
|
+
writeConfig({ minFreeMb: 1024 });
|
|
199
|
+
expect(getMinFreeMb()).toBe(1024);
|
|
200
|
+
process.env.AGENT_YES_MIN_FREE_MB = "2048";
|
|
201
|
+
expect(getMinFreeMb()).toBe(2048);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("treats non-positive/garbage/sub-1 as no floor", () => {
|
|
205
|
+
process.env.AGENT_YES_MIN_FREE_MB = "0";
|
|
206
|
+
expect(getMinFreeMb()).toBeUndefined();
|
|
207
|
+
process.env.AGENT_YES_MIN_FREE_MB = "nope";
|
|
208
|
+
expect(getMinFreeMb()).toBeUndefined();
|
|
209
|
+
process.env.AGENT_YES_MIN_FREE_MB = "0.5";
|
|
210
|
+
expect(getMinFreeMb()).toBeUndefined();
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe("getSpawnWaitMs", () => {
|
|
215
|
+
it("defaults to 10 minutes when unset", () => {
|
|
216
|
+
expect(getSpawnWaitMs()).toBe(600_000);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("reads config and lets env override; allows 0 (don't wait)", () => {
|
|
220
|
+
writeConfig({ spawnWaitMs: 5000 });
|
|
221
|
+
expect(getSpawnWaitMs()).toBe(5000);
|
|
222
|
+
process.env.AGENT_YES_SPAWN_WAIT_MS = "0";
|
|
223
|
+
expect(getSpawnWaitMs()).toBe(0);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("falls back to the default on negative/garbage", () => {
|
|
227
|
+
process.env.AGENT_YES_SPAWN_WAIT_MS = "-1";
|
|
228
|
+
expect(getSpawnWaitMs()).toBe(600_000);
|
|
229
|
+
process.env.AGENT_YES_SPAWN_WAIT_MS = "soon";
|
|
230
|
+
expect(getSpawnWaitMs()).toBe(600_000);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
149
234
|
describe("getSpawnHook / hasSpawnHook", () => {
|
|
150
235
|
const isPosix = process.platform !== "win32";
|
|
151
236
|
|
package/ts/workspaceConfig.ts
CHANGED
|
@@ -25,6 +25,24 @@ interface Config {
|
|
|
25
25
|
* arbitrary local code that runs on every spawn.
|
|
26
26
|
*/
|
|
27
27
|
spawnHook?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Max number of concurrently-live agents the daemon will admit via
|
|
30
|
+
* `/api/spawn`. `0`/unset = unlimited (current behavior). See {@link getMaxAgents}.
|
|
31
|
+
*/
|
|
32
|
+
maxAgents?: number;
|
|
33
|
+
/**
|
|
34
|
+
* Refuse a new spawn when system MemAvailable is below this many MB — a memory
|
|
35
|
+
* floor so a burst of spawns can't drive the host into the OOM-killer. `0`/unset
|
|
36
|
+
* = no floor. See {@link getMinFreeMb}.
|
|
37
|
+
*/
|
|
38
|
+
minFreeMb?: number;
|
|
39
|
+
/**
|
|
40
|
+
* How long (ms) a CLI spawn will block-and-wait for capacity (φ-backoff) before
|
|
41
|
+
* failing open and proceeding anyway. Prevents a burst of recursive `ay <cli>`
|
|
42
|
+
* spawns from storming the host while never permanently deadlocking a workflow.
|
|
43
|
+
* Unset = default 10 min. See {@link getSpawnWaitMs}.
|
|
44
|
+
*/
|
|
45
|
+
spawnWaitMs?: number;
|
|
28
46
|
}
|
|
29
47
|
|
|
30
48
|
function configPath(): string {
|
|
@@ -131,6 +149,52 @@ export function hasSpawnHook(): boolean {
|
|
|
131
149
|
return getSpawnHook() !== null;
|
|
132
150
|
}
|
|
133
151
|
|
|
152
|
+
/**
|
|
153
|
+
* Cap on concurrently-live agents admitted via `/api/spawn`. Env
|
|
154
|
+
* `AGENT_YES_MAX_AGENTS` overrides the config `maxAgents`. A non-positive,
|
|
155
|
+
* missing, or unparseable value means **unlimited** (returns undefined), which
|
|
156
|
+
* preserves the historical no-cap behavior. Exists to stop an unbounded fan-out
|
|
157
|
+
* of agents from exhausting host RAM and tripping the OOM-killer.
|
|
158
|
+
*/
|
|
159
|
+
export function getMaxAgents(): number | undefined {
|
|
160
|
+
const raw = process.env.AGENT_YES_MAX_AGENTS?.trim();
|
|
161
|
+
const n = raw !== undefined && raw !== "" ? Number(raw) : readConfig().maxAgents;
|
|
162
|
+
// Floor BEFORE the >0 check: a fractional 0<n<1 would otherwise floor to 0 and
|
|
163
|
+
// turn "invalid/unlimited" into "reject every spawn" (live >= 0 always true).
|
|
164
|
+
const v = typeof n === "number" && Number.isFinite(n) ? Math.floor(n) : NaN;
|
|
165
|
+
return v > 0 ? v : undefined;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Minimum system MemAvailable (MB) required to admit a new spawn. Env
|
|
170
|
+
* `AGENT_YES_MIN_FREE_MB` overrides config `minFreeMb`. Non-positive/unset =
|
|
171
|
+
* no floor (undefined). Complements {@link getMaxAgents}: a count cap alone
|
|
172
|
+
* can't stop OOM when individual agents are large, so we also refuse to spawn
|
|
173
|
+
* when free memory is already low.
|
|
174
|
+
*/
|
|
175
|
+
export function getMinFreeMb(): number | undefined {
|
|
176
|
+
const raw = process.env.AGENT_YES_MIN_FREE_MB?.trim();
|
|
177
|
+
const n = raw !== undefined && raw !== "" ? Number(raw) : readConfig().minFreeMb;
|
|
178
|
+
// Floor before the >0 check (see getMaxAgents) so a fractional value can't
|
|
179
|
+
// collapse to a meaningless 0 floor.
|
|
180
|
+
const v = typeof n === "number" && Number.isFinite(n) ? Math.floor(n) : NaN;
|
|
181
|
+
return v > 0 ? v : undefined;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Max time (ms) a CLI spawn blocks waiting for capacity before failing open.
|
|
186
|
+
* Env `AGENT_YES_SPAWN_WAIT_MS` overrides config `spawnWaitMs`. A non-negative
|
|
187
|
+
* finite value is used as-is (0 = don't wait, check once then proceed); anything
|
|
188
|
+
* missing/garbage falls back to the 10-minute default. Bounded fail-open is
|
|
189
|
+
* deliberate: recursive spawns must never deadlock permanently on each other.
|
|
190
|
+
*/
|
|
191
|
+
export function getSpawnWaitMs(): number {
|
|
192
|
+
const DEFAULT = 600_000;
|
|
193
|
+
const raw = process.env.AGENT_YES_SPAWN_WAIT_MS?.trim();
|
|
194
|
+
const n = raw !== undefined && raw !== "" ? Number(raw) : readConfig().spawnWaitMs;
|
|
195
|
+
return typeof n === "number" && Number.isFinite(n) && n >= 0 ? Math.floor(n) : DEFAULT;
|
|
196
|
+
}
|
|
197
|
+
|
|
134
198
|
/** Persist the workspace root, tilde-expanded and resolved to an absolute path. */
|
|
135
199
|
export function setWorkspaceRoot(dir: string): string {
|
|
136
200
|
const abs = path.resolve(expandTilde(dir));
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
import "./ts-jDEwTsVt.js";
|
|
2
|
-
import "./logger-CDIsZ-Pp.js";
|
|
3
|
-
import "./versionChecker-C0UJyFcN.js";
|
|
4
|
-
import "./pidStore-BfoBWUjc.js";
|
|
5
|
-
import "./globalPidIndex-DlmmJlO8.js";
|
|
6
|
-
import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-H7IIw10E.js";
|
|
7
|
-
|
|
8
|
-
export { SUPPORTED_CLIS };
|