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
21 changes: 18 additions & 3 deletions .claude/skills/hera/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,9 @@ they opt in. Once you're spawned/promoted per the bullets above, everything belo
## 3. The coordination tools

All take `cwd`. `orchestrator` is optional with exactly one live binding and **required** with 2+.
Arg names below are exact — do not invent others. These nine cover bootstrap, messaging, and status;
the plan-DAG authoring/mutation tools live in the companion `hera-plan` skill (pointer at the end of
this section).
Arg names below are exact — do not invent others. These ten cover bootstrap, messaging, status, and
revive; the plan-DAG authoring/mutation tools live in the companion `hera-plan` skill (pointer at the
end of this section).

### Bootstrap / join

Expand Down Expand Up @@ -148,6 +148,18 @@ this section).
The gater treats a `failed` blocker as explicitly failed (no need to wait for session death).
Coordinators/freelancers just update status.

- **`hera_revive(cwd, role_name, [orchestrator])`** — coordinator-only PULL-revive of one role you
coordinate. Reach for this when a role you spawned looks stuck — `hera_tree_updates`/`hera_status`
show no progress, especially after something like a session-supervisor restart (which SIGHUPs every
PTY it owns, leaving a worker dead or suspended). It inspects the role's live session and takes
exactly one action: a dead session is restarted in place; a live-but-genuinely-stuck session (idle,
NOT parked at a prompt) is kicked (stopped and resumed in place); anything else — busy, blocked on a
question, a live coordinator, or a kick already in flight — is left untouched and reported as such
(`skipped_busy` / `skipped_blocked_on_prompt` / `skipped_coordinator_live` / `skipped_restart_pending`).
This is **pull-only** — nothing calls it automatically, and it can never thrash a session that is
actually working or waiting on an answer, since it applies the identical idle+not-blocked gate the
TUI's own `Enter`-key revive uses. It targets a DIFFERENT role than your own (self-targeting errors).

- **`hera_tree_updates(cwd, [orchestrator], [since])`** — scan the caller's orchestrator **subtree**
(nested sub-orchestrators included) for messages since a cursor. Returns **TLDR-only subject lines —
no bodies** (capped at 200), plus a `next_cursor`. The cursor is stored **per-role** and auto-advances
Expand Down Expand Up @@ -220,6 +232,9 @@ or using in-session sub-agents.
doorbell line.
- **Want whole-team state?** `hera_tree_updates(cwd=$PWD)`, then `hera_get_messages(ids=[…])` for the
ones worth reading.
- **A role looks stuck (no progress, especially after a session-supervisor restart)?** Don't spawn a
duplicate worker on a hunch — try `hera_revive(cwd=$PWD, role_name=<name>)` first. It's a safe,
idle+not-blocked-gated no-op if the role turns out to be fine, busy, or waiting on a question.
- **How completion flows back:** a worker finishing sends a closing `hera_send(status="done", …)` — the
synchronous status apply rolls its task to `in_review` + `ready_to_close`, visible in the rail. A
worker that cannot complete sends `hera_send(status="failed", …)` — rolls to `in_review` WITHOUT
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,7 @@ If the recipient has a live agent session the daemon also writes a single notifi
| `hera_inbox` | Fetch the caller role's unread messages (oldest first), cancel their pending pane deliveries, and mark them read. |
| `hera_mark_read` | Mark a specific list of message IDs read and cancel their pending deliveries. |
| `hera_status` | Set the caller role's status (`idle`/`working`/`blocked`/`done`/`failed`), mirrored to `task_meta`; `done` rolls the worker's task to in-review + `ready_to_close`; `failed` rolls to in-review without `ready_to_close`. Optional `handoff_note` (string) and `request_recycle` (bool) are accepted from **any hera-bound role kind** (coordinator, worker, or freelance): `handoff_note` is stamped to `task_meta` for the next recycle's seed prompt, `request_recycle=true` flags a pending [self-service recycle](#context-budget-stop-hook) that the daemon acts on once the session goes idle — for a coordinator this is driven by the `coord-hook` budget nudge, for a worker/freelance role only by a human-initiated rail `B` bounce. |
| `hera_revive` | Coordinator-only PULL-revive of one role the caller coordinates, by `role_name`. A dead session (no live process) restarts in place; a live-but-genuinely-stuck session (idle, not blocked on a prompt) is kicked (stopped and resumed in place) — the same safety gate the rail's `Enter`-key revive already enforces, so it can never thrash a session that's actually working or waiting on an answer. Anything else (busy, blocked on a question, a live coordinator, a kick already in flight) is left untouched and reported as such. Pull-only — nothing calls it automatically; a coordinator reaches for it when `hera_status`/`hera_tree_updates` show no progress. |
| `hera_tree_updates` | Scan the caller's orchestrator subtree for messages since a per-role cursor; returns TLDR subject lines only and auto-advances the cursor. |
| `hera_get_messages` | Fetch full message bodies by ID (after `hera_tree_updates`), scoped to the caller's orchestrator subtree. |
| `hera_plan_node` | Author a single planned node under the caller's orchestrator (coordinator-only). Params: `name`, `kind` (`worker`\|`subcoord`, default `worker`), `prompt` (worker nodes) or `goal` (subcoord nodes — required; the goal handed to the spawned coordinator), optional `archetype` ([diligence profile](#diligence-profiles-model-tiering)) persisted on the node and copied onto the task it materializes. A `subcoord` node materializes as a distinct coordinator agent with its own task, worktree, and child orchestrator. |
Expand Down
1 change: 1 addition & 0 deletions context/knowledge/gotchas/daemon-rpc.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@
- **`agent.ReconcileStaleSessionsExcept(db, alive)` is the reusable primitive; `ReconcileStaleSessions` is now a thin `Except(db, nil)` wrapper.** It flips InProgress→InReview for every task EXCEPT those in `alive` and RETURNS the flipped IDs (the true orphans) so the caller signals exactly them. Nil `alive` ⇒ flips all (the in-process equivalent). `sendBounceSignals(db, ids)` is the extracted ARGUS_BOUNCED inner loop shared by `replayBounceSignals` (file-fed, OFF) and `reattachSupervised` (orphan-fed, ON).
- **OFF mode is byte-identical to pre-P3 — proven by the unchanged `replayBounceSignals`/`writeLiveTasksFile` suite passing.** The flag gates the entire re-attach path via `d.supClient != nil`; no behavior change when the supervisor is disabled.
- **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.

