[object Object]

← back to Terminal Status

terminal-status: cross-process proc-table cache + negative-rescan valve (TK-11398)

ce0496085795dc9093a9451398bc02cf280d1471 · 2026-09-11 12:20:03 -0700 · Steve Abrams

Paints were taking ~100s and often hitting the 60s ps ceiling and silently
no-op'ing, leaving tabs showing a PREVIOUS session's dot (observed: a tab
advertising a finished, unrelated ticket while working another) -- the
false-green class, since the wall looks authoritative while being wrong.

Root cause is a thundering herd, not a constant cost. _PROC_CACHE is
per-PROCESS and every dot call is a fresh python3, so the cache was dead on
arrival and all ~42 live sessions each ran their own full `ps -ax`. Measured:
three consecutive scans cost 2.30s / 4.90s / 7.08s -- the scans mutually slow
each other, so the herd is self-amplifying.

DOCUMENTED DECISION REVERSAL: the in-code scope note said a cross-process file
cache was "not worth the risk" because a stale table could miss a just-started
session and wrongly report "no owning terminal". Reversed deliberately, by
claude-run-11255, with Steve's explicit approval, on the new measured fact
above. The objection is preserved rather than traded away: a cached table is
only ever trusted for a POSITIVE answer -- every negative ownership conclusion
(current_owner finding no owning runtime; assert_owner about to refuse a stale
writer) re-scans fresh and re-decides before it is allowed to fail. A negative
can cost one extra scan; it can never produce a wrong refusal.

_splice_self_chain() is what makes the cache actually pay off: a cached table
predates the current process, so ancestors(os.getpid()) dead-ended and forced
the valve on EVERY call, cancelling the whole benefit (observed directly via
the new debug seam: "HIT" immediately followed by "MISS -> scan (fresh=True
forced)"). It now walks our own chain with targeted single-pid ps calls
(0.01-0.10s each, O(depth) not O(2000)) and returns None rather than a partial
chain, so failure can only cost a scan.

_DISK_TTL is 30s, not 5s: a scan measures 15-70s under load, so a 5s window
expires before the next invocation can reach it -- the same trap the existing
per-PROCESS TTL note describes. Staleness is bounded by the valve, not the TTL.

TERMINAL_STATUS_DEBUG=1 prints proc-cache HIT/MISS to stderr -- a cache whose
hit rate you cannot observe is a cache you cannot prove works. Opt-in only.

Verified end-to-end, with injected faults (positive-only tests would prove
nothing about a correctness valve; each fixture asserts it is not a no-op):
  - warm resolve: 17-72s -> 1.4-3.0s, single HIT, no forced rescan
  - real paint:   ~100s  -> 4.41s, dot file verified
  - fault A (70 claude/codex rows stripped from a fresh cache):
      HIT -> valve -> resolves the REAL owner pid 30729
  - fault B (my tty rewritten to impostor pid 999001):
      HIT -> valve -> resolves REAL pid 30729, impostor rejected
  - `audit` clean, no new drift

Complements 9696608 (per-process TTL 5s->120s, another session, TK-11466
followup), which fixes the intra-run double scan; this fixes the cross-session
herd. Both are needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012LVUzeaBYQJsjSYSWtQZYy

Files touched

Diff

commit ce0496085795dc9093a9451398bc02cf280d1471
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Sep 11 12:20:03 2026 -0700

    terminal-status: cross-process proc-table cache + negative-rescan valve (TK-11398)
    
    Paints were taking ~100s and often hitting the 60s ps ceiling and silently
    no-op'ing, leaving tabs showing a PREVIOUS session's dot (observed: a tab
    advertising a finished, unrelated ticket while working another) -- the
    false-green class, since the wall looks authoritative while being wrong.
    
    Root cause is a thundering herd, not a constant cost. _PROC_CACHE is
    per-PROCESS and every dot call is a fresh python3, so the cache was dead on
    arrival and all ~42 live sessions each ran their own full `ps -ax`. Measured:
    three consecutive scans cost 2.30s / 4.90s / 7.08s -- the scans mutually slow
    each other, so the herd is self-amplifying.
    
    DOCUMENTED DECISION REVERSAL: the in-code scope note said a cross-process file
    cache was "not worth the risk" because a stale table could miss a just-started
    session and wrongly report "no owning terminal". Reversed deliberately, by
    claude-run-11255, with Steve's explicit approval, on the new measured fact
    above. The objection is preserved rather than traded away: a cached table is
    only ever trusted for a POSITIVE answer -- every negative ownership conclusion
    (current_owner finding no owning runtime; assert_owner about to refuse a stale
    writer) re-scans fresh and re-decides before it is allowed to fail. A negative
    can cost one extra scan; it can never produce a wrong refusal.
    
    _splice_self_chain() is what makes the cache actually pay off: a cached table
    predates the current process, so ancestors(os.getpid()) dead-ended and forced
    the valve on EVERY call, cancelling the whole benefit (observed directly via
    the new debug seam: "HIT" immediately followed by "MISS -> scan (fresh=True
    forced)"). It now walks our own chain with targeted single-pid ps calls
    (0.01-0.10s each, O(depth) not O(2000)) and returns None rather than a partial
    chain, so failure can only cost a scan.
    
    _DISK_TTL is 30s, not 5s: a scan measures 15-70s under load, so a 5s window
    expires before the next invocation can reach it -- the same trap the existing
    per-PROCESS TTL note describes. Staleness is bounded by the valve, not the TTL.
    
    TERMINAL_STATUS_DEBUG=1 prints proc-cache HIT/MISS to stderr -- a cache whose
    hit rate you cannot observe is a cache you cannot prove works. Opt-in only.
    
    Verified end-to-end, with injected faults (positive-only tests would prove
    nothing about a correctness valve; each fixture asserts it is not a no-op):
      - warm resolve: 17-72s -> 1.4-3.0s, single HIT, no forced rescan
      - real paint:   ~100s  -> 4.41s, dot file verified
      - fault A (70 claude/codex rows stripped from a fresh cache):
          HIT -> valve -> resolves the REAL owner pid 30729
      - fault B (my tty rewritten to impostor pid 999001):
          HIT -> valve -> resolves REAL pid 30729, impostor rejected
      - `audit` clean, no new drift
    
    Complements 9696608 (per-process TTL 5s->120s, another session, TK-11466
    followup), which fixes the intra-run double scan; this fixes the cross-session
    herd. Both are needed.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_012LVUzeaBYQJsjSYSWtQZYy
