chapterhouse 0.3.8 → 0.3.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/config.js CHANGED
@@ -47,6 +47,7 @@ const configSchema = z.object({
47
47
  API_RATE_LIMIT_SSE_MAX_CONNECTIONS: z.string().optional(),
48
48
  ENABLE_SQUAD: z.string().optional(),
49
49
  CHAPTERHOUSE_WORKIQ_AUTO_INSTALL: z.string().optional(),
50
+ CHAPTERHOUSE_CHAT_SSE: z.string().optional(),
50
51
  });
51
52
  export const DEFAULT_MODEL = "claude-sonnet-4.6";
52
53
  export const DEFAULT_TEAM_WIKI_CACHE_TTL_MINUTES = 60;
@@ -221,6 +222,7 @@ export function parseRuntimeConfig(env, options = {}) {
221
222
  apiRateLimitSseMaxConnections,
222
223
  squadEnabled: raw.ENABLE_SQUAD === "1",
223
224
  workiqAutoInstall: parseBooleanEnv("CHAPTERHOUSE_WORKIQ_AUTO_INSTALL", raw.CHAPTERHOUSE_WORKIQ_AUTO_INSTALL, true),
225
+ chatSseEnabled: raw.CHAPTERHOUSE_CHAT_SSE === "1",
224
226
  };
225
227
  }
226
228
  const runtimeConfig = parseRuntimeConfig(process.env);