## Session-supervisor (P4 — default ON + rollback)

Expand Down
2 changes: 1 addition & 1 deletion context/knowledge/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) | 108 |
| [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 |
Expand Down
19 changes: 19 additions & 0 deletions internal/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,24 @@ func (d *Daemon) heraSpawnWorker(in mcp.HeraSpawnInput) (*mcp.HeraSpawnResult, e
return &mcp.HeraSpawnResult{Task: res.Task, Role: res.Role, Binding: res.Binding}, nil
}

// heraReviveRole performs the PULL-revive gating+action (add-hera-revive) for
// the hera_revive MCP tool, injected via SetHeraReviver. The gating sequence
// lives in the shared hera.ReviveRole primitive (mirrors RecycleCoord's
// architecture); this method just supplies the real daemon.HeraReviveRunner
// adapter over d.db + d.runner. See design.md D3 for why the TUI's Enter-key
// revive (internal/tui/heraactions.go) is NOT routed through this same call —
// it keeps its own inline implementation, sharing every underlying primitive
// (agent.BlockedOnPrompt, db.ReviveHeraWorkerToInProgress,
// SessionRunner.KickRerender/StartOrReattach) but not the top-level orchestration.
func (d *Daemon) heraReviveRole(in mcp.HeraReviveInput) (string, error) {
rr := NewHeraReviveRunner(d.db, d.runner, d.cfgFn)
outcome, err := hera.ReviveRole(d.db, rr, in.TaskID, in.IsCoordinator)
if err != nil {
return "", err
}
return string(outcome), nil
}

// heraGaterMaterialize is the gater's Materializer adapter (add-hera-plan-substrate):
// it binds + starts a pre-created planned role via the shared
// agent.MaterializeHeraWorker primitive. The gater resolves project / base_branch /
Expand Down Expand Up @@ -1081,6 +1099,7 @@ func (d *Daemon) Serve(sockPath string) error {
// in place when the native service is wired, and is simply absent here.
if cfg.Hera.Enabled {
mcpSrv.SetHeraService(hera.New(d.db, d.notifier), d.db, d.heraSpawnWorker)
mcpSrv.SetHeraReviver(d.heraReviveRole)
}
mcpSrv.SetArtifactManager(d.db)
mcpSrv.SetProfileResolver(d.db)
Expand Down
Loading
Loading