chapterhouse 0.3.0 → 0.3.2

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.
@@ -619,11 +619,50 @@ test("S5-01: subagent.failed event updates agent_tasks status to error", async (
619
619
  toolCallId: "subagent-call-003",
620
620
  agentName: "Zoe",
621
621
  agentDisplayName: "Zoe — QA",
622
- error: "Timeout after 600s",
622
+ error: "Timeout after 1800s",
623
623
  });
624
624
  const errorWrite = state.dbWrites.find((w) => w.sql.includes("UPDATE") && w.sql.includes("agent_tasks") && w.sql.includes("error"));
625
625
  assert.ok(errorWrite, "subagent.failed must UPDATE agent_tasks to error status");
626
626
  assert.ok(JSON.stringify(errorWrite.args).includes("subagent-call-003"), "UPDATE must target the correct task_id");
627
627
  state.pendingReject?.(new Error("test teardown"));
628
628
  });
629
+ // ---------------------------------------------------------------------------
630
+ // REGRESSION: #35 — per-session isolation
631
+ // This test would have caught the original bug. With a global shared queue,
632
+ // session B's message would queue behind session A's blocking turn and the
633
+ // Promise.race would time out. With per-session queues, session B completes
634
+ // independently.
635
+ // ---------------------------------------------------------------------------
636
+ test("regression #35: session A blocking does not delay session B (concurrent sessions)", async (t) => {
637
+ const { orchestrator, state, client } = await loadOrchestratorModule(t, {
638
+ config: { copilotModel: "claude-sonnet-4.6", selfEditEnabled: false, squadEnabled: true },
639
+ sendResult: "__PENDING__", // session A will block in sendAndWait
640
+ });
641
+ await orchestrator.initOrchestrator(client);
642
+ // Send to session A (project /project/a) — parks because sendResult is __PENDING__
643
+ const sessionACallbacks = [];
644
+ orchestrator.sendToOrchestrator("slow request to project A", { type: "web", connectionId: "conn-a", projectPath: "/project/a" }, (text, done) => sessionACallbacks.push({ text, done }));
645
+ // Yield to event loop so session A's drain loop runs and parks in sendAndWait
646
+ await new Promise((resolve) => setTimeout(resolve, 20));
647
+ // Now session A is blocked inside sendAndWait. Switch sendResult so session B
648
+ // gets a fast response. session A's pending reject is still captured in its closure.
649
+ state.sendResult = "session B complete";
650
+ // Send to session B (different project) — must complete without waiting for session A
651
+ const sessionBDone = new Promise((resolve) => {
652
+ orchestrator.sendToOrchestrator("quick request to project B", { type: "web", connectionId: "conn-b", projectPath: "/project/b" }, (text, done) => {
653
+ if (done)
654
+ resolve(text);
655
+ });
656
+ });
657
+ // Session B must respond before the deadline (session A is still blocked indefinitely)
658
+ const result = await Promise.race([
659
+ sessionBDone,
660
+ new Promise((_, reject) => setTimeout(() => reject(new Error("session B was blocked by session A — global queue bug reproduced")), 300)),
661
+ ]);
662
+ assert.equal(result, "session B complete", "session B must resolve independently of blocked session A");
663
+ assert.equal(sessionACallbacks.length, 0, "session A must still be pending (no response yet)");
664
+ // Clean up: reject session A's pending promise so the test can finish
665
+ state.pendingReject?.(new Error("test teardown"));
666
+ await new Promise((resolve) => setTimeout(resolve, 10));
667
+ });
629
668
  //# sourceMappingURL=orchestrator.test.js.map
