Files
web-terminal/docs/plans/w7-orphan-session-clients.md
Yaojia Wang f000a9d979 docs(plan): schedule the orphan tmux session client parity work
The server half shipped 2026-07-30 as feature A (4892fa7/f6ef19e/d39a0ab) and
both native clients are at zero. This is NOT a regression — OrphanSessionInfo is
deliberately a separate type rather than a flag on LiveSessionInfo, and
src/server.ts:600 names the reason: the clients decode /live-sessions, so a
variant shape there would have broken them.

It is worth planning anyway because SessionManager.shutdown only detaches the
tmux client for tmux-backed sessions (manager.ts:410-417), so a server restart
or a desktop-app quit turns every session into an orphan. The web launcher lists
them and re-adopts on tap; the phone still shows an empty list — which is the
walk-away case this product exists for.

The plan doc carries the eleven traps a client implementer will hit, each one a
shipped review finding or a documented server subtlety — notably that preview
returns cols/rows of literally 0 (both clients currently size thumbnails from
that response), that a 429 must not collapse to "no orphans", and that the
destructive confirmation must come from count-idle rather than card count (that
one nearly ended 33 shells while the dialog said 24).

Also corrects two now-false claims found while cross-referencing: the ROADMAP
row for tmux discovery was half-stale, and ANDROID_CLIENT_PLAN's do-not-consume
list still listed POST /projects/worktree (both clients consume it now) and
GET /sessions (iOS consumes it, Android does not — so "match iOS" now argues
for building it, not against).
2026-07-30 18:25:09 +02:00

