Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,37 @@
An empty field means the process is gone or `ps` did not answer, and under
load `ps` is the thing that goes quiet, so the kernel is asked again.

### Bounded `keep` retention in gc

- **`keep=true` on a DEAD session now expires.** `pty gc`'s sweep skips a
`keep`-tagged exited/vanished session only while it has been dead for less
than `--keep-max-age` (default `7d`), then reclaims it. Exit-time retention
is unchanged and still unconditional; running sessions are never swept
whatever their age. Agents tag the session they are debugging right now and
never come back to untag it, so the previous forever-exemption grew the
registry without bound (740 of 911 sessions on one host).
- **New `pty gc --keep-max-age <dur>`** — `Ns`/`Nm`/`Nh`/`Nd`, or bare `0` to
sweep every dead `keep` session on this pass. A unit-less non-zero value is
rejected rather than guessed at. Age is anchored on `exitedAt`, falling back
to `createdAt` for a `vanished` session that never wrote one — the same
anchor precedence `pty list --older-than` uses. Records with neither
timestamp never expire (except under `0`).
- **`GcResult` gains `keepExpired: string[]`** — dead sessions swept despite a
`keep` tag, disjoint from `removed` so callers can report the two reasons
apart. Public `@compoundingtech/pty/client` API surface change; the module
also now exports `DEFAULT_KEEP_MAX_AGE_MS` and `isKeepExpired`.
- **CLI output** — `Removed (keep expired after 7d): <name>` (`Would remove …`
under `--dry-run`), a `N keep-expired sessions` term in the summary bar, and
the retained-session line now names the window it is counting down:
`Kept (keep tag): <name> — swept once dead for 7d, or remove the keep tag to
reap it now`.
- Tests in `tests/gc-keep-expiry.test.ts` (8 new) cover: young keep session
retained, expired one swept and reported apart from the untagged sweep,
custom windows in both flag spellings, `0` sweeping a just-exited session,
`createdAt` anchoring for a session with no exit record, a running keep
session surviving `--keep-max-age 0`, non-mutating dry runs, and flag
validation.

### Complete session termination

- `pty kill` now stops the PTY child and its complete descendant tree. A
Expand Down
25 changes: 19 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ pty run -e -- npm test # ephemeral: reap even on `pt
pty run --tag owner=forge -- node srv.js # tag a session with metadata
pty run --env PORT=3000 --env MODE=dev -- node srv.js # persisted child env overlay
pty run --unset-env NO_COLOR -- node srv.js # persisted inherited-env removal
pty run --tag keep=true -- npm test # keep it even past a gc sweep, until you rm it
pty run --tag keep=true -- npm test # keep it past a gc sweep for 7d after it dies
pty run --cwd /path -- node server.js # run in a specific directory

pty rename my-label # inside a session: add/change its displayName
Expand Down Expand Up @@ -195,7 +195,7 @@ Two per-session flags override the configured default either way:

| Flag | Effect |
|---|---|
| tag `keep=true` | Force **preserve**, and survive even a `pty gc` sweep — metadata/`lastLines`/events last until you `pty rm` it. Wins over everything, including `--ephemeral`. |
| tag `keep=true` | Force **preserve**, including past a `pty gc` sweep — metadata/`lastLines`/events last until `pty gc --keep-max-age` (default 7d) after the session died, or until you `pty rm` it. Wins over everything, including `--ephemeral`. |
| `pty run -e` (`--ephemeral`) | Force **reap**, on *any* shutdown incl. `pty kill` and `strategy=permanent`. `keep` still wins over it. |

`strategy=permanent` sessions are always preserved (their supervisor reconciles
Expand All @@ -220,15 +220,28 @@ the supporting daemon.
```sh
pty run -d -- npm test # shipped default: reaped when it finishes
PTY_REAP_ON_EXIT=false pty run -d -- npm test # preserved: peekable until gc sweeps it
pty run -d --tag keep=true -- npm test # force-keep, even past a gc sweep, until you rm it
pty run -d --tag keep=true -- npm test # force-keep, past a gc sweep, for 7d after it dies
pty run -d -e -- npm test # ephemeral: reaps on any shutdown, leaves no trace
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). `pty list` is strictly
observational and never removes registry state. `keep=true` and
`strategy=permanent` are exempt from the gc sweep.
observational and never removes registry state. `strategy=permanent` sessions
are exempt from the sweep; `keep=true` is exempt for a bounded window.

**`keep` expires.** A `keep`-tagged session is exempt from the sweep until it
has been *dead* longer than `pty gc --keep-max-age <dur>` (default `7d`), then
it is swept like any other stale record and reported as
`Removed (keep expired after 7d): <name>`. Nobody ever comes back to untag a
session they pinned mid-debug, so an unbounded exemption turns the registry
into an append-only log. Running sessions are never swept, whatever their age.

```sh
pty gc --keep-max-age 30d # a month of retention instead of a week
pty gc --keep-max-age 0 # the keep exemption is over: sweep the backlog now
pty gc -n --keep-max-age 0 # …preview that first; keep-expired sessions are counted separately
```

### Events

Expand Down Expand Up @@ -387,7 +400,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. `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.
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. `strategy=permanent` sessions are exempt; `keep=true` sessions are exempt until they have been dead longer than `--keep-max-age` (default 7d), which an interval-driven gc then reclaims on its own.

```sh
pty gc --print-launchd-plist > ~/Library/LaunchAgents/com.compoundingtech.pty.gc.plist
Expand Down
2 changes: 1 addition & 1 deletion completions/pty.bash
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ _pty() {
fi
;;
gc)
COMPREPLY=($(compgen -W "-n --dry-run --idle-days --fast-fail-window --fast-fail-limit --print-launchd-plist --interval" -- "${cur}"))
COMPREPLY=($(compgen -W "-n --dry-run --idle-days --keep-max-age --fast-fail-window --fast-fail-limit --print-launchd-plist --interval" -- "${cur}"))
;;
tag)
if [[ "${cur}" == -* ]]; then
Expand Down
1 change: 1 addition & 0 deletions completions/pty.fish
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ complete -c pty -n '__pty_using_command recover' -l snapshot -d 'Captured capabi
complete -c pty -n '__pty_using_command rm remove' -a '(__pty_sessions)' -d 'Session'
complete -c pty -n '__pty_using_command gc' -l dry-run -s n -d 'Preview without changing anything'
complete -c pty -n '__pty_using_command gc' -l idle-days -d 'Reap permanents with no attach in N days'
complete -c pty -n '__pty_using_command gc' -l keep-max-age -d 'Keep-tag retention for dead sessions (default 7d; 0 = now)'
complete -c pty -n '__pty_using_command gc' -l fast-fail-window -d 'Fast-fail window (seconds; default 60)'
complete -c pty -n '__pty_using_command gc' -l fast-fail-limit -d 'Consecutive fast fails before flapping (default 3)'
complete -c pty -n '__pty_using_command gc' -l print-launchd-plist -d 'Emit a launchd plist that runs pty gc'
Expand Down
1 change: 1 addition & 0 deletions completions/pty.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ _pty() {
_arguments \
'(n --dry-run){n,--dry-run}[Preview without changing anything]' \
'--idle-days[Reap permanents with no attach in N days]' \
'--keep-max-age[Keep-tag retention for dead sessions (default 7d; 0 = now)]' \
'--fast-fail-window[Fast-fail window (seconds; default 60)]' \
'--fast-fail-limit[Consecutive fast fails before flapping (default 3)]' \
'--print-launchd-plist[Emit a launchd plist that runs pty gc]' \
Expand Down
4 changes: 2 additions & 2 deletions docs/disk-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,8 @@ the historical ambient-inheritance behavior.
- `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`.
- `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.
- `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.
- `keep=true` — exempts the session from reaping, both the daemon's exit-time self-cleanup and `pty gc`'s sweep. Its metadata, `lastLines`, and events file survive its death until an explicit `pty rm`. Any value other than `false`/`0`/`no`/`off` counts as set, so a mis-spelled value errs toward retaining. Without this tag, a non-permanent session's files are gone the moment its command finishes.
- Lifetime: a non-permanent session's files are removed by its own daemon during shutdown once the child process terminates. Files therefore outlive the process only for `keep`, `strategy=permanent`, external `pty kill`, and `vanished` sessions (SIGKILLed daemon — no cleanup code ran). Readers that poll these files after a session finishes must set `keep=true` or accept the race.
- `keep=true` — exempts the session from reaping: unconditionally from the daemon's exit-time self-cleanup, and from `pty gc`'s sweep until the session has been dead longer than `pty gc --keep-max-age <dur>` (default 7d; `0` sweeps every dead keep session on that pass). Age is anchored on `exitedAt`, or `createdAt` for a `vanished` session that never wrote one. Its metadata, `lastLines`, and events file therefore survive its death until that window elapses or an explicit `pty rm`, whichever comes first; a running session is never swept regardless of age. Any value other than `false`/`0`/`no`/`off` counts as set, so a mis-spelled value errs toward retaining. Without this tag, a non-permanent session's files are gone the moment its command finishes.
- Lifetime: a non-permanent session's files are removed by its own daemon during shutdown once the child process terminates. Files therefore outlive the process only for `keep` (bounded by `--keep-max-age`), `strategy=permanent`, external `pty kill`, and `vanished` sessions (SIGKILLed daemon — no cleanup code ran). Readers that poll these files after a session finishes must set `keep=true` or accept the race.
- Concurrent writers: last-write-wins; readers never see torn files. Cross-process writers can lose updates to the read-modify-write window.

## `<name>.events.jsonl` (tier 1)
Expand Down
64 changes: 53 additions & 11 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
getMetadataPath,
DEFAULT_SESSION_DIR,
hasProcessExitedForReap,
DEFAULT_KEEP_MAX_AGE_MS,
type SessionInfo,
type SessionMetadata,
} from "./sessions.ts";
Expand Down Expand Up @@ -334,26 +335,30 @@ Won't remove a running session — kill it first.
Examples:
pty rm myserver`,

gc: `Usage: pty gc [-n] [--idle-days N] [--fast-fail-window=N] [--fast-fail-limit=N]
gc: `Usage: pty gc [-n] [--idle-days N] [--keep-max-age <dur>] [--fast-fail-window=N] [--fast-fail-limit=N]
pty gc --print-launchd-plist [--interval=N]

One reconciliation pass: sweep exited/vanished, orphan-kill \`parent=<name>\` children,
reap abandoned permanents, respawn \`strategy=permanent\` sessions.

Non-permanent sessions remove themselves as they exit, so the sweep is a backstop:
it mainly catches \`vanished\` sessions, whose daemon was killed outright and so
never ran its own cleanup. Sessions tagged \`keep\` are never swept.
never ran its own cleanup. A session tagged \`keep\` is swept only once it has been
dead longer than --keep-max-age; running sessions are never swept.

Flags:
-n, --dry-run Preview without changing anything
--idle-days N Also reap permanents with no attach in N days
--keep-max-age <dur> How long \`keep\` holds a DEAD session against the sweep
(default 7d; 0 sweeps every dead keep session now)
--fast-fail-window=N Fast-fail window seconds (default 60; per-session tag wins)
--fast-fail-limit=N Consecutive fast fails before flapping (default 3; per-session tag wins)
--print-launchd-plist Print a macOS launchd plist that runs 'pty gc' on an interval
--interval=N Plist StartInterval seconds (default 30)

Examples:
pty gc --dry-run
pty gc --keep-max-age 0
pty gc --print-launchd-plist > ~/Library/LaunchAgents/com.compoundingtech.pty.gc.plist`,

tag: `Usage: pty tag <ref> Show tags
Expand Down Expand Up @@ -583,6 +588,8 @@ Lifecycle:
permanent-respawn, exited-sweep
pty gc --dry-run Preview without changing anything (alias: -n)
pty gc --idle-days N Also reap permanents with no attach in N days
pty gc --keep-max-age <dur> How long a \`keep\` tag holds a DEAD session against
the sweep (default 7d; 0 sweeps the backlog now)
pty gc --fast-fail-window=N Fast-fail window (seconds) for the respawn cap
(default 60; per-session strategy.fast-fail-window wins)
pty gc --fast-fail-limit=N Consecutive fast fails before a permanent is flagged
Expand Down Expand Up @@ -1443,6 +1450,7 @@ async function main(): Promise<void> {
let idleDays: number | undefined;
let fastFailWindowSec: number | undefined;
let fastFailLimit: number | undefined;
let keepMaxAgeMs: number | undefined;
const parsePositive = (flag: string, raw: string): number => {
const v = parseInt(raw, 10);
if (!Number.isFinite(v) || v <= 0) {
Expand All @@ -1451,6 +1459,19 @@ async function main(): Promise<void> {
}
return v;
};
// Durations, unlike the integer flags, have a meaningful zero: `0`
// means "the keep exemption is over, sweep the backlog now". The
// unit-less spelling is accepted only for zero, since `--keep-max-age 7`
// would otherwise be ambiguous between seconds and days.
const parseAge = (flag: string, raw: string): number => {
if (raw.trim() === "0") return 0;
const ms = parseDuration(raw);
if (ms == null) {
console.error(`pty gc: ${flag} expects a duration like 12h, 7d, or 0 (got "${raw}")`);
process.exit(1);
}
return ms;
};
for (let i = 0; i < gcArgs.length; i++) {
const a = gcArgs[i];
if (a === "--interval" && i + 1 < gcArgs.length) {
Expand All @@ -1469,13 +1490,17 @@ async function main(): Promise<void> {
fastFailLimit = parsePositive("--fast-fail-limit", gcArgs[++i]);
} else if (a.startsWith("--fast-fail-limit=")) {
fastFailLimit = parsePositive("--fast-fail-limit", a.slice("--fast-fail-limit=".length));
} else if (a === "--keep-max-age" && i + 1 < gcArgs.length) {
keepMaxAgeMs = parseAge("--keep-max-age", gcArgs[++i]);
} else if (a.startsWith("--keep-max-age=")) {
keepMaxAgeMs = parseAge("--keep-max-age", a.slice("--keep-max-age=".length));
}
}
if (printPlist) {
printLaunchdPlist(interval);
break;
}
await cmdGc(dryRun, idleDays, fastFailWindowSec, fastFailLimit);
await cmdGc({ dryRun, idleDays, fastFailWindowSec, fastFailLimit, keepMaxAgeMs });
break;
}

Expand Down Expand Up @@ -3157,13 +3182,16 @@ async function cmdRm(name: string): Promise<void> {
console.log(`Session "${name}" removed.`);
}

async function cmdGc(
dryRun: boolean,
idleDays?: number,
fastFailWindowSec?: number,
fastFailLimit?: number,
): Promise<void> {
const result = await gc({ dryRun, idleDays, fastFailWindowSec, fastFailLimit });
async function cmdGc(opts: {
dryRun: boolean;
idleDays?: number;
fastFailWindowSec?: number;
fastFailLimit?: number;
keepMaxAgeMs?: number;
}): Promise<void> {
const { dryRun } = opts;
const result = await gc(opts);
const keepMaxAgeMs = opts.keepMaxAgeMs ?? DEFAULT_KEEP_MAX_AGE_MS;
const prunedTags = await pruneOrphanLayoutTags({ dryRun });

const killedVerb = dryRun ? "Would kill orphan child" : "Killed orphan child";
Expand Down Expand Up @@ -3208,11 +3236,21 @@ async function cmdGc(
for (const name of result.removed) {
console.log(`${removeVerb}: ${name}`);
}
// Reported apart from the plain sweep above: an operator who tagged these
// sessions asked for them to survive, so the reason they went away anyway
// has to be visible rather than looking like the keep tag was ignored.
for (const name of result.keepExpired) {
console.log(
`${removeVerb} (keep expired after ${formatDuration(keepMaxAgeMs)}): ${name}`,
);
}
// Deliberately NOT counted as an action below: a kept session is a
// no-op. It is printed anyway so "why is this dead session still
// listed?" has a visible answer instead of looking like a gc bug.
for (const name of result.kept) {
console.log(`Kept (keep tag): ${name} — remove the keep tag to reap it`);
console.log(
`Kept (keep tag): ${name} — swept once dead for ${formatDuration(keepMaxAgeMs)}, or remove the keep tag to reap it now`,
);
}
for (const { name, removedKeys } of prunedTags) {
console.log(
Expand All @@ -3230,6 +3268,7 @@ async function cmdGc(
result.flapped.length +
result.flappingSkipped.length +
result.removed.length +
result.keepExpired.length +
totalTags;

if (totalActions === 0) {
Expand Down Expand Up @@ -3262,6 +3301,9 @@ async function cmdGc(
if (result.removed.length > 0) {
parts.push(`${result.removed.length} stale session${result.removed.length === 1 ? "" : "s"}`);
}
if (result.keepExpired.length > 0) {
parts.push(`${result.keepExpired.length} keep-expired session${result.keepExpired.length === 1 ? "" : "s"}`);
}
if (totalTags > 0) {
parts.push(`${totalTags} orphan tag${totalTags === 1 ? "" : "s"}`);
}
Expand Down
4 changes: 3 additions & 1 deletion src/client-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ export {
cleanupSocket, cleanupAll,
// Exposed for the same reason as `isReservedTagKey`: downstream tools
// (relay, layout, supervisors) need to answer "is this session exempt
// from reaping?" without re-deriving which tag values count as set.
// from reaping?" without re-deriving which tag values count as set —
// including how long the `keep` exemption lasts against `pty gc`.
KEEP_TAG, isKeepRequested, shouldReapAtExit,
DEFAULT_KEEP_MAX_AGE_MS, isKeepExpired,
type SessionInfo, type SessionMetadata, type MetadataPatch, type MetadataPatchResult,
type SessionExitEvidence, type SessionExitEvidenceTail,
type SessionExitEvidenceResult, type RemoveSessionGenerationResult,
Expand Down
Loading
Loading