Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion extensions/teams/cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
}): 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 }> = [];
Expand All @@ -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 {
Expand Down
62 changes: 60 additions & 2 deletions extensions/teams/leader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<boolean> {
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<string, TeammateRpc>();
Expand Down Expand Up @@ -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,
Comment on lines +729 to +731
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Stop auto-GC from deleting live leader-owned teams

Running gcStaleTeamDirs() on every session_start can silently remove another team that is still actively led. In extensions/teams/cleanup.ts, gcStaleTeamDirs() explicitly ignores role === "lead" and only treats live attach claims or in_progress tasks as activity, so a team that has been open for more than 24h with pending/completed tasks and no workers will be classified as stale even while its leader session is still running. This was an opt-in /team gc behavior before, but wiring it into startup means opening a fresh session can now delete someone else’s live team directory unexpectedly.

Useful? React with 👍 / 👎.

excludeTeamIds: new Set([currentTeamId]),
}).catch(() => {
// Best-effort; never block the session.
});

await refreshTasks();
renderWidget();

Expand Down Expand Up @@ -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`);

Expand All @@ -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.
}
}
}
});

Expand Down
Loading