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
33 changes: 33 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: Test
on:
pull_request:
push:
branches: [main]

jobs:
vitest:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
# Match flake.nix (nodejs_22) so the CI test runtime tracks the
# Nix-built runtime and versions don't skew.
node-version: 22
cache: npm
- name: Install test-only binaries (absent on ubuntu-latest)
# shells.test.ts exercises real zsh/fish shell integration; and
# restart-guardrail spawns a `claude --resume` session — its guard keys
# purely on the stored argv, so a no-op stub named `claude` is enough to
# create the session under test.
run: |
sudo apt-get update
sudo apt-get install -y zsh fish
printf '#!/bin/sh\nexec sleep 300\n' | sudo tee /usr/local/bin/claude >/dev/null
sudo chmod +x /usr/local/bin/claude
- run: npm ci
# The vitest suite exercises the compiled CLI/daemon (dist/cli.js,
# dist/server.js), so build before running the tests.
- run: npm run build
- run: npm test
35 changes: 24 additions & 11 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,30 @@

## Unreleased

### Non-permanent sessions clean themselves up at exit (BREAKING)

A session that is not `strategy=permanent` now removes its own registry entry — metadata, events file, socket, pid — as its daemon shuts down, once its child process has terminated. Previously the entry lingered until an external `pty gc` sweep noticed it (or until the 24-hour dead-session TTL). Cleanup is now caused by the exit rather than discovered later, which removes both the sweep's polling interval and the window in which a finished session is still listed in `pty ls`.

- **Applies to clean exits and crashes alike** — both mean the command is finished.
- **New `keep` tag.** `keep=true` exempts a session from reaping, by both the exit-time path and `pty gc`'s sweep, so its metadata/`lastLines`/events survive for inspection until an explicit `pty rm`. Any value other than `false`/`0`/`no`/`off` counts as set. Settable at spawn (`--tag keep=true`) or on a running session (`pty tag <ref> keep=true`) — the exit path re-reads tags from disk, so pinning a session you are about to debug works.
- **Exempt:** `keep`, `strategy=permanent` (its supervisor reconciles against the dead session's metadata), external `pty kill`/SIGTERM/SIGINT (stop-and-keep, still deliberately distinct from `pty rm`), and `vanished` sessions (the daemon was SIGKILLed, so no cleanup code ran).
- **`pty gc`'s sweep becomes a backstop.** Its remaining prey is `vanished` sessions — the one case exit-time cleanup structurally cannot cover. `GcResult` gains `kept: string[]`, and `pty gc` prints `Kept (keep tag): <name>` so a retained dead session does not look like a gc bug. `pty gc`'s other duties (orphan-kill, abandoned-reap, permanent respawn) are unchanged.
- **`--ephemeral` is now a narrower override**, not the only way to get cleanup: it reaps on *any* shutdown, including `pty kill` and including `strategy=permanent`. `keep` wins over it.

**Migration.** Anything that touched a non-permanent session after it finished must now tag it `keep=true`. Previously such callers had until the next gc tick; they now have no window at all. Affected: `pty peek` (saved output), `pty stats`, `pty restart <ref>` and `pty attach`'s restart prompt (both now report `not found` for a session that reaped itself), `pty tag` on a dead session, and any reader of `<id>.json` / `<id>.events.jsonl` on disk. `strategy=permanent` sessions are unaffected.
### Configurable exit-time cleanup for finished sessions

Whether a finished non-permanent session reaps itself at exit, or is preserved
(kept listed + `pty peek`-able until `pty gc` sweeps it), is now configurable via
the **`PTY_REAP_ON_EXIT`** environment variable — a network/global knob the
daemon inherits, so it can be set fleet-wide. `false`/`0`/`no`/`off` → preserve;
unset or anything else → **reap** (the shipped default). Two per-session flags
override the configured default either way:

- **New `keep` tag.** `keep=true` forces **preserve** regardless of the
configured default, and exempts the session from `pty gc`'s sweep too, so its
metadata/`lastLines`/events survive until an explicit `pty rm`. Any value
other than `false`/`0`/`no`/`off` counts as set. Settable at spawn
(`--tag keep=true`) or on a running session (`pty tag <ref> keep=true`) — the
exit path and gc both re-read tags from disk. Wins over `--ephemeral`.
- **`--ephemeral` forces reap** regardless of the configured default, on *any*
shutdown including `pty kill` and including `strategy=permanent`. `keep` wins
over it. (Behavior change for existing `-e` callers: a `pty kill`'d ephemeral
session is now reaped rather than kept.)
- **`pty gc` reports kept sessions.** `GcResult` gains `kept: string[]`, and
`pty gc` prints `Kept (keep tag): <name>` so a retained dead session does not
look like a gc bug. gc remains the owner of finished-session cleanup — it
sweeps preserved/`vanished` non-permanent sessions; its other duties
(orphan-kill, abandoned-reap, permanent respawn) are unchanged.

## 0.12.0

Expand Down
56 changes: 31 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ pty run -d -- node server.js # start in the background
pty run -a -- node server.js # create or attach if already running
pty run -e -- npm test # ephemeral: reap even on `pty kill` / permanent
pty run --tag owner=forge -- node srv.js # tag a session with metadata
pty run --tag keep=true -- npm test # keep the session listed after it exits
pty run --tag keep=true -- npm test # keep it even past a gc sweep, until you rm it
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 @@ -98,7 +98,7 @@ pty emit user.deploy.started # emit a user event (inside a session)
pty emit myserver user.build.finished --json '{"ok":true}' # with JSON payload
pty emit myserver user.note --text "checkpoint reached" # with a text payload

pty restart myserver # restart an exited session (needs `keep=true` if non-permanent)
pty restart myserver # restart an exited session (must have been preserved)
pty kill myserver # terminate a running session
pty rm myserver # remove an exited session's metadata
pty gc # reconcile: kill orphan children, respawn permanents, sweep vanished
Expand Down Expand Up @@ -149,35 +149,41 @@ Use `pty run -d` to explicitly create a background session from inside another s

### Session lifecycle and cleanup

A session that is **not** `strategy=permanent` removes itself when its command
finishes. The daemon deletes its own registry entry — metadata, events file,
socket, pid — as the last thing it does before exiting. Cleanup is caused by the
exit rather than discovered by a later sweep, so a finished session never sits in
`pty ls` waiting to be noticed.
What happens to a **non-`strategy=permanent`** session when its command finishes
is **configurable**, via the `PTY_REAP_ON_EXIT` environment variable — a
network/global knob (the daemon inherits it, so setting it once configures a
whole fleet):

This applies whether the command exited cleanly or crashed: both mean the session
is finished. Four cases are deliberately exempt:
- **`reap` (shipped default).** The daemon removes its own registry entry —
metadata, events, socket, pid — as it shuts down, so a finished session does
not linger in `pty ls`.
- **`preserve` (`PTY_REAP_ON_EXIT=false`).** The finished session is kept: its
final screen and registry entry stay listed and `pty peek`-able until
`pty gc`'s sweep reclaims it — so a crash you want to inspect is still there.

| Case | Why it is kept |
Two per-session flags override the configured default either way:

| Flag | Effect |
|---|---|
| tag `keep=true` | You asked to inspect it. Metadata, `lastLines`, and events survive until you `pty rm` it. |
| tag `strategy=permanent` | Its supervisor reconciles against the dead session's metadata to respawn it. |
| `pty kill` (and any external SIGTERM/SIGINT) | The command did not finish — someone stopped it, nearly always to go look at it. `kill` is stop-and-keep; `rm` is the one that removes. |
| `status=vanished` | The daemon itself was SIGKILLed/OOM-killed, so no cleanup code ever ran. Nothing inside the process can cover this case. |
| 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`. |
| `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
against the dead metadata to respawn them). `pty kill` also preserves — it is
stop-and-keep, deliberately distinct from `pty rm`.

