Skip to content

Session lifecycle: cleanup of unused detached sessions, review, protection, End All Eligible (stacked on #542) - #544

Draft
raiseCatError wants to merge 5 commits into
devfrom
feature/session-lifecycle-20261111
Draft

raiseCatError wants to merge 5 commits into
devfrom
feature/session-lifecycle-20261111

Conversation

@raiseCatError

@raiseCatError raiseCatError commented Nov 11, 2026 •

Copy link
Copy Markdown
Owner

Session lifecycle: event-driven cleanup of unused detached sessions, with review, protection and End All Eligible in the existing startup picker.

Stacked on #542 (branch fix/startup-sessions-20261111, b63cc3b). Review and merge #542 first; this PR's diff against dev includes #542's two commits until then.

What NMSh does today (investigated)

The per-user service nmshd owns every PTY and shell. A window is a client that controls at most one session. Rex's persistence and NMSh's are separate lifecycles: NMSh never reads intent from a host pane.

Event Observed behaviour
Rex pane closes Checked with real Rex against an isolated NMSh (own HOME, config and runtime dir): the session went to detached, its shell stayed alive, and the unused countdown started.
Rex session killed (rex kill) Checked with real Rex: same; detached, shell alive, fresh grace period.
Any window closes / frontend SIGHUP onTerminate sends detach.
Frontend crash or SIGKILL Socket closes; the service detaches.
/exit, /zsh, /fish, /bash Detach and hand off; nmsh from that shell returns to it.
Shell exits on its own Session ends; detached, the next launch archives it.
Several frontends One controls; a second attach is refused.
nmshd SIGTERM / SIGHUP Ends every shell and task.
nmshd crash or SIGKILL Shells lose their PTY and end; the next launch archives each with a "service stopped" note. No auto-restart.
Detached with work Commands, queue, background tasks keep running.

Policy

One pure function, eligibility() in src/session/lifecycle.ts, decides from facts the service observed: attached, ending, protected, running, waiting for input, queued commands, background tasks, shell jobs, child processes, used, detached-since, last activity. The service applies it, and the picker shows the service's answer.

Situation Result Automatic?
Close, detach, crash, Rex pane or Rex quit Detach only Never ends
Detached, never ran a command, past the grace (default 10 min; Off, 10 min, 30 min, 1 h) Ended after re-verifying, right before, that it is still detached, unused, unprotected, idle, and has no child processes (pgrep -P on the service's own PTY child; unreadable means keep). Transcript archived with the reason. Yes
Used, idle, detached over a day Tagged "cleanup suggested"; I Review Idle Sessions, E End All Eligible; one launch line when the picker is not shown Needs confirmation
Idle expiry (Off by default; 1, 3, 7 days) Ended after the same re-verification Only when turned on
Attached, running, fullscreen, still in shell startup, waiting, queued, tasks, jobs, children Never ended by a timer Protected by rules
Protected (P) Never ended by cleanup or End All Eligible; X still works after its confirmation Protected
X End Selected Lists every target; each re-read and ended by id Confirmation
E End All Eligible Lists exactly the eligible sessions with reasons; the service re-verifies each and keeps any that changed (busy) Confirmation
Explicit end of a shell that ignores SIGHUP SIGKILL after 3 s, only after confirming the pid is still the service's own child, so End never leaves a half-ended session Part of confirmed end
Session created before any policy reached the service Never ended by a timer Old behaviour kept

Upgrades: the policy travels in each frontend's hello. Only sessions created by a connection whose own hello stated a policy can ever be ended by a timer. An unknown kill condition is refused, never read as unconditional (unused=<s>;idle=<s>, bounded on both sides). An older frontend states none and changes nothing. Older services ignore the field, and the new protect and kill when=eligible requests are only sent to services that advertise the new lifecycle feature. A configuration without the new keys gets unused cleanup at 10 minutes and idle expiry off.

Updates are event driven. Each detached session has at most one timer, armed on detach, prompt, policy change or protection change, at the moment it could become eligible, and cleared on attach, exit and end. A detached session holding work is looked at again at most hourly. Nothing polls the process table.

Picker (extended, no new manager)

  • Header counts: working, waiting, idle; plus attached elsewhere and ended while away.
  • Rows: a cleanup tag (protected, countdown, suggested).
  • Keys: E End All Eligible, I Review Idle Sessions, P Protect/Unprotect, C Configure Cleanup (two bounded rows; ←→ change, Enter save, Esc back). The existing keys stay: Enter Resume, N/Esc Start New (leave running), X End Selected.
  • Against an older service with no lifecycle facts, none of this shows.
  • There is no Detach key: every session in the startup picker is already detached. Detach is closing the window or /exit. End is always an explicit, confirmed X or E.

Settings: Config → Sessions → Clean up unused sessions and End idle sessions (advanced).

Commits

  • 10970f5: lifecycle policy, service timers, protocol, picker, settings, docs.
  • db6a8a1: Setup reachability for the two settings; re-arm after a clean re-check; idle age shown once.
  • c7a749e: fixes from an adversarial review:
    • startup and fullscreen count as work;
    • an unknown kill condition is refused;
    • I then X goes through the service's re-check;
    • SIGKILL only to a pid whose parent is still the service;
    • policy awareness is per connection;
    • a shell switch during the check is detected;
    • one clock;
    • no timer for protected sessions.

Automated verification (head c7a749e)

  • Focused tests: 56/56 pass. The files are sessionLifecyclePolicy, sessionLifecycleService (14 tests with real shells and isolated runtime dirs), startupLifecyclePicker, startupKillSafety and startupRestore. The startup-busy test fails without its fix.
  • GitHub CI run 43069026603: every job succeeded. That covers Detect, Quality (Node 26), Node 22 compatibility, Tests ubuntu-24.04 1–3/3 and macos-latest 1–3/3, CI, and Verify 22.x/26.x. Fedora and the timing smokes are dispatch-only and were skipped.
  • Local npm run verify: build ok, 4,584/4,623 pass, 25 failures in 20 files. Load average was 30–60 from other sessions' suites.
    • Six failures are the same on pristine dev c690de0: GNU screen, alias refresh, editor panel, suite-runner tmpdir hygiene, configured-completion 4096 bound, and attached-session second frontend.
    • 19 are "timed out waiting" for a frontend prompt. Run side by side on dev and on this branch, those 20 files gave dev 75 failures and the branch 62, with different names each run.
    • One (README image drawing) failed once and passed on both trees in the rerun. The cause is not established, and this PR does not touch image code.
  • Logs: /Volumes/T7Tmp/ai-dev/nq/evidence/session-lifecycle/ (see README.md there). The combined tree still needs the orchestrator's sequential full verify.
  • Real Rex check on an isolated NMSh: details in the PR comment. Pane close and rex kill only detach; the real 10-minute cleanup fired 1 s after its deadline and archived with the reason.

Unresolved risks

  • Default behaviour change for new sessions: a session that never ran a command now ends 10 minutes after it is detached, unless the setting is Off. Configs without the key get 10 minutes. Sessions that already exist under an older service, or that were created by a window that stated no policy, are never ended by a timer.
  • Explicit End escalates to SIGKILL after 3 s when the shell ignores SIGHUP. A shell whose exit hooks take longer than 3 s (for example writing a very large history file) can be cut short.
  • The picker listing cannot see child processes. A session can show as eligible and then be kept when E re-checks it; the line says "checked again and kept".
  • Shell helpers count as child processes, for example gitstatusd or async prompt workers. Their sessions are never cleaned automatically and are re-checked about once an hour while detached.
  • Ordinary helper failures leave it to the person. If pgrep is unavailable or times out, nothing is cleaned automatically.
  • Settings take effect for the service at its next hello. That happens on the next window, the picker's C, or saving Config, which sends a listing.
  • No physical terminal testing yet.

Needs human terminal testing

Use throwaway sessions only:

  1. Rex: open nmsh --new in a pane, close the pane; nmsh --sessions lists it detached; reopen within 10 min and reattach (no cleanup); close again and leave it 10+ min; it ends and /resume shows the archived transcript with "Ended by NMSh session cleanup".
  2. A detached session running sleep 3600, one at read x, one with sleep 3600 &: none end after 10 min.
  3. Picker with 3+ detached sessions: header counts, tags, P (tag shows protected), E (lists only eligible), I (selects long-idle), C (change, Enter, reopen shows the value).
  4. Ghostty and Terminal.app: same as 1 with window close.

…ions

With several detached sessions, Enter resumes the highlighted session (or
the selected ones) instead of starting a new one; N or Esc starts new. X
kills the selection or the highlighted session after a confirmation that
names the count and lists them, so A then X is Kill all; sessions attached
in another window are never touched and each outcome is reported. Rows and
the one-session prompt show name, directory, shell and the same evidence
based status as /sessions. The picker redraws on resize. Nothing about
launching or leaving NMSh ends a session.
The one-session prompt treated a kill the service refused (attached in
another window) as done and swallowed failures; it now returns to the
prompt with the reason. Ending a session goes through one function,
endDetachedSession: an unreachable service is a failure (it used to read
as "already ended"), a refusal because a window attached meanwhile is
"not touched", and a shell this window ended whose archive failed is
reported as ended but not archived yet, with the spool kept. The service
refuses to attach a session between its confirmed kill and the shell's
exit. Bulk confirmations list every target by name, and the outcome line
puts failures first.
…nd All Eligible

The service decides from facts it observed, through one pure function
(src/session/lifecycle.ts) that the picker also reads. A detached
session that never ran a command ends after a grace period (Config ->
Sessions -> Clean up unused sessions; 10 minutes by default), after the
service re-verifies right before that it is still detached, unused,
unprotected, holds no command, input wait, queue, task or job, and has
no child processes (pgrep -P on its own unreaped PTY child; unreadable
keeps it). The spool records why, and the archived transcript says so.
Used idle sessions are only suggested for review after a day; idle
expiry is opt-in. Attached, working and waiting sessions are never ended
by a timer, and sessions created before a policy reached the service
keep their old behaviour.

The policy travels in each hello, bounded; protect and kill
when=eligible go only to services that advertise 'lifecycle'. One timer
per detached session, armed on detach, prompt, policy or protection
change and cleared on attach, exit and end.

The startup picker counts working, waiting and idle sessions, tags
cleanup status, and adds E End All Eligible (exact list; the service
re-verifies each and keeps any that changed), I Review Idle Sessions,
P Protect and C cleanup settings. An explicit end of a shell that
ignores SIGHUP now completes: SIGKILL to the same child after 3 s.
…k; say idle age once

The two Session Lifecycle settings are listed in Setup's Sessions & alerts section, as every Settings row must be. A cleanup re-check that finds no child processes but a changed session re-arms the ordinary schedule instead of waiting an hour. Picker rows no longer repeat the idle age the activity column already shows.
- A shell still in its startup (an rc file waiting on a question, or
  exec'ing tmux) or holding a fullscreen program counts as busy, never
  as an unused session to clean up.
- An unknown kill condition is refused instead of read as an
  unconditional kill.
- Sessions chosen by Review Idle Sessions are ended through the
  service's eligibility re-check (child processes included), as E is.
- Escalation to SIGKILL confirms the pid is still this service's own
  child first: node-pty reaps before it reports the exit.
- Only sessions created by a connection whose own hello stated a
  policy can be ended by a timer; another window's policy never
  reaches an older frontend's sessions.
- A shell switch during a child-process read is detected; lifecycle
  facts use one clock; protected sessions arm no timer.
@raiseCatError

raiseCatError commented Nov 11, 2026 •

Copy link
Copy Markdown
Owner Author

Verification at c7a749e

Focused tests (all pass)

  • tests/sessionLifecyclePolicy.test.ts: states from facts; grace period; work, protection, ending and unknown children always keep a session; review after a day; idle expiry is opt-in; legacy sessions; bounded policy encoding.

  • tests/sessionLifecycleService.test.ts: real shells, isolated runtime dirs, short timers. Covers: an abandoned new session ended and archived with the reason; reattach before the deadline; closing one of several frontends; jobs, a long command, an input wait and child processes all kept; unknown children kept; timer firing during an attach race; protected; idle expiry off and on; sessions created before a policy; End All Eligible re-verification; SIGHUP-ignoring shell escalation.

  • tests/startupLifecyclePicker.test.ts: header counts, tags, E/I/P/C, older service, settings screen, config defaults, kept outcome.

  • tests/startupKillSafety.test.ts (from Startup picker: distinct Resume, New and Kill; counted kill confirmations #542).

  • Total for these four files plus tests/startupRestore.test.ts: 56/56 pass; the service file now has 14 tests.

  • An independent adversarial review found real problems, fixed in c7a749e:

    • A shell still in startup, or one holding a fullscreen program, counted as unused.
    • An unknown when was read as an unconditional kill.
    • I then X skipped the service's re-check.
    • SIGKILL escalation did not confirm the pid was still the service's child.
    • Policy awareness was service-wide, so it could reach an older frontend's sessions.

    The startup case has a real-shell test, which fails without the fix.

Crash, restart and orphaned service state, and stale claim PIDs, are covered by the existing tests/recoveryRace.test.ts and tests/liveHardening.test.ts. This change signals no PID it did not create. The only signals go to the service's own PTY child, and SIGKILL is sent only after confirming that pid's parent is still the service.

Real Rex check (isolated NMSh: own HOME, config, data and NMSH_RUNTIME_DIR)

rex new running nmsh --new. The session was attached and not marked used: NMSh runs no command of its own at startup.

  • rex block close: detached, shell alive, countdown started.
  • Reattached from a second rex new … --attach: countdown cancelled.
  • rex kill of that Rex session: detached again, with a fresh grace period.
  • Left alone, the session ended 1 s after its 10-minute deadline. The spool reason read "Ended by NMSh session cleanup: never used; detached 10m." and recovery archived it with that note.

No other Rex or NMSh session was touched.

npm run verify

At c7a749e, with load average 30–60 from other sessions' suites on this Mac: build passed, and 4,584 of 4,623 tests passed (25 failures, 13 skipped).

  • 4 are the known pristine-dev failures: GNU screen, alias refresh, editor panel, suite-runner tmpdir hygiene.
  • The rest are live-frontend tests timing out at about 20 s, plus a few wall-clock budgets.
  • I ran every file with a failure (20 files, 190 tests) on pristine dev c690de0 and on this branch, side by side under the same load: dev failed 75 and this branch 62. The failing names mostly differ between the two runs, and none of them involves session lifecycle.

This Mac cannot give a clean full run while other suites load it. Hosted CI below covers those files.

GitHub CI

Run 43069026603 on c7a749e: all required jobs succeeded. That covers Detect, Quality (Node 26), Node 22 compatibility, Tests ubuntu-24.04 1–3/3 and macos-latest 1–3/3, CI, and Verify 22.x/26.x. Fedora and timing smoke are dispatch-only and were skipped. Earlier run 43067160094 on db6a8a1 also succeeded.

Still needs physical testing

See the PR body: Rex, Ghostty and Terminal.app close/reopen, real work kept, the picker keys, and the settings screen.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant