chapterhouse 0.3.1 → 0.3.3

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.
@@ -98,6 +98,7 @@ async function loadOrchestratorModule(t, overrides = {}) {
98
98
  { slug: "coder", name: "Kaylee", model: "claude-sonnet-4.6" },
99
99
  ],
100
100
  sendResult: "Finished successfully",
101
+ taskEvents: new Map(),
101
102
  ...overrides,
102
103
  };
103
104
  const client = createFakeClient(state);
@@ -181,8 +182,17 @@ async function loadOrchestratorModule(t, overrides = {}) {
181
182
  get: () => undefined,
182
183
  all: () => [],
183
184
  }),
185
+ transaction: (fn) => fn,
184
186
  }),
185
187
  bumpProjectLastUsed: (_projectRoot) => { },
188
+ appendTaskEvent: (taskId, kind, toolName, summary) => {
189
+ const seq = (state.taskEvents.get(taskId)?.length ?? 0) + 1;
190
+ const ev = { id: seq, taskId, seq, ts: Date.now(), kind, toolName, summary };
191
+ if (!state.taskEvents.has(taskId))
192
+ state.taskEvents.set(taskId, []);
193
+ state.taskEvents.get(taskId).push(ev);
194
+ return ev;
195
+ },
186
196
  },
187
197
  });
