chapterhouse 0.3.9 → 0.3.11
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/api/server-runtime.js +3 -0
- package/dist/api/server.js +179 -1
- package/dist/api/server.test.js +6 -0
- package/dist/api/turn-sse.integration.test.js +352 -0
- package/dist/config.js +3 -0
- package/dist/copilot/orchestrator.js +226 -9
- package/dist/copilot/orchestrator.test.js +42 -0
- package/dist/copilot/ring-buffer.js +34 -0
- package/dist/copilot/ring-buffer.test.js +82 -0
- package/dist/copilot/session-manager.js +14 -1
- package/dist/copilot/task-event-log.js +3 -26
- package/dist/copilot/turn-event-log.js +298 -0
- package/dist/copilot/turn-event-log.test.js +326 -0
- package/dist/copilot/turn-events.js +11 -0
- package/dist/store/db.js +15 -0
- package/package.json +1 -1
- package/web/dist/assets/index-D92WYeM5.js +219 -0
- package/web/dist/assets/index-D92WYeM5.js.map +1 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/index-vL9s_H8H.js +0 -213
- package/web/dist/assets/index-vL9s_H8H.js.map +0 -1
|
@@ -20,6 +20,7 @@ import { getSquadCoordinatorSystemMessage } from "../squad/charter.js";
|
|
|
20
20
|
import { childLogger } from "../util/logger.js";
|
|
21
21
|
import { squadEventBus } from "./squad-event-bus.js";
|
|
22
22
|
import { initTaskEventLog } from "./task-event-log.js";
|
|
23
|
+
import { emitTurnEvent, persistTurnEvents, scheduleClearTurnLog, } from "./turn-event-log.js";
|
|
23
24
|
import { SessionManager, SessionRegistry, SESSION_IDLE_TTL_MS, SESSION_MAX_ACTIVE, } from "./session-manager.js";
|
|
24
25
|
const log = childLogger("orchestrator");
|
|
25
26
|
const MAX_RETRIES = 3;
|
|
@@ -391,15 +392,15 @@ async function executeOnSession(manager, item) {
|
|
|
391
392
|
const unsubToolDone = session.on("tool.execution_complete", (event) => {
|
|
392
393
|
toolCallExecuted = true;
|
|
393
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;
|
|
394
403
|
if (item.onActivity) {
|
|
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;
|
|
403
404
|
item.onActivity({
|
|
404
405
|
kind: "tool_complete",
|
|
405
406
|
toolCallId: data.toolCallId,
|
|
@@ -408,6 +409,16 @@ async function executeOnSession(manager, item) {
|
|
|
408
409
|
detailedContent,
|
|
409
410
|
}, item.turnId);
|
|
410
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 });
|
|
411
422
|
});
|
|
412
423
|
const unsubToolStart = item.onActivity
|
|
413
424
|
? session.on("tool.execution_start", (event) => {
|
|
@@ -475,6 +486,29 @@ async function executeOnSession(manager, item) {
|
|
|
475
486
|
})
|
|
476
487
|
: () => { };
|
|
477
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
|
+
});
|
|
478
512
|
const db = getDb();
|
|
479
513
|
// Set of task IDs for subagents spawned in THIS turn — used to filter nested tool events.
|
|
480
514
|
const activeSubagentTaskIds = new Set();
|
|
@@ -497,6 +531,17 @@ async function executeOnSession(manager, item) {
|
|
|
497
531
|
payload: { agentName: agentSlug, priority: "normal" },
|
|
498
532
|
timestamp: new Date(),
|
|
499
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 });
|
|
500
545
|
}
|
|
501
546
|
catch { /* non-fatal */ }
|
|
502
547
|
});
|
|
@@ -514,6 +559,17 @@ async function executeOnSession(manager, item) {
|
|
|
514
559
|
payload: { agentName: taskRow?.agent_slug ?? "", reason: "complete" },
|
|
515
560
|
timestamp: new Date(),
|
|
516
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 });
|
|
517
573
|
}
|
|
518
574
|
catch { /* non-fatal */ }
|
|
519
575
|
});
|
|
@@ -531,6 +587,16 @@ async function executeOnSession(manager, item) {
|
|
|
531
587
|
payload: { agentName: taskRow?.agent_slug ?? "", error: data.error ?? "Subagent failed" },
|
|
532
588
|
timestamp: new Date(),
|
|
533
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 });
|
|
534
600
|
}
|
|
535
601
|
catch { /* non-fatal */ }
|
|
536
602
|
});
|
|
@@ -584,8 +650,16 @@ async function executeOnSession(manager, item) {
|
|
|
584
650
|
accumulated += "\n";
|
|
585
651
|
}
|
|
586
652
|
toolCallExecuted = false;
|
|
587
|
-
|
|
653
|
+
const delta = event.data.deltaContent;
|
|
654
|
+
accumulated += delta;
|
|
588
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
|
+
});
|
|
589
663
|
});
|
|
590
664
|
try {
|
|
591
665
|
const result = await session.sendAndWait({ prompt: item.prompt, ...(item.attachments && item.attachments.length > 0 ? { attachments: item.attachments } : {}) }, ORCHESTRATOR_TIMEOUT_MS);
|
|
@@ -628,6 +702,8 @@ async function executeOnSession(manager, item) {
|
|
|
628
702
|
unsubSubFailDb();
|
|
629
703
|
unsubNestedToolStart();
|
|
630
704
|
unsubNestedToolDone();
|
|
705
|
+
unsubTurnToolStart();
|
|
706
|
+
unsubTurnReasoning();
|
|
631
707
|
}
|
|
632
708
|
});
|
|
633
709
|
}
|
|
@@ -776,6 +852,147 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
|
|
|
776
852
|
}
|
|
777
853
|
})();
|
|
778
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
|
+
}
|
|
779
996
|
/** Cancel all queued and in-flight messages across all active sessions. */
|
|
780
997
|
export async function cancelCurrentMessage() {
|
|
781
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) {
|
|
@@ -22,35 +22,12 @@
|
|
|
22
22
|
* @module copilot/task-event-log
|
|
23
23
|
*/
|
|
24
24
|
import { squadEventBus } from "./squad-event-bus.js";
|
|
25
|
+
import { RingBuffer } from "./ring-buffer.js";
|
|
25
26
|
// ---------------------------------------------------------------------------
|
|
26
|
-
// Ring buffer
|
|
27
|
+
// Ring buffer — re-export so existing imports stay compatible
|
|
27
28
|
// ---------------------------------------------------------------------------
|
|
29
|
+
export { RingBuffer };
|
|
28
30
|
export const RING_BUFFER_CAPACITY = 500;
|
|
29
|
-
export class RingBuffer {
|
|
30
|
-
_capacity;
|
|
31
|
-
_items;
|
|
32
|
-
constructor(capacity) {
|
|
33
|
-
if (capacity < 1)
|
|
34
|
-
throw new RangeError("RingBuffer capacity must be >= 1");
|
|
35
|
-
this._capacity = capacity;
|
|
36
|
-
this._items = [];
|
|
37
|
-
}
|
|
38
|
-
push(item) {
|
|
39
|
-
if (this._items.length >= this._capacity) {
|
|
40
|
-
this._items.shift(); // evict oldest
|
|
41
|
-
}
|
|
42
|
-
this._items.push(item);
|
|
43
|
-
}
|
|
44
|
-
getAll() {
|
|
45
|
-
return this._items.slice();
|
|
46
|
-
}
|
|
47
|
-
get size() {
|
|
48
|
-
return this._items.length;
|
|
49
|
-
}
|
|
50
|
-
get capacity() {
|
|
51
|
-
return this._capacity;
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
31
|
// ---------------------------------------------------------------------------
|
|
55
32
|
// Singleton state
|
|
56
33
|
// ---------------------------------------------------------------------------
|