@tmustier/pi-agent-teams 0.4.0 → 0.5.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.
@@ -0,0 +1,419 @@
1
+ /**
2
+ * Integration test for team cleanup and garbage collection.
3
+ *
4
+ * Tests: cleanupTeamDir (with worktree removal), gcStaleTeamDirs,
5
+ * cleanupWorktrees (git worktree + branch lifecycle).
6
+ *
7
+ * Requires: git (creates temporary git repos and worktrees).
8
+ *
9
+ * Usage: npx tsx scripts/integration-cleanup-test.mts
10
+ */
11
+
12
+ import * as fs from "node:fs";
13
+ import * as path from "node:path";
14
+ import * as os from "node:os";
15
+ import { execFileSync } from "node:child_process";
16
+
17
+ import { cleanupTeamDir, gcStaleTeamDirs, assertTeamDirWithinTeamsRoot } from "../extensions/teams/cleanup.js";
18
+ import { cleanupWorktrees } from "../extensions/teams/worktree.js";
19
+ import { ensureTeamConfig } from "../extensions/teams/team-config.js";
20
+ import { createTask } from "../extensions/teams/task-store.js";
21
+
22
+ // ── helpers ──────────────────────────────────────────────────────────
23
+ let passed = 0;
24
+ let failed = 0;
25
+
26
+ function assert(condition: boolean, label: string) {
27
+ if (condition) {
28
+ passed++;
29
+ console.log(` ✓ ${label}`);
30
+ } else {
31
+ failed++;
32
+ console.error(` ✗ ${label}`);
33
+ }
34
+ }
35
+
36
+ function assertEq(actual: unknown, expected: unknown, label: string) {
37
+ const ok = JSON.stringify(actual) === JSON.stringify(expected);
38
+ if (!ok) {
39
+ console.error(` actual: ${JSON.stringify(actual)}`);
40
+ console.error(` expected: ${JSON.stringify(expected)}`);
41
+ }
42
+ assert(ok, label);
43
+ }
44
+
45
+ function git(args: string[], cwd: string): string {
46
+ return execFileSync("git", args, { cwd, encoding: "utf8", timeout: 30_000 }).trim();
47
+ }
48
+
49
+ function gitLines(args: string[], cwd: string): string[] {
50
+ return git(args, cwd).split("\n").filter((l) => l.length > 0);
51
+ }
52
+
53
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pi-teams-cleanup-"));
54
+ console.log(`\nCleanup test root: ${tmpRoot}\n`);
55
+
56
+ // ── setup: create a temporary git repo ────────────────────────────
57
+ const repoDir = path.join(tmpRoot, "repo");
58
+ fs.mkdirSync(repoDir, { recursive: true });
59
+ git(["init"], repoDir);
60
+ git(["config", "user.email", "test@test.com"], repoDir);
61
+ git(["config", "user.name", "Test"], repoDir);
62
+ fs.writeFileSync(path.join(repoDir, "README.md"), "# Test repo\n");
63
+ git(["add", "."], repoDir);
64
+ git(["commit", "-m", "init"], repoDir);
65
+
66
+ const teamsRoot = path.join(tmpRoot, "teams");
67
+ fs.mkdirSync(teamsRoot, { recursive: true });
68
+
69
+ // ── Test 1: assertTeamDirWithinTeamsRoot ─────────────────────────
70
+ console.log("1. assertTeamDirWithinTeamsRoot");
71
+ {
72
+ const result = assertTeamDirWithinTeamsRoot(teamsRoot, path.join(teamsRoot, "team-1"));
73
+ assert(result.teamDirAbs.includes("team-1"), "accepts child dir");
74
+
75
+ let threw = false;
76
+ try {
77
+ assertTeamDirWithinTeamsRoot(teamsRoot, teamsRoot);
78
+ } catch {
79
+ threw = true;
80
+ }
81
+ assert(threw, "rejects same dir");
82
+
83
+ threw = false;
84
+ try {
85
+ assertTeamDirWithinTeamsRoot(teamsRoot, path.join(teamsRoot, ".."));
86
+ } catch {
87
+ threw = true;
88
+ }
89
+ assert(threw, "rejects parent dir");
90
+ }
91
+
92
+ // ── Test 2: cleanupWorktrees on empty dir ────────────────────────
93
+ console.log("\n2. cleanupWorktrees (no worktrees)");
94
+ {
95
+ const teamDir = path.join(teamsRoot, "team-no-wt");
96
+ fs.mkdirSync(teamDir, { recursive: true });
97
+
98
+ const result = await cleanupWorktrees({ teamDir, teamId: "team-no-wt", repoCwd: repoDir });
99
+ assertEq(result.removedWorktrees.length, 0, "no worktrees removed");
100
+ assertEq(result.removedBranches.length, 0, "no branches removed");
101
+ assertEq(result.warnings.length, 0, "no warnings");
102
+ }
103
+
104
+ // ── Test 3: cleanupWorktrees removes worktrees and branches ──────
105
+ console.log("\n3. cleanupWorktrees (with worktrees + branches)");
106
+ {
107
+ const teamId = "team-wt-test";
108
+ const teamDir = path.join(teamsRoot, teamId);
109
+ const wtDir = path.join(teamDir, "worktrees");
110
+ fs.mkdirSync(wtDir, { recursive: true });
111
+
112
+ const shortTeam = teamId.slice(0, 12);
113
+ const agent1Path = path.join(wtDir, "agent1");
114
+ const agent2Path = path.join(wtDir, "agent2");
115
+ const branch1 = `pi-teams/${shortTeam}/agent1`;
116
+ const branch2 = `pi-teams/${shortTeam}/agent2`;
117
+
118
+ // Create worktrees using git
119
+ git(["worktree", "add", "-b", branch1, agent1Path, "HEAD"], repoDir);
120
+ git(["worktree", "add", "-b", branch2, agent2Path, "HEAD"], repoDir);
121
+
122
+ // Verify they exist
123
+ const wtListBefore = gitLines(["worktree", "list", "--porcelain"], repoDir);
124
+ const branchesBefore = gitLines(["branch"], repoDir);
125
+ assert(wtListBefore.some((l) => l.includes("agent1")), "worktree agent1 exists before cleanup");
126
+ assert(wtListBefore.some((l) => l.includes("agent2")), "worktree agent2 exists before cleanup");
127
+ assert(branchesBefore.some((l) => l.includes(branch1)), "branch1 exists before cleanup");
128
+ assert(branchesBefore.some((l) => l.includes(branch2)), "branch2 exists before cleanup");
129
+
130
+ // Clean up
131
+ const result = await cleanupWorktrees({ teamDir, teamId, repoCwd: repoDir });
132
+ assertEq(result.removedWorktrees.length, 2, "2 worktrees removed");
133
+ assertEq(result.removedBranches.length, 2, "2 branches removed");
134
+ assert(result.removedBranches.includes(branch1), "branch1 in removed list");
135
+ assert(result.removedBranches.includes(branch2), "branch2 in removed list");
136
+
137
+ // Verify they're gone
138
+ const wtListAfter = gitLines(["worktree", "list", "--porcelain"], repoDir);
139
+ const branchesAfter = gitLines(["branch"], repoDir);
140
+ assert(!wtListAfter.some((l) => l.includes("agent1")), "worktree agent1 removed after cleanup");
141
+ assert(!wtListAfter.some((l) => l.includes("agent2")), "worktree agent2 removed after cleanup");
142
+ assert(!branchesAfter.some((l) => l.includes(branch1)), "branch1 removed after cleanup");
143
+ assert(!branchesAfter.some((l) => l.includes(branch2)), "branch2 removed after cleanup");
144
+
145
+ // Worktrees dir itself should be removed (empty)
146
+ assert(!fs.existsSync(wtDir), "worktrees dir removed");
147
+ }
148
+
149
+ // ── Test 4: cleanupTeamDir removes worktrees + entire dir ────────
150
+ console.log("\n4. cleanupTeamDir (full cleanup including worktrees)");
151
+ {
152
+ const teamId = "team-full-cleanup";
153
+ const teamDir = path.join(teamsRoot, teamId);
154
+ const wtDir = path.join(teamDir, "worktrees");
155
+ fs.mkdirSync(wtDir, { recursive: true });
156
+
157
+ const shortTeam = teamId.slice(0, 12);
158
+ const agentPath = path.join(wtDir, "worker1");
159
+ const branch = `pi-teams/${shortTeam}/worker1`;
160
+
161
+ git(["worktree", "add", "-b", branch, agentPath, "HEAD"], repoDir);
162
+
163
+ // Also create some team artifacts
164
+ await ensureTeamConfig(teamDir, { teamId, taskListId: teamId, leadName: "lead", style: "normal" });
165
+ fs.mkdirSync(path.join(teamDir, "mailboxes"), { recursive: true });
166
+ fs.writeFileSync(path.join(teamDir, "mailboxes", "test.json"), "[]");
167
+
168
+ assert(fs.existsSync(teamDir), "team dir exists before cleanup");
169
+ assert(fs.existsSync(agentPath), "worktree path exists before cleanup");
170
+
171
+ const result = await cleanupTeamDir(teamsRoot, teamDir, { teamId, repoCwd: repoDir });
172
+ assert(!fs.existsSync(teamDir), "team dir removed");
173
+ assertEq(result.worktreeResult.removedWorktrees.length, 1, "1 worktree removed");
174
+ assertEq(result.worktreeResult.removedBranches.length, 1, "1 branch removed");
175
+
176
+ // Verify git state
177
+ const branches = gitLines(["branch"], repoDir);
178
+ assert(!branches.some((l) => l.includes(branch)), "branch removed from git");
179
+ }
180
+
181
+ // ── Test 5: gcStaleTeamDirs basic flow ───────────────────────────
182
+ console.log("\n5. gcStaleTeamDirs (basic)");
183
+ {
184
+ // Create 3 team dirs: old-idle, old-active (online worker), recent-idle
185
+ const oldIdleDir = path.join(teamsRoot, "old-idle");
186
+ fs.mkdirSync(oldIdleDir, { recursive: true });
187
+ await ensureTeamConfig(oldIdleDir, { teamId: "old-idle", taskListId: "old-idle", leadName: "lead", style: "normal" });
188
+ // Backdate both the directory mtime AND the config.json createdAt.
189
+ // NOTE: the lead member stays status: "online" (as ensureTeamConfig creates it).
190
+ // GC must ignore the lead's status — only workers count.
191
+ const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
192
+ const oldIdleConfig = JSON.parse(fs.readFileSync(path.join(oldIdleDir, "config.json"), "utf8"));
193
+ oldIdleConfig.createdAt = twoDaysAgo.toISOString();
194
+ fs.writeFileSync(path.join(oldIdleDir, "config.json"), JSON.stringify(oldIdleConfig, null, 2));
195
+ fs.utimesSync(oldIdleDir, twoDaysAgo, twoDaysAgo);
196
+
197
+ const oldActiveDir = path.join(teamsRoot, "old-active");
198
+ fs.mkdirSync(oldActiveDir, { recursive: true });
199
+ await ensureTeamConfig(oldActiveDir, { teamId: "old-active", taskListId: "old-active", leadName: "lead", style: "normal" });
200
+ // Add an online *worker* (role: "worker") and backdate — GC must keep this.
201
+ const configPath = path.join(oldActiveDir, "config.json");
202
+ const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
203
+ config.members = [
204
+ { name: "lead", role: "lead", status: "online" },
205
+ { name: "worker1", role: "worker", status: "online" },
206
+ ];
207
+ config.createdAt = twoDaysAgo.toISOString();
208
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
209
+ fs.utimesSync(oldActiveDir, twoDaysAgo, twoDaysAgo);
210
+
211
+ const recentDir = path.join(teamsRoot, "recent-idle");
212
+ fs.mkdirSync(recentDir, { recursive: true });
213
+ await ensureTeamConfig(recentDir, { teamId: "recent-idle", taskListId: "recent-idle", leadName: "lead", style: "normal" });
214
+
215
+ // Dry run — should identify old-idle for removal
216
+ const dryResult = await gcStaleTeamDirs({
217
+ teamsRootDir: teamsRoot,
218
+ maxAgeMs: 24 * 60 * 60 * 1000, // 24h
219
+ repoCwd: repoDir,
220
+ dryRun: true,
221
+ });
222
+
223
+ assert(dryResult.removed.includes("old-idle"), "dry run: old-idle marked for removal");
224
+ assert(!dryResult.removed.includes("old-active"), "dry run: old-active NOT marked (has online member)");
225
+ assert(!dryResult.removed.includes("recent-idle"), "dry run: recent-idle NOT marked (too new)");
226
+ assert(fs.existsSync(oldIdleDir), "dry run: old-idle still exists");
227
+
228
+ // Actual run
229
+ const result = await gcStaleTeamDirs({
230
+ teamsRootDir: teamsRoot,
231
+ maxAgeMs: 24 * 60 * 60 * 1000,
232
+ repoCwd: repoDir,
233
+ dryRun: false,
234
+ });
235
+
236
+ assert(result.removed.includes("old-idle"), "gc: old-idle removed");
237
+ assert(!result.removed.includes("old-active"), "gc: old-active kept");
238
+ assert(!fs.existsSync(oldIdleDir), "gc: old-idle dir deleted from disk");
239
+ assert(fs.existsSync(oldActiveDir), "gc: old-active dir still exists");
240
+ assert(fs.existsSync(recentDir), "gc: recent-idle dir still exists");
241
+ }
242
+
243
+ // ── Test 6: gcStaleTeamDirs skips dirs with in_progress tasks ───
244
+ console.log("\n6. gcStaleTeamDirs (in-progress tasks)");
245
+ {
246
+ const teamId = "old-busy";
247
+ const teamDir = path.join(teamsRoot, teamId);
248
+ fs.mkdirSync(teamDir, { recursive: true });
249
+ await ensureTeamConfig(teamDir, { teamId, taskListId: teamId, leadName: "lead", style: "normal" });
250
+
251
+ // Create an in_progress task
252
+ const task = await createTask(teamDir, teamId, { subject: "test task", description: "busy" });
253
+ const taskFile = path.join(teamDir, "tasks", teamId, `${task.id}.json`);
254
+ const taskData = JSON.parse(fs.readFileSync(taskFile, "utf8"));
255
+ taskData.status = "in_progress";
256
+ taskData.owner = "agent1";
257
+ fs.writeFileSync(taskFile, JSON.stringify(taskData, null, 2));
258
+
259
+ // Backdate both config createdAt and dir mtime
260
+ const oldBusyConfig = JSON.parse(fs.readFileSync(path.join(teamDir, "config.json"), "utf8"));
261
+ const twoDaysAgoLocal = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
262
+ oldBusyConfig.createdAt = twoDaysAgoLocal.toISOString();
263
+ fs.writeFileSync(path.join(teamDir, "config.json"), JSON.stringify(oldBusyConfig, null, 2));
264
+ fs.utimesSync(teamDir, twoDaysAgoLocal, twoDaysAgoLocal);
265
+
266
+ const result = await gcStaleTeamDirs({
267
+ teamsRootDir: teamsRoot,
268
+ maxAgeMs: 24 * 60 * 60 * 1000,
269
+ repoCwd: repoDir,
270
+ dryRun: false,
271
+ });
272
+
273
+ assert(!result.removed.includes(teamId), "gc: old-busy NOT removed (in_progress task)");
274
+ assert(fs.existsSync(teamDir), "gc: old-busy dir still exists");
275
+ assert(result.skipped.some((s) => s.teamId === teamId && s.reason === "has active work"), "gc: skipped with reason");
276
+ }
277
+
278
+ // ── Test 7: gcStaleTeamDirs ignores online lead member ───────────
279
+ console.log("\n7. gcStaleTeamDirs (ignores online lead)");
280
+ {
281
+ // ensureTeamConfig creates the lead as status: "online". GC must still
282
+ // remove the team when no *workers* are online, even if the lead is.
283
+ const teamId = "old-lead-only";
284
+ const teamDir = path.join(teamsRoot, teamId);
285
+ fs.mkdirSync(teamDir, { recursive: true });
286
+ await ensureTeamConfig(teamDir, { teamId, taskListId: teamId, leadName: "lead", style: "normal" });
287
+ // Backdate — do NOT change lead status to offline.
288
+ const twoDaysAgoLocal = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
289
+ const cfg = JSON.parse(fs.readFileSync(path.join(teamDir, "config.json"), "utf8"));
290
+ cfg.createdAt = twoDaysAgoLocal.toISOString();
291
+ fs.writeFileSync(path.join(teamDir, "config.json"), JSON.stringify(cfg, null, 2));
292
+ fs.utimesSync(teamDir, twoDaysAgoLocal, twoDaysAgoLocal);
293
+
294
+ const result = await gcStaleTeamDirs({
295
+ teamsRootDir: teamsRoot,
296
+ maxAgeMs: 24 * 60 * 60 * 1000,
297
+ repoCwd: repoDir,
298
+ dryRun: false,
299
+ });
300
+
301
+ assert(result.removed.includes(teamId), "gc: team with only online lead is removed");
302
+ assert(!fs.existsSync(teamDir), "gc: old-lead-only dir deleted");
303
+ }
304
+
305
+ // ── Test 8: gcStaleTeamDirs respects live attach claims ──────────
306
+ console.log("\n8. gcStaleTeamDirs (respects attach claims)");
307
+ {
308
+ const teamId = "old-attached";
309
+ const teamDir = path.join(teamsRoot, teamId);
310
+ fs.mkdirSync(teamDir, { recursive: true });
311
+ await ensureTeamConfig(teamDir, { teamId, taskListId: teamId, leadName: "lead", style: "normal" });
312
+ // Backdate the team — would normally be GC'd.
313
+ const twoDaysAgoLocal = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
314
+ const cfg = JSON.parse(fs.readFileSync(path.join(teamDir, "config.json"), "utf8"));
315
+ cfg.createdAt = twoDaysAgoLocal.toISOString();
316
+ fs.writeFileSync(path.join(teamDir, "config.json"), JSON.stringify(cfg, null, 2));
317
+ fs.utimesSync(teamDir, twoDaysAgoLocal, twoDaysAgoLocal);
318
+
319
+ // Write a fresh attach claim (heartbeat is recent).
320
+ const claimPath = path.join(teamDir, ".attach-claim.json");
321
+ fs.writeFileSync(claimPath, JSON.stringify({
322
+ holderSessionId: "other-session",
323
+ claimedAt: new Date().toISOString(),
324
+ heartbeatAt: new Date().toISOString(),
325
+ pid: process.pid,
326
+ }));
327
+
328
+ const result = await gcStaleTeamDirs({
329
+ teamsRootDir: teamsRoot,
330
+ maxAgeMs: 24 * 60 * 60 * 1000,
331
+ repoCwd: repoDir,
332
+ dryRun: false,
333
+ });
334
+
335
+ assert(!result.removed.includes(teamId), "gc: old-attached NOT removed (has live claim)");
336
+ assert(fs.existsSync(teamDir), "gc: old-attached dir still exists");
337
+ assert(result.skipped.some((s) => s.teamId === teamId && s.reason === "has active work"), "gc: skipped with reason");
338
+
339
+ // Now test with a stale claim — should be removed.
340
+ const staleClaim = {
341
+ holderSessionId: "dead-session",
342
+ claimedAt: twoDaysAgoLocal.toISOString(),
343
+ heartbeatAt: twoDaysAgoLocal.toISOString(),
344
+ pid: 99999,
345
+ };
346
+ fs.writeFileSync(claimPath, JSON.stringify(staleClaim));
347
+
348
+ const result2 = await gcStaleTeamDirs({
349
+ teamsRootDir: teamsRoot,
350
+ maxAgeMs: 24 * 60 * 60 * 1000,
351
+ repoCwd: repoDir,
352
+ dryRun: false,
353
+ });
354
+
355
+ assert(result2.removed.includes(teamId), "gc: old-attached removed (stale claim)");
356
+ assert(!fs.existsSync(teamDir), "gc: old-attached dir deleted after stale claim");
357
+ }
358
+
359
+ // ── Test 9: gcStaleTeamDirs ignores _styles and _hooks dirs ──────
360
+ console.log("\n9. gcStaleTeamDirs (ignores underscore dirs)");
361
+ {
362
+ const stylesDir = path.join(teamsRoot, "_styles");
363
+ const hooksDir = path.join(teamsRoot, "_hooks");
364
+ fs.mkdirSync(stylesDir, { recursive: true });
365
+ fs.mkdirSync(hooksDir, { recursive: true });
366
+ const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
367
+ fs.utimesSync(stylesDir, twoDaysAgo, twoDaysAgo);
368
+ fs.utimesSync(hooksDir, twoDaysAgo, twoDaysAgo);
369
+
370
+ const result = await gcStaleTeamDirs({
371
+ teamsRootDir: teamsRoot,
372
+ maxAgeMs: 24 * 60 * 60 * 1000,
373
+ repoCwd: repoDir,
374
+ dryRun: true,
375
+ });
376
+
377
+ assert(!result.removed.includes("_styles"), "gc: _styles not targeted");
378
+ assert(!result.removed.includes("_hooks"), "gc: _hooks not targeted");
379
+ }
380
+
381
+ // ── Test 10: cleanupWorktrees handles missing repo gracefully ────
382
+ console.log("\n10. cleanupWorktrees (no repo context)");
383
+ {
384
+ const teamDir = path.join(teamsRoot, "no-repo");
385
+ const wtDir = path.join(teamDir, "worktrees");
386
+ const fakePath = path.join(wtDir, "fake-agent");
387
+ fs.mkdirSync(fakePath, { recursive: true });
388
+ fs.writeFileSync(path.join(fakePath, "some-file.txt"), "leftover");
389
+
390
+ // No repoCwd, and the worktree isn't a valid git dir
391
+ const result = await cleanupWorktrees({ teamDir, teamId: "no-repo" });
392
+ assertEq(result.removedWorktrees.length, 1, "removed via filesystem fallback");
393
+ assert(!fs.existsSync(fakePath), "fake-agent dir removed");
394
+ }
395
+
396
+ // ── cleanup ──────────────────────────────────────────────────────
397
+ console.log("\n─── Cleanup ───");
398
+ try {
399
+ // Remove any remaining git worktrees from the test repo
400
+ const wtList = gitLines(["worktree", "list", "--porcelain"], repoDir);
401
+ for (const line of wtList) {
402
+ if (line.startsWith("worktree ") && !line.includes(repoDir)) {
403
+ const wtPath = line.replace("worktree ", "");
404
+ try {
405
+ git(["worktree", "remove", "--force", wtPath], repoDir);
406
+ } catch {
407
+ // ignore
408
+ }
409
+ }
410
+ }
411
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
412
+ console.log(` Removed ${tmpRoot}`);
413
+ } catch (err) {
414
+ console.warn(` Warning: cleanup failed: ${err}`);
415
+ }
416
+
417
+ // ── summary ──────────────────────────────────────────────────────
418
+ console.log(`\n═══ Results: ${passed} passed, ${failed} failed ═══\n`);
419
+ process.exit(failed > 0 ? 1 : 0);