chapterhouse 0.11.1 → 0.11.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.
@@ -268,10 +268,6 @@ export function createAgentsRouter(options) {
268
268
  res.setHeader("Connection", "keep-alive");
269
269
  res.setHeader("X-Accel-Buffering", "no");
270
270
  res.flushHeaders();
271
- const rawLastId = req.headers["last-event-id"];
272
- const lastSeq = rawLastId && !Array.isArray(rawLastId) && /^\d+$/.test(rawLastId.trim())
273
- ? parseInt(rawLastId.trim(), 10)
274
- : undefined;
275
271
  const sendEvent = (event) => {
276
272
  let payload;
277
273
  if (event.kind === "output_delta") {
@@ -303,41 +299,48 @@ export function createAgentsRouter(options) {
303
299
  }
304
300
  res.write(`id: ${event.seq}\ndata: ${JSON.stringify(payload)}\n\n`);
305
301
  };
306
- let replayHighSeq = lastSeq;
307
- if (lastSeq !== undefined) {
308
- const bufferedEvents = getTaskLogEvents(taskId);
309
- const oldestBufferedSeq = bufferedEvents[0]?.seq;
310
- const bufferMissesRange = oldestBufferedSeq === undefined || oldestBufferedSeq > lastSeq + 1;
311
- if (bufferMissesRange) {
312
- const dbEvents = getTaskEvents(taskId, lastSeq);
313
- for (const event of dbEvents) {
314
- sendEvent(event);
315
- if (replayHighSeq === undefined || event.seq > replayHighSeq) {
316
- replayHighSeq = event.seq;
317
- }
318
- }
319
- }
320
- }
321
- const replayEvents = getTaskLogEvents(taskId, replayHighSeq ?? 0);
322
- const backlog = replayEvents.length > 0 ? replayEvents : getTaskEvents(taskId, replayHighSeq ?? 0);
323
- for (const event of backlog) {
324
- sendEvent(event);
325
- }
326
302
  const isTerminal = () => {
327
303
  const row = getDb()
328
304
  .prepare(`SELECT status FROM agent_tasks WHERE task_id = ?`)
329
305
  .get(taskId);
330
306
  return row ? TERMINAL_TASK_STATUSES.has(row.status) : true;
331
307
  };
332
- res.write(`: connected task=${taskId}\n\n`);
333
- if (isTerminal()) {
334
- res.end();
335
- return;
336
- }
337
- const heartbeat = setInterval(() => {
338
- res.write(`: keep-alive\n\n`);
339
- }, 15_000);
340
308
  setupSseCleanup((registerCleanup, cleanupNow) => {
309
+ req.on("close", cleanupNow);
310
+ res.on("close", cleanupNow);
311
+ res.on("error", cleanupNow);
312
+ const rawLastId = req.headers["last-event-id"];
313
+ const lastSeq = rawLastId && !Array.isArray(rawLastId) && /^\d+$/.test(rawLastId.trim())
314
+ ? parseInt(rawLastId.trim(), 10)
315
+ : undefined;
316
+ let replayHighSeq = lastSeq;
317
+ if (lastSeq !== undefined) {
318
+ const bufferedEvents = getTaskLogEvents(taskId);
319
+ const oldestBufferedSeq = bufferedEvents[0]?.seq;
320
+ const bufferMissesRange = oldestBufferedSeq === undefined || oldestBufferedSeq > lastSeq + 1;
321
+ if (bufferMissesRange) {
322
+ const dbEvents = getTaskEvents(taskId, lastSeq);
323
+ for (const event of dbEvents) {
324
+ sendEvent(event);
325
+ if (replayHighSeq === undefined || event.seq > replayHighSeq) {
326
+ replayHighSeq = event.seq;
327
+ }
328
+ }
329
+ }
330
+ }
331
+ const replayEvents = getTaskLogEvents(taskId, replayHighSeq ?? 0);
332
+ const backlog = replayEvents.length > 0 ? replayEvents : getTaskEvents(taskId, replayHighSeq ?? 0);
333
+ for (const event of backlog) {
334
+ sendEvent(event);
335
+ }
336
+ res.write(`: connected task=${taskId}\n\n`);
337
+ if (isTerminal()) {
338
+ res.end();
339
+ return;
340
+ }
341
+ const heartbeat = setInterval(() => {
342
+ res.write(`: keep-alive\n\n`);
343
+ }, 15_000);
341
344
  registerCleanup(() => clearInterval(heartbeat));
342
345
  registerCleanup(subscribeTaskLog(taskId, (event) => {
343
346
  sendEvent(event);
@@ -359,7 +362,6 @@ export function createAgentsRouter(options) {
359
362
  res.end();
360
363
  }
361
364
  }));
362
- req.on("close", cleanupNow);
363
365
  });
364
366
  });
