From 9881e2c4113e59c33c5d59af692b041490432c60 Mon Sep 17 00:00:00 2001 From: Thibault Dody Date: Mon, 2 Nov 2026 14:41:37 -0500 Subject: [PATCH 01/44] =?UTF-8?q?docs(specs):=20fewer=20forks=20per=20refr?= =?UTF-8?q?esh=20=E2=80=94=20measure,=20one=20tmux=20and=20one=20ps=20snap?= =?UTF-8?q?shot,=20git=20in=20the=20background,=20gh=20without=20keychain?= =?UTF-8?q?=20reads=20(THI-450)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- ...26-11-02-fewer-forks-per-refresh-design.md | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 docs/superpowers/specs/2026-11-02-fewer-forks-per-refresh-design.md diff --git a/docs/superpowers/specs/2026-11-02-fewer-forks-per-refresh-design.md b/docs/superpowers/specs/2026-11-02-fewer-forks-per-refresh-design.md new file mode 100644 index 00000000..f6205811 --- /dev/null +++ b/docs/superpowers/specs/2026-11-02-fewer-forks-per-refresh-design.md @@ -0,0 +1,123 @@ +# Fewer forks per refresh + +`build_state` — the body of every dashboard refresh, run by the state hub about once a second and by any REST +caller — measures its own cost, forks two processes per refresh instead of six, keeps git off the request path, and +stops paying a keychain read for every `gh` call. THI-450. + +## What was measured (2026-11-02, prod, 11 Claude panes, load avg ~10) + +- **~200 forks in 30 s** sampled from the server's process tree (a lower bound; short-lived ones slip between + samples): `ps` ~73, `git` ~60, `tmux` ~27, `security` 12, `gh` ~8, plus `ssh -G`, `claude agents --json`, the + tmux-continuum save. +- **Every refresh, caches warm:** 6 fixed forks — `tmux list-windows`, `tmux list-panes -a -F '#{pane_id}'` + (`panes.all_pane_ids`), `tmux list-panes -a -F '#{pane_id}\t#{pane_pid}'` (`session_status._tmux_pane_pids`, bare + `tmux` that ignores `PERISCOPE_TMUX_SOCKET`), and three full process tables in three formats + (`session_status.proc_table`, `session_status._claude_procs`, `agent_processes.process_snapshot`) — plus one + `capture-pane` per working or freshly-output pane. About 9 per refresh, up to 17. +- **Every ~15 s:** `git_pr.cached_git_state` runs 7 git commands per pane cwd, serially on the request path, and + every cwd was warmed in the same refresh so they all expire together: ~77 serial git forks in one refresh. +- **Under load:** `session_status`'s 1 s caches have no lock. A refresh slower than 1 s lets each of the fan-out's + up-to-32 threads re-fork `tmux list-panes` and two `ps` in parallel. +- **`gh`:** 11 calls in 20 s (per-branch `gh pr list --head`, `gh pr view` for linked PRs, the reviews queue's + `gh pr list --state open`). gh's keyring on macOS execs `/usr/bin/security` to read its token on every call, and gh + spawns `gh send-telemetry`. The plan-usage keychain reads (`usage._read_oauth_token`) are correctly rate-limited + (per account per 300 s) and are not the anomaly. + +## Scope + +In: A (measure), B (fixed forks 6 → 2), C (git off the request path), D (`gh` without keychain reads). +Out, its own ticket: `capture-pane` through the existing control-mode client instead of a fork per pane (touches the +mirror code and `docs/invariants.md` #2–#7); batching the per-branch `gh pr list --head` calls into one query per +repo (decide from A's numbers). + +## Decisions + +**D1 — Forks are counted by a Python audit hook.** `sys.addaudithook` on the `subprocess.Popen` event (which +`subprocess.run`, `check_output` and `Popen` all raise) counts every fork the server makes, by command (`argv[0]` +basename plus the first subcommand, e.g. `git status`, `tmux list-panes`), in a rolling 60 s window. No call site +changes, and background threads count too. Installed once in the app's lifespan; the hook returns at once for every +other audit event. + +**D2 — Each refresh reports its cost.** `build_state` records its duration; the state hub records the gap between +broadcasts (what a dashboard actually sees). `/api/state` carries `refresh: {build_s, build_max_s, gap_s, +forks_per_min, top_forks}` (last build, max over 60 s, last gap, total and the five largest commands over 60 s). The +resource popover (THI-448) shows one line: `refresh 0.4 s · every 1.1 s · 140 forks/min`, with the top commands in +its tooltip. A build slower than 3 s logs a warning naming its top forkers. + +**D3 — One tmux call per refresh.** A single `tmux list-panes -a -F ` +through `_tmux_argv` (so it honours `PERISCOPE_TMUX_SOCKET`), cached for 1 s behind a lock. From it: +`list_windows` keeps its exact row shape, built from each window's active pane; `all_pane_ids` and +`session_status._tmux_pane_pids` read every pane. Invariants kept: focus from `#{window_active}`, never +`#{window_activity}` (#1); `#{window_id}` and `#{@periscope_id}` carried for stamping by window id (#11); the full +window roster reaches `_attach_git_then_resolve_pids` (#13, #19); `all_pane_ids` still includes a split window's +background panes, and its caller still skips GC on an empty result. + +**D4 — One process table per refresh.** `agent_processes.process_snapshot()` (pid, ppid, start time, state, comm, +args; locked, 1 s) becomes the only `ps` table. `session_status.proc_table` and `_claude_procs` read it instead of +forking their own (`ps -axo pid=,ppid=,command=` and `ps -axo pid=,etime=,comm=`); a process's age comes from its +start time. The 15 s `ps eww` environment scan stays: process environment has no other source. Both snapshots are +locked, so the fan-out can no longer stampede them. + +**D5 — Git: two commands per refresh, refreshed in the background.** `git_state_for` runs +`git --no-optional-locks status --porcelain=v2 --branch --untracked-files=normal` (branch, detached sha, ahead count +from `# branch.ab`, untracked entries) and `git diff HEAD --shortstat` (line counts) instead of seven commands. +`--no-optional-locks` keeps the status call from taking the index lock under the user's own git commands. The git +dir, common dir (`resolve_repo`) and remote URL (`github_slug`) are cached per cwd for 10 minutes. The output shape +(`branch`, `git`, `repo_slug`, `repo_key`, `repo_label`) is unchanged. + +`cached_git_state` becomes stale-while-revalidate: it returns the cached entry and, when it is stale, starts a +deduplicated background refresh through `_bg`; each entry's next refresh is 15 s ± 5 s (jittered, so cwds warmed +together don't expire together). A cwd seen for the first time is still filled synchronously: the pid resolver +matches panes on `(branch, cwd)` after a tmux restart (`_attach_git_then_resolve_pids`), so `branch` must not be +missing on first sight. The cache gets a lock. + +**D6 — `gh` reads its token once, not per call.** One helper builds the environment for every `gh` periscope runs +(`git_pr`, `reviews`, `cleanup`, `projects`): `GH_TOKEN` from `gh auth token` (read once, cached for an hour, +re-read after an auth failure), `GH_TELEMETRY=false` and `GH_NO_UPDATE_NOTIFIER=1`. gh reads `GH_TOKEN` before its +stored credentials, so no call execs `security`, and none spawns `gh send-telemetry`. If `gh auth token` fails, the +calls run without `GH_TOKEN` as today. + +## Changes + +- `periscope/forks.py` (new): the audit hook, the rolling counter, and `refresh` stats (D1, D2). +- `periscope/app.py`: installs the hook in the lifespan. +- `periscope/routes/state.py`, `periscope/state_hub.py`: build duration, broadcast gap, the `refresh` block (D2). +- `periscope/panes.py` (+ `tmux.py` if the format lives there): the one list-panes snapshot; `list_windows` and + `all_pane_ids` read it (D3). +- `periscope/session_status.py`: `_tmux_pane_pids`, `proc_table`, `_claude_procs` read the shared snapshots (D3, D4). +- `periscope/git_pr.py`, `periscope/gitutil.py`: the two-command refresh, background refresh, jitter, lock, the + 10-minute repo-identity cache (D5). +- `periscope/gh.py` (new, or a function in `gitutil.py` if that reads better): the `gh` environment (D6); every `gh` + call site uses it. +- `static/src/chrome/ResourcePill.jsx`, `static/src/chrome/resources.js`: the refresh line (D2). +- `docs/invariants.md`: a line under #1 / #11 that the window rows now come from one `list-panes` snapshot. + +## Testing + +- `tests/test_forks.py`: the hook counts `subprocess.run` / `Popen` by command; the window rolls; other audit events + are ignored. +- `tests/test_panes.py`: windows derived from a list-panes fixture match today's `list_windows` rows exactly (fields, + order, active-pane selection, split windows); `all_pane_ids` includes background panes; an empty tmux answer. +- `tests/test_session_status.py`: `proc_table` and `_claude_procs` built from a `process_snapshot` fixture match + today's outputs (age from start time). +- `tests/test_git_pr.py`: the porcelain-v2 parse against fixtures for clean, dirty, untracked-only, detached, ahead, + no-upstream and non-repo; stale-while-revalidate (stale returns cached and refreshes once; first sight fills + synchronously; jittered expiry); one fork pair per refresh. +- `tests/test_gh.py` (or alongside): the environment carries `GH_TOKEN`/`GH_TELEMETRY`/`GH_NO_UPDATE_NOTIFIER`; the + token is reused within the hour and re-read after an auth failure; no token → calls still run. +- vitest: the refresh line's formatting. +- Before/after on prod-like load: the `refresh` block's `forks_per_min`, `build_s` and `gap_s` from a dev server on + :17375 against the same panes, recorded in the PR. + +## Assumptions + +- `git status --porcelain=v2 --untracked-files=normal` counts untracked entries the same way as `ls-files -o + --exclude-standard --directory --no-empty-directory` (a wholly-untracked directory is one entry). The tests compare + both on fixture repos. +- The short sha for a detached HEAD is the first 7 characters of `# branch.oid` (today: `rev-parse --short`, which + can be longer when 7 is ambiguous). +- A cwd's branch shown on the dashboard can lag a checkout by up to ~20 s (the stale entry is served while the + background refresh runs); pid rebinding uses the same, possibly stale, branch. +- `gh auth token` prints the token gh would use for github.com; a token revoked within the hour fails one call, which + triggers the re-read. +- The audit hook's per-event cost (a string compare) is negligible against periscope's file I/O. From 7a641f3c53332f1f0936ee67e12f146e9efd3970 Mon Sep 17 00:00:00 2001 From: Thibault Dody Date: Mon, 2 Nov 2026 16:13:10 -0500 Subject: [PATCH 02/44] docs(plans): fewer forks per refresh implementation plan (THI-450) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../2026-11-02-fewer-forks-per-refresh.md | 2323 +++++++++++++++++ 1 file changed, 2323 insertions(+) create mode 100644 docs/superpowers/plans/2026-11-02-fewer-forks-per-refresh.md diff --git a/docs/superpowers/plans/2026-11-02-fewer-forks-per-refresh.md b/docs/superpowers/plans/2026-11-02-fewer-forks-per-refresh.md new file mode 100644 index 00000000..9ed326f5 --- /dev/null +++ b/docs/superpowers/plans/2026-11-02-fewer-forks-per-refresh.md @@ -0,0 +1,2323 @@ +# Fewer Forks Per Refresh Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** `build_state` measures its own cost, forks one `tmux` and one `ps` per refresh instead of six processes, keeps git off the request path, and runs every `gh` without a keychain read (THI-450; spec `docs/superpowers/specs/2026-11-02-fewer-forks-per-refresh-design.md`). + +**Architecture:** A Python audit hook (`periscope/forks.py`) counts every `subprocess.Popen` by command over a rolling minute; `build_state` and the state hub record their timings there and `/api/state` reports them as `refresh`. One `list-panes -a` (`tmux.list_panes`, refreshed by every `list_windows`) and one `ps` (`agent_processes.process_snapshot`) feed every per-refresh reader; git reads two commands per cwd behind a jittered stale-while-revalidate cache, and `gh` goes through one `gh.run` that carries a token read once an hour. + +**Tech Stack:** Python 3.14 / FastAPI / pytest + pytest-mock; Preact + `@preact/signals`, Vite, vitest; tmux, git (porcelain v2), gh. + +--- + +## Conventions + +- **Worktree:** `/Users/thibaultdody/dev/worktrees/periscope/thi-450-fewer-forks` (branch `thibaultdody/thi-450-build_state-runs-too-many-subprocesses-per-tick-so-the`). Every path below is relative to it. Never touch the prod tree `/Users/thibaultdody/github/tomplex/periscope` or anything on `:17374`. +- **Read first:** the spec, `docs/invariants.md` (#1, #8, #11, #13, #18, #19), `docs/testing.md`, `tests/conftest.py`. +- **Python tests:** `uv run pytest -q `; full suite `uv run pytest -q`. +- **Frontend:** the worktree has no `node_modules` — run `npm install` once before any `npm`/`npx` command. Tests `npm test`; build `npm run build`. +- **Gate:** `bin/check; echo "exit=$?"` — read the real exit code, never pipe it through `tail`/`head`. +- **Before each commit:** `uv run ruff check --fix && uv run ruff format ` on the task's Python files (sorts imports the steps add). +- **Commits:** one per task, single-line subject `area: what (THI-450)`, a blank line, then the implementing model's attribution line. Commit only the task's paths: new files need `git add ` first, then `git commit -m "" -m "Co-Authored-By: Claude Opus 5.5 (1M context) " -- `. Replace the Co-Authored-By line with your own model's if you are not Claude Opus 5.5. +- **Background work** goes through `periscope.log._bg` (invariant #8). Tests never start a real background thread for it: they patch the module's `_bg`, as `test_reviews.py` already does. +- **No test forks real tmux, ps or gh.** `tests/conftest.py` seeds each new snapshot cache with an unexpiring empty entry (the `_config_dirs_cache` pattern `test_session_status.py` already uses); a test of the real path resets the seed itself. + +## File structure + +| File | Change | Responsibility | +|---|---|---| +| `periscope/forks.py` | Create | Audit hook, rolling fork counter, build/gap timings, the `refresh` block | +| `periscope/app.py` | Modify (lifespan, ~L54–58) | Install the hook | +| `periscope/routes/state.py` | Modify (L16, L69, L167–208) | Time `build_state`; add `refresh` to the blob | +| `periscope/state_hub.py` | Modify (imports, `_run_loop` L96–111) | Record the gap between broadcasts | +| `periscope/session_status.py` | Modify (L29–58, L128–241, L243–259) | `proc_table` / `_claude_procs` from `process_snapshot`; `_tmux_pane_pids` from `tmux.recent_panes`; `parse_etime` deleted | +| `periscope/window_view.py` | Modify (comment L164–171) | Comment names the shared snapshot | +| `periscope/tmux.py` | Modify (imports, `_run` L33–43, new section) | `list_panes` / `recent_panes`; `_run(env=)` | +| `periscope/panes.py` | Modify (L21, L385–449) | `list_windows` and `all_pane_ids` read `list_panes` / `recent_panes` | +| `periscope/git_pr.py` | Modify (L18–43, L65–128, gh call sites) | Two-command `git_state_for`, repo-identity cache, SWR `cached_git_state`, `gh.run` | +| `periscope/gh.py` | Create | The one way periscope runs `gh` | +| `periscope/reviews.py`, `periscope/cleanup.py`, `periscope/projects.py` | Modify (their `gh` call) | Use `gh.run` | +| `static/src/store.js`, `static/src/poll.js` | Modify | `refresh` signal | +| `static/src/chrome/resources.js`, `static/src/chrome/ResourcePill.jsx`, `static/styles.css` | Modify | The popover's refresh line | +| `static/dist/*` | Rebuild | Committed bundle | +| `tests/conftest.py` | Modify | Seed tmux / ps / gh-token caches; fresh git caches | +| `tests/test_forks.py`, `tests/test_gh.py` | Create | Hook, counter, stats; gh environment | +| `tests/test_panes.py`, `tests/test_tmux.py`, `tests/test_session_status.py`, `tests/test_git_pr.py`, `tests/test_reviews.py`, `tests/test_state_hub.py`, `tests/routes/test_state.py` | Modify | Cases per task | +| `static/src/chrome/__tests__/resources.test.js`, `resourcePillRender.test.jsx` | Modify | Refresh line | +| `docs/invariants.md`, `docs/testing.md`, `CLAUDE.md` | Modify | Snapshot note, conftest seeds, module table | + +`periscope/gitutil.py` does not change: the 10-minute identity cache lives in `git_pr.py`, because `resolve_repo` / `github_slug` have other callers (open_ops, reviews) that need a live answer. + +--- + +## Task 1: The fork counter + +**Files:** +- Create: `periscope/forks.py` +- Modify: `periscope/app.py` (lifespan, the first lines after `async def lifespan`) +- Test: `tests/test_forks.py` (new) + +- [ ] **Step 1: Write the failing tests** — create `tests/test_forks.py`: + +```python +"""forks — the audit hook that counts every fork the server makes, and the +per-refresh cost /api/state reports.""" + +import subprocess +import sys +from collections import deque +from types import SimpleNamespace + +import pytest + +from periscope import forks + + +@pytest.fixture(autouse=True) +def _fresh_counters(monkeypatch): + monkeypatch.setattr(forks, "_forks", deque()) + + +def _popen(*argv): + forks._on_audit("subprocess.Popen", (argv[0], list(argv), None, None)) + + +@pytest.mark.parametrize( + ("argv", "key"), + [ + (["git", "-C", "/repo", "status", "--porcelain=v2"], "git status"), + (["git", "--no-optional-locks", "-C", "repo", "diff", "HEAD"], "git diff"), + (["/opt/homebrew/bin/tmux", "-L", "periscope-x", "list-panes", "-a"], "tmux list-panes"), + (["ps", "-axo", "pid=,ppid=,lstart=,state=,comm=,args="], "ps"), + (["ps", "eww", "-o", "pid=,command=", "-p", "1,2"], "ps eww"), + (["gh", "pr", "list", "--head", "x"], "gh pr"), + (["/bin/sh", "-c", "echo hi"], "sh"), + ([b"/usr/bin/true"], "true"), + ], +) +def test_command_key_is_the_binary_and_its_subcommand(argv, key): + assert forks._command_key(argv) == key + + +def test_hook_ignores_every_other_audit_event(): + forks._on_audit("open", ("/etc/hosts", "r", 0)) + # An argv the key can't be read from is dropped, never raised: raising + # would abort the Popen being audited. + forks._on_audit("subprocess.Popen", ("x", [], None, None)) + assert forks.fork_counts() == {} + + +def test_counts_roll_off_after_a_minute(monkeypatch): + clock = [1000.0] + monkeypatch.setattr(forks, "time", SimpleNamespace(monotonic=lambda: clock[0])) + _popen("git", "-C", "/r", "status") + clock[0] += 30 + _popen("git", "-C", "/r", "status") + _popen("ps", "-axo", "pid=") + assert forks.fork_counts() == {"git status": 2, "ps": 1} + clock[0] += 31 + assert forks.fork_counts() == {"git status": 1, "ps": 1} + + +def test_installed_hook_counts_run_and_popen(): + forks.install() + subprocess.run(["true"], check=True) + subprocess.Popen(["true"]).wait() + assert forks.fork_counts()["true"] == 2 + + +def test_install_adds_the_hook_once(monkeypatch): + added = [] + monkeypatch.setattr(forks, "_installed", False) + monkeypatch.setattr(sys, "addaudithook", added.append) + forks.install() + forks.install() + assert added == [forks._on_audit] +``` + +- [ ] **Step 2: Run them and see them fail** + +Run: `uv run pytest -q tests/test_forks.py` +Expected: collection error `ImportError: cannot import name 'forks' from 'periscope'`. + +- [ ] **Step 3: Implement** — create `periscope/forks.py`: + +```python +"""Every fork the server makes, counted by command over a rolling minute. + +Counted by a Python audit hook on `subprocess.Popen` — the event +`subprocess.run`, `check_output` and asyncio's subprocesses all raise — so no +call site changes and background threads count too.""" + +import os +import re +import sys +import threading +import time +from collections import Counter, deque +from collections.abc import Sequence +from typing import Any + +_WINDOW_S = 60.0 +# The subcommand is the first word-like argument after the binary, so +# `git -C status` counts as `git status` and `ps -axo pid=,…` as `ps`. +_SUBCOMMAND_RE = re.compile(r"[a-z][a-z0-9-]*") +# Options whose value comes next and could pass for a subcommand +# (`tmux -L `, `git -C `, `sh -c