π fix(relay): stop reading tmux client_activity as "a human typed" (#5685) - #5700
Conversation
An attached-but-unused terminal tab permanently blocked API-error recovery.
When an agent stops on a retryable API error the relay recovers by typing
`try again` into its pane, and it correctly refuses to do that while somebody is
sitting there. It decided somebody was there by asking tmux for
`#{client_activity}`.
That field does not mean "a human typed". It means "this client sent us bytes",
and a terminal emulator sends bytes for reasons that have nothing to do with a
person: replies to the capability, colour and cursor-position queries the running
application writes, plus mouse and focus reports where those are enabled. A
Claude Code TUI issues such queries on its own schedule, so an attached but
UNUSED tab keeps client_activity advancing indefinitely.
That is the most ordinary setup there is. `just contribute-hive <backend> local`
prints an attach hint, contributors leave the session attached in a tab to watch
it, and from then on every retryable API error parked until a person noticed.
Observed and confirmed causally (hivecommons#5685): four consecutive deferrals over six
minutes, each recomputing a fresh "last input" age from gaps of ~3 and ~4.5
minutes β which is neither human typing nor a fixed poll β with `mouse` off and
`focus-events` off, released the instant `tmux detach-client` ran. Same relay,
same task, same unresolved error, no keystrokes.
Two changes, deliberately independent:
1. client_activity becomes a NECESSARY condition rather than the whole answer,
corroborated against whether the pane actually changed since the previous
check. A keystroke draws something; a terminal answering a query does not, so
an unchanged pane is positive evidence that the bytes tmux counted were the
terminal talking. The converse is weaker β a pane can change because the CLI
redrew β so that direction stays cautious and is bounded by (2) rather than
by being made cleverer.
2. Deferral is capped at HUMAN_PRESENCE_MAX_DEFERRALS ticks (3, ~6 minutes)
whatever presence says. This is the second time the signal has been wrong in
the same direction β hivecommons#5277 established that "attached" is not "present", and
the fix for it replaced one proxy with a slightly better proxy. A task parked
forever on a signal the relay cannot verify is worse than one `try again`
landing next to a human, who can see it and say so. The cap bounds the next
wrong proxy, not just this one.
The presence fingerprint is kept separate from lastPaneFingerprint, which the
stall backstop consumes destructively; sharing it would make the two detectors
eat each other's evidence. Both it and the deferral budget are per task.
The log line no longer says `someone is active β¦ last input Ns ago`. That
wording stated the assumption as fact, and it is what made this hard to see: the
message asserted a human had typed, so the natural reading was to doubt the
contributor rather than the field. It now reports the evidence.
Protection for someone genuinely at the pane is unchanged, and pinned: making
the relay never defer fails nine tests, including the pre-existing hivecommons#5094 and
hivecommons#5277 human-protection ones.
The hivecommons#5277 budget test moves from a CONSTANT pane to one that changes each tick.
Its property β refusing to type must not spend a retry the agent never got β is
unchanged; its old fixture is now known to describe an attached-but-unused
terminal rather than a person, which is exactly the case this fixes.
Refs hivecommons#5685
Signed-off-by: Doug Baggett <doug.baggett@gmail.com>
The relay stands its recovery actions down while the pane looks in use, so how you watch it matters β and attaching is not the same as looking. contributor-relay.md's "What happens on a task" said "attach to it to watch", which is the sentence that steers contributors straight into hivecommons#5685. It now points at a table of the three ways to watch and what each one costs: - `tmux capture-pane -p` registers no client and blocks nothing. - `tmux attach` registers one. - The dashboard's browser terminal (`/terminal/?arg=β¦`) also registers one β it proxies to a real `tmux attach-session`. It is NOT a read-only viewer despite feeling like one, and that is worth stating plainly because it is the path the docs otherwise steer operators toward. It also records the two bounds from the fix, so a contributor who leaves a tab attached knows what does and does not happen: the pane-change corroboration, and the HIVE_HUMAN_PRESENCE_MAX_DEFERRALS cap. Refs hivecommons#5685 Signed-off-by: Doug Baggett <doug.baggett@gmail.com>
|
[APPROVALNOTIFIER] This PR is NOT APPROVED This pull-request has been approved by: The full list of commands accepted by this bot can be found here. DetailsNeeds approval from an approver in each of these files:Approvers can indicate their approval by writing |
clubanderson
left a comment
There was a problem hiding this comment.
Reviewed the relay changes against #5685. The patch no longer treats tmux client_activity alone as human input: it corroborates with pane changes, resets the evidence per task, and caps deferrals so recovery cannot park forever. Validation: cd src && go build ./... passed; node --test bin/contributor-relay.test.js passed (261/261). Full go test ./... hit the existing Darwin /private/var vs /var cmd/bd path expectation, unrelated to this PR.
|
LGTM label has been added. DetailsGit tree hash: fcce9d5e6af71da91fdb4fd50af466f8bbf36eaa |
|
Branch targeting check β this PR is based on v5, but the defect it fixes is live on v4, which is the repository's default branch and what deployed hives and contributor relays actually run. Evidence:
Same applies to #5688 (merged to v5, fixing #5681): Not asking for a particular resolution β retarget, cherry-pick, or 'v5 is where this belongs and v4 gets it at the merge' are all reasonable and the maintainers know which. Flagging only because both fixes are for defects observed on v4 and currently land where the affected code isn't. |
A contributor relay works one issue at a time out of a single persistent checkout, and nothing resets that checkout between tasks. The task prompt told the agent to fork, clone, commit, push and open a PR, but mentioned a branch exactly once β "push your branch to your fork remote" β and never said which branch to start from or target. The base was therefore whatever the previous task happened to leave checked out. With two active branches that is enough for one branch-specific issue to redirect every later PR of a session. On 2026-09-02 issue #5617, titled "[v5] reviewer lane follow-ups β¦", correctly put the checkout on v5; the four PRs after it (#5688, #5700, #5705, #5711) inherited v5, and three of them were fixes for defects live on the deployed v4. Branch ancestry confirms inheritance rather than choice β each is 1β2 commits ahead of v5 and 64β67 ahead of v4, with no base_ref_changed event on any of them. The cost was real: #5688 and the scanner's independent #5693 fixed the same defect on two branches, a maintainer had to adjudicate between them, and the v4 fixes were backported by hand hours later. The failure is invisible from every seat. The agent has nothing to check against, the contributor sees PRs opening and merging normally, and a maintainer sees correctly-formed PRs on a plausible branch. buildTaskPromptBody now names the base and tells the agent to start its work branch from it ('git checkout -b <branch> upstream/<base>'), open the PR with 'gh pr create --base <base>', and confirm the PR's base before reporting done. The base comes from taskBaseBranch: the branch this hive was built from β the same upstreamBranch() the onboarding page's clone command already names (#3990), so the two answers agree β unless the issue title carries a release-line tag such as "[v5]", which wins. The tag shape is narrow on purpose ('v' plus digits, the shape image_pulls.go matches and .github/release-lines.yml lists), so the lane prefixes the classifier routes on ("[quality]", "[architect]") cannot be read as branches. The prompt is the load-bearing half. Fixing only the workspace was measured and found insufficient: a working branch reset from v5 onto v4 mid-task, holding zero commits and a clean tree, was restored to v5 by the agent, because the plan it had already formed said v5. An agent follows what it was told over what it finds, so the instruction has to carry the answer. When no base resolves at all, the prompt still refuses inheritance and names the substitute β the upstream repository's own default branch β rather than falling silent. Not included, and deliberately: resetting the workspace to the base before each task. bin/contributor-relay.sh performs no git operations today (it sets a cwd and types a prompt), so that defence in depth means a new protocol field plus a 'git reset --hard' against a contributor's persistent checkout β a destructive new surface that deserves its own change and its own observation, not a rider on this one. Regression coverage in contribute_task_base_branch_test.go asserts the prompt names a base, that a task following a branch-specific one is told this hive's branch rather than the previous task's, that the base is derived rather than re-hardcoded, that an uninjected build branch falls back instead of emitting "unknown", that an unresolvable base still forbids inheritance, and that lane prefixes are not mistaken for release lines. Fixes #5729 Signed-off-by: Danathar <doug.baggett@gmail.com>
Summary
An attached-but-unused terminal tab permanently blocked automatic API-error recovery.
When an agent stops on a retryable API error the relay recovers by typing
try againinto its pane, and it correctly refuses to do that while somebody is sitting there. It decided somebody was there by asking tmux for#{client_activity}.That field does not mean "a human typed." It means "this client sent us bytes", and a terminal emulator sends bytes for reasons that have nothing to do with a person: replies to the capability, colour and cursor-position queries the running application writes, plus mouse and focus reports where enabled. A Claude Code TUI issues such queries on its own schedule, so an attached but unused tab keeps
client_activityadvancing indefinitely.That is the most ordinary setup there is β
just contribute-hive <backend> localprints an attach hint, contributors leave the session attached in a tab to watch it β so every retryable API error parked until a person noticed and bumped it by hand. Which is precisely the unattended-operation property this recovery path exists to provide.Two changes, deliberately independent
1. Corroborate the signal with an observation.
client_activitybecomes a necessary condition rather than the whole answer, checked against whether the pane actually changed since the previous tick.So an unchanged pane is positive evidence that the bytes tmux counted were the terminal talking, not a person. The converse is weaker β a pane can change because the CLI redrew something of its own β so that direction stays cautious and is bounded by (2) rather than by being made cleverer.
2. Cap the deferral. Whatever presence says, stop deferring after
HIVE_HUMAN_PRESENCE_MAX_DEFERRALSticks (3, ~6 minutes).This is the part worth arguing for on its own. This signal has now been wrong twice in the same direction: #5277 established that "attached" is not "present", and its fix replaced one proxy with a slightly better proxy. A task parked forever on a signal the relay cannot verify is worse than one
try againlanding next to a human, who can see it and say so. The cap bounds the next wrong proxy, not just this one.Together these are options (1) and (4) from the issue, which is what it recommends.
Evidence this is the cause
From the issue, confirmed causally by the reporter β four consecutive deferrals over six minutes with nobody typing,
mouseoff andfocus-eventsoff, released the instanttmux detach-clientran (same relay, same task, same unresolved error, no keystrokes):Gaps of ~3 and ~4.5 minutes: neither human typing nor a fixed poll.
The log line
It no longer says
someone is active β¦ last input Ns ago. That wording stated the assumption as fact, and the issue is right that it is what made this hard to see β the message asserted a human had typed, so the natural reading was to doubt the contributor rather than the field. It now reports the evidence and which branch was taken.Cluster claimed (files/functions)
bin/contributor-relay.shβHUMAN_PRESENCE_MAX_DEFERRALS;paneEditedSincePresenceCheck+ its per-task state andresetHumanPresenceEvidence(wired intoresetTransientNudgeState); the branch rework and log wording inhandleTransientAPIError; four test exports.bin/contributor-relay.test.jsβ a#5685block (5 tests) plus one fixture change, below.src/docs/contributor-relay.md,CHANGELOG.md.tmuxSessionHumanPresenceis unchanged β it is the raw tmux reading, and #5277's tests read it directly. The corroboration sits at the consumer, where the decision is actually made.paneHasPresentHuman()(the #5281 autonomy-nudge seam) is deliberately not touched. It is still the stricter bare-attached check, and its comment says #5277 was meant to update it β a real loose end, but a different nudge with a different budget, and not this bug.One existing test changed, and why
#5277 a suppressed nudge still consumes no retry budgetheld the pane constant while asserting a human was present. That fixture is now known to describe an attached-but-unused terminal β it is the bug β so it moves to a pane that changes each tick, which is what someone composing looks like. Its assertions are unchanged: refusing to type must not spend a retry the agent never got. This is the "today's tests can only exercise the proxy, which is why this passed" line in the issue, made concrete.Validation
node --check(via CI's exact copy-to-.jsstep),node bin/contributor-relay.test.js, andbash bin/contributor-agent.test.shβ the whole "Contributor Relay Syntax + Tests" CI job β all pass locally. 261/261, up from 256.src/scripts/check-docs-links.py: 130 files, all links and anchors resolve.Mutation-checked, three ways, so neither mechanism passes vacuously and the guard is proven to still guard:
paneEditedtrue (revert to the pureclient_activityproxy)#5685 an attached-but-unused terminal no longer blocks recoveryfails#5685 no presence signal can park a task forever(+ its per-task sibling) fails#5094 with a human attached β¦and four#5277testsNote for reviewers running the suite locally:
HIVE_AGENT_CWDin the environment fails one unrelated pre-existing test (a relaunch cds somewhere resolvable).env -u HIVE_AGENT_CWD node bin/contributor-relay.test.jsis clean onv5before this change too.Docs
The issue asks for one thing to be stated in the fix, and it is: the dashboard's
/terminal/?arg=β¦proxy is not a read-only viewer. It attaches a real client and closes the gate exactly as a localtmux attachdoes, which matters because that is the path the docs otherwise steer operators toward.contributor-relay.md's "attach to it to watch" β the sentence that leads straight into this bug β now points at a table of the three ways to watch and what each costs, withtmux capture-pane -pas the one that registers no client.Not claimed
Options (2) and (3) from the issue. (2), a longer quiet window, is tuning a proxy that is wrong in kind and would still misfire on a terminal that polls more often. (3),
HIVE_ASSUME_UNATTENDED=1, is an escape hatch that puts the burden on the person least placed to know the mechanism β and (1)+(4) make it unnecessary.Related issues
Refs #5685. Related: #5277 (the same lesson one level up), #5094 and #5121 (the recovery this gate disabled), #5281 (the other nudge, untouched), #5654.
Testing
node --check+node bin/contributor-relay.test.js+bash bin/contributor-agent.test.shβ the full relay CI job; 261/261.β hive: backend=claude model=claude-opus-5