Skip to content

feat(session-manager): per-window last-activity time on the wire (#{window_activity}) - #1364

Merged
ZacxDev merged 2 commits into
mainfrom
zach/t516-window-activity
Sep 7, 2026
Merged

feat(session-manager): per-window last-activity time on the wire (#{window_activity})#1364
ZacxDev merged 2 commits into
mainfrom
zach/t516-window-activity

Conversation

@ZacxDev

@ZacxDev ZacxDev commented Sep 7, 2026

Copy link
Copy Markdown
Member

Producer half of clawgate task 516. The consumer half is ZacxDev/homelab-infra (containers/clawgate) — merge this one first; the consumer decode is permissive, so the two can land in either order without breaking a page, but the field only becomes observable once this ships.

What and why

clawgate's tmux page renders a card per window. Its "4m ago" came from Snapshot.ReceivedAt — how long ago clawgate last heard from the host — so all 45 cards under one host showed the same number and none of them said anything about its own window. The renderer was never the missing piece; the per-window datum was.

tmux already exposes #{window_activity} (epoch seconds, updated when the window's active pane produces output), and list-windows already runs once per host, so this costs no extra tmux invocation and no fifth ssh round trip — the same argument #{pid}/#{start_time} ride on.

  • WINDOW_FORMAT gains #{window_activity} before #{session_name} (session name must stay last or the bounded maxsplit stops absorbing pipes in a session name).
  • split_window_line returns it; parse_windows and parse_tmux_server_id are unchanged in contract.
  • New parse_window_activity{window_id: epoch_seconds}. Absent, never zero: an empty, non-numeric or non-positive value is omitted, because epoch 0 renders as "56 years ago" — a fabricated measurement in the costume of a real one.
  • fold_windows writes window_activity only when it has a value. It is the one optional row key and is documented apart from the row ledger in the module docstring.

Measured on the real fleet — not a fixture

scripts/session-manager --json from this branch, 2026-09-07, both hosts:

host rows carrying window_activity distinct times zero/negative
workbench 54 54 36 0
laptop 32 32 18 0
total 86 86 0

Distinct-times > 1 per host is the assertion that matters: windows on one host genuinely disagree, which is what the consumer needs.

Known limit, measured rather than assumed

12 of the laptop's 32 windows report 2021-01-01 — the same frozen pre-NTP RTC value that host's tmux #{start_time} carries (already documented above WINDOW_FORMAT). They are windows that have produced no output since their tmux server started, so tmux stamped them at the server's start instant, recorded under a clock that was wrong and does not move when NTP later corrects it. They render as ~49,000h.

Deliberately not clamped: the relative claim ("nothing has happened here since the server came up") is true, only the magnitude is wrong, and suppressing it would swap a visibly absurd number for a silent absence — the substitution this whole file refuses. A consumer wanting a ceiling has to choose one deliberately; there is no honest one to pick here. Recorded in the code at WINDOW_FORMAT and mirrored in the consumer's renderer comment.

RED → GREEN matrix

This commit's test file applied to a clean worktree of origin/main (969f0581) with the unchanged session-manager:

18 failed, 804 passed

At HEAD of this branch:

823 passed

The 18 red-at-base: the two format-contract tests, the eight parse_window_activity cases, five fold_windows cases, the end-to-end seam test, and the two ledger/golden guards that had to learn the new key.

Wider sweep, unchanged by this PR: test_agent_ledger, test_bar_status, test_claude_sessions, test_clawgate_predicate_single_source, test_session_manager_skill_size, test_standup_local_health, test_tmux_snapshot_push, test_skill_descriptions, test_drift_check1237 passed.

Mutation battery

Restore verified by sha256sum before/after; D0 unmutated control green first.

# mutation result
D1 parse_window_activity accepts a non-positive epoch KILLEDdrops_a_value_it_cannot_trust[0], [-1]
D2 fold_windows writes the key even when unmeasured KILLEDcarries_NO_KEY_not_a_zero
D3 move #{window_activity} after #{session_name} KILLEDfields_come_BEFORE_session_name
D4 never wire the parsed map into fold_windows KILLEDreaches_the_REPORT_per_window_end_to_end
D5 int(float(x)) instead of int(x) KILLEDdrops_a_value_it_cannot_trust[17.5], [1e9]
D6 drop the windows_measured gate on the activity parse SURVIVED — by design

Each kill was checked to fail with that test's own node id, not a neighbour's.

D6 is reported, not hidden. The same windows_measured flag already gates slot_ids two lines up, so on a failed list-windows every row's window_id is None and fold_windows looks nothing up regardless — the conditional is depth, not a defence anyone has watched work. That is now stated at the parse site rather than implied away, and the outcome is pinned by test_a_PARTIAL_list_windows_read_publishes_no_activity, which fails the call while still returning parseable rows so the refusal is a real decision rather than an empty-string artefact.

Not verified

  • No live clawgate page was driven from this branch's collector; the cross-repo seam was verified by feeding this branch's real --json capture through the consumer's real ingest + view code (86/86 windows decoded, 18/36 distinct times per host), not by watching the deployed pusher.
  • --lean (LEAN_ROW_FIELDS) deliberately does not carry the new key; that view is a separate, trimmed contract and widening it was out of scope.

tmux exposes #{window_activity} — epoch seconds, updated when a window's
active pane produces output. It rides along on the list-windows call that
already runs once per host, so it costs no extra tmux invocation and no
fifth ssh round trip.

The consumer half of this (clawgate's tmux page) has only ever had a
per-HOST timestamp, so every card under one host showed the same age and
none of them said anything about its own window. This is the producer
side of that fix.

WHAT CHANGED
  * WINDOW_FORMAT gains #{window_activity}, BEFORE #{session_name} — the
    session name must stay last or the bounded maxsplit stops absorbing
    pipes in a session name.
  * split_window_line returns the new field; parse_windows and
    parse_tmux_server_id read the same rows through it, unchanged in
    contract.
  * parse_window_activity: {window_id: epoch_seconds}. A value that is
    empty, non-numeric or non-positive is OMITTED, never carried as 0 —
    an epoch 0 renders as "56 years ago", a fabricated measurement in the
    costume of a real one.
  * fold_windows takes window_activity and writes the key ONLY when it
    has a value. This is the one optional row key, documented apart from
    the row ledger in the module docstring.
  * The collector parses it per host off the same list-windows output.

MEASURED ON THE REAL FLEET (2026-09-07, both hosts)
  86 windows total, 86 carrying the field (32/32 laptop, 54/54
  workbench), 0 zero-or-negative, 18 and 36 distinct times per host —
  i.e. windows on one host genuinely disagree, which is the whole point.

  KNOWN LIMIT, MEASURED NOT ASSUMED: 12 of the laptop's 32 report
  2021-01-01 — the same frozen pre-NTP RTC value that host's tmux
  #{start_time} carries. They are windows that have produced no output
  since their tmux server started, so tmux stamped them at the server's
  start instant under a wrong clock. Deliberately NOT clamped: the
  relative claim is true, only the magnitude is wrong, and suppressing it
  would swap a visibly absurd number for a silent absence.

TESTS — RED AT BASE, GREEN AT HEAD
  Applying this commit's test file to a clean worktree of origin/main
  (969f058) with the UNCHANGED session-manager: 18 failed, 804 passed.
  At HEAD: 823 passed. The 18 are the format-contract pair, the eight
  parse_window_activity cases, the five fold_windows cases and the two
  ledger/golden guards.

MUTATION BATTERY (restore verified by sha256; D0 unmutated control green)
  D1 accept a non-positive epoch          KILLED (drops_a_value[0], [-1])
  D2 write the key even when unmeasured   KILLED (carries_NO_KEY_not_a_zero)
  D3 move the field after session_name    KILLED (fields_come_BEFORE_session_name)
  D4 never wire the map into fold_windows KILLED (reaches_the_REPORT_end_to_end)
  D5 int(float(x)) instead of int(x)      KILLED (drops_a_value[17.5], [1e9])
  D6 drop the windows_measured gate on
     the activity parse                   SURVIVED — BY DESIGN, and recorded
       in the code at the parse site. The SAME flag already gates
       slot_ids, so on a failed list-windows every row's window_id is
       None and nothing is looked up regardless. The conditional is depth,
       not a defence anyone has watched work; the OUTCOME is pinned by
       test_a_PARTIAL_list_windows_read_publishes_no_activity, which
       fails the call while still returning parseable rows so the refusal
       is a real decision rather than an empty-string artefact.

Refs clawgate task 516.

Claude-Session-Id: 85a6e6ff-c760-4f4f-a177-0ae02612bc9d
…he spelling

CI caught this and my local sweep did not: `devrc-pytests` went red on
`test_tmux_reply_agent.py::test_the_server_id_format_matches_the_collectors`
(1 failed of 22019). It is MINE, not a flake.

ATTRIBUTION, not a re-run
  The guard asserted the collector's WINDOW_FORMAT *source line* as one
  exact string. The previous commit appended `#{window_activity}` to that
  format, so the substring stopped matching:

    assert 'WINDOW_FORMAT = "#{window_id}|#{window_index}|#{pid}|
            #{start_time}|#{session_name}"' in src
    AssertionError: the collector's window format changed; re-derive
                    TMUX_SERVER_ID_FORMAT from it

  It is deterministic, not contention: 3 for 3 failures against the pushed
  collector at 0.55s / 0.25s / 0.26s, and every other test in the same run
  is unmoved (the file's other 107 pass in ~54s, slowest call 3.04s). Load
  inflates every test in a run; an assertion failure inflates exactly one,
  and this one is not inflated at all. Nor is it in the known-flake family
  (test_mjs_parses[attachments.mjs] contention, test_subsystem_store_api
  fsync contention) — different file, different mechanism, and those are
  timing failures while this is a string comparison.

WHY THE GUARD, NOT THE FORMAT, IS WHAT CHANGES
  The seam it protects is real: `tmux-reply-agent` asks tmux for
  `#{pid}:#{start_time}` and compares the result against the
  `tmux_server_id` the collector stores, so two spellings of that token
  would refuse EVERY write and read as "the feature does not work".

  But it pinned a WHOLE LITERAL to protect a TWO-TOKEN relationship, so it
  failed on an addition it does not care about — `#{window_activity}` is a
  per-window activity time with nothing to do with the server id, and both
  tokens the guard exists for were still present, still server-level and
  still joined the same way. A guard that fails on changes it does not care
  about, and would pass a reordering it does, is pinned to a spelling.

  It now reads the collector's LOADED VALUE and asserts the relationship:
  both tokens present, `#{pid}` BEFORE `#{start_time}` (the id is built
  pid-first, so a transposition must fail), the joiner still
  `f"{pid}:{started}"`, and the agent's format built from tokens the
  collector's format actually carries.

MUTATION-VERIFIED — the rewrite is stronger, not weaker
  N0 unmutated control                                    PASS
  N1 collector drops #{start_time}                        KILLED
  N2 collector transposes pid and start_time              KILLED (new —
       the old literal-substring guard could not see a reordering it
       still spelled correctly)
  N3 collector stops joining as <pid>:<start_time>        KILLED
  N4 the agent asks tmux for a different shape            KILLED
  N5 an UNRELATED field is added to WINDOW_FORMAT         PASSES — the
       regression this rewrite exists for; the old guard failed here.
  Each kill checked against that assertion's own message. Restore of both
  mutated scripts verified by sha256.

scripts/tests/test_tmux_reply_agent.py 108 passed;
scripts/tests/test_session_manager.py 824 passed.

Claude-Session-Id: 85a6e6ff-c760-4f4f-a177-0ae02612bc9d
@ZacxDev
ZacxDev merged commit 112a522 into main Sep 7, 2026
2 checks passed
@ZacxDev
ZacxDev deleted the zach/t516-window-activity branch September 7, 2026 23:42
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