Skip to content

πŸ› fix(relay): stop reading tmux client_activity as "a human typed" (#5685) - #5700

Merged
clubanderson merged 2 commits into
hivecommons:v5from
Danathar:fix/5685-human-presence-input-line
Sep 2, 2026
Merged

πŸ› fix(relay): stop reading tmux client_activity as "a human typed" (#5685)#5700
clubanderson merged 2 commits into
hivecommons:v5from
Danathar:fix/5685-human-presence-input-line

Conversation

@Danathar

@Danathar Danathar commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

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 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 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 β€” 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_activity becomes a necessary condition rather than the whole answer, checked against whether the pane actually changed since the previous tick.

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, 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_DEFERRALS ticks (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 again landing 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, mouse off and focus-events off, released the instant tmux detach-client ran (same relay, same task, same unresolved error, no keystrokes):

[11:07:47] … someone is active on hive-claude-7645 (last input 136s ago), so not typing a retry
[11:09:47] … someone is active on hive-claude-7645 (last input  75s ago), so not typing a retry
[11:11:47] … someone is active on hive-claude-7645 (last input 195s ago), so not typing a retry
[11:13:47] … someone is active on hive-claude-7645 (last input  45s ago), so not typing a retry
           ← tmux detach-client
[11:15:47] Transient API error on ct-…-5681-… β€” sending retry 1/3

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 and resetHumanPresenceEvidence (wired into resetTransientNudgeState); the branch rework and log wording in handleTransientAPIError; four test exports.
  • bin/contributor-relay.test.js β€” a #5685 block (5 tests) plus one fixture change, below.
  • src/docs/contributor-relay.md, CHANGELOG.md.

tmuxSessionHumanPresence is 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 budget held 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-.js step), node bin/contributor-relay.test.js, and bash 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:

    Mutation Result
    Force paneEdited true (revert to the pure client_activity proxy) only #5685 an attached-but-unused terminal no longer blocks recovery fails
    Remove the deferral cap only #5685 no presence signal can park a task forever (+ its per-task sibling) fails
    Never defer at all (the over-correction) 9 tests fail, including the pre-existing #5094 with a human attached … and four #5277 tests
  • Note for reviewers running the suite locally: HIVE_AGENT_CWD in the environment fails one unrelated pre-existing test (a relaunch cds somewhere resolvable). env -u HIVE_AGENT_CWD node bin/contributor-relay.test.js is clean on v5 before 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 local tmux attach does, 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, with tmux capture-pane -p as 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.
  • Go build/test not applicable β€” no Go changed.

β€” hive: backend=claude model=claude-opus-5

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>
@kubestellar-prow kubestellar-prow Bot added the dco-signoff: yes Indicates the PR's author has signed the DCO. label Sep 2, 2026
@kubestellar-prow

Copy link
Copy Markdown
Contributor

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by:
Once this PR has been reviewed and has the lgtm label, please assign hanthor for approval. For more information see the Code Review Process.

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@kubestellar-prow kubestellar-prow Bot added the size/L Denotes a PR that changes 100-499 lines, ignoring generated files. label Sep 2, 2026

@clubanderson clubanderson left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@kubestellar-prow kubestellar-prow Bot added the lgtm Indicates that a PR is ready to be merged. label Sep 2, 2026
@kubestellar-prow

Copy link
Copy Markdown
Contributor

LGTM label has been added.

DetailsGit tree hash: fcce9d5e6af71da91fdb4fd50af466f8bbf36eaa

@Danathar

Danathar commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

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): loadLeases is present on v5 and absent on v4, and the v4-deployed hub lost an in-flight contributor task to a restart again today at 16:38Z for exactly that reason.

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.

@clubanderson
clubanderson merged commit d18d039 into hivecommons:v5 Sep 2, 2026
42 of 43 checks passed
clubanderson pushed a commit that referenced this pull request Sep 3, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dco-signoff: yes Indicates the PR's author has signed the DCO. lgtm Indicates that a PR is ready to be merged. size/L Denotes a PR that changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants