Skip to content

Commit 86dcc5e

Browse files
authored
Merge pull request #173 from compoundingtech/schickling-assistant/2026-09-04-keep-expiry
feat(gc): expire keep-tagged exited sessions after a max age
2 parents 94cd891 + a2ae2db commit 86dcc5e

11 files changed

Lines changed: 434 additions & 32 deletions

File tree

CHANGELOG.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,37 @@
6969
An empty field means the process is gone or `ps` did not answer, and under
7070
load `ps` is the thing that goes quiet, so the kernel is asked again.
7171

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

74105
- `pty kill` now stops the PTY child and its complete descendant tree. A

README.md

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ pty run -e -- npm test # ephemeral: reap even on `pt
5959
pty run --tag owner=forge -- node srv.js # tag a session with metadata
6060
pty run --env PORT=3000 --env MODE=dev -- node srv.js # persisted child env overlay
6161
pty run --unset-env NO_COLOR -- node srv.js # persisted inherited-env removal
62-
pty run --tag keep=true -- npm test # keep it even past a gc sweep, until you rm it
62+
pty run --tag keep=true -- npm test # keep it past a gc sweep for 7d after it dies
6363
pty run --cwd /path -- node server.js # run in a specific directory
6464

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

196196
| Flag | Effect |
197197
|---|---|
198-
| 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`. |
198+
| 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`. |
199199
| `pty run -e` (`--ephemeral`) | Force **reap**, on *any* shutdown incl. `pty kill` and `strategy=permanent`. `keep` still wins over it. |
200200

