instar 1.3.647 → 1.3.648
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/dist/commands/reflect.d.ts.map +1 -1
- package/dist/commands/reflect.js +7 -3
- package/dist/commands/reflect.js.map +1 -1
- package/dist/commands/server.d.ts.map +1 -1
- package/dist/commands/server.js +35 -0
- package/dist/commands/server.js.map +1 -1
- package/dist/commands/setup.d.ts.map +1 -1
- package/dist/commands/setup.js +17 -0
- package/dist/commands/setup.js.map +1 -1
- package/dist/config/ConfigDefaults.d.ts.map +1 -1
- package/dist/config/ConfigDefaults.js +14 -0
- package/dist/config/ConfigDefaults.js.map +1 -1
- package/dist/core/CoherenceGate.d.ts.map +1 -1
- package/dist/core/CoherenceGate.js +24 -0
- package/dist/core/CoherenceGate.js.map +1 -1
- package/dist/core/CoherenceReviewer.d.ts +9 -0
- package/dist/core/CoherenceReviewer.d.ts.map +1 -1
- package/dist/core/CoherenceReviewer.js +18 -2
- package/dist/core/CoherenceReviewer.js.map +1 -1
- package/dist/core/InputGuard.d.ts.map +1 -1
- package/dist/core/InputGuard.js +17 -3
- package/dist/core/InputGuard.js.map +1 -1
- package/dist/core/MessageSentinel.d.ts.map +1 -1
- package/dist/core/MessageSentinel.js +19 -2
- package/dist/core/MessageSentinel.js.map +1 -1
- package/dist/core/MessagingToneGate.d.ts +8 -0
- package/dist/core/MessagingToneGate.d.ts.map +1 -1
- package/dist/core/MessagingToneGate.js +16 -1
- package/dist/core/MessagingToneGate.js.map +1 -1
- package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
- package/dist/core/PostUpdateMigrator.js +10 -0
- package/dist/core/PostUpdateMigrator.js.map +1 -1
- package/dist/core/SingleInstanceLock.d.ts +103 -0
- package/dist/core/SingleInstanceLock.d.ts.map +1 -0
- package/dist/core/SingleInstanceLock.js +300 -0
- package/dist/core/SingleInstanceLock.js.map +1 -0
- package/dist/core/SpawnCapIntelligenceProvider.d.ts +89 -0
- package/dist/core/SpawnCapIntelligenceProvider.d.ts.map +1 -0
- package/dist/core/SpawnCapIntelligenceProvider.js +164 -0
- package/dist/core/SpawnCapIntelligenceProvider.js.map +1 -0
- package/dist/core/hostSpawnSemaphore.d.ts +188 -0
- package/dist/core/hostSpawnSemaphore.d.ts.map +1 -0
- package/dist/core/hostSpawnSemaphore.js +471 -0
- package/dist/core/hostSpawnSemaphore.js.map +1 -0
- package/dist/core/intelligenceProviderFactory.d.ts +0 -12
- package/dist/core/intelligenceProviderFactory.d.ts.map +1 -1
- package/dist/core/intelligenceProviderFactory.js +27 -9
- package/dist/core/intelligenceProviderFactory.js.map +1 -1
- package/dist/core/types.d.ts +18 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/scaffold/templates.d.ts.map +1 -1
- package/dist/scaffold/templates.js +5 -0
- package/dist/scaffold/templates.js.map +1 -1
- package/dist/server/CapabilityIndex.d.ts.map +1 -1
- package/dist/server/CapabilityIndex.js +9 -0
- package/dist/server/CapabilityIndex.js.map +1 -1
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +28 -0
- package/dist/server/routes.js.map +1 -1
- package/package.json +4 -2
- package/scripts/lint-no-direct-destructive.js +4 -0
- package/scripts/lint-no-unbounded-llm-spawn.js +137 -0
- package/src/data/builtin-manifest.json +63 -63
- package/src/scaffold/templates.ts +5 -0
- package/upgrades/1.3.648.md +99 -0
- package/upgrades/side-effects/forkbomb-prevention-simple.md +138 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SpawnCapIntelligenceProvider — P1 chokepoint + P3 bounded ingress of the
|
|
3
|
+
* SIMPLE fork-bomb prevention design (docs/specs/forkbomb-prevention-simple.md).
|
|
4
|
+
*
|
|
5
|
+
* A thin WRAPPER provider, layered EXACTLY like wrapIntelligenceWithCircuitBreaker:
|
|
6
|
+
* its `evaluate()` ACQUIRES a host-wide spawn slot (the HostSpawnSemaphore),
|
|
7
|
+
* calls the inner provider's `evaluate()` (the actual `claude -p` / `codex exec`
|
|
8
|
+
* spawn), and RELEASES the slot in a `finally`. It is installed at every return
|
|
9
|
+
* arm of `buildIntelligenceProvider`, so EVERY provider the factory hands out is
|
|
10
|
+
* bounded — and the acquire is PER-`evaluate()`, which is load-bearing:
|
|
11
|
+
* CoherenceGate builds its provider ONCE and fans ~10 reviewers in parallel
|
|
12
|
+
* through that ONE shared instance (the primary incident driver), so each of the
|
|
13
|
+
* N concurrent `evaluate()` calls must independently acquire a slot. A
|
|
14
|
+
* build-time acquire would NOT bind the fan-out.
|
|
15
|
+
*
|
|
16
|
+
* P3 — BOUNDED INGRESS (never an unbounded wait queue):
|
|
17
|
+
* When the host cap is saturated, acquire POLLS the holder-set on a short
|
|
18
|
+
* interval (~100ms) up to `acquireMs` (default 5000ms) — NOT an in-memory
|
|
19
|
+
* waiter queue. Each poll is a cheap lock+count; the caller's large prompt
|
|
20
|
+
* state stays where it already lives (no queue-node heap growth). A bound on
|
|
21
|
+
* CONCURRENT POLLERS (`waitersMax`, default 64) caps the waiters too.
|
|
22
|
+
*
|
|
23
|
+
* On genuine exhaustion (timeout OR waiters-full):
|
|
24
|
+
* - A GATING call (options.attribution.gating === true) THROWS
|
|
25
|
+
* LlmCapacityUnavailableError — the caller's catch fails CLOSED (hold), NOT
|
|
26
|
+
* auto-pass. The four gate seams (MessageSentinel / InputGuard /
|
|
27
|
+
* MessagingToneGate / CoherenceReviewer) recognize this typed error.
|
|
28
|
+
* - A non-gating BACKGROUND call also THROWS LlmCapacityUnavailableError;
|
|
29
|
+
* its existing catch degrades to its heuristic/no-LLM path (loud + counted
|
|
30
|
+
* via DegradationReporter, never silent) — same shape as a circuit-open
|
|
31
|
+
* throw, so existing background catches already handle it.
|
|
32
|
+
*
|
|
33
|
+
* This wrapper SHEDS BEFORE the inner provider runs — no subprocess spawns on a
|
|
34
|
+
* shed, which is the whole point (no `claude -p`, no RSS).
|
|
35
|
+
*/
|
|
36
|
+
import { getHostSpawnSemaphore, configuredSpawnAcquireMs, configuredSpawnWaitersMax, } from './hostSpawnSemaphore.js';
|
|
37
|
+
/**
|
|
38
|
+
* Thrown when the host-wide spawn cap is saturated and the bounded-acquire
|
|
39
|
+
* window elapsed (or the concurrent-waiter ceiling was hit). A typed,
|
|
40
|
+
* recognizable shed — distinct from a rate-limit (LlmCircuitOpenError) and from
|
|
41
|
+
* a provider error — so each gate seam can fail CLOSED on it specifically.
|
|
42
|
+
*/
|
|
43
|
+
export class LlmCapacityUnavailableError extends Error {
|
|
44
|
+
reason;
|
|
45
|
+
waitedMs;
|
|
46
|
+
capacityUnavailable = true;
|
|
47
|
+
constructor(reason, waitedMs) {
|
|
48
|
+
super(`LLM spawn capacity unavailable (${reason}) after ${waitedMs}ms — host concurrent-spawn cap saturated`);
|
|
49
|
+
this.reason = reason;
|
|
50
|
+
this.waitedMs = waitedMs;
|
|
51
|
+
this.name = 'LlmCapacityUnavailableError';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Narrowing helper for the gate seams (avoids `instanceof` import churn). */
|
|
55
|
+
export function isCapacityUnavailable(err) {
|
|
56
|
+
return (err instanceof LlmCapacityUnavailableError ||
|
|
57
|
+
(typeof err === 'object' && err !== null && err.capacityUnavailable === true));
|
|
58
|
+
}
|
|
59
|
+
// Process-wide count of callers currently POLLING for a slot (P3 waiters bound).
|
|
60
|
+
// A waiter is one in-flight `evaluate()` spinning on acquire; bounding it caps
|
|
61
|
+
// the concurrent already-allocated calls, with no per-waiter queue-node heap.
|
|
62
|
+
let _activePollers = 0;
|
|
63
|
+
/** Live count of callers currently polling for a spawn slot (P3 observability). */
|
|
64
|
+
export function activeSpawnPollers() {
|
|
65
|
+
return _activePollers;
|
|
66
|
+
}
|
|
67
|
+
/** Test seam. */
|
|
68
|
+
export function _resetSpawnPollersForTest() {
|
|
69
|
+
_activePollers = 0;
|
|
70
|
+
}
|
|
71
|
+
export class SpawnCapIntelligenceProvider {
|
|
72
|
+
inner;
|
|
73
|
+
semaphore;
|
|
74
|
+
acquireMs;
|
|
75
|
+
waitersMax;
|
|
76
|
+
pollIntervalMs;
|
|
77
|
+
now;
|
|
78
|
+
genId;
|
|
79
|
+
sleep;
|
|
80
|
+
constructor(inner, deps = {}) {
|
|
81
|
+
this.inner = inner;
|
|
82
|
+
this.semaphore = deps.semaphore ?? getHostSpawnSemaphore();
|
|
83
|
+
this.acquireMs = deps.acquireMs ?? configuredSpawnAcquireMs();
|
|
84
|
+
this.waitersMax = deps.waitersMax ?? configuredSpawnWaitersMax();
|
|
85
|
+
this.pollIntervalMs = deps.pollIntervalMs ?? 100;
|
|
86
|
+
this.now = deps.now ?? (() => Date.now());
|
|
87
|
+
this.genId =
|
|
88
|
+
deps.genId ?? (() => `spawn:${process.pid}:${this.now().toString(36)}:${Math.random().toString(36).slice(2, 10)}`);
|
|
89
|
+
this.sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
90
|
+
}
|
|
91
|
+
async evaluate(prompt, options) {
|
|
92
|
+
const id = this.genId();
|
|
93
|
+
const startedAt = this.now();
|
|
94
|
+
// P3 waiters bound — refuse to even start polling past the ceiling. This is
|
|
95
|
+
// checked BEFORE incrementing so the ceiling is a hard cap on concurrent
|
|
96
|
+
// pollers. The shed is the same typed error so the gate seams fail closed.
|
|
97
|
+
if (_activePollers >= this.waitersMax) {
|
|
98
|
+
throw new LlmCapacityUnavailableError('waiters-full', 0);
|
|
99
|
+
}
|
|
100
|
+
_activePollers++;
|
|
101
|
+
let acquired = false;
|
|
102
|
+
try {
|
|
103
|
+
// Fast path — try once immediately (no sleep) before entering the poll loop.
|
|
104
|
+
if (this.semaphore.acquire(id)) {
|
|
105
|
+
acquired = true;
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
acquired = await this.pollAcquire(id, startedAt);
|
|
109
|
+
}
|
|
110
|
+
if (!acquired) {
|
|
111
|
+
throw new LlmCapacityUnavailableError('acquire-timeout', this.now() - startedAt);
|
|
112
|
+
}
|
|
113
|
+
// Slot held — run the actual spawn. The semaphore is released in finally.
|
|
114
|
+
return await this.inner.evaluate(prompt, options);
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
_activePollers--;
|
|
118
|
+
if (acquired) {
|
|
119
|
+
// Crash-safe: release is idempotent (unknown id is a no-op).
|
|
120
|
+
try {
|
|
121
|
+
this.semaphore.release(id);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
/* @silent-fallback-ok: a release failure self-heals via prune-dead
|
|
125
|
+
(this pid will keep going / eventually die) — never throw out of
|
|
126
|
+
finally and mask the real result. */
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/** Poll the semaphore every `pollIntervalMs` until acquired or the budget elapses. */
|
|
132
|
+
async pollAcquire(id, startedAt) {
|
|
133
|
+
const deadline = startedAt + this.acquireMs;
|
|
134
|
+
// Defense-in-depth iteration ceiling: the loop is wall-clock bound by
|
|
135
|
+
// `deadline`, but a frozen/non-advancing clock (a test injection, never
|
|
136
|
+
// production) could otherwise spin. Cap iterations at the worst-case poll
|
|
137
|
+
// count plus a margin so the loop ALWAYS terminates.
|
|
138
|
+
const maxIters = Math.ceil(this.acquireMs / Math.max(1, this.pollIntervalMs)) + 8;
|
|
139
|
+
for (let iter = 0; iter < maxIters; iter++) {
|
|
140
|
+
// Sleep first (the immediate try already happened in evaluate()).
|
|
141
|
+
const remaining = deadline - this.now();
|
|
142
|
+
if (remaining <= 0)
|
|
143
|
+
return false;
|
|
144
|
+
await this.sleep(Math.min(this.pollIntervalMs, remaining));
|
|
145
|
+
if (this.semaphore.acquire(id))
|
|
146
|
+
return true;
|
|
147
|
+
if (this.now() >= deadline)
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Wrap a provider with the host spawn cap. No-ops on null (so a possibly-null
|
|
155
|
+
* factory result passes through unchanged) and is idempotent (never double-wraps).
|
|
156
|
+
*/
|
|
157
|
+
export function wrapIntelligenceWithSpawnCap(provider, deps) {
|
|
158
|
+
if (!provider)
|
|
159
|
+
return null;
|
|
160
|
+
if (provider instanceof SpawnCapIntelligenceProvider)
|
|
161
|
+
return provider;
|
|
162
|
+
return new SpawnCapIntelligenceProvider(provider, deps);
|
|
163
|
+
}
|
|
164
|
+
//# sourceMappingURL=SpawnCapIntelligenceProvider.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"SpawnCapIntelligenceProvider.js","sourceRoot":"","sources":["../../src/core/SpawnCapIntelligenceProvider.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hostSpawnSemaphore — P1 of the SIMPLE fork-bomb prevention design.
|
|
3
|
+
*
|
|
4
|
+
* Spec: docs/specs/forkbomb-prevention-simple.md (§P1, §P3, §D-CAP).
|
|
5
|
+
* Source postmortem: the-portal/docs/postmortems/2026-06-20-echo-instar-forkbomb-oom.md.
|
|
6
|
+
*
|
|
7
|
+
* THE PRIMARY CONTROL. A single host-local COUNTING SEMAPHORE that bounds how
|
|
8
|
+
* many LLM subprocesses ("claude -p" / "codex exec" / …) run AT ONCE across
|
|
9
|
+
* EVERY compliant Instar agent + server instance on this host. The 2026-06-20
|
|
10
|
+
* incident fork-bombed a 128GB macOS host into OOM twice (~230-289 concurrent
|
|
11
|
+
* `claude -p` ≈ 90-115GB) because `evaluate()` spawned one subprocess per call
|
|
12
|
+
* with ZERO concurrency control and CoherenceGate fans ~10 reviewers in
|
|
13
|
+
* parallel per message. This bounds that.
|
|
14
|
+
*
|
|
15
|
+
* MECHANISM — a holder-SET model (NOT decrement/increment counter math):
|
|
16
|
+
* A host-local file (`~/.instar/host-spawn-holders.json`, NOT a synced
|
|
17
|
+
* volume), guarded by an exclusive O_CREAT|O_EXCL lock (the in-tree
|
|
18
|
+
* ProjectRoundLock pattern). The cap is enforced by COUNTING LIVE holder
|
|
19
|
+
* records — never by mutating a shared integer:
|
|
20
|
+
* - acquire(id): under the lock, prune dead holders, and if
|
|
21
|
+
* liveHolders < cap append `{id, pid, hostname, heartbeat}` (atomic
|
|
22
|
+
* temp+rename) → true; else false.
|
|
23
|
+
* - release(id): under the lock, remove THIS id.
|
|
24
|
+
* Crash-safe by construction: a double-release is a no-op (id already gone),
|
|
25
|
+
* a pid-reuse can't steal a slot (unique id, not pid), a partial write is
|
|
26
|
+
* discarded (temp+rename), a crashed holder is reclaimed by prune-dead
|
|
27
|
+
* (pid not alive AND heartbeat stale, on THIS host only).
|
|
28
|
+
*
|
|
29
|
+
* HOST-LOCAL-LOCK CONTRACT (mirrors ResumeQueue.ts, the 2026-06-15 lesson):
|
|
30
|
+
* - A FOREIGN-hostname holder is NEVER pruned/reclaimed (refuse-loud — a
|
|
31
|
+
* pid check is meaningless cross-host on a shared volume).
|
|
32
|
+
* - A `df -P` host-local-disk confirmation gates reclaim (fail-closed: if we
|
|
33
|
+
* cannot positively confirm the holders file is on a local disk, we NEVER
|
|
34
|
+
* prune a holder — we only ever decline to reclaim, never decline to bound).
|
|
35
|
+
*
|
|
36
|
+
* BOUNDED INGRESS (P3) lives in the wrapper (SpawnCapIntelligenceProvider) via
|
|
37
|
+
* poll-retry against `acquire()` — this module is the pure counting primitive.
|
|
38
|
+
*/
|
|
39
|
+
/** One live holder of a spawn slot. */
|
|
40
|
+
export interface SpawnHolder {
|
|
41
|
+
/** Unique per-acquire id (NOT the pid — pid-reuse must not steal a slot). */
|
|
42
|
+
id: string;
|
|
43
|
+
pid: number;
|
|
44
|
+
hostname: string;
|
|
45
|
+
/** ms epoch of the last heartbeat (acquire time, refreshed by long holders). */
|
|
46
|
+
heartbeat: number;
|
|
47
|
+
}
|
|
48
|
+
/** Heartbeat staleness window — a holder is reclaim-eligible (pid dead OR, on a
|
|
49
|
+
* provably host-local disk, heartbeat older than this) only past it. Kept long
|
|
50
|
+
* (a slow `claude -p` cold-start + run can legitimately exceed a minute), and
|
|
51
|
+
* pid-liveness is the PRIMARY signal — heartbeat staleness is secondary and
|
|
52
|
+
* only consulted when the pid is also gone. */
|
|
53
|
+
export declare const HOLDER_STALE_MS: number;
|
|
54
|
+
/**
|
|
55
|
+
* FD1 (from ResumeQueue) — is `p` on a HOST-LOCAL filesystem? FAIL-CLOSED:
|
|
56
|
+
* anything we cannot positively confirm as local returns false, so a holder on
|
|
57
|
+
* a genuine shared/network volume is NEVER reclaimed (the two-hosts-one-volume
|
|
58
|
+
* corruption the host-lock invariant protects against). `df -P` device-column
|
|
59
|
+
* classification — re-implemented here (not imported from monitoring/) so
|
|
60
|
+
* core/ never depends on monitoring/.
|
|
61
|
+
*/
|
|
62
|
+
export declare function isPathHostLocalDefault(p: string): boolean;
|
|
63
|
+
/** Pure FD1 classifier over the `df -P` device-source column. FAIL-CLOSED. */
|
|
64
|
+
export declare function classifyDfSourceLocal(source: string): boolean;
|
|
65
|
+
export interface HostSpawnSemaphoreDeps {
|
|
66
|
+
/** Absolute path to the holders file. Default `~/.instar/host-spawn-holders.json`. */
|
|
67
|
+
holdersPath?: string;
|
|
68
|
+
/** Concurrent-spawn cap. Default resolved by `resolveSpawnCap()`. */
|
|
69
|
+
cap?: number;
|
|
70
|
+
now?: () => number;
|
|
71
|
+
hostname?: () => string;
|
|
72
|
+
/** pid liveness probe (tests override). */
|
|
73
|
+
pidAlive?: (pid: number) => boolean;
|
|
74
|
+
/** Host-local FS probe (tests override; default `isPathHostLocalDefault`). */
|
|
75
|
+
isPathHostLocal?: (p: string) => boolean;
|
|
76
|
+
/** Unique-id generator (tests override for determinism). */
|
|
77
|
+
genId?: () => string;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Resolve the concurrent-spawn cap. Precedence (D-CAP):
|
|
81
|
+
* INSTAR_HOST_SPAWN_MAX env > config intelligence.spawnCap.maxConcurrent > 8.
|
|
82
|
+
* A safety FLOOR — read with a plain `??` default, NEVER resolveDevAgentGate
|
|
83
|
+
* (it is ON by default, ships never-dark). A non-positive / non-finite value
|
|
84
|
+
* is ignored (falls through to the next source) so a typo can't disable the cap.
|
|
85
|
+
*/
|
|
86
|
+
export declare function resolveSpawnCap(configCap?: number, env?: NodeJS.ProcessEnv): number;
|
|
87
|
+
/**
|
|
88
|
+
* Resolve the bounded-acquire poll budget in ms (P3). Precedence (D-CAP):
|
|
89
|
+
* INSTAR_SPAWN_ACQUIRE_MS env > config intelligence.spawnCap.acquireMs > 5000.
|
|
90
|
+
* Read with a plain `??` default (safety floor, never resolveDevAgentGate).
|
|
91
|
+
*/
|
|
92
|
+
export declare function resolveSpawnAcquireMs(configMs?: number, env?: NodeJS.ProcessEnv): number;
|
|
93
|
+
/**
|
|
94
|
+
* Resolve the concurrent-pollers ceiling (P3). Precedence (D-CAP):
|
|
95
|
+
* INSTAR_SPAWN_WAITERS_MAX env > config intelligence.spawnCap.waitersMax > 64.
|
|
96
|
+
*/
|
|
97
|
+
export declare function resolveSpawnWaitersMax(configMax?: number, env?: NodeJS.ProcessEnv): number;
|
|
98
|
+
export interface SpawnSemaphoreStatus {
|
|
99
|
+
cap: number;
|
|
100
|
+
/** Live holders after pruning, this host + foreign. */
|
|
101
|
+
liveHolders: number;
|
|
102
|
+
/** Holders whose hostname is THIS host. */
|
|
103
|
+
localHolders: number;
|
|
104
|
+
/** Holders whose hostname is a DIFFERENT host (never reclaimed). */
|
|
105
|
+
foreignHolders: number;
|
|
106
|
+
holdersPath: string;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Host-wide counting semaphore over LLM-subprocess spawns. One instance per
|
|
110
|
+
* process is fine — the cross-process coordination is the file + flock, not
|
|
111
|
+
* the object. Stateless beyond the file.
|
|
112
|
+
*/
|
|
113
|
+
export declare class HostSpawnSemaphore {
|
|
114
|
+
private readonly holdersPath;
|
|
115
|
+
private readonly cap;
|
|
116
|
+
private readonly now;
|
|
117
|
+
private readonly host;
|
|
118
|
+
private readonly pidAlive;
|
|
119
|
+
private readonly isPathHostLocal;
|
|
120
|
+
private readonly genId;
|
|
121
|
+
/** Memoized host-local determination (a fixed path's FS type can't change at
|
|
122
|
+
* runtime; the `df -P` probe is expensive + synchronous, so it runs ONCE per
|
|
123
|
+
* instance — never per acquire() on the hot fan-out path). */
|
|
124
|
+
private _fsLocalCache;
|
|
125
|
+
constructor(deps?: HostSpawnSemaphoreDeps);
|
|
126
|
+
getCap(): number;
|
|
127
|
+
/**
|
|
128
|
+
* Try to take a slot under id `id`. Returns true if a slot was appended
|
|
129
|
+
* (liveHolders < cap after pruning), false if the host is at the cap.
|
|
130
|
+
* Holds the exclusive flock for the whole read-prune-decide-write window.
|
|
131
|
+
*/
|
|
132
|
+
acquire(id: string): boolean;
|
|
133
|
+
/** Release the slot held under `id`. A double-release / unknown id is a no-op. */
|
|
134
|
+
release(id: string): void;
|
|
135
|
+
/** Refresh a long-held slot's heartbeat so a slow legit spawn is never reclaimed. */
|
|
136
|
+
heartbeat(id: string): void;
|
|
137
|
+
/** Read-only status (count of live holders after a prune). Best-effort: never throws. */
|
|
138
|
+
status(): SpawnSemaphoreStatus;
|
|
139
|
+
/**
|
|
140
|
+
* Prune dead holders. A holder is reclaimable ONLY if:
|
|
141
|
+
* - it is on THIS host (a foreign-hostname holder is NEVER reclaimed —
|
|
142
|
+
* refuse-loud — the cross-host shared-volume hazard), AND
|
|
143
|
+
* - its pid is no longer alive (PRIMARY signal), AND
|
|
144
|
+
* - its heartbeat is stale past HOLDER_STALE_MS (SECONDARY signal — a slow
|
|
145
|
+
* spawn whose pid is alive is NEVER reclaimed regardless of heartbeat).
|
|
146
|
+
* AND a `df -P` host-local confirmation gates ALL reclaim (fail-closed: if we
|
|
147
|
+
* cannot confirm the holders file is on a local disk, we keep ALL holders —
|
|
148
|
+
* over-counting is the safe direction for a cap; it never under-bounds).
|
|
149
|
+
*/
|
|
150
|
+
private pruneDead;
|
|
151
|
+
private readHolders;
|
|
152
|
+
private writeHolders;
|
|
153
|
+
/**
|
|
154
|
+
* Run `fn` while holding the exclusive O_CREAT|O_EXCL lock on `<holdersPath>.lock`.
|
|
155
|
+
* On a lock-contention failure (another process holds it), returns
|
|
156
|
+
* `fallbackOnLockFail` — for `acquire`, fallback=false is the SAFE direction
|
|
157
|
+
* (refuse the slot under contention rather than over-grant past the cap).
|
|
158
|
+
* The lock is short-held (a file read + JSON parse + write) so contention is
|
|
159
|
+
* brief; a crashed lock-holder's stale lock is reclaimed by pid-death.
|
|
160
|
+
*/
|
|
161
|
+
private withLock;
|
|
162
|
+
/** Remove the lock file if its recorded holder pid is dead (crash recovery). */
|
|
163
|
+
private reclaimStaleLock;
|
|
164
|
+
private ensureDir;
|
|
165
|
+
}
|
|
166
|
+
/** Default holders file path: `~/.instar/host-spawn-holders.json` (host-local). */
|
|
167
|
+
export declare function defaultHoldersPath(): string;
|
|
168
|
+
/** Operator config for the spawn cap (intelligence.spawnCap.*). All optional. */
|
|
169
|
+
export interface SpawnCapConfig {
|
|
170
|
+
maxConcurrent?: number;
|
|
171
|
+
acquireMs?: number;
|
|
172
|
+
waitersMax?: number;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Inject the config-resolved spawn-cap knobs once at server boot (server.ts).
|
|
176
|
+
* Idempotent: re-resolves the singleton so a later getter sees the configured
|
|
177
|
+
* cap. The env vars still win inside the `resolve*` helpers.
|
|
178
|
+
*/
|
|
179
|
+
export declare function configureHostSpawnSemaphore(cfg?: SpawnCapConfig): void;
|
|
180
|
+
/** The process-wide semaphore. Lazily constructed with env/config/8 cap. */
|
|
181
|
+
export declare function getHostSpawnSemaphore(): HostSpawnSemaphore;
|
|
182
|
+
/** Config-aware acquire-budget resolver (env > injected config > 5000). */
|
|
183
|
+
export declare function configuredSpawnAcquireMs(): number;
|
|
184
|
+
/** Config-aware waiters-ceiling resolver (env > injected config > 64). */
|
|
185
|
+
export declare function configuredSpawnWaitersMax(): number;
|
|
186
|
+
/** Test seam — reset the singleton + injected config. */
|
|
187
|
+
export declare function _resetHostSpawnSemaphoreForTest(): void;
|
|
188
|
+
//# sourceMappingURL=hostSpawnSemaphore.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hostSpawnSemaphore.d.ts","sourceRoot":"","sources":["../../src/core/hostSpawnSemaphore.ts"],"names":[],"mappings":"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"}
|