@@ -261,6 +263,7 @@ export const config = {
261
263
  apiRateLimitSseMaxConnections: runtimeConfig.apiRateLimitSseMaxConnections,
262
264
  squadEnabled: runtimeConfig.squadEnabled,
263
265
  workiqAutoInstall: runtimeConfig.workiqAutoInstall,
266
+ chatSseEnabled: runtimeConfig.chatSseEnabled,
264
267
  copilotAuthToken: runtimeConfig.copilotAuthToken,
265
268
  get copilotModel() {
266
269
  return _copilotModel;
@@ -19,6 +19,8 @@ import { normalizeProjectPath, setChannelProject } from "../squad/context.js";
19
19
  import { getSquadCoordinatorSystemMessage } from "../squad/charter.js";
20
20
  import { childLogger } from "../util/logger.js";
21
21
  import { squadEventBus } from "./squad-event-bus.js";
22
+ import { initTaskEventLog } from "./task-event-log.js";
23
+ import { emitTurnEvent, persistTurnEvents, scheduleClearTurnLog, } from "./turn-event-log.js";
22
24
  import { SessionManager, SessionRegistry, SESSION_IDLE_TTL_MS, SESSION_MAX_ACTIVE, } from "./session-manager.js";
23
25
  const log = childLogger("orchestrator");
24
26
  const MAX_RETRIES = 3;
@@ -299,6 +301,8 @@ export async function initOrchestrator(client) {
299
301
  copilotClient = client;
300
302
  // Initialize governance hook pipeline before any session is created.
301
303
  initHookPipeline();
304
+ // Initialize per-task ring buffer — subscribes to squadEventBus for session:tool_call events.
305
+ initTaskEventLog();
302
306
  // (Re-)create the registry — supports multiple initOrchestrator calls in tests
303
307
  if (registry) {
304
308
  await registry.shutdown();
@@ -388,15 +392,15 @@ async function executeOnSession(manager, item) {
388
392
  const unsubToolDone = session.on("tool.execution_complete", (event) => {
389
393
  toolCallExecuted = true;
390
394
  toolCallCount++;
395
+ const data = event.data;
396
+ const result = data.result;
397
+ const resultPreview = typeof result?.content === "string" ? result.content.slice(0, 400) : undefined;
398
+ const detailedContent = typeof result?.detailedContent === "string"
399
+ ? result.detailedContent
400
+ : typeof result?.content === "string"
401
+ ? result.content
402
+ : undefined;
391
403
  if (item.onActivity) {
392
- const data = event.data;
393
- const result = data.result;
394
- const resultPreview = typeof result?.content === "string" ? result.content.slice(0, 400) : undefined;
395
- const detailedContent = typeof result?.detailedContent === "string"
396
- ? result.detailedContent
397
- : typeof result?.content === "string"
398
- ? result.content
399
- : undefined;
400
404
  item.onActivity({
401
405
  kind: "tool_complete",
402
406
  toolCallId: data.toolCallId,
@@ -405,6 +409,16 @@ async function executeOnSession(manager, item) {
405
409
  detailedContent,
406
410
  }, item.turnId);
407
411
  }
412
+ // Emit turn:delta with tool-call part (coexistence — #130)
413
+ const toolPart = {
414
+ type: "tool-call",
415
+ toolCallId: String(data.toolCallId ?? ""),
416
+ toolName: String(data.toolName ?? "unknown"),
417
+ status: data.success !== false ? "done" : "failed",
418
+ resultPreview,
419
+ detailedContent,
420
+ };
421
+ emitTurnEvent(sessionKey, { type: "turn:delta", turnId: item.turnId, sessionKey, part: toolPart });
408
422
  });
409
423
  const unsubToolStart = item.onActivity
410
424
  ? session.on("tool.execution_start", (event) => {
@@ -472,6 +486,29 @@ async function executeOnSession(manager, item) {
472
486
  })
473
487
  : () => { };
474
488
  // Always persist SDK subagent dispatches to agent_tasks so Workers tab shows them.
489
+ // Also emit turn events unconditionally alongside existing callback path (#130).
490
+ const unsubTurnToolStart = session.on("tool.execution_start", (event) => {
491
+ const data = event.data;
492
+ // Skip nested subagent tool calls (handled via subagent.started/completed)
493
+ const part = {
494
+ type: "tool-call",
495
+ toolCallId: String(data.toolCallId ?? ""),
496
+ toolName: String(data.toolName ?? "unknown"),
497
+ mcpServerName: typeof data.mcpServerName === "string" ? data.mcpServerName : undefined,
498
+ arguments: data.arguments,
499
+ status: "running",
500
+ };
501
+ emitTurnEvent(sessionKey, { type: "turn:delta", turnId: item.turnId, sessionKey, part });
502
+ });
503
+ const unsubTurnReasoning = session.on("assistant.reasoning_delta", (event) => {
504
+ const part = {
505
+ type: "thinking",
506
+ reasoningId: event.data.reasoningId,
507
+ text: event.data.deltaContent,
508
+ active: true,
509
+ };
510
+ emitTurnEvent(sessionKey, { type: "turn:delta", turnId: item.turnId, sessionKey, part });
511
+ });
475
512
  const db = getDb();
476
513
  // Set of task IDs for subagents spawned in THIS turn — used to filter nested tool events.
477
514
  const activeSubagentTaskIds = new Set();
@@ -494,6 +531,17 @@ async function executeOnSession(manager, item) {
494
531
  payload: { agentName: agentSlug, priority: "normal" },
495
532
  timestamp: new Date(),
496
533
  });
534
+ // Emit turn:delta with subagent part (coexistence — #130)
535
+ const subPart = {
536
+ type: "subagent",
537
+ toolCallId: String(data.toolCallId ?? ""),
538
+ agentName: data.agentName ?? agentSlug,
539
+ agentDisplayName: data.agentDisplayName ?? agentSlug,
540
+ agentDescription: description,
541
+ agentSlug,
542
+ status: "running",
543
+ };
544
+ emitTurnEvent(sessionKey, { type: "turn:delta", turnId: item.turnId, sessionKey, part: subPart });
497
545
  }
498
546
  catch { /* non-fatal */ }
499
547
  });
@@ -511,6 +559,17 @@ async function executeOnSession(manager, item) {
511
559
  payload: { agentName: taskRow?.agent_slug ?? "", reason: "complete" },
512
560
  timestamp: new Date(),
513
561
  });
562
+ // Emit turn:delta with subagent completed part (coexistence — #130)
563
+ const doneData = event.data;
564
+ const donePart = {
565
+ type: "subagent",
566
+ toolCallId: String(doneData.toolCallId ?? ""),
567
+ agentName: doneData.agentName ?? taskRow?.agent_slug ?? "agent",
568
+ agentDisplayName: doneData.agentDisplayName ?? taskRow?.agent_slug ?? "agent",
569
+ status: "done",
570
+ durationMs: typeof doneData.durationMs === "number" ? doneData.durationMs : undefined,
571
+ };
572
+ emitTurnEvent(sessionKey, { type: "turn:delta", turnId: item.turnId, sessionKey, part: donePart });
514
573
  }
515
574
  catch { /* non-fatal */ }
516
575
  });
