polygram 0.17.11 → 0.17.12
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/.claude-plugin/plugin.json +1 -1
- package/lib/error/classify.js +14 -0
- package/lib/handlers/dispatcher.js +54 -1
- package/lib/handlers/drop-redeliver.js +19 -0
- package/lib/handlers/should-handle.js +52 -0
- package/lib/media-group-buffer.js +29 -0
- package/lib/ops/auth-disabled-gate.js +43 -0
- package/lib/ops/heartbeat.js +56 -0
- package/lib/process/channels-tool-dispatcher.js +12 -1
- package/lib/sdk/callbacks.js +2 -1
- package/lib/secret-detect.js +13 -1
- package/lib/telegram/chunk.js +20 -6
- package/lib/telegram/process-agent-reply.js +5 -3
- package/lib/telegram/streamer.js +6 -1
- package/package.json +2 -1
- package/polygram.js +188 -41
- package/lib/async-lock.js +0 -49
- package/lib/claude-bin.js +0 -246
- package/lib/compaction-warn.js +0 -59
- package/lib/context-usage.js +0 -93
- package/lib/process/channels-bridge-protocol.js +0 -199
- package/lib/process/channels-bridge-server.js +0 -274
- package/lib/process/channels-bridge.mjs +0 -477
- package/lib/process/cli-process.js +0 -4029
- package/lib/process/factory.js +0 -215
- package/lib/process/hook-event-tail.js +0 -162
- package/lib/process/hook-settings.js +0 -181
- package/lib/process/polygram-hook-append.js +0 -71
- package/lib/process/process.js +0 -215
- package/lib/process/sdk-process.js +0 -880
- package/lib/process-guard.js +0 -296
- package/lib/process-manager.js +0 -628
- package/lib/tmux/log-tail.js +0 -334
- package/lib/tmux/orphan-sweep.js +0 -79
- package/lib/tmux/poll-scheduler.js +0 -110
- package/lib/tmux/startup-gate.js +0 -250
- package/lib/tmux/tmux-runner.js +0 -412
package/lib/process-manager.js
DELETED
|
@@ -1,628 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ProcessManager — generic collection of `Process` instances.
|
|
3
|
-
*
|
|
4
|
-
* Holds Map<sessionKey, Process>. Doesn't know or care which concrete
|
|
5
|
-
* Process subclass it's holding. SdkProcess + TmuxProcess both
|
|
6
|
-
* implement the same `lib/process/process.js` interface.
|
|
7
|
-
*
|
|
8
|
-
* Per-session dispatch (send, kill, interrupt, etc.) just delegates
|
|
9
|
-
* to the Process. Collection logic (LRU eviction, killChat, shutdown)
|
|
10
|
-
* lives here.
|
|
11
|
-
*
|
|
12
|
-
* Weighted LRU per Phase 0 F-spike-2: tmux backend is ~10× SDK pm's
|
|
13
|
-
* RSS (545MB vs 50MB). We evict to keep Σ Process.cost ≤ budget
|
|
14
|
-
* rather than count ≤ cap. Default: SDK cost=1, tmux cost=3,
|
|
15
|
-
* budget=10 → "10 SDK | 3 tmux | mixed in between."
|
|
16
|
-
*
|
|
17
|
-
* Lifecycle callbacks (onInit, onClose, onStreamChunk, etc.) get wired
|
|
18
|
-
* to each Process's EventEmitter at spawn. Process emits, pm forwards
|
|
19
|
-
* to operator's callback.
|
|
20
|
-
*
|
|
21
|
-
* Phase 1 only (this file): SDK-only factory; ProcessManager behaviour
|
|
22
|
-
* matches the current `lib/sdk/process-manager.js` API exactly. After
|
|
23
|
-
* Phase 1 lands and tests pass, the old per-bot pm class is deleted.
|
|
24
|
-
*
|
|
25
|
-
* See `docs/0.10.0-process-manager-abstraction-plan.md` for the full
|
|
26
|
-
* design.
|
|
27
|
-
*/
|
|
28
|
-
|
|
29
|
-
'use strict';
|
|
30
|
-
|
|
31
|
-
const DEFAULT_BUDGET = 10; // total Σ cost (SDK cost=1, tmux cost=3)
|
|
32
|
-
const DEFAULT_LRU_WAIT_MS = 300_000;
|
|
33
|
-
|
|
34
|
-
// callback name → event name
|
|
35
|
-
const CALLBACK_TO_EVENT = {
|
|
36
|
-
onInit: 'init',
|
|
37
|
-
onClose: 'close',
|
|
38
|
-
onResult: 'result',
|
|
39
|
-
onStreamChunk: 'stream-chunk',
|
|
40
|
-
onToolUse: 'tool-use',
|
|
41
|
-
onAssistantMessageStart: 'assistant-message-start',
|
|
42
|
-
onAutonomousAssistantMessage: 'autonomous-assistant-message',
|
|
43
|
-
onCompactBoundary: 'compact-boundary',
|
|
44
|
-
// 0.12.0-rc.13: per-chat compaction warning. CliProcess emits
|
|
45
|
-
// 'compaction-warn' {kind:'proactive'|'reactive', pct?} when (proactive)
|
|
46
|
-
// context crosses the chat's threshold at turn-end, or (reactive) claude is
|
|
47
|
-
// auto-compacting now. The callback posts a chat message proposing /compact
|
|
48
|
-
// — opt-in per chat. See docs/0.12.0-file-send.md / lib/compaction-warn.js.
|
|
49
|
-
onCompactionWarn: 'compaction-warn',
|
|
50
|
-
// 0.12.0 background-work visibility (Use 3). CliProcess emits 'bg-work-status'
|
|
51
|
-
// {state:'running'|'cleared', count?} when a detached background shell is first
|
|
52
|
-
// observed running idle past its turn, and again when it clears. The callback
|
|
53
|
-
// posts/edits a "⏳ working in background" status message so a long job reads as
|
|
54
|
-
// working, not stuck. See docs/0.12.0-background-work-lifecycle-plan.md.
|
|
55
|
-
onBgWorkStatus: 'bg-work-status',
|
|
56
|
-
// 0.16 busy-aware ceiling: CliProcess emits 'turn-extended' the FIRST time a
|
|
57
|
-
// turn passes the 30-min checkpoint while still provably working. The callback
|
|
58
|
-
// posts a one-time "⏳ still working — /stop to cancel" message so a long turn
|
|
59
|
-
// reads as alive (not the old false "stream interrupted"). See
|
|
60
|
-
// docs/0.16-turn-ceiling-busy-aware-spec.md.
|
|
61
|
-
onTurnExtended: 'turn-extended',
|
|
62
|
-
// 0.12 interactive questions: CliProcess emits 'question-asked'
|
|
63
|
-
// {sessionKey, chatId, threadId, turnId, toolCallId, questions} when claude calls
|
|
64
|
-
// the `ask` tool. The callback (polygram) renders the Telegram inline keyboard;
|
|
65
|
-
// the user's tap/typed answer routes back via pm.answerQuestion → writeQuestionAnswer.
|
|
66
|
-
onQuestionAsked: 'question-asked',
|
|
67
|
-
// 0.12.0 question-progress-resume: CliProcess emits 'question-resumed' (no payload) when a
|
|
68
|
-
// blocking `ask` resolves with a real answer and the turn resumes working. The callback
|
|
69
|
-
// re-arms the per-turn reactor (it cleared during the wait, no hooks re-lit it). See
|
|
70
|
-
// docs/0.12.0-question-resume-progress-spec.md.
|
|
71
|
-
onQuestionResumed: 'question-resumed',
|
|
72
|
-
// 0.13 D2: CliProcess emits 'input-dropped' {turnId, msgId, chatId, source}
|
|
73
|
-
// when a ledgered input was confirmed dropped (never seen/acked by cycle-end
|
|
74
|
-
// + confirm window, contract observed). polygram redelivers ONCE via the D4
|
|
75
|
-
// tail (lib/handlers/drop-redeliver.js).
|
|
76
|
-
onInputDropped: 'input-dropped',
|
|
77
|
-
onQueueDrop: 'queue-drop',
|
|
78
|
-
onThinking: 'thinking',
|
|
79
|
-
// Tmux backend: TUI shows in-pane approval prompt. SDK backend
|
|
80
|
-
// uses canUseTool callback directly (no event). Polygram wires
|
|
81
|
-
// onApprovalRequired to route tmux prompts through the SAME
|
|
82
|
-
// approval card UI used by SDK's canUseTool flow.
|
|
83
|
-
onApprovalRequired: 'approval-required',
|
|
84
|
-
// 0.13 P4: the tmux-era rows (onExtraTurnReply/-Started, onAutosteerResolution/
|
|
85
|
-
// -MatchMiss) were removed — zero emitters on any backend since the 0.12 tmux
|
|
86
|
-
// deletion; the 'autosteer-resolution' audit trail returns as D2 ledger events.
|
|
87
|
-
// 0.13 D3: 'turn-start' (UserPromptSubmit; payload {hasPending, anchorMsgId})
|
|
88
|
-
// and 'idle' get polygram-side consumers — the session feedback controller's
|
|
89
|
-
// start/stop edges for cycles with no pending turn. ('idle' is ALSO wired
|
|
90
|
-
// internally for LRU waiters in _wireCallbacks — both fire.)
|
|
91
|
-
onTurnStart: 'turn-start',
|
|
92
|
-
onIdle: 'idle',
|
|
93
|
-
// R8: tmux backend autosteer paste failure. TmuxProcess.injectUserMessage
|
|
94
|
-
// fires `inject-fail` when its fire-and-forget paste rejects. Before
|
|
95
|
-
// this was wired the event had no consumer — a failed autosteer was
|
|
96
|
-
// silent until the stale-turn sweep caught it turnTimeoutMs later.
|
|
97
|
-
// The handler logs the failure and clears the ✍ on the failed msgId.
|
|
98
|
-
onInjectFail: 'inject-fail',
|
|
99
|
-
// 0.10.0: tmux backend turn-phase predicate (observer-only Commit 1
|
|
100
|
-
// of the patience-model unification — see docs/0.10.0-tmux-patience-
|
|
101
|
-
// model-solution.md). TmuxProcess emits `phase-change` on every
|
|
102
|
-
// TurnPhase transition; polygram persists it as `turn-phase-change`
|
|
103
|
-
// in the events DB so the soak can verify the predicate's
|
|
104
|
-
// trajectory against real workloads before Commits 2-3 start
|
|
105
|
-
// consuming turn.phase for control flow. SDK backend never emits
|
|
106
|
-
// this — predicate is tmux-specific.
|
|
107
|
-
onPhaseChange: 'phase-change',
|
|
108
|
-
// 0.10.0 H1: tmux backend hook-based turn observability. TmuxProcess
|
|
109
|
-
// tails a per-session ndjson that claude appends to via
|
|
110
|
-
// `--settings`-injected command hooks (PreToolUse/PostToolUse/
|
|
111
|
-
// UserPromptSubmit/Stop/SubagentStop/Notification). Each event is
|
|
112
|
-
// forwarded here so polygram persists it as `hook-event` in the
|
|
113
|
-
// events DB for the H1 soak. OBSERVER-ONLY — no control flow
|
|
114
|
-
// consumes the events yet (mirrors Commit 1 of the patience-model
|
|
115
|
-
// unification). SDK backend never emits — hooks are tmux-specific.
|
|
116
|
-
// See docs/0.10.0-tmux-hook-observability.md.
|
|
117
|
-
onHookEvent: 'hook-event',
|
|
118
|
-
// 0.10.0 rc.42 (review-driven #1): tmux backend turn-timeout event.
|
|
119
|
-
// Mirrors sdk-process.js's `_logEvent('turn-timeout', ...)` so both
|
|
120
|
-
// backends emit the same diagnostic. Payload distinguishes
|
|
121
|
-
// `idle-ceiling` vs `hard-backstop` (the H3 racers) so operators can
|
|
122
|
-
// tell a wedged-silent subagent from a runaway tool loop.
|
|
123
|
-
onTurnTimeout: 'turn-timeout',
|
|
124
|
-
// 0.10.0 rc.42 (review-driven #8): tmux backend hook-tail
|
|
125
|
-
// degradation event. The hook ndjson is load-bearing for H3 idle
|
|
126
|
-
// heartbeats; a persistently broken tail silently resurrects
|
|
127
|
-
// msg-884-class kills. Emitting the event surfaces the degradation
|
|
128
|
-
// in the events DB so it's visible in forensics, not just
|
|
129
|
-
// logger.warn.
|
|
130
|
-
onHookTailError: 'hook-tail-error',
|
|
131
|
-
// 0.10.0 rc.42 (review-driven #15): tmux backend stop-hook-resolved
|
|
132
|
-
// event. Fires when a turn settled via the H4 Stop-hook synth path
|
|
133
|
-
// instead of the canonical JSONL `result` (i.e. JSONL was broken or
|
|
134
|
-
// stuck and Stop rescued the turn). The synth's `via: 'stop-hook'`
|
|
135
|
-
// field was previously dead — only the tests read it. Persisting
|
|
136
|
-
// the event lets the soak count how often H4 actually fires its
|
|
137
|
-
// rescue contract.
|
|
138
|
-
onStopHookResolved: 'stop-hook-resolved',
|
|
139
|
-
// 0.10.0 rc.43: claude TUI's "This session is N old…" interactive
|
|
140
|
-
// menu auto-dismissed by `_waitForReady`. Surfacing the event so
|
|
141
|
-
// soak can count how often aged-session resumes hit this path.
|
|
142
|
-
onSessionAgePromptDismissed: 'session-age-prompt-dismissed',
|
|
143
|
-
// 0.12 CliProcess observability — typed hook events from cli-process.js
|
|
144
|
-
// _handleHookEvent. Each gets its own callback so polygram can persist
|
|
145
|
-
// structured rows to the events DB for soak-time aggregate queries.
|
|
146
|
-
// - hook-lag-sample: Phase 1.8 — per-event lag_ms (target: median<2s, p99<5s)
|
|
147
|
-
// - tool-result: Phase 1.3 — PostToolUse durationMs per tool
|
|
148
|
-
// - subagent-start / subagent-done: Phase 1.3 — typed subagent lifecycle
|
|
149
|
-
// (we DO get tool-use='Agent' via onToolUse, but agent_type + durationMs
|
|
150
|
-
// only fire on these typed events). SDK backend never emits — hooks
|
|
151
|
-
// are CliProcess-specific (and were tmux-specific in 0.10–0.11).
|
|
152
|
-
onHookLagSample: 'hook-lag-sample',
|
|
153
|
-
onToolResult: 'tool-result',
|
|
154
|
-
onSubagentStart: 'subagent-start',
|
|
155
|
-
onSubagentDone: 'subagent-done',
|
|
156
|
-
};
|
|
157
|
-
|
|
158
|
-
class ProcessManager {
|
|
159
|
-
/**
|
|
160
|
-
* @param {object} opts
|
|
161
|
-
* @param {(sessionKey: string, ctx: object) => Process} opts.processFactory
|
|
162
|
-
* — required. Returns a Process instance (not yet started).
|
|
163
|
-
* @param {number} [opts.budget=10] — weighted LRU budget
|
|
164
|
-
* @param {object} [opts.db] — used for _logEvent (matches today's pm)
|
|
165
|
-
* @param {object} [opts.logger=console]
|
|
166
|
-
* @param {object} [opts.callbacks={}] — keys: onInit, onClose, ...
|
|
167
|
-
* @param {number} [opts.lruWaitMs] — how long getOrSpawn parks
|
|
168
|
-
* when all entries are in-flight
|
|
169
|
-
*/
|
|
170
|
-
constructor({
|
|
171
|
-
processFactory,
|
|
172
|
-
budget = DEFAULT_BUDGET,
|
|
173
|
-
db,
|
|
174
|
-
logger = console,
|
|
175
|
-
callbacks = {},
|
|
176
|
-
lruWaitMs = DEFAULT_LRU_WAIT_MS,
|
|
177
|
-
} = {}) {
|
|
178
|
-
if (typeof processFactory !== 'function') {
|
|
179
|
-
throw new TypeError('ProcessManager: processFactory function required');
|
|
180
|
-
}
|
|
181
|
-
this.processFactory = processFactory;
|
|
182
|
-
this.budget = budget;
|
|
183
|
-
this.db = db;
|
|
184
|
-
this.logger = logger;
|
|
185
|
-
this.callbacks = { ...callbacks };
|
|
186
|
-
this.lruWaitMs = lruWaitMs;
|
|
187
|
-
this.procs = new Map(); // sessionKey → Process
|
|
188
|
-
this._lruWaiters = []; // [{ resolve, reject, timer }]
|
|
189
|
-
this._shuttingDown = false;
|
|
190
|
-
// sessionKey → in-flight start() Promise. Lets a concurrent
|
|
191
|
-
// getOrSpawn for the same key await the spawn instead of
|
|
192
|
-
// returning a proc whose start() hasn't resolved (see getOrSpawn).
|
|
193
|
-
this._starting = new Map();
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
// ─── Introspection ───────────────────────────────────────────────
|
|
197
|
-
|
|
198
|
-
has(sessionKey) { return this.procs.has(sessionKey); }
|
|
199
|
-
get(sessionKey) { return this.procs.get(sessionKey) || null; }
|
|
200
|
-
keys() { return [...this.procs.keys()]; }
|
|
201
|
-
get size() { return this.procs.size; }
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
* Current total cost across all live processes.
|
|
205
|
-
*/
|
|
206
|
-
get totalCost() {
|
|
207
|
-
let sum = 0;
|
|
208
|
-
for (const p of this.procs.values()) {
|
|
209
|
-
if (!p.closed) sum += p.cost;
|
|
210
|
-
}
|
|
211
|
-
return sum;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
// ─── Spawn + LRU ─────────────────────────────────────────────────
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* Returns the Process for sessionKey, spawning if absent.
|
|
218
|
-
* Evicts other processes (oldest non-in-flight first) to make room
|
|
219
|
-
* when adding a new Process would exceed budget.
|
|
220
|
-
*
|
|
221
|
-
* @param {string} sessionKey
|
|
222
|
-
* @param {object} spawnContext — passed through to processFactory + start()
|
|
223
|
-
*/
|
|
224
|
-
async getOrSpawn(sessionKey, spawnContext) {
|
|
225
|
-
if (this._shuttingDown) throw new Error('shutdown');
|
|
226
|
-
|
|
227
|
-
const existing = this.procs.get(sessionKey);
|
|
228
|
-
if (existing && !existing.closed) {
|
|
229
|
-
// getOrSpawn registers the proc in this.procs BEFORE awaiting
|
|
230
|
-
// start(). A concurrent getOrSpawn for the same key (a second
|
|
231
|
-
// Telegram message landing during the ~11s tmux spawn) would
|
|
232
|
-
// otherwise get this still-spawning proc and call send() on it
|
|
233
|
-
// — pasting a turn into a TUI that is not ready, which silently
|
|
234
|
-
// drops the paste and returns an empty turn (shumorobot
|
|
235
|
-
// production 2026-05-16: msg 2 of a 3-message burst returned
|
|
236
|
-
// "No response generated"). Await the in-flight spawn so every
|
|
237
|
-
// caller receives a proc whose start() has fully resolved.
|
|
238
|
-
const pendingStart = this._starting.get(sessionKey);
|
|
239
|
-
if (pendingStart) await pendingStart;
|
|
240
|
-
// Reload-on-drift (cli): a warm cli proc can't hot-swap model/effort
|
|
241
|
-
// (spawn-time flags). If the resolved config has drifted and the proc is
|
|
242
|
-
// idle, kill it (preserves session_id) and fall through to a cold respawn
|
|
243
|
-
// → --resume keeps the conversation, the new --model/--effort takes
|
|
244
|
-
// effect. In-flight cli procs and SDK procs (no wouldReloadFor — they
|
|
245
|
-
// apply model live) are reused unchanged.
|
|
246
|
-
if (typeof existing.wouldReloadFor === 'function' && existing.wouldReloadFor(spawnContext)) {
|
|
247
|
-
this._logEvent('cli-config-reload', {
|
|
248
|
-
sessionKey,
|
|
249
|
-
from_model: existing.model,
|
|
250
|
-
from_effort: existing.effort,
|
|
251
|
-
});
|
|
252
|
-
await this.kill(sessionKey, 'config-reload');
|
|
253
|
-
// fall through to the cold-spawn path below — respawns with --resume
|
|
254
|
-
} else {
|
|
255
|
-
return existing;
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
// Provisional new-process cost — ask the factory but don't start yet.
|
|
260
|
-
const newProc = this.processFactory(sessionKey, spawnContext);
|
|
261
|
-
const newCost = newProc.cost;
|
|
262
|
-
|
|
263
|
-
while (this.totalCost + newCost > this.budget) {
|
|
264
|
-
const evicted = this._evictLRU(); // skips inFlight + background-job-pinned
|
|
265
|
-
if (evicted) continue;
|
|
266
|
-
// _evictLRU freed nothing. Policy C — split by WHY:
|
|
267
|
-
if (this._hasPinnedSession()) {
|
|
268
|
-
// A DURABLE blocker (live background job) holds a slot. Don't park on it (could be
|
|
269
|
-
// ~an hour) and don't kill it. The budget caps RSS, not correctness — so treat it as
|
|
270
|
-
// SOFT: spawn over budget + warn; the operator reclaims by /reset-ing a chat.
|
|
271
|
-
const pinned = this._pinnedSessionKeys();
|
|
272
|
-
this._logEvent('lru-overflow-pinned', {
|
|
273
|
-
active: this.procs.size,
|
|
274
|
-
totalCost: this.totalCost,
|
|
275
|
-
budget: this.budget,
|
|
276
|
-
newCost,
|
|
277
|
-
pinned,
|
|
278
|
-
});
|
|
279
|
-
this.logger.warn?.(
|
|
280
|
-
`[pm] budget ${this.budget} exceeded (~${this.totalCost + newCost}): all free slots hold ` +
|
|
281
|
-
`live background jobs [${pinned.join(', ')}]. Spawning over limit — /reset one of those ` +
|
|
282
|
-
`chats to reclaim memory.`,
|
|
283
|
-
);
|
|
284
|
-
break; // soft overflow — spawn anyway
|
|
285
|
-
}
|
|
286
|
-
// No pin — the blockers are all in-flight TURNS (transient, finish in seconds). Keep the
|
|
287
|
-
// existing behavior: park briefly for a slot rather than needlessly overflow.
|
|
288
|
-
await this._awaitLruSlot();
|
|
289
|
-
if (this._shuttingDown) {
|
|
290
|
-
try { await newProc.kill('shutdown'); } catch {}
|
|
291
|
-
throw new Error('shutdown');
|
|
292
|
-
}
|
|
293
|
-
// Loop again — budget may have freed up.
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
this._wireCallbacks(newProc);
|
|
297
|
-
this.procs.set(sessionKey, newProc);
|
|
298
|
-
newProc.lastUsedTs = Date.now();
|
|
299
|
-
// Publish the in-flight start() Promise so concurrent getOrSpawn
|
|
300
|
-
// callers (above) can await it instead of racing the spawn.
|
|
301
|
-
const startP = newProc.start(spawnContext);
|
|
302
|
-
this._starting.set(sessionKey, startP);
|
|
303
|
-
try {
|
|
304
|
-
await startP;
|
|
305
|
-
} catch (err) {
|
|
306
|
-
this.procs.delete(sessionKey);
|
|
307
|
-
throw err;
|
|
308
|
-
} finally {
|
|
309
|
-
this._starting.delete(sessionKey);
|
|
310
|
-
}
|
|
311
|
-
return newProc;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
_evictLRU() {
|
|
315
|
-
let oldest = null;
|
|
316
|
-
let oldestKey = null;
|
|
317
|
-
let pinnedSkipped = 0;
|
|
318
|
-
for (const [k, p] of this.procs.entries()) {
|
|
319
|
-
if (p.inFlight) continue;
|
|
320
|
-
// PIN: a session with a live detached background job is NOT evictable — killing it
|
|
321
|
-
// would silently drop the job (and its report-back wakeup). Skip like inFlight.
|
|
322
|
-
if (p.hasActiveBackgroundWork()) { pinnedSkipped++; continue; }
|
|
323
|
-
// PIN (0.13 D1, S9): a session blocked on an open interactive question is
|
|
324
|
-
// NOT evictable — the keyboard is live and claude is blocked on the ask;
|
|
325
|
-
// killing it silently strands both. Skip like inFlight.
|
|
326
|
-
if (typeof p.hasOpenQuestions === 'function' && p.hasOpenQuestions()) { pinnedSkipped++; continue; }
|
|
327
|
-
if (!oldest || (p.lastUsedTs || 0) < (oldest.lastUsedTs || 0)) {
|
|
328
|
-
oldest = p;
|
|
329
|
-
oldestKey = k;
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
if (!oldest) {
|
|
333
|
-
this._logEvent('lru-full', {
|
|
334
|
-
active: this.procs.size,
|
|
335
|
-
totalCost: this.totalCost,
|
|
336
|
-
budget: this.budget,
|
|
337
|
-
pinnedSkipped,
|
|
338
|
-
});
|
|
339
|
-
return false;
|
|
340
|
-
}
|
|
341
|
-
this._logEvent('evict', {
|
|
342
|
-
session_key: oldestKey,
|
|
343
|
-
cost: oldest.cost,
|
|
344
|
-
backend: oldest.backend,
|
|
345
|
-
pinnedSkipped,
|
|
346
|
-
});
|
|
347
|
-
oldest.kill('evict').catch(() => {});
|
|
348
|
-
this.procs.delete(oldestKey);
|
|
349
|
-
return true;
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
/**
|
|
353
|
-
* A DURABLE eviction blocker: a non-inFlight session holding a slot because it has a live
|
|
354
|
-
* background job (vs an inFlight TURN, which is transient and frees in seconds). Used to
|
|
355
|
-
* split park-vs-overflow when _evictLRU can free nothing.
|
|
356
|
-
*/
|
|
357
|
-
_hasPinnedSession() {
|
|
358
|
-
for (const p of this.procs.values()) {
|
|
359
|
-
if (!p.inFlight && p.hasActiveBackgroundWork()) return true;
|
|
360
|
-
// 0.16 (MF-B): an extended busy-aware-ceiling turn is a DURABLE blocker —
|
|
361
|
-
// it can hold its slot up to the hard backstop (90min), not "seconds" like
|
|
362
|
-
// a normal in-flight turn. Treat it as a pin so getOrSpawn SOFT-overflows
|
|
363
|
-
// (spawn over budget + warn) instead of park-then-reject, which would deny
|
|
364
|
-
// service to other chats for the full 5-min LRU wait.
|
|
365
|
-
if (p.inFlight && typeof p.hasExtendedTurn === 'function' && p.hasExtendedTurn()) return true;
|
|
366
|
-
}
|
|
367
|
-
return false;
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
_pinnedSessionKeys() {
|
|
371
|
-
const keys = [];
|
|
372
|
-
for (const [k, p] of this.procs.entries()) {
|
|
373
|
-
if (!p.inFlight && p.hasActiveBackgroundWork()) keys.push(k);
|
|
374
|
-
else if (p.inFlight && typeof p.hasExtendedTurn === 'function' && p.hasExtendedTurn()) keys.push(k);
|
|
375
|
-
}
|
|
376
|
-
return keys;
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
async _awaitLruSlot() {
|
|
380
|
-
return new Promise((resolve, reject) => {
|
|
381
|
-
const timer = setTimeout(() => {
|
|
382
|
-
const idx = this._lruWaiters.findIndex((w) => w.resolve === resolve);
|
|
383
|
-
if (idx !== -1) this._lruWaiters.splice(idx, 1);
|
|
384
|
-
this._logEvent('lru-wait-timeout', { wait_ms: this.lruWaitMs });
|
|
385
|
-
reject(new Error(`lru wait timed out after ${this.lruWaitMs}ms`));
|
|
386
|
-
}, this.lruWaitMs);
|
|
387
|
-
this._lruWaiters.push({ resolve, reject, timer });
|
|
388
|
-
this._logEvent('lru-wait', {
|
|
389
|
-
active: this.procs.size,
|
|
390
|
-
totalCost: this.totalCost,
|
|
391
|
-
budget: this.budget,
|
|
392
|
-
});
|
|
393
|
-
});
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
_maybeSignalLruWaiter() {
|
|
397
|
-
const w = this._lruWaiters.shift();
|
|
398
|
-
if (w) { clearTimeout(w.timer); w.resolve(); }
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
// ─── Per-session dispatch ────────────────────────────────────────
|
|
402
|
-
|
|
403
|
-
async send(sessionKey, prompt, opts) {
|
|
404
|
-
const proc = this.procs.get(sessionKey);
|
|
405
|
-
if (!proc) throw new Error(`no process for sessionKey ${sessionKey}`);
|
|
406
|
-
proc.lastUsedTs = Date.now();
|
|
407
|
-
return proc.send(prompt, opts);
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
async kill(sessionKey, reason = 'kill') {
|
|
411
|
-
const proc = this.procs.get(sessionKey);
|
|
412
|
-
if (!proc) return false;
|
|
413
|
-
this.procs.delete(sessionKey);
|
|
414
|
-
try { await proc.kill(reason); } catch {}
|
|
415
|
-
this._maybeSignalLruWaiter();
|
|
416
|
-
return true;
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
async killChat(chatId) {
|
|
420
|
-
const targets = [];
|
|
421
|
-
const idStr = String(chatId);
|
|
422
|
-
for (const [sk, p] of this.procs.entries()) {
|
|
423
|
-
if (p.chatId === idStr) targets.push([sk, p]);
|
|
424
|
-
}
|
|
425
|
-
for (const [sk] of targets) this.procs.delete(sk);
|
|
426
|
-
const results = await Promise.allSettled(
|
|
427
|
-
targets.map(([_, p]) => p.kill('killChat')),
|
|
428
|
-
);
|
|
429
|
-
for (let i = 0; i < targets.length; i++) {
|
|
430
|
-
this._maybeSignalLruWaiter();
|
|
431
|
-
}
|
|
432
|
-
return results;
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
async shutdown() {
|
|
436
|
-
this._shuttingDown = true;
|
|
437
|
-
// Reject parked lru waiters.
|
|
438
|
-
for (const w of this._lruWaiters) {
|
|
439
|
-
clearTimeout(w.timer);
|
|
440
|
-
w.reject(new Error('shutdown'));
|
|
441
|
-
}
|
|
442
|
-
this._lruWaiters.length = 0;
|
|
443
|
-
|
|
444
|
-
const all = [...this.procs.values()];
|
|
445
|
-
this.procs.clear();
|
|
446
|
-
await Promise.allSettled(all.map((p) => p.kill('shutdown')));
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
// ─── Optional async — feature-detect at call site if needed ──────
|
|
450
|
-
|
|
451
|
-
/**
|
|
452
|
-
* Shared dispatch for the five optional async methods. Returns the
|
|
453
|
-
* Process method's value on success, `unsupportedDefault` when the
|
|
454
|
-
* Process is missing/closed OR throws UNSUPPORTED_OPERATION /
|
|
455
|
-
* NOT_IMPLEMENTED_YET. Other errors propagate.
|
|
456
|
-
*/
|
|
457
|
-
async _invokeOptional(sessionKey, methodName, args, unsupportedDefault) {
|
|
458
|
-
const p = this.procs.get(sessionKey);
|
|
459
|
-
if (!p || p.closed) return unsupportedDefault;
|
|
460
|
-
try { return await p[methodName](...args); }
|
|
461
|
-
catch (err) {
|
|
462
|
-
if (err && (err.code === 'UNSUPPORTED_OPERATION' || err.code === 'NOT_IMPLEMENTED_YET')) {
|
|
463
|
-
return unsupportedDefault;
|
|
464
|
-
}
|
|
465
|
-
throw err;
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
async interrupt(sessionKey) {
|
|
470
|
-
return this._invokeOptional(sessionKey, 'interrupt', [], false);
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
async setModel(sessionKey, model) {
|
|
474
|
-
return this._invokeOptional(sessionKey, 'setModel', [model], false);
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
/**
|
|
478
|
-
* Review F#10: return the backend name for a live process so callers
|
|
479
|
-
* (slash-commands) can word their UX accurately. Returns null if no
|
|
480
|
-
* live process exists.
|
|
481
|
-
*/
|
|
482
|
-
getBackend(sessionKey) {
|
|
483
|
-
const p = this.procs.get(sessionKey);
|
|
484
|
-
return (p && !p.closed) ? p.backend : null;
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
async applyFlagSettings(sessionKey, settings) {
|
|
488
|
-
return this._invokeOptional(sessionKey, 'applyFlagSettings', [settings], false);
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
async setPermissionMode(sessionKey, mode) {
|
|
492
|
-
return this._invokeOptional(sessionKey, 'setPermissionMode', [mode], false);
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
async resetSession(sessionKey, opts) {
|
|
496
|
-
const p = this.procs.get(sessionKey);
|
|
497
|
-
// No active process for this key — return no-op. Matches the
|
|
498
|
-
// pre-0.10.0 SDK pm semantic (`closed: false` = "we did not close
|
|
499
|
-
// anything"). Caller can distinguish "session was already gone"
|
|
500
|
-
// from "we just closed an active session."
|
|
501
|
-
if (!p) return { closed: false, drainedPendings: 0 };
|
|
502
|
-
try {
|
|
503
|
-
const result = await p.resetSession(opts);
|
|
504
|
-
// The Process's resetSession closes itself; remove from Map
|
|
505
|
-
// and signal LRU.
|
|
506
|
-
if (this.procs.get(sessionKey) === p) {
|
|
507
|
-
this.procs.delete(sessionKey);
|
|
508
|
-
}
|
|
509
|
-
this._maybeSignalLruWaiter();
|
|
510
|
-
return result;
|
|
511
|
-
} catch (err) {
|
|
512
|
-
if (err.code === 'UNSUPPORTED_OPERATION' || err.code === 'NOT_IMPLEMENTED_YET') {
|
|
513
|
-
const drained = p.drainQueue('RESET_SESSION');
|
|
514
|
-
await this.kill(sessionKey, 'reset');
|
|
515
|
-
return { closed: true, drainedPendings: drained };
|
|
516
|
-
}
|
|
517
|
-
throw err;
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
async getContextUsage(sessionKey) {
|
|
522
|
-
return this._invokeOptional(sessionKey, 'getContextUsage', [], null);
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
// ─── Optional sync hot-path — never throws (R1-F1) ───────────────
|
|
526
|
-
|
|
527
|
-
drainQueue(sessionKey, code = 'INTERRUPTED') {
|
|
528
|
-
const p = this.procs.get(sessionKey);
|
|
529
|
-
if (!p) return 0;
|
|
530
|
-
return p.drainQueue(code);
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
injectUserMessage(sessionKey, opts) {
|
|
534
|
-
const p = this.procs.get(sessionKey);
|
|
535
|
-
if (!p || p.closed) return false;
|
|
536
|
-
return p.injectUserMessage(opts);
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
// 0.12 interactive questions: hand an answer back to a blocking `ask` tool call.
|
|
540
|
-
// Returns false if the session is gone (claude is dead → nothing to answer).
|
|
541
|
-
answerQuestion(sessionKey, toolCallId, result) {
|
|
542
|
-
const p = this.procs.get(sessionKey);
|
|
543
|
-
if (!p || p.closed || typeof p.writeQuestionAnswer !== 'function') return false;
|
|
544
|
-
return p.writeQuestionAnswer(toolCallId, result);
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
steer(sessionKey, text, opts) {
|
|
548
|
-
const p = this.procs.get(sessionKey);
|
|
549
|
-
if (!p || p.closed) return false;
|
|
550
|
-
return p.steer(text, opts);
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
// ─── Internal helpers ────────────────────────────────────────────
|
|
554
|
-
|
|
555
|
-
/**
|
|
556
|
-
* For each callback in this.callbacks, register a listener on the
|
|
557
|
-
* Process that forwards the event payload to the callback. Wire
|
|
558
|
-
* the standard event names; Process subclasses are free to emit
|
|
559
|
-
* additional events that pm doesn't forward.
|
|
560
|
-
*
|
|
561
|
-
* Also subscribes to 'idle' (Process became inFlight=false) and
|
|
562
|
-
* 'close' (Process closed itself) so the pm can signal parked
|
|
563
|
-
* LRU waiters + remove from the Map.
|
|
564
|
-
*/
|
|
565
|
-
_wireCallbacks(proc) {
|
|
566
|
-
for (const [cbName, eventName] of Object.entries(CALLBACK_TO_EVENT)) {
|
|
567
|
-
const fn = this.callbacks[cbName];
|
|
568
|
-
if (typeof fn !== 'function') continue;
|
|
569
|
-
proc.on(eventName, (...args) => {
|
|
570
|
-
try { fn(proc.sessionKey, ...args, proc); }
|
|
571
|
-
catch (err) {
|
|
572
|
-
this.logger.error?.(`[pm:${proc.label}] callback ${cbName} threw: ${err.message}`);
|
|
573
|
-
}
|
|
574
|
-
});
|
|
575
|
-
}
|
|
576
|
-
// Generic 'error' channel — log + forward via onError if provided.
|
|
577
|
-
proc.on('error', (err) => {
|
|
578
|
-
this.logger.error?.(`[pm:${proc.label}] process error: ${err.message}`);
|
|
579
|
-
if (typeof this.callbacks.onError === 'function') {
|
|
580
|
-
try { this.callbacks.onError(proc.sessionKey, err, proc); }
|
|
581
|
-
catch (e) { this.logger.error?.(`[pm:${proc.label}] onError threw: ${e.message}`); }
|
|
582
|
-
}
|
|
583
|
-
});
|
|
584
|
-
// 'idle': a turn completed and pendingQueue is empty. Signal any
|
|
585
|
-
// parked LRU waiter that a non-in-flight slot is available.
|
|
586
|
-
proc.on('idle', () => this._maybeSignalLruWaiter());
|
|
587
|
-
// 'close': process closed itself (iteration loop exited or
|
|
588
|
-
// _closeQuery returned). Remove from the Map + signal LRU.
|
|
589
|
-
proc.on('close', () => {
|
|
590
|
-
if (this.procs.get(proc.sessionKey) === proc) {
|
|
591
|
-
this.procs.delete(proc.sessionKey);
|
|
592
|
-
}
|
|
593
|
-
this._maybeSignalLruWaiter();
|
|
594
|
-
});
|
|
595
|
-
// P0 #3: channels backend emits 'bridge-disconnected' when its socket to
|
|
596
|
-
// the spawned bridge dies (claude crash, bridge crash, EOF). The disconnect
|
|
597
|
-
// handler in CliProcess already drained pendingTurns; here we kill
|
|
598
|
-
// the dead Process so it leaves the Map and frees its LRU slot. Next
|
|
599
|
-
// user-msg on the same sessionKey triggers a fresh getOrSpawn — which
|
|
600
|
-
// calls Process.start with the persisted claudeSessionId, recovering the
|
|
601
|
-
// conversation via `claude --resume`.
|
|
602
|
-
//
|
|
603
|
-
// We don't re-spawn proactively: an idle disconnected session shouldn't
|
|
604
|
-
// burn LRU budget. Lazy respawn on next message is the right shape.
|
|
605
|
-
proc.on('bridge-disconnected', () => {
|
|
606
|
-
this.logger.warn?.(`[pm:${proc.label}] channels bridge disconnected — killing dead instance for lazy respawn`);
|
|
607
|
-
// Kill is idempotent and removes the proc from the Map via the 'close'
|
|
608
|
-
// listener wired just above.
|
|
609
|
-
proc.kill('bridge-disconnected').catch(err => {
|
|
610
|
-
this.logger.warn?.(`[pm:${proc.label}] kill on bridge-disconnect failed: ${err.message}`);
|
|
611
|
-
});
|
|
612
|
-
});
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
_logEvent(kind, detail) {
|
|
616
|
-
try {
|
|
617
|
-
this.db?.logEvent?.(kind, detail || {});
|
|
618
|
-
} catch (err) {
|
|
619
|
-
this.logger.error?.(`[pm] logEvent ${kind} failed: ${err.message}`);
|
|
620
|
-
}
|
|
621
|
-
}
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
module.exports = {
|
|
625
|
-
ProcessManager,
|
|
626
|
-
DEFAULT_BUDGET,
|
|
627
|
-
CALLBACK_TO_EVENT,
|
|
628
|
-
};
|