Skip to content
Merged
Changes from 1 commit
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
53 changes: 51 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,14 @@ 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 👍 / 👎.

}).catch(() => {
// Best-effort; never block the session.
});

await refreshTasks();
renderWidget();

Expand Down Expand Up @@ -804,6 +836,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 +850,22 @@ 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 teammates were active, no tasks in ANY namespace,
// and no other session holds an 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 && !(await teamDirHasAnyTasks(teamDir))) {
await cleanupTeamDir(getTeamsRootDir(), teamDir);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Lock attach claims before deleting an empty team

This shutdown cleanup has a race with /team attach: if another session acquires a fresh attach claim after readTeamAttachClaim(teamDir) returns but before cleanupTeamDir() runs, we will still delete the directory out from under the newly attached session. acquireTeamAttachClaim() serializes claim updates with a lock in extensions/teams/team-attach-claim.ts, but this path only does an unlocked read followed by unconditional deletion, so an empty team can disappear immediately after another leader successfully attaches to it.

Useful? React with 👍 / 👎.

}
} catch {
// Best-effort; never block shutdown.
}
}
}
});

Expand Down
Loading