chapterhouse 0.9.1 → 0.10.0
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 +1 -1
- package/agents/korg.agent.md +20 -0
- package/dist/api/auth.js +11 -1
- package/dist/api/auth.test.js +29 -0
- package/dist/api/errors.js +23 -0
- package/dist/api/route-coverage.test.js +61 -21
- package/dist/api/routes/agents.js +472 -0
- package/dist/api/routes/memory.js +299 -0
- package/dist/api/routes/projects.js +170 -0
- package/dist/api/routes/sessions.js +347 -0
- package/dist/api/routes/system.js +82 -0
- package/dist/api/routes/wiki.js +455 -0
- package/dist/api/routes/wiki.test.js +49 -0
- package/dist/api/send-json.js +16 -0
- package/dist/api/send-json.test.js +18 -0
- package/dist/api/server-runtime.js +45 -3
- package/dist/api/server.js +34 -1764
- package/dist/api/server.test.js +239 -8
- package/dist/api/sse-hub.js +37 -0
- package/dist/cli.js +1 -1
- package/dist/config.js +151 -58
- package/dist/config.test.js +29 -0
- package/dist/copilot/okr-mapper.js +2 -11
- package/dist/copilot/orchestrator.js +358 -352
- package/dist/copilot/orchestrator.test.js +139 -4
- package/dist/copilot/prompt-date.js +2 -1
- package/dist/copilot/session-manager.js +25 -23
- package/dist/copilot/session-manager.test.js +35 -1
- package/dist/copilot/standup.js +2 -2
- package/dist/copilot/task-event-log.js +7 -1
- package/dist/copilot/task-event-log.test.js +13 -0
- package/dist/copilot/tools/agent.js +608 -0
- package/dist/copilot/tools/index.js +19 -0
- package/dist/copilot/tools/memory.js +678 -0
- package/dist/copilot/tools/models.js +2 -0
- package/dist/copilot/tools/okr.js +171 -0
- package/dist/copilot/tools/wiki.js +333 -0
- package/dist/copilot/tools-deps.js +4 -0
- package/dist/copilot/tools.agent.test.js +10 -8
- package/dist/copilot/tools.inventory.test.js +76 -0
- package/dist/copilot/tools.js +1 -1725
- package/dist/copilot/tools.okr.test.js +31 -0
- package/dist/copilot/tools.wiki.test.js +358 -6
- package/dist/copilot/turn-event-log.js +31 -4
- package/dist/copilot/turn-event-log.test.js +24 -2
- package/dist/copilot/workiq-installer.test.js +2 -2
- package/dist/daemon-install.js +3 -2
- package/dist/daemon.js +9 -17
- package/dist/integrations/ado-client.js +90 -9
- package/dist/integrations/ado-client.test.js +56 -0
- package/dist/integrations/team-push.js +1 -0
- package/dist/integrations/team-push.test.js +6 -0
- package/dist/integrations/teams-notify.js +1 -0
- package/dist/integrations/teams-notify.test.js +5 -0
- package/dist/memory/active-scope.test.js +0 -1
- package/dist/memory/checkpoint.js +89 -72
- package/dist/memory/checkpoint.test.js +23 -3
- package/dist/memory/eot.js +194 -89
- package/dist/memory/eot.test.js +186 -3
- package/dist/memory/hooks.js +2 -4
- package/dist/memory/housekeeping-scheduler.js +1 -1
- package/dist/memory/housekeeping-scheduler.test.js +1 -2
- package/dist/memory/housekeeping.js +100 -3
- package/dist/memory/housekeeping.test.js +33 -2
- package/dist/memory/reflect.test.js +2 -0
- package/dist/memory/scope-lock.js +26 -0
- package/dist/memory/scope-lock.test.js +118 -0
- package/dist/memory/scopes.test.js +0 -1
- package/dist/mode-context.js +58 -5
- package/dist/mode-context.test.js +68 -0
- package/dist/paths.js +1 -0
- package/dist/setup.js +3 -2
- package/dist/shared/api-schemas.js +48 -5
- package/dist/store/connection.js +96 -0
- package/dist/store/db.js +5 -1498
- package/dist/store/db.test.js +182 -1
- package/dist/store/migrations.js +460 -0
- package/dist/store/repositories/memory.js +281 -0
- package/dist/store/repositories/okr.js +3 -0
- package/dist/store/repositories/projects.js +5 -0
- package/dist/store/repositories/sessions.js +284 -0
- package/dist/store/repositories/wiki.js +60 -0
- package/dist/store/schema.js +501 -0
- package/dist/util/logger.js +3 -2
- package/dist/wiki/consolidation.js +50 -9
- package/dist/wiki/consolidation.test.js +45 -0
- package/dist/wiki/frontmatter.js +45 -14
- package/dist/wiki/frontmatter.test.js +26 -1
- package/dist/wiki/fs.js +16 -4
- package/dist/wiki/fs.test.js +84 -0
- package/dist/wiki/index-manager.js +30 -2
- package/dist/wiki/index-manager.test.js +43 -12
- package/dist/wiki/ingest.js +17 -1
- package/dist/wiki/lock.js +11 -1
- package/dist/wiki/log-manager.js +2 -7
- package/dist/wiki/migrate.js +44 -17
- package/dist/wiki/project-registry.js +10 -5
- package/dist/wiki/project-registry.test.js +14 -0
- package/dist/wiki/scheduler.js +1 -1
- package/dist/wiki/seed-team-wiki.js +2 -1
- package/dist/wiki/team-sync.js +31 -6
- package/dist/wiki/team-sync.test.js +81 -0
- package/package.json +1 -1
- package/web/dist/assets/WikiEdit-BZXAdarz.js +30 -0
- package/web/dist/assets/WikiEdit-BZXAdarz.js.map +1 -0
- package/web/dist/assets/WikiGraph-KrCYco4v.js +2 -0
- package/web/dist/assets/WikiGraph-KrCYco4v.js.map +1 -0
- package/web/dist/assets/index-CUm2Wbuh.js +250 -0
- package/web/dist/assets/index-CUm2Wbuh.js.map +1 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/index-iQrv3lQN.js +0 -286
- package/web/dist/assets/index-iQrv3lQN.js.map +0 -1
|
@@ -10,6 +10,10 @@ function createFakeClient(state) {
|
|
|
10
10
|
this.options = options;
|
|
11
11
|
}
|
|
12
12
|
on(eventName, handler) {
|
|
13
|
+
state.sessionOnCalls++;
|
|
14
|
+
if (state.sessionOnThrowAt === state.sessionOnCalls) {
|
|
15
|
+
throw new Error(`session.on failed for ${eventName}`);
|
|
16
|
+
}
|
|
13
17
|
const handlers = this.listeners.get(eventName) || [];
|
|
14
18
|
handlers.push(handler);
|
|
15
19
|
this.listeners.set(eventName, handlers);
|
|
@@ -67,6 +71,7 @@ function createFakeClient(state) {
|
|
|
67
71
|
const session = new FakeSession(options);
|
|
68
72
|
state.lastSession = {
|
|
69
73
|
emit: (eventName, data) => session.emit(eventName, data),
|
|
74
|
+
listenerCount: (eventName) => session.listeners.get(eventName)?.length ?? 0,
|
|
70
75
|
};
|
|
71
76
|
return session;
|
|
72
77
|
},
|
|
@@ -116,6 +121,9 @@ async function loadOrchestratorModule(t, overrides = {}) {
|
|
|
116
121
|
],
|
|
117
122
|
sendResult: "Finished successfully",
|
|
118
123
|
promptMemoryContexts: [],
|
|
124
|
+
loggerWarnings: [],
|
|
125
|
+
loggerErrors: [],
|
|
126
|
+
sessionOnCalls: 0,
|
|
119
127
|
taskEvents: new Map(),
|
|
120
128
|
projectRegistry: {},
|
|
121
129
|
resolveProjectArgs: [],
|
|
@@ -130,6 +138,7 @@ async function loadOrchestratorModule(t, overrides = {}) {
|
|
|
130
138
|
housekeepingRuns: [],
|
|
131
139
|
housekeepingInFlight: false,
|
|
132
140
|
activeScope: makeScope(1, "chapterhouse", "Chapterhouse", "Core Chapterhouse work."),
|
|
141
|
+
stopClassifierCalls: 0,
|
|
133
142
|
wikiPages: [{ title: "Chapterhouse", summary: "wiki summary", last_updated: "2026-05-13" }],
|
|
134
143
|
...overrides,
|
|
135
144
|
};
|
|
@@ -153,6 +162,19 @@ async function loadOrchestratorModule(t, overrides = {}) {
|
|
|
153
162
|
},
|
|
154
163
|
},
|
|
155
164
|
});
|
|
165
|
+
t.mock.module("../util/logger.js", {
|
|
166
|
+
namedExports: {
|
|
167
|
+
childLogger: () => ({
|
|
168
|
+
info: () => { },
|
|
169
|
+
warn: (...args) => {
|
|
170
|
+
state.loggerWarnings.push(args);
|
|
171
|
+
},
|
|
172
|
+
error: (...args) => {
|
|
173
|
+
state.loggerErrors.push(args);
|
|
174
|
+
},
|
|
175
|
+
}),
|
|
176
|
+
},
|
|
177
|
+
});
|
|
156
178
|
t.mock.module("../config.js", {
|
|
157
179
|
namedExports: {
|
|
158
180
|
config: state.config,
|
|
@@ -417,8 +439,13 @@ async function loadOrchestratorModule(t, overrides = {}) {
|
|
|
417
439
|
deleteState: (key) => {
|
|
418
440
|
state.store.delete(key);
|
|
419
441
|
},
|
|
420
|
-
getCopilotSession: (
|
|
421
|
-
upsertCopilotSession: (
|
|
442
|
+
getCopilotSession: (sessionKey) => state.copilotSessions?.get(sessionKey),
|
|
443
|
+
upsertCopilotSession: (sessionKey, _mode, copilotSessionId, _projectRoot, model) => {
|
|
444
|
+
state.copilotSessions?.set(sessionKey, { copilotSessionId, model });
|
|
445
|
+
},
|
|
446
|
+
deleteCopilotSession: (sessionKey) => {
|
|
447
|
+
state.copilotSessions?.delete(sessionKey);
|
|
448
|
+
},
|
|
422
449
|
getTaskSessionKey: (taskId) => state.taskSessionKeys?.get(taskId) ?? "default",
|
|
423
450
|
getDb: () => ({
|
|
424
451
|
prepare: (sql) => ({
|
|
@@ -485,6 +512,13 @@ async function loadOrchestratorModule(t, overrides = {}) {
|
|
|
485
512
|
},
|
|
486
513
|
},
|
|
487
514
|
});
|
|
515
|
+
t.mock.module("./classifier.js", {
|
|
516
|
+
namedExports: {
|
|
517
|
+
stopClassifier: () => {
|
|
518
|
+
state.stopClassifierCalls++;
|
|
519
|
+
},
|
|
520
|
+
},
|
|
521
|
+
});
|
|
488
522
|
t.mock.module("./agents.js", {
|
|
489
523
|
namedExports: {
|
|
490
524
|
loadAgents: () => {
|
|
@@ -904,8 +938,8 @@ test("sendToOrchestrator logs both sides, remembers web auth context, and record
|
|
|
904
938
|
assert.equal(final, "All green");
|
|
905
939
|
assert.deepEqual(state.sessionPrompts, [{ prompt: "[via web] Summarize the deployment" }]);
|
|
906
940
|
assert.deepEqual(state.routerArgs, [["[via web] Summarize the deployment", "claude-sonnet-4.6", []]]);
|
|
907
|
-
assert.
|
|
908
|
-
assert.equal(orchestrator.getCurrentAuthorizationHeader(),
|
|
941
|
+
assert.equal(orchestrator.getCurrentAuthenticatedUser(), undefined);
|
|
942
|
+
assert.equal(orchestrator.getCurrentAuthorizationHeader(), undefined);
|
|
909
943
|
assert.deepEqual(orchestrator.getLastAuthenticatedUser(), user);
|
|
910
944
|
assert.deepEqual(orchestrator.getLastRouteResult(), {
|
|
911
945
|
model: "claude-sonnet-4.6",
|
|
@@ -1438,6 +1472,47 @@ test("feedAgentResult emits a delta even when the agent result is empty", async
|
|
|
1438
1472
|
assert.equal(deltas.length, 1);
|
|
1439
1473
|
assert.deepEqual(deltas[0]?.part, { type: "text", text: "" });
|
|
1440
1474
|
});
|
|
1475
|
+
test("feedAgentResult logs fire-and-forget acknowledgement failures instead of leaking rejections", async (t) => {
|
|
1476
|
+
const { orchestrator, state } = await loadOrchestratorModule(t, {
|
|
1477
|
+
config: { copilotModel: "claude-sonnet-4.6", selfEditEnabled: true },
|
|
1478
|
+
});
|
|
1479
|
+
const unhandled = [];
|
|
1480
|
+
const onUnhandledRejection = (reason) => {
|
|
1481
|
+
unhandled.push(reason);
|
|
1482
|
+
};
|
|
1483
|
+
process.on("unhandledRejection", onUnhandledRejection);
|
|
1484
|
+
t.after(() => {
|
|
1485
|
+
process.off("unhandledRejection", onUnhandledRejection);
|
|
1486
|
+
});
|
|
1487
|
+
t.mock.method(global, "setImmediate", (() => {
|
|
1488
|
+
throw new Error("setImmediate exploded");
|
|
1489
|
+
}));
|
|
1490
|
+
orchestrator.feedAgentResult("task-uninitialized", "coder", "done");
|
|
1491
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1492
|
+
assert.deepEqual(unhandled, []);
|
|
1493
|
+
assert.ok(state.loggerErrors.some((entry) => entry[1] === "unhandled rejection in feedAgentResult"), "feedAgentResult should log fire-and-forget failures");
|
|
1494
|
+
});
|
|
1495
|
+
test("sendToOrchestrator logs top-level async callback failures on error delivery", async (t) => {
|
|
1496
|
+
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
1497
|
+
config: { copilotModel: "claude-sonnet-4.6", selfEditEnabled: false },
|
|
1498
|
+
createSessionError: "fatal: SDK host permanently unavailable",
|
|
1499
|
+
});
|
|
1500
|
+
const unhandled = [];
|
|
1501
|
+
const onUnhandledRejection = (reason) => {
|
|
1502
|
+
unhandled.push(reason);
|
|
1503
|
+
};
|
|
1504
|
+
process.on("unhandledRejection", onUnhandledRejection);
|
|
1505
|
+
t.after(() => {
|
|
1506
|
+
process.off("unhandledRejection", onUnhandledRejection);
|
|
1507
|
+
});
|
|
1508
|
+
await orchestrator.initOrchestrator(client);
|
|
1509
|
+
orchestrator.sendToOrchestrator("hello", { type: "background" }, () => {
|
|
1510
|
+
throw new Error("callback exploded");
|
|
1511
|
+
});
|
|
1512
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
1513
|
+
assert.deepEqual(unhandled, []);
|
|
1514
|
+
assert.ok(state.loggerErrors.some((entry) => entry[1] === "unhandled rejection in sendToOrchestrator"), "sendToOrchestrator should log top-level async failures");
|
|
1515
|
+
});
|
|
1441
1516
|
test("enqueueForSse emits exactly one turn lifecycle pair for sse-web turns", async (t) => {
|
|
1442
1517
|
const { orchestrator, client } = await loadOrchestratorModule(t, {
|
|
1443
1518
|
config: {
|
|
@@ -1698,10 +1773,54 @@ test("shutdownAgents disconnects all sessions, clears maps, and clears active ta
|
|
|
1698
1773
|
await orchestrator.shutdownAgents();
|
|
1699
1774
|
assert.equal(state.disconnectCalls, 2, "disconnect must be called once per session (default + chat:1)");
|
|
1700
1775
|
assert.equal(state.clearActiveTasksCalls, 1, "clearActiveTasks must be called exactly once");
|
|
1776
|
+
assert.equal(state.stopClassifierCalls, 1, "classifier session must be stopped during shutdown");
|
|
1701
1777
|
// Re-init after shutdown must create a fresh session (proves sessionMap was cleared)
|
|
1702
1778
|
await orchestrator.initOrchestrator(client);
|
|
1703
1779
|
assert.ok(state.createSessionCalls.length > sessionsAfterInit + 1, "re-init after shutdown must create a new session (not reuse stale sessionMap entry)");
|
|
1704
1780
|
});
|
|
1781
|
+
test("failed persistent session resume clears the stored session id even when replacement creation fails", async (t) => {
|
|
1782
|
+
const copilotSessions = new Map([
|
|
1783
|
+
["chat:stale", { copilotSessionId: "stale-session", model: "claude-sonnet-4.6" }],
|
|
1784
|
+
]);
|
|
1785
|
+
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
1786
|
+
config: { copilotModel: "claude-sonnet-4.6", selfEditEnabled: false },
|
|
1787
|
+
copilotSessions,
|
|
1788
|
+
createSessionError: "replacement create failed",
|
|
1789
|
+
});
|
|
1790
|
+
await orchestrator.initOrchestrator(client);
|
|
1791
|
+
let final = "";
|
|
1792
|
+
orchestrator.sendToOrchestrator("hello", { type: "background", sessionKey: "chat:stale" }, (text, done) => {
|
|
1793
|
+
if (done)
|
|
1794
|
+
final = text;
|
|
1795
|
+
});
|
|
1796
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1797
|
+
assert.deepEqual(state.resumeSessionCalls.map((call) => call.savedId), ["stale-session"]);
|
|
1798
|
+
assert.match(final, /replacement create failed/);
|
|
1799
|
+
assert.equal(copilotSessions.has("chat:stale"), false, "failed resume must remove the stale persisted session id");
|
|
1800
|
+
});
|
|
1801
|
+
test("subagent completion logs a warning when the persisted result is truncated", async (t) => {
|
|
1802
|
+
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
1803
|
+
config: { copilotModel: "claude-sonnet-4.6", selfEditEnabled: false },
|
|
1804
|
+
sendResult: "__PENDING__",
|
|
1805
|
+
});
|
|
1806
|
+
await orchestrator.initOrchestrator(client);
|
|
1807
|
+
orchestrator.sendToOrchestrator("run a subagent", { type: "background", sessionKey: "chat:long-result" }, () => { });
|
|
1808
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1809
|
+
const longContent = "x".repeat(10_001);
|
|
1810
|
+
state.lastSession?.emit("subagent.started", {
|
|
1811
|
+
toolCallId: "task-long",
|
|
1812
|
+
agentName: "coder",
|
|
1813
|
+
agentDisplayName: "Coder",
|
|
1814
|
+
agentDescription: "Writes code",
|
|
1815
|
+
});
|
|
1816
|
+
state.lastSession?.emit("subagent.completed", {
|
|
1817
|
+
toolCallId: "task-long",
|
|
1818
|
+
result: { content: longContent },
|
|
1819
|
+
});
|
|
1820
|
+
assert.ok(state.loggerWarnings.some((args) => JSON.stringify(args).includes("truncated")), "long subagent result truncation should be logged");
|
|
1821
|
+
state.pendingReject?.(new Error("test teardown"));
|
|
1822
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1823
|
+
});
|
|
1705
1824
|
// ---------------------------------------------------------------------------
|
|
1706
1825
|
// feedAgentResult routes to the correct non-default session
|
|
1707
1826
|
// ---------------------------------------------------------------------------
|
|
@@ -1762,6 +1881,21 @@ test("ensureOrchestratorSession cleans up in-flight promise on session creation
|
|
|
1762
1881
|
// agent_tasks, so agent dispatches were invisible.
|
|
1763
1882
|
// Fix: unconditional DB subscriptions in executeOnSession write/update rows.
|
|
1764
1883
|
// ---------------------------------------------------------------------------
|
|
1884
|
+
test("executeOnSession unsubscribes partially registered listeners when setup throws", async (t) => {
|
|
1885
|
+
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
1886
|
+
sendResult: "__PENDING__",
|
|
1887
|
+
sessionOnThrowAt: 2,
|
|
1888
|
+
});
|
|
1889
|
+
await orchestrator.initOrchestrator(client);
|
|
1890
|
+
const received = [];
|
|
1891
|
+
orchestrator.sendToOrchestrator("dispatch something", { type: "background" }, (text, done) => {
|
|
1892
|
+
received.push({ text, done });
|
|
1893
|
+
});
|
|
1894
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
1895
|
+
assert.ok(state.lastSession, "FakeSession should have been created");
|
|
1896
|
+
assert.equal(state.lastSession.listenerCount("tool.execution_start"), 0, "setup failures must clean up listeners that were already registered");
|
|
1897
|
+
assert.ok(received.some((entry) => entry.done && entry.text.startsWith("Error:")), "setup failure should still surface as a turn error");
|
|
1898
|
+
});
|
|
1765
1899
|
test("S5-01: subagent.started event inserts an adhoc row into agent_tasks", async (t) => {
|
|
1766
1900
|
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
1767
1901
|
sendResult: "__PENDING__",
|
|
@@ -2070,6 +2204,7 @@ test("#98: interruptCurrentTurn aborts active turn and starts replacement turn",
|
|
|
2070
2204
|
await orchestrator.interruptCurrentTurn("default", "replacement request", { type: "background" }, (text, done) => { if (done)
|
|
2071
2205
|
secondResults.push(text); }, undefined, undefined, (abortedId) => { interruptedTurnId = abortedId; });
|
|
2072
2206
|
assert.equal(state.abortCalls, 1, "abort must be called exactly once");
|
|
2207
|
+
assert.ok(interruptedTurnId, "interrupt callback should report the aborted turn id");
|
|
2073
2208
|
// Resolve the pending session so the replacement turn can complete
|
|
2074
2209
|
state.pendingReject?.(new Error("aborted"));
|
|
2075
2210
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
@@ -15,30 +15,26 @@
|
|
|
15
15
|
// CHAPTERHOUSE_SESSION_MAX_ACTIVE (default: 20)
|
|
16
16
|
// ---------------------------------------------------------------------------
|
|
17
17
|
import { childLogger } from "../util/logger.js";
|
|
18
|
+
import { config } from "../config.js";
|
|
18
19
|
const log = childLogger("session-manager");
|
|
19
20
|
// ---------------------------------------------------------------------------
|
|
20
21
|
// Env-configurable eviction parameters
|
|
21
22
|
// ---------------------------------------------------------------------------
|
|
22
|
-
const
|
|
23
|
-
export const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (!isNaN(parsed) && parsed > 0)
|
|
38
|
-
return parsed;
|
|
39
|
-
}
|
|
40
|
-
return DEFAULT_SESSION_MAX_ACTIVE;
|
|
41
|
-
})();
|
|
23
|
+
export const SESSION_IDLE_TTL_MS = config.sessionIdleTtlMs;
|
|
24
|
+
export const SESSION_MAX_ACTIVE = config.sessionMaxActive;
|
|
25
|
+
export class SessionCapacityError extends Error {
|
|
26
|
+
statusCode = 503;
|
|
27
|
+
expose = true;
|
|
28
|
+
activeSessions;
|
|
29
|
+
maxActive;
|
|
30
|
+
constructor(sessionKey, activeSessions, maxActive) {
|
|
31
|
+
super(`Cannot create session '${sessionKey}': maximum active sessions (${maxActive}) reached and no idle session is available to evict.`);
|
|
32
|
+
this.name = "SessionCapacityError";
|
|
33
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
34
|
+
this.activeSessions = activeSessions;
|
|
35
|
+
this.maxActive = maxActive;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
42
38
|
// ---------------------------------------------------------------------------
|
|
43
39
|
// SessionManager — owns one session key
|
|
44
40
|
// ---------------------------------------------------------------------------
|
|
@@ -310,8 +306,10 @@ export class SessionRegistry {
|
|
|
310
306
|
const existing = this.managers.get(sessionKey);
|
|
311
307
|
if (existing)
|
|
312
308
|
return existing;
|
|
313
|
-
if (this.nonPersistentSize() >= this.options.maxActive) {
|
|
314
|
-
this.evictLRU()
|
|
309
|
+
if (!this.isPersistentSessionKey(sessionKey) && this.nonPersistentSize() >= this.options.maxActive) {
|
|
310
|
+
if (!this.evictLRU()) {
|
|
311
|
+
throw new SessionCapacityError(sessionKey, this.nonPersistentSize(), this.options.maxActive);
|
|
312
|
+
}
|
|
315
313
|
}
|
|
316
314
|
const manager = this.createManager(sessionKey);
|
|
317
315
|
this.managers.set(sessionKey, manager);
|
|
@@ -384,16 +382,20 @@ export class SessionRegistry {
|
|
|
384
382
|
.sort(([, a], [, b]) => a.lastActivityAt - b.lastActivityAt);
|
|
385
383
|
if (evictable.length === 0) {
|
|
386
384
|
log.warn({ size: this.managers.size, max: this.options.maxActive }, "At max active sessions and no idle sessions available for LRU eviction");
|
|
387
|
-
return;
|
|
385
|
+
return false;
|
|
388
386
|
}
|
|
389
387
|
const [sessionKey, manager] = evictable[0];
|
|
390
388
|
this.managers.delete(sessionKey);
|
|
391
389
|
void manager.evict("lru-bumped");
|
|
392
390
|
log.info({ sessionKey, reason: "lru-bumped" }, "session.evicted");
|
|
391
|
+
return true;
|
|
393
392
|
}
|
|
394
393
|
nonPersistentSize() {
|
|
395
394
|
return [...this.managers.values()].filter((manager) => !manager.isPersistent).length;
|
|
396
395
|
}
|
|
396
|
+
isPersistentSessionKey(sessionKey) {
|
|
397
|
+
return sessionKey.startsWith("agent:");
|
|
398
|
+
}
|
|
397
399
|
/** Shut down all sessions. Stops the eviction timer and disconnects every session. */
|
|
398
400
|
async shutdown() {
|
|
399
401
|
this.stopEvictionTimer();
|
|
@@ -197,6 +197,41 @@ test("SessionRegistry: LRU eviction fires when at maxActive capacity", async ()
|
|
|
197
197
|
assert.ok(disconnectLog.includes("s1"), "s1 (oldest) should be LRU-evicted");
|
|
198
198
|
assert.ok(!registry.get("s1"), "s1 should be removed from registry");
|
|
199
199
|
});
|
|
200
|
+
test("SessionRegistry: getOrCreate rejects new non-persistent sessions when cap is full and all sessions are busy", async () => {
|
|
201
|
+
let unblock1;
|
|
202
|
+
let unblock2;
|
|
203
|
+
let callCount = 0;
|
|
204
|
+
const { registry } = (() => {
|
|
205
|
+
const r = new SessionRegistry({ idleTtlMs: 60_000, maxActive: 2 }, (sk) => {
|
|
206
|
+
const t = makeFakeSession();
|
|
207
|
+
const worker = () => {
|
|
208
|
+
callCount++;
|
|
209
|
+
return new Promise((res) => {
|
|
210
|
+
if (callCount === 1)
|
|
211
|
+
unblock1 = () => res("one");
|
|
212
|
+
else
|
|
213
|
+
unblock2 = () => res("two");
|
|
214
|
+
});
|
|
215
|
+
};
|
|
216
|
+
return new SessionManager(sk, worker, factory(t.session));
|
|
217
|
+
});
|
|
218
|
+
return { registry: r };
|
|
219
|
+
})();
|
|
220
|
+
const first = registry.getOrCreate("s1");
|
|
221
|
+
const second = registry.getOrCreate("s2");
|
|
222
|
+
first.enqueue(makeDeferred().item);
|
|
223
|
+
second.enqueue(makeDeferred().item);
|
|
224
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
225
|
+
assert.throws(() => registry.getOrCreate("s3"), (error) => error instanceof Error
|
|
226
|
+
&& error.name === "SessionCapacityError"
|
|
227
|
+
&& error.statusCode === 503
|
|
228
|
+
&& /maximum active sessions/i.test(error.message));
|
|
229
|
+
assert.equal(registry.size(), 2, "registry must not exceed maxActive");
|
|
230
|
+
assert.equal(registry.get("s3"), undefined);
|
|
231
|
+
unblock1();
|
|
232
|
+
unblock2();
|
|
233
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
234
|
+
});
|
|
200
235
|
test("SessionRegistry: explicit close evicts an idle session", async () => {
|
|
201
236
|
const { registry, disconnectLog } = makeRegistry();
|
|
202
237
|
const m = registry.getOrCreate("my-session");
|
|
@@ -208,7 +243,6 @@ test("SessionRegistry: explicit close evicts an idle session", async () => {
|
|
|
208
243
|
assert.ok(disconnectLog.includes("my-session"), "session should be disconnected");
|
|
209
244
|
});
|
|
210
245
|
test("SessionRegistry: close is deferred when session is processing", async () => {
|
|
211
|
-
const { registry, disconnectLog } = makeRegistry();
|
|
212
246
|
let unblock;
|
|
213
247
|
// Replace the factory with a blocking one
|
|
214
248
|
const { registry: reg2, disconnectLog: dl2 } = (() => {
|
package/dist/copilot/standup.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { config } from "../config.js";
|
|
2
|
-
import { sendToOrchestrator, getCurrentAuthorizationHeader, getCurrentAuthenticatedUser,
|
|
2
|
+
import { sendToOrchestrator, getCurrentAuthorizationHeader, getCurrentAuthenticatedUser, } from "./orchestrator.js";
|
|
3
3
|
import { TeamPushClient } from "../integrations/team-push.js";
|
|
4
4
|
import { getMyOkrsSummary } from "./tools.js";
|
|
5
5
|
import { readPage, writePage } from "../wiki/fs.js";
|
|
@@ -106,7 +106,7 @@ function formatDate(now) {
|
|
|
106
106
|
return `${year}-${month}-${day}`;
|
|
107
107
|
}
|
|
108
108
|
async function defaultGetMyOkrs(period) {
|
|
109
|
-
const getCurrentUser = () => getCurrentAuthenticatedUser()
|
|
109
|
+
const getCurrentUser = () => getCurrentAuthenticatedUser();
|
|
110
110
|
return await getMyOkrsSummary({
|
|
111
111
|
createTeamPushClient: () => new TeamPushClient({
|
|
112
112
|
getAuthorizationHeader: getCurrentAuthorizationHeader,
|
|
@@ -34,6 +34,7 @@ export const RING_BUFFER_CAPACITY = 500;
|
|
|
34
34
|
// ---------------------------------------------------------------------------
|
|
35
35
|
const taskBuffers = new Map();
|
|
36
36
|
const taskListeners = new Map();
|
|
37
|
+
let eventBusCleanup;
|
|
37
38
|
// ---------------------------------------------------------------------------
|
|
38
39
|
// Internal helpers
|
|
39
40
|
// ---------------------------------------------------------------------------
|
|
@@ -64,6 +65,9 @@ function notifyListeners(taskId, event) {
|
|
|
64
65
|
* Call once from initOrchestrator(). Returns an unsub / cleanup function.
|
|
65
66
|
*/
|
|
66
67
|
export function initTaskEventLog() {
|
|
68
|
+
if (eventBusCleanup) {
|
|
69
|
+
return eventBusCleanup;
|
|
70
|
+
}
|
|
67
71
|
const unsubToolCall = agentEventBus.subscribe("session:tool_call", (event) => {
|
|
68
72
|
const taskId = event.sessionId;
|
|
69
73
|
if (!taskId)
|
|
@@ -91,10 +95,12 @@ export function initTaskEventLog() {
|
|
|
91
95
|
if (event.sessionId)
|
|
92
96
|
clearTaskLog(event.sessionId);
|
|
93
97
|
});
|
|
94
|
-
|
|
98
|
+
eventBusCleanup = () => {
|
|
95
99
|
unsubToolCall();
|
|
96
100
|
unsubDestroyed();
|
|
101
|
+
eventBusCleanup = undefined;
|
|
97
102
|
};
|
|
103
|
+
return eventBusCleanup;
|
|
98
104
|
}
|
|
99
105
|
/**
|
|
100
106
|
* Return all ring-buffered events for a task, optionally filtered to seq > afterSeq.
|
|
@@ -128,6 +128,19 @@ describe("task event log — bus-wired", () => {
|
|
|
128
128
|
assert.equal(events[0].seq, 1);
|
|
129
129
|
clearTaskLog("task-001");
|
|
130
130
|
});
|
|
131
|
+
it("does not double-subscribe when initialized twice", () => {
|
|
132
|
+
const secondShutdown = initTaskEventLog();
|
|
133
|
+
try {
|
|
134
|
+
agentEventBus.emit(makeToolCallEvent({ sessionId: "task-idempotent", kind: "tool_start", seq: 1, toolName: "view" }));
|
|
135
|
+
const events = getTaskLogEvents("task-idempotent");
|
|
136
|
+
assert.equal(events.length, 1);
|
|
137
|
+
assert.equal(events[0].toolName, "view");
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
secondShutdown();
|
|
141
|
+
clearTaskLog("task-idempotent");
|
|
142
|
+
}
|
|
143
|
+
});
|
|
131
144
|
it("accumulates multiple events in order", () => {
|
|
132
145
|
agentEventBus.emit(makeToolCallEvent({ sessionId: "task-002", kind: "tool_start", seq: 1, toolName: "bash" }));
|
|
133
146
|
agentEventBus.emit(makeToolCallEvent({ sessionId: "task-002", kind: "tool_complete", seq: 2, summary: "ok" }));
|