---
 terminal_status.py | 134 +++++++++++++++++++++++++++++++++++++++++++++++++++--
 1 file changed, 131 insertions(+), 3 deletions(-)

diff --git a/terminal_status.py b/terminal_status.py
index 87cf3b1..55d0f40 100644
--- a/terminal_status.py
+++ b/terminal_status.py
@@ -166,13 +166,130 @@ class Process:
 _PROC_CACHE = {"at": 0.0, "rows": None}
 _PROC_TTL = float(os.environ.get("TERMINAL_STATUS_PROC_TTL", "120"))
 
+# TK-11398 (2026-09-11): DOCUMENTED DECISION REVERSAL -- the scope note above
+# says a cross-process file cache is "not worth the risk". Reversed here, by
+# claude-run-11255, with Steve's explicit approval, on this NEW MEASURED FACT:
+# concurrent scans do not cost a CONSTANT ~15s, they mutually slow each other.
+# Three consecutive `ps -axo pid,ppid,tty,lstart,comm` runs on this box (1,945
+# procs, 42 live claude sessions) measured 2.30s / 4.90s / 7.08s. With ~42
+# sessions each shelling out a fresh python3 -- so the per-PROCESS cache above
+# is dead on arrival every invocation -- the herd drives a single paint past the
+# 60s ps ceiling and NOTHING paints. That is strictly worse than the staleness
+# it was avoiding: the observed failure was a tab silently keeping a PREVIOUS
+# session's dot (advertising a finished, unrelated ticket) because the paint
+# no-oped. A silently wrong dot is the false-green class.
+#
+# The original objection is preserved exactly, not traded away: a table up to
+# TTL seconds stale could miss a just-started session and wrongly report "no
+# owning terminal". So a cached table is only ever trusted for a POSITIVE
+# answer. Every NEGATIVE ownership conclusion -- current_owner() finding no
+# owning runtime, or assert_owner() about to refuse a stale writer -- re-scans
+# fresh and re-decides before it is allowed to fail. Worst case a negative
+# costs one extra scan; it can never produce a wrong refusal.
+# 30s, not 5s: a scan on this box measures 15-70s under load, so a 5s window
+# expires before the next invocation can ever reach it -- the same trap the
+# per-PROCESS TTL note above describes. Staleness is bounded by the
+# negative-rescan valve, not by this number, so the window can afford to be
+# wide enough to actually catch the herd.
+_DISK_TTL = float(os.environ.get("TERMINAL_STATUS_DISK_TTL", "30"))
+
+
+def _disk_cache_path():
+    return Path(tempfile.gettempdir()) / f"terminal-status-proc-{os.getuid()}.json"
+
+
+def _disk_cache_get():
+    """Recent scan from a sibling invocation, or None. Never raises."""
+    try:
+        path = _disk_cache_path()
+        st = path.stat()
+        if st.st_uid != os.getuid():          # never trust another user's file
+            return None
+        if time.time() - st.st_mtime >= _DISK_TTL:
+            return None
+        payload = json.loads(path.read_text())
+        return {int(p[0]): Process(int(p[0]), int(p[1]), p[2], p[3], p[4])
+                for p in payload}
+    except (OSError, ValueError, TypeError, KeyError, IndexError):
+        return None
+
+
+def _disk_cache_put(rows):
+    """Publish this scan for sibling invocations. Best effort, never raises."""
+    try:
+        path = _disk_cache_path()
+        payload = [[p.pid, p.ppid, p.tty, p.started, p.command]
+                   for p in rows.values()]
+        fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".ts-proc-")
+        try:
+            with os.fdopen(fd, "w") as handle:
+                json.dump(payload, handle)
+            os.chmod(tmp, 0o600)
+            os.replace(tmp, path)             # atomic; readers see whole files
+        except BaseException:
+            os.unlink(tmp)
+            raise
+    except OSError:
+        pass
+
+
+def _splice_self_chain(rows):
+    """Make a CACHED table usable for ancestry resolution.
+
+    A cached table was written before THIS process existed, so
+    ancestors(os.getpid(), rows) dead-ends immediately and current_owner()
+    reports a false negative -- which then forces a full rescan on every call
+    and cancels the cache's entire benefit. Walk our own chain with targeted
+    single-pid ps calls (measured 0.01-0.10s each, O(depth ~8) not O(2000))
+    and splice in whatever the cached table is too old to know about.
+
+    Returns None if the chain cannot be resolved completely; the caller then
+    falls back to a full scan. A partial chain is never returned, so this can
+    only ever cost an extra scan -- never manufacture a wrong answer.
+    """
+    pid = os.getpid()
+    for _ in range(32):
+        if pid <= 1 or pid in rows:
+            return rows
+        try:
+            out = subprocess.run(
+                ["ps", "-o", "ppid=,tty=,lstart=,comm=", "-p", str(pid)],
+                capture_output=True, text=True, timeout=10,
+                env={**os.environ, "LC_ALL": "C"})
+        except (OSError, subprocess.TimeoutExpired):
+            return None
+        line = out.stdout.strip()
+        if out.returncode or not line:
+            return None
+        parts = line.split(None, 7)
+        if len(parts) != 8:
+            return None
+        try:
+            ppid = int(parts[0])
+        except ValueError:
+            return None
+        rows[pid] = Process(pid, ppid, parts[1], " ".join(parts[2:7]), parts[7])
+        pid = ppid
+    return None
+
 
 def processes(fresh=False):
     now = time.monotonic()
     if not fresh and _PROC_CACHE["rows"] is not None \
             and now - _PROC_CACHE["at"] < _PROC_TTL:
         return _PROC_CACHE["rows"]
