From b14d8ceaeb7e355c72f13a3c88661be48a6c8419 Mon Sep 17 00:00:00 2001 From: Aaron Newton Date: Sun, 2 Aug 2026 12:30:03 -0700 Subject: [PATCH] Suppress hera rail (?) needs-input for sustained-active roles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A hera role that is genuinely, sustainedly active should never show the "(?)" needs-input glyph, regardless of its bound task's workflow status or a stale self-reported `blocked` hera status left over from another hat on a dual-bound sub-coordinator. RoleView.needsInputOwn() now checks a new per-task SustainedActive signal first, computed once per argus task (not per role) via a new agent.SustainedActivityTick — a grace-tolerant sibling of agent.ResumeActivityTick added because ResumeActivityTick's own zero-grace design (needed for BUG-065's stricter clear path) proved, via a new test, to never let a genuinely-but-burstily-active session converge at all. ResumeActivityTick itself is unchanged. Also documents (but does not fix) a separately-found daemon-bounce race where Daemon.SessionStatus can transiently report a supervisor-still-alive session as dead, incorrectly rolling a hera worker's task to in_review/ready_to_close — flagged in gotchas/daemon-rpc.md as a follow-up. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- context/knowledge/gotchas/daemon-rpc.md | 2 + context/knowledge/gotchas/events.md | 8 + context/knowledge/gotchas/hera-view.md | 1 + context/knowledge/index.md | 6 +- internal/agent/needsinput.go | 44 +++++ internal/agent/needsinput_test.go | 117 +++++++++++++ internal/tui/app.go | 60 +++++++ internal/tui/hera/bug024_test.go | 4 +- internal/tui/hera/bug028_repro_test.go | 12 +- internal/tui/hera/model.go | 48 ++++- .../tui/hera/model_sustainedactive_test.go | 141 +++++++++++++++ internal/tui/hera/model_test.go | 48 ++--- internal/tui/hera/page.go | 30 +++- internal/tui/hera/pin_nonroot_test.go | 4 +- internal/tui/hera/plan_test.go | 2 +- .../.openspec.yaml | 2 + .../design.md | 71 ++++++++ .../proposal.md | 34 ++++ .../specs/hera-view/spec.md | 165 ++++++++++++++++++ .../tasks.md | 41 +++++ openspec/specs/hera-view/spec.md | 57 ++++-- 21 files changed, 842 insertions(+), 55 deletions(-) create mode 100644 internal/tui/hera/model_sustainedactive_test.go create mode 100644 openspec/changes/archive/2026-08-02-narrow-needs-input-sustained-active/.openspec.yaml create mode 100644 openspec/changes/archive/2026-08-02-narrow-needs-input-sustained-active/design.md create mode 100644 openspec/changes/archive/2026-08-02-narrow-needs-input-sustained-active/proposal.md create mode 100644 openspec/changes/archive/2026-08-02-narrow-needs-input-sustained-active/specs/hera-view/spec.md create mode 100644 openspec/changes/archive/2026-08-02-narrow-needs-input-sustained-active/tasks.md diff --git a/context/knowledge/gotchas/daemon-rpc.md b/context/knowledge/gotchas/daemon-rpc.md index 9030978b..e141bdea 100644 --- a/context/knowledge/gotchas/daemon-rpc.md +++ b/context/knowledge/gotchas/daemon-rpc.md @@ -112,6 +112,8 @@ - **A re-attached LIVE worker stranded in InReview is RESTORED to InProgress on reattach — `reattachSupervised` revives the live set, not just orphans (BUG-B).** `ReconcileStaleSessionsExcept` only handles the orphan direction (InProgress→InReview for tasks NOT alive); a live worker the supervisor confirms alive that is already parked in InReview (from a prior BUG-050 roll or an earlier reconcile) would otherwise stay mislabeled forever — across repeated bounces EVERY live worker drifts into InReview (0 in_progress on a busy daemon). So after the re-attach `Get` loop, `reattachSupervised` calls `db.ReviveHeraWorkerToInProgress(id)` for every task in `liveSet`. The same helper backs the TUI's in-place revive (`reviveHeraWorker` success branch → `App.reviveRestoreInProgress`, local `*db.DB` only; `--remote` defers to the local daemon's reattach). **`ReviveHeraWorkerToInProgress` is the exact inverse of `RollHeraWorkerToReview` and MUST refuse to un-roll a genuinely-finished worker** — it no-ops unless the task is worker-bound AND currently InReview AND NOT awaiting close-out, where "awaiting close-out" = `meta:hera.ready_to_close=true` (the done/clean-exit stamp) OR a terminal role-status (`done`/`failed`). That guard is what keeps #707 / BUG-050 intact: a done/failed worker with a still-idle-alive session stays InReview for coordinator close-out; only a non-terminal live worker flips back. DB status only, never touches the session, idempotent. - **`hera_revive` (add-hera-revive) is a THIRD caller of `ReviveHeraWorkerToInProgress`, giving a coordinator (not just a human at the TUI) a way to PULL-revive a bound role.** The gating sequence — dead session (any role kind) restarts unconditionally; a LIVE coordinator is never auto-restarted; a live worker/freelance session only gets kicked when idle AND not blocked on a prompt AND no restart already pending — lives ONCE in `internal/hera.ReviveRole` (mirrors `RecycleCoord`'s architecture: pure function over narrow `ReviveStore`/`ReviveRunner` interfaces), wired daemon-side by `daemon.HeraReviveRunner`. **Deliberately NOT unified with the TUI's `Enter`-key revive** (`internal/tui/heraactions.go`'s `heraReattach`/`reviveHeraWorker`) — see `openspec/changes/archive/*-add-hera-revive/design.md` D3: the TUI's kick additionally resizes to the CURRENT PANE's dimensions (no such surface exists for a headless MCP caller, which instead preserves the session's existing PTY size) and is threaded through tview's `QueueUpdateDraw` model. Every individual check the TUI's inline version performs stays single-sourced regardless (`agent.BlockedOnPrompt`, `ReviveHeraWorkerToInProgress` itself, `SessionRunner.KickRerender`/`StartOrReattach`) — only the ~10-line ORDERING of those checks is expressed twice, a known and accepted residual overlap, not silent duplication. +- **KNOWN, UNFIXED: `Daemon.SessionStatus` can transiently report a session the supervisor is STILL RUNNING as `Alive=false` right around a daemon bounce, causing a client-side false "confirmed exit" that fires `RollHeraWorkerToReview` on a task that never actually died (found during narrow-needs-input-sustained-active's ground-truth investigation, deliberately NOT fixed there — flagged as its own follow-up).** Live repro (task `1785216680765732000`): `ux.log` showed `stream: ended ... err=EOF` → `stream: SessionStatus ... alive=false pid=16443` → `client.removeSession` (the CONFIRMED-exit path, not `removeSessionStreamLost`) at `2026-08-02T10:51:08.933`, and `task_meta` shows `ready_to_close` stamped at `.948` — 15ms later, via `App.handleSessionExitUI`'s `RollHeraWorkerToReview` call (`internal/tui/app.go`, "hera worker close-out" log line, NOT the daemon's own `transitionTaskOnExit`). But `daemon.log` proves pid 16443 never actually died: no `rpc.StartSession` appears again until the NEXT bounce, and `reattach: re-attached live supervisor sessions reattached=10 live=10` logs 3 seconds later — the supervisor held the session alive the entire time. `internal/daemon/client/stream.go`'s `isSessionAlive()` calls `Daemon.SessionStatus`, whose handler (`sessionCore.SessionStatus`, `internal/daemon/sessioncore.go`) does `sess := c.runner.Get(req.TaskID); if sess == nil { ...Alive stays false unless HasPendingRestart... }` — in supervisor mode `c.runner` is the supervisor-CLIENT, and exactly HOW `Get` can transiently miss a session the supervisor's OWN `Running()`/reattach pass (above) reports live moments later is not yet root-caused (candidates: a per-task lazy-attach gap in the supervisor-client's local session map vs. `reattachSupervised`'s bulk `Get` loop; a race between the OLD daemon process's own teardown — `cleanup()` closes `d.supClient` AFTER `Shutdown()` already closed `ln`, per the code above — and a client-side reconnect dial landing on a narrow window). Distinct from the `Running()==nil` guard above (that guards the BULK reattach's own live-set query; this is a PER-TASK `SessionStatus` RPC racing independently) and from the #707 cache-vs-EOF race (that's a same-process exit-info-cache-write race, not a cross-process daemon-bounce timing race). Would strand ANY hera-worker-bound task in `in_review`/`ready_to_close` on an unlucky daemon restart, single-hat or dual-hat — a dual-bound sub-coordinator (see `gotchas/hera-view.md`'s narrow-needs-input-sustained-active entry) just makes the resulting symptom more visible (task `in_review` while a coordinator hat visibly keeps working), since `ReviveHeraWorkerToInProgress`'s `heraWorkerAwaitingCloseout` guard then correctly (per its OWN invariant) refuses to un-roll a task once `ready_to_close` is stamped, treating the false exit identically to a genuine one. NOT reproduced with a minimal unit test yet — needs its own dedicated investigation before a fix is attempted. + ## Session-supervisor (P4 — default ON + rollback) - **`cfg.Supervisor.Enabled` now defaults to TRUE (absent key ⇒ supervisor mode), mirroring `hera.enabled` exactly.** The base value moved to `true` in `config.DefaultConfig` (NOT in `db/config.go`, whose parse logic is unchanged — absent key inherits the now-true default, explicit `"true"`/`"false"` still win). The OFF (in-process) path is the same code as P2/P3 and is **retained one release as the supported rollback**, NOT deleted: an explicit `supervisor.enabled = false` in the DB or `config.toml` restores the daemon's in-process runner (byte-identical to pre-P2). config.toml still wins over the DB, so a power user's `[supervisor] enabled = false` is a valid rollback even with the DB unset (`TestDB_Config_SupervisorTOMLRollback`). diff --git a/context/knowledge/gotchas/events.md b/context/knowledge/gotchas/events.md index 8ad60766..b07ae168 100644 --- a/context/knowledge/gotchas/events.md +++ b/context/knowledge/gotchas/events.md @@ -140,3 +140,11 @@ Both callers compute the per-session streak the same way the BUG-029 escalation **Both callers thread a new per-tick counter map exactly like the other maps in this file** (`only store if != 0`, rebuilt fresh from the currently-running set): `internal/tui/app.go`'s `detectNeedsInputSticky` (`App.needsInputSettle`, computed only for sessions in `idleIDs` — a busy session never even gets a tail read for this pass) and `internal/api/push.go`'s `computeNeedsInput` (`idleWatcherState.needsInputSettle`; `computeNeedsInput`'s signature grew a `prevSettle map[string]int` parameter and a 6th `newSettle` return value — every existing call site, including tests, needed the extra threaded argument). One PRE-EXISTING daemon test (`TestDetectNeedsInputTick`'s BUG-061 subtest) had its fixture corrected: it modeled the flooding scenario as `idle=["a","b"]` with the tail switched to plain content for TWO consecutive ticks — which is now, correctly, the exact scenario BUG-072 resolves, not the true BUG-061 case (a session that NEVER goes idle). Fixed by dropping "a" from the `idle` list for those ticks (genuinely busy/flooding, `settledOf` never engages) while keeping the original "must not clear from tail-decay alone" assertion intact. See `TestSettleTick` (`internal/agent/needsinput_test.go`) for the step-function pin (threshold-exact settlement, no-grace immediate reset on either a not-idle or still-signal-present tick, still-blocked-idle never accumulates), the `NeedsInputClear` "settled activity" subtests for the pure-function clear-path pin (including its own BUG-063 interaction), and `TestComputeNeedsInput_SettledActivityClears` / `TestDetectNeedsInputSticky_SettledActivityClears` (with their `..._StillBlockedIdleDoesNotSettle` and `TestComputeNeedsInput_NotIdleNeverSettles` regression-guard siblings) for the end-to-end repro through the real callers. + +## `ResumeActivityTick`'s zero-grace design never lets a genuinely-but-burstily-active session cross its own threshold — a NEW sibling step function was needed for the Hera rail's display gate (narrow-needs-input-sustained-active) + +Ground-truth repro (hera role `contrib-classifier`, demonstrably active 30k+ tokens over 7+ minutes, still showing `(?)`): `ux.log` showed this exact task's rerender/revive logic alternating "busy" / "blocked on user prompt" within seconds, repeatedly — its actual PTY content periodically LOOKS like a parked selection prompt to the content classifier even while genuinely mid-flow. `TestResumeActivityTick`'s own "mostly-working output with occasional single-tick misses never converges either" subtest PROVES why: `ResumeActivityTick`'s deliberate zero-grace-period reset (a single non-working tick resets the streak to zero outright, by design — see BUG-065 above) means a session whose classification misses more often than once every `NeedsInputResumeTicks` (5) ticks can run for HOURS of genuine, substantial activity and never once cross the threshold — indistinguishable, by this counter alone, from a session that is still genuinely parked. + +**Fix: a NEW, separately-named pure step function, `agent.SustainedActivityTick(prevTicks int, workingNow bool) (newTicks int, sustained bool)`, used ONLY by the Hera rail's `RoleView.SustainedActive` display signal — `ResumeActivityTick` itself is UNCHANGED and still exclusively backs `NeedsInputClear`'s `resumedOf` (BUG-065) and `autoClearBlockedHeraRoles` (BUG-066).** `SustainedActivityTick` mirrors `EscalateParkedSelection`'s BUG-060 one-tick-grace encoding exactly (a negative-sentinel `prevTicks` holds a streak through a single isolated miss rather than discarding it; a SECOND consecutive miss is a genuine break and resets for real) but reuses `NeedsInputResumeTicks` as its threshold — no new dial. This is safe for its own narrower purpose (suppressing a DISPLAY glyph, not clearing a stored flag): under-suppressing (staying flagged a tick or two longer) is the accepted failure mode everywhere in this file, and `TestSustainedActivityTick`'s own "sparse still-genuinely-blocked pattern" subtest confirms the one-tick grace is NOT generous enough to let a genuinely-still-parked session (the same sparse pattern `ResumeActivityTick`'s anti-false-clear test uses) falsely converge. + +Threaded as a genuinely NEW per-tick counter (`App.sustainedActiveTicks`, independent of `App.needsInputResume` — never sharing state with BUG-065's stricter counter) feeding `App.sustainedActiveIDs` → `HeraPage.SetSustainedActive` → `BuildModel`'s new 5th parameter → `buildRoleView` → `RoleView.SustainedActive`, computed PER ARGUS TASK (not per hera role) so two roles sharing one live binding's task ID (a dual-bound sub-coordinator's parent-orchestrator worker hat and its own child-orchestrator coordinator hat, per `MaterializeHeraSubCoordinator`) automatically read the identical value. `RoleView.needsInputOwn()` checks `SustainedActive` FIRST and returns `false` unconditionally when true — suppressing BOTH the content-scan `NeedsInput` flag AND a self-reported `blocked` hera status, regardless of which hat carries the stale value. See `gotchas/hera-view.md`'s own entry for the rail-side wiring and `TestSustainedActivityTick` (`internal/agent/needsinput_test.go`) + `TestBuildModel_SustainedActive*` (`internal/tui/hera/model_sustainedactive_test.go`) for the pins. diff --git a/context/knowledge/gotchas/hera-view.md b/context/knowledge/gotchas/hera-view.md index e344ed98..e8f9ac2e 100644 --- a/context/knowledge/gotchas/hera-view.md +++ b/context/knowledge/gotchas/hera-view.md @@ -79,6 +79,7 @@ M6a scaffolds the native Hera view: a `HeraPage` (rail | coordinator pane | agen - **Per-coordinator `Archive (N)` expando is distinct from the bottom Archive section.** `appendOrchWorkers` hoists a coordinator's ARCHIVED roles out of inline rendering into a per-coord expando (collapsed by default, `coordArchiveOpen[orchID]`, `railRow.archiveOwner`). The bottom Archive section (`collArchive`/`archiveCollapsed`) is for archived ROOT orchestrators. Both are `rrArchiveExpando`; `drawRow`/`ToggleCollapse` branch on `archiveOwner > 0`. - **The rail spinner animates on REAL session activity (`RoleView.IsActive` = live binding + bound argus task `in_progress`), NOT the hera `working` role-status — BUG-003.** The hera role Status (`idle`/`working`/`blocked`/`done`/`failed`) is a manual/MCP-set ladder value that NEVER reconciles down: it stays `working` after the session idles, stops, or dies. Binding the spinner to it (the original rail-parity #8 code) made stopped/idle/days-old roles animate. `statusIcon` now derives the spinner from `role.IsActive()` (post-BUG-C: `Live && SessionRunning && !SessionIdle` — liveness + session-running + content-idle, no longer task-status gated; see the BUG-036/BUG-C bullet below). Precedence (post-BUG-F): needs-input `(?)` > `IsActive()` spinner > `ready_to_close` > `failed` > `done` role-status > `idle` role-status > `Live` (moon-stars) > unbound dimmed. needs-input outranks everything (BUG-A; the `blocked` role-status surfaces AS needs-input via `needsInputOwn`, so it too stays on top). **`IsActive()` OUTRANKS the stale-able `ready_to_close`/`failed`/`done` resting glyphs (BUG-F)** — a live, running, producing worker shows the spinner, not a stale done-roll stamp; when it goes idle/exits `IsActive()` drops false and the resting glyph returns. **`failed` renders as red ✕ (distinct from `done` ✓ and `ready_to_close` ✓)** — its precedence is BELOW needs-input/`IsActive()`/`ready_to_close` but ABOVE `done`, so a QUIET failed worker shows red ✕, but a failed worker still actively producing shows the spinner (BUG-F). Native has NO per-task idle/needs-input flag (the plugin sourced those from the API); post-BUG-C the reported stale cases are excluded because they are not `SessionRunning` (stopped/dead — note the binding does NOT end on exit, so `Live` alone won't exclude them) or are `SessionIdle` (parked), NOT because of task status. `coordStatusLabel` (Details) applies the same honesty via `IsActive`: stale `working` → `live` (binding alive but session idle OR dead) or `stopped` (binding gone); a live, running, content-active role honestly reads `working` regardless of task status (BUG-C). Keep `statusIcon` pure (frame in, no `time.Now()` inside) so tests pass explicit frames; `Rail.animFrame` is recomputed from wall-clock each `Draw` (`spinnerFrame()` mirrors the task list). The spinner only advances while the app's spinner loop redraws (any session actively running). - **The needs-input gate is now LIVENESS-based for ALL role kinds — a live worker, coordinator, or freelance surfaces "(?)" regardless of bound-task status (BUG-A supersedes the BUG-028 worker-only carve-out).** `buildRoleView`'s gate is `taskInProgress || rv.Live`, and that whole branch runs only under a LIVE binding (rv.Live is unconditionally true there), so it collapses to "trust the content-aware `needsInput` map." The old worker-`in_progress` gate was the pre-content-aware BUG-023 protection (a finished worker's stale done-summary marker lingered forever and pinned `(?)` on every ancestor). Post-BUG-032/034/035 the set is content-aware (flagged only while a CURRENT awaiting-input signal shows; clears on input/archive), so the task-status gate is obsolete AND wrong for a live `in_review` worker that genuinely blocks (a hera worker sits in `in_review` while its session lingers for close-out per #707 — `RollHeraWorkerToReview` keeps the binding LIVE, only status+meta change). **BUG-023 is now preserved by LIVENESS, not task status: a worker is "finished" when its SESSION EXITS, which ENDS its binding (rv.Live→false → this branch no longer runs → suppressed).** **Requires BOTH halves: the App feed `needsInputForHeraRail` (app.go) must ADMIT any hera-bound task (worker OR coordinator) regardless of status — it takes the `heraManaged` union (`mergeManagedFromMeta(workers, coordinators)`), no longer just coordinators — AND `buildRoleView` must not re-gate on in_progress.** Hera-bound tasks are MANAGED, so admitting a non-in_progress one never reaches the unmanaged attention-summary count (`UnmanagedNeedsInputCount` subtracts the managed set, keyed on the binding regardless of liveness; the flat-task-list `needsInputInProgress`/`computeRuntimeState` gate stays strictly in_progress for BUG-005/BUG-006). See `TestBuildModel_LiveWorkerInReviewSurfacesNeedsInput` + `TestBuildModel_ExitedWorkerSuppressesNeedsInput` + `TestBUG028_ExitedWorkerStaysCleared` + `TestNeedsInputForHeraRail_AdmitsHeraRolesRegardlessOfStatus` + `TestBUGA_Integration_LiveInReviewWorkerAtPromptSurfaces`. +- **BUG-A's "regardless of task status" invariant is now NARROWED by a sustained-activity suppression (narrow-needs-input-sustained-active): `needsInputOwn()` returns `false` unconditionally when `RoleView.SustainedActive` is true, BEFORE evaluating either OR'd source (the content-scan `NeedsInput` flag, or a self-reported `blocked` hera ladder status).** Ground-truth repro: a hera role (`contrib-classifier`, a nested sub-orchestrator coordinator dual-bound to the same argus task as its parent-orchestrator worker hat, per `MaterializeHeraSubCoordinator`) showed "(?)" while demonstrably, continuously active (30k+ tokens streamed over 7+ minutes) — the task's `in_review`/`ready_to_close` status turned out to come from a SEPARATE daemon-bounce race (see `gotchas/daemon-rpc.md`), not a stale hat-status bug, but the false-positive `(?)` itself was real and worth fixing on its own terms: `(?)` is supposed to mean "genuinely stuck, no path forward without a human," and a sustained-active role is not stuck. `SustainedActive` is computed PER ARGUS TASK (not per role), in `App.detectNeedsInputSticky` via a NEW grace-tolerant sibling of `agent.ResumeActivityTick` — `agent.SustainedActivityTick` (one-tick grace mirroring `EscalateParkedSelection`'s BUG-060 pattern, added because `TestResumeActivityTick`'s own "mostly-working... never converges either" case PROVES the existing zero-grace `ResumeActivityTick` never lets a genuinely-but-burstily-active session cross the threshold at all — a single non-working tick resets that counter's streak outright, and ordinary tool-call-to-tool-call pacing trips this more often than the 5-tick window allows). `ResumeActivityTick` itself is UNCHANGED and still backs `NeedsInputClear`'s `resumedOf` (BUG-065) and `autoClearBlockedHeraRoles` (BUG-066) — `SustainedActivityTick` is a NEW, separately-tracked counter (`App.sustainedActiveTicks`, independent of `needsInputResume`) used ONLY by this display gate. Because it is task-scoped, TWO roles bound to the SAME live task (a dual-bound sub-coordinator's worker hat + coordinator hat) automatically read the IDENTICAL value — a stale `blocked` status stranded on one hat is suppressed the instant the shared session demonstrates sustained activity, with no per-hat lookup. Threaded exactly like `NeedsInput`/`SessionIdle`/`SessionRunning`: `App.sustainedActiveIDs` → `HeraPage.SetSustainedActive` → `BuildModel`'s new 5th param → `buildRoleView` → `RoleView.SustainedActive`. A role that is merely `IsActive` (one tick) but not yet `SustainedActive` is UNAFFECTED — BUG-A's original precedence still holds until activity is sustained long enough to be trusted, so this is a narrowing, not a removal, of the invariant. See `TestBuildModel_SustainedActiveSuppressesContentNeedsInput` + `TestBuildModel_SustainedActiveSuppressesBlockedRoleStatus` + `TestBuildModel_SustainedActiveSuppressesAcrossDualBoundHats` + `TestBuildModel_SustainedActiveDoesNotMaskUnrelatedIdleBlocked` + `TestSustainedActivityTick`. - **A coordinator-LESS orchestrator header surfaces the needs-input rollup via `OrchView.SubtreeNeedsInput`, not the coordinator glyph (BUG-028).** `drawOrchRow`'s leading status glyph comes from `o.CoordRole()`; with a coordinator role its `statusIcon` already carries the subtree rollup (via `ShowsNeedsInput`→`SubtreeNeedsInput`). But the rail collapses every orch by default (`firstRunCollapse` "tidy summary"), so a blocked worker is hidden and the COLLAPSED header is the only cue — and an orch whose coordinator role was NUKED (BUG-022 Tier-2, skipped by `BuildModel`) has `CoordRole()==nil` and rendered NO needs-input cue at all (unlike the always-flat task list's `projectStatusIcon` aggregate). Fix: `rollupNeedsInput` stamps the per-orch rollup onto `OrchView.SubtreeNeedsInput` (= `subtree[o.ID]`, the same transitive `orchSubtreeNeedsInput` value the coordinator role gets), and `drawOrchRow` renders `theme.IconNeedsInput` on the header in an `else if o.SubtreeNeedsInput` branch — ONLY when no coordinator glyph carries it (no double-render). BUG-023 protection is via liveness (an exited worker's ended binding drops it from the rollup), so a finished worker still clears the header. See `TestBUG028_CoordinatorlessOrchSurfacesSubtreeNeedsInput` + `TestBUG028_Integration_CoordinatorlessHeaderSurfacesNeedsInput`. - **`RoleView.Cancelled` (from `hera_roles.cancelled_at IS NOT NULL`) projects to `planview.StateCancelled` in the plan widget.** `heraPlanNodesWithBridge` stamps `Node.State = StateCancelled` when `RoleView.Cancelled` is true; `planNodeState` checks `Cancelled` before `Planned` so a cancelled role never renders as a violet `○`. The rail itself does not render a cancelled glyph — `statusIcon` ignores `Cancelled` because a cancelled planned role has no binding and no task; it never appears as a rail row (only planned/live roles with bindings reach the rail). - **The PR cell on rail rows reads the same `prMeta` ("pr" namespace) the Details roster uses — `Rail.SetPRMeta` is called from `doRefresh`.** A managed role whose bound task's cached `state` (`model.ParsePRState`) is actionable (`model.PRState.IsActionable`) renders a right-aligned `PR` tag, reserving width so the name truncates instead of overwriting it. Gated on STATE, not `url` presence: the poller retains `url` after merge/close, so a merged PR would flag forever if `rolePR` only checked `url != ""` — that was BUG (fixed alongside the same details.go `roleMark` bug, both keyed off the shared `IsActionable` predicate that also backs `theme.PRGlyph`). Best-effort; never fetched by the view. diff --git a/context/knowledge/index.md b/context/knowledge/index.md index 00ad5823..3490b38a 100644 --- a/context/knowledge/index.md +++ b/context/knowledge/index.md @@ -4,7 +4,7 @@ Non-obvious invariants and gotchas, split by topic. Read the relevant file when | File | Topic | Bullets | | --- | --- | --- | -| [gotchas/daemon-rpc.md](gotchas/daemon-rpc.md) | Daemon lifecycle, RPC timeouts, reconciliation races, session resume, Claude /clear recapture, binary staleness (SHA-256 content hash, not mtime), self-update, launchd auto-start + PATH, stream Since offset, paste-boundary flush, *.test fork-bomb backstop, singleton flock, PR poller (eligibility, terminal-state skip, batched per-repo graphql w/ alias-safe ids + chunked keep-stale), evidence-based completion (ExitInfo.CleanExit predicate; reconcile→InReview never Complete), hera worker finish policy (BUG-050 RollHeraWorkerToReview), startup hera-binding reconciliation, session-supervisor P1–P4 (dark PTY-owner, daemon-as-client behind cfg.Supervisor.Enabled, re-attach on bounce, default ON + in-process rollback, #707 cache-vs-EOF relay race), callWithTimeout nil-rpc guard, TUI supervisor restart, go-install skew (doctor restart-vs-path-divergence, supervisor-checked-on-auto-start, ProtocolVersion 2→3 old-supervisor-unknown, double-confirm supervisor restart), revive-restores-in_progress (BUG-B ReviveHeraWorkerToInProgress, inverse of RollHeraWorkerToReview), host-suspend watchdog (ARGUS_HOST_SUSPENDED advisory note — wall-clock gap>3m between 30s ticks, unconditional not Hera-gated, sibling of sendBounceSignals, one-shot no-dedup baseline-before-loop, monotonic-strip required, advisory-only no state mutation), Claude Code's own background-session supervisor (orphaned-worker root cause: single-PID SIGTERM can never reach a session Claude Code itself detached to its per-user supervisor; `claude agents`/`claude stop` detection+fix SHIPPED via internal/claudeagents + Runner.Stop fire-and-forget reap, not a signal-scoping bug), doctor Stop-hook registration check (detect-missing-coord-hook: REGISTERED/NOT REGISTERED/UNKNOWN, advisory-only, never gates the binary-coherence exit code), resume-time session-ID recapture (agent.RefreshResumeSessionID mirrors the exit hook because hera workers idle/StreamLost never reach captureSessionIDPostExit; Claude-only; wired at reattachSupervised orphans + TUI startSession + REST resume/restart; idempotent, never blanks/fabricates), doctor diligence-profile-library check (add-doctor-profile-check: FOUND/NONE FOUND/UNKNOWN, library-existence-only not per-project binding, missing-dir vs unreadable-dir tri-state, advisory-only), hera_revive coordinator PULL-revive (add-hera-revive: third ReviveHeraWorkerToInProgress caller, shared internal/hera.ReviveRole gate, deliberately not unified with the TUI's Enter-key revive) | 109 | +| [gotchas/daemon-rpc.md](gotchas/daemon-rpc.md) | Daemon lifecycle, RPC timeouts, reconciliation races, session resume, Claude /clear recapture, binary staleness (SHA-256 content hash, not mtime), self-update, launchd auto-start + PATH, stream Since offset, paste-boundary flush, *.test fork-bomb backstop, singleton flock, PR poller (eligibility, terminal-state skip, batched per-repo graphql w/ alias-safe ids + chunked keep-stale), evidence-based completion (ExitInfo.CleanExit predicate; reconcile→InReview never Complete), hera worker finish policy (BUG-050 RollHeraWorkerToReview), startup hera-binding reconciliation, session-supervisor P1–P4 (dark PTY-owner, daemon-as-client behind cfg.Supervisor.Enabled, re-attach on bounce, default ON + in-process rollback, #707 cache-vs-EOF relay race), callWithTimeout nil-rpc guard, TUI supervisor restart, go-install skew (doctor restart-vs-path-divergence, supervisor-checked-on-auto-start, ProtocolVersion 2→3 old-supervisor-unknown, double-confirm supervisor restart), revive-restores-in_progress (BUG-B ReviveHeraWorkerToInProgress, inverse of RollHeraWorkerToReview), host-suspend watchdog (ARGUS_HOST_SUSPENDED advisory note — wall-clock gap>3m between 30s ticks, unconditional not Hera-gated, sibling of sendBounceSignals, one-shot no-dedup baseline-before-loop, monotonic-strip required, advisory-only no state mutation), Claude Code's own background-session supervisor (orphaned-worker root cause: single-PID SIGTERM can never reach a session Claude Code itself detached to its per-user supervisor; `claude agents`/`claude stop` detection+fix SHIPPED via internal/claudeagents + Runner.Stop fire-and-forget reap, not a signal-scoping bug), doctor Stop-hook registration check (detect-missing-coord-hook: REGISTERED/NOT REGISTERED/UNKNOWN, advisory-only, never gates the binary-coherence exit code), resume-time session-ID recapture (agent.RefreshResumeSessionID mirrors the exit hook because hera workers idle/StreamLost never reach captureSessionIDPostExit; Claude-only; wired at reattachSupervised orphans + TUI startSession + REST resume/restart; idempotent, never blanks/fabricates), doctor diligence-profile-library check (add-doctor-profile-check: FOUND/NONE FOUND/UNKNOWN, library-existence-only not per-project binding, missing-dir vs unreadable-dir tri-state, advisory-only), hera_revive coordinator PULL-revive (add-hera-revive: third ReviveHeraWorkerToInProgress caller, shared internal/hera.ReviveRole gate, deliberately not unified with the TUI's Enter-key revive), KNOWN UNFIXED daemon-bounce SessionStatus false-negative (Daemon.SessionStatus transiently reports a supervisor-still-alive session as dead right after a daemon restart, firing RollHeraWorkerToReview on a task that never died — found + documented by narrow-needs-input-sustained-active, not fixed there) | 110 | | [gotchas/pty-terminal.md](gotchas/pty-terminal.md) | PTY sizing, x/vt emulator, ring buffer, replay cache, paint cache, lazyScreen, test concurrency, ESC-boundary alignment, live rebuild from log tail, monotonic firstByteOffset, scrollOffset clamp, waitLoop close-after-drain order, rerender gates (unchanged-cols, cache invalidation, blocked-on-prompt), OSC 0x9C-in-UTF8 strip filter, persistent preview emulator (PreviewVT reuse-via-RIS), plugin terminalpane cursor sync, alt-screen keyboard-scroll + scroll-mode-entry suppression (BUG-031, not just the wheel), scroll-past-window lazy extend (BUG-E), scroll replay authored-width emulate-clip (live-scroll corruption), dimension-change resize-in-place instead of lossy 8MB-tail rebuild (BUG-068, overlapping/garbled live-view corruption), ring-wrap exact-offset log catch-up instead of lossy 8MB-tail rebuild (BUG-073, BUG-068's ring-wrap sibling — reached by backgrounding a busy agent's pane, not resize), live incremental-feed atomic (raw,total) snapshot instead of two separate racy calls (BUG-075, TOCTOU race distinct from BUG-068/073/074 — reachable on an actively-streamed pane with no bind/resize at all, causes a duplicated recent phrase + a couple of dropped characters), log/ring merge no-splice-across-unrecoverable-gap (BUG-076, readLiveRebuildHistory understates emuFedTotal to logSize instead of gluing non-contiguous log+ring bytes together when the on-disk log lags the ring by more than 256KB under a heavy output burst — distinct root cause from BUG-075 sharing the same no-bind-event signature) | 66 | | [gotchas/ui-threading.md](gotchas/ui-threading.md) | tview thread safety, tick-goroutine rules, lazyScreen fill invariant, paste/input batching, tmux UX-tearing post-mortem (no Sync; 3 legit repair callsites), OnBranchChange log-only contract, EventFocus drift recovery, stderr/stdout-after-Init fd 2 guards, status-bar notice auto-expire (15s TTL, lazy revert via 1s tick, no Sync/timer), SetScreen swallows tcell Init() errors (no-ctty nil-tty EnableMouse panic, probeTerminal preflight guard), probeTerminal false-positive-on-every-real-terminal regression (tcell devTty.Close() nil-`f` → os.ErrInvalid, fix discards Close() err + probeTerminalDev pty-slave test seam) | 29 | | [gotchas/ci-gates.md](gotchas/ci-gates.md) | `make pre-pr` per-gate failure recipes (fmt-check, test-cover-gate floor, lint-pr new-from-rev, vuln stdlib continue-on-error, macOS PTY-exhaustion flake in internal/agent under full-suite -race, same flake class also hits internal/tui/terminal + internal/tui) | 6 | @@ -16,10 +16,10 @@ Non-obvious invariants and gotchas, split by topic. Read the relevant file when | [gotchas/misc.md](gotchas/misc.md) | DB patterns, Go idioms, Codex, Pi/ollama prelaunch, MCP, PRs, file explorer, quick-add, scheduled tasks, links, task auto-naming, settings two-pane UI, plugin-scoped token tagging, task_meta sidecar, plugin settings registry, Claude session discovery (folder encoding, JSONL parse), config.toml override layer (precedence, partial-map zeroing, mtime live-reload), hera plugin contract (POST/DELETE /notify), model selection (--model injection, codex-resume re-append), per-schedule model override (TaskCreator taskModel → HeadlessInput.Model), wall-clock duration clamp, link PR indicator (IsPR single-source + prLinkGutter), openspec archive workflow (rc0-on-abort, authoring-order fold, orphaned-superseded-requirement audit, authoring-drift fixes, doubled-date-prefix on pre-dated change folders), diligence profiles (resolution fail-open, in-repo precedence, daemon-side resolution vs sandbox EPERM, [panel] deferred seam, 3 schema columns, embedded seed install via InstallDefaults, archetype/rigor JSON casing fix + native-dispatch convention pointer), opencode backend (capture-style/no --session-id, --session resume, root-commit-keyed capture filtered by directory, SQLite-first+JSON-fallback fail-open, MCP type:"remote", ctrl+r switcher excludes it), builtin skills/routing spawn-time injection (unconditional --add-dir + --append-system-prompt-file, self-gating content, isTestBinary() short-circuit, ensureBuiltinRoutingFn test seam, manual install-claude-skills.sh mechanism retired, skills embed-drift vs .claude/skills unfixed, generic directory-tree iteration with no per-name whitelist, hera-review/hera-review-test-adversary parity shipped + hera-spawn-review/resolve-archetype-model parity shipped once profile_resolve/internal-review infra landed), landing risk of stacking work on a not-yet-merged integration branch (hera-nav-palette took 3 attempts across #865/#882/#886 — worktree/branch cleanup before merge stranded stacked branches twice despite the code itself being correct), `argus doctor` diligence-profile-library check cross-ref (add-doctor-profile-check: see gotchas/daemon-rpc.md) | 194 | | [gotchas/orchestration.md](gotchas/orchestration.md) | depends_on DAG / depswatcher / link-unlink-halt / plan_slug all RETIRED (Hera is the single model; base_branch kept; tasks start immediately), task_set_result opaque-to-daemon, ARGUS_TASK_ID env export, (name,project) idempotency, schema column ordering, hera schema/store M1 (FK-cascade, NULL partial-index, per-(task,orchestrator) multi-binding + ErrHeraAmbiguous, transactional role+binding), hera M4 (born-bound spawn + AfterPersist hook; auto-adopt removed; ReconcileBindings keyed on task-row; HeraCoordinatorOrientation headline = coord DISPATCHES not implements (actual work → hera_spawn_worker regardless of repo; self-invoking hera_new_orchestrator = relabel-and-implement-solo antipattern, not a sub-team); new orch only for multi-project/phase sub-team whose WORKERS do the work; hera_new_orchestrator CODE guard rejects a caller already coordinating a DIFFERENT orch, before orch-create, fail-open, MCP-only), hera M5 (subtree TLDR roll-up: SubtreeOrchIDs BFS + cycle guard, tree_read_cursors cascade, advisory per-role cursor), hera plan-DAG substrate (planned node = role w/ no binding, gate on role-status done, failed-blocker → HOLD + ping coordinator, in-tx DFS cycle check, check-in pulled via inbox), hera_join reject+redirect to new hera_move tool (fix-hera-join-move-binding; self-promotion remains the only 2+-binding path), coordinator context management (coord-hook global-settings requirement + ARGUS_TASK_ID-first self-gate, unconditional context_size stamp vs budget-gated block, presence-=-block no-cooldown nudge, hera_status handoff_note/request_recycle widened to ANY hera-bound role kind not just coordinator (add-worker-bounce), request_recycle as flag-only vs watcher-driven action, recycle_coord self-service idle-wait vs human-forced immediate (human-forced stays coordinator-only), RecycleWatcher.tickTask role-kind filter widened to worker/freelance (coordinator-kind still preferred among 2+ live bindings), SessionID-clear-before-Recycle ordering, best-effort stray-job cleanup, zero-follow-up-call seed prompt, B key bounces worker/freelance via self-service instruct-and-wait instead of no-op (add-worker-bounce), no-op only on an empty selection), coord-hook stamp gate ALSO widened coordinator-only → any hera-bound role for context_size specifically (add-worker-context-indicator, budget/nudge/recycle stays coordinator-only — a separate widening from add-worker-bounce's hera_status/RecycleWatcher/B-key changes above), RecycleRunner.Restart must resolve via the caller-supplied roleID not a re-derived taskID-keyed lookup (fix-recycle-restart-ambiguous-binding, dual-bound self-service recycle previously wedged forever on ErrHeraAmbiguous), hera plan-DAG hygiene (add-hera-plan-hygiene: planned-node parent-orchestrator liveness filter + cascade-cancel on archive/nuke is belt-and-braces not either-alone, materializeNode consecutive-failure escalation mirrors holdAndPing's ping-once dedup not EscalateParkedSelection's torn-read threshold, freelance-role flat-section staleness is data-hygiene not a display bug) | 78 | | [gotchas/dag-rendering.md](gotchas/dag-rendering.md) | dagview is now a LAYOUT LIBRARY (dagview.Compute longest-path stage placement) consumed by internal/tui/planview — standalone widget no longer mounted; PLAN-DAG widget (planview) replaced the retired orchestration-tree graph, heraTreeNodes/tree.go DELETED. Sugiyama-lite layer math, rune-vs-byte truncation, single-line edges, branch-change log-only. planview node State/colour from RoleView.TaskStatus/TaskResult ({"failed":true}→red ✕); planview surfaces OnEnter/OnDrillIn/OnDrillOut/OnBranchChange (read-only nav, no edit callbacks); branchShape folds cursor+fanned-group+orch-title | 18 | -| [gotchas/hera-view.md](gotchas/hera-view.md) | Native Hera view (`internal/tui/hera`): 2nd tab display label "Projects" (internal names stay Hera); 2nd tab always native (cfg.Hera.Enabled gates only daemon MCP tools, not tick refresh); structural multi-binding fan-out; freelance section; ready_to_close from task_meta; goroutine-free debounced Refresher on UI thread; rail nav j/k/Up/Down + Space (tab nav 1/2/3 only); no Sync + full-rect coverage. Panes fed from in-process runner ring (poll, not SSE); coord-vs-agent session rule; multi-binding disambiguated by role.OrchID; PTY align via ForceResyncPTY + off-thread SyncPanes. Thin mutation layer (ops.go over M1; shared agent.SpawnHeraWorker); rail keyset via OnXxx callbacks; multi-binding isolation; s/S step hera ROLE status; modal.ConfirmModal + NewInputForm; remote=nil ⇒ inert. Details 2 modes (worker→terminal, coordinator→stacked roster-over-PLAN — same geometry as roster-over-tree); embedded PLAN-DAG graph via heraPlanNodesWithBridge(orch, bridgeIndex) — coordinators not plan nodes, Drillable needs WithBridge form; planview↔hera import one-way (hera imports planview); OnDrillIn page-owned (drillIntoChild→bridgeIndex→PushOrch), OnEnter App-owned; handleDetailsKey Esc-at-root escapes pane; node colour from TaskStatus/TaskResult. Ctrl+Z→fullscreen (closes the Claude-Code-own-supervisor detach footgun, worse than a mere SIGTSTP); Enter revives dead (startSession) or suspended worker (reviveHeraWorker→KickRerender, idle+not-blocked gated; live coordinator navigate-only); jumpToLeaf expands ancestor coordinators (EnsureAncestorsExpanded via canonicalParents + OrchIDsForTask) before SelectByTaskID so a folded coord doesn't swallow the join (BUG-007); J-detach (DetachCoordinator) = re-parent teardown without recreate, shared teardownParentLinks, idempotent, detach sentinel by pointer identity; nested sub-coord = headerless worker-bridge row → heraCoordReparentTarget qualifies worker w/ BridgeChildOrchID!=0 so J detach/re-parent reaches the child orch (plain worker never misclassified); needs-input rail gate is LIVENESS-based for ALL kinds (buildRoleView `taskInProgress || rv.Live`; live worker/coordinator/freelance surfaces (?) regardless of task status, incl. a worker in in_review per #707 — needsInputForHeraRail admits the heraManaged union (workers+coordinators), BUG-A supersedes the BUG-028 worker-only carve-out; BUG-023 now guarded by binding-liveness not task-status; flat task-list stays in_progress-gated); needs-input OUTRANKS ready_to_close in RoleStatusIcon (an actively-blocked worker is not ready to close, BUG-A); coordinator-less orch header surfaces rollup via OrchView.SubtreeNeedsInput in drawOrchRow else-if (BUG-028); content-aware spinner (RoleView.IsActive gated on !SessionIdle, fed from App content-idle set so a parked fullscreen agent stops animating, BUG-036); IsActive spinner OUTRANKS ready_to_close/failed/done in RoleStatusIcon (BUG-F, icon-precedence completion of BUG-C; resting case kept via IsActive's running/!idle gate); bulk cascade-nuke silent multi-second freeze (BUG-062, no data race — synchronous per-task session-stop RPC on the tview goroutine) fixed via backgrounded stop (heraGoSafe) + SyncPTYSize panic recovery mirroring Draw(); kanban_status (add-hera-kanban-status) independent 4th axis on top-level coordinators (active/backlog/blocked/done, default active, hera_orchestrators column no CHECK) grouping the rail's Active bucket with dividers, stepped by m/M (wraps, distinct from s/S role-status clamp); needs-input rollup EXCLUDES archived nodes (exclude-archived-from-needs-input-rollup) via a DEDICATED archive-aware orchSubtreeNeedsInput walk (not BridgeSubtree reuse — BridgeSubtree keeps archived rows for dimmed-in-place rendering), gating descent on the bridging role's Archived AND the worker-bridge target orch's own !c.Archived; archived role's own row still shows (?); rail Enter-reattach `live` check now tests Alive() not mere non-nil (BUG-064, shared HeraPage.sessionLive) — a cached-but-disconnected coordinator handle (BUG-013) used to make the first Enter skip reattach and only focus, requiring a second Enter routed through the pane's own InputHandler to actually restart; kanban groups auto-fold to the focused group (add-kanban-focus-fold): Active gains a uniform header/divider (headerless special-case retired), Rail.focusedKanban resolved BEFORE buildRows via focusGroupOf (chicken-and-egg), step() boundary-crossing expand/collapse via landOnGroupMember, SetModel/SelectByTaskID/EnsureAncestorsExpanded each independently re-focus, not persisted; ctrl+j switcher literal case (mirrors Ctrl+Z) + exported JumpToTask (jumpToLeaf now a thin wrapper); ctrl+k global palette Hera reach (two enumerated non-keymap literal rows — fullscreen/copy — + heraRailActionRegistry over existing OnXxx callbacks); rail partial-fold reveal (appendOrchWorkers/appendWorkerRow revealOnly mode + appendOrchRevealPath, extends the one true traversal rather than forking a parallel one, fold state never mutated); BUG-064 (nested-reveal-lost-on-reexpand variant): appendWorkerRow's bridged-child branch needed the same else-if child.SubtreeNeedsInput fallback appendPinnedRole already had, else re-expanding an outer coordinator while a nested sub-coordinator stayed collapsed silently dropped the nested needs-input leaf; ctrl+g jump-to-next-needs-input (Rail.NextNeedsInputTaskID scan-and-cycle over built row order + HeraPage.JumpToNextNeedsInput reusing JumpToTask verbatim; candidates require row.role!=nil so a top-level coordinator's own need — folded into the rrOrch header, unreachable via SelectByTaskID — is deliberately excluded, unlike a nested sub-coordinator's bridging row), worker/freelance context-pressure indicator (add-worker-context-indicator: always-reserved trailing 2-col slot, coordinator-excluded, local-mode-only ContextPercent, bare coordinator count), context-size undercounting root cause is transcript_path's documented async-write lag not sidechains (fix-context-stop-lag: no Stop-hook field carries usage data, last_assistant_message is plain text; bounded retry-and-take-max in readContextSizeReal is the fix, gated by an early exit — contextSizeReadPrevious seam compares one scan against the task's prior stamp, skipping the retry unless the scan is below-prior or there's no prior stamp yet, so the ~200ms budget isn't an unconditional per-turn tax across the whole coordinator/worker/freelance fleet; isSidechain skip kept as cheap defensive hardening, empirically a no-op under the current CLI); ctrl+g/ctrl+b excursion re-arm was count-based not identity-based (BUG-069, live dogfood repro) — a stale never-resolved needs-input role kept the count >=1 forever so a restore re-armed and froze on the very next rebuild regardless of novelty, silently discarding the operator's post-restore navigation; fixed via role-ID set-membership tracking (`Rail.armedNeedsInputIDs`/`Model.needsInputRoleIDs`/`hasNewNeedsInputID`) that continuously refreshes until a genuinely new distinct role id appears; separately confirmed (not fixed) a narrow pre-existing `currentRef()`/`restoreCursor` gap shared with BUG-002 for cursor-on-fold-row capture; the identity-tracking fix still froze a bogus snapshot on a Rail's first-ever `SetModel` call when stale needs-input predated a TUI launch/relaunch (BUG-070, discovered dogfooding BUG-069 — no rows yet for `currentRef()`, fold state still the previous session's persisted layout) — fixed via a `r.rows == nil` early-return guard (seed-only, no capture) on the literal first call; the partial-fold reveal was stateless across rebuilds, so a selected role revealed only via needs-input vanished (yanking cursor + panes) the instant its own flag cleared (BUG-071) — fixed via `Rail.applyStickyReveal` forcing `SubtreeNeedsInput` along the selected row's ancestor chain in the fresh model before `buildRows`, re-derived from the current cursor identity every rebuild so it releases the moment selection moves elsewhere; size-drift kill+resume kick extended to Hera panes (BUG-074, `heraKickRerender`/`maybeKickPaneRerender`) — a plain `ForceResyncPTY()` can't repair scrollback already committed at a different width, and Hera panes are MORE exposed than the main agent view since `bindPane` resizes on every single bind; evaluated from `Draw()` (fresh per-pane width via `coordKickedFor`/`agentKickedFor`), never from `bindPane` itself (whose tracked width can still be 0 for a pane not yet shown, e.g. the agent pane during details mode); BUG-076 (false "Session not running" after ordinary rail nav) — root cause was `handleSessionExitUI`'s post-kick auto-restart gate checking ONLY `a.mode==modeAgent`, never true on the Hera tab, so every BUG-074 size-drift kick fired from a Hera pane genuinely stopped the session and then always skipped the restart as "user navigated away"; fixed via `App.isViewingTaskSession`/`HeraPage.IsBoundToTask` recognizing the Hera-tab-with-bound-pane case too | 170 | +| [gotchas/hera-view.md](gotchas/hera-view.md) | Native Hera view (`internal/tui/hera`): 2nd tab display label "Projects" (internal names stay Hera); 2nd tab always native (cfg.Hera.Enabled gates only daemon MCP tools, not tick refresh); structural multi-binding fan-out; freelance section; ready_to_close from task_meta; goroutine-free debounced Refresher on UI thread; rail nav j/k/Up/Down + Space (tab nav 1/2/3 only); no Sync + full-rect coverage. Panes fed from in-process runner ring (poll, not SSE); coord-vs-agent session rule; multi-binding disambiguated by role.OrchID; PTY align via ForceResyncPTY + off-thread SyncPanes. Thin mutation layer (ops.go over M1; shared agent.SpawnHeraWorker); rail keyset via OnXxx callbacks; multi-binding isolation; s/S step hera ROLE status; modal.ConfirmModal + NewInputForm; remote=nil ⇒ inert. Details 2 modes (worker→terminal, coordinator→stacked roster-over-PLAN — same geometry as roster-over-tree); embedded PLAN-DAG graph via heraPlanNodesWithBridge(orch, bridgeIndex) — coordinators not plan nodes, Drillable needs WithBridge form; planview↔hera import one-way (hera imports planview); OnDrillIn page-owned (drillIntoChild→bridgeIndex→PushOrch), OnEnter App-owned; handleDetailsKey Esc-at-root escapes pane; node colour from TaskStatus/TaskResult. Ctrl+Z→fullscreen (closes the Claude-Code-own-supervisor detach footgun, worse than a mere SIGTSTP); Enter revives dead (startSession) or suspended worker (reviveHeraWorker→KickRerender, idle+not-blocked gated; live coordinator navigate-only); jumpToLeaf expands ancestor coordinators (EnsureAncestorsExpanded via canonicalParents + OrchIDsForTask) before SelectByTaskID so a folded coord doesn't swallow the join (BUG-007); J-detach (DetachCoordinator) = re-parent teardown without recreate, shared teardownParentLinks, idempotent, detach sentinel by pointer identity; nested sub-coord = headerless worker-bridge row → heraCoordReparentTarget qualifies worker w/ BridgeChildOrchID!=0 so J detach/re-parent reaches the child orch (plain worker never misclassified); needs-input rail gate is LIVENESS-based for ALL kinds (buildRoleView `taskInProgress || rv.Live`; live worker/coordinator/freelance surfaces (?) regardless of task status, incl. a worker in in_review per #707 — needsInputForHeraRail admits the heraManaged union (workers+coordinators), BUG-A supersedes the BUG-028 worker-only carve-out; BUG-023 now guarded by binding-liveness not task-status; flat task-list stays in_progress-gated); needs-input OUTRANKS ready_to_close in RoleStatusIcon (an actively-blocked worker is not ready to close, BUG-A); coordinator-less orch header surfaces rollup via OrchView.SubtreeNeedsInput in drawOrchRow else-if (BUG-028); content-aware spinner (RoleView.IsActive gated on !SessionIdle, fed from App content-idle set so a parked fullscreen agent stops animating, BUG-036); IsActive spinner OUTRANKS ready_to_close/failed/done in RoleStatusIcon (BUG-F, icon-precedence completion of BUG-C; resting case kept via IsActive's running/!idle gate); bulk cascade-nuke silent multi-second freeze (BUG-062, no data race — synchronous per-task session-stop RPC on the tview goroutine) fixed via backgrounded stop (heraGoSafe) + SyncPTYSize panic recovery mirroring Draw(); kanban_status (add-hera-kanban-status) independent 4th axis on top-level coordinators (active/backlog/blocked/done, default active, hera_orchestrators column no CHECK) grouping the rail's Active bucket with dividers, stepped by m/M (wraps, distinct from s/S role-status clamp); needs-input rollup EXCLUDES archived nodes (exclude-archived-from-needs-input-rollup) via a DEDICATED archive-aware orchSubtreeNeedsInput walk (not BridgeSubtree reuse — BridgeSubtree keeps archived rows for dimmed-in-place rendering), gating descent on the bridging role's Archived AND the worker-bridge target orch's own !c.Archived; archived role's own row still shows (?); rail Enter-reattach `live` check now tests Alive() not mere non-nil (BUG-064, shared HeraPage.sessionLive) — a cached-but-disconnected coordinator handle (BUG-013) used to make the first Enter skip reattach and only focus, requiring a second Enter routed through the pane's own InputHandler to actually restart; kanban groups auto-fold to the focused group (add-kanban-focus-fold): Active gains a uniform header/divider (headerless special-case retired), Rail.focusedKanban resolved BEFORE buildRows via focusGroupOf (chicken-and-egg), step() boundary-crossing expand/collapse via landOnGroupMember, SetModel/SelectByTaskID/EnsureAncestorsExpanded each independently re-focus, not persisted; ctrl+j switcher literal case (mirrors Ctrl+Z) + exported JumpToTask (jumpToLeaf now a thin wrapper); ctrl+k global palette Hera reach (two enumerated non-keymap literal rows — fullscreen/copy — + heraRailActionRegistry over existing OnXxx callbacks); rail partial-fold reveal (appendOrchWorkers/appendWorkerRow revealOnly mode + appendOrchRevealPath, extends the one true traversal rather than forking a parallel one, fold state never mutated); BUG-064 (nested-reveal-lost-on-reexpand variant): appendWorkerRow's bridged-child branch needed the same else-if child.SubtreeNeedsInput fallback appendPinnedRole already had, else re-expanding an outer coordinator while a nested sub-coordinator stayed collapsed silently dropped the nested needs-input leaf; ctrl+g jump-to-next-needs-input (Rail.NextNeedsInputTaskID scan-and-cycle over built row order + HeraPage.JumpToNextNeedsInput reusing JumpToTask verbatim; candidates require row.role!=nil so a top-level coordinator's own need — folded into the rrOrch header, unreachable via SelectByTaskID — is deliberately excluded, unlike a nested sub-coordinator's bridging row), worker/freelance context-pressure indicator (add-worker-context-indicator: always-reserved trailing 2-col slot, coordinator-excluded, local-mode-only ContextPercent, bare coordinator count), context-size undercounting root cause is transcript_path's documented async-write lag not sidechains (fix-context-stop-lag: no Stop-hook field carries usage data, last_assistant_message is plain text; bounded retry-and-take-max in readContextSizeReal is the fix, gated by an early exit — contextSizeReadPrevious seam compares one scan against the task's prior stamp, skipping the retry unless the scan is below-prior or there's no prior stamp yet, so the ~200ms budget isn't an unconditional per-turn tax across the whole coordinator/worker/freelance fleet; isSidechain skip kept as cheap defensive hardening, empirically a no-op under the current CLI); ctrl+g/ctrl+b excursion re-arm was count-based not identity-based (BUG-069, live dogfood repro) — a stale never-resolved needs-input role kept the count >=1 forever so a restore re-armed and froze on the very next rebuild regardless of novelty, silently discarding the operator's post-restore navigation; fixed via role-ID set-membership tracking (`Rail.armedNeedsInputIDs`/`Model.needsInputRoleIDs`/`hasNewNeedsInputID`) that continuously refreshes until a genuinely new distinct role id appears; separately confirmed (not fixed) a narrow pre-existing `currentRef()`/`restoreCursor` gap shared with BUG-002 for cursor-on-fold-row capture; the identity-tracking fix still froze a bogus snapshot on a Rail's first-ever `SetModel` call when stale needs-input predated a TUI launch/relaunch (BUG-070, discovered dogfooding BUG-069 — no rows yet for `currentRef()`, fold state still the previous session's persisted layout) — fixed via a `r.rows == nil` early-return guard (seed-only, no capture) on the literal first call; the partial-fold reveal was stateless across rebuilds, so a selected role revealed only via needs-input vanished (yanking cursor + panes) the instant its own flag cleared (BUG-071) — fixed via `Rail.applyStickyReveal` forcing `SubtreeNeedsInput` along the selected row's ancestor chain in the fresh model before `buildRows`, re-derived from the current cursor identity every rebuild so it releases the moment selection moves elsewhere; size-drift kill+resume kick extended to Hera panes (BUG-074, `heraKickRerender`/`maybeKickPaneRerender`) — a plain `ForceResyncPTY()` can't repair scrollback already committed at a different width, and Hera panes are MORE exposed than the main agent view since `bindPane` resizes on every single bind; evaluated from `Draw()` (fresh per-pane width via `coordKickedFor`/`agentKickedFor`), never from `bindPane` itself (whose tracked width can still be 0 for a pane not yet shown, e.g. the agent pane during details mode); BUG-076 (false "Session not running" after ordinary rail nav) — root cause was `handleSessionExitUI`'s post-kick auto-restart gate checking ONLY `a.mode==modeAgent`, never true on the Hera tab, so every BUG-074 size-drift kick fired from a Hera pane genuinely stopped the session and then always skipped the restart as "user navigated away"; fixed via `App.isViewingTaskSession`/`HeraPage.IsBoundToTask` recognizing the Hera-tab-with-bound-pane case too; needs-input sharpened to suppress on sustained activity (narrow-needs-input-sustained-active: `RoleView.SustainedActive`, per-TASK not per-role so a dual-bound sub-coordinator's stale-blocked worker hat is suppressed by the coordinator hat's own sustained activity with no per-hat lookup; `needsInputOwn()` checks it first; fed via a NEW `agent.SustainedActivityTick` grace-tolerant sibling of `ResumeActivityTick`, not a change to `ResumeActivityTick` itself) | 171 | | [gotchas/messaging.md](gotchas/messaging.md) | task_messages caps (64 KiB / 500 unread / 50/min), self-send rejection, recipient existence check, reliable-notify delivery (single-writer, Ctrl+U pre-clear, CR not LF, 5-min deadline, ack-cancels), archive cleanup, task_ask polling, REST send; hera M2 (read_at NULL invariant, enqueue-time delivery stamp, hera: delivery-ID prefix, eager inbox cancel, doorbell trust boundary, worker-done-must-not-archive-role) | 33 | | [gotchas/remote-tui.md](gotchas/remote-tui.md) | `--remote URL --token` mode: apiclient + apistore architecture, two compile-time assertions, four TUI sites that type-assert to *db.DB for local-only ops, raw endpoints for full model.Task round-trip, 30s config refresher, daemon-admin actions that don't apply remotely | 13 | -| [gotchas/events.md](gotchas/events.md) | Events ring + SSE stream: emission-outside-mu invariant, subscribe-before-snapshot fencing, sink save/restore in tests, ring eviction shape, idle watcher unconditional-run, task.completed double-emit, session.needs_input daemon-authoritative idle-gated sticky watcher, never-idle-parked-prompt flagged via content-stability fingerprint (BUG-032, streaming false-positive guard), emulated-screen detection for cursor-addressed alt-screen prompts (BUG-033, ScreenRenderer reuse-via-RIS, raw fast-path + emulate-on-miss), needs-input sticky flag clears on input-delivered-or-archive not signal-decay (BUG-034, shared agent.NeedsInputClear + needsInputSince baseline; clear filter reads LastUserInput NOT LastInput — system reliable-notify delivery uses WriteInputSystem so it never clears a parked worker's autonomous (?), the BUG-034 regression fix), never-idle pass flags free-text endsInQuestion gated on the working-affordance ("esc to interrupt") being ABSENT — content-stability ALONE re-breaks BUG-032 (BUG-035 GAP A, agent.AwaitingInputFingerprint replaces SelectionPromptFingerprint); selection matches any numbered option + wording-tolerant chooser footer (BUG-035 GAP B), content-aware idle for fullscreen agents (agent.ContentIdle emulated-screen stability + working-affordance gate, parallel to Session.IsIdle NOT folded into it; idle-push once-on-transition via shouldFireIdlePush cycle gate; RoleView.SessionIdle suppresses the rail spinner, BUG-036), never-converging content fingerprint escalates via a bounded consecutive-tick counter rather than loosening the chrome allowlist (BUG-029, agent.ParkedSelectionSignal + agent.EscalateParkedSelection, NeedsInputEscalationTicks=8, separate path from ContentFingerprint itself), escalation counter's original all-or-nothing reset was fragile against an isolated single-tick detection miss (blinking cursor glyph, or a torn read racing the daemon's concurrent log-file writer) — a genuinely, continuously-parked hera worker could never reach the threshold, explaining a live "first sibling flags reliably, later siblings under the same coordinator never do" repro; fixed via a one-tick grace period (negative-sentinel encoding, `escalated` stays true through an already-past-threshold grace tick to avoid flicker) rather than loosening detection itself (BUG-060), a fixed-size tail window can be PERMANENTLY (not just occasionally) flooded by Claude's blinking-cursor redraw until real content falls out of reach — deterministic 100%-miss confirmed via live repro, not a torn read (BUG-061, agent.SubstantiveTail expand-on-degenerate-tail read + degenerateSuffixStart raw-byte periodicity trim, wired into both the TUI disk-log read and the push watcher's ring-buffer read; sticky carry-forward in both detectNeedsInputSticky and computeNeedsInput no longer re-requires a fresh tail match, agent.NeedsInputClear is the only clear path), no hera adopt/reconcile loop on the ring, a cleared flag can be PERMANENTLY re-stuck by a stale re-candidacy after a candidacy gap (BUG-063, NeedsInputClear baseline forgotten the instant a task drops out of `candidates` even for one tick; fixed via a `running`-scoped cleared-marker (`prevCleared`/`newCleared`) that survives the gap and suppresses a same-timestamp re-candidacy; accepted scope limit: can't distinguish stale content from a genuinely distinct second prompt at the same timestamp), a hera coordinator's relayed answer (WriteInputSystem) could never clear the flag through BUG-034's own user-input path even after the worker demonstrably resumed real work (BUG-065, NeedsInputClear gained a third `resumedOf` clear condition fed by agent.ResumeActivityTick — mirrors EscalateParkedSelection but tracks sustained "working"-affordance ticks with no grace period on a miss, since under-clearing is safe but a false clear is not), a role's SELF-REPORTED hera_status="blocked" is a wholly separate signal ORed into the same rail (?) glyph (RoleView.needsInputOwn) with no auto-clear of its own — set only by an explicit hera_status tool call or manual s/S, so a direct pane reply never cleared it (BUG-066, agent.ClearBlockedRoleStatus: direct-reply-after-blockedAt clears immediately with no threshold, OR the same BUG-065 resumed-activity signal for a coordinator-relayed answer; db.ListBlockedHeraRoleBindings/ClearBlockedRoleStatus read/write split; App.autoClearBlockedHeraRoles + Server.autoClearBlockedHeraRoles run as a separate small pass scoped to the usually-empty blocked set, not folded into computeNeedsInput/detectNeedsInputSticky), BUG-063's own accepted scope limit resurfaces (and is fixed) in a multi-question AskUserQuestion/brainstorm flow — a SEPARATE, pre-existing bug from BUG-066, not a #904 regression, confirmed via a cross-task shared-ScreenRenderer test that disproves contamination (BUG-067, NeedsInputClear gained a fingerprintOf param + ClearedMarker{At,FP,HasFP} replacing the plain timestamp marker so a stale-recandidacy suppression additionally requires matching CONTENT, not just timestamp — a distinct later prompt at the identical lastInputOf timestamp now re-arms instead of being silently swallowed), a worker that resolves its own block and settles into idle FASTER than the resumed-activity threshold had NO clear path at all — stuck until an incidental keystroke (BUG-072, NeedsInputClear gained a fourth `settledOf` clear condition fed by agent.SettleTick — re-runs the SAME idle-gated signal check that raises the flag as a negative/clearing signal, gated on genuine Session.IsIdle() so it can never conflate with BUG-061's flooding hazard, small NeedsInputSettleTicks=2 threshold since idle rules out flooding by construction) | 20 | +| [gotchas/events.md](gotchas/events.md) | Events ring + SSE stream: emission-outside-mu invariant, subscribe-before-snapshot fencing, sink save/restore in tests, ring eviction shape, idle watcher unconditional-run, task.completed double-emit, session.needs_input daemon-authoritative idle-gated sticky watcher, never-idle-parked-prompt flagged via content-stability fingerprint (BUG-032, streaming false-positive guard), emulated-screen detection for cursor-addressed alt-screen prompts (BUG-033, ScreenRenderer reuse-via-RIS, raw fast-path + emulate-on-miss), needs-input sticky flag clears on input-delivered-or-archive not signal-decay (BUG-034, shared agent.NeedsInputClear + needsInputSince baseline; clear filter reads LastUserInput NOT LastInput — system reliable-notify delivery uses WriteInputSystem so it never clears a parked worker's autonomous (?), the BUG-034 regression fix), never-idle pass flags free-text endsInQuestion gated on the working-affordance ("esc to interrupt") being ABSENT — content-stability ALONE re-breaks BUG-032 (BUG-035 GAP A, agent.AwaitingInputFingerprint replaces SelectionPromptFingerprint); selection matches any numbered option + wording-tolerant chooser footer (BUG-035 GAP B), content-aware idle for fullscreen agents (agent.ContentIdle emulated-screen stability + working-affordance gate, parallel to Session.IsIdle NOT folded into it; idle-push once-on-transition via shouldFireIdlePush cycle gate; RoleView.SessionIdle suppresses the rail spinner, BUG-036), never-converging content fingerprint escalates via a bounded consecutive-tick counter rather than loosening the chrome allowlist (BUG-029, agent.ParkedSelectionSignal + agent.EscalateParkedSelection, NeedsInputEscalationTicks=8, separate path from ContentFingerprint itself), escalation counter's original all-or-nothing reset was fragile against an isolated single-tick detection miss (blinking cursor glyph, or a torn read racing the daemon's concurrent log-file writer) — a genuinely, continuously-parked hera worker could never reach the threshold, explaining a live "first sibling flags reliably, later siblings under the same coordinator never do" repro; fixed via a one-tick grace period (negative-sentinel encoding, `escalated` stays true through an already-past-threshold grace tick to avoid flicker) rather than loosening detection itself (BUG-060), a fixed-size tail window can be PERMANENTLY (not just occasionally) flooded by Claude's blinking-cursor redraw until real content falls out of reach — deterministic 100%-miss confirmed via live repro, not a torn read (BUG-061, agent.SubstantiveTail expand-on-degenerate-tail read + degenerateSuffixStart raw-byte periodicity trim, wired into both the TUI disk-log read and the push watcher's ring-buffer read; sticky carry-forward in both detectNeedsInputSticky and computeNeedsInput no longer re-requires a fresh tail match, agent.NeedsInputClear is the only clear path), no hera adopt/reconcile loop on the ring, a cleared flag can be PERMANENTLY re-stuck by a stale re-candidacy after a candidacy gap (BUG-063, NeedsInputClear baseline forgotten the instant a task drops out of `candidates` even for one tick; fixed via a `running`-scoped cleared-marker (`prevCleared`/`newCleared`) that survives the gap and suppresses a same-timestamp re-candidacy; accepted scope limit: can't distinguish stale content from a genuinely distinct second prompt at the same timestamp), a hera coordinator's relayed answer (WriteInputSystem) could never clear the flag through BUG-034's own user-input path even after the worker demonstrably resumed real work (BUG-065, NeedsInputClear gained a third `resumedOf` clear condition fed by agent.ResumeActivityTick — mirrors EscalateParkedSelection but tracks sustained "working"-affordance ticks with no grace period on a miss, since under-clearing is safe but a false clear is not), a role's SELF-REPORTED hera_status="blocked" is a wholly separate signal ORed into the same rail (?) glyph (RoleView.needsInputOwn) with no auto-clear of its own — set only by an explicit hera_status tool call or manual s/S, so a direct pane reply never cleared it (BUG-066, agent.ClearBlockedRoleStatus: direct-reply-after-blockedAt clears immediately with no threshold, OR the same BUG-065 resumed-activity signal for a coordinator-relayed answer; db.ListBlockedHeraRoleBindings/ClearBlockedRoleStatus read/write split; App.autoClearBlockedHeraRoles + Server.autoClearBlockedHeraRoles run as a separate small pass scoped to the usually-empty blocked set, not folded into computeNeedsInput/detectNeedsInputSticky), BUG-063's own accepted scope limit resurfaces (and is fixed) in a multi-question AskUserQuestion/brainstorm flow — a SEPARATE, pre-existing bug from BUG-066, not a #904 regression, confirmed via a cross-task shared-ScreenRenderer test that disproves contamination (BUG-067, NeedsInputClear gained a fingerprintOf param + ClearedMarker{At,FP,HasFP} replacing the plain timestamp marker so a stale-recandidacy suppression additionally requires matching CONTENT, not just timestamp — a distinct later prompt at the identical lastInputOf timestamp now re-arms instead of being silently swallowed), a worker that resolves its own block and settles into idle FASTER than the resumed-activity threshold had NO clear path at all — stuck until an incidental keystroke (BUG-072, NeedsInputClear gained a fourth `settledOf` clear condition fed by agent.SettleTick — re-runs the SAME idle-gated signal check that raises the flag as a negative/clearing signal, gated on genuine Session.IsIdle() so it can never conflate with BUG-061's flooding hazard, small NeedsInputSettleTicks=2 threshold since idle rules out flooding by construction), ResumeActivityTick's zero-grace design proven (by its own new test) to never let a genuinely-but-burstily-active session cross its threshold at all — a NEW grace-tolerant sibling `agent.SustainedActivityTick` (one-tick grace mirroring BUG-060, own separate tick-counter, reuses NeedsInputResumeTicks) backs the Hera rail's `RoleView.SustainedActive` display gate ONLY, leaving ResumeActivityTick itself and its BUG-065/BUG-066 callers unchanged (narrow-needs-input-sustained-active) | 21 | | [gotchas/macos-app.md](gotchas/macos-app.md) | Native macOS app (`macos/` SwiftPM): `swift test` silently runs ZERO tests on CLT-only (exits 0, failures "pass") ⇒ suite is an executable target run via `make mac-test`; SwiftPM sandbox can't NEST in an argus agent sandbox ⇒ `--disable-sandbox` on all mac-* targets; swift-testing on a non-test target needs explicit -F/-rpath/-plugin-path probed via FileManager (manifest sandbox forbids subprocesses); `_Concurrency.Task` never bare `Task` (ArgusKit Task model shadows it); stream state machines' streamOpening() re-arms reconnect-on-failure (else retries die forever); subscribe-before-snapshot event fencing client-side (buffer stream → snapshot /api/tasks → drain; resync/unknown re-snapshot never crash); TerminalControllers cached per task ID, pruned only on snapshot disappearance; `bytes.lines` swallows empty lines ⇒ SSE parsed via ByteLineSplitter raw-byte iteration (else no event ever dispatches + spliced-SGR garbage); SwiftTerm TerminalView never takes first responder under SwiftUI hosting ⇒ FocusTakingTerminalView (else terminal is read-only); Ctrl+Z (0x1A) stripped from outbound terminal input ⇒ pure `ArgusKit.TerminalInput.sanitize` called at `TerminalCoordinator.send` (else Claude Code's background-session supervisor orphans the session; TUI parity, swallow-not-remap); `open Foo.app` drops env ⇒ ARGUS_MAC_* hooks need direct binary exec | 12 | ## Cross-cutting Rules diff --git a/internal/agent/needsinput.go b/internal/agent/needsinput.go index de9cf011..ea90f7d0 100644 --- a/internal/agent/needsinput.go +++ b/internal/agent/needsinput.go @@ -849,6 +849,50 @@ func ResumeActivityTick(prevTicks int, workingNow bool) (newTicks int, resumed b return newTicks, newTicks >= NeedsInputResumeTicks } +// SustainedActivityTick is a grace-tolerant sibling of ResumeActivityTick, used +// ONLY by the Hera rail's SustainedActive signal (narrow-needs-input-sustained- +// active) — NOT by NeedsInputClear's resumedOf (BUG-065) or +// autoClearBlockedHeraRoles' per-role blocked-status auto-clear, both of which +// keep calling ResumeActivityTick unchanged. +// +// ResumeActivityTick's zero-grace design is deliberately strict for BUG-065's +// coordinator-relay-answer clear path: clearing a still-genuinely-stuck agent is +// unsafe, so a single non-working tick resets the streak outright (see its own +// doc comment). But TestResumeActivityTick's "mostly-working... never converges +// either" case demonstrates that same strictness never lets a genuinely, +// substantially active agent reach the threshold at all when its content +// classification is bursty — an occasional single-tick miss amid mostly-working +// output (ordinary tool-call-to-tool-call pacing), rather than a session that is +// still genuinely parked — which is exactly the false-positive this signal +// exists to suppress (ground-truth repro: hera role contrib-classifier, active +// 7+ minutes / 30k+ tokens, its content classifier alternating "busy"/"blocked on +// user prompt" within seconds). +// +// Mirrors EscalateParkedSelection's BUG-060 one-tick grace exactly: a single +// ISOLATED miss holds the streak pending the next tick (encoded as a negative +// sentinel, matching EscalateParkedSelection's own encoding) rather than +// discarding it outright; a SECOND consecutive miss while already in grace is a +// genuine break and resets for real. Reuses NeedsInputResumeTicks as the +// threshold — no new dial. +func SustainedActivityTick(prevTicks int, workingNow bool) (newTicks int, sustained bool) { + if workingNow { + streak := prevTicks + if streak < 0 { + streak = -streak // resume the streak a prior isolated miss held in grace + } + newTicks = streak + 1 + return newTicks, newTicks >= NeedsInputResumeTicks + } + if prevTicks > 0 { + // First miss after a streak: hold it in grace rather than discarding — + // confirmed or forgiven by the very next tick. + return -prevTicks, prevTicks >= NeedsInputResumeTicks + } + // prevTicks <= 0: already at zero, or this is the SECOND consecutive miss + // while already in grace — a genuine break, reset for real. + return 0, false +} + // NeedsInputSettleTicks bounds how many CONSECUTIVE ticks a flagged session // must be genuinely RAW-IDLE (Session.IsIdle — no new PTY output, not merely // "not currently generating") with NO current needs-input signal in its tail diff --git a/internal/agent/needsinput_test.go b/internal/agent/needsinput_test.go index 117bca82..c0965bc4 100644 --- a/internal/agent/needsinput_test.go +++ b/internal/agent/needsinput_test.go @@ -702,6 +702,123 @@ func TestResumeActivityTick(t *testing.T) { t.Fatalf("expected no meaningful accumulated credit from sparse working ticks, got %d", ticks) } }) + + // narrow-needs-input-sustained-active: reproduces the contrib-classifier + // ground-truth finding — a role demonstrably, substantially active for many + // minutes (30k+ tokens streamed over 7+ minutes) whose PTY content + // nonetheless periodically LOOKS like a parked selection prompt to the + // content classifier (ux.log showed this exact task's rerender/revive logic + // alternating "busy" / "blocked on user prompt" within seconds, repeatedly). + // Unlike the sparse single-utterance-acknowledgment pattern above (clearly + // still blocked), this models MOSTLY-working output with only an OCCASIONAL + // single-tick miss — the kind of variance ordinary tool-call-to-tool-call + // pacing produces from a genuinely active agent, not a re-parking one. + t.Run("mostly-working output with occasional single-tick misses never converges either (zero grace period)", func(t *testing.T) { + ticks := 0 + var resumed bool + // 40 ticks, one miss every 4th tick (75% "working") — never 5 CONSECUTIVE + // working ticks, so under the current zero-grace design this can run + // indefinitely without ever crossing NeedsInputResumeTicks. + everConverged := false + for i := 0; i < 40; i++ { + working := i%4 != 3 + ticks, resumed = ResumeActivityTick(ticks, working) + if resumed { + everConverged = true + } + } + if everConverged { + t.Fatalf("did not expect convergence under the current zero-grace ResumeActivityTick for a mostly-working (miss every 4th tick) pattern — ticks=%d", ticks) + } + }) +} + +// TestSustainedActivityTick pins the grace-tolerant sibling introduced by +// narrow-needs-input-sustained-active — used ONLY by the Hera rail's +// SustainedActive signal, never by ResumeActivityTick's existing callers. +func TestSustainedActivityTick(t *testing.T) { + t.Run("a sustained working streak resumes exactly at the threshold, not before (matches ResumeActivityTick)", func(t *testing.T) { + ticks := 0 + var sustained bool + for i := 0; i < NeedsInputResumeTicks-1; i++ { + ticks, sustained = SustainedActivityTick(ticks, true) + testutil.Equal(t, sustained, false) + } + testutil.Equal(t, ticks, NeedsInputResumeTicks-1) + ticks, sustained = SustainedActivityTick(ticks, true) + testutil.Equal(t, ticks, NeedsInputResumeTicks) + testutil.Equal(t, sustained, true) + }) + + t.Run("mostly-working output with a miss every 4th tick DOES converge (the fix for finding #3)", func(t *testing.T) { + ticks := 0 + var sustained bool + converged := false + convergedAtTick := -1 + for i := 0; i < 40; i++ { + working := i%4 != 3 + ticks, sustained = SustainedActivityTick(ticks, working) + if sustained && !converged { + converged = true + convergedAtTick = i + } + } + if !converged { + t.Fatalf("expected SustainedActivityTick to converge on a mostly-working (miss every 4th tick) pattern via its one-tick grace, ticks=%d", ticks) + } + if convergedAtTick > 10 { + t.Fatalf("expected prompt convergence (well under 40 ticks), converged at tick %d", convergedAtTick) + } + }) + + t.Run("a single isolated miss is forgiven — the streak resumes rather than resetting", func(t *testing.T) { + ticks := 0 + ticks, _ = SustainedActivityTick(ticks, true) // 1 + ticks, _ = SustainedActivityTick(ticks, true) // 2 + ticks, _ = SustainedActivityTick(ticks, true) // 3 + ticks, sustained := SustainedActivityTick(ticks, false) + if sustained { + t.Fatalf("did not expect sustained on the grace tick itself") + } + // The held streak (3) must resume, not reset to 1, on the very next + // working tick. + ticks, sustained = SustainedActivityTick(ticks, true) + testutil.Equal(t, ticks, 4) + testutil.Equal(t, sustained, false) + ticks, sustained = SustainedActivityTick(ticks, true) + testutil.Equal(t, ticks, 5) + testutil.Equal(t, sustained, true) + }) + + t.Run("two consecutive misses is a genuine break, not grace", func(t *testing.T) { + ticks := 0 + ticks, _ = SustainedActivityTick(ticks, true) // 1 + ticks, _ = SustainedActivityTick(ticks, true) // 2 + ticks, _ = SustainedActivityTick(ticks, true) // 3 + ticks, _ = SustainedActivityTick(ticks, false) // grace (held at -3) + ticks, sustained := SustainedActivityTick(ticks, false) + testutil.Equal(t, ticks, 0) + testutil.Equal(t, sustained, false) + // Must start over from 1, not resume the discarded streak. + ticks, sustained = SustainedActivityTick(ticks, true) + testutil.Equal(t, ticks, 1) + testutil.Equal(t, sustained, false) + }) + + t.Run("a sparse still-genuinely-blocked pattern still never converges despite the grace tolerance", func(t *testing.T) { + // Same anti-false-clear pattern as ResumeActivityTick's sparse test above + // (a brief single-utterance acknowledgment, still genuinely blocked) — the + // one-tick grace must not be generous enough to let this converge. + ticks := 0 + var sustained bool + pattern := []bool{false, true, true, false, false, true, false, true, true, false} + for _, w := range pattern { + ticks, sustained = SustainedActivityTick(ticks, w) + if sustained { + t.Fatalf("sustained on a sparse/non-continuous working pattern despite grace: %v", pattern) + } + } + }) } // TestSettleTick pins the pure step function backing NeedsInputClear's diff --git a/internal/tui/app.go b/internal/tui/app.go index 008b9207..5ecee91a 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -304,6 +304,20 @@ type App struct { // advanced for the (usually empty) set of tasks currently holding a live // hera binding with status=="blocked", not every running session. heraBlockedResume map[string]int + // sustainedActiveTicks carries the agent.SustainedActivityTick counter (a + // grace-tolerant SIBLING of agent.ResumeActivityTick / needsInputResume + // above, tracked independently so its one-tick grace tolerance never leaks + // into BUG-065's strict resumedOf path): consecutive ticks (tolerating a + // single isolated miss) a running task has shown the "working" affordance. + sustainedActiveTicks map[string]int + // sustainedActiveIDs carries this tick's per-task sustained-activity reading + // (agent.SustainedActivityTick, backed by sustainedActiveTicks above) — task + // IDs that have shown several CONSECUTIVE (grace-tolerant) ticks of + // demonstrated "working" content. Fed to HeraPage.SetSustainedActive each + // tick so RoleView.SustainedActive can suppress the rail's "(?)" glyph for a + // genuinely sustained-active role, regardless of task status or a stale + // self-reported `blocked` hera status (narrow-needs-input-sustained-active). + sustainedActiveIDs []string // needsInputScreen re-emulates a session's log tail to the visible screen so // needs-input detection matches the rendered screen, not StripANSI(raw) — // catching fullscreen (alt-screen) prompts whose cursor-addressed glyphs are @@ -2215,6 +2229,46 @@ func (a *App) detectNeedsInputSticky(idleIDs, runningIDs, prevNeedsInput []strin a.needsInputResume = newResume resumedOf := func(id string) bool { return resumed[id] } + // Sustained-active pass for the Hera rail's needs-input display gate + // (narrow-needs-input-sustained-active): a role whose bound task has + // demonstrated several consecutive "working" ticks must never show "(?)", + // regardless of task status or a stale self-reported `blocked` hera status + // on any hat bound to the same task. Computed once per TASK ID here (not + // per role), so two hera roles sharing one live binding's task ID (a + // dual-bound sub-coordinator's parent-orchestrator worker hat and its own + // child-orchestrator coordinator hat) automatically read the identical + // value — no per-role lookup needed. + // + // Uses agent.SustainedActivityTick — a grace-tolerant SIBLING of + // agent.ResumeActivityTick above, not the same call — because + // ResumeActivityTick's zero-grace-period design (deliberately strict for + // BUG-065's coordinator-relay-answer clear path immediately above) never + // lets a genuinely, substantially active-but-bursty session (ordinary + // tool-call-to-tool-call pacing occasionally reading as non-"working") + // cross the threshold at all — see TestResumeActivityTick's "mostly-working + // ... never converges either" case. Tracked in its own tick-counter map + // (a.sustainedActiveTicks), independent of a.needsInputResume, so this + // grace tolerance never leaks into BUG-065's strict path. + newSustainedTicks := make(map[string]int, len(runningIDs)) + sustainedActiveIDs := make([]string, 0, len(runningIDs)) + for _, id := range runningIDs { + tail := readSessionLogTailBytes(id, detectNeedsInputTailBytes) + if len(tail) == 0 { + continue + } + cols, rows := needsInputScreenSize(id) + _, working := agent.ContentIdleFingerprint(a.needsInputScreen, tail, cols, rows) + ticks, sustained := agent.SustainedActivityTick(a.sustainedActiveTicks[id], working) + if ticks != 0 { + newSustainedTicks[id] = ticks + } + if sustained { + sustainedActiveIDs = append(sustainedActiveIDs, id) + } + } + a.sustainedActiveTicks = newSustainedTicks + a.sustainedActiveIDs = sustainedActiveIDs + // Settlement pass (BUG-072): independent of candidacy, mirroring the // resumed-activity pass above, but tracking the OPPOSITE resolution shape — // a session that goes genuinely idle with its CURRENT tail no longer @@ -2640,6 +2694,12 @@ func (a *App) refreshTasksWithIDs(runningIDs, idleIDs []string) { // the App's running list so the rail spinner (IsActive) is gated on a RUNNING // session, not just a live binding — a dead worker never animates. a.heraPage.SetSessionRunning(runningIDs) + // Sustained-active set (narrow-needs-input-sustained-active): the SAME + // per-task agent.ResumeActivityTick reading detectNeedsInputSticky already + // computed this tick, exposed so the rail's needs-input gate can suppress a + // genuinely sustained-active role's "(?)" glyph regardless of task status + // or a stale self-reported `blocked` hera status. + a.heraPage.SetSustainedActive(a.sustainedActiveIDs) a.tasklist.SetPRStates(a.readPRStates()) a.tasklist.SetHeraWorkers(heraWorkers) a.tasklist.SetHeraCoordinators(heraCoordinators) diff --git a/internal/tui/hera/bug024_test.go b/internal/tui/hera/bug024_test.go index 57185130..2a193da1 100644 --- a/internal/tui/hera/bug024_test.go +++ b/internal/tui/hera/bug024_test.go @@ -84,7 +84,7 @@ func TestRail_StatusStepAnchorsCursorAndUpdatesGlyph(t *testing.T) { r := NewRail() rebuild := func() { - m, err := BuildModel(d, nil, nil, nil) + m, err := BuildModel(d, nil, nil, nil, nil) testutil.NoError(t, err) r.SetModel(m) } @@ -123,7 +123,7 @@ func TestRail_StatusStepAnchorsCursorAndUpdatesGlyph(t *testing.T) { // roleViewByID builds the model and returns the RoleView for roleID under orch. func roleViewByID(t *testing.T, d *db.DB, orchID, roleID int64) *RoleView { t.Helper() - m, err := BuildModel(d, nil, nil, nil) + m, err := BuildModel(d, nil, nil, nil, nil) testutil.NoError(t, err) for _, sec := range [][]OrchView{m.Pinned, m.Active, m.Archived} { for i := range sec { diff --git a/internal/tui/hera/bug028_repro_test.go b/internal/tui/hera/bug028_repro_test.go index a98fb39f..5961fd4c 100644 --- a/internal/tui/hera/bug028_repro_test.go +++ b/internal/tui/hera/bug028_repro_test.go @@ -23,7 +23,7 @@ func TestBUG028_PermissionBlockedWorkerRowShowsNeedsInput(t *testing.T) { // at a permission prompt (PTY idle, task not yet finished). seedBoundRole(t, d, orch, "wkr", db.HeraKindWorker, "t-wkr") - m, err := BuildModel(d, map[string]bool{"t-wkr": true}, nil, nil) + m, err := BuildModel(d, map[string]bool{"t-wkr": true}, nil, nil, nil) testutil.NoError(t, err) wkr := roleByName(t, &m, orch, "wkr") @@ -46,7 +46,7 @@ func TestBUG028_CoordinatorlessOrchSurfacesSubtreeNeedsInput(t *testing.T) { // Worker only — no coordinator role (e.g. it was nuked). seedBoundRole(t, d, orch, "wkr", db.HeraKindWorker, "t-wkr") - m, err := BuildModel(d, map[string]bool{"t-wkr": true}, nil, nil) + m, err := BuildModel(d, map[string]bool{"t-wkr": true}, nil, nil, nil) testutil.NoError(t, err) ov := m.OrchByID(orch) @@ -58,7 +58,7 @@ func TestBUG028_CoordinatorlessOrchSurfacesSubtreeNeedsInput(t *testing.T) { // can genuinely ask a fresh question in that state, so "(?)" must persist. flagged := map[string]bool{"t-wkr": true} testutil.NoError(t, d.SetStatus("t-wkr", model.StatusInReview)) - m2, err := BuildModel(d, flagged, nil, nil) + m2, err := BuildModel(d, flagged, nil, nil, nil) testutil.NoError(t, err) testutil.Equal(t, m2.OrchByID(orch).SubtreeNeedsInput, true) @@ -66,7 +66,7 @@ func TestBUG028_CoordinatorlessOrchSurfacesSubtreeNeedsInput(t *testing.T) { // header rollup clears even though the App still names the task in its set. _, err = d.EndHeraBindingsForTask("t-wkr", "exit") testutil.NoError(t, err) - m3, err := BuildModel(d, flagged, nil, nil) + m3, err := BuildModel(d, flagged, nil, nil, nil) testutil.NoError(t, err) testutil.Equal(t, m3.OrchByID(orch).SubtreeNeedsInput, false) } @@ -86,7 +86,7 @@ func TestBUG028_BlockedCoordinatorSurfacesEvenWhenTaskComplete(t *testing.T) { // Coordinator task rolled to complete while its session stays alive + blocked. testutil.NoError(t, d.SetStatus("t-coord", model.StatusComplete)) - m, err := BuildModel(d, map[string]bool{"t-coord": true}, nil, nil) + m, err := BuildModel(d, map[string]bool{"t-coord": true}, nil, nil, nil) testutil.NoError(t, err) cr := m.OrchByID(orch).CoordRole() testutil.Equal(t, cr.TaskStatus, "complete") @@ -112,7 +112,7 @@ func TestBUG028_ExitedWorkerStaysCleared(t *testing.T) { _, err := d.EndHeraBindingsForTask("t-wkr", "exit") // session exited → binding ends testutil.NoError(t, err) - m, err := BuildModel(d, map[string]bool{"t-wkr": true}, nil, nil) // sticky marker lingers + m, err := BuildModel(d, map[string]bool{"t-wkr": true}, nil, nil, nil) // sticky marker lingers testutil.NoError(t, err) testutil.Equal(t, roleByName(t, &m, orch, "wkr").NeedsInput, false) testutil.Equal(t, m.OrchByID(orch).CoordRole().SubtreeNeedsInput, false) diff --git a/internal/tui/hera/model.go b/internal/tui/hera/model.go index e1e4bb6e..72f41fdb 100644 --- a/internal/tui/hera/model.go +++ b/internal/tui/hera/model.go @@ -59,6 +59,17 @@ type RoleView struct { // running" from "dead worker, binding lingering"; the running signal does, and // gates the spinner (IsActive) so a dead worker never animates. SessionRunning bool + // SustainedActive reports whether this role's bound task has demonstrated + // several CONSECUTIVE ticks of genuine working activity (the App's + // agent.ResumeActivityTick debounce — the SAME signal that already clears + // the content-scan needs-input flag, no new threshold). Unlike IsActive + // (single-tick liveness), this requires SUSTAINED activity before it is + // trusted enough to suppress a needs-input signal — see needsInputOwn. + // Computed per TASK (not per role), so two roles sharing one live binding's + // task ID (a dual-bound sub-coordinator's parent-orchestrator worker hat and + // its own child-orchestrator coordinator hat) read the identical value + // (narrow-needs-input-sustained-active). + SustainedActive bool // SubtreeNeedsInput is the needs-input ROLLUP computed by BuildModel's // post-pass: true when this role itself OR any descendant role in its // orchestration subtree (transitively across BRIDGED sub-orchestrators) needs @@ -201,7 +212,22 @@ func (r *RoleView) IsActive() bool { // row render the "(?)" attention glyph: the authoritative per-task needs-input // flag (NeedsInput) OR the role's self-asserted hera `blocked` status. This is // the unit the BuildModel rollup aggregates over a subtree. +// +// SustainedActive is checked FIRST and, when true, suppresses BOTH of the OR'd +// sources unconditionally (narrow-needs-input-sustained-active, sharpening +// BUG-A): "(?)" is meant to mean "genuinely stuck, no path forward without a +// human," and a role that has demonstrated several consecutive ticks of real +// working activity is not stuck — regardless of the bound task's workflow +// status (in_review/ready_to_close) or a stale self-reported `blocked` value +// left over from an earlier phase or a DIFFERENT hat bound to the same +// dual-bound task (SustainedActive is computed per TASK, so it is naturally +// shared across every role bound to that task). A role that is merely IsActive +// (one tick) but not yet SustainedActive is unaffected — this narrowing applies +// only once activity is sustained long enough to be trusted. func (r *RoleView) needsInputOwn() bool { + if r.SustainedActive { + return false + } return r.NeedsInput || (r.HasStatus && r.Status == db.HeraStatusBlocked) } @@ -953,7 +979,15 @@ func (s Selection) KanbanTarget() *OrchView { // fine (no role counts as active). It feeds RoleView.SessionRunning so the // spinner is suppressed for a dead worker whose binding lingers (BUG-C); it does // NOT affect the needs-input rollup. -func BuildModel(r HeraReader, needsInput map[string]bool, sessionIdle map[string]bool, sessionRunning map[string]bool) (Model, error) { +// sustainedActive is the authoritative per-task sustained-activity set (the +// App's agent.ResumeActivityTick debounce — several CONSECUTIVE ticks of +// demonstrated working content), keyed by live argus task ID. nil/empty is fine +// (no role is treated as sustained-active). It feeds RoleView.SustainedActive, +// which suppresses needsInputOwn() unconditionally — regardless of the bound +// task's workflow status or a stale self-reported `blocked` hera status on any +// hat bound to the same task (narrow-needs-input-sustained-active, sharpening +// BUG-A). +func BuildModel(r HeraReader, needsInput map[string]bool, sessionIdle map[string]bool, sessionRunning map[string]bool, sustainedActive map[string]bool) (Model, error) { var m Model if r == nil { return m, nil @@ -1039,7 +1073,7 @@ func BuildModel(r HeraReader, needsInput map[string]bool, sessionIdle map[string if role.NukedAt != nil { continue } - rv := buildRoleView(r, role, roleToBinding, roleToLatest, heraMeta, taskByID, needsInput, sessionIdle, sessionRunning) + rv := buildRoleView(r, role, roleToBinding, roleToLatest, heraMeta, taskByID, needsInput, sessionIdle, sessionRunning, sustainedActive) if role.Kind == db.HeraKindFreelance && role.ArchivedAt == nil && o.ArchivedAt == nil { // Active freelance roles live in their own top-level section. m.Freelance = append(m.Freelance, rv) @@ -1173,7 +1207,7 @@ func (m *Model) orchSubtreeNeedsInput(orchID int64) bool { // buildRoleView projects one db.HeraRole into a RoleView, resolving its live // binding's task, status row, and ready_to_close flag. -func buildRoleView(r HeraReader, role *db.HeraRole, roleToBinding map[int64]*db.HeraBinding, roleToLatest map[int64]*db.HeraBinding, heraMeta map[string]map[string]string, taskByID map[string]*model.Task, needsInput map[string]bool, sessionIdle map[string]bool, sessionRunning map[string]bool) RoleView { +func buildRoleView(r HeraReader, role *db.HeraRole, roleToBinding map[int64]*db.HeraBinding, roleToLatest map[int64]*db.HeraBinding, heraMeta map[string]map[string]string, taskByID map[string]*model.Task, needsInput map[string]bool, sessionIdle map[string]bool, sessionRunning map[string]bool, sustainedActive map[string]bool) RoleView { rv := RoleView{ RoleID: role.ID, OrchID: role.OrchestratorID, @@ -1215,6 +1249,14 @@ func buildRoleView(r HeraReader, role *db.HeraRole, roleToBinding map[int64]*db. // does not end on session exit, so rv.Live alone would still spin a dead // worker; gating IsActive on SessionRunning excludes it. rv.SessionRunning = sessionRunning[taskID] + // Sustained-active (narrow-needs-input-sustained-active): the App's + // per-tick agent.ResumeActivityTick debounce, keyed by live task. Suppresses + // needsInputOwn() unconditionally — see RoleView.SustainedActive. Two roles + // sharing this SAME taskID (a dual-bound sub-coordinator's parent-orchestrator + // worker hat and its own child-orchestrator coordinator hat) read the + // identical value, so a stale blocked flag on one hat is suppressed the + // moment the shared session demonstrates sustained activity. + rv.SustainedActive = sustainedActive[taskID] // Own needs-input from the authoritative App-tick set (keyed by live task). // The App's needsInputIDs scan is content-aware (post-BUG-032/034/035): a // task is in the set only while it shows a CURRENT awaiting-input signal, diff --git a/internal/tui/hera/model_sustainedactive_test.go b/internal/tui/hera/model_sustainedactive_test.go new file mode 100644 index 00000000..6abda90b --- /dev/null +++ b/internal/tui/hera/model_sustainedactive_test.go @@ -0,0 +1,141 @@ +package hera + +import ( + "testing" + "time" + + "github.com/drn/argus/internal/db" + "github.com/drn/argus/internal/model" + "github.com/drn/argus/internal/testutil" +) + +// TestBuildModel_SustainedActiveSuppressesContentNeedsInput is the headline for +// narrow-needs-input-sustained-active: a role whose bound task is SustainedActive +// never shows "(?)", even though the content-scan flag (NeedsInput) is set. +func TestBuildModel_SustainedActiveSuppressesContentNeedsInput(t *testing.T) { + d := memDB(t) + orch := seedOrch(t, d, "orch") + seedBoundRole(t, d, orch, "coord", db.HeraKindCoordinator, "t-coord") + seedBoundRole(t, d, orch, "wkr", db.HeraKindWorker, "t-wkr") + + flagged := map[string]bool{"t-wkr": true} + + // Without SustainedActive, the flag surfaces as before (no regression). + m, err := BuildModel(d, flagged, nil, nil, nil) + testutil.NoError(t, err) + wkr := roleByName(t, &m, orch, "wkr") + testutil.Equal(t, wkr.NeedsInput, true) + testutil.Equal(t, wkr.ShowsNeedsInput(), true) + testutil.Equal(t, coordSubtreeNI(t, &m, orch), true) + + // With SustainedActive on the same task, "(?)" is suppressed even though the + // content-scan flag is still set. + sustained := map[string]bool{"t-wkr": true} + m2, err := BuildModel(d, flagged, nil, nil, sustained) + testutil.NoError(t, err) + wkr2 := roleByName(t, &m2, orch, "wkr") + testutil.Equal(t, wkr2.NeedsInput, true) // raw signal still recorded + testutil.Equal(t, wkr2.SustainedActive, true) + testutil.Equal(t, wkr2.ShowsNeedsInput(), false) + testutil.Equal(t, coordSubtreeNI(t, &m2, orch), false) +} + +// TestBuildModel_SustainedActiveSuppressesBlockedRoleStatus proves the OTHER +// OR'd source of needsInputOwn() — a self-reported `blocked` hera role status — +// is suppressed the same way. +func TestBuildModel_SustainedActiveSuppressesBlockedRoleStatus(t *testing.T) { + d := memDB(t) + orch := seedOrch(t, d, "orch") + seedBoundRole(t, d, orch, "coord", db.HeraKindCoordinator, "t-coord") + wkr := seedBoundRole(t, d, orch, "wkr", db.HeraKindWorker, "t-wkr") + testutil.NoError(t, d.UpsertHeraRoleStatus(wkr.ID, db.HeraStatusBlocked)) + + // Without SustainedActive, a self-reported blocked status surfaces "(?)". + m, err := BuildModel(d, nil, nil, nil, nil) + testutil.NoError(t, err) + rv := roleByName(t, &m, orch, "wkr") + testutil.Equal(t, rv.HasStatus, true) + testutil.Equal(t, rv.Status, db.HeraStatusBlocked) + testutil.Equal(t, rv.ShowsNeedsInput(), true) + + // SustainedActive on the same task suppresses it. + sustained := map[string]bool{"t-wkr": true} + m2, err := BuildModel(d, nil, nil, nil, sustained) + testutil.NoError(t, err) + rv2 := roleByName(t, &m2, orch, "wkr") + testutil.Equal(t, rv2.Status, db.HeraStatusBlocked) // raw ladder value unchanged + testutil.Equal(t, rv2.SustainedActive, true) + testutil.Equal(t, rv2.ShowsNeedsInput(), false) +} + +// TestBuildModel_SustainedActiveSuppressesAcrossDualBoundHats is the ground-truth +// repro: a task holds TWO live hera bindings (a parent-orchestrator worker-kind +// role and a child-orchestrator coordinator-kind role, per +// agent.MaterializeHeraSubCoordinator's dual-bound sub-coordinator shape). One +// hat carries a stale self-reported `blocked` status left over from an earlier +// phase; the OTHER hat is the one whose session is demonstrably, currently +// active. Because SustainedActive is computed per TASK (shared taskID), not per +// role, BOTH roles read the identical value — the stale blocked hat is +// suppressed WITHOUT any code needing to look up the other binding. +func TestBuildModel_SustainedActiveSuppressesAcrossDualBoundHats(t *testing.T) { + d := memDB(t) + parentOrch := seedOrch(t, d, "ai-swot") + childOrch := seedOrch(t, d, "contrib-classifier") + + const sharedTask = "t-shared" + testutil.NoError(t, d.Add(&model.Task{ID: sharedTask, Name: sharedTask, Status: model.StatusInProgress, Project: "p", CreatedAt: time.Now()})) + + workerHat, err := d.CreateHeraRole(db.CreateHeraRoleInput{OrchestratorID: parentOrch, Name: "contribution-classifier", Kind: db.HeraKindWorker, ArgusProject: "p"}) + testutil.NoError(t, err) + _, err = d.CreateHeraBinding(db.CreateHeraBindingInput{RoleID: workerHat.ID, OrchestratorID: parentOrch, ArgusTaskID: sharedTask, WorktreePath: "/wt/" + sharedTask}) + testutil.NoError(t, err) + testutil.NoError(t, d.UpsertHeraRoleStatus(workerHat.ID, db.HeraStatusBlocked)) // stale, left over + + coordHat, err := d.CreateHeraRole(db.CreateHeraRoleInput{OrchestratorID: childOrch, Name: "coord", Kind: db.HeraKindCoordinator, ArgusProject: "p"}) + testutil.NoError(t, err) + _, err = d.CreateHeraBinding(db.CreateHeraBindingInput{RoleID: coordHat.ID, OrchestratorID: childOrch, ArgusTaskID: sharedTask, WorktreePath: "/wt/" + sharedTask}) + testutil.NoError(t, err) + testutil.NoError(t, d.UpsertHeraRoleStatus(coordHat.ID, db.HeraStatusWorking)) // genuinely active hat + + // Task rolled to in_review (mirrors the ground-truth daemon-bounce finding); + // irrelevant to needsInputOwn either way, included for realism. + testutil.NoError(t, d.SetStatus(sharedTask, model.StatusInReview)) + + // Without SustainedActive: the stale blocked worker hat surfaces "(?)". + m, err := BuildModel(d, nil, nil, nil, nil) + testutil.NoError(t, err) + workerRV := roleByName(t, &m, parentOrch, "contribution-classifier") + testutil.Equal(t, workerRV.ShowsNeedsInput(), true) + + // With the shared task SustainedActive: BOTH hats suppress "(?)" — the + // worker hat's stale blocked status included — with no per-hat logic. + sustained := map[string]bool{sharedTask: true} + m2, err := BuildModel(d, nil, nil, nil, sustained) + testutil.NoError(t, err) + workerRV2 := roleByName(t, &m2, parentOrch, "contribution-classifier") + coordRV2 := roleByName(t, &m2, childOrch, "coord") + testutil.Equal(t, workerRV2.SustainedActive, true) + testutil.Equal(t, coordRV2.SustainedActive, true) + testutil.Equal(t, workerRV2.ShowsNeedsInput(), false) + testutil.Equal(t, coordRV2.ShowsNeedsInput(), false) +} + +// TestBuildModel_SustainedActiveDoesNotMaskUnrelatedIdleBlocked confirms no +// regression: a role that is genuinely idle/blocked with no demonstrated +// sustained activity on ITS OWN task still shows "(?)" exactly as before — the +// suppression is per-task, not global. +func TestBuildModel_SustainedActiveDoesNotMaskUnrelatedIdleBlocked(t *testing.T) { + d := memDB(t) + orch := seedOrch(t, d, "orch") + seedBoundRole(t, d, orch, "coord", db.HeraKindCoordinator, "t-coord") + blockedWkr := seedBoundRole(t, d, orch, "blocked-wkr", db.HeraKindWorker, "t-blocked") + testutil.NoError(t, d.UpsertHeraRoleStatus(blockedWkr.ID, db.HeraStatusBlocked)) + seedBoundRole(t, d, orch, "other-wkr", db.HeraKindWorker, "t-other") + + // A DIFFERENT task ("t-other") is sustained-active; "t-blocked" is not. + sustained := map[string]bool{"t-other": true} + m, err := BuildModel(d, nil, nil, nil, sustained) + testutil.NoError(t, err) + testutil.Equal(t, roleByName(t, &m, orch, "blocked-wkr").ShowsNeedsInput(), true) + testutil.Equal(t, roleByName(t, &m, orch, "other-wkr").ShowsNeedsInput(), false) +} diff --git a/internal/tui/hera/model_test.go b/internal/tui/hera/model_test.go index ca4bc6d3..83fe2809 100644 --- a/internal/tui/hera/model_test.go +++ b/internal/tui/hera/model_test.go @@ -72,7 +72,7 @@ func seedBoundRole(t *testing.T, d *db.DB, orchID int64, name string, kind db.He } func TestBuildModel_NilReaderEmpty(t *testing.T) { - m, err := BuildModel(nil, nil, nil, nil) + m, err := BuildModel(nil, nil, nil, nil, nil) testutil.NoError(t, err) testutil.Equal(t, m.IsEmpty(), true) } @@ -86,7 +86,7 @@ func TestBuildModel_PopulatesKanbanStatus(t *testing.T) { blocked := seedOrch(t, d, "kb-blocked") testutil.NoError(t, d.SetHeraOrchestratorKanbanStatus(blocked, db.HeraKanbanBlocked)) - m, err := BuildModel(d, nil, nil, nil) + m, err := BuildModel(d, nil, nil, nil, nil) testutil.NoError(t, err) testutil.Equal(t, m.OrchByID(def).KanbanStatus, db.HeraKanbanActive) testutil.Equal(t, m.OrchByID(blocked).KanbanStatus, db.HeraKanbanBlocked) @@ -103,7 +103,7 @@ func TestBuildModel_PartitionsSections(t *testing.T) { archID := seedOrch(t, d, "arch-orch") testutil.NoError(t, d.ArchiveHeraOrchestrator(archID)) - m, err := BuildModel(d, nil, nil, nil) + m, err := BuildModel(d, nil, nil, nil, nil) testutil.NoError(t, err) testutil.Equal(t, len(m.Active), 1) testutil.Equal(t, len(m.Pinned), 1) @@ -132,7 +132,7 @@ func TestBuildModel_FiltersNuked(t *testing.T) { testutil.NoError(t, d.NukeHeraRole(wNuke.ID)) testutil.NoError(t, d.NukeHeraOrchestrator(gone)) - m, err := BuildModel(d, nil, nil, nil) + m, err := BuildModel(d, nil, nil, nil, nil) testutil.NoError(t, err) // The nuked orchestrator is in no section. @@ -184,7 +184,7 @@ func TestBuildModel_MultiBindingFanOut(t *testing.T) { _, err = d.CreateHeraBinding(db.CreateHeraBindingInput{RoleID: roleB.ID, ArgusTaskID: sharedTask, WorktreePath: "/wt/b"}) testutil.NoError(t, err) - m, err := BuildModel(d, nil, nil, nil) + m, err := BuildModel(d, nil, nil, nil, nil) testutil.NoError(t, err) testutil.Equal(t, len(m.Active), 2) @@ -207,7 +207,7 @@ func TestBuildModel_FreelanceHoisted(t *testing.T) { seedBoundRole(t, d, orch, "coord", db.HeraKindCoordinator, "t-coord") seedBoundRole(t, d, orch, "free", db.HeraKindFreelance, "t-free") - m, err := BuildModel(d, nil, nil, nil) + m, err := BuildModel(d, nil, nil, nil, nil) testutil.NoError(t, err) // Coordinator stays under the orchestrator; freelance hoists out. testutil.Equal(t, len(m.Active[0].Roles), 1) @@ -224,7 +224,7 @@ func TestBuildModel_ReadyToCloseAndStatus(t *testing.T) { testutil.NoError(t, d.SetMeta("t-rc", db.HeraMetaNamespace, db.HeraMetaKeyReadyToClose, "true")) testutil.NoError(t, d.UpsertHeraRoleStatus(role.ID, db.HeraStatusWorking)) - m, err := BuildModel(d, nil, nil, nil) + m, err := BuildModel(d, nil, nil, nil, nil) testutil.NoError(t, err) rv := m.Active[0].Roles[0] testutil.Equal(t, rv.ReadyToClose, true) @@ -243,7 +243,7 @@ func TestBuildModel_ContextSize(t *testing.T) { seedBoundRole(t, d, orch, "wkr", db.HeraKindWorker, "t-ctx") testutil.NoError(t, d.SetMeta("t-ctx", db.HeraMetaNamespace, db.HeraMetaKeyContextSize, "48213")) - m, err := BuildModel(d, nil, nil, nil) + m, err := BuildModel(d, nil, nil, nil, nil) testutil.NoError(t, err) rv := m.Active[0].Roles[0] testutil.Equal(t, rv.ContextSize, 48213) @@ -258,7 +258,7 @@ func TestBuildModel_ContextSize_AbsentOrMalformed(t *testing.T) { orch := seedOrch(t, d, "orch") seedBoundRole(t, d, orch, "wkr", db.HeraKindWorker, "t-ctx-absent") - m, err := BuildModel(d, nil, nil, nil) + m, err := BuildModel(d, nil, nil, nil, nil) testutil.NoError(t, err) testutil.Equal(t, m.Active[0].Roles[0].ContextSize, 0) }) @@ -269,7 +269,7 @@ func TestBuildModel_ContextSize_AbsentOrMalformed(t *testing.T) { seedBoundRole(t, d, orch, "wkr", db.HeraKindWorker, "t-ctx-bad") testutil.NoError(t, d.SetMeta("t-ctx-bad", db.HeraMetaNamespace, db.HeraMetaKeyContextSize, "not-a-number")) - m, err := BuildModel(d, nil, nil, nil) + m, err := BuildModel(d, nil, nil, nil, nil) testutil.NoError(t, err) testutil.Equal(t, m.Active[0].Roles[0].ContextSize, 0) }) @@ -282,7 +282,7 @@ func TestBuildModel_BridgeTaskID(t *testing.T) { t.Run("live role: bridge equals live task", func(t *testing.T) { role := seedBoundRole(t, d, orch, "live", db.HeraKindWorker, "t-live") _ = role - m, err := BuildModel(d, nil, nil, nil) + m, err := BuildModel(d, nil, nil, nil, nil) testutil.NoError(t, err) var rv *RoleView for i := range m.Active[0].Roles { @@ -301,7 +301,7 @@ func TestBuildModel_BridgeTaskID(t *testing.T) { testutil.NoError(t, err) testutil.NoError(t, d.EndHeraBinding(bnd.ID, db.HeraEndReasonUserDeleted)) - m, err := BuildModel(d, nil, nil, nil) + m, err := BuildModel(d, nil, nil, nil, nil) testutil.NoError(t, err) var rv *RoleView for i := range m.Active[0].Roles { @@ -500,7 +500,7 @@ func TestBuildModel_PopulatesDetailsFields(t *testing.T) { role := seedBoundRole(t, d, orchID, "coord", db.HeraKindCoordinator, "t-c") testutil.NoError(t, d.UpsertHeraRoleStatus(role.ID, db.HeraStatusWorking)) - m, err := BuildModel(d, nil, nil, nil) + m, err := BuildModel(d, nil, nil, nil, nil) testutil.NoError(t, err) ov := m.Active[0] testutil.Equal(t, ov.CreatedAt.IsZero(), false) @@ -813,7 +813,7 @@ func TestBuildModel_NeedsInputStamped(t *testing.T) { seedBoundRole(t, d, orch, "coord", db.HeraKindCoordinator, "t-coord") seedBoundRole(t, d, orch, "wkr", db.HeraKindWorker, "t-wkr") - m, err := BuildModel(d, map[string]bool{"t-wkr": true}, nil, nil) + m, err := BuildModel(d, map[string]bool{"t-wkr": true}, nil, nil, nil) testutil.NoError(t, err) wkr := roleByName(t, &m, orch, "wkr") testutil.Equal(t, wkr.NeedsInput, true) @@ -822,7 +822,7 @@ func TestBuildModel_NeedsInputStamped(t *testing.T) { testutil.Equal(t, coordSubtreeNI(t, &m, orch), true) // Without the set, nothing flags. - m2, err := BuildModel(d, nil, nil, nil) + m2, err := BuildModel(d, nil, nil, nil, nil) testutil.NoError(t, err) testutil.Equal(t, roleByName(t, &m2, orch, "wkr").NeedsInput, false) testutil.Equal(t, coordSubtreeNI(t, &m2, orch), false) @@ -841,14 +841,14 @@ func TestBuildModel_SessionIdleSuppressesSpinner(t *testing.T) { running := map[string]bool{"t-coord": true, "t-wkr": true} // No content-idle: a live in_progress worker with a running session spins. - m, err := BuildModel(d, nil, nil, running) + m, err := BuildModel(d, nil, nil, running, nil) testutil.NoError(t, err) wkr := roleByName(t, &m, orch, "wkr") testutil.Equal(t, wkr.SessionIdle, false) testutil.Equal(t, wkr.IsActive(), true) // Content-idle for the worker's task → SessionIdle stamped, IsActive false. - m2, err := BuildModel(d, nil, map[string]bool{"t-wkr": true}, running) + m2, err := BuildModel(d, nil, map[string]bool{"t-wkr": true}, running, nil) testutil.NoError(t, err) wkr2 := roleByName(t, &m2, orch, "wkr") testutil.Equal(t, wkr2.SessionIdle, true) @@ -879,7 +879,7 @@ func TestBuildModel_LiveWorkerInReviewSpins(t *testing.T) { // Live in_review worker with a RUNNING session, NOT content-idle (actively // producing) → spins. - m, err := BuildModel(d, nil, nil, running) + m, err := BuildModel(d, nil, nil, running, nil) testutil.NoError(t, err) wkr := roleByName(t, &m, orch, "wkr") testutil.Equal(t, wkr.TaskStatus, model.StatusInReview.String()) @@ -890,7 +890,7 @@ func TestBuildModel_LiveWorkerInReviewSpins(t *testing.T) { // Same live in_review worker, now content-idle (parked/done) → no spinner // (BUG-036 stays safe under the running+content-idle predicate). - m2, err := BuildModel(d, nil, map[string]bool{"t-wkr": true}, running) + m2, err := BuildModel(d, nil, map[string]bool{"t-wkr": true}, running, nil) testutil.NoError(t, err) wkr2 := roleByName(t, &m2, orch, "wkr") testutil.Equal(t, wkr2.SessionIdle, true) @@ -900,7 +900,7 @@ func TestBuildModel_LiveWorkerInReviewSpins(t *testing.T) { // set) whose binding LINGERS (bindings don't end on session exit) stays Live // with an in_review task but MUST NOT spin. This is the case Live && !idle // alone got wrong — the running gate closes it. - m3, err := BuildModel(d, nil, nil, map[string]bool{"t-coord": true}) // t-wkr NOT running + m3, err := BuildModel(d, nil, nil, map[string]bool{"t-coord": true}, nil) // t-wkr NOT running testutil.NoError(t, err) wkr3 := roleByName(t, &m3, orch, "wkr") testutil.Equal(t, wkr3.Live, true) @@ -927,7 +927,7 @@ func TestBuildModel_LiveWorkerInReviewSurfacesNeedsInput(t *testing.T) { flagged := map[string]bool{"t-wkr": true} // In_progress + flagged: surfaces (the always-correct path). - m, err := BuildModel(d, flagged, nil, nil) + m, err := BuildModel(d, flagged, nil, nil, nil) testutil.NoError(t, err) testutil.Equal(t, roleByName(t, &m, orch, "wkr").NeedsInput, true) testutil.Equal(t, coordSubtreeNI(t, &m, orch), true) @@ -936,7 +936,7 @@ func TestBuildModel_LiveWorkerInReviewSurfacesNeedsInput(t *testing.T) { // #707). It is still in the content-aware needsInput set → still genuinely // blocked → "(?)" MUST persist on the row AND roll up to the coordinator. testutil.NoError(t, d.SetStatus("t-wkr", model.StatusInReview)) - m2, err := BuildModel(d, flagged, nil, nil) + m2, err := BuildModel(d, flagged, nil, nil, nil) testutil.NoError(t, err) testutil.Equal(t, roleByName(t, &m2, orch, "wkr").NeedsInput, true) testutil.Equal(t, coordSubtreeNI(t, &m2, orch), true) @@ -961,7 +961,7 @@ func TestBuildModel_ExitedWorkerSuppressesNeedsInput(t *testing.T) { _, err := d.EndHeraBindingsForTask("t-wkr", "exit") testutil.NoError(t, err) - m, err := BuildModel(d, flagged, nil, nil) + m, err := BuildModel(d, flagged, nil, nil, nil) testutil.NoError(t, err) // The worker role is no longer live, so neither its own row nor the ancestor // coordinator's rollup pins "(?)". @@ -978,7 +978,7 @@ func (errReader) ListHeraOrchestrators(bool) ([]*db.HeraOrchestrator, error) { } func TestBuildModel_PropagatesReadError(t *testing.T) { - _, err := BuildModel(errReader{}, nil, nil, nil) + _, err := BuildModel(errReader{}, nil, nil, nil, nil) testutil.Contains(t, errString(err), "boom") } diff --git a/internal/tui/hera/page.go b/internal/tui/hera/page.go index 80329da0..465596e7 100644 --- a/internal/tui/hera/page.go +++ b/internal/tui/hera/page.go @@ -92,6 +92,13 @@ type HeraPage struct { // BuildModel so a dead worker whose binding lingers stops its spinner // (RoleView.SessionRunning → IsActive false; BUG-C). sessionRunning map[string]bool + // sustainedActive is the authoritative per-task sustained-activity set the App + // pushes each tick (the same agent.ResumeActivityTick debounce that already + // clears the content-scan needs-input flag). doRefresh threads it into + // BuildModel so RoleView.SustainedActive can suppress the rail's "(?)" glyph + // for a role whose bound task has demonstrated several consecutive ticks of + // genuine activity (narrow-needs-input-sustained-active). + sustainedActive map[string]bool // tierResolver stamps the diligence-tiering readout (AppliedModel/Effort + // ProfileWarning) onto each RoleView during doRefresh. The App wires it (local @@ -476,6 +483,27 @@ func (p *HeraPage) SetSessionRunning(ids []string) { p.sessionRunning = m } +// SetSustainedActive records the task IDs the App classified as sustained-active +// this tick — several CONSECUTIVE ticks of demonstrated working content +// (agent.ResumeActivityTick's existing debounce, the same signal that already +// clears the content-scan needs-input flag). doRefresh threads it into +// BuildModel so RoleView.SustainedActive can suppress the rail's "(?)" glyph for +// a genuinely sustained-active role, regardless of the bound task's workflow +// status or a stale self-reported `blocked` hera status on any hat bound to the +// same task (narrow-needs-input-sustained-active). Pure setter; the tick +// already schedules the rebuild. MUST run on the tview thread. +func (p *HeraPage) SetSustainedActive(ids []string) { + if len(ids) == 0 { + p.sustainedActive = nil + return + } + m := make(map[string]bool, len(ids)) + for _, id := range ids { + m[id] = true + } + p.sustainedActive = m +} + // SetClipboardHint toggles whether the focused terminal pane advertises a // staged agent clipboard payload via a `(ctrl+y copy)` border-title affordance. // The App refreshes it each tick from the daemon for the focused pane's task @@ -506,7 +534,7 @@ func (p *HeraPage) Refresh() { // remote mode the reader is nil → BuildModel returns an empty model and Draw // renders the unavailable banner, so this stays a cheap no-op. func (p *HeraPage) doRefresh() { - m, err := BuildModel(p.reader, p.needsInput, p.sessionIdle, p.sessionRunning) + m, err := BuildModel(p.reader, p.needsInput, p.sessionIdle, p.sessionRunning, p.sustainedActive) if err != nil { uxlog.Log("[hera-view] rail refresh failed: %v", err) return diff --git a/internal/tui/hera/pin_nonroot_test.go b/internal/tui/hera/pin_nonroot_test.go index 48e856bc..8a98c0a9 100644 --- a/internal/tui/hera/pin_nonroot_test.go +++ b/internal/tui/hera/pin_nonroot_test.go @@ -246,7 +246,7 @@ func TestBuildModel_RoleViewPinned(t *testing.T) { worker := seedBoundRole(t, d, orchID, "leaf", db.HeraKindWorker, "t11") testutil.NoError(t, d.PinHeraRole(worker.ID)) - m, err := BuildModel(d, nil, nil, nil) + m, err := BuildModel(d, nil, nil, nil, nil) testutil.NoError(t, err) var got *RoleView @@ -261,7 +261,7 @@ func TestBuildModel_RoleViewPinned(t *testing.T) { testutil.Equal(t, got.Pinned, true) testutil.NoError(t, d.UnpinHeraRole(worker.ID)) - m2, err := BuildModel(d, nil, nil, nil) + m2, err := BuildModel(d, nil, nil, nil, nil) testutil.NoError(t, err) for i := range m2.Active { for j := range m2.Active[i].Roles { diff --git a/internal/tui/hera/plan_test.go b/internal/tui/hera/plan_test.go index 263cad3a..1850d8df 100644 --- a/internal/tui/hera/plan_test.go +++ b/internal/tui/hera/plan_test.go @@ -28,7 +28,7 @@ func seedPlannedRole(t *testing.T, d *db.DB, orchID int64, name string) *db.Hera // RoleView.Planned discriminator (Stage 2). func orchViewByName(t *testing.T, d *db.DB, name string) *OrchView { t.Helper() - m, err := BuildModel(d, nil, nil, nil) + m, err := BuildModel(d, nil, nil, nil, nil) testutil.NoError(t, err) for _, sec := range [][]OrchView{m.Pinned, m.Active, m.Archived} { for i := range sec { diff --git a/openspec/changes/archive/2026-08-02-narrow-needs-input-sustained-active/.openspec.yaml b/openspec/changes/archive/2026-08-02-narrow-needs-input-sustained-active/.openspec.yaml new file mode 100644 index 00000000..d6589364 --- /dev/null +++ b/openspec/changes/archive/2026-08-02-narrow-needs-input-sustained-active/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-02 diff --git a/openspec/changes/archive/2026-08-02-narrow-needs-input-sustained-active/design.md b/openspec/changes/archive/2026-08-02-narrow-needs-input-sustained-active/design.md new file mode 100644 index 00000000..3f526e89 --- /dev/null +++ b/openspec/changes/archive/2026-08-02-narrow-needs-input-sustained-active/design.md @@ -0,0 +1,71 @@ +## Context + +`RoleView.ShowsNeedsInput()` (`internal/tui/hera/model.go`) is the sole source of the rail/plan-view/Details `(?)` glyph. It reads `needsInputOwn()`: + +```go +func (r *RoleView) needsInputOwn() bool { + return r.NeedsInput || (r.HasStatus && r.Status == db.HeraStatusBlocked) +} +``` + +`r.NeedsInput` is a per-task content-scan flag (`App.detectNeedsInputSticky` → `agent.NeedsInputClear`, fed through `HeraPage.SetNeedsInput` → `BuildModel` → `buildRoleView`, keyed by the role's live binding's argus task ID). `r.Status == HeraStatusBlocked` is the role's OWN self-reported hera status ladder value (per role, not per task). + +Ground-truth investigation (live DB + daemon.log + ux.log for hera role `contrib-classifier`, a nested sub-orchestrator coordinator dual-bound to the same argus task as its parent-orchestrator worker hat `contribution-classifier`) ruled out the working hypothesis that a stale self-reported `blocked` ladder value on one hat was the cause (both hats read `hera_role_status = working`, and the existing `autoClearBlockedHeraRoles`/`ClearBlockedRoleStatus` auto-clear pass is already keyed by shared task ID, not per-role, so it is already cross-hat-safe). The false positive instead traces to `r.NeedsInput`: `agent.ResumeActivityTick`'s sustained-activity clear signal (`resumedOf`, used only inside `agent.NeedsInputClear`) requires 5 CONSECUTIVE ticks of the "working" content affordance with a hard reset to zero on any single non-working tick — and this role's actual output (long narrated numbered findings, periodic decision points) plausibly interrupts that streak often enough that it never reaches 5, even during many minutes of genuine, substantial activity. `ux.log` for the exact task shows its content-classification flip-flopping between "busy" and "blocked on user prompt" within seconds, repeatedly, supporting this. + +Separately (NOT in scope for this change — see Non-Goals), the investigation found that `ready_to_close`/`in_review` on this task's shared workflow status came from a daemon-bounce race (`Daemon.SessionStatus` transiently reporting a supervisor-still-alive session as dead right after a daemon restart, incorrectly firing `RollHeraWorkerToReview`). That is a daemon/supervisor reattach reliability bug, tracked as a documented follow-up, not fixed here. + +## Goals / Non-Goals + +**Goals:** + +- A role that is sustained-active (per-task, debounced, reusing existing machinery) never shows `(?)`, regardless of the bound task's workflow status or a stale self-reported `blocked` value from any hat bound to the same task. +- A genuinely idle, parked-at-a-prompt, or unresolved-`blocked`-with-no-subsequent-activity role is unaffected — no regression on real blocking cases. +- Dual-bound (subcoord) cross-hat scoping is closed BY CONSTRUCTION: the new signal is computed once per TASK (not per role/binding), so any two roles sharing a live binding to the same task automatically share the same sustained-active reading — no separate "look at the other hat" logic is needed. +- Reuse `agent.ResumeActivityTick`'s existing tick machinery as the FIRST cut, unchanged. Only add a scoped grace-period softening if a test demonstrates the zero-grace reset genuinely fails to converge for bursty-but-real activity — and if so, scope it to this new consumer only, without touching `ResumeActivityTick`'s existing semantics (BUG-065's coordinator-relay-answer clear path has its own tuned meaning and its own callers). + +**Non-Goals:** + +- Fixing the daemon-bounce `Daemon.SessionStatus` false-negative race that causes `RollHeraWorkerToReview` to fire on a still-live session. Documented as a gotcha follow-up (`gotchas/daemon-rpc.md`), not fixed here — it is a daemon/supervisor reattach reliability concern, materially bigger and higher-stakes than this display-layer change, and is not required to satisfy the behavior change above (BUG-015's `coordStatusLabel` already renders task-status and role-status as independent, intentionally-decoupled axes; this change only touches the `(?)` glyph). +- Any change to `RollHeraWorkerToReview`, `ReviveHeraWorkerToInProgress`, `hera.ReviveRole`, or the worker-completion lifecycle in general. +- Any change to `agent.ResumeActivityTick`'s existing callers (`agent.NeedsInputClear`'s `resumedOf`, `autoClearBlockedHeraRoles`'s per-role blocked-status auto-clear) beyond, if needed, a scoped grace-period variant used ONLY by the new sustained-active signal. + +## Decisions + +### D1 — Compute sustained-active once per TASK, shared across dual-bound hats + +The existing "resumed" boolean set inside `App.detectNeedsInputSticky` is already computed for every RUNNING task ID, independent of needs-input candidacy — this is exactly the per-task, already-debounced signal needed. It is currently a function-local variable (`resumed := make(map[string]bool)`, used only to build the `resumedOf` closure passed into `agent.NeedsInputClear`). This change exposes it as a new `App` field (mirroring `needsInputResume`/`needsInputSettle`) so the caller (the tick loop) can also feed it to `HeraPage.SetSustainedActive`, threaded through `BuildModel`/`buildRoleView` into `RoleView.SustainedActive` — keyed by the SAME live-binding argus task ID `NeedsInput`/`SessionIdle`/`SessionRunning` already use. Since a dual-bound sub-coordinator's two roles (worker hat + coordinator hat) share the SAME task ID, they automatically read the SAME `SustainedActive` value — no per-hat lookup or "check the other binding" logic required. + +Alternative considered: compute sustained-active independently inside `buildRoleView` from `SessionRunning`/`SessionIdle` (i.e., reuse `IsActive()`'s instantaneous liveness check) instead of threading a new debounced set. Rejected — `IsActive()` has no consecutive-tick requirement, so a single-frame blip of activity would flap the glyph off and back on; the coordinator's own instruction (and the acceptance criteria) explicitly call for the SAME debounce pattern already proven for BUG-065, not a new one. + +### D2 — Gate `needsInputOwn()`, not `ShowsNeedsInput()` or the rollup + +`needsInputOwn()` is the single function both `ShowsNeedsInput()` (own-row glyph) and the `SubtreeNeedsInput` rollup's leaf computation ultimately depend on. Gating here (rather than gating `ShowsNeedsInput()` directly, or gating in `buildRoleView` before either OR'd clause is even read) keeps the change to one function, keeps `r.NeedsInput`/`r.Status` as the raw, ungated signals available for any other future consumer, and mirrors the shape of the existing OR — sustained-active is a THIRD, suppressing condition checked first: + +```go +func (r *RoleView) needsInputOwn() bool { + if r.SustainedActive { + return false + } + return r.NeedsInput || (r.HasStatus && r.Status == db.HeraStatusBlocked) +} +``` + +Alternative considered: suppress upstream in `App`/`detectNeedsInputSticky` so `r.NeedsInput` itself never gets set while sustained-active, and separately suppress the blocked-ladder read in `buildRoleView`. Rejected — two separate suppression sites for what is conceptually ONE invariant ("(?) means genuinely stuck") is more surface area for the two to drift; `needsInputOwn()` is already the documented single choke point (BUG-018, "ShowsNeedsInput reads ONLY the role's own signal"). + +### D3 — First cut reuses `agent.ResumeActivityTick` unchanged; grace-period softening only if a test proves non-convergence + +Per the coordinator's explicit direction: implement the plain reuse first, add a repro test simulating the bursty/narrated-output pattern observed in `ux.log` (an occasional non-"working" tick more often than once every `agent.NeedsInputResumeTicks` (5) ticks) against `agent.ResumeActivityTick`, and only add a scoped grace period (mirroring `EscalateParkedSelection`'s BUG-060 one-tick-grace pattern) if that test demonstrates the streak never reaches threshold. If added, it is a NEW, separately-named step function (not a modification of `ResumeActivityTick` itself) so BUG-065's existing coordinator-relay-answer callers keep their current, deliberately-strict semantics ("a single non-working tick resets the streak outright... the failure mode this guards against — clearing a still-stuck agent — is not [safe]"). + +## Risks / Trade-offs + +- **[Risk]** Widening what "sustained-active" suppresses could, in theory, mask a role that flips from active back to genuinely blocked within the same tick window it was previously marked sustained-active, for a few ticks, until `SustainedActive` itself drops (mirrors the existing `IsActive()` staleness trade-off already accepted for the spinner/BUG-F). → **Mitigation:** `SustainedActive` is read fresh every tick from the same running-session content signal `IsActive()`/`resumedOf` already use — it drops the instant the session goes idle or stops producing, same as every other content-derived signal in this file; under-suppression for a tick or two is the accepted trade-off already made everywhere else in this codebase's needs-input family ("under-clearing... is safe, a false clear is not" — but here a stale-active read that goes stale within a tick is bounded to a single tick, not indefinite). +- **[Risk]** A genuinely-blocked role that ALSO happens to be actively burning tokens on an unrelated background sub-task (rare, but structurally possible for a coordinator with live workers) could have its own `(?)` suppressed by its bound task's sustained activity even though the role itself is the one that's stuck. → **Mitigation:** out of scope for this change — `SustainedActive` is intentionally task/session-scoped (matching `IsActive()`, `SessionRunning`, `SessionIdle` — all already session-scoped, not role-scoped), and a role's PTY session being genuinely busy is itself strong evidence the role is not the one blocked; this mirrors the product ask directly ("if there is no job to do" — an actively-producing PTY is doing SOME job). +- **[Risk]** The deferred daemon-bounce race (Non-Goals) continues to mis-stamp `in_review`/`ready_to_close` on an unlucky daemon restart. → **Mitigation:** documented precisely in `gotchas/daemon-rpc.md` with reproduction evidence (task ID, timestamps, code pointers) so a follow-up worker can pick it up without re-deriving ground truth; explicitly out of scope here per the coordinator's scope call. + +## Migration Plan + +No data migration. Purely a TUI display-logic change (new in-memory per-tick field + one gating function) plus, if triggered, a new pure step function alongside `agent.ResumeActivityTick`. No schema, config, or API changes. Rollback is a plain revert. + +## Open Questions + +None — scope and approach are settled per the coordinator's msg #4174. diff --git a/openspec/changes/archive/2026-08-02-narrow-needs-input-sustained-active/proposal.md b/openspec/changes/archive/2026-08-02-narrow-needs-input-sustained-active/proposal.md new file mode 100644 index 00000000..fae8402f --- /dev/null +++ b/openspec/changes/archive/2026-08-02-narrow-needs-input-sustained-active/proposal.md @@ -0,0 +1,34 @@ +## Why + +The Hera rail's needs-input `(?)` glyph is meant to mean "this agent is genuinely stuck right now, with no path forward without you." Aaron's own words: "(?) interrupts the user from whatever they are doing... I don't want to do that if there is no job to do. Further, if several (?) exist that the user has dismissed, then when one of them or another agent DOES need help it diminishes their likeliness of unblocking an agent that needs it." Ground-truth investigation of a live false positive (hera role `contrib-classifier`, a nested sub-orchestrator coordinator dual-bound to the same argus task as its parent-orchestrator worker hat) confirmed the role was genuinely, continuously active (mid-tool-call, 30k+ tokens streamed over 7+ minutes) while still showing `(?)`. The role's self-reported hera status was `working` on both of its bindings the whole time — not a stale `blocked` ladder value — so the false positive traces to the PTY-content needs-input scan (`RoleView.NeedsInput`) not clearing despite sustained activity, most plausibly because this role's bursty, narrated output repeatedly re-triggers the content classifier's "looks like a parked prompt" signature before `agent.ResumeActivityTick`'s zero-grace-period consecutive-tick counter can sustain the 5-tick threshold needed to clear it. + +False positives are not neutral: they train the operator to dismiss `(?)` on sight, which degrades the signal for every other agent that is genuinely blocked. + +## What Changes + +- A role that is **sustained-active** (per-task, genuinely and continuously producing output — reusing the existing `agent.ResumeActivityTick` debounce, not a new threshold) SHALL NOT show the `(?)` needs-input glyph, regardless of the bound task's workflow status (`in_review`/`ready_to_close`) or a self-reported `blocked` hera role status left over from an earlier phase or a different hat on the same dual-bound task. +- This is threaded as a new per-task signal (`RoleView.SustainedActive`), computed once per tick and shared naturally across any roles bound to the same underlying argus task (including a dual-bound sub-coordinator's worker hat and coordinator hat), the same way `SessionIdle`/`SessionRunning`/`NeedsInput` are already threaded from `App` through `HeraPage`/`BuildModel`/`buildRoleView`. +- `RoleView.needsInputOwn()` (the sole source of the rail's `(?)` glyph, `ShowsNeedsInput`) is gated on this new signal: sustained-active suppresses BOTH of its existing OR'd sources (the content-scan flag and the self-reported `blocked` ladder value) uniformly. +- This deliberately narrows BUG-A's existing, documented invariant ("`(?)` admits any hera-managed role... regardless of task status") — an intentional sharpening, not a regression. A genuinely idle, parked-at-a-prompt, or unresolved-`blocked`-with-no-subsequent-activity role is unaffected and still shows `(?)` exactly as today. +- **Non-goal, explicitly deferred**: a separate daemon-bounce reliability bug was found during this investigation (`Daemon.SessionStatus` can transiently report a supervisor-still-alive session as dead during a daemon restart, incorrectly rolling a hera worker's task to `in_review`/`ready_to_close` via `RollHeraWorkerToReview`). It is documented as a gotcha (see Impact) but is NOT fixed by this change — it is materially bigger and higher-stakes (daemon/supervisor reattach reliability) than this display-layer fix and is not required to satisfy the behavior change above. + +## Capabilities + +### New Capabilities + +(none) + +### Modified Capabilities + +- `hera-view`: narrows the rail/plan-view/Details status-glyph precedence requirement so a role's needs-input `(?)` signal is suppressed while the role is sustained-active, regardless of task status or a self-reported `blocked` hera status from another hat on the same dual-bound task. + +## Impact + +- `internal/tui/app.go`: expose the existing per-task sustained-activity signal (`agent.ResumeActivityTick`'s `resumed` set, currently computed and used only inline within `detectNeedsInputSticky`) as a new tracked set fed to the Hera page each tick. +- `internal/tui/hera/page.go`: new `HeraPage.SetSustainedActive` setter, mirroring `SetNeedsInput`/`SetSessionIdle`/`SetSessionRunning`. +- `internal/tui/hera/model.go`: `BuildModel`/`buildRoleView` gain a fourth per-task map parameter; `RoleView` gains a `SustainedActive` field; `needsInputOwn()` is gated on it. +- Test call sites across `internal/tui/hera/*_test.go` updated for the new `BuildModel` parameter. +- `context/knowledge/gotchas/hera-view.md`: BUG-A entry updated to describe the sharpened invariant. +- `context/knowledge/gotchas/events.md`: note on the new consumer of `agent.ResumeActivityTick`. +- `context/knowledge/gotchas/daemon-rpc.md`: new entry documenting the daemon-bounce `SessionStatus` false-negative race as a known, unfixed follow-up. +- Possible (only if a test demonstrates non-convergence): a minimal grace-period softening scoped to this new consumer of `agent.ResumeActivityTick`, mirroring the existing `EscalateParkedSelection`/BUG-060 pattern, without touching `ResumeActivityTick`'s existing tuned semantics for BUG-065's coordinator-relay-answer use case. diff --git a/openspec/changes/archive/2026-08-02-narrow-needs-input-sustained-active/specs/hera-view/spec.md b/openspec/changes/archive/2026-08-02-narrow-needs-input-sustained-active/specs/hera-view/spec.md new file mode 100644 index 00000000..2f6d17aa --- /dev/null +++ b/openspec/changes/archive/2026-08-02-narrow-needs-input-sustained-active/specs/hera-view/spec.md @@ -0,0 +1,165 @@ +## MODIFIED Requirements + +### Requirement: Status-icon precedence on role rows (area 3) + +The system SHALL choose a role row's status glyph by this precedence: (1) `NeedsInput` — the role's OWN needs-input signal (a PTY prompt or a `blocked` hera role status), UNLESS the role is currently `SustainedActive` (see "Needs-input '(?)' reflects only a role's own signal on every surface") — wins over EVERYTHING, including a role's own `ready_to_close` mark (BUG-A: a role genuinely blocked on a user prompt is the one actionable thing in the subtree, and must never be masked); otherwise (2) GENUINE activity (`RoleView.IsActive` — `Live && SessionRunning && !SessionIdle`, a session/content-derived signal, NOT gated on the bound argus task's status, BUG-C) renders the ACTIVE SPINNER's animated frame (see "Active agents animate a spinner glyph") — this outranks the stale-able resting states below it (BUG-F), because a role producing output again is more current than any of those stamps; otherwise (3) `ready_to_close` renders a distinct review glyph; otherwise (4) an operator/agent-set `failed` hera role status renders a distinct red `✕` (D2, `make-hera-plan-living`), never conflated with `done`; otherwise (5) a `done` hera role status renders its distinct static glyph; otherwise (6) an `idle` hera role status renders the static idle glyph; otherwise (7) binding presence (`Live`) renders a "live" glyph; otherwise (8) an unbound/dimmed glyph. The spinner is sourced from REAL session activity, never the stale `working` hera role status (BUG-003): a `working` role that is not genuinely active falls through to (7)/(8) and renders a static glyph. `ready_to_close` is read from the task-addressed `task_meta` "hera" namespace, not the hera tables. + +`SustainedActive` is a STRICTER, debounced form of activity than `IsActive` (multiple consecutive ticks of demonstrated "working" content, not a single instantaneous reading — see "Needs-input '(?)' reflects only a role's own signal on every surface"). A role that is merely `IsActive` (one tick of genuine activity) but NOT YET `SustainedActive` still has its needs-input signal win per (1) unchanged — this narrowing applies ONLY once activity has been sustained long enough to be trusted, not on the first tick of renewed output. + +This precedence applies identically to a coordinator-shaped row (an orchestrator header, or a bridging worker row that is itself a nested sub-coordinator): its `NeedsInput` term is its OWN signal only, never a descendant's rollup — see "Needs-input '(?)' reflects only a role's own signal on every surface." + +Derived from: `internal/tui/widget/rolestatusicon.go` (`RoleStatusIcon`, `RoleStatusInputs`), `internal/tui/hera/rail.go` (`statusIcon`), `internal/tui/hera/model.go` (`RoleView.IsActive`, `RoleView.SustainedActive`, `RoleView.ShowsNeedsInput`, `buildRoleView` reads `ready_to_close`). + +#### Scenario: Needs-input overrides ready_to_close and everything else + +- **WHEN** a role shows its own needs-input "(?)" signal AND also carries `meta:hera.ready_to_close=true`, and the role is not `SustainedActive` +- **THEN** the row renders the needs-input glyph, not the review/ready glyph + +#### Scenario: ready_to_close overrides a stale working status + +- **WHEN** a role's bound task carries `meta:hera.ready_to_close=true`, the role status is working, and the role is not genuinely active and not in needs-input +- **THEN** the row renders the review/ready glyph, not a spinner + +#### Scenario: Genuine activity renders the animated spinner + +- **WHEN** a role holds a live binding whose session is running and not content-idle, and it is not in needs-input +- **THEN** the row renders the active spinner's frame (animated), not a static glyph — regardless of the bound argus task's status + +#### Scenario: Genuine activity outranks ready_to_close, failed, and done + +- **WHEN** a role is genuinely active (per the previous scenario) AND also carries `ready_to_close`, `failed`, or `done` +- **THEN** the row renders the active spinner, not the resting glyph — the resting glyph returns once the role goes idle or the session ends (BUG-F) + +#### Scenario: Stale working role-status does not animate + +- **WHEN** a role's hera status is `working` but it is not genuinely active (no live binding, the session is not running, or the session is content-idle) +- **THEN** the row renders a static glyph (its resting state, live, or dimmed-unbound), not the spinner + +#### Scenario: Failed renders a distinct glyph + +- **WHEN** a role's hera status is `failed` and it is not in needs-input, not genuinely active, and not ready_to_close +- **THEN** the row renders a distinct red `✕`, never the `done` checkmark + +#### Scenario: Blocked outranks activity, but not sustained activity + +- **WHEN** a role has a status row of `blocked` (a needs-input source) and is not ready_to_close, and the role is genuinely active (`IsActive`) but NOT `SustainedActive` +- **THEN** the row renders the needs-input/blocked glyph (static), not the spinner + +#### Scenario: Sustained activity suppresses a blocked or content-flagged needs-input signal + +- **WHEN** a role is `SustainedActive` (several consecutive ticks of demonstrated working content), regardless of whether its `NeedsInput` content flag or its self-reported `blocked` hera status is also set +- **THEN** the row renders the active spinner, not the needs-input glyph + +#### Scenario: Live-but-statusless role + +- **WHEN** a role holds a live binding but has no status row and is not ready_to_close and not in needs-input +- **THEN** the row renders the in-review "live" glyph rather than the unbound glyph + +#### Scenario: A coordinator's own status glyph is unaffected by a blocked descendant + +- **WHEN** a coordinator role is idle/working/done and is NOT itself in needs-input, but some descendant role in its orchestration subtree IS +- **THEN** the coordinator's row renders its own status glyph (idle/working/done), never the needs-input glyph + +### Requirement: Active agents animate a spinner glyph (area 3) + +The system SHALL render a genuinely-active role's status glyph as an animated spinner frame from the active spinner (`widget.SpinnerFrame`), advancing with the wall-clock frame counter, rather than a static glyph. A role is genuinely active (`RoleView.IsActive`) when it holds a live binding AND its session is RUNNING AND NOT content-idle (`Live && SessionRunning && !SessionIdle`) — sourced from REAL session activity, NOT the hera role `working` status field, and NOT gated on the bound argus task's status (BUG-C). The hera role status is a manual/MCP-set ladder value that never reconciles down (it stays `working` after a session idles, stops, or dies), so it MUST NOT drive the spinner. A dead/stopped session is excluded via the `SessionRunning` gate (BUG-003) — a hera binding does NOT end when its session exits, so liveness alone cannot exclude a dead worker; `SessionRunning` does, since a dead session drops out of the App's running set. + +The content-idle gate fixes a fullscreen (alt-screen) agent parked at its prompt (BUG-036): such an agent repaints continuously, so it never reaches the raw-byte idle set and would otherwise animate the spinner forever even though it is doing nothing. When the App's content-idle signal (the animation-stripped emulated-screen stability classification) marks the role's bound session idle, the role is NOT active and renders a static idle/live glyph (or the needs-input glyph if it is at a prompt, which already outranks the spinner). A genuinely content-ACTIVE agent — emulated content changing tick-to-tick, or showing the "working" affordance — still spins, REGARDLESS of its bound task's status, including a worker deliberately sitting in `in_review` with its session still alive and producing output (BUG-C, #707). + +A role's own needs-input signal (including an operator/agent-set `blocked` assertion) takes precedence over the spinner regardless of the bound task's status (BUG-A) — UNLESS the role has demonstrated `SustainedActive` (several consecutive ticks of the working affordance, debounced via `agent.ResumeActivityTick`; see "Needs-input '(?)' reflects only a role's own signal on every surface"), in which case the spinner wins and needs-input is suppressed. A merely-`IsActive` role (one tick, not yet sustained) does not suppress needs-input — BUG-A's original precedence is preserved until activity is sustained long enough to be trusted. Genuine activity, meanwhile, still OUTRANKS the stale-able resting states below it: `ready_to_close`, `failed`, and `done` no longer take precedence over the spinner (BUG-F) — a role producing output again is more current than any of those stamps, and the resting glyph returns once the role goes idle or its session ends. Non-active states (idle, content-idle, needs-input/blocked without sustained activity, done, ready_to_close, failed, unbound, stopped) remain static. + +Derived from: `internal/tui/widget/rolestatusicon.go` (`RoleStatusIcon`), `internal/tui/hera/rail.go` (`statusIcon`), `internal/tui/hera/model.go` (`RoleView.IsActive`, `RoleView.SustainedActive`, `RoleView.SessionRunning`, `RoleView.SessionIdle`), `internal/tui/widget/spinnerstate.go` (`SpinnerFrame`). + +#### Scenario: Genuinely active role spins + +- **WHEN** a role holds a live binding, its session is running and not content-idle, and it is not in needs-input +- **THEN** its status glyph is the active spinner's frame for the current animation frame, and the glyph differs across frames — regardless of its bound argus task's status + +#### Scenario: Genuine activity outranks ready_to_close, failed, and done + +- **WHEN** a role is genuinely active (live, running, not content-idle) AND also carries `ready_to_close`, `failed`, or `done` +- **THEN** its status glyph is the animated spinner, not the resting glyph (BUG-F) + +#### Scenario: Stale-working stopped role is static + +- **WHEN** a role's hera status is `working` but it holds no live binding, or its session is no longer running (a stopped/dead session) +- **THEN** its status glyph does not animate + +#### Scenario: A live in_review role still spins if genuinely active + +- **WHEN** a role holds a live binding whose bound argus task has left `in_progress` (e.g. an auto-completed coordinator or worker now `in_review`), its session is running, and it is content-active (not content-idle) +- **THEN** its status glyph still animates — the task-status transition alone does not stop the spinner (BUG-C, #707) + +#### Scenario: Content-idle fullscreen role is static + +- **WHEN** a role holds a live binding and a running session, but the App marks its session content-idle (parked fullscreen agent, stable emulated screen, no "working" affordance) +- **THEN** its status glyph does not animate — it renders a static idle/live glyph (or the needs-input glyph if it is at a prompt) + +#### Scenario: Blocked outranks activity until activity is sustained + +- **WHEN** a role's hera status is `blocked` (or it is otherwise in needs-input), and the role is not `SustainedActive` +- **THEN** its status glyph is the needs-input glyph, not the spinner + +#### Scenario: Sustained activity overrides a blocked or content-flagged needs-input signal + +- **WHEN** a role's hera status is `blocked` (or it otherwise carries a needs-input content flag), but the role has demonstrated `SustainedActive` (several consecutive ticks of the working affordance) +- **THEN** its status glyph is the animated spinner, not the needs-input glyph + +#### Scenario: Details coordinator label is honest about stale working + +- **WHEN** the Details pane renders a coordinator whose hera status is `working` but which is not genuinely active +- **THEN** the label reads `live` (binding still alive) or `stopped` (binding gone), not `working` + +### Requirement: Needs-input "(?)" reflects only a role's own signal on every surface (area rail) + +The system SHALL derive the needs-input "(?)" indicator on EVERY coordinator-shaped render surface — the rail's collapsed orchestrator header, a bridging worker row that is itself a nested sub-coordinator, the Details pane's `coordinator:` status line, a Details roster row, and a plan-DAG node icon — EXCLUSIVELY from that role's OWN needs-input signal (`RoleView.needsInputOwn()`), computed identically to how a plain leaf role's own glyph is derived. A descendant role's needs-input state — however deep, and across however many bridged sub-orchestrator levels — SHALL NOT cause any ancestor's own icon to render the needs-input indicator. This holds uniformly across all five surfaces because they share one classifier (`roleStatusInputs`/`widget.RoleStatusIcon`, reading `RoleView.ShowsNeedsInput()`, which returns `needsInputOwn()` alone) — not five independent implementations that could drift. + +`needsInputOwn()` is now gated on a THIRD, suppressing signal: `RoleView.SustainedActive` — whether the role's bound argus task has demonstrated several CONSECUTIVE ticks of genuine working activity (reusing `agent.ResumeActivityTick`'s existing debounced tick-counter machinery, the same one BUG-065's coordinator-relay-answer clear path already relies on — no new threshold). When `SustainedActive` is true, `needsInputOwn()` returns `false` UNCONDITIONALLY — regardless of the task's own workflow status (`in_progress`/`in_review`/`ready_to_close`) and regardless of whether the OR'd content-scan flag (`NeedsInput`) or the self-reported `blocked` hera ladder status is also set. This is an intentional narrowing of the prior "admits any hera-managed role, regardless of task status" invariant (BUG-A): `(?)` is meant to mean "genuinely stuck, no path forward without a human" — a role sustained-active is, by construction, not stuck. + +`SustainedActive` is computed ONCE PER ARGUS TASK (not per role or per binding) from the same signal already used to clear the content-scan flag via `agent.NeedsInputClear`'s `resumedOf`, threaded through `HeraPage.SetSustainedActive` → `BuildModel` → `buildRoleView` exactly like `NeedsInput`/`SessionIdle`/`SessionRunning` already are. Because it is task-scoped rather than role-scoped, TWO roles bound to the SAME live argus task (a dual-bound sub-coordinator's parent-orchestrator worker hat and its own child-orchestrator coordinator hat — see `MaterializeHeraSubCoordinator`) automatically read the IDENTICAL `SustainedActive` value: a self-reported `blocked` hera status stranded on one hat is suppressed the moment the SHARED underlying session demonstrates sustained activity, regardless of which hat's binding is being rendered. No per-hat "check the other binding" logic exists or is needed. + +An orchestrator with NO coordinator role (its coordinator role was deleted/nuked) SHALL NOT surface any needs-input indicator on its header, in any state — there being no "own" signal for such a header to derive from, the fallback that once rendered the rollup directly on this header is removed outright, not narrowed. + +The needs-input ROLLUP COMPUTATION itself (`RoleView.SubtreeNeedsInput`, `OrchView.SubtreeNeedsInput`, populated by `rollupNeedsInput`/`orchSubtreeNeedsInput`) is UNCHANGED by this requirement: it remains transitive across bridged sub-orchestrators, cycle-safe, and excludes archived roles from counting toward an ancestor, exactly as before — it is computed FROM each role's (now sustained-active-gated) own signal, so a sustained-active role's suppressed own-signal also does not contribute to the rollup, but the rollup mechanism and its traversal are otherwise untouched. Its role is narrowed, not removed — it exists solely to gate the partial-fold-reveal mechanism (deciding which specific closed-fold descendant rows to peek through), never to drive any icon's display directly anymore. A genuinely blocked (non-sustained-active) descendant therefore remains fully visible — as its OWN row, peeked through any number of closed ancestor folds — via "Rail reveals the ancestor path to a hidden needs-input descendant through closed folds," which this requirement does not change and does not duplicate. + +Derived from: `internal/tui/hera/model.go` (`RoleView.ShowsNeedsInput` returns `needsInputOwn()` alone; `RoleView.SustainedActive`; `RoleView.SubtreeNeedsInput`, `OrchView.SubtreeNeedsInput`, `rollupNeedsInput`, `orchSubtreeNeedsInput` unchanged traversal), `internal/tui/app.go` (the per-task sustained-active set exposed from `detectNeedsInputSticky`'s existing `agent.ResumeActivityTick` pass), `internal/tui/hera/page.go` (`HeraPage.SetSustainedActive`), `internal/tui/hera/rail.go` (`statusIcon`/`roleStatusInputs` read `ShowsNeedsInput`; `drawOrchRow`'s coordinator-less fallback branch removed; the reveal gates in `appendOrch`/`appendOrchWorkers`/`appendOrchRevealPath`/`appendWorkerRow`/`appendPinnedRole` unchanged), `internal/tui/hera/details.go` (`coordinator:` status line and `rosterStatusText`/`drawRosterRow`, both reading the same shared classifier, unchanged code), `internal/tui/hera/plan.go` (`planNodeIcon`, unchanged code, reads the same shared classifier). + +#### Scenario: A blocked worker's own row shows "(?)"; its ancestor coordinators do not + +- **WHEN** a worker two or more bridged sub-orchestrator levels below the root is blocked on a prompt, and no coordinator in the chain is itself blocked +- **THEN** the worker's own row renders "(?)", and every intervening sub-coordinator's row and the root coordinator's header render their OWN status glyphs, never "(?)" + +#### Scenario: A coordinator-less orchestrator header never shows "(?)", even with a blocked descendant + +- **WHEN** a collapsed orchestrator has a blocked (needs-input) worker in its subtree but no coordinator role (e.g. the coordinator was nuked) +- **THEN** the orchestrator header renders no needs-input indicator, regardless of the descendant's state + +#### Scenario: A blocked descendant remains reachable via the closed-fold reveal despite no header glyph + +- **WHEN** a coordinator's fold is collapsed and a descendant several levels down is blocked, and the coordinator itself is not +- **THEN** the coordinator's header shows its own status glyph (not "(?)"), while the specific blocked descendant's row is still rendered, peeked through the closed fold, exactly as the reveal mechanism already provides + +#### Scenario: A coordinator's own needs-input signal still surfaces on its header regardless of descendants + +- **WHEN** a coordinator role is itself blocked on a prompt (own signal) and is NOT `SustainedActive`, independent of whatever state its descendants are in +- **THEN** the coordinator's header renders the needs-input "(?)" indicator, exactly as any other role's own signal would + +#### Scenario: The Details status line and roster follow the same own-signal-only rule + +- **WHEN** the Details pane is showing a coordinator whose own signal is clear but which has a blocked descendant, and its roster includes a bridging worker row that is itself a nested sub-coordinator with a blocked descendant but no own signal +- **THEN** neither the `coordinator:` status line nor that roster row renders the needs-input glyph or the `"needs-input"` text label + +#### Scenario: A sustained-active role never shows "(?)", regardless of task status or a stale blocked flag + +- **WHEN** a role's bound argus task is `SustainedActive` (several consecutive ticks of demonstrated working content), AND the role's bound task carries `in_review`/`meta:hera.ready_to_close=true`, AND/OR the role's own hera status is a stale self-reported `blocked` value +- **THEN** the role's row renders no needs-input "(?)" indicator on any of the five coordinator-shaped surfaces — the active spinner (or another lower-precedence glyph) renders instead + +#### Scenario: A dual-bound sub-coordinator's stale blocked hat is suppressed by the other hat's sustained activity + +- **WHEN** an argus task holds two live hera bindings (a parent-orchestrator worker-kind role and a child-orchestrator coordinator-kind role, per `MaterializeHeraSubCoordinator`), one of those roles carries a stale self-reported `blocked` hera status, and the SHARED underlying session is `SustainedActive` +- **THEN** NEITHER role's row renders the needs-input "(?)" indicator — the shared per-task `SustainedActive` signal suppresses both, without any code needing to look up the other role's binding + +#### Scenario: A genuinely idle or unresolved-blocked role with no subsequent activity still shows "(?)" + +- **WHEN** a role's own needs-input signal is set (content flag or self-reported `blocked`) and the role's bound task has NOT demonstrated `SustainedActive` since +- **THEN** the role's row renders the needs-input "(?)" indicator exactly as before this change — no regression on genuine blocking cases diff --git a/openspec/changes/archive/2026-08-02-narrow-needs-input-sustained-active/tasks.md b/openspec/changes/archive/2026-08-02-narrow-needs-input-sustained-active/tasks.md new file mode 100644 index 00000000..c19ca94a --- /dev/null +++ b/openspec/changes/archive/2026-08-02-narrow-needs-input-sustained-active/tasks.md @@ -0,0 +1,41 @@ +## 1. Sustained-active signal (App) + +- [x] 1.1 Expose the `resumed` set already computed inside `App.detectNeedsInputSticky` (currently a function-local closure feeding `agent.NeedsInputClear`'s `resumedOf`) as a new tracked field on `App`, mirroring `needsInputResume`/`needsInputSettle`. +- [x] 1.2 At the tick call site, feed the exposed set to `HeraPage.SetSustainedActive`. + +## 2. Threading through the Hera model + +- [x] 2.1 Add `HeraPage.SetSustainedActive(ids []string)` mirroring `SetNeedsInput`/`SetSessionIdle`/`SetSessionRunning`. +- [x] 2.2 Add a `sustainedActive map[string]bool` param to `BuildModel` and `buildRoleView`; wire `page.go`'s `doRefresh`/rebuild call site. +- [x] 2.3 Add `RoleView.SustainedActive bool`, populated in `buildRoleView` from the new map keyed by the role's live-binding argus task ID (same live-binding branch as `NeedsInput`/`SessionIdle`/`SessionRunning`). +- [x] 2.4 Update every existing `BuildModel(...)` call site across `internal/tui/hera/*_test.go` for the new fourth parameter (pass `nil` unless the test needs it). + +## 3. Gate needs-input on sustained activity + +- [x] 3.1 Update `RoleView.needsInputOwn()` to return `false` unconditionally when `SustainedActive` is true, before evaluating the existing `NeedsInput`/`blocked`-status OR. + +## 4. Convergence test + optional grace-period softening + +- [x] 4.1 Add a test against `agent.ResumeActivityTick` reproducing the bursty/narrated-output pattern from `ux.log` (an occasional non-"working" tick more often than once every `agent.NeedsInputResumeTicks` ticks) and assert whether the consecutive-tick streak ever reaches threshold. +- [x] 4.2 If 4.1 demonstrates non-convergence: add a new, separately-named step function (NOT a modification of `ResumeActivityTick`) with a minimal one-tick grace period mirroring `EscalateParkedSelection`/BUG-060, used only by the new sustained-active signal; wire it in place of `ResumeActivityTick` for that consumer only. If 4.1 converges fine, skip this step. + +## 5. Tests for the behavior change + +- [x] 5.1 Test: a role with `NeedsInput=true` and `SustainedActive=true` does not show `(?)` (`ShowsNeedsInput()` false). +- [x] 5.2 Test: a role with a self-reported `blocked` hera status and `SustainedActive=true` does not show `(?)`. +- [x] 5.3 Test: a dual-bound task (worker-hat role + coordinator-hat role sharing one live binding's task ID) where one role's hera status is stale `blocked` — with the shared task's `SustainedActive=true` — suppresses `(?)` on BOTH roles. +- [x] 5.4 Test: a genuinely idle or blocked role with `SustainedActive=false` still shows `(?)` exactly as before (no regression). +- [x] 5.5 Test: `SustainedActive=true` plus `in_review`/`ready_to_close` task status still suppresses `(?)` (task status was already irrelevant to `ShowsNeedsInput`, confirm it stays that way). +- [x] 5.6 Update/extend existing BUG-A tests (`TestBuildModel_LiveWorkerInReviewSurfacesNeedsInput`, `TestBUGA_Integration_LiveInReviewWorkerAtPromptSurfaces`, etc.) to pass `sustainedActive=nil`/`false` where they assert the PRE-existing behavior still holds unchanged, and add the new sharpened-invariant coverage alongside rather than replacing them. + +## 6. Documentation + +- [x] 6.1 Update `context/knowledge/gotchas/hera-view.md`'s BUG-A entry to describe the sharpened invariant (sustained-active suppresses needs-input regardless of task status or a stale blocked flag from another hat on a dual-bound task). +- [x] 6.2 Update `context/knowledge/gotchas/events.md` to note the new consumer of `agent.ResumeActivityTick`'s tick machinery (the Hera rail's `SustainedActive`, alongside the existing `resumedOf`/`heraBlockedResume` consumers). +- [x] 6.3 Add a new entry to `context/knowledge/gotchas/daemon-rpc.md` documenting the daemon-bounce `Daemon.SessionStatus` false-negative race found during this investigation (task 1785216680765732000, the 10:51:08.948 timestamp trail, `isSessionAlive`/`Daemon.SessionStatus` vs `reattachSupervised`) as a known, unfixed follow-up — explicitly out of scope for this change. + +## 7. Verification + +- [x] 7.1 Run `make pre-pr` clean. +- [x] 7.2 Archive this change (merge the delta spec into `openspec/specs/hera-view/spec.md`, move the change folder to `openspec/changes/archive/`) within the same PR before merge. +- [x] 7.3 Open the PR via `mcp__argus__iris_gh_pr_create`. diff --git a/openspec/specs/hera-view/spec.md b/openspec/specs/hera-view/spec.md index 73c5fb72..3e1bfa65 100644 --- a/openspec/specs/hera-view/spec.md +++ b/openspec/specs/hera-view/spec.md @@ -148,15 +148,17 @@ Derived from: `internal/tui/hera/rail.go` (`drawRow`, `drawOrchRow`, `drawRoleRo ### Requirement: Status-icon precedence on role rows (area 3) -The system SHALL choose a role row's status glyph by this precedence: (1) `NeedsInput` — the role's OWN needs-input signal (a PTY prompt or a `blocked` hera role status) — wins over EVERYTHING, including a role's own `ready_to_close` mark (BUG-A: a role genuinely blocked on a user prompt is the one actionable thing in the subtree, and must never be masked); otherwise (2) GENUINE activity (`RoleView.IsActive` — `Live && SessionRunning && !SessionIdle`, a session/content-derived signal, NOT gated on the bound argus task's status, BUG-C) renders the ACTIVE SPINNER's animated frame (see "Active agents animate a spinner glyph") — this outranks the stale-able resting states below it (BUG-F), because a role producing output again is more current than any of those stamps; otherwise (3) `ready_to_close` renders a distinct review glyph; otherwise (4) an operator/agent-set `failed` hera role status renders a distinct red `✕` (D2, `make-hera-plan-living`), never conflated with `done`; otherwise (5) a `done` hera role status renders its distinct static glyph; otherwise (6) an `idle` hera role status renders the static idle glyph; otherwise (7) binding presence (`Live`) renders a "live" glyph; otherwise (8) an unbound/dimmed glyph. The spinner is sourced from REAL session activity, never the stale `working` hera role status (BUG-003): a `working` role that is not genuinely active falls through to (7)/(8) and renders a static glyph. `ready_to_close` is read from the task-addressed `task_meta` "hera" namespace, not the hera tables. +The system SHALL choose a role row's status glyph by this precedence: (1) `NeedsInput` — the role's OWN needs-input signal (a PTY prompt or a `blocked` hera role status), UNLESS the role is currently `SustainedActive` (see "Needs-input '(?)' reflects only a role's own signal on every surface") — wins over EVERYTHING, including a role's own `ready_to_close` mark (BUG-A: a role genuinely blocked on a user prompt is the one actionable thing in the subtree, and must never be masked); otherwise (2) GENUINE activity (`RoleView.IsActive` — `Live && SessionRunning && !SessionIdle`, a session/content-derived signal, NOT gated on the bound argus task's status, BUG-C) renders the ACTIVE SPINNER's animated frame (see "Active agents animate a spinner glyph") — this outranks the stale-able resting states below it (BUG-F), because a role producing output again is more current than any of those stamps; otherwise (3) `ready_to_close` renders a distinct review glyph; otherwise (4) an operator/agent-set `failed` hera role status renders a distinct red `✕` (D2, `make-hera-plan-living`), never conflated with `done`; otherwise (5) a `done` hera role status renders its distinct static glyph; otherwise (6) an `idle` hera role status renders the static idle glyph; otherwise (7) binding presence (`Live`) renders a "live" glyph; otherwise (8) an unbound/dimmed glyph. The spinner is sourced from REAL session activity, never the stale `working` hera role status (BUG-003): a `working` role that is not genuinely active falls through to (7)/(8) and renders a static glyph. `ready_to_close` is read from the task-addressed `task_meta` "hera" namespace, not the hera tables. + +`SustainedActive` is a STRICTER, debounced form of activity than `IsActive` (multiple consecutive ticks of demonstrated "working" content, not a single instantaneous reading — see "Needs-input '(?)' reflects only a role's own signal on every surface"). A role that is merely `IsActive` (one tick of genuine activity) but NOT YET `SustainedActive` still has its needs-input signal win per (1) unchanged — this narrowing applies ONLY once activity has been sustained long enough to be trusted, not on the first tick of renewed output. This precedence applies identically to a coordinator-shaped row (an orchestrator header, or a bridging worker row that is itself a nested sub-coordinator): its `NeedsInput` term is its OWN signal only, never a descendant's rollup — see "Needs-input '(?)' reflects only a role's own signal on every surface." -Derived from: `internal/tui/widget/rolestatusicon.go` (`RoleStatusIcon`, `RoleStatusInputs`), `internal/tui/hera/rail.go` (`statusIcon`), `internal/tui/hera/model.go` (`RoleView.IsActive`, `RoleView.ShowsNeedsInput`, `buildRoleView` reads `ready_to_close`). +Derived from: `internal/tui/widget/rolestatusicon.go` (`RoleStatusIcon`, `RoleStatusInputs`), `internal/tui/hera/rail.go` (`statusIcon`), `internal/tui/hera/model.go` (`RoleView.IsActive`, `RoleView.SustainedActive`, `RoleView.ShowsNeedsInput`, `buildRoleView` reads `ready_to_close`). #### Scenario: Needs-input overrides ready_to_close and everything else -- **WHEN** a role shows its own needs-input "(?)" signal AND also carries `meta:hera.ready_to_close=true` +- **WHEN** a role shows its own needs-input "(?)" signal AND also carries `meta:hera.ready_to_close=true`, and the role is not `SustainedActive` - **THEN** the row renders the needs-input glyph, not the review/ready glyph #### Scenario: ready_to_close overrides a stale working status @@ -184,11 +186,16 @@ Derived from: `internal/tui/widget/rolestatusicon.go` (`RoleStatusIcon`, `RoleSt - **WHEN** a role's hera status is `failed` and it is not in needs-input, not genuinely active, and not ready_to_close - **THEN** the row renders a distinct red `✕`, never the `done` checkmark -#### Scenario: Blocked outranks activity +#### Scenario: Blocked outranks activity, but not sustained activity -- **WHEN** a role has a status row of `blocked` (a needs-input source) and is not ready_to_close +- **WHEN** a role has a status row of `blocked` (a needs-input source) and is not ready_to_close, and the role is genuinely active (`IsActive`) but NOT `SustainedActive` - **THEN** the row renders the needs-input/blocked glyph (static), not the spinner +#### Scenario: Sustained activity suppresses a blocked or content-flagged needs-input signal + +- **WHEN** a role is `SustainedActive` (several consecutive ticks of demonstrated working content), regardless of whether its `NeedsInput` content flag or its self-reported `blocked` hera status is also set +- **THEN** the row renders the active spinner, not the needs-input glyph + #### Scenario: Live-but-statusless role - **WHEN** a role holds a live binding but has no status row and is not ready_to_close and not in needs-input @@ -1796,9 +1803,9 @@ The system SHALL render a genuinely-active role's status glyph as an animated sp The content-idle gate fixes a fullscreen (alt-screen) agent parked at its prompt (BUG-036): such an agent repaints continuously, so it never reaches the raw-byte idle set and would otherwise animate the spinner forever even though it is doing nothing. When the App's content-idle signal (the animation-stripped emulated-screen stability classification) marks the role's bound session idle, the role is NOT active and renders a static idle/live glyph (or the needs-input glyph if it is at a prompt, which already outranks the spinner). A genuinely content-ACTIVE agent — emulated content changing tick-to-tick, or showing the "working" affordance — still spins, REGARDLESS of its bound task's status, including a worker deliberately sitting in `in_review` with its session still alive and producing output (BUG-C, #707). -A role's own needs-input signal (including an operator/agent-set `blocked` assertion) takes precedence over the spinner regardless of the bound task's status (BUG-A). Genuine activity, however, now OUTRANKS the stale-able resting states below it: `ready_to_close`, `failed`, and `done` no longer take precedence over the spinner (BUG-F) — a role producing output again is more current than any of those stamps, and the resting glyph returns once the role goes idle or its session ends. Non-active states (idle, content-idle, needs-input/blocked, done, ready_to_close, failed, unbound, stopped) remain static. +A role's own needs-input signal (including an operator/agent-set `blocked` assertion) takes precedence over the spinner regardless of the bound task's status (BUG-A) — UNLESS the role has demonstrated `SustainedActive` (several consecutive ticks of the working affordance, debounced via `agent.ResumeActivityTick`; see "Needs-input '(?)' reflects only a role's own signal on every surface"), in which case the spinner wins and needs-input is suppressed. A merely-`IsActive` role (one tick, not yet sustained) does not suppress needs-input — BUG-A's original precedence is preserved until activity is sustained long enough to be trusted. Genuine activity, meanwhile, still OUTRANKS the stale-able resting states below it: `ready_to_close`, `failed`, and `done` no longer take precedence over the spinner (BUG-F) — a role producing output again is more current than any of those stamps, and the resting glyph returns once the role goes idle or its session ends. Non-active states (idle, content-idle, needs-input/blocked without sustained activity, done, ready_to_close, failed, unbound, stopped) remain static. -Derived from: `internal/tui/widget/rolestatusicon.go` (`RoleStatusIcon`), `internal/tui/hera/rail.go` (`statusIcon`), `internal/tui/hera/model.go` (`RoleView.IsActive`, `RoleView.SessionRunning`, `RoleView.SessionIdle`), `internal/tui/widget/spinnerstate.go` (`SpinnerFrame`). +Derived from: `internal/tui/widget/rolestatusicon.go` (`RoleStatusIcon`), `internal/tui/hera/rail.go` (`statusIcon`), `internal/tui/hera/model.go` (`RoleView.IsActive`, `RoleView.SustainedActive`, `RoleView.SessionRunning`, `RoleView.SessionIdle`), `internal/tui/widget/spinnerstate.go` (`SpinnerFrame`). #### Scenario: Genuinely active role spins @@ -1825,11 +1832,16 @@ Derived from: `internal/tui/widget/rolestatusicon.go` (`RoleStatusIcon`), `inter - **WHEN** a role holds a live binding and a running session, but the App marks its session content-idle (parked fullscreen agent, stable emulated screen, no "working" affordance) - **THEN** its status glyph does not animate — it renders a static idle/live glyph (or the needs-input glyph if it is at a prompt) -#### Scenario: Blocked outranks activity +#### Scenario: Blocked outranks activity until activity is sustained -- **WHEN** a role's hera status is `blocked` (or it is otherwise in needs-input) +- **WHEN** a role's hera status is `blocked` (or it is otherwise in needs-input), and the role is not `SustainedActive` - **THEN** its status glyph is the needs-input glyph, not the spinner +#### Scenario: Sustained activity overrides a blocked or content-flagged needs-input signal + +- **WHEN** a role's hera status is `blocked` (or it otherwise carries a needs-input content flag), but the role has demonstrated `SustainedActive` (several consecutive ticks of the working affordance) +- **THEN** its status glyph is the animated spinner, not the needs-input glyph + #### Scenario: Details coordinator label is honest about stale working - **WHEN** the Details pane renders a coordinator whose hera status is `working` but which is not genuinely active @@ -2261,13 +2273,17 @@ The rail SHALL respond to mouse wheel scroll events within its rect by moving th ### Requirement: Needs-input "(?)" reflects only a role's own signal on every surface (area rail) -The system SHALL derive the needs-input "(?)" indicator on EVERY coordinator-shaped render surface — the rail's collapsed orchestrator header, a bridging worker row that is itself a nested sub-coordinator, the Details pane's `coordinator:` status line, a Details roster row, and a plan-DAG node icon — EXCLUSIVELY from that role's OWN needs-input signal (`RoleView.needsInputOwn()`: a current, content-aware PTY prompt or a self-asserted `blocked` hera status), computed identically to how a plain leaf role's own glyph is derived. A descendant role's needs-input state — however deep, and across however many bridged sub-orchestrator levels — SHALL NOT cause any ancestor's own icon to render the needs-input indicator. This holds uniformly across all five surfaces because they share one classifier (`roleStatusInputs`/`widget.RoleStatusIcon`, reading `RoleView.ShowsNeedsInput()`, which returns `needsInputOwn()` alone) — not five independent implementations that could drift. +The system SHALL derive the needs-input "(?)" indicator on EVERY coordinator-shaped render surface — the rail's collapsed orchestrator header, a bridging worker row that is itself a nested sub-coordinator, the Details pane's `coordinator:` status line, a Details roster row, and a plan-DAG node icon — EXCLUSIVELY from that role's OWN needs-input signal (`RoleView.needsInputOwn()`), computed identically to how a plain leaf role's own glyph is derived. A descendant role's needs-input state — however deep, and across however many bridged sub-orchestrator levels — SHALL NOT cause any ancestor's own icon to render the needs-input indicator. This holds uniformly across all five surfaces because they share one classifier (`roleStatusInputs`/`widget.RoleStatusIcon`, reading `RoleView.ShowsNeedsInput()`, which returns `needsInputOwn()` alone) — not five independent implementations that could drift. + +`needsInputOwn()` is now gated on a THIRD, suppressing signal: `RoleView.SustainedActive` — whether the role's bound argus task has demonstrated several CONSECUTIVE ticks of genuine working activity (reusing `agent.ResumeActivityTick`'s existing debounced tick-counter machinery, the same one BUG-065's coordinator-relay-answer clear path already relies on — no new threshold). When `SustainedActive` is true, `needsInputOwn()` returns `false` UNCONDITIONALLY — regardless of the task's own workflow status (`in_progress`/`in_review`/`ready_to_close`) and regardless of whether the OR'd content-scan flag (`NeedsInput`) or the self-reported `blocked` hera ladder status is also set. This is an intentional narrowing of the prior "admits any hera-managed role, regardless of task status" invariant (BUG-A): `(?)` is meant to mean "genuinely stuck, no path forward without a human" — a role sustained-active is, by construction, not stuck. + +`SustainedActive` is computed ONCE PER ARGUS TASK (not per role or per binding) from the same signal already used to clear the content-scan flag via `agent.NeedsInputClear`'s `resumedOf`, threaded through `HeraPage.SetSustainedActive` → `BuildModel` → `buildRoleView` exactly like `NeedsInput`/`SessionIdle`/`SessionRunning` already are. Because it is task-scoped rather than role-scoped, TWO roles bound to the SAME live argus task (a dual-bound sub-coordinator's parent-orchestrator worker hat and its own child-orchestrator coordinator hat — see `MaterializeHeraSubCoordinator`) automatically read the IDENTICAL `SustainedActive` value: a self-reported `blocked` hera status stranded on one hat is suppressed the moment the SHARED underlying session demonstrates sustained activity, regardless of which hat's binding is being rendered. No per-hat "check the other binding" logic exists or is needed. An orchestrator with NO coordinator role (its coordinator role was deleted/nuked) SHALL NOT surface any needs-input indicator on its header, in any state — there being no "own" signal for such a header to derive from, the fallback that once rendered the rollup directly on this header is removed outright, not narrowed. -The needs-input ROLLUP COMPUTATION itself (`RoleView.SubtreeNeedsInput`, `OrchView.SubtreeNeedsInput`, populated by `rollupNeedsInput`/`orchSubtreeNeedsInput`) is UNCHANGED by this requirement: it remains transitive across bridged sub-orchestrators, cycle-safe, and excludes archived roles from counting toward an ancestor, exactly as before. Its role is narrowed, not removed — it exists solely to gate the partial-fold-reveal mechanism (deciding which specific closed-fold descendant rows to peek through), never to drive any icon's display directly anymore. A blocked descendant therefore remains fully visible — as its OWN row, peeked through any number of closed ancestor folds — via "Rail reveals the ancestor path to a hidden needs-input descendant through closed folds," which this requirement does not change and does not duplicate. +The needs-input ROLLUP COMPUTATION itself (`RoleView.SubtreeNeedsInput`, `OrchView.SubtreeNeedsInput`, populated by `rollupNeedsInput`/`orchSubtreeNeedsInput`) is UNCHANGED by this requirement: it remains transitive across bridged sub-orchestrators, cycle-safe, and excludes archived roles from counting toward an ancestor, exactly as before — it is computed FROM each role's (now sustained-active-gated) own signal, so a sustained-active role's suppressed own-signal also does not contribute to the rollup, but the rollup mechanism and its traversal are otherwise untouched. Its role is narrowed, not removed — it exists solely to gate the partial-fold-reveal mechanism (deciding which specific closed-fold descendant rows to peek through), never to drive any icon's display directly anymore. A genuinely blocked (non-sustained-active) descendant therefore remains fully visible — as its OWN row, peeked through any number of closed ancestor folds — via "Rail reveals the ancestor path to a hidden needs-input descendant through closed folds," which this requirement does not change and does not duplicate. -Derived from: `internal/tui/hera/model.go` (`RoleView.ShowsNeedsInput` returns `needsInputOwn()` alone; `RoleView.SubtreeNeedsInput`, `OrchView.SubtreeNeedsInput`, `rollupNeedsInput`, `orchSubtreeNeedsInput` unchanged), `internal/tui/hera/rail.go` (`statusIcon`/`roleStatusInputs` read `ShowsNeedsInput`; `drawOrchRow`'s coordinator-less fallback branch removed; the reveal gates in `appendOrch`/`appendOrchWorkers`/`appendOrchRevealPath`/`appendWorkerRow`/`appendPinnedRole` unchanged), `internal/tui/hera/details.go` (`coordinator:` status line and `rosterStatusText`/`drawRosterRow`, both reading the same shared classifier, unchanged code), `internal/tui/hera/plan.go` (`planNodeIcon`, unchanged code, reads the same shared classifier). +Derived from: `internal/tui/hera/model.go` (`RoleView.ShowsNeedsInput` returns `needsInputOwn()` alone; `RoleView.SustainedActive`; `RoleView.SubtreeNeedsInput`, `OrchView.SubtreeNeedsInput`, `rollupNeedsInput`, `orchSubtreeNeedsInput` unchanged traversal), `internal/tui/app.go` (the per-task sustained-active set exposed from `detectNeedsInputSticky`'s existing `agent.ResumeActivityTick` pass), `internal/tui/hera/page.go` (`HeraPage.SetSustainedActive`), `internal/tui/hera/rail.go` (`statusIcon`/`roleStatusInputs` read `ShowsNeedsInput`; `drawOrchRow`'s coordinator-less fallback branch removed; the reveal gates in `appendOrch`/`appendOrchWorkers`/`appendOrchRevealPath`/`appendWorkerRow`/`appendPinnedRole` unchanged), `internal/tui/hera/details.go` (`coordinator:` status line and `rosterStatusText`/`drawRosterRow`, both reading the same shared classifier, unchanged code), `internal/tui/hera/plan.go` (`planNodeIcon`, unchanged code, reads the same shared classifier). #### Scenario: A blocked worker's own row shows "(?)"; its ancestor coordinators do not @@ -2286,7 +2302,7 @@ Derived from: `internal/tui/hera/model.go` (`RoleView.ShowsNeedsInput` returns ` #### Scenario: A coordinator's own needs-input signal still surfaces on its header regardless of descendants -- **WHEN** a coordinator role is itself blocked on a prompt (own signal), independent of whatever state its descendants are in +- **WHEN** a coordinator role is itself blocked on a prompt (own signal) and is NOT `SustainedActive`, independent of whatever state its descendants are in - **THEN** the coordinator's header renders the needs-input "(?)" indicator, exactly as any other role's own signal would #### Scenario: The Details status line and roster follow the same own-signal-only rule @@ -2294,6 +2310,21 @@ Derived from: `internal/tui/hera/model.go` (`RoleView.ShowsNeedsInput` returns ` - **WHEN** the Details pane is showing a coordinator whose own signal is clear but which has a blocked descendant, and its roster includes a bridging worker row that is itself a nested sub-coordinator with a blocked descendant but no own signal - **THEN** neither the `coordinator:` status line nor that roster row renders the needs-input glyph or the `"needs-input"` text label +#### Scenario: A sustained-active role never shows "(?)", regardless of task status or a stale blocked flag + +- **WHEN** a role's bound argus task is `SustainedActive` (several consecutive ticks of demonstrated working content), AND the role's bound task carries `in_review`/`meta:hera.ready_to_close=true`, AND/OR the role's own hera status is a stale self-reported `blocked` value +- **THEN** the role's row renders no needs-input "(?)" indicator on any of the five coordinator-shaped surfaces — the active spinner (or another lower-precedence glyph) renders instead + +#### Scenario: A dual-bound sub-coordinator's stale blocked hat is suppressed by the other hat's sustained activity + +- **WHEN** an argus task holds two live hera bindings (a parent-orchestrator worker-kind role and a child-orchestrator coordinator-kind role, per `MaterializeHeraSubCoordinator`), one of those roles carries a stale self-reported `blocked` hera status, and the SHARED underlying session is `SustainedActive` +- **THEN** NEITHER role's row renders the needs-input "(?)" indicator — the shared per-task `SustainedActive` signal suppresses both, without any code needing to look up the other role's binding + +#### Scenario: A genuinely idle or unresolved-blocked role with no subsequent activity still shows "(?)" + +- **WHEN** a role's own needs-input signal is set (content flag or self-reported `blocked`) and the role's bound task has NOT demonstrated `SustainedActive` since +- **THEN** the role's row renders the needs-input "(?)" indicator exactly as before this change — no regression on genuine blocking cases + ### Requirement: Worker/freelance rail rows show a context-pressure indicator (area 3) A live worker-kind or freelance-kind rail role row SHALL reserve a trailing 2-character slot — a