feat(gc): replace long-running supervisor with cron-driven `pty gc`
The launchd-managed `pty-supervisor` daemon raced /Volumes/SSD's mount
on Mac boot: permanent sessions whose `dist/server.js` lived under the
external volume failed restart with MODULE_NOT_FOUND, exhausted 5
retries x 2s backoff within 10s, then were marked "no metadata" and
never came back without manual `pty up`. A long-running supervisor will
always have this class of bug — it can't be sure its dependencies are
ready when launchd fires it.
Replace it with a stateless reconciliation pass:
pty gc
invoked periodically by launchd (`StartInterval=30` by default) or any
other cron-driver. Three steps:
1. Orphan-children: sessions tagged `parent=<name>` whose parent's
metadata is gone OR whose parent's pid isn't alive get SIGTERM'd
and cleaned up. New user-facing tag.
2. Permanent respawn: every `strategy=permanent` session that's
exited/vanished is respawned via `spawnDaemon`. Sessions tagged
`ptyfile` re-read pty.toml to pick up edits.
3. Sweep: exited/vanished non-permanent sessions get `cleanupAll`'d
(the historic gc behavior).
Each run is independent — no persistent restart bookkeeping, no
MAX_RESTARTS, no backoff state to drift out of sync with reality.
Cron interval IS the rate limit; if /Volumes/SSD isn't mounted, the
invocation exits with ENOENT and the next tick tries again.
Install helper: `pty gc --print-launchd-plist [--interval=N]` prints
a minimal plist to stdout. No FDA wrapper, no compiled C binary, no
esbuild bundling — `<ProgramArguments>[pty, gc]</ProgramArguments>`
with `<StartInterval>`. User redirects to ~/Library/LaunchAgents/
and `launchctl load`s themselves.
Breaking changes:
- Deleted `pty supervisor *` commands entirely. Users who installed
the old supervisor should `launchctl unload` + remove the old
plist (see CHANGELOG for the exact commands).
- Deleted event types `session_restart`, `session_failed`,
`supervisor_start`, `supervisor_stop`. Added `session_respawn`.
- Deleted reserved tag `supervisor.status`. Added user-facing tag
`parent=<name>`.
- Dropped `strategy=temporary` (was functionally identical to no
strategy tag under the new sweep behavior).
- `gc()`'s return shape changed from `string[]` to a structured
`GcResult` with four buckets (`removed`, `killedOrphanChildren`,
`respawned`, `respawnFailed`). Public API.
Net delta: ~1830 lines deleted, ~485 added.
list & gc ergonomics: vanished status, --summary/--status/age filters, gc --dry-run (closes #21)
Four changes, one PR:
1. Third session status `vanished` for the "daemon is gone, no exit
record written" case (SIGKILL / OOM / crash). Listed in its own
yellow-headered bucket with a warning icon so it doesn't blend into
cleanly-exited sessions. Reapable by `pty gc` like any other dead
session. TTL anchor falls back to createdAt so they can't accumulate
indefinitely.
2. `pty list --summary` (+ `--json --summary`) — compact counts and
oldest/newest pointers, respects all other filters.
3. `pty list --status <state>` and `--older-than/--newer-than <Ns|Nm|Nh|Nd>`
filters. Compose with `--filter-tag` and `--summary`. Grammar is
deliberately single-unit (no `1h30m`) to keep --help trivial.
4. `pty gc --dry-run` (`-n`) — preview what would be removed without
mutating anything. Covers exited AND vanished sessions AND orphan
`:l<pid>-<rand>` layout tags in one pass.
Client API:
- gc({ dryRun }), pruneOrphanLayoutTags({ dryRun })
- New `isGone(status)` helper — replaces hand-rolled
`status === "exited"` checks that should have included vanished all
along. Swept existing callers (run -a, peek fallback, pty up/down
cleanup, stats --all) to use it.
- New parseDuration/formatDuration exports for downstream tools.
Tests:
- duration.test.ts — full grammar coverage.
- list-filters.test.ts — vanished inference, --status, age filters,
--summary (text + json), filter composition.
- gc.test.ts — extended with --dry-run and vanished-session reaping.
726 → 728 local tests pass (+37 new), 0 failures.
lean-core: gc clean-only + delete pty up/down + trim GcResult; bump 0.12.0
Sub-batch 2/N of the reboot cutover branch. Executes §8 items 1–8 + 10
of notes/lean-pty-core-supervision-spec.md.
BREAKING — CLI surface:
- Removed `pty up` and `pty down`. Manifest processing moves to
`convoy up` / `convoy down`. `pty.toml` file name stays; convoy reads
it verbatim via `readPtyFile` + `commandWithEnvExports`, both still
exported from `@myobie/pty/client`.
- Removed `pty gc --fast-fail-window` and `--fast-fail-limit`.
- `pty gc` is clean-only: STEP 1 (orphan-kill), STEP 1.5 (abandoned-reap
cwd-gone + opt-in idle), STEP 3 (sweep exited non-permanent). STEP 2
(permanent respawn) is gone; permanent sessions that exit stay
in-place on disk for convoy's reconcile loop.
BREAKING — API surface:
- GcResult trimmed to `{ removed, killedOrphanChildren, abandoned }`.
- Deleted sessions.ts:respawnPermanent + classifyFlapping + the
FlappingDecision interface.
- Removed the `[flapping]` badge from strategyMarker / `pty list`
(convoy renders its own list if desired).
- SessionFlappingEvent + EventType.SESSION_FLAPPING stay exported for
convoy to import + emit via appendEventSync. Payload frozen per
spec §8.1.
Tests:
- Deleted: tests/gc-flapping.test.ts, tests/gc-permanent.test.ts,
tests/up-down.test.ts, tests/up-name-decouple.test.ts.
- Extracted PTY_ROOT length backstop tests into
tests/pty-root-length-backstop.test.ts (from the previous
gc-flap-clear-badge-root-len.test.ts, dropping the flap-clear +
badge cases).
- Updated tests/gc-abandoned.test.ts + gc-parent-child.test.ts to
assert post-reboot behavior.
- Full suite: 1158 passed + 1 flake (demos/agent-teams timestamp
timing, unrelated to §8), 21 skipped, 0 real failures.
Version bump 0.11.0 → 0.12.0. `flake.nix` npmDepsHash refreshed.
BREAKING — Storage format on 0.12.0:
- No tag or event REMOVED from the wire — pty stops writing the
`strategy.status` / `strategy.consecutive-fast-fails` /
`strategy.last-respawn-at` / `strategy.command-hash` tags, but they
remain reserved-shape for convoy to write. `session_flapping` event
stays defined.
Cutover-branch commit; does NOT merge until reboot moment.