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
1 change: 1 addition & 0 deletions context/knowledge/gotchas/messaging.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,3 +190,4 @@ true)` and `db.Delete(id)`; entrypoints that go through `db.Update`
done keeps role active + messageable" / "explicit archive removes role from
recipient resolution"). Do NOT "fix" a recipient bounce by making archived
roles messageable — done must not archive; explicit archive must.
- **`hera_send` auto-revives an explicit-`to` recipient before delivering (add-hera-send-auto-revive)** — a coordinator sending to a DIFFERENT, explicitly-named role (never the worker/freelance default-to-coordinator route, never a self-send) reuses `internal/hera.ReviveRole`/`s.heraRevive` VERBATIM, the exact same primitive `hera_revive` calls — no new gating logic. It is entirely soft-fail: no live binding (`db.ErrHeraNotFound`, the common planned/never-spawned/ended case), a lookup error, a revive error, or `s.heraRevive == nil` (reviver not wired) all skip silently or Warn-log, and the message send proceeds and succeeds/fails purely on its own merits either way. A successful attempt renders a `- **revive**: <outcome>` line via the shared `heraReviveOutcomeMessage` and logs the identical `slog.Info("[hera] revive", ...)` line `hera_revive` emits — an auto-triggered revive is indistinguishable in logs from a manual one.
2 changes: 1 addition & 1 deletion context/knowledge/index.md

Large diffs are not rendered by default.

28 changes: 28 additions & 0 deletions internal/mcp/hera.go
Original file line number Diff line number Diff line change
Expand Up @@ -1271,6 +1271,31 @@ func (s *Server) toolHeraSend(id interface{}, args json.RawMessage) *Response {
}
}

// Auto-revive (add-hera-send-auto-revive): a coordinator sending to an
// explicitly named, different role gets the exact same PULL-revive
// hera_revive already provides — reused verbatim via s.heraRevive /
// internal/hera.ReviveRole, no new gating logic. Soft-fail throughout: a
// missing binding, a lookup error, a revive error, or heraRevive not
// being wired all skip silently (or Warn-log) and never block the send.
var reviveOutcome string
if caller.role.Kind == db.HeraKindCoordinator && p.To != "" && toRole.ID != caller.role.ID && s.heraRevive != nil {
binding, bindErr := heraLiveOrNil(s.heraStore.HeraLiveBindingByRole(toRole.ID))
if bindErr != nil {
slog.Warn("[hera] send: auto-revive binding lookup failed", "to_role", toRole.Name, "err", bindErr)
} else if binding != nil {
outcome, reviveErr := s.heraRevive(HeraReviveInput{
TaskID: binding.ArgusTaskID,
IsCoordinator: toRole.Kind == db.HeraKindCoordinator,
})
if reviveErr != nil {
slog.Warn("[hera] send: auto-revive failed", "to_role", toRole.Name, "task_id", binding.ArgusTaskID, "err", reviveErr)
} else {
reviveOutcome = outcome
slog.Info("[hera] revive", "orch", caller.orch.Name, "role", toRole.Name, "task_id", binding.ArgusTaskID, "outcome", outcome)
}
}
}

msg, err := s.heraSvc.Send(caller.role.ID, toRole.ID, p.Body, p.Tldr, p.InReplyTo)
if err != nil {
switch {
Expand Down Expand Up @@ -1300,6 +1325,9 @@ func (s *Server) toolHeraSend(id interface{}, args json.RawMessage) *Response {
fmt.Fprintf(&b, "- **message_id**: %d\n", msg.ID)
fmt.Fprintf(&b, "- **to**: %s\n", toRole.Name)
fmt.Fprintf(&b, "- **delivery_mode**: %s\n", msg.DeliveryMode)
if reviveOutcome != "" {
fmt.Fprintf(&b, "- **revive**: %s\n", heraReviveOutcomeMessage(reviveOutcome, toRole.Name))
}
return toolResult(id, b.String())
}

Expand Down
212 changes: 212 additions & 0 deletions internal/mcp/hera_send_revive_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
package mcp

import (
"encoding/json"
"errors"
"fmt"
"strings"
"testing"

"github.com/drn/argus/internal/db"
"github.com/drn/argus/internal/testutil"
)

// --- hera_send auto-revive (add-hera-send-auto-revive) ---
//
// These tests exercise toolHeraSend's new auto-revive attempt using the same
// fakeHeraReviver harness hera_revive_test.go defines: they assert whether
// the reviver was called, with what args, and how the outcome (or its
// absence) is rendered in the hera_send response — never a real PTY/runner.

func TestHeraSend_AutoRevive_DeadRecipientRestartedBeforeSend(t *testing.T) {
s, d := testHeraServer(t)
coordWt, workerWt := setupOrchWithWorker(t, s, d)
_ = workerWt

fr := &fakeHeraReviver{outcome: "restarted_dead"}
s.SetHeraReviver(fr.reviver())

orch, err := d.HeraOrchestratorByName("test-orch")
testutil.NoError(t, err)
workerRole, err := d.HeraRoleByName(orch.ID, "w1")
testutil.NoError(t, err)
binding, err := d.HeraLiveBindingByRole(workerRole.ID)
testutil.NoError(t, err)

resp := doRequest(t, s, "tools/call", ToolCallParams{
Name: "hera_send",
Arguments: json.RawMessage(fmt.Sprintf(`{
"cwd":%q,"to":"w1","body":"wake up","tldr":"wake"
}`, coordWt)),
})
testutil.NoError(t, respErr(resp))
cr := callResult(t, resp)
if cr.IsError {
t.Fatalf("expected success, got error: %s", cr.Content[0].Text)
}
testutil.Equal(t, fr.called, true)
testutil.Equal(t, fr.calledWith.TaskID, binding.ArgusTaskID)
testutil.Equal(t, fr.calledWith.IsCoordinator, false)
testutil.Contains(t, cr.Content[0].Text, "- **revive**:")
testutil.Contains(t, cr.Content[0].Text, "restarted")
testutil.Contains(t, cr.Content[0].Text, "Message sent")
}

func TestHeraSend_AutoRevive_SkipOutcomesStillDeliver(t *testing.T) {
for _, outcome := range []string{
"skipped_busy",
"skipped_blocked_on_prompt",
"skipped_coordinator_live",
"kicked_stuck",
} {
t.Run(outcome, func(t *testing.T) {
s, d := testHeraServer(t)
coordWt, _ := setupOrchWithWorker(t, s, d)

fr := &fakeHeraReviver{outcome: outcome}
s.SetHeraReviver(fr.reviver())

resp := doRequest(t, s, "tools/call", ToolCallParams{
Name: "hera_send",
Arguments: json.RawMessage(fmt.Sprintf(`{
"cwd":%q,"to":"w1","body":"hi","tldr":"hi"
}`, coordWt)),
})
testutil.NoError(t, respErr(resp))
cr := callResult(t, resp)
if cr.IsError {
t.Fatalf("expected success, got error: %s", cr.Content[0].Text)
}
testutil.Equal(t, fr.called, true)
testutil.Contains(t, cr.Content[0].Text, "- **revive**:")
testutil.Contains(t, cr.Content[0].Text, "Message sent")
testutil.Contains(t, cr.Content[0].Text, "**to**: w1")
})
}
}

func TestHeraSend_AutoRevive_NoLiveBindingSkipsSilentlyAndSendSucceeds(t *testing.T) {
s, d := testHeraServer(t)
coordTask := seedCoordinator(t, s, d, "O", "/wt/coord")

orch, err := d.HeraOrchestratorByName("O")
testutil.NoError(t, err)
_, err = d.CreateHeraPlannedRole(db.CreateHeraRoleInput{
OrchestratorID: orch.ID, Name: "planned-1", Kind: db.HeraKindWorker, ArgusProject: "test-project", Prompt: "later",
})
testutil.NoError(t, err)

fr := &fakeHeraReviver{outcome: "restarted_dead"}
s.SetHeraReviver(fr.reviver())

resp := doRequest(t, s, "tools/call", ToolCallParams{
Name: "hera_send",
Arguments: json.RawMessage(fmt.Sprintf(`{
"cwd":%q,"to":"planned-1","body":"hi","tldr":"hi"
}`, coordTask.Worktree)),
})
testutil.NoError(t, respErr(resp))
cr := callResult(t, resp)
if cr.IsError {
t.Fatalf("expected success (message still stored/attempted), got error: %s", cr.Content[0].Text)
}
testutil.Equal(t, fr.called, false)
if strings.Contains(cr.Content[0].Text, "- **revive**:") {
t.Fatalf("expected no revive line when recipient has no live binding, got: %s", cr.Content[0].Text)
}
testutil.Contains(t, cr.Content[0].Text, "Message sent")
}

func TestHeraSend_AutoRevive_ReviverNilDoesNotBlockSend(t *testing.T) {
s, d := testHeraServer(t)
coordWt, _ := setupOrchWithWorker(t, s, d)

// testHeraServer does NOT call SetHeraReviver — s.heraRevive stays nil.
resp := doRequest(t, s, "tools/call", ToolCallParams{
Name: "hera_send",
Arguments: json.RawMessage(fmt.Sprintf(`{
"cwd":%q,"to":"w1","body":"hi","tldr":"hi"
}`, coordWt)),
})
testutil.NoError(t, respErr(resp))
cr := callResult(t, resp)
if cr.IsError {
t.Fatalf("expected success, got error: %s", cr.Content[0].Text)
}
if strings.Contains(cr.Content[0].Text, "- **revive**:") {
t.Fatalf("expected no revive line when no reviver is wired, got: %s", cr.Content[0].Text)
}
testutil.Contains(t, cr.Content[0].Text, "Message sent")
}

func TestHeraSend_AutoRevive_ReviveErrorDoesNotBlockSend(t *testing.T) {
s, d := testHeraServer(t)
coordWt, _ := setupOrchWithWorker(t, s, d)

fr := &fakeHeraReviver{err: errors.New("boom")}
s.SetHeraReviver(fr.reviver())

resp := doRequest(t, s, "tools/call", ToolCallParams{
Name: "hera_send",
Arguments: json.RawMessage(fmt.Sprintf(`{
"cwd":%q,"to":"w1","body":"hi","tldr":"hi"
}`, coordWt)),
})
testutil.NoError(t, respErr(resp))
cr := callResult(t, resp)
if cr.IsError {
t.Fatalf("expected success despite revive error, got error: %s", cr.Content[0].Text)
}
testutil.Equal(t, fr.called, true)
if strings.Contains(cr.Content[0].Text, "- **revive**:") {
t.Fatalf("expected no revive line when the revive call itself errors, got: %s", cr.Content[0].Text)
}
testutil.Contains(t, cr.Content[0].Text, "Message sent")
}

func TestHeraSend_AutoRevive_WorkerDefaultRouteNeverTriggers(t *testing.T) {
s, d := testHeraServer(t)
_, workerWt := setupOrchWithWorker(t, s, d)

fr := &fakeHeraReviver{outcome: "restarted_dead"}
s.SetHeraReviver(fr.reviver())

// Worker sends with no explicit "to" → defaults to the (live) coordinator.
resp := doRequest(t, s, "tools/call", ToolCallParams{
Name: "hera_send",
Arguments: json.RawMessage(fmt.Sprintf(`{
"cwd":%q,"body":"status","tldr":"status","status":"working"
}`, workerWt)),
})
testutil.NoError(t, respErr(resp))
cr := callResult(t, resp)
if cr.IsError {
t.Fatalf("expected success, got error: %s", cr.Content[0].Text)
}
testutil.Equal(t, fr.called, false)
if strings.Contains(cr.Content[0].Text, "- **revive**:") {
t.Fatalf("expected no revive line on the default worker->coordinator route, got: %s", cr.Content[0].Text)
}
}

func TestHeraSend_AutoRevive_SelfSendNeverTriggers(t *testing.T) {
s, d := testHeraServer(t)
coordWt, _ := setupOrchWithWorker(t, s, d)

fr := &fakeHeraReviver{outcome: "restarted_dead"}
s.SetHeraReviver(fr.reviver())

resp := doRequest(t, s, "tools/call", ToolCallParams{
Name: "hera_send",
Arguments: json.RawMessage(fmt.Sprintf(`{
"cwd":%q,"to":"coord","body":"hi","tldr":"hi"
}`, coordWt)),
})
testutil.NoError(t, respErr(resp))
cr := callResult(t, resp)
if !cr.IsError {
t.Fatal("expected the existing self-send rejection")
}
testutil.Contains(t, cr.Content[0].Text, "cannot send a message to self")
testutil.Equal(t, fr.called, false)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-03
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
## Context

`hera_revive` (`internal/mcp/hera.go:toolHeraRevive`, add-hera-revive) already gives a coordinator a pull/on-demand way to inspect one bound role's live session state and, based on it, restart a dead session, kick a stuck one, or leave it alone — via the shared, PTY-free-testable `internal/hera.ReviveRole` decision function. `hera_send` (`toolHeraSend`) resolves an explicit `to` recipient and hands off to `s.heraSvc.Send` with no session-liveness awareness at all. The gap this closes is purely "call the existing primitive automatically at the right moment in `hera_send`" — no new gating logic, no new MCP tool, no change to `ReviveRole` itself.

## Goals / Non-Goals

**Goals:**

- A coordinator's `hera_send` to an explicit, different recipient transparently revives that recipient first, using the exact same outcome set and gating order `hera_revive` already uses.
- The revive attempt can never block, delay meaningfully, or fail the message send. Every failure mode (no live binding, lookup error, revive error, reviver not wired) is a silent or log-only skip.
- The coordinator can tell, from the `hera_send` response alone, whether the recipient was dead/stuck/fine — without a second round-trip.

**Non-Goals:**

- No change to `internal/hera.ReviveRole`, its outcomes, or its gating order — this change is a new CALL SITE only.
- No auto-revive for the worker/freelance default-to-coordinator send path. A worker/freelance's default recipient is "the active coordinator" — a live coordinator is already `ReviveRole`'s own `skipped_coordinator_live` case, so attempting it would almost always no-op; more importantly, only a coordinator has the authority to revive a role it coordinates (`hera_revive` itself is coordinator-only), and a worker/freelance sender is not the coordinator of its own coordinator.
- No auto-revive when the recipient equals the caller's own role (self-send is already rejected by `heraSvc.Send` on other grounds; the revive attempt is skipped before that rejection is even reached).
- No behavior change to `hera_revive` itself, and no removal of the standalone tool — this is additive.

## Decisions

**D1 — Gate on caller kind + explicit `to` + non-self, mirroring `hera_revive`'s own authority check.** `hera_revive` rejects non-coordinator callers outright (`caller.role.Kind != db.HeraKindCoordinator`). Auto-revive-on-send reuses the same authority boundary rather than inventing a softer one: only `caller.role.Kind == db.HeraKindCoordinator` triggers an attempt, and only when the recipient was resolved via an explicit `to` (the coordinator explicitly named a role it coordinates) — never the worker/freelance default-route path, which resolves a coordinator, not a role the sender coordinates. `toRole.ID != caller.role.ID` guards the degenerate self-target case, matching `hera_revive`'s explicit own-role rejection (here expressed as a skip rather than an error, since `hera_send` self-sends are already invalid on other grounds and should fail with the existing self-send error, not a revive-related one).

**D2 — Placement: after recipient resolution, before `s.heraSvc.Send`, never blocking the send.** The attempt sits strictly between resolving `toRole` and calling `Send`. Every exit from the attempt — no live binding, a lookup error, a revive error, `s.heraRevive == nil` — falls through to the unconditional `Send` call. This is a deliberate asymmetry from `hera_revive` (a standalone tool where a revive failure IS the whole point and must surface as an error): here, revive is a courtesy side-effect of send, and send's own success/failure semantics must stay exactly as they are today.

**D3 — Reuse `heraReviveOutcomeMessage` and the same log line verbatim; no new outcome vocabulary.** The `hera_send` response's `- **revive**: <outcome>` line and the `slog.Info("[hera] revive", ...)` call use the identical rendering/logging helpers `toolHeraRevive` already has, so an auto-triggered revive is indistinguishable in logs and in outcome vocabulary from a manual one. This also means no new documentation of outcome semantics is needed beyond a cross-reference to the existing `hera_revive` requirement.

**D4 — Error handling granularity: `ErrHeraNotFound` is the expected/common case (Info/Debug at most), any other lookup or revive error is a Warn.** A recipient with no live binding (planned node, never spawned, ended) is not a bug — it's the everyday case of messaging a role that hasn't materialized yet or has wound down. Logging it above Debug/Info would spam the daemon log on every ordinary send to such a role. A different lookup error, or a `heraRevive` call error, is unexpected and worth a Warn — mirroring how `toolHeraSend`'s own status-apply soft-fail (D1 in make-hera-plan-living) already logs a Warn on failure while proceeding.

## Risks / Trade-offs

- **[Risk] A coordinator sending many messages to the same already-fine recipient pays a `HeraLiveBindingByRole` lookup (and, when there IS a live binding, a full `ReviveRole` gate evaluation — `IsAlive`/`IsIdle`/`BlockedOnPrompt`/`HasPendingRestart`) on every single send.** → Mitigation: these are the same checks `hera_revive` already performs synchronously per call, and `hera_send` already does comparable per-call DB work (role resolution, rate-limit check, inbox-cap check). No new I/O class is introduced; this is not expected to be a meaningful cost at hera's message volumes.
- **[Risk] A coordinator that intentionally messages a role it does NOT want woken (e.g. deliberately leaving a paused role parked) now wakes it as a side effect of sending.** → Mitigation: this is the intended behavior per the mission — the whole point is removing the need for a separate `hera_revive` call before messaging a sleeping child. `ReviveRole`'s own gating already protects the cases that matter (a live coordinator, a busy role, a role blocked on a prompt are all left untouched) — the only case actually revived-by-side-effect is a role that was dead or genuinely stuck, which is exactly the case a coordinator sending it a message wants alive anyway.
- **[Risk] Silent skip on `ErrHeraNotFound` could mask a genuine naming/resolution bug (e.g. a role that SHOULD have a live binding but doesn't due to a bug elsewhere).** → Mitigation: this is unchanged risk surface — `hera_send` already tolerates sending to a role with no live binding today (the message is durably stored regardless; only best-effort doorbell delivery is affected), so auto-revive attempting and silently skipping adds no new failure mode beyond what already exists.

## Open Questions

None — the mission brief fixes the design; see the brief's explicit numbered "Behavior to add" list for the exact call sequence.
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
## Why

A coordinator that notices a bound role has gone quiet must currently make two calls to reach it: `hera_revive` to wake the session, then `hera_send` to actually deliver the message. The two-step dance is easy to forget — a coordinator sends first, gets no response, and only later thinks to check whether the recipient's session is even alive. `hera_revive` already encodes the exact safety gate needed (dead → restart, stuck-but-idle → kick, busy/blocked/live-coordinator → leave alone) via the shared `internal/hera.ReviveRole` primitive. `hera_send` should just call it automatically so a coordinator never has to remember the separate step.

## What Changes

- `hera_send` (`internal/mcp/hera.go`, `toolHeraSend`), when the caller is a coordinator sending to an explicitly named `to` recipient (not the worker/freelance default-to-coordinator path, and not itself), now attempts a revive of that recipient BEFORE delivering the message — reusing `s.heraRevive`/`internal/hera.ReviveRole` verbatim, the exact same primitive `hera_revive` already calls. No new gating logic is introduced.
- The attempt is soft-fail and best-effort: a recipient with no live binding (a planned node, never spawned, or ended role), a lookup error, a revive error, or `heraRevive` not being wired (daemon didn't configure a reviver) all skip the auto-revive step silently (Info/Debug log at most) and the message send proceeds regardless.
- On a successful revive attempt, the `hera_send` tool response gains a `- **revive**: <outcome>` line (rendered via the existing `heraReviveOutcomeMessage`) alongside the existing `message_id`/`to`/`delivery_mode` lines, so the coordinator learns in one round-trip whether the recipient was dead, stuck, or already fine. The line is omitted entirely when no revive attempt was made.
- A `slog.Info("[hera] revive", ...)` line is emitted matching `toolHeraRevive`'s existing one, so an auto-triggered revive is indistinguishable in logs from a manual `hera_revive` call.

## Capabilities

### New Capabilities

(none)

### Modified Capabilities

- `hera-messaging`: the "hera_send recipient resolution and defaults" requirement's sibling gains a new requirement, "hera_send auto-revives a dead or stuck recipient," describing the coordinator-only, explicit-`to`-only auto-revive attempt and its soft-fail semantics.

## Impact

- `internal/mcp/hera.go` (`toolHeraSend`: auto-revive attempt wired between recipient resolution and `s.heraSvc.Send`)
- `internal/mcp/hera_test.go` (new test cases)
- `context/knowledge/gotchas/messaging.md`, `context/knowledge/index.md`
- No schema/data migration, no new dependencies, no new MCP tool, no REST/API surface change, no TUI behavior change. Reuses `internal/hera.ReviveRole` and `heraReviveOutcomeMessage` exactly as `hera_revive` already does — no changes to `internal/hera/revive.go`.
Loading
Loading