@addai/node 0.4.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.
Files changed (133) hide show
  1. package/README.md +96 -0
  2. package/dist/agent-auth.d.ts +9 -0
  3. package/dist/agent-auth.js +96 -0
  4. package/dist/attachments.d.ts +20 -0
  5. package/dist/attachments.js +164 -0
  6. package/dist/capabilities.d.ts +64 -0
  7. package/dist/capabilities.js +406 -0
  8. package/dist/claude-binary.d.ts +16 -0
  9. package/dist/claude-binary.js +145 -0
  10. package/dist/claude-config.d.ts +11 -0
  11. package/dist/claude-config.js +144 -0
  12. package/dist/claude-print.d.ts +35 -0
  13. package/dist/claude-print.js +312 -0
  14. package/dist/claude-spawn.d.ts +40 -0
  15. package/dist/claude-spawn.js +220 -0
  16. package/dist/cli.d.ts +2 -0
  17. package/dist/cli.js +137 -0
  18. package/dist/codex-binary.d.ts +4 -0
  19. package/dist/codex-binary.js +70 -0
  20. package/dist/codex-spawn.d.ts +47 -0
  21. package/dist/codex-spawn.js +320 -0
  22. package/dist/command-runner.d.ts +9 -0
  23. package/dist/command-runner.js +592 -0
  24. package/dist/config.d.ts +7 -0
  25. package/dist/config.js +38 -0
  26. package/dist/control-server.d.ts +5 -0
  27. package/dist/control-server.js +292 -0
  28. package/dist/diskguard.d.ts +96 -0
  29. package/dist/diskguard.js +435 -0
  30. package/dist/error-classify.d.ts +20 -0
  31. package/dist/error-classify.js +159 -0
  32. package/dist/events.d.ts +26 -0
  33. package/dist/events.js +97 -0
  34. package/dist/gemini-binary.d.ts +4 -0
  35. package/dist/gemini-binary.js +64 -0
  36. package/dist/gemini-spawn.d.ts +52 -0
  37. package/dist/gemini-spawn.js +369 -0
  38. package/dist/git-identity.d.ts +18 -0
  39. package/dist/git-identity.js +104 -0
  40. package/dist/grok-binary.d.ts +4 -0
  41. package/dist/grok-binary.js +70 -0
  42. package/dist/grok-spawn.d.ts +41 -0
  43. package/dist/grok-spawn.js +495 -0
  44. package/dist/guardrails.d.ts +32 -0
  45. package/dist/guardrails.js +125 -0
  46. package/dist/hang-watchdog.d.ts +17 -0
  47. package/dist/hang-watchdog.js +48 -0
  48. package/dist/harness-registry.d.ts +55 -0
  49. package/dist/harness-registry.js +180 -0
  50. package/dist/heartbeat.d.ts +13 -0
  51. package/dist/heartbeat.js +95 -0
  52. package/dist/index.d.ts +12 -0
  53. package/dist/index.js +440 -0
  54. package/dist/install.d.ts +63 -0
  55. package/dist/install.js +467 -0
  56. package/dist/jsonl-tail.d.ts +20 -0
  57. package/dist/jsonl-tail.js +192 -0
  58. package/dist/kimi-binary.d.ts +4 -0
  59. package/dist/kimi-binary.js +68 -0
  60. package/dist/kimi-spawn.d.ts +47 -0
  61. package/dist/kimi-spawn.js +319 -0
  62. package/dist/lockfile.d.ts +30 -0
  63. package/dist/lockfile.js +169 -0
  64. package/dist/mcp-config.d.ts +23 -0
  65. package/dist/mcp-config.js +133 -0
  66. package/dist/memory-capture.d.ts +20 -0
  67. package/dist/memory-capture.js +45 -0
  68. package/dist/memory-pack.d.ts +34 -0
  69. package/dist/memory-pack.js +171 -0
  70. package/dist/model-filter.d.ts +4 -0
  71. package/dist/model-filter.js +34 -0
  72. package/dist/node-health.d.ts +18 -0
  73. package/dist/node-health.js +74 -0
  74. package/dist/pairing.d.ts +20 -0
  75. package/dist/pairing.js +129 -0
  76. package/dist/paths.d.ts +9 -0
  77. package/dist/paths.js +48 -0
  78. package/dist/precompact-hook.d.ts +27 -0
  79. package/dist/precompact-hook.js +136 -0
  80. package/dist/projects.d.ts +25 -0
  81. package/dist/projects.js +255 -0
  82. package/dist/prompt-keystrokes.d.ts +3 -0
  83. package/dist/prompt-keystrokes.js +56 -0
  84. package/dist/pty-helper.d.ts +26 -0
  85. package/dist/pty-helper.js +156 -0
  86. package/dist/request-pump.d.ts +12 -0
  87. package/dist/request-pump.js +249 -0
  88. package/dist/run-retry.d.ts +28 -0
  89. package/dist/run-retry.js +81 -0
  90. package/dist/self-update.d.ts +75 -0
  91. package/dist/self-update.js +192 -0
  92. package/dist/session-runner.d.ts +130 -0
  93. package/dist/session-runner.js +2271 -0
  94. package/dist/sleep-detector.d.ts +11 -0
  95. package/dist/sleep-detector.js +32 -0
  96. package/dist/state-migrate.d.ts +8 -0
  97. package/dist/state-migrate.js +91 -0
  98. package/dist/store.d.ts +5 -0
  99. package/dist/store.js +82 -0
  100. package/dist/supabase-client.d.ts +15 -0
  101. package/dist/supabase-client.js +119 -0
  102. package/dist/time-context.d.ts +10 -0
  103. package/dist/time-context.js +71 -0
  104. package/dist/tokens.d.ts +4 -0
  105. package/dist/tokens.js +33 -0
  106. package/dist/tui/app.d.ts +35 -0
  107. package/dist/tui/app.js +82 -0
  108. package/dist/tui/console-capture.d.ts +13 -0
  109. package/dist/tui/console-capture.js +62 -0
  110. package/dist/tui/dashboard.d.ts +42 -0
  111. package/dist/tui/dashboard.js +205 -0
  112. package/dist/tui/data.d.ts +57 -0
  113. package/dist/tui/data.js +62 -0
  114. package/dist/tui/harnesses.d.ts +16 -0
  115. package/dist/tui/harnesses.js +354 -0
  116. package/dist/tui/render.d.ts +21 -0
  117. package/dist/tui/render.js +116 -0
  118. package/dist/tui/requests.d.ts +18 -0
  119. package/dist/tui/requests.js +127 -0
  120. package/dist/tui/run.d.ts +13 -0
  121. package/dist/tui/run.js +178 -0
  122. package/dist/tui/transcript.d.ts +39 -0
  123. package/dist/tui/transcript.js +234 -0
  124. package/dist/tui.d.ts +1 -0
  125. package/dist/tui.js +314 -0
  126. package/dist/types.d.ts +65 -0
  127. package/dist/types.js +3 -0
  128. package/dist/win.d.ts +67 -0
  129. package/dist/win.js +317 -0
  130. package/package.json +60 -0
  131. package/scripts/fix-pty-helper.js +28 -0
  132. package/scripts/precompact-capture.js +292 -0
  133. package/scripts/smoke-test.sh +74 -0
