fix(orchestrator): resolve adapters by herdr instance id, not harness kind (B2)

AdapterFactory.Herdrs is keyed by herdr instance id (e.g. "homesrv-claude"),
but Reconcile, expire, and rotate all looked adapters up by session.Harness
(the harness kind, e.g. "claude"). In production this key never resolves,
so every one of those call sites silently no-ops via a bare `continue`:
orphaned panes are never killed on restart, expired leases never kill their
pane, and rotation exits before it begins.

Add Coordinator.adapterFor(taskID, session), matching the fallback already
used correctly by refreshSessionHealth (HerdrID, then the lease's
HarnessID, then Harness as a last resort), and route all four call sites
through it.

Regression test TestAdapterResolvedByHerdrIDNotHarnessKind registers an
adapter under "homesrv-claude" and leases with Session.Harness == "claude"
(reproducing the real key mismatch) and asserts rotation still fires — the
existing rotation tests used a keyed-by-nothing fake adapter that matched
any lookup string and so masked this bug entirely.

AUDIT.md B2.
This commit is contained in:
kami
2026-07-27 18:18:50 +04:00
parent ce6f02f9e6
commit ac38b59322
2 changed files with 94 additions and 11 deletions
+24 -11
View File
@@ -175,6 +175,25 @@ type SessionHealth struct {
LastError string `json:"last_error,omitempty"`
}
// adapterFor resolves the herdr adapter for a session. Session.HerdrID (the
// registered herdr instance id, e.g. "homesrv-claude") is authoritative;
// Session.Harness (the harness kind, e.g. "claude") is only a fallback for
// sessions persisted before HerdrID was tracked. Adapters are keyed by
// instance id, so falling back to the lease's harness id (recorded on the
// task) rather than the kind keeps this resolvable even then.
func (c *Coordinator) adapterFor(taskID string, session herdr.Session) (herdr.Adapter, error) {
id := session.HerdrID
if id == "" {
if task, ok := c.Store.Task(taskID); ok && task.Lease != nil {
id = task.Lease.HarnessID
}
}
if id == "" {
id = session.Harness
}
return c.Adapters.Adapter(id)
}
func (c *Coordinator) MonitorHealth() MonitorHealth {
c.healthMu.RLock()
defer c.healthMu.RUnlock()
@@ -218,13 +237,7 @@ func (c *Coordinator) refreshSessionHealth(ctx context.Context) {
}
c.healthMu.Unlock()
for taskID, session := range sessions {
adapterID := session.HerdrID
if adapterID == "" {
if task, ok := c.Store.Task(taskID); ok && task.Lease != nil {
adapterID = task.Lease.HarnessID
}
}
a, err := c.Adapters.Adapter(adapterID)
a, err := c.adapterFor(taskID, session)
if err != nil {
continue
}
@@ -288,7 +301,7 @@ func (c *Coordinator) Reconcile(ctx context.Context) error {
if ok && t.State == domain.StateLeased {
continue
}
if a, err := c.Adapters.Adapter(session.Harness); err == nil {
if a, err := c.adapterFor(taskID, session); err == nil {
_ = a.Kill(ctx, session)
}
delete(c.sessions, taskID)
@@ -373,7 +386,7 @@ func (c *Coordinator) expire(ctx context.Context) ([]domain.Event, error) {
c.mu.Lock()
for taskID, s := range c.sessions {
if t, ok := c.Store.Task(taskID); ok && t.State == domain.StateLeased {
if a, ae := c.Adapters.Adapter(s.Harness); ae == nil {
if a, ae := c.adapterFor(taskID, s); ae == nil {
if p, ok := a.(herdr.PaneExit); ok {
if exited, ee := p.PaneExited(ctx, s); ee == nil && exited {
b, _ := json.Marshal(map[string]string{"reason": "pane_exited", "harness_id": s.Harness})
@@ -394,7 +407,7 @@ func (c *Coordinator) expire(ctx context.Context) ([]domain.Event, error) {
s, ok := c.sessions[e.TaskID]
delete(c.sessions, e.TaskID)
if ok {
if a, ae := c.Adapters.Adapter(s.Harness); ae == nil {
if a, ae := c.adapterFor(e.TaskID, s); ae == nil {
_ = a.Kill(ctx, s)
}
}
@@ -416,7 +429,7 @@ func (c *Coordinator) rotate(ctx context.Context, hard float64) {
if !ok || task.State != domain.StateLeased {
continue
}
a, err := c.Adapters.Adapter(session.Harness)
a, err := c.adapterFor(taskID, session)
if err != nil {
continue
}