Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions context/knowledge/gotchas/hera-view.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ M6a scaffolds the native Hera view: a `HeraPage` (rail | coordinator pane | agen
- **PTY size alignment (CLAUDE.md rule 5): a hera pane and the main agent pane compute the SAME size for the same rect — they are the same `TerminalPane` widget.** When binding a session, `bindPane` calls `ForceResyncPTY()` so a session previously sized for the full-width main agent view gets resized DOWN to the narrower hera pane on the next Draw (without it, the seeded `ptyCols` matches `wantCols` and no resize fires, leaving the agent painting at the stale width). `SyncPanes()` (App tick GOROUTINE + spinner loop, never a QueueUpdateDraw callback) issues the Resize RPC off the main thread. It is safe to call off-tab: a pane that wasn't drawn this frame has zero `pendingResize`, so an off-tab `SyncPanes` can't fight the main agent view's resize of the same task. Never reach for `Sync()` to fix a size mismatch — fix the alignment.
- **A plain `ForceResyncPTY()` resize can't repair scrollback already committed at a different width — Hera panes never had the main agent view's kill+resume safety net for that (BUG-074), and are MORE exposed to it than the main view.** SIGWINCH re-flows only live UI; cursor-positioning codes baked into earlier PTY output stay wrong once re-emulated at a new size (the `agent.ShouldKickRerender`/`RerenderMargin` mechanism exists precisely for this — see `App.maybeKickRerender`/gotchas/pty-terminal.md — but it was wired ONLY into the main agent view's entry path, never into `bindPane`). Reproduced offline against a real dogfood session log (small, well under any 8MB-window concern — this is independent of BUG-068/BUG-073's lossy-tail-rebuild mechanism): a session resized once during its real life shows a duplicated footer/blank-frame pattern when replayed at a fixed size, exactly matching a live screenshot report. Hera panes hit this MORE than the main view because `bindPane` calls `ForceResyncPTY()` unconditionally on every single bind — any task viewed repeatedly in Hera accumulates real size transitions in its history over time. Fix: `App.maybeKickRerenderAtWidth(task, sess, panelCols)` is the width-parameterized core extracted from `maybeKickRerender` (which now just supplies `computePTYSize()`'s result); `App.heraKickRerender(taskID, panelCols)` is the Hera-facing entry point, wired via `HeraPage.SetRerenderKicker` (mirrors `SetSessionResolver`'s pattern — resolves `task`/`sess` by ID itself so `internal/tui/hera` never needs direct `db`/`runner` access). Shares `isRedundantAttach`'s cache (keyed by task ID only, not by which surface asked — a task alternately viewed from the main view and a Hera pane can kick once per surface-switch; accepted, see design.md for `fix-hera-pane-rerender-kick`).
- **The kick check is evaluated from `Draw()` (`HeraPage.maybeKickPaneRerender`), never from `bindPane` itself, despite `bindPane` being where the session is actually resolved.** `bindPane` runs synchronously in the input handler, BEFORE Draw() has had a chance to give a newly-shown pane its real rect — the clearest case is a coordinator selected first (details mode: the agent pane is never shown, never `SetRect`, tracked width stays 0) then a worker selected for the first time this session: at THAT bind moment the agent pane's width is still 0, so a synchronous read there would silently skip the check for the very first coordinator→worker transition every session. Fix: `Draw()` calls `maybeKickPaneRerender(bound, kickedFor, cols)` right after each of its four `SetRect` call sites (fullscreen coord/agent, split coord/agent) — `cols` there is always the JUST-computed, correct value. `coordKickedFor`/`agentKickedFor` (paired with `coordBound`/`agentBound`) suppress a redundant call every frame while the same task stays bound and visible — NOT a correctness gate (that's still `isRedundantAttach`, downstream), purely an optimization to skip the DB+runner lookup; `bindPane` resets the marker to `""` on unbind so a later rebind to the SAME task still gets evaluated.
- **The kick itself is debounced by a 300ms wall-clock dwell (`hera.KickDebounce`, fix-hera-tick-and-kick-perf) — ordinary rail nav ALONE (no resize, no fullscreen toggle) swings a bound pane between full-width and split-width, crossing `RerenderMargin` on every hop of a fast Cmd+Arrow traversal and kicking every transiently-bound task ("kick storm").** `maybeKickPaneRerender` now arms a per-pane `kickPending{taskID, cols, deadline}` on first seeing a newly-bound task past the margin instead of firing immediately; only a LATER call, once the dwell elapses AND the same task is STILL bound, actually invokes `kickRerender`. A rebind to a DIFFERENT task before the dwell re-arms against the new task, discarding the old one un-fired. **`bindPane` also zeroes `pending` on unbind** — omitting this (the first implementation attempt) left a stale, already-past deadline surviving an unbind+rebind-to-the-SAME-task, firing the kick immediately instead of dwelling afresh (caught by `TestPanes_KickDebounce_UnbindMidDwellThenRebindSameTask`). No new goroutine/timer — checked on the same Draw cadence, mirroring `Refresher`'s goroutine-free style, via an injectable `kickNow`/`SetKickClock` seam for tests.
- **Main-thread-safe pane reads only.** Pane operations on the tview thread use the lock-free/local SessionHandle methods (`Alive`, `RecentOutput*`, `WriteInput`, `Session()`) — never the RPC-blocking `PID/IsIdle/PTYSize/InitialPTYSize`. The blocking `SyncPTYSize`→`Resize` RPC runs only from the tick goroutine via `SyncPanes`. `SetSession`/`SetTaskID`/`applySelection`/`reconcileSessions` are main-goroutine-only (SetSession resets emulator state).
- **`reconcileSessions` (App tick, main thread) does the nil→live late bind AND the dead→live re-resolve (BUG-013).** A pane bound to a task with no live session yet (coordinator/worker still starting) gets its session attached on a later tick. `doRefresh` re-runs `applySelection` after `SetModel` because the rebuild replaces the model's backing arrays, invalidating the prior `Selection` pointers (task IDs usually match, so `bindPane` no-ops and emulators survive).
- **A present-but-DEAD pane session MUST be re-resolved, not just a changed taskID (BUG-013).** When the daemon tears a pane's stream down (StreamLost relay / daemon bounce) while the agent PTY is still alive, `RemoteSession.Alive()` flips false but the agent lives. The pane held the dead handle forever: `reconcileOne` used to bail on ANY present session and `bindPane` no-ops on the unchanged taskID, so `forwardKey` dropped every keystroke (silently) until a full TUI restart re-dialed the stream. Fix: `reconcileOne` treats `!Alive()` like nil and re-resolves — the daemon client re-dials a fresh stream on a cache-miss `Get` when the daemon reports the process alive. Replace ONLY with a live, DISTINCT handle; leave the pane alone when the resolver yields nil (process gone → replay) or the same not-yet-evicted handle (retry next tick), or you reset the emulator every tick. `forwardKey` now logs the drop (`[hera-view]`) and re-resolves before dropping. Orthogonal to the `Enter`-reattach path, which leaves a LIVE coordinator navigate-only and restarts a truly dead PROCESS — BUG-013 is a live process with a stale handle.
Expand All @@ -26,6 +27,9 @@ M6a scaffolds the native Hera view: a `HeraPage` (rail | coordinator pane | agen
- **Freelance-kind roles are HOISTED into a top-level Freelance section, not nested under their orchestrator.** `BuildModel` skips active freelance-kind roles when filling an orchestrator's `Roles` and appends them to `Model.Freelance` instead. This is a 6a read-only interpretation of the "freelance" section (Hera derived it from unmanaged live argus tasks; that data source is out of scope for the read-only hera store). 6b can revisit if real unmanaged-task freelance is wanted.
- **`ready_to_close` (M4) is read from the task-addressed `task_meta` "hera" namespace, NOT the hera tables.** `BuildModel` does one `ListMetaByNamespace("hera")` batch read and flags `RoleView.ReadyToClose` when the role's bound task has `ready_to_close=true`. The rail renders it with the distinct `theme.IconReview` mark, which WINS over the role's idle/working/blocked/done status icon but LOSES to needs-input `(?)` (BUG-A: an actively-blocked worker is not ready to close). A meta read error is non-fatal (the flag just doesn't render).
- **The rail rebuild runs on the tview thread; that's fine because hera-store reads are mutex-guarded and fast — the "never on the UI thread" rule is about GIT, not DB reads.** `Refresher` (the Argus-native analog of Hera's `RailRefresher`) is goroutine-/timer-free: `Schedule()` is driven by the app tick + tab entry and coalesces bursts into one rebuild per debounce window via an injected clock (`SetNow`), so it's deterministic to test. `refreshHera` is `heraPage.Refresh()` on tab entry (forces a flush) and `ScheduleRefresh()` on the tick while `ActiveTab()==TabHera`.
- **`doRefresh`'s `BuildModel`+`SetModel` pass is gated by cheap change-detection (`HeraPage.shouldRebuild`/`markRebuilt`, fix-hera-tick-and-kick-perf) — the debounce above only bounds how OFTEN a rebuild opportunity arrives, not whether one that arrives is actually worth paying for; every rebuild used to run `ListHeraOrchestrators(true)` (archived included) + all bindings + `Rail.buildRows()`'s full `canonicalParents`/`structuralReach` graph walk UNCONDITIONALLY, once a second, scaling with TOTAL historical role/orchestrator/binding count (Aaron's ~900+) regardless of active-agent count — measured at 34ms/30MB/133k allocs per rebuild at that scale (`internal/tui/hera/doRefresh_bench_test.go`).** The gate combines a cheap SQLite `PRAGMA data_version` fingerprint (`db.DB.DataVersion`, near-O(1), catches any OTHER connection's write — the dominant real source, since daemon/MCP-driven hera mutations use a separate connection from the TUI's own) with a `maps.Equal` comparison of the four per-tick RUNTIME maps also fed into the model (`needsInput`/`sessionIdle`/`sessionRunning`/`sustainedActive` — bounded by LIVE session count, cheap even at 900+ total roles); either changing triggers a rebuild, so a quiet DB with active agents still animates the spinner/`(?)` glyphs correctly. Post-gate steady-state cost measured at ~850ns/400B/12 allocs per tick (idle) — a ~35,000x reduction.
- **`PRAGMA data_version` does NOT change what a connection reads back after ITS OWN write (SQLite's documented same-connection blind spot, verified against `modernc.org/sqlite` under this repo's WAL DSN) — the TUI's `a.db` both reads (the tick) and writes (hera mutations, reconciliation) through ONE connection.** Closed not by ignoring the blind spot but by `HeraPage.Refresh()` (the general "force it now" primitive — tab entry, every `App.heraRefresh()`-driven mutation, and tests) calling `InvalidateChangeGate()` before flushing, so ITS OWN "forces an immediate rebuild" doc contract holds regardless of the gate. **Scoping the invalidation to `heraRefresh()` alone (the first implementation attempt) broke `TestRefresh_StatusStepReprojectsPlanNode`**, which writes through the same `*db.DB` the page reads from and calls `Refresh()` directly, expecting an unconditional rebuild — `Refresh()` itself is the right chokepoint, not each individual caller. `ScheduleRefresh` (the tick's own periodic, non-forced call) is deliberately left subject to the gate.
- **Explicit non-goal, not silently dropped: the gate does NOT cover `refreshTasksWithIDs`'s own base reads** (`db.Tasks()`, `ListMetaByNamespace("pr")`/`("hera")`, `ManagedTaskIDs()`) or lazy-load archived rows out of `Rail.buildRows()`'s graph algorithms — see `openspec/changes/archive/.../fix-hera-tick-and-kick-perf/proposal.md`'s Impact section for why both were scoped out (more un-audited local-write call sites; risk to `rail.go`'s heavily invariant-laden fold logic, respectively). The double `Tasks()` fetch per tick (once in `refreshTasksWithIDs`, once inside `BuildModel`) IS de-duplicated — `HeraPage.SetTasks` + an internal `tasksReader` wrapper serve `BuildModel`'s `Tasks()` call from the App's already-fetched snapshot instead of a second query, constructed transiently at the `BuildModel` call site (never stored back into `p.reader`) so it can't interfere with the fingerprint's own type-assertion against the unwrapped reader.
- **`HeraPage.Machine()`, not `Focus()`.** A method named `Focus()` collides with `tview.Primitive.Focus(func(tview.Primitive))` and silently breaks the Primitive interface (the page stops being a valid tview widget). The focus-machine accessor is `Machine()`. The `FocusMachine` is wired for 6b (Advance/Retreat/present-pane rebalance); 6a keeps focus on the rail and only reads `State()` to pick the border-highlight palette.
- **The rail navigates with `j`/`k`/Up/Down and folds with Space — never arrows-for-parent-nav like Hera's upstream rail did.** Historically the rail was BARRED from binding Left/Right because the global handler ate them for tab switching; that arrow-key tab-cycling has since been removed (tab nav is `1`/`2`/`3` only), so Left/Right now fall through to the page: a terminal-focused pane (coord/worker-agent) forwards them to the PTY, a coordinator's details region routes them to the embedded plan widget's cursor (slot/member nav, M7), while the rail leaves them unused (free for future horizontal navigation).
- **`Ctrl+Alt+Left/Right` walk the focus ladder (Retreat/Advance), mirroring `Tab`/`Backtab`.** Handled in `HeraPage.InputHandler`'s top-level key switch BEFORE the per-region forward, so they reach `FocusMachine.Retreat`/`Advance` rather than being forwarded to a focused terminal pane's PTY (a plain Left/Right still forwards). The modifier check is `Modifiers()&(ModCtrl|ModAlt) != 0` — **either** modifier, not strictly both — matching the main agent view's Ctrl+Alt+arrow pane switch (`app.go`); terminals are inconsistent about which of Ctrl/Alt they report for this chord, so the loose check is the proven pattern. This binding lives in the page handler (not `handleGlobalKey`) precisely because plain Left/Right intentionally fall through the global handler to the focused view.
Expand Down
Loading
Loading