188
198
  t.mock.module("./episode-writer.js", {
@@ -626,4 +636,238 @@ test("S5-01: subagent.failed event updates agent_tasks status to error", async (
626
636
  assert.ok(JSON.stringify(errorWrite.args).includes("subagent-call-003"), "UPDATE must target the correct task_id");
627
637
  state.pendingReject?.(new Error("test teardown"));
628
638
  });
639
+ // ---------------------------------------------------------------------------
640
+ // REGRESSION: #35 — per-session isolation
641
+ // This test would have caught the original bug. With a global shared queue,
642
+ // session B's message would queue behind session A's blocking turn and the
643
+ // Promise.race would time out. With per-session queues, session B completes
644
+ // independently.
645
+ // ---------------------------------------------------------------------------
646
+ test("regression #35: session A blocking does not delay session B (concurrent sessions)", async (t) => {
647
+ const { orchestrator, state, client } = await loadOrchestratorModule(t, {
648
+ config: { copilotModel: "claude-sonnet-4.6", selfEditEnabled: false, squadEnabled: true },
649
+ sendResult: "__PENDING__", // session A will block in sendAndWait
650
+ });
651
+ await orchestrator.initOrchestrator(client);
652
+ // Send to session A (project /project/a) — parks because sendResult is __PENDING__
653
+ const sessionACallbacks = [];
654
+ orchestrator.sendToOrchestrator("slow request to project A", { type: "web", connectionId: "conn-a", projectPath: "/project/a" }, (text, done) => sessionACallbacks.push({ text, done }));
655
+ // Yield to event loop so session A's drain loop runs and parks in sendAndWait
656
+ await new Promise((resolve) => setTimeout(resolve, 20));
657
+ // Now session A is blocked inside sendAndWait. Switch sendResult so session B
658
+ // gets a fast response. session A's pending reject is still captured in its closure.
659
+ state.sendResult = "session B complete";
660
+ // Send to session B (different project) — must complete without waiting for session A
661
+ const sessionBDone = new Promise((resolve) => {
662
+ orchestrator.sendToOrchestrator("quick request to project B", { type: "web", connectionId: "conn-b", projectPath: "/project/b" }, (text, done) => {
663
+ if (done)
664
+ resolve(text);
665
+ });
666
+ });
667
+ // Session B must respond before the deadline (session A is still blocked indefinitely)
668
+ const result = await Promise.race([
669
+ sessionBDone,
670
+ new Promise((_, reject) => setTimeout(() => reject(new Error("session B was blocked by session A — global queue bug reproduced")), 300)),
671
+ ]);
672
+ assert.equal(result, "session B complete", "session B must resolve independently of blocked session A");
673
+ assert.equal(sessionACallbacks.length, 0, "session A must still be pending (no response yet)");
674
+ // Clean up: reject session A's pending promise so the test can finish
675
+ state.pendingReject?.(new Error("test teardown"));
676
+ await new Promise((resolve) => setTimeout(resolve, 10));
677
+ });
678
+ // ---------------------------------------------------------------------------
679
+ // #81 — task spawn args (name/description) must win over SDK agent_type fields
680
+ // Root cause: subagent.started only carries agent_type boilerplate. The actual
681
+ // spawn params (name, description) arrive earlier via tool.execution_start for
682
+ // toolName === "task". We stash them keyed by toolCallId and prefer them in the
683
+ // INSERT so the worker tab shows "kaylee" instead of "general-purpose".
684
+ // ---------------------------------------------------------------------------
685
+ test("#81: tool.execution_start stash + subagent.started → agent_tasks uses spawn name/description", async (t) => {
686
+ const { orchestrator, state, client } = await loadOrchestratorModule(t, {
687
+ sendResult: "__PENDING__",
688
+ });
689
+ await orchestrator.initOrchestrator(client);
690
+ orchestrator.sendToOrchestrator("dispatch a worker", { type: "background" }, () => { });
691
+ await new Promise((resolve) => setTimeout(resolve, 10));
692
+ assert.ok(state.lastSession, "FakeSession must have been created");
693
+ // Step 1: emit tool.execution_start for a "task" call with spawn parameters
694
+ state.lastSession.emit("tool.execution_start", {
695
+ toolName: "task",
696
+ toolCallId: "tc-spawn-1",
697
+ arguments: { name: "kaylee", description: "🔧 Kaylee: test spawn" },
698
+ });
699
+ // Step 2: emit subagent.started with the same toolCallId — SDK only knows agent_type details
700
+ state.lastSession.emit("subagent.started", {
701
+ toolCallId: "tc-spawn-1",
702
+ agentName: "general-purpose",
703
+ agentDisplayName: "General Purpose Agent",
704
+ agentDescription: "Full-capability agent boilerplate",
705
+ });
706
+ const insertWrite = state.dbWrites.find((w) => w.sql.includes("INSERT") && w.sql.includes("agent_tasks"));
707
+ assert.ok(insertWrite, "subagent.started must INSERT a row into agent_tasks");
708
+ const argsJson = JSON.stringify(insertWrite.args);
709
+ assert.ok(argsJson.includes("kaylee"), `agent_slug must be "kaylee" but got: ${argsJson}`);
710
+ assert.ok(argsJson.includes("🔧 Kaylee: test spawn"), `description must be spawn description but got: ${argsJson}`);
711
+ assert.ok(!argsJson.includes("general-purpose"), `agent_slug must NOT fall back to "general-purpose" when spawn name is available`);
712
+ state.pendingReject?.(new Error("test teardown"));
713
+ });
714
+ test("#81 fallback: subagent.started with no prior tool.execution_start uses agentName/agentDescription", async (t) => {
715
+ const { orchestrator, state, client } = await loadOrchestratorModule(t, {
716
+ sendResult: "__PENDING__",
717
+ });
718
+ await orchestrator.initOrchestrator(client);
719
+ orchestrator.sendToOrchestrator("dispatch a worker", { type: "background" }, () => { });
720
+ await new Promise((resolve) => setTimeout(resolve, 10));
721
+ assert.ok(state.lastSession, "FakeSession must have been created");
722
+ // No tool.execution_start emitted — subagent.started fires cold
723
+ state.lastSession.emit("subagent.started", {
724
+ toolCallId: "tc-no-spawn",
725
+ agentName: "general-purpose",
726
+ agentDisplayName: "General Purpose Agent",
727
+ agentDescription: "Full-capability agent boilerplate",
728
+ });
729
+ const insertWrite = state.dbWrites.find((w) => w.sql.includes("INSERT") && w.sql.includes("agent_tasks"));
730
+ assert.ok(insertWrite, "subagent.started must still INSERT a row without spawn args");
731
+ const argsJson = JSON.stringify(insertWrite.args);
732
+ assert.ok(argsJson.includes("general-purpose"), `agent_slug must fall back to agentName ("general-purpose") when no spawn args: ${argsJson}`);
733
+ assert.ok(argsJson.includes("Full-capability agent boilerplate"), `description must fall back to agentDescription: ${argsJson}`);
734
+ state.pendingReject?.(new Error("test teardown"));
735
+ });
736
+ test("#81: activity callback receives resolved agentSlug from spawn args (SSE live path)", async (t) => {
737
+ const { orchestrator, state, client } = await loadOrchestratorModule(t, {
738
+ sendResult: "__PENDING__",
739
+ });
740
+ await orchestrator.initOrchestrator(client);
741
+ const activityEvents = [];
742
+ orchestrator.sendToOrchestrator("dispatch a worker", { type: "background" }, () => { }, undefined, // no attachments
743
+ (event) => { activityEvents.push(event); });
744
+ await new Promise((resolve) => setTimeout(resolve, 10));
745
+ assert.ok(state.lastSession, "FakeSession must have been created");
746
+ // Stash spawn args via tool.execution_start
747
+ state.lastSession.emit("tool.execution_start", {
748
+ toolName: "task",
749
+ toolCallId: "tc-activity-1",
750
+ arguments: { name: "kaylee", description: "🔧 Kaylee: test spawn" },
751
+ });
752
+ // SDK fires subagent.started with boilerplate agent_type fields
753
+ state.lastSession.emit("subagent.started", {
754
+ toolCallId: "tc-activity-1",
755
+ agentName: "general-purpose",
756
+ agentDisplayName: "General Purpose Agent",
757
+ agentDescription: "Full-capability agent boilerplate",
758
+ });
759
+ const startedEvent = activityEvents.find((e) => e.kind === "subagent_started");
760
+ assert.ok(startedEvent, "onActivity must have been called with a subagent_started event");
761
+ assert.equal(startedEvent.agentSlug, "kaylee", `agentSlug in activity event must be "kaylee" (spawn name), got: ${startedEvent.agentSlug}`);
762
+ assert.ok(String(startedEvent.agentDescription).includes("🔧 Kaylee"), `agentDescription in activity event must use spawn description, got: ${startedEvent.agentDescription}`);
763
+ state.pendingReject?.(new Error("test teardown"));
764
+ });
765
+ // ---------------------------------------------------------------------------
766
+ // #86: Nested tool-call events streamed to /workers detail pane
767
+ // ---------------------------------------------------------------------------
768
+ test("#86: tool.execution_start with parentToolCallId matching active subagent calls appendTaskEvent", async (t) => {
769
+ const { orchestrator, state, client } = await loadOrchestratorModule(t, {
770
+ sendResult: "__PENDING__",
771
+ });
772
+ await orchestrator.initOrchestrator(client);
773
+ orchestrator.sendToOrchestrator("dispatch kaylee", { type: "background" }, () => { });
774
+ await new Promise((resolve) => setTimeout(resolve, 10));
775
+ assert.ok(state.lastSession, "FakeSession must have been created");
776
+ // Register the subagent task via subagent.started so activeSubagentTaskIds is populated
777
+ state.lastSession.emit("subagent.started", {
778
+ toolCallId: "subagent-task-001",
779
+ agentName: "kaylee",
780
+ agentDisplayName: "Kaylee — Backend Dev",
781
+ agentDescription: "Fix the streaming gap",
782
+ });
783
+ // Fire a nested tool.execution_start with parentToolCallId pointing to the subagent
784
+ state.lastSession.emit("tool.execution_start", {
785
+ toolCallId: "nested-call-001",
786
+ toolName: "bash",
787
+ parentToolCallId: "subagent-task-001",
788
+ arguments: { command: "npm run build" },
789
+ });
790
+ const events = state.taskEvents.get("subagent-task-001") ?? [];
791
+ assert.equal(events.length, 1, "appendTaskEvent must have been called once for the nested tool start");
792
+ assert.equal(events[0].kind, "tool_start");
793
+ assert.equal(events[0].toolName, "bash");
794
+ assert.equal(events[0].summary, "npm run build");
795
+ state.pendingReject?.(new Error("test teardown"));
796
+ });
797
+ test("#86: tool.execution_start with parentToolCallId NOT in active subagents is ignored", async (t) => {
798
+ const { orchestrator, state, client } = await loadOrchestratorModule(t, {
799
+ sendResult: "__PENDING__",
800
+ });
801
+ await orchestrator.initOrchestrator(client);
802
+ orchestrator.sendToOrchestrator("run something", { type: "background" }, () => { });
803
+ await new Promise((resolve) => setTimeout(resolve, 10));
804
+ assert.ok(state.lastSession, "FakeSession must have been created");
805
+ // No subagent.started fired — activeSubagentTaskIds is empty
806
+ state.lastSession.emit("tool.execution_start", {
807
+ toolCallId: "nested-call-002",
808
+ toolName: "bash",
809
+ parentToolCallId: "unknown-parent",
810
+ arguments: { command: "echo hi" },
811
+ });
812
+ const events = state.taskEvents.get("unknown-parent") ?? [];
813
+ assert.equal(events.length, 0, "appendTaskEvent must NOT be called when parentToolCallId is not a known subagent");
814
+ state.pendingReject?.(new Error("test teardown"));
815
+ });
816
+ test("#86: tool.execution_complete with parentToolCallId calls appendTaskEvent with tool_complete", async (t) => {
817
+ const { orchestrator, state, client } = await loadOrchestratorModule(t, {
818
+ sendResult: "__PENDING__",
819
+ });
820
+ await orchestrator.initOrchestrator(client);
821
+ orchestrator.sendToOrchestrator("dispatch agent", { type: "background" }, () => { });
822
+ await new Promise((resolve) => setTimeout(resolve, 10));
823
+ assert.ok(state.lastSession, "FakeSession must have been created");
824
+ state.lastSession.emit("subagent.started", {
825
+ toolCallId: "subagent-task-002",
826
+ agentName: "zoe",
827
+ agentDisplayName: "Zoe — QA",
828
+ agentDescription: "Run tests",
829
+ });
830
+ state.lastSession.emit("tool.execution_complete", {
831
+ toolCallId: "nested-call-003",
832
+ parentToolCallId: "subagent-task-002",
833
+ success: true,
834
+ result: { content: "All tests passed" },
835
+ });
836
+ const events = state.taskEvents.get("subagent-task-002") ?? [];
837
+ assert.equal(events.length, 1, "appendTaskEvent must have been called for tool_complete");
838
+ assert.equal(events[0].kind, "tool_complete");
839
+ assert.ok(String(events[0].summary).includes("All tests passed"), `summary must include result content, got: ${events[0].summary}`);
840
+ state.pendingReject?.(new Error("test teardown"));
841
+ });
842
+ test("#86: subagent.completed removes task from activeSubagentTaskIds — subsequent nested events ignored", async (t) => {
843
+ const { orchestrator, state, client } = await loadOrchestratorModule(t, {
844
+ sendResult: "__PENDING__",
845
+ });
846
+ await orchestrator.initOrchestrator(client);
847
+ orchestrator.sendToOrchestrator("dispatch agent", { type: "background" }, () => { });
848
+ await new Promise((resolve) => setTimeout(resolve, 10));
849
+ assert.ok(state.lastSession, "FakeSession must have been created");
850
+ state.lastSession.emit("subagent.started", {
851
+ toolCallId: "subagent-task-003",
852
+ agentName: "wash",
853
+ agentDisplayName: "Wash",
854
+ agentDescription: "UI work",
855
+ });
856
+ // Complete the subagent — removes from activeSubagentTaskIds
857
+ state.lastSession.emit("subagent.completed", {
858
+ toolCallId: "subagent-task-003",
859
+ agentName: "wash",
860
+ agentDisplayName: "Wash",
861
+ });
862
+ // Nested event arriving after completion must be ignored
863
+ state.lastSession.emit("tool.execution_start", {
864
+ toolCallId: "late-tool-call",
865
+ toolName: "view",
866
+ parentToolCallId: "subagent-task-003",
867
+ arguments: { path: "/some/file" },
868
+ });
869
+ const events = state.taskEvents.get("subagent-task-003") ?? [];
870
+ assert.equal(events.length, 0, "No task events must be recorded after subagent completes");
871
+ state.pendingReject?.(new Error("test teardown"));
872
+ });
629
873
  //# sourceMappingURL=orchestrator.test.js.map
@@ -0,0 +1,337 @@
1
+ // ---------------------------------------------------------------------------
2
+ // SessionManager — per-session queue, lifecycle, and SDK session holder
3
+ // SessionRegistry — owns all active sessions; handles TTL + LRU + explicit eviction
4
+ //
5
+ // Eviction strategy (Hybrid):
6
+ // 1. Explicit close — caller calls registry.close(sessionKey, "explicit-close")
7
+ // 2. TTL fallback — idle sessions evicted after CHAPTERHOUSE_SESSION_IDLE_TTL_MS
8
+ // 3. LRU cap — when maxActive is reached, evict least-recently-used idle session
9
+ //
10
+ // Invariant: never evict a session that is mid-turn or has queued messages.
11
+ // canEvict = !isProcessing && queueDepth === 0
12
+ //
13
+ // Config:
14
+ // CHAPTERHOUSE_SESSION_IDLE_TTL_MS (default: 1 800 000 ms = 30 min)
15
+ // CHAPTERHOUSE_SESSION_MAX_ACTIVE (default: 20)
16
+ // ---------------------------------------------------------------------------
17
+ import { childLogger } from "../util/logger.js";
18
+ const log = childLogger("session-manager");
19
+ // ---------------------------------------------------------------------------
20
+ // Env-configurable eviction parameters
21
+ // ---------------------------------------------------------------------------
22
+ const DEFAULT_SESSION_IDLE_TTL_MS = 1_800_000; // 30 min
23
+ export const SESSION_IDLE_TTL_MS = (() => {
24
+ const env = process.env.CHAPTERHOUSE_SESSION_IDLE_TTL_MS;
25
+ if (env) {
26
+ const parsed = parseInt(env, 10);
27
+ if (!isNaN(parsed) && parsed > 0)
28
+ return parsed;
29
+ }
30
+ return DEFAULT_SESSION_IDLE_TTL_MS;
31
+ })();
32
+ const DEFAULT_SESSION_MAX_ACTIVE = 20;
33
+ export const SESSION_MAX_ACTIVE = (() => {
34
+ const env = process.env.CHAPTERHOUSE_SESSION_MAX_ACTIVE;
35
+ if (env) {
36
+ const parsed = parseInt(env, 10);
37
+ if (!isNaN(parsed) && parsed > 0)
38
+ return parsed;
39
+ }
40
+ return DEFAULT_SESSION_MAX_ACTIVE;
41
+ })();
42
+ // ---------------------------------------------------------------------------
43
+ // SessionManager — owns one session key
44
+ // ---------------------------------------------------------------------------
45
+ export class SessionManager {
46
+ worker;
47
+ sessionFactory;
48
+ sessionKey;
49
+ _queue = [];
50
+ _processing = false;
51
+ _session;
52
+ _sessionCreatePromise;
53
+ _currentModel;
54
+ _recentTiers = [];
55
+ _lastActivityAt = Date.now();
56
+ /** Set by registry.close() when the session is busy at close time. The drain loop
57
+ * honors this after the queue fully empties — evicting without violating the
58
+ * never-evict-mid-turn invariant. */
59
+ _pendingClose = false;
60
+ _onPendingCloseEvict;
61
+ constructor(sessionKey, worker, sessionFactory) {
62
+ this.worker = worker;
63
+ this.sessionFactory = sessionFactory;
64
+ this.sessionKey = sessionKey;
65
+ }
66
+ // ── Observation surface ──────────────────────────────────────────────────
67
+ get isProcessing() {
68
+ return this._processing;
69
+ }
70
+ get queueDepth() {
71
+ return this._queue.length;
72
+ }
73
+ /** True when the session can be safely evicted. */
74
+ get canEvict() {
75
+ return !this._processing && this._queue.length === 0;
76
+ }
77
+ get lastActivityAt() {
78
+ return this._lastActivityAt;
79
+ }
80
+ /** True when an explicit close was requested while the session was busy. */
81
+ get pendingClose() {
82
+ return this._pendingClose;
83
+ }
84
+ /**
85
+ * Mark this session for deferred eviction. Called by SessionRegistry.close()
86
+ * when the session is mid-turn or has queued messages. The drain loop calls
87
+ * `onEvict` after the queue fully empties.
88
+ */
89
+ setPendingClose(onEvict) {
90
+ this._pendingClose = true;
91
+ this._onPendingCloseEvict = onEvict;
92
+ }
93
+ // ── Session and model state (for orchestrator.ts) ────────────────────────
94
+ get session() {
95
+ return this._session;
96
+ }
97
+ set session(s) {
98
+ this._session = s;
99
+ }
100
+ get currentModel() {
101
+ return this._currentModel;
102
+ }
103
+ set currentModel(m) {
104
+ this._currentModel = m;
105
+ }
106
+ get recentTiers() {
107
+ return this._recentTiers;
108
+ }
109
+ get sessionCreatePromise() {
110
+ return this._sessionCreatePromise;
111
+ }
112
+ set sessionCreatePromise(p) {
113
+ this._sessionCreatePromise = p;
114
+ }
115
+ addRecentTier(tier) {
116
+ this._recentTiers.push(tier);
117
+ if (this._recentTiers.length > 5) {
118
+ this._recentTiers = this._recentTiers.slice(-5);
119
+ }
120
+ }
121
+ // ── Queue ────────────────────────────────────────────────────────────────
122
+ enqueue(item) {
123
+ this._queue.push(item);
124
+ this._lastActivityAt = Date.now();
125
+ const depth = this._queue.length;
126
+ if (depth > 1) {
127
+ log.debug({ sessionKey: this.sessionKey, depth }, "session.queue.depth");
128
+ }
129
+ void this.drain();
130
+ }
131
+ /**
132
+ * Drain the queue one item at a time.
133
+ * Guarantees per-session FIFO ordering and one concurrent turn per session.
134
+ */
135
+ async drain() {
136
+ if (this._processing)
137
+ return;
138
+ this._processing = true;
139
+ while (this._queue.length > 0) {
140
+ const item = this._queue.shift();
141
+ const start = Date.now();
142
+ log.info({ sessionKey: this.sessionKey, sourceChannel: item.sourceChannel }, "session.turn.started");
143
+ try {
144
+ const result = await this.worker(item, this);
145
+ const durationMs = Date.now() - start;
146
+ log.info({ sessionKey: this.sessionKey, durationMs }, "session.turn.finished");
147
+ item.resolve(result);
148
+ }
149
+ catch (err) {
150
+ const durationMs = Date.now() - start;
151
+ log.warn({ sessionKey: this.sessionKey, durationMs, err: err instanceof Error ? err.message : String(err) }, "session.turn.failed");
152
+ item.reject(err);
153
+ }
154
+ this._lastActivityAt = Date.now();
155
+ }
156
+ this._processing = false;
157
+ // Honor deferred explicit-close: evict now that the queue is empty.
158
+ if (this._pendingClose && this._queue.length === 0) {
159
+ log.info({ sessionKey: this.sessionKey }, "session.pendingClose.evicting");
160
+ this._onPendingCloseEvict?.();
161
+ }
162
+ }
163
+ // ── Session lifecycle ────────────────────────────────────────────────────
164
+ /** Ensure the CopilotSession exists, creating/resuming if needed. Concurrency-safe. */
165
+ async ensureSession() {
166
+ if (this._session)
167
+ return this._session;
168
+ if (this._sessionCreatePromise)
169
+ return this._sessionCreatePromise;
170
+ const projectRoot = this.sessionKey.startsWith("project:")
171
+ ? this.sessionKey.slice("project:".length)
172
+ : undefined;
173
+ const promise = this.sessionFactory(this.sessionKey, projectRoot);
174
+ this._sessionCreatePromise = promise;
175
+ try {
176
+ const session = await promise;
177
+ this._session = session;
178
+ return session;
179
+ }
180
+ finally {
181
+ this._sessionCreatePromise = undefined;
182
+ }
183
+ }
184
+ /** Invalidate the cached session so the next ensureSession() creates a fresh one. */
185
+ invalidateSession() {
186
+ this._session = undefined;
187
+ this._sessionCreatePromise = undefined;
188
+ }
189
+ /** Reject all queued messages without evicting the session. Returns count drained. */
190
+ cancelQueued() {
191
+ const count = this._queue.length;
192
+ while (this._queue.length > 0) {
193
+ const item = this._queue.shift();
194
+ item.reject(new Error("Cancelled"));
195
+ }
196
+ return count;
197
+ }
198
+ /** Abort the in-flight SDK request on this session. Returns true if abort was sent. */
199
+ async abortCurrentTurn() {
200
+ if (!this._session || !this._processing)
201
+ return false;
202
+ try {
203
+ await this._session.abort();
204
+ return true;
205
+ }
206
+ catch {
207
+ return false;
208
+ }
209
+ }
210
+ /**
211
+ * Evict this session: reject queued items and disconnect the SDK session.
212
+ * Safe to call even when not processing (canEvict check is the caller's responsibility).
213
+ */
214
+ async evict(reason) {
215
+ // Reject any messages still in the queue
216
+ while (this._queue.length > 0) {
217
+ const item = this._queue.shift();
218
+ item.reject(new Error(`Session evicted: ${reason}`));
219
+ }
220
+ // Disconnect the SDK session
221
+ if (this._session) {
222
+ try {
223
+ await this._session.disconnect();
224
+ }
225
+ catch {
226
+ // best effort
227
+ }
228
+ this._session = undefined;
229
+ }
230
+ }
231
+ }
232
+ // ---------------------------------------------------------------------------
233
+ // SessionRegistry — owns all sessions, drives eviction
234
+ // ---------------------------------------------------------------------------
235
+ export class SessionRegistry {
236
+ options;
237
+ createManager;
238
+ managers = new Map();
239
+ evictionTimer;
240
+ constructor(options, createManager) {
241
+ this.options = options;
242
+ this.createManager = createManager;
243
+ }
244
+ /**
245
+ * Return the SessionManager for the given key, creating one if needed.
246
+ * Triggers LRU eviction if maxActive is reached.
247
+ */
248
+ getOrCreate(sessionKey) {
249
+ const existing = this.managers.get(sessionKey);
250
+ if (existing)
251
+ return existing;
252
+ if (this.managers.size >= this.options.maxActive) {
253
+ this.evictLRU();
254
+ }
255
+ const manager = this.createManager(sessionKey);
256
+ this.managers.set(sessionKey, manager);
257
+ log.info({ sessionKey, activeSessions: this.managers.size }, "session.created");
258
+ return manager;
259
+ }
260
+ get(sessionKey) {
261
+ return this.managers.get(sessionKey);
262
+ }
263
+ getAll() {
264
+ return this.managers;
265
+ }
266
+ size() {
267
+ return this.managers.size;
268
+ }
269
+ /**
270
+ * Explicitly close a session (e.g., browser tab closed).
271
+ * If busy (mid-turn or queued messages), sets _pendingClose on the manager so the
272
+ * drain loop evicts it as soon as the queue empties — honoring the explicit-close
273
+ * intent without violating the never-evict-mid-turn invariant.
274
+ */
275
+ close(sessionKey, reason) {
276
+ const manager = this.managers.get(sessionKey);
277
+ if (!manager)
278
+ return;
279
+ if (!manager.canEvict) {
280
+ log.info({ sessionKey, reason }, "session.close.deferred — session is busy; will evict when queue drains");
281
+ manager.setPendingClose(() => {
282
+ this.managers.delete(sessionKey);
283
+ void manager.evict(reason);
284
+ log.info({ sessionKey, reason }, "session.evicted (deferred)");
285
+ });
286
+ return;
287
+ }
288
+ this.managers.delete(sessionKey);
289
+ void manager.evict(reason);
290
+ log.info({ sessionKey, reason }, "session.evicted");
291
+ }
292
+ /** Start the periodic TTL eviction scan. */
293
+ startEvictionTimer() {
294
+ if (this.evictionTimer)
295
+ return;
296
+ // Scan at most once per minute, or half the TTL interval (whichever is shorter)
297
+ const intervalMs = Math.min(this.options.idleTtlMs / 2, 60_000);
298
+ this.evictionTimer = setInterval(() => this.runTtlEviction(), intervalMs);
299
+ }
300
+ stopEvictionTimer() {
301
+ if (this.evictionTimer) {
302
+ clearInterval(this.evictionTimer);
303
+ this.evictionTimer = undefined;
304
+ }
305
+ }
306
+ runTtlEviction() {
307
+ const now = Date.now();
308
+ for (const [sessionKey, manager] of [...this.managers.entries()]) {
309
+ if (manager.canEvict && now - manager.lastActivityAt > this.options.idleTtlMs) {
310
+ const idleMs = now - manager.lastActivityAt;
311
+ this.managers.delete(sessionKey);
312
+ void manager.evict("ttl-expired");
313
+ log.info({ sessionKey, reason: "ttl-expired", idleMs }, "session.evicted");
314
+ }
315
+ }
316
+ }
317
+ evictLRU() {
318
+ const evictable = [...this.managers.entries()]
319
+ .filter(([, m]) => m.canEvict)
320
+ .sort(([, a], [, b]) => a.lastActivityAt - b.lastActivityAt);
321
+ if (evictable.length === 0) {
322
+ log.warn({ size: this.managers.size, max: this.options.maxActive }, "At max active sessions and no idle sessions available for LRU eviction");
323
+ return;
324
+ }
325
+ const [sessionKey, manager] = evictable[0];
326
+ this.managers.delete(sessionKey);
327
+ void manager.evict("lru-bumped");
328
+ log.info({ sessionKey, reason: "lru-bumped" }, "session.evicted");
329
+ }
330
+ /** Shut down all sessions. Stops the eviction timer and disconnects every session. */
331
+ async shutdown() {
332
+ this.stopEvictionTimer();
333
+ await Promise.allSettled([...this.managers.values()].map((m) => m.evict("explicit-close")));
334
+ this.managers.clear();
335
+ }
336
+ }
337
+ //# sourceMappingURL=session-manager.js.map