agentbox-app 0.1.7 → 0.1.12
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 +220 -58
- package/bin/agentbox.mjs +18 -3
- package/briefs/message-rules.md +24 -3
- package/briefs/worker.md +63 -49
- package/briefs/writing-rules.md +90 -131
- package/main/account-discovery.mjs +21 -20
- package/main/account-tooling.mjs +11 -11
- package/main/agent-activity.mjs +45 -5
- package/main/agent-schedule.mjs +14 -14
- package/main/agent-sessions.mjs +94 -10
- package/main/agent-updates.mjs +116 -0
- package/main/agents.mjs +52 -46
- package/main/analytics.mjs +27 -0
- package/main/approval-prompt-server.mjs +2 -2
- package/main/approvals.mjs +14 -5
- package/main/artifact-path.mjs +11 -0
- package/main/claude-bin.mjs +32 -5
- package/main/claude-input.mjs +45 -3
- package/main/claude-plan.mjs +25 -13
- package/main/code-change.mjs +3 -3
- package/main/codex-account.mjs +1 -1
- package/main/codex-bin.mjs +18 -0
- package/main/codex-launch-env.mjs +31 -0
- package/main/codex-memory-gate.mjs +260 -0
- package/main/codex-models.mjs +12 -7
- package/main/codex-threads.mjs +46 -23
- package/main/codex-usage-file.mjs +7 -7
- package/main/codex-usage.mjs +4 -4
- package/main/codex-watch.mjs +12 -12
- package/main/config.mjs +82 -17
- package/main/demo.mjs +17 -14
- package/main/dev-window.mjs +9 -9
- package/main/doc-scheme.mjs +2 -0
- package/main/engine-setup.mjs +204 -0
- package/main/feedback.mjs +55 -0
- package/main/folder-pictures.mjs +48 -0
- package/main/frame-scrollbars.mjs +90 -0
- package/main/fresh-user.mjs +7 -4
- package/main/git-change-offthread.mjs +70 -0
- package/main/git-change-thread.mjs +11 -0
- package/main/git-change.mjs +9 -3
- package/main/img-scheme.mjs +70 -1
- package/main/instruction-settings.mjs +134 -60
- package/main/ipc.mjs +335 -42
- package/main/leftovers.mjs +518 -0
- package/main/live-replies.mjs +43 -5
- package/main/machine.mjs +48 -1
- package/main/main.mjs +138 -15
- package/main/memory-gate-history.mjs +254 -0
- package/main/memory-gate-server.mjs +461 -0
- package/main/memory-gate.mjs +206 -0
- package/main/message-priority.mjs +87 -0
- package/main/notify.mjs +1 -1
- package/main/rail-note.mjs +2 -2
- package/main/refresh-agent-models.mjs +21 -0
- package/main/repeats.mjs +4 -4
- package/main/row-label.mjs +115 -51
- package/main/runs-on.mjs +93 -0
- package/main/serve.mjs +77 -5
- package/main/settings.mjs +245 -26
- package/main/ship-queue.mjs +279 -0
- package/main/sign-in-files.mjs +39 -0
- package/main/source-updater.mjs +298 -0
- package/main/store/home.mjs +84 -7
- package/main/store/project.mjs +15 -19
- package/main/store/work-items.mjs +150 -6
- package/main/store.mjs +245 -13
- package/main/supervisor.mjs +1634 -417
- package/main/task-commands.mjs +8 -10
- package/main/task-folders.mjs +94 -16
- package/main/task-remote-control.mjs +3 -3
- package/main/task-terminals.mjs +14 -2
- package/main/team/index.mjs +440 -0
- package/main/team/memory-cloud.mjs +247 -0
- package/main/team/projects.mjs +162 -0
- package/main/team/session.mjs +225 -0
- package/main/team/sign-in.mjs +106 -0
- package/main/team/supabase-backend.mjs +231 -0
- package/main/team/sync.mjs +163 -0
- package/main/updater.mjs +15 -5
- package/main/write-before-reload.mjs +50 -0
- package/mcp/core/page.mjs +10 -12
- package/mcp/core/work.mjs +123 -26
- package/mcp/tools.mjs +13 -6
- package/package.json +21 -9
- package/renderer/dist/assets/agentbox-icon-F1oN9nj_.svg +15 -0
- package/renderer/dist/assets/index-ChKsQkRw.js +1169 -0
- package/renderer/dist/assets/index-ClBXnq3B.css +32 -0
- package/renderer/dist/index.html +2 -2
- package/scripts/after-install.mjs +14 -0
- package/scripts/approval-server.sh +7 -2
- package/scripts/memory-gate-hook.sh +59 -0
- package/shared/agent-import.mjs +39 -16
- package/shared/agents.d.mts +26 -8
- package/shared/agents.mjs +100 -108
- package/shared/analytics-events.mjs +19 -2
- package/shared/answers.mjs +12 -0
- package/shared/bridge-map.d.mts +12 -0
- package/shared/bridge-map.mjs +38 -1
- package/shared/claude-commands.generated.mjs +2 -2
- package/shared/claude-models.generated.mjs +6 -6
- package/shared/claude-usage.mjs +6 -6
- package/shared/codex-import.mjs +7 -7
- package/shared/codex-modes.d.mts +50 -0
- package/shared/codex-modes.mjs +1 -1
- package/shared/codex-usage.mjs +7 -8
- package/shared/demo-world.mjs +6 -6
- package/shared/engines.mjs +27 -10
- package/shared/feedback.d.mts +14 -0
- package/shared/feedback.mjs +44 -0
- package/shared/first-run-examples.mjs +4 -4
- package/shared/first-run-practice.d.mts +16 -0
- package/shared/first-run-practice.mjs +176 -70
- package/shared/fresh-user-home.mjs +34 -4
- package/shared/instruction-defaults.json +2 -2
- package/shared/product-name.mjs +4 -2
- package/shared/project-folder-check.mjs +9 -3
- package/shared/rank.d.mts +1 -0
- package/shared/rank.mjs +22 -0
- package/shared/referenced-files.mjs +1 -1
- package/shared/runs-on.d.mts +20 -0
- package/shared/runs-on.mjs +79 -0
- package/shared/schemes.d.mts +11 -0
- package/shared/schemes.mjs +1 -1
- package/shared/shortcuts.d.mts +41 -0
- package/shared/shortcuts.mjs +236 -0
- package/shared/spawn-trouble.d.mts +2 -0
- package/shared/spawn-trouble.mjs +40 -19
- package/shared/team-rules.d.mts +17 -0
- package/shared/team-rules.mjs +230 -0
- package/shared/team-status.d.mts +11 -0
- package/shared/team-status.mjs +90 -0
- package/shared/terminal-links.d.mts +2 -0
- package/shared/terminal-links.mjs +55 -0
- package/shared/terminal-state.d.mts +1 -0
- package/shared/terminal-state.mjs +16 -0
- package/shared/thread-cards.d.mts +19 -0
- package/shared/thread-cards.mjs +254 -0
- package/shared/usage.d.mts +1 -1
- package/shared/usage.mjs +6 -7
- package/shared/work-items.mjs +258 -28
- package/shared/work-lines.mjs +393 -16
- package/worker-permissions.json +1 -1
- package/renderer/dist/assets/cel-dusk-CS5I7kJS.webp +0 -0
- package/renderer/dist/assets/cel-dusk-DUslqS6B.webp +0 -0
- package/renderer/dist/assets/cel-dusk-Jt_xVGxp.webp +0 -0
- package/renderer/dist/assets/cel-dusk-sm-BBkV-tWT.webp +0 -0
- package/renderer/dist/assets/frost-haze-CxPSOBEg.webp +0 -0
- package/renderer/dist/assets/frost-haze-sm-C_w21qgd.webp +0 -0
- package/renderer/dist/assets/ice-BC8luINc.webp +0 -0
- package/renderer/dist/assets/ice-CP9PrKHo.webp +0 -0
- package/renderer/dist/assets/ice-river-D6ziOhLY.webp +0 -0
- package/renderer/dist/assets/ice-river-UVM2-JY_.webp +0 -0
- package/renderer/dist/assets/ice-river-bErJwYWW.webp +0 -0
- package/renderer/dist/assets/ice-river-sm-Bg1U-SIj.webp +0 -0
- package/renderer/dist/assets/ice-sm-CbVcg3_D.webp +0 -0
- package/renderer/dist/assets/index-B7hhfHrT.js +0 -684
- package/renderer/dist/assets/index-CwiDv5bL.css +0 -32
- package/renderer/dist/assets/lake-C0WaW-6E.webp +0 -0
- package/renderer/dist/assets/lake-CSKjLC5K.webp +0 -0
- package/renderer/dist/assets/lake-ETGcOb25.webp +0 -0
- package/renderer/dist/assets/lake-harbour-BCQRbrHb.webp +0 -0
- package/renderer/dist/assets/lake-harbour-B_wfXTmE.webp +0 -0
- package/renderer/dist/assets/lake-harbour-Te1pEvBh.webp +0 -0
- package/renderer/dist/assets/lake-harbour-sm-Cme_ShEg.webp +0 -0
- package/renderer/dist/assets/lake-sm-CbG72Hbv.webp +0 -0
- package/renderer/dist/assets/lino-coast-CUZHcucI.webp +0 -0
- package/renderer/dist/assets/lino-coast-DP4J7cgd.webp +0 -0
- package/renderer/dist/assets/lino-coast-kOIAffr_.webp +0 -0
- package/renderer/dist/assets/lino-coast-sm-C1Mdbgit.webp +0 -0
- package/renderer/dist/assets/moon-pines-BskgnnXR.webp +0 -0
- package/renderer/dist/assets/moon-pines-CFSIIwt0.webp +0 -0
- package/renderer/dist/assets/moon-pines-PNhdOYww.webp +0 -0
- package/renderer/dist/assets/moon-pines-sm-C6K1NomK.webp +0 -0
- package/renderer/dist/assets/orbit-rings-2XV36Ch3.webp +0 -0
- package/renderer/dist/assets/orbit-rings-C4NrcYGQ.webp +0 -0
- package/renderer/dist/assets/orbit-rings-DGLk07dT.webp +0 -0
- package/renderer/dist/assets/orbit-rings-sm-Dky3YdvL.webp +0 -0
- package/renderer/dist/assets/peach-haze-2-C6f7xnWw.webp +0 -0
- package/renderer/dist/assets/peach-haze-2-sm-Dj_Ez_Sb.webp +0 -0
- package/renderer/dist/assets/pixel-harbour-DQjqMROg.webp +0 -0
- package/renderer/dist/assets/pixel-harbour-Dw71H_bc.webp +0 -0
- package/renderer/dist/assets/pixel-harbour-kOZfm-GM.webp +0 -0
- package/renderer/dist/assets/pixel-harbour-sm-Dturjuky.webp +0 -0
- package/renderer/dist/assets/pixel-orbit-C296JVMO.webp +0 -0
- package/renderer/dist/assets/pixel-orbit-C4oMRDSx.webp +0 -0
- package/renderer/dist/assets/pixel-orbit-CqVmQ4eh.webp +0 -0
- package/renderer/dist/assets/pixel-orbit-sm-CHagC_nO.webp +0 -0
- package/renderer/dist/assets/pixel-ridge-C6LYu6uE.webp +0 -0
- package/renderer/dist/assets/pixel-ridge-CIUCEESs.webp +0 -0
- package/renderer/dist/assets/pixel-ridge-sm-BnE0F_w-.webp +0 -0
- package/renderer/dist/assets/riso-hills-7heKVtry.webp +0 -0
- package/renderer/dist/assets/riso-hills-CIjAL3J6.webp +0 -0
- package/renderer/dist/assets/riso-hills-sm-DHsnbCfP.webp +0 -0
- package/renderer/dist/assets/slate-haze-BPWdkNsw.webp +0 -0
- package/renderer/dist/assets/slate-haze-sm-B0Cb5a2W.webp +0 -0
- package/renderer/dist/assets/valley-haze-BDHPqoJy.webp +0 -0
- package/renderer/dist/assets/valley-haze-sm-CTyjmPAG.webp +0 -0
- package/renderer/dist/assets/vector-dunes-DzzWLdKo.webp +0 -0
- package/renderer/dist/assets/vector-dunes-Yr9NBQUy.webp +0 -0
- package/renderer/dist/assets/vector-dunes-sm-D8MYY6ms.webp +0 -0
- package/renderer/dist/assets/watercolour-mist-BCGKN4u4.webp +0 -0
- package/renderer/dist/assets/watercolour-mist-BF-jk5MC.webp +0 -0
- package/renderer/dist/assets/watercolour-mist-sm-Ds1xzTw9.webp +0 -0
- package/renderer/dist/assets/woodblock-sea-B1um67Z9.webp +0 -0
- package/renderer/dist/assets/woodblock-sea-DKQdYs32.webp +0 -0
- package/renderer/dist/assets/woodblock-sea-sm-BJzNq8xh.webp +0 -0
- package/renderer/dist/emulator/audio-rules.mjs +0 -74
- package/renderer/dist/emulator/data/compression/README.md +0 -11
- package/renderer/dist/emulator/data/compression/extract7z.js +0 -1
- package/renderer/dist/emulator/data/compression/extractzip.js +0 -1
- package/renderer/dist/emulator/data/compression/libunrar.js +0 -519
- package/renderer/dist/emulator/data/compression/libunrar.wasm +0 -0
- package/renderer/dist/emulator/data/cores/README.md +0 -22
- package/renderer/dist/emulator/data/cores/mgba-legacy-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/mgba-thread-legacy-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/mgba-thread-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/mgba-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/package.json +0 -65
- package/renderer/dist/emulator/data/cores/reports/mgba.json +0 -1
- package/renderer/dist/emulator/data/emulator.css +0 -1614
- package/renderer/dist/emulator/data/loader.js +0 -170
- package/renderer/dist/emulator/data/localization/README.md +0 -63
- package/renderer/dist/emulator/data/localization/af-FR.json +0 -339
- package/renderer/dist/emulator/data/localization/ar-AR.json +0 -301
- package/renderer/dist/emulator/data/localization/ben-BEN.json +0 -301
- package/renderer/dist/emulator/data/localization/de-GER.json +0 -301
- package/renderer/dist/emulator/data/localization/el-GR.json +0 -301
- package/renderer/dist/emulator/data/localization/en-US.json +0 -339
- package/renderer/dist/emulator/data/localization/es-ES.json +0 -310
- package/renderer/dist/emulator/data/localization/fa-AF.json +0 -310
- package/renderer/dist/emulator/data/localization/hi-HI.json +0 -301
- package/renderer/dist/emulator/data/localization/it-IT.json +0 -302
- package/renderer/dist/emulator/data/localization/ja-JA.json +0 -301
- package/renderer/dist/emulator/data/localization/jv-JV.json +0 -301
- package/renderer/dist/emulator/data/localization/ko-KO.json +0 -336
- package/renderer/dist/emulator/data/localization/pt-BR.json +0 -361
- package/renderer/dist/emulator/data/localization/retroarch.json +0 -617
- package/renderer/dist/emulator/data/localization/ro-RO.json +0 -310
- package/renderer/dist/emulator/data/localization/ru-RU.json +0 -301
- package/renderer/dist/emulator/data/localization/tr-TR.json +0 -310
- package/renderer/dist/emulator/data/localization/vi-VN.json +0 -302
- package/renderer/dist/emulator/data/localization/zh-CN.json +0 -351
- package/renderer/dist/emulator/data/src/GameManager.js +0 -464
- package/renderer/dist/emulator/data/src/compression.js +0 -142
- package/renderer/dist/emulator/data/src/emulator.js +0 -6158
- package/renderer/dist/emulator/data/src/gamepad.js +0 -167
- package/renderer/dist/emulator/data/src/nipplejs.js +0 -1
- package/renderer/dist/emulator/data/src/shaders.js +0 -236
- package/renderer/dist/emulator/data/src/socket.io.min.js +0 -7
- package/renderer/dist/emulator/data/src/storage.js +0 -125
- package/renderer/dist/emulator/data/version.json +0 -5
- package/renderer/dist/emulator/host.js +0 -299
- package/renderer/dist/emulator/index.html +0 -28
- package/renderer/dist/emulator/provenance.json +0 -6
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
// THE COORDINATOR EVERY WORKER'S COMMANDS ASK BEFORE THEY RUN (w-3958c3753d).
|
|
2
|
+
//
|
|
3
|
+
// main/memory-gate.mjs decides; this file is everything around the decision:
|
|
4
|
+
// the local socket the hook (scripts/memory-gate-hook.sh) asks on, reading the
|
|
5
|
+
// Mac's memory pressure, sampling what each running command actually uses, and
|
|
6
|
+
// writing what was learned to disk.
|
|
7
|
+
//
|
|
8
|
+
// ONE COORDINATOR PER MAC, WHICHEVER APP STARTED FIRST. Two Agentbox builds can
|
|
9
|
+
// run side by side (the packaged one and a dev one), and two gates each letting
|
|
10
|
+
// two heavy commands through is four. The socket file itself is the lock: a
|
|
11
|
+
// listening socket belongs to a live process and dies with it. The first app
|
|
12
|
+
// to listen owns it; the next finds it answering and stands by, trying again
|
|
13
|
+
// every `takeoverMs`, and takes it over once nobody answers. Its own workers
|
|
14
|
+
// meanwhile ask the owner, carrying the rank they were spawned with.
|
|
15
|
+
//
|
|
16
|
+
// NOTHING HERE STOPS A RUNNING PROGRAM. Sampling only measures. Pausing
|
|
17
|
+
// programs was the first design; a paused program frees no memory, trips its
|
|
18
|
+
// own time limits and stays frozen forever if the app dies (agreed with Codex,
|
|
19
|
+
// 2026-10-04), so the only lever is when a command is allowed to START.
|
|
20
|
+
//
|
|
21
|
+
// WHAT RESTARTS DO. App quit: the supervisor kills its workers anyway, and
|
|
22
|
+
// `stop()` answers everything still waiting with "go", so nothing is left
|
|
23
|
+
// holding. App crash: an idle `claude -p` exits about 2.6 s after its input
|
|
24
|
+
// closes (measured), a hook mid-wait sees the socket go and runs its command,
|
|
25
|
+
// and a standby app takes over. Mac restart or sleep: nothing is carried across
|
|
26
|
+
// but the learned history; a grant whose agent process is gone, or whose pid
|
|
27
|
+
// now belongs to a different program, is released at the next sample.
|
|
28
|
+
|
|
29
|
+
import fs from 'node:fs';
|
|
30
|
+
import os from 'node:os';
|
|
31
|
+
import net from 'node:net';
|
|
32
|
+
import path from 'node:path';
|
|
33
|
+
import http from 'node:http';
|
|
34
|
+
import { execFile } from 'node:child_process';
|
|
35
|
+
import { MemoryGate, pressureFrom } from './memory-gate.mjs';
|
|
36
|
+
import { CommandHistory } from './memory-gate-history.mjs';
|
|
37
|
+
import { etimeSeconds } from './leftovers.mjs';
|
|
38
|
+
|
|
39
|
+
export const PROTOCOL = 1;
|
|
40
|
+
export const DEFAULT_LOCK_PORT = 47263;
|
|
41
|
+
|
|
42
|
+
/** Where every Agentbox on this Mac meets. Short, because macOS caps a socket
|
|
43
|
+
* path at 104 bytes, and in the per-user temp folder, so it is this user's. */
|
|
44
|
+
export function defaultSocketPath() {
|
|
45
|
+
return path.join(os.tmpdir(), 'agentbox-memory-gate.sock');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The deny Claude Code reads from a PreToolUse hook. */
|
|
49
|
+
export function denyOutput(reason) {
|
|
50
|
+
return JSON.stringify({
|
|
51
|
+
hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason },
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function run(cmd, args) {
|
|
56
|
+
return new Promise((resolve) => {
|
|
57
|
+
execFile(cmd, args, { timeout: 5_000, maxBuffer: 16 * 1024 * 1024 }, (err, stdout) => resolve(err ? null : stdout));
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** `kern.memorystatus_vm_pressure_level` and `kern.memorystatus_level`, or nulls. */
|
|
62
|
+
export async function readMacPressure() {
|
|
63
|
+
const out = await run('/usr/sbin/sysctl', ['-n', 'kern.memorystatus_vm_pressure_level', 'kern.memorystatus_level']);
|
|
64
|
+
const [level, freePct] = String(out ?? '').trim().split(/\s+/).map(Number);
|
|
65
|
+
return { level: Number.isFinite(level) ? level : null, freePct: Number.isFinite(freePct) ? freePct : null };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export { etimeSeconds };
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Every process: pid, parent, resident MB, when it started (wall clock, to
|
|
72
|
+
* the second) and its command line. One `ps` costs about 33 ms on a loaded
|
|
73
|
+
* Mac (measured 2026-10-04), and it only runs while a command is granted.
|
|
74
|
+
*/
|
|
75
|
+
export async function listProcesses() {
|
|
76
|
+
const out = await run('/bin/ps', ['-axo', 'pid=,ppid=,rss=,etime=,args=']);
|
|
77
|
+
if (!out) return [];
|
|
78
|
+
const now = Date.now();
|
|
79
|
+
const list = [];
|
|
80
|
+
for (const line of out.split('\n')) {
|
|
81
|
+
const m = line.match(/^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/);
|
|
82
|
+
if (!m) continue;
|
|
83
|
+
const age = etimeSeconds(m[4]);
|
|
84
|
+
list.push({ pid: +m[1], ppid: +m[2], mb: +m[3] / 1024, startedAt: age === null ? undefined : now - age * 1000, args: m[5].slice(0, 400) });
|
|
85
|
+
}
|
|
86
|
+
return list;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The process an agent IS: Claude Code's `claude`, or Codex's app-server.
|
|
90
|
+
// Used only to find whose command a hook was, never to decide anything.
|
|
91
|
+
const AGENT = /(^|\/)(claude|codex)(\s|$)/;
|
|
92
|
+
|
|
93
|
+
export class MemoryGateServer {
|
|
94
|
+
constructor({
|
|
95
|
+
socketPath = defaultSocketPath(),
|
|
96
|
+
historyFile = null,
|
|
97
|
+
history = null,
|
|
98
|
+
gate = {},
|
|
99
|
+
scoreFor = null,
|
|
100
|
+
// WHOSE CODEX THREAD IS THIS (w-e5225b62ba). A Claude worker carries its row
|
|
101
|
+
// in its environment; one `codex app-server` serves every Codex thread of a
|
|
102
|
+
// login, so there is one environment for all of them and the thread has to
|
|
103
|
+
// say who it is. It does, by the `session_id` in its payload, measured to be
|
|
104
|
+
// the app-server's own `thread.id`. Answering null means "not this app's",
|
|
105
|
+
// and such a command is let straight through.
|
|
106
|
+
ownerOf = null,
|
|
107
|
+
readPressure = readMacPressure,
|
|
108
|
+
sampleProcesses = listProcesses,
|
|
109
|
+
pollMs = 2_000,
|
|
110
|
+
takeoverMs = 5_000,
|
|
111
|
+
// The port whose holder is the one coordinator on this Mac. Fixed so that
|
|
112
|
+
// every Agentbox finds the same one; a test passes its own.
|
|
113
|
+
lockPort = DEFAULT_LOCK_PORT,
|
|
114
|
+
idleReleaseMs = 30_000,
|
|
115
|
+
maxGrantMs = 3 * 60 * 60_000,
|
|
116
|
+
clock = Date.now,
|
|
117
|
+
log = () => {},
|
|
118
|
+
} = {}) {
|
|
119
|
+
this.socketPath = socketPath;
|
|
120
|
+
this.historyFile = historyFile;
|
|
121
|
+
this.history = history ?? loadHistory(historyFile);
|
|
122
|
+
this.clock = clock;
|
|
123
|
+
this.idleReleaseMs = idleReleaseMs;
|
|
124
|
+
this.maxGrantMs = maxGrantMs;
|
|
125
|
+
this.gate = new MemoryGate({ ...gate, history: this.history, clock });
|
|
126
|
+
this.scoreFor = scoreFor;
|
|
127
|
+
this.ownerOf = ownerOf;
|
|
128
|
+
this.readPressure = readPressure;
|
|
129
|
+
this.sampleProcesses = sampleProcesses;
|
|
130
|
+
this.pollMs = pollMs;
|
|
131
|
+
this.takeoverMs = takeoverMs;
|
|
132
|
+
this.lockPort = lockPort;
|
|
133
|
+
this.log = log;
|
|
134
|
+
this.role = 'stopped';
|
|
135
|
+
this.grants = new Map();
|
|
136
|
+
this.lastPids = new Set();
|
|
137
|
+
this.reading = { level: null, freePct: null };
|
|
138
|
+
// `foreign` is counted apart from `asked` so the figures on the Agents page
|
|
139
|
+
// stay about this app's own work: a Codex session it did not start is let
|
|
140
|
+
// through and is not one of its agents.
|
|
141
|
+
this.counts = { asked: 0, waited: 0, refused: 0, foreign: 0 };
|
|
142
|
+
this.dirty = false;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async start() {
|
|
146
|
+
if (this.role !== 'stopped') return this.role;
|
|
147
|
+
this.stopping = false;
|
|
148
|
+
await this._claim();
|
|
149
|
+
return this.role;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async stop() {
|
|
153
|
+
this.stopping = true;
|
|
154
|
+
clearTimeout(this.takeoverTimer);
|
|
155
|
+
clearInterval(this.pollTimer);
|
|
156
|
+
// Everything still waiting goes: the gate is off, so nothing should hold.
|
|
157
|
+
for (const w of this.gate.waiting.values()) for (const r of w.replies) r({ allow: true });
|
|
158
|
+
this.gate.waiting.clear();
|
|
159
|
+
if (this.server) {
|
|
160
|
+
const server = this.server;
|
|
161
|
+
this.server = null;
|
|
162
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
163
|
+
server.closeAllConnections?.();
|
|
164
|
+
try { fs.unlinkSync(this.socketPath); } catch {}
|
|
165
|
+
}
|
|
166
|
+
// The lock goes last, so nobody can take over while the socket is still ours.
|
|
167
|
+
if (this.lock) {
|
|
168
|
+
const lock = this.lock;
|
|
169
|
+
this.lock = null;
|
|
170
|
+
await new Promise((resolve) => lock.close(() => resolve()));
|
|
171
|
+
}
|
|
172
|
+
this._saveHistory();
|
|
173
|
+
this.role = 'stopped';
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
setSlots(n) { this.gate.setSlots(n); }
|
|
177
|
+
|
|
178
|
+
status() {
|
|
179
|
+
const snap = this.gate.snapshot();
|
|
180
|
+
return { role: this.role, ...snap, freePct: this.reading.freePct, counts: { ...this.counts } };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/* --------------------------- owning the socket --------------------------- */
|
|
184
|
+
|
|
185
|
+
async _claim() {
|
|
186
|
+
if (this.stopping) return;
|
|
187
|
+
if (await this._listen()) {
|
|
188
|
+
this.role = 'owner';
|
|
189
|
+
this.log(`memory gate: coordinating on ${this.socketPath}`);
|
|
190
|
+
this._poll();
|
|
191
|
+
this.pollTimer = setInterval(() => this._poll(), this.pollMs);
|
|
192
|
+
this.pollTimer.unref?.();
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
this.role = 'standby';
|
|
196
|
+
this.takeoverTimer = setTimeout(() => this._claim(), this.takeoverMs);
|
|
197
|
+
this.takeoverTimer.unref?.();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// WHO COORDINATES IS DECIDED BY A PORT THE KERNEL HOLDS, NOT BY THE SOCKET
|
|
201
|
+
// FILE (agreed with Codex, 2026-10-05). The socket used to be its own lock:
|
|
202
|
+
// a socket that did not answer within a second was taken for dead, unlinked
|
|
203
|
+
// and replaced, so a stalled owner and a new one could both be running. Now
|
|
204
|
+
// the owner binds 127.0.0.1:`lockPort` first and keeps it for its whole
|
|
205
|
+
// life; the kernel refuses a second bind (EADDRINUSE, measured on macOS) and
|
|
206
|
+
// frees the port only when the owner process dies. Only the holder may
|
|
207
|
+
// unlink and bind the socket, and an app that cannot get the port stands by
|
|
208
|
+
// and never touches anything.
|
|
209
|
+
async _listen() {
|
|
210
|
+
const bind = (server, where) => new Promise((resolve) => {
|
|
211
|
+
const onError = (err) => { server.off('listening', onListen); resolve(err); };
|
|
212
|
+
const onListen = () => { server.off('error', onError); resolve(null); };
|
|
213
|
+
server.once('error', onError);
|
|
214
|
+
server.once('listening', onListen);
|
|
215
|
+
server.listen(where);
|
|
216
|
+
});
|
|
217
|
+
const lock = net.createServer((c) => c.destroy());
|
|
218
|
+
const lockErr = await bind(lock, { host: '127.0.0.1', port: this.lockPort, exclusive: true });
|
|
219
|
+
if (lockErr) {
|
|
220
|
+
if (lockErr.code !== 'EADDRINUSE') this.log(`memory gate: could not take the lock: ${lockErr.message}`);
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
lock.unref?.();
|
|
224
|
+
const server = http.createServer((req, res) => this._handle(req, res));
|
|
225
|
+
let err = await bind(server, this.socketPath);
|
|
226
|
+
if (err?.code === 'EADDRINUSE') {
|
|
227
|
+
// We hold the lock, so whatever sits at the path belongs to an owner that
|
|
228
|
+
// is gone.
|
|
229
|
+
try { fs.unlinkSync(this.socketPath); } catch {}
|
|
230
|
+
err = await bind(server, this.socketPath);
|
|
231
|
+
}
|
|
232
|
+
if (err) {
|
|
233
|
+
this.log(`memory gate: could not listen: ${err.message}`);
|
|
234
|
+
await new Promise((r) => lock.close(() => r()));
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
try { fs.chmodSync(this.socketPath, 0o600); } catch {}
|
|
238
|
+
this.server = server;
|
|
239
|
+
this.lock = lock;
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/* ------------------------------- requests ------------------------------- */
|
|
244
|
+
|
|
245
|
+
_handle(req, res) {
|
|
246
|
+
if (req.method === 'GET' && req.url === '/status') {
|
|
247
|
+
res.setHeader('content-type', 'application/json');
|
|
248
|
+
res.end(JSON.stringify(this.status()));
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
let raw = '';
|
|
252
|
+
req.setEncoding('utf8');
|
|
253
|
+
req.on('data', (d) => { if (raw.length < 1_000_000) raw += d; });
|
|
254
|
+
req.on('end', () => {
|
|
255
|
+
let body;
|
|
256
|
+
try { body = JSON.parse(raw); } catch { res.end(''); return; }
|
|
257
|
+
if (req.url === '/pre') this._pre(body, req, res);
|
|
258
|
+
else if (req.url === '/post') { this._post(body); res.end(''); }
|
|
259
|
+
else res.end('');
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* WHOSE ROW THIS COMMAND BELONGS TO, or null for "not this app's".
|
|
265
|
+
*
|
|
266
|
+
* THE ENVIRONMENT WINS. A Claude Code worker is a process per work item and
|
|
267
|
+
* carries the row on it (`memoryGateEnv`), and that is the authority: reading a
|
|
268
|
+
* session id first would let a Codex thread id that happened to collide
|
|
269
|
+
* relabel a Claude worker's command.
|
|
270
|
+
*
|
|
271
|
+
* ONLY THEN THE SESSION ID, which is the Codex path and the reason this method
|
|
272
|
+
* exists. One `codex app-server` serves every Codex thread of a login, so its
|
|
273
|
+
* environment names no row and the thread names itself: the `session_id` in
|
|
274
|
+
* the payload was measured to be the app-server's own `thread.id`.
|
|
275
|
+
*
|
|
276
|
+
* A LOOKUP THAT THROWS IS A COMMAND WE LET RUN. The lookup reaches into the
|
|
277
|
+
* supervisor's live sessions, and a store mid-write there must not be able to
|
|
278
|
+
* stop an agent's shell command.
|
|
279
|
+
*
|
|
280
|
+
* AND AN ASK THAT NAMES NEITHER IS STILL GATED, with no row, exactly as it was
|
|
281
|
+
* before any of this existed. "Let it through" is reserved for a thread we
|
|
282
|
+
* POSITIVELY know is somebody else's — a session id we were given and do not
|
|
283
|
+
* recognise. Treating a nameless ask as a stranger's would have been a way for
|
|
284
|
+
* a payload we failed to read to walk past the gate unmeasured.
|
|
285
|
+
*/
|
|
286
|
+
_whose(body, hook) {
|
|
287
|
+
const item = typeof body?.item === 'string' ? body.item.trim() : '';
|
|
288
|
+
if (item) return { item, product: body.product || null };
|
|
289
|
+
const session = typeof hook?.session_id === 'string' ? hook.session_id.trim() : '';
|
|
290
|
+
if (!session) return { item: null, product: body?.product || null };
|
|
291
|
+
if (typeof this.ownerOf !== 'function') return null;
|
|
292
|
+
let owner;
|
|
293
|
+
try { owner = this.ownerOf(session); } catch { return null; }
|
|
294
|
+
if (!owner?.item) return null;
|
|
295
|
+
return { item: String(owner.item), product: owner.product ?? null };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
_pre(body, req, res) {
|
|
299
|
+
const hook = body?.hook ?? {};
|
|
300
|
+
const command = hook?.tool_input?.command;
|
|
301
|
+
if (typeof command !== 'string' || !command.trim()) { res.end(''); return; }
|
|
302
|
+
const whose = this._whose(body, hook);
|
|
303
|
+
// A COMMAND THIS APP DID NOT START IS NOT ITS TO HOLD. Only a Codex ask can
|
|
304
|
+
// land here unowned, and letting it run is the only honest answer: holding a
|
|
305
|
+
// stranger's command behind our queue would stall somebody's terminal with
|
|
306
|
+
// nothing on any screen to say why.
|
|
307
|
+
if (!whose) { this.counts.foreign++; res.end(''); return; }
|
|
308
|
+
const id = String(hook.tool_use_id || `${body.ppid}-${Date.now()}-${Math.random()}`);
|
|
309
|
+
const live = typeof this.scoreFor === 'function' ? this.scoreFor(whose.product, whose.item) : null;
|
|
310
|
+
const score = Number.isFinite(live) ? live : Number(body.score) || 0;
|
|
311
|
+
this.counts.asked++;
|
|
312
|
+
let answered = false;
|
|
313
|
+
this.gate.submit({
|
|
314
|
+
id, command, score, item: whose.item, product: whose.product,
|
|
315
|
+
background: !!hook?.tool_input?.run_in_background,
|
|
316
|
+
}, (decision) => {
|
|
317
|
+
answered = true;
|
|
318
|
+
if (decision.allow) {
|
|
319
|
+
const grant = this.grants.get(id) ?? {
|
|
320
|
+
id, ppid: Number(body.ppid) || null, anchor: null, anchorArgs: null,
|
|
321
|
+
baseline: this.lastPids, wallStart: Date.now(), startedAt: this.clock(), ambiguous: false, emptySince: null,
|
|
322
|
+
};
|
|
323
|
+
this.grants.set(id, grant);
|
|
324
|
+
res.end('');
|
|
325
|
+
} else {
|
|
326
|
+
this.counts.refused++;
|
|
327
|
+
res.end(denyOutput(decision.reason));
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
if (!answered) this.counts.waited++;
|
|
331
|
+
// The agent stopped waiting (killed, interrupted, timed out): forget it.
|
|
332
|
+
res.on('close', () => {
|
|
333
|
+
if (!answered) this.gate.finish(id);
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
_post(body) {
|
|
338
|
+
const hook = body?.hook ?? {};
|
|
339
|
+
const id = String(hook.tool_use_id ?? '');
|
|
340
|
+
const grant = this.grants.get(id);
|
|
341
|
+
this.grants.delete(id);
|
|
342
|
+
const was = this.gate.finish(id);
|
|
343
|
+
if (!was) return;
|
|
344
|
+
if (grant?.ambiguous) return;
|
|
345
|
+
this.history.record(was.command, {
|
|
346
|
+
peakMb: was.peakMb,
|
|
347
|
+
durationMs: Number(hook.duration_ms) || null,
|
|
348
|
+
background: was.background,
|
|
349
|
+
});
|
|
350
|
+
this.dirty = true;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/* ------------------------------ measuring ------------------------------ */
|
|
354
|
+
|
|
355
|
+
async _poll() {
|
|
356
|
+
if (this.polling) return;
|
|
357
|
+
this.polling = true;
|
|
358
|
+
try {
|
|
359
|
+
this.reading = await this.readPressure();
|
|
360
|
+
this.gate.setPressure(pressureFrom(this.reading));
|
|
361
|
+
// Processes are only looked at while something is granted: nothing
|
|
362
|
+
// else needs them, and each look costs a `ps`.
|
|
363
|
+
if (this.grants.size) this._sample(await this.sampleProcesses());
|
|
364
|
+
this.gate.tick();
|
|
365
|
+
if (this.dirty && (this.saves = (this.saves ?? 0) + 1) % 15 === 0) this._saveHistory();
|
|
366
|
+
} catch (err) {
|
|
367
|
+
this.log(`memory gate: poll failed: ${err.message}`);
|
|
368
|
+
} finally {
|
|
369
|
+
this.polling = false;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* One look at every process. Each grant finds the agent process it belongs to
|
|
375
|
+
* (the nearest `claude` or `codex` above the hook), and is measured as the
|
|
376
|
+
* new processes under that agent since it was granted. Two grants on one
|
|
377
|
+
* agent at once share what they find and teach the history nothing.
|
|
378
|
+
*/
|
|
379
|
+
_sample(procs) {
|
|
380
|
+
const byPid = new Map(procs.map((p) => [p.pid, p]));
|
|
381
|
+
const children = new Map();
|
|
382
|
+
for (const p of procs) {
|
|
383
|
+
if (!children.has(p.ppid)) children.set(p.ppid, []);
|
|
384
|
+
children.get(p.ppid).push(p);
|
|
385
|
+
}
|
|
386
|
+
const perAnchor = new Map();
|
|
387
|
+
const now = this.clock();
|
|
388
|
+
for (const grant of this.grants.values()) {
|
|
389
|
+
if (!this.gate.running.has(grant.id)) { this.grants.delete(grant.id); continue; }
|
|
390
|
+
// The last resort, for a grant whose agent could never be found: no
|
|
391
|
+
// report back and nothing to measure for three hours is over.
|
|
392
|
+
if (!grant.anchor && now - grant.startedAt >= this.maxGrantMs) {
|
|
393
|
+
this.grants.delete(grant.id);
|
|
394
|
+
this.gate.finish(grant.id);
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
if (!grant.anchor) {
|
|
398
|
+
let at = byPid.get(grant.ppid);
|
|
399
|
+
for (let up = 0; at && up < 8 && !AGENT.test(at.args); up++) at = byPid.get(at.ppid);
|
|
400
|
+
const found = at && AGENT.test(at.args) ? at : byPid.get(grant.ppid);
|
|
401
|
+
if (found) { grant.anchor = found.pid; grant.anchorArgs = found.args; }
|
|
402
|
+
}
|
|
403
|
+
const anchor = grant.anchor ? byPid.get(grant.anchor) : null;
|
|
404
|
+
// Its agent is gone, or the pid now belongs to some other program.
|
|
405
|
+
if (grant.anchor && (!anchor || anchor.args !== grant.anchorArgs)) {
|
|
406
|
+
this.grants.delete(grant.id);
|
|
407
|
+
this.gate.finish(grant.id);
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
if (!anchor) continue;
|
|
411
|
+
if (!perAnchor.has(anchor.pid)) perAnchor.set(anchor.pid, []);
|
|
412
|
+
perAnchor.get(anchor.pid).push(grant);
|
|
413
|
+
}
|
|
414
|
+
for (const [anchorPid, grants] of perAnchor) {
|
|
415
|
+
// A process is the command's when it started after the earliest grant
|
|
416
|
+
// on this agent. `ps` reports start to the second, so a second's margin
|
|
417
|
+
// errs toward counting. Without a start time, anything not seen in the
|
|
418
|
+
// previous look counts instead.
|
|
419
|
+
const since = Math.min(...grants.map((g) => g.wallStart)) - 1_000;
|
|
420
|
+
const baseline = grants.reduce((acc, g) => (acc && g.baseline.size < acc.size ? g.baseline : acc ?? g.baseline), null);
|
|
421
|
+
const ours = (p) => (typeof p.startedAt === 'number' ? p.startedAt >= since : !baseline.has(p.pid));
|
|
422
|
+
let mb = 0;
|
|
423
|
+
const stack = [...(children.get(anchorPid) ?? [])];
|
|
424
|
+
while (stack.length) {
|
|
425
|
+
const p = stack.pop();
|
|
426
|
+
if (ours(p)) mb += p.mb;
|
|
427
|
+
stack.push(...(children.get(p.pid) ?? []));
|
|
428
|
+
}
|
|
429
|
+
for (const g of grants) {
|
|
430
|
+
if (grants.length > 1) g.ambiguous = true;
|
|
431
|
+
this.gate.observe(g.id, Math.round(mb));
|
|
432
|
+
// NOTHING RUNNING UNDER IT FOR A WHILE: its "after" report was lost
|
|
433
|
+
// (the hook was killed, the agent crashed mid-call). Every running
|
|
434
|
+
// command has at least the shell it runs in, so an empty reading that
|
|
435
|
+
// lasts is a command that is over.
|
|
436
|
+
if (mb > 0) g.emptySince = null;
|
|
437
|
+
else if (g.emptySince === null) g.emptySince = now;
|
|
438
|
+
else if (now - g.emptySince >= this.idleReleaseMs) {
|
|
439
|
+
this.grants.delete(g.id);
|
|
440
|
+
this.gate.finish(g.id);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
this.lastPids = new Set(procs.map((p) => p.pid));
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
_saveHistory() {
|
|
448
|
+
if (!this.historyFile || !this.dirty) return;
|
|
449
|
+
try {
|
|
450
|
+
const tmp = `${this.historyFile}.${process.pid}.tmp`;
|
|
451
|
+
fs.writeFileSync(tmp, JSON.stringify(this.history.toJSON()));
|
|
452
|
+
fs.renameSync(tmp, this.historyFile);
|
|
453
|
+
this.dirty = false;
|
|
454
|
+
} catch (err) { this.log(`memory gate: could not save history: ${err.message}`); }
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function loadHistory(file) {
|
|
459
|
+
if (!file) return new CommandHistory();
|
|
460
|
+
try { return CommandHistory.fromJSON(JSON.parse(fs.readFileSync(file, 'utf8'))); } catch { return new CommandHistory(); }
|
|
461
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// HEAVY COMMANDS WAIT THEIR TURN WHEN MEMORY IS SHORT (w-3958c3753d).
|
|
2
|
+
//
|
|
3
|
+
// THE BOTTLENECK WAS NEVER HOW MANY AGENTS ARE OPEN. On the founder's 16 GB M4,
|
|
4
|
+
// 2026-10-04, swap reached 10.6 GB of 11.3 and the load average 180 on ten cores
|
|
5
|
+
// with four or five agents running. An agent waiting on the model is 220-340 MB;
|
|
6
|
+
// one running a test suite or a build passes a gigabyte. Agents spend most of
|
|
7
|
+
// their time waiting on the model, so many of them can be open at once as long
|
|
8
|
+
// as only a few run something heavy at the same moment.
|
|
9
|
+
//
|
|
10
|
+
// SO EVERY SHELL COMMAND AN AGENT RUNS ASKS THIS FIRST, through a hook the
|
|
11
|
+
// worker carries (scripts/memory-gate-hook.sh, main/memory-gate-server.mjs).
|
|
12
|
+
// Asking costs about 9 ms (measured with curl over the local socket, 30 runs);
|
|
13
|
+
// agents ran a median of 2 commands per active minute and at most 13, against a
|
|
14
|
+
// median of 6.8 s of model thinking between commands. This file is the answer
|
|
15
|
+
// to the question, and does no I/O so every rule below is a test.
|
|
16
|
+
//
|
|
17
|
+
// THE RULES, agreed with Codex over five rounds before any of this was written:
|
|
18
|
+
// - A command known to be light (main/memory-gate-history.mjs) starts at once,
|
|
19
|
+
// unless memory is critical. A lane of `lightLane` keeps even those bounded.
|
|
20
|
+
// - A heavy or never-seen command needs one of `slots`, and only while memory
|
|
21
|
+
// is normal. A never-seen one holds its slot for `specWindowMs`, and after
|
|
22
|
+
// that only while it is measured at `heavyMb` or more: a dev server that
|
|
23
|
+
// sits at 60 MB should not block everyone's tests for an afternoon.
|
|
24
|
+
// - Waiting commands go in the app's own order (the supervisor's `_score`:
|
|
25
|
+
// the project's place, then urgent / high / medium / low), oldest first on a
|
|
26
|
+
// tie, and a command that has waited `agingMs` moves up one level so
|
|
27
|
+
// nothing low starves behind a stream of high ones.
|
|
28
|
+
// - NOTHING LOWER SLIPS PAST A HIGHER COMMAND WAITING FOR A SLOT. Otherwise a
|
|
29
|
+
// stream of small never-seen commands keeps taking the slot an urgent test
|
|
30
|
+
// is waiting for. Light commands are the exception: they need no slot.
|
|
31
|
+
// - A running command is never stopped, paused or killed. Pausing was the
|
|
32
|
+
// first design and Codex took it apart: a paused program frees no memory,
|
|
33
|
+
// trips its own time limits, and stays frozen forever if the app dies.
|
|
34
|
+
// - No command waits forever. After `maxWaitMs` the agent is told no, with a
|
|
35
|
+
// reason it can act on, and the hook's own time limit sits above that.
|
|
36
|
+
|
|
37
|
+
import { CommandHistory, HEAVY_MB } from './memory-gate-history.mjs';
|
|
38
|
+
import { NAME } from '../shared/product-name.mjs';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The kernel's own pressure reading (`kern.memorystatus_vm_pressure_level`:
|
|
42
|
+
* 1 normal, 2 warn, 4 critical) and the free share it reports
|
|
43
|
+
* (`kern.memorystatus_level`), as one of three words. Free memory is the early
|
|
44
|
+
* signal: the kernel only says "warn" once it is already compressing hard.
|
|
45
|
+
*/
|
|
46
|
+
export function pressureFrom({ level, freePct } = {}, { tightPct = 12, criticalPct = 5 } = {}) {
|
|
47
|
+
const free = typeof freePct === 'number' && Number.isFinite(freePct) ? freePct : null;
|
|
48
|
+
if (level >= 4 || (free !== null && free < criticalPct)) return 'critical';
|
|
49
|
+
if (level >= 2 || (free !== null && free < tightPct)) return 'tight';
|
|
50
|
+
return 'normal';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** One heavy slot per 8 GB, at least one; two on a machine it cannot read. */
|
|
54
|
+
export function autoSlots(memBytes) {
|
|
55
|
+
const gb = Math.round((Number(memBytes) || 0) / 1024 ** 3);
|
|
56
|
+
if (gb <= 0) return 2;
|
|
57
|
+
return Math.max(1, Math.floor(gb / 8));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const DEFAULTS = {
|
|
61
|
+
slots: 2,
|
|
62
|
+
specWindowMs: 10_000,
|
|
63
|
+
heavyMb: HEAVY_MB,
|
|
64
|
+
lightLane: 8,
|
|
65
|
+
maxWaitMs: 20 * 60_000,
|
|
66
|
+
agingMs: 5 * 60_000,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export function refusal(waitedMs) {
|
|
70
|
+
const minutes = Math.max(1, Math.round(waitedMs / 60_000));
|
|
71
|
+
return `This computer is short of memory, so ${NAME} held this command for ${minutes} minute${minutes === 1 ? '' : 's'} `
|
|
72
|
+
+ 'while heavier work ran, and it still cannot start it. Do not retry it straight away. Carry on with anything '
|
|
73
|
+
+ 'that does not need it, or end your turn and say you are waiting for memory.';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export class MemoryGate {
|
|
77
|
+
constructor({ history = new CommandHistory(), clock = Date.now, ...opts } = {}) {
|
|
78
|
+
Object.assign(this, DEFAULTS, opts);
|
|
79
|
+
this.history = history;
|
|
80
|
+
this.clock = clock;
|
|
81
|
+
this.pressure = 'normal';
|
|
82
|
+
this.running = new Map();
|
|
83
|
+
this.waiting = new Map();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
setPressure(p) {
|
|
87
|
+
if (p === this.pressure) return;
|
|
88
|
+
this.pressure = p;
|
|
89
|
+
this._pump();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
setSlots(n) {
|
|
93
|
+
this.slots = Math.max(1, Math.round(Number(n) || 1));
|
|
94
|
+
this._pump();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Ask to run `req.command`. `reply` is called exactly once per call with
|
|
99
|
+
* `{ allow: true }` or `{ allow: false, reason }`, now or later.
|
|
100
|
+
*/
|
|
101
|
+
submit(req, reply) {
|
|
102
|
+
const id = String(req.id);
|
|
103
|
+
if (this.running.has(id)) { reply({ allow: true, cls: this.running.get(id).cls }); return; }
|
|
104
|
+
const already = this.waiting.get(id);
|
|
105
|
+
if (already) { already.replies.push(reply); return; }
|
|
106
|
+
this.waiting.set(id, {
|
|
107
|
+
id,
|
|
108
|
+
item: req.item ?? null,
|
|
109
|
+
product: req.product ?? null,
|
|
110
|
+
command: String(req.command ?? ''),
|
|
111
|
+
score: Number.isFinite(Number(req.score)) ? Number(req.score) : 0,
|
|
112
|
+
background: !!req.background,
|
|
113
|
+
cls: this.history.classify(req.command),
|
|
114
|
+
arrived: this.clock(),
|
|
115
|
+
replies: [reply],
|
|
116
|
+
});
|
|
117
|
+
this._pump();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The command ended (or its asker went away). Returns what it was, or null. */
|
|
121
|
+
finish(id) {
|
|
122
|
+
const key = String(id);
|
|
123
|
+
const was = this.running.get(key) ?? null;
|
|
124
|
+
if (was) this.running.delete(key);
|
|
125
|
+
if (this.waiting.delete(key) || was) this._pump();
|
|
126
|
+
return was;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A sampled footprint of a running command, in MB. Only the peak is kept. */
|
|
130
|
+
observe(id, mb) {
|
|
131
|
+
const r = this.running.get(String(id));
|
|
132
|
+
if (!r || !Number.isFinite(mb)) return;
|
|
133
|
+
r.peakMb = Math.max(r.peakMb ?? 0, mb);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
tick() { this._pump(); }
|
|
137
|
+
|
|
138
|
+
snapshot() {
|
|
139
|
+
const now = this.clock();
|
|
140
|
+
return {
|
|
141
|
+
pressure: this.pressure,
|
|
142
|
+
slots: this.slots,
|
|
143
|
+
running: [...this.running.values()].map((r) => ({
|
|
144
|
+
id: r.id, item: r.item, product: r.product, command: r.command, cls: r.cls,
|
|
145
|
+
peakMb: r.peakMb, ranMs: now - r.startedAt, holdsSlot: this._holdsSlot(r, now),
|
|
146
|
+
})),
|
|
147
|
+
waiting: this._ordered(now).map((w) => ({
|
|
148
|
+
id: w.id, item: w.item, product: w.product, command: w.command, cls: w.cls,
|
|
149
|
+
score: w.score, waitedMs: now - w.arrived,
|
|
150
|
+
})),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
_holdsSlot(r, now) {
|
|
155
|
+
if ((r.peakMb ?? 0) >= this.heavyMb) return true;
|
|
156
|
+
if (r.cls === 'heavy') return true;
|
|
157
|
+
if (r.cls === 'unknown') {
|
|
158
|
+
if (now - r.startedAt < this.specWindowMs) return true;
|
|
159
|
+
// Never measured is not evidence that it is small.
|
|
160
|
+
return r.peakMb === null;
|
|
161
|
+
}
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
_ordered(now) {
|
|
166
|
+
const eff = (w) => w.score + Math.floor((now - w.arrived) / this.agingMs);
|
|
167
|
+
return [...this.waiting.values()].sort((a, b) => (eff(b) - eff(a)) || (a.arrived - b.arrived));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
_pump() {
|
|
171
|
+
const now = this.clock();
|
|
172
|
+
for (const w of [...this.waiting.values()]) {
|
|
173
|
+
if (now - w.arrived >= this.maxWaitMs) {
|
|
174
|
+
this.waiting.delete(w.id);
|
|
175
|
+
for (const r of w.replies) r({ allow: false, reason: refusal(now - w.arrived) });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (this.pressure === 'critical') return;
|
|
179
|
+
let slotsUsed = 0;
|
|
180
|
+
let lightUsed = 0;
|
|
181
|
+
for (const r of this.running.values()) {
|
|
182
|
+
if (this._holdsSlot(r, now)) slotsUsed++;
|
|
183
|
+
else if (r.cls === 'light') lightUsed++;
|
|
184
|
+
}
|
|
185
|
+
let blocked = false;
|
|
186
|
+
for (const w of this._ordered(now)) {
|
|
187
|
+
if (w.cls === 'light') {
|
|
188
|
+
if (lightUsed < this.lightLane) { this._admit(w, now); lightUsed++; }
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (blocked) continue;
|
|
192
|
+
if (this.pressure === 'tight' || slotsUsed >= this.slots) { blocked = true; continue; }
|
|
193
|
+
this._admit(w, now);
|
|
194
|
+
slotsUsed++;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
_admit(w, now) {
|
|
199
|
+
this.waiting.delete(w.id);
|
|
200
|
+
this.running.set(w.id, {
|
|
201
|
+
id: w.id, item: w.item, product: w.product, command: w.command, cls: w.cls,
|
|
202
|
+
background: w.background, startedAt: now, peakMb: null,
|
|
203
|
+
});
|
|
204
|
+
for (const r of w.replies) r({ allow: true, cls: w.cls });
|
|
205
|
+
}
|
|
206
|
+
}
|