201201
`strategy=permanent` sessions are always preserved (their supervisor reconciles
@@ -220,15 +220,28 @@ the supporting daemon.
220220
```sh
221221
pty run -d -- npm test # shipped default: reaped when it finishes
222222
PTY_REAP_ON_EXIT=false pty run -d -- npm test # preserved: peekable until gc sweeps it
223-
pty run -d --tag keep=true -- npm test # force-keep, even past a gc sweep, until you rm it
223+
pty run -d --tag keep=true -- npm test # force-keep, past a gc sweep, for 7d after it dies
224224
pty run -d -e -- npm test # ephemeral: reaps on any shutdown, leaves no trace
225225
pty rm mybuild # explicit removal beats keep
226226
```
227227

228228
`pty gc`'s sweep reclaims preserved-and-finished (and `vanished`) non-permanent
229229
sessions — see [Auto-running gc](#auto-running-gc). `pty list` is strictly
230-
observational and never removes registry state. `keep=true` and
231-
`strategy=permanent` are exempt from the gc sweep.
230+
observational and never removes registry state. `strategy=permanent` sessions
231+
are exempt from the sweep; `keep=true` is exempt for a bounded window.
232+
233+
**`keep` expires.** A `keep`-tagged session is exempt from the sweep until it
234+
has been *dead* longer than `pty gc --keep-max-age <dur>` (default `7d`), then
235+
it is swept like any other stale record and reported as
236+
`Removed (keep expired after 7d): <name>`. Nobody ever comes back to untag a
237+
session they pinned mid-debug, so an unbounded exemption turns the registry
238+
into an append-only log. Running sessions are never swept, whatever their age.
239+
240+
```sh
241+
pty gc --keep-max-age 30d # a month of retention instead of a week
242+
pty gc --keep-max-age 0 # the keep exemption is over: sweep the backlog now
243+
pty gc -n --keep-max-age 0 # …preview that first; keep-expired sessions are counted separately
244+
```
232245

233246
### Events
234247

@@ -387,7 +400,7 @@ Cycles (A→B, B→A) resolve deterministically by name-sorted iteration: whiche
387400

388401
`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:
389402

390-
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.
403+
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.
391404

392405
```sh
393406
pty gc --print-launchd-plist > ~/Library/LaunchAgents/com.compoundingtech.pty.gc.plist

completions/pty.bash

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ _pty() {
9595
fi
9696
;;
9797
gc)
98-
COMPREPLY=($(compgen -W "-n --dry-run --idle-days --fast-fail-window --fast-fail-limit --print-launchd-plist --interval" -- "${cur}"))
98+
COMPREPLY=($(compgen -W "-n --dry-run --idle-days --keep-max-age --fast-fail-window --fast-fail-limit --print-launchd-plist --interval" -- "${cur}"))
9999
;;
100100
tag)
101101
if [[ "${cur}" == -* ]]; then

completions/pty.fish

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ complete -c pty -n '__pty_using_command recover' -l snapshot -d 'Captured capabi
133133
complete -c pty -n '__pty_using_command rm remove' -a '(__pty_sessions)' -d 'Session'
134134
complete -c pty -n '__pty_using_command gc' -l dry-run -s n -d 'Preview without changing anything'
135135
complete -c pty -n '__pty_using_command gc' -l idle-days -d 'Reap permanents with no attach in N days'
136+
complete -c pty -n '__pty_using_command gc' -l keep-max-age -d 'Keep-tag retention for dead sessions (default 7d; 0 = now)'
136137
complete -c pty -n '__pty_using_command gc' -l fast-fail-window -d 'Fast-fail window (seconds; default 60)'
137138
complete -c pty -n '__pty_using_command gc' -l fast-fail-limit -d 'Consecutive fast fails before flapping (default 3)'
138139
complete -c pty -n '__pty_using_command gc' -l print-launchd-plist -d 'Emit a launchd plist that runs pty gc'

completions/pty.zsh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ _pty() {
149149
_arguments \
150150
'(n --dry-run){n,--dry-run}[Preview without changing anything]' \
151151
'--idle-days[Reap permanents with no attach in N days]' \
152+
'--keep-max-age[Keep-tag retention for dead sessions (default 7d; 0 = now)]' \
152153
'--fast-fail-window[Fast-fail window (seconds; default 60)]' \
153154
'--fast-fail-limit[Consecutive fast fails before flapping (default 3)]' \
154155
'--print-launchd-plist[Emit a launchd plist that runs pty gc]' \

docs/disk-layout.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,8 @@ the historical ambient-inheritance behavior.
110110
- `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`.
111111
- `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.
112112
- `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.
113-
- `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.
114-
- 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.
113+
- `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.
114+
- 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.
115115
- Concurrent writers: last-write-wins; readers never see torn files. Cross-process writers can lose updates to the read-modify-write window.
116116

117117
## `<name>.events.jsonl` (tier 1)

src/cli.ts

Lines changed: 53 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import {
4545
getMetadataPath,
4646
DEFAULT_SESSION_DIR,
4747
hasProcessExitedForReap,
48+
DEFAULT_KEEP_MAX_AGE_MS,
4849
type SessionInfo,
4950
type SessionMetadata,
5051
} from "./sessions.ts";
@@ -334,26 +335,30 @@ Won't remove a running session — kill it first.
334335
Examples:
335336
pty rm myserver`,
336337

337-
gc: `Usage: pty gc [-n] [--idle-days N] [--fast-fail-window=N] [--fast-fail-limit=N]
338+
gc: `Usage: pty gc [-n] [--idle-days N] [--keep-max-age <dur>] [--fast-fail-window=N] [--fast-fail-limit=N]
338339
pty gc --print-launchd-plist [--interval=N]
339340
340341
One reconciliation pass: sweep exited/vanished, orphan-kill \`parent=<name>\` children,
341342
reap abandoned permanents, respawn \`strategy=permanent\` sessions.
342343
343344
Non-permanent sessions remove themselves as they exit, so the sweep is a backstop:
344345
it mainly catches \`vanished\` sessions, whose daemon was killed outright and so
345-
never ran its own cleanup. Sessions tagged \`keep\` are never swept.
346+
never ran its own cleanup. A session tagged \`keep\` is swept only once it has been
347+
dead longer than --keep-max-age; running sessions are never swept.
346348
347349
Flags:
348350
-n, --dry-run Preview without changing anything
349351
--idle-days N Also reap permanents with no attach in N days
352+
--keep-max-age <dur> How long \`keep\` holds a DEAD session against the sweep
353+
(default 7d; 0 sweeps every dead keep session now)
350354
--fast-fail-window=N Fast-fail window seconds (default 60; per-session tag wins)
351355
--fast-fail-limit=N Consecutive fast fails before flapping (default 3; per-session tag wins)
352356
--print-launchd-plist Print a macOS launchd plist that runs 'pty gc' on an interval
353357
--interval=N Plist StartInterval seconds (default 30)
354358
355359
Examples:
356360
pty gc --dry-run
361+
pty gc --keep-max-age 0
357362
pty gc --print-launchd-plist > ~/Library/LaunchAgents/com.compoundingtech.pty.gc.plist`,
358363

359364
tag: `Usage: pty tag <ref> Show tags
@@ -583,6 +588,8 @@ Lifecycle:
583588
permanent-respawn, exited-sweep
584589
pty gc --dry-run Preview without changing anything (alias: -n)
585590
pty gc --idle-days N Also reap permanents with no attach in N days
591+
pty gc --keep-max-age <dur> How long a \`keep\` tag holds a DEAD session against
592+
the sweep (default 7d; 0 sweeps the backlog now)
586593
pty gc --fast-fail-window=N Fast-fail window (seconds) for the respawn cap
587594
(default 60; per-session strategy.fast-fail-window wins)
588595
pty gc --fast-fail-limit=N Consecutive fast fails before a permanent is flagged
@@ -1443,6 +1450,7 @@ async function main(): Promise<void> {
14431450
let idleDays: number | undefined;
14441451
let fastFailWindowSec: number | undefined;
14451452
let fastFailLimit: number | undefined;
1453+
let keepMaxAgeMs: number | undefined;
14461454
const parsePositive = (flag: string, raw: string): number => {
14471455
const v = parseInt(raw, 10);
14481456
if (!Number.isFinite(v) || v <= 0) {
@@ -1451,6 +1459,19 @@ async function main(): Promise<void> {
14511459
}
14521460
return v;
14531461
};
1462+
// Durations, unlike the integer flags, have a meaningful zero: `0`
1463+
// means "the keep exemption is over, sweep the backlog now". The
1464+
// unit-less spelling is accepted only for zero, since `--keep-max-age 7`
1465+
// would otherwise be ambiguous between seconds and days.
1466+
const parseAge = (flag: string, raw: string): number => {
1467+
if (raw.trim() === "0") return 0;
1468+
const ms = parseDuration(raw);
1469+
if (ms == null) {
1470+
console.error(`pty gc: ${flag} expects a duration like 12h, 7d, or 0 (got "${raw}")`);
1471+
process.exit(1);
1472+
}
1473+
return ms;
1474+
};
14541475
for (let i = 0; i < gcArgs.length; i++) {
14551476
const a = gcArgs[i];
14561477
if (a === "--interval" && i + 1 < gcArgs.length) {
@@ -1469,13 +1490,17 @@ async function main(): Promise<void> {
14691490
fastFailLimit = parsePositive("--fast-fail-limit", gcArgs[++i]);
14701491
} else if (a.startsWith("--fast-fail-limit=")) {
14711492
fastFailLimit = parsePositive("--fast-fail-limit", a.slice("--fast-fail-limit=".length));
1493+
} else if (a === "--keep-max-age" && i + 1 < gcArgs.length) {
1494+
keepMaxAgeMs = parseAge("--keep-max-age", gcArgs[++i]);
1495+
} else if (a.startsWith("--keep-max-age=")) {
1496+
keepMaxAgeMs = parseAge("--keep-max-age", a.slice("--keep-max-age=".length));
14721497
}
14731498
}
14741499
if (printPlist) {
14751500
printLaunchdPlist(interval);
14761501
break;
14771502
}
1478-
await cmdGc(dryRun, idleDays, fastFailWindowSec, fastFailLimit);
1503+
await cmdGc({ dryRun, idleDays, fastFailWindowSec, fastFailLimit, keepMaxAgeMs });
14791504
break;
14801505
}
14811506

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

3160-
async function cmdGc(
3161-
dryRun: boolean,
3162-
idleDays?: number,
3163-
fastFailWindowSec?: number,
3164-
fastFailLimit?: number,
3165-
): Promise<void> {
3166-
const result = await gc({ dryRun, idleDays, fastFailWindowSec, fastFailLimit });
3185+
async function cmdGc(opts: {
3186+
dryRun: boolean;
3187+
idleDays?: number;
3188+
fastFailWindowSec?: number;
3189+
fastFailLimit?: number;
3190+
keepMaxAgeMs?: number;
3191+
}): Promise<void> {
3192+
const { dryRun } = opts;
3193+
const result = await gc(opts);
3194+
const keepMaxAgeMs = opts.keepMaxAgeMs ?? DEFAULT_KEEP_MAX_AGE_MS;
31673195
const prunedTags = await pruneOrphanLayoutTags({ dryRun });
31683196

31693197
const killedVerb = dryRun ? "Would kill orphan child" : "Killed orphan child";
@@ -3208,11 +3236,21 @@ async function cmdGc(
32083236
for (const name of result.removed) {
32093237
console.log(`${removeVerb}: ${name}`);
32103238
}
3239+
// Reported apart from the plain sweep above: an operator who tagged these
3240+
// sessions asked for them to survive, so the reason they went away anyway
3241+
// has to be visible rather than looking like the keep tag was ignored.
3242+
for (const name of result.keepExpired) {
3243+
console.log(
3244+
`${removeVerb} (keep expired after ${formatDuration(keepMaxAgeMs)}): ${name}`,
3245+
);
3246+
}
32113247
// Deliberately NOT counted as an action below: a kept session is a
32123248
// no-op. It is printed anyway so "why is this dead session still
32133249
// listed?" has a visible answer instead of looking like a gc bug.
32143250
for (const name of result.kept) {
3215-
console.log(`Kept (keep tag): ${name} — remove the keep tag to reap it`);
3251+
console.log(
3252+
`Kept (keep tag): ${name} — swept once dead for ${formatDuration(keepMaxAgeMs)}, or remove the keep tag to reap it now`,
3253+
);
32163254
}
32173255
for (const { name, removedKeys } of prunedTags) {
32183256
console.log(
@@ -3230,6 +3268,7 @@ async function cmdGc(
32303268
result.flapped.length +
32313269
result.flappingSkipped.length +
32323270
result.removed.length +
3271+
result.keepExpired.length +
32333272
totalTags;
32343273

32353274
if (totalActions === 0) {
@@ -3262,6 +3301,9 @@ async function cmdGc(
32623301
if (result.removed.length > 0) {
32633302
parts.push(`${result.removed.length} stale session${result.removed.length === 1 ? "" : "s"}`);
32643303
}
3304+
if (result.keepExpired.length > 0) {
3305+
parts.push(`${result.keepExpired.length} keep-expired session${result.keepExpired.length === 1 ? "" : "s"}`);
3306+
}
32653307
if (totalTags > 0) {
32663308
parts.push(`${totalTags} orphan tag${totalTags === 1 ? "" : "s"}`);
32673309
}

src/client-api.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@ export {
1010
cleanupSocket, cleanupAll,
1111
// Exposed for the same reason as `isReservedTagKey`: downstream tools
1212
// (relay, layout, supervisors) need to answer "is this session exempt
13-
// from reaping?" without re-deriving which tag values count as set.
13+
// from reaping?" without re-deriving which tag values count as set —
14+
// including how long the `keep` exemption lasts against `pty gc`.
1415
KEEP_TAG, isKeepRequested, shouldReapAtExit,
16+
DEFAULT_KEEP_MAX_AGE_MS, isKeepExpired,
1517
type SessionInfo, type SessionMetadata, type MetadataPatch, type MetadataPatchResult,
1618
type SessionExitEvidence, type SessionExitEvidenceTail,
1719
type SessionExitEvidenceResult, type RemoveSessionGenerationResult,

0 commit comments

Comments
 (0)