@@ -528,6 +587,16 @@ async function executeOnSession(manager, item) {
528
587
  payload: { agentName: taskRow?.agent_slug ?? "", error: data.error ?? "Subagent failed" },
529
588
  timestamp: new Date(),
530
589
  });
590
+ // Emit turn:delta with subagent failed part (coexistence — #130)
591
+ const failPart = {
592
+ type: "subagent",
593
+ toolCallId: String(data.toolCallId ?? ""),
594
+ agentName: data.agentName ?? taskRow?.agent_slug ?? "agent",
595
+ agentDisplayName: data.agentDisplayName ?? taskRow?.agent_slug ?? "agent",
596
+ status: "failed",
597
+ error: data.error,
598
+ };
599
+ emitTurnEvent(sessionKey, { type: "turn:delta", turnId: item.turnId, sessionKey, part: failPart });
531
600
  }
532
601
  catch { /* non-fatal */ }
533
602
  });
@@ -581,8 +650,16 @@ async function executeOnSession(manager, item) {
581
650
  accumulated += "\n";
582
651
  }
583
652
  toolCallExecuted = false;
584
- accumulated += event.data.deltaContent;
653
+ const delta = event.data.deltaContent;
654
+ accumulated += delta;
585
655
  item.callback(accumulated, false, item.turnId);
656
+ // Emit alongside existing callback path (coexistence — #130)
657
+ emitTurnEvent(sessionKey, {
658
+ type: "turn:delta",
659
+ turnId: item.turnId,
660
+ sessionKey,
661
+ part: { type: "text", text: delta },
662
+ });
586
663
  });
587
664
  try {
588
665
  const result = await session.sendAndWait({ prompt: item.prompt, ...(item.attachments && item.attachments.length > 0 ? { attachments: item.attachments } : {}) }, ORCHESTRATOR_TIMEOUT_MS);
@@ -625,6 +702,8 @@ async function executeOnSession(manager, item) {
625
702
  unsubSubFailDb();
626
703
  unsubNestedToolStart();
627
704
  unsubNestedToolDone();
705
+ unsubTurnToolStart();
706
+ unsubTurnReasoning();
628
707
  }
629
708
  });
630
709
  }
@@ -773,6 +852,147 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
773
852
  }
774
853
  })();
775
854
  }
