@gamaze/hicortex 0.19.5 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -4
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +8 -1
- package/dist/consolidate.d.ts +21 -0
- package/dist/consolidate.js +37 -0
- package/dist/dashboard.js +5 -2
- package/dist/identity-store.d.ts +3 -3
- package/dist/identity-store.js +2 -2
- package/dist/init.d.ts +3 -1
- package/dist/init.js +93 -1
- package/dist/llm-flight.d.ts +30 -0
- package/dist/llm-flight.js +190 -0
- package/dist/llm.d.ts +75 -0
- package/dist/llm.js +227 -13
- package/dist/mcp-server.d.ts +16 -0
- package/dist/mcp-server.js +38 -0
- package/dist/nightly.d.ts +41 -0
- package/dist/nightly.js +203 -91
- package/dist/opencode-transcript-reader.d.ts +63 -0
- package/dist/opencode-transcript-reader.js +181 -0
- package/dist/status.js +23 -0
- package/dist/telemetry.d.ts +12 -8
- package/dist/telemetry.js +5 -5
- package/dist/types.d.ts +42 -0
- package/dist/uninstall.js +34 -0
- package/opencode-plugin/hicortex/index.ts +981 -0
- package/package.json +8 -5
- package/pi-extension/hicortex/README.md +56 -0
- package/pi-extension/hicortex/index.ts +888 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DEFAULT_STALE_MS = void 0;
|
|
4
|
+
exports.llmFlightLockPath = llmFlightLockPath;
|
|
5
|
+
exports.acquireLlmFlight = acquireLlmFlight;
|
|
6
|
+
/**
|
|
7
|
+
* Single-flight guard for LLM endpoint calls (#355).
|
|
8
|
+
*
|
|
9
|
+
* Local single-user model servers (one big-context model on a personal
|
|
10
|
+
* machine) have a standing failure mode: TWO concurrent large-context
|
|
11
|
+
* requests stall or OOM the server — and take the whole machine with it.
|
|
12
|
+
* One Hicortex server is several LLM callers at once: the daemon distills
|
|
13
|
+
* every inbound /distill concurrently, and the nightly's consolidation is a
|
|
14
|
+
* separate OS process on its own timers. This guard makes "never two
|
|
15
|
+
* in-flight requests to the same endpoint" STRUCTURAL instead of a hope
|
|
16
|
+
* that timers do not overlap.
|
|
17
|
+
*
|
|
18
|
+
* Same idiom as capture.ts's capture.lock (A5): O_EXCL create, dead-pid or
|
|
19
|
+
* TTL staleness with a TOCTOU re-race, and FAIL-OPEN — a filesystem refusal
|
|
20
|
+
* logs a loud warning and lets the call proceed unserialized. The guard must
|
|
21
|
+
* never be the reason recall or distillation stops on a healthy endpoint.
|
|
22
|
+
*
|
|
23
|
+
* The lock file lives in the hicortex home, one file per endpoint
|
|
24
|
+
* (sha1 of `provider@baseUrl`), so an install with multiple endpoints
|
|
25
|
+
* serializes within each endpoint, not across them.
|
|
26
|
+
*/
|
|
27
|
+
const node_crypto_1 = require("node:crypto");
|
|
28
|
+
const node_fs_1 = require("node:fs");
|
|
29
|
+
const node_path_1 = require("node:path");
|
|
30
|
+
/**
|
|
31
|
+
* Default staleness floor: a lock older than this is stale REGARDLESS of the
|
|
32
|
+
* recorded pid (guards the recycled-pid case, capture A5 fix 2). The caller
|
|
33
|
+
* passes `staleMs = max(this default, 2× llmTimeoutMs)` so an operator who
|
|
34
|
+
* raises the timeout ceiling can never have a LIVE call's lock reclaimed
|
|
35
|
+
* mid-flight (CR #355 finding 3 — a reclaim-while-running is exactly the
|
|
36
|
+
* two-concurrent-calls crash class this guard exists to prevent).
|
|
37
|
+
*/
|
|
38
|
+
exports.DEFAULT_STALE_MS = 30 * 60 * 1000;
|
|
39
|
+
/**
|
|
40
|
+
* A lock file younger than this whose holder is unreadable (empty/invalid
|
|
41
|
+
* JSON) is treated as LIVE: its creator is between the O_EXCL create and the
|
|
42
|
+
* writeSync — stealing in that window is the same crash class. Only an
|
|
43
|
+
* unreadable file OLDER than the grace period is junk to reclaim
|
|
44
|
+
* (CR #355 finding 4).
|
|
45
|
+
*/
|
|
46
|
+
const GRACE_MS = 2_000;
|
|
47
|
+
/** LLM-scale polling: waits are seconds-to-minutes, not capture-scale. */
|
|
48
|
+
const POLL_MS = 100;
|
|
49
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
50
|
+
/** Warn at most once per process that the guard failed open (#355 fail-open). */
|
|
51
|
+
let warnedOpen = false;
|
|
52
|
+
/** Lock path for an endpoint key — exported for tests and diagnostics. */
|
|
53
|
+
function llmFlightLockPath(homeDir, endpointKey) {
|
|
54
|
+
const hash = (0, node_crypto_1.createHash)("sha1").update(endpointKey).digest("hex").slice(0, 16);
|
|
55
|
+
return (0, node_path_1.join)(homeDir, `llm-flight-${hash}.lock`);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Acquire the single-flight lock for `endpointKey`, waiting up to `waitMs`.
|
|
59
|
+
* Never throws: every filesystem surprise degrades to `{ kind: "open" }`.
|
|
60
|
+
*/
|
|
61
|
+
async function acquireLlmFlight(homeDir, endpointKey, waitMs, staleMs = exports.DEFAULT_STALE_MS) {
|
|
62
|
+
const lockPath = llmFlightLockPath(homeDir, endpointKey);
|
|
63
|
+
try {
|
|
64
|
+
(0, node_fs_1.mkdirSync)(homeDir, { recursive: true });
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
/* best effort — the create below surfaces real problems */
|
|
68
|
+
}
|
|
69
|
+
const deadline = Date.now() + waitMs;
|
|
70
|
+
for (;;) {
|
|
71
|
+
const attempt = tryAcquireOnce(lockPath, endpointKey, staleMs);
|
|
72
|
+
if (attempt.kind !== "busy")
|
|
73
|
+
return attempt;
|
|
74
|
+
if (Date.now() >= deadline)
|
|
75
|
+
return { kind: "timeout" };
|
|
76
|
+
await sleep(Math.max(1, Math.min(POLL_MS, deadline - Date.now())));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** One acquire attempt: create-if-free, else reclaim-if-stale. */
|
|
80
|
+
function tryAcquireOnce(lockPath, endpointKey, staleMs) {
|
|
81
|
+
const release = () => {
|
|
82
|
+
try {
|
|
83
|
+
(0, node_fs_1.unlinkSync)(lockPath);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
/* already gone */
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
const create = () => {
|
|
90
|
+
try {
|
|
91
|
+
const fd = (0, node_fs_1.openSync)(lockPath, "wx"); // O_CREAT | O_EXCL
|
|
92
|
+
// The lease records how long THIS holder may legitimately run
|
|
93
|
+
// (now + staleMs) — a waiter with a smaller budget judges staleness
|
|
94
|
+
// by the recorded lease, never by its own parameter (2nd-review
|
|
95
|
+
// finding 3: a short-timeout waiter must not TTL-reclaim a live
|
|
96
|
+
// long-timeout holder's lock).
|
|
97
|
+
(0, node_fs_1.writeSync)(fd, JSON.stringify({
|
|
98
|
+
pid: process.pid,
|
|
99
|
+
endpoint: endpointKey,
|
|
100
|
+
leaseUntil: Date.now() + staleMs,
|
|
101
|
+
}));
|
|
102
|
+
(0, node_fs_1.closeSync)(fd);
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
if (err.code === "EEXIST")
|
|
107
|
+
return false;
|
|
108
|
+
throw err;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
try {
|
|
112
|
+
if (create())
|
|
113
|
+
return { kind: "acquired", release };
|
|
114
|
+
const holder = readHolder(lockPath);
|
|
115
|
+
if (!isStale(lockPath, holder, staleMs))
|
|
116
|
+
return { kind: "busy" };
|
|
117
|
+
// Stale. Re-verify the holder has not changed (another reclaimer may
|
|
118
|
+
// have taken it), unlink, re-race the O_EXCL create (capture fix 12).
|
|
119
|
+
// Compare by VALUE (pid/endpoint) — readHolder returns a fresh object
|
|
120
|
+
// per call, so reference equality would always differ.
|
|
121
|
+
const recheck = readHolder(lockPath);
|
|
122
|
+
if (recheck?.pid !== holder?.pid ||
|
|
123
|
+
recheck?.endpoint !== holder?.endpoint) {
|
|
124
|
+
return { kind: "busy" };
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
(0, node_fs_1.unlinkSync)(lockPath);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
/* raced with another reclaimer */
|
|
131
|
+
}
|
|
132
|
+
return create() ? { kind: "acquired", release } : { kind: "busy" };
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// Filesystem refused the lock op entirely — do not wedge LLM traffic on
|
|
136
|
+
// the guard; proceed unserialized (the behaviour before #355).
|
|
137
|
+
if (!warnedOpen) {
|
|
138
|
+
warnedOpen = true;
|
|
139
|
+
console.warn(`[hicortex] LLM single-flight lock unavailable (${lockPath}) — ` +
|
|
140
|
+
`proceeding WITHOUT serialization. This is safe for recall but ` +
|
|
141
|
+
`concurrent LLM calls can stall a single-user local model server.`);
|
|
142
|
+
}
|
|
143
|
+
return { kind: "open" };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function readHolder(lockPath) {
|
|
147
|
+
try {
|
|
148
|
+
const parsed = JSON.parse((0, node_fs_1.readFileSync)(lockPath, "utf-8"));
|
|
149
|
+
if (typeof parsed.pid === "number" && Number.isFinite(parsed.pid))
|
|
150
|
+
return parsed;
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/** Stale = dead pid, OR past the HOLDER's recorded lease, OR (lease-less
|
|
158
|
+
* lock files) older than the waiter's `staleMs`. An unreadable holder
|
|
159
|
+
* (mid-write or corrupt) is live within the grace period, junk after it. */
|
|
160
|
+
function isStale(lockPath, holder, staleMs) {
|
|
161
|
+
if (!holder) {
|
|
162
|
+
try {
|
|
163
|
+
return Date.now() - (0, node_fs_1.statSync)(lockPath).mtimeMs > GRACE_MS;
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (!isProcessAlive(holder.pid))
|
|
170
|
+
return true;
|
|
171
|
+
if (typeof holder.leaseUntil === "number" && Number.isFinite(holder.leaseUntil)) {
|
|
172
|
+
return Date.now() > holder.leaseUntil;
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
return Date.now() - (0, node_fs_1.statSync)(lockPath).mtimeMs > staleMs;
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function isProcessAlive(pid) {
|
|
182
|
+
try {
|
|
183
|
+
process.kill(pid, 0);
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
// ESRCH = no such process; EPERM = exists but not ours (still alive).
|
|
188
|
+
return err.code === "EPERM";
|
|
189
|
+
}
|
|
190
|
+
}
|
package/dist/llm.d.ts
CHANGED
|
@@ -32,6 +32,31 @@ export interface LlmConfig {
|
|
|
32
32
|
ollamaFlushEvery?: number;
|
|
33
33
|
/** Ms to wait after an ollama flush for the runner to release. */
|
|
34
34
|
ollamaFlushWaitMs?: number;
|
|
35
|
+
/** ONE per-call timeout ceiling for every phase (#337). Default 900000 — the
|
|
36
|
+
* AbortSignal.timeout value passed by all four phase wrappers (the old
|
|
37
|
+
* 600000 scoring special-case is gone). See HicortexConfig.llmTimeoutMs. */
|
|
38
|
+
timeoutMs?: number;
|
|
39
|
+
/** Consecutive ladder-exhausted total failures before the circuit breaker
|
|
40
|
+
* opens (#337). Default 3; 0 disables. See HicortexConfig.llmBreakerThreshold. */
|
|
41
|
+
breakerThreshold?: number;
|
|
42
|
+
/** How long an OPEN breaker stays open before the next call becomes a trial
|
|
43
|
+
* (#337). Default 600000. See HicortexConfig.llmBreakerCooldownMs. */
|
|
44
|
+
breakerCooldownMs?: number;
|
|
45
|
+
/** Timeout for the readiness probe's single generation attempt (#337).
|
|
46
|
+
* Default 60000. See HicortexConfig.llmProbeTimeoutMs. */
|
|
47
|
+
probeTimeoutMs?: number;
|
|
48
|
+
/** TTL the daemon caches a probe outcome for (#337). Default 300000.
|
|
49
|
+
* See HicortexConfig.llmProbeTtlMs. */
|
|
50
|
+
probeTtlMs?: number;
|
|
51
|
+
/** Single-flight serialization: at most ONE in-flight LLM request per
|
|
52
|
+
* endpoint, ever (#355). Default true — a correctness property for local
|
|
53
|
+
* single-user model servers (two concurrent large-context calls stall/OOM
|
|
54
|
+
* the server and the machine under it). See HicortexConfig.llmSingleFlight. */
|
|
55
|
+
singleFlight?: boolean;
|
|
56
|
+
/** How long a queued call waits for the in-flight call before failing as
|
|
57
|
+
* endpoint-down (#355). Default 900000 — the same ceiling as llmTimeoutMs.
|
|
58
|
+
* See HicortexConfig.llmSingleFlightWaitMs. */
|
|
59
|
+
singleFlightWaitMs?: number;
|
|
35
60
|
}
|
|
36
61
|
/**
|
|
37
62
|
* Resolve LLM configuration from explicit config-file overrides or
|
|
@@ -135,6 +160,18 @@ export interface LlmResult {
|
|
|
135
160
|
text: string;
|
|
136
161
|
usage?: LlmUsage;
|
|
137
162
|
}
|
|
163
|
+
/** Thrown when the per-endpoint circuit breaker is open (#337) — no network I/O happened. */
|
|
164
|
+
export declare class LlmCircuitOpenError extends Error {
|
|
165
|
+
constructor(endpoint: string, cooldownRemainingMs: number);
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* The TOTAL-failure class the retry ladder matches (#337, unchanged strings —
|
|
169
|
+
* this is the same matcher the ladder has always used, now also the breaker's
|
|
170
|
+
* definition of "endpoint may be down"). A fast HTTP 500, a parse error, or a
|
|
171
|
+
* rate limit is NOT in this class: those prove the endpoint ANSWERS.
|
|
172
|
+
*/
|
|
173
|
+
declare function isTotalFailure(message: string): boolean;
|
|
174
|
+
export { isTotalFailure };
|
|
138
175
|
export declare class LlmClient {
|
|
139
176
|
private config;
|
|
140
177
|
private ollamaCallCount;
|
|
@@ -144,6 +181,18 @@ export declare class LlmClient {
|
|
|
144
181
|
private get rateLimitedUntil();
|
|
145
182
|
/** Check if we're currently rate limited */
|
|
146
183
|
get isRateLimited(): boolean;
|
|
184
|
+
/**
|
|
185
|
+
* True once the endpoint's circuit breaker has tripped and no success has
|
|
186
|
+
* reset it since (#337). Note this stays true past the cooldown until a
|
|
187
|
+
* trial succeeds — "past cooldown" means calls are LET THROUGH (one trial),
|
|
188
|
+
* not "healthy". The nightly reads this after runConsolidation to override
|
|
189
|
+
* a fail-soft "completed" report with "endpoint_down".
|
|
190
|
+
*/
|
|
191
|
+
get breakerOpen(): boolean;
|
|
192
|
+
/** Record one ladder-exhausted TOTAL failure; open the breaker at threshold. */
|
|
193
|
+
private recordBreakerFailure;
|
|
194
|
+
/** Any success resets the endpoint's breaker (closed + counter zeroed). */
|
|
195
|
+
private resetBreaker;
|
|
147
196
|
private handleRateLimit;
|
|
148
197
|
/**
|
|
149
198
|
* Fast-tier completion (importance scoring, simple tasks). One model serves
|
|
@@ -168,8 +217,34 @@ export declare class LlmClient {
|
|
|
168
217
|
* serves all phases (#231) — thin wrapper kept for call-site readability.
|
|
169
218
|
*/
|
|
170
219
|
completeClassify(prompt: string, maxTokens?: number): Promise<LlmResult>;
|
|
220
|
+
/**
|
|
221
|
+
* Readiness probe (#337): ONE minimal generation request (max output 1
|
|
222
|
+
* token) through the normal provider dispatch. Asks the question liveness
|
|
223
|
+
* checks CANNOT: "can this endpoint GENERATE right now?" — the incident
|
|
224
|
+
* gateway kept answering /v1/models for hours while every completion hung.
|
|
225
|
+
* Single attempt: no retry ladder (a dead endpoint must cost one fast
|
|
226
|
+
* failure, not a 3.5-min ladder), and it never accrues to the circuit
|
|
227
|
+
* breaker (probing is diagnosis, not traffic). Catch-all → false; the
|
|
228
|
+
* callers translate that into "endpoint_down" / a 503, never an exception.
|
|
229
|
+
*/
|
|
230
|
+
probe(timeoutMs?: number): Promise<boolean>;
|
|
171
231
|
private complete;
|
|
172
232
|
private completeOnce;
|
|
233
|
+
/** The single-flight wait budget for a call with ceiling `timeoutMs`.
|
|
234
|
+
* An explicit `llmSingleFlightWaitMs` wins; otherwise the default is
|
|
235
|
+
* max(900 s, llmTimeoutMs) so a waiter never gives up before a legitimate
|
|
236
|
+
* in-flight call's own (possibly raised) ceiling expires (2nd-review
|
|
237
|
+
* finding 2 — a hardcoded 900 s made a raised-timeout install treat a
|
|
238
|
+
* healthy-busy endpoint as down). */
|
|
239
|
+
private flightWaitMs;
|
|
240
|
+
/** Resolve the flight guard for this call, or undefined when disabled.
|
|
241
|
+
* `staleMs` is derived from THIS call's timeout ceiling (≥ the 30-min
|
|
242
|
+
* floor, 2× timeout) so a raised `llmTimeoutMs` can never get a live
|
|
243
|
+
* call's lock reclaimed mid-flight (CR #355 finding 3). The lock file
|
|
244
|
+
* records its own lease, so reclaim is judged by the HOLDER's lease, not
|
|
245
|
+
* this waiter's parameter (2nd-review finding 3). */
|
|
246
|
+
private acquireFlightGuard;
|
|
247
|
+
private dispatchOnce;
|
|
173
248
|
/**
|
|
174
249
|
* Claude CLI: shell out to `claude -p` for subscription users.
|
|
175
250
|
* No API key needed — uses CC's authenticated session.
|
package/dist/llm.js
CHANGED
|
@@ -18,14 +18,29 @@
|
|
|
18
18
|
* OpenAI, Anthropic, Google, Ollama, OpenRouter, and Claude CLI.
|
|
19
19
|
*/
|
|
20
20
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
-
exports.LlmClient = exports.RateLimitError = exports.resolveLlmConfigForCC = void 0;
|
|
21
|
+
exports.LlmClient = exports.LlmCircuitOpenError = exports.RateLimitError = exports.resolveLlmConfigForCC = void 0;
|
|
22
22
|
exports.resolveExplicitLlmConfig = resolveExplicitLlmConfig;
|
|
23
23
|
exports.applyTierTuningOverlay = applyTierTuningOverlay;
|
|
24
24
|
exports.resolveSavedLlmConfig = resolveSavedLlmConfig;
|
|
25
25
|
exports.findClaudeBinary = findClaudeBinary;
|
|
26
26
|
exports.claudeCliConfig = claudeCliConfig;
|
|
27
27
|
exports.probeOllama = probeOllama;
|
|
28
|
+
exports.isTotalFailure = isTotalFailure;
|
|
28
29
|
const config_read_js_1 = require("./config-read.js");
|
|
30
|
+
// #355: single-flight guard + canonical home (where the per-endpoint flight
|
|
31
|
+
// lock files live — the daemon and the nightly share the home, so the lock
|
|
32
|
+
// serializes them across processes).
|
|
33
|
+
const llm_flight_js_1 = require("./llm-flight.js");
|
|
34
|
+
const paths_js_1 = require("./paths.js");
|
|
35
|
+
// #337: the openai-compat + anthropic request paths fetch through undici's OWN
|
|
36
|
+
// fetch with an explicit dispatcher (below). Node's global fetch is also undici
|
|
37
|
+
// under the hood, but with a hidden 5-minute response-HEADER timer that fires
|
|
38
|
+
// FIRST on non-streaming completions — a completion only sends its headers after
|
|
39
|
+
// generation finishes, so a legitimate >5-min generation is abandoned client-side
|
|
40
|
+
// while the server keeps generating for the dead client (the 2026-08-23/24
|
|
41
|
+
// incident's amplification mechanism). The ollama path already streams to dodge
|
|
42
|
+
// this; these paths get the dispatcher instead.
|
|
43
|
+
const undici_1 = require("undici");
|
|
29
44
|
/**
|
|
30
45
|
* Resolve LLM configuration from explicit config-file overrides or
|
|
31
46
|
* Hicortex-specific env vars only. Returns null when nothing explicit is set.
|
|
@@ -104,6 +119,36 @@ function applyTierTuningOverlay(llmConfig, savedConfig) {
|
|
|
104
119
|
if (savedConfig.ollamaFlushWaitMs !== undefined) {
|
|
105
120
|
llmConfig.ollamaFlushWaitMs = (0, config_read_js_1.readPositiveConfig)(savedConfig, "ollamaFlushWaitMs", 180000);
|
|
106
121
|
}
|
|
122
|
+
// #337 resilience knobs. Same boundary discipline as the keys above: absent =
|
|
123
|
+
// call-site defaults (timeout 900 s, threshold 3, cooldown 10 min, probe
|
|
124
|
+
// timeout 60 s, probe TTL 5 min), wrong-typed values warn and fall back.
|
|
125
|
+
// breakerThreshold uses readNonNegativeConfig because 0 is a VALID value
|
|
126
|
+
// ("disable the breaker") — the same reason ollamaFlushEvery uses it.
|
|
127
|
+
if (savedConfig.llmTimeoutMs !== undefined) {
|
|
128
|
+
llmConfig.timeoutMs = (0, config_read_js_1.readPositiveConfig)(savedConfig, "llmTimeoutMs", 900000);
|
|
129
|
+
}
|
|
130
|
+
if (savedConfig.llmBreakerThreshold !== undefined) {
|
|
131
|
+
llmConfig.breakerThreshold = (0, config_read_js_1.readNonNegativeConfig)(savedConfig, "llmBreakerThreshold", 3);
|
|
132
|
+
}
|
|
133
|
+
if (savedConfig.llmBreakerCooldownMs !== undefined) {
|
|
134
|
+
llmConfig.breakerCooldownMs = (0, config_read_js_1.readPositiveConfig)(savedConfig, "llmBreakerCooldownMs", 600000);
|
|
135
|
+
}
|
|
136
|
+
if (savedConfig.llmProbeTimeoutMs !== undefined) {
|
|
137
|
+
llmConfig.probeTimeoutMs = (0, config_read_js_1.readPositiveConfig)(savedConfig, "llmProbeTimeoutMs", 60000);
|
|
138
|
+
}
|
|
139
|
+
if (savedConfig.llmProbeTtlMs !== undefined) {
|
|
140
|
+
llmConfig.probeTtlMs = (0, config_read_js_1.readPositiveConfig)(savedConfig, "llmProbeTtlMs", 300000);
|
|
141
|
+
}
|
|
142
|
+
// #355 single-flight. Default ON (a correctness property, not a tuning
|
|
143
|
+
// option); the wait budget defaults to the timeout ceiling so a queued call
|
|
144
|
+
// never gives up before the in-flight call's own ceiling expires.
|
|
145
|
+
const singleFlight = (0, config_read_js_1.readStrictBoolean)(savedConfig, "llmSingleFlight");
|
|
146
|
+
if (singleFlight !== undefined) {
|
|
147
|
+
llmConfig.singleFlight = singleFlight;
|
|
148
|
+
}
|
|
149
|
+
if (savedConfig.llmSingleFlightWaitMs !== undefined) {
|
|
150
|
+
llmConfig.singleFlightWaitMs = (0, config_read_js_1.readPositiveConfig)(savedConfig, "llmSingleFlightWaitMs", 900000);
|
|
151
|
+
}
|
|
107
152
|
}
|
|
108
153
|
/**
|
|
109
154
|
* Resolve an LlmConfig from a saved ~/.hicortex/config.json object.
|
|
@@ -234,6 +279,18 @@ async function probeOllama(baseUrl = "http://localhost:11434") {
|
|
|
234
279
|
// ---------------------------------------------------------------------------
|
|
235
280
|
// LLM Client class
|
|
236
281
|
// ---------------------------------------------------------------------------
|
|
282
|
+
// #337: one timeout ceiling. undici (Node's fetch implementation) silently
|
|
283
|
+
// enforces a ~5-minute response-header timer and a body timer on every request.
|
|
284
|
+
// On a NON-STREAMING completion the headers only arrive when generation
|
|
285
|
+
// finishes, so that hidden timer — not our AbortSignal — was the real ceiling,
|
|
286
|
+
// and when it fired the server kept generating for the abandoned client. This
|
|
287
|
+
// module-scoped Agent disables both timers for the LLM request paths that opt
|
|
288
|
+
// in (completeAnthropic + completeOpenAiCompat), making `llmTimeoutMs` the only
|
|
289
|
+
// ceiling. Module scope = one connection pool shared by every LlmClient in the
|
|
290
|
+
// process (the ollama path keeps global fetch — it already streams, so the
|
|
291
|
+
// header timer can't fire there; claude-cli is a subprocess with its own
|
|
292
|
+
// timeout).
|
|
293
|
+
const llmDispatcher = new undici_1.Agent({ headersTimeout: 0, bodyTimeout: 0 });
|
|
237
294
|
const DEFAULT_RATE_LIMIT_RETRY_MS = 5 * 60 * 60 * 1000 + 60_000; // 5h01m safety margin
|
|
238
295
|
class RateLimitError extends Error {
|
|
239
296
|
retryAfterMs;
|
|
@@ -251,6 +308,28 @@ exports.RateLimitError = RateLimitError;
|
|
|
251
308
|
// (#231), so in practice there is a single client per process today; the
|
|
252
309
|
// module-level map keeps the state shared correctly if that ever changes.
|
|
253
310
|
const rateLimitedUntilByEndpoint = new Map();
|
|
311
|
+
const breakerByEndpoint = new Map();
|
|
312
|
+
/** Thrown when the per-endpoint circuit breaker is open (#337) — no network I/O happened. */
|
|
313
|
+
class LlmCircuitOpenError extends Error {
|
|
314
|
+
constructor(endpoint, cooldownRemainingMs) {
|
|
315
|
+
super(`LLM circuit breaker open for ${endpoint} — failing fast ` +
|
|
316
|
+
`(endpoint deemed down; next trial in ~${Math.round(cooldownRemainingMs / 1000)}s)`);
|
|
317
|
+
this.name = "LlmCircuitOpenError";
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
exports.LlmCircuitOpenError = LlmCircuitOpenError;
|
|
321
|
+
/**
|
|
322
|
+
* The TOTAL-failure class the retry ladder matches (#337, unchanged strings —
|
|
323
|
+
* this is the same matcher the ladder has always used, now also the breaker's
|
|
324
|
+
* definition of "endpoint may be down"). A fast HTTP 500, a parse error, or a
|
|
325
|
+
* rate limit is NOT in this class: those prove the endpoint ANSWERS.
|
|
326
|
+
*/
|
|
327
|
+
function isTotalFailure(message) {
|
|
328
|
+
return (message.includes("fetch failed") ||
|
|
329
|
+
message.includes("ECONNREFUSED") ||
|
|
330
|
+
message.includes("timeout") ||
|
|
331
|
+
message.includes("Headers Timeout"));
|
|
332
|
+
}
|
|
254
333
|
class LlmClient {
|
|
255
334
|
config;
|
|
256
335
|
ollamaCallCount = 0;
|
|
@@ -268,6 +347,43 @@ class LlmClient {
|
|
|
268
347
|
get isRateLimited() {
|
|
269
348
|
return Date.now() < this.rateLimitedUntil;
|
|
270
349
|
}
|
|
350
|
+
/**
|
|
351
|
+
* True once the endpoint's circuit breaker has tripped and no success has
|
|
352
|
+
* reset it since (#337). Note this stays true past the cooldown until a
|
|
353
|
+
* trial succeeds — "past cooldown" means calls are LET THROUGH (one trial),
|
|
354
|
+
* not "healthy". The nightly reads this after runConsolidation to override
|
|
355
|
+
* a fail-soft "completed" report with "endpoint_down".
|
|
356
|
+
*/
|
|
357
|
+
get breakerOpen() {
|
|
358
|
+
const st = breakerByEndpoint.get(this.endpointKey);
|
|
359
|
+
return st !== undefined && st.openedAt !== null;
|
|
360
|
+
}
|
|
361
|
+
/** Record one ladder-exhausted TOTAL failure; open the breaker at threshold. */
|
|
362
|
+
recordBreakerFailure() {
|
|
363
|
+
const threshold = this.config.breakerThreshold ?? 3;
|
|
364
|
+
if (threshold <= 0)
|
|
365
|
+
return; // 0 disables — never open, never fast-fail
|
|
366
|
+
const st = breakerByEndpoint.get(this.endpointKey) ?? { failures: 0, openedAt: null };
|
|
367
|
+
st.failures += 1;
|
|
368
|
+
if (st.failures >= threshold) {
|
|
369
|
+
// (Re)open. Re-opening (a failed trial past cooldown) restarts the
|
|
370
|
+
// cooldown window from NOW — the endpoint just proved itself still dead.
|
|
371
|
+
st.openedAt = Date.now();
|
|
372
|
+
// One structured line per opening — the runbook's grep target. Same
|
|
373
|
+
// key=value style as event=budget_exhausted (consolidate.ts).
|
|
374
|
+
console.warn(`[hicortex] event=circuit_open endpoint=${this.endpointKey} ` +
|
|
375
|
+
`failures=${st.failures} threshold=${threshold} ` +
|
|
376
|
+
`cooldown_ms=${this.config.breakerCooldownMs ?? 600_000}`);
|
|
377
|
+
}
|
|
378
|
+
breakerByEndpoint.set(this.endpointKey, st);
|
|
379
|
+
}
|
|
380
|
+
/** Any success resets the endpoint's breaker (closed + counter zeroed). */
|
|
381
|
+
resetBreaker() {
|
|
382
|
+
const st = breakerByEndpoint.get(this.endpointKey);
|
|
383
|
+
if (st && (st.failures !== 0 || st.openedAt !== null)) {
|
|
384
|
+
breakerByEndpoint.set(this.endpointKey, { failures: 0, openedAt: null });
|
|
385
|
+
}
|
|
386
|
+
}
|
|
271
387
|
handleRateLimit(resp) {
|
|
272
388
|
// Parse Retry-After header if present (seconds)
|
|
273
389
|
const retryAfter = resp.headers.get("retry-after");
|
|
@@ -289,7 +405,11 @@ class LlmClient {
|
|
|
289
405
|
*/
|
|
290
406
|
async completeFast(prompt, maxTokens) {
|
|
291
407
|
const tokens = maxTokens ?? this.config.maxTokens ?? 8192;
|
|
292
|
-
|
|
408
|
+
// #337: ONE ceiling for every phase (llmTimeoutMs, default 900 s). The old
|
|
409
|
+
// 600 s scoring special-case assumed fast-tier calls are short — but the
|
|
410
|
+
// ceiling only ever mattered when the endpoint was wedged, and a wedged
|
|
411
|
+
// endpoint wedges scoring too. One knob, one place.
|
|
412
|
+
const result = await this.complete(this.config.model, prompt, tokens, this.config.timeoutMs ?? 900_000);
|
|
293
413
|
const flushEvery = this.config.ollamaFlushEvery ?? 0;
|
|
294
414
|
if (this.config.provider === "ollama" && flushEvery > 0) {
|
|
295
415
|
this.ollamaCallCount++;
|
|
@@ -306,7 +426,7 @@ class LlmClient {
|
|
|
306
426
|
*/
|
|
307
427
|
async completeReflect(prompt, maxTokens) {
|
|
308
428
|
const tokens = maxTokens ?? this.config.maxTokens ?? 8192;
|
|
309
|
-
return this.complete(this.config.model, prompt, tokens, 900_000);
|
|
429
|
+
return this.complete(this.config.model, prompt, tokens, this.config.timeoutMs ?? 900_000);
|
|
310
430
|
}
|
|
311
431
|
/**
|
|
312
432
|
* Distillation-tier completion (session knowledge extraction). One model
|
|
@@ -314,7 +434,7 @@ class LlmClient {
|
|
|
314
434
|
*/
|
|
315
435
|
async completeDistill(prompt, maxTokens) {
|
|
316
436
|
const tokens = maxTokens ?? this.config.maxTokens ?? 8192;
|
|
317
|
-
return this.complete(this.config.model, prompt, tokens, 900_000);
|
|
437
|
+
return this.complete(this.config.model, prompt, tokens, this.config.timeoutMs ?? 900_000);
|
|
318
438
|
}
|
|
319
439
|
/**
|
|
320
440
|
* Classification-tier completion (memory tag classification). One model
|
|
@@ -322,9 +442,40 @@ class LlmClient {
|
|
|
322
442
|
*/
|
|
323
443
|
async completeClassify(prompt, maxTokens) {
|
|
324
444
|
const tokens = maxTokens ?? this.config.maxTokens ?? 8192;
|
|
325
|
-
return this.complete(this.config.model, prompt, tokens, 900_000);
|
|
445
|
+
return this.complete(this.config.model, prompt, tokens, this.config.timeoutMs ?? 900_000);
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Readiness probe (#337): ONE minimal generation request (max output 1
|
|
449
|
+
* token) through the normal provider dispatch. Asks the question liveness
|
|
450
|
+
* checks CANNOT: "can this endpoint GENERATE right now?" — the incident
|
|
451
|
+
* gateway kept answering /v1/models for hours while every completion hung.
|
|
452
|
+
* Single attempt: no retry ladder (a dead endpoint must cost one fast
|
|
453
|
+
* failure, not a 3.5-min ladder), and it never accrues to the circuit
|
|
454
|
+
* breaker (probing is diagnosis, not traffic). Catch-all → false; the
|
|
455
|
+
* callers translate that into "endpoint_down" / a 503, never an exception.
|
|
456
|
+
*/
|
|
457
|
+
async probe(timeoutMs) {
|
|
458
|
+
try {
|
|
459
|
+
const budget = timeoutMs ?? this.config.probeTimeoutMs ?? 60_000;
|
|
460
|
+
await this.completeOnce(this.config.model, "Reply with OK.", 1, budget, budget);
|
|
461
|
+
return true;
|
|
462
|
+
}
|
|
463
|
+
catch {
|
|
464
|
+
return false;
|
|
465
|
+
}
|
|
326
466
|
}
|
|
327
467
|
async complete(model, prompt, maxTokens, timeoutMs) {
|
|
468
|
+
// Breaker BEFORE anything else (#337) — an open breaker must cost zero
|
|
469
|
+
// network I/O and zero ladder time. Past the cooldown we fall through:
|
|
470
|
+
// this call IS the trial.
|
|
471
|
+
const breakerSt = breakerByEndpoint.get(this.endpointKey);
|
|
472
|
+
if (breakerSt !== undefined && breakerSt.openedAt !== null) {
|
|
473
|
+
const elapsed = Date.now() - breakerSt.openedAt;
|
|
474
|
+
const cooldownMs = this.config.breakerCooldownMs ?? 600_000;
|
|
475
|
+
if (elapsed < cooldownMs) {
|
|
476
|
+
throw new LlmCircuitOpenError(this.endpointKey, cooldownMs - elapsed);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
328
479
|
if (this.isRateLimited) {
|
|
329
480
|
throw new RateLimitError(this.rateLimitedUntil - Date.now());
|
|
330
481
|
}
|
|
@@ -332,24 +483,83 @@ class LlmClient {
|
|
|
332
483
|
let lastErr;
|
|
333
484
|
for (let attempt = 0; attempt <= retryDelays.length; attempt++) {
|
|
334
485
|
try {
|
|
335
|
-
|
|
486
|
+
const result = await this.completeOnce(model, prompt, maxTokens, timeoutMs);
|
|
487
|
+
this.resetBreaker(); // any success closes the endpoint's breaker
|
|
488
|
+
return result;
|
|
336
489
|
}
|
|
337
490
|
catch (err) {
|
|
338
491
|
lastErr = err instanceof Error ? err : new Error(String(err));
|
|
339
|
-
|
|
340
|
-
if (attempt < retryDelays.length && (msg.includes("fetch failed") || msg.includes("ECONNREFUSED") || msg.includes("timeout") || msg.includes("Headers Timeout"))) {
|
|
492
|
+
if (attempt < retryDelays.length && isTotalFailure(lastErr.message)) {
|
|
341
493
|
const delay = retryDelays[attempt];
|
|
342
|
-
console.log(`[hicortex] LLM call failed (${
|
|
494
|
+
console.log(`[hicortex] LLM call failed (${lastErr.message.slice(0, 60)}), retry ${attempt + 1}/${retryDelays.length} in ${delay / 1000}s...`);
|
|
343
495
|
await new Promise(r => setTimeout(r, delay));
|
|
344
496
|
}
|
|
345
|
-
else {
|
|
497
|
+
else if (!isTotalFailure(lastErr.message)) {
|
|
498
|
+
// Non-retryable (HTTP error with a response, parse error,
|
|
499
|
+
// RateLimitError): fail this call now — and it NEVER accrues to the
|
|
500
|
+
// breaker; an endpoint that answers is not breaker-down.
|
|
346
501
|
throw lastErr;
|
|
347
502
|
}
|
|
503
|
+
// else: total-class failure on the LAST attempt — the ladder is
|
|
504
|
+
// exhausted; fall out of the loop to count + throw below.
|
|
348
505
|
}
|
|
349
506
|
}
|
|
507
|
+
// Ladder exhausted on total-class errors — the only path that accrues to
|
|
508
|
+
// the breaker (#337).
|
|
509
|
+
this.recordBreakerFailure();
|
|
350
510
|
throw lastErr;
|
|
351
511
|
}
|
|
352
|
-
async completeOnce(model, prompt, maxTokens, timeoutMs
|
|
512
|
+
async completeOnce(model, prompt, maxTokens, timeoutMs,
|
|
513
|
+
/** Optional cap on the single-flight WAIT budget (the probe passes its
|
|
514
|
+
* own timeout so a busy endpoint costs one bounded failure, not the full
|
|
515
|
+
* 900 s queue budget — the #337 "one fast failure" contract). */
|
|
516
|
+
maxFlightWaitMs) {
|
|
517
|
+
// #355 single-flight: at most ONE in-flight request per endpoint, ever —
|
|
518
|
+
// wrapping the per-attempt dispatch (not the ladder) so the lock is
|
|
519
|
+
// released between retries and a crashed attempt cannot outlive its call.
|
|
520
|
+
// The probe serializes too: a probe racing a real call would be exactly
|
|
521
|
+
// the two-concurrent-callers pattern this guard exists to prevent.
|
|
522
|
+
// Fail-open by construction: acquireLlmFlight never throws; a filesystem
|
|
523
|
+
// refusal degrades to unserialized dispatch.
|
|
524
|
+
const waitMs = Math.min(this.flightWaitMs(timeoutMs), maxFlightWaitMs ?? Infinity);
|
|
525
|
+
const guard = await this.acquireFlightGuard(timeoutMs, waitMs);
|
|
526
|
+
if (guard?.kind === "timeout") {
|
|
527
|
+
// Message deliberately contains "timeout" so isTotalFailure() matches:
|
|
528
|
+
// the ladder retries it and the breaker accrues on exhaustion — a
|
|
529
|
+
// persistently contended endpoint is indistinguishable from a slow one.
|
|
530
|
+
throw new Error(`single-flight wait timeout after ${waitMs}ms for ${this.endpointKey} — ` +
|
|
531
|
+
`another LLM call holds the flight lock (treated as endpoint-down)`);
|
|
532
|
+
}
|
|
533
|
+
try {
|
|
534
|
+
return await this.dispatchOnce(model, prompt, maxTokens, timeoutMs);
|
|
535
|
+
}
|
|
536
|
+
finally {
|
|
537
|
+
if (guard?.kind === "acquired")
|
|
538
|
+
guard.release();
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
/** The single-flight wait budget for a call with ceiling `timeoutMs`.
|
|
542
|
+
* An explicit `llmSingleFlightWaitMs` wins; otherwise the default is
|
|
543
|
+
* max(900 s, llmTimeoutMs) so a waiter never gives up before a legitimate
|
|
544
|
+
* in-flight call's own (possibly raised) ceiling expires (2nd-review
|
|
545
|
+
* finding 2 — a hardcoded 900 s made a raised-timeout install treat a
|
|
546
|
+
* healthy-busy endpoint as down). */
|
|
547
|
+
flightWaitMs(timeoutMs) {
|
|
548
|
+
return this.config.singleFlightWaitMs ?? Math.max(900_000, timeoutMs);
|
|
549
|
+
}
|
|
550
|
+
/** Resolve the flight guard for this call, or undefined when disabled.
|
|
551
|
+
* `staleMs` is derived from THIS call's timeout ceiling (≥ the 30-min
|
|
552
|
+
* floor, 2× timeout) so a raised `llmTimeoutMs` can never get a live
|
|
553
|
+
* call's lock reclaimed mid-flight (CR #355 finding 3). The lock file
|
|
554
|
+
* records its own lease, so reclaim is judged by the HOLDER's lease, not
|
|
555
|
+
* this waiter's parameter (2nd-review finding 3). */
|
|
556
|
+
async acquireFlightGuard(timeoutMs, waitMs) {
|
|
557
|
+
if (this.config.singleFlight === false)
|
|
558
|
+
return undefined; // kill switch
|
|
559
|
+
const staleMs = Math.max(llm_flight_js_1.DEFAULT_STALE_MS, 2 * timeoutMs);
|
|
560
|
+
return (0, llm_flight_js_1.acquireLlmFlight)((0, paths_js_1.hicortexHome)(), this.endpointKey, waitMs, staleMs);
|
|
561
|
+
}
|
|
562
|
+
async dispatchOnce(model, prompt, maxTokens, timeoutMs) {
|
|
353
563
|
if (this.config.provider === "claude-cli") {
|
|
354
564
|
return this.completeClaude(model, prompt, timeoutMs);
|
|
355
565
|
}
|
|
@@ -512,7 +722,7 @@ class LlmClient {
|
|
|
512
722
|
const baseUrl = this.config.baseUrl.replace(/\/$/, "");
|
|
513
723
|
const hasVersion = /\/v\d+\/?$/.test(baseUrl);
|
|
514
724
|
const url = hasVersion ? `${baseUrl}/messages` : `${baseUrl}/v1/messages`;
|
|
515
|
-
const resp = await fetch(url, {
|
|
725
|
+
const resp = await (0, undici_1.fetch)(url, {
|
|
516
726
|
method: "POST",
|
|
517
727
|
headers: {
|
|
518
728
|
"Content-Type": "application/json",
|
|
@@ -525,6 +735,8 @@ class LlmClient {
|
|
|
525
735
|
max_tokens: maxTokens,
|
|
526
736
|
}),
|
|
527
737
|
signal: AbortSignal.timeout(timeoutMs),
|
|
738
|
+
// #337: disable undici's hidden header/body timers (see llmDispatcher).
|
|
739
|
+
dispatcher: llmDispatcher,
|
|
528
740
|
});
|
|
529
741
|
if (resp.status === 429)
|
|
530
742
|
this.handleRateLimit(resp);
|
|
@@ -584,11 +796,13 @@ class LlmClient {
|
|
|
584
796
|
if (thinking !== undefined) {
|
|
585
797
|
body.chat_template_kwargs = { enable_thinking: thinking };
|
|
586
798
|
}
|
|
587
|
-
const resp = await fetch(url, {
|
|
799
|
+
const resp = await (0, undici_1.fetch)(url, {
|
|
588
800
|
method: "POST",
|
|
589
801
|
headers,
|
|
590
802
|
body: JSON.stringify(body),
|
|
591
803
|
signal: AbortSignal.timeout(timeoutMs),
|
|
804
|
+
// #337: disable undici's hidden header/body timers (see llmDispatcher).
|
|
805
|
+
dispatcher: llmDispatcher,
|
|
592
806
|
});
|
|
593
807
|
if (resp.status === 429)
|
|
594
808
|
this.handleRateLimit(resp);
|
package/dist/mcp-server.d.ts
CHANGED
|
@@ -12,6 +12,22 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import express from "express";
|
|
14
14
|
import type { MemorySearchResult } from "./types.js";
|
|
15
|
+
/**
|
|
16
|
+
* Resolve the /distill probe gate (#337): true when the endpoint recently
|
|
17
|
+
* proved it can GENERATE (cached outcome inside its TTL, or a fresh probe),
|
|
18
|
+
* false when the probe failed — the caller answers 503 so the capture client
|
|
19
|
+
* holds its cursor and retries next run (nothing lost, dup-over-loss).
|
|
20
|
+
* Structural `llm` parameter (anything with probe()) so wiring tests drive
|
|
21
|
+
* the real cache + TTL discipline with a counting stub.
|
|
22
|
+
*/
|
|
23
|
+
export declare function resolveDistillProbeGate(llm: {
|
|
24
|
+
probe(timeoutMs?: number): Promise<boolean>;
|
|
25
|
+
}, llmConfig: {
|
|
26
|
+
provider: string;
|
|
27
|
+
model: string;
|
|
28
|
+
baseUrl: string;
|
|
29
|
+
probeTtlMs?: number;
|
|
30
|
+
}): Promise<boolean>;
|
|
15
31
|
/**
|
|
16
32
|
* Resolve the request body-size limit in MB (#7, #328 item 2b). Pure —
|
|
17
33
|
* exported for tests. Precedence: HICORTEX_DISTILL_BODY_LIMIT_MB env >
|