diff --git a/context/knowledge/gotchas/ci-gates.md b/context/knowledge/gotchas/ci-gates.md index 6e642703..b4f08a86 100644 --- a/context/knowledge/gotchas/ci-gates.md +++ b/context/knowledge/gotchas/ci-gates.md @@ -7,3 +7,4 @@ - **`lint-pr`** — uses `--new-from-rev=origin/master`, so it only flags issues your diff introduced (incl. `staticcheck` deprecations like `SA1019` on new lines). Fix them; no blanket `//nolint`. - **`vuln`** — CI runs govulncheck with `continue-on-error: true`, so Go stdlib-only CVEs (`Found in: @go1.x.y`, "Standard library") never block CI (fixable only by bumping the toolchain). Confirm the failure is toolchain-only (fails on a clean `origin/master` tree too), note it in the PR, run the remaining gates individually. Module-level findings in bumpable deps still must be fixed. - **`test-cover-gate` flakes in `internal/agent` under full-suite `-race` on a loaded macOS dev machine** — real-PTY tests panic with `session.go: invalid memory address or nil pointer dereference` after a setup error `device not configured` (macOS PTY-device exhaustion when many packages' subprocess/PTY tests run concurrently). `go test ./internal/agent/...` in isolation passes cleanly; `go test -p 1 ./...` (fully serialized across packages) also passes with normal coverage. Confirm via that isolation/serialization split before suspecting a real regression — do NOT weaken or skip the affected tests. Not observed to reproduce on CI (Linux runners, different PTY limits). +- **The same class of flake also hits `internal/tui/terminal` (many `x/vt.SafeEmulator` goroutines each backed by an `io.Pipe`, same resource-contention shape as a PTY) — and, less often, `internal/tui` itself** — a per-package 120s timeout under full-suite `-race`, surfaced as a goroutine dump with everything parked in `io.(*pipe).read`/`SafeEmulator.Read` (`internal/tui/terminal`) or mid-`db.OpenInMemory()`/`sql.Open` (`internal/tui`), never a real panic. `go test ./internal/tui/terminal/...` in isolation passes in ~90s; confirmed reproducing with a fully clean tree (no diff at all) via `git stash`, so it is NOT a signal that a given change caused it — it's whichever package happens to be mid-flight when the loaded machine's scheduler starves it past 120s. Same diagnosis recipe as the `internal/agent` case above: isolate or serialize (`-p 1`) before suspecting a regression. diff --git a/context/knowledge/gotchas/hera-view.md b/context/knowledge/gotchas/hera-view.md index 1621c3f1..e344ed98 100644 --- a/context/knowledge/gotchas/hera-view.md +++ b/context/knowledge/gotchas/hera-view.md @@ -280,3 +280,10 @@ M6a scaffolds the native Hera view: a `HeraPage` (rail | coordinator pane | agen - **`Rail.MouseHandler` (mirrors `gitpanel.FilePanel` and `taskview.TaskListView`) is the FIX for the routing test's own stale comment: the rail previously had NO `MouseHandler`, so a wheel event that `page.go`'s `regionAt` misrouted to the rail column silently hit `Box`'s default (which only handles `MouseLeftDown`) and was never consumed — a genuinely broken scroll, not just an untested one.** `page.go`'s `MouseHandler` already gates dispatch on `regionAt(x) == FocusRail` before calling `p.rail.MouseHandler()`, so the new handler's own `InRect` check is a second, redundant-in-practice guard for direct callers (tests) rather than the thing doing the real gating. - **Wheel up/down call the SAME `CursorUp`/`CursorDown` (→ `step()`) the `k`/`j`/arrow keys already use — no separate scroll-offset path.** `step()` already handles selectable-row skipping, kanban-group boundary crossing, and persistence (`setCursor`'s `persist()` call), so a wheel notch is indistinguishable from a keyboard nav step; `adjustOffset` (Draw-time) keeps the cursor in view exactly as it does for keyboard nav. This holds regardless of `filterInput` — `MouseHandler` calls `CursorUp`/`CursorDown` directly, bypassing the `InputHandler`'s filter-vs-nav key routing entirely, but both paths bottom out in the same `step()`. - **`tview.MouseScrollUp` maps to `CursorDown` and `MouseScrollDown` maps to `CursorUp` — INVERTED relative to `gitpanel.FilePanel`'s pane-scroll convention, deliberately (dogfood feedback, same day as ship).** `FilePanel` (and `terminalpane`/`dagview`/`modal.HelpModal`/`SettingsView`) scroll a content PANE, where `ScrollUp` conventionally reveals earlier/upper content. This widget scrolls the CURSOR itself — the fingers are "dragging the cursor," not "dragging the pane" — so the intuitive mapping is the cursor moving in the SAME direction as the trackpad gesture, which on a Mac with natural scrolling reaches this widget as the OPPOSITE tview event from what a pane-scroll would want. Do not "fix" this to match `FilePanel` without re-confirming on an actual trackpad — the first shipped version used the pane-scroll direction and Aaron reported it backwards on his first test. + +## BUG-076: Hera-pane size-drift kick never auto-restarts (false "Session not running" after ordinary rail nav) + +- **Root cause was NOT a liveness misclassification — the session really did get stopped, by a legitimate-looking size-drift kick (`heraKickRerender`, BUG-074) whose auto-restart then unconditionally skipped itself because `handleSessionExitUI`'s "is the user still watching" gate (`stillViewing`) checked ONLY `a.mode == modeAgent` — the classic fullscreen agent view's mode. The native Hera view never sets `a.mode` to `modeAgent`; it stays `modeTaskList` with `header.ActiveTab()==TabHera` regardless of which pane the rail cursor is on. So EVERY kick fired from a Hera pane — and BUG-074 made that nearly every task's first Hera-view bind, since Hera panes are narrower than the main agent view and almost any previously-wider-attached (or never-attached) task exceeds `RerenderMargin` — took the "user navigated away, settle at InReview" branch verbatim, even though the operator was staring right at the pane through the Hera tab.** The kick's own gating (`ShouldKickRerender`) requires the session to be idle, so it doesn't fire mid-stream — it fires in the gap between turns, which is exactly the "moment" a rail j/k glance-away-and-back window covers: the operator sees it fine, looks away, and by the time they look back the async kick (RPC round trip + `QueueUpdateDraw` dispatch, see `maybeKickRerenderAtWidth`) has already stopped the session and skipped the restart. The task settles at InReview with a genuinely dead session; the pane's next resolve returns nil and `terminalpane.go`'s `sess == nil && !tp.HasContent()` branch paints "Session not running - press Enter to start" — literally true at that point, just for the wrong reason. Reproduces on BOTH the coordinator and agent/worker Hera panes (both route through `heraKickRerender`); the same `stillViewing` gate is also reachable from the classic agent view if the operator switches tasks inside the kick's async window, so the bug family isn't Hera-exclusive even though Hera nav is what makes it "very frequent." +- **Fix: widen the restart gate to recognize BOTH ways of "watching" — `App.isViewingTaskSession(taskID)` returns true for the classic `a.mode==modeAgent` case OR for `a.mode==modeTaskList && ActiveTab()==TabHera && heraPage.IsBoundToTask(taskID)`.** `HeraPage.IsBoundToTask` (`internal/tui/hera/panes.go`) is a pure read of `coordBound`/`agentBound` — true if EITHER pane currently shows taskID, independent of which pane has keyboard focus (the coordinator pane stays bound the whole time any worker under it is selected, so it must count as "viewed" even while the rail cursor sits on a worker row). `handleSessionExitUI`'s `stillViewing` now calls `isViewingTaskSession` instead of the inline `modeAgent`-only check; `startSession`'s own `a.mode==modeAgent` pane-attach guard is untouched (still correctly a no-op from the Hera branch — the classic `a.agentPane` isn't shown then, and the ALREADY-RUNNING Hera reconcile tick (`reconcileOne`'s late-bind case) picks up the freshly-resumed session on its own). +- **Do not "fix" this by making the kick less aggressive (e.g. skipping it from Hera panes) — the kick itself is correct and necessary** (BUG-074's whole point: a Hera pane's narrower width needs the same kill+resume repair the main agent view gets, or scrollback stays corrupted). The bug was purely in the EXIT-TIME restart decision, not the kick decision. +- Regression coverage: `internal/tui/hera/panes_test.go`'s `TestPanes_IsBoundToTask` (pure `IsBoundToTask` semantics — coordinator bound the whole time a worker is selected, unrelated/empty task rejected); `internal/tui/app_test.go`'s `TestApp_IsViewingTaskSession` (all four `isViewingTaskSession` branches, including "Hera-bound but a different tab is active"); `internal/tui/heraactions_test.go`'s `TestHandleSessionExitUI_RerenderRestartsWhenViewedViaHeraPane` (end-to-end through a real Hera tab selection + `handleSessionExitUI`, confirmed to fail with `status=in_review` against the old `modeAgent`-only gate before asserting the fixed behavior). diff --git a/context/knowledge/index.md b/context/knowledge/index.md index feb3dbea..5e6764db 100644 --- a/context/knowledge/index.md +++ b/context/knowledge/index.md @@ -7,7 +7,7 @@ Non-obvious invariants and gotchas, split by topic. Read the relevant file when | [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/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) | 65 | | [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) | 5 | +| [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 | | [gotchas/sandbox.md](gotchas/sandbox.md) | macOS sandbox-exec SBPL profiles, symlink resolution, allowed paths, Chrome support dir for Playwright, AppleEvent allowlist, Messages.app legacy alias, picker modal, TCC re-prompt fix (stable-signed local binary via workflow-neutral `make install-signed`; first-sign Keychain "Always Allow") | 26 | | [gotchas/worktree.md](gotchas/worktree.md) | Worktree creation ordering, transactional CreateAndStart, cleanup, path validation, stale ref pruning, missing-worktree pre-flight, delete entry-point parity, safe-name slash stripping, orphan-sweep ancestor guard, shared local-branch-ref merge-base staleness, bulk cascade-nuke silent-freeze root cause + per-repo cleanup lock + heraGoSafe panic recovery (BUG-062) | 24 | | [gotchas/keybindings.md](gotchas/keybindings.md) | Customizable keymap (internal/tui/keymap single-source: defaults+config overrides, Resolve-recognizes/predicate-in-switch, handleAgentKey double-resolve ordering, structural/agent-modifier validation, tcell-accurate Matches, live-accessor wiring, isRailMutationKey + help GENERATED from keymap, config.toml-only no DB rows); key routing, ctrl sequences, tcell modifier quirks, agent view nav, plugin-view full surrender + double-Ctrl+Q failsafe, plugin help overlay, keyenc single-source + mod-7 Cmd+arrow round-trip, control-frame read-pump threading, plugin resize-envelope reconciliation, ctrl+r Claude session switcher, ctrl+j unified task/role switcher (grouped folder mode, hera-reach via HeraPage.OnSwitcher, HeraManaged entries), ctrl+k global command palette (unconditional-dispatch-while-still-rebindable, per-context action registries incl. CtxTaskList/CtxSettings synthetic-event replay, keymap.BindingFor/ContextOrder), plugin-view reconnect (backoff, laidOut survives resume), hera rail m/M kanban-status step (wraps, gated one layer up via Selection.KanbanTarget not handleRailMutation), ctrl+j fixed to also reach the plain Tasks tab (resolves CtxAgent's own binding from handleGlobalKey rather than a new CtxTaskList entry; exposed + fixed a closeTaskSwitcherModal focus-restore bug), ctrl+g global jump-to-next-needs-input (unconditional dispatch alongside ctrl+k, no Hera-page-local literal case needed) | 56 | @@ -16,7 +16,7 @@ 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) | 169 | +| [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/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 | diff --git a/internal/tui/app.go b/internal/tui/app.go index dfa33ea8..008b9207 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -1679,6 +1679,35 @@ func (a *App) HandleSessionExit(taskID string, info daemon.ExitInfo) { }) } +// isViewingTaskSession reports whether the operator is currently watching +// taskID's live output — either the classic fullscreen agent view, or the +// native Hera view with taskID bound to one of its two terminal panes +// (coordinator or agent/worker), regardless of which pane the rail cursor +// currently selects. Used by handleSessionExitUI to decide whether a +// size-drift kill+resume kick should auto-restart in place. +// +// BUG-076: heraKickRerender (BUG-074) reuses the same pendingRerenderRestart +// + Stop() mechanism as the classic agent view's maybeKickRerender, but the +// Hera tab never sets a.mode to modeAgent — it stays modeTaskList with +// ActiveTab()==TabHera. Gating the restart on modeAgent alone meant EVERY +// kick fired from a Hera pane (which is nearly every task's first Hera-view +// bind, since Hera panes are narrower than the main agent view) stopped the +// session and then always skipped the auto-restart as "user navigated +// away" — even though the operator was still looking right at it, just +// through the Hera view. The task settled at InReview and the pane showed +// "Session not running" until a manual Enter (heraReattach) restarted it. +// See gotchas/hera-view.md BUG-076. +func (a *App) isViewingTaskSession(taskID string) bool { + a.mu.Lock() + viewingAgent := a.mode == modeAgent && a.agentState.TaskID == taskID + viewingHeraTab := a.mode == modeTaskList && a.header.ActiveTab() == widget.TabHera + a.mu.Unlock() + if viewingAgent { + return true + } + return viewingHeraTab && a.heraPage.IsBoundToTask(taskID) +} + // handleSessionExitUI runs on the tview main goroutine (inside QueueUpdateDraw). // Called by both NotifySessionExit (in-process) and HandleSessionExit (daemon). // pendingRestart is captured by the caller from a non-RPC source (in-process: @@ -1825,9 +1854,7 @@ func (a *App) handleSessionExitUI(taskID string, cleanExit, pendingRestart bool) // settles at InReview and the user can resume it manually later. if !cleanExit && a.pendingRerenderRestart[taskID] { delete(a.pendingRerenderRestart, taskID) - a.mu.Lock() - stillViewing := a.mode == modeAgent && a.agentState.TaskID == taskID - a.mu.Unlock() + stillViewing := a.isViewingTaskSession(taskID) if !stillViewing { uxlog.Log("[tui] rerender: user navigated away from task=%s, skipping auto-restart", taskID) a.statusbar.ClearInfo() diff --git a/internal/tui/app_test.go b/internal/tui/app_test.go index 658a9f2a..4bcb13c2 100644 --- a/internal/tui/app_test.go +++ b/internal/tui/app_test.go @@ -3089,6 +3089,52 @@ func TestApp_HandleSessionExitUI_FlipToInReview(t *testing.T) { testutil.Equal(t, got.Status, model.StatusInReview) } +// TestApp_IsViewingTaskSession is the BUG-076 regression at the unit level: +// the classic fullscreen agent view (modeAgent) is one way to be "viewing" a +// task's live session, but the native Hera view — which never sets +// a.mode to modeAgent, staying modeTaskList with ActiveTab()==TabHera — is +// another, and the old check only recognized the first. A task bound to +// either the coordinator or agent/worker pane while the Hera tab is active +// must count as viewed; an unbound task, a different tab, or a Hera binding +// while some other tab is active must not. +func TestApp_IsViewingTaskSession(t *testing.T) { + d := testDB(t) + app := New(d, agent.NewRunner(nil), false) + + t.Run("classic agent view", func(t *testing.T) { + app.mode = modeAgent + app.agentState.Reset("t1", "n") + testutil.Equal(t, app.isViewingTaskSession("t1"), true) + testutil.Equal(t, app.isViewingTaskSession("other"), false) + }) + + orch := seedHeraOrch(t, d, "orch") + seedHeraBoundRole(t, d, orch, "coord", db.HeraKindCoordinator, "t-coord") + seedHeraBoundRole(t, d, orch, "wkr", db.HeraKindWorker, "t-wkr") + app.heraPage.Refresh() + + t.Run("hera tab with worker selected: both coord and worker panes count as viewed", func(t *testing.T) { + app.mode = modeTaskList + app.header.SetTab(widget.TabHera) + // The worker's row lives under the orchestrator's fold — expand it first + // (mirrors JumpToTask's own ancestor-expand step) so SelectByTaskID finds it. + app.heraPage.Rail().EnsureAncestorsExpanded(orch) + if !app.heraPage.Rail().SelectByTaskID("t-wkr") { + t.Fatal("expected a rail row for t-wkr") + } + testutil.Equal(t, app.isViewingTaskSession("t-wkr"), true) + testutil.Equal(t, app.isViewingTaskSession("t-coord"), true) + testutil.Equal(t, app.isViewingTaskSession("unrelated"), false) + }) + + t.Run("hera binding present but a different tab is active", func(t *testing.T) { + app.mode = modeTaskList + app.header.SetTab(widget.TabTasks) + testutil.Equal(t, app.isViewingTaskSession("t-wkr"), false) + testutil.Equal(t, app.isViewingTaskSession("t-coord"), false) + }) +} + // TestHandleSessionExitUI_RerenderGateEntersOnNonCleanExit pins that the // pendingRerenderRestart gate (now keyed on !cleanExit, not "stopped") is // entered for ANY non-clean exit — including a crash (err!=nil), not just a diff --git a/internal/tui/hera/panes.go b/internal/tui/hera/panes.go index cfee88ff..45e94391 100644 --- a/internal/tui/hera/panes.go +++ b/internal/tui/hera/panes.go @@ -344,6 +344,21 @@ func (p *HeraPage) FocusedTerminalTaskID() string { return "" } +// IsBoundToTask reports whether taskID currently feeds either terminal pane +// (coordinator or agent/worker) — regardless of which pane has keyboard +// focus, or whether the agent region is showing Details instead of a +// terminal. Unlike FocusedTerminalTaskID (scoped to the one focused pane, +// for the clipboard/copy seam), this answers "is the Hera view showing this +// task's live output at all" — the App uses it (alongside the classic +// fullscreen agent view's own TaskID check) to decide whether a +// size-drift kill+resume kick (heraKickRerender, BUG-074) should +// auto-restart in place instead of silently letting the task settle at +// InReview, since the Hera tab never sets the App's mode to its fullscreen +// agent-view mode (BUG-076 — see gotchas/hera-view.md). +func (p *HeraPage) IsBoundToTask(taskID string) bool { + return taskID != "" && (p.coordBound == taskID || p.agentBound == taskID) +} + // SelectionContext returns the current (role, orchestrator, task) selection. // // 6c EXTENSION POINT: this is the clean seam mutations hang off. A mutation diff --git a/internal/tui/hera/panes_test.go b/internal/tui/hera/panes_test.go index a0769b67..96e69c91 100644 --- a/internal/tui/hera/panes_test.go +++ b/internal/tui/hera/panes_test.go @@ -147,6 +147,35 @@ func TestPanes_WorkerSelectionFeedsAgentPane(t *testing.T) { testutil.Equal(t, p.SelectionContext().TaskID(), "t-wkr") } +// TestPanes_IsBoundToTask proves IsBoundToTask recognizes both the +// coordinator and the selected worker's task as "currently shown by the Hera +// view" — regardless of which pane has keyboard focus — and rejects an +// unrelated or empty task ID. The App uses this (via isViewingTaskSession) to +// decide whether a size-drift kick's exit handler should auto-restart in +// place instead of letting the task settle at InReview (BUG-076): the +// coordinator pane is bound the whole time a worker under it is selected, so +// a live coordinator must read as "bound" even while the rail cursor sits on +// the worker row. +func TestPanes_IsBoundToTask(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") + + coordSess := &fakeSession{id: "t-coord", alive: true} + wkrSess := &fakeSession{id: "t-wkr", alive: true} + p := NewHeraPage(d) + p.SetSessionResolver(resolverFor(map[string]*fakeSession{"t-coord": coordSess, "t-wkr": wkrSess})) + p.Refresh() + testutil.Equal(t, p.IsBoundToTask(""), false) + + testutil.Equal(t, selectRoleByName(p, "wkr"), true) + testutil.Equal(t, p.IsBoundToTask("t-wkr"), true) // agent pane + testutil.Equal(t, p.IsBoundToTask("t-coord"), true) // coord pane, unfocused + testutil.Equal(t, p.IsBoundToTask("unrelated-task"), false) + testutil.Equal(t, p.IsBoundToTask(""), false) +} + // TestPanes_DrawInvokesRerenderKicker proves Draw calls the wired // RerenderKicker with each pane's OWN fresh width — for BOTH the coordinator // pane and the worker/agent pane — exactly once per genuine bind, and that a diff --git a/internal/tui/heraactions_test.go b/internal/tui/heraactions_test.go index 446f9e72..574deb76 100644 --- a/internal/tui/heraactions_test.go +++ b/internal/tui/heraactions_test.go @@ -1061,6 +1061,73 @@ func TestSmoke_HeraReattachRestartsSession(t *testing.T) { testutil.Equal(t, started, true) } +// TestHandleSessionExitUI_RerenderRestartsWhenViewedViaHeraPane is the BUG-076 +// regression: a size-drift kick's auto-restart (handleSessionExitUI's +// pendingRerenderRestart branch) used to gate "is the user still watching" +// on a.mode==modeAgent alone, which is NEVER true while on the Hera tab (it +// stays modeTaskList with ActiveTab()==TabHera). So a kick fired from a Hera +// pane (heraKickRerender, BUG-074) always fell through to "user navigated +// away", leaving the task stuck at InReview with a genuinely-dead session — +// exactly the "Session not running" false detach reported after ordinary +// rail navigation. This proves the coordinator pane being bound in the Hera +// view (no rail navigation away at all) is now sufficient for the kick's +// exit handler to restart in place instead of settling. +func TestHandleSessionExitUI_RerenderRestartsWhenViewedViaHeraPane(t *testing.T) { + app, d := heraRepoApp(t) + orch := seedHeraOrch(t, d, "orch") + repo := d.Config().Projects["p"].Path + testutil.NoError(t, d.Add(&model.Task{ + ID: "tc", Name: "tc", Status: model.StatusInProgress, SessionID: "sid-1", + Project: "p", Worktree: repo, CreatedAt: time.Now(), + })) + coord, err := d.CreateHeraRole(db.CreateHeraRoleInput{OrchestratorID: orch, Name: "coord", Kind: db.HeraKindCoordinator, ArgusProject: "p"}) + testutil.NoError(t, err) + _, err = d.CreateHeraBinding(db.CreateHeraBindingInput{RoleID: coord.ID, ArgusTaskID: "tc", WorktreePath: repo}) + testutil.NoError(t, err) + + sim, stop := wireApp(t, app) + defer stop() + t.Cleanup(func() { app.runner.StopAll() }) + + sim.InjectKey(tcell.KeyRune, '2', 0) // → Hera tab; orch header (the coordinator) selects + syncUI(t, app.tapp) + + // Simulate a size-drift kick that already stopped the session (mirrors what + // heraKickRerender's async goroutine does), then run the exit handler — + // the SAME path a real kick's Stop() would trigger. + var boundToHera bool + readUI(t, app.tapp, func() { + boundToHera = app.heraPage.IsBoundToTask("tc") + app.pendingRerenderRestart["tc"] = true + app.handleSessionExitUI("tc", false /* non-clean: kick-induced stop */, false) + }) + if !boundToHera { + t.Fatal("expected the coordinator pane bound to tc after selecting the Hera tab") + } + + // The restart branch must have fired: status back to InProgress (not left + // at InReview) and startSession invoked. Poll since startSession's DB + // write happens inline but the process spawn is fire-and-forget. + deadline := time.Now().Add(3 * time.Second) + restarted := false + for time.Now().Before(deadline) { + syncUI(t, app.tapp) + if got, _ := d.Get("tc"); got != nil && got.Status == model.StatusInProgress { + restarted = true + break + } + time.Sleep(30 * time.Millisecond) + } + if !restarted { + got, _ := d.Get("tc") + status := "?" + if got != nil { + status = got.Status.String() + } + t.Fatalf("BUG-076 regression: task did not auto-restart while viewed via the Hera pane (status=%s)", status) + } +} + // --- BUG-017: coord/orchestrator HEADER delete cascades the full subtree ------ // seedHeraRoleOnTask binds a NEW role under orchID to an ALREADY-EXISTING argus