855
+ /**
856
+ * Abort the active turn on `sessionKey` and immediately start a new turn with `newPrompt`.
857
+ * Uses the zero-gap technique: enqueue the replacement BEFORE awaiting abort so the
858
+ * drain loop picks it up on its next iteration without any processing gap.
859
+ *
860
+ * If no session is currently active for `sessionKey`, falls back to a normal
861
+ * sendToOrchestrator call.
862
+ *
863
+ * @param onInterrupted Called with the aborted turn's ID immediately before the
864
+ * replacement turn starts. Use to emit a `turn-interrupted` SSE event so the
865
+ * frontend can drop the partial in-flight bubble.
866
+ */
867
+ export async function interruptCurrentTurn(sessionKey, newPrompt, source, callback, attachments, onActivity, onInterrupted) {
868
+ const manager = registry?.get(sessionKey);
869
+ // If no session exists or it isn't processing, fall back to a normal send.
870
+ if (!manager || !manager.isProcessing) {
871
+ return sendToOrchestrator(newPrompt, source, callback, attachments, onActivity);
872
+ }
873
+ updateUserContext(source);
874
+ updateRequestContext(source);
875
+ const turnId = randomUUID();
876
+ const sourceLabel = source.type === "web" ? "web" : "background";
877
+ const sourceChannel = source.type === "web" ? "web" : undefined;
878
+ const authUser = source.type === "web" ? source.user : undefined;
879
+ const authHeader = source.type === "web" ? source.authorizationHeader?.trim() || undefined : undefined;
880
+ const taggedPrompt = source.type === "background"
881
+ ? newPrompt
882
+ : `[via ${sourceLabel}] ${newPrompt}`;
883
+ // Clear any messages already waiting in the queue behind the in-flight turn —
884
+ // their context is stale once we interrupt.
885
+ manager.cancelQueued();
886
+ // Enqueue the replacement BEFORE awaiting abort. When sendAndWait resolves on
887
+ // abort, the drain loop immediately finds the replacement in the queue — zero gap.
888
+ void (async () => {
889
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
890
+ try {
891
+ const finalContent = await new Promise((resolve, reject) => {
892
+ manager.enqueue({
893
+ prompt: taggedPrompt,
894
+ attachments,
895
+ callback,
896
+ onActivity: onActivity,
897
+ turnId,
898
+ isInterrupt: true,
899
+ onInterrupted,
900
+ sourceChannel,
901
+ sessionKey,
902
+ authUser,
903
+ authHeader,
904
+ resolve,
905
+ reject,
906
+ });
907
+ });
908
+ callback(finalContent, true, turnId);
909
+ try {
910
+ logMessage("out", sourceLabel, finalContent);
911
+ }
912
+ catch { /* best-effort */ }
913
+ try {
914
+ logConversation("user", newPrompt, sourceLabel, sessionKey);
915
+ }
916
+ catch { /* best-effort */ }
917
+ try {
918
+ logConversation("assistant", finalContent, sourceLabel, sessionKey);
919
+ }
920
+ catch { /* best-effort */ }
921
+ if (copilotClient) {
922
+ maybeWriteEpisode(copilotClient).catch((err) => {
923
+ log.error({ err: err instanceof Error ? err.message : err }, "Episode write failed (non-fatal)");
924
+ });
925
+ }
926
+ return;
927
+ }
928
+ catch (err) {
929
+ const msg = err instanceof Error ? err.message : String(err);
930
+ if (/cancelled|abort/i.test(msg))
931
+ return;
932
+ if (isRecoverableError(err) && attempt < MAX_RETRIES) {
933
+ const delay = RECONNECT_DELAYS_MS[Math.min(attempt, RECONNECT_DELAYS_MS.length - 1)];
934
+ log.warn({ msg, attempt: attempt + 1, maxRetries: MAX_RETRIES, delayMs: delay }, "Recoverable error on interrupt turn, retrying");
935
+ await sleep(delay);
936
+ try {
937
+ await ensureClient();
938
+ }
939
+ catch { /* will fail again on next attempt */ }
940
+ continue;
941
+ }
942
+ log.error({ msg }, "Error processing interrupt turn");
943
+ callback(`Error: ${msg}`, true, turnId);
944
+ return;
945
+ }
946
+ }
947
+ })();
948
+ // Abort the in-flight turn AFTER enqueueing the replacement — SDK sends the
949
+ // abort RPC; server emits session.idle; sendAndWait resolves; drain loop picks
950
+ // up the replacement immediately.
951
+ await manager.abortCurrentTurn();
952
+ log.info({ sessionKey, replacementTurnId: turnId }, "turn.interrupted");
953
+ }
954
+ /**
955
+ * Enqueue a turn for the new POST→SSE chat path (#130).
956
+ *
957
+ * Unlike `sendToOrchestrator`, this function:
958
+ * - Returns the `turnId` immediately without waiting for the turn to complete.
959
+ * - Emits turn:started, turn:complete, and turn:error events to the turn event log.
960
+ * - Does NOT write to sseClients — the SSE channel delivers events via subscribeSession().
961
+ * - Supports interrupt: true which calls interruptCurrentTurn under the hood.
962
+ *
963
+ * @returns turnId (UUID)
964
+ */
965
+ export function enqueueForSse(opts) {
966
+ const { sessionKey, prompt, attachments, authUser, authHeader, interrupt } = opts;
967
+ const turnId = randomUUID();
968
+ const source = { type: "background", sessionKey };
969
+ const taggedPrompt = `[via sse] ${prompt}`;
970
+ // Emit turn:started immediately so the SSE client sees it before any delta
971
+ emitTurnEvent(sessionKey, { type: "turn:started", turnId, sessionKey, prompt, attachments });
972
+ const callback = (text, done, tid) => {
973
+ if (done) {
974
+ emitTurnEvent(sessionKey, { type: "turn:complete", turnId: tid, sessionKey, finalMessage: text });
975
+ persistTurnEvents(tid, sessionKey);
976
+ scheduleClearTurnLog(tid);
977
+ }
978
+ // Note: mid-turn text deltas are emitted by executeOnSession's delta handler
979
+ };
980
+ const onQueued = (position, tid) => {
981
+ emitTurnEvent(sessionKey, { type: "turn:queued", turnId: tid, sessionKey, position });
982
+ };
983
+ const onInterrupted = (abortedTurnId) => {
984
+ emitTurnEvent(sessionKey, { type: "turn:interrupted", turnId: abortedTurnId, sessionKey });
985
+ persistTurnEvents(abortedTurnId, sessionKey);
986
+ scheduleClearTurnLog(abortedTurnId);
987
+ };
988
+ if (interrupt) {
989
+ void interruptCurrentTurn(sessionKey, taggedPrompt, source, callback, attachments, undefined, onInterrupted);
990
+ }
991
+ else {
992
+ void sendToOrchestrator(taggedPrompt, source, callback, attachments, undefined, onQueued);
993
+ }
994
+ return turnId;
995
+ }
776
996
  /** Cancel all queued and in-flight messages across all active sessions. */
