chapterhouse 0.3.12 → 0.3.14
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 +2 -69
- package/dist/api/server.js +15 -157
- package/dist/api/server.test.js +1 -1
- package/dist/api/turn-sse.integration.test.js +36 -0
- package/dist/cli.js +0 -30
- package/dist/config.js +0 -3
- package/dist/copilot/agent-event-bus.js +41 -0
- package/dist/copilot/agent-event-bus.test.js +23 -0
- package/dist/copilot/agents.js +4 -59
- package/dist/copilot/orchestrator.js +60 -65
- package/dist/copilot/orchestrator.test.js +73 -158
- package/dist/copilot/task-event-log.js +5 -5
- package/dist/copilot/task-event-log.test.js +68 -142
- package/dist/copilot/tools.js +9 -85
- package/dist/daemon.js +0 -22
- package/dist/store/db.js +2 -50
- package/dist/store/db.test.js +0 -45
- package/package.json +1 -3
- package/web/dist/assets/index-BlIWCM11.js +217 -0
- package/web/dist/assets/index-BlIWCM11.js.map +1 -0
- package/web/dist/assets/{index-BtAcw3EP.css → index-lvHFM_ut.css} +1 -1
- package/web/dist/index.html +2 -2
- package/dist/api/ralph.js +0 -153
- package/dist/api/ralph.test.js +0 -101
- package/dist/copilot/agents.squad.test.js +0 -72
- package/dist/copilot/hooks.js +0 -157
- package/dist/copilot/hooks.test.js +0 -315
- package/dist/copilot/squad-event-bus.js +0 -27
- package/dist/copilot/tools.squad.test.js +0 -168
- package/dist/squad/charter.js +0 -125
- package/dist/squad/charter.test.js +0 -89
- package/dist/squad/context.js +0 -48
- package/dist/squad/context.test.js +0 -59
- package/dist/squad/discovery.js +0 -268
- package/dist/squad/discovery.test.js +0 -154
- package/dist/squad/index.js +0 -9
- package/dist/squad/init-cli.js +0 -109
- package/dist/squad/init.js +0 -395
- package/dist/squad/init.test.js +0 -351
- package/dist/squad/mirror.js +0 -83
- package/dist/squad/mirror.scheduler.js +0 -80
- package/dist/squad/mirror.scheduler.test.js +0 -197
- package/dist/squad/mirror.test.js +0 -172
- package/dist/squad/registry.js +0 -162
- package/dist/squad/registry.test.js +0 -31
- package/dist/squad/squad-coordinator-system-message.test.js +0 -190
- package/dist/squad/squad-session-routing.test.js +0 -260
- package/dist/squad/types.js +0 -4
- package/dist/squad/worktree.js +0 -295
- package/dist/squad/worktree.test.js +0 -189
- package/dist/store/squad-sessions.test.js +0 -341
- package/web/dist/assets/index-BR2cks94.js +0 -219
- package/web/dist/assets/index-BR2cks94.js.map +0 -1
|
@@ -68,7 +68,6 @@ async function loadOrchestratorModule(t, overrides = {}) {
|
|
|
68
68
|
config: {
|
|
69
69
|
copilotModel: "missing-model",
|
|
70
70
|
selfEditEnabled: true,
|
|
71
|
-
squadEnabled: false,
|
|
72
71
|
},
|
|
73
72
|
routeResults: [],
|
|
74
73
|
routerArgs: [],
|
|
@@ -85,8 +84,6 @@ async function loadOrchestratorModule(t, overrides = {}) {
|
|
|
85
84
|
ensureDefaultAgentsCalls: 0,
|
|
86
85
|
loadAgentsCalls: 0,
|
|
87
86
|
setActiveAgentCalls: [],
|
|
88
|
-
setChannelProjectCalls: [],
|
|
89
|
-
channelProjects: new Map(),
|
|
90
87
|
parseMentionArgs: [],
|
|
91
88
|
buildAgentRosterArgs: [],
|
|
92
89
|
clearActiveTasksCalls: 0,
|
|
@@ -142,22 +139,6 @@ async function loadOrchestratorModule(t, overrides = {}) {
|
|
|
142
139
|
resetClient: async () => client,
|
|
143
140
|
},
|
|
144
141
|
});
|
|
145
|
-
t.mock.module("../squad/context.js", {
|
|
146
|
-
namedExports: {
|
|
147
|
-
setChannelProject: (channelKey, projectRoot) => {
|
|
148
|
-
state.setChannelProjectCalls.push({ channelKey, projectRoot });
|
|
149
|
-
state.channelProjects.set(channelKey, projectRoot);
|
|
150
|
-
},
|
|
151
|
-
getChannelProject: (channelKey) => state.channelProjects.get(channelKey),
|
|
152
|
-
normalizeProjectPath: (projectPath) => `normalized:${projectPath}`,
|
|
153
|
-
},
|
|
154
|
-
});
|
|
155
|
-
t.mock.module("../squad/charter.js", {
|
|
156
|
-
namedExports: {
|
|
157
|
-
buildSquadSystemPrefix: async (_projectRoot) => "# Squad context\n",
|
|
158
|
-
getSquadCoordinatorSystemMessage: async (_projectRoot) => "# Squad Coordinator\nYou are the Squad coordinator.\n",
|
|
159
|
-
},
|
|
160
|
-
});
|
|
161
142
|
t.mock.module("../store/db.js", {
|
|
162
143
|
namedExports: {
|
|
163
144
|
logConversation: (role, content, source) => {
|
|
@@ -184,7 +165,6 @@ async function loadOrchestratorModule(t, overrides = {}) {
|
|
|
184
165
|
}),
|
|
185
166
|
transaction: (fn) => fn,
|
|
186
167
|
}),
|
|
187
|
-
bumpProjectLastUsed: (_projectRoot) => { },
|
|
188
168
|
appendTaskEvent: (taskId, kind, toolName, summary) => {
|
|
189
169
|
const seq = (state.taskEvents.get(taskId)?.length ?? 0) + 1;
|
|
190
170
|
const ev = { id: seq, taskId, seq, ts: Date.now(), kind, toolName, summary };
|
|
@@ -281,7 +261,6 @@ test("sendToOrchestrator logs both sides, remembers web auth context, and record
|
|
|
281
261
|
config: {
|
|
282
262
|
copilotModel: "claude-sonnet-4.6",
|
|
283
263
|
selfEditEnabled: true,
|
|
284
|
-
squadEnabled: false,
|
|
285
264
|
},
|
|
286
265
|
routeResults: [
|
|
287
266
|
{
|
|
@@ -337,7 +316,6 @@ test("@mentions route through the orchestrator session without invoking the mode
|
|
|
337
316
|
config: {
|
|
338
317
|
copilotModel: "claude-sonnet-4.6",
|
|
339
318
|
selfEditEnabled: true,
|
|
340
|
-
squadEnabled: false,
|
|
341
319
|
},
|
|
342
320
|
parseMentionResult: { agentSlug: "designer", message: "polish the landing page" },
|
|
343
321
|
sendResult: "Delegated",
|
|
@@ -355,48 +333,11 @@ test("@mentions route through the orchestrator session without invoking the mode
|
|
|
355
333
|
assert.deepEqual(state.routerArgs, []);
|
|
356
334
|
assert.deepEqual(state.sessionPrompts, [{ prompt: "[via web] polish the landing page" }]);
|
|
357
335
|
});
|
|
358
|
-
test("web projectPath activates squad context, rebuilds the orchestrator roster, and routes mentions per connection", async (t) => {
|
|
359
|
-
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
360
|
-
config: {
|
|
361
|
-
copilotModel: "claude-sonnet-4.6",
|
|
362
|
-
selfEditEnabled: true,
|
|
363
|
-
squadEnabled: true,
|
|
364
|
-
},
|
|
365
|
-
parseMentionResult: { agentSlug: "ripley", message: "audit the squad routing" },
|
|
366
|
-
sendResult: "Squad handled it",
|
|
367
|
-
});
|
|
368
|
-
await orchestrator.initOrchestrator(client);
|
|
369
|
-
const final = await new Promise((resolve) => {
|
|
370
|
-
orchestrator.sendToOrchestrator("@ripley audit the squad routing", { type: "web", connectionId: "conn-squad", projectPath: "~/workspace/mock-squad" }, (text, done) => {
|
|
371
|
-
if (done) {
|
|
372
|
-
resolve(text);
|
|
373
|
-
}
|
|
374
|
-
});
|
|
375
|
-
});
|
|
376
|
-
assert.equal(final, "Squad handled it");
|
|
377
|
-
assert.deepEqual(state.setChannelProjectCalls, [{
|
|
378
|
-
channelKey: "conn-squad",
|
|
379
|
-
projectRoot: "normalized:~/workspace/mock-squad",
|
|
380
|
-
}]);
|
|
381
|
-
assert.deepEqual(state.parseMentionArgs.at(-1), {
|
|
382
|
-
text: "@ripley audit the squad routing",
|
|
383
|
-
projectRoot: "normalized:~/workspace/mock-squad",
|
|
384
|
-
});
|
|
385
|
-
assert.deepEqual(state.setActiveAgentCalls, [{ channelKey: "conn-squad", agentSlug: "ripley" }]);
|
|
386
|
-
assert.deepEqual(state.sessionPrompts.at(-1), [{ prompt: "[via web] audit the squad routing" }][0]);
|
|
387
|
-
// Project sessions use getSquadCoordinatorSystemMessage (not buildAgentRoster) — only the default
|
|
388
|
-
// session init triggers a buildAgentRoster call.
|
|
389
|
-
assert.deepEqual(state.buildAgentRosterArgs, [undefined]);
|
|
390
|
-
assert.equal(state.createSessionCalls.length, 2);
|
|
391
|
-
assert.equal(typeof orchestrator.getCurrentChannelKey, "function");
|
|
392
|
-
assert.equal(orchestrator.getCurrentChannelKey(), undefined);
|
|
393
|
-
});
|
|
394
336
|
test("feedAgentResult injects a background completion turn and proactively notifies listeners", async (t) => {
|
|
395
337
|
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
396
338
|
config: {
|
|
397
339
|
copilotModel: "claude-sonnet-4.6",
|
|
398
340
|
selfEditEnabled: true,
|
|
399
|
-
squadEnabled: false,
|
|
400
341
|
},
|
|
401
342
|
sendResult: "Agent complete",
|
|
402
343
|
});
|
|
@@ -427,7 +368,6 @@ test("cancelCurrentMessage aborts the active request and agent helpers expose ru
|
|
|
427
368
|
config: {
|
|
428
369
|
copilotModel: "claude-sonnet-4.6",
|
|
429
370
|
selfEditEnabled: true,
|
|
430
|
-
squadEnabled: false,
|
|
431
371
|
},
|
|
432
372
|
sendResult: "__PENDING__",
|
|
433
373
|
});
|
|
@@ -449,85 +389,99 @@ test("cancelCurrentMessage aborts the active request and agent helpers expose ru
|
|
|
449
389
|
await orchestrator.shutdownAgents();
|
|
450
390
|
assert.equal(state.clearActiveTasksCalls, 1);
|
|
451
391
|
});
|
|
452
|
-
|
|
392
|
+
// ---------------------------------------------------------------------------
|
|
393
|
+
// REGRESSION: #35 — per-session isolation
|
|
394
|
+
// This test would have caught the original bug. With a global shared queue,
|
|
395
|
+
// session B's message would queue behind session A's blocking turn and the
|
|
396
|
+
// Promise.race would time out. With per-session queues, session B completes
|
|
397
|
+
// independently.
|
|
398
|
+
// ---------------------------------------------------------------------------
|
|
399
|
+
test("regression #35: session A blocking does not delay session B (concurrent sessions)", async (t) => {
|
|
453
400
|
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
454
|
-
config: {
|
|
455
|
-
|
|
456
|
-
selfEditEnabled: true,
|
|
457
|
-
squadEnabled: false,
|
|
458
|
-
},
|
|
459
|
-
sendResult: "Routed correctly",
|
|
460
|
-
taskSessionKeys: new Map([["proj-task-1", "project:/repo/squad"]]),
|
|
401
|
+
config: { copilotModel: "claude-sonnet-4.6", selfEditEnabled: false },
|
|
402
|
+
sendResult: "__PENDING__", // session A will block in sendAndWait
|
|
461
403
|
});
|
|
462
404
|
await orchestrator.initOrchestrator(client);
|
|
463
|
-
//
|
|
464
|
-
const
|
|
465
|
-
|
|
466
|
-
|
|
405
|
+
// Send to session A (explicit sessionKey "chat:a") — parks because sendResult is __PENDING__
|
|
406
|
+
const sessionACallbacks = [];
|
|
407
|
+
orchestrator.sendToOrchestrator("slow request to session A", { type: "background", sessionKey: "chat:a" }, (text, done) => sessionACallbacks.push({ text, done }));
|
|
408
|
+
// Yield to event loop so session A's drain loop runs and parks in sendAndWait
|
|
409
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
410
|
+
// Now session A is blocked inside sendAndWait. Switch sendResult so session B
|
|
411
|
+
// gets a fast response. Session A's pending reject is still captured in its closure.
|
|
412
|
+
state.sendResult = "session B complete";
|
|
413
|
+
// Send to session B (different sessionKey) — must complete without waiting for session A
|
|
414
|
+
const sessionBDone = new Promise((resolve) => {
|
|
415
|
+
orchestrator.sendToOrchestrator("quick request to session B", { type: "background", sessionKey: "chat:b" }, (text, done) => {
|
|
416
|
+
if (done)
|
|
417
|
+
resolve(text);
|
|
418
|
+
});
|
|
467
419
|
});
|
|
468
|
-
//
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
assert.equal(
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
assert.ok(prompt?.prompt.includes("Squad feature done"), "prompt should include the result text");
|
|
420
|
+
// Session B must respond before the deadline (session A is still blocked indefinitely)
|
|
421
|
+
const result = await Promise.race([
|
|
422
|
+
sessionBDone,
|
|
423
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("session B was blocked by session A — global queue bug reproduced")), 300)),
|
|
424
|
+
]);
|
|
425
|
+
assert.equal(result, "session B complete", "session B must resolve independently of blocked session A");
|
|
426
|
+
assert.equal(sessionACallbacks.length, 0, "session A must still be pending (no response yet)");
|
|
427
|
+
// Clean up: reject session A's pending promise so the test can finish
|
|
428
|
+
state.pendingReject?.(new Error("test teardown"));
|
|
429
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
479
430
|
});
|
|
480
431
|
// ---------------------------------------------------------------------------
|
|
481
|
-
//
|
|
432
|
+
// Orchestrator lifecycle: shutdown clears all sessions
|
|
482
433
|
// ---------------------------------------------------------------------------
|
|
483
|
-
test("cancelCurrentMessage targets the active project session, not the default session", async (t) => {
|
|
484
|
-
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
485
|
-
config: {
|
|
486
|
-
copilotModel: "claude-sonnet-4.6",
|
|
487
|
-
selfEditEnabled: false,
|
|
488
|
-
// squadEnabled true so sendToOrchestrator derives a "project:…" session key
|
|
489
|
-
squadEnabled: true,
|
|
490
|
-
},
|
|
491
|
-
sendResult: "__PENDING__",
|
|
492
|
-
});
|
|
493
|
-
await orchestrator.initOrchestrator(client);
|
|
494
|
-
// Default session was created during init
|
|
495
|
-
const sessionsAfterInit = state.createSessionCalls.length;
|
|
496
|
-
// Send to a project path — this should open a separate project session
|
|
497
|
-
orchestrator.sendToOrchestrator("scaffold the new API", { type: "web", connectionId: "conn-project", projectPath: "/repo/squad-proj" }, () => { });
|
|
498
|
-
// Yield to the event loop so the project session is created and the
|
|
499
|
-
// sendAndWait promise is parked in PENDING state.
|
|
500
|
-
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
501
|
-
assert.equal(state.createSessionCalls.length, sessionsAfterInit + 1, "a distinct project session must be created, separate from the default session");
|
|
502
|
-
// Cancel should abort the active project session, not the default one
|
|
503
|
-
const cancelled = await orchestrator.cancelCurrentMessage();
|
|
504
|
-
assert.equal(cancelled, true, "cancelCurrentMessage should return true when an active request was aborted");
|
|
505
|
-
assert.equal(state.abortCalls, 1, "exactly one abort — on the in-flight project session");
|
|
506
|
-
});
|
|
507
434
|
test("shutdownAgents disconnects all sessions, clears maps, and clears active tasks", async (t) => {
|
|
508
435
|
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
509
|
-
config: { copilotModel: "claude-sonnet-4.6", selfEditEnabled: false
|
|
436
|
+
config: { copilotModel: "claude-sonnet-4.6", selfEditEnabled: false },
|
|
510
437
|
sendResult: "ok",
|
|
511
438
|
});
|
|
512
439
|
// Init default session
|
|
513
440
|
await orchestrator.initOrchestrator(client);
|
|
514
441
|
const sessionsAfterInit = state.createSessionCalls.length;
|
|
515
|
-
// Trigger a
|
|
516
|
-
orchestrator.sendToOrchestrator("hello from
|
|
442
|
+
// Trigger a second session by sending a background message with a distinct sessionKey
|
|
443
|
+
orchestrator.sendToOrchestrator("hello from session chat:1", { type: "background", sessionKey: "chat:1" }, (_text, _done) => { });
|
|
517
444
|
// Yield so the async session-creation IIFE runs
|
|
518
445
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
519
|
-
assert.ok(state.createSessionCalls.length > sessionsAfterInit, "a
|
|
446
|
+
assert.ok(state.createSessionCalls.length > sessionsAfterInit, "a second session should have been created");
|
|
520
447
|
// Shutdown — both sessions should be disconnected
|
|
521
448
|
await orchestrator.shutdownAgents();
|
|
522
|
-
assert.equal(state.disconnectCalls, 2, "disconnect must be called once per session (default +
|
|
449
|
+
assert.equal(state.disconnectCalls, 2, "disconnect must be called once per session (default + chat:1)");
|
|
523
450
|
assert.equal(state.clearActiveTasksCalls, 1, "clearActiveTasks must be called exactly once");
|
|
524
451
|
// Re-init after shutdown must create a fresh session (proves sessionMap was cleared)
|
|
525
452
|
await orchestrator.initOrchestrator(client);
|
|
526
453
|
assert.ok(state.createSessionCalls.length > sessionsAfterInit + 1, "re-init after shutdown must create a new session (not reuse stale sessionMap entry)");
|
|
527
454
|
});
|
|
455
|
+
// ---------------------------------------------------------------------------
|
|
456
|
+
// feedAgentResult routes to the correct non-default session
|
|
457
|
+
// ---------------------------------------------------------------------------
|
|
458
|
+
test("feedAgentResult routes to a non-default session when the task's session_key is non-default", async (t) => {
|
|
459
|
+
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
460
|
+
config: { copilotModel: "claude-sonnet-4.6", selfEditEnabled: false },
|
|
461
|
+
sendResult: "Routed correctly",
|
|
462
|
+
taskSessionKeys: new Map([["chat-task-1", "chat:abc"]]),
|
|
463
|
+
});
|
|
464
|
+
await orchestrator.initOrchestrator(client);
|
|
465
|
+
// initOrchestrator creates the default session — record that baseline
|
|
466
|
+
const sessionsAfterInit = state.createSessionCalls.length;
|
|
467
|
+
const notified = new Promise((resolve) => {
|
|
468
|
+
orchestrator.setProactiveNotify(resolve);
|
|
469
|
+
});
|
|
470
|
+
// Task belongs to "chat:abc" session; feedAgentResult must open that session
|
|
471
|
+
orchestrator.feedAgentResult("chat-task-1", "coder", "Feature done");
|
|
472
|
+
assert.equal(await notified, "Routed correctly");
|
|
473
|
+
// A second createSession call proves the orchestrator opened a fresh non-default session
|
|
474
|
+
// rather than reusing the already-open default session.
|
|
475
|
+
assert.equal(state.createSessionCalls.length, sessionsAfterInit + 1, "feedAgentResult should spin up a non-default session, not recycle the default one");
|
|
476
|
+
// The prompt must reference the task and agent
|
|
477
|
+
const prompt = state.sessionPrompts.at(-1);
|
|
478
|
+
assert.ok(prompt?.prompt.includes("chat-task-1"), "prompt should reference the task id");
|
|
479
|
+
assert.ok(prompt?.prompt.includes("coder"), "prompt should reference the agent slug");
|
|
480
|
+
assert.ok(prompt?.prompt.includes("Feature done"), "prompt should include the result text");
|
|
481
|
+
});
|
|
528
482
|
test("ensureOrchestratorSession cleans up in-flight promise on session creation failure", async (t) => {
|
|
529
483
|
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
530
|
-
config: { copilotModel: "claude-sonnet-4.6", selfEditEnabled: false
|
|
484
|
+
config: { copilotModel: "claude-sonnet-4.6", selfEditEnabled: false },
|
|
531
485
|
// Non-recoverable error so the retry loop exits immediately
|
|
532
486
|
createSessionError: "fatal: SDK host permanently unavailable",
|
|
533
487
|
sendResult: "unreachable",
|
|
@@ -552,13 +506,13 @@ test("ensureOrchestratorSession cleans up in-flight promise on session creation
|
|
|
552
506
|
assert.ok(state.createSessionCalls.length > countAfterFirst, "a second message must trigger a new createSession attempt, proving sessionCreatePromises was cleaned up");
|
|
553
507
|
});
|
|
554
508
|
// ---------------------------------------------------------------------------
|
|
555
|
-
// S5-01: SDK subagent events persist to agent_tasks (
|
|
509
|
+
// S5-01: SDK subagent events persist to agent_tasks (agent dispatch tracking)
|
|
556
510
|
// Root cause: executeOnSession subscribed to subagent.* events for the UI
|
|
557
511
|
// activity feed but never wrote rows to agent_tasks. Workers tab only reads
|
|
558
|
-
// agent_tasks, so
|
|
512
|
+
// agent_tasks, so agent dispatches were invisible.
|
|
559
513
|
// Fix: unconditional DB subscriptions in executeOnSession write/update rows.
|
|
560
514
|
// ---------------------------------------------------------------------------
|
|
561
|
-
test("S5-01: subagent.started event inserts
|
|
515
|
+
test("S5-01: subagent.started event inserts an adhoc row into agent_tasks", async (t) => {
|
|
562
516
|
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
563
517
|
sendResult: "__PENDING__",
|
|
564
518
|
});
|
|
@@ -571,7 +525,7 @@ test("S5-01: subagent.started event inserts a squad row into agent_tasks", async
|
|
|
571
525
|
// Yield so the async IIFE reaches sendAndWait (which is pending)
|
|
572
526
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
573
527
|
assert.ok(state.lastSession, "FakeSession should have been created");
|
|
574
|
-
// Fire a subagent.started event — simulates the SDK's task tool starting
|
|
528
|
+
// Fire a subagent.started event — simulates the SDK's task tool starting an agent dispatch
|
|
575
529
|
state.lastSession.emit("subagent.started", {
|
|
576
530
|
toolCallId: "subagent-call-001",
|
|
577
531
|
agentName: "Kaylee",
|
|
@@ -581,7 +535,7 @@ test("S5-01: subagent.started event inserts a squad row into agent_tasks", async
|
|
|
581
535
|
// The handler is synchronous — DB write should be in state.dbWrites immediately
|
|
582
536
|
const insertWrite = state.dbWrites.find((w) => w.sql.includes("INSERT") && w.sql.includes("agent_tasks"));
|
|
583
537
|
assert.ok(insertWrite, "subagent.started must INSERT a row into agent_tasks");
|
|
584
|
-
assert.ok((insertWrite.sql + JSON.stringify(insertWrite.args)).includes("
|
|
538
|
+
assert.ok((insertWrite.sql + JSON.stringify(insertWrite.args)).includes("adhoc"), "inserted row must carry source='adhoc'");
|
|
585
539
|
assert.ok(JSON.stringify(insertWrite.args).includes("subagent-call-001"), "task_id must equal the toolCallId from the event");
|
|
586
540
|
// Resolve the pending sendAndWait so the test can clean up
|
|
587
541
|
state.pendingReject?.(new Error("test teardown"));
|
|
@@ -637,45 +591,6 @@ test("S5-01: subagent.failed event updates agent_tasks status to error", async (
|
|
|
637
591
|
state.pendingReject?.(new Error("test teardown"));
|
|
638
592
|
});
|
|
639
593
|
// ---------------------------------------------------------------------------
|
|
640
|
-
// REGRESSION: #35 — per-session isolation
|
|
641
|
-
// This test would have caught the original bug. With a global shared queue,
|
|
642
|
-
// session B's message would queue behind session A's blocking turn and the
|
|
643
|
-
// Promise.race would time out. With per-session queues, session B completes
|
|
644
|
-
// independently.
|
|
645
|
-
// ---------------------------------------------------------------------------
|
|
646
|
-
test("regression #35: session A blocking does not delay session B (concurrent sessions)", async (t) => {
|
|
647
|
-
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
648
|
-
config: { copilotModel: "claude-sonnet-4.6", selfEditEnabled: false, squadEnabled: true },
|
|
649
|
-
sendResult: "__PENDING__", // session A will block in sendAndWait
|
|
650
|
-
});
|
|
651
|
-
await orchestrator.initOrchestrator(client);
|
|
652
|
-
// Send to session A (project /project/a) — parks because sendResult is __PENDING__
|
|
653
|
-
const sessionACallbacks = [];
|
|
654
|
-
orchestrator.sendToOrchestrator("slow request to project A", { type: "web", connectionId: "conn-a", projectPath: "/project/a" }, (text, done) => sessionACallbacks.push({ text, done }));
|
|
655
|
-
// Yield to event loop so session A's drain loop runs and parks in sendAndWait
|
|
656
|
-
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
657
|
-
// Now session A is blocked inside sendAndWait. Switch sendResult so session B
|
|
658
|
-
// gets a fast response. session A's pending reject is still captured in its closure.
|
|
659
|
-
state.sendResult = "session B complete";
|
|
660
|
-
// Send to session B (different project) — must complete without waiting for session A
|
|
661
|
-
const sessionBDone = new Promise((resolve) => {
|
|
662
|
-
orchestrator.sendToOrchestrator("quick request to project B", { type: "web", connectionId: "conn-b", projectPath: "/project/b" }, (text, done) => {
|
|
663
|
-
if (done)
|
|
664
|
-
resolve(text);
|
|
665
|
-
});
|
|
666
|
-
});
|
|
667
|
-
// Session B must respond before the deadline (session A is still blocked indefinitely)
|
|
668
|
-
const result = await Promise.race([
|
|
669
|
-
sessionBDone,
|
|
670
|
-
new Promise((_, reject) => setTimeout(() => reject(new Error("session B was blocked by session A — global queue bug reproduced")), 300)),
|
|
671
|
-
]);
|
|
672
|
-
assert.equal(result, "session B complete", "session B must resolve independently of blocked session A");
|
|
673
|
-
assert.equal(sessionACallbacks.length, 0, "session A must still be pending (no response yet)");
|
|
674
|
-
// Clean up: reject session A's pending promise so the test can finish
|
|
675
|
-
state.pendingReject?.(new Error("test teardown"));
|
|
676
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
677
|
-
});
|
|
678
|
-
// ---------------------------------------------------------------------------
|
|
679
594
|
// #81 — task spawn args (name/description) must win over SDK agent_type fields
|
|
680
595
|
// Root cause: subagent.started only carries agent_type boilerplate. The actual
|
|
681
596
|
// spawn params (name, description) arrive earlier via tool.execution_start for
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Maintains a `Map<taskId, RingBuffer<TaskEvent>>` in the daemon process,
|
|
5
5
|
* capped at 500 events per task. Subscribes to the process-wide
|
|
6
|
-
* `
|
|
6
|
+
* `agentEventBus` for `session:tool_call` events so the ring buffer stays
|
|
7
7
|
* in sync without the caller needing to wire anything extra.
|
|
8
8
|
*
|
|
9
9
|
* Consumers:
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
*
|
|
22
22
|
* @module copilot/task-event-log
|
|
23
23
|
*/
|
|
24
|
-
import {
|
|
24
|
+
import { agentEventBus } from "./agent-event-bus.js";
|
|
25
25
|
import { RingBuffer } from "./ring-buffer.js";
|
|
26
26
|
// ---------------------------------------------------------------------------
|
|
27
27
|
// Ring buffer — re-export so existing imports stay compatible
|
|
@@ -59,11 +59,11 @@ function notifyListeners(taskId, event) {
|
|
|
59
59
|
// Public API
|
|
60
60
|
// ---------------------------------------------------------------------------
|
|
61
61
|
/**
|
|
62
|
-
* Subscribe to the
|
|
62
|
+
* Subscribe to the agent event bus and maintain the ring buffer.
|
|
63
63
|
* Call once from initOrchestrator(). Returns an unsub / cleanup function.
|
|
64
64
|
*/
|
|
65
65
|
export function initTaskEventLog() {
|
|
66
|
-
const unsubToolCall =
|
|
66
|
+
const unsubToolCall = agentEventBus.subscribe("session:tool_call", (event) => {
|
|
67
67
|
const taskId = event.sessionId;
|
|
68
68
|
if (!taskId)
|
|
69
69
|
return;
|
|
@@ -81,7 +81,7 @@ export function initTaskEventLog() {
|
|
|
81
81
|
buf.push(taskEvent);
|
|
82
82
|
notifyListeners(taskId, taskEvent);
|
|
83
83
|
});
|
|
84
|
-
const unsubDestroyed =
|
|
84
|
+
const unsubDestroyed = agentEventBus.subscribe("session:destroyed", (event) => {
|
|
85
85
|
if (event.sessionId)
|
|
86
86
|
clearTaskLog(event.sessionId);
|
|
87
87
|
});
|