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
2 changes: 1 addition & 1 deletion cmd/flotilla/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -488,7 +488,7 @@ func cmdSend(args []string) error {
if err != nil {
return fmt.Errorf("agent %q: read live command: %w", agentName, err)
}
drv, liveSurface, _, err := surface.ResolveLiveDriver(agent.Surface, pane, func(string) (string, error) { return liveCommand, nil })
drv, liveSurface, err := resolveSendLiveDriver(cfg, agentName, pane, liveCommand)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When active-harness.json contains a parseable but unregistered surface and the pane command is generic (node), this call makes flotilla send fail instead of falling back to the roster. Validate the overlay surface or treat unregistered values as torn before resolving the live driver.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cmd/flotilla/main.go, line 491:

<comment>When `active-harness.json` contains a parseable but unregistered `surface` and the pane command is generic (`node`), this call makes `flotilla send` fail instead of falling back to the roster. Validate the overlay surface or treat unregistered values as torn before resolving the live driver.</comment>

<file context>
@@ -488,7 +488,7 @@ func cmdSend(args []string) error {
 		return fmt.Errorf("agent %q: read live command: %w", agentName, err)
 	}
-	drv, liveSurface, _, err := surface.ResolveLiveDriver(agent.Surface, pane, func(string) (string, error) { return liveCommand, nil })
+	drv, liveSurface, err := resolveSendLiveDriver(cfg, agentName, pane, liveCommand)
 	if err != nil {
 		return fmt.Errorf("agent %q: %w", agentName, err)
</file context>

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FIX-landed at ca83f559b4d6a3212455f583974a04371eba9cde.

workspace.EffectiveSurface treats an unregistered overlay Surface as torn (surface.Registered) and fail-safes to the roster. Coverage: TestEffectiveSurfaceUnregisteredOverlayFallsBackToRoster, TestResolveSendLiveDriverUnregisteredOverlayFallsBackToRoster (generic node + overlay not-a-driver + roster grok → send driver grok, no error). Shared seam, not send-only.

if err != nil {
return fmt.Errorf("agent %q: %w", agentName, err)
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/flotilla/notify_fleet_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ func loadFleetStatusBlock(rosterPath, skipFrom string) (string, error) {
generatedAt = fi.ModTime().UTC().Format(time.RFC3339)
}
dispositions := statusSeatDispositions(rosterDir, cfg)
doc := buildStatusJSON(cfg, xo, generatedAt, snap, loopByAgent, dispositions)
doc := buildStatusJSON(cfg, xo, generatedAt, snap, loopByAgent, dispositions, statusSurfaces(cfg))
sdoc := status.Doc{
GeneratedAt: doc.GeneratedAt,
XO: doc.XO,
Expand Down
10 changes: 10 additions & 0 deletions cmd/flotilla/send_delivery.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ const (
sendRetryMaxAttempts = 3
)

// resolveSendLiveDriver picks the CLI-send driver. The active-harness overlay is
// the configured surface fed to ResolveLiveDriver, so a generic runtime pane
// (node/python) falls back to the overlay harness, not the stale roster primary.
func resolveSendLiveDriver(cfg *roster.Config, agentName, pane, liveCommand string) (surface.Driver, string, error) {
drv, live, _, err := surface.ResolveLiveDriver(agentSurface(cfg, agentName), pane, func(string) (string, error) {
return liveCommand, nil
})
return drv, live, err
}

func deliverSendOnce(drv surface.Driver, pane, message string) error {
confirm := surface.Confirm{SendEnter: deliver.SendEnter, Sleep: time.Sleep}
if surface.SelfHealEnabled() {
Expand Down
28 changes: 28 additions & 0 deletions cmd/flotilla/send_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package main
import (
"strings"
"testing"

"github.com/jim80net/flotilla/internal/roster"
)

// The mirror precedence matrix: --no-mirror forces off, --mirror forces on, else the
Expand All @@ -28,6 +30,32 @@ func TestShouldMirror(t *testing.T) {
}
}

func TestResolveSendLiveDriverGenericNodeUsesOverlay(t *testing.T) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: TestResolveSendLiveDriverGenericNodeUsesOverlay duplicates the send-check block of TestGenericNodeAssessAndSendUseOverlayNotRoster in watch_test.go — same agent, overlay, cfg, inputs, and assertions. If the send-path coverage is meant to live in send_test.go, drop the duplicated send block from watch_test.go so the same scenario is asserted once.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cmd/flotilla/send_test.go, line 35:

<comment>TestResolveSendLiveDriverGenericNodeUsesOverlay duplicates the send-check block of TestGenericNodeAssessAndSendUseOverlayNotRoster in watch_test.go — same agent, overlay, cfg, inputs, and assertions. If the send-path coverage is meant to live in send_test.go, drop the duplicated send block from watch_test.go so the same scenario is asserted once.</comment>

<file context>
@@ -30,6 +32,19 @@ func TestShouldMirror(t *testing.T) {
 
 // --mirror and --no-mirror together is a clear error (caught right after flag parse,
 // before any roster load or tmux delivery).
+func TestResolveSendLiveDriverGenericNodeUsesOverlay(t *testing.T) {
+	root := t.TempDir()
+	writeAgentOverlay(t, root, "backend", `{"slot":"fallback-0","surface":"codex"}`)
</file context>

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FIX-landed at ca83f559b4d6a3212455f583974a04371eba9cde.

Watch coverage is TestGenericNodeAssessUsesOverlayNotRoster (assess only). Send coverage stays in cmd/flotilla/send_test.go (TestResolveSendLiveDriverGenericNodeUsesOverlay). Duplicate send-check block removed.

Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
root := t.TempDir()
writeAgentOverlay(t, root, "backend", `{"slot":"fallback-0","surface":"codex"}`)
cfg := &roster.Config{Agents: []roster.Agent{{Name: "backend", Surface: "grok"}}}
drv, live, err := resolveSendLiveDriver(cfg, "backend", "%42", "node")
if err != nil {
t.Fatal(err)
}
if drv.Name() != "codex" || live != "codex" {
t.Fatalf("send driver=%q live=%q, want overlay codex (roster is grok)", drv.Name(), live)
}
}

func TestResolveSendLiveDriverUnregisteredOverlayFallsBackToRoster(t *testing.T) {
root := t.TempDir()
writeAgentOverlay(t, root, "backend", `{"surface":"not-a-driver"}`)
cfg := &roster.Config{Agents: []roster.Agent{{Name: "backend", Surface: "grok"}}}
drv, live, err := resolveSendLiveDriver(cfg, "backend", "%42", "node")
if err != nil {
t.Fatalf("unregistered overlay must fail-safe to roster, not error: %v", err)
}
if drv.Name() != "grok" || live != "grok" {
t.Fatalf("send driver=%q live=%q, want roster grok (overlay not-a-driver is torn)", drv.Name(), live)
}
}

// --mirror and --no-mirror together is a clear error (caught right after flag parse,
// before any roster load or tmux delivery).
func TestCmdSendRejectsBothMirrorFlags(t *testing.T) {
Expand Down
39 changes: 32 additions & 7 deletions cmd/flotilla/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"github.com/jim80net/flotilla/internal/surface"
"github.com/jim80net/flotilla/internal/utilization"
"github.com/jim80net/flotilla/internal/watch"
"github.com/jim80net/flotilla/internal/workspace"
)

// cmdStatus prints a one-line-per-desk view of the fleet's last-known state. It
Expand Down Expand Up @@ -76,7 +77,7 @@ func cmdStatus(args []string) error {
if fi, statErr := os.Stat(*snapshotPath); statErr == nil {
generatedAt = fi.ModTime().UTC().Format(time.RFC3339)
}
doc := buildStatusJSON(cfg, xo, generatedAt, snap, loopByAgent, dispositions)
doc := buildStatusJSON(cfg, xo, generatedAt, snap, loopByAgent, dispositions, statusSurfaces(cfg))
doc.Quality = harnessquality.LoadSummary(filepath.Dir(*rosterPath), now)
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
Expand Down Expand Up @@ -124,8 +125,9 @@ type statusItem struct {

// buildStatusJSON assembles the --json document. Pure (no I/O) so it is
// unit-testable with an in-memory snapshot; cmdStatus supplies generated_at
// (the snapshot's mtime), the loaded snapshot, and pre-derived loop evidence.
func buildStatusJSON(cfg *roster.Config, xo, generatedAt string, snap watch.Snapshot, loopByAgent map[string]loopposture.Evidence, dispositions map[string]statusSeatDisposition) statusDoc {
// (the snapshot's mtime), the loaded snapshot, pre-derived loop evidence, and
// overlay-first surfaces resolved by the command layer (statusSurfaces).
func buildStatusJSON(cfg *roster.Config, xo, generatedAt string, snap watch.Snapshot, loopByAgent map[string]loopposture.Evidence, dispositions map[string]statusSeatDisposition, surfaces map[string]string) statusDoc {
doc := statusDoc{GeneratedAt: generatedAt, XO: xo, Agents: make([]statusItem, 0, len(cfg.Agents))}
if generatedAt != "" {
doc.GeneratedAtScope = "detector_snapshot_only"
Expand All @@ -146,9 +148,13 @@ func buildStatusJSON(cfg *roster.Config, xo, generatedAt string, snap watch.Snap
displayPosture = "unavailable"
queueState = utilization.QueueUnknown
}
surf := a.Surface
if s, ok := surfaces[a.Name]; ok {
surf = s
}
item := statusItem{
Name: a.Name,
Surface: effectiveSurface(a.Surface),
Surface: effectiveSurface(surf),
State: state,
LoopPosture: string(displayPosture),
QueueState: queueState,
Expand Down Expand Up @@ -179,8 +185,27 @@ func summarizeStatusItems(items []statusItem) utilization.Summary {
return utilization.Build(agents)
}

// effectiveSurface resolves an agent's surface name for display: an empty roster
// surface means the default driver, which the docs name "claude-code".
// statusSurfaces resolves overlay-first configured surfaces for every roster
// agent. The command layer (cmdStatus, notify) calls this so buildStatusJSON
// stays pure.
func statusSurfaces(cfg *roster.Config) map[string]string {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: statusSurfaces and loadAgentSurfaces are identical helpers (iterate roster agents, map each to EffectiveSurface) added in two different call layers, and a third copy already exists in goals.agentSurfacesFromRoster. Extract one helper into internal/workspace (e.g. EffectiveSurfaces(cfg)) and call it from status, dash server, and goals so overlay-first resolution stays in one place.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cmd/flotilla/status.go, line 190:

<comment>statusSurfaces and loadAgentSurfaces are identical helpers (iterate roster agents, map each to EffectiveSurface) added in two different call layers, and a third copy already exists in goals.agentSurfacesFromRoster. Extract one helper into internal/workspace (e.g. EffectiveSurfaces(cfg)) and call it from status, dash server, and goals so overlay-first resolution stays in one place.</comment>

<file context>
@@ -179,9 +184,23 @@ func summarizeStatusItems(items []statusItem) utilization.Summary {
+// statusSurfaces resolves overlay-first configured surfaces for every roster
+// agent. The command layer (cmdStatus, notify) calls this so buildStatusJSON
+// stays pure.
+func statusSurfaces(cfg *roster.Config) map[string]string {
+	if cfg == nil {
+		return nil
</file context>

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FIX-landed at 8cbd684d3c75ef6d56b35b49d3935ad11a33047b.

workspace.EffectiveSurfaces([]NamedSurface) is the shared seam (no roster import). statusSurfaces, loadAgentSurfaces, and agentSurfacesFromRoster call it. Goals still lowercases keys and skips empty after that map. Overlay-first semantics unchanged. Generic-fixture coverage: TestEffectiveSurfacesGenericFixturesOverlayFirst.

return workspace.EffectiveSurfaces(namedSurfaces(cfg))
}

func namedSurfaces(cfg *roster.Config) []workspace.NamedSurface {
if cfg == nil {
return nil
}
out := make([]workspace.NamedSurface, len(cfg.Agents))
for i, a := range cfg.Agents {
out[i] = workspace.NamedSurface{Name: a.Name, RosterSurface: a.Surface}
}
return out
}

// effectiveSurface resolves an agent's surface name for display: empty means
// the default driver, which the docs name "claude-code". Callers pass the
// overlay-first configured surface, not the raw roster field.
func effectiveSurface(s string) string {
if s == "" {
return "claude-code"
Expand Down Expand Up @@ -208,7 +233,7 @@ func writeStatusWithDispositions(out io.Writer, cfg *roster.Config, xo, snapshot
fmt.Fprintf(out, "flotilla status — no readable detector snapshot at %s\n", snapshotPath)
fmt.Fprintln(out, " (run `flotilla watch` with change_detector: true to populate it; desks shown as unknown)")
}
utilSummary := buildStatusJSON(cfg, xo, "", snap, loopByAgent, dispositions).Utilization
utilSummary := buildStatusJSON(cfg, xo, "", snap, loopByAgent, dispositions, nil).Utilization
fmt.Fprintf(out, "Fleet — %s\n", utilization.Line(utilSummary))
if read := utilization.WallRead(utilSummary); read != "" {
fmt.Fprintf(out, "Next — %s\n", read)
Expand Down
37 changes: 29 additions & 8 deletions cmd/flotilla/status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ func TestBuildStatusJSON(t *testing.T) {
"data": surface.StateWorking,
}}

doc := buildStatusJSON(cfg, "xo", "2026-06-17T17:00:00Z", snap, nil, statusSeatDispositions(t.TempDir(), cfg))
doc := buildStatusJSON(cfg, "xo", "2026-06-17T17:00:00Z", snap, nil, statusSeatDispositions(t.TempDir(), cfg), nil)

if doc.GeneratedAt != "2026-06-17T17:00:00Z" {
t.Errorf("generated_at = %q", doc.GeneratedAt)
Expand All @@ -190,7 +190,7 @@ func TestBuildStatusJSON(t *testing.T) {
if xo.Name != "xo" || xo.Role != "hub" || xo.Surface != "claude-code" || xo.State != "idle" {
t.Errorf("xo item = %+v, want {xo hub claude-code idle}", xo)
}
// Non-XO desks carry no role; surface comes from the roster.
// Non-XO desks carry no role; surface comes from overlay-first configured surface (roster when no overlay).
if doc.Agents[1].Role != "" {
t.Errorf("non-XO agent should have no role, got %q", doc.Agents[1].Role)
}
Expand All @@ -214,9 +214,30 @@ func TestBuildStatusJSON(t *testing.T) {
}
}

func TestBuildStatusJSONOverlayBeatsRoster(t *testing.T) {
cfg := &roster.Config{Agents: []roster.Agent{
{Name: "xo", Surface: "grok"},
{Name: "backend", Surface: "grok"},
{Name: "frontend", Surface: "grok"},
}}
snap := watch.Snapshot{DeskStates: map[string]surface.State{
"xo": surface.StateIdle,
"backend": surface.StateIdle,
"frontend": surface.StateIdle,
}}
surfaces := map[string]string{"backend": "codex"}
doc := buildStatusJSON(cfg, "xo", "2026-09-05T23:17:33Z", snap, nil, statusSeatDispositions(t.TempDir(), cfg), surfaces)
if doc.Agents[1].Name != "backend" || doc.Agents[1].Surface != "codex" {
t.Fatalf("backend status surface = %+v, want overlay codex not roster grok", doc.Agents[1])
}
if doc.Agents[2].Surface != "grok" {
t.Fatalf("frontend with no overlay = %q, want roster grok", doc.Agents[2].Surface)
}
}

func TestBuildStatusJSONOmitsGeneratedAtScopeWithoutSnapshot(t *testing.T) {
cfg := &roster.Config{Agents: []roster.Agent{{Name: "xo"}, {Name: "backend"}, {Name: "frontend"}}}
doc := buildStatusJSON(cfg, "xo", "", watch.Snapshot{}, nil, statusSeatDispositions(t.TempDir(), cfg))
doc := buildStatusJSON(cfg, "xo", "", watch.Snapshot{}, nil, statusSeatDispositions(t.TempDir(), cfg), nil)
raw, err := json.Marshal(doc)
if err != nil {
t.Fatal(err)
Expand Down Expand Up @@ -258,7 +279,7 @@ func TestBuildStatusJSON_LoopPostureV10(t *testing.T) {
Settled: false, BacklogKnown: true, AwaitingAuthN: 1, Park: loopposture.ParkStrict,
},
}
doc := buildStatusJSON(cfg, "xo", "2026-07-09T00:00:00Z", snap, loop, statusSeatDispositions(t.TempDir(), cfg))
doc := buildStatusJSON(cfg, "xo", "2026-07-09T00:00:00Z", snap, loop, statusSeatDispositions(t.TempDir(), cfg), nil)
if doc.Utilization.Idle != 4 || doc.Utilization.IdleEmptyQueue != 2 || doc.Utilization.IdleHasQueue != 2 || doc.Utilization.AcceptsDispatch != 2 || doc.Utilization.AwaitingAuthority != 1 {
t.Fatalf("utilization queue split = %+v", doc.Utilization)
}
Expand Down Expand Up @@ -322,7 +343,7 @@ func TestStatusCloseOutDispositionTriState(t *testing.T) {
loop[agent.Name] = loopposture.Evidence{Pane: surface.StateIdle, InSnapshot: true, SnapshotFresh: true, BacklogKnown: true, UnblockedN: 1}
}
dispositions := statusSeatDispositions(rosterDir, cfg)
doc := buildStatusJSON(cfg, "xo", "", snap, loop, dispositions)
doc := buildStatusJSON(cfg, "xo", "", snap, loop, dispositions, nil)
for _, index := range []int{0, 1} {
if got := doc.Agents[index]; got.State != "closed-out" || got.LoopPosture != "unavailable" || got.QueueState != utilization.QueueUnknown {
t.Errorf("proven closed seat %q = %+v, want closed-out/unavailable/unknown", got.Name, got)
Expand Down Expand Up @@ -357,7 +378,7 @@ func TestStatusCloseOutDispositionTriState(t *testing.T) {
unreadableCfg := &roster.Config{Agents: []roster.Agent{{Name: "backend"}}}
unreadableSnap := watch.Snapshot{DeskStates: map[string]surface.State{"backend": surface.StateIdle}}
unreadableLoop := map[string]loopposture.Evidence{"backend": {Pane: surface.StateIdle, InSnapshot: true, SnapshotFresh: true, BacklogKnown: true, UnblockedN: 1}}
unreadableDoc := buildStatusJSON(unreadableCfg, "backend", "", unreadableSnap, unreadableLoop, statusSeatDispositions(unreadableDir, unreadableCfg))
unreadableDoc := buildStatusJSON(unreadableCfg, "backend", "", unreadableSnap, unreadableLoop, statusSeatDispositions(unreadableDir, unreadableCfg), nil)
if got := unreadableDoc.Agents[0]; got.State != "unknown" || got.LoopPosture != "unavailable" || got.QueueState != utilization.QueueUnknown {
t.Fatalf("unreadable disposition = %+v, want unknown/unavailable/unknown", got)
}
Expand All @@ -377,7 +398,7 @@ func TestStatusUnavailableLiveEvidenceOverridesIdleAvailabilityWithoutCloseOut(t
"frontend": {Pane: surface.StateErrored, InSnapshot: true, SnapshotFresh: true, BacklogKnown: true},
"xo": {Pane: surface.StateIdle, InSnapshot: true, SnapshotFresh: true, BacklogKnown: true, UnblockedN: 1},
}
doc := buildStatusJSON(cfg, "xo", "", snap, loop, statusSeatDispositions(t.TempDir(), cfg))
doc := buildStatusJSON(cfg, "xo", "", snap, loop, statusSeatDispositions(t.TempDir(), cfg), nil)
if got := doc.Agents[0]; got.State != "crashed" || got.LoopPosture == "available" {
t.Fatalf("no-session backend = %+v, want crashed and not available", got)
}
Expand Down Expand Up @@ -426,7 +447,7 @@ func TestStatusUsageVisibilityAndHonestAbsence(t *testing.T) {
"alpha": {RemainingPercent: 8, Window: "weekly", ObservedAt: now.Add(-time.Hour), StaleAfter: now.Add(-time.Minute)},
},
}
doc := buildStatusJSON(cfg, "alpha", now.Format(time.RFC3339), snap, nil, statusSeatDispositions(t.TempDir(), cfg))
doc := buildStatusJSON(cfg, "alpha", now.Format(time.RFC3339), snap, nil, statusSeatDispositions(t.TempDir(), cfg), nil)
if doc.Agents[0].Usage == nil || doc.Agents[0].Usage.RemainingPercent != 8 {
t.Fatalf("alpha JSON usage = %+v", doc.Agents[0].Usage)
}
Expand Down
17 changes: 8 additions & 9 deletions cmd/flotilla/watch.go
Original file line number Diff line number Diff line change
Expand Up @@ -2633,18 +2633,17 @@ func mirrorRelayToLedger(cfg *roster.Config, j watch.Job) {
// to the portable roster — the overlay is the runtime source of truth for which harness
// a desk runs.
//
// Reading the overlay is fail-SAFE: a missing overlay (the common, un-switched case) and
// a torn/unreadable overlay BOTH fall through to the roster surface — a bad overlay must
// never make a live desk unroutable. An unknown name falls back to "" so surface.Get
// resolves the default rather than erroring on a non-roster name.
// Reading the overlay is fail-SAFE: a missing overlay (the common, un-switched case),
// a torn/unreadable overlay, and an overlay Surface that is not a registered driver
// ALL fall through to the roster surface — a bad overlay must never make a live desk
// unroutable. An unknown name falls back to "" so surface.Get resolves the default
// rather than erroring on a non-roster name.
func agentSurface(cfg *roster.Config, name string) string {
if ov, ok, err := workspace.ReadActiveOverlay(name); err == nil && ok && ov.Surface != "" {
return ov.Surface
}
rosterSurface := ""
if a, err := cfg.Agent(name); err == nil {
return a.Surface
rosterSurface = a.Surface
}
return ""
return workspace.EffectiveSurface(name, rosterSurface)
}

// resolveWatchLiveDriver is the single pane-first driver resolver for watch
Expand Down
19 changes: 19 additions & 0 deletions cmd/flotilla/watch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,25 @@ func TestAgentSurfaceTornOverlayFallsBackToRoster(t *testing.T) {
}
}

func TestGenericNodeAssessUsesOverlayNotRoster(t *testing.T) {
root := t.TempDir()
writeAgentOverlay(t, root, "backend", `{"slot":"fallback-0","surface":"codex"}`)
cfg := &roster.Config{Agents: []roster.Agent{{Name: "backend", Surface: "grok"}}}
if got := agentSurface(cfg, "backend"); got != "codex" {
t.Fatalf("agentSurface = %q, want overlay codex", got)
}
var assessed string
got := assessWatchResolvedPane(cfg, "backend", "%42", func(string) (string, error) {
return "node", nil
}, func(drv surface.Driver, pane string) surface.State {
assessed = drv.Name()
return surface.StateIdle
})
if got != surface.StateIdle || assessed != "codex" {
t.Fatalf("assess generic node: state=%v driver=%q, want idle/codex (not roster grok)", got, assessed)
}
}

func TestResolveWatchLiveDriverAllConfiguredToLiveDirections(t *testing.T) {
t.Setenv("FLOTILLA_WORKSPACE_ROOT", t.TempDir())
surfaces := []struct{ surface, command string }{
Expand Down
2 changes: 1 addition & 1 deletion docs/harness-subscription-switching.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ This design adds those four pieces as **extensions** to `launch.Recipe`, the wor
| `flotilla resume` runs arbitrary shell + cwd | `cmd/flotilla/resume.go:36-43`, `internal/launch/launch.go:20-25` | `switch` calls the same `runResume` primitive after updating the active slot |
| Surface driver SPI (`Driver`, optional `RecycleBridge`, `ComposerStateProbe`, …) | `internal/surface/surface.go:61-85`, `internal/surface/recycle.go:17-37` | Add optional `RateLimitProbe`; extend `switch` to use FROM-driver handoff + TO-driver takeover |
| Registered drivers | `claude-code`, `codex`, `grok`, `aider`, `opencode`, and `pi` are registered (`internal/surface/surface_test.go`) | Same registry; a **cursor** driver joins when it ships (no cursor driver file exists in `internal/surface/` today). *Note: `pi` ships with live-captured Working... markers (pi 0.73.1); OpenCode Go provider id is `opencode-go`.* |
| Roster `Agent.surface` + `approval_sensitive` | `internal/roster/roster.go:25-44` | Runtime overlay `~/.flotilla/<agent>/active-harness.json` consulted before roster; `approval_sensitive` gates auto-switch |
| Roster `Agent.surface` + `approval_sensitive` | `internal/roster/roster.go:25-44`; overlay-first `workspace.EffectiveSurface` used by status, send, watch assess, and dash before roster | Runtime overlay `~/.flotilla/<agent>/active-harness.json` consulted before roster; `approval_sensitive` gates auto-switch |
| Context-preserving recycle (same harness) | `cmd/flotilla/recycle.go`, `RecycleBridge` on claude, grok, codex, **and pi** (#728) | Cross-harness switch reuses handoff/takeover turns; opencode/aider still lack `RecycleBridge` (capability-refuse). Pi retains `ErrNoGracefulClose` + handoff-gated kill; all-turn upstream 400 still needs #729 recovery. |
| Cross-harness migration pattern (manual orchestration) | `openspec/changes/archive/2026-06-23-recycle-cross-harness-grok/design.md` §3 option (C) | Formalize as `flotilla switch` with idempotency + status record |
| Anthropic "temporarily limiting requests" detection | **Not present** — claude `Assess` is Working/Idle/Shell only (`internal/surface/claude.go:88-106`) | New rate-limit classifier + watch-side storm detector |
Expand Down
7 changes: 5 additions & 2 deletions docs/usage-limit-resilience.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,11 @@ provider is not under active poison cooldown, watch dispatches
## Ledger / turn-final provenance

After a switch, `~/.flotilla/<agent>/active-harness.json` names the live slot and
`last-switch.json` records the transition. Turn-finals authored during a downgrade
window should note the active tier so reviewers know which model produced the work.
`last-switch.json` records the transition. Status, send, watch assessment, and dash
routing read that overlay surface before the roster primary, so a generic `node`
pane is classified and delivered through the overlay harness. Turn-finals authored
during a downgrade window should note the active tier so reviewers know which model
produced the work.

## Related

Expand Down
6 changes: 4 additions & 2 deletions internal/dash/control/library.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/jim80net/flotilla/internal/surface"
"github.com/jim80net/flotilla/internal/transport"
"github.com/jim80net/flotilla/internal/watch"
"github.com/jim80net/flotilla/internal/workspace"
)

// dashProvenance is the CoS ledger "from" marker for a dash-issued action, so a
Expand Down Expand Up @@ -260,9 +261,10 @@ func (c *LibraryController) Route(_ context.Context, target, message string) (Ro
if err != nil {
return RouteResult{}, ErrUnknownTarget
}
drv, ok := c.getDriver(agent.Surface)
surf := workspace.EffectiveSurface(agentName, agent.Surface)
drv, ok := c.getDriver(surf)
if !ok {
return RouteResult{}, fmt.Errorf("agent %q: unknown surface %q", agentName, agent.Surface)
return RouteResult{}, fmt.Errorf("agent %q: unknown surface %q", agentName, surf)
}
release, err := c.acquireTxn(pane)
if err != nil {
Expand Down
Loading
Loading