diff --git a/CHANGELOG.md b/CHANGELOG.md index e397c003c..830d0f3cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ Hive did not historically maintain a complete changelog. This file starts a prag ### Fixed +- An attached-but-unused terminal tab no longer permanently blocks automatic API-error recovery ([#5685](https://github.com/kubestellar/hive/issues/5685)). 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 — but it decided "somebody is 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 on its own: 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 kept `client_activity` advancing indefinitely — and the most ordinary setup there is (`just contribute-hive local` prints an attach hint, contributors leave the session attached in a tab to watch it) parked every retryable API error until a person noticed and bumped it by hand. Confirmed causally on 2026-09-02: four consecutive deferrals over six minutes with nobody typing and both mouse reporting and focus-events off, released the instant `tmux detach-client` ran — same relay, same task, same unresolved error, no keystrokes. `client_activity` is now a necessary condition rather than the whole answer, corroborated against whether the pane actually **changed** since the previous check: a keystroke draws something, a query reply does not, so an unchanged pane is positive evidence that the bytes tmux counted were the terminal talking. Independently, deferral is now capped at `HIVE_HUMAN_PRESENCE_MAX_DEFERRALS` ticks (3, ~6 minutes) whatever the presence signal says — this is the second time in a row this signal has been wrong in the same direction (after [#5277](https://github.com/kubestellar/hive/issues/5277), "attached" is not "present"), so the fix bounds the damage of the next wrong proxy rather than only sharpening the current one. Protection for a person genuinely at the pane is unchanged: a pane that changes while a client reports input still stands the watchdog down. The log line also no longer says `someone is active … last input Ns ago`, which stated the assumption as fact and is what made this hard to see. `src/docs/contributor-relay.md` now documents that `tmux capture-pane -p` watches without registering a client, while `tmux attach` **and the dashboard's browser terminal** both attach a real one. + - The reviewer lane's escalated-PR work list is now ordered by each PR's real creation time instead of a PR-number proxy ([#5617](https://github.com/kubestellar/hive/issues/5617)). The list is capped at three PRs per kick and documented "oldest first", but its rows carried no age signal at all — `ci-failing.json` recorded no creation time, so ordering fell back to (repo name, PR number). PR numbers are monotonic only *within* a repo, so that proxy sorted by repo **name** first, and against the per-kick cap the result was starvation rather than mere cosmetic disorder: on a multi-repo hive, a month-old escalated PR in a late-alphabet repo sat behind newer ones from an early-alphabet repo on every kick, indefinitely. The creation time was already fetched during PR enumeration and simply never threaded into the work-list artifact; it now is, and each row renders an `opened:` line so the reviewer can confirm the ordering without running `gh pr list` (which the kick's invariants forbid). Rows from an older hub that recorded no creation time keep the previous (repo, number) proxy among themselves and sort after every row whose age is known, so a stale artifact degrades to the old behavior rather than being reshuffled. - Hold-gated PRs can no longer silently carry unreviewed commits into a merge ([#5589](https://github.com/kubestellar/hive/issues/5589)). A PR sitting under a hold label could accumulate commits from other authors or agents (typically a worktree cut from a contaminated base), and once the hold lifted the merge lanes treated it like any other approved PR — the diff a human saw under the hold was not the diff that merged. The governor now snapshots each hold-gated PR's head SHA plus commit/author sets into a PVC ledger (`/data/metrics/hold-guard.json`) and compares at lift time: an unchanged head clears silently, while any drift keeps the PR out of `merge-eligible.json` and `ci-failing.json`, posts a one-time evidence comment naming the unreviewed commits and authors (plain text, never @-mentions), re-applies the `hold` label, and re-pins the snapshot to the drifted head so removing the re-applied hold after reading the evidence is the fresh approval. Both auto-merge sweeps additionally now respect hold and `do-not-merge` labels directly — the self-authored sweep previously listed PRs independently of the enumeration hold gate and would squash a hold-labelled App PR on green. from [#5480](https://github.com/kubestellar/hive/issues/5480): an App-bot comment review submitted through `hive-review` and attributed as `agent_pr_reviewed`, plus an advisory bead included in the advisory digest. `hive-review` now prints its asynchronous result path, and the kick requires a confirmed `ok` result plus the bead before removing `needs-human`, applying a terminal reviewer label, or closing; if either write fails, the PR remains in the human queue for a later retry instead of becoming silently adjudicated. diff --git a/bin/contributor-relay.sh b/bin/contributor-relay.sh index 552d315fd..6e274d5ba 100755 --- a/bin/contributor-relay.sh +++ b/bin/contributor-relay.sh @@ -1989,6 +1989,23 @@ function paneShowsLoginRequiredError(text) { // strand it replaces. const HUMAN_PRESENCE_IDLE_MS = Number(process.env.HIVE_HUMAN_PRESENCE_IDLE_MS) || 5 * 60 * 1000; +// HUMAN_PRESENCE_MAX_DEFERRALS bounds how many consecutive progress ticks a +// presence reading may park a retryable API error before the relay retries +// anyway (kubestellar/hive#5685). +// +// It exists because presence is the one input here the relay cannot verify. If +// the signal is wrong — and #5685 is the second time it has been, after #5277 — +// a task stalls until a person happens to notice, which is precisely the +// unattended-operation property this recovery path exists to provide. A task +// parked forever on an unverifiable signal is worse than one `try again` +// landing next to a human, who can see it and say so. +// +// Three ticks is ~6 minutes at PROGRESS_REPORT_INTERVAL_MS — long enough that +// somebody genuinely mid-keystroke finishes their thought, short enough that a +// misread does not consume the task's whole 30-minute ceiling. The budget is +// per task, reset with the rest of the transient-error state. +const HUMAN_PRESENCE_MAX_DEFERRALS = Number(process.env.HIVE_HUMAN_PRESENCE_MAX_DEFERRALS) || 3; + // tmuxSessionHumanPresence reports whether a human is at the agent's tmux // session, and how confident that answer is. // @@ -2037,6 +2054,68 @@ function tmuxSessionHumanPresence() { } } +// lastPresencePaneFingerprint is the pane as it looked the last time a presence +// question was asked, and presenceDeferralCount how many consecutive ticks that +// answer has parked a retry. Both are scoped to the CURRENT task and reset with +// the rest of the transient-error state (kubestellar/hive#5685). +// +// The fingerprint is deliberately SEPARATE from lastPaneFingerprint, which the +// stall backstop owns. That one is consumed destructively — the first read that +// sees new output records it and reports no change to the next reader — so +// sharing it would make the two detectors eat each other's evidence. See the +// note on paneChangedSince for the same hazard in the other direction. +let lastPresencePaneFingerprint = null; +let presenceDeferralCount = 0; + +function resetHumanPresenceEvidence() { + lastPresencePaneFingerprint = null; + presenceDeferralCount = 0; +} + +// paneEditedSincePresenceCheck records the pane and reports whether it differs +// from the previous recording — the observation that decides #5685. +// +// A KEYSTROKE CHANGES THE SCREEN. A TERMINAL'S AUTOMATIC REPLIES DO NOT. +// +// tmux's `client_activity` advances whenever a client sends 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 — and +// every retryable API error parked until someone noticed. Confirmed causally +// (#5685): four consecutive deferrals with nobody typing, each recomputing a +// fresh "last input" age from gaps of 3 and 4.5 minutes, released the instant +// `tmux detach-client` ran, with mouse reporting and focus-events both off. +// +// So client_activity cannot decide this alone. It is kept as a NECESSARY +// condition — no bytes at all means nobody typed — and corroborated here with +// the thing that actually distinguishes the two cases. 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 something of +// its own. That direction stays cautious (it defers) and is bounded by +// HUMAN_PRESENCE_MAX_DEFERRALS rather than by being made cleverer — the lesson +// of #5277 and #5685 together is that replacing one proxy with a slightly +// better proxy buys one release, so the second mechanism is a cap, not a +// sharper guess. +// +// DESTRUCTIVE, and called exactly once per tick from handleTransientAPIError. +function paneEditedSincePresenceCheck(tmuxLines) { + const fingerprint = Array.isArray(tmuxLines) ? tmuxLines.join('\n') : String(tmuxLines || ''); + // An empty capture means tmux told us nothing, not that the pane is quiet. + // Unknown resolves to "someone is there", the same rule every other uncertain + // presence answer in this file follows. + if (!fingerprint) return true; + const previous = lastPresencePaneFingerprint; + lastPresencePaneFingerprint = fingerprint; + // First reading of this task: there is nothing to compare against, so the + // pane cannot yet be called quiet. Costs at most one deferred tick, which the + // cap bounds anyway. + if (previous === null) return true; + return fingerprint !== previous; +} + // tmuxSessionHasAttachedClient reports only whether a client is CONNECTED. It // deliberately says nothing about whether a person is there — see // tmuxSessionHumanPresence for the question callers actually want. Kept because @@ -2574,6 +2653,11 @@ let lastTransientNudgeAt = 0; function resetTransientNudgeState() { transientNudgeCount = 0; lastTransientNudgeAt = 0; + // #5685: the presence evidence is scoped to the task too. A previous task's + // final pane is not a baseline for this one — comparing against it would read + // the first tick of new work as "someone edited the pane" — and a budget + // spent deferring one task must not deny the next its retries. + resetHumanPresenceEvidence(); } // Autonomy-nudge state (kubestellar/hive#5281), scoped to the CURRENT task. @@ -2876,18 +2960,39 @@ function handleTransientAPIError(tmuxLines) { }; // A human AT the pane owns it, and a watchdog must never type over someone - // mid-keystroke. But presence is a recency question, not a connection one - // (#5277): a dashboard terminal tab left open is a connected client and not a - // person, and treating the two alike disabled recovery entirely for as long - // as the tab lived. An attached-but-quiet client falls through to the retry - // below; only a recently active one still takes this branch. + // mid-keystroke. Deciding whether one is there has now been wrong twice in + // the same direction, each time by trusting a proxy: + // + // #5277 — "a client is attached" is not "a human is here". A dashboard tab + // left open is a connected client and not a person. + // #5685 — "this client sent bytes" is not "a human typed". An attached but + // UNUSED terminal answers the queries the CLI writes, so + // client_activity advances on its own, forever. + // + // So two signals must now agree before a retry is withheld: tmux saw input + // recently (necessary — no bytes, nobody typed) AND the pane actually changed + // since the last time we looked (what tells a keystroke from a terminal + // reply, because only one of those draws anything). And whatever they say, + // the deferral is capped: an unverifiable signal must not be able to park a + // task indefinitely. const presence = tmuxSessionHumanPresence(); - if (presence.active) { + const paneEdited = paneEditedSincePresenceCheck(tmuxLines); + const deferralsLeft = presenceDeferralCount < HUMAN_PRESENCE_MAX_DEFERRALS; + + if (presence.active && paneEdited && deferralsLeft) { + presenceDeferralCount++; + // Report the EVIDENCE, not a conclusion drawn from it. The old wording said + // "someone is active … last input Ns ago", which stated the assumption as + // fact — and that is what made #5685 hard to see: the message asserted a + // human had typed, so the natural reading was to doubt the contributor + // rather than the field. const since = presence.idleMs === null - ? 'activity unknown' - : `last input ${Math.round(presence.idleMs / 1000)}s ago`; + ? 'client activity unknown' + : `client sent input ${Math.round(presence.idleMs / 1000)}s ago`; console.warn(`Task ${currentTask.task_id} stopped on a retryable API error; ` + - `someone is active on ${TMUX_SESSION} (${since}), so not typing a retry`); + `${TMUX_SESSION} looks in use (${since}, and the pane changed since the ` + + `last check), so not typing a retry ` + + `(${presenceDeferralCount}/${HUMAN_PRESENCE_MAX_DEFERRALS} deferrals)`); send({ ...progressBase, status: 'blocked_on_human', @@ -2897,9 +3002,23 @@ function handleTransientAPIError(tmuxLines) { }); return; } - if (presence.attached) { + + if (presence.active && !paneEdited) { + // The #5685 case: tmux counted bytes, but nothing was drawn. A person + // composing at the pane changes it; a terminal answering a capability query + // does not. + console.warn(`Task ${currentTask.task_id} stopped on a retryable API error; ` + + `${TMUX_SESSION} reported client input but the pane is unchanged — that is ` + + `the terminal answering the CLI's queries, not someone typing, so ` + + `proceeding with the retry`); + } else if (presence.active && !deferralsLeft) { + console.warn(`Task ${currentTask.task_id} stopped on a retryable API error; ` + + `${TMUX_SESSION} still looks in use, but ${HUMAN_PRESENCE_MAX_DEFERRALS} ` + + `deferrals is the cap — retrying rather than parking the task on a signal ` + + `we cannot verify`); + } else if (presence.attached) { console.warn(`Task ${currentTask.task_id} stopped on a retryable API error; ` + - `a client is attached to ${TMUX_SESSION} but has been idle ` + + `a client is attached to ${TMUX_SESSION} but has sent no input for ` + `${Math.round(presence.idleMs / 1000)}s, so proceeding with the retry`); } @@ -3730,6 +3849,10 @@ if (process.env.HIVE_RELAY_TEST_MODE === '1') { tmuxSessionHasAttachedClient, tmuxSessionHumanPresence, HUMAN_PRESENCE_IDLE_MS, + HUMAN_PRESENCE_MAX_DEFERRALS, + paneEditedSincePresenceCheck, + resetHumanPresenceEvidence, + getPresenceDeferralCount: () => presenceDeferralCount, TRANSIENT_API_ERROR_MAX_NUDGES, TRANSIENT_API_ERROR_NUDGE_MESSAGE, getTransientNudgeCount: () => transientNudgeCount, diff --git a/bin/contributor-relay.test.js b/bin/contributor-relay.test.js index 41b26edae..c0633d998 100644 --- a/bin/contributor-relay.test.js +++ b/bin/contributor-relay.test.js @@ -4420,14 +4420,25 @@ test('#5277 the newest client decides — one active client protects the pane', }); test('#5277 a suppressed nudge still consumes no retry budget', () => { + // The pane CHANGES on every tick here, which is what a person composing at it + // looks like. It used to be a constant — but #5685 established that a + // constant pane with advancing client_activity is an attached-but-unused + // terminal answering the CLI's queries, not a human, and that case now + // correctly retries. The property this test exists for is unchanged: while + // the relay is refusing to type, it must not spend a retry the agent never + // got. Ticked to the deferral cap, since past it the relay retries by design + // (see the #5685 block below). + let typed = ''; const relay = loadRelay({ - backend: 'claude', paneText: CLAUDE_API_ERROR_PANE, + backend: 'claude', + paneText: () => CLAUDE_API_ERROR_PANE.replace('\u276f ', `\u276f ${typed}`), attachedClients: true, attachedIdleMs: 10 * 1000, }); try { relay.setCliReady(true); assignTask(relay, 't-budget'); - for (let i = 0; i < 5; i++) { + for (let i = 0; i < relay.HUMAN_PRESENCE_MAX_DEFERRALS; i++) { + typed += 'x'; relay.__clearTransientNudgeCooldown(); relay.__crashTick(); } @@ -4438,6 +4449,179 @@ test('#5277 a suppressed nudge still consumes no retry budget', () => { } finally { teardown(relay); } }); +// --------------------------------------------------------------------------- +// kubestellar/hive#5685 — "this client sent bytes" is not "a human typed". +// +// #5277 replaced one proxy (a client is attached) with a slightly better proxy +// (a client sent input recently). tmux's `client_activity` advances whenever a +// client sends BYTES, and a terminal emulator sends bytes on its own: 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 forever and every retryable API error parked. +// +// Observed live and confirmed causally: four consecutive deferrals over six +// minutes with nobody typing (mouse reporting off, focus-events off), released +// the instant `tmux detach-client` ran — same relay, same task, same error, no +// keystrokes. The old tests could only exercise the proxy, which is why this +// passed: every one of them held the pane CONSTANT while claiming a human was +// there, which is precisely the state that is now known not to be a human. +// +// The fix corroborates client_activity with the observation that actually +// separates the two cases — a keystroke draws something, a query reply does +// not — and caps the deferral so no presence signal can park a task forever. +// --------------------------------------------------------------------------- + +test('#5685 an attached-but-unused terminal no longer blocks recovery', () => { + // The incident, exactly. The stub answers list-clients with a FRESH timestamp + // on every call, so client_activity advances on every tick just as a real + // terminal answering queries makes it. The pane never changes, because nobody + // is typing into it. + const relay = loadRelay({ + backend: 'claude', paneText: CLAUDE_API_ERROR_PANE, + attachedClients: true, attachedIdleMs: 60 * 1000, + }); + try { + relay.setCliReady(true); + assignTask(relay, 't-unused-tab'); + const before = relay.__tmuxSends().length; + + // Tick one has no earlier pane to compare against, so it defers — one tick, + // by design, and the cap bounds it even if that reasoning were wrong. + relay.__crashTick(); + assert.strictEqual(relay.getTransientNudgeCount(), 0, + 'the first reading has no baseline, so it defers'); + + // Tick two can see the pane is byte-for-byte what it was, while tmux still + // reports input a minute ago. That combination is a terminal talking. + relay.__clearTransientNudgeCooldown(); + relay.__crashTick(); + assert.ok(relay.__tmuxSends().slice(before).some(c => c.includes(relay.TRANSIENT_API_ERROR_NUDGE_MESSAGE)), + 'client_activity advancing over an unchanged pane is the terminal answering ' + + 'the CLI, not a person — the retry must be typed'); + assert.strictEqual(relay.getTransientNudgeCount(), 1); + } finally { teardown(relay); } +}); + +test('#5685 someone actually typing still owns the pane', () => { + // The control, and the reason the fix is not just "ignore client_activity". + // A person composing changes what is drawn; that, plus recent input, is a + // human. Without this the fix could pass by never deferring at all. + let typed = ''; + const relay = loadRelay({ + backend: 'claude', + paneText: () => CLAUDE_API_ERROR_PANE.replace('\u276f ', `\u276f ${typed}`), + attachedClients: true, attachedIdleMs: 10 * 1000, + }); + try { + relay.setCliReady(true); + assignTask(relay, 't-real-human'); + const before = relay.__tmuxSends().length; + for (let i = 0; i < 2; i++) { + typed += 'why did you '; + relay.__clearTransientNudgeCooldown(); + relay.__crashTick(); + } + assert.ok(!relay.__tmuxSends().slice(before).some(c => c.includes(relay.TRANSIENT_API_ERROR_NUDGE_MESSAGE)), + 'a pane that changes while a client reports input is someone composing'); + const blocked = relay.__sent.filter(m => m.status === 'blocked_on_human'); + assert.strictEqual(blocked.length, 2, 'and they should be told the agent needs them'); + assert.strictEqual(blocked[0].attention, true); + } finally { teardown(relay); } +}); + +test('#5685 no presence signal can park a task forever', () => { + // Option (4) from the issue: whatever presence says, stop deferring after N + // ticks. This is the mechanism that bounds the damage of the NEXT wrong + // proxy, so it is pinned against the strongest possible presence reading — + // a pane changing every tick with input reported seconds ago. + let typed = ''; + const relay = loadRelay({ + backend: 'claude', + paneText: () => CLAUDE_API_ERROR_PANE.replace('\u276f ', `\u276f ${typed}`), + attachedClients: true, attachedIdleMs: 5 * 1000, + }); + try { + relay.setCliReady(true); + assignTask(relay, 't-capped'); + const before = relay.__tmuxSends().length; + const cap = relay.HUMAN_PRESENCE_MAX_DEFERRALS; + + for (let i = 0; i < cap; i++) { + typed += 'x'; + relay.__clearTransientNudgeCooldown(); + relay.__crashTick(); + } + assert.strictEqual(relay.getTransientNudgeCount(), 0, + `the first ${cap} ticks are the human's`); + assert.strictEqual(relay.getPresenceDeferralCount(), cap); + + typed += 'x'; + relay.__clearTransientNudgeCooldown(); + relay.__crashTick(); + assert.ok(relay.__tmuxSends().slice(before).some(c => c.includes(relay.TRANSIENT_API_ERROR_NUDGE_MESSAGE)), + 'past the cap the relay retries rather than parking the task on a signal ' + + 'it cannot verify'); + } finally { teardown(relay); } +}); + +test('#5685 paneEditedSincePresenceCheck reads keystrokes, not client chatter', () => { + const relay = loadRelay({ backend: 'claude' }); + try { + const pane = ['● API Error: Connection lost mid-response.', '❯ ']; + + assert.strictEqual(relay.paneEditedSincePresenceCheck(pane), true, + 'the first reading has nothing to compare against, so it must not claim quiet'); + assert.strictEqual(relay.paneEditedSincePresenceCheck(pane), false, + 'an identical pane is positive evidence that nobody typed'); + assert.strictEqual(relay.paneEditedSincePresenceCheck(['● API Error: Connection lost mid-response.', '❯ wait']), true, + 'a composed character changes the pane'); + assert.strictEqual(relay.paneEditedSincePresenceCheck([]), true, + 'an empty capture is tmux telling us nothing, which resolves to "someone is there"'); + + // The reset is what keeps one task's final pane from being the next task's + // baseline — which would read the first tick of new work as a keystroke. + relay.resetHumanPresenceEvidence(); + assert.strictEqual(relay.paneEditedSincePresenceCheck(pane), true, + 'after a reset there is no baseline again'); + } finally { teardown(relay); } +}); + +test('#5685 a new task starts with a clean deferral budget', () => { + // A budget spent deferring one task must not deny the next its retries, and + // one task's final pane must not be the next task's baseline. Sequenced the + // way #5094 does it — and the way the hub actually drives it — with the first + // task handed back before a second is assigned. + let typed = ''; + const relay = loadRelay({ + backend: 'claude', + paneText: () => CLAUDE_API_ERROR_PANE.replace('\u276f ', `\u276f ${typed}`), + attachedClients: true, attachedIdleMs: 10 * 1000, + }); + try { + relay.setCliReady(true); + assignTask(relay, 't-first'); + typed += 'x'; + relay.__crashTick(); + assert.ok(relay.getPresenceDeferralCount() > 0, 'the first task deferred at least once'); + + // Burn the rest of the first task: deferrals to the cap, then retries to + // the retry budget, which hands it back. + for (let i = 0; i < relay.HUMAN_PRESENCE_MAX_DEFERRALS + relay.TRANSIENT_API_ERROR_MAX_NUDGES + 1; i++) { + typed += 'x'; + relay.__clearTransientNudgeCooldown(); + relay.__crashTick(); + } + assert.ok(!relay.getCurrentTask(), 'the first task must be released before the next'); + + assignTask(relay, 't-second'); + assert.strictEqual(relay.getPresenceDeferralCount(), 0, + 'the new task starts with its own deferral budget'); + assert.strictEqual(relay.getTransientNudgeCount(), 0, + 'and its own retry budget'); + } finally { teardown(relay); } +}); + test('#5277 the idle-client retry is still bounded and still fails honestly', () => { const relay = loadRelay({ backend: 'claude', paneText: CLAUDE_API_ERROR_PANE, diff --git a/src/docs/contributor-relay.md b/src/docs/contributor-relay.md index 078fb67e2..c89030255 100644 --- a/src/docs/contributor-relay.md +++ b/src/docs/contributor-relay.md @@ -209,12 +209,33 @@ Set your model: export AGENT_MODEL= ## What happens on a task 1. The hive assigns an issue that fits your trust tier's rate limits and passes the admin's filters. -2. The relay writes the task context, injects a short-lived GitHub token, and drives your CLI in a tmux session (attach to it to watch — or intervene). +2. The relay writes the task context, injects a short-lived GitHub token, and drives your CLI in a tmux session (watch it, or intervene — see below for how to watch without taking the pane). 3. Progress is reported back every 2 minutes; the result (PR opened, success/failure) is reported when the CLI finishes. 4. Completed tasks that open a PR count toward automatic tier promotion — and toward the hive's public `/leaderboard`. Contributors never hold long-lived repo credentials: the relay receives short-lived GitHub tokens per task, and API keys for the contributor's own model provider never leave their machine. +### Watching without taking the pane + +The relay treats the pane as **yours** while it looks like someone is using it: a watchdog must never type over somebody mid-keystroke, so recovery actions — the API-error retry in particular — stand down. Attaching a client is how you tell it you are there. + +That matters more than it sounds, because *attaching* is not the same as *looking*: + +| How you watch | Registers a tmux client? | Blocks automatic recovery? | +|---|---|---| +| `tmux capture-pane -p -t ` | no | no | +| `tmux attach -t ` | yes | while the pane looks in use | +| The dashboard's browser terminal (`/terminal/?arg=…`) | yes — it proxies to a real `tmux attach-session` | same as a local attach | + +So `tmux capture-pane -p -t ` is the read-only way to look: it returns a snapshot without ever appearing in `tmux list-clients`. `tmux attach` and the dashboard terminal both put a real client on the session, and the dashboard route is **not** a read-only viewer despite feeling like one. + +Leaving a tab attached is fine — it does not park a task by itself. Two things bound it ([#5685](https://github.com/kubestellar/hive/issues/5685)): + +- The relay corroborates tmux's `client_activity` against whether the pane actually **changed**. A keystroke draws something; a terminal answering the capability, colour and cursor-position queries the CLI writes does not. So an attached-but-unused tab — which advances `client_activity` on its own — no longer reads as a person. +- Deferral is capped at `HIVE_HUMAN_PRESENCE_MAX_DEFERRALS` ticks (3, roughly six minutes) regardless. Presence is a signal the relay cannot verify, and a task parked forever on an unverifiable signal is worse than one `try again` landing next to somebody. + +`tmux detach-client` remains the unambiguous way to hand the pane back. + ## Multi-hub subscription A single relay can subscribe to multiple hives. Register with each hive first, then provide matching comma-separated lists: