carlos / platform Public

Sidecar isolation Task 8 (3/4): status reports worker rows; the agent wires readiness and the replicator (D8, D9, D11)

layoutStatusReport widens from instance-kind rows to ActivatorManaged ||
WorkerManaged, so carlos status --layout, the console's downgrade guard
and the migrate guard see a nested worker row with the existing per-row
words (plan 4's R15 and S6 reopened, spec §2). The unmanaged report judges
worker rows by path shape the same way. newLayoutPass hands the pass the
cached agent-start workers readiness and layout.BoxReplicatorHolds.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 5954fb493e45962b852dc1551133f949bf7073c8 parent f327c2f
2 files changed, +148 −39
  • internal/host/agent_layout_test.go +105 −11
  • internal/host/layoutleg.go +43 −28
diff --git a/internal/host/agent_layout_test.go b/internal/host/agent_layout_test.go
index 849a38bc0..27908173f 100644
--- a/internal/host/agent_layout_test.go
+++ b/internal/host/agent_layout_test.go
@@ -521,13 +521,19 @@ func TestLayoutStatusReportWithNoPassArmedIsUnmanagedByPathShape(t *testing.T) {
{Host: "unflagged.example.com", Kind: "instance", DBPath: provision.NestedDBPath("/data", "unflagged.example.com"), Socket: provision.NestedSocket("unflagged.example.com")},
{Host: "flat.example.com", Kind: "instance", Hibernate: true, DBPath: provision.DefaultDBPath("/data", "flat.example.com"), Socket: provision.DefaultSocket("flat.example.com")},
{Host: "external.example.com", Kind: "instance", Socket: provision.DefaultSocket("external.example.com")},
+ // Worker rows are judged by path shape like instance rows (sidecar
+ // isolation D8): a nested worker would be stranded by a downgrade
+ // exactly as a nested instance would.
{Host: "side.example.com", Kind: "sidecar"},
+ {Host: "bot.shop.abc123" + provision.SidecarSuffix, Kind: "sidecar", DBPath: provision.NestedDBPath("/data", "bot.shop.abc123"+provision.SidecarSuffix)},
+ {Host: "unit.shop.abc123" + provision.SidecarSuffix, Kind: "sidecar", Backing: registry.BackingUnit, DBPath: provision.NestedDBPath("/data", "unit.shop.abc123"+provision.SidecarSuffix)},
{Host: "static.example.com", Kind: "static"},
})
if got == nil || got.Mode != LayoutModeUnmanaged {
t.Fatalf("report = %+v, want Mode unmanaged with no pass armed", got)
}
- want := map[string]string{"nested.example.com": "nested", "unflagged.example.com": "nested", "flat.example.com": "flat", "external.example.com": "external"}
+ want := map[string]string{"nested.example.com": "nested", "unflagged.example.com": "nested", "flat.example.com": "flat", "external.example.com": "external",
+ "side.example.com": "flat", "bot.shop.abc123" + provision.SidecarSuffix: "nested"}
if len(got.Rows) != len(want) {
t.Fatalf("rows = %+v, want %v", got.Rows, want)
}
@@ -617,12 +623,13 @@ func TestLayoutStatusReportModeFollowsTheLastIntentSeen(t *testing.T) {
}
// TestLayoutStatusReportRowsCoverOnlyInstanceKindRoutes pins R15's row
-// selection: an ActivatorManaged row goes through the pass's own Classify
-// (proven here by a row shaped to classify as foreign, so the reason comes
-// through too), an unflagged socket instance route reports "external", and
-// everything else — a sidecar, a static route, a service route, and an
-// instance route with no socket at all — carries no row, because the
-// report is not the place any of those are decided.
+// selection as widened by sidecar isolation D8: an ActivatorManaged row and
+// a WorkerManaged row go through the pass's own Classify (proven here by
+// rows shaped to classify as foreign, so the reason comes through too), an
+// unflagged socket instance route reports "external", and everything else
+// — a static route, a service route, and an instance route with no socket
+// at all — carries no row, because the report is not the place any of
+// those are decided.
func TestLayoutStatusReportRowsCoverOnlyInstanceKindRoutes(t *testing.T) {
resetLayoutGlobals(t)
dd, sd := t.TempDir(), t.TempDir()
@@ -636,7 +643,8 @@ func TestLayoutStatusReportRowsCoverOnlyInstanceKindRoutes(t *testing.T) {
Socket: "/elsewhere/managed.sock", DBPath: "/elsewhere/managed.db", Status: "ready"},
// Hibernate=false with a socket: externally supervised.
{Host: "external.example.com", Kind: "instance", Hibernate: false, Socket: "/elsewhere/external.sock"},
- // A sidecar, explicitly out of scope (R15, finding S6).
+ // A sidecar: in scope since D8 reopened R15 and S6; with no
+ // database path it is foreign, and the reason says so.
{Host: "side.example.com", Kind: "sidecar"},
// Not instance-kind at all.
{Host: "static.example.com", Kind: "static"},
@@ -653,8 +661,8 @@ func TestLayoutStatusReportRowsCoverOnlyInstanceKindRoutes(t *testing.T) {
if report.Mode != "nested" {
t.Errorf("mode = %q, want nested", report.Mode)
}
- if len(report.Rows) != 2 {
- t.Fatalf("rows = %+v, want exactly managed and external", report.Rows)
+ if len(report.Rows) != 3 {
+ t.Fatalf("rows = %+v, want exactly managed, external and the sidecar", report.Rows)
}
managed := report.Rows["managed.example.com"]
if managed.Layout != "foreign" || managed.Reason == "" {
@@ -664,7 +672,11 @@ func TestLayoutStatusReportRowsCoverOnlyInstanceKindRoutes(t *testing.T) {
if external.Layout != "external" || external.Reason != "" {
t.Errorf("external row = %+v, want external with no reason", external)
}
- for _, host := range []string{"side.example.com", "static.example.com", "svc.example.com", "nosock.example.com"} {
+ side := report.Rows["side.example.com"]
+ if side.Layout != "foreign" || !strings.Contains(side.Reason, "worker db") {
+ t.Errorf("sidecar row = %+v, want foreign with Classify's worker reason", side)
+ }
+ for _, host := range []string{"static.example.com", "svc.example.com", "nosock.example.com"} {
if _, ok := report.Rows[host]; ok {
t.Errorf("%s has a row, want none", host)
}
@@ -1044,3 +1056,85 @@ func TestLayoutLegRetriesWhileTheDefaultIsBlocked(t *testing.T) {
t.Fatal("a typo waits on nothing")
}
}
+
+// TestLayoutReportListsWorkerRows pins sidecar isolation D8's status rule:
+// worker rows are reported through the pass's own Classify with the
+// existing per-row words (flat, nested, fenced, foreign), whether or not
+// this agent's workers readiness lets the pass move them, so carlos status
+// --layout, the console's downgrade guard and the migrate guard all see a
+// nested worker. A unit-backed or tunnelled sidecar row is nobody's to
+// move and carries no row.
+func TestLayoutReportListsWorkerRows(t *testing.T) {
+ resetLayoutGlobals(t)
+ dd, sd := t.TempDir(), t.TempDir()
+ reg := restartTestRegistry(t)
+ armedLayoutPass.Store(&layout.Pass{Registry: reg, DataDir: dd, SocketDir: sd})
+ noteLayoutIntent(release.LayoutModeNested)
+ flat := "flat.shop.abc123" + provision.SidecarSuffix
+ nested := "nested.shop.abc123" + provision.SidecarSuffix
+ fenced := "moving.shop.abc123" + provision.SidecarSuffix
+ socketed := "odd.shop.abc123" + provision.SidecarSuffix
+ unit := "unit.shop.abc123" + provision.SidecarSuffix
+ if err := os.MkdirAll(filepath.Join(dd, "layout"), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(provision.LayoutJournalPath(dd, fenced), []byte("{}"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ routes := []registry.Route{
+ {Host: flat, Kind: "sidecar", Hibernate: true, Status: "ready", DBPath: provision.DefaultDBPath(dd, flat)},
+ {Host: nested, Kind: "sidecar", Status: "ready", DBPath: provision.NestedDBPath(dd, nested)},
+ {Host: fenced, Kind: "sidecar", Status: "ready", DBPath: provision.DefaultDBPath(dd, fenced)},
+ {Host: socketed, Kind: "sidecar", Status: "ready", DBPath: provision.NestedDBPath(dd, socketed), Socket: filepath.Join(sd, "x.sock")},
+ {Host: unit, Kind: "sidecar", Status: "ready", Backing: registry.BackingUnit, DBPath: provision.NestedDBPath(dd, unit)},
+ {Host: "inst.example.com", Kind: "instance", Hibernate: true, Socket: filepath.Join(sd, "inst.example.com.sock"), DBPath: provision.DefaultDBPath(dd, "inst.example.com")},
+ }
+ for _, rt := range routes {
+ if err := reg.Upsert(rt); err != nil {
+ t.Fatal(err)
+ }
+ }
+ report := layoutStatusReport(routes)
+ if report == nil || report.Mode != "nested" {
+ t.Fatalf("report = %+v", report)
+ }
+ want := map[string]string{flat: "flat", nested: "nested", fenced: "fenced", socketed: "foreign", "inst.example.com": "flat"}
+ if len(report.Rows) != len(want) {
+ t.Fatalf("rows = %+v, want %v", report.Rows, want)
+ }
+ for host, word := range want {
+ if got := report.Rows[host]; got.Layout != word {
+ t.Errorf("%s = %+v, want %s", host, got, word)
+ }
+ }
+ if r := report.Rows[fenced]; !strings.Contains(r.Reason, provision.LayoutJournalPath(dd, fenced)) {
+ t.Errorf("fenced worker reason = %q, want the journal path", r.Reason)
+ }
+ if r := report.Rows[socketed]; !strings.Contains(r.Reason, "worker db") {
+ t.Errorf("socketed sidecar reason = %q, want Classify's worker reason", r.Reason)
+ }
+ if _, listed := report.Rows[unit]; listed {
+ t.Errorf("a unit-backed sidecar row was reported: %+v", report.Rows[unit])
+ }
+}
+
+// The pass is wired to the agent-start workers readiness (D11) through the
+// same cached accessor the reconciler's mint reads, and to the production
+// replicator observation (D9).
+func TestNewLayoutPassWiresWorkersReadinessAndReplicator(t *testing.T) {
+ resetLayoutGlobals(t)
+ prev := startWorkers.Load()
+ t.Cleanup(func() { startWorkers.Store(prev) })
+ startWorkers.Store(nil)
+ p := newLayoutPass(restartTestRegistry(t), nil, t.TempDir(), func() error { return nil }, true)
+ if p.WorkersReady == nil || p.WorkersReady() {
+ t.Fatalf("WorkersReady with nothing armed = %v, want wired and false", p.WorkersReady != nil && p.WorkersReady())
+ }
+ startWorkers.Store(&WorkersReadiness{ready: true, word: workersNested})
+ if !p.WorkersReady() {
+ t.Fatal("WorkersReady does not read the armed readiness")
+ }
+ if p.Replicator == nil {
+ t.Fatal("Replicator is not wired")
+ }
+}
diff --git a/internal/host/layoutleg.go b/internal/host/layoutleg.go
index 37d3b88b5..8ccc4606c 100644
--- a/internal/host/layoutleg.go
+++ b/internal/host/layoutleg.go
@@ -161,20 +161,30 @@ func noteLayoutDefault(mode, reason string) {
// layoutStatusReport builds the box's layout report for the status
// snapshot (design §8.4, amendment R15): the mode this box last converged
-// to, and every instance-kind route's word from the armed pass's own
-// Classify — the same classification a wake would see, so status never
-// says something different from what the activator actually does.
+// to, and every managed route's word from the armed pass's own Classify —
+// the same classification a wake would see, so status never says
+// something different from what the activator actually does.
+//
+// Managed means an activator-managed instance row or a native worker row
+// (activator.WorkerManaged). Plan 4's R15 and S6 left sidecars out while
+// they could not be nested; once they can, a nested worker row status
+// omits is one the console's downgrade guard cannot see, and a channel
+// rollback would strand it (sidecar isolation spec §2, D8). A worker row
+// gets no new word: flat, nested, fenced, conflict or foreign like any
+// other row, whether or not this agent's workers readiness lets the pass
+// move it. Everything that is neither (static, service, a unit-backed or
+// tunnelled row) carries no row.
//
// With no activator armed there is no pass and no Classify, but the box
-// is not silent either: it reports Mode "unmanaged" with every
-// instance-kind row judged by path shape alone (provision.NestedPaths),
-// because the one thing a reader needs to know about such a box is
-// whether a downgrade would strand a nested database, and that is a fact
-// about paths, not about wakes. Nothing wakes a hibernate-flagged row on
-// such a box, so no row there is managed, and the console's downgrade
-// guard (layoutguard.go) treats the report like any other: a row named
-// nested blocks, flat rows do not. Silence from a box at or above the
-// floor would otherwise block its fleet's rollback for good.
+// is not silent either: it reports Mode "unmanaged" with every managed-
+// shaped row judged by path shape alone (provision.NestedPaths), because
+// the one thing a reader needs to know about such a box is whether a
+// downgrade would strand a nested database, and that is a fact about
+// paths, not about wakes. Nothing wakes a hibernate-flagged row on such a
+// box, so no row there is managed, and the console's downgrade guard
+// (layoutguard.go) treats the report like any other: a row named nested
+// blocks, flat rows do not. Silence from a box at or above the floor
+// would otherwise block its fleet's rollback for good.
//
// routes is the same list the caller already read for its other probes;
// this never lists the registry itself, mirroring every other probe in
@@ -191,14 +201,8 @@ func layoutStatusReport(routes []registry.Route) *status.LayoutReport {
def := lastLayoutDefault()
rows := map[string]status.RowLayout{}
for _, rt := range routes {
- // Only instance-kind rows: a sidecar (Kind "sidecar") is outside
- // the pass entirely (finding S6), and so is everything that is
- // neither an instance nor a sidecar (static, service).
- if rt.Kind != "instance" {
- continue
- }
switch {
- case activator.ActivatorManaged(rt):
+ case activator.ActivatorManaged(rt), activator.WorkerManaged(rt):
word, reason := pass.Classify(rt, routes)
// A flat host the pass keeps fenced and down after a refused
// nested move classifies flat (no journal), but it does not
@@ -207,7 +211,7 @@ func layoutStatusReport(routes []registry.Route) *status.LayoutReport {
word, reason = activator.LayoutFenced, "held down after a refused move to nested: "+why
}
rows[rt.Host] = status.RowLayout{Layout: string(word), Reason: reason, Note: pass.Note(rt.Host)}
- case rt.Socket != "" && !rt.Hibernate:
+ case rt.Kind == "instance" && rt.Socket != "" && !rt.Hibernate:
// ActivatorManaged's own doc: an unflagged socket route is run
// by another supervisor, nobody's to migrate or isolate.
rows[rt.Host] = status.RowLayout{Layout: "external"}
@@ -222,18 +226,20 @@ func layoutStatusReport(routes []registry.Route) *status.LayoutReport {
const LayoutModeUnmanaged = "unmanaged"
// unmanagedLayoutReport is layoutStatusReport's answer with no pass armed.
-// Instance-kind rows only, as the managed report: fenced when a journal or
-// hold marker names the host (a move was in flight, or a refused move kept
-// the host down, when the activator went away, and the files are still
-// where recovery expects them), nested by path shape, external for an
-// unflagged socket route, flat otherwise. The fenced check comes first,
-// as in activator.Classify: a mid-move host's row can still carry flat
-// paths while its files are already between two directories.
+// The same rows as the managed report (instance-kind and worker rows):
+// fenced when a journal or hold marker names the host (a move was in
+// flight, or a refused move kept the host down, when the activator went
+// away, and the files are still where recovery expects them), nested by
+// path shape, external for an unflagged socket route, flat otherwise. The
+// fenced check comes first, as in activator.Classify: a mid-move host's
+// row can still carry flat paths while its files are already between two
+// directories.
func unmanagedLayoutReport(routes []registry.Route) *status.LayoutReport {
rows := map[string]status.RowLayout{}
dd := provisionTickConfig(os.Getenv).DataDir
for _, rt := range routes {
- if rt.Kind != "instance" {
+ worker := activator.WorkerManaged(rt)
+ if rt.Kind != "instance" && !worker {
continue
}
switch {
@@ -243,6 +249,8 @@ func unmanagedLayoutReport(routes []registry.Route) *status.LayoutReport {
rows[rt.Host] = status.RowLayout{Layout: string(activator.LayoutFenced), Reason: "held down after a refused move to nested (no activator on this box to retry it)"}
case provision.NestedPaths(dd, rt):
rows[rt.Host] = status.RowLayout{Layout: string(activator.LayoutNested), Reason: "no activator on this box; judged by path shape"}
+ case worker:
+ rows[rt.Host] = status.RowLayout{Layout: string(activator.LayoutFlat)}
case rt.Socket != "" && !rt.Hibernate:
rows[rt.Host] = status.RowLayout{Layout: "external"}
case rt.Hibernate && rt.Socket != "":
@@ -319,6 +327,13 @@ func newLayoutPass(reg *registry.Registry, svc *activator.Service, dd string, ne
NestedOK: nestedOK,
Serialize: serializeWithConfigSync,
DeferAwake: true,
+ // Worker rows (sidecar isolation D8) are the pass's only while the
+ // agent-start readiness holds (D11): the cached answer, read
+ // through the same accessor the reconciler's mint uses, never
+ // re-evaluated. Their move also asks the box-wide replicator's
+ // state (D9), observed through systemctl as the agent user.
+ WorkersReady: func() bool { return agentWorkers().Ready() },
+ Replicator: layout.BoxReplicatorHolds,
}
// Assigned only when non-nil: a nil *Service inside the interface would
// not be a nil Fence, and the pass would call through it.