diff --git a/extensions/teams/cleanup.ts b/extensions/teams/cleanup.ts index 61b2f34..b2040f4 100644 --- a/extensions/teams/cleanup.ts +++ b/extensions/teams/cleanup.ts @@ -79,13 +79,15 @@ export async function gcStaleTeamDirs(opts: { maxAgeMs: number; repoCwd?: string; dryRun?: boolean; + /** Team IDs to skip (e.g. the current session's team). */ + excludeTeamIds?: ReadonlySet; }): Promise<{ scanned: number; removed: string[]; skipped: Array<{ teamId: string; reason: string }>; warnings: string[]; }> { - const { teamsRootDir, maxAgeMs, repoCwd, dryRun } = opts; + const { teamsRootDir, maxAgeMs, repoCwd, dryRun, excludeTeamIds } = opts; const teamsRootAbs = path.resolve(teamsRootDir); const removed: string[] = []; const skipped: Array<{ teamId: string; reason: string }> = []; @@ -103,6 +105,10 @@ export async function gcStaleTeamDirs(opts: { const now = Date.now(); for (const teamId of teamEntries) { + if (excludeTeamIds?.has(teamId)) { + skipped.push({ teamId, reason: "excluded" }); + continue; + } const teamDir = path.join(teamsRootAbs, teamId); let stat: fs.Stats; try { diff --git a/extensions/teams/leader.ts b/extensions/teams/leader.ts index e675712..9834fe3 100644 --- a/extensions/teams/leader.ts +++ b/extensions/teams/leader.ts @@ -9,8 +9,9 @@ import { TEAM_MAILBOX_NS, taskAssignmentPayload } from "./protocol.js"; import { createTask, listTasks, unassignTasksForAgent, updateTask, type TeamTask } from "./task-store.js"; import { TeammateRpc } from "./teammate-rpc.js"; import { ensureTeamConfig, loadTeamConfig, setMemberStatus, upsertMember, type TeamConfig } from "./team-config.js"; -import { getTeamDir } from "./paths.js"; -import { heartbeatTeamAttachClaim, releaseTeamAttachClaim } from "./team-attach-claim.js"; +import { getTeamDir, getTeamsRootDir } from "./paths.js"; +import { assessAttachClaimFreshness, heartbeatTeamAttachClaim, readTeamAttachClaim, releaseTeamAttachClaim } from "./team-attach-claim.js"; +import { cleanupTeamDir, gcStaleTeamDirs } from "./cleanup.js"; import { ensureWorktreeCwd, cleanupWorktrees } from "./worktree.js"; import { ActivityTracker, TranscriptTracker } from "./activity-tracker.js"; import { openInteractiveWidget } from "./teams-panel.js"; @@ -106,6 +107,29 @@ async function createSessionForTeammate( } } +/** Check if a team dir has any task files across all task-list namespaces. */ +async function teamDirHasAnyTasks(teamDir: string): Promise { + const tasksDir = path.join(teamDir, "tasks"); + let taskListDirs: string[]; + try { + taskListDirs = await fs.promises.readdir(tasksDir); + } catch { + return false; + } + for (const listDir of taskListDirs) { + const listPath = path.join(tasksDir, listDir); + try { + const stat = await fs.promises.stat(listPath); + if (!stat.isDirectory()) continue; + const files = await fs.promises.readdir(listPath); + if (files.some((f) => f.endsWith(".json") && !f.startsWith("."))) return true; + } catch { + continue; + } + } + return false; +} + // Message parsers are shared with the worker implementation. export function runLeader(pi: ExtensionAPI): void { const teammates = new Map(); @@ -701,6 +725,15 @@ export function runLeader(pi: ExtensionAPI): void { style, }); + // Startup GC: silently remove stale team directories from previous sessions (24h age floor). + void gcStaleTeamDirs({ + teamsRootDir: getTeamsRootDir(), + maxAgeMs: 24 * 60 * 60 * 1000, + excludeTeamIds: new Set([currentTeamId]), + }).catch(() => { + // Best-effort; never block the session. + }); + await refreshTasks(); renderWidget(); @@ -804,6 +837,7 @@ export function runLeader(pi: ExtensionAPI): void { if (!currentCtx) return; await releaseActiveAttachClaim(currentCtx); stopLoops(); + const hadTeammates = teammates.size > 0; const strings = getTeamsStrings(style); await stopAllTeammates(currentCtx, `The ${strings.teamNoun} is over`); @@ -817,6 +851,30 @@ export function runLeader(pi: ExtensionAPI): void { } catch { // Best-effort — don't block shutdown. } + + // Exit cleanup: delete own team directory if it's empty. + // Conservative: only if no RPC teammates were active, no online workers in + // config (manual/tmux), no tasks in ANY namespace, and no fresh attach claim. + // (Dirs with completed tasks are left for the 24h startup GC — intentionally + // asymmetric for safety.) + if (!hadTeammates) { + try { + const claim = await readTeamAttachClaim(teamDir); + const claimIsLive = claim !== null && !assessAttachClaimFreshness(claim).isStale; + if (claimIsLive) { + // Another session is using this team — don't delete. + } else { + // Also check config for online non-lead members (manual/tmux workers). + const cfg = await loadTeamConfig(teamDir); + const hasOnlineWorkers = cfg?.members.some((m) => m.role !== "lead" && m.status === "online") ?? false; + if (!hasOnlineWorkers && !(await teamDirHasAnyTasks(teamDir))) { + await cleanupTeamDir(getTeamsRootDir(), teamDir); + } + } + } catch { + // Best-effort; never block shutdown. + } + } } });