-
Notifications
You must be signed in to change notification settings - Fork 15
feat(teams): automatic startup GC + exit cleanup of empty team dirs (from #8) #30
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<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>(); | ||
|
|
@@ -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, | ||
| }).catch(() => { | ||
| // Best-effort; never block the session. | ||
| }); | ||
|
|
||
| await refreshTasks(); | ||
| renderWidget(); | ||
|
|
||
|
|
@@ -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`); | ||
|
|
||
|
|
@@ -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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This shutdown cleanup has a race with Useful? React with 👍 / 👎. |
||
| } | ||
| } catch { | ||
| // Best-effort; never block shutdown. | ||
| } | ||
| } | ||
| } | ||
| }); | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Running
gcStaleTeamDirs()on everysession_startcan silently remove another team that is still actively led. Inextensions/teams/cleanup.ts,gcStaleTeamDirs()explicitly ignoresrole === "lead"and only treats live attach claims orin_progresstasks 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 gcbehavior before, but wiring it into startup means opening a fresh session can now delete someone else’s live team directory unexpectedly.Useful? React with 👍 / 👎.