@@ -0,0 +1,307 @@
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
+ constructor(sessionKey, worker, sessionFactory) {
57
+ this.worker = worker;
58
+ this.sessionFactory = sessionFactory;
59
+ this.sessionKey = sessionKey;
60
+ }
61
+ // ── Observation surface ──────────────────────────────────────────────────
62
+ get isProcessing() {
63
+ return this._processing;
64
+ }
65
+ get queueDepth() {
66
+ return this._queue.length;
67
+ }
68
+ /** True when the session can be safely evicted. */
69
+ get canEvict() {
70
+ return !this._processing && this._queue.length === 0;
71
+ }
72
+ get lastActivityAt() {
73
+ return this._lastActivityAt;
74
+ }
75
+ // ── Session and model state (for orchestrator.ts) ────────────────────────
76
+ get session() {
77
+ return this._session;
78
+ }
79
+ set session(s) {
80
+ this._session = s;
81
+ }
82
+ get currentModel() {
83
+ return this._currentModel;
84
+ }
85
+ set currentModel(m) {
86
+ this._currentModel = m;
87
+ }
88
+ get recentTiers() {
89
+ return this._recentTiers;
90
+ }
91
+ get sessionCreatePromise() {
92
+ return this._sessionCreatePromise;
93
+ }
94
+ set sessionCreatePromise(p) {
95
+ this._sessionCreatePromise = p;
96
+ }
97
+ addRecentTier(tier) {
98
+ this._recentTiers.push(tier);
99
+ if (this._recentTiers.length > 5) {
100
+ this._recentTiers = this._recentTiers.slice(-5);
101
+ }
102
+ }
103
+ // ── Queue ────────────────────────────────────────────────────────────────
104
+ enqueue(item) {
105
+ this._queue.push(item);
106
+ this._lastActivityAt = Date.now();
107
+ const depth = this._queue.length;
108
+ if (depth > 1) {
109
+ log.debug({ sessionKey: this.sessionKey, depth }, "session.queue.depth");
110
+ }
111
+ void this.drain();
112
+ }
113
+ /**
114
+ * Drain the queue one item at a time.
115
+ * Guarantees per-session FIFO ordering and one concurrent turn per session.
116
+ */
117
+ async drain() {
118
+ if (this._processing)
119
+ return;
120
+ this._processing = true;
121
+ while (this._queue.length > 0) {
122
+ const item = this._queue.shift();
123
+ const start = Date.now();
124
+ log.info({ sessionKey: this.sessionKey, sourceChannel: item.sourceChannel }, "session.turn.started");
125
+ try {
126
+ const result = await this.worker(item, this);
127
+ const durationMs = Date.now() - start;
128
+ log.info({ sessionKey: this.sessionKey, durationMs }, "session.turn.finished");
129
+ item.resolve(result);
130
+ }
131
+ catch (err) {
132
+ const durationMs = Date.now() - start;
133
+ log.warn({ sessionKey: this.sessionKey, durationMs, err: err instanceof Error ? err.message : String(err) }, "session.turn.failed");
134
+ item.reject(err);
135
+ }
136
+ this._lastActivityAt = Date.now();
137
+ }
138
+ this._processing = false;
139
+ }
140
+ // ── Session lifecycle ────────────────────────────────────────────────────
141
+ /** Ensure the CopilotSession exists, creating/resuming if needed. Concurrency-safe. */
142
+ async ensureSession() {
143
+ if (this._session)
144
+ return this._session;
145
+ if (this._sessionCreatePromise)
146
+ return this._sessionCreatePromise;
147
+ const projectRoot = this.sessionKey.startsWith("project:")
148
+ ? this.sessionKey.slice("project:".length)
149
+ : undefined;
150
+ const promise = this.sessionFactory(this.sessionKey, projectRoot);
151
+ this._sessionCreatePromise = promise;
152
+ try {
153
+ const session = await promise;
154
+ this._session = session;
155
+ return session;
156
+ }
157
+ finally {
158
+ this._sessionCreatePromise = undefined;
159
+ }
160
+ }
161
+ /** Invalidate the cached session so the next ensureSession() creates a fresh one. */
162
+ invalidateSession() {
163
+ this._session = undefined;
164
+ this._sessionCreatePromise = undefined;
165
+ }
166
+ /** Reject all queued messages without evicting the session. Returns count drained. */
167
+ cancelQueued() {
168
+ const count = this._queue.length;
169
+ while (this._queue.length > 0) {
170
+ const item = this._queue.shift();
171
+ item.reject(new Error("Cancelled"));
172
+ }
173
+ return count;
174
+ }
175
+ /** Abort the in-flight SDK request on this session. Returns true if abort was sent. */
176
+ async abortCurrentTurn() {
177
+ if (!this._session || !this._processing)
178
+ return false;
179
+ try {
180
+ await this._session.abort();
181
+ return true;
182
+ }
183
+ catch {
184
+ return false;
185
+ }
186
+ }
187
+ /**
188
+ * Evict this session: reject queued items and disconnect the SDK session.
189
+ * Safe to call even when not processing (canEvict check is the caller's responsibility).
190
+ */
191
+ async evict(reason) {
192
+ // Reject any messages still in the queue
193
+ while (this._queue.length > 0) {
194
+ const item = this._queue.shift();
195
+ item.reject(new Error(`Session evicted: ${reason}`));
196
+ }
197
+ // Disconnect the SDK session
198
+ if (this._session) {
199
+ try {
200
+ await this._session.disconnect();
201
+ }
202
+ catch {
203
+ // best effort
204
+ }
205
+ this._session = undefined;
206
+ }
207
+ }
208
+ }
209
+ // ---------------------------------------------------------------------------
210
+ // SessionRegistry — owns all sessions, drives eviction
211
+ // ---------------------------------------------------------------------------
212
+ export class SessionRegistry {
213
+ options;
214
+ createManager;
215
+ managers = new Map();
216
+ evictionTimer;
217
+ constructor(options, createManager) {
218
+ this.options = options;
219
+ this.createManager = createManager;
220
+ }
221
+ /**
222
+ * Return the SessionManager for the given key, creating one if needed.
223
+ * Triggers LRU eviction if maxActive is reached.
224
+ */
225
+ getOrCreate(sessionKey) {
226
+ const existing = this.managers.get(sessionKey);
227
+ if (existing)
228
+ return existing;
229
+ if (this.managers.size >= this.options.maxActive) {
230
+ this.evictLRU();
231
+ }
232
+ const manager = this.createManager(sessionKey);
233
+ this.managers.set(sessionKey, manager);
234
+ log.info({ sessionKey, activeSessions: this.managers.size }, "session.created");
235
+ return manager;
236
+ }
237
+ get(sessionKey) {
238
+ return this.managers.get(sessionKey);
239
+ }
240
+ getAll() {
241
+ return this.managers;
242
+ }
243
+ size() {
244
+ return this.managers.size;
245
+ }
246
+ /**
247
+ * Explicitly close a session (e.g., browser tab closed).
248
+ * Deferred (with warning) if the session is currently busy.
249
+ */
250
+ close(sessionKey, reason) {
251
+ const manager = this.managers.get(sessionKey);
252
+ if (!manager)
253
+ return;
254
+ if (!manager.canEvict) {
255
+ log.warn({ sessionKey, reason }, "Eviction deferred — session is mid-turn or has queued messages");
256
+ return;
257
+ }
258
+ this.managers.delete(sessionKey);
259
+ void manager.evict(reason);
260
+ log.info({ sessionKey, reason }, "session.evicted");
261
+ }
262
+ /** Start the periodic TTL eviction scan. */
263
+ startEvictionTimer() {
264
+ if (this.evictionTimer)
265
+ return;
266
+ // Scan at most once per minute, or half the TTL interval (whichever is shorter)
267
+ const intervalMs = Math.min(this.options.idleTtlMs / 2, 60_000);
268
+ this.evictionTimer = setInterval(() => this.runTtlEviction(), intervalMs);
269
+ }
270
+ stopEvictionTimer() {
271
+ if (this.evictionTimer) {
272
+ clearInterval(this.evictionTimer);
273
+ this.evictionTimer = undefined;
274
+ }
275
+ }
276
+ runTtlEviction() {
277
+ const now = Date.now();
278
+ for (const [sessionKey, manager] of [...this.managers.entries()]) {
279
+ if (manager.canEvict && now - manager.lastActivityAt > this.options.idleTtlMs) {
280
+ const idleMs = now - manager.lastActivityAt;
281
+ this.managers.delete(sessionKey);
282
+ void manager.evict("ttl-expired");
283
+ log.info({ sessionKey, reason: "ttl-expired", idleMs }, "session.evicted");
284
+ }
285
+ }
286
+ }
287
+ evictLRU() {
288
+ const evictable = [...this.managers.entries()]
289
+ .filter(([, m]) => m.canEvict)
290
+ .sort(([, a], [, b]) => a.lastActivityAt - b.lastActivityAt);
291
+ if (evictable.length === 0) {
292
+ log.warn({ size: this.managers.size, max: this.options.maxActive }, "At max active sessions and no idle sessions available for LRU eviction");
293
+ return;
294
+ }
295
+ const [sessionKey, manager] = evictable[0];
296
+ this.managers.delete(sessionKey);
297
+ void manager.evict("lru-bumped");
298
+ log.info({ sessionKey, reason: "lru-bumped" }, "session.evicted");
299
+ }
300
+ /** Shut down all sessions. Stops the eviction timer and disconnects every session. */
301
+ async shutdown() {
302
+ this.stopEvictionTimer();
303
+ await Promise.allSettled([...this.managers.values()].map((m) => m.evict("explicit-close")));
304
+ this.managers.clear();
305
+ }
306
+ }
307
+ //# sourceMappingURL=session-manager.js.map
@@ -0,0 +1,292 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Tests for SessionManager and SessionRegistry
3
+ // ---------------------------------------------------------------------------
4
+ import assert from "node:assert/strict";
5
+ import test from "node:test";
6
+ import { SessionManager, SessionRegistry, } from "./session-manager.js";
7
+ function makeFakeSession() {
8
+ const tracker = {
9
+ disconnectCalls: 0,
10
+ abortCalls: 0,
11
+ session: null,
12
+ };
13
+ tracker.session = {
14
+ sessionId: "fake-" + Math.random().toString(36).slice(2),
15
+ async disconnect() { tracker.disconnectCalls++; },
16
+ async abort() { tracker.abortCalls++; },
17
+ on() { return () => { }; },
18
+ async sendAndWait() { return { data: { content: "ok" } }; },
19
+ async setModel() { },
20
+ getState() { return "connected"; },
21
+ };
22
+ return tracker;
23
+ }
24
+ function factory(session) {
25
+ return async () => session;
26
+ }
27
+ function makeDeferred() {
28
+ let resolve;
29
+ let reject;
30
+ const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
31
+ const item = {
32
+ prompt: "test prompt",
33
+ callback: () => { },
34
+ sessionKey: "default",
35
+ resolve,
36
+ reject,
37
+ };
38
+ return { item, promise, resolve, reject };
39
+ }
40
+ // ---------------------------------------------------------------------------
41
+ // SessionManager tests
42
+ // ---------------------------------------------------------------------------
43
+ test("SessionManager: canEvict is true when idle with empty queue", () => {
44
+ const { session } = makeFakeSession();
45
+ const manager = new SessionManager("default", async () => "ok", factory(session));
46
+ assert.equal(manager.isProcessing, false);
47
+ assert.equal(manager.queueDepth, 0);
48
+ assert.equal(manager.canEvict, true);
49
+ });
50
+ test("SessionManager: canEvict is false while processing, true after", async () => {
51
+ const { session } = makeFakeSession();
52
+ let unblock;
53
+ const worker = () => new Promise((r) => { unblock = () => r("done"); });
54
+ const manager = new SessionManager("default", worker, factory(session));
55
+ const { item } = makeDeferred();
56
+ manager.enqueue(item);
57
+ await new Promise((r) => setTimeout(r, 0));
58
+ assert.equal(manager.isProcessing, true, "should be processing after enqueue");
59
+ assert.equal(manager.canEvict, false, "must not evict while processing");
60
+ unblock();
61
+ await new Promise((r) => setTimeout(r, 0));
62
+ assert.equal(manager.isProcessing, false, "should be idle after worker completes");
63
+ assert.equal(manager.canEvict, true, "can evict once idle");
64
+ });
65
+ test("SessionManager: canEvict is false when queue has pending items", async () => {
66
+ const { session } = makeFakeSession();
67
+ let unblock;
68
+ const worker = () => new Promise((r) => { unblock = () => r("done"); });
69
+ const manager = new SessionManager("default", worker, factory(session));
70
+ const { item: i1 } = makeDeferred();
71
+ const { item: i2 } = makeDeferred();
72
+ manager.enqueue(i1);
73
+ manager.enqueue(i2);
74
+ await new Promise((r) => setTimeout(r, 0));
75
+ assert.equal(manager.queueDepth, 1, "second item still queued");
76
+ assert.equal(manager.canEvict, false, "must not evict with queued items");
77
+ unblock();
78
+ await new Promise((r) => setTimeout(r, 5));
79
+ assert.equal(manager.queueDepth, 0, "queue should drain");
80
+ // Clean up: unblock second (which completes instantly via the same worker closure)
81
+ });
82
+ test("SessionManager: cancelQueued drains pending queue items and rejects them", async () => {
83
+ const { session } = makeFakeSession();
84
+ let unblock;
85
+ const worker = () => new Promise((r) => { unblock = () => r("first"); });
86
+ const manager = new SessionManager("default", worker, factory(session));
87
+ const { item: i1 } = makeDeferred();
88
+ const { item: i2, promise: p2 } = makeDeferred();
89
+ const { item: i3, promise: p3 } = makeDeferred();
90
+ manager.enqueue(i1);
91
+ manager.enqueue(i2);
92
+ manager.enqueue(i3);
93
+ await new Promise((r) => setTimeout(r, 0));
94
+ assert.equal(manager.queueDepth, 2, "two items queued while first processes");
95
+ const drained = manager.cancelQueued();
96
+ assert.equal(drained, 2, "two queued items should be cancelled");
97
+ await assert.rejects(p2, /Cancelled/, "item2 should reject with Cancelled");
98
+ await assert.rejects(p3, /Cancelled/, "item3 should reject with Cancelled");
99
+ unblock();
100
+ await new Promise((r) => setTimeout(r, 0));
101
+ });
102
+ test("SessionManager: evict disconnects session and rejects queued items", async () => {
103
+ const t1 = makeFakeSession();
104
+ let unblock;
105
+ const worker = () => new Promise((r) => { unblock = () => r("ok"); });
106
+ const manager = new SessionManager("default", worker, factory(t1.session));
107
+ const { item: i1 } = makeDeferred();
108
+ const { item: i2, promise: p2 } = makeDeferred();
109
+ manager.enqueue(i1);
110
+ manager.enqueue(i2);
111
+ await new Promise((r) => setTimeout(r, 0));
112
+ // Evict: rejects queued items and disconnects session
113
+ const evictDone = manager.evict("explicit-close");
114
+ await assert.rejects(p2, /evicted/, "queued item should be rejected with evicted");
115
+ await evictDone;
116
+ assert.equal(t1.disconnectCalls, 0, "session not yet created — no disconnect needed");
117
+ // Now with a primed session
118
+ const t2 = makeFakeSession();
119
+ const manager2 = new SessionManager("s2", async () => "ok", factory(t2.session));
120
+ await manager2.ensureSession();
121
+ await manager2.evict("explicit-close");
122
+ assert.equal(t2.disconnectCalls, 1, "session should be disconnected on evict");
123
+ unblock();
124
+ await new Promise((r) => setTimeout(r, 0));
125
+ });
126
+ test("SessionManager: abortCurrentTurn returns false when not processing", async () => {
127
+ const { session } = makeFakeSession();
128
+ const manager = new SessionManager("default", async () => "ok", factory(session));
129
+ await manager.ensureSession();
130
+ const aborted = await manager.abortCurrentTurn();
131
+ assert.equal(aborted, false);
132
+ });
133
+ test("SessionManager: abortCurrentTurn calls session.abort() when processing", async () => {
134
+ const t = makeFakeSession();
135
+ const worker = async (_item, mgr) => {
136
+ await mgr.ensureSession();
137
+ return new Promise(() => { }); // park forever
138
+ };
139
+ const manager = new SessionManager("default", worker, factory(t.session));
140
+ const { item } = makeDeferred();
141
+ manager.enqueue(item);
142
+ await new Promise((r) => setTimeout(r, 0));
143
+ assert.equal(manager.isProcessing, true, "manager must be processing");
144
+ const aborted = await manager.abortCurrentTurn();
145
+ assert.equal(aborted, true, "abortCurrentTurn should return true");
146
+ assert.equal(t.abortCalls, 1, "session.abort() must be called once");
147
+ });
148
+ test("SessionManager: addRecentTier caps at 5 entries", () => {
149
+ const { session } = makeFakeSession();
150
+ const manager = new SessionManager("default", async () => "ok", factory(session));
151
+ for (let i = 0; i < 8; i++)
152
+ manager.addRecentTier("fast");
153
+ assert.equal(manager.recentTiers.length, 5, "should cap at 5 recent tiers");
154
+ });
155
+ // ---------------------------------------------------------------------------
156
+ // SessionRegistry tests
157
+ // ---------------------------------------------------------------------------
158
+ function makeRegistry(opts = {}) {
159
+ const { idleTtlMs = 60_000, maxActive = 10 } = opts;
160
+ const disconnectLog = [];
161
+ const registry = new SessionRegistry({ idleTtlMs, maxActive }, (sk) => {
162
+ const t = makeFakeSession();
163
+ // Instrument disconnect to capture key
164
+ const orig = t.session.disconnect.bind(t.session);
165
+ t.session.disconnect = async () => {
166
+ disconnectLog.push(sk);
167
+ await orig();
168
+ };
169
+ return new SessionManager(sk, async () => "ok", factory(t.session));
170
+ });
171
+ return { registry, disconnectLog };
172
+ }
173
+ test("SessionRegistry: getOrCreate returns same manager for same key", () => {
174
+ const { registry } = makeRegistry();
175
+ const m1 = registry.getOrCreate("default");
176
+ const m2 = registry.getOrCreate("default");
177
+ assert.ok(m1 instanceof SessionManager);
178
+ assert.equal(m1, m2, "same key returns same manager");
179
+ assert.equal(registry.size(), 1);
180
+ });
181
+ test("SessionRegistry: LRU eviction fires when at maxActive capacity", async () => {
182
+ const { registry, disconnectLog } = makeRegistry({ maxActive: 3, idleTtlMs: 60_000 });
183
+ // Create 3 sessions with staggered timestamps so LRU is deterministic
184
+ const m1 = registry.getOrCreate("s1");
185
+ await new Promise((r) => setTimeout(r, 2));
186
+ registry.getOrCreate("s2");
187
+ await new Promise((r) => setTimeout(r, 2));
188
+ registry.getOrCreate("s3");
189
+ // Prime s1's session so it gets disconnected
190
+ await m1.ensureSession();
191
+ assert.equal(registry.size(), 3);
192
+ // Adding a 4th should evict s1 (oldest lastActivityAt)
193
+ registry.getOrCreate("s4");
194
+ await new Promise((r) => setTimeout(r, 5));
195
+ assert.equal(registry.size(), 3, "size should remain at max");
196
+ assert.ok(disconnectLog.includes("s1"), "s1 (oldest) should be LRU-evicted");
197
+ assert.ok(!registry.get("s1"), "s1 should be removed from registry");
198
+ });
199
+ test("SessionRegistry: explicit close evicts an idle session", async () => {
200
+ const { registry, disconnectLog } = makeRegistry();
201
+ const m = registry.getOrCreate("my-session");
202
+ await m.ensureSession();
203
+ assert.equal(registry.size(), 1);
204
+ registry.close("my-session", "explicit-close");
205
+ await new Promise((r) => setTimeout(r, 5));
206
+ assert.equal(registry.size(), 0, "session should be removed");
207
+ assert.ok(disconnectLog.includes("my-session"), "session should be disconnected");
208
+ });
209
+ test("SessionRegistry: close is deferred when session is processing", async () => {
210
+ const { registry, disconnectLog } = makeRegistry();
211
+ let unblock;
212
+ // Replace the factory with a blocking one
213
+ const { registry: reg2, disconnectLog: dl2 } = (() => {
214
+ const log = [];
215
+ const r = new SessionRegistry({ idleTtlMs: 60_000, maxActive: 10 }, (sk) => {
216
+ const t = makeFakeSession();
217
+ t.session.disconnect = async () => { log.push(sk); };
218
+ const worker = () => new Promise((res) => { unblock = () => res("done"); });
219
+ return new SessionManager(sk, worker, factory(t.session));
220
+ });
221
+ return { registry: r, disconnectLog: log };
222
+ })();
223
+ const m = reg2.getOrCreate("busy");
224
+ const { item } = makeDeferred();
225
+ m.enqueue(item);
226
+ await new Promise((r) => setTimeout(r, 0));
227
+ assert.equal(m.isProcessing, true);
228
+ // close() should be deferred (session is busy)
229
+ reg2.close("busy", "explicit-close");
230
+ await new Promise((r) => setTimeout(r, 5));
231
+ assert.ok(reg2.get("busy"), "busy session must remain in registry");
232
+ assert.equal(dl2.length, 0, "disconnect must not fire while processing");
233
+ unblock();
234
+ await new Promise((r) => setTimeout(r, 5));
235
+ });
236
+ test("SessionRegistry: TTL eviction removes sessions idle beyond the TTL", async () => {
237
+ const SHORT_TTL = 40;
238
+ const { registry, disconnectLog } = makeRegistry({ idleTtlMs: SHORT_TTL });
239
+ const m = registry.getOrCreate("idle-session");
240
+ await m.ensureSession();
241
+ registry.startEvictionTimer();
242
+ // Wait past 3 × TTL — the eviction scan interval is min(TTL/2, 60s) = 20ms
243
+ await new Promise((r) => setTimeout(r, SHORT_TTL * 5));
244
+ registry.stopEvictionTimer();
245
+ assert.ok(disconnectLog.includes("idle-session"), "idle session must be evicted after TTL");
246
+ assert.ok(!registry.get("idle-session"), "idle session must be removed");
247
+ });
248
+ test("SessionRegistry: shutdown disconnects all sessions", async () => {
249
+ const { registry, disconnectLog } = makeRegistry();
250
+ for (const sk of ["a", "b", "c"]) {
251
+ const m = registry.getOrCreate(sk);
252
+ await m.ensureSession();
253
+ }
254
+ assert.equal(registry.size(), 3);
255
+ await registry.shutdown();
256
+ assert.equal(registry.size(), 0, "registry must be empty after shutdown");
257
+ assert.equal(disconnectLog.length, 3, "all sessions must be disconnected");
258
+ });
259
+ test("SessionRegistry: concurrent sessions process independently", async () => {
260
+ const completionOrder = [];
261
+ let unblockA;
262
+ const registry = new SessionRegistry({ idleTtlMs: 60_000, maxActive: 10 }, (sk) => {
263
+ const t = makeFakeSession();
264
+ const worker = async (_item, _mgr) => {
265
+ if (sk === "session-a") {
266
+ await new Promise((r) => { unblockA = r; });
267
+ }
268
+ completionOrder.push(sk);
269
+ return `${sk} done`;
270
+ };
271
+ return new SessionManager(sk, worker, factory(t.session));
272
+ });
273
+ const mA = registry.getOrCreate("session-a");
274
+ const mB = registry.getOrCreate("session-b");
275
+ const { item: iA, promise: pA } = makeDeferred();
276
+ const { item: iB, promise: pB } = makeDeferred();
277
+ mA.enqueue(iA);
278
+ mB.enqueue(iB);
279
+ // Session B should complete before session A (A is parked)
280
+ const resultB = await Promise.race([
281
+ pB,
282
+ new Promise((_, reject) => setTimeout(() => reject(new Error("session B was blocked by session A")), 200)),
283
+ ]);
284
+ assert.equal(resultB, "session-b done", "session B must complete independently");
285
+ assert.equal(completionOrder[0], "session-b", "session B must finish first");
286
+ assert.ok(mA.isProcessing, "session A should still be processing");
287
+ unblockA();
288
+ const resultA = await pA;
289
+ assert.equal(resultA, "session-a done");
290
+ assert.deepEqual(completionOrder, ["session-b", "session-a"]);
291
+ });
292
+ //# sourceMappingURL=session-manager.test.js.map
@@ -1,5 +1,6 @@
1
1
  import { getExampleProjectPath } from "../home-path.js";
