@animalabs/connectome-host 0.3.1

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.
Files changed (123) hide show
  1. package/.env.example +20 -0
  2. package/.github/workflows/publish.yml +65 -0
  3. package/ARCHITECTURE.md +355 -0
  4. package/CHANGELOG.md +30 -0
  5. package/HEADLESS-FLEET-PLAN.md +330 -0
  6. package/README.md +189 -0
  7. package/UNIFIED-TREE-PLAN.md +242 -0
  8. package/bun.lock +288 -0
  9. package/docs/AGENT-MEMORY-GUIDE.md +214 -0
  10. package/docs/AGENT-ONBOARDING.md +541 -0
  11. package/docs/ATTENTION-AND-GATING.md +120 -0
  12. package/docs/DEPLOYMENTS.md +130 -0
  13. package/docs/DEV-ENVIRONMENT.md +215 -0
  14. package/docs/LIBRARY-PIPELINE.md +286 -0
  15. package/docs/LOCUS-ROUTING-DESIGN.md +115 -0
  16. package/docs/claudeai-evacuation.md +228 -0
  17. package/docs/debug-context-api.md +208 -0
  18. package/docs/webui-deployment.md +219 -0
  19. package/package.json +33 -0
  20. package/recipes/SETUP.md +308 -0
  21. package/recipes/TRIUMVIRATE-SETUP.md +467 -0
  22. package/recipes/claude-export-revive.json +19 -0
  23. package/recipes/clerk.json +157 -0
  24. package/recipes/knowledge-miner.json +174 -0
  25. package/recipes/knowledge-reviewer.json +76 -0
  26. package/recipes/mcpl-editor-test.json +38 -0
  27. package/recipes/prompts/transplant-addendum.md +17 -0
  28. package/recipes/triumvirate.json +63 -0
  29. package/recipes/webui-fleet-test.json +27 -0
  30. package/recipes/webui-test.json +16 -0
  31. package/recipes/zulip-miner.json +87 -0
  32. package/scripts/connectome-doctor +373 -0
  33. package/scripts/evacuator.ts +956 -0
  34. package/scripts/import-claudeai-export.ts +747 -0
  35. package/scripts/lib/line-reader.ts +53 -0
  36. package/scripts/test-historical-thinking.ts +148 -0
  37. package/scripts/warmup-session.ts +336 -0
  38. package/src/agent-name.ts +58 -0
  39. package/src/commands.ts +1074 -0
  40. package/src/headless.ts +528 -0
  41. package/src/index.ts +867 -0
  42. package/src/logging-adapter.ts +208 -0
  43. package/src/mcpl-config.ts +199 -0
  44. package/src/modules/activity-module.ts +270 -0
  45. package/src/modules/channel-mode-module.ts +260 -0
  46. package/src/modules/fleet-module.ts +1705 -0
  47. package/src/modules/fleet-types.ts +225 -0
  48. package/src/modules/lessons-module.ts +465 -0
  49. package/src/modules/mcpl-admin-module.ts +345 -0
  50. package/src/modules/retrieval-module.ts +327 -0
  51. package/src/modules/settings-module.ts +143 -0
  52. package/src/modules/subagent-module.ts +2074 -0
  53. package/src/modules/subscription-gc-module.ts +322 -0
  54. package/src/modules/time-module.ts +85 -0
  55. package/src/modules/tui-module.ts +76 -0
  56. package/src/modules/web-ui-curve-page.ts +249 -0
  57. package/src/modules/web-ui-module.ts +2595 -0
  58. package/src/recipe.ts +1003 -0
  59. package/src/session-manager.ts +278 -0
  60. package/src/state/agent-tree-reducer.ts +431 -0
  61. package/src/state/fleet-tree-aggregator.ts +195 -0
  62. package/src/strategies/frontdesk-strategy.ts +364 -0
  63. package/src/synesthete.ts +68 -0
  64. package/src/tui.ts +2200 -0
  65. package/src/types/bun-ffi.d.ts +6 -0
  66. package/src/web/protocol.ts +648 -0
  67. package/test/agent-name.test.ts +62 -0
  68. package/test/agent-tree-fleet-launch.test.ts +64 -0
  69. package/test/agent-tree-reducer-parity.test.ts +194 -0
  70. package/test/agent-tree-reducer.test.ts +443 -0
  71. package/test/agent-tree-rollup.test.ts +109 -0
  72. package/test/autobio-progress-snapshot.test.ts +40 -0
  73. package/test/claudeai-export-importer.test.ts +386 -0
  74. package/test/evacuator.test.ts +332 -0
  75. package/test/fleet-commands.test.ts +244 -0
  76. package/test/fleet-no-subfleets.test.ts +147 -0
  77. package/test/fleet-orchestration.test.ts +353 -0
  78. package/test/fleet-recipe.test.ts +124 -0
  79. package/test/fleet-route.test.ts +44 -0
  80. package/test/fleet-smoke.test.ts +479 -0
  81. package/test/fleet-subscribe-union-e2e.test.ts +123 -0
  82. package/test/fleet-subscribe-union.test.ts +85 -0
  83. package/test/fleet-tree-aggregator-e2e.test.ts +110 -0
  84. package/test/fleet-tree-aggregator.test.ts +215 -0
  85. package/test/frontdesk-strategy.test.ts +326 -0
  86. package/test/headless-describe.test.ts +182 -0
  87. package/test/headless-smoke.test.ts +190 -0
  88. package/test/logging-adapter.test.ts +87 -0
  89. package/test/mcpl-admin-module.test.ts +213 -0
  90. package/test/mcpl-agent-overlay.test.ts +126 -0
  91. package/test/mock-headless-child.ts +133 -0
  92. package/test/recipe-cache-ttl.test.ts +37 -0
  93. package/test/recipe-env.test.ts +157 -0
  94. package/test/recipe-path-resolution.test.ts +170 -0
  95. package/test/subagent-async-timeout.test.ts +381 -0
  96. package/test/subagent-fork-materialise.test.ts +241 -0
  97. package/test/subagent-peek-scoping.test.ts +180 -0
  98. package/test/subagent-peek-zombie.test.ts +148 -0
  99. package/test/subagent-reaper-in-flight.test.ts +229 -0
  100. package/test/subscription-gc-module.test.ts +136 -0
  101. package/test/warmup-handoff.test.ts +150 -0
  102. package/test/web-ui-bind.test.ts +51 -0
  103. package/test/web-ui-module.test.ts +246 -0
  104. package/test/web-ui-protocol.test.ts +0 -0
  105. package/tsconfig.json +14 -0
  106. package/web/bun.lock +357 -0
  107. package/web/index.html +12 -0
  108. package/web/package.json +24 -0
  109. package/web/src/App.tsx +1931 -0
  110. package/web/src/Context.tsx +158 -0
  111. package/web/src/ContextDocument.tsx +150 -0
  112. package/web/src/Files.tsx +271 -0
  113. package/web/src/Lessons.tsx +164 -0
  114. package/web/src/Mcpl.tsx +310 -0
  115. package/web/src/Stream.tsx +119 -0
  116. package/web/src/TreeSidebar.tsx +283 -0
  117. package/web/src/Usage.tsx +182 -0
  118. package/web/src/main.tsx +7 -0
  119. package/web/src/styles.css +26 -0
  120. package/web/src/tree.ts +268 -0
  121. package/web/src/wire.ts +120 -0
  122. package/web/tsconfig.json +21 -0
  123. package/web/vite.config.ts +32 -0
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Postmortem 2026-05-28 P3 #8: scope `peek` and the `gatherContext` HUD to
3
+ * the caller's descendants only. Today both surfaces show the global roster:
4
+ *
5
+ * - `peek()` (no arg) returns snapshots for every running subagent in the
6
+ * fleet, including peers and cousins of the caller.
7
+ * - `peek(name)` accepts any name — there's no descendant check.
8
+ * - `gatherContext` iterates `activeSubagents` directly with no parent
9
+ * filter, so when the HUD is on the caller's context is injected with
10
+ * the full fleet roster every turn.
11
+ *
12
+ * This invites a "coordinate with peers" anti-pattern (visible in the
13
+ * production miner's narration: scouts gossiping about siblings being
14
+ * zombied) and lets one slow scout's status saturate the orchestrator's
15
+ * context and trigger retry-storm spawn behavior.
16
+ *
17
+ * The fix uses `parentMap` (already maintained for cancelChildren) to walk
18
+ * descendants. A caller sees only its own subtree — peers and ancestors are
19
+ * filtered out.
20
+ */
21
+ import { describe, test, expect } from 'bun:test';
22
+ import {
23
+ SubagentModule,
24
+ type ActiveSubagent,
25
+ } from '../src/modules/subagent-module.js';
26
+
27
+ interface FakeLiveState {
28
+ frameworkAgentName: string;
29
+ displayName: string;
30
+ systemPrompt: string;
31
+ contextManager: { compile: () => Promise<{ messages: unknown[] }> };
32
+ currentStream: string;
33
+ pendingToolCalls: Array<{ name: string; input?: unknown }>;
34
+ activeCallIds: Set<string>;
35
+ requestInFlightSince?: number;
36
+ }
37
+
38
+ /** Build a peek-ready SubagentModule with a tree of subagents wired up:
39
+ *
40
+ * root ('top')
41
+ * ├── child-a (parent: root)
42
+ * │ └── grandchild-a1 (parent: child-a)
43
+ * ├── child-b (parent: root)
44
+ * └── peer (parent: 'other-root') — NOT in the tree
45
+ *
46
+ * Caller framework agent names: root = 'fw-top', child-a = 'fw-a',
47
+ * child-b = 'fw-b', grandchild-a1 = 'fw-a1', peer = 'fw-peer'.
48
+ */
49
+ function makeTree(): SubagentModule {
50
+ const mod = new SubagentModule();
51
+ const privateView = mod as unknown as {
52
+ liveSubagents: Map<string, FakeLiveState>;
53
+ frameworkNameIndex: Map<string, string>;
54
+ };
55
+
56
+ const installOne = (displayName: string, frameworkAgentName: string, parent?: string): void => {
57
+ const live: FakeLiveState = {
58
+ frameworkAgentName,
59
+ displayName,
60
+ systemPrompt: 'test',
61
+ contextManager: { compile: async () => ({ messages: [] }) },
62
+ currentStream: '',
63
+ pendingToolCalls: [],
64
+ activeCallIds: new Set(),
65
+ };
66
+ privateView.liveSubagents.set(displayName, live);
67
+ privateView.frameworkNameIndex.set(frameworkAgentName, displayName);
68
+ const entry: ActiveSubagent = {
69
+ name: displayName,
70
+ type: 'spawn',
71
+ task: `task-${displayName}`,
72
+ status: 'running',
73
+ startedAt: Date.now(),
74
+ lastActivityAt: Date.now(),
75
+ toolCallsCount: 0,
76
+ findingsCount: 0,
77
+ };
78
+ mod.activeSubagents.set(`entry-${displayName}`, entry);
79
+ if (parent !== undefined) mod.parentMap.set(displayName, parent);
80
+ };
81
+
82
+ installOne('child-a', 'fw-a', 'fw-top');
83
+ installOne('child-b', 'fw-b', 'fw-top');
84
+ installOne('grandchild-a1', 'fw-a1', 'fw-a');
85
+ installOne('peer', 'fw-peer', 'fw-other-root');
86
+
87
+ return mod;
88
+ }
89
+
90
+ describe('SubagentModule peek + HUD descendant scoping (P3 #8)', () => {
91
+ test('peek() with no name and a caller returns only descendants', async () => {
92
+ const mod = makeTree();
93
+ // The top-level caller ('fw-top') owns child-a, child-b, grandchild-a1
94
+ // (transitively). It does NOT own peer.
95
+ const callerView = mod as unknown as {
96
+ handlePeek: (input: { name?: string }, caller?: string) => Promise<{ success: boolean; data: unknown }>;
97
+ };
98
+ const result = await callerView.handlePeek({}, 'fw-top');
99
+ const data = result.data as Array<{ name: string }>;
100
+ const names = new Set(data.map(s => s.name));
101
+ expect(names.has('child-a')).toBe(true);
102
+ expect(names.has('child-b')).toBe(true);
103
+ expect(names.has('grandchild-a1')).toBe(true);
104
+ expect(names.has('peer')).toBe(false);
105
+ });
106
+
107
+ test('peek() called by a mid-tree caller scopes to that subtree', async () => {
108
+ const mod = makeTree();
109
+ const callerView = mod as unknown as {
110
+ handlePeek: (input: { name?: string }, caller?: string) => Promise<{ success: boolean; data: unknown }>;
111
+ };
112
+ // child-a's subtree is just grandchild-a1 — siblings (child-b) and
113
+ // cousins (peer) and ancestors (root) must be invisible.
114
+ const result = await callerView.handlePeek({}, 'fw-a');
115
+ const data = result.data as Array<{ name: string }>;
116
+ const names = new Set(data.map(s => s.name));
117
+ expect(names.has('grandchild-a1')).toBe(true);
118
+ expect(names.has('child-b')).toBe(false);
119
+ expect(names.has('peer')).toBe(false);
120
+ expect(names.has('child-a')).toBe(false); // caller doesn't see itself
121
+ });
122
+
123
+ test('peek(name) on a non-descendant returns empty even if the name exists', async () => {
124
+ const mod = makeTree();
125
+ const callerView = mod as unknown as {
126
+ handlePeek: (input: { name?: string }, caller?: string) => Promise<{ success: boolean; data: unknown }>;
127
+ };
128
+ // child-a tries to peek peer (which exists fleet-wide but is not a
129
+ // descendant of child-a). Should return "no such subagent" from
130
+ // child-a's perspective.
131
+ const result = await callerView.handlePeek({ name: 'peer' }, 'fw-a');
132
+ expect(result.data).toEqual({ message: "No running subagent named 'peer'" });
133
+ });
134
+
135
+ test('peek(name) on a descendant works as before', async () => {
136
+ const mod = makeTree();
137
+ const callerView = mod as unknown as {
138
+ handlePeek: (input: { name?: string }, caller?: string) => Promise<{ success: boolean; data: unknown }>;
139
+ };
140
+ const result = await callerView.handlePeek({ name: 'grandchild-a1' }, 'fw-a');
141
+ const data = result.data as Array<{ name: string }>;
142
+ expect(data.length).toBe(1);
143
+ expect(data[0].name).toBe('grandchild-a1');
144
+ });
145
+
146
+ test('peek() without a caller (e.g. internal call) still sees everything', async () => {
147
+ // Backward compat: not all callers thread an identity through (e.g.
148
+ // internal observability paths, tests). When no caller is provided, the
149
+ // descendant filter is bypassed.
150
+ const mod = makeTree();
151
+ const all = await mod.peek();
152
+ expect(all.length).toBe(4); // child-a + child-b + grandchild-a1 + peer
153
+ });
154
+
155
+ test('gatherContext HUD is scoped to descendants of the calling agent', async () => {
156
+ const mod = makeTree();
157
+ // Force the HUD on by directly setting the persisted flag's source — the
158
+ // module checks ctx?.getState().hudEnabled, but in this minimal test
159
+ // there's no ctx, so the early return at the top of gatherContext would
160
+ // bail. Pre-fix the function returned [] without ctx; post-fix the
161
+ // descendant filter must still be exercised. To keep the test focused on
162
+ // scoping (not on persistence wiring), stub ctx with a hudEnabled=true
163
+ // state-bag and an empty setState.
164
+ (mod as unknown as { ctx: unknown }).ctx = {
165
+ getState: () => ({ hudEnabled: true }),
166
+ setState: () => {},
167
+ };
168
+ const injections = await mod.gatherContext('fw-a');
169
+ expect(injections.length).toBeGreaterThan(0);
170
+ const text = (injections[0].content[0] as { text: string }).text;
171
+ // Each HUD line begins with two spaces then the subagent name and ' [type]'.
172
+ // Anchor on that to avoid substring collisions (grandchild-a1 contains
173
+ // the literal 'child-a' as a substring, which would make a naïve
174
+ // includes() check false-positive).
175
+ expect(text).toMatch(/^\s*grandchild-a1 \[/m);
176
+ expect(text).not.toMatch(/^\s*child-b \[/m);
177
+ expect(text).not.toMatch(/^\s*peer \[/m);
178
+ expect(text).not.toMatch(/^\s*child-a \[/m); // caller's own entry not surfaced
179
+ });
180
+ });
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Postmortem 2026-05-28 F2: dual-clock zombie bug in SubagentModule.
3
+ *
4
+ * The reaper (reclaimZombieSlots) was correctly updated to use `lastActivityAt`
5
+ * — the merciful clock that distinguishes "long-running but progressing" from
6
+ * "silently stuck." The peek observability surface (peekOne / isZombie field)
7
+ * still uses `startedAt`, which flags ANY subagent older than 30s as a zombie
8
+ * the moment it's between tokens.
9
+ *
10
+ * This false positive saturates orchestrator context: scouts narrate about
11
+ * peer zombies, the orchestrator re-spawns them as -retryN, abandoned
12
+ * originals get aborted, the admin UI paints them red. The reap doesn't even
13
+ * have to happen for the symptom to manifest.
14
+ *
15
+ * These tests assert the merciful (lastActivityAt-based) semantics from peek's
16
+ * point of view. Each test seeds the module's private maps directly to keep
17
+ * the surface small — `peek()` does not need a full framework / context-manager
18
+ * stack to exercise the predicate.
19
+ */
20
+ import { describe, test, expect } from 'bun:test';
21
+ import { SubagentModule, type ActiveSubagent } from '../src/modules/subagent-module.js';
22
+
23
+ interface FakeLiveState {
24
+ frameworkAgentName: string;
25
+ displayName: string;
26
+ systemPrompt: string;
27
+ contextManager: { compile: () => Promise<{ messages: unknown[] }> };
28
+ currentStream: string;
29
+ pendingToolCalls: Array<{ name: string; input?: unknown }>;
30
+ activeCallIds: Set<string>;
31
+ }
32
+
33
+ /** Build a peek-ready SubagentModule with one synthetic subagent installed. */
34
+ function makeModuleWithSubagent(opts: {
35
+ displayName: string;
36
+ startedAt: number;
37
+ lastActivityAt: number;
38
+ currentStream?: string;
39
+ pendingToolCalls?: Array<{ name: string; input?: unknown }>;
40
+ status?: 'running' | 'completed' | 'failed';
41
+ }): SubagentModule {
42
+ const mod = new SubagentModule();
43
+ const live: FakeLiveState = {
44
+ frameworkAgentName: `fw-${opts.displayName}`,
45
+ displayName: opts.displayName,
46
+ systemPrompt: 'test',
47
+ // Minimal stub — peekOne wraps compile() in try/catch and treats failures as
48
+ // "context manager mid-modification, return what we have," so this is fine.
49
+ contextManager: { compile: async () => ({ messages: [] }) },
50
+ currentStream: opts.currentStream ?? '',
51
+ pendingToolCalls: opts.pendingToolCalls ?? [],
52
+ activeCallIds: new Set(),
53
+ };
54
+ const entry: ActiveSubagent = {
55
+ name: opts.displayName,
56
+ type: 'spawn',
57
+ task: 'test-task',
58
+ status: opts.status ?? 'running',
59
+ startedAt: opts.startedAt,
60
+ lastActivityAt: opts.lastActivityAt,
61
+ toolCallsCount: 0,
62
+ findingsCount: 0,
63
+ };
64
+
65
+ // `liveSubagents` is private — go around the visibility modifier. The map is
66
+ // the canonical source for peek; we keep the test's surface minimal by
67
+ // skipping the spawn/fork wiring.
68
+ (mod as unknown as { liveSubagents: Map<string, FakeLiveState> }).liveSubagents.set(
69
+ opts.displayName,
70
+ live,
71
+ );
72
+ // activeSubagents is `readonly` (re-binding the Map is what's forbidden), so
73
+ // mutating it directly is fine.
74
+ mod.activeSubagents.set(`entry-${opts.displayName}`, entry);
75
+ return mod;
76
+ }
77
+
78
+ describe('SubagentModule.peek — F2 dual-clock zombie predicate', () => {
79
+ test('healthy long-running scout is NOT zombie (lastActivityAt fresh)', async () => {
80
+ // Productive scout 5 minutes in, last bumped activity 5s ago, currently
81
+ // between an inference round and its first token. Pre-fix: peek reports
82
+ // isZombie=true because elapsedMs from startedAt is 5min > 30s. Post-fix:
83
+ // peek consults lastActivityAt, sees fresh activity, reports isZombie=false.
84
+ const now = Date.now();
85
+ const mod = makeModuleWithSubagent({
86
+ displayName: 'scout',
87
+ startedAt: now - 5 * 60_000,
88
+ lastActivityAt: now - 5_000,
89
+ });
90
+ const [snap] = await mod.peek('scout');
91
+ expect(snap).toBeDefined();
92
+ expect(snap!.isZombie).toBe(false);
93
+ });
94
+
95
+ test('genuinely stuck subagent IS zombie (lastActivityAt stale, no stream/tools)', async () => {
96
+ // Activity stopped 5 minutes ago with no current stream and no pending
97
+ // tools — the case the reaper exists to catch. Silence picked well above
98
+ // both 30s (old) and 120s (post-F3) thresholds so the test stays valid
99
+ // regardless of future threshold tuning.
100
+ const now = Date.now();
101
+ const mod = makeModuleWithSubagent({
102
+ displayName: 'stuck',
103
+ startedAt: now - 10 * 60_000,
104
+ lastActivityAt: now - 5 * 60_000,
105
+ });
106
+ const [snap] = await mod.peek('stuck');
107
+ expect(snap!.isZombie).toBe(true);
108
+ });
109
+
110
+ test('subagent currently streaming is NOT zombie even with old lastActivityAt', async () => {
111
+ // currentStream non-empty short-circuits the predicate regardless of clocks.
112
+ const now = Date.now();
113
+ const mod = makeModuleWithSubagent({
114
+ displayName: 'streaming',
115
+ startedAt: now - 10 * 60_000,
116
+ lastActivityAt: now - 5 * 60_000,
117
+ currentStream: 'tokens flowing',
118
+ });
119
+ const [snap] = await mod.peek('streaming');
120
+ expect(snap!.isZombie).toBe(false);
121
+ });
122
+
123
+ test('subagent with pending tool calls is NOT zombie', async () => {
124
+ // pendingToolCalls non-empty also short-circuits — agent is awaiting tool
125
+ // results, not stuck.
126
+ const now = Date.now();
127
+ const mod = makeModuleWithSubagent({
128
+ displayName: 'awaiting-tools',
129
+ startedAt: now - 10 * 60_000,
130
+ lastActivityAt: now - 5 * 60_000,
131
+ pendingToolCalls: [{ name: 'files--read', input: { path: '/x' } }],
132
+ });
133
+ const [snap] = await mod.peek('awaiting-tools');
134
+ expect(snap!.isZombie).toBe(false);
135
+ });
136
+
137
+ test('completed subagent is never reported as zombie', async () => {
138
+ const now = Date.now();
139
+ const mod = makeModuleWithSubagent({
140
+ displayName: 'done',
141
+ startedAt: now - 10 * 60_000,
142
+ lastActivityAt: now - 5 * 60_000,
143
+ status: 'completed',
144
+ });
145
+ const [snap] = await mod.peek('done');
146
+ expect(snap!.isZombie).toBe(false);
147
+ });
148
+ });
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Postmortem 2026-05-28 F3: reaper's guard does not cover the
3
+ * "request in flight, awaiting first token" window.
4
+ *
5
+ * Today's reap guard is `!live.currentStream && pendingToolCalls.length === 0`.
6
+ * At inference:started and inference:stream_resumed, both are cleared. The
7
+ * agent then sits with the guard open while the next LLM request is on the
8
+ * wire. With Opus on 100–165K-token contexts under rate-limited MCP tools,
9
+ * single-round TTFT routinely exceeds 30s, and the periodic reaper executes
10
+ * mid-request even though the agent is genuinely progressing.
11
+ *
12
+ * Forensic signature in production: every reaped scout's last message in the
13
+ * Chronicle store is an unconsumed `tool_result` — i.e., tool results came
14
+ * back, stream_resumed cleared the guards, the next request was dispatched,
15
+ * the reaper struck before the first token.
16
+ *
17
+ * Tests:
18
+ * 1) reaper does NOT cancel an entry with `requestInFlightSince` set, even
19
+ * when lastActivityAt is older than the threshold.
20
+ * 2) reaper DOES cancel a genuinely silent entry (control — proves the
21
+ * threshold path is still alive).
22
+ * 3) Lifecycle: trace events set/clear `requestInFlightSince` on the live
23
+ * subagent state via the module's onTrace handler.
24
+ */
25
+ import { describe, test, expect } from 'bun:test';
26
+ import {
27
+ SubagentModule,
28
+ type ActiveSubagent,
29
+ } from '../src/modules/subagent-module.js';
30
+ import type { TraceEvent } from '@animalabs/agent-framework';
31
+
32
+ interface FakeLiveState {
33
+ frameworkAgentName: string;
34
+ displayName: string;
35
+ systemPrompt: string;
36
+ contextManager: { compile: () => Promise<{ messages: unknown[] }> };
37
+ currentStream: string;
38
+ pendingToolCalls: Array<{ name: string; input?: unknown }>;
39
+ activeCallIds: Set<string>;
40
+ /** Populated when an inference request has been dispatched but no token
41
+ * has arrived yet. The reaper must treat this as a protected state. */
42
+ requestInFlightSince?: number;
43
+ }
44
+
45
+ function installSubagent(
46
+ mod: SubagentModule,
47
+ opts: {
48
+ displayName: string;
49
+ frameworkAgentName?: string;
50
+ startedAt: number;
51
+ lastActivityAt: number;
52
+ currentStream?: string;
53
+ pendingToolCalls?: Array<{ name: string; input?: unknown }>;
54
+ requestInFlightSince?: number;
55
+ status?: 'running' | 'completed' | 'failed';
56
+ },
57
+ ): { live: FakeLiveState; entry: ActiveSubagent } {
58
+ const frameworkAgentName = opts.frameworkAgentName ?? `fw-${opts.displayName}`;
59
+ const live: FakeLiveState = {
60
+ frameworkAgentName,
61
+ displayName: opts.displayName,
62
+ systemPrompt: 'test',
63
+ contextManager: { compile: async () => ({ messages: [] }) },
64
+ currentStream: opts.currentStream ?? '',
65
+ pendingToolCalls: opts.pendingToolCalls ?? [],
66
+ activeCallIds: new Set(),
67
+ requestInFlightSince: opts.requestInFlightSince,
68
+ };
69
+ const entry: ActiveSubagent = {
70
+ name: opts.displayName,
71
+ type: 'spawn',
72
+ task: 'test-task',
73
+ status: opts.status ?? 'running',
74
+ startedAt: opts.startedAt,
75
+ lastActivityAt: opts.lastActivityAt,
76
+ toolCallsCount: 0,
77
+ findingsCount: 0,
78
+ };
79
+ const privateView = mod as unknown as {
80
+ liveSubagents: Map<string, FakeLiveState>;
81
+ frameworkNameIndex: Map<string, string>;
82
+ };
83
+ privateView.liveSubagents.set(opts.displayName, live);
84
+ privateView.frameworkNameIndex.set(frameworkAgentName, opts.displayName);
85
+ mod.activeSubagents.set(`entry-${opts.displayName}`, entry);
86
+ return { live, entry };
87
+ }
88
+
89
+ /** Captures the trace handler that SubagentModule.setFramework registers, so
90
+ * the lifecycle test can fire events without spinning up the full framework. */
91
+ function makeTraceCapturingFramework(): {
92
+ framework: { onTrace: (cb: (e: TraceEvent) => void) => void };
93
+ fire: (event: TraceEvent) => void;
94
+ } {
95
+ let captured: ((e: TraceEvent) => void) | null = null;
96
+ return {
97
+ framework: {
98
+ onTrace(cb: (e: TraceEvent) => void) {
99
+ captured = cb;
100
+ },
101
+ },
102
+ fire(event: TraceEvent) {
103
+ if (!captured) throw new Error('onTrace was never called');
104
+ captured(event);
105
+ },
106
+ };
107
+ }
108
+
109
+ describe('SubagentModule reaper — F3 request-in-flight protection', () => {
110
+ test('reaper does NOT cancel an agent with a request in flight', () => {
111
+ // Postmortem scenario: stream_resumed fired, currentStream + pendingToolCalls
112
+ // cleared, the next LLM round is on the wire. lastActivityAt is frozen at
113
+ // the moment of stream_resumed dispatch. TTFT is 45s — pre-fix the reaper
114
+ // would strike mid-request because all of (!currentStream, no pending tools,
115
+ // silentMs > threshold) hold. With the in-flight guard, the agent is left
116
+ // alone.
117
+ const now = Date.now();
118
+ const mod = new SubagentModule();
119
+ const { entry } = installSubagent(mod, {
120
+ displayName: 'opus-in-flight',
121
+ startedAt: now - 5 * 60_000,
122
+ lastActivityAt: now - 3 * 60_000, // well past any threshold
123
+ requestInFlightSince: now - 3 * 60_000, // request dispatched, no token yet
124
+ });
125
+
126
+ const reclaimed = (mod as unknown as { reclaimZombieSlots: () => number })
127
+ .reclaimZombieSlots();
128
+
129
+ expect(reclaimed).toBe(0);
130
+ expect(entry.status).toBe('running');
131
+ });
132
+
133
+ test('reaper DOES cancel a genuinely silent agent (control)', () => {
134
+ // Same staleness, but no request in flight — this is the "stuck" case the
135
+ // reaper exists to clean up. 5 min silence exceeds both the pre- and
136
+ // post-fix thresholds, so this test stays valid if the threshold is
137
+ // tuned in either direction.
138
+ const now = Date.now();
139
+ const mod = new SubagentModule();
140
+ const { entry } = installSubagent(mod, {
141
+ displayName: 'genuinely-stuck',
142
+ startedAt: now - 10 * 60_000,
143
+ lastActivityAt: now - 5 * 60_000,
144
+ });
145
+
146
+ const reclaimed = (mod as unknown as { reclaimZombieSlots: () => number })
147
+ .reclaimZombieSlots();
148
+
149
+ expect(reclaimed).toBe(1);
150
+ // Postmortem 2026-05-28 P1 #4: zombie-reaped subagents land in
151
+ // 'cancelled' (terminal-but-benign), not 'failed'. Aligns the
152
+ // SubagentModule's terminal state with the reducer's split between
153
+ // genuine faults (inference:failed) and benign cancels.
154
+ expect(entry.status).toBe('cancelled');
155
+ });
156
+
157
+ test('peek.isZombie also respects requestInFlightSince', async () => {
158
+ // The peek surface drives the orchestrator's perception. Postmortem F2 was
159
+ // about dual clocks; F3 is the same shape: a healthy in-flight agent must
160
+ // not report isZombie=true.
161
+ const now = Date.now();
162
+ const mod = new SubagentModule();
163
+ installSubagent(mod, {
164
+ displayName: 'opus-in-flight',
165
+ startedAt: now - 5 * 60_000,
166
+ lastActivityAt: now - 3 * 60_000,
167
+ requestInFlightSince: now - 3 * 60_000,
168
+ });
169
+ const [snap] = await mod.peek('opus-in-flight');
170
+ expect(snap!.isZombie).toBe(false);
171
+ });
172
+
173
+ test('lifecycle: inference:started sets requestInFlightSince', () => {
174
+ const now = Date.now();
175
+ const mod = new SubagentModule();
176
+ const { live } = installSubagent(mod, {
177
+ displayName: 'lc',
178
+ frameworkAgentName: 'fw-lc',
179
+ startedAt: now - 10_000,
180
+ lastActivityAt: now - 10_000,
181
+ });
182
+ expect(live.requestInFlightSince).toBeUndefined();
183
+
184
+ const fake = makeTraceCapturingFramework();
185
+ // setFramework registers the onTrace handler. Cast: our fake satisfies the
186
+ // surface SubagentModule uses at registration time.
187
+ mod.setFramework(fake.framework as never);
188
+
189
+ fake.fire({ type: 'inference:started', agentName: 'fw-lc', timestamp: Date.now() } as never);
190
+ expect(typeof live.requestInFlightSince).toBe('number');
191
+ });
192
+
193
+ test('lifecycle: first inference:tokens clears requestInFlightSince', () => {
194
+ const now = Date.now();
195
+ const mod = new SubagentModule();
196
+ const { live } = installSubagent(mod, {
197
+ displayName: 'lc2',
198
+ frameworkAgentName: 'fw-lc2',
199
+ startedAt: now - 10_000,
200
+ lastActivityAt: now - 10_000,
201
+ requestInFlightSince: now - 5_000,
202
+ });
203
+
204
+ const fake = makeTraceCapturingFramework();
205
+ mod.setFramework(fake.framework as never);
206
+
207
+ fake.fire({ type: 'inference:tokens', agentName: 'fw-lc2', content: 'hello', timestamp: Date.now() } as never);
208
+ expect(live.requestInFlightSince).toBeUndefined();
209
+ });
210
+
211
+ test('lifecycle: inference:stream_resumed sets requestInFlightSince again', () => {
212
+ // After a tool round, the framework emits stream_resumed and dispatches a
213
+ // new request. This is the gap the postmortem traced reaps to.
214
+ const now = Date.now();
215
+ const mod = new SubagentModule();
216
+ const { live } = installSubagent(mod, {
217
+ displayName: 'lc3',
218
+ frameworkAgentName: 'fw-lc3',
219
+ startedAt: now - 60_000,
220
+ lastActivityAt: now - 60_000,
221
+ });
222
+
223
+ const fake = makeTraceCapturingFramework();
224
+ mod.setFramework(fake.framework as never);
225
+
226
+ fake.fire({ type: 'inference:stream_resumed', agentName: 'fw-lc3', timestamp: Date.now() } as never);
227
+ expect(typeof live.requestInFlightSince).toBe('number');
228
+ });
229
+ });
@@ -0,0 +1,136 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+ import { SubscriptionGcModule } from '../src/modules/subscription-gc-module.js';
3
+ import type { ModuleContext, ProcessEvent, ProcessState } from '@animalabs/agent-framework';
4
+
5
+ function mockCtx() {
6
+ let state: unknown = null;
7
+ const traceListeners: Array<(e: { type: string; agentName?: string }) => void> = [];
8
+ const toolCalls: Array<{ name: string; input: Record<string, unknown> }> = [];
9
+ const ctx = {
10
+ getState: () => state,
11
+ setState: (s: unknown) => {
12
+ state = s;
13
+ },
14
+ onTrace: (l: (e: { type: string }) => void) => {
15
+ traceListeners.push(l);
16
+ return () => {};
17
+ },
18
+ callTool: async (call: { name: string; input: Record<string, unknown> }) => {
19
+ toolCalls.push(call);
20
+ return { success: true, data: {}, isError: false };
21
+ },
22
+ } as unknown as ModuleContext;
23
+ return { ctx, traceListeners, toolCalls, getState: () => state };
24
+ }
25
+
26
+ function ambient(
27
+ channelId: string,
28
+ text: string,
29
+ opts: { isMention?: boolean; isDM?: boolean; serverId?: string } = {},
30
+ ): ProcessEvent {
31
+ return {
32
+ type: 'mcpl:push-event',
33
+ serverId: opts.serverId ?? 'discord',
34
+ featureSet: 'discord.messaging',
35
+ eventId: 'e1',
36
+ content: [{ type: 'text', text }],
37
+ origin: {
38
+ source: 'discord',
39
+ channelId,
40
+ isMention: !!opts.isMention,
41
+ isDM: !!opts.isDM,
42
+ },
43
+ timestamp: '2026-01-01T00:00:00Z',
44
+ inferenceId: 'i1',
45
+ } as unknown as ProcessEvent;
46
+ }
47
+
48
+ const PS = {} as ProcessState;
49
+
50
+ describe('SubscriptionGcModule', () => {
51
+ test('auto-unsubscribes when ambient crosses the limit', async () => {
52
+ const m = new SubscriptionGcModule({ defaultLimitChars: 10, serverId: 'discord' });
53
+ const { ctx, toolCalls } = mockCtx();
54
+ await m.start(ctx);
55
+
56
+ let r = await m.onProcess(ambient('c1', 'abcdef'), PS); // 6 — under
57
+ expect(toolCalls.length).toBe(0);
58
+ expect(r.addMessages).toBeUndefined();
59
+
60
+ r = await m.onProcess(ambient('c1', 'ghijkl'), PS); // +6 = 12 > 10 → unsub
61
+ expect(toolCalls.length).toBe(1);
62
+ expect(toolCalls[0].name).toBe('mcpl--discord--unsubscribe_channel');
63
+ expect(toolCalls[0].input.channelId).toBe('c1');
64
+ expect(r.addMessages?.length).toBe(1);
65
+
66
+ await m.stop();
67
+ });
68
+
69
+ test('does not count mentions, DMs, or other servers', async () => {
70
+ const m = new SubscriptionGcModule({ defaultLimitChars: 5 });
71
+ const { ctx, toolCalls } = mockCtx();
72
+ await m.start(ctx);
73
+
74
+ await m.onProcess(ambient('c1', 'longmention', { isMention: true }), PS);
75
+ await m.onProcess(ambient('c1', 'longdm', { isDM: true }), PS);
76
+ await m.onProcess(ambient('c1', 'otherserver', { serverId: 'slack' }), PS);
77
+ expect(toolCalls.length).toBe(0);
78
+
79
+ await m.stop();
80
+ });
81
+
82
+ test('an activation resets all counters', async () => {
83
+ const m = new SubscriptionGcModule({ defaultLimitChars: 10 });
84
+ const { ctx, traceListeners, toolCalls } = mockCtx();
85
+ await m.start(ctx);
86
+
87
+ await m.onProcess(ambient('c1', 'abcdefgh'), PS); // 8 — under
88
+ traceListeners[0]({ type: 'inference:started', agentName: 'lena' }); // reset
89
+ await m.onProcess(ambient('c1', 'abcdefgh'), PS); // 8 again, not 16 → no unsub
90
+ expect(toolCalls.length).toBe(0);
91
+
92
+ await m.stop();
93
+ });
94
+
95
+ test('"off" override pins a channel; counters persist across restart', async () => {
96
+ const m = new SubscriptionGcModule({ defaultLimitChars: 5 });
97
+ const { ctx, toolCalls, getState } = mockCtx();
98
+ await m.start(ctx);
99
+
100
+ await m.handleToolCall({
101
+ id: 't1',
102
+ name: 'set_channel_idle_limit',
103
+ input: { channelId: 'c1', limit: 'off' },
104
+ });
105
+ await m.onProcess(ambient('c1', 'waytoolongambient'), PS);
106
+ expect(toolCalls.length).toBe(0); // pinned → never unsubscribed
107
+
108
+ // A different channel still accrues, and state is persisted.
109
+ await m.onProcess(ambient('c2', 'abc'), PS);
110
+ const persisted = getState() as { overrides: Record<string, unknown>; counters: Record<string, number> };
111
+ expect(persisted.overrides.c1).toBe('off');
112
+
113
+ // Simulate restart: a new module loads the persisted state (counters carry
114
+ // across — a restart is not an activation).
115
+ const m2 = new SubscriptionGcModule({ defaultLimitChars: 5 });
116
+ const restart = mockCtx();
117
+ // Feed the prior persisted state into the "restarted" module.
118
+ const ctx2 = {
119
+ getState: () => persisted,
120
+ setState: () => {},
121
+ onTrace: () => () => {},
122
+ callTool: async (c: { name: string; input: Record<string, unknown> }) => {
123
+ restart.toolCalls.push(c);
124
+ return { success: true, data: {}, isError: false };
125
+ },
126
+ } as unknown as ModuleContext;
127
+ await m2.start(ctx2);
128
+ // c2 was at 3; +3 = 6 > 5 → unsubscribe (counter survived the "restart")
129
+ await m2.onProcess(ambient('c2', 'def'), PS);
130
+ expect(restart.toolCalls.length).toBe(1);
131
+ expect(restart.toolCalls[0].input.channelId).toBe('c2');
132
+
133
+ await m.stop();
134
+ await m2.stop();
135
+ });
136
+ });