instar 1.3.646 → 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 +41 -2
- 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/feedback-factory/store/JsonlFeedbackStore.d.ts +2 -0
- package/dist/feedback-factory/store/JsonlFeedbackStore.d.ts.map +1 -1
- package/dist/feedback-factory/store/JsonlFeedbackStore.js +36 -2
- package/dist/feedback-factory/store/JsonlFeedbackStore.js.map +1 -1
- package/dist/messaging/TelegramAdapter.d.ts.map +1 -1
- package/dist/messaging/TelegramAdapter.js +6 -2
- package/dist/messaging/TelegramAdapter.js.map +1 -1
- package/dist/monitoring/CoherenceMonitor.d.ts.map +1 -1
- package/dist/monitoring/CoherenceMonitor.js +6 -1
- package/dist/monitoring/CoherenceMonitor.js.map +1 -1
- package/dist/monitoring/CommitmentSentinel.d.ts.map +1 -1
- package/dist/monitoring/CommitmentSentinel.js +7 -2
- package/dist/monitoring/CommitmentSentinel.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/dist/utils/jsonl-tail.d.ts +50 -0
- package/dist/utils/jsonl-tail.d.ts.map +1 -0
- package/dist/utils/jsonl-tail.js +96 -0
- package/dist/utils/jsonl-tail.js.map +1 -0
- 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.647.md +56 -0
- package/upgrades/1.3.648.md +99 -0
- package/upgrades/eli16/eventloop-bounded-jsonl-reads.md +47 -0
- package/upgrades/side-effects/eventloop-bounded-jsonl-reads.md +96 -0
- package/upgrades/side-effects/forkbomb-prevention-simple.md +138 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SingleInstanceLock — P2 of the SIMPLE fork-bomb prevention design.
|
|
3
|
+
*
|
|
4
|
+
* Spec: docs/specs/forkbomb-prevention-simple.md (§P2, §D-LOCK).
|
|
5
|
+
*
|
|
6
|
+
* A per-agent, HOST-LOCAL lock so launchd + fleet + tmux cannot run DUPLICATE
|
|
7
|
+
* server instances of the same agent — the 3× multiplier that made the
|
|
8
|
+
* 2026-06-20 fork-bomb catastrophic (three concurrent server instances each
|
|
9
|
+
* re-flooding `claude -p`).
|
|
10
|
+
*
|
|
11
|
+
* MECHANISM (mirrors ProjectRoundLock O_CREAT|O_EXCL + the ResumeQueue host-lock
|
|
12
|
+
* contract):
|
|
13
|
+
* - Lock file `<stateDir>/local/server-instance.lock`, holder record =
|
|
14
|
+
* `{ pid, hostname, heartbeat }` (ms-epoch mtime-style heartbeat).
|
|
15
|
+
* - A FOREIGN-hostname lock is NEVER pid-probed or reclaimed (refuse-loud —
|
|
16
|
+
* the multi-machine shared-state-dir hazard, 2026-06-15): a legit standby on
|
|
17
|
+
* a DIFFERENT host with its own non-shared state dir boots freely.
|
|
18
|
+
* - Same-host stale reclaim is gated on a `df -P` host-local-disk confirmation
|
|
19
|
+
* (fail-closed: cannot-confirm-local → never reclaim).
|
|
20
|
+
* - DEPLOY HANDOFF (not too blunt): on finding a LIVE same-host holder, WAIT a
|
|
21
|
+
* bounded grace for it to release (a normal restart kills-then-respawns, so
|
|
22
|
+
* the outgoing holder's exit handler frees the lock) before refusing — so a
|
|
23
|
+
* clean restart hands off and only a genuine DUPLICATE (two independent
|
|
24
|
+
* supervisors racing) is refused.
|
|
25
|
+
* - Override: `INSTAR_ALLOW_SECOND_INSTANCE=1` boots a deliberate admin/debug
|
|
26
|
+
* instance without the lock.
|
|
27
|
+
*
|
|
28
|
+
* Release is wired by the CALLER via BOTH a `finally`/shutdown path AND a
|
|
29
|
+
* process exit handler (SIGTERM/SIGINT/exit) — see `installReleaseHandlers`.
|
|
30
|
+
*/
|
|
31
|
+
interface InstanceLockRecord {
|
|
32
|
+
pid: number;
|
|
33
|
+
hostname: string;
|
|
34
|
+
heartbeat: number;
|
|
35
|
+
}
|
|
36
|
+
export interface SingleInstanceLockResult {
|
|
37
|
+
acquired: boolean;
|
|
38
|
+
/** Why not (when acquired=false). */
|
|
39
|
+
reason?: 'duplicate-live-instance' | 'foreign-host-conflict' | 'override-bypassed';
|
|
40
|
+
/** The conflicting holder (when refused on a same/foreign-host live lock). */
|
|
41
|
+
currentHolder?: InstanceLockRecord;
|
|
42
|
+
/** True when bypassed via INSTAR_ALLOW_SECOND_INSTANCE (acquired stays true). */
|
|
43
|
+
overridden?: boolean;
|
|
44
|
+
}
|
|
45
|
+
export interface SingleInstanceLockDeps {
|
|
46
|
+
/** `.instar` state dir — the lock lives under `<stateDir>/local/`. */
|
|
47
|
+
stateDir: string;
|
|
48
|
+
now?: () => number;
|
|
49
|
+
hostname?: () => string;
|
|
50
|
+
pidAlive?: (pid: number) => boolean;
|
|
51
|
+
/** Host-local FS probe (tests override). */
|
|
52
|
+
isStateDirHostLocal?: (stateDir: string) => boolean;
|
|
53
|
+
/** Deploy-handoff grace (ms) to wait for an outgoing same-host holder. Default 8000. */
|
|
54
|
+
handoffGraceMs?: number;
|
|
55
|
+
/** Poll interval (ms) while waiting for handoff. Default 250. */
|
|
56
|
+
pollIntervalMs?: number;
|
|
57
|
+
/** Awaitable delay (tests override). */
|
|
58
|
+
sleep?: (ms: number) => Promise<void>;
|
|
59
|
+
/** Override env read (tests inject). Default `process.env`. */
|
|
60
|
+
env?: NodeJS.ProcessEnv;
|
|
61
|
+
/** Logger (default console.error). */
|
|
62
|
+
log?: (msg: string) => void;
|
|
63
|
+
}
|
|
64
|
+
/** `df -P` host-local classifier (FAIL-CLOSED) — same contract as ResumeQueue/semaphore. */
|
|
65
|
+
export declare function isStateDirHostLocalForLock(stateDir: string): boolean;
|
|
66
|
+
export declare class SingleInstanceLock {
|
|
67
|
+
private readonly stateDir;
|
|
68
|
+
private readonly now;
|
|
69
|
+
private readonly host;
|
|
70
|
+
private readonly pidAlive;
|
|
71
|
+
private readonly isStateDirHostLocal;
|
|
72
|
+
private readonly handoffGraceMs;
|
|
73
|
+
private readonly pollIntervalMs;
|
|
74
|
+
private readonly sleep;
|
|
75
|
+
private readonly env;
|
|
76
|
+
private readonly log;
|
|
77
|
+
private held;
|
|
78
|
+
constructor(deps: SingleInstanceLockDeps);
|
|
79
|
+
lockPath(): string;
|
|
80
|
+
/**
|
|
81
|
+
* Try to acquire the single-instance lock. Resolves with `acquired: false`
|
|
82
|
+
* (and a reason) on a genuine duplicate; `acquired: true` on success or a
|
|
83
|
+
* deliberate override.
|
|
84
|
+
*/
|
|
85
|
+
acquire(): Promise<SingleInstanceLockResult>;
|
|
86
|
+
/** Refresh the heartbeat (optional — the lock is primarily pid-guarded). */
|
|
87
|
+
heartbeat(): void;
|
|
88
|
+
/** Release the lock — ONLY if WE still hold it (pid+host match). Idempotent. */
|
|
89
|
+
release(): void;
|
|
90
|
+
private readLock;
|
|
91
|
+
/** Atomic O_CREAT|O_EXCL write. Returns false if another writer won the race. */
|
|
92
|
+
private tryWriteLock;
|
|
93
|
+
private ensureDir;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Wire release on every exit path (SIGTERM/SIGINT/exit). The caller's graceful
|
|
97
|
+
* shutdown should ALSO call `lock.release()` directly (belt-and-suspenders); the
|
|
98
|
+
* `exit` handler is the last-resort net for a hard exit. Returns a disposer that
|
|
99
|
+
* removes the handlers (rarely needed; useful in tests).
|
|
100
|
+
*/
|
|
101
|
+
export declare function installReleaseHandlers(lock: SingleInstanceLock): () => void;
|
|
102
|
+
export {};
|
|
103
|
+
//# sourceMappingURL=SingleInstanceLock.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"SingleInstanceLock.d.ts","sourceRoot":"","sources":["../../src/core/SingleInstanceLock.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAUH,UAAU,kBAAkB;IAC1B,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,wBAAwB;IACvC,QAAQ,EAAE,OAAO,CAAC;IAClB,qCAAqC;IACrC,MAAM,CAAC,EAAE,yBAAyB,GAAG,uBAAuB,GAAG,mBAAmB,CAAC;IACnF,8EAA8E;IAC9E,aAAa,CAAC,EAAE,kBAAkB,CAAC;IACnC,iFAAiF;IACjF,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AAED,MAAM,WAAW,sBAAsB;IACrC,sEAAsE;IACtE,QAAQ,EAAE,MAAM,CAAC;IACjB,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,MAAM,CAAC;IACxB,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;IACpC,4CAA4C;IAC5C,mBAAmB,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC;IACpD,wFAAwF;IACxF,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,iEAAiE;IACjE,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,wCAAwC;IACxC,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACtC,+DAA+D;IAC/D,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,sCAAsC;IACtC,GAAG,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;CAC7B;AAED,4FAA4F;AAC5F,wBAAgB,0BAA0B,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAmBpE;AAED,qBAAa,kBAAkB;IAC7B,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAe;IACnC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAS;IAC9B,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAA2B;IACpD,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAAgC;IACpE,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAS;IACxC,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAS;IACxC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAgC;IACtD,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAoB;IACxC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAwB;IAC5C,OAAO,CAAC,IAAI,CAAS;gBAET,IAAI,EAAE,sBAAsB;IAaxC,QAAQ,IAAI,MAAM;IAIlB;;;;OAIG;IACG,OAAO,IAAI,OAAO,CAAC,wBAAwB,CAAC;IAoFlD,4EAA4E;IAC5E,SAAS,IAAI,IAAI;IAajB,gFAAgF;IAChF,OAAO,IAAI,IAAI;IAkBf,OAAO,CAAC,QAAQ;IAehB,iFAAiF;IACjF,OAAO,CAAC,YAAY;IAmCpB,OAAO,CAAC,SAAS;CAIlB;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,kBAAkB,GAAG,MAAM,IAAI,CAiB3E"}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SingleInstanceLock — P2 of the SIMPLE fork-bomb prevention design.
|
|
3
|
+
*
|
|
4
|
+
* Spec: docs/specs/forkbomb-prevention-simple.md (§P2, §D-LOCK).
|
|
5
|
+
*
|
|
6
|
+
* A per-agent, HOST-LOCAL lock so launchd + fleet + tmux cannot run DUPLICATE
|
|
7
|
+
* server instances of the same agent — the 3× multiplier that made the
|
|
8
|
+
* 2026-06-20 fork-bomb catastrophic (three concurrent server instances each
|
|
9
|
+
* re-flooding `claude -p`).
|
|
10
|
+
*
|
|
11
|
+
* MECHANISM (mirrors ProjectRoundLock O_CREAT|O_EXCL + the ResumeQueue host-lock
|
|
12
|
+
* contract):
|
|
13
|
+
* - Lock file `<stateDir>/local/server-instance.lock`, holder record =
|
|
14
|
+
* `{ pid, hostname, heartbeat }` (ms-epoch mtime-style heartbeat).
|
|
15
|
+
* - A FOREIGN-hostname lock is NEVER pid-probed or reclaimed (refuse-loud —
|
|
16
|
+
* the multi-machine shared-state-dir hazard, 2026-06-15): a legit standby on
|
|
17
|
+
* a DIFFERENT host with its own non-shared state dir boots freely.
|
|
18
|
+
* - Same-host stale reclaim is gated on a `df -P` host-local-disk confirmation
|
|
19
|
+
* (fail-closed: cannot-confirm-local → never reclaim).
|
|
20
|
+
* - DEPLOY HANDOFF (not too blunt): on finding a LIVE same-host holder, WAIT a
|
|
21
|
+
* bounded grace for it to release (a normal restart kills-then-respawns, so
|
|
22
|
+
* the outgoing holder's exit handler frees the lock) before refusing — so a
|
|
23
|
+
* clean restart hands off and only a genuine DUPLICATE (two independent
|
|
24
|
+
* supervisors racing) is refused.
|
|
25
|
+
* - Override: `INSTAR_ALLOW_SECOND_INSTANCE=1` boots a deliberate admin/debug
|
|
26
|
+
* instance without the lock.
|
|
27
|
+
*
|
|
28
|
+
* Release is wired by the CALLER via BOTH a `finally`/shutdown path AND a
|
|
29
|
+
* process exit handler (SIGTERM/SIGINT/exit) — see `installReleaseHandlers`.
|
|
30
|
+
*/
|
|
31
|
+
import fs from 'node:fs';
|
|
32
|
+
import os from 'node:os';
|
|
33
|
+
import path from 'node:path';
|
|
34
|
+
import { execFileSync } from 'node:child_process';
|
|
35
|
+
import { SafeFsExecutor } from './SafeFsExecutor.js';
|
|
36
|
+
import { isAlive } from './ProjectRoundLock.js';
|
|
37
|
+
/** `df -P` host-local classifier (FAIL-CLOSED) — same contract as ResumeQueue/semaphore. */
|
|
38
|
+
export function isStateDirHostLocalForLock(stateDir) {
|
|
39
|
+
let out;
|
|
40
|
+
try {
|
|
41
|
+
// lint-allow-sync-spawn: a bounded (3s) one-shot host-FS classification, run
|
|
42
|
+
// only at server-BOOT during single-instance-lock acquisition (never on a
|
|
43
|
+
// request/event-loop path). Mirrors ResumeQueue's host-lock df -P contract.
|
|
44
|
+
out = execFileSync('df', ['-P', stateDir], { timeout: 3000, encoding: 'utf-8' });
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// @silent-fallback-ok: df failed ⇒ cannot confirm local ⇒ fail-closed.
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
const lines = out.trim().split('\n');
|
|
51
|
+
if (lines.length < 2)
|
|
52
|
+
return false;
|
|
53
|
+
const source = lines[1]?.trim().split(/\s+/)[0] ?? '';
|
|
54
|
+
if (!source)
|
|
55
|
+
return false;
|
|
56
|
+
if (source.startsWith('//'))
|
|
57
|
+
return false;
|
|
58
|
+
if (/^[^/][^:]*:/.test(source))
|
|
59
|
+
return false;
|
|
60
|
+
if (source.startsWith('/dev/'))
|
|
61
|
+
return true;
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
export class SingleInstanceLock {
|
|
65
|
+
stateDir;
|
|
66
|
+
now;
|
|
67
|
+
host;
|
|
68
|
+
pidAlive;
|
|
69
|
+
isStateDirHostLocal;
|
|
70
|
+
handoffGraceMs;
|
|
71
|
+
pollIntervalMs;
|
|
72
|
+
sleep;
|
|
73
|
+
env;
|
|
74
|
+
log;
|
|
75
|
+
held = false;
|
|
76
|
+
constructor(deps) {
|
|
77
|
+
this.stateDir = deps.stateDir;
|
|
78
|
+
this.now = deps.now ?? (() => Date.now());
|
|
79
|
+
this.host = (deps.hostname ?? (() => os.hostname()))();
|
|
80
|
+
this.pidAlive = deps.pidAlive ?? isAlive;
|
|
81
|
+
this.isStateDirHostLocal = deps.isStateDirHostLocal ?? isStateDirHostLocalForLock;
|
|
82
|
+
this.handoffGraceMs = deps.handoffGraceMs ?? 8000;
|
|
83
|
+
this.pollIntervalMs = deps.pollIntervalMs ?? 250;
|
|
84
|
+
this.sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
85
|
+
this.env = deps.env ?? process.env;
|
|
86
|
+
this.log = deps.log ?? ((m) => console.error(m));
|
|
87
|
+
}
|
|
88
|
+
lockPath() {
|
|
89
|
+
return path.join(this.stateDir, 'local', 'server-instance.lock');
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Try to acquire the single-instance lock. Resolves with `acquired: false`
|
|
93
|
+
* (and a reason) on a genuine duplicate; `acquired: true` on success or a
|
|
94
|
+
* deliberate override.
|
|
95
|
+
*/
|
|
96
|
+
async acquire() {
|
|
97
|
+
// Operator override — a deliberate second/admin instance.
|
|
98
|
+
if (this.env['INSTAR_ALLOW_SECOND_INSTANCE'] === '1') {
|
|
99
|
+
this.log('[single-instance] INSTAR_ALLOW_SECOND_INSTANCE=1 — bypassing the single-instance lock (deliberate second instance).');
|
|
100
|
+
// Still write our record so a LATER third instance sees a holder.
|
|
101
|
+
this.tryWriteLock();
|
|
102
|
+
this.held = true;
|
|
103
|
+
return { acquired: true, overridden: true };
|
|
104
|
+
}
|
|
105
|
+
const deadline = this.now() + this.handoffGraceMs;
|
|
106
|
+
for (;;) {
|
|
107
|
+
const existing = this.readLock();
|
|
108
|
+
if (!existing) {
|
|
109
|
+
// Free — take it.
|
|
110
|
+
if (this.tryWriteLock()) {
|
|
111
|
+
this.held = true;
|
|
112
|
+
return { acquired: true };
|
|
113
|
+
}
|
|
114
|
+
// Lost a creation race — loop and re-read.
|
|
115
|
+
if (this.now() >= deadline) {
|
|
116
|
+
return { acquired: false, reason: 'duplicate-live-instance' };
|
|
117
|
+
}
|
|
118
|
+
await this.sleep(this.pollIntervalMs);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
// FOREIGN-host lock → NEVER reclaim (refuse-loud). A standby on another
|
|
122
|
+
// host with its own non-shared state dir boots freely; this branch only
|
|
123
|
+
// fires when two hosts SHARE a state dir (a misconfiguration we refuse
|
|
124
|
+
// rather than corrupt).
|
|
125
|
+
if (existing.hostname && existing.hostname !== this.host) {
|
|
126
|
+
this.log(`[single-instance] lock held by FOREIGN host "${existing.hostname}" (this host "${this.host}"). ` +
|
|
127
|
+
`Refusing — the state dir must be host-local; a shared volume is unsupported. ` +
|
|
128
|
+
`If this is a deliberate second instance, set INSTAR_ALLOW_SECOND_INSTANCE=1.`);
|
|
129
|
+
return { acquired: false, reason: 'foreign-host-conflict', currentHolder: existing };
|
|
130
|
+
}
|
|
131
|
+
// Same-host lock. Is the holder still alive?
|
|
132
|
+
if (this.pidAlive(existing.pid)) {
|
|
133
|
+
// A LIVE same-host holder. Could be a normal restart's outgoing instance
|
|
134
|
+
// (it will exit + release shortly) OR a genuine duplicate. Wait a bounded
|
|
135
|
+
// grace for a handoff before refusing.
|
|
136
|
+
if (this.now() >= deadline) {
|
|
137
|
+
this.log(`[single-instance] refusing to start: a LIVE instance (pid ${existing.pid}) of this agent is already ` +
|
|
138
|
+
`running on this host after a ${this.handoffGraceMs}ms handoff grace. This is the duplicate-flood guard. ` +
|
|
139
|
+
`If this is intentional, set INSTAR_ALLOW_SECOND_INSTANCE=1.`);
|
|
140
|
+
return { acquired: false, reason: 'duplicate-live-instance', currentHolder: existing };
|
|
141
|
+
}
|
|
142
|
+
await this.sleep(this.pollIntervalMs);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
// Same-host DEAD holder — reclaim ONLY on a host-local-disk confirmation.
|
|
146
|
+
if (!this.isStateDirHostLocal(this.stateDir)) {
|
|
147
|
+
this.log(`[single-instance] same-host lock holder (pid ${existing.pid}) is dead, but the state dir could not be ` +
|
|
148
|
+
`confirmed host-local (df -P) — refusing to reclaim (fail-closed). Set INSTAR_ALLOW_SECOND_INSTANCE=1 to override.`);
|
|
149
|
+
return { acquired: false, reason: 'foreign-host-conflict', currentHolder: existing };
|
|
150
|
+
}
|
|
151
|
+
// Reclaim the stale lock atomically and take it.
|
|
152
|
+
try {
|
|
153
|
+
SafeFsExecutor.safeUnlinkSync(this.lockPath(), { operation: 'SingleInstanceLock.acquire:reclaim-stale' });
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
/* race with another reclaimer — loop and re-read */
|
|
157
|
+
}
|
|
158
|
+
if (this.tryWriteLock()) {
|
|
159
|
+
this.held = true;
|
|
160
|
+
this.log(`[single-instance] reclaimed a stale same-host lock (dead pid ${existing.pid}).`);
|
|
161
|
+
return { acquired: true };
|
|
162
|
+
}
|
|
163
|
+
if (this.now() >= deadline) {
|
|
164
|
+
return { acquired: false, reason: 'duplicate-live-instance', currentHolder: existing };
|
|
165
|
+
}
|
|
166
|
+
await this.sleep(this.pollIntervalMs);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/** Refresh the heartbeat (optional — the lock is primarily pid-guarded). */
|
|
170
|
+
heartbeat() {
|
|
171
|
+
if (!this.held)
|
|
172
|
+
return;
|
|
173
|
+
const rec = this.readLock();
|
|
174
|
+
if (rec && rec.pid === process.pid && rec.hostname === this.host) {
|
|
175
|
+
rec.heartbeat = this.now();
|
|
176
|
+
try {
|
|
177
|
+
fs.writeFileSync(this.lockPath(), JSON.stringify(rec), 'utf-8');
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
/* best-effort */
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/** Release the lock — ONLY if WE still hold it (pid+host match). Idempotent. */
|
|
185
|
+
release() {
|
|
186
|
+
if (!this.held)
|
|
187
|
+
return;
|
|
188
|
+
this.held = false;
|
|
189
|
+
const rec = this.readLock();
|
|
190
|
+
if (!rec)
|
|
191
|
+
return;
|
|
192
|
+
if (rec.pid !== process.pid || rec.hostname !== this.host) {
|
|
193
|
+
// A successor already took it — never delete someone else's lock.
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
SafeFsExecutor.safeUnlinkSync(this.lockPath(), { operation: 'SingleInstanceLock.release' });
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
/* @silent-fallback-ok: the lock is already gone — release is idempotent */
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// ── Internals ─────────────────────────────────────────────────────
|
|
204
|
+
readLock() {
|
|
205
|
+
try {
|
|
206
|
+
const raw = fs.readFileSync(this.lockPath(), 'utf-8');
|
|
207
|
+
const obj = JSON.parse(raw);
|
|
208
|
+
if (typeof obj?.pid !== 'number')
|
|
209
|
+
return null;
|
|
210
|
+
if (typeof obj?.hostname !== 'string')
|
|
211
|
+
return null;
|
|
212
|
+
const heartbeat = typeof obj?.heartbeat === 'number' ? obj.heartbeat : 0;
|
|
213
|
+
return { pid: obj.pid, hostname: obj.hostname, heartbeat };
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
// @silent-fallback-ok: a missing/corrupt lock file reads as "no holder"
|
|
217
|
+
// (null) — the acquire loop then treats the slot as free, the safe direction.
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/** Atomic O_CREAT|O_EXCL write. Returns false if another writer won the race. */
|
|
222
|
+
tryWriteLock() {
|
|
223
|
+
this.ensureDir();
|
|
224
|
+
const rec = { pid: process.pid, hostname: this.host, heartbeat: this.now() };
|
|
225
|
+
const body = JSON.stringify(rec);
|
|
226
|
+
const tmp = `${this.lockPath()}.tmp.${process.pid}`;
|
|
227
|
+
try {
|
|
228
|
+
const fd = fs.openSync(tmp, 'wx', 0o600);
|
|
229
|
+
fs.writeSync(fd, body);
|
|
230
|
+
fs.closeSync(fd);
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
// @silent-fallback-ok: a tmp-file write race ⇒ report "did not acquire"
|
|
234
|
+
// (false); the acquire loop re-reads and retries — never a silent grant.
|
|
235
|
+
try {
|
|
236
|
+
SafeFsExecutor.safeUnlinkSync(tmp, { operation: 'SingleInstanceLock.tryWriteLock:cleanup-tmp' });
|
|
237
|
+
}
|
|
238
|
+
catch { /* @silent-fallback-ok: tmp cleanup is best-effort */ }
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
// Use link()/rename guarded by an O_EXCL pre-check: rename would clobber an
|
|
242
|
+
// existing lock, so only rename when the target does NOT exist. The window
|
|
243
|
+
// is narrow; a clobber would only happen between two simultaneous fresh
|
|
244
|
+
// starts, and the pid-guarded release prevents deleting the winner's lock.
|
|
245
|
+
try {
|
|
246
|
+
if (fs.existsSync(this.lockPath())) {
|
|
247
|
+
// Someone created the lock between our read and now — back off.
|
|
248
|
+
try {
|
|
249
|
+
SafeFsExecutor.safeUnlinkSync(tmp, { operation: 'SingleInstanceLock.tryWriteLock:exists-backoff' });
|
|
250
|
+
}
|
|
251
|
+
catch { /* @silent-fallback-ok: tmp cleanup is best-effort */ }
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
fs.renameSync(tmp, this.lockPath());
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
// @silent-fallback-ok: a rename race ⇒ report "did not acquire" (false);
|
|
259
|
+
// the acquire loop re-reads and retries — never a silent grant past the cap.
|
|
260
|
+
try {
|
|
261
|
+
SafeFsExecutor.safeUnlinkSync(tmp, { operation: 'SingleInstanceLock.tryWriteLock:cleanup-tmp' });
|
|
262
|
+
}
|
|
263
|
+
catch { /* @silent-fallback-ok: tmp cleanup is best-effort */ }
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
ensureDir() {
|
|
268
|
+
const localDir = path.join(this.stateDir, 'local');
|
|
269
|
+
if (!fs.existsSync(localDir))
|
|
270
|
+
fs.mkdirSync(localDir, { recursive: true });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Wire release on every exit path (SIGTERM/SIGINT/exit). The caller's graceful
|
|
275
|
+
* shutdown should ALSO call `lock.release()` directly (belt-and-suspenders); the
|
|
276
|
+
* `exit` handler is the last-resort net for a hard exit. Returns a disposer that
|
|
277
|
+
* removes the handlers (rarely needed; useful in tests).
|
|
278
|
+
*/
|
|
279
|
+
export function installReleaseHandlers(lock) {
|
|
280
|
+
const onExit = () => {
|
|
281
|
+
try {
|
|
282
|
+
lock.release();
|
|
283
|
+
}
|
|
284
|
+
catch { /* best-effort on teardown */ }
|
|
285
|
+
};
|
|
286
|
+
// 'exit' must be synchronous — release() is sync, so this is safe.
|
|
287
|
+
process.on('exit', onExit);
|
|
288
|
+
// SIGTERM/SIGINT: the server already installs its own graceful shutdown that
|
|
289
|
+
// exits the process, which fires 'exit'. We add direct handlers too so the
|
|
290
|
+
// lock frees even if some other SIGTERM handler exits first.
|
|
291
|
+
const onSignal = () => { onExit(); };
|
|
292
|
+
process.on('SIGTERM', onSignal);
|
|
293
|
+
process.on('SIGINT', onSignal);
|
|
294
|
+
return () => {
|
|
295
|
+
process.off('exit', onExit);
|
|
296
|
+
process.off('SIGTERM', onSignal);
|
|
297
|
+
process.off('SIGINT', onSignal);
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
//# sourceMappingURL=SingleInstanceLock.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"SingleInstanceLock.js","sourceRoot":"","sources":["../../src/core/SingleInstanceLock.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,89 @@
|
|
|
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 type { IntelligenceProvider, IntelligenceOptions } from './types.js';
|
|
37
|
+
import { HostSpawnSemaphore } from './hostSpawnSemaphore.js';
|
|
38
|
+
/**
|
|
39
|
+
* Thrown when the host-wide spawn cap is saturated and the bounded-acquire
|
|
40
|
+
* window elapsed (or the concurrent-waiter ceiling was hit). A typed,
|
|
41
|
+
* recognizable shed — distinct from a rate-limit (LlmCircuitOpenError) and from
|
|
42
|
+
* a provider error — so each gate seam can fail CLOSED on it specifically.
|
|
43
|
+
*/
|
|
44
|
+
export declare class LlmCapacityUnavailableError extends Error {
|
|
45
|
+
readonly reason: 'acquire-timeout' | 'waiters-full';
|
|
46
|
+
readonly waitedMs: number;
|
|
47
|
+
readonly capacityUnavailable: true;
|
|
48
|
+
constructor(reason: 'acquire-timeout' | 'waiters-full', waitedMs: number);
|
|
49
|
+
}
|
|
50
|
+
/** Narrowing helper for the gate seams (avoids `instanceof` import churn). */
|
|
51
|
+
export declare function isCapacityUnavailable(err: unknown): err is LlmCapacityUnavailableError;
|
|
52
|
+
export interface SpawnCapProviderDeps {
|
|
53
|
+
semaphore?: HostSpawnSemaphore;
|
|
54
|
+
/** Poll-retry budget in ms. Default resolved from env/config/5000. */
|
|
55
|
+
acquireMs?: number;
|
|
56
|
+
/** Concurrent-poller ceiling. Default resolved from env/config/64. */
|
|
57
|
+
waitersMax?: number;
|
|
58
|
+
/** Poll interval in ms (default 100). */
|
|
59
|
+
pollIntervalMs?: number;
|
|
60
|
+
now?: () => number;
|
|
61
|
+
/** Unique-id generator (default the semaphore's). */
|
|
62
|
+
genId?: () => string;
|
|
63
|
+
/** Awaitable delay (tests override to avoid real timers). */
|
|
64
|
+
sleep?: (ms: number) => Promise<void>;
|
|
65
|
+
}
|
|
66
|
+
/** Live count of callers currently polling for a spawn slot (P3 observability). */
|
|
67
|
+
export declare function activeSpawnPollers(): number;
|
|
68
|
+
/** Test seam. */
|
|
69
|
+
export declare function _resetSpawnPollersForTest(): void;
|
|
70
|
+
export declare class SpawnCapIntelligenceProvider implements IntelligenceProvider {
|
|
71
|
+
private readonly inner;
|
|
72
|
+
private readonly semaphore;
|
|
73
|
+
private readonly acquireMs;
|
|
74
|
+
private readonly waitersMax;
|
|
75
|
+
private readonly pollIntervalMs;
|
|
76
|
+
private readonly now;
|
|
77
|
+
private readonly genId;
|
|
78
|
+
private readonly sleep;
|
|
79
|
+
constructor(inner: IntelligenceProvider, deps?: SpawnCapProviderDeps);
|
|
80
|
+
evaluate(prompt: string, options?: IntelligenceOptions): Promise<string>;
|
|
81
|
+
/** Poll the semaphore every `pollIntervalMs` until acquired or the budget elapses. */
|
|
82
|
+
private pollAcquire;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Wrap a provider with the host spawn cap. No-ops on null (so a possibly-null
|
|
86
|
+
* factory result passes through unchanged) and is idempotent (never double-wraps).
|
|
87
|
+
*/
|
|
88
|
+
export declare function wrapIntelligenceWithSpawnCap(provider: IntelligenceProvider | null | undefined, deps?: SpawnCapProviderDeps): IntelligenceProvider | null;
|
|
89
|
+
//# sourceMappingURL=SpawnCapIntelligenceProvider.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"SpawnCapIntelligenceProvider.d.ts","sourceRoot":"","sources":["../../src/core/SpawnCapIntelligenceProvider.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AAEH,OAAO,KAAK,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AAC5E,OAAO,EACL,kBAAkB,EAInB,MAAM,yBAAyB,CAAC;AAEjC;;;;;GAKG;AACH,qBAAa,2BAA4B,SAAQ,KAAK;IAGlD,QAAQ,CAAC,MAAM,EAAE,iBAAiB,GAAG,cAAc;IACnD,QAAQ,CAAC,QAAQ,EAAE,MAAM;IAH3B,QAAQ,CAAC,mBAAmB,EAAG,IAAI,CAAU;gBAElC,MAAM,EAAE,iBAAiB,GAAG,cAAc,EAC1C,QAAQ,EAAE,MAAM;CAO5B;AAED,8EAA8E;AAC9E,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,OAAO,GAAG,GAAG,IAAI,2BAA2B,CAKtF;AAED,MAAM,WAAW,oBAAoB;IACnC,SAAS,CAAC,EAAE,kBAAkB,CAAC;IAC/B,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,sEAAsE;IACtE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,yCAAyC;IACzC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,qDAAqD;IACrD,KAAK,CAAC,EAAE,MAAM,MAAM,CAAC;IACrB,6DAA6D;IAC7D,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACvC;AAOD,mFAAmF;AACnF,wBAAgB,kBAAkB,IAAI,MAAM,CAE3C;AAED,iBAAiB;AACjB,wBAAgB,yBAAyB,IAAI,IAAI,CAEhD;AAED,qBAAa,4BAA6B,YAAW,oBAAoB;IAUrE,OAAO,CAAC,QAAQ,CAAC,KAAK;IATxB,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAqB;IAC/C,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAS;IACxC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAe;IACnC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAe;IACrC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAgC;gBAGnC,KAAK,EAAE,oBAAoB,EAC5C,IAAI,GAAE,oBAAyB;IAY3B,QAAQ,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,mBAAmB,GAAG,OAAO,CAAC,MAAM,CAAC;IA0C9E,sFAAsF;YACxE,WAAW;CAiB1B;AAED;;;GAGG;AACH,wBAAgB,4BAA4B,CAC1C,QAAQ,EAAE,oBAAoB,GAAG,IAAI,GAAG,SAAS,EACjD,IAAI,CAAC,EAAE,oBAAoB,GAC1B,oBAAoB,GAAG,IAAI,CAI7B"}
|