2
2
  export function getOrchestratorSystemMessage(opts) {
3
+ const versionBanner = opts?.version ? `\nYou are running inside chapterhouse v${opts.version}.\n` : "";
3
4
  const memoryBlock = opts?.memorySummary
4
5
  ? `\n## Memory\nYou have a persistent memory store. Here's what you currently remember:\n\n${opts.memorySummary}\n`
5
6
  : "\n## Memory\nYou have a persistent memory store. It's currently empty — use `remember` to start building it!\n";
@@ -25,7 +26,7 @@ This restriction does NOT apply to:
25
26
  : "";
26
27
  const osName = process.platform === "darwin" ? "macOS" : process.platform === "win32" ? "Windows" : "Linux";
27
28
  return `You are Chapterhouse, a team-level AI assistant for engineering teams running 24/7 on the user's machine (${osName}). You are the engineering team's always-on assistant.
28
-
29
+ ${versionBanner}
29
30
  ${userContextBlock}
30
31
  ## Your Architecture
31
32
 
@@ -14,4 +14,12 @@ test("orchestrator prompt expands shorthand paths with the current home director
14
14
  assert.match(message, new RegExp(join(homedir(), "dev", "myapp").replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
15
15
  assert.doesNotMatch(message, /"~\/dev\/myapp"/);
16
16
  });
17
+ test("orchestrator prompt includes chapterhouse version banner when version is provided", () => {
18
+ const message = getOrchestratorSystemMessage({ version: "1.2.3" });
19
+ assert.match(message, /chapterhouse v1\.2\.3/);
20
+ });
21
+ test("orchestrator prompt omits version banner when version is not provided", () => {
22
+ const message = getOrchestratorSystemMessage();
23
+ assert.doesNotMatch(message, /chapterhouse v\d/);
24
+ });
17
25
  //# sourceMappingURL=system-message.test.js.map