@intentic/constants 1.311.0 → 1.313.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/README.md +13 -3
- package/package.json +28 -5
- package/src/allow.d.mts +8 -0
- package/src/allow.mjs +42 -0
- package/src/assertion-measure.mjs +2 -2
- package/src/heavy/heavy-exec.cjs +44 -0
- package/src/heavy/heavy-hook.cjs +61 -0
- package/src/heavy/heavy-rules.cjs +195 -0
- package/src/heavy/heavy-rules.d.cts +71 -0
- package/src/heavy/heavy-turn.cjs +113 -0
- package/src/memory-room.d.mts +68 -0
- package/src/memory-room.mjs +324 -0
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ flowchart LR
|
|
|
8
8
|
c -->|"index: browser-safe values"| web["Web app · public site"]
|
|
9
9
|
c -->|"index"| daemon["Daemon · platform api · CLIs"]
|
|
10
10
|
c -->|"./node: repoRoot"| scripts["Scripts, configs,<br/>dev servers"]
|
|
11
|
-
c -->|"plain .mjs tables"| gates["_tools/checks ·
|
|
11
|
+
c -->|"plain .mjs tables"| gates["_tools/checks · verify scripts<br/>daemon's own checks"]
|
|
12
12
|
```
|
|
13
13
|
|
|
14
14
|
- The index has no Node imports, so the web app and the public site bundle the same values the daemon reads.
|
|
@@ -17,8 +17,18 @@ flowchart LR
|
|
|
17
17
|
- `./node` holds `repoRoot` and `packageRoot`, which find the monorepo by walking up to `pnpm-workspace.yaml`
|
|
18
18
|
instead of counting `../..`. The `paths` check refuses counted roots.
|
|
19
19
|
- The `.mjs` modules (`control-bytes`, `contract-shrink`, `assertion-measure`, `mirror-roots`, `test-suites`,
|
|
20
|
-
`vocabulary`, `ci-infra-steps`) are plain JavaScript with `.d.mts` types, so
|
|
21
|
-
them by path before any install or build, and the daemon applies the same
|
|
20
|
+
`vocabulary`, `ci-infra-steps`, `memory-room`, `allow`, `heavy-rules`) are plain JavaScript with `.d.mts` types, so
|
|
21
|
+
the checks and the verify scripts import them by path before any install or build, and the daemon applies the same
|
|
22
|
+
rule from the same file.
|
|
23
|
+
- `heavy-rules.cjs` is the one heavy-command table (which programs queue, their pools, max-holds and deadlines, and
|
|
24
|
+
the owner's overrides merged on top); `heavy-hook.cjs` (a `node --require` preload) and `heavy-exec.cjs` (behind the
|
|
25
|
+
sandbox's wrappers for native programs) judge a program by what it is as it starts. CommonJS, so the hook loads
|
|
26
|
+
on any Node an agent's project pins.
|
|
27
|
+
- `memory-room` is the one formula for whether the sandbox has room (limit, used, free, stall, and what each workload
|
|
28
|
+
class costs). The daemon's resource budget judges by it, and it is also the `memory-room` command `queue-run` asks:
|
|
29
|
+
it asks the daemon's room socket, and applies the formula itself where no daemon answers.
|
|
30
|
+
- `./allow` reads the two exception forms every check and guard suite honours: the site pragma
|
|
31
|
+
`// allow(<check>): <reason>` and the commit trailer `Allow: <check> — <reason>`.
|
|
22
32
|
- `WORKSPACE_ROOT` and `HISTORY_ROOT` are defaults only: a running daemon reads its real roots from config.
|
|
23
33
|
|
|
24
34
|
## Key files
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentic/constants",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.313.0",
|
|
4
4
|
"description": "Shared constants for the intentic packages, ports, paths, and image references the daemon, CLIs and desktop app all agree on",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -23,7 +23,16 @@
|
|
|
23
23
|
"src/mirror-roots.mjs",
|
|
24
24
|
"src/mirror-roots.d.mts",
|
|
25
25
|
"src/ci-infra-steps.mjs",
|
|
26
|
-
"src/ci-infra-steps.d.mts"
|
|
26
|
+
"src/ci-infra-steps.d.mts",
|
|
27
|
+
"src/memory-room.mjs",
|
|
28
|
+
"src/memory-room.d.mts",
|
|
29
|
+
"src/allow.mjs",
|
|
30
|
+
"src/allow.d.mts",
|
|
31
|
+
"src/heavy/heavy-rules.cjs",
|
|
32
|
+
"src/heavy/heavy-rules.d.cts",
|
|
33
|
+
"src/heavy/heavy-turn.cjs",
|
|
34
|
+
"src/heavy/heavy-hook.cjs",
|
|
35
|
+
"src/heavy/heavy-exec.cjs"
|
|
27
36
|
],
|
|
28
37
|
"exports": {
|
|
29
38
|
".": {
|
|
@@ -64,13 +73,27 @@
|
|
|
64
73
|
"./test-suites": {
|
|
65
74
|
"types": "./src/test-suites.d.mts",
|
|
66
75
|
"default": "./src/test-suites.mjs"
|
|
67
|
-
}
|
|
76
|
+
},
|
|
77
|
+
"./memory-room": {
|
|
78
|
+
"types": "./src/memory-room.d.mts",
|
|
79
|
+
"default": "./src/memory-room.mjs"
|
|
80
|
+
},
|
|
81
|
+
"./allow": {
|
|
82
|
+
"types": "./src/allow.d.mts",
|
|
83
|
+
"default": "./src/allow.mjs"
|
|
84
|
+
},
|
|
85
|
+
"./heavy-rules": {
|
|
86
|
+
"types": "./src/heavy/heavy-rules.d.cts",
|
|
87
|
+
"default": "./src/heavy/heavy-rules.cjs"
|
|
88
|
+
},
|
|
89
|
+
"./heavy-hook": "./src/heavy/heavy-hook.cjs",
|
|
90
|
+
"./heavy-exec": "./src/heavy/heavy-exec.cjs"
|
|
68
91
|
},
|
|
69
92
|
"devDependencies": {
|
|
70
93
|
"@intentic/testing": "0.0.0",
|
|
71
94
|
"@intentic/tsconfig": "0.0.0",
|
|
72
|
-
"@types/bun": "1.4.
|
|
73
|
-
"@types/node": "24.13.
|
|
95
|
+
"@types/bun": "1.4.2",
|
|
96
|
+
"@types/node": "24.13.6",
|
|
74
97
|
"@typescript/native-preview": "7.0.0-dev.20260707.2"
|
|
75
98
|
},
|
|
76
99
|
"scripts": {
|
package/src/allow.d.mts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Types for allow.mjs: the site pragma and commit trailer that excuse a check's finding.
|
|
2
|
+
export function pragmaReason(text: string, check: string): string | undefined;
|
|
3
|
+
export function allowedAt(lines: readonly string[], line: number, check: string): boolean;
|
|
4
|
+
export interface AllowTrailer {
|
|
5
|
+
readonly check: string;
|
|
6
|
+
readonly reason: string;
|
|
7
|
+
}
|
|
8
|
+
export function allowTrailers(text: string): AllowTrailer[];
|
package/src/allow.mjs
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// THE TWO WAYS TO SAY A CHECK'S FINDING IS RIGHT HERE, read the same way by every check and suite that honours them.
|
|
2
|
+
//
|
|
3
|
+
// - A SITE PRAGMA, `// allow(<check>): <reason>`, for one place in the code: on the flagged line, or anywhere in the
|
|
4
|
+
// comment block directly above it. It lives on the declaration it excuses, so a rename carries it and a deletion
|
|
5
|
+
// takes it away, which a list of names kept somewhere else cannot do.
|
|
6
|
+
// - A COMMIT TRAILER, `Allow: <check> — <reason>`, for a change: what the range adds to that check is accepted, with the
|
|
7
|
+
// reason in the history. `—`, `–`, `-` or `:` may separate the two.
|
|
8
|
+
//
|
|
9
|
+
// A reason is required in both: an exception nobody can explain is a finding nobody fixed.
|
|
10
|
+
|
|
11
|
+
const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
|
|
12
|
+
|
|
13
|
+
// Comment openers the pragma may follow: a line comment, a block comment or one of its continuation lines.
|
|
14
|
+
const COMMENT_LINE = /^\s*(?:\/\/|\/\*|\*)/;
|
|
15
|
+
|
|
16
|
+
/** The reason a pragma for `check` in `text` gives, or undefined when `text` holds none with a reason. */
|
|
17
|
+
export const pragmaReason = (text, check) => {
|
|
18
|
+
const found = new RegExp(String.raw`(?:\/\/|\/\*|\*)\s*allow\(${escape(check)}\):\s*(\S[^\n]*?)\s*(?:\*\/)?\s*$`, "m").exec(text);
|
|
19
|
+
return found?.[1];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Whether the site on 1-based `line` of `lines` carries a pragma for `check`: on the line itself, or in the comment block
|
|
24
|
+
* that ends on the line above it.
|
|
25
|
+
*/
|
|
26
|
+
export const allowedAt = (lines, line, check) => {
|
|
27
|
+
const says = (text) => pragmaReason(text, check) !== undefined;
|
|
28
|
+
if (says(lines[line - 1] ?? "")) {
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
for (let at = line - 2; at >= 0 && COMMENT_LINE.test(lines[at]); at--) {
|
|
32
|
+
if (says(lines[at])) {
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const TRAILER = /^Allow:[ \t]*([\w./-]+?)[ \t]*(?:—|–|:|-{1,2})[ \t]*(\S.*?)[ \t]*$/gm;
|
|
40
|
+
|
|
41
|
+
/** Every `Allow: <check> — <reason>` line in a commit message or a trailer listing, in order. */
|
|
42
|
+
export const allowTrailers = (text) => [...text.matchAll(TRAILER)].map(([, check, reason]) => ({ check, reason }));
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Three numbers per test file (exact matchers, loose matchers, asserted-text characters) that flag a weaker second
|
|
2
|
-
// version;
|
|
3
|
-
// since the push
|
|
2
|
+
// version; read through assertion-ratchet.mjs by the push check and the check after each land, so
|
|
3
|
+
// both agree. Regex over source, not an AST, since the push check runs before install.
|
|
4
4
|
|
|
5
5
|
// Asserted text that shrinks past this fraction of what it was, with no test removed, is a narrowing.
|
|
6
6
|
export const NARROWING = 0.75;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// The other half of heavy-hook.cjs, for programs that are not node scripts (pnpm's own binary, bun, cargo): a thin
|
|
2
|
+
// wrapper of that name, first on the agent's PATH (_sandbox/sandbox/bin/heavy-shims), runs
|
|
3
|
+
// `node heavy-exec.cjs <wrapper dir> <program> <args…>`. The program is the one its wrapper stands for, its arguments
|
|
4
|
+
// are the ones it was given; heavy-turn.cjs judges them, and then this process becomes the real program, found on the
|
|
5
|
+
// PATH past the wrappers.
|
|
6
|
+
"use strict";
|
|
7
|
+
|
|
8
|
+
const { accessSync, constants } = require("node:fs");
|
|
9
|
+
const { delimiter, join, resolve } = require("node:path");
|
|
10
|
+
const { takeTurn } = require("./heavy-turn.cjs");
|
|
11
|
+
|
|
12
|
+
const [shims = "", program = "", ...args] = process.argv.slice(2);
|
|
13
|
+
|
|
14
|
+
// The real program: the first executable of that name on the PATH that is not a wrapper. The PATH it runs with keeps the
|
|
15
|
+
// wrappers, so a program it starts in turn (a package script's `bun test`) is judged the same way.
|
|
16
|
+
const pathEntries = (process.env.PATH ?? "").split(delimiter).filter((entry) => entry !== "" && resolve(entry) !== resolve(shims));
|
|
17
|
+
const real = pathEntries
|
|
18
|
+
.map((entry) => join(entry, program))
|
|
19
|
+
.find((candidate) => {
|
|
20
|
+
try {
|
|
21
|
+
accessSync(candidate, constants.X_OK);
|
|
22
|
+
return true;
|
|
23
|
+
} catch {
|
|
24
|
+
// allow(silent-catch): absent or not executable is exactly "not this one"
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
if (real === undefined) {
|
|
30
|
+
process.stderr.write(`${program}: command not found\n`);
|
|
31
|
+
process.exit(127);
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
takeTurn({ program, args, command: [real, ...args] });
|
|
35
|
+
} catch {
|
|
36
|
+
// allow(silent-catch): a table that does not parse runs the program as if unjudged
|
|
37
|
+
}
|
|
38
|
+
// `real` passed the executable check above, so this execve does not fail the way that aborts a process.
|
|
39
|
+
if (typeof process.execve === "function") {
|
|
40
|
+
process.execve(real, [program, ...args], process.env);
|
|
41
|
+
}
|
|
42
|
+
const { spawnSync } = require("node:child_process");
|
|
43
|
+
const run = spawnSync(real, args, { stdio: "inherit", env: process.env, argv0: program });
|
|
44
|
+
process.exit(run.status ?? 1);
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Loaded into every node program an agent's command starts (`NODE_OPTIONS=--require …/heavy-hook.cjs`, set by the
|
|
2
|
+
// daemon, _sandbox/sandbox/src/agent/tools/agent-terminals.ts), so the heavy-command queue follows the program that
|
|
3
|
+
// actually runs — `vitest` under `pnpm test`, `tsc` under a `make` target, `turbo` under an npm script — and not the words
|
|
4
|
+
// an agent typed. The program is the script node was asked to run, named as its package publishes it (`vitest`, `tsc`,
|
|
5
|
+
// `npx`); heavy-turn.cjs decides the rest. Nothing here may be why a program did not run.
|
|
6
|
+
"use strict";
|
|
7
|
+
|
|
8
|
+
const { readFileSync, realpathSync } = require("node:fs");
|
|
9
|
+
const { basename, dirname, join, resolve } = require("node:path");
|
|
10
|
+
|
|
11
|
+
// The command a script is published as: the `bin` entry of its nearest package.json that names this very file, else its
|
|
12
|
+
// own file name without its extension.
|
|
13
|
+
const programOf = (script) => {
|
|
14
|
+
let real = script;
|
|
15
|
+
try {
|
|
16
|
+
real = realpathSync(script);
|
|
17
|
+
} catch {
|
|
18
|
+
// allow(silent-catch): a script path that does not resolve is named by what it says
|
|
19
|
+
}
|
|
20
|
+
let dir = dirname(real);
|
|
21
|
+
for (let depth = 0; depth < 6; depth += 1) {
|
|
22
|
+
let manifest;
|
|
23
|
+
try {
|
|
24
|
+
manifest = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
25
|
+
} catch {
|
|
26
|
+
// allow(silent-catch): a directory without a package.json (or with a broken one) names nothing; look further up
|
|
27
|
+
manifest = undefined;
|
|
28
|
+
}
|
|
29
|
+
if (manifest !== undefined) {
|
|
30
|
+
const name = typeof manifest.name === "string" ? manifest.name.split("/").pop() : undefined;
|
|
31
|
+
const bins = typeof manifest.bin === "string" ? { [name ?? ""]: manifest.bin } : (manifest.bin ?? {});
|
|
32
|
+
const named = Object.entries(bins).find(([, path]) => typeof path === "string" && resolve(dir, path) === real)?.[0];
|
|
33
|
+
if (named !== undefined && named !== "") {
|
|
34
|
+
return named;
|
|
35
|
+
}
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
const up = dirname(dir);
|
|
39
|
+
if (up === dir) {
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
dir = up;
|
|
43
|
+
}
|
|
44
|
+
return basename(real).replace(/\.[cm]?[jt]s$/u, "");
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const script = process.argv[1];
|
|
49
|
+
if (process.env.INTENTIC_HEAVY !== undefined && script !== undefined && script !== "") {
|
|
50
|
+
const { takeTurn } = require("./heavy-turn.cjs");
|
|
51
|
+
takeTurn({
|
|
52
|
+
program: programOf(script),
|
|
53
|
+
args: process.argv.slice(2),
|
|
54
|
+
command: [process.execPath, ...process.execArgv, ...process.argv.slice(1)],
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
// allow(silent-catch): a table that does not parse or a program that cannot be named runs as if unjudged
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = { programOf };
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// Which programs are heavy enough to take turns, and how the queue treats them: the one table the daemon
|
|
2
|
+
// (_sandbox/sandbox/src/platform/resources/heavy-commands.ts), the program hook that queues a heavy program as it starts
|
|
3
|
+
// (heavy-hook.cjs, heavy-exec.cjs), queue-run's callers and the repository's own scripts (_tools/scripts/lib/
|
|
4
|
+
// heavy-slot.mjs) all read. CommonJS with no dependencies, so it loads under `node --require` in any project, on any
|
|
5
|
+
// Node an agent's project pins, and ES modules import it by name.
|
|
6
|
+
//
|
|
7
|
+
// A rule's `pattern` is matched against the program that actually runs and its arguments, `<program> <args…>` (`pnpm
|
|
8
|
+
// --filter web test`, `vitest run`, `tsc -b`), never against the shell line an agent typed: quotes, heredocs, globs and
|
|
9
|
+
// assignments are gone by then, and a line that only mentions a program never starts it. First match wins; an `exempt`
|
|
10
|
+
// rule stops the search for what it matches.
|
|
11
|
+
"use strict";
|
|
12
|
+
|
|
13
|
+
// What queue-run exits with when it ran nothing because the wait was up and the rule said `skip`: EX_TEMPFAIL, so a
|
|
14
|
+
// caller records "not measured", never "failed".
|
|
15
|
+
const QUEUE_SKIPPED_EXIT_CODE = 75;
|
|
16
|
+
|
|
17
|
+
// Characters of an invocation a rule's regex runs against; bounds backtracking, since node has no regex timeout.
|
|
18
|
+
const MATCH_LIMIT = 4096;
|
|
19
|
+
|
|
20
|
+
// The shipped rules and settings. Every rule is something measured pinning a sandbox, not merely slow.
|
|
21
|
+
const SHIPPED_HEAVY_COMMANDS = Object.freeze({
|
|
22
|
+
// How many matching programs may run at once across the sandbox; two fits twice the peak of one bounded run.
|
|
23
|
+
limit: 2,
|
|
24
|
+
defaultPool: "heavy",
|
|
25
|
+
// How long a program waits for a slot before running anyway, so a stuck queue cannot turn into a dead sandbox.
|
|
26
|
+
waitSeconds: 900,
|
|
27
|
+
// How long a matching program also waits for memory (memory-room.mjs), before its slot.
|
|
28
|
+
memoryGateSeconds: 120,
|
|
29
|
+
// How long a program may HOLD a slot before it is killed. Generous on purpose: this ends a hang, it does not cap
|
|
30
|
+
// honest work, and a build still running after half an hour on a sandbox is already the anomaly. 0 switches it off.
|
|
31
|
+
maxHoldSeconds: 1800,
|
|
32
|
+
// For a rule that says nothing: for a person's command the queue must never be the reason it did not run.
|
|
33
|
+
onDeadline: "run",
|
|
34
|
+
rules: Object.freeze([
|
|
35
|
+
// A watch, a server or a language server is meant to outlive its command, so it can neither take turns nor be
|
|
36
|
+
// killed for holding a slot: queueing one holds the slot until somebody stops it. `-w` is NOT here: `pnpm -w
|
|
37
|
+
// test` is a workspace-root fan-out, the heaviest command there is.
|
|
38
|
+
{
|
|
39
|
+
id: "long-lived",
|
|
40
|
+
pattern: "--watch|--lsp\\b|\\bnodemon\\b|\\b(pnpm|npm|yarn|bun)\\s+(run\\s+)?(dev|serve|start)\\b",
|
|
41
|
+
exempt: true,
|
|
42
|
+
},
|
|
43
|
+
// A repo-wide verification, one at a time in the same pool: two of them are the most expensive thing that can
|
|
44
|
+
// happen to a sandbox at once (measured: 24.9 GiB against a 16 GiB cap), and the second is usually the same
|
|
45
|
+
// work over the same tree, which turbo's cache makes nearly free once the first is done. Never two at once,
|
|
46
|
+
// not even after the wait: a verification that did not run costs a check the next land repeats.
|
|
47
|
+
{ id: "repo-verify", pattern: "\\b(pnpm|npm|yarn|bun)\\s+(run\\s+)?verify(:\\S+)?\\b", limit: 1, onDeadline: "skip" },
|
|
48
|
+
// A program's name joined to `.` or `-` is another word (`check-tsc`), not the program.
|
|
49
|
+
{ id: "vitest", pattern: "(?<![-.])\\b(vitest|jest)\\b(?![-.])" },
|
|
50
|
+
{ id: "typechecker", pattern: "(?<![-.])\\b(tsc|tsgo|vue-tsc)\\b(?![-.])" },
|
|
51
|
+
{ id: "turbo-fanout", pattern: "\\bturbo\\b.*\\brun\\b.*\\b(build|test|typecheck|check)\\b" },
|
|
52
|
+
{ id: "package-script", pattern: "\\b(pnpm|npm|yarn|bun)\\b.*\\b(test|typecheck|verify|check|build)\\b" },
|
|
53
|
+
{ id: "cargo", pattern: "^cargo\\s+(\\+\\S+\\s+)?(build|test|check|clippy|bench|doc)\\b" },
|
|
54
|
+
]),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Every rule an earlier release shipped, as it shipped it (matched against the agent's shell line then). A stored rule
|
|
58
|
+
// equal to one of these was seeded, not chosen, so converting a stored file drops it and the current rule takes over.
|
|
59
|
+
const EARLIER_SHIPPED_RULES = Object.freeze([
|
|
60
|
+
{
|
|
61
|
+
id: "read-only",
|
|
62
|
+
pattern: "^\\s*(grep|rg|iq|ag|cat|bat|head|tail|less|ls|find|fd|wc|which|echo|git\\s+(log|status|diff|show|blame))\\b",
|
|
63
|
+
exempt: true,
|
|
64
|
+
},
|
|
65
|
+
{ id: "long-lived", pattern: "--watch|\\bnodemon\\b|\\b(pnpm|npm|yarn|bun)\\s+(run\\s+)?(dev|serve|start)\\b", exempt: true },
|
|
66
|
+
{ id: "repo-verify", pattern: "\\b(pnpm|npm|yarn|bun)\\s+(run\\s+)?verify(:\\S+)?\\b", limit: 1 },
|
|
67
|
+
{ id: "repo-verify", pattern: "\\b(pnpm|npm|yarn|bun)\\s+(run\\s+)?verify(:\\S+)?\\b", limit: 1, onDeadline: "skip" },
|
|
68
|
+
{ id: "vitest", pattern: "\\bvitest\\b" },
|
|
69
|
+
{ id: "vitest", pattern: "(?<![-.])\\bvitest\\b(?![-.])" },
|
|
70
|
+
{ id: "typechecker", pattern: "\\b(tsc|tsgo|vue-tsc)\\b" },
|
|
71
|
+
{ id: "typechecker", pattern: "(?<![-.])\\b(tsc|tsgo|vue-tsc)\\b(?![-.])" },
|
|
72
|
+
{ id: "turbo-fanout", pattern: "\\bturbo\\b[^&|;]*\\brun\\b[^&|;]*\\b(build|test|typecheck|check)\\b" },
|
|
73
|
+
{ id: "package-script", pattern: "\\b(pnpm|npm|yarn|bun)\\b[^&|;]*\\b(test|typecheck|verify|check|build)\\b" },
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
const SETTING_KEYS = Object.freeze(["limit", "defaultPool", "waitSeconds", "memoryGateSeconds", "maxHoldSeconds", "onDeadline"]);
|
|
77
|
+
const RULE_KEYS = Object.freeze(["id", "pattern", "pool", "limit", "maxHoldSeconds", "exempt", "onDeadline"]);
|
|
78
|
+
|
|
79
|
+
// Two rules are the same when every field either names is equal; key order and absent-versus-undefined do not count.
|
|
80
|
+
const sameRule = (left, right) => RULE_KEYS.every((key) => left[key] === right[key]);
|
|
81
|
+
|
|
82
|
+
// The rules and settings in force: the shipped table with the owner's overrides on top. A setting the overrides name
|
|
83
|
+
// replaces the shipped one; an edit naming a shipped rule's id changes the fields it names, or removes that rule
|
|
84
|
+
// (`disabled`); an edit naming any other id, with a pattern, is the owner's own rule, and the owner's own rules are
|
|
85
|
+
// matched before the shipped ones. `queue: false` stops queueing without touching how heavy programs are ranked.
|
|
86
|
+
const mergeHeavyRules = (overrides = {}) => {
|
|
87
|
+
const settings = Object.fromEntries(SETTING_KEYS.map((key) => [key, overrides[key] ?? SHIPPED_HEAVY_COMMANDS[key]]));
|
|
88
|
+
const edits = Array.isArray(overrides.ruleEdits) ? overrides.ruleEdits : [];
|
|
89
|
+
const shippedIds = new Set(SHIPPED_HEAVY_COMMANDS.rules.map((rule) => rule.id));
|
|
90
|
+
const editOf = new Map(edits.filter((edit) => shippedIds.has(edit.id)).map((edit) => [edit.id, edit]));
|
|
91
|
+
const own = edits.filter((edit) => !shippedIds.has(edit.id) && edit.disabled !== true && typeof edit.pattern === "string");
|
|
92
|
+
const shipped = SHIPPED_HEAVY_COMMANDS.rules.flatMap((rule) => {
|
|
93
|
+
const edit = editOf.get(rule.id);
|
|
94
|
+
if (edit === undefined) {
|
|
95
|
+
return [rule];
|
|
96
|
+
}
|
|
97
|
+
if (edit.disabled === true) {
|
|
98
|
+
return [];
|
|
99
|
+
}
|
|
100
|
+
const { disabled: _disabled, ...fields } = edit;
|
|
101
|
+
return [{ ...rule, ...fields }];
|
|
102
|
+
});
|
|
103
|
+
return { ...settings, queue: overrides.queue !== false, rules: [...own.map(({ disabled: _disabled, ...rule }) => rule), ...shipped] };
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// A stored file of the earlier shape (every setting and the whole rule list) as overrides: only what differs from what
|
|
107
|
+
// was shipped. A rule equal to a rule some release shipped goes, so the current one takes its place; an empty list was
|
|
108
|
+
// how the queue was switched off.
|
|
109
|
+
const overridesOf = (full) => {
|
|
110
|
+
const overrides = {};
|
|
111
|
+
for (const key of SETTING_KEYS) {
|
|
112
|
+
if (full[key] !== undefined && full[key] !== SHIPPED_HEAVY_COMMANDS[key]) {
|
|
113
|
+
overrides[key] = full[key];
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const rules = Array.isArray(full.rules) ? full.rules : [];
|
|
117
|
+
if (Array.isArray(full.rules) && rules.length === 0) {
|
|
118
|
+
overrides.queue = false;
|
|
119
|
+
}
|
|
120
|
+
const shipped = [...SHIPPED_HEAVY_COMMANDS.rules, ...EARLIER_SHIPPED_RULES];
|
|
121
|
+
const edits = rules.filter((rule) => !shipped.some((known) => sameRule(rule, known)));
|
|
122
|
+
if (edits.length > 0) {
|
|
123
|
+
overrides.ruleEdits = edits.map((rule) => Object.fromEntries(RULE_KEYS.filter((key) => rule[key] !== undefined).map((key) => [key, rule[key]])));
|
|
124
|
+
}
|
|
125
|
+
return overrides;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const compileRule = (rule, report) => {
|
|
129
|
+
try {
|
|
130
|
+
return { rule, regex: new RegExp(rule.pattern, "i") };
|
|
131
|
+
} catch (error) {
|
|
132
|
+
report?.(`${rule.id}: ${error instanceof Error ? error.message : "bad pattern"}`);
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// The rule a program's invocation falls under, with what the queue does with it; undefined for one that is not heavy.
|
|
138
|
+
const matchInvocation = (invocation, config, report) => {
|
|
139
|
+
const text = invocation.slice(0, MATCH_LIMIT);
|
|
140
|
+
for (const rule of config.rules) {
|
|
141
|
+
const compiled = compileRule(rule, report);
|
|
142
|
+
if (compiled === undefined || !compiled.regex.test(text)) {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (rule.exempt === true) {
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
id: rule.id,
|
|
150
|
+
pool: rule.pool ?? config.defaultPool,
|
|
151
|
+
limit: rule.limit ?? config.limit,
|
|
152
|
+
maxHold: rule.maxHoldSeconds ?? config.maxHoldSeconds,
|
|
153
|
+
onDeadline: rule.onDeadline ?? config.onDeadline,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
return undefined;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// queue-run's flags for a match (bin/queue-run), before its `--`.
|
|
160
|
+
const queueArgs = (match, config) => [
|
|
161
|
+
"--pool",
|
|
162
|
+
match.pool,
|
|
163
|
+
"--limit",
|
|
164
|
+
String(match.limit),
|
|
165
|
+
"--wait",
|
|
166
|
+
String(config.waitSeconds),
|
|
167
|
+
"--memory-gate",
|
|
168
|
+
String(config.memoryGateSeconds),
|
|
169
|
+
"--max-hold",
|
|
170
|
+
String(match.maxHold),
|
|
171
|
+
"--on-deadline",
|
|
172
|
+
match.onDeadline,
|
|
173
|
+
"--label",
|
|
174
|
+
match.id,
|
|
175
|
+
];
|
|
176
|
+
|
|
177
|
+
// When a slot held this long is worth a line in the log: half of what its own rule lets it hold, never for a rule that
|
|
178
|
+
// lets it hold forever.
|
|
179
|
+
const holdWarnSeconds = (maxHold) => (maxHold > 0 ? maxHold / 2 : undefined);
|
|
180
|
+
|
|
181
|
+
// The rule a pool's holder ran under, by the label queue-run names it with, as the merged table has it.
|
|
182
|
+
const ruleById = (config, id) => config.rules.find((rule) => rule.id === id);
|
|
183
|
+
|
|
184
|
+
module.exports = {
|
|
185
|
+
QUEUE_SKIPPED_EXIT_CODE,
|
|
186
|
+
MATCH_LIMIT,
|
|
187
|
+
SHIPPED_HEAVY_COMMANDS,
|
|
188
|
+
EARLIER_SHIPPED_RULES,
|
|
189
|
+
mergeHeavyRules,
|
|
190
|
+
overridesOf,
|
|
191
|
+
matchInvocation,
|
|
192
|
+
queueArgs,
|
|
193
|
+
holdWarnSeconds,
|
|
194
|
+
ruleById,
|
|
195
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Types for heavy-rules.cjs, the one heavy-command table the daemon, the program hook and the scripts read.
|
|
2
|
+
export type OnDeadline = "run" | "skip";
|
|
3
|
+
|
|
4
|
+
export interface HeavyCommandRule {
|
|
5
|
+
readonly id: string;
|
|
6
|
+
// JS regex source, case-insensitive, matched against `<program> <args…>` of the program that runs.
|
|
7
|
+
readonly pattern: string;
|
|
8
|
+
readonly pool?: string | undefined;
|
|
9
|
+
readonly limit?: number | undefined;
|
|
10
|
+
readonly maxHoldSeconds?: number | undefined;
|
|
11
|
+
readonly exempt?: boolean | undefined;
|
|
12
|
+
readonly onDeadline?: OnDeadline | undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// An owner's change to the table: a shipped rule's id changes the fields it names, or removes it (`disabled`); any other
|
|
16
|
+
// id, with a pattern, is the owner's own rule.
|
|
17
|
+
export interface HeavyCommandRuleEdit {
|
|
18
|
+
readonly id: string;
|
|
19
|
+
readonly pattern?: string | undefined;
|
|
20
|
+
readonly pool?: string | undefined;
|
|
21
|
+
readonly limit?: number | undefined;
|
|
22
|
+
readonly maxHoldSeconds?: number | undefined;
|
|
23
|
+
readonly exempt?: boolean | undefined;
|
|
24
|
+
readonly onDeadline?: OnDeadline | undefined;
|
|
25
|
+
readonly disabled?: boolean | undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface HeavyCommandSettings {
|
|
29
|
+
readonly limit: number;
|
|
30
|
+
readonly defaultPool: string;
|
|
31
|
+
readonly waitSeconds: number;
|
|
32
|
+
readonly memoryGateSeconds: number;
|
|
33
|
+
readonly maxHoldSeconds: number;
|
|
34
|
+
readonly onDeadline: OnDeadline;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface HeavyCommandOverrides {
|
|
38
|
+
readonly limit?: number | undefined;
|
|
39
|
+
readonly defaultPool?: string | undefined;
|
|
40
|
+
readonly waitSeconds?: number | undefined;
|
|
41
|
+
readonly memoryGateSeconds?: number | undefined;
|
|
42
|
+
readonly maxHoldSeconds?: number | undefined;
|
|
43
|
+
readonly onDeadline?: OnDeadline | undefined;
|
|
44
|
+
readonly queue?: boolean | undefined;
|
|
45
|
+
readonly ruleEdits?: readonly HeavyCommandRuleEdit[] | undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface HeavyCommands extends HeavyCommandSettings {
|
|
49
|
+
// False stops queueing; heavy programs keep their class either way.
|
|
50
|
+
readonly queue: boolean;
|
|
51
|
+
readonly rules: readonly HeavyCommandRule[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface HeavyMatch {
|
|
55
|
+
readonly id: string;
|
|
56
|
+
readonly pool: string;
|
|
57
|
+
readonly limit: number;
|
|
58
|
+
readonly maxHold: number;
|
|
59
|
+
readonly onDeadline: OnDeadline;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const QUEUE_SKIPPED_EXIT_CODE: 75;
|
|
63
|
+
export const MATCH_LIMIT: number;
|
|
64
|
+
export const SHIPPED_HEAVY_COMMANDS: HeavyCommandSettings & { readonly rules: readonly HeavyCommandRule[] };
|
|
65
|
+
export const EARLIER_SHIPPED_RULES: readonly HeavyCommandRule[];
|
|
66
|
+
export function mergeHeavyRules(overrides?: HeavyCommandOverrides): HeavyCommands;
|
|
67
|
+
export function overridesOf(full: Partial<HeavyCommandSettings> & { readonly rules?: readonly HeavyCommandRule[] | undefined }): HeavyCommandOverrides;
|
|
68
|
+
export function matchInvocation(invocation: string, config: HeavyCommands, report?: (detail: string) => void): HeavyMatch | undefined;
|
|
69
|
+
export function queueArgs(match: HeavyMatch, config: HeavyCommandSettings): string[];
|
|
70
|
+
export function holdWarnSeconds(maxHold: number): number | undefined;
|
|
71
|
+
export function ruleById(config: HeavyCommands, id: string): HeavyCommandRule | undefined;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// What a heavy program does as it starts, shared by the two ways one is caught: heavy-hook.cjs, loaded into every node
|
|
2
|
+
// program an agent's command starts, and heavy-exec.cjs, which the thin wrappers for native programs (pnpm, bun, cargo)
|
|
3
|
+
// run. Both know the program for a fact, and its arguments as it received them; heavy-rules.cjs says whether it is heavy.
|
|
4
|
+
//
|
|
5
|
+
// A heavy program is put in the toolchain class (its rank for the OOM killer, its CPU and IO priority) right away,
|
|
6
|
+
// whether or not anything queues it, then replaces itself with the same run behind queue-run (a slot, and memory first),
|
|
7
|
+
// or behind offload-run when the owner sends that kind of work to a runner. Everything it starts inherits both, and
|
|
8
|
+
// nothing below it is judged again: INTENTIC_HEAVY_HELD and queue-run's INTENTIC_QUEUE_SLOT say it is already covered.
|
|
9
|
+
//
|
|
10
|
+
// The daemon hands the table in INTENTIC_HEAVY, as JSON: { rules (heavy-rules.cjs mergeHeavyRules), queue, queueRun,
|
|
11
|
+
// offloadRun, offload: { <rule id>: <runner> }, klass: { oomScoreAdj, nice, lowIo } }. Every failure here is a program
|
|
12
|
+
// that runs as if nothing had looked at it.
|
|
13
|
+
"use strict";
|
|
14
|
+
|
|
15
|
+
const { spawnSync } = require("node:child_process");
|
|
16
|
+
const { accessSync, constants, readFileSync, writeFileSync } = require("node:fs");
|
|
17
|
+
const { getPriority, setPriority } = require("node:os");
|
|
18
|
+
const { matchInvocation, queueArgs } = require("./heavy-rules.cjs");
|
|
19
|
+
|
|
20
|
+
// The spec the daemon handed down, or undefined when this program is not under it or is already covered.
|
|
21
|
+
const pendingSpec = (env = process.env) => {
|
|
22
|
+
const raw = env.INTENTIC_HEAVY;
|
|
23
|
+
if (raw === undefined || raw === "" || env.INTENTIC_HEAVY_HELD !== undefined || env.INTENTIC_QUEUE_SLOT !== undefined) {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
return JSON.parse(raw);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// The class applied to this very process, raised only: descendants inherit all three at fork.
|
|
30
|
+
const applyClass = (klass) => {
|
|
31
|
+
if (klass === undefined || process.platform !== "linux") {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
const current = Number(readFileSync("/proc/self/oom_score_adj", "utf8").trim());
|
|
36
|
+
if (Number.isFinite(current) && current < klass.oomScoreAdj) {
|
|
37
|
+
writeFileSync("/proc/self/oom_score_adj", String(klass.oomScoreAdj));
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
// allow(silent-catch): procfs hidden by a hardened runtime; the kernel then weighs size alone
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
if (getPriority() < klass.nice) {
|
|
44
|
+
setPriority(klass.nice);
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
// allow(silent-catch): a priority this process may not set leaves it where it was
|
|
48
|
+
}
|
|
49
|
+
if (klass.lowIo) {
|
|
50
|
+
spawnSync("ionice", ["-c", "2", "-n", "7", "-p", String(process.pid)], { stdio: "ignore" });
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// POSIX single-quoting, as offload-run's --here prefix is read by a shell.
|
|
55
|
+
const quote = (word) => (/^[\w@%+=:,./-]+$/u.test(word) ? word : `'${word.replaceAll("'", `'\\''`)}'`);
|
|
56
|
+
|
|
57
|
+
// Programs every runner has on its PATH; anything else is a project's own, run there from its node_modules.
|
|
58
|
+
const ON_EVERY_PATH = new Set(["node", "npm", "npx", "pnpm", "yarn", "bun", "cargo"]);
|
|
59
|
+
const remoteArgv = (program, args) => (ON_EVERY_PATH.has(program) ? [program, ...args] : ["npx", "--no-install", program, ...args]);
|
|
60
|
+
|
|
61
|
+
// Whether `path` can be exec'd; checked first because a failed execve aborts the process rather than throwing.
|
|
62
|
+
const runnable = (path) => {
|
|
63
|
+
try {
|
|
64
|
+
accessSync(path, constants.X_OK);
|
|
65
|
+
return true;
|
|
66
|
+
} catch {
|
|
67
|
+
// allow(silent-catch): absent or not executable is exactly "cannot run it"
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// This process becomes `argv`: the same pid, so the class and every signal aimed at it still land. A Node too old to
|
|
73
|
+
// replace itself runs it as a child and leaves with its code, the way heavy-slot.mjs does. Returns only when `argv[0]`
|
|
74
|
+
// cannot be run at all, for the caller to run the program in place.
|
|
75
|
+
const become = (argv, env) => {
|
|
76
|
+
if (!runnable(argv[0])) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (typeof process.execve === "function") {
|
|
80
|
+
process.execve(argv[0], argv, env);
|
|
81
|
+
}
|
|
82
|
+
const run = spawnSync(argv[0], argv.slice(1), { stdio: "inherit", env });
|
|
83
|
+
process.exit(run.status ?? (run.signal === null ? 1 : 128));
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Judges one program as it starts. Returns false for one that is not heavy (or not under the daemon's table), true for
|
|
88
|
+
* one that was classed and runs in place; a queued or offloaded one does not return.
|
|
89
|
+
*/
|
|
90
|
+
const takeTurn = ({ program, args, command, env = process.env }) => {
|
|
91
|
+
const spec = pendingSpec(env);
|
|
92
|
+
if (spec === undefined) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
const match = matchInvocation([program, ...args].join(" "), spec.rules);
|
|
96
|
+
if (match === undefined) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
env.INTENTIC_HEAVY_HELD = match.id;
|
|
100
|
+
applyClass(spec.klass);
|
|
101
|
+
const queue = spec.queue === true && typeof spec.queueRun === "string" && runnable(spec.queueRun) ? [spec.queueRun, ...queueArgs(match, spec.rules), "--"] : [];
|
|
102
|
+
const runner = spec.offload?.[match.id];
|
|
103
|
+
if (runner !== undefined && typeof spec.offloadRun === "string") {
|
|
104
|
+
const here = queue.length === 0 ? "" : `${queue.map(quote).join(" ")} `;
|
|
105
|
+
become([spec.offloadRun, "--to", runner, "--label", match.id, "--here", here, "--", ...remoteArgv(program, args)], env);
|
|
106
|
+
}
|
|
107
|
+
if (queue.length > 0) {
|
|
108
|
+
become([...queue, ...command], env);
|
|
109
|
+
}
|
|
110
|
+
return true;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
module.exports = { takeTurn, pendingSpec, remoteArgv };
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Types for memory-room.mjs, the one room formula the daemon's ResourceBudget and the scripts share.
|
|
2
|
+
export type RoomWorkload = "agentRuntime" | "service" | "panel" | "install" | "command" | "toolchain";
|
|
3
|
+
|
|
4
|
+
export const COST_BYTES: Readonly<Record<RoomWorkload, number>>;
|
|
5
|
+
export const PERSON_RESERVE_BYTES: number;
|
|
6
|
+
export const TEST_PROCESS_BYTES: Readonly<{ fanOutWorker: number; standaloneWorker: number; typecheck: number; ceiling: number }>;
|
|
7
|
+
export const STALL_PERCENT: number;
|
|
8
|
+
export const RESERVATION_MS: number;
|
|
9
|
+
export const ROOM_SOCKET: string;
|
|
10
|
+
export const READING_FILES: readonly string[];
|
|
11
|
+
|
|
12
|
+
export interface MemoryReading {
|
|
13
|
+
// memory.high, else memory.max, else the machine's memory; undefined where nothing bounds it.
|
|
14
|
+
readonly limitBytes: number | undefined;
|
|
15
|
+
// Working set plus swap; undefined where nothing measures it.
|
|
16
|
+
readonly usedBytes: number | undefined;
|
|
17
|
+
// The swapped part of `usedBytes`; 0 when swap is off or unaccounted.
|
|
18
|
+
readonly swapBytes: number;
|
|
19
|
+
// The machine's MemAvailable, which free memory never exceeds; absent where the machine does not say.
|
|
20
|
+
readonly availableBytes?: number | undefined;
|
|
21
|
+
// Memory PSI `full avg10`, in percent; 0 when healthy or unreported.
|
|
22
|
+
readonly stallPercent: number;
|
|
23
|
+
// memory.events `oom_kill`, cumulative; undefined without a cgroup.
|
|
24
|
+
readonly oomKills: number | undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ShortMemory {
|
|
28
|
+
readonly limitBytes: number;
|
|
29
|
+
readonly residentBytes: number;
|
|
30
|
+
readonly swapBytes: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type RoomVerdict = "run" | "wait" | "refuse";
|
|
34
|
+
|
|
35
|
+
export interface RoomJudgement {
|
|
36
|
+
readonly verdict: RoomVerdict;
|
|
37
|
+
readonly needBytes: number;
|
|
38
|
+
readonly freeBytes: number | undefined;
|
|
39
|
+
readonly reservedBytes: number;
|
|
40
|
+
// Why the sandbox is short; absent on `run`.
|
|
41
|
+
readonly diagnosis?: string;
|
|
42
|
+
// The reading a byte shortfall was decided on; absent on a stall, whose ceiling is not what is wrong.
|
|
43
|
+
readonly memory?: ShortMemory;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface RoomAnswer extends RoomJudgement {
|
|
47
|
+
readonly waitedMs: number;
|
|
48
|
+
readonly source: "daemon" | "formula";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function readingFrom(read: (path: string) => string | undefined): MemoryReading;
|
|
52
|
+
export function readReadingSync(): MemoryReading;
|
|
53
|
+
export function needBytes(workload: RoomWorkload, attended: boolean): number;
|
|
54
|
+
export function judge(reading: MemoryReading, request: { readonly workload: RoomWorkload; readonly attended: boolean; readonly reservedBytes?: number }): RoomJudgement;
|
|
55
|
+
export function freeBytesOf(reading: MemoryReading): number | undefined;
|
|
56
|
+
export function askRoom(options?: {
|
|
57
|
+
readonly workload?: RoomWorkload;
|
|
58
|
+
readonly waitSeconds?: number;
|
|
59
|
+
readonly label?: string;
|
|
60
|
+
readonly socketPath?: string;
|
|
61
|
+
readonly intervalMs?: number;
|
|
62
|
+
readonly read?: () => MemoryReading;
|
|
63
|
+
}): Promise<RoomAnswer>;
|
|
64
|
+
export function askFree(options?: { readonly socketPath?: string; readonly read?: () => MemoryReading }): Promise<{
|
|
65
|
+
readonly freeBytes: number | undefined;
|
|
66
|
+
readonly source: "daemon" | "formula";
|
|
67
|
+
}>;
|
|
68
|
+
export function askFreeSync(options?: { readonly socketPath?: string }): number | undefined;
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Whether this sandbox has room for more work, by one formula: the daemon's ResourceBudget
|
|
3
|
+
// (_sandbox/sandbox/src/platform/resources/resource-budget.ts) judges every turn, child and heavy command with it and
|
|
4
|
+
// serves the verdict on a local socket, and the scripts that run where no daemon answers (CI, a plain checkout, a
|
|
5
|
+
// daemon restarting) apply the same functions to the same files. Plain JavaScript with no dependencies, so a script
|
|
6
|
+
// imports it by path before any install, and `memory-room` runs it as a command.
|
|
7
|
+
//
|
|
8
|
+
// limit memory.high (where the kernel starts throttling the whole cgroup), else memory.max, else the machine's
|
|
9
|
+
// memory, never more than the machine's
|
|
10
|
+
// used the working set (memory.current less the inactive file cache the kernel reclaims first) plus what was
|
|
11
|
+
// pushed to swap; at a root cgroup, which has no memory.current, the machine's own used memory and swap
|
|
12
|
+
// free limit − used, and never more than the machine itself has available (MemAvailable): a sandbox on a
|
|
13
|
+
// shared machine cannot take memory the machine does not have, whatever its own limit says
|
|
14
|
+
// stall memory PSI `full avg10`: the share of the last ten seconds in which everything waited on memory
|
|
15
|
+
import { spawnSync } from "node:child_process";
|
|
16
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
17
|
+
import { request } from "node:http";
|
|
18
|
+
import { pathToFileURL } from "node:url";
|
|
19
|
+
|
|
20
|
+
const GIB = 1024 ** 3;
|
|
21
|
+
const MIB = 1024 ** 2;
|
|
22
|
+
|
|
23
|
+
// What starting one more of each workload class takes out of the sandbox (keys: WorkloadClass in workload-class.ts).
|
|
24
|
+
// A reservation holds it for RESERVATION_MS, until the reading shows what the work grew into; a runner fits its memory
|
|
25
|
+
// divided by an agent's cost.
|
|
26
|
+
export const COST_BYTES = Object.freeze({
|
|
27
|
+
// A runtime, its MCP servers and the first commands it runs.
|
|
28
|
+
agentRuntime: GIB,
|
|
29
|
+
service: 512 * MIB,
|
|
30
|
+
panel: 512 * MIB,
|
|
31
|
+
install: GIB,
|
|
32
|
+
command: 256 * MIB,
|
|
33
|
+
// A build, test or typecheck a heavy-command rule matched; the queue's slots bound how many run at once.
|
|
34
|
+
toolchain: GIB,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// What one test or typecheck process holds at its peak, for the scripts that size a fan-out to the free memory
|
|
38
|
+
// (_tools/scripts/verify/test-workers.mjs) and the ceiling that stops a runaway one (_tools/scripts/lib/memory-ceiling.mjs).
|
|
39
|
+
export const TEST_PROCESS_BYTES = Object.freeze({
|
|
40
|
+
// One bun worker's share in a turbo fan-out: two of the four concurrent tasks are the heavy packages, so between the
|
|
41
|
+
// web worker's 3 GiB and the rest.
|
|
42
|
+
fanOutWorker: 1.5 * GIB,
|
|
43
|
+
// One bun worker on the web package at its peak (measured 2026-09-25: 2.4 to 3.2 GiB over the web suite), the size a
|
|
44
|
+
// lone `suites` run sizes to.
|
|
45
|
+
standaloneWorker: 3 * GIB,
|
|
46
|
+
// One typecheck task: most packages' tsgo or vue-tsc settle under 1 GiB, the web package's vue-tsc may take its 4 GiB
|
|
47
|
+
// heap, and at most one of those runs among the others.
|
|
48
|
+
typecheck: 2 * GIB,
|
|
49
|
+
// Past this one process is a leak, not a suite: twice the largest worker measured. On 2026-09-25 a single bun process
|
|
50
|
+
// reached 14.7 GiB resident plus 31 GiB of swap, twice in one day, and stalled every conversation on the sandbox.
|
|
51
|
+
ceiling: 6 * GIB,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Work nobody is waiting on must leave room for a person's turn on top of its own cost, so it loses every tie.
|
|
55
|
+
export const PERSON_RESERVE_BYTES = COST_BYTES.agentRuntime;
|
|
56
|
+
// Percent of full avg10 at which the sandbox counts as grinding, whatever its byte count says.
|
|
57
|
+
export const STALL_PERCENT = 20;
|
|
58
|
+
export const RESERVATION_MS = 90_000;
|
|
59
|
+
// Where the daemon answers `GET /room`; never the daemon's own socket, which the front relays to the internet.
|
|
60
|
+
export const ROOM_SOCKET = process.env.INTENTIC_ROOM_SOCKET ?? "/run/intentic/room.sock";
|
|
61
|
+
|
|
62
|
+
const CGROUP = "/sys/fs/cgroup";
|
|
63
|
+
// Every file the reading is made of, for a caller that reads them itself (the daemon, asynchronously).
|
|
64
|
+
export const READING_FILES = Object.freeze([
|
|
65
|
+
`${CGROUP}/memory.high`,
|
|
66
|
+
`${CGROUP}/memory.max`,
|
|
67
|
+
`${CGROUP}/memory.current`,
|
|
68
|
+
`${CGROUP}/memory.stat`,
|
|
69
|
+
`${CGROUP}/memory.swap.current`,
|
|
70
|
+
`${CGROUP}/memory.pressure`,
|
|
71
|
+
`${CGROUP}/memory.events`,
|
|
72
|
+
"/proc/meminfo",
|
|
73
|
+
"/proc/pressure/memory",
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
const numeric = (text) => {
|
|
77
|
+
const trimmed = text?.trim() ?? "";
|
|
78
|
+
return /^\d+$/u.test(trimmed) ? Number(trimmed) : undefined;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// `key value` lines, and meminfo's `Key: value kB`.
|
|
82
|
+
const keyed = (text) =>
|
|
83
|
+
Object.fromEntries(
|
|
84
|
+
(text ?? "")
|
|
85
|
+
.split("\n")
|
|
86
|
+
.map((line) => line.trim().replace(/\s+kB$/u, "").split(/\s+/u))
|
|
87
|
+
.filter((parts) => parts.length === 2 && Number.isFinite(Number(parts[1])))
|
|
88
|
+
.map(([key, value]) => [key.replace(/:$/u, ""), Number(value)]),
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const fullAvg10 = (text) => {
|
|
92
|
+
const line = (text ?? "").split("\n").find((entry) => entry.startsWith("full"));
|
|
93
|
+
const value = line?.match(/avg10=([0-9.]+)/u)?.[1];
|
|
94
|
+
return value === undefined ? undefined : Number(value);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const kib = (fields, key) => (fields[key] === undefined ? undefined : fields[key] * 1024);
|
|
98
|
+
|
|
99
|
+
/** The reading, from a reader that answers each of READING_FILES with its text or undefined. */
|
|
100
|
+
export const readingFrom = (read) => {
|
|
101
|
+
const meminfo = keyed(read("/proc/meminfo"));
|
|
102
|
+
const machine = kib(meminfo, "MemTotal");
|
|
103
|
+
const bound = Math.min(
|
|
104
|
+
numeric(read(`${CGROUP}/memory.high`)) ?? Number.POSITIVE_INFINITY,
|
|
105
|
+
numeric(read(`${CGROUP}/memory.max`)) ?? Number.POSITIVE_INFINITY,
|
|
106
|
+
machine ?? Number.POSITIVE_INFINITY,
|
|
107
|
+
);
|
|
108
|
+
const current = numeric(read(`${CGROUP}/memory.current`));
|
|
109
|
+
let usedBytes;
|
|
110
|
+
let swapBytes = 0;
|
|
111
|
+
if (current !== undefined) {
|
|
112
|
+
// Unaccounted swap (swapaccount off) is none, never unknown: it must not blank a measurable ceiling.
|
|
113
|
+
swapBytes = numeric(read(`${CGROUP}/memory.swap.current`)) ?? 0;
|
|
114
|
+
usedBytes = Math.max(0, current - (keyed(read(`${CGROUP}/memory.stat`)).inactive_file ?? 0)) + swapBytes;
|
|
115
|
+
} else if (machine !== undefined && kib(meminfo, "MemAvailable") !== undefined) {
|
|
116
|
+
swapBytes = Math.max(0, (kib(meminfo, "SwapTotal") ?? 0) - (kib(meminfo, "SwapFree") ?? 0));
|
|
117
|
+
usedBytes = Math.max(0, machine - (kib(meminfo, "MemAvailable") ?? 0)) + swapBytes;
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
limitBytes: Number.isFinite(bound) ? bound : undefined,
|
|
121
|
+
usedBytes,
|
|
122
|
+
swapBytes,
|
|
123
|
+
availableBytes: kib(meminfo, "MemAvailable"),
|
|
124
|
+
stallPercent: fullAvg10(read(`${CGROUP}/memory.pressure`)) ?? fullAvg10(read("/proc/pressure/memory")) ?? 0,
|
|
125
|
+
oomKills: keyed(read(`${CGROUP}/memory.events`)).oom_kill,
|
|
126
|
+
};
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const readSync = (path) => {
|
|
130
|
+
try {
|
|
131
|
+
return readFileSync(path, "utf8");
|
|
132
|
+
} catch {
|
|
133
|
+
// allow(silent-catch): a file this kernel or platform does not have is the absent reading every caller handles
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export const readReadingSync = () => readingFrom(readSync);
|
|
139
|
+
|
|
140
|
+
const gib = (bytes) => `${(bytes / GIB).toFixed(1)} GiB`;
|
|
141
|
+
|
|
142
|
+
/** What starting one more of `workload` needs free: its own cost, plus a person's turn when nobody is waiting on it. */
|
|
143
|
+
export const needBytes = (workload, attended) => (COST_BYTES[workload] ?? COST_BYTES.agentRuntime) + (attended ? 0 : PERSON_RESERVE_BYTES);
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The verdict for one more of `workload`: `run`, or, when the sandbox is short, `refuse` for work a person is waiting
|
|
147
|
+
* on (they are told, and decide) and `wait` for work nobody is (it is held until there is room). `reservedBytes` is what
|
|
148
|
+
* other admissions still hold off the reading. An unmeasurable sandbox has no opinion, and runs.
|
|
149
|
+
*/
|
|
150
|
+
export const judge = (reading, { workload, attended, reservedBytes = 0 }) => {
|
|
151
|
+
const need = needBytes(workload, attended);
|
|
152
|
+
const { limitBytes, usedBytes, swapBytes, stallPercent, availableBytes } = reading;
|
|
153
|
+
const unreserved = freeBytesOf(reading);
|
|
154
|
+
if (limitBytes === undefined || usedBytes === undefined || unreserved === undefined) {
|
|
155
|
+
return { verdict: "run", needBytes: need, freeBytes: undefined, reservedBytes };
|
|
156
|
+
}
|
|
157
|
+
const freeBytes = Math.max(0, unreserved - reservedBytes);
|
|
158
|
+
const shortOf = (diagnosis, memory) => ({
|
|
159
|
+
verdict: attended ? "refuse" : "wait",
|
|
160
|
+
needBytes: need,
|
|
161
|
+
freeBytes,
|
|
162
|
+
reservedBytes,
|
|
163
|
+
diagnosis,
|
|
164
|
+
...(memory === undefined ? {} : { memory }),
|
|
165
|
+
});
|
|
166
|
+
// PSI is the sandbox's own only under a cgroup; at the root it is the machine's, and still the one there is.
|
|
167
|
+
if (stallPercent >= STALL_PERCENT) {
|
|
168
|
+
return shortOf(`The sandbox is short of memory: for ${Math.round(stallPercent)}% of the last ten seconds, everything in it was waiting on memory`);
|
|
169
|
+
}
|
|
170
|
+
if (freeBytes >= need) {
|
|
171
|
+
return { verdict: "run", needBytes: need, freeBytes, reservedBytes };
|
|
172
|
+
}
|
|
173
|
+
// The machine, not the sandbox's own limit, is what ran out: saying "9 of 16 GiB used" would read as a wrong refusal.
|
|
174
|
+
if (availableBytes !== undefined && availableBytes < limitBytes - usedBytes) {
|
|
175
|
+
const held = reservedBytes > 0 ? `, and ${gib(reservedBytes)} held for work that just started` : "";
|
|
176
|
+
return shortOf(`Sandbox memory is low: the machine it runs on has ${gib(availableBytes)} available${held}`);
|
|
177
|
+
}
|
|
178
|
+
// Resident and swapped are named apart once paging starts: the limit bounds resident pages only, so their sum can
|
|
179
|
+
// exceed it, and "19.2 GiB of 16.0 GiB used" reads as a bug.
|
|
180
|
+
const used =
|
|
181
|
+
swapBytes > 0
|
|
182
|
+
? `${gib(usedBytes - swapBytes)} resident + ${gib(swapBytes)} swapped, against ${gib(limitBytes)}`
|
|
183
|
+
: `${gib(usedBytes)} of ${gib(limitBytes)} used`;
|
|
184
|
+
// Named, or a sandbox reading 3 GiB free would seem to refuse work that needs 2.
|
|
185
|
+
const held = reservedBytes > 0 ? `, and ${gib(reservedBytes)} held for work that just started` : "";
|
|
186
|
+
return shortOf(`Sandbox memory is low: ${used}${held}`, { limitBytes, residentBytes: usedBytes - swapBytes, swapBytes });
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
/** Free memory as the formula counts it, or undefined where nothing bounds or measures it. */
|
|
190
|
+
export const freeBytesOf = (reading) =>
|
|
191
|
+
reading.limitBytes === undefined || reading.usedBytes === undefined
|
|
192
|
+
? undefined
|
|
193
|
+
: Math.max(0, Math.min(reading.limitBytes - reading.usedBytes, reading.availableBytes ?? Number.POSITIVE_INFINITY));
|
|
194
|
+
|
|
195
|
+
// ---- asking the daemon, with the formula itself as the fallback ----
|
|
196
|
+
|
|
197
|
+
const askSocket = (workload, waitSeconds, label, socketPath) =>
|
|
198
|
+
new Promise((resolve) => {
|
|
199
|
+
const query = new URLSearchParams({ class: workload, wait: String(waitSeconds), label });
|
|
200
|
+
const asked = request({ socketPath, path: `/room?${query}`, method: "GET", timeout: (waitSeconds + 15) * 1000 }, (response) => {
|
|
201
|
+
let body = "";
|
|
202
|
+
response.setEncoding("utf8");
|
|
203
|
+
response.on("data", (chunk) => (body += chunk));
|
|
204
|
+
response.on("end", () => {
|
|
205
|
+
try {
|
|
206
|
+
resolve(response.statusCode === 200 ? JSON.parse(body) : undefined);
|
|
207
|
+
} catch {
|
|
208
|
+
// allow(silent-catch): an answer that does not parse is no answer; the formula below decides instead
|
|
209
|
+
resolve(undefined);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
// No socket (CI, a plain checkout), a daemon restarting, a hung answer: the formula below decides instead.
|
|
214
|
+
asked.on("error", () => resolve(undefined));
|
|
215
|
+
asked.on("timeout", () => asked.destroy());
|
|
216
|
+
asked.end();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const askSnapshot = (socketPath) =>
|
|
220
|
+
new Promise((resolve) => {
|
|
221
|
+
const asked = request({ socketPath, path: "/snapshot", method: "GET", timeout: 3_000 }, (response) => {
|
|
222
|
+
let body = "";
|
|
223
|
+
response.setEncoding("utf8");
|
|
224
|
+
response.on("data", (chunk) => (body += chunk));
|
|
225
|
+
response.on("end", () => {
|
|
226
|
+
try {
|
|
227
|
+
resolve(response.statusCode === 200 ? JSON.parse(body) : undefined);
|
|
228
|
+
} catch {
|
|
229
|
+
// allow(silent-catch): an answer that does not parse is no answer; the formula below decides instead
|
|
230
|
+
resolve(undefined);
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
asked.on("error", () => resolve(undefined));
|
|
235
|
+
asked.on("timeout", () => asked.destroy());
|
|
236
|
+
asked.end();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Free memory as the daemon counts it (the reading less what work it admitted still holds), for a caller that sizes
|
|
241
|
+
* itself rather than asking to start; where no daemon answers, the formula's own free memory.
|
|
242
|
+
*/
|
|
243
|
+
export const askFree = async ({ socketPath = ROOM_SOCKET, read = readReadingSync } = {}) => {
|
|
244
|
+
const snapshot = await askSnapshot(socketPath);
|
|
245
|
+
return snapshot === undefined ? { freeBytes: freeBytesOf(read()), source: "formula" } : { freeBytes: snapshot.freeBytes, source: "daemon" };
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
// The same, for a caller that cannot wait on a promise (a script's default argument): this module run as a command.
|
|
249
|
+
export const askFreeSync = ({ socketPath = ROOM_SOCKET } = {}) => {
|
|
250
|
+
if (existsSync(socketPath)) {
|
|
251
|
+
const run = spawnSync(process.execPath, [import.meta.filename, "--free", "--socket", socketPath], { encoding: "utf8", timeout: 5_000 });
|
|
252
|
+
try {
|
|
253
|
+
const answer = JSON.parse(run.stdout);
|
|
254
|
+
if (typeof answer.freeBytes === "number") {
|
|
255
|
+
return answer.freeBytes;
|
|
256
|
+
}
|
|
257
|
+
} catch {
|
|
258
|
+
// allow(silent-catch): a daemon that did not answer leaves the formula to decide, below
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return freeBytesOf(readReadingSync());
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The daemon's verdict for one more of `workload`, waiting up to `waitSeconds` for room; where no daemon answers, the
|
|
268
|
+
* same formula over the same files, polled every `intervalMs`. Never throws: every failure is an answer of `run`.
|
|
269
|
+
*/
|
|
270
|
+
export const askRoom = async ({ workload = "toolchain", waitSeconds = 0, label = "command", socketPath = ROOM_SOCKET, intervalMs = 5_000, read = readReadingSync } = {}) => {
|
|
271
|
+
const answered = await askSocket(workload, waitSeconds, label, socketPath);
|
|
272
|
+
if (answered !== undefined) {
|
|
273
|
+
return { ...answered, source: "daemon" };
|
|
274
|
+
}
|
|
275
|
+
const startedAt = Date.now();
|
|
276
|
+
let verdict = judge(read(), { workload, attended: false });
|
|
277
|
+
while (verdict.verdict !== "run" && Date.now() - startedAt < waitSeconds * 1000) {
|
|
278
|
+
await sleep(intervalMs);
|
|
279
|
+
verdict = judge(read(), { workload, attended: false });
|
|
280
|
+
}
|
|
281
|
+
return { ...verdict, waitedMs: Date.now() - startedAt, source: "formula" };
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
// `memory-room --class toolchain --wait 120 --label NAME` holds a command until there is room, printing why it waited,
|
|
285
|
+
// and always exits 0: a gate that cannot read the sandbox must not be the reason a command did not run. `--json` prints
|
|
286
|
+
// the answer instead; `--free` prints the free memory as the daemon counts it, for a caller that sizes itself to it.
|
|
287
|
+
const main = async (args) => {
|
|
288
|
+
const option = (name, fallback) => {
|
|
289
|
+
const at = args.indexOf(`--${name}`);
|
|
290
|
+
return at === -1 ? fallback : (args[at + 1] ?? fallback);
|
|
291
|
+
};
|
|
292
|
+
if (args.includes("--free")) {
|
|
293
|
+
process.stdout.write(`${JSON.stringify(await askFree({ socketPath: option("socket", ROOM_SOCKET) }))}\n`);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
const waitSeconds = Math.max(0, Number(option("wait", "0")) || 0);
|
|
297
|
+
const label = option("label", "command");
|
|
298
|
+
const answer = await askRoom({ workload: option("class", "toolchain"), waitSeconds, label, intervalMs: Math.max(50, Number(option("interval-ms", "5000")) || 5000) });
|
|
299
|
+
if (args.includes("--json")) {
|
|
300
|
+
process.stdout.write(`${JSON.stringify(answer)}\n`);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
const free = answer.freeBytes === undefined ? "?" : gib(answer.freeBytes);
|
|
304
|
+
if (answer.waitedMs > 0) {
|
|
305
|
+
process.stderr.write(
|
|
306
|
+
answer.verdict === "run"
|
|
307
|
+
? `[memory-room] ${label}: waited ${Math.round(answer.waitedMs / 1000)}s for memory, ${free} free — starting.\n`
|
|
308
|
+
: `[memory-room] ${label}: still short of memory after ${Math.round(answer.waitedMs / 1000)}s (${free} free) — starting anyway.\n`,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const invokedAs = process.argv[1] === undefined ? undefined : (() => {
|
|
314
|
+
try {
|
|
315
|
+
return pathToFileURL(realpathSync(process.argv[1])).href;
|
|
316
|
+
} catch {
|
|
317
|
+
// allow(silent-catch): an argv[1] that is not a file is not this module being run as a command
|
|
318
|
+
return undefined;
|
|
319
|
+
}
|
|
320
|
+
})();
|
|
321
|
+
if (invokedAs === import.meta.url) {
|
|
322
|
+
await main(process.argv.slice(2)).catch((error) => process.stderr.write(`[memory-room] skipped: ${error instanceof Error ? error.message : String(error)}\n`));
|
|
323
|
+
process.exit(0);
|
|
324
|
+
}
|