feat(session-manager): per-window last-activity time on the wire (#{window_activity}) - #1364
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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), andlist-windowsalready 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_FORMATgains#{window_activity}before#{session_name}(session name must stay last or the bounded maxsplit stops absorbing pipes in a session name).split_window_linereturns it;parse_windowsandparse_tmux_server_idare unchanged in contract.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_windowswriteswindow_activityonly 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 --jsonfrom this branch, 2026-09-07, both hosts:window_activityDistinct-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 aboveWINDOW_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_FORMATand 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 unchangedsession-manager:At HEAD of this branch:
The 18 red-at-base: the two format-contract tests, the eight
parse_window_activitycases, fivefold_windowscases, 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_check— 1237 passed.Mutation battery
Restore verified by
sha256sumbefore/after;D0unmutated control green first.parse_window_activityaccepts a non-positive epochdrops_a_value_it_cannot_trust[0],[-1]fold_windowswrites the key even when unmeasuredcarries_NO_KEY_not_a_zero#{window_activity}after#{session_name}fields_come_BEFORE_session_namefold_windowsreaches_the_REPORT_per_window_end_to_endint(float(x))instead ofint(x)drops_a_value_it_cannot_trust[17.5],[1e9]windows_measuredgate on the activity parseEach kill was checked to fail with that test's own node id, not a neighbour's.
D6 is reported, not hidden. The same
windows_measuredflag already gatesslot_idstwo lines up, so on a failedlist-windowsevery row'swindow_idisNoneandfold_windowslooks 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 bytest_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
--jsoncapture 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.