/** * GC adapter for gjc-tagged tmux sessions. Destructive cleanup is authorized * only for detached pane-less sessions whose exact runtime marker revalidates a * terminal state. Project/branch/orphan heuristics are discovery signals only. */ import * as fs from "node:fs"; import * as path from "node:path"; import { GitCommandError, worktree } from "../utils/git"; import type { GcCollectResult, GcContext, GcPruneOutcome, GcRecord, GcStoreAdapter } from "./gc-runtime"; import { GJC_COORDINATOR_SESSION_ID_ENV, GJC_TMUX_OWNER_GENERATION_ENV, GJC_TMUX_OWNER_STATE_DIR_ENV, readTerminalRuntimeStateMarker, } from "./session-state-sidecar"; import { GJC_TMUX_PROFILE_VALUE, GJC_TMUX_SESSION_PREFIX, hasGjcTmuxProviderAuthoritySync } from "./tmux-common"; import { type GjcTmuxSessionStatus, type GjcTmuxSessionsForGc, listTmuxSessionsForGc, readTmuxSessionTagsForGc, removeGjcTmuxSession, } from "./tmux-sessions"; const STORE = "tmux_sessions" as const; const TOCTOU_SKIP = "tmux_revalidation_failed_or_became_live"; const ORPHAN_MAX_AGE_MS = 24 * 60 * 60 * 1000; type CollectedTmuxIdentity = { nativeSessionId: string; ownerGeneration: string; sessionId: string; sessionStateFile: string; project: string; createdAt: string; providerKind: "native-tmux" | "windows-psmux"; psmuxIncarnation?: string; providerCommand?: string; }; const collectedIdentities = new WeakMap(); function collectedIdentity(session: GjcTmuxSessionStatus): CollectedTmuxIdentity | undefined { if ( !session.nativeSessionId || !session.ownerGeneration || !session.sessionId || !session.sessionStateFile || !session.project || !session.createdAt ) return undefined; const stateDir = path.dirname(session.sessionStateFile); const providerKind = hasGjcTmuxProviderAuthoritySync({ stateDir, sessionId: session.sessionId, generation: session.ownerGeneration, }) ? "windows-psmux" : "native-tmux"; if (providerKind === "windows-psmux" && !session.psmuxIncarnation) return undefined; return { nativeSessionId: session.nativeSessionId, ownerGeneration: session.ownerGeneration, sessionId: session.sessionId, sessionStateFile: session.sessionStateFile, project: session.project, createdAt: session.createdAt, psmuxIncarnation: session.psmuxIncarnation, providerKind, providerCommand: session.providerAuthority?.command, }; } function authorityEnv(identity: CollectedTmuxIdentity, env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { if (identity.providerKind !== "windows-psmux") return env; return { ...env, GJC_TMUX_COMMAND: identity.providerCommand, [GJC_COORDINATOR_SESSION_ID_ENV]: identity.sessionId, [GJC_TMUX_OWNER_GENERATION_ENV]: identity.ownerGeneration, [GJC_TMUX_OWNER_STATE_DIR_ENV]: path.dirname(identity.sessionStateFile), }; } function pathExists(path: string): boolean { try { return fs.existsSync(path); } catch { return false; } } function detail(project?: string, branch?: string): string | undefined { const parts = []; if (project) parts.push(`project=${project}`); if (branch) parts.push(`branch=${branch}`); return parts.length > 0 ? parts.join(" ") : undefined; } function unclassifiedRecord(id: string, reason: string, project?: string, branch?: string): GcRecord { return { store: STORE, id, path: project, root: project, pid_status: "none", status: "unclassified", stale: false, removable: false, action: "none", reason, detail: detail(project, branch), }; } function branchMatches(candidate: string | undefined, branch: string): boolean { if (!candidate) return false; const branchNames = new Set([ branch, branch.startsWith("refs/heads/") ? branch.slice("refs/heads/".length) : `refs/heads/${branch}`, ]); return branchNames.has(candidate); } function isNotGitRepositoryError(error: unknown): boolean { return error instanceof GitCommandError && /not a git repository/i.test(error.message); } async function hasLiveWorktreeForBranch(project: string, branch: string): Promise { try { const entries = await worktree.list(project); return entries.some(entry => branchMatches(entry.branch, branch)); } catch (error) { if (isNotGitRepositoryError(error)) return false; throw error; } } function isSessionLive(session: Pick): boolean { return session.attached || session.panePids.length > 0; } function liveRecord(session: GjcTmuxSessionStatus, reason: string): GcRecord { return { store: STORE, id: session.name, path: session.project, root: session.project, pid_status: "alive", status: "live", stale: false, removable: false, action: "none", reason, detail: detail(session.project, session.branch), }; } function staleRecord(session: GjcTmuxSessionStatus, reason: string): GcRecord { return { store: STORE, id: session.name, path: session.project, root: session.project, pid_status: "none", status: "stale", stale: true, removable: true, action: "none", reason, detail: `${detail(session.project, session.branch) ?? ""} createdAt=${session.createdAt}`.trim(), }; } async function hasTerminalRuntimeMarker(input: { sessionId?: string | null; sessionStateFile?: string | null; project?: string | null; }): Promise { const marker = await readTerminalRuntimeStateMarker({ stateFile: input.sessionStateFile, sessionId: input.sessionId, cwd: input.project, }); return marker.terminal; } function isOldEnoughForOrphanGc(session: GjcTmuxSessionStatus): boolean { const createdAt = Date.parse(session.createdAt); return Number.isFinite(createdAt) && Date.now() - createdAt >= ORPHAN_MAX_AGE_MS; } function isGjcOwnedOrphan(session: GjcTmuxSessionStatus): boolean { return session.name.startsWith(GJC_TMUX_SESSION_PREFIX) || session.name === "gajae_code"; } async function classifyTaggedSession(session: GjcTmuxSessionStatus): Promise { const { name, project, branch } = session; if (isSessionLive(session)) return liveRecord(session, "tmux_session_attached_or_has_live_panes"); if (await hasTerminalRuntimeMarker(session)) return staleRecord(session, "terminal_runtime_marker_detached_idle_session"); if (!project || !branch) { const reason = isGjcOwnedOrphan(session) && isOldEnoughForOrphanGc(session) ? "metadata_less_gjc_owned_idle_orphan_missing_terminal_marker" : "missing_project_or_branch_tag"; return unclassifiedRecord(name, reason, project, branch); } if (!pathExists(project)) return unclassifiedRecord(name, "project_missing_without_terminal_marker", project, branch); if (!(await hasLiveWorktreeForBranch(project, branch))) return unclassifiedRecord(name, "branch_no_worktree_without_terminal_marker", project, branch); return { store: STORE, id: name, path: project, root: project, pid_status: "none", status: "live", stale: false, removable: false, action: "none", reason: "project_and_branch_worktree_present", detail: detail(project, branch), }; } function classifyUntaggedSession(session: GjcTmuxSessionStatus): GcRecord { return unclassifiedRecord(session.name, "untagged_tmux_session"); } async function revalidateRemovable( record: GcRecord, identity: CollectedTmuxIdentity, env: NodeJS.ProcessEnv, ): Promise { const tags = readTmuxSessionTagsForGc(record.id, authorityEnv(identity, env)); if ( tags.nativeSessionId !== identity.nativeSessionId || tags.ownerGeneration !== identity.ownerGeneration || tags.sessionId !== identity.sessionId || tags.sessionStateFile !== identity.sessionStateFile || tags.project !== identity.project || tags.createdAt !== identity.createdAt || tags.psmuxIncarnation !== identity.psmuxIncarnation ) return false; if (tags.attached || (tags.panePids?.length ?? 0) > 0) return false; if (tags.profile !== GJC_TMUX_PROFILE_VALUE) return false; return await hasTerminalRuntimeMarker(tags); } export const tmuxSessionsGcAdapter: GcStoreAdapter = { store: STORE, async collect(ctx: GcContext): Promise { const records: GcRecord[] = []; const errors: GcCollectResult["errors"] = []; let sessions: GjcTmuxSessionsForGc; try { sessions = listTmuxSessionsForGc(ctx.env); } catch (error) { return { records, errors: [ { store: STORE, scope: "list_sessions", message: error instanceof Error ? error.message : String(error), }, ], }; } for (const session of sessions.tagged) { try { const record = await classifyTaggedSession(session); records.push(record); if (record.removable) { const identity = collectedIdentity(session); if (identity) collectedIdentities.set(record, identity); } } catch (error) { errors.push({ store: STORE, scope: session.name, message: error instanceof Error ? error.message : String(error), }); records.push(unclassifiedRecord(session.name, "worktree_list_failed", session.project, session.branch)); } } for (const session of sessions.untagged) { records.push(classifyUntaggedSession(session)); } return { records, errors }; }, async prune(record: GcRecord, ctx: GcContext): Promise { if (record.store !== STORE || record.status !== "stale" || !record.removable) { return { removed: false, skipped: "not_removable_tmux_session" }; } try { const identity = collectedIdentities.get(record); if (!identity || !(await revalidateRemovable(record, identity, ctx.env))) { return { removed: false, skipped: TOCTOU_SKIP }; } removeGjcTmuxSession(record.id, authorityEnv(identity, ctx.env), identity); return { removed: true }; } catch (error) { return { removed: false, error: error instanceof Error ? error.message : String(error) }; } }, };