99 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Orphan tmux sessions on iOS + Android (client parity)
> **Feature id:** `w7-orphan-session-clients` · **Branch:** `develop` · **Effort: M** · **Server changes:** ZERO (all five routes shipped 2026-07-30 as feature **A**).
## Why this exists (and why it is not a regression)
The server half shipped on `develop` in three commits — `4892fa7` (feature), `f6ef19e` (review round 1, 10 findings), `d39a0ab` (round 2). Both native clients are at **zero**: `grep -rn "orphan-sessions" ios/ android/` returns nothing.
**This is a new gap, not a regression.** `OrphanSessionInfo` is deliberately a *separate* type rather than a flag on `LiveSessionInfo`, and `src/server.ts:600` names the reason outright — the Android/iOS clients decode `/live-sessions`, so a variant shape there would have broken them. The existing decoders are untouched and remain correct.
**Why it still matters.** `SessionManager.shutdown` calls `pty.kill()` for tmux-backed sessions (`src/session/manager.ts:410-417`) — that detaches the tmux *client*, it does not kill the shell. So **every session survives a server restart or a desktop-app quit as an untracked tmux session**. Before feature A those were unreachable forever. Today the web launcher shows them in a "可恢复" section and one tap re-adopts them; **the phone still shows an empty session list**. That is precisely the walk-away case this product exists for (`CLAUDE.md` → "What This Is"), which is why this is worth a plan rather than a backlog line.
## Contract (routes / types / semantics)
All five routes live in `src/server.ts:592-670`, in their **own namespace** — not under `/live-sessions`.
| Method / path | Origin | Rate-limited | Success | Failure |
|---|---|---|---|---|
| `GET /orphan-sessions` | **no** | yes | `200` bare `OrphanSessionInfo[]`, server-sorted most-recently-active first | `429` empty body. **Never 500** — tmux failures return `[]` (`src/session/tmux.ts:237-239`) |
| `GET /orphan-sessions/count-idle?idleDays=N` | **no** | yes | `200 {"count": number}` | `400 {"error":"idleDays must be a number >= 1"}`, `429` |
| `GET /orphan-sessions/:id/preview` | **no** | yes | `200 {id, cols: 0, rows: 0, data}` | `400 {"error":"invalid session id"}`, `404` empty, `429` |
| `DELETE /orphan-sessions?idleDays=N` | **YES** | no | `200 {"killed": number, "ids": string[]}` | `403` empty, `400` |
| `DELETE /orphan-sessions/:id` | **YES** | no | `204` empty | `403`, `404` empty, `400` |
- **Rate limit**: `ORPHAN_PREVIEW_RATE_MAX = 240` per 60 s per IP, **one shared bucket across all three GETs** (`src/server.ts:98-100, 260`). A polling client previewing N cards burns N+1 tokens per tick — budget it explicitly.
- **Access token**: ordinary remote routes, so `authGate` covers all five (`src/server.ts:432-474`). Unauthed with `Accept: text/html`**302 → `/login`**; otherwise `401 {"error":"authentication required"}`. Both clients already froze the "`Accept` must not contain `text/html`" rule for `POST /auth` ([`ios-completion.md`](./ios-completion.md) §1.1) — same trap, same fix.
```ts
// src/types.ts:339-350 — every field required on the wire
export interface OrphanSessionInfo {
id: string; // bare UUID v4; the tmux name is web_<id>
createdAt: number; // epoch ms
lastActivityAt: number; // tmux's OWN clock — survives server restarts
attached: boolean; // tmux client count > 0
cols: number;
rows: number;
}
```
**Web reference implementation** — mirror it: `public/launcher.ts` (the section, cards, polling, cleanup flow, copy) and `public/preview-grid.ts` (shared card factory, the five fetch wrappers, `orphanAsLive`).
## The eleven things a client implementer will get wrong
Each of these is either a shipped review finding or a documented server subtlety. They are the reason this is a plan and not a one-line ticket.
1. **"Join" is not a route.** Opening an orphan is `attach(<id>)` on the ordinary WS — Case 3.5 (`src/session/manager.ts:162-170`) runs `tmux new-session -A -s web_<id>`, re-adopts the shell, and it becomes a normal `LiveSessionInfo`. No new protocol, no new endpoint. FE precedent: `public/launcher.ts:161-171`.
2. **Preview returns `cols: 0, rows: 0` literally** (`src/server.ts:645`) — the shape matches `/live-sessions/:id/preview` only so one FE code path renders both. Size the offscreen terminal from `OrphanSessionInfo.cols/rows`, never from the preview body. **This is the single most likely silent bug on both clients** (iOS `SessionThumbnailRenderer`, Android `ThumbnailPipeline` — both currently size from the preview response).
3. **Failure must never collapse to empty.** `[]` means "no orphans"; a 429/500/network error must leave the section untouched. One 429 rendering as "all your recovered sessions are gone" would tear down every mounted card. Web returns `null` for failure (`public/preview-grid.ts:216-231`); this was a MEDIUM review finding.
4. **`attached: true` ≠ "this server is streaming it."** It is tmux's own client count — a plain `tmux attach` or a second server process counts (`src/session/tmux.ts:64-70`). "Untracked here" does not mean "abandoned". Bulk cleanup skips attached sessions on purpose.
5. **Never count cards for the destructive confirmation.** The web grid caps at `MAX_ORPHAN_CARDS = 24` while the bulk DELETE matches everything the server enumerates — on the real host the dialog said "24" and would have ended 33 shells (HIGH finding, fixed in `f6ef19e`). `count-idle` exists solely to answer this; **if it fails, refuse to proceed** (`public/launcher.ts:245-256`).
6. **`lastActivityAt` is `max(window_activity, session_activity)`** (`src/session/tmux.ts:196-206`). Reading the client clock alone was the CRITICAL round-1 bug: it only advances on *client* activity, so a detached session printing output forever looks idle, and idle-cleanup would kill exactly the long-running unattended sessions this app exists for (3 of 69 real sessions were one click from deletion). Do not re-derive idleness from `createdAt` or from `lastOutputAt` semantics.
7. **Status is unknowable.** The web adapter maps to `status: 'unknown'`, `cwd: null`, `clientCount: attached ? 1 : 0`, `lastOutputAt: lastActivityAt` (`public/preview-grid.ts:256-277`), noting that claiming `'idle'` would assert something never looked at. Both clients' status decoders already map unknown → `.unknown`; **reuse it, do not invent an `orphan` status.**
8. **The whole surface is inert when `USE_TMUX` is off**`listOrphans` returns `[]`, `mayActOnOrphan` returns false (`src/session/manager.ts:437, 449`). There is **no capability endpoint** (`GET /config/ui` exposes only `allowAutoMode`/`costBudgetUsd`), so an empty list is the only signal — hide the section, never error. Same discovery hole [`w5-android-parity.md`](./w5-android-parity.md) documents for `worktreeEnabled`.
9. **Preview once per card, latched on success** — each call spawns a `tmux capture-pane` (~58 ms measured). Latch on "preview succeeded", not "card created", or a card that loses its one request stays blank forever (`public/launcher.ts:213-227`).
10. **Origin-iff-guarded, both directions.** The three GETs must **not** carry `Origin`; both DELETEs must carry it byte-equal. Both clients already enforce this structurally (iOS `Endpoints.swift:81`, Android `ApiRoute.toHttpRequest`) — a route-shape test asserting **both** directions is mandatory, matching the existing `GitRouteShapeTest` / `gitOpsRoute` split.
11. **404 is overloaded three ways** on preview and single-delete: tmux off / id not a `web_*` session / the id is already tracked live. The client cannot distinguish them — surface it inertly, never as "session died". Ids are UUID v4 (`SESSION_ID_RE`); pre-validate with the existing `Validation` before any request.
## Files to change
| Path | Concrete change |
|---|---|
| `ios/Packages/APIClient/Sources/APIClient/**` | `OrphanSession.swift`: model + the five route builders (3 RO, 2 G) + `count-idle`; lossy decode; reuse `pathId`/`percentEncode` choke points |
| `ios/App/WebTerm/Screens/SessionListScreen.swift` + a new section view | The "可恢复" section, hidden when the list is empty; tap → `attach(id)` through the existing controller |
| `ios/App/WebTerm/Components/SessionThumbnail.swift` | Accept an explicit size rather than reading `cols/rows` from the preview body (see trap #2) |
| `android/api-client/src/main/kotlin/.../{models,routes}` | `OrphanSession.kt` + the five routes on `Endpoints.kt`; all five `ACCESS_TOKEN_GATE` (no route-defined 401 exists here) |
| `android/app/src/main/java/.../{viewmodels,screens}` | Same section + adopt wiring; same thumbnail sizing fix in `ThumbnailPipeline` |
| `docs/ANDROID_CLIENT_PLAN.md` §4.2/§4.3 | Move `/orphan-sessions*` out of §4.4's do-not-consume list into the consumed tables **when the work lands**, not before |
| `docs/PLAN_IOS_CLIENT.md` §7.1 | Append the delivered capability (no new `T-iOS-*` id — §7.1's stated rule) |
| `docs/PROGRESS_LOG.md` | Orchestrator appends the entry (not the builder) |
## TDD steps (ordered)
Both clients are independent lanes after the contract is frozen; within a lane, API layer before UI.
**Phase A — API layer (parallel: iOS ∥ Android).** RED the route shapes first: Origin-iff-guarded in both directions; `count-idle` query encoding and the `400` mapping; the `429`-is-not-empty rule (#3); `cols/rows: 0` decoded but *not* used for sizing (#2); UUID pre-validation.
**Phase B — presentation logic.** Sorting is server-side — assert the client preserves order and does not re-sort by `createdAt` (#6). Status maps to `.unknown` (#7). Empty list hides the section (#8).
**Phase C — destructive flow.** `count-idle` drives the confirmation number; a failed `count-idle` blocks the action (#5); attached sessions are never offered for bulk cleanup (#4).
**Phase D — adopt.** Tapping an orphan goes through the existing `attach(id)` path and the card transitions to a live session (#1); no new endpoint is introduced.
**Phase E — end-to-end.** iOS `IntegrationTests` has a real-tmux-capable harness already; add a leg that creates a `web_<uuid>` tmux session, sees it listed, previews it, adopts it, and kills it. Mirror the discipline of `test/integration/orphan-sessions.test.ts`, which **deliberately never issues the bulk DELETE** because it runs against the developer's real tmux server (`:7-11`) — the client leg must be equally careful.
> **Dispatch gate (house rule, [`PLAN_IOS_CLIENT.md`](../PLAN_IOS_CLIENT.md) §7):** the phases above are dispatch metadata only. The full RED test list is expanded by each task owner at start-of-work into `## RED test list` below, **before** any implementation. Do not dispatch from the phase descriptions alone.
## Security
- Origin-iff-guarded on all five, asserted both directions (#10). The token is additive and never a substitute — same rule as [`ios-completion.md`](./ios-completion.md) §1.1.
- `preview.data` is raw `capture-pane -p -e` output — **attacker-influenced bytes with escape sequences preserved**. Render it only through the existing offscreen-terminal path that already treats `/live-sessions/:id/preview` as untrusted; never into a text view, never into a log.
- The destructive confirmation must state a **true** number (#5). A dialog that undercounts is a data-loss bug, not a UX nit — it already nearly was one.
- `Accept` must not contain `text/html`, or the auth gate answers with a login page a JSON decoder will mis-parse.
## Effort & dependencies
**Effort: M** (~34 days across both clients). Zero server change. No external dependency. Depends on nothing in flight; the API layers of both clients are stable after `ios-completion`.
Related: [`w5-android-parity.md`](./w5-android-parity.md) (same shape — server ahead, client behind), [`ios-completion.md`](./ios-completion.md) (frozen token contract, Origin-iff-G rule).
## RED test list (appended by each task owner at start-of-work)
<!-- empty until dispatched — see the dispatch gate above -->