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/api/server-runtime.js +3 -0
- package/dist/api/server.js +191 -6
- 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 +229 -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 +139 -0
- package/dist/copilot/task-event-log.test.js +275 -0
- 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-BtAcw3EP.css +10 -0
- package/web/dist/assets/index-DwNyeKgU.js +219 -0
- package/web/dist/assets/index-DwNyeKgU.js.map +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-0dDxvEWK.js +0 -211
- package/web/dist/assets/index-0dDxvEWK.js.map +0 -1
- package/web/dist/assets/index-26ooi9MH.css +0 -10
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
|
-
|
|
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) {
|