365
367
  // ---------------------------------------------------------------------------
@@ -370,19 +372,21 @@ export function createAgentsRouter(options) {
370
372
  // Chat-specific events (delta, message, queued) are NOT emitted here.
371
373
  // ---------------------------------------------------------------------------
372
374
  router.get("/api/agents/stream", (req, res) => {
373
- res.writeHead(200, {
374
- "Content-Type": "text/event-stream",
375
- "Cache-Control": "no-cache",
376
- Connection: "keep-alive",
377
- });
378
- res.write(formatSseData({ type: "connected" }));
379
- const heartbeat = setInterval(() => { res.write(`:ping\n\n`); }, 20_000);
380
375
  setupSseCleanup((registerCleanup, cleanupNow) => {
376
+ req.on("close", cleanupNow);
377
+ res.on("close", cleanupNow);
378
+ res.on("error", cleanupNow);
379
+ res.writeHead(200, {
380
+ "Content-Type": "text/event-stream",
381
+ "Cache-Control": "no-cache",
382
+ Connection: "keep-alive",
383
+ });
384
+ res.write(formatSseData({ type: "connected" }));
385
+ const heartbeat = setInterval(() => { res.write(`:ping\n\n`); }, 20_000);
381
386
  registerCleanup(() => clearInterval(heartbeat));
382
387
  registerCleanup(agentEventBus.subscribeAll((event) => {
383
388
  res.write(formatSseData({ type: "agent_event", agentEvent: event }));
384
389
  }));
385
- req.on("close", cleanupNow);
386
390
  });
387
391
  });
388
392
  router.post("/api/agents/:slug/reload-confirm", async (req, res) => {
@@ -10,7 +10,7 @@ import { BadRequestError, parseRequest } from "../errors.js";
10
10
  import { sendJson } from "../send-json.js";
11
11
  import { formatSseData } from "../sse.js";
12
12
  import { setupSseCleanup } from "../server-runtime.js";
13
- import { associateSseClient, broadcastSsePayload, nextSseConnectionId, pendingSseMessages, sseClients, } from "../sse-hub.js";
13
+ import { associateSseClient, broadcastSsePayload, drainPendingSseMessages, nextSseConnectionId, sseClients, } from "../sse-hub.js";
14
14
  const requiredString = (message) => z.string({ error: message }).trim().min(1, message);
15
15
  const messageRequestSchema = z.object({
16
16
  prompt: requiredString("Missing 'prompt' in request body"),
@@ -46,37 +46,35 @@ export function createSessionsRouter() {
46
46
  const router = Router();
47
47
  router.get("/stream", (req, res) => {
48
48
  const connectionId = nextSseConnectionId();
49
- res.writeHead(200, {
50
- "Content-Type": "text/event-stream",
51
- "Cache-Control": "no-cache",
52
- Connection: "keep-alive",
53
- });
54
- res.write(formatSseData({ type: "connected", connectionId }));
55
- while (pendingSseMessages.length > 0) {
56
- const queued = pendingSseMessages.shift();
57
- if (!queued) {
58
- continue;
59
- }
60
- res.write(formatSseData({ type: "message", content: queued }));
61
- }
62
- sseClients.set(connectionId, { res, sessionKey: null });
63
- const unsubscribeStatus = onStatusChange((status, message) => {
64
- res.write(formatSseData({ type: "status", status, message }));
65
- });
66
- const currentStatus = getStatus();
67
- if (currentStatus.status !== "idle") {
68
- res.write(formatSseData({ type: "status", ...currentStatus }));
69
- }
70
- const heartbeat = setInterval(() => {
71
- res.write(`:ping\n\n`);
72
- }, 20_000);
73
49
  setupSseCleanup((registerCleanup, cleanupNow) => {
74
- registerCleanup(() => clearInterval(heartbeat));
75
- registerCleanup(unsubscribeStatus);
50
+ req.on("close", cleanupNow);
51
+ res.on("close", cleanupNow);
52
+ res.on("error", cleanupNow);
53
+ res.writeHead(200, {
54
+ "Content-Type": "text/event-stream",
55
+ "Cache-Control": "no-cache",
56
+ Connection: "keep-alive",
57
+ });
58
+ sseClients.set(connectionId, { res, sessionKey: null });
76
59
  registerCleanup(() => {
77
60
  sseClients.delete(connectionId);
78
61
  });
79
- req.on("close", cleanupNow);
62
+ const unsubscribeStatus = onStatusChange((status, message) => {
63
+ res.write(formatSseData({ type: "status", status, message }));
64
+ });
65
+ registerCleanup(unsubscribeStatus);
66
+ const heartbeat = setInterval(() => {
67
+ res.write(`:ping\n\n`);
68
+ }, 20_000);
69
+ registerCleanup(() => clearInterval(heartbeat));
70
+ res.write(formatSseData({ type: "connected", connectionId }));
71
+ for (const queued of drainPendingSseMessages()) {
72
+ res.write(formatSseData({ type: "message", content: queued.content }));
73
+ }
74
+ const currentStatus = getStatus();
75
+ if (currentStatus.status !== "idle") {
76
+ res.write(formatSseData({ type: "status", ...currentStatus }));
77
+ }
80
78
  });
81
79
  });
82
80
  // ---------------------------------------------------------------------------
@@ -266,40 +264,43 @@ export function createSessionsRouter() {
266
264
  res.setHeader("Connection", "keep-alive");
267
265
  res.setHeader("X-Accel-Buffering", "no");
268
266
  res.flushHeaders();
269
- // Parse Last-Event-ID for reconnect replay
270
- const rawLastId = req.headers["last-event-id"];
271
- const lastSeq = rawLastId && !Array.isArray(rawLastId) && /^\d+$/.test(rawLastId.trim())
272
- ? parseInt(rawLastId.trim(), 10)
273
- : undefined;
274
- const maxCurrentSeq = getSessionMaxSeqFromDb(sessionKey, { includeHistorical });
275
- const effectiveLastSeq = maxCurrentSeq !== undefined && lastSeq !== undefined && lastSeq > maxCurrentSeq
276
- ? 0
277
- : lastSeq;
278
267
  // Helper: send a named SSE event with an id: field
279
268
  const sendEvent = (event, seq) => {
280
269
  const payload = JSON.stringify(event);
281
270
  res.write(`id: ${seq}\ndata: ${payload}\n\n`);
282
271
  };
283
- // If Last-Event-ID is present and the session ring buffer doesn't cover it,
284
- // fall back to SQLite for replay of completed turns.
285
- let replayHighSeq = effectiveLastSeq;
286
- if (effectiveLastSeq !== undefined) {
287
- const oldestBuf = oldestSessionSeq(sessionKey);
288
- const bufferMissesRange = oldestBuf === undefined || oldestBuf > effectiveLastSeq + 1;
289
- if (bufferMissesRange) {
290
- // Replay from SQLite (completed turns)
291
- const dbEvents = getSessionEventsFromDb(sessionKey, effectiveLastSeq, { includeHistorical });
292
- for (const e of dbEvents) {
293
- sendEvent(e, e._seq);
294
- if (replayHighSeq === undefined || e._seq > replayHighSeq)
295
- replayHighSeq = e._seq;
272
+ setupSseCleanup((registerCleanup, cleanupNow) => {
273
+ req.on("close", cleanupNow);
274
+ res.on("close", cleanupNow);
275
+ res.on("error", cleanupNow);
276
+ // Parse Last-Event-ID for reconnect replay
277
+ const rawLastId = req.headers["last-event-id"];
278
+ const lastSeq = rawLastId && !Array.isArray(rawLastId) && /^\d+$/.test(rawLastId.trim())
279
+ ? parseInt(rawLastId.trim(), 10)
280
+ : undefined;
281
+ const maxCurrentSeq = getSessionMaxSeqFromDb(sessionKey, { includeHistorical });
282
+ const effectiveLastSeq = maxCurrentSeq !== undefined && lastSeq !== undefined && lastSeq > maxCurrentSeq
283
+ ? 0
284
+ : lastSeq;
285
+ // If Last-Event-ID is present and the session ring buffer doesn't cover it,
286
+ // fall back to SQLite for replay of completed turns.
287
+ let replayHighSeq = effectiveLastSeq;
288
+ if (effectiveLastSeq !== undefined) {
289
+ const oldestBuf = oldestSessionSeq(sessionKey);
290
+ const bufferMissesRange = oldestBuf === undefined || oldestBuf > effectiveLastSeq + 1;
291
+ if (bufferMissesRange) {
292
+ // Replay from SQLite (completed turns)
293
+ const dbEvents = getSessionEventsFromDb(sessionKey, effectiveLastSeq, { includeHistorical });
294
+ for (const e of dbEvents) {
295
+ sendEvent(e, e._seq);
296
+ if (replayHighSeq === undefined || e._seq > replayHighSeq)
297
+ replayHighSeq = e._seq;
298
+ }
296
299
  }
297
300
  }
298
- }
299
- // Subscribe to session events (replays ring buffer for afterSeq, then live).
300
- // Use replayHighSeq (not lastSeq) so ring-buffer replay starts after any DB
301
- // events we already sent — avoids double-replay overlap (Fix 5).
302
- setupSseCleanup((registerCleanup, cleanupNow) => {
301
+ // Subscribe to session events (replays ring buffer for afterSeq, then live).
302
+ // Use replayHighSeq (not lastSeq) so ring-buffer replay starts after any DB
303
+ // events we already sent avoids double-replay overlap (Fix 5).
303
304
  registerCleanup(subscribeSession(sessionKey, (e) => {
304
305
  sendEvent(e, e._seq);
305
306
  }, replayHighSeq));
@@ -310,7 +311,6 @@ export function createSessionsRouter() {
310
311
  res.write(`: keep-alive\n\n`);
311
312
  }, 15_000);
312
313
  registerCleanup(() => clearInterval(keepAlive));
313
- req.on("close", cleanupNow);
314
314
  });
315
315
  });
316
316
  }
@@ -1,14 +1,51 @@
1
1
  import { formatSseData } from "./sse.js";
2
+ import { childLogger } from "../util/logger.js";
3
+ export const MAX_PENDING_SSE_MESSAGES = 256;
4
+ export const PENDING_SSE_MESSAGE_MAX_AGE_MS = 10 * 60 * 1_000;
2
5
  export const sseClients = new Map();
3
6
  export const pendingSseMessages = [];
7
+ const log = childLogger("sse-hub");
4
8
  let connectionCounter = 0;
5
9
  export function nextSseConnectionId() {
6
10
  connectionCounter += 1;
7
11
  return `web-${connectionCounter}`;
8
12
  }
13
+ function removeClient(connectionId, err) {
14
+ log.warn({ err, connectionId }, "SSE client write failed; removing client");
15
+ sseClients.delete(connectionId);
16
+ }
17
+ function writeClient(connectionId, client, payload) {
18
+ try {
19
+ client.res.write(payload);
20
+ return true;
21
+ }
22
+ catch (err) {
23
+ removeClient(connectionId, err);
24
+ return false;
25
+ }
26
+ }
27
+ function prunePendingSseMessages(now = Date.now()) {
28
+ while (pendingSseMessages.length > 0 &&
29
+ now - (pendingSseMessages[0]?.queuedAt ?? now) > PENDING_SSE_MESSAGE_MAX_AGE_MS) {
30
+ pendingSseMessages.shift();
31
+ }
32
+ }
33
+ function enqueuePendingSseMessage(content) {
34
+ const now = Date.now();
35
+ prunePendingSseMessages(now);
36
+ while (pendingSseMessages.length >= MAX_PENDING_SSE_MESSAGES) {
37
+ pendingSseMessages.shift();
38
+ }
39
+ pendingSseMessages.push({ content, queuedAt: now });
40
+ }
41
+ export function drainPendingSseMessages() {
42
+ prunePendingSseMessages();
43
+ return pendingSseMessages.splice(0, pendingSseMessages.length);
44
+ }
9
45
  export function broadcastSsePayload(payload) {
10
- for (const [, client] of sseClients) {
11
- client.res.write(formatSseData(payload));
46
+ const frame = formatSseData(payload);
47
+ for (const [connectionId, client] of sseClients) {
48
+ writeClient(connectionId, client, frame);
12
49
  }
13
50
  }
14
51
  export function associateSseClient(connectionId, sessionKey) {
@@ -18,20 +55,22 @@ export function associateSseClient(connectionId, sessionKey) {
18
55
  }
19
56
  }
20
57
  export function broadcastSsePayloadToSession(sessionKey, payload) {
21
- for (const [, client] of sseClients) {
58
+ const frame = formatSseData(payload);
59
+ for (const [connectionId, client] of sseClients) {
22
60
  if (client.sessionKey === sessionKey) {
23
- client.res.write(formatSseData(payload));
61
+ writeClient(connectionId, client, frame);
24
62
  }
25
63
  }
26
64
  }
27
65
  /** Broadcast a proactive message to all connected SSE clients (for background task completions). */
28
66
  export function broadcastToSSE(text) {
29
67
  if (sseClients.size === 0) {
30
- pendingSseMessages.push(text);
68
+ enqueuePendingSseMessage(text);
31
69
  return;
32
70
  }
33
- for (const [, client] of sseClients) {
34
- client.res.write(formatSseData({ type: "message", content: text }));
71
+ const frame = formatSseData({ type: "message", content: text });
72
+ for (const [connectionId, client] of sseClients) {
73
+ writeClient(connectionId, client, frame);
35
74
  }
36
75
  }
37
76
  //# sourceMappingURL=sse-hub.js.map
@@ -0,0 +1,75 @@
1
+ import assert from "node:assert/strict";
2
+ import { afterEach, test } from "node:test";
3
+ import { broadcastSsePayload, broadcastSsePayloadToSession, broadcastToSSE, pendingSseMessages, sseClients, } from "./sse-hub.js";
4
+ function fakeResponse(write) {
5
+ return { write };
6
+ }
7
+ afterEach(() => {
8
+ sseClients.clear();
9
+ pendingSseMessages.length = 0;
10
+ Date.now = originalDateNow;
11
+ });
12
+ const originalDateNow = Date.now;
13
+ test("broadcastSsePayload continues fan-out when a client write throws and removes bad clients", () => {
14
+ const delivered = [];
15
+ sseClients.set("bad", {
16
+ sessionKey: null,
17
+ res: fakeResponse(() => {
18
+ throw new Error("socket closed");
19
+ }),
20
+ });
21
+ sseClients.set("good", {
22
+ sessionKey: null,
23
+ res: fakeResponse((chunk) => {
24
+ delivered.push(chunk);
25
+ return true;
26
+ }),
27
+ });
28
+ assert.doesNotThrow(() => broadcastSsePayload({ type: "cancelled" }));
29
+ assert.equal(delivered.length, 1);
30
+ assert.equal(sseClients.has("bad"), false);
31
+ assert.equal(sseClients.has("good"), true);
32
+ });
33
+ test("broadcastSsePayloadToSession removes a throwing matching client and still delivers to other matching clients", () => {
34
+ const delivered = [];
35
+ sseClients.set("bad", {
36
+ sessionKey: "agent:one",
37
+ res: fakeResponse(() => {
38
+ throw new Error("socket closed");
39
+ }),
40
+ });
41
+ sseClients.set("other-session", {
42
+ sessionKey: "agent:two",
43
+ res: fakeResponse(() => {
44
+ throw new Error("wrong session should not be written");
45
+ }),
46
+ });
47
+ sseClients.set("good", {
48
+ sessionKey: "agent:one",
49
+ res: fakeResponse((chunk) => {
50
+ delivered.push(chunk);
51
+ return true;
52
+ }),
53
+ });
54
+ assert.doesNotThrow(() => broadcastSsePayloadToSession("agent:one", { type: "agent_reloaded" }));
55
+ assert.equal(delivered.length, 1);
56
+ assert.equal(sseClients.has("bad"), false);
57
+ assert.equal(sseClients.has("good"), true);
58
+ assert.equal(sseClients.has("other-session"), true);
59
+ });
60
+ test("pending SSE messages are capped at 256 entries", () => {
61
+ for (let i = 0; i < 300; i++) {
62
+ broadcastToSSE(`message ${i}`);
63
+ }
64
+ assert.equal(pendingSseMessages.length, 256);
65
+ assert.equal(pendingSseMessages[0]?.content, "message 44");
66
+ assert.equal(pendingSseMessages[255]?.content, "message 299");
67
+ });
68
+ test("pending SSE messages older than 10 minutes are dropped when enqueueing", () => {
69
+ Date.now = () => 1_000;
70
+ broadcastToSSE("old");
71
+ Date.now = () => 1_000 + 10 * 60 * 1_000 + 1;
72
+ broadcastToSSE("fresh");
73
+ assert.deepEqual(pendingSseMessages.map((message) => message.content), ["fresh"]);
74
+ });
75
+ //# sourceMappingURL=sse-hub.test.js.map
package/dist/status.js CHANGED
@@ -1,19 +1,27 @@
1
+ import { childLogger } from "./util/logger.js";
1
2
  let currentStatus = "idle";
2
3
  let statusMessage = "";
3
4
  const listeners = new Set();
5
+ const log = childLogger("status");
6
+ function notifyListeners() {
7
+ for (const listener of listeners) {
8
+ try {
9
+ listener(currentStatus, statusMessage);
10
+ }
11
+ catch (err) {
12
+ log.warn({ err }, "status listener threw");
13
+ }
14
+ }
15
+ }
4
16
  export function setBusy(reason, message) {
5
17
  currentStatus = reason;
6
18
  statusMessage = message;
7
- for (const listener of listeners) {
8
- listener(currentStatus, statusMessage);
9
- }
19
+ notifyListeners();
10
20
  }
11
21
  export function setIdle() {
12
22
  currentStatus = "idle";
13
23
  statusMessage = "";
14
- for (const listener of listeners) {
15
- listener(currentStatus, statusMessage);
16
- }
24
+ notifyListeners();
17
25
  }
18
26
  export function getStatus() {
19
27
  return { status: currentStatus, message: statusMessage };
@@ -19,4 +19,22 @@ test("tracks dreaming status and notifies listeners", () => {
19
19
  { status: "idle", message: "" },
20
20
  ]);
21
21
  });
22
+ test("continues notifying status listeners when one listener throws", () => {
23
+ const seen = [];
24
+ const unsubscribeThrowing = onStatusChange(() => {
25
+ throw new Error("write failed");
26
+ });
27
+ const unsubscribeHealthy = onStatusChange((status, message) => {
28
+ seen.push({ status, message });
29
+ });
30
+ try {
31
+ assert.doesNotThrow(() => setBusy("dreaming", "Still notifying"));
32
+ assert.deepEqual(seen, [{ status: "dreaming", message: "Still notifying" }]);
33
+ }
34
+ finally {
35
+ unsubscribeHealthy();
36
+ unsubscribeThrowing();
37
+ setIdle();
38
+ }
39
+ });
22
40
  //# sourceMappingURL=status.test.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chapterhouse",
3
- "version": "0.11.1",
3
+ "version": "0.11.3",
4
4
  "description": "Chapterhouse — a team-level AI assistant for engineering teams, built on the GitHub Copilot SDK. Web UI only.",
5
5
  "bin": {
6
6
  "chapterhouse": "dist/cli.js"