```sh
pty run -d -- npm test # gone from `pty ls` the moment it finishes
pty run -d --tag keep=true -- npm test # sticks around afterwards so you can read it
pty tag mybuild keep=true # ...or pin a session that is still running
pty rm mybuild # explicit removal beats keep
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 -e -- npm test # ephemeral: reaps on any shutdown, leaves no trace
pty rm mybuild # explicit removal beats keep
```

Because of the `vanished` row, `pty gc`'s sweep is a **backstop, not the primary
path** — see [Auto-running gc](#auto-running-gc). Vanished sessions are also
reclaimed lazily by the 24-hour dead-session TTL on any `pty list`.

To reap a session on *any* shutdown, including `pty kill` and including
`strategy=permanent`, use `pty run -e` (`--ephemeral`). `keep` still wins over it.
`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.

### Events

Expand Down Expand Up @@ -331,7 +337,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:

Sweeping finished sessions is **no longer** a reason to run gc on a timer — non-permanent sessions [reap themselves at exit](#session-lifecycle-and-cleanup). What is left for the sweep is `vanished` sessions, whose daemon was killed outright and so never ran its own cleanup; those are also reclaimed lazily by the 24-hour dead-session TTL on any `pty list`. The interval now buys you respawn latency for permanents and orphan-kill promptness, not `pty ls` hygiene.
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.

```sh
pty gc --print-launchd-plist > ~/Library/LaunchAgents/com.compoundingtech.pty.gc.plist
Expand Down
10 changes: 7 additions & 3 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
writeMetadata,
readMetadata,
shouldReapAtExit,
reapOnExitDefault,
type SessionMetadata,
} from "./sessions.ts";
import { EventWriter, clearEvents, EventType, type EventRecord } from "./events.ts";
Expand Down Expand Up @@ -1033,8 +1034,9 @@ if (process.argv[1]?.endsWith("/server.js")) {
// reasons, and only one of them is an "exit":
//
// - the child process terminated on its own (cleanly or by crashing)
// — the session is finished, and finished non-permanent sessions are
// garbage. Reap.
// — the session is finished; whether it self-reaps is the config
// default (`reapOnExitDefault`, via `PTY_REAP_ON_EXIT`), unless a
// per-session `keep`/`--ephemeral` overrides it.
// - someone stopped the daemon from outside (`pty kill` → SIGTERM,
// SIGINT, or the spawner watchdog reclaiming a leaked daemon) — the
// child had not finished; an operator interrupted it, almost always
Expand All @@ -1049,7 +1051,9 @@ if (process.argv[1]?.endsWith("/server.js")) {
function reapAtExit(): boolean {
if (externalKill && !isEphemeral) return false;
const tags = readMetadata(config.name)?.tags ?? config.tags;
return shouldReapAtExit(tags, isEphemeral);
// `PTY_REAP_ON_EXIT` (network/global config) sets the default when no
// per-session `keep`/`--ephemeral` override applies; shipped default reaps.
return shouldReapAtExit(tags, isEphemeral, reapOnExitDefault());
}

// Hard deadline for a graceful shutdown before the daemon force-exits. The
Expand Down
61 changes: 40 additions & 21 deletions src/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,36 +430,55 @@ export function isKeepRequested(tags?: Record<string, string>): boolean {

/** Should the daemon remove its own registry entry as it shuts down?
*
* This is the policy behind exit-time cleanup. A non-permanent session
* that dies is garbage the moment it dies — cleaning up as part of its own
* lifecycle removes both the polling interval of an external sweep and the
* window in which a dead session is still listed. Precedence, highest
* first:
* Exit-time reaping is CONFIGURABLE. `defaultReap` is the config default (see
* `reapOnExitDefault` — the `PTY_REAP_ON_EXIT` network/global knob), and two
* per-session flags override it either way. Precedence, highest first:
*
* 1. `keep` — the explicit "don't reap me" exemption always wins, even
* over `--ephemeral`. Its entire purpose is retaining a dead
* session's logs and scrollback for debugging.
* 2. `--ephemeral` — the historic explicit opt-in. Still forces a reap
* even for a permanent session, which is what it did before this
* policy existed; unchanged so no existing caller regresses.
* 3. `strategy=permanent` — never self-reaps. Its supervisor (convoy,
* or `pty gc`'s respawn step) reads the metadata of the dead session
* to respawn it. Self-reaping would destroy the very record the
* supervisor reconciles against.
* 4. Everything else — non-permanent, so reap.
* 1. `keep` — force PRESERVE. Always wins, even over `--ephemeral`, and also
* exempts the session from `pty gc`'s sweep. Retains a dead session's
* logs and scrollback for debugging past even a gc pass.
* 2. `--ephemeral` — force REAP. Reaps as the session shuts down (the
* aggressive opt-in), even for a `strategy=permanent` session, so a
* caller that wants no trace left gets it regardless of the config
* default.
* 3. `strategy=permanent` — force PRESERVE. Its supervisor / `pty gc`'s
* respawn step reconciles against the dead session's metadata, so
* reaping it would destroy the record the respawn needs.
* 4. `defaultReap` — the config default when none of the above apply.
* `true` reaps a finished non-permanent session at exit; `false`
* PRESERVES it (its metadata lingers, peekable, until `pty gc`'s sweep
* reclaims it).
*
* Note what is *not* representable here: a session whose daemon was itself
* SIGKILL'd (`status=vanished`) never runs this code, because the process
* that would run it is the one that died. Vanished sessions still require
* an external sweep. */
* A session whose daemon was SIGKILL'd (`status=vanished`) never runs this
* code and is reclaimed by gc's sweep. */
export function shouldReapAtExit(
tags: Record<string, string> | undefined,
ephemeral: boolean,
// Optional so existing 2-arg callers (relay/layout/supervisors read this to
// answer "is this session exempt from reaping?") keep working AND get the
// correct env-driven default without having to thread it themselves.
defaultReap: boolean = reapOnExitDefault(),
): boolean {
if (isKeepRequested(tags)) return false;
if (ephemeral) return true;
if (tags?.strategy === "permanent") return false;
return true;
return defaultReap;
}

/** Resolve the config default for exit-time reaping from the environment.
*
* `PTY_REAP_ON_EXIT` is the network/global config knob: the daemon reads its
* own env (which the launching network sets), so setting it fleet-wide
* configures the default for every session — mirroring the env-var config
* style pty already uses for `PTY_SHUTDOWN_DEADLINE_MS`. `false`/`0`/`no`/`off`
* → PRESERVE; unset or anything else → REAP (the shipped default). Per-session
* `keep` / `--ephemeral` override this default either way. */
export function reapOnExitDefault(
env: NodeJS.ProcessEnv = process.env,
): boolean {
const raw = env.PTY_REAP_ON_EXIT;
if (raw === undefined) return true;
return !KEEP_FALSEY.has(raw.trim().toLowerCase());
}

/** Look up a session by either its stable `name` (immutable id) or its
Expand Down
Loading
Loading