chapterhouse 0.3.1 → 0.3.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.
- package/README.md +73 -1
- package/dist/api/auth.js +4 -3
- package/dist/api/auth.test.js +27 -39
- package/dist/api/server.js +43 -2
- package/dist/api/team.js +4 -2
- package/dist/cli.js +8 -2
- package/dist/config.js +3 -0
- package/dist/copilot/episode-writer.js +4 -2
- package/dist/copilot/orchestrator.js +410 -356
- package/dist/copilot/orchestrator.test.js +244 -0
- package/dist/copilot/session-manager.js +337 -0
- package/dist/copilot/session-manager.test.js +358 -0
- package/dist/copilot/system-message.js +2 -1
- package/dist/copilot/system-message.test.js +8 -0
- package/dist/copilot/workiq-installer.js +91 -0
- package/dist/copilot/workiq-installer.test.js +148 -0
- package/dist/daemon.js +12 -1
- package/dist/integrations/teams-notify.js +3 -1
- package/dist/squad/index.js +1 -0
- package/dist/squad/init-cli.js +109 -0
- package/dist/squad/init.js +395 -0
- package/dist/squad/init.test.js +351 -0
- package/dist/squad/mirror.js +4 -2
- package/dist/squad/mirror.scheduler.js +9 -7
- package/dist/store/db.js +58 -5
- package/dist/store/db.test.js +69 -0
- package/dist/version.js +7 -0
- package/dist/wiki/team-sync.js +3 -1
- package/package.json +4 -3
- package/web/dist/assets/index-BkB7gY18.css +10 -0
- package/web/dist/assets/index-DSqc46G_.js +208 -0
- package/web/dist/assets/index-DSqc46G_.js.map +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-CxT9905O.css +0 -10
- package/web/dist/assets/index-DI3rnGm-.js +0 -142
- package/web/dist/assets/index-DI3rnGm-.js.map +0 -1
|
@@ -1,11 +1,13 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
1
2
|
import { approveAll } from "@github/copilot-sdk";
|
|
2
3
|
import { createTools } from "./tools.js";
|
|
3
4
|
import { getOrchestratorSystemMessage } from "./system-message.js";
|
|
5
|
+
import { CHAPTERHOUSE_VERSION } from "../version.js";
|
|
4
6
|
import { config, DEFAULT_MODEL } from "../config.js";
|
|
5
7
|
import { loadMcpConfig } from "./mcp-config.js";
|
|
6
8
|
import { getSkillDirectories } from "./skills.js";
|
|
7
9
|
import { resetClient } from "./client.js";
|
|
8
|
-
import { logConversation, getState, setState, deleteState, getCopilotSession, upsertCopilotSession, getTaskSessionKey, getDb, bumpProjectLastUsed } from "../store/db.js";
|
|
10
|
+
import { logConversation, getState, setState, deleteState, getCopilotSession, upsertCopilotSession, getTaskSessionKey, getDb, bumpProjectLastUsed, appendTaskEvent } from "../store/db.js";
|
|
9
11
|
import { maybeWriteEpisode } from "./episode-writer.js";
|
|
10
12
|
import { getWikiSummary } from "../wiki/context.js";
|
|
11
13
|
import { SESSIONS_DIR } from "../paths.js";
|
|
@@ -14,11 +16,8 @@ import { loadAgents, ensureDefaultAgents, clearActiveTasks, getAgentRegistry, se
|
|
|
14
16
|
import { normalizeProjectPath, setChannelProject } from "../squad/context.js";
|
|
15
17
|
import { getSquadCoordinatorSystemMessage } from "../squad/charter.js";
|
|
16
18
|
import { childLogger } from "../util/logger.js";
|
|
19
|
+
import { SessionManager, SessionRegistry, SESSION_IDLE_TTL_MS, SESSION_MAX_ACTIVE, } from "./session-manager.js";
|
|
17
20
|
const log = childLogger("orchestrator");
|
|
18
|
-
/**
|
|
19
|
-
* Permission handler for the orchestrator session.
|
|
20
|
-
* Approves all tool requests so @chapterhouse has full access to all tools.
|
|
21
|
-
*/
|
|
22
21
|
const orchestratorPermissionHandler = approveAll;
|
|
23
22
|
const MAX_RETRIES = 3;
|
|
24
23
|
const RECONNECT_DELAYS_MS = [1_000, 3_000, 10_000];
|
|
@@ -33,61 +32,84 @@ let proactiveNotifyFn;
|
|
|
33
32
|
export function setProactiveNotify(fn) {
|
|
34
33
|
proactiveNotifyFn = fn;
|
|
35
34
|
}
|
|
35
|
+
const turnContextStorage = new AsyncLocalStorage();
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Module-level state (not per-session)
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
36
39
|
let copilotClient;
|
|
37
40
|
let healthCheckTimer;
|
|
38
41
|
let currentUserContext;
|
|
42
|
+
/**
|
|
43
|
+
* Last-seen auth context — persists after a turn completes so that callers
|
|
44
|
+
* which inspect it outside of an active turn (e.g. /api/cancel) still see the
|
|
45
|
+
* most recent values. Tools that run DURING a turn should use the per-turn
|
|
46
|
+
* AsyncLocalStorage context for safety in concurrent-session scenarios.
|
|
47
|
+
*/
|
|
39
48
|
let currentAuthenticatedUser;
|
|
40
49
|
let currentAuthorizationHeader;
|
|
41
|
-
// Router state
|
|
42
|
-
let recentTiers = [];
|
|
43
50
|
let lastRouteResult;
|
|
44
51
|
export function getLastRouteResult() {
|
|
45
52
|
return lastRouteResult;
|
|
46
53
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
54
|
+
const taskEventListeners = new Map();
|
|
55
|
+
export function subscribeTaskEvents(taskId, listener) {
|
|
56
|
+
if (!taskEventListeners.has(taskId)) {
|
|
57
|
+
taskEventListeners.set(taskId, new Set());
|
|
58
|
+
}
|
|
59
|
+
taskEventListeners.get(taskId).add(listener);
|
|
60
|
+
return () => {
|
|
61
|
+
const set = taskEventListeners.get(taskId);
|
|
62
|
+
if (set) {
|
|
63
|
+
set.delete(listener);
|
|
64
|
+
if (set.size === 0)
|
|
65
|
+
taskEventListeners.delete(taskId);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function emitTaskEvent(taskId, event) {
|
|
70
|
+
const set = taskEventListeners.get(taskId);
|
|
71
|
+
if (set) {
|
|
72
|
+
for (const listener of set) {
|
|
73
|
+
try {
|
|
74
|
+
listener(event);
|
|
75
|
+
}
|
|
76
|
+
catch { /* non-fatal */ }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// SessionRegistry — the single owner of all per-session orchestrators
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
let registry;
|
|
84
|
+
function buildRegistry() {
|
|
85
|
+
return new SessionRegistry({ idleTtlMs: SESSION_IDLE_TTL_MS, maxActive: SESSION_MAX_ACTIVE }, (sessionKey) => new SessionManager(sessionKey, processItem, createOrResumeSession));
|
|
86
|
+
}
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Context getters — exported for tools.ts and server.ts
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
53
90
|
export function getCurrentSessionKey() {
|
|
54
|
-
return
|
|
55
|
-
}
|
|
56
|
-
const messageQueue = [];
|
|
57
|
-
let processing = false;
|
|
58
|
-
let currentCallback;
|
|
59
|
-
/** The channel currently being processed — tools use this to tag new workers. */
|
|
60
|
-
let currentSourceChannel;
|
|
61
|
-
/** Get the channel that originated the message currently being processed. */
|
|
91
|
+
return turnContextStorage.getStore()?.sessionKey ?? "default";
|
|
92
|
+
}
|
|
62
93
|
export function getCurrentSourceChannel() {
|
|
63
|
-
return
|
|
94
|
+
return turnContextStorage.getStore()?.sourceChannel;
|
|
64
95
|
}
|
|
65
|
-
let currentChannelKey;
|
|
66
96
|
export function getCurrentChannelKey() {
|
|
67
|
-
return
|
|
97
|
+
return turnContextStorage.getStore()?.channelKey;
|
|
68
98
|
}
|
|
69
|
-
/**
|
|
70
|
-
* The activity callback for the message currently being processed. Tool handlers
|
|
71
|
-
* (notably `delegate_to_agent`) read this to forward child-session events back
|
|
72
|
-
* to the parent's SSE connection.
|
|
73
|
-
*/
|
|
74
|
-
let currentActivityCallback;
|
|
75
99
|
export function getCurrentActivityCallback() {
|
|
76
|
-
return
|
|
100
|
+
return turnContextStorage.getStore()?.activityCallback;
|
|
77
101
|
}
|
|
78
102
|
export function getCurrentAuthenticatedUser() {
|
|
79
|
-
return currentAuthenticatedUser;
|
|
103
|
+
return turnContextStorage.getStore()?.authUser ?? currentAuthenticatedUser;
|
|
80
104
|
}
|
|
81
105
|
export function getLastAuthenticatedUser() {
|
|
82
106
|
const raw = getState(LAST_AUTHENTICATED_USER_KEY);
|
|
83
|
-
if (!raw)
|
|
107
|
+
if (!raw)
|
|
84
108
|
return undefined;
|
|
85
|
-
}
|
|
86
109
|
try {
|
|
87
110
|
const parsed = JSON.parse(raw);
|
|
88
|
-
if (!parsed?.id || !parsed?.name || !parsed?.email || !parsed?.role)
|
|
111
|
+
if (!parsed?.id || !parsed?.name || !parsed?.email || !parsed?.role)
|
|
89
112
|
return undefined;
|
|
90
|
-
}
|
|
91
113
|
return parsed;
|
|
92
114
|
}
|
|
93
115
|
catch {
|
|
@@ -95,8 +117,11 @@ export function getLastAuthenticatedUser() {
|
|
|
95
117
|
}
|
|
96
118
|
}
|
|
97
119
|
export function getCurrentAuthorizationHeader() {
|
|
98
|
-
return currentAuthorizationHeader;
|
|
120
|
+
return turnContextStorage.getStore()?.authHeader ?? currentAuthorizationHeader;
|
|
99
121
|
}
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// Internal helpers
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
100
125
|
function getSessionConfig() {
|
|
101
126
|
const tools = createTools({
|
|
102
127
|
client: copilotClient,
|
|
@@ -118,17 +143,16 @@ function sameUserContext(a, b) {
|
|
|
118
143
|
return a?.name === b?.name && a?.role === b?.role;
|
|
119
144
|
}
|
|
120
145
|
function updateUserContext(source) {
|
|
121
|
-
if (source.type !== "web")
|
|
146
|
+
if (source.type !== "web")
|
|
122
147
|
return;
|
|
123
|
-
}
|
|
124
148
|
const nextContext = source.user
|
|
125
149
|
? { name: source.user.name, role: source.user.role }
|
|
126
150
|
: undefined;
|
|
127
|
-
if (sameUserContext(currentUserContext, nextContext))
|
|
151
|
+
if (sameUserContext(currentUserContext, nextContext))
|
|
128
152
|
return;
|
|
129
|
-
}
|
|
130
153
|
currentUserContext = nextContext;
|
|
131
|
-
|
|
154
|
+
// Invalidate the default session so it's recreated with the updated system message
|
|
155
|
+
registry?.get("default")?.invalidateSession();
|
|
132
156
|
}
|
|
133
157
|
function updateRequestContext(source) {
|
|
134
158
|
if (source.type !== "web") {
|
|
@@ -142,7 +166,6 @@ function updateRequestContext(source) {
|
|
|
142
166
|
setState(LAST_AUTHENTICATED_USER_KEY, JSON.stringify(source.user));
|
|
143
167
|
}
|
|
144
168
|
}
|
|
145
|
-
/** Feed an agent task result into the orchestrator as a new turn. */
|
|
146
169
|
export function feedAgentResult(taskId, agentSlug, result) {
|
|
147
170
|
const prompt = `[Agent task completed] @${agentSlug} finished task ${taskId}:\n\n${result}`;
|
|
148
171
|
const sessionKey = getTaskSessionKey(taskId);
|
|
@@ -155,7 +178,6 @@ export function feedAgentResult(taskId, agentSlug, result) {
|
|
|
155
178
|
function sleep(ms) {
|
|
156
179
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
157
180
|
}
|
|
158
|
-
/** Ensure the SDK client is connected, resetting if necessary. Coalesces concurrent resets. */
|
|
159
181
|
let resetPromise;
|
|
160
182
|
async function ensureClient() {
|
|
161
183
|
if (copilotClient && copilotClient.getState() === "connected") {
|
|
@@ -171,7 +193,6 @@ async function ensureClient() {
|
|
|
171
193
|
}
|
|
172
194
|
return resetPromise;
|
|
173
195
|
}
|
|
174
|
-
/** Start periodic health check that proactively reconnects the client. */
|
|
175
196
|
function startHealthCheck() {
|
|
176
197
|
if (healthCheckTimer)
|
|
177
198
|
return;
|
|
@@ -183,9 +204,10 @@ function startHealthCheck() {
|
|
|
183
204
|
if (state !== "connected") {
|
|
184
205
|
log.info({ state }, "Health check: client not connected, resetting");
|
|
185
206
|
await ensureClient();
|
|
186
|
-
//
|
|
187
|
-
|
|
188
|
-
|
|
207
|
+
// Invalidate all cached sessions — they're tied to the old connection
|
|
208
|
+
for (const [, mgr] of registry.getAll()) {
|
|
209
|
+
mgr.invalidateSession();
|
|
210
|
+
}
|
|
189
211
|
}
|
|
190
212
|
}
|
|
191
213
|
catch (err) {
|
|
@@ -193,27 +215,7 @@ function startHealthCheck() {
|
|
|
193
215
|
}
|
|
194
216
|
}, HEALTH_CHECK_INTERVAL_MS);
|
|
195
217
|
}
|
|
196
|
-
/**
|
|
197
|
-
async function ensureOrchestratorSession(sessionKey, projectRoot) {
|
|
198
|
-
const existing = sessionMap.get(sessionKey);
|
|
199
|
-
if (existing)
|
|
200
|
-
return existing;
|
|
201
|
-
// Coalesce concurrent callers for the same key
|
|
202
|
-
const inFlight = sessionCreatePromises.get(sessionKey);
|
|
203
|
-
if (inFlight)
|
|
204
|
-
return inFlight;
|
|
205
|
-
const promise = createOrResumeSession(sessionKey, projectRoot);
|
|
206
|
-
sessionCreatePromises.set(sessionKey, promise);
|
|
207
|
-
try {
|
|
208
|
-
const session = await promise;
|
|
209
|
-
sessionMap.set(sessionKey, session);
|
|
210
|
-
return session;
|
|
211
|
-
}
|
|
212
|
-
finally {
|
|
213
|
-
sessionCreatePromises.delete(sessionKey);
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
/** Internal: actually create or resume a session (not concurrency-safe — use ensureOrchestratorSession). */
|
|
218
|
+
/** Internal: create or resume a CopilotSession. Called by SessionManager.ensureSession(). */
|
|
217
219
|
async function createOrResumeSession(sessionKey, projectRoot) {
|
|
218
220
|
const client = await ensureClient();
|
|
219
221
|
const { tools, mcpServers, skillDirectories } = getSessionConfig();
|
|
@@ -223,7 +225,6 @@ async function createOrResumeSession(sessionKey, projectRoot) {
|
|
|
223
225
|
backgroundCompactionThreshold: 0.80,
|
|
224
226
|
bufferExhaustionThreshold: 0.95,
|
|
225
227
|
};
|
|
226
|
-
// Build the correct system message for this session mode
|
|
227
228
|
let systemMessageContent;
|
|
228
229
|
if (isProjectSession && projectRoot) {
|
|
229
230
|
systemMessageContent = await getSquadCoordinatorSystemMessage(projectRoot);
|
|
@@ -232,9 +233,9 @@ async function createOrResumeSession(sessionKey, projectRoot) {
|
|
|
232
233
|
const memorySummary = getWikiSummary();
|
|
233
234
|
systemMessageContent = getOrchestratorSystemMessage({
|
|
234
235
|
...getSystemMessageOptions(memorySummary, isProjectSession ? projectRoot : undefined),
|
|
236
|
+
version: CHAPTERHOUSE_VERSION,
|
|
235
237
|
});
|
|
236
238
|
}
|
|
237
|
-
// Try to resume from copilot_sessions; fall back to legacy max_state key for the default session
|
|
238
239
|
const stored = getCopilotSession(sessionKey);
|
|
239
240
|
const savedSessionId = stored?.copilotSessionId ?? (sessionKey === "default" ? getState(ORCHESTRATOR_SESSION_KEY) : undefined);
|
|
240
241
|
if (savedSessionId) {
|
|
@@ -253,7 +254,9 @@ async function createOrResumeSession(sessionKey, projectRoot) {
|
|
|
253
254
|
});
|
|
254
255
|
log.info({ sessionKey }, "Session resumed successfully");
|
|
255
256
|
upsertCopilotSession(sessionKey, isProjectSession ? "project" : "default", session.sessionId, projectRoot, config.copilotModel);
|
|
256
|
-
|
|
257
|
+
const mgr = registry?.get(sessionKey);
|
|
258
|
+
if (mgr)
|
|
259
|
+
mgr.currentModel = config.copilotModel;
|
|
257
260
|
return session;
|
|
258
261
|
}
|
|
259
262
|
catch (err) {
|
|
@@ -262,7 +265,6 @@ async function createOrResumeSession(sessionKey, projectRoot) {
|
|
|
262
265
|
deleteState(ORCHESTRATOR_SESSION_KEY);
|
|
263
266
|
}
|
|
264
267
|
}
|
|
265
|
-
// Create a fresh session
|
|
266
268
|
log.info({ sessionKey }, "Creating new session");
|
|
267
269
|
const session = await client.createSession({
|
|
268
270
|
model: config.copilotModel,
|
|
@@ -277,20 +279,25 @@ async function createOrResumeSession(sessionKey, projectRoot) {
|
|
|
277
279
|
});
|
|
278
280
|
log.info({ sessionKey, sessionId: session.sessionId.slice(0, 8) }, "Session created");
|
|
279
281
|
upsertCopilotSession(sessionKey, isProjectSession ? "project" : "default", session.sessionId, projectRoot, config.copilotModel);
|
|
280
|
-
// Backward compat: also persist the default session to the legacy state key
|
|
281
282
|
if (sessionKey === "default")
|
|
282
283
|
setState(ORCHESTRATOR_SESSION_KEY, session.sessionId);
|
|
283
|
-
|
|
284
|
+
const mgr = registry?.get(sessionKey);
|
|
285
|
+
if (mgr)
|
|
286
|
+
mgr.currentModel = config.copilotModel;
|
|
284
287
|
return session;
|
|
285
288
|
}
|
|
286
289
|
export async function initOrchestrator(client) {
|
|
287
290
|
copilotClient = client;
|
|
291
|
+
// (Re-)create the registry — supports multiple initOrchestrator calls in tests
|
|
292
|
+
if (registry) {
|
|
293
|
+
await registry.shutdown();
|
|
294
|
+
}
|
|
295
|
+
registry = buildRegistry();
|
|
296
|
+
registry.startEvictionTimer();
|
|
288
297
|
const { mcpServers, skillDirectories } = getSessionConfig();
|
|
289
|
-
// Initialize agent system
|
|
290
298
|
ensureDefaultAgents();
|
|
291
299
|
const agents = loadAgents();
|
|
292
300
|
log.info({ count: agents.length, agents: agents.map((a) => `@${a.slug}`) }, "Agents loaded");
|
|
293
|
-
// Validate configured model against available models
|
|
294
301
|
try {
|
|
295
302
|
const models = await client.listModels();
|
|
296
303
|
const configured = config.copilotModel;
|
|
@@ -307,16 +314,17 @@ export async function initOrchestrator(client) {
|
|
|
307
314
|
log.info({ skillDirectories }, "Skill directories");
|
|
308
315
|
log.info("Persistent session mode — conversation history maintained by SDK");
|
|
309
316
|
startHealthCheck();
|
|
310
|
-
// Eagerly create/resume the default orchestrator session
|
|
311
317
|
try {
|
|
312
|
-
|
|
318
|
+
const defaultManager = registry.getOrCreate("default");
|
|
319
|
+
await defaultManager.ensureSession();
|
|
313
320
|
}
|
|
314
321
|
catch (err) {
|
|
315
322
|
log.error({ err: err instanceof Error ? err.message : err }, "Failed to create initial session (will retry on first message)");
|
|
316
323
|
}
|
|
317
324
|
}
|
|
318
|
-
/** How long to wait for the orchestrator to finish a turn (30 min default).
|
|
325
|
+
/** How long to wait for the orchestrator to finish a single session turn (30 min default).
|
|
319
326
|
* Override with CHAPTERHOUSE_ORCHESTRATOR_TIMEOUT_MS env var (parsed as integer ms).
|
|
327
|
+
* Applies per-session-turn; concurrent sessions each have their own independent timeout.
|
|
320
328
|
* Part of the 3-layer timing contract — see systemd unit TimeoutStopSec comment. */
|
|
321
329
|
const DEFAULT_ORCHESTRATOR_TIMEOUT_MS = 1_800_000;
|
|
322
330
|
export const ORCHESTRATOR_TIMEOUT_MS = (() => {
|
|
@@ -328,245 +336,301 @@ export const ORCHESTRATOR_TIMEOUT_MS = (() => {
|
|
|
328
336
|
}
|
|
329
337
|
return DEFAULT_ORCHESTRATOR_TIMEOUT_MS;
|
|
330
338
|
})();
|
|
331
|
-
/**
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
}
|
|
361
|
-
});
|
|
362
|
-
const unsubToolStart = onActivity
|
|
363
|
-
? session.on("tool.execution_start", (event) => {
|
|
364
|
-
const data = event.data;
|
|
365
|
-
onActivity({
|
|
366
|
-
kind: "tool_start",
|
|
367
|
-
toolCallId: data.toolCallId,
|
|
368
|
-
toolName: data.toolName,
|
|
369
|
-
mcpServerName: data.mcpServerName,
|
|
370
|
-
arguments: data.arguments,
|
|
371
|
-
});
|
|
372
|
-
})
|
|
373
|
-
: () => { };
|
|
374
|
-
const unsubReasoning = onActivity
|
|
375
|
-
? session.on("assistant.reasoning_delta", (event) => {
|
|
376
|
-
onActivity({
|
|
377
|
-
kind: "thinking_delta",
|
|
378
|
-
reasoningId: event.data.reasoningId,
|
|
379
|
-
deltaContent: event.data.deltaContent,
|
|
380
|
-
});
|
|
381
|
-
})
|
|
382
|
-
: () => { };
|
|
383
|
-
const unsubSubStart = onActivity
|
|
384
|
-
? session.on("subagent.started", (event) => {
|
|
385
|
-
const data = event.data;
|
|
386
|
-
onActivity({
|
|
387
|
-
kind: "subagent_started",
|
|
388
|
-
toolCallId: data.toolCallId,
|
|
389
|
-
agentName: data.agentName,
|
|
390
|
-
agentDisplayName: data.agentDisplayName,
|
|
391
|
-
agentDescription: data.agentDescription,
|
|
392
|
-
});
|
|
393
|
-
})
|
|
394
|
-
: () => { };
|
|
395
|
-
const unsubSubDone = onActivity
|
|
396
|
-
? session.on("subagent.completed", (event) => {
|
|
397
|
-
const data = event.data;
|
|
398
|
-
onActivity({
|
|
399
|
-
kind: "subagent_completed",
|
|
400
|
-
toolCallId: data.toolCallId,
|
|
401
|
-
agentName: data.agentName,
|
|
402
|
-
agentDisplayName: data.agentDisplayName,
|
|
403
|
-
durationMs: data.durationMs,
|
|
404
|
-
});
|
|
405
|
-
})
|
|
406
|
-
: () => { };
|
|
407
|
-
const unsubSubFail = onActivity
|
|
408
|
-
? session.on("subagent.failed", (event) => {
|
|
409
|
-
const data = event.data;
|
|
410
|
-
onActivity({
|
|
411
|
-
kind: "subagent_failed",
|
|
412
|
-
toolCallId: data.toolCallId,
|
|
413
|
-
agentName: data.agentName,
|
|
414
|
-
agentDisplayName: data.agentDisplayName,
|
|
415
|
-
error: data.error,
|
|
416
|
-
});
|
|
417
|
-
})
|
|
418
|
-
: () => { };
|
|
419
|
-
// Always persist SDK subagent dispatches to agent_tasks so Workers tab shows them.
|
|
420
|
-
// These fire when the built-in `task` tool (Squad coordinator) routes work to a
|
|
421
|
-
// specialist — separate from `delegate_to_agent` which handles CH-registry agents.
|
|
422
|
-
const db = getDb();
|
|
423
|
-
const unsubSubStartDb = session.on("subagent.started", (event) => {
|
|
424
|
-
try {
|
|
339
|
+
/**
|
|
340
|
+
* Execute a single queued item on its session.
|
|
341
|
+
* Wraps the entire turn in AsyncLocalStorage so all tool handlers (e.g. delegate_to_agent,
|
|
342
|
+
* register_task) see the correct per-session context even when multiple sessions run
|
|
343
|
+
* concurrently. This is the core of the per-session isolation guarantee.
|
|
344
|
+
*/
|
|
345
|
+
async function executeOnSession(manager, item) {
|
|
346
|
+
const { sessionKey } = manager;
|
|
347
|
+
const session = await manager.ensureSession();
|
|
348
|
+
// Update last-seen globals (backwards compat — for callers that inspect after a turn ends)
|
|
349
|
+
currentAuthenticatedUser = item.authUser;
|
|
350
|
+
currentAuthorizationHeader = item.authHeader;
|
|
351
|
+
return turnContextStorage.run({
|
|
352
|
+
sessionKey,
|
|
353
|
+
sourceChannel: item.sourceChannel,
|
|
354
|
+
channelKey: item.channelKey,
|
|
355
|
+
authUser: item.authUser,
|
|
356
|
+
authHeader: item.authHeader,
|
|
357
|
+
activityCallback: item.onActivity,
|
|
358
|
+
}, async () => {
|
|
359
|
+
let accumulated = "";
|
|
360
|
+
let toolCallExecuted = false;
|
|
361
|
+
let toolCallCount = 0;
|
|
362
|
+
// Per-turn map: toolCallId → spawn args stashed from tool.execution_start when toolName === "task".
|
|
363
|
+
// Correlates the SDK's subagent.started event (which only carries agent_type fields) with the
|
|
364
|
+
// actual spawn parameters (name, description) passed to the task() tool call.
|
|
365
|
+
const spawnArgsMap = new Map();
|
|
366
|
+
// Unconditional capture — must fire even when onActivity is absent so the DB handler can resolve names.
|
|
367
|
+
const unsubSpawnCapture = session.on("tool.execution_start", (event) => {
|
|
425
368
|
const data = event.data;
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
369
|
+
if (data.toolName === "task" && data.toolCallId) {
|
|
370
|
+
const args = (data.arguments ?? {});
|
|
371
|
+
spawnArgsMap.set(data.toolCallId, {
|
|
372
|
+
name: typeof args.name === "string" ? args.name : undefined,
|
|
373
|
+
description: typeof args.description === "string" ? args.description : undefined,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
const unsubToolDone = session.on("tool.execution_complete", (event) => {
|
|
378
|
+
toolCallExecuted = true;
|
|
379
|
+
toolCallCount++;
|
|
380
|
+
if (item.onActivity) {
|
|
381
|
+
const data = event.data;
|
|
382
|
+
const result = data.result;
|
|
383
|
+
const resultPreview = typeof result?.content === "string" ? result.content.slice(0, 400) : undefined;
|
|
384
|
+
const detailedContent = typeof result?.detailedContent === "string"
|
|
385
|
+
? result.detailedContent
|
|
386
|
+
: typeof result?.content === "string"
|
|
387
|
+
? result.content
|
|
388
|
+
: undefined;
|
|
389
|
+
item.onActivity({
|
|
390
|
+
kind: "tool_complete",
|
|
391
|
+
toolCallId: data.toolCallId,
|
|
392
|
+
success: data.success,
|
|
393
|
+
resultPreview,
|
|
394
|
+
detailedContent,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
const unsubToolStart = item.onActivity
|
|
399
|
+
? session.on("tool.execution_start", (event) => {
|
|
400
|
+
const data = event.data;
|
|
401
|
+
item.onActivity({
|
|
402
|
+
kind: "tool_start",
|
|
403
|
+
toolCallId: data.toolCallId,
|
|
404
|
+
toolName: data.toolName,
|
|
405
|
+
mcpServerName: data.mcpServerName,
|
|
406
|
+
arguments: data.arguments,
|
|
407
|
+
});
|
|
408
|
+
})
|
|
409
|
+
: () => { };
|
|
410
|
+
const unsubReasoning = item.onActivity
|
|
411
|
+
? session.on("assistant.reasoning_delta", (event) => {
|
|
412
|
+
item.onActivity({
|
|
413
|
+
kind: "thinking_delta",
|
|
414
|
+
reasoningId: event.data.reasoningId,
|
|
415
|
+
deltaContent: event.data.deltaContent,
|
|
416
|
+
});
|
|
417
|
+
})
|
|
418
|
+
: () => { };
|
|
419
|
+
const unsubSubStart = item.onActivity
|
|
420
|
+
? session.on("subagent.started", (event) => {
|
|
421
|
+
const data = event.data;
|
|
422
|
+
const spawnArgs = spawnArgsMap.get(data.toolCallId);
|
|
423
|
+
const agentSlug = (typeof spawnArgs?.name === "string" ? spawnArgs.name : (data.agentName || "unknown"))
|
|
424
|
+
.toLowerCase()
|
|
425
|
+
.replace(/\s+/g, "-");
|
|
426
|
+
const resolvedDescription = (typeof spawnArgs?.description === "string"
|
|
427
|
+
? spawnArgs.description
|
|
428
|
+
: data.agentDescription || data.agentDisplayName || `Squad dispatch: ${agentSlug}`).slice(0, 500);
|
|
429
|
+
item.onActivity({
|
|
430
|
+
kind: "subagent_started",
|
|
431
|
+
toolCallId: data.toolCallId,
|
|
432
|
+
agentName: data.agentName,
|
|
433
|
+
agentDisplayName: data.agentDisplayName,
|
|
434
|
+
agentDescription: resolvedDescription,
|
|
435
|
+
agentSlug,
|
|
436
|
+
});
|
|
437
|
+
})
|
|
438
|
+
: () => { };
|
|
439
|
+
const unsubSubDone = item.onActivity
|
|
440
|
+
? session.on("subagent.completed", (event) => {
|
|
441
|
+
const data = event.data;
|
|
442
|
+
item.onActivity({
|
|
443
|
+
kind: "subagent_completed",
|
|
444
|
+
toolCallId: data.toolCallId,
|
|
445
|
+
agentName: data.agentName,
|
|
446
|
+
agentDisplayName: data.agentDisplayName,
|
|
447
|
+
durationMs: data.durationMs,
|
|
448
|
+
});
|
|
449
|
+
})
|
|
450
|
+
: () => { };
|
|
451
|
+
const unsubSubFail = item.onActivity
|
|
452
|
+
? session.on("subagent.failed", (event) => {
|
|
453
|
+
const data = event.data;
|
|
454
|
+
item.onActivity({
|
|
455
|
+
kind: "subagent_failed",
|
|
456
|
+
toolCallId: data.toolCallId,
|
|
457
|
+
agentName: data.agentName,
|
|
458
|
+
agentDisplayName: data.agentDisplayName,
|
|
459
|
+
error: data.error,
|
|
460
|
+
});
|
|
461
|
+
})
|
|
462
|
+
: () => { };
|
|
463
|
+
// Always persist SDK subagent dispatches to agent_tasks so Workers tab shows them.
|
|
464
|
+
const db = getDb();
|
|
465
|
+
// Set of task IDs for subagents spawned in THIS turn — used to filter nested tool events.
|
|
466
|
+
const activeSubagentTaskIds = new Set();
|
|
467
|
+
const unsubSubStartDb = session.on("subagent.started", (event) => {
|
|
468
|
+
try {
|
|
469
|
+
const data = event.data;
|
|
470
|
+
const spawnArgs = spawnArgsMap.get(data.toolCallId);
|
|
471
|
+
const agentSlug = (typeof spawnArgs?.name === "string" ? spawnArgs.name : (data.agentName || "unknown"))
|
|
472
|
+
.toLowerCase()
|
|
473
|
+
.replace(/\s+/g, "-");
|
|
474
|
+
const description = (typeof spawnArgs?.description === "string"
|
|
475
|
+
? spawnArgs.description
|
|
476
|
+
: data.agentDescription || data.agentDisplayName || `Squad dispatch: ${agentSlug}`).slice(0, 500);
|
|
477
|
+
db.prepare(`INSERT OR IGNORE INTO agent_tasks (task_id, agent_slug, description, status, origin_channel, session_key, source) VALUES (?, ?, ?, 'running', ?, ?, 'squad')`).run(data.toolCallId, agentSlug, description, item.sourceChannel || null, sessionKey);
|
|
478
|
+
activeSubagentTaskIds.add(data.toolCallId);
|
|
479
|
+
}
|
|
480
|
+
catch { /* non-fatal */ }
|
|
481
|
+
});
|
|
482
|
+
const unsubSubDoneDb = session.on("subagent.completed", (event) => {
|
|
483
|
+
try {
|
|
484
|
+
spawnArgsMap.delete(event.data.toolCallId);
|
|
485
|
+
activeSubagentTaskIds.delete(event.data.toolCallId);
|
|
486
|
+
db.prepare(`UPDATE agent_tasks SET status = 'completed', completed_at = CURRENT_TIMESTAMP WHERE task_id = ?`).run(event.data.toolCallId);
|
|
487
|
+
}
|
|
488
|
+
catch { /* non-fatal */ }
|
|
489
|
+
});
|
|
490
|
+
const unsubSubFailDb = session.on("subagent.failed", (event) => {
|
|
491
|
+
try {
|
|
492
|
+
const data = event.data;
|
|
493
|
+
spawnArgsMap.delete(data.toolCallId);
|
|
494
|
+
activeSubagentTaskIds.delete(data.toolCallId);
|
|
495
|
+
db.prepare(`UPDATE agent_tasks SET status = 'error', result = ?, completed_at = CURRENT_TIMESTAMP WHERE task_id = ?`).run(data.error || "Subagent failed", data.toolCallId);
|
|
496
|
+
}
|
|
497
|
+
catch { /* non-fatal */ }
|
|
498
|
+
});
|
|
499
|
+
// ---------------------------------------------------------------------------
|
|
500
|
+
// Nested tool-call streaming — capture tool.execution_start / _complete events
|
|
501
|
+
// whose parentToolCallId matches a known subagent task id, persist them to
|
|
502
|
+
// agent_task_events, and broadcast to per-task SSE subscribers.
|
|
503
|
+
// ---------------------------------------------------------------------------
|
|
504
|
+
const unsubNestedToolStart = session.on("tool.execution_start", (event) => {
|
|
505
|
+
try {
|
|
506
|
+
const data = event.data;
|
|
507
|
+
const parentId = data.parentToolCallId;
|
|
508
|
+
if (!parentId || !activeSubagentTaskIds.has(parentId))
|
|
509
|
+
return;
|
|
510
|
+
const toolName = data.toolName ?? null;
|
|
511
|
+
const args = data.arguments ?? {};
|
|
512
|
+
let summary = null;
|
|
513
|
+
if (typeof args.command === "string")
|
|
514
|
+
summary = args.command.slice(0, 120);
|
|
515
|
+
else if (typeof args.path === "string")
|
|
516
|
+
summary = args.path.slice(0, 120);
|
|
517
|
+
else if (typeof args.query === "string")
|
|
518
|
+
summary = args.query.slice(0, 120);
|
|
519
|
+
else if (typeof args.prompt === "string")
|
|
520
|
+
summary = args.prompt.slice(0, 120);
|
|
521
|
+
const ev = appendTaskEvent(parentId, "tool_start", toolName, summary);
|
|
522
|
+
if (ev)
|
|
523
|
+
emitTaskEvent(parentId, ev);
|
|
524
|
+
}
|
|
525
|
+
catch { /* non-fatal */ }
|
|
526
|
+
});
|
|
527
|
+
const unsubNestedToolDone = session.on("tool.execution_complete", (event) => {
|
|
528
|
+
try {
|
|
529
|
+
const data = event.data;
|
|
530
|
+
const parentId = data.parentToolCallId;
|
|
531
|
+
if (!parentId || !activeSubagentTaskIds.has(parentId))
|
|
532
|
+
return;
|
|
533
|
+
const success = data.success !== false;
|
|
534
|
+
const resultContent = data.result?.content ?? data.result?.detailedContent;
|
|
535
|
+
const summary = typeof resultContent === "string"
|
|
536
|
+
? (success ? resultContent.slice(0, 120) : `error: ${resultContent.slice(0, 100)}`)
|
|
537
|
+
: (success ? "ok" : "error");
|
|
538
|
+
const ev = appendTaskEvent(parentId, "tool_complete", null, summary);
|
|
539
|
+
if (ev)
|
|
540
|
+
emitTaskEvent(parentId, ev);
|
|
541
|
+
}
|
|
542
|
+
catch { /* non-fatal */ }
|
|
543
|
+
});
|
|
544
|
+
const unsubDelta = session.on("assistant.message_delta", (event) => {
|
|
545
|
+
if (toolCallExecuted && accumulated.length > 0 && !accumulated.endsWith("\n")) {
|
|
546
|
+
accumulated += "\n";
|
|
547
|
+
}
|
|
548
|
+
toolCallExecuted = false;
|
|
549
|
+
accumulated += event.data.deltaContent;
|
|
550
|
+
item.callback(accumulated, false);
|
|
551
|
+
});
|
|
439
552
|
try {
|
|
440
|
-
const
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
catch { /* non-fatal */ }
|
|
444
|
-
});
|
|
445
|
-
const unsubDelta = session.on("assistant.message_delta", (event) => {
|
|
446
|
-
// After a tool call completes, ensure a line break separates the text blocks
|
|
447
|
-
// so they don't visually run together in the rendered chat.
|
|
448
|
-
if (toolCallExecuted && accumulated.length > 0 && !accumulated.endsWith("\n")) {
|
|
449
|
-
accumulated += "\n";
|
|
553
|
+
const result = await session.sendAndWait({ prompt: item.prompt, ...(item.attachments && item.attachments.length > 0 ? { attachments: item.attachments } : {}) }, ORCHESTRATOR_TIMEOUT_MS);
|
|
554
|
+
const finalContent = result?.data?.content || accumulated || "(No response)";
|
|
555
|
+
return finalContent;
|
|
450
556
|
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
if (/timeout/i.test(msg)) {
|
|
465
|
-
if (accumulated.length > 0) {
|
|
466
|
-
log.warn({ timeoutSec: ORCHESTRATOR_TIMEOUT_MS / 1000, charCount: accumulated.length }, "Timeout with partial response — returning partial");
|
|
467
|
-
return accumulated;
|
|
557
|
+
catch (err) {
|
|
558
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
559
|
+
if (/timeout/i.test(msg)) {
|
|
560
|
+
if (accumulated.length > 0) {
|
|
561
|
+
log.warn({ sessionKey, timeoutSec: ORCHESTRATOR_TIMEOUT_MS / 1000, charCount: accumulated.length }, "Timeout with partial response — returning partial");
|
|
562
|
+
return accumulated;
|
|
563
|
+
}
|
|
564
|
+
if (toolCallCount > 0) {
|
|
565
|
+
log.warn({ sessionKey, timeoutSec: ORCHESTRATOR_TIMEOUT_MS / 1000, toolCallCount }, "Timeout — tool calls ran but no text yet, session still working");
|
|
566
|
+
return "I'm still working on this — I've started processing but it's taking longer than expected. I'll send you the results when I'm done.";
|
|
567
|
+
}
|
|
568
|
+
log.warn({ sessionKey, timeoutSec: ORCHESTRATOR_TIMEOUT_MS / 1000 }, "Timeout with no activity — session may be stuck");
|
|
569
|
+
return "Sorry, that request timed out before I could start working on it. Try again or break it into smaller pieces?";
|
|
468
570
|
}
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
571
|
+
if (/closed|destroy|disposed|invalid|expired|not found/i.test(msg)) {
|
|
572
|
+
log.warn({ sessionKey, msg }, "Session appears dead, will recreate");
|
|
573
|
+
manager.invalidateSession();
|
|
574
|
+
if (sessionKey === "default")
|
|
575
|
+
deleteState(ORCHESTRATOR_SESSION_KEY);
|
|
474
576
|
}
|
|
475
|
-
|
|
476
|
-
log.warn({ timeoutSec: ORCHESTRATOR_TIMEOUT_MS / 1000 }, "Timeout with no activity — session may be stuck");
|
|
477
|
-
return "Sorry, that request timed out before I could start working on it. Try again or break it into smaller pieces?";
|
|
577
|
+
throw err;
|
|
478
578
|
}
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
579
|
+
finally {
|
|
580
|
+
unsubDelta();
|
|
581
|
+
unsubToolDone();
|
|
582
|
+
unsubToolStart();
|
|
583
|
+
unsubSpawnCapture();
|
|
584
|
+
unsubReasoning();
|
|
585
|
+
unsubSubStart();
|
|
586
|
+
unsubSubDone();
|
|
587
|
+
unsubSubFail();
|
|
588
|
+
unsubSubStartDb();
|
|
589
|
+
unsubSubDoneDb();
|
|
590
|
+
unsubSubFailDb();
|
|
591
|
+
unsubNestedToolStart();
|
|
592
|
+
unsubNestedToolDone();
|
|
486
593
|
}
|
|
487
|
-
|
|
488
|
-
}
|
|
489
|
-
finally {
|
|
490
|
-
unsubDelta();
|
|
491
|
-
unsubToolDone();
|
|
492
|
-
unsubToolStart();
|
|
493
|
-
unsubReasoning();
|
|
494
|
-
unsubSubStart();
|
|
495
|
-
unsubSubDone();
|
|
496
|
-
unsubSubFail();
|
|
497
|
-
unsubSubStartDb();
|
|
498
|
-
unsubSubDoneDb();
|
|
499
|
-
unsubSubFailDb();
|
|
500
|
-
currentCallback = undefined;
|
|
501
|
-
currentActivityCallback = undefined;
|
|
502
|
-
currentProcessingSessionKey = undefined;
|
|
503
|
-
}
|
|
594
|
+
});
|
|
504
595
|
}
|
|
505
|
-
/**
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
596
|
+
/**
|
|
597
|
+
* Process a single queued item: route model, handle @mentions, execute.
|
|
598
|
+
* This is the SessionManager worker — one call per turn, inside the drain loop.
|
|
599
|
+
*/
|
|
600
|
+
async function processItem(item, manager) {
|
|
601
|
+
const { sessionKey } = manager;
|
|
602
|
+
if (item.targetAgent && item.targetAgent !== "chapterhouse") {
|
|
603
|
+
setActiveAgent(item.channelKey || "default", item.targetAgent);
|
|
604
|
+
return executeOnSession(manager, item);
|
|
512
605
|
}
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
const
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
result = await executeOnSession(sessionKey, item.prompt, item.callback, item.attachments, item.onActivity);
|
|
606
|
+
const currentModel = manager.currentModel ?? config.copilotModel;
|
|
607
|
+
const routeResult = await resolveModel(item.prompt, currentModel, manager.recentTiers);
|
|
608
|
+
if (routeResult.switched) {
|
|
609
|
+
log.info({ model: routeResult.model, tier: routeResult.overrideName || routeResult.tier }, "Auto-routing: switching model");
|
|
610
|
+
config.copilotModel = routeResult.model;
|
|
611
|
+
const existingSession = manager.session;
|
|
612
|
+
if (existingSession) {
|
|
613
|
+
try {
|
|
614
|
+
await existingSession.setModel(routeResult.model);
|
|
615
|
+
manager.currentModel = routeResult.model;
|
|
616
|
+
log.info({ sessionKey }, "Model switched in-place");
|
|
525
617
|
}
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
log.info({ model: routeResult.model, tier: routeResult.overrideName || routeResult.tier }, "Auto-routing: switching model");
|
|
532
|
-
config.copilotModel = routeResult.model;
|
|
533
|
-
const existingSession = sessionMap.get(sessionKey);
|
|
534
|
-
if (existingSession) {
|
|
535
|
-
try {
|
|
536
|
-
await existingSession.setModel(routeResult.model);
|
|
537
|
-
sessionModelMap.set(sessionKey, routeResult.model);
|
|
538
|
-
log.info({ sessionKey }, "Model switched in-place");
|
|
539
|
-
}
|
|
540
|
-
catch (err) {
|
|
541
|
-
log.warn({ sessionKey, err: err instanceof Error ? err.message : err }, "setModel() failed, will recreate session");
|
|
542
|
-
sessionMap.delete(sessionKey);
|
|
543
|
-
if (sessionKey === "default")
|
|
544
|
-
deleteState(ORCHESTRATOR_SESSION_KEY);
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
if (routeResult.tier) {
|
|
549
|
-
recentTiers.push(routeResult.tier);
|
|
550
|
-
if (recentTiers.length > 5)
|
|
551
|
-
recentTiers = recentTiers.slice(-5);
|
|
552
|
-
}
|
|
553
|
-
lastRouteResult = routeResult;
|
|
554
|
-
result = await executeOnSession(sessionKey, item.prompt, item.callback, item.attachments, item.onActivity);
|
|
618
|
+
catch (err) {
|
|
619
|
+
log.warn({ sessionKey, err: err instanceof Error ? err.message : err }, "setModel() failed, will recreate session");
|
|
620
|
+
manager.invalidateSession();
|
|
621
|
+
if (sessionKey === "default")
|
|
622
|
+
deleteState(ORCHESTRATOR_SESSION_KEY);
|
|
555
623
|
}
|
|
556
|
-
item.resolve(result);
|
|
557
624
|
}
|
|
558
|
-
catch (err) {
|
|
559
|
-
item.reject(err);
|
|
560
|
-
}
|
|
561
|
-
currentSourceChannel = undefined;
|
|
562
|
-
currentChannelKey = undefined;
|
|
563
625
|
}
|
|
564
|
-
|
|
626
|
+
if (routeResult.tier) {
|
|
627
|
+
manager.addRecentTier(routeResult.tier);
|
|
628
|
+
}
|
|
629
|
+
lastRouteResult = routeResult;
|
|
630
|
+
return executeOnSession(manager, item);
|
|
565
631
|
}
|
|
566
632
|
function isRecoverableError(err) {
|
|
567
633
|
const msg = err instanceof Error ? err.message : String(err);
|
|
568
|
-
// Timeouts are NOT retryable on a persistent session — the message was already
|
|
569
|
-
// sent and likely processed; re-sending creates "duplicate" responses.
|
|
570
634
|
if (/timeout/i.test(msg))
|
|
571
635
|
return false;
|
|
572
636
|
return /disconnect|connection|EPIPE|ECONNRESET|ECONNREFUSED|socket|closed|ENOENT|spawn|not found|expired|stale/i.test(msg);
|
|
@@ -576,14 +640,10 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
|
|
|
576
640
|
updateRequestContext(source);
|
|
577
641
|
const sourceLabel = source.type === "web" ? "web" : "background";
|
|
578
642
|
logMessage("in", sourceLabel, prompt);
|
|
579
|
-
// Derive the session key: project sessions come from web messages with a projectPath;
|
|
580
|
-
// background completions carry their own sessionKey; everything else is "default".
|
|
581
643
|
let sessionKey;
|
|
582
644
|
if (source.type === "web" && source.projectPath && config.squadEnabled) {
|
|
583
645
|
sessionKey = "project:" + normalizeProjectPath(source.projectPath);
|
|
584
|
-
// Keep the legacy channel-project map in sync for tools that read it
|
|
585
646
|
setChannelProject(source.connectionId, normalizeProjectPath(source.projectPath));
|
|
586
|
-
// Bump last-used timestamp so sidebar can sort by real activity
|
|
587
647
|
bumpProjectLastUsed(normalizeProjectPath(source.projectPath));
|
|
588
648
|
}
|
|
589
649
|
else if (source.type === "background" && source.sessionKey) {
|
|
@@ -593,36 +653,46 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
|
|
|
593
653
|
sessionKey = "default";
|
|
594
654
|
}
|
|
595
655
|
const channelKey = source.type === "web" ? source.connectionId : "default";
|
|
596
|
-
// Pass projectRoot to parseAtMention only for project sessions so the default
|
|
597
|
-
// chat does not get Squad roster injection.
|
|
598
656
|
const projectRoot = sessionKey.startsWith("project:") ? sessionKey.slice("project:".length) : undefined;
|
|
599
|
-
// Parse @mention routing (e.g., "@coder fix the bug" → target "coder")
|
|
600
657
|
const mention = parseAtMention(prompt, projectRoot);
|
|
601
658
|
const targetAgent = mention?.agentSlug;
|
|
602
659
|
const routedPrompt = mention ? mention.message : prompt;
|
|
603
|
-
// Tag the prompt with its source channel
|
|
604
660
|
const taggedPrompt = source.type === "background"
|
|
605
661
|
? routedPrompt
|
|
606
662
|
: `[via ${sourceLabel}] ${routedPrompt}`;
|
|
607
|
-
// Log role: background events are "system", user messages are "user"
|
|
608
663
|
const logRole = source.type === "background" ? "system" : "user";
|
|
609
|
-
// Determine the source channel for agent origin tracking
|
|
610
664
|
const sourceChannel = source.type === "web" ? "web" : undefined;
|
|
611
|
-
//
|
|
665
|
+
// Capture auth context at enqueue time — prevents cross-session contamination
|
|
666
|
+
// when concurrent sessions are processing simultaneously.
|
|
667
|
+
const authUser = source.type === "web" ? source.user : undefined;
|
|
668
|
+
const authHeader = source.type === "web" ? source.authorizationHeader?.trim() || undefined : undefined;
|
|
669
|
+
const manager = registry.getOrCreate(sessionKey);
|
|
612
670
|
void (async () => {
|
|
613
671
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
614
672
|
try {
|
|
615
673
|
const finalContent = await new Promise((resolve, reject) => {
|
|
616
|
-
|
|
617
|
-
|
|
674
|
+
manager.enqueue({
|
|
675
|
+
prompt: taggedPrompt,
|
|
676
|
+
attachments,
|
|
677
|
+
callback,
|
|
678
|
+
// Cast: QueuedMessage.onActivity uses a wide event type to avoid circular
|
|
679
|
+
// type dependencies. orchestrator.ts always passes valid ActivityEvent objects.
|
|
680
|
+
onActivity: onActivity,
|
|
681
|
+
sourceChannel,
|
|
682
|
+
targetAgent,
|
|
683
|
+
channelKey,
|
|
684
|
+
sessionKey,
|
|
685
|
+
authUser,
|
|
686
|
+
authHeader,
|
|
687
|
+
resolve,
|
|
688
|
+
reject,
|
|
689
|
+
});
|
|
618
690
|
});
|
|
619
|
-
// Deliver response to user FIRST, then log best-effort
|
|
620
691
|
callback(finalContent, true);
|
|
621
692
|
try {
|
|
622
693
|
logMessage("out", sourceLabel, finalContent);
|
|
623
694
|
}
|
|
624
695
|
catch { /* best-effort */ }
|
|
625
|
-
// Log both sides of the conversation, scoped to the session
|
|
626
696
|
try {
|
|
627
697
|
logConversation(logRole, prompt, sourceLabel, sessionKey);
|
|
628
698
|
}
|
|
@@ -631,9 +701,6 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
|
|
|
631
701
|
logConversation("assistant", finalContent, sourceLabel, sessionKey);
|
|
632
702
|
}
|
|
633
703
|
catch { /* best-effort */ }
|
|
634
|
-
// Episodic memory: if enough turns have accumulated since the last
|
|
635
|
-
// summary, kick off a background write. Fire-and-forget — never blocks
|
|
636
|
-
// the user reply path.
|
|
637
704
|
if (copilotClient) {
|
|
638
705
|
maybeWriteEpisode(copilotClient).catch((err) => {
|
|
639
706
|
log.error({ err: err instanceof Error ? err.message : err }, "Episode write failed (non-fatal)");
|
|
@@ -643,7 +710,6 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
|
|
|
643
710
|
}
|
|
644
711
|
catch (err) {
|
|
645
712
|
const msg = err instanceof Error ? err.message : String(err);
|
|
646
|
-
// Don't retry cancelled messages
|
|
647
713
|
if (/cancelled|abort/i.test(msg)) {
|
|
648
714
|
return;
|
|
649
715
|
}
|
|
@@ -651,7 +717,6 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
|
|
|
651
717
|
const delay = RECONNECT_DELAYS_MS[Math.min(attempt, RECONNECT_DELAYS_MS.length - 1)];
|
|
652
718
|
log.warn({ msg, attempt: attempt + 1, maxRetries: MAX_RETRIES, delayMs: delay }, "Recoverable error, retrying");
|
|
653
719
|
await sleep(delay);
|
|
654
|
-
// Reset client before retry in case the connection is stale
|
|
655
720
|
try {
|
|
656
721
|
await ensureClient();
|
|
657
722
|
}
|
|
@@ -665,34 +730,28 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
|
|
|
665
730
|
}
|
|
666
731
|
})();
|
|
667
732
|
}
|
|
668
|
-
/** Cancel
|
|
733
|
+
/** Cancel all queued and in-flight messages across all active sessions. */
|
|
669
734
|
export async function cancelCurrentMessage() {
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
if (activeSession && currentCallback) {
|
|
679
|
-
try {
|
|
680
|
-
await activeSession.abort();
|
|
681
|
-
log.info({ sessionKey: currentProcessingSessionKey }, "Aborted in-flight request");
|
|
682
|
-
return true;
|
|
683
|
-
}
|
|
684
|
-
catch (err) {
|
|
685
|
-
log.error({ err: err instanceof Error ? err.message : err }, "Abort failed");
|
|
735
|
+
if (!registry)
|
|
736
|
+
return false;
|
|
737
|
+
let drained = 0;
|
|
738
|
+
const aborts = [];
|
|
739
|
+
for (const [, manager] of registry.getAll()) {
|
|
740
|
+
drained += manager.cancelQueued();
|
|
741
|
+
if (manager.isProcessing) {
|
|
742
|
+
aborts.push(manager.abortCurrentTurn());
|
|
686
743
|
}
|
|
687
744
|
}
|
|
688
|
-
|
|
745
|
+
const results = await Promise.all(aborts);
|
|
746
|
+
const aborted = results.some(Boolean);
|
|
747
|
+
return aborted || drained > 0;
|
|
689
748
|
}
|
|
690
749
|
/** Switch the model on the live default orchestrator session without destroying it. */
|
|
691
750
|
export function switchSessionModel(newModel) {
|
|
692
|
-
const
|
|
693
|
-
if (session) {
|
|
694
|
-
return session.setModel(newModel).then(() => {
|
|
695
|
-
|
|
751
|
+
const manager = registry?.get("default");
|
|
752
|
+
if (manager?.session) {
|
|
753
|
+
return manager.session.setModel(newModel).then(() => {
|
|
754
|
+
manager.currentModel = newModel;
|
|
696
755
|
});
|
|
697
756
|
}
|
|
698
757
|
return Promise.resolve();
|
|
@@ -700,9 +759,9 @@ export function switchSessionModel(newModel) {
|
|
|
700
759
|
/** Return a snapshot of currently running workers for API/UI consumers. */
|
|
701
760
|
export function getAgentInfo() {
|
|
702
761
|
const allTasks = getActiveTasks().filter((t) => t.status === "running");
|
|
703
|
-
const
|
|
762
|
+
const reg = getAgentRegistry();
|
|
704
763
|
return allTasks.map((t) => {
|
|
705
|
-
const agent =
|
|
764
|
+
const agent = reg.find((a) => a.slug === t.agentSlug);
|
|
706
765
|
return {
|
|
707
766
|
slug: t.agentSlug,
|
|
708
767
|
name: agent?.name || t.agentSlug,
|
|
@@ -714,16 +773,11 @@ export function getAgentInfo() {
|
|
|
714
773
|
}
|
|
715
774
|
/** Clean up on shutdown/restart. */
|
|
716
775
|
export async function shutdownAgents() {
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
}
|
|
721
|
-
catch (err) {
|
|
722
|
-
log.error({ sessionKey: key, err: err instanceof Error ? err.message : err }, "Error disconnecting session during shutdown");
|
|
723
|
-
}
|
|
776
|
+
if (!registry) {
|
|
777
|
+
await clearActiveTasks();
|
|
778
|
+
return;
|
|
724
779
|
}
|
|
725
|
-
|
|
726
|
-
sessionModelMap.clear();
|
|
780
|
+
await registry.shutdown();
|
|
727
781
|
await clearActiveTasks();
|
|
728
782
|
}
|
|
729
783
|
//# sourceMappingURL=orchestrator.js.map
|