From 11f36270ad285fb95b2ad0c0bf0de39b5729c401 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:49:13 +0200 Subject: [PATCH 1/5] refactor(sessions): make listing observational agent-session-id: dev3.dotfiles-cos-misc-agent-runtime-simplification agent-tool: Codex agent-tool-version: 0.145.0 agent-model: gpt-5.6-sol agent-runtime-profile: /home/schickling/.config/coding-agents/profile.json agent-skills-manifest: /nix/store/nk9iml2841l1yjjg0f6f0d3y60zkg1nn-agent-skills-corpus/share/agent-skills/manifest.json tooling-profile: dotfiles@4a0515f --- CHANGELOG.md | 12 ++++ README.md | 11 ++-- docs/client.md | 3 +- src/sessions.ts | 68 +++++++++++------------ tests/list-filters.test.ts | 21 ++++--- tests/list-live-session-race.test.ts | 17 +++--- tests/list-purity.test.ts | 83 ++++++++++++++++++++++++++++ 7 files changed, 152 insertions(+), 63 deletions(-) create mode 100644 tests/list-purity.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 722bab8..1459cb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +### Read-only session listing + +- `listSessions()` and `pty list` are now strictly observational: they no + longer create an absent registry, unlink stale socket/pid files, remove + malformed metadata, or reap old dead sessions. A dead daemon with a stale + socket is reported as exited/vanished in the first listing instead of + requiring a mutating priming pass. +- Read-only APIs no longer perform lifecycle cleanup. Stale registry cleanup + belongs to explicit operations such as `pty gc` and `pty rm`; normal daemon + self-reaping is unchanged. In particular, `pty gc --dry-run` now performs no + registry writes or removals. + ### Attach-only CLI policy - `pty attach --no-restart ` attaches only to a currently running diff --git a/README.md b/README.md index 08597ec..66f81fb 100644 --- a/README.md +++ b/README.md @@ -183,9 +183,9 @@ pty rm mybuild # explicit removal beats keep ``` `pty gc`'s sweep reclaims preserved-and-finished (and `vanished`) non-permanent -sessions — see [Auto-running gc](#auto-running-gc). Dead sessions are also -reclaimed lazily by the 24-hour dead-session TTL on any `pty list`. `keep=true` -and `strategy=permanent` are exempt from both. +sessions — see [Auto-running gc](#auto-running-gc). `pty list` is strictly +observational and never removes registry state. `keep=true` and +`strategy=permanent` are exempt from the gc sweep. ### Events @@ -198,7 +198,8 @@ pty events --recent myserver # dump recent events and exit pty events --json myserver # raw JSONL output ``` -Event files auto-truncate at 1,000 lines and are cleaned up with the 24-hour dead session TTL. +Event files auto-truncate at 1,000 lines and are removed with their session by +lifecycle cleanup (daemon self-reap, `pty gc`, or `pty rm`). ### On-disk format @@ -339,7 +340,7 @@ Cycles (A→B, B→A) resolve deterministically by name-sorted iteration: whiche `pty gc` is a one-shot reconciliation pass. The intended deployment is to run it on a short interval so permanent sessions come back quickly and orphans get cleaned promptly. The CLI ships an install helper for macOS: -Whether finished sessions need the sweep depends on [`PTY_REAP_ON_EXIT`](#session-lifecycle-and-cleanup): under the shipped `reap` default they self-clean at exit, so the sweep's finished-session duty is mostly `vanished` sessions (daemon killed outright, so it never ran its own cleanup) plus anything left listed by `preserve` mode. Those are also reclaimed lazily by the 24-hour dead-session TTL on any `pty list`. So the interval primarily buys you respawn latency for permanents and orphan-kill promptness — and, in `preserve` mode, `pty ls` hygiene. `keep=true` and `strategy=permanent` sessions are exempt. +Whether finished sessions need the sweep depends on [`PTY_REAP_ON_EXIT`](#session-lifecycle-and-cleanup): under the shipped `reap` default they self-clean at exit, so the sweep's finished-session duty is mostly `vanished` sessions (daemon killed outright, so it never ran its own cleanup) plus anything left listed by `preserve` mode. `pty list` only observes this state; it never cleans it up. So the interval primarily buys you respawn latency for permanents and orphan-kill promptness — and, in `preserve` mode, `pty ls` hygiene. `keep=true` and `strategy=permanent` sessions are exempt. ```sh pty gc --print-launchd-plist > ~/Library/LaunchAgents/com.compoundingtech.pty.gc.plist diff --git a/docs/client.md b/docs/client.md index bd2ab9c..4798baf 100644 --- a/docs/client.md +++ b/docs/client.md @@ -13,7 +13,8 @@ import { PacketReader, MessageType } from "@compoundingtech/pty/protocol"; ### `listSessions(): Promise` -List all sessions (running + exited within 24h). +List all retained sessions without mutating the registry. Cleanup is owned by +explicit lifecycle operations such as `gc()` and `cleanupAll()`. ### `getSession(name: string): Promise` diff --git a/src/sessions.ts b/src/sessions.ts index 689608e..512789d 100644 --- a/src/sessions.ts +++ b/src/sessions.ts @@ -14,8 +14,6 @@ export const DEFAULT_SESSION_DIR = path.join(os.homedir(), ".local", "state", "p let hasWarnedLegacyRootEnv = false; let hasWarnedRootMasksLegacy = false; -const DEAD_SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours - const VALID_NAME_RE = /^[a-zA-Z0-9._-]+$/; // Maximum bytes available to `sockaddr_un.sun_path`. Darwin/BSD = 104, @@ -332,9 +330,13 @@ export interface ListSessionsOptions { const DEFAULT_SOCKET_PROBE_BUDGET_MS = 500; +/** Return one bounded, read-only observation of the session registry. + * + * This function deliberately performs no lifecycle work: it does not create + * the registry, unlink stale runtime files, repair malformed records, or reap + * old sessions. Callers that intend to mutate lifecycle state must use an + * explicit operation such as `gc()` or `cleanupAll()`. */ export async function listSessions(options: ListSessionsOptions = {}): Promise { - ensureSessionDir(); - let entries: string[]; try { entries = fs.readdirSync(getSessionDir()); @@ -387,10 +389,19 @@ export async function listSessions(options: ListSessionsOptions = {}): Promise e.endsWith(".json")).sort(); for (const jsonFile of jsonFiles) { const name = jsonFile.replace(/\.json$/, ""); @@ -411,27 +422,21 @@ export async function listSessions(options: ListSessionsOptions = {}): Promise DEAD_SESSION_TTL_MS) { - const pid = readPid(name); - if (pid === null || !isProcessAlive(pid)) { - cleanupAll(name); - continue; - } - } + // A live pid remains authoritative even if its socket inode is temporarily + // absent. Listing observes that mismatch; it never "repairs" it. + const pid = readPid(name); + if (pid !== null && isProcessAlive(pid)) { + sessions.push({ + name, + socketPath: getSocketPath(name), + pid, + status: metadata.exitedAt ? "exited" : "running", + metadata, + }); + continue; } // Vanished = dead daemon with no exit record. SIGKILL / OOM / crash. @@ -683,15 +688,6 @@ export async function gc( const globalIdleDays = opts.idleDays; const globalFastFailWindow = opts.fastFailWindowSec; const globalFastFailLimit = opts.fastFailLimit; - // First call to `listSessions` is intentionally throwaway — it has a - // side effect (`cleanupSocket`) on sessions whose daemon SIGKILL'd - // without writing an exit record, and those sessions are then *missing* - // from the returned array (their entry is dropped because `seen` set - // contained the name but the alive checks failed). A second call sees - // them via the `.json` files loop as `status=vanished`. Without this - // priming pass, step 1's orphan-kill misses vanished sessions whose - // sockets were still on disk when gc started. - await listSessions(); const initial = await listSessions(); // STEP 1: orphan-children. Sort by name so cycles (A→B, B→A) resolve diff --git a/tests/list-filters.test.ts b/tests/list-filters.test.ts index 1a7cee7..4570f7e 100644 --- a/tests/list-filters.test.ts +++ b/tests/list-filters.test.ts @@ -159,13 +159,11 @@ describe("vanished status", () => { }, 15000); }); -describe("listSessions guards against deleting state for live daemons", () => { +describe("listSessions is observational", () => { // Refs https://github.com/compoundingtech/pty/issues/34. listSessions used to // unconditionally `cleanupSocket` whenever the socket-reachable probe - // failed and `cleanupAll` whenever metadata was older than 24h. Both - // ran even if the recorded pid was still alive — once the .sock or - // .json was gone, the still-running daemon became invisible to every - // future scan. These tests pin the new behaviour: live pid wins. + // failed and `cleanupAll` whenever metadata was older than 24h. A read path + // must never mutate either case; these tests pin that boundary. it("keeps a session whose socket file is missing but recorded pid is still alive", () => { const dir = makeSessionDir(); const name = uniqueName(); @@ -175,8 +173,7 @@ describe("listSessions guards against deleting state for live daemons", () => { command: "cat", args: [], displayCommand: "cat", cwd: os.tmpdir(), createdAt: new Date().toISOString(), })); - // Note: no .sock file written. Without the guard, scan-and-cleanup paths - // would fall into the .json branch and delete metadata-on-age. + // Note: no .sock file written. The pid still proves that the daemon lives. // Force the .json into the >24h bucket so the second guard is exercised. const old = new Date(Date.now() - 48 * 3600_000).toISOString(); @@ -188,12 +185,12 @@ describe("listSessions guards against deleting state for live daemons", () => { const r = runCli(dir, "list", "--json"); expect(r.status).toBe(0); const found = JSON.parse(r.stdout).find((s: any) => s.name === name); - expect(found, "session should still be listed because its pid is alive").toBeDefined(); + expect(found?.status, "session should be running because its pid is alive").toBe("running"); // Metadata file must survive the call so the next scan also sees it. expect(fs.existsSync(path.join(dir, `${name}.json`))).toBe(true); }, 10000); - it("does delete metadata older than 24h when the pid is dead", () => { + it("reports old dead metadata without deleting it", () => { const dir = makeSessionDir(); const name = uniqueName(); // Pid 0x7fffffff is "guaranteed dead" on Linux/macOS in practice. @@ -206,8 +203,10 @@ describe("listSessions guards against deleting state for live daemons", () => { const r = runCli(dir, "list", "--json"); expect(r.status).toBe(0); - expect(JSON.parse(r.stdout).find((s: any) => s.name === name)).toBeUndefined(); - expect(fs.existsSync(path.join(dir, `${name}.json`))).toBe(false); + const found = JSON.parse(r.stdout).find((s: any) => s.name === name); + expect(found?.status).toBe("vanished"); + expect(fs.existsSync(path.join(dir, `${name}.pid`))).toBe(true); + expect(fs.existsSync(path.join(dir, `${name}.json`))).toBe(true); }, 10000); }); diff --git a/tests/list-live-session-race.test.ts b/tests/list-live-session-race.test.ts index dd13a4e..119ed9e 100644 --- a/tests/list-live-session-race.test.ts +++ b/tests/list-live-session-race.test.ts @@ -108,7 +108,7 @@ describe("pty list: concurrency robustness (do not reap a live session on a tran expect(fs.existsSync(path.join(dir, `${name}.sock`))).toBe(true); }, 20000); - it("still reaps a genuinely dead session's stale socket (positive proof of death)", async () => { + it("reports a genuinely dead session without reaping during list", async () => { const dir = makeSessionDir(); const name = "dead-reap"; const pid = await startDaemon(dir, name); @@ -123,15 +123,12 @@ describe("pty list: concurrency robustness (do not reap a live session on a tran await new Promise((r) => setTimeout(r, 50)); } - // First list has positive proof of death (readable dead pid + unreachable - // socket) → it reaps the stale socket/pid. - runCli(dir, ["list", "--json"]); - expect(fs.existsSync(path.join(dir, `${name}.sock`))).toBe(false); - - // The session stays addressable as vanished (a SIGKILLed daemon wrote no - // exit record) once only its metadata remains. - const list2 = JSON.parse(runCli(dir, ["list", "--json"]).stdout); - const found = list2.find((s: any) => s.name === name); + // Positive proof of death affects the observation, not registry contents. + // Cleanup is owned by the explicit gc/rm path. + const list = JSON.parse(runCli(dir, ["list", "--json"]).stdout); + expect(fs.existsSync(path.join(dir, `${name}.sock`))).toBe(true); + expect(fs.existsSync(path.join(dir, `${name}.pid`))).toBe(true); + const found = list.find((s: any) => s.name === name); expect(found).toBeDefined(); expect(found.status).toBe("vanished"); }, 20000); diff --git a/tests/list-purity.test.ts b/tests/list-purity.test.ts new file mode 100644 index 0000000..a60b808 --- /dev/null +++ b/tests/list-purity.test.ts @@ -0,0 +1,83 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { gc, listSessions } from "../src/sessions.ts"; + +const roots: string[] = []; + +const withRoot = () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "pty-list-purity-")); + roots.push(root); + process.env.PTY_ROOT = root; + return root; +}; + +const writeStaleSession = (root: string, name: string) => { + fs.writeFileSync(path.join(root, `${name}.sock`), ""); + fs.writeFileSync(path.join(root, `${name}.pid`), "2147483647"); + fs.writeFileSync(path.join(root, `${name}.json`), JSON.stringify({ + command: "true", + args: [], + displayCommand: "true", + cwd: root, + createdAt: new Date().toISOString(), + })); +}; + +afterEach(() => { + delete process.env.PTY_ROOT; + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +describe("listSessions is observational", () => { + it("does not create an absent registry", async () => { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), "pty-list-purity-parent-")); + roots.push(parent); + const root = path.join(parent, "absent"); + process.env.PTY_ROOT = root; + + expect(await listSessions()).toEqual([]); + expect(fs.existsSync(root)).toBe(false); + }); + + it("reports a stale-socket session in one pass without deleting artifacts", async () => { + const root = withRoot(); + writeStaleSession(root, "stale"); + + const sessions = await listSessions({ socketProbeBudgetMs: 5 }); + + expect(sessions.map(({ name, status }) => ({ name, status }))).toEqual([ + { name: "stale", status: "vanished" }, + ]); + expect(fs.existsSync(path.join(root, "stale.sock"))).toBe(true); + expect(fs.existsSync(path.join(root, "stale.pid"))).toBe(true); + }); + + it("does not delete corrupt metadata", async () => { + const root = withRoot(); + fs.writeFileSync(path.join(root, "corrupt.json"), "{"); + + expect(await listSessions()).toEqual([]); + expect(fs.existsSync(path.join(root, "corrupt.json"))).toBe(true); + }); + + it("leaves cleanup to gc while keeping gc dry-run non-mutating", async () => { + const root = withRoot(); + writeStaleSession(root, "dry"); + + const preview = await gc({ dryRun: true }); + + expect(preview.removed).toEqual(["dry"]); + expect(fs.existsSync(path.join(root, "dry.sock"))).toBe(true); + expect(fs.existsSync(path.join(root, "dry.pid"))).toBe(true); + expect(fs.existsSync(path.join(root, "dry.json"))).toBe(true); + + const applied = await gc(); + + expect(applied.removed).toEqual(["dry"]); + expect(fs.existsSync(path.join(root, "dry.sock"))).toBe(false); + expect(fs.existsSync(path.join(root, "dry.pid"))).toBe(false); + expect(fs.existsSync(path.join(root, "dry.json"))).toBe(false); + }); +}); From 92b80f35af28cfe3da755d95cbce8086999b4766 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:02:51 +0200 Subject: [PATCH 2/5] fix(gc): reclaim unlisted registry debris agent-session-id: dev3.dotfiles-cos-misc-agent-runtime-simplification agent-tool: Codex agent-tool-version: 0.145.0 agent-model: gpt-5.6-sol agent-runtime-profile: /home/schickling/.config/coding-agents/profile.json agent-skills-manifest: /nix/store/nk9iml2841l1yjjg0f6f0d3y60zkg1nn-agent-skills-corpus/share/agent-skills/manifest.json tooling-profile: dotfiles@4a0515f --- CHANGELOG.md | 4 +- src/sessions.ts | 131 +++++++++++++++++++++++++++++++++++++- tests/list-purity.test.ts | 49 ++++++++++++++ 3 files changed, 182 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1459cb5..4fe845c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,9 @@ - Read-only APIs no longer perform lifecycle cleanup. Stale registry cleanup belongs to explicit operations such as `pty gc` and `pty rm`; normal daemon self-reaping is unchanged. In particular, `pty gc --dry-run` now performs no - registry writes or removals. + registry writes or removals. `pty gc` inventories dead socket/pid debris even + when metadata is missing or malformed, and revalidates it while holding the + per-name creation lock before removal. ### Attach-only CLI policy diff --git a/src/sessions.ts b/src/sessions.ts index 512789d..d7638b4 100644 --- a/src/sessions.ts +++ b/src/sessions.ts @@ -330,6 +330,124 @@ export interface ListSessionsOptions { const DEFAULT_SOCKET_PROBE_BUDGET_MS = 500; +interface RawCleanupCandidate { + name: string; +} + +type MetadataArtifactState = "absent" | "valid" | "malformed" | "unreadable"; + +function inspectMetadataArtifact( + name: string, + hasMetadata: boolean, +): MetadataArtifactState { + if (!hasMetadata) return "absent"; + try { + const parsed: unknown = JSON.parse(fs.readFileSync(getMetadataPath(name), "utf-8")); + return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) + ? "valid" + : "malformed"; + } catch (error) { + return error instanceof SyntaxError ? "malformed" : "unreadable"; + } +} + +/** Inventory registry debris that cannot be represented as a SessionInfo. + * + * This is deliberately separate from listSessions: observation never mutates, + * while gc still needs to discover stale runtime artifacts whose metadata is + * missing or malformed. A readable dead pid is positive proof that an + * associated socket is stale; malformed metadata without any runtime files is + * also reclaimable. Ambiguous startup shapes (socket + missing/invalid pid) are + * retained. */ +async function inventoryRawCleanupCandidates( + options: ListSessionsOptions = {}, + onlyNames?: ReadonlySet, +): Promise { + let entries: string[]; + try { + entries = fs.readdirSync(getSessionDir()); + } catch { + return []; + } + + const names = new Set(); + for (const entry of entries) { + let name: string | undefined; + if (entry.endsWith(".events.jsonl")) name = entry.slice(0, -".events.jsonl".length); + else if (entry.endsWith(".sock")) name = entry.slice(0, -".sock".length); + else if (entry.endsWith(".pid")) name = entry.slice(0, -".pid".length); + else if (entry.endsWith(".json")) name = entry.slice(0, -".json".length); + if (name && (!onlyNames || onlyNames.has(name))) names.add(name); + } + + const entrySet = new Set(entries); + const candidates = [...names].sort().map((name) => { + const hasSocket = entrySet.has(`${name}.sock`); + const hasPid = entrySet.has(`${name}.pid`); + const hasMetadata = entrySet.has(`${name}.json`); + const metadataState = inspectMetadataArtifact(name, hasMetadata); + const pid = hasPid ? readPid(name) : null; + const pidDead = pid !== null && !isProcessAlive(pid); + return { name, hasSocket, hasPid, hasMetadata, metadataState, pidDead }; + }).filter(({ metadataState }) => + metadataState === "absent" || metadataState === "malformed" + ); + + const socketsToProbe = candidates + .filter(({ hasSocket, pidDead }) => hasSocket && pidDead) + .map(({ name }) => getSocketPath(name)); + const reachability = await probeSocketsWithinBudget( + socketsToProbe, + options.socketProbeBudgetMs ?? DEFAULT_SOCKET_PROBE_BUDGET_MS, + options.socketProbe ?? isSocketReachable, + ); + + return candidates.flatMap((candidate) => { + if (candidate.pidDead) { + if ( + !candidate.hasSocket || + reachability.get(getSocketPath(candidate.name)) === false + ) { + return [{ name: candidate.name }]; + } + return []; + } + if (candidate.hasMetadata && !candidate.hasPid && !candidate.hasSocket) { + return [{ name: candidate.name }]; + } + return []; + }); +} + +/** Apply one raw-artifact cleanup only while owning the per-name creation lock. + * + * Re-inventorying under the lock closes the observation/apply race: if a live + * generation appeared, or the evidence became ambiguous, cleanup is skipped. */ +async function cleanupRawCandidateGuarded( + candidate: RawCleanupCandidate, + options: ListSessionsOptions = {}, +): Promise { + if (!acquireLock(candidate.name)) return false; + try { + const current = await inventoryRawCleanupCandidates( + options, + new Set([candidate.name]), + ); + if (!current.some(({ name }) => name === candidate.name)) return false; + + cleanupSocket(candidate.name); + try { + fs.unlinkSync(getMetadataPath(candidate.name)); + } catch {} + try { + fs.unlinkSync(getEventsPath(candidate.name)); + } catch {} + return true; + } finally { + releaseLock(candidate.name); + } +} + /** Return one bounded, read-only observation of the session registry. * * This function deliberately performs no lifecycle work: it does not create @@ -688,6 +806,17 @@ export async function gc( const globalIdleDays = opts.idleDays; const globalFastFailWindow = opts.fastFailWindowSec; const globalFastFailLimit = opts.fastFailLimit; + const rawCandidates = await inventoryRawCleanupCandidates(); + const rawRemoved: string[] = []; + if (dryRun) { + rawRemoved.push(...rawCandidates.map(({ name }) => name)); + } else { + for (const candidate of rawCandidates) { + if (await cleanupRawCandidateGuarded(candidate)) { + rawRemoved.push(candidate.name); + } + } + } const initial = await listSessions(); // STEP 1: orphan-children. Sort by name so cycles (A→B, B→A) resolve @@ -863,7 +992,7 @@ export async function gc( // if it failed we leave the metadata around so the next tick can try // again. const finalList = dryRun ? initial : await listSessions(); - const removed: string[] = []; + const removed: string[] = [...rawRemoved]; const kept: string[] = []; for (const s of finalList) { if (!isGone(s.status)) continue; diff --git a/tests/list-purity.test.ts b/tests/list-purity.test.ts index a60b808..8cff174 100644 --- a/tests/list-purity.test.ts +++ b/tests/list-purity.test.ts @@ -25,6 +25,16 @@ const writeStaleSession = (root: string, name: string) => { })); }; +const writeRawDebris = ( + root: string, + name: string, + { corruptMetadata }: { corruptMetadata: boolean }, +) => { + fs.writeFileSync(path.join(root, `${name}.sock`), ""); + fs.writeFileSync(path.join(root, `${name}.pid`), "2147483647"); + if (corruptMetadata) fs.writeFileSync(path.join(root, `${name}.json`), "{"); +}; + afterEach(() => { delete process.env.PTY_ROOT; for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); @@ -80,4 +90,43 @@ describe("listSessions is observational", () => { expect(fs.existsSync(path.join(root, "dry.pid"))).toBe(false); expect(fs.existsSync(path.join(root, "dry.json"))).toBe(false); }); + + it.each([ + ["corrupt metadata", true], + ["missing metadata", false], + ])("previews and applies guarded cleanup for raw debris with %s", async ( + _label, + corruptMetadata, + ) => { + const root = withRoot(); + writeRawDebris(root, "debris", { corruptMetadata }); + + expect(await listSessions()).toEqual([]); + const preview = await gc({ dryRun: true }); + + expect(preview.removed).toEqual(["debris"]); + expect(fs.existsSync(path.join(root, "debris.sock"))).toBe(true); + expect(fs.existsSync(path.join(root, "debris.pid"))).toBe(true); + expect(fs.existsSync(path.join(root, "debris.json"))).toBe(corruptMetadata); + + const applied = await gc(); + + expect(applied.removed).toEqual(["debris"]); + expect(fs.existsSync(path.join(root, "debris.sock"))).toBe(false); + expect(fs.existsSync(path.join(root, "debris.pid"))).toBe(false); + expect(fs.existsSync(path.join(root, "debris.json"))).toBe(false); + }); + + it("does not clean raw debris while another creator owns the name", async () => { + const root = withRoot(); + writeRawDebris(root, "locked", { corruptMetadata: true }); + fs.writeFileSync(path.join(root, "locked.lock"), String(process.pid)); + + const result = await gc(); + + expect(result.removed).toEqual([]); + expect(fs.existsSync(path.join(root, "locked.sock"))).toBe(true); + expect(fs.existsSync(path.join(root, "locked.pid"))).toBe(true); + expect(fs.existsSync(path.join(root, "locked.json"))).toBe(true); + }); }); From e0db781b8c4374edcf861060ce4390b26ce9ccc5 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:15:02 +0200 Subject: [PATCH 3/5] test(gc): ratchet ambiguous debris preservation agent-session-id: dev3.dotfiles-cos-misc-agent-runtime-simplification agent-tool: Codex agent-tool-version: 0.145.0 agent-model: gpt-5.6-sol agent-runtime-profile: /home/schickling/.config/coding-agents/profile.json agent-skills-manifest: /nix/store/nk9iml2841l1yjjg0f6f0d3y60zkg1nn-agent-skills-corpus/share/agent-skills/manifest.json tooling-profile: dotfiles@4a0515f --- src/sessions.ts | 7 ++--- tests/list-purity.test.ts | 55 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/src/sessions.ts b/src/sessions.ts index d7638b4..7c0f67e 100644 --- a/src/sessions.ts +++ b/src/sessions.ts @@ -330,7 +330,8 @@ export interface ListSessionsOptions { const DEFAULT_SOCKET_PROBE_BUDGET_MS = 500; -interface RawCleanupCandidate { +/** @internal Testable gc observation/apply token; not part of client-api. */ +export interface RawCleanupCandidate { name: string; } @@ -359,7 +360,7 @@ function inspectMetadataArtifact( * associated socket is stale; malformed metadata without any runtime files is * also reclaimable. Ambiguous startup shapes (socket + missing/invalid pid) are * retained. */ -async function inventoryRawCleanupCandidates( +export async function inventoryRawCleanupCandidates( options: ListSessionsOptions = {}, onlyNames?: ReadonlySet, ): Promise { @@ -423,7 +424,7 @@ async function inventoryRawCleanupCandidates( * * Re-inventorying under the lock closes the observation/apply race: if a live * generation appeared, or the evidence became ambiguous, cleanup is skipped. */ -async function cleanupRawCandidateGuarded( +export async function cleanupRawCandidateGuarded( candidate: RawCleanupCandidate, options: ListSessionsOptions = {}, ): Promise { diff --git a/tests/list-purity.test.ts b/tests/list-purity.test.ts index 8cff174..fa45a79 100644 --- a/tests/list-purity.test.ts +++ b/tests/list-purity.test.ts @@ -1,8 +1,14 @@ import * as fs from "node:fs"; +import * as net from "node:net"; import * as os from "node:os"; import * as path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { gc, listSessions } from "../src/sessions.ts"; +import { + cleanupRawCandidateGuarded, + gc, + inventoryRawCleanupCandidates, + listSessions, +} from "../src/sessions.ts"; const roots: string[] = []; @@ -129,4 +135,51 @@ describe("listSessions is observational", () => { expect(fs.existsSync(path.join(root, "locked.pid"))).toBe(true); expect(fs.existsSync(path.join(root, "locked.json"))).toBe(true); }); + + it.each([ + ["live pid", (root: string) => { + fs.writeFileSync(path.join(root, "changed.pid"), String(process.pid)); + }], + ["ambiguous missing pid", (root: string) => { + fs.unlinkSync(path.join(root, "changed.pid")); + }], + ])("revalidates an initially reclaimable candidate that becomes %s", async ( + _label, + makeNonReclaimable, + ) => { + const root = withRoot(); + writeRawDebris(root, "changed", { corruptMetadata: false }); + const [candidate] = await inventoryRawCleanupCandidates(); + expect(candidate?.name).toBe("changed"); + if (!candidate) throw new Error("Expected raw cleanup candidate."); + + makeNonReclaimable(root); + const cleaned = await cleanupRawCandidateGuarded(candidate); + + expect(cleaned).toBe(false); + expect(fs.existsSync(path.join(root, "changed.sock"))).toBe(true); + }); + + it("preserves dead-pid debris when its socket is reachable", async () => { + const root = withRoot(); + fs.writeFileSync(path.join(root, "reachable.pid"), "2147483647"); + const socketPath = path.join(root, "reachable.sock"); + const server = net.createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(socketPath, resolve); + }); + + try { + const preview = await gc({ dryRun: true }); + const applied = await gc(); + + expect(preview.removed).toEqual([]); + expect(applied.removed).toEqual([]); + expect(fs.existsSync(socketPath)).toBe(true); + expect(fs.existsSync(path.join(root, "reachable.pid"))).toBe(true); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); }); From 19c5456090a4f1a24a73b7d0e37a15357a48e0c8 Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Thu, 30 Jul 2026 15:41:52 +0200 Subject: [PATCH 4/5] docs(sessions): describe read-only socket classification --- src/sessions.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/sessions.ts b/src/sessions.ts index 7c0f67e..a9b7c7b 100644 --- a/src/sessions.ts +++ b/src/sessions.ts @@ -466,15 +466,14 @@ export async function listSessions(options: ListSessionsOptions = {}): Promise(); - // Find running sessions (have .sock files). A live session is destroyed here - // ONLY on POSITIVE proof of death — a readable pid whose process is gone AND - // an unreachable socket. A transiently-unreadable pid must NOT be mistaken - // for a dead process: the daemon creates its .sock (listen) BEFORE it writes - // its .pid, and the plain pidfile write can be caught mid-flight, so under - // concurrent multi-agent load a `pty list` can momentarily read a null pid - // for a perfectly healthy session. Reaping on that (the old behavior) deleted - // a live daemon's socket/pid out from under it, making it invisible and - // getting it GC'd + re-launched by consumers that reconcile on not-running. + // Classify sessions that have .sock files without changing registry state. + // A live pid or reachable socket proves the daemon is alive. A readable dead + // pid plus an unreachable socket lets retained metadata report the session as + // exited/vanished below. An unreadable pid plus an unreachable socket remains + // ambiguous and is omitted: the daemon creates its .sock (listen) BEFORE it + // writes its .pid, and the plain pidfile write can be caught mid-flight. + // Artifact cleanup belongs exclusively to explicit lifecycle operations such + // as gc/rm. const sockFiles = entries.filter((e) => e.endsWith(".sock")).sort(); const socketCandidates = sockFiles.map((sockFile) => { const name = sockFile.replace(/\.sock$/, ""); From e95beab9a62a4632e04c5bdd677ebfbd618bd7d8 Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Thu, 30 Jul 2026 15:50:17 +0200 Subject: [PATCH 5/5] docs(sessions): describe ambiguous socket reporting --- src/sessions.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/sessions.ts b/src/sessions.ts index a9b7c7b..ed1708c 100644 --- a/src/sessions.ts +++ b/src/sessions.ts @@ -470,8 +470,10 @@ export async function listSessions(options: ListSessionsOptions = {}): Promise e.endsWith(".sock")).sort();