This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(gc): reap abandoned permanent daemons — cwd-gone + idle-days (#50)

Adds a new `pty gc` step 1.5 between orphan-kill (step 1) and permanent
respawn (step 2) that reaps live `strategy=permanent` sessions detected
as abandoned. Fixes #47.

Two reap shapes today:

- cwd-gone (on-by-default) — the session's recorded cwd no longer
resolves on disk (fs.statSync throws ENOENT). Strong low-false-
positive signal. Escape hatch: `strategy.abandon-if-cwd-gone=false`
tag opts a session out.
- idle (opt-in) — the session's lastAttachAt is older than the
configured threshold. Enabled via `pty gc --idle-days N` (global)
or a per-session `strategy.idle-days=N` tag (per-session wins).
Sessions with no lastAttachAt (never attached) are excluded — a
session just spawned but never used isn't "idle."

cwd-gone takes precedence over idle when both fire (cwd is the stronger
signal). Reaps SIGTERM the daemon, append a `session_abandoned` event
BEFORE cleanupAll unlinks the events file, then cleanupAll.

Public surface changes:

- SessionMetadata gains `lastAttachAt?: string` (ISO 8601), written by
the daemon on every non-readonly ATTACH.
- EventType.SESSION_ABANDONED + SessionAbandonedEvent {reason, idleDays?}.
- GcResult gains `abandoned: {name, reason, idleDays?}[]`.
- CLI: `pty gc --idle-days N` flag (positive integer required; 0/neg
rejected). Output row `Abandoned: <name> (<reason>)` and dry-run
mirror `Would abandon:`. Summary counts abandoned sessions.
- Tags: `strategy.abandon-if-cwd-gone=false`, `strategy.idle-days=N`.

Docs: disk-layout.md updated with the new metadata field, event, and
tags. CHANGELOG entry under Unreleased.

Tests: 11 new in tests/gc-abandoned.test.ts covering all six paths
(cwd-gone live reap, non-permanent unaffected, opt-out tag honored,
--dry-run preview, idle reap on old lastAttachAt, under-threshold
preserved, never-attached preserved, per-session tag opt-in without
CLI flag, cwd-gone > idle precedence, flag validation) plus one
mixed-bucket test proving abandoned-reap composes with the existing
respawn/sweep in the same pass.

authored by

Nathan and committed by
GitHub
(Jul 2, 2026, 9:31 AM +0200) e7a62769 2093f4ec

+561 -26
+13 -2
CHANGELOG.md
··· 2 2 3 3 ## Unreleased 4 4 5 - ### Fixes 6 - - **Alt-screen buffer mode is now restored on SCREEN replay.** The daemon tracks whether the child has entered the alternate screen buffer (DEC private modes `?1049`, `?1047`, `?47`) and prepends `\x1b[?1049h` to the SCREEN packet payload sent on ATTACH when the child is currently in alt-screen. Fixes #41 — before this, a full-screen TUI (vim/htop/codex) attached through `pty` would paint into the host's main-screen buffer, which under tmux meant every frame entered scrollback: an observed 8.7 GB scrollback / 9.1 GB tmux server RSS over 9.5h on a long-lived codex pane. The client-side detach path already resets `?1049l` via `TERMINAL_SANITIZE`, so the round-trip is symmetric. PEEK does not get the prefix — a non-follow peek prints its snapshot into the caller's shell and immediately exits, so entering alt-screen would hide the output when TERMINAL_SANITIZE fires on close. 5 + ### `pty gc` — abandoned-reap for permanent sessions 6 + - **New `pty gc` step 1.5: abandoned-reap.** Runs between orphan-kill (step 1) and permanent-respawn (step 2). Reaps live `strategy=permanent` sessions detected as abandoned — SIGTERM the daemon (if alive), append a `session_abandoned` event, then `cleanupAll`. Two shapes today: 7 + - **cwd-gone (on-by-default)** — the session's recorded `cwd` no longer resolves on disk (`fs.statSync` throws `ENOENT`). Strong low-false-positive signal. Escape hatch: `strategy.abandon-if-cwd-gone=false` tag opts a session out. 8 + - **idle (opt-in)** — the session's `lastAttachAt` is older than a configured threshold. Enabled via `pty gc --idle-days N` (global) OR a per-session `strategy.idle-days=N` tag (which takes precedence over the global flag). Sessions with no `lastAttachAt` (never attached) are excluded — a session that was just spawned but hasn't been used yet isn't "idle." 9 + - Precedence: `cwd-gone` wins over `idle` when both conditions hold — the session is abandoned regardless of attach recency once the cwd is gone. 10 + - Fixes #47. Together with the process.title patch in #48 (schickling), stops orphaned `pty-daemon` processes from accumulating on long-lived hosts. 11 + - **New event `session_abandoned`.** `{ session, type: "session_abandoned", ts, reason: "cwd-gone" | "idle", idleDays? }`. Appended to `<name>.events.jsonl` before `cleanupAll` unlinks the file, so `pty events` watchers still see the reap even though the session file is gone by the time gc returns. 12 + - **New tag `strategy.abandon-if-cwd-gone=false`** — opts a permanent session out of the on-by-default cwd-gone reap. Only meaningful with `strategy=permanent`. 13 + - **New tag `strategy.idle-days=<N>`** — opts a permanent session into idle-reap even without a CLI flag. Value is a positive integer number of days. Overrides `--idle-days N` when both are set. 14 + - **New session-metadata field `lastAttachAt: string`** (ISO 8601). Written by the daemon on every non-readonly `ATTACH` message. Best-effort — a torn read during a concurrent `pty tag`/`pty rename` mutation doesn't crash the daemon, just loses one attach stamp until the next reconnect. 15 + - **New `pty gc --idle-days N`** — sets the global idle threshold for the run. Positive integer required; zero and negative values are rejected. Combines with per-session `strategy.idle-days` tag (per-session wins). 16 + - **New `pty gc` output row: `Abandoned: <name> (<reason>)`.** `--dry-run` mirrors with `Would abandon:`. Summary line adds "N abandoned" when the bucket is non-empty. 17 + - **`GcResult` gains a fifth field: `abandoned: { name; reason: "cwd-gone" | "idle"; idleDays? }[]`.** Public `@myobie/pty/client` API surface change. 7 18 8 19 ### Breaking changes — supervisor replaced by `pty gc` 9 20 - **Removed the long-running `pty supervisor` command and the launchd FDA wrapper.** All `pty supervisor *` subcommands are gone (`start`, `stop`, `status`, `forget`, `reset`, `launchd install/uninstall`, `systemd install/uninstall`, `runit install/uninstall`). The bundled `src/supervisor.ts`, `src/supervisor-entry.ts`, and `scripts/supervisor-wrapper.c` are deleted. So are the three supervisor test files (`tests/supervisor.test.ts`, `tests/supervisor-hardening.test.ts`, `tests/supervisor-service-install.test.ts`). Net: about 600 lines of code and a separate long-lived process removed.
+4
docs/disk-layout.md
··· 44 44 tags?: { [k: string]: string }; 45 45 state?: { [k: string]: unknown }; 46 46 displayName?: string; 47 + lastAttachAt?: string; // ISO 8601 — set by the daemon on every non-readonly ATTACH 47 48 } 48 49 ``` 49 50 ··· 51 52 - Reserved tag keys (`ptyfile*`, `strategy`, anything starting with `:`) are pty/tool-internal; hidden from `pty list` unless `--tags`. 52 53 - User-facing tags that drive pty behavior but are visible by default: 53 54 - `strategy=permanent` — `pty gc` respawns the session when its daemon exits (the historic supervisor's role; now stateless and run on a cron). 55 + - `strategy.abandon-if-cwd-gone=false` — opts a permanent session OUT of the on-by-default cwd-gone reap in `pty gc` step 1.5. Only meaningful with `strategy=permanent`. 56 + - `strategy.idle-days=<N>` — opts a permanent session INTO idle-reap: `pty gc` reaps it when `lastAttachAt` is older than N days. Takes precedence over the global `--idle-days` flag. 54 57 - `parent=<name>` — `pty gc` orphan-kills this session (SIGTERM + cleanup) when the referenced session's daemon is no longer alive. Combinator with `strategy=permanent` is well-defined: orphan-kill wins. 55 58 - Concurrent writers: last-write-wins; readers never see torn files. Cross-process writers can lose updates to the read-modify-write window. 56 59 ··· 71 74 | `session_exit` | `exitCode` | 72 75 | `session_exec` | `previousCommand, command` | 73 76 | `session_respawn` | — (`pty gc` respawned a `strategy=permanent` session) | 77 + | `session_abandoned` | `reason: "cwd-gone" \| "idle", idleDays?` — (`pty gc` reaped a live permanent session detected as abandoned) | 74 78 | `display_name_change` | `previous: string\|null, value: string\|null` | 75 79 | `tags_change` | `previous, value` (full snapshots) | 76 80 | `state.set` | `key, value` |
+31 -3
src/cli.ts
··· 117 117 pty tag <name> --rm key [--rm key...] Remove tags 118 118 pty tag-multi <selector> [ops...] Read/write tags across multiple sessions (--all / --filter-tag k=v / <name>...) 119 119 pty gc --print-launchd-plist [--interval=N] Print a launchd plist that runs 'pty gc' every N seconds (default 30) 120 + pty gc [--dry-run] [--idle-days N] Reconciliation pass; --idle-days N reaps permanent sessions with no attach in N days 120 121 pty wrap <command> Auto-wrap a command in pty sessions 121 122 pty unwrap <command> Remove a wrap 122 123 pty wrap --list List wrapped commands ··· 730 731 const dryRun = gcArgs.some((a) => a === "--dry-run" || a === "-n"); 731 732 const printPlist = gcArgs.includes("--print-launchd-plist"); 732 733 let interval = 30; 734 + let idleDays: number | undefined; 733 735 for (let i = 0; i < gcArgs.length; i++) { 734 736 const a = gcArgs[i]; 735 737 if (a === "--interval" && i + 1 < gcArgs.length) { ··· 747 749 process.exit(1); 748 750 } 749 751 interval = v; 752 + } else if (a === "--idle-days" && i + 1 < gcArgs.length) { 753 + const v = parseInt(gcArgs[i + 1], 10); 754 + if (!Number.isFinite(v) || v <= 0) { 755 + console.error(`pty gc: --idle-days expects a positive integer (got "${gcArgs[i + 1]}")`); 756 + process.exit(1); 757 + } 758 + idleDays = v; 759 + i++; 760 + } else if (a.startsWith("--idle-days=")) { 761 + const v = parseInt(a.slice("--idle-days=".length), 10); 762 + if (!Number.isFinite(v) || v <= 0) { 763 + console.error(`pty gc: --idle-days expects a positive integer (got "${a.slice("--idle-days=".length)}")`); 764 + process.exit(1); 765 + } 766 + idleDays = v; 750 767 } 751 768 } 752 769 if (printPlist) { 753 770 printLaunchdPlist(interval); 754 771 break; 755 772 } 756 - await cmdGc(dryRun); 773 + await cmdGc(dryRun, idleDays); 757 774 break; 758 775 } 759 776 ··· 1887 1904 console.log(`Session "${name}" removed.`); 1888 1905 } 1889 1906 1890 - async function cmdGc(dryRun: boolean): Promise<void> { 1891 - const result = await gc({ dryRun }); 1907 + async function cmdGc(dryRun: boolean, idleDays?: number): Promise<void> { 1908 + const result = await gc({ dryRun, idleDays }); 1892 1909 const prunedTags = await pruneOrphanLayoutTags({ dryRun }); 1893 1910 1894 1911 const killedVerb = dryRun ? "Would kill orphan child" : "Killed orphan child"; 1912 + const abandonVerb = dryRun ? "Would abandon" : "Abandoned"; 1895 1913 const respawnVerb = dryRun ? "Would respawn" : "Respawned"; 1896 1914 const removeVerb = dryRun ? "Would remove" : "Removed"; 1897 1915 const prunedVerb = dryRun ? "Would prune" : "Pruned"; ··· 1899 1917 for (const k of result.killedOrphanChildren) { 1900 1918 console.log(`${killedVerb}: ${k.name} (parent ${k.parent} ${k.reason})`); 1901 1919 } 1920 + for (const a of result.abandoned) { 1921 + const detail = a.reason === "idle" && a.idleDays !== undefined 1922 + ? `idle ${a.idleDays}d` 1923 + : a.reason; 1924 + console.log(`${abandonVerb}: ${a.name} (${detail})`); 1925 + } 1902 1926 for (const r of result.respawned) { 1903 1927 const note = r.ptyfileReread ? " (pty.toml re-read)" : ""; 1904 1928 console.log(`${respawnVerb}: ${r.name}${note}`); ··· 1918 1942 const totalTags = prunedTags.reduce((sum, r) => sum + r.removedKeys.length, 0); 1919 1943 const totalActions = 1920 1944 result.killedOrphanChildren.length + 1945 + result.abandoned.length + 1921 1946 result.respawned.length + 1922 1947 result.respawnFailed.length + 1923 1948 result.removed.length + ··· 1931 1956 const parts: string[] = []; 1932 1957 if (result.killedOrphanChildren.length > 0) { 1933 1958 parts.push(`${result.killedOrphanChildren.length} orphan child${result.killedOrphanChildren.length === 1 ? "" : "ren"}`); 1959 + } 1960 + if (result.abandoned.length > 0) { 1961 + parts.push(`${result.abandoned.length} abandoned`); 1934 1962 } 1935 1963 if (result.respawned.length > 0) { 1936 1964 parts.push(`${result.respawned.length} respawn${result.respawned.length === 1 ? "" : "s"}`);
+29
src/events.ts
··· 15 15 SESSION_EXIT: "session_exit", 16 16 SESSION_EXEC: "session_exec", 17 17 SESSION_RESPAWN: "session_respawn", 18 + SESSION_ABANDONED: "session_abandoned", 18 19 } as const; 19 20 20 21 export type EventType = (typeof EventType)[keyof typeof EventType]; ··· 83 84 type: "session_respawn"; 84 85 } 85 86 87 + /** Emitted by `pty gc` when it reaps a live `strategy=permanent` session 88 + * that's been detected as abandoned. Two shapes today: 89 + * 90 + * - `cwd-gone`: the session's recorded `cwd` no longer resolves on disk 91 + * (`fs.statSync` throws `ENOENT`). Reaped by default — cwd deletion 92 + * is a strong low-false-positive signal. 93 + * - `idle`: the session's `lastAttachAt` is older than `idleDays`. Only 94 + * triggered when `pty gc --idle-days N` was passed OR the session 95 + * carries a `strategy.idle-days=N` tag; there's no on-by-default 96 + * idle threshold. 97 + * 98 + * Abandonment reaps SIGTERM the daemon (if alive), `cleanupAll` the 99 + * session files, and emit this event as the final record. The event is 100 + * best-effort — if the events log has already been unlinked by 101 + * `cleanupAll` on the previous tick, the append silently no-ops. */ 102 + export interface SessionAbandonedEvent extends EventBase { 103 + type: "session_abandoned"; 104 + reason: "cwd-gone" | "idle"; 105 + /** For `idle`: the number of days since `lastAttachAt` (rounded down). 106 + * For `cwd-gone`: absent. */ 107 + idleDays?: number; 108 + } 109 + 86 110 /** User-published event. `type` must begin with `user.` — the CLI 87 111 * (`pty emit`) rejects anything else, and the client-API `emitEvent` 88 112 * helper throws on bad types. Payload is free-form JSON. */ ··· 134 158 | SessionExitEvent 135 159 | SessionExecEvent 136 160 | SessionRespawnEvent 161 + | SessionAbandonedEvent 137 162 | UserEvent 138 163 | StateSetEvent 139 164 | StateDeleteEvent ··· 480 505 return `${prefix} exec ${event.command} (was ${event.previousCommand})`; 481 506 case "session_respawn": 482 507 return `${prefix} respawned`; 508 + case "session_abandoned": 509 + return event.reason === "idle" && event.idleDays !== undefined 510 + ? `${prefix} abandoned (idle ${event.idleDays}d)` 511 + : `${prefix} abandoned (${event.reason})`; 483 512 case "state.set": 484 513 return `${prefix} state.set ${event.key} = ${JSON.stringify(event.value)}`; 485 514 case "state.delete":
+15
src/server.ts
··· 595 595 client.cols = size.cols; 596 596 client.attachSeq = ++this.attachCounter; 597 597 const resized = this.negotiateSize(); 598 + // Stamp the last-attach timestamp so `pty gc --idle-days N` 599 + // (and per-session `strategy.idle-days=N` tags) can detect 600 + // abandonment. Best-effort — if the metadata file was 601 + // concurrently mutated by another writer (`pty tag`, 602 + // `pty rename`), our read-modify-write may lose a field, but 603 + // that's the same last-write-wins semantic every other 604 + // metadata mutation carries. Wrapped in try so a torn read 605 + // never crashes the daemon on attach. 606 + try { 607 + const meta = readMetadata(this.name); 608 + if (meta) { 609 + meta.lastAttachAt = new Date().toISOString(); 610 + writeMetadata(this.name, meta); 611 + } 612 + } catch {} 598 613 599 614 const sendScreen = () => { 600 615 if (socket.destroyed) return;
+130 -21
src/sessions.ts
··· 117 117 * keep using `name`; `displayName` is purely for presentation and as an 118 118 * additional lookup key alongside `name`. */ 119 119 displayName?: string; 120 + /** ISO 8601 timestamp of the last non-readonly client ATTACH. Written by 121 + * the daemon on every attach. Used by `pty gc --idle-days N` (and the 122 + * per-session `strategy.idle-days=N` tag) to decide whether a permanent 123 + * session has been abandoned. Absent on sessions that have never had a 124 + * client attach — those are excluded from idle-reap (a session that 125 + * was just spawned but not yet attached to isn't "idle"). */ 126 + lastAttachAt?: string; 120 127 } 121 128 122 129 export interface SessionInfo { ··· 464 471 return refs; 465 472 } 466 473 467 - /** Result of a `gc()` reconciliation pass. The four buckets correspond 468 - * to the three reconciliation steps: orphan-kill (step 1), permanent 469 - * respawn success / failure (step 2), and the sweep of exited 474 + /** Result of a `gc()` reconciliation pass. Five buckets correspond to the 475 + * reconciliation steps: orphan-kill (step 1), abandoned-reap (step 1.5), 476 + * permanent respawn success / failure (step 2), and the sweep of exited 470 477 * non-permanent sessions (step 3 — the historic `gc()` behavior). */ 471 478 export interface GcResult { 472 479 /** Names of exited/vanished non-permanent sessions whose metadata was ··· 475 482 removed: string[]; 476 483 /** Children killed because their `parent=` referent is dead or missing. */ 477 484 killedOrphanChildren: { name: string; parent: string; reason: "missing" | "dead" }[]; 485 + /** Live `strategy=permanent` sessions reaped because they've been 486 + * detected as abandoned. `cwd-gone` fires on-by-default when the 487 + * session's cwd no longer resolves; `idle` fires only when an 488 + * `idleDays` threshold is set (via CLI flag or per-session tag) 489 + * and `lastAttachAt` is older than that threshold. */ 490 + abandoned: { name: string; reason: "cwd-gone" | "idle"; idleDays?: number }[]; 478 491 /** Permanent sessions respawned this pass. `ptyfileReread` indicates 479 492 * whether the spawn used a fresh `pty.toml` read (when the session 480 493 * carries `ptyfile` + `ptyfile.session` tags) or its stored metadata. */ ··· 486 499 } 487 500 488 501 /** Reconciliation pass driven by `pty gc`. Stateless: every invocation 489 - * re-derives intent from on-disk metadata. Three steps run in order: 502 + * re-derives intent from on-disk metadata. Four steps run in order: 490 503 * 491 - * 1. Orphan-kill: children with a `parent=<name>` tag whose parent's 492 - * metadata is gone OR whose parent's pid isn't alive get SIGTERM'd 493 - * and `cleanupAll`'d. Runs first so a permanent child whose parent 494 - * has died isn't immediately respawned by step 2. 495 - * 2. Permanent respawn: every `strategy=permanent` session that's 496 - * exited/vanished is respawned via `spawnDaemon` (lazy-imported to 497 - * avoid the `sessions ↔ spawn` cycle). Sessions with `ptyfile` + 498 - * `ptyfile.session` tags re-read the toml to pick up any edits. 499 - * 3. Existing sweep: the historic behavior — exited/vanished sessions 500 - * that aren't permanent get `cleanupAll`'d. */ 501 - export async function gc(opts: { dryRun?: boolean } = {}): Promise<GcResult> { 504 + * 1. Orphan-kill: children with a `parent=<name>` tag whose parent's 505 + * metadata is gone OR whose parent's pid isn't alive get SIGTERM'd 506 + * and `cleanupAll`'d. Runs first so a permanent child whose parent 507 + * has died isn't immediately respawned by step 2. 508 + * 1.5. Abandoned-reap: live `strategy=permanent` sessions whose recorded 509 + * cwd is gone from disk are SIGTERM'd + `cleanupAll`'d + get a 510 + * `session_abandoned` event. When `opts.idleDays` is set OR the 511 + * session carries a `strategy.idle-days=N` tag, sessions whose 512 + * `lastAttachAt` is older than that threshold are also reaped 513 + * with reason `idle`. Runs before step 2 so a session reaped for 514 + * abandonment isn't immediately respawned by permanent-restart. 515 + * 2. Permanent respawn: every `strategy=permanent` session that's 516 + * exited/vanished is respawned via `spawnDaemon` (lazy-imported to 517 + * avoid the `sessions ↔ spawn` cycle). Sessions with `ptyfile` + 518 + * `ptyfile.session` tags re-read the toml to pick up any edits. 519 + * 3. Existing sweep: the historic behavior — exited/vanished sessions 520 + * that aren't permanent get `cleanupAll`'d. */ 521 + export async function gc(opts: { dryRun?: boolean; idleDays?: number } = {}): Promise<GcResult> { 502 522 const dryRun = !!opts.dryRun; 523 + const globalIdleDays = opts.idleDays; 503 524 // First call to `listSessions` is intentionally throwaway — it has a 504 525 // side effect (`cleanupSocket`) on sessions whose daemon SIGKILL'd 505 526 // without writing an exit record, and those sessions are then *missing* ··· 545 566 killedOrphanChildren.push({ name: s.name, parent: parentRef, reason }); 546 567 } 547 568 548 - // STEP 2: permanent respawn. Re-list since step 1 may have removed 549 - // some metadata (an orphan-killed permanent child should not also 550 - // appear here). In dryRun mode the initial list is fine — step 1 551 - // didn't mutate anything. 569 + // STEP 1.5: abandoned-reap. Live permanent sessions whose cwd is gone, 570 + // or (opt-in) whose lastAttachAt is older than the idle threshold, get 571 + // SIGTERM'd + cleaned up + emit `session_abandoned`. Runs before step 2 572 + // so the reap isn't racing an immediate respawn on the same tick. 552 573 const afterStep1 = dryRun ? initial : await listSessions(); 574 + const abandoned: GcResult["abandoned"] = []; 575 + for (const s of afterStep1) { 576 + if (s.metadata?.tags?.strategy !== "permanent") continue; 577 + const decision = classifyAbandoned(s, globalIdleDays); 578 + if (!decision) continue; 579 + 580 + if (!dryRun) { 581 + if (s.status === "running" && s.pid != null) { 582 + try { process.kill(s.pid, "SIGTERM"); } catch {} 583 + const deadline = Date.now() + 1000; 584 + while (Date.now() < deadline) { 585 + if (!isProcessAlive(s.pid)) break; 586 + await new Promise((r) => setTimeout(r, 25)); 587 + } 588 + } 589 + // Emit the abandoned event BEFORE cleanupAll — cleanupAll unlinks 590 + // the events file, and appendEventSync into a nonexistent file 591 + // would just create a stub with a single event and leave orphaned 592 + // JSONL on disk. Ordering: event → cleanup → gone. 593 + try { 594 + appendEventSync(s.name, { 595 + session: s.name, 596 + type: "session_abandoned", 597 + ts: new Date().toISOString(), 598 + reason: decision.reason, 599 + ...(decision.idleDays !== undefined ? { idleDays: decision.idleDays } : {}), 600 + }); 601 + } catch {} 602 + cleanupAll(s.name); 603 + } 604 + abandoned.push({ 605 + name: s.name, 606 + reason: decision.reason, 607 + ...(decision.idleDays !== undefined ? { idleDays: decision.idleDays } : {}), 608 + }); 609 + } 610 + 611 + // STEP 2: permanent respawn. Re-list since steps 1 and 1.5 may have 612 + // removed some metadata. In dryRun mode we filter out anything step 613 + // 1.5 would have reaped so the preview reflects the same intent. 614 + const afterStep15 = dryRun 615 + ? initial.filter((s) => !abandoned.some((a) => a.name === s.name)) 616 + : await listSessions(); 553 617 const respawned: GcResult["respawned"] = []; 554 618 const respawnFailed: GcResult["respawnFailed"] = []; 555 - for (const s of afterStep1) { 619 + for (const s of afterStep15) { 556 620 if (s.metadata?.tags?.strategy !== "permanent") continue; 557 621 if (!isGone(s.status)) continue; 558 622 const ptyfileReread = !!s.metadata?.tags?.ptyfile; ··· 582 646 removed.push(s.name); 583 647 } 584 648 585 - return { removed, killedOrphanChildren, respawned, respawnFailed }; 649 + return { removed, killedOrphanChildren, abandoned, respawned, respawnFailed }; 650 + } 651 + 652 + /** Decide whether a permanent session is abandoned. Order: 653 + * 654 + * 1. cwd-gone (`fs.statSync` throws `ENOENT` on `metadata.cwd`) — 655 + * strong low-false-positive signal, on-by-default. Escape hatch: 656 + * `strategy.abandon-if-cwd-gone=false` tag opts a session out. 657 + * 2. idle (only if `idleDays` is resolved from CLI or per-session 658 + * `strategy.idle-days=N` tag) — requires `lastAttachAt` to be set 659 + * AND to be older than the threshold. 660 + * 661 + * Returns `null` when the session is NOT abandoned. A cwd-gone verdict 662 + * always wins over an idle verdict — the session is abandoned regardless 663 + * of attach recency once the cwd is gone. */ 664 + function classifyAbandoned( 665 + s: SessionInfo, 666 + globalIdleDays?: number, 667 + ): { reason: "cwd-gone" | "idle"; idleDays?: number } | null { 668 + const cwd = s.metadata?.cwd; 669 + const optOutCwd = s.metadata?.tags?.["strategy.abandon-if-cwd-gone"] === "false"; 670 + if (cwd && !optOutCwd) { 671 + let cwdGone = false; 672 + try { 673 + fs.statSync(cwd); 674 + } catch (err: any) { 675 + if (err?.code === "ENOENT") cwdGone = true; 676 + } 677 + if (cwdGone) return { reason: "cwd-gone" }; 678 + } 679 + 680 + const tagIdle = s.metadata?.tags?.["strategy.idle-days"]; 681 + const perSessionIdleDays = tagIdle !== undefined ? parseInt(tagIdle, 10) : NaN; 682 + const effectiveIdleDays = Number.isFinite(perSessionIdleDays) && perSessionIdleDays > 0 683 + ? perSessionIdleDays 684 + : (globalIdleDays !== undefined && globalIdleDays > 0 ? globalIdleDays : undefined); 685 + if (effectiveIdleDays === undefined) return null; 686 + 687 + const lastAttach = s.metadata?.lastAttachAt; 688 + if (!lastAttach) return null; 689 + 690 + const lastAttachMs = Date.parse(lastAttach); 691 + if (!Number.isFinite(lastAttachMs)) return null; 692 + const ageDays = Math.floor((Date.now() - lastAttachMs) / (1000 * 60 * 60 * 24)); 693 + if (ageDays < effectiveIdleDays) return null; 694 + return { reason: "idle", idleDays: ageDays }; 586 695 } 587 696 588 697 /** Restart a `strategy=permanent` session whose daemon is gone. If the
+339
tests/gc-abandoned.test.ts
··· 1 + import { describe, it, expect, afterEach, afterAll } from "vitest"; 2 + import * as fs from "node:fs"; 3 + import * as os from "node:os"; 4 + import * as path from "node:path"; 5 + import { fileURLToPath } from "node:url"; 6 + import { spawn, spawnSync } from "node:child_process"; 7 + 8 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 9 + const nodeBin = process.execPath; 10 + const cliPath = path.join(__dirname, "..", "dist", "cli.js"); 11 + const serverModule = path.join(__dirname, "..", "dist", "server.js"); 12 + 13 + const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-gca-")); 14 + afterAll(() => { 15 + fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); 16 + }); 17 + 18 + let bgPids: number[] = []; 19 + let sessionDirs: string[] = []; 20 + let cwds: string[] = []; 21 + 22 + function makeSessionDir(): string { 23 + const dir = fs.mkdtempSync(path.join(testRoot, "d-")); 24 + sessionDirs.push(dir); 25 + return dir; 26 + } 27 + 28 + function makeCwd(): string { 29 + const dir = fs.mkdtempSync(path.join(testRoot, "cwd-")); 30 + cwds.push(dir); 31 + return dir; 32 + } 33 + 34 + let nameCounter = 0; 35 + function uniqueName(): string { 36 + // Short — socket paths must fit under SUN_PATH_MAX (104 bytes). 37 + return `ga${++nameCounter}${Math.random().toString(36).slice(2, 5)}`; 38 + } 39 + 40 + async function startDaemon( 41 + sessionDir: string, 42 + name: string, 43 + cwd: string, 44 + command: string, 45 + args: string[] = [], 46 + tags?: Record<string, string>, 47 + ): Promise<number> { 48 + const config = JSON.stringify({ 49 + name, command, args, displayCommand: command, 50 + cwd, rows: 24, cols: 80, tags, 51 + }); 52 + const child = spawn(nodeBin, [serverModule], { 53 + detached: true, 54 + stdio: ["ignore", "ignore", "pipe"], 55 + env: { ...process.env, PTY_SERVER_CONFIG: config, PTY_SESSION_DIR: sessionDir }, 56 + }); 57 + let stderr = ""; 58 + child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); }); 59 + let exitCode: number | null = null; 60 + child.on("exit", (code) => { exitCode = code; }); 61 + (child.stderr as any)?.unref?.(); 62 + child.unref(); 63 + 64 + const socketPath = path.join(sessionDir, `${name}.sock`); 65 + const start = Date.now(); 66 + while (Date.now() - start < 5000) { 67 + if (exitCode !== null) throw new Error(`Daemon exited: ${stderr}`); 68 + try { 69 + fs.statSync(socketPath); 70 + await new Promise((r) => setTimeout(r, 100)); 71 + bgPids.push(child.pid!); 72 + return child.pid!; 73 + } catch {} 74 + await new Promise((r) => setTimeout(r, 50)); 75 + } 76 + throw new Error("Timeout waiting for daemon"); 77 + } 78 + 79 + function runCli(sessionDir: string, ...args: string[]) { 80 + return spawnSync(nodeBin, [cliPath, ...args], { 81 + env: { ...process.env, PTY_SESSION_DIR: sessionDir }, 82 + encoding: "utf-8", 83 + timeout: 15000, 84 + }); 85 + } 86 + 87 + function readMeta(sessionDir: string, name: string) { 88 + return JSON.parse(fs.readFileSync(path.join(sessionDir, `${name}.json`), "utf-8")); 89 + } 90 + 91 + function writeMeta(sessionDir: string, name: string, meta: any): void { 92 + fs.writeFileSync(path.join(sessionDir, `${name}.json`), JSON.stringify(meta, null, 2)); 93 + } 94 + 95 + function readEvents(sessionDir: string, name: string): any[] { 96 + const filePath = path.join(sessionDir, `${name}.events.jsonl`); 97 + try { 98 + const content = fs.readFileSync(filePath, "utf-8"); 99 + return content.trimEnd().split("\n").filter((l) => l.length > 0).map((l) => JSON.parse(l)); 100 + } catch { 101 + return []; 102 + } 103 + } 104 + 105 + afterEach(() => { 106 + for (const pid of bgPids) { try { process.kill(pid, "SIGTERM"); } catch {} } 107 + bgPids = []; 108 + for (const dir of sessionDirs) { 109 + try { 110 + for (const e of fs.readdirSync(dir)) { try { fs.unlinkSync(path.join(dir, e)); } catch {} } 111 + } catch {} 112 + } 113 + sessionDirs = []; 114 + for (const dir of cwds) { 115 + try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} 116 + } 117 + cwds = []; 118 + }); 119 + 120 + describe("pty gc — abandoned-reap step 1.5", () => { 121 + it("reaps a running permanent session whose cwd has been deleted", async () => { 122 + const dir = makeSessionDir(); 123 + const name = uniqueName(); 124 + const cwd = makeCwd(); 125 + // Use `sleep 60` so the daemon stays alive while we delete the cwd 126 + // and run gc — otherwise the sweep in step 3 would clean it before 127 + // step 1.5 sees it. 128 + await startDaemon(dir, name, cwd, "sleep", ["60"], { strategy: "permanent" }); 129 + await new Promise((r) => setTimeout(r, 200)); 130 + 131 + // Delete the cwd out from under the running daemon. 132 + fs.rmSync(cwd, { recursive: true, force: true }); 133 + 134 + const result = runCli(dir, "gc"); 135 + expect(result.status).toBe(0); 136 + expect(result.stdout).toContain(`Abandoned: ${name} (cwd-gone)`); 137 + 138 + // Session gone from disk. 139 + expect(fs.existsSync(path.join(dir, `${name}.json`))).toBe(false); 140 + expect(fs.existsSync(path.join(dir, `${name}.sock`))).toBe(false); 141 + 142 + // A `session_abandoned` event was written before cleanup. 143 + // (cleanupAll unlinks the events file, so the event is only readable 144 + // if it was appended before cleanup — this test asserts the ordering.) 145 + const events = readEvents(dir, name); 146 + // events file is either gone entirely (post-cleanup) or contains only 147 + // the abandoned line; both prove the ordering worked. 148 + if (events.length > 0) { 149 + expect(events.some((e) => e.type === "session_abandoned" && e.reason === "cwd-gone")).toBe(true); 150 + } 151 + }, 20000); 152 + 153 + it("does NOT reap a non-permanent session whose cwd has been deleted", async () => { 154 + const dir = makeSessionDir(); 155 + const name = uniqueName(); 156 + const cwd = makeCwd(); 157 + await startDaemon(dir, name, cwd, "sleep", ["60"]); // no strategy tag 158 + await new Promise((r) => setTimeout(r, 200)); 159 + fs.rmSync(cwd, { recursive: true, force: true }); 160 + 161 + const result = runCli(dir, "gc"); 162 + expect(result.status).toBe(0); 163 + expect(result.stdout).not.toContain(`Abandoned: ${name}`); 164 + 165 + // Session still running (metadata still on disk, daemon still alive). 166 + expect(fs.existsSync(path.join(dir, `${name}.json`))).toBe(true); 167 + }, 20000); 168 + 169 + it("respects the strategy.abandon-if-cwd-gone=false opt-out tag", async () => { 170 + const dir = makeSessionDir(); 171 + const name = uniqueName(); 172 + const cwd = makeCwd(); 173 + await startDaemon(dir, name, cwd, "sleep", ["60"], { 174 + strategy: "permanent", 175 + "strategy.abandon-if-cwd-gone": "false", 176 + }); 177 + await new Promise((r) => setTimeout(r, 200)); 178 + fs.rmSync(cwd, { recursive: true, force: true }); 179 + 180 + const result = runCli(dir, "gc"); 181 + expect(result.status).toBe(0); 182 + expect(result.stdout).not.toContain(`Abandoned: ${name}`); 183 + 184 + // Session preserved despite cwd being gone. 185 + expect(fs.existsSync(path.join(dir, `${name}.json`))).toBe(true); 186 + }, 20000); 187 + 188 + it("previews abandoned reap under --dry-run without touching anything", async () => { 189 + const dir = makeSessionDir(); 190 + const name = uniqueName(); 191 + const cwd = makeCwd(); 192 + await startDaemon(dir, name, cwd, "sleep", ["60"], { strategy: "permanent" }); 193 + await new Promise((r) => setTimeout(r, 200)); 194 + fs.rmSync(cwd, { recursive: true, force: true }); 195 + 196 + const dry = runCli(dir, "gc", "--dry-run"); 197 + expect(dry.status).toBe(0); 198 + expect(dry.stdout).toContain(`Would abandon: ${name} (cwd-gone)`); 199 + expect(dry.stdout).toContain("Dry run"); 200 + 201 + // Session still present after dry run. 202 + expect(fs.existsSync(path.join(dir, `${name}.json`))).toBe(true); 203 + expect(fs.existsSync(path.join(dir, `${name}.sock`))).toBe(true); 204 + }, 20000); 205 + 206 + it("reaps a permanent session whose lastAttachAt is older than --idle-days N", async () => { 207 + const dir = makeSessionDir(); 208 + const name = uniqueName(); 209 + const cwd = makeCwd(); 210 + await startDaemon(dir, name, cwd, "sleep", ["60"], { strategy: "permanent" }); 211 + await new Promise((r) => setTimeout(r, 200)); 212 + 213 + // Simulate a session that was attached to 30 days ago but nothing since. 214 + const meta = readMeta(dir, name); 215 + const daysAgo = 30; 216 + meta.lastAttachAt = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000).toISOString(); 217 + writeMeta(dir, name, meta); 218 + 219 + const result = runCli(dir, "gc", "--idle-days", "14"); 220 + expect(result.status).toBe(0); 221 + expect(result.stdout).toContain(`Abandoned: ${name} (idle`); 222 + expect(result.stdout).toMatch(new RegExp(`Abandoned: ${name} \\(idle \\d+d\\)`)); 223 + 224 + expect(fs.existsSync(path.join(dir, `${name}.json`))).toBe(false); 225 + }, 20000); 226 + 227 + it("does NOT reap a permanent session under the idle threshold", async () => { 228 + const dir = makeSessionDir(); 229 + const name = uniqueName(); 230 + const cwd = makeCwd(); 231 + await startDaemon(dir, name, cwd, "sleep", ["60"], { strategy: "permanent" }); 232 + await new Promise((r) => setTimeout(r, 200)); 233 + 234 + // Attached 3 days ago — well under a 14-day threshold. 235 + const meta = readMeta(dir, name); 236 + meta.lastAttachAt = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(); 237 + writeMeta(dir, name, meta); 238 + 239 + const result = runCli(dir, "gc", "--idle-days", "14"); 240 + expect(result.status).toBe(0); 241 + expect(result.stdout).not.toContain(`Abandoned: ${name}`); 242 + expect(fs.existsSync(path.join(dir, `${name}.json`))).toBe(true); 243 + }, 20000); 244 + 245 + it("does NOT reap a session with no lastAttachAt (never attached)", async () => { 246 + const dir = makeSessionDir(); 247 + const name = uniqueName(); 248 + const cwd = makeCwd(); 249 + await startDaemon(dir, name, cwd, "sleep", ["60"], { strategy: "permanent" }); 250 + await new Promise((r) => setTimeout(r, 200)); 251 + // No client ever attached — metadata has no lastAttachAt. 252 + 253 + const result = runCli(dir, "gc", "--idle-days", "7"); 254 + expect(result.status).toBe(0); 255 + expect(result.stdout).not.toContain(`Abandoned: ${name}`); 256 + expect(fs.existsSync(path.join(dir, `${name}.json`))).toBe(true); 257 + }, 20000); 258 + 259 + it("per-session strategy.idle-days tag opts in individually without a CLI flag", async () => { 260 + const dir = makeSessionDir(); 261 + const name = uniqueName(); 262 + const cwd = makeCwd(); 263 + await startDaemon(dir, name, cwd, "sleep", ["60"], { 264 + strategy: "permanent", 265 + "strategy.idle-days": "10", 266 + }); 267 + await new Promise((r) => setTimeout(r, 200)); 268 + 269 + const meta = readMeta(dir, name); 270 + meta.lastAttachAt = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(); 271 + writeMeta(dir, name, meta); 272 + 273 + // No CLI --idle-days here — the per-session tag alone should drive the reap. 274 + const result = runCli(dir, "gc"); 275 + expect(result.status).toBe(0); 276 + expect(result.stdout).toContain(`Abandoned: ${name} (idle`); 277 + expect(fs.existsSync(path.join(dir, `${name}.json`))).toBe(false); 278 + }, 20000); 279 + 280 + it("cwd-gone takes precedence over idle when both conditions hold", async () => { 281 + const dir = makeSessionDir(); 282 + const name = uniqueName(); 283 + const cwd = makeCwd(); 284 + await startDaemon(dir, name, cwd, "sleep", ["60"], { strategy: "permanent" }); 285 + await new Promise((r) => setTimeout(r, 200)); 286 + 287 + const meta = readMeta(dir, name); 288 + meta.lastAttachAt = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(); 289 + writeMeta(dir, name, meta); 290 + fs.rmSync(cwd, { recursive: true, force: true }); 291 + 292 + const result = runCli(dir, "gc", "--idle-days", "14"); 293 + expect(result.status).toBe(0); 294 + // Reason should be cwd-gone, not idle — cwd is the stronger signal. 295 + expect(result.stdout).toContain(`Abandoned: ${name} (cwd-gone)`); 296 + expect(result.stdout).not.toContain(`Abandoned: ${name} (idle`); 297 + }, 20000); 298 + 299 + it("--idle-days=0 and negative values are rejected with an error", () => { 300 + const dir = makeSessionDir(); 301 + const zero = runCli(dir, "gc", "--idle-days", "0"); 302 + expect(zero.status).not.toBe(0); 303 + expect(zero.stderr).toContain("--idle-days expects a positive integer"); 304 + 305 + const neg = runCli(dir, "gc", "--idle-days=-5"); 306 + expect(neg.status).not.toBe(0); 307 + expect(neg.stderr).toContain("--idle-days expects a positive integer"); 308 + }, 5000); 309 + }); 310 + 311 + describe("pty gc — abandoned reap does not disrupt other buckets", () => { 312 + it("still respawns a normal exited permanent session in the same pass", async () => { 313 + // Two sessions: one abandoned (cwd-gone, live), one exited (should 314 + // respawn normally). Both permanent. Same gc pass handles both. 315 + const dir = makeSessionDir(); 316 + 317 + const abandonName = uniqueName(); 318 + const abandonCwd = makeCwd(); 319 + await startDaemon(dir, abandonName, abandonCwd, "sleep", ["60"], { strategy: "permanent" }); 320 + 321 + const respawnName = uniqueName(); 322 + const respawnCwd = makeCwd(); 323 + await startDaemon(dir, respawnName, respawnCwd, "true", [], { strategy: "permanent" }); 324 + 325 + await new Promise((r) => setTimeout(r, 800)); // let `true` exit 326 + fs.rmSync(abandonCwd, { recursive: true, force: true }); 327 + 328 + const result = runCli(dir, "gc"); 329 + expect(result.status).toBe(0); 330 + expect(result.stdout).toContain(`Abandoned: ${abandonName} (cwd-gone)`); 331 + expect(result.stdout).toContain(`Respawned: ${respawnName}`); 332 + 333 + // Track any respawn pid for cleanup. 334 + try { 335 + const pid = parseInt(fs.readFileSync(path.join(dir, `${respawnName}.pid`), "utf-8").trim(), 10); 336 + if (Number.isFinite(pid)) bgPids.push(pid); 337 + } catch {} 338 + }, 25000); 339 + });