@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.
- package/README.md +96 -0
- package/dist/agent-auth.d.ts +9 -0
- package/dist/agent-auth.js +96 -0
- package/dist/attachments.d.ts +20 -0
- package/dist/attachments.js +164 -0
- package/dist/capabilities.d.ts +64 -0
- package/dist/capabilities.js +406 -0
- package/dist/claude-binary.d.ts +16 -0
- package/dist/claude-binary.js +145 -0
- package/dist/claude-config.d.ts +11 -0
- package/dist/claude-config.js +144 -0
- package/dist/claude-print.d.ts +35 -0
- package/dist/claude-print.js +312 -0
- package/dist/claude-spawn.d.ts +40 -0
- package/dist/claude-spawn.js +220 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +137 -0
- package/dist/codex-binary.d.ts +4 -0
- package/dist/codex-binary.js +70 -0
- package/dist/codex-spawn.d.ts +47 -0
- package/dist/codex-spawn.js +320 -0
- package/dist/command-runner.d.ts +9 -0
- package/dist/command-runner.js +592 -0
- package/dist/config.d.ts +7 -0
- package/dist/config.js +38 -0
- package/dist/control-server.d.ts +5 -0
- package/dist/control-server.js +292 -0
- package/dist/diskguard.d.ts +96 -0
- package/dist/diskguard.js +435 -0
- package/dist/error-classify.d.ts +20 -0
- package/dist/error-classify.js +159 -0
- package/dist/events.d.ts +26 -0
- package/dist/events.js +97 -0
- package/dist/gemini-binary.d.ts +4 -0
- package/dist/gemini-binary.js +64 -0
- package/dist/gemini-spawn.d.ts +52 -0
- package/dist/gemini-spawn.js +369 -0
- package/dist/git-identity.d.ts +18 -0
- package/dist/git-identity.js +104 -0
- package/dist/grok-binary.d.ts +4 -0
- package/dist/grok-binary.js +70 -0
- package/dist/grok-spawn.d.ts +41 -0
- package/dist/grok-spawn.js +495 -0
- package/dist/guardrails.d.ts +32 -0
- package/dist/guardrails.js +125 -0
- package/dist/hang-watchdog.d.ts +17 -0
- package/dist/hang-watchdog.js +48 -0
- package/dist/harness-registry.d.ts +55 -0
- package/dist/harness-registry.js +180 -0
- package/dist/heartbeat.d.ts +13 -0
- package/dist/heartbeat.js +95 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +440 -0
- package/dist/install.d.ts +63 -0
- package/dist/install.js +467 -0
- package/dist/jsonl-tail.d.ts +20 -0
- package/dist/jsonl-tail.js +192 -0
- package/dist/kimi-binary.d.ts +4 -0
- package/dist/kimi-binary.js +68 -0
- package/dist/kimi-spawn.d.ts +47 -0
- package/dist/kimi-spawn.js +319 -0
- package/dist/lockfile.d.ts +30 -0
- package/dist/lockfile.js +169 -0
- package/dist/mcp-config.d.ts +23 -0
- package/dist/mcp-config.js +133 -0
- package/dist/memory-capture.d.ts +20 -0
- package/dist/memory-capture.js +45 -0
- package/dist/memory-pack.d.ts +34 -0
- package/dist/memory-pack.js +171 -0
- package/dist/model-filter.d.ts +4 -0
- package/dist/model-filter.js +34 -0
- package/dist/node-health.d.ts +18 -0
- package/dist/node-health.js +74 -0
- package/dist/pairing.d.ts +20 -0
- package/dist/pairing.js +129 -0
- package/dist/paths.d.ts +9 -0
- package/dist/paths.js +48 -0
- package/dist/precompact-hook.d.ts +27 -0
- package/dist/precompact-hook.js +136 -0
- package/dist/projects.d.ts +25 -0
- package/dist/projects.js +255 -0
- package/dist/prompt-keystrokes.d.ts +3 -0
- package/dist/prompt-keystrokes.js +56 -0
- package/dist/pty-helper.d.ts +26 -0
- package/dist/pty-helper.js +156 -0
- package/dist/request-pump.d.ts +12 -0
- package/dist/request-pump.js +249 -0
- package/dist/run-retry.d.ts +28 -0
- package/dist/run-retry.js +81 -0
- package/dist/self-update.d.ts +75 -0
- package/dist/self-update.js +192 -0
- package/dist/session-runner.d.ts +130 -0
- package/dist/session-runner.js +2271 -0
- package/dist/sleep-detector.d.ts +11 -0
- package/dist/sleep-detector.js +32 -0
- package/dist/state-migrate.d.ts +8 -0
- package/dist/state-migrate.js +91 -0
- package/dist/store.d.ts +5 -0
- package/dist/store.js +82 -0
- package/dist/supabase-client.d.ts +15 -0
- package/dist/supabase-client.js +119 -0
- package/dist/time-context.d.ts +10 -0
- package/dist/time-context.js +71 -0
- package/dist/tokens.d.ts +4 -0
- package/dist/tokens.js +33 -0
- package/dist/tui/app.d.ts +35 -0
- package/dist/tui/app.js +82 -0
- package/dist/tui/console-capture.d.ts +13 -0
- package/dist/tui/console-capture.js +62 -0
- package/dist/tui/dashboard.d.ts +42 -0
- package/dist/tui/dashboard.js +205 -0
- package/dist/tui/data.d.ts +57 -0
- package/dist/tui/data.js +62 -0
- package/dist/tui/harnesses.d.ts +16 -0
- package/dist/tui/harnesses.js +354 -0
- package/dist/tui/render.d.ts +21 -0
- package/dist/tui/render.js +116 -0
- package/dist/tui/requests.d.ts +18 -0
- package/dist/tui/requests.js +127 -0
- package/dist/tui/run.d.ts +13 -0
- package/dist/tui/run.js +178 -0
- package/dist/tui/transcript.d.ts +39 -0
- package/dist/tui/transcript.js +234 -0
- package/dist/tui.d.ts +1 -0
- package/dist/tui.js +314 -0
- package/dist/types.d.ts +65 -0
- package/dist/types.js +3 -0
- package/dist/win.d.ts +67 -0
- package/dist/win.js +317 -0
- package/package.json +60 -0
- package/scripts/fix-pty-helper.js +28 -0
- package/scripts/precompact-capture.js +292 -0
- 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
|
+
}
|