fix(doctor): say when the server is running panes nothing holds

An interrupted `tty7 run` strands a pane. So does a window reconciling its
layout while something else edits the tree — measured at 17 from 160
operations, every one a live `zsh` holding a pty and its descriptors, and
`tty7 pane close --orphans` ended all 17.

None of that was news to tty7: `pane ls --all` names them, the switcher lists
them, and the reaper works. What was missing is that nothing volunteered they
existed. `doctor` printed `status  pid …, up 6s, 2 panes` and counted the
stray among them, so the one verb somebody runs when something feels wrong had
the number and did not say what it meant.

A row and a `server.orphans` count. Deliberately not an exit code: one stray
after an interrupted `run` is ordinary and the reference documents it as such,
so `tty7 doctor || alert` firing on it would cry wolf. What was missing was
the sentence, not an alarm.

Counted inside the arm that already has the machine tree in hand for the
dangling-context row, so it costs no extra round trip, and under `server`
because it takes a server to have panes at all.

The first draft put the count at the top level of the JSON, and
`the_doctor_json_sections_are_the_ones_the_reference_names` — added earlier
today for exactly this — caught it as an undocumented section before it went
anywhere.
This commit is contained in:
l0ng-ai
2026-08-23 08:13:50 +08:00
parent 96b7597497
commit ac90f6c212
3 changed files with 54 additions and 2 deletions
+10
View File
@@ -166,6 +166,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
this account's open-file limit is the ceiling on how many panes can run at
once; raise it (`ulimit -n`, …)", keeping the original error in parentheses.
- **`doctor` now says when the server is running panes nothing holds.** An
interrupted `tty7 run` leaves one behind, and a window reconciling its layout
against concurrent edits leaves more — each a live shell holding a pty and
its descriptors. They were findable (`tty7 pane ls --all`, the switcher) and
recoverable (`tty7 pane close --orphans`), but nothing volunteered that they
were there: doctor's status row counted them among the panes without saying
any were stray. It is a row and a `server.orphans` count, not an exit code —
one stray after an interrupted `run` is ordinary, and failing on it would cry
wolf.
- **`doctor` now checks the configured shell.** A `shell` naming something
that is not there — missing, a directory, not executable — makes every new
tab and every `tty7 new` fail, while `doctor` reported `config ok`, because
+38
View File
@@ -2000,13 +2000,51 @@ fn doctor(ctx: &Context, backend: &mut dyn Backend) -> Result<Outcome> {
// an id the reader has no reason to doubt, which is the state
// `doctor` is run in. Only checked when the server answered —
// there is nothing to check against otherwise.
// Shells the server runs that no workspace holds. An interrupted
// `tty7 run` leaves one; a window reconciling its layout against
// concurrent edits leaves more — measured at 17 from 160
// operations, every one a live shell holding a pty and its
// descriptors. They are recoverable and findable, but nothing
// volunteered that they were there: the `status` row counts them
// among the panes without saying any are stray.
//
// A row, not an exit code. One orphan after an interrupted `run`
// is ordinary and documented as such, and `tty7 doctor || alert`
// firing on it would cry wolf. What was missing is the sentence.
//
// Counted here because the tree is already in hand for the row
// below, and because it takes a server to have panes at all.
let mut strays = 0usize;
if let Ok(machine) = fetch_machine(backend) {
dangling = Some(note_dangling_context(&mut rows, ctx, &machine));
if let Ok(running) = backend.list_panes() {
let held: std::collections::HashSet<u64> = machine
.workspaces
.iter()
.flat_map(|ws| ws.tabs.iter())
.flat_map(|tab| tab.root.pane_ids())
.collect();
strays = running
.iter()
.filter(|info| !held.contains(&info.pane_id))
.count();
}
if strays > 0 {
rows.push(vec![
"stray panes".to_string(),
format!(
"{} running that no workspace holds — `tty7 pane ls --all` \
names them, `tty7 pane close --orphans` ends them",
panes_count(strays)
),
]);
}
}
server = json!({
"reachable": true,
"dialect_ok": dialect_ok,
"build": hello.build,
"orphans": strays,
"status": serde_json::to_value(&status)?,
"routes": serde_json::to_value(&routes)?,
});
+6 -2
View File
@@ -320,7 +320,7 @@ nothing, so `tty7 agents` shows it standing still and `tty7 wait` sits there
until it times out. Outdated hooks fail the same quiet way. Hooks are a local
install, so under `-m` the row reads `unknown`.
JSON: `{"context":{"config_dir","workspace","pane"},"server":{"reachable","dialect_ok","build","status","routes"},"config":{"ok","state","dir_writable","shell_problem"},"hooks":{"installed","outdated","not_installed"}}`
JSON: `{"context":{"config_dir","workspace","pane"},"server":{"reachable","dialect_ok","build","orphans","status","routes"},"config":{"ok","state","dir_writable","shell_problem"},"hooks":{"installed","outdated","not_installed"}}`
— the context fields are booleans, not values, and each `hooks` field is a list
of agent slugs. `context` also carries `workspace_gone` and `pane_gone` when a
server answered and could be asked; both are absent when none did, so that "it
@@ -329,7 +329,11 @@ false only when the file failed to parse and tty7 is running on defaults;
`config.state` is the same sentence the table prints. `config.dir_writable` is
probed by writing, not read off the mode bits, because a read-only mount or an
ACL leaves a `0700` directory that refuses every write — false means settings
cannot be saved and `tty7 new` cannot file a workspace. `config.shell_problem` is null unless the
cannot be saved and `tty7 new` cannot file a workspace. `server.orphans` counts the panes the
server is running that no workspace holds — an interrupted `run` leaves one,
and each is a live shell holding a pty. It is a row and a number, not an exit
code: one stray is ordinary, and `tty7 pane close --orphans` ends them.
`config.shell_problem` is null unless the
configured `shell` names something that cannot be launched — missing, a
directory, not executable — in which case every new tab and every `tty7 new`
fails; a shell given as a bare name is left to `PATH` and never reported here,