@comfanion/workflow 4.38.3-dev.2 → 4.38.4-dev.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.
@@ -1,315 +0,0 @@
1
- /**
2
- * Memory Leak Stress Tests
3
- *
4
- * Goal: prove the 3 plugins are NOT the source of a memory leak.
5
- *
6
- * Strategy:
7
- * 1. Run each plugin's hot path N times in a tight loop
8
- * 2. Measure heap snapshots at intervals
9
- * 3. Assert heap growth is sub-linear (flat / bounded)
10
- * 4. Specifically test known risk areas:
11
- * - custom-compaction: closure variables (lastActiveAgent, lastSessionId)
12
- * - file-indexer: module-level pendingFiles Map
13
- * - version-check: stateless (baseline)
14
- *
15
- * NOTE: Tests use only the public plugin interface (no internal imports).
16
- *
17
- * Run only leak tests:
18
- * bun test plugins/__tests__/leak-stress.test.ts
19
- */
20
- import { describe, it, expect, beforeEach, afterEach } from "bun:test"
21
- import { join } from "path"
22
- import {
23
- createMockCtx,
24
- createTempDir,
25
- cleanupTempDir,
26
- FIXTURE_SESSION_STATE,
27
- FIXTURE_STORY_MD,
28
- FIXTURE_EPIC_STATE,
29
- FIXTURE_TODOS,
30
- FIXTURE_CONFIG_YAML,
31
- } from "./helpers/mock-ctx"
32
- import { CustomCompactionPlugin } from "../custom-compaction"
33
- import { FileIndexerPlugin } from "../file-indexer"
34
- import { VersionCheckPlugin } from "../version-check"
35
-
36
- // ---------------------------------------------------------------------------
37
- // Helpers
38
- // ---------------------------------------------------------------------------
39
- function heapMB(): number {
40
- return process.memoryUsage().heapUsed / 1024 / 1024
41
- }
42
-
43
- function tryGC(): void {
44
- if (typeof globalThis.gc === "function") {
45
- globalThis.gc()
46
- }
47
- }
48
-
49
- /** Take N heap snapshots at equal intervals during a callback. */
50
- async function profile(
51
- iterations: number,
52
- snapshots: number,
53
- fn: (i: number) => Promise<void>,
54
- ): Promise<number[]> {
55
- const interval = Math.floor(iterations / snapshots)
56
- const heaps: number[] = []
57
-
58
- tryGC()
59
- heaps.push(heapMB())
60
-
61
- for (let i = 0; i < iterations; i++) {
62
- await fn(i)
63
- if ((i + 1) % interval === 0) {
64
- tryGC()
65
- heaps.push(heapMB())
66
- }
67
- }
68
-
69
- return heaps
70
- }
71
-
72
- /**
73
- * Check that heap growth is bounded:
74
- * last snapshot - first snapshot < maxGrowthMB.
75
- */
76
- function assertBoundedGrowth(
77
- heaps: number[],
78
- maxGrowthMB: number,
79
- label: string,
80
- ): void {
81
- const growth = heaps[heaps.length - 1] - heaps[0]
82
- const peak = Math.max(...heaps)
83
- const valley = Math.min(...heaps)
84
-
85
- console.log(
86
- ` [${label}] heap snapshots (MB): ${heaps.map((h) => h.toFixed(1)).join(" -> ")}`,
87
- )
88
- console.log(
89
- ` [${label}] growth: ${growth.toFixed(1)} MB, peak: ${peak.toFixed(1)} MB, valley: ${valley.toFixed(1)} MB`,
90
- )
91
-
92
- expect(growth).toBeLessThan(maxGrowthMB)
93
- }
94
-
95
- // =============================================================================
96
- // 1. CUSTOM COMPACTION -- hot path: chat.message + compaction
97
- // =============================================================================
98
- describe("leak: custom-compaction", () => {
99
- const ITERATIONS = 500
100
- const SNAPSHOTS = 5
101
- const MAX_GROWTH_MB = 30
102
-
103
- it("chat.message + compaction cycle doesn't leak", async () => {
104
- const dir = await createTempDir({
105
- ".opencode/session-state.yaml": FIXTURE_SESSION_STATE,
106
- ".opencode/state/todos.json": FIXTURE_TODOS,
107
- "docs/sprint-artifacts/sprint-1/stories/story-01-03-jwt-refresh.md":
108
- FIXTURE_STORY_MD,
109
- })
110
- const ctx = createMockCtx(dir)
111
- const hooks = await CustomCompactionPlugin(ctx as any)
112
-
113
- const heaps = await profile(ITERATIONS, SNAPSHOTS, async (i) => {
114
- await hooks["chat.message"]!(
115
- { agent: "dev", sessionID: `s-${i}` } as any,
116
- { message: {}, parts: [] } as any,
117
- )
118
- const output = { context: [] as string[], prompt: undefined }
119
- await hooks["experimental.session.compacting"]!(
120
- { sessionID: `s-${i}` } as any,
121
- output as any,
122
- )
123
- })
124
-
125
- assertBoundedGrowth(heaps, MAX_GROWTH_MB, "compaction-cycle")
126
- await cleanupTempDir(dir)
127
- })
128
-
129
- it("chat.message rapid-fire doesn't accumulate strings", async () => {
130
- const dir = await createTempDir()
131
- const ctx = createMockCtx(dir)
132
- const hooks = await CustomCompactionPlugin(ctx as any)
133
-
134
- const heaps = await profile(2000, SNAPSHOTS, async (i) => {
135
- await hooks["chat.message"]!(
136
- { agent: `agent-${i % 50}`, sessionID: `s-${i}` } as any,
137
- { message: {}, parts: [] } as any,
138
- )
139
- })
140
-
141
- assertBoundedGrowth(heaps, MAX_GROWTH_MB, "chat.message-rapid")
142
- await cleanupTempDir(dir)
143
- })
144
-
145
- it("event hook doesn't leak on repeated session.idle", async () => {
146
- const dir = await createTempDir()
147
- const ctx = createMockCtx(dir)
148
- const hooks = await CustomCompactionPlugin(ctx as any)
149
-
150
- const heaps = await profile(1000, SNAPSHOTS, async () => {
151
- await hooks.event!({ event: { type: "session.idle" } as any })
152
- })
153
-
154
- assertBoundedGrowth(heaps, MAX_GROWTH_MB, "event-idle")
155
- await cleanupTempDir(dir)
156
- })
157
-
158
- it("plugin factory doesn't leak across many instances", async () => {
159
- const heaps = await profile(100, SNAPSHOTS, async (i) => {
160
- const dir = await createTempDir()
161
- const ctx = createMockCtx(dir)
162
- const hooks = await CustomCompactionPlugin(ctx as any)
163
- await hooks["chat.message"]!(
164
- { agent: "dev", sessionID: `s-${i}` } as any,
165
- { message: {}, parts: [] } as any,
166
- )
167
- const output = { context: [] as string[], prompt: undefined }
168
- await hooks["experimental.session.compacting"]!(
169
- { sessionID: `s-${i}` } as any,
170
- output as any,
171
- )
172
- await cleanupTempDir(dir)
173
- })
174
-
175
- assertBoundedGrowth(heaps, 50, "compaction-instances")
176
- })
177
- })
178
-
179
- // =============================================================================
180
- // 2. FILE INDEXER -- hot path: event handler (file.edited queueing)
181
- // =============================================================================
182
- describe("leak: file-indexer", () => {
183
- const ITERATIONS = 1000
184
- const SNAPSHOTS = 5
185
- const MAX_GROWTH_MB = 30
186
-
187
- it("file.edited event handler doesn't leak (cycling file names)", async () => {
188
- const dir = await createTempDir({
189
- ".opencode/config.yaml": FIXTURE_CONFIG_YAML,
190
- })
191
- const ctx = createMockCtx(dir)
192
- const hooks = await FileIndexerPlugin(ctx as any)
193
-
194
- const heaps = await profile(ITERATIONS, SNAPSHOTS, async (i) => {
195
- await hooks.event!({
196
- event: {
197
- type: "file.edited",
198
- properties: { file: join(dir, `src/file-${i % 50}.ts`) },
199
- } as any,
200
- })
201
- })
202
-
203
- assertBoundedGrowth(heaps, MAX_GROWTH_MB, "file-edited-events")
204
- await cleanupTempDir(dir)
205
- })
206
-
207
- it("disabled plugin doesn't accumulate anything", async () => {
208
- const dir = await createTempDir({
209
- ".opencode/config.yaml": `vectorizer:\n enabled: false\n auto_index: false\n`,
210
- })
211
- const ctx = createMockCtx(dir)
212
- const hooks = await FileIndexerPlugin(ctx as any)
213
-
214
- const heaps = await profile(2000, SNAPSHOTS, async () => {
215
- await hooks.event!({
216
- event: {
217
- type: "file.edited",
218
- properties: { file: join(dir, "src/app.ts") },
219
- } as any,
220
- })
221
- })
222
-
223
- assertBoundedGrowth(heaps, 10, "disabled-plugin")
224
- await cleanupTempDir(dir)
225
- })
226
- })
227
-
228
- // =============================================================================
229
- // 3. VERSION CHECK -- hot path: event handler (no-op)
230
- // =============================================================================
231
- describe("leak: version-check", () => {
232
- const ITERATIONS = 2000
233
- const SNAPSHOTS = 5
234
- const MAX_GROWTH_MB = 10
235
-
236
- it("no-op event handler doesn't leak", async () => {
237
- const dir = await createTempDir()
238
- const ctx = createMockCtx(dir)
239
- const hooks = await VersionCheckPlugin(ctx as any)
240
-
241
- const heaps = await profile(ITERATIONS, SNAPSHOTS, async () => {
242
- await hooks.event!({ event: { type: "session.idle" } as any })
243
- })
244
-
245
- assertBoundedGrowth(heaps, MAX_GROWTH_MB, "version-noop")
246
- await cleanupTempDir(dir)
247
- })
248
-
249
- it("multiple plugin instances don't leak", async () => {
250
- const heaps = await profile(100, SNAPSHOTS, async () => {
251
- const dir = await createTempDir()
252
- const ctx = createMockCtx(dir)
253
- const hooks = await VersionCheckPlugin(ctx as any)
254
- await hooks.event!({ event: { type: "session.idle" } as any })
255
- await cleanupTempDir(dir)
256
- })
257
-
258
- assertBoundedGrowth(heaps, 30, "version-instances")
259
- })
260
- })
261
-
262
- // =============================================================================
263
- // 4. COMBINED -- simulate a realistic session with all 3 plugins
264
- // =============================================================================
265
- describe("leak: combined realistic session", () => {
266
- it("all 3 plugins running together don't leak", async () => {
267
- const dir = await createTempDir({
268
- ".opencode/session-state.yaml": FIXTURE_SESSION_STATE,
269
- ".opencode/state/todos.json": FIXTURE_TODOS,
270
- ".opencode/config.yaml": FIXTURE_CONFIG_YAML,
271
- "docs/sprint-artifacts/sprint-1/stories/story-01-03-jwt-refresh.md":
272
- FIXTURE_STORY_MD,
273
- })
274
- const ctx = createMockCtx(dir)
275
-
276
- const [compaction, indexer, versionCheck] = await Promise.all([
277
- CustomCompactionPlugin(ctx as any),
278
- FileIndexerPlugin(ctx as any),
279
- VersionCheckPlugin(ctx as any),
280
- ])
281
-
282
- const heaps = await profile(300, 5, async (i) => {
283
- // Simulate realistic session cycle:
284
- // 1. Agent sends message
285
- await compaction["chat.message"]!(
286
- { agent: "dev", sessionID: `s-${i}` } as any,
287
- { message: {}, parts: [] } as any,
288
- )
289
-
290
- // 2. File gets edited
291
- await indexer.event!({
292
- event: {
293
- type: "file.edited",
294
- properties: { file: join(dir, `src/module-${i % 20}.ts`) },
295
- } as any,
296
- })
297
-
298
- // 3. Session idle
299
- await compaction.event!({ event: { type: "session.idle" } as any })
300
- await versionCheck.event!({ event: { type: "session.idle" } as any })
301
-
302
- // 4. Every 50 iterations, simulate compaction
303
- if (i % 50 === 0) {
304
- const output = { context: [] as string[], prompt: undefined }
305
- await compaction["experimental.session.compacting"]!(
306
- { sessionID: `s-${i}` } as any,
307
- output as any,
308
- )
309
- }
310
- })
311
-
312
- assertBoundedGrowth(heaps, 40, "combined-session")
313
- await cleanupTempDir(dir)
314
- })
315
- })
@@ -1,205 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from "bun:test"
2
- import { readFile } from "fs/promises"
3
- import { join } from "path"
4
- import { createTempDir, cleanupTempDir } from "./helpers/mock-ctx"
5
- import { write, update, read, read_by_id, read_five } from "../../tools/usethis_todo"
6
-
7
- describe("usethis_todo tool", () => {
8
- let tempDir: string
9
- let originalXdg: string | undefined
10
-
11
- beforeEach(async () => {
12
- tempDir = await createTempDir()
13
- originalXdg = process.env.XDG_DATA_HOME
14
- process.env.XDG_DATA_HOME = join(tempDir, "xdg-data")
15
- })
16
-
17
- afterEach(async () => {
18
- if (originalXdg === undefined) {
19
- delete process.env.XDG_DATA_HOME
20
- } else {
21
- process.env.XDG_DATA_HOME = originalXdg
22
- }
23
- await cleanupTempDir(tempDir)
24
- })
25
-
26
- it("writes enhanced and native todo files", async () => {
27
- const ctx = { sessionID: "sess-test", directory: tempDir } as any
28
- const output = await write.execute(
29
- {
30
- todos: [
31
- { id: "A1", content: "First task", description: "Longer details", status: "todo", priority: "HIGH" },
32
- { id: "A2", content: "Second task", status: "todo", priority: "LOW" },
33
- ],
34
- },
35
- ctx
36
- )
37
-
38
- expect(output).toContain("TODO Graph")
39
-
40
- const enhancedPath = join(tempDir, ".opencode", "session-todos", "sess-test.json")
41
- const enhanced = JSON.parse(await readFile(enhancedPath, "utf-8"))
42
- expect(enhanced.length).toBe(2)
43
- expect(enhanced[0].content).toBe("First task")
44
- expect(enhanced[0].description).toBe("Longer details")
45
-
46
- const nativePath = join(
47
- process.env.XDG_DATA_HOME!,
48
- "opencode",
49
- "storage",
50
- "todo",
51
- "sess-test.json"
52
- )
53
- const native = JSON.parse(await readFile(nativePath, "utf-8"))
54
- expect(native.length).toBe(2)
55
- expect(native[0].content).toContain("First task")
56
- expect(native[0].content).toContain("Longer details")
57
- })
58
-
59
- it("update merges by id and can add new tasks", async () => {
60
- const ctx = { sessionID: "sess-merge", directory: tempDir } as any
61
-
62
- await write.execute(
63
- {
64
- todos: [
65
- { id: "A1", content: "First task", description: "v1", status: "todo", priority: "MED" },
66
- ],
67
- },
68
- ctx
69
- )
70
-
71
- const result = await update.execute(
72
- {
73
- todos: [
74
- { id: "A1", content: "First task", description: "v2", status: "done", priority: "MED" },
75
- { id: "A2", content: "Second task", status: "todo", priority: "LOW" },
76
- ],
77
- },
78
- ctx
79
- )
80
-
81
- expect(result).toContain("Updated 2 task(s)")
82
-
83
- const enhancedPath = join(tempDir, ".opencode", "session-todos", "sess-merge.json")
84
- const enhanced = JSON.parse(await readFile(enhancedPath, "utf-8"))
85
- expect(enhanced.length).toBe(2)
86
- expect(enhanced.find((t: any) => t.id === "A1")?.status).toBe("done")
87
- expect(enhanced.find((t: any) => t.id === "A1")?.description).toBe("v2")
88
- })
89
-
90
- it("auto-promotes ready -> done when releases exist", async () => {
91
- const ctx = { sessionID: "sess-rel", directory: tempDir } as any
92
-
93
- await write.execute(
94
- {
95
- todos: [
96
- {
97
- id: "A1",
98
- content: "Ship it",
99
- status: "ready",
100
- priority: "HIGH",
101
- releases: ["4.38.4"],
102
- },
103
- ],
104
- },
105
- ctx
106
- )
107
-
108
- const enhancedPath = join(tempDir, ".opencode", "session-todos", "sess-rel.json")
109
- const enhanced = JSON.parse(await readFile(enhancedPath, "utf-8"))
110
- expect(enhanced.find((t: any) => t.id === "A1")?.status).toBe("done")
111
- })
112
-
113
- it("read returns graph with content", async () => {
114
- const ctx = { sessionID: "sess-read", directory: tempDir } as any
115
- await write.execute(
116
- {
117
- todos: [
118
- { id: "A1", content: "Task content", status: "todo", priority: "HIGH" },
119
- ],
120
- },
121
- ctx
122
- )
123
-
124
- const output = await read.execute({}, ctx)
125
- expect(output).toContain("Task content")
126
- expect(output).toContain("Available Now")
127
- })
128
-
129
- it("read_five returns up to 5 available tasks with description", async () => {
130
- const ctx = { sessionID: "sess-five", directory: tempDir } as any
131
- await write.execute(
132
- {
133
- todos: [
134
- { id: "A1", content: "T1", description: "D1", status: "todo", priority: "HIGH", blockedBy: ["B1"] },
135
- { id: "A2", content: "T2", status: "todo", priority: "MED" },
136
- { id: "A3", content: "T3", status: "todo", priority: "LOW" },
137
- { id: "A4", content: "T4", status: "todo", priority: "LOW" },
138
- { id: "A5", content: "T5", status: "todo", priority: "LOW" },
139
- { id: "A6", content: "T6", status: "todo", priority: "LOW" },
140
- { id: "B1", content: "B", status: "in_progress", priority: "LOW", blockedBy: ["C1"] },
141
- { id: "C1", content: "C", status: "in_progress", priority: "MED" },
142
- ],
143
- },
144
- ctx
145
- )
146
-
147
- const out = await read_five.execute({}, ctx)
148
- expect(out).toContain("Next 5")
149
- expect(out).toContain("A1")
150
- expect(out).toContain("T1")
151
- expect(out).toContain("D1")
152
- expect(out).toContain("+1 more")
153
- expect(out).toContain("Blocked By (resolved)")
154
- expect(out).toContain("B1")
155
- expect(out).toContain("C1")
156
- })
157
-
158
- it("read_by_id returns task with resolved blockers", async () => {
159
- const ctx = { sessionID: "sess-get", directory: tempDir } as any
160
- await write.execute(
161
- {
162
- todos: [
163
- {
164
- id: "A1",
165
- content: "Task content",
166
- description: "More details",
167
- status: "todo",
168
- priority: "HIGH",
169
- blockedBy: ["B1"],
170
- },
171
- {
172
- id: "B1",
173
- content: "Blocker task",
174
- status: "done",
175
- priority: "LOW",
176
- blockedBy: ["C1"],
177
- },
178
- {
179
- id: "C1",
180
- content: "Root blocker",
181
- status: "todo",
182
- priority: "MED",
183
- },
184
- ],
185
- },
186
- ctx
187
- )
188
-
189
- const out = await read_by_id.execute({ id: "A1" }, ctx)
190
- expect(out).toContain("Task:")
191
- expect(out).toContain("A1")
192
- expect(out).toContain("Task content")
193
- expect(out).toContain("More details")
194
-
195
- expect(out).toContain("Blocked By (resolved):")
196
- expect(out).toContain("B1")
197
- expect(out).toContain("C1")
198
- })
199
-
200
- it("read_by_id returns not found", async () => {
201
- const ctx = { sessionID: "sess-miss", directory: tempDir } as any
202
- const out = await read_by_id.execute({ id: "NOPE" }, ctx)
203
- expect(out).toContain("not found")
204
- })
205
- })