@@ -0,0 +1,56 @@
1
+ "use strict";
2
+ // Encodes a user prompt into the raw keystroke byte-stream we type into
3
+ // the Claude TUI's PTY before pressing Enter to submit.
4
+ //
5
+ // THE BUG THIS FIXES — "half the prompt gets cut off in TUI mode":
6
+ // The TUI path types the prompt into the PTY as raw keystrokes. The
7
+ // terminal/Ink input parser interprets terminal control bytes, so any
8
+ // literal ESC / C0 control character *inside the prompt content* (e.g.
9
+ // ANSI colour codes like \x1b[31m that get captured into a for/loop
10
+ // node's item data) is read as the start of an escape sequence and
11
+ // SWALLOWS the surrounding text. An ESC before a bracketed run
12
+ // (\x1b[0,1,...,199]) eats everything up to the terminating "]" — which
13
+ // is how a single stray byte truncates ~half a prompt. It is
14
+ // content-dependent, hence "sometimes".
15
+ //
16
+ // Print mode (`claude -p <prompt>`) is immune because the prompt is
17
+ // passed as an argv string and never parsed as terminal input.
18
+ //
19
+ // Bracketed paste (\x1b[200~ … \x1b[201~) does NOT help — the terminal
20
+ // strips/interprets the embedded ESC regardless of the paste framing.
21
+ //
22
+ // THE FIX: strip terminal control sequences/characters OUT of the content
23
+ // first, THEN turn real newlines into the in-message line-break chord.
24
+ //
25
+ // Newlines must become ESC+CR (Alt/Shift+Enter inside claude) — a bare \r
26
+ // is Enter=submit and would fire the message at the first newline. The
27
+ // ESC+CR pairs are added AFTER sanitisation so they aren't stripped.
28
+ Object.defineProperty(exports, "__esModule", { value: true });
29
+ exports.TUI_NEWLINE = void 0;
30
+ exports.stripTerminalControls = stripTerminalControls;
31
+ exports.encodeTuiPromptKeystrokes = encodeTuiPromptKeystrokes;
32
+ // The in-message line-break chord (Alt/Shift+Enter) as the TUI sees it.
33
+ exports.TUI_NEWLINE = '\x1b\r';
34
+ // Strip ANSI/VT escape sequences and stray C0 control bytes from prompt
35
+ // content. Tabs (\t) and newlines (\n) are preserved; \r is normalized to
36
+ // \n separately by the caller path below.
37
+ function stripTerminalControls(text) {
38
+ return text
39
+ // eslint-disable-next-line no-control-regex
40
+ .replace(/\x1b\[[0-9;:<=>?]*[ -\/]*[@-~]/g, '') // CSI sequences (colours, cursor moves, …)
41
+ // eslint-disable-next-line no-control-regex
42
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '') // OSC sequences (titles, hyperlinks, …)
43
+ // eslint-disable-next-line no-control-regex
44
+ .replace(/\x1b[@-Z\\-_]/g, '') // two-character ESC sequences
45
+ // eslint-disable-next-line no-control-regex
46
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, ''); // residual C0 controls (keep \t \n)
47
+ }
48
+ function encodeTuiPromptKeystrokes(prompt) {
49
+ // 1. Remove terminal control bytes so nothing in the content is parsed
50
+ // as an escape sequence that eats surrounding text.
51
+ const safe = stripTerminalControls(prompt);
52
+ // 2. Normalize CRLF / lone CR to LF so the rewrite is uniform.
53
+ const normalized = safe.replace(/\r\n?/g, '\n');
54
+ // 3. Turn real newlines into the in-message line-break chord.
55
+ return normalized.replace(/\n/g, exports.TUI_NEWLINE);
56
+ }
@@ -0,0 +1,26 @@
1
+ export interface PtyHelperRepair {
2
+ /** Helpers that were not executable and now are. */
3
+ repaired: string[];
4
+ failed: Array<{
5
+ path: string;
6
+ error: string;
7
+ }>;
8
+ }
9
+ /** node-pty's package root, resolved the way Node itself would — so a
10
+ * hoisted copy is found just as reliably as a nested one. `fromDir`
11
+ * overrides the resolution base (this module's own directory by default).
12
+ * Returns null when node-pty isn't installed. */
13
+ export declare function findNodePtyDir(fromDir?: string): string | null;
14
+ /** chmod +x every spawn-helper under `nodePtyDir` that lacks it. Only
15
+ * spawn-helper: the sibling pty.node is dlopen'd, never executed. Never
16
+ * throws — a runtime that can't repair itself must still boot. */
17
+ export declare function repairPtyHelpers(nodePtyDir: string | null): PtyHelperRepair;
18
+ /** Resolve node-pty and restore its helper's +x bit. Safe to call on every
19
+ * boot and before any PTY spawn: a healthy install costs a few stats. */
20
+ export declare function ensurePtyHelperExecutable(fromDir?: string): PtyHelperRepair & {
21
+ dir: string | null;
22
+ };
23
+ /** One-line summary for logs, or null when there was nothing to say. */
24
+ export declare function describeRepair(r: PtyHelperRepair & {
25
+ dir: string | null;
26
+ }): string | null;
@@ -0,0 +1,156 @@
1
+ "use strict";
2
+ // Keeps node-pty's `spawn-helper` executable.
3
+ //
4
+ // On macOS node-pty does not fork/exec the target itself: it posix_spawn()s
5
+ // a small `spawn-helper` binary as argv[0] (cwd, then the real command,
6
+ // follow as argv[1..]). That helper ships inside node-pty's prebuilds, and
7
+ // npm's tarball extraction sometimes lands it as 0644 — reproducibly so for
8
+ // installs made through `npx`, which is how AiNode daemons run. posix_spawn
9
+ // then fails with EACCES and node-pty throws the opaque
10
+ //
11
+ // posix_spawnp failed.
12
+ //
13
+ // which is all the operator ever sees. Every PTY path on that machine is
14
+ // dead: remote harness logins (codex --device-auth, claude/gemini OAuth)
15
+ // and claude TUI runs alike.
16
+ //
17
+ // A postinstall script has always tried to restore the bit, but it looked
18
+ // only in <our-package>/node_modules/node-pty — a path that exists only when
19
+ // node-pty is NESTED. npm hoists shared deps to the tree root, so under npx
20
+ // node-pty sits beside us, not beneath us, and the fix silently no-op'd on
21
+ // exactly the installs that needed it (live incident 2026-07-29,
22
+ // Lennys-Mac-mini). Resolve the package instead of guessing its path, and
23
+ // re-check at daemon start so an already-broken install heals on restart
24
+ // rather than waiting for a reinstall.
25
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
26
+ if (k2 === undefined) k2 = k;
27
+ var desc = Object.getOwnPropertyDescriptor(m, k);
28
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
29
+ desc = { enumerable: true, get: function() { return m[k]; } };
30
+ }
31
+ Object.defineProperty(o, k2, desc);
32
+ }) : (function(o, m, k, k2) {
33
+ if (k2 === undefined) k2 = k;
34
+ o[k2] = m[k];
35
+ }));
36
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
37
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
38
+ }) : function(o, v) {
39
+ o["default"] = v;
40
+ });
41
+ var __importStar = (this && this.__importStar) || (function () {
42
+ var ownKeys = function(o) {
43
+ ownKeys = Object.getOwnPropertyNames || function (o) {
44
+ var ar = [];
45
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
46
+ return ar;
47
+ };
48
+ return ownKeys(o);
49
+ };
50
+ return function (mod) {
51
+ if (mod && mod.__esModule) return mod;
52
+ var result = {};
53
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
54
+ __setModuleDefault(result, mod);
55
+ return result;
56
+ };
57
+ })();
58
+ Object.defineProperty(exports, "__esModule", { value: true });
59
+ exports.findNodePtyDir = findNodePtyDir;
60
+ exports.repairPtyHelpers = repairPtyHelpers;
61
+ exports.ensurePtyHelperExecutable = ensurePtyHelperExecutable;
62
+ exports.describeRepair = describeRepair;
63
+ const fs = __importStar(require("fs"));
64
+ const path = __importStar(require("path"));
65
+ /** node-pty's package root, resolved the way Node itself would — so a
66
+ * hoisted copy is found just as reliably as a nested one. `fromDir`
67
+ * overrides the resolution base (this module's own directory by default).
68
+ * Returns null when node-pty isn't installed. */
69
+ function findNodePtyDir(fromDir) {
70
+ const paths = [fromDir ?? __dirname];
71
+ let entry;
72
+ try {
73
+ // Resolve the package's main entry rather than a subpath: an "exports"
74
+ // map can refuse `node-pty/package.json` outright.
75
+ entry = require.resolve('node-pty', { paths });
76
+ }
77
+ catch {
78
+ return null;
79
+ }
80
+ // Walk up from lib/index.js to the directory holding node-pty's manifest.
81
+ let dir = path.dirname(entry);
82
+ for (let i = 0; i < 5; i++) {
83
+ try {
84
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
85
+ if (pkg.name === 'node-pty')
86
+ return dir;
87
+ }
88
+ catch { /* keep climbing */ }
89
+ const up = path.dirname(dir);
90
+ if (up === dir)
91
+ break;
92
+ dir = up;
93
+ }
94
+ return null;
95
+ }
96
+ /** Every place node-pty may keep a `spawn-helper`: a locally compiled build
97
+ * and each shipped prebuild arch. */
98
+ function helperCandidates(nodePtyDir) {
99
+ const out = [
100
+ path.join(nodePtyDir, 'build', 'Release', 'spawn-helper'),
101
+ path.join(nodePtyDir, 'build', 'Debug', 'spawn-helper'),
102
+ ];
103
+ const prebuilds = path.join(nodePtyDir, 'prebuilds');
104
+ try {
105
+ for (const arch of fs.readdirSync(prebuilds))
106
+ out.push(path.join(prebuilds, arch, 'spawn-helper'));
107
+ }
108
+ catch { /* no prebuilds dir — build/ candidates still stand */ }
109
+ return out;
110
+ }
111
+ /** chmod +x every spawn-helper under `nodePtyDir` that lacks it. Only
112
+ * spawn-helper: the sibling pty.node is dlopen'd, never executed. Never
113
+ * throws — a runtime that can't repair itself must still boot. */
114
+ function repairPtyHelpers(nodePtyDir) {
115
+ const res = { repaired: [], failed: [] };
116
+ if (!nodePtyDir)
117
+ return res;
118
+ for (const helper of helperCandidates(nodePtyDir)) {
119
+ let mode;
120
+ try {
121
+ const st = fs.statSync(helper);
122
+ if (!st.isFile())
123
+ continue;
124
+ mode = st.mode;
125
+ }
126
+ catch {
127
+ continue;
128
+ } // not present for this arch/build
129
+ if ((mode & 0o111) !== 0)
130
+ continue; // already executable
131
+ try {
132
+ fs.chmodSync(helper, (mode & 0o777) | 0o755);
133
+ res.repaired.push(helper);
134
+ }
135
+ catch (err) {
136
+ res.failed.push({ path: helper, error: err.message });
137
+ }
138
+ }
139
+ return res;
140
+ }
141
+ /** Resolve node-pty and restore its helper's +x bit. Safe to call on every
142
+ * boot and before any PTY spawn: a healthy install costs a few stats. */
143
+ function ensurePtyHelperExecutable(fromDir) {
144
+ const dir = findNodePtyDir(fromDir);
145
+ return { dir, ...repairPtyHelpers(dir) };
146
+ }
147
+ /** One-line summary for logs, or null when there was nothing to say. */
148
+ function describeRepair(r) {
149
+ if (r.repaired.length > 0) {
150
+ return `[pty] restored +x on node-pty spawn-helper (${r.repaired.length}): ${r.repaired.join(', ')}`;
151
+ }
152
+ if (r.failed.length > 0) {
153
+ return `[pty] spawn-helper is not executable and could not be fixed: ${r.failed.map(f => `${f.path} (${f.error})`).join(', ')}`;
154
+ }
155
+ return null;
156
+ }
@@ -0,0 +1,12 @@
1
+ export declare function inflightCount(): number;
2
+ export declare function start(): void;
3
+ export declare function stop(): void;
4
+ /**
5
+ * Wait for all in-flight requests to finish, with a hard ceiling so a
6
+ * hung spawn can't block daemon shutdown forever. Resolves when either
7
+ * all promises settle OR the timeout elapses.
8
+ */
9
+ export declare function drain(timeoutMs: number): Promise<{
10
+ drained: number;
11
+ timedOut: number;
12
+ }>;
@@ -0,0 +1,249 @@
1
+ "use strict";
2
+ // Polls Supabase for pending requests targeted at this runtime and runs
3
+ // them via session-runner. Bounded concurrency keeps a single runtime
4
+ // from spawning unlimited Claude processes when a burst of requests
5
+ // arrives.
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.inflightCount = inflightCount;
8
+ exports.start = start;
9
+ exports.stop = stop;
10
+ exports.drain = drain;
11
+ const supabase_client_1 = require("./supabase-client");
12
+ const store_1 = require("./store");
13
+ const session_runner_1 = require("./session-runner");
14
+ const node_health_1 = require("./node-health");
15
+ const diskguard_1 = require("./diskguard");
16
+ // Poll cadence for new pending requests. We previously polled every
17
+ // 250ms (4 RPS per daemon) which sounded harmless but under load —
18
+ // each call could hold a Supabase pooler connection for the full RPC
19
+ // timeout — three or four paired daemons would saturate the project's
20
+ // connection pool, every request 522'd, and the daemon's own RPCs
21
+ // timed out too, producing a death spiral. 2s is a fine UX trade
22
+ // (chat_send lands within 2s vs 250ms; users won't notice) and is
23
+ // ~16x cheaper at steady state. Successful pickups still re-tick
24
+ // immediately so bursts drain fast.
25
+ const POLL_INTERVAL_MS = 2_000;
26
+ const MAX_CONCURRENT = 4;
27
+ // Anti-leak backstop ONLY — how long the pump will hold a concurrency slot
28
+ // for a run whose promise never resolves (e.g. a spawn whose onExit is
29
+ // lost). This does NOT kill the agent and is NOT an execution limit: it
30
+ // only frees the slot so a leak can't wedge the pump; the run keeps going
31
+ // and keeps heartbeating if it's still alive. Real time limits come from
32
+ // the node's timeout (enforced in session-runner), never from here. For a
33
+ // request WITH a node timeout we pad it; with none (unlimited) we fall
34
+ // back to this large default.
35
+ const SLOT_LEAK_BACKSTOP_MS = 12 * 60 * 60 * 1000; // 12 hours
36
+ const SLOT_TIMEOUT_GRACE_MS = 5 * 60 * 1000; // pad over a node timeout
37
+ let timer = null;
38
+ let stopped = false;
39
+ let inflight = 0;
40
+ // Request ids with a runRequest currently in-flight on THIS daemon.
41
+ // Guards against launching a second run for the same request — e.g. if a
42
+ // reclaim ever requeues one whose spawn is still alive — which is exactly
43
+ // the duplicate-session / wrong-attribution bug we're fixing.
44
+ const activeRequestIds = new Set();
45
+ // Failure backoff state: when Supabase goes unreachable we used to log
46
+ // `pick failed: fetch failed` every 250ms (= 14k/hour) and keep hammering
47
+ // the endpoint. Now we exponentially back off and dedupe identical
48
+ // errors: only the first occurrence + once per minute thereafter is
49
+ // logged, and the next-tick delay is `min(60s, base * 2^n)` so a 10-min
50
+ // outage produces ~10 log lines instead of 240,000.
51
+ let consecutivePickFailures = 0;
52
+ let lastLoggedErrorMessage = '';
53
+ let lastLoggedAt = 0;
54
+ let nextPickAllowedAt = 0;
55
+ // Health-gate log dedupe: when we refuse pickup (e.g. disk full) we don't
56
+ // want a log line every 2s. Log the first occurrence + once/minute.
57
+ let lastHealthBlockLogged = '';
58
+ let lastHealthBlockAt = 0;
59
+ // Exported so /stats can surface "pump stuck at N inflight" without
60
+ // having to guess.
61
+ function inflightCount() { return inflight; }
62
+ async function tick() {
63
+ if (stopped)
64
+ return;
65
+ if (inflight >= MAX_CONCURRENT)
66
+ return;
67
+ // Honour the backoff window — skip this tick if we recently failed.
68
+ if (Date.now() < nextPickAllowedAt)
69
+ return;
70
+ // Honour the shared circuit breaker — when Supabase is in distress
71
+ // every poller in the daemon pauses, not just this one.
72
+ if ((0, supabase_client_1.rpcShouldSkip)())
73
+ return;
74
+ const pairing = (0, store_1.readPairing)();
75
+ if (!pairing)
76
+ return;
77
+ // Health gate: don't ACCEPT work this node physically can't finish. A full
78
+ // scratch disk makes every spawn die with ENOSPC — by refusing the pickup
79
+ // we leave the request `pending` so the server-side failover hands it to a
80
+ // healthy backup, instead of us picking it up only to fail it immediately.
81
+ const health = (0, node_health_1.evaluateNodeHealth)({ diskUsedPct: (0, node_health_1.readDiskUsedPct)((0, diskguard_1.getDiskGuard)().config.tempRoot) });
82
+ if (!health.healthy) {
83
+ const msg = health.blockers.map(b => b.message).join('; ');
84
+ const now = Date.now();
85
+ if (msg !== lastHealthBlockLogged || (now - lastHealthBlockAt) > 60_000) {
86
+ console.warn(`[request-pump] refusing pickup — node unhealthy: ${msg}`);
87
+ lastHealthBlockLogged = msg;
88
+ lastHealthBlockAt = now;
89
+ }
90
+ // Kick a disk sweep so we recover and resume accepting work.
91
+ void (0, diskguard_1.getDiskGuard)().sweep('health-gate').catch(() => { });
92
+ return;
93
+ }
94
+ let row = null;
95
+ try {
96
+ // runtime_pick_next_request returns a row record. PostgREST wraps
97
+ // it as an object or null depending on the function shape.
98
+ const result = await (0, supabase_client_1.rpc)('runtime_pick_next_request', {
99
+ p_token: pairing.daemonToken,
100
+ });
101
+ row = (result && result.id) ? result : null;
102
+ // Success — reset backoff state.
103
+ if (consecutivePickFailures > 0) {
104
+ console.log(`[request-pump] recovered after ${consecutivePickFailures} consecutive failures`);
105
+ consecutivePickFailures = 0;
106
+ lastLoggedErrorMessage = '';
107
+ }
108
+ }
109
+ catch (err) {
110
+ if (err instanceof supabase_client_1.RpcError && err.status === 401) {
111
+ console.error('[request-pump] unauthorized; will retry on next heartbeat.');
112
+ return;
113
+ }
114
+ consecutivePickFailures++;
115
+ // Exponential backoff: 250ms × 2^n, capped at 60s. After 8 failures
116
+ // we're at the cap so a sustained outage doesn't snowball.
117
+ const backoffMs = Math.min(60_000, POLL_INTERVAL_MS * Math.pow(2, Math.min(consecutivePickFailures, 8)));
118
+ nextPickAllowedAt = Date.now() + backoffMs;
119
+ const msg = err.message;
120
+ const now = Date.now();
121
+ // Log first occurrence + every 60s of a sustained failure stream,
122
+ // plus on message change.
123
+ if (msg !== lastLoggedErrorMessage || (now - lastLoggedAt) > 60_000) {
124
+ console.error(`[request-pump] pick failed (#${consecutivePickFailures}, backing off ${backoffMs}ms):`, msg);
125
+ lastLoggedErrorMessage = msg;
126
+ lastLoggedAt = now;
127
+ }
128
+ return;
129
+ }
130
+ if (!row)
131
+ return;
132
+ // Defense-in-depth: never run the same request twice concurrently. The
133
+ // server-side reclaim now keys off real activity so it shouldn't requeue
134
+ // a live request, but if one ever slips through, running it again is the
135
+ // double-session bug. Leave the row as the pick RPC set it; the original
136
+ // in-flight run will carry it to a terminal state.
137
+ if (activeRequestIds.has(row.id)) {
138
+ console.warn(`[request-pump] skipping re-pick of in-flight request [reqId=${row.id}] — already running`);
139
+ return;
140
+ }
141
+ inflight++;
142
+ const reqId = row.id;
143
+ activeRequestIds.add(reqId);
144
+ // Liveness heartbeat: refresh last_activity_at every 60s while this
145
+ // request runs, so the server-side reclaim (which keys off real activity,
146
+ // not the one-shot picked_up_at) never requeues a healthy long-running
147
+ // agent out from under us and re-spawns it as a new session. Bounded by
148
+ // the hard-timeout race below — when that settles, the interval clears.
149
+ const heartbeat = setInterval(() => {
150
+ void (0, supabase_client_1.rpc)('runtime_request_touch', {
151
+ p_token: pairing.daemonToken,
152
+ p_request_id: reqId,
153
+ }).catch(() => { });
154
+ }, 60_000);
155
+ heartbeat.unref?.();
156
+ // Track for graceful drain on SIGTERM/SIGINT.
157
+ let resolveInflight;
158
+ const inflightPromise = new Promise((r) => { resolveInflight = r; });
159
+ inflightPromises.add(inflightPromise);
160
+ // Race the real promise against a hard timeout. Whichever settles
161
+ // first wins; the timeout's resolution is enough to free the slot —
162
+ // server-side reclaim handles the database row.
163
+ let timeoutFired = false;
164
+ // Slot backstop = node timeout + grace if the node set one, else the
165
+ // large leak-only default. Frees the slot; never kills the run.
166
+ const slotBackstopMs = (row.timeout_seconds && row.timeout_seconds > 0)
167
+ ? row.timeout_seconds * 1000 + SLOT_TIMEOUT_GRACE_MS
168
+ : SLOT_LEAK_BACKSTOP_MS;
169
+ const timeoutPromise = new Promise((resolve) => {
170
+ const t = setTimeout(() => {
171
+ timeoutFired = true;
172
+ console.error(`[request-pump] runRequest exceeded slot backstop (${Math.round(slotBackstopMs / 60000)}m) [reqId=${reqId}] — freeing pump slot. The run continues; server-side reclaim handles the DB row only if it's actually dead.`);
173
+ resolve();
174
+ }, slotBackstopMs);
175
+ t.unref?.();
176
+ });
177
+ const runPromise = (0, session_runner_1.runRequest)(row).catch(err => console.error(`[request-pump] runRequest threw [reqId=${reqId}]: ${err.message}`));
178
+ // Heartbeat + in-flight registration live for the REAL lifetime of the
179
+ // run (until the spawn exits), NOT just until the slot's hard timeout.
180
+ // A task that runs longer than REQUEST_HARD_TIMEOUT_MS must keep
181
+ // refreshing last_activity_at, or the server-side reclaim would requeue
182
+ // and re-spawn it mid-run — the exact double-session bug we're fixing.
183
+ void runPromise.finally(() => {
184
+ clearInterval(heartbeat);
185
+ activeRequestIds.delete(reqId);
186
+ // Drain tracking follows the REAL run, not the slot. These used to be
187
+ // released together on the race below, which meant a run that outlived
188
+ // its slot backstop vanished from `inflightPromises` while its child was
189
+ // still working — so drain() had nothing to wait for and a shutdown or a
190
+ // remote roll would hand over mid-turn. The backstop exists to free a
191
+ // concurrency slot, not to declare the run finished.
192
+ inflightPromises.delete(inflightPromise);
193
+ resolveInflight();
194
+ });
195
+ // Slot accounting is separate: free the concurrency slot when the run
196
+ // finishes OR after the hard timeout (whichever is first), so a run that
197
+ // never resolves (onExit lost) can't leak a slot forever. The run keeps
198
+ // going + keeps heartbeating past this point if it's still alive.
199
+ Promise.race([runPromise, timeoutPromise]).finally(() => {
200
+ if (!timeoutFired)
201
+ inflight--;
202
+ else
203
+ inflight = Math.max(0, inflight - 1);
204
+ });
205
+ // Re-tick immediately so back-to-back requests don't wait for the
206
+ // next interval. If there's nothing to pick up the next call is a
207
+ // cheap no-op.
208
+ if (!stopped && inflight < MAX_CONCURRENT) {
209
+ queueMicrotask(() => { void tick(); });
210
+ }
211
+ }
212
+ // Set of pending inflight promises — one per active runRequest. Used
213
+ // by `drain()` to await graceful completion on SIGTERM.
214
+ const inflightPromises = new Set();
215
+ function start() {
216
+ if (timer || stopped)
217
+ return;
218
+ // Fire immediately so the first request after pairing isn't held up
219
+ // by a full polling interval.
220
+ void tick();
221
+ timer = setInterval(() => { void tick(); }, POLL_INTERVAL_MS);
222
+ }
223
+ function stop() {
224
+ stopped = true;
225
+ if (timer) {
226
+ clearInterval(timer);
227
+ timer = null;
228
+ }
229
+ }
230
+ /**
231
+ * Wait for all in-flight requests to finish, with a hard ceiling so a
232
+ * hung spawn can't block daemon shutdown forever. Resolves when either
233
+ * all promises settle OR the timeout elapses.
234
+ */
235
+ async function drain(timeoutMs) {
236
+ if (inflightPromises.size === 0)
237
+ return { drained: 0, timedOut: 0 };
238
+ const started = inflightPromises.size;
239
+ const deadline = new Promise((resolve) => {
240
+ const t = setTimeout(resolve, timeoutMs);
241
+ t.unref?.();
242
+ });
243
+ await Promise.race([
244
+ Promise.allSettled([...inflightPromises]).then(() => undefined),
245
+ deadline,
246
+ ]);
247
+ const remaining = inflightPromises.size;
248
+ return { drained: started - remaining, timedOut: remaining };
249
+ }
@@ -0,0 +1,28 @@
1
+ export interface FailIo {
2
+ /** retry_count from the request row at pick time — drives the backoff step. */
3
+ retryCount: number;
4
+ /** ladder_hops from the request row at pick time. */
5
+ ladderCount: number;
6
+ /** Ask the server to requeue (runtime_request_mark_retry). Resolves true if
7
+ * requeued, false if the retry cap is already spent. */
8
+ markRetry: (code: string | undefined, message: string | undefined, delayMs: number) => Promise<boolean>;
9
+ /** Clear the stored resume session id (request row + parent chat) AND requeue
10
+ * the request, in one server-side call. Resolves true if requeued fresh,
11
+ * false if the retry cap is spent / row no longer retryable. Optional so
12
+ * older call sites (and tests) can omit it — when absent, a session-invalid
13
+ * failure falls through to the classify+ladder tail just as any other
14
+ * failure would. */
15
+ clearSessionAndRetry?: (code: string | undefined, message: string | undefined, delayMs: number) => Promise<boolean>;
16
+ /** Server decides: hop to the next eligible provider ('laddered') or write
17
+ * the terminal dead_letter status ('dead_letter'). This is BOTH the ladder
18
+ * path AND the dead-letter writer — the server picks based on remaining
19
+ * ladder budget + eligible online agents. */
20
+ ladderOrDeadLetter: (code: string | undefined, message: string | undefined) => Promise<'laddered' | 'dead_letter'>;
21
+ /** Write the terminal failed status. Only used as a last-resort local write
22
+ * when ladderOrDeadLetter itself is unreachable — the server is otherwise
23
+ * the single writer of terminal/dead_letter status. */
24
+ writeFailed: (code?: string, message?: string) => Promise<void>;
25
+ /** Fire-and-forget request event for observability. */
26
+ emit: (type: string, payload: unknown) => unknown;
27
+ }
28
+ export declare function failOrRetry(io: FailIo, errorCode?: string, errorMessage?: string): Promise<'retried' | 'laddered' | 'failed'>;
@@ -0,0 +1,81 @@
1
+ "use strict";
2
+ // Single choke point for "this run did not finish cleanly": classify the
3
+ // error, and either requeue the request for a same-provider retry (transient,
4
+ // server-side cap not reached), hop to the next provider in the ladder
5
+ // (ladderable, or transient budget spent), or let the server write the
6
+ // terminal dead_letter status (terminal, or ladder budget spent). The
7
+ // requeue/ladder are server-side status flips back to 'pending' with a
8
+ // next_attempt_at backoff, so retries and hops survive daemon restarts and
9
+ // the relay trigger never fires a user-facing failure for an attempt we're
10
+ // still going to make.
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.failOrRetry = failOrRetry;
13
+ const error_classify_1 = require("./error-classify");
14
+ async function failOrRetry(io, errorCode, errorMessage) {
15
+ // 1. Stale/invalid resume id → clear the stored session and retry ONCE
16
+ // fresh. Checked before the transient/ladderable/terminal split:
17
+ // resuming the same missing id would fail identically, so the id MUST
18
+ // be dropped before the requeue. The retry_count<3 budget (shared
19
+ // server-side with mark_retry / stale-reclaim) bounds this — and a
20
+ // fresh session can't reproduce this exact error, so it is a genuine
21
+ // one-shot for this cause, not an infinite loop. If clearSessionAndRetry
22
+ // is absent, returns false, or throws, fall through to the classify+
23
+ // ladder tail below rather than writing failed directly — the server
24
+ // remains the single dead-letter writer.
25
+ if (io.clearSessionAndRetry && (0, error_classify_1.isSessionInvalid)(errorCode, errorMessage)) {
26
+ const delayMs = (0, error_classify_1.retryDelayMs)(io.retryCount);
27
+ try {
28
+ if (await io.clearSessionAndRetry(errorCode, errorMessage, delayMs)) {
29
+ void io.emit('session_invalid_retry', {
30
+ errorCode: errorCode ?? null,
31
+ errorMessage: (errorMessage ?? '').slice(0, 500) || null,
32
+ delayMs,
33
+ attempt: io.retryCount + 1,
34
+ });
35
+ return 'retried';
36
+ }
37
+ }
38
+ catch {
39
+ // RPC unreachable / cap spent — fall through to classify+ladder so an
40
+ // honest terminal/ladder decision stands rather than wedging.
41
+ }
42
+ }
43
+ const verdict = (0, error_classify_1.classifyFailure)(errorCode, errorMessage);
44
+ // 2. Transient → same-provider retry until budget spent.
45
+ if (verdict === 'transient') {
46
+ const delayMs = (0, error_classify_1.retryDelayMs)(io.retryCount);
47
+ try {
48
+ if (await io.markRetry(errorCode, errorMessage, delayMs)) {
49
+ void io.emit('retry_scheduled', {
50
+ errorCode: errorCode ?? null,
51
+ errorMessage: (errorMessage ?? '').slice(0, 500) || null,
52
+ delayMs,
53
+ attempt: io.retryCount + 1,
54
+ });
55
+ return 'retried';
56
+ }
57
+ }
58
+ catch {
59
+ // RPC unreachable / budget spent — fall through to the ladder rather
60
+ // than dying on a transient signal.
61
+ }
62
+ // transient budget spent → try the ladder rather than dying.
63
+ }
64
+ // 3. ladderable, or transient-budget-spent → ask the server to hop to the
65
+ // next eligible provider or dead-letter. terminal also routes here so
66
+ // the server writes the dead_letter status (single writer) rather than
67
+ // the daemon writing 'failed' directly.
68
+ try {
69
+ const outcome = await io.ladderOrDeadLetter(errorCode, errorMessage);
70
+ if (outcome === 'laddered') {
71
+ void io.emit('provider_laddered', { errorCode: errorCode ?? null, hop: io.ladderCount + 1 });
72
+ return 'laddered';
73
+ }
74
+ return 'failed'; // dead_letter written server-side
75
+ }
76
+ catch {
77
+ // RPC unreachable → last-resort local terminal write so the row settles.
78
+ await io.writeFailed(errorCode, errorMessage);
79
+ return 'failed';
80
+ }
81
+ }