-    rows = _scan_processes()
+    rows = None if fresh else _disk_cache_get()
+    if rows is not None:
+        rows = _splice_self_chain(rows)
+    # Testability seam (TK-11398): a cache whose hit-rate you cannot observe is
+    # a cache you cannot prove works. Explicit opt-in flag; never set by callers.
+    if os.environ.get("TERMINAL_STATUS_DEBUG"):
+        print("proc-cache: %s%s" % ("HIT" if rows is not None else "MISS -> scan",
+                                    " (fresh=True forced)" if fresh else ""),
+              file=sys.stderr)
+    if rows is None:
+        rows = _scan_processes()
+        _disk_cache_put(rows)
     _PROC_CACHE["at"] = time.monotonic()
     _PROC_CACHE["rows"] = rows
     return rows
@@ -229,7 +346,7 @@ def owners(rows):
     return {tty: group[0] for tty, group in candidates.items() if len(group) == 1}
 
 
-def current_owner(rows):
+def current_owner(rows, _retried=False):
     # Resolve ownership by ancestry FIRST. CLAUDE_CODE_CHILD_SESSION is set on
     # any tool-spawned shell (the Bash tool, hooks), including a legit top-level
     # session's own subshell, so it can't be the sole refusal signal — that made
@@ -243,9 +360,16 @@ def current_owner(rows):
             # Do not walk through a headless nested agent into its parent's tty.
             if live.get(p.tty) == p.owner():
                 return p.owner()
+            # TK-11398: never let a CACHED table produce a negative. A table up
+            # to _DISK_TTL old can miss a just-started session; re-scan and
+            # re-decide before refusing.
+            if not _retried:
+                return current_owner(processes(fresh=True), _retried=True)
             raise StatusError("Headless or nested agent; refusing parent-terminal write")
     # No owning runtime in ancestry at all: only now does the child-session flag
     # decide — a true background subagent has no terminal of its own.
+    if not _retried:
+        return current_owner(processes(fresh=True), _retried=True)
     if os.environ.get("CLAUDE_CODE_CHILD_SESSION"):
         raise StatusError("A subagent has no terminal of its own; refusing to paint")
     raise StatusError("No owning Claude/Codex terminal in this process ancestry")
@@ -343,7 +467,11 @@ class Store:
         # the scan from the critical section.
         rows = rows if rows is not None else self.process_provider()
         if owners(rows).get(owner.tty) != owner:
-            raise StatusError("Terminal owner changed; refusing stale writer")
+            # TK-11398: a cached table may simply be stale. Re-scan fresh and
+            # re-check before refusing a writer that is in fact still the owner.
+            rows = processes(fresh=True)
+            if owners(rows).get(owner.tty) != owner:
+                raise StatusError("Terminal owner changed; refusing stale writer")
 
     @contextlib.contextmanager
     def lock(self, owner, rows=None):

← 9696608 terminal-status: fix paint hang — raise per-process PROC_TTL  ·  back to Terminal Status  ·  fix: cache ps scan + fix 60s ceiling exit-code in terminal-s 1c16fa2 →