777
997
  export async function cancelCurrentMessage() {
778
998
  if (!registry)
@@ -870,4 +870,46 @@ test("#86: subagent.completed removes task from activeSubagentTaskIds — subseq
870
870
  assert.equal(events.length, 0, "No task events must be recorded after subagent completes");
871
871
  state.pendingReject?.(new Error("test teardown"));
872
872
  });
873
+ // ---------------------------------------------------------------------------
874
+ // #98 — interruptCurrentTurn
875
+ // ---------------------------------------------------------------------------
876
+ test("#98: interruptCurrentTurn aborts active turn and starts replacement turn", async (t) => {
877
+ const { orchestrator, state, client } = await loadOrchestratorModule(t, {
878
+ sendResult: "__PENDING__",
879
+ });
880
+ await orchestrator.initOrchestrator(client);
881
+ const firstResults = [];
882
+ const secondResults = [];
883
+ let interruptedTurnId;
884
+ // Start a long-running first turn
885
+ orchestrator.sendToOrchestrator("first long request", { type: "background" }, (text, done) => { if (done)
886
+ firstResults.push(text); });
887
+ // Let the first turn get in-flight
888
+ await new Promise((resolve) => setTimeout(resolve, 10));
889
+ assert.ok(state.abortCalls === 0, "no abort yet");
890
+ // Interrupt with a replacement turn
891
+ await orchestrator.interruptCurrentTurn("default", "replacement request", { type: "background" }, (text, done) => { if (done)
892
+ secondResults.push(text); }, undefined, undefined, (abortedId) => { interruptedTurnId = abortedId; });
893
+ assert.equal(state.abortCalls, 1, "abort must be called exactly once");
894
+ // Resolve the pending session so the replacement turn can complete
895
+ state.pendingReject?.(new Error("aborted"));
896
+ await new Promise((resolve) => setTimeout(resolve, 50));
897
+ });
898
+ test("#98: interruptCurrentTurn falls back to normal send when no session is active", async (t) => {
899
+ const { orchestrator, state, client } = await loadOrchestratorModule(t, {
900
+ sendResult: "fallback response",
901
+ });
902
+ await orchestrator.initOrchestrator(client);
903
+ const results = [];
904
+ // No active turn — should fall back to sendToOrchestrator
905
+ await new Promise((resolve) => {
906
+ orchestrator.interruptCurrentTurn("default", "normal prompt", { type: "background" }, (text, done) => { if (done) {
907
+ results.push(text);
908
+ resolve();
909
+ } });
910
+ });
911
+ assert.equal(results.length, 1, "fallback turn must complete");
912
+ assert.equal(results[0], "fallback response");
913
+ assert.equal(state.abortCalls, 0, "no abort when session is idle");
914
+ });
873
915
  //# sourceMappingURL=orchestrator.test.js.map
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Shared generic ring buffer — extracted from task-event-log.ts (#116) for #130.
3
+ *
4
+ * Fixed-capacity FIFO: when capacity is reached, the oldest item is evicted
5
+ * before the new one is pushed.
6
+ *
7
+ * @module copilot/ring-buffer
8
+ */
9
+ export class RingBuffer {
10
+ _capacity;
11
+ _items;
12
+ constructor(capacity) {
13
+ if (capacity < 1)
14
+ throw new RangeError("RingBuffer capacity must be >= 1");
15
+ this._capacity = capacity;
16
+ this._items = [];
17
+ }
18
+ push(item) {
19
+ if (this._items.length >= this._capacity) {
20
+ this._items.shift(); // evict oldest
21
+ }
22
+ this._items.push(item);
23
+ }
24
+ getAll() {
25
+ return this._items.slice();
26
+ }
27
+ get size() {
28
+ return this._items.length;
29
+ }
30
+ get capacity() {
31
+ return this._capacity;
32
+ }
33
+ }
34
+ //# sourceMappingURL=ring-buffer.js.map
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Unit tests for src/copilot/ring-buffer.ts — #130
3
+ *
4
+ * Covers:
5
+ * 1. Basic push and getAll
6
+ * 2. Capacity enforcement (evicts oldest)
7
+ * 3. getAll returns a copy (not a reference)
8
+ * 4. size and capacity getters
9
+ * 5. RangeError on capacity < 1
10
+ * 6. Single-item buffer
11
+ */
12
+ import { describe, it } from "node:test";
13
+ import assert from "node:assert/strict";
14
+ import { RingBuffer } from "./ring-buffer.js";
15
+ describe("RingBuffer", () => {
16
+ it("stores and retrieves items in insertion order", () => {
17
+ const buf = new RingBuffer(5);
18
+ buf.push(1);
19
+ buf.push(2);
20
+ buf.push(3);
21
+ assert.deepEqual(buf.getAll(), [1, 2, 3]);
22
+ });
23
+ it("evicts the oldest item when at capacity", () => {
24
+ const buf = new RingBuffer(3);
25
+ buf.push(1);
26
+ buf.push(2);
27
+ buf.push(3);
28
+ buf.push(4); // evicts 1
29
+ assert.deepEqual(buf.getAll(), [2, 3, 4]);
30
+ });
31
+ it("evicts multiple items correctly", () => {
32
+ const buf = new RingBuffer(3);
33
+ for (let i = 1; i <= 6; i++)
34
+ buf.push(i);
35
+ assert.deepEqual(buf.getAll(), [4, 5, 6]);
36
+ });
37
+ it("getAll returns a copy — mutations do not affect the buffer", () => {
38
+ const buf = new RingBuffer(5);
39
+ buf.push(10);
40
+ buf.push(20);
41
+ const snapshot = buf.getAll();
42
+ snapshot.push(999);
43
+ assert.deepEqual(buf.getAll(), [10, 20], "internal array was mutated unexpectedly");
44
+ });
45
+ it("size reflects the number of stored items", () => {
46
+ const buf = new RingBuffer(10);
47
+ assert.equal(buf.size, 0);
48
+ buf.push("a");
49
+ assert.equal(buf.size, 1);
50
+ buf.push("b");
51
+ assert.equal(buf.size, 2);
52
+ });
53
+ it("size does not exceed capacity after overfill", () => {
54
+ const buf = new RingBuffer(3);
55
+ for (let i = 0; i < 100; i++)
56
+ buf.push(i);
57
+ assert.equal(buf.size, 3);
58
+ });
59
+ it("capacity getter returns the constructor value", () => {
60
+ const buf = new RingBuffer(42);
61
+ assert.equal(buf.capacity, 42);
62
+ });
63
+ it("throws RangeError for capacity < 1", () => {
64
+ assert.throws(() => new RingBuffer(0), RangeError);
65
+ assert.throws(() => new RingBuffer(-5), RangeError);
66
+ });
67
+ it("works for a single-item capacity buffer", () => {
68
+ const buf = new RingBuffer(1);
69
+ buf.push("first");
70
+ buf.push("second"); // evicts first
71
+ assert.deepEqual(buf.getAll(), ["second"]);
72
+ assert.equal(buf.size, 1);
73
+ });
74
+ it("stores objects by reference (shallow copy behaviour)", () => {
75
+ const obj = { x: 1 };
76
+ const buf = new RingBuffer(5);
77
+ buf.push(obj);
78
+ obj.x = 42;
79
+ assert.equal(buf.getAll()[0].x, 42, "RingBuffer should not deep-clone items");
80
+ });
81
+ });
82
+ //# sourceMappingURL=ring-buffer.test.js.map
@@ -53,6 +53,8 @@ export class SessionManager {
53
53
  _currentModel;
54
54
  _recentTiers = [];
55
55
  _lastActivityAt = Date.now();
56
+ /** turnId of the turn currently being processed by the worker. Set before worker entry, cleared after. */
57
+ _currentTurnId;
56
58
  /** Set by registry.close() when the session is busy at close time. The drain loop
57
59
  * honors this after the queue fully empties — evicting without violating the
58
60
  * never-evict-mid-turn invariant. */
@@ -77,6 +79,10 @@ export class SessionManager {
77
79
  get lastActivityAt() {
78
80
  return this._lastActivityAt;
79
81
  }
82
+ /** turnId of the turn currently in-flight (being processed by the worker). Undefined when idle. */
83
+ get currentTurnId() {
84
+ return this._currentTurnId;
85
+ }
80
86
  /** True when an explicit close was requested while the session was busy. */
81
87
  get pendingClose() {
82
88
  return this._pendingClose;
@@ -146,11 +152,17 @@ export class SessionManager {
146
152
  this._processing = true;
147
153
  while (this._queue.length > 0) {
148
154
  const item = this._queue.shift();
155
+ // If this item is an interrupt, notify before starting — lets the frontend drop
156
+ // the partial in-flight bubble before the replacement turn begins.
157
+ if (item.isInterrupt && this._currentTurnId) {
158
+ item.onInterrupted?.(this._currentTurnId);
159
+ }
149
160
  // Notify before the worker starts — closing the window where backend queue
150
161
  // length has dropped but the frontend still shows stale "N ahead" counts.
151
162
  item.onAdvance?.(this._queue.length);
163
+ this._currentTurnId = item.turnId;
152
164
  const start = Date.now();
153
- log.info({ sessionKey: this.sessionKey, sourceChannel: item.sourceChannel }, "session.turn.started");
165
+ log.info({ sessionKey: this.sessionKey, sourceChannel: item.sourceChannel, isInterrupt: item.isInterrupt ?? false }, "session.turn.started");
154
166
  try {
155
167
  const result = await this.worker(item, this);
156
168
  const durationMs = Date.now() - start;
@@ -164,6 +176,7 @@ export class SessionManager {
164
176
  }
165
177
  this._lastActivityAt = Date.now();
166
178
  }
179
+ this._currentTurnId = undefined;
167
180
  this._processing = false;
168
181
  // Honor deferred explicit-close: evict now that the queue is empty.
169
182
  if (this._pendingClose && this._queue.length === 0) {