carlos / platform Public

isolated exec: plan 3, the activator's unit path

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ1ZvWj7naf73GjTX8MBv5
Paul Campbell pushed by paul@keymail.dev ac978215fc3033ee7bcbf1d837677f782e7f508f parent 08a0ed8
1 file changed, +2203 −0
  • docs/superpowers/plans/2026-09-11-isolated-exec-3-activator.md +2203 −0
diff --git a/docs/superpowers/plans/2026-09-11-isolated-exec-3-activator.md b/docs/superpowers/plans/2026-09-11-isolated-exec-3-activator.md
new file mode 100644
index 000000000..30fd9ca92
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-11-isolated-exec-3-activator.md
@@ -0,0 +1,2203 @@
+# Isolated Exec, Plan 3 of 4: The Activator's Unit Path
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Teach the exec backend a second spawn path: a `nested` host is launched as `carlos-instance@<iid>` through the control service, with a launch file, per-role log sockets, termination proven by unit state and cgroup emptiness, and the activator's lease, restore, seed, replication, hibernation, control socket and sidecar machinery unchanged. A `flat` host keeps today's `childProc` path byte for byte. Every consumer of the flat shape learns the nested one.
+
+**Architecture:** `ExecBackend` classifies each route (`flat`, `nested`, `fenced`, `conflict`, `foreign`; spec §8.1) and dispatches `Start`, `Seed`, `StartSidecar`, `HasSidecar`, `NextDue` and `instanceEnv` on it. The nested path is new code in `internal/activator` (`layout.go`, `paths.go`, `unitproc.go`, `loglistener.go`, `prepare.go`) plus a `Control` seam whose production implementation is `instancectl.Client`. `activator.go` changes in exactly three places (spec §6): the lease release on an unproven stop, the sidecar restart under the lock, and the route the wake runs on. Consumers of the shape (`syncexec`, the schedule token, `movespass`, the litestream glob) follow the classification. The agent sweeps orphan units and checks the mounts and `/ready` before any nested wake.
+
+**Tech Stack:** Go 1.26, `internal/instancectl` (plan 2: `Escape`, `UnitName`, `SliceName`, `Launch`, `LaunchFileName`, `ExitRecord`, `ExitRecordName`, `StartResponse`, `SeedResponse`, `SweepResponse`, the HTTP protocol of §5.3), `internal/instancefs`, `internal/provision` (plan 1 helpers), `internal/controlsock`, `internal/applog`, `internal/cgroup` tiers.
+
+**Spec:** `docs/superpowers/specs/2026-09-11-isolated-exec-design.md` (revision 12): §6 (all of it), §7, §8.1 (the classification table), §8.5 (the gate the agent honours), §9, §13.
+
+## Global Constraints
+
+- Gate before every commit: `go build ./...`, `go vet ./...`, `gofmt -l .` empty, `go test ./... -count=1`; env `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`; socket-binding packages (`internal/activator`, `internal/edge`, `internal/host`, root) need `TMPDIR=/tmp/g` outside the sandbox. Cross-compile gate: darwin, windows, linux-arm64 build; Linux-only syscalls live in `_linux.go` with `!linux` stubs.
+- **A flat host's behaviour does not change.** The existing `Start`/`Seed`/`StartSidecar` bodies become `startChild`/`seedChild`/`startSidecarChild` and are called for `flat` and `foreign` rows unchanged; every existing activator test keeps passing without edits to its assertions.
+- Classification (spec §8.1): `flat` = `DBPath == provision.DefaultDBPath(dataDir, host) && Socket == provision.DefaultSocket(host)`; `nested` = `DBPath == provision.NestedDBPath(dataDir, host) && Socket == provision.NestedSocket(host)`; `fenced` = a journal exists at `provision.LayoutJournalPath(dataDir, host)`; `conflict` = another activator-managed row on the box has the same `provision.InstanceID`; anything else `foreign`. `fenced` and `conflict` refuse to wake with a reason; `foreign` spawns flat.
+- Nested `Start` order (spec §6.1): prepare (dirs, admin token, env, control listener, log listener, launch file) → `POST /start` with the tier → `waitHealthyCtx` on `rt.Socket` → `0660` normalisation → return a `unitProc` with the invocation id. On health failure: `POST /stop`, wait for `done`, clean up.
+- `childEnv` produces one entry per key, last value wins (spec §6.2, codex R1-16); the launch file's `Env` is that list, `Argv` is `[/opt/carlos/live/<host>, --socket, rt.Socket, --db, dbPath]` for run; for seed the parsed `rt.Seed` argv plus `--db dbPath` exactly as `seedChild` builds it; for sidecar `[/opt/carlos/live/<host>, sidecar, run]`.
+- Termination (spec §6.5, codex R2-5): `done` closes only when the unit is terminal (`ActiveState` `inactive` or `failed` with the proc's own invocation, or `inactive` with an empty invocation) AND the unit's cgroup is absent or `populated 0`. Log EOF never means exit. The exit record is wording only. A foreign invocation is stopped and waited for.
+- `unitProc.Stop` returns `ErrStillRunning` when `ctx` expires before `done`; `stop()` then does not call `rep.Close` (spec §6.3).
+- Every control call carries a deadline: 30s for start/stop/reset, 150s for seed, 60s for sweep, 5s for ready (spec §5.3).
+- Before any nested wake the agent requires: all three mounts carry `nosymfollow` (`instancefs.MountNoSymfollow`), the control socket answers `GET /ready` with 204; otherwise the wake refuses with the reason (spec §8.5, §8.6).
+- Copy rules: event lines keep today's wording (`instance exited: ...`, `instance exited (exit 0)`, plus `instance exited (status unknown: no exit record)`); no em dashes; comments are full sentences explaining why.
+- Commit after every task with the session's trailer. Branch `worktree-isolated-exec-spec`.
+
+---
+
+## File Structure
+
+| File | Responsibility |
+|---|---|
+| `internal/provision/provision.go` (modify) | `LayoutJournalPath(dataDir, host)`; `InstanceRunDirOf`, `LogSocket` already exist |
+| `internal/instancefs/instancefs.go` + `_linux.go` + `_other.go` (modify) | `ReadBeneathLimit(dir, name, max)` |
+| `internal/instancectl/client.go` (create) | `Client`: the agent-side HTTP client over the control socket, one method per verb, with the deadlines |
+| `internal/instancectl/client_test.go` (create) | client against a `Server` with fakes |
+| `internal/activator/layout.go` (create) | `Layout`, `LayoutInputs`, `Classify` |
+| `internal/activator/paths.go` (create) | `instancePaths` and `pathsFor(rt)`: every per-host path by layout |
+| `internal/activator/control.go` (create) | the `Control` interface the backend calls; `UnitState` and `CgroupEvents` seams |
+| `internal/activator/loglistener.go` (create) | `logListener` |
+| `internal/activator/unitproc.go` (create) | `unitProc`, the exit observer, `ErrStillRunning` |
+| `internal/activator/prepare.go` (create) | `prepare(rt, role)` and the launch-file writer |
+| `internal/activator/backend_exec.go` (modify) | dispatch by layout; `startUnit`, `seedUnit`, `startSidecarUnit`; `childEnv` dedupe; path helpers replaced by `pathsFor` |
+| `internal/activator/activator.go` (modify) | the three changes of §6.3, §6.7, §6.8; `Routes` seam; `SweepOrphans` hook |
+| `internal/activator/*_test.go` (create/modify) | tests per task |
+| `internal/appconfig/syncexec.go` (modify) | nested write path; reap walks `instances/*/` |
+| `internal/schedule/runner.go`, `internal/host/schedule_wiring.go` (modify) | token path by layout |
+| `internal/host/movespass.go` (modify) | DB path from the row; refuse a fenced host |
+| `internal/ops/litestream.go` (modify) | glob gains `instances/*/*.db` |
+| `internal/host/agent.go` (modify) | wiring: `Control`, `LayoutInputs`, `CgroupRoot`, `Routes`, startup sweep, mount and ready gate |
+
+---
+
+### Task 1: Journal path, `ReadBeneathLimit`, and the control client
+
+**Files:**
+- Modify: `internal/provision/provision.go` (append after `LogSocket`)
+- Modify: `internal/instancefs/instancefs.go`, `instancefs_linux.go`, `instancefs_other.go`
+- Create: `internal/instancectl/client.go`, `internal/instancectl/client_test.go`
+- Test: `internal/provision/provision_test.go`, `internal/instancefs/instancefs_linux_test.go` (append)
+
+**Interfaces:**
+- Produces:
+ ```go
+ // provision
+ func LayoutJournalPath(dataDir, host string) string // <dataDir>/layout/<host>.json
+ // instancefs
+ var ErrTooLarge = errors.New("instancefs: file larger than the limit")
+ func ReadBeneathLimit(dir, name string, max int64) ([]byte, error)
+ // instancectl
+ type Client struct { Path string; Dial func(ctx context.Context, path string) (net.Conn, error) } // Dial nil = unixdial-free plain dial (the control socket is not an upstream)
+ func NewClient(path string) *Client
+ func (c *Client) Start(ctx context.Context, host string, role Role, memoryMax int64) (string, error) // invocation id
+ func (c *Client) Seed(ctx context.Context, host string, memoryMax int64) (SeedResponse, error)
+ func (c *Client) Stop(ctx context.Context, host string, role Role) error
+ func (c *Client) Reset(ctx context.Context, host string, role Role) error
+ func (c *Client) Sweep(ctx context.Context) (SweepResponse, error)
+ func (c *Client) Ready(ctx context.Context) error // nil on 204; *NotReadyError carrying the ProbeReport on 503
+ type NotReadyError struct{ Report ProbeReport }
+ const ( DeadlineControl = 30*time.Second; DeadlineSeed = 150*time.Second; DeadlineSweep = 60*time.Second; DeadlineReady = 5*time.Second )
+ ```
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `internal/provision/provision_test.go`:
+
+```go
+func TestLayoutJournalPath(t *testing.T) {
+ if got := LayoutJournalPath("/data/", "h.example.com"); got != "/data/layout/h.example.com.json" {
+ t.Errorf("LayoutJournalPath = %q", got)
+ }
+}
+```
+
+Append to `internal/instancefs/instancefs_linux_test.go`:
+
+```go
+func TestReadBeneathLimitRefusesALargeFile(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "big"), make([]byte, 4096), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := ReadBeneathLimit(dir, "big", 1024); !errors.Is(err, ErrTooLarge) {
+ t.Fatalf("err = %v, want ErrTooLarge", err)
+ }
+ got, err := ReadBeneathLimit(dir, "big", 4096)
+ if err != nil || len(got) != 4096 {
+ t.Fatalf("exact limit: %d bytes, %v", len(got), err)
+ }
+}
+```
+
+Create `internal/instancectl/client_test.go`:
+
+```go
+package instancectl
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+// serveFake starts a Server on a temp unix socket with the given fake
+// systemctl and a probe report, and returns a Client for it.
+func serveFake(t *testing.T, ctl *fakeCtl, report *ProbeReport) *Client {
+ t.Helper()
+ root := t.TempDir()
+ s := &Server{
+ Systemctl: ctl.run, PeerOK: func(net.Conn) error { return nil },
+ CgroupRoot: filepath.Join(root, "cgroup"), ProbeOut: filepath.Join(root, "probe.out"),
+ FixtureOK: func() error { return nil }, ProbeEvery: time.Hour, Logf: t.Logf,
+ }
+ if report != nil {
+ b, _ := json.Marshal(report)
+ if err := os.WriteFile(s.ProbeOut, b, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ s.RunProbe(context.Background())
+ }
+ dir, err := os.MkdirTemp("", "icc")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { os.RemoveAll(dir) })
+ sock := filepath.Join(dir, "c.sock")
+ ln, err := net.Listen("unix", sock)
+ if err != nil {
+ t.Skipf("cannot bind a unix socket here (%v); run with TMPDIR=/tmp/g outside the sandbox", err)
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ t.Cleanup(cancel)
+ go s.Serve(ctx, ln) //nolint:errcheck
+ t.Cleanup(func() { ln.Close() })
+ return NewClient(sock)
+}
+
+func TestClientStartCarriesTierAndReturnsInvocation(t *testing.T) {
+ ctl := &fakeCtl{answers: map[string]string{"show -p InvocationID --value": "inv1\n"}}
+ c := serveFake(t, ctl, nil)
+ inv, err := c.Start(context.Background(), "h.example.com", RoleRun, 536870912)
+ if err != nil || inv != "inv1" {
+ t.Fatalf("Start = %q, %v", inv, err)
+ }
+ if !strings.Contains(ctl.joined(), "MemoryMax=536870912") {
+ t.Errorf("tier not sent: %s", ctl.joined())
+ }
+}
+
+func TestClientSeedStopSweepReady(t *testing.T) {
+ ctl := &fakeCtl{answers: map[string]string{
+ "show -p InvocationID,Result,ExecMainStatus,ExecMainCode": "InvocationID=s1\nResult=success\nExecMainStatus=0\nExecMainCode=exited\n",
+ "list-units": "", "list-jobs": "",
+ }}
+ c := serveFake(t, ctl, &ProbeReport{Ready: true})
+ res, err := c.Seed(context.Background(), "h.example.com", 536870912)
+ if err != nil || res.Result != "success" || res.InvocationID != "s1" {
+ t.Fatalf("Seed = %+v, %v", res, err)
+ }
+ if err := c.Stop(context.Background(), "h.example.com", RoleSidecar); err != nil {
+ t.Fatalf("Stop: %v", err)
+ }
+ if err := c.Reset(context.Background(), "h.example.com", RoleRun); err != nil {
+ t.Fatalf("Reset: %v", err)
+ }
+ sw, err := c.Sweep(context.Background())
+ if err != nil || len(sw.Units) != 0 {
+ t.Fatalf("Sweep = %+v, %v", sw, err)
+ }
+ if err := c.Ready(context.Background()); err != nil {
+ t.Fatalf("Ready: %v", err)
+ }
+}
+
+func TestClientReadyReturnsTheReport(t *testing.T) {
+ ctl := &fakeCtl{}
+ c := serveFake(t, ctl, &ProbeReport{Ready: false, Lines: []VerdictLine{{Check: "pid1", Verdict: "fail", Detail: "pid=7"}}})
+ err := c.Ready(context.Background())
+ var nr *NotReadyError
+ if !errors.As(err, &nr) || len(nr.Report.Lines) != 1 || nr.Report.Lines[0].Check != "pid1" {
+ t.Fatalf("Ready = %v", err)
+ }
+}
+
+func TestClientSurfacesA502AsAnError(t *testing.T) {
+ ctl := &fakeCtl{fail: map[string]error{"start": errors.New("boom")}} // keyed the way fakeCtl.run matches: by leading-argument prefix
+ c := serveFake(t, ctl, nil)
+ if _, err := c.Start(context.Background(), "h.example.com", RoleRun, 536870912); err == nil || !strings.Contains(err.Error(), "boom") {
+ t.Fatalf("Start error = %v, want the server's message", err)
+ }
+}
+
+func TestClientRefusesWhenTheSocketIsAbsent(t *testing.T) {
+ c := NewClient(filepath.Join(t.TempDir(), "absent.sock"))
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ if err := c.Ready(ctx); err == nil || !strings.Contains(err.Error(), "instance control socket") {
+ t.Fatalf("absent socket: %v", err)
+ }
+}
+```
+
+`fakeCtl` already exists in `server_test.go` (same package).
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/provision ./internal/instancefs ./internal/instancectl -run 'TestLayoutJournalPath|TestReadBeneathLimit|TestClient' -count=1`
+Expected: build failures for the three undefined symbols.
+
+- [ ] **Step 3: Write the code**
+
+`internal/provision/provision.go`, after `LogSocket`:
+
+```go
+// LayoutJournalPath is the migration journal for a host in flight
+// (isolated-exec design §8.2): under <dataDir>/layout, outside every tenant
+// mount. Its presence is what makes a host `fenced` (§8.1); plan 4 writes
+// it, and every consumer that must not act on a half-migrated host checks
+// for it here.
+func LayoutJournalPath(dataDir, host string) string {
+ return filepath.Join(dataDir, "layout", host+".json")
+}
+```
+
+`internal/instancefs/instancefs.go`:
+
+```go
+// ErrTooLarge is returned by ReadBeneathLimit when the file exceeds max.
+var ErrTooLarge = errors.New("instancefs: file larger than the limit")
+
+// ReadBeneathLimit is ReadBeneath with a size cap. Files in an instance
+// directory are tenant-writable, and some are read by the agent outside the
+// tenant's memory wall (the exit record, the launch file), so no read there
+// is unbounded.
+func ReadBeneathLimit(dir, name string, max int64) ([]byte, error) {
+ f, err := OpenBeneath(dir, name, os.O_RDONLY, 0)
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+ b, err := io.ReadAll(io.LimitReader(f, max+1))
+ if err != nil {
+ return nil, err
+ }
+ if int64(len(b)) > max {
+ return nil, fmt.Errorf("%w: %s over %d bytes", ErrTooLarge, filepath.Join(dir, name), max)
+ }
+ return b, nil
+}
+```
+
+(add `"fmt"` to that file's imports; `OpenBeneath` on non-Linux returns `ErrUnsupported`, so no stub is needed.)
+
+`internal/instancectl/client.go`:
+
+```go
+package instancectl
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// Deadlines per verb (design §5.3). A start or stop that exceeds its
+// deadline is a failed wake or an unproven stop, never a hang: §6.7 puts
+// one such call on a path that must not stall the activator. The seed's is
+// long because a seed is allowed to be slow.
+const (
+ DeadlineControl = 30 * time.Second
+ DeadlineSeed = 150 * time.Second
+ DeadlineSweep = 60 * time.Second
+ DeadlineReady = 5 * time.Second
+)
+
+// Client is the agent's side of the control channel. It is plain HTTP over
+// the unix socket at Path; unixdial's checks are for UPSTREAMS the edge
+// proxies to, and this socket is the one path they refuse, so the client
+// dials it directly.
+type Client struct {
+ Path string
+ // Dial is the dialer; nil means net.Dialer. Tests do not need to override
+ // it, but a caller composing several clients may.
+ Dial func(ctx context.Context, path string) (net.Conn, error)
+}
+
+func NewClient(path string) *Client { return &Client{Path: path} }
+
+// NotReadyError carries the probe verdict a 503 /ready returned.
+type NotReadyError struct{ Report ProbeReport }
+
+func (e *NotReadyError) Error() string {
+ var fails []string
+ for _, l := range e.Report.Lines {
+ if l.Verdict != "ok" {
+ fails = append(fails, l.Check+" "+l.Verdict+" "+l.Detail)
+ }
+ }
+ if len(fails) == 0 {
+ return "instance control: box not ready (no verdict yet)"
+ }
+ return "instance control: box not ready: " + strings.Join(fails, "; ")
+}
+
+func (c *Client) httpClient() *http.Client {
+ dial := c.Dial
+ if dial == nil {
+ dial = func(ctx context.Context, path string) (net.Conn, error) {
+ return (&net.Dialer{}).DialContext(ctx, "unix", path)
+ }
+ }
+ return &http.Client{Transport: &http.Transport{
+ DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { return dial(ctx, c.Path) },
+ DisableKeepAlives: true,
+ }}
+}
+
+// do issues one verb with the given deadline and decodes a JSON body when
+// there is one. A non-2xx answer becomes an error carrying the server's
+// message; a dial failure names the socket so a forgotten enable reads as
+// what it is from carlos logs.
+func (c *Client) do(ctx context.Context, deadline time.Duration, method, path string, q url.Values, out any) (int, error) {
+ ctx, cancel := context.WithTimeout(ctx, deadline)
+ defer cancel()
+ u := "http://control" + path
+ if len(q) > 0 {
+ u += "?" + q.Encode()
+ }
+ req, err := http.NewRequestWithContext(ctx, method, u, nil)
+ if err != nil {
+ return 0, err
+ }
+ resp, err := c.httpClient().Do(req)
+ if err != nil {
+ return 0, fmt.Errorf("instance control socket %s: %w", c.Path, err)
+ }
+ defer resp.Body.Close()
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
+ if resp.StatusCode == http.StatusServiceUnavailable && path == "/ready" {
+ var r ProbeReport
+ _ = json.Unmarshal(body, &r)
+ return resp.StatusCode, &NotReadyError{Report: r}
+ }
+ if resp.StatusCode/100 != 2 {
+ return resp.StatusCode, fmt.Errorf("instance control %s: %d: %s", path, resp.StatusCode, strings.TrimSpace(string(body)))
+ }
+ if out != nil && len(body) > 0 {
+ if err := json.Unmarshal(body, out); err != nil {
+ return resp.StatusCode, fmt.Errorf("instance control %s: %v", path, err)
+ }
+ }
+ return resp.StatusCode, nil
+}
+
+func hostRole(host string, role Role) url.Values {
+ q := url.Values{"host": {host}}
+ if role != "" {
+ q.Set("role", string(role))
+ }
+ return q
+}
+
+// Start starts the run or sidecar unit for host with the memory tier and
+// returns the invocation id the server read after the start.
+func (c *Client) Start(ctx context.Context, host string, role Role, memoryMax int64) (string, error) {
+ q := hostRole(host, role)
+ q.Set("memory_max", strconv.FormatInt(memoryMax, 10))
+ var out StartResponse
+ if _, err := c.do(ctx, DeadlineControl, http.MethodPost, "/start", q, &out); err != nil {
+ return "", err
+ }
+ return out.InvocationID, nil
+}
+
+// Seed runs the seed unit to completion and returns its retained result.
+func (c *Client) Seed(ctx context.Context, host string, memoryMax int64) (SeedResponse, error) {
+ q := hostRole(host, "")
+ q.Set("memory_max", strconv.FormatInt(memoryMax, 10))
+ var out SeedResponse
+ _, err := c.do(ctx, DeadlineSeed, http.MethodPost, "/seed", q, &out)
+ return out, err
+}
+
+func (c *Client) Stop(ctx context.Context, host string, role Role) error {
+ _, err := c.do(ctx, DeadlineControl, http.MethodPost, "/stop", hostRole(host, role), nil)
+ return err
+}
+
+func (c *Client) Reset(ctx context.Context, host string, role Role) error {
+ _, err := c.do(ctx, DeadlineControl, http.MethodPost, "/reset", hostRole(host, role), nil)
+ return err
+}
+
+// Sweep stops every instance unit on the box (design §6.7). A 502 carries
+// the units that were stopped and the ones that would not; both are
+// returned with the error.
+func (c *Client) Sweep(ctx context.Context) (SweepResponse, error) {
+ var out SweepResponse
+ status, err := c.do(ctx, DeadlineSweep, http.MethodPost, "/sweep", nil, &out)
+ if err != nil && status == http.StatusBadGateway {
+ return out, errors.Join(err, errors.New("some instance units would not stop"))
+ }
+ return out, err
+}
+
+// Ready is nil on 204 and a *NotReadyError on 503.
+func (c *Client) Ready(ctx context.Context) error {
+ _, err := c.do(ctx, DeadlineReady, http.MethodGet, "/ready", nil, nil)
+ return err
+}
+```
+
+`Sweep`'s 502 decoding: `do` returns before decoding on non-2xx; make `do` decode `out` for a 502 on `/sweep` too (add `|| (resp.StatusCode == http.StatusBadGateway && path == "/sweep")` to the decode condition, before the non-2xx return), so the caller sees the partial list.
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/provision ./internal/instancefs ./internal/instancectl -count=1` (outside the sandbox)
+Expected: PASS.
+
+- [ ] **Step 5: gofmt and commit**
+
+```bash
+gofmt -l internal/provision internal/instancefs internal/instancectl
+git add internal/provision internal/instancefs internal/instancectl
+git commit -m "instancectl client, LayoutJournalPath, ReadBeneathLimit (plan 3, task 1)"
+```
+
+---
+
+### Task 2: Layout classification and per-host paths
+
+**Files:**
+- Create: `internal/activator/layout.go`, `internal/activator/paths.go`
+- Create: `internal/activator/layout_test.go`
+
+**Interfaces:**
+- Produces:
+ ```go
+ type Layout string
+ const ( LayoutFlat Layout = "flat"; LayoutNested Layout = "nested"; LayoutFenced Layout = "fenced"; LayoutConflict Layout = "conflict"; LayoutForeign Layout = "foreign" )
+ type LayoutInputs struct {
+ DataDir string
+ Journal func(host string) bool // nil: never fenced
+ Peers func() ([]registry.Route, error) // nil: never conflict
+ }
+ func Classify(rt registry.Route, in LayoutInputs) (Layout, string) // layout and a reason for fenced/conflict/foreign
+ func ActivatorManaged(rt registry.Route) bool // Kind instance, Hibernate, Backing "", Tunnel "", Socket != ""
+ type instancePaths struct { layout Layout; dataDir, runDir, dbPath, envFile, sidecarEnv, configEnv string }
+ func (b *ExecBackend) pathsFor(rt registry.Route) instancePaths
+ ```
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `internal/activator/layout_test.go`:
+
+```go
+package activator
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+func managed(host, db, sock string) registry.Route {
+ return registry.Route{Host: host, Kind: "instance", Hibernate: true, DBPath: db, Socket: sock}
+}
+
+func TestClassifyFlatAndNested(t *testing.T) {
+ in := LayoutInputs{DataDir: "/data"}
+ if l, _ := Classify(managed("h.example.com", "/data/h.example.com.db", "/run/carlos/h.example.com.sock"), in); l != LayoutFlat {
+ t.Errorf("flat row = %s", l)
+ }
+ if l, _ := Classify(managed("h.example.com", provision.NestedDBPath("/data", "h.example.com"), provision.NestedSocket("h.example.com")), in); l != LayoutNested {
+ t.Errorf("nested row = %s", l)
+ }
+ if l, r := Classify(managed("h.example.com", "/data/sub/store.db", "/run/carlos/h.example.com.sock"), in); l != LayoutForeign || !strings.Contains(r, "store.db") {
+ t.Errorf("custom row = %s %q", l, r)
+ }
+ // A nested DB with a flat socket is not nested: both columns must agree.
+ if l, _ := Classify(managed("h.example.com", provision.NestedDBPath("/data", "h.example.com"), "/run/carlos/h.example.com.sock"), in); l != LayoutForeign {
+ t.Errorf("half-migrated row = %s", l)
+ }
+}
+
+func TestClassifyFencedWinsOverEverything(t *testing.T) {
+ in := LayoutInputs{DataDir: "/data", Journal: func(h string) bool { return h == "h.example.com" }}
+ if l, r := Classify(managed("h.example.com", "/data/h.example.com.db", "/run/carlos/h.example.com.sock"), in); l != LayoutFenced || r == "" {
+ t.Errorf("fenced = %s %q", l, r)
+ }
+ if l, _ := Classify(managed("o.example.com", "/data/o.example.com.db", "/run/carlos/o.example.com.sock"), in); l != LayoutFlat {
+ t.Errorf("other host = %s", l)
+ }
+}
+
+func TestClassifyConflictRefusesBothHosts(t *testing.T) {
+ long := strings.Repeat("a", 60) + ".example.com"
+ twin := strings.Repeat("a", 60) + ".example.org"
+ if provision.InstanceID(long) == provision.InstanceID(twin) {
+ t.Skip("fixture hosts do not collide; pick two that do")
+ }
+ // Force a collision through the Peers seam: a peer whose InstanceID we
+ // fake by giving it the SAME host string is not a conflict (it is the
+ // same row); so build the collision with a peer that is a different
+ // host mapping to the same identifier, which needs a real hash
+ // collision. Instead test the rule directly: a nested row whose
+ // identifier is shared by another managed row classifies as conflict.
+ a := managed(long, provision.NestedDBPath("/data", long), provision.NestedSocket(long))
+ b := managed(twin, provision.NestedDBPath("/data", twin), provision.NestedSocket(twin))
+ in := LayoutInputs{DataDir: "/data", Peers: func() ([]registry.Route, error) { return []registry.Route{a, b}, nil }, SameID: func(x, y string) bool { return true }}
+ if l, r := Classify(a, in); l != LayoutConflict || !strings.Contains(r, twin) {
+ t.Errorf("conflict = %s %q", l, r)
+ }
+}
+
+func TestClassifyIgnoresUnmanagedPeers(t *testing.T) {
+ a := managed("h.example.com", "/data/h.example.com.db", "/run/carlos/h.example.com.sock")
+ ext := registry.Route{Host: "x.example.com", Kind: "instance", Hibernate: false, Socket: "/run/carlos/x.example.com.sock"}
+ in := LayoutInputs{DataDir: "/data", Peers: func() ([]registry.Route, error) { return []registry.Route{a, ext}, nil }, SameID: func(x, y string) bool { return true }}
+ if l, _ := Classify(a, in); l != LayoutFlat {
+ t.Errorf("an unmanaged peer must not conflict: %s", l)
+ }
+ if ActivatorManaged(ext) {
+ t.Error("unflagged socket route reported managed")
+ }
+}
+```
+
+`SameID` is a test seam (`func(a, b string) bool`, nil = `provision.InstanceID(a) == provision.InstanceID(b)`) so a collision can be exercised without a real hash collision.
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/activator -run TestClassify -count=1`
+Expected: build failure.
+
+- [ ] **Step 3: Write `layout.go` and `paths.go`**
+
+`internal/activator/layout.go`:
+
+```go
+package activator
+
+import (
+ "fmt"
+
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// Layout is the classification of an activator-managed route's paths
+// (isolated-exec design §8.1). It decides the spawn path: flat and foreign
+// rows run today's exec child; nested rows run as units; fenced and
+// conflict rows refuse to wake.
+type Layout string
+
+const (
+ LayoutFlat Layout = "flat"
+ LayoutNested Layout = "nested"
+ LayoutFenced Layout = "fenced"
+ LayoutConflict Layout = "conflict"
+ LayoutForeign Layout = "foreign"
+)
+
+// LayoutInputs are the box facts Classify needs beyond the row: where the
+// data dir is, whether a migration journal exists for a host (plan 4
+// writes them), and the other rows on the box (for identifier conflicts).
+// Nil seams mean "never fenced" and "never conflict", which is what unit
+// tests without a box want.
+type LayoutInputs struct {
+ DataDir string
+ Journal func(host string) bool
+ Peers func() ([]registry.Route, error)
+ // SameID reports whether two hosts share an instance identifier. Nil
+ // means provision.InstanceID equality; tests inject a collision.
+ SameID func(a, b string) bool
+}
+
+// ActivatorManaged is the activator's own predicate for a route it owns
+// (activator.go's per-host gate and edge.go's proxy decision): an instance
+// route, hibernate-flagged, with a socket, not unit-backed, not
+// tunnel-served. An unflagged socket route is an externally supervised
+// process and is nobody's to migrate or isolate (design §8.2 step 1).
+func ActivatorManaged(rt registry.Route) bool {
+ return rt.Kind == "instance" && rt.Hibernate && rt.Socket != "" && rt.Backing == "" && rt.Tunnel == ""
+}
+
+// Classify returns the layout and, for anything but flat and nested, the
+// reason a member or operator sees.
+func Classify(rt registry.Route, in LayoutInputs) (Layout, string) {
+ if in.Journal != nil && in.Journal(rt.Host) {
+ return LayoutFenced, "layout migration in flight; see " + provision.LayoutJournalPath(in.DataDir, rt.Host)
+ }
+ if in.Peers != nil {
+ same := in.SameID
+ if same == nil {
+ same = func(a, b string) bool { return provision.InstanceID(a) == provision.InstanceID(b) }
+ }
+ peers, err := in.Peers()
+ if err == nil {
+ for _, p := range peers {
+ if p.Host != rt.Host && ActivatorManaged(p) && same(p.Host, rt.Host) {
+ return LayoutConflict, fmt.Sprintf("instance identifier collides with %s; neither host wakes until one is removed", p.Host)
+ }
+ }
+ }
+ }
+ switch {
+ case rt.DBPath == provision.DefaultDBPath(in.DataDir, rt.Host) && rt.Socket == provision.DefaultSocket(rt.Host):
+ return LayoutFlat, ""
+ case rt.DBPath == provision.NestedDBPath(in.DataDir, rt.Host) && rt.Socket == provision.NestedSocket(rt.Host):
+ return LayoutNested, ""
+ }
+ return LayoutForeign, fmt.Sprintf("db %q socket %q are neither the flat nor the nested defaults", rt.DBPath, rt.Socket)
+}
+```
+
+`internal/activator/paths.go`:
+
+```go
+package activator
+
+import (
+ "path/filepath"
+
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// instancePaths is every per-host path the exec backend touches, resolved
+// once per call by layout so that no method builds a path from DataDir and
+// the host on its own. Flat and foreign rows keep today's flat names; a
+// nested row's files live in its instance directory with the same names
+// (design §3.1).
+type instancePaths struct {
+ layout Layout
+ reason string
+ dataDir string // the directory that holds the four files
+ runDir string // the directory that holds app.sock and the control socket
+ dbPath string
+ envFile string
+ sidecarEnv string
+ configEnv string
+}
+
+func (b *ExecBackend) pathsFor(rt registry.Route) instancePaths {
+ l, reason := Classify(rt, b.layoutInputs())
+ p := instancePaths{layout: l, reason: reason, dataDir: b.DataDir, runDir: provision.SocketDir}
+ if l == LayoutNested {
+ p.dataDir = provision.InstanceDir(b.DataDir, rt.Host)
+ p.runDir = provision.InstanceRunDir(rt.Host)
+ }
+ p.dbPath = rt.DBPath
+ if p.dbPath == "" {
+ p.dbPath = provision.DefaultDBPath(b.DataDir, rt.Host)
+ }
+ p.envFile = filepath.Join(p.dataDir, rt.Host+".env")
+ p.sidecarEnv = filepath.Join(p.dataDir, rt.Host+"-sidecar.env")
+ p.configEnv = filepath.Join(p.dataDir, rt.Host+".config.env")
+ return p
+}
+
+// layoutInputs is the backend's LayoutInputs with DataDir filled in.
+func (b *ExecBackend) layoutInputs() LayoutInputs {
+ in := b.Layout
+ in.DataDir = b.DataDir
+ return in
+}
+```
+
+Add to `ExecBackend` (backend_exec.go struct) the fields this task introduces, with comments:
+
+```go
+ // Layout supplies the box facts the classification of §8.1 needs beyond
+ // the row: journals and peers. Zero value: never fenced, never conflict.
+ Layout LayoutInputs
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/activator -run 'TestClassify' -count=1`
+Expected: PASS.
+
+- [ ] **Step 5: Replace the flat path builders with `pathsFor`**
+
+In `backend_exec.go`: `defaultDBPath` is deleted; every `filepath.Join(b.DataDir, host+".env")` (`Seed`, `Start`, `NextDue`, `instanceEnv`), `filepath.Join(b.DataDir, host+".config.env")` (`instanceEnv`) and `sidecarEnvPath` become reads of `b.pathsFor(rt)` fields. `instanceEnv(host string)` becomes `instanceEnv(rt registry.Route)`. For a flat row every resulting path is byte-identical to today's, which the existing tests prove.
+
+Run: `TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/activator -count=1` (outside the sandbox)
+Expected: PASS with no test edits.
+
+- [ ] **Step 6: gofmt and commit**
+
+```bash
+gofmt -l internal/activator
+git add internal/activator
+git commit -m "activator: layout classification and per-host paths by layout (plan 3, task 2)"
+```
+
+---
+
+### Task 3: `childEnv` deduplication, the `Control` seam, the log listener
+
+**Files:**
+- Modify: `internal/activator/backend_exec.go` (`childEnv`)
+- Create: `internal/activator/control.go`, `internal/activator/loglistener.go`
+- Create: `internal/activator/loglistener_test.go`; modify `internal/activator/activator_test.go` (one new test for `childEnv`)
+
+**Interfaces:**
+- Produces:
+ ```go
+ // control.go
+ type Control interface {
+ Start(ctx context.Context, host string, role instancectl.Role, memoryMax int64) (string, error)
+ Seed(ctx context.Context, host string, memoryMax int64) (instancectl.SeedResponse, error)
+ Stop(ctx context.Context, host string, role instancectl.Role) error
+ Reset(ctx context.Context, host string, role instancectl.Role) error
+ Sweep(ctx context.Context) (instancectl.SweepResponse, error)
+ Ready(ctx context.Context) error
+ }
+ // UnitState answers systemctl show -p ActiveState,InvocationID for a unit; production execs systemctl (unprivileged).
+ type UnitState func(ctx context.Context, unit string) (active, invocation string, err error)
+ // CgroupEvents reports whether the unit's cgroup exists and is populated, from <root>/carlos.slice/carlos-instances.slice/<slice>/<unit>/cgroup.events.
+ type CgroupEvents func(slice, unit string) (exists, populated bool, err error)
+ func systemctlUnitState(ctx context.Context, unit string) (string, string, error)
+ func cgroupEventsUnder(root string) CgroupEvents
+ // loglistener.go
+ type logListener struct{ ... }
+ func listenLogs(path string, emit func(line string)) (*logListener, error) // binds path 0600; accepts ONE connection per Reset; tees lines to os.Stdout and emit
+ func (l *logListener) Close() error
+ ```
+ `instancectl.Client` satisfies `Control` (compile-time assertion in `control.go`: `var _ Control = (*instancectl.Client)(nil)`).
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `internal/activator/activator_test.go`:
+
+```go
+func TestChildEnvKeepsOneEntryPerKeyLastWins(t *testing.T) {
+ t.Setenv("PATH", "/usr/bin")
+ env := childEnv(map[string]string{"PATH": "/tenant/bin", "A": "1"})
+ seen := map[string]string{}
+ for _, kv := range env {
+ k, v, _ := strings.Cut(kv, "=")
+ if _, dup := seen[k]; dup {
+ t.Fatalf("duplicate key %s in %v", k, env)
+ }
+ seen[k] = v
+ }
+ if seen["PATH"] != "/tenant/bin" {
+ t.Errorf("PATH = %q, want the tenant's value to win over the inherited one", seen["PATH"])
+ }
+ if seen["A"] != "1" {
+ t.Errorf("A = %q", seen["A"])
+ }
+}
+```
+
+Create `internal/activator/loglistener_test.go`:
+
+```go
+package activator
+
+import (
+ "net"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+)
+
+func TestLogListenerTeesOneConnectionAndRefusesASecond(t *testing.T) {
+ dir, err := os.MkdirTemp("", "ll")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { os.RemoveAll(dir) })
+ path := filepath.Join(dir, "h.run.sock")
+ var mu sync.Mutex
+ var lines []string
+ l, err := listenLogs(path, func(s string) { mu.Lock(); lines = append(lines, s); mu.Unlock() })
+ if err != nil {
+ t.Skipf("cannot bind here (%v); run with TMPDIR=/tmp/g outside the sandbox", err)
+ }
+ defer l.Close()
+ c, err := net.Dial("unix", path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := c.Write([]byte("hello\nworld\n")); err != nil {
+ t.Fatal(err)
+ }
+ c.Close()
+ deadline := time.Now().Add(2 * time.Second)
+ for {
+ mu.Lock()
+ n := len(lines)
+ mu.Unlock()
+ if n == 2 || time.Now().After(deadline) {
+ break
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ mu.Lock()
+ got := strings.Join(lines, "|")
+ mu.Unlock()
+ if got != "hello|world" {
+ t.Fatalf("lines = %q", got)
+ }
+ // A second connection during the same invocation is closed unread.
+ c2, err := net.Dial("unix", path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ c2.SetReadDeadline(time.Now().Add(time.Second))
+ buf := make([]byte, 1)
+ if _, err := c2.Read(buf); err == nil {
+ t.Fatal("second connection was not closed")
+ }
+ c2.Close()
+ if l.Exited() {
+ t.Error("EOF on the log connection must not mark the process exited")
+ }
+}
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/activator -run 'TestChildEnvKeepsOneEntry|TestLogListener' -count=1`
+Expected: `TestChildEnvKeepsOneEntry` FAILS (duplicate PATH today); `TestLogListener` fails to build.
+
+- [ ] **Step 3: Write the code**
+
+`childEnv` in `backend_exec.go` becomes:
+
+```go
+// childEnv is the child's complete environment: the agent's own, minus
+// every CARLOS_* variable, with the instance's file values layered on top.
+// One entry per key, last value wins: os/exec deduplicates cmd.Env that
+// way, but the unit path hands this list to syscall.Exec, which passes
+// duplicates through, and a Go child then keeps the FIRST occurrence
+// (design §6.2, codex R1-16). Composing it deduplicated here keeps the
+// two spawn paths identical.
+func childEnv(fileEnv map[string]string) []string {
+ order := make([]string, 0, 32)
+ values := make(map[string]string, 32)
+ set := func(k, v string) {
+ if _, seen := values[k]; !seen {
+ order = append(order, k)
+ }
+ values[k] = v
+ }
+ for _, kv := range os.Environ() {
+ if strings.HasPrefix(kv, "CARLOS_") {
+ continue
+ }
+ k, v, ok := strings.Cut(kv, "=")
+ if !ok {
+ continue
+ }
+ set(k, v)
+ }
+ keys := make([]string, 0, len(fileEnv))
+ for k := range fileEnv {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ for _, k := range keys {
+ set(k, fileEnv[k])
+ }
+ env := make([]string, 0, len(order))
+ for _, k := range order {
+ env = append(env, k+"="+values[k])
+ }
+ return env
+}
+```
+
+(`sort` import; map iteration order for `fileEnv` is made deterministic by sorting keys, which also makes the launch file stable across writes.)
+
+`internal/activator/control.go`:
+
+```go
+package activator
+
+import (
+ "context"
+ "errors"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/instancectl"
+)
+
+// Control is what the exec backend needs from the root instance-control
+// channel (design §5.3). instancectl.Client is the production
+// implementation; tests use a fake.
+type Control interface {
+ Start(ctx context.Context, host string, role instancectl.Role, memoryMax int64) (string, error)
+ Seed(ctx context.Context, host string, memoryMax int64) (instancectl.SeedResponse, error)
+ Stop(ctx context.Context, host string, role instancectl.Role) error
+ Reset(ctx context.Context, host string, role instancectl.Role) error
+ Sweep(ctx context.Context) (instancectl.SweepResponse, error)
+ Ready(ctx context.Context) error
+}
+
+var _ Control = (*instancectl.Client)(nil)
+
+// UnitState answers `systemctl show -p ActiveState,InvocationID <unit>`,
+// which needs no privilege. An unloaded unit answers inactive with an
+// empty invocation.
+type UnitState func(ctx context.Context, unit string) (active, invocation string, err error)
+
+func systemctlUnitState(ctx context.Context, unit string) (string, string, error) {
+ out, err := exec.CommandContext(ctx, "systemctl", "show", "-p", "ActiveState,InvocationID", unit).Output()
+ if err != nil {
+ return "", "", err
+ }
+ var active, inv string
+ for _, line := range strings.Split(string(out), "\n") {
+ if k, v, ok := strings.Cut(line, "="); ok {
+ switch k {
+ case "ActiveState":
+ active = strings.TrimSpace(v)
+ case "InvocationID":
+ inv = strings.TrimSpace(v)
+ }
+ }
+ }
+ return active, inv, nil
+}
+
+// CgroupEvents reads the unit's cgroup.events under the host slice. cgroup
+// v2 files are world-readable, so the agent reads them as carlos, as the
+// sampler does. An absent directory is the unit's cgroup already removed.
+type CgroupEvents func(slice, unit string) (exists, populated bool, err error)
+
+func cgroupEventsUnder(root string) CgroupEvents {
+ return func(slice, unit string) (bool, bool, error) {
+ b, err := os.ReadFile(filepath.Join(root, "carlos.slice", "carlos-instances.slice", slice, unit, "cgroup.events"))
+ if errors.Is(err, os.ErrNotExist) {
+ return false, false, nil
+ }
+ if err != nil {
+ return true, true, err
+ }
+ return true, !strings.Contains(string(b), "populated 0"), nil
+ }
+}
+```
+
+`internal/activator/loglistener.go`:
+
+```go
+package activator
+
+import (
+ "bufio"
+ "io"
+ "net"
+ "os"
+ "sync"
+ "sync/atomic"
+
+ "github.com/carlosframework/platform/internal/applog"
+ "github.com/carlosframework/platform/internal/edge"
+)
+
+// logListener is the agent's end of a unit's stdout (design §6.4): the
+// unit's StandardOutput=file: connects to this AF_UNIX socket once, at
+// exec, and every line is teed to the agent's own stdout (journald) and to
+// the app log sink, exactly as teeStd does for an exec child. One
+// connection per invocation is accepted; a second is closed unread. EOF
+// ends log collection and nothing else: it is never a sign the process is
+// gone (codex R1-4).
+type logListener struct {
+ ln net.Listener
+ emit func(string)
+ accepted atomic.Bool
+ closed atomic.Bool
+ wg sync.WaitGroup
+}
+
+func listenLogs(path string, emit func(string)) (*logListener, error) {
+ ln, err := edge.SecureUnixListen(path)
+ if err != nil {
+ return nil, err
+ }
+ l := &logListener{ln: ln, emit: emit}
+ l.wg.Add(1)
+ go l.accept()
+ return l, nil
+}
+
+func (l *logListener) accept() {
+ defer l.wg.Done()
+ for {
+ c, err := l.ln.Accept()
+ if err != nil {
+ return
+ }
+ if l.accepted.Swap(true) {
+ c.Close()
+ continue
+ }
+ go l.tee(c)
+ }
+}
+
+func (l *logListener) tee(c net.Conn) {
+ defer c.Close()
+ var sink io.Writer = os.Stdout
+ if l.emit != nil {
+ sink = io.MultiWriter(os.Stdout, applog.NewLineWriter(execLogLineMax, l.emit))
+ }
+ r := bufio.NewReaderSize(c, execLogLineMax)
+ _, _ = io.Copy(sink, r)
+}
+
+// Exited always reports false: a log listener knows nothing about process
+// state, and this method exists so a caller cannot mistake it for one.
+func (l *logListener) Exited() bool { return false }
+
+func (l *logListener) Close() error {
+ if l.closed.Swap(true) {
+ return nil
+ }
+ err := l.ln.Close()
+ l.wg.Wait()
+ return err
+}
+```
+
+`edge.SecureUnixListen` does not exist exported; `secureUnixListen(socketPath string) (net.Listener, error)` is unexported in `internal/edge/purge_listen_unix.go` with a stub in `purge_listen_windows.go`. Export both as `SecureUnixListen` (rename, update its callers in `internal/edge`) in this task; it is the one owner-only unix listen the repo has and the log socket wants exactly its umask discipline. `teeStd` feeds `b.Logs.App(release.RouteAccount(rt), rt.App, rt.Host, line)`; `logEmitter` in Task 4 uses the same call.
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/activator ./internal/edge -count=1` (outside the sandbox)
+Expected: PASS.
+
+- [ ] **Step 5: gofmt and commit**
+
+```bash
+gofmt -l internal/activator internal/edge
+git add internal/activator internal/edge
+git commit -m "activator: deduplicated child env, the Control seam, the per-role log listener (plan 3, task 3)"
+```
+
+---
+
+### Task 4: `prepare`, the launch file, `unitProc` and the exit observer
+
+**Files:**
+- Create: `internal/activator/prepare.go`, `internal/activator/unitproc.go`
+- Create: `internal/activator/unitproc_test.go`
+- Modify: `internal/activator/backend_exec.go` (`ExecBackend` gains `Control`, `UnitState`, `CgroupEvents`, `CgroupRoot`; `Start`/`Seed`/`StartSidecar`/`HasSidecar` dispatch)
+
+**Interfaces:**
+- Produces:
+ ```go
+ var ErrStillRunning = errors.New("activator: stop unproven; processes remain")
+ type unitProc struct { host string; role instancectl.Role; unit, slice, invocation string; ctl *edge.ControlListener; logs *logListener; exited atomic.Bool; done chan struct{}; stopRequested atomic.Bool }
+ func (p *unitProc) Stop(ctx context.Context) error
+ func (p *unitProc) Exited() bool
+ func (b *ExecBackend) prepare(rt registry.Route, role instancectl.Role, extraEnv map[string]string) (paths instancePaths, ctl *edge.ControlListener, logs *logListener, err error)
+ func (b *ExecBackend) writeLaunch(paths instancePaths, role instancectl.Role, host string, argv, env []string) error
+ func (b *ExecBackend) startUnit(ctx context.Context, rt registry.Route) (Proc, string, error)
+ func (b *ExecBackend) seedUnit(ctx context.Context, rt registry.Route) error
+ func (b *ExecBackend) startSidecarUnit(ctx context.Context, rt registry.Route) (Proc, error)
+ func (b *ExecBackend) observe(p *unitProc, rt registry.Route) // the goroutine of §6.5
+ ```
+- `ExecBackend` new fields: `Control Control` (nil: nested wakes refuse with "instance control channel not configured"), `UnitState UnitState` (nil: `systemctlUnitState`), `CgroupEvents CgroupEvents` (nil: `cgroupEventsUnder("/sys/fs/cgroup")`), `NestedOK func() error` (nil: ok; production: the mounts and `/ready` check, Task 6), `ObservePoll time.Duration` (0: 1s).
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `internal/activator/unitproc_test.go`:
+
+```go
+package activator
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/instancectl"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// fakeControl records verbs and lets a test drive the unit's state.
+type fakeControl struct {
+ mu sync.Mutex
+ calls []string
+ inv string
+ seed instancectl.SeedResponse
+ startErr error
+ stopHook func(host string, role instancectl.Role)
+ readyErr error
+}
+
+func (f *fakeControl) rec(s string) { f.mu.Lock(); f.calls = append(f.calls, s); f.mu.Unlock() }
+func (f *fakeControl) Start(_ context.Context, host string, role instancectl.Role, max int64) (string, error) {
+ f.rec("start " + host + " " + string(role))
+ return f.inv, f.startErr
+}
+func (f *fakeControl) Seed(_ context.Context, host string, max int64) (instancectl.SeedResponse, error) {
+ f.rec("seed " + host)
+ return f.seed, nil
+}
+func (f *fakeControl) Stop(_ context.Context, host string, role instancectl.Role) error {
+ f.rec("stop " + host + " " + string(role))
+ if f.stopHook != nil {
+ f.stopHook(host, role)
+ }
+ return nil
+}
+func (f *fakeControl) Reset(_ context.Context, host string, role instancectl.Role) error { f.rec("reset"); return nil }
+func (f *fakeControl) Sweep(context.Context) (instancectl.SweepResponse, error) { f.rec("sweep"); return instancectl.SweepResponse{}, nil }
+func (f *fakeControl) Ready(context.Context) error { f.rec("ready"); return f.readyErr }
+func (f *fakeControl) joined() string { f.mu.Lock(); defer f.mu.Unlock(); return strings.Join(f.calls, "\n") }
+
+// fakeUnit is the unit's observable state: what `systemctl show` and
+// cgroup.events would say.
+type fakeUnit struct {
+ mu sync.Mutex
+ active string
+ inv string
+ exists bool
+ populated bool
+}
+
+func (u *fakeUnit) set(active, inv string, exists, populated bool) {
+ u.mu.Lock()
+ u.active, u.inv, u.exists, u.populated = active, inv, exists, populated
+ u.mu.Unlock()
+}
+func (u *fakeUnit) state(context.Context, string) (string, string, error) {
+ u.mu.Lock()
+ defer u.mu.Unlock()
+ return u.active, u.inv, nil
+}
+func (u *fakeUnit) events(string, string) (bool, bool, error) {
+ u.mu.Lock()
+ defer u.mu.Unlock()
+ return u.exists, u.populated, nil
+}
+
+// nestedBackend returns an ExecBackend wired for a nested host with fakes,
+// and the route. The data and run dirs are temp dirs standing in for
+// /data/instances/<iid> and /run/carlos/instances/<iid>; provision's
+// helpers are not used for the temp paths, so the route is built to
+// classify as nested by construction through the Layout seam.
+func nestedBackend(t *testing.T, ctl *fakeControl, unit *fakeUnit) (*ExecBackend, registry.Route) {
+ t.Helper()
+ root, err := os.MkdirTemp("", "nb")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { os.RemoveAll(root) })
+ host := "h.example.com"
+ b := testExecBackend(t)
+ b.DataDir = filepath.Join(root, "data")
+ b.Control = ctl
+ b.UnitState = unit.state
+ b.CgroupEvents = unit.events
+ b.ObservePoll = 20 * time.Millisecond
+ b.RunDirOverride = filepath.Join(root, "run")
+ stageLive(t, b, host, "v1")
+ rt := registry.Route{Host: host, Kind: "instance", Hibernate: true,
+ DBPath: provision.NestedDBPath(b.DataDir, host), Socket: filepath.Join(b.RunDirOverride, "instances", host, "app.sock")}
+ return b, rt
+}
+```
+
+`RunDirOverride` is a test-only field: `pathsFor` uses it as the socket dir when non-empty (so a test can point `/run/carlos` at a temp dir); `Classify` compares `rt.Socket` against `provision.NestedSocket(host)` and would call this row foreign, so `pathsFor` passes a `LayoutInputs` whose `SocketDir` overrides `provision.SocketDir`: add `SocketDir string` to `LayoutInputs` (empty = `provision.SocketDir`) and use it in `Classify`'s two socket comparisons and in `pathsFor`. Update Task 2's `Classify` accordingly (flat: `filepath.Join(sockDir, host+".sock")`; nested: `filepath.Join(sockDir, "instances", provision.InstanceID(host), "app.sock")`); the Task 2 tests keep passing with an empty `SocketDir`.
+
+```go
+func TestStartUnitWritesTheLaunchFileAndReturnsAUnitProc(t *testing.T) {
+ ctl := &fakeControl{inv: "inv1"}
+ unit := &fakeUnit{}
+ b, rt := nestedBackend(t, ctl, unit)
+ // The fake control does not start anything, so health must be faked:
+ // bind a listener at rt.Socket that answers 200.
+ stopServing := serveOK(t, rt.Socket)
+ defer stopServing()
+ unit.set("active", "inv1", true, true)
+ p, ver, err := b.Start(context.Background(), rt)
+ if err != nil {
+ t.Fatalf("Start: %v", err)
+ }
+ _ = ver
+ up, ok := p.(*unitProc)
+ if !ok || up.invocation != "inv1" {
+ t.Fatalf("proc = %T %+v", p, p)
+ }
+ raw, err := os.ReadFile(filepath.Join(provision.InstanceDir(b.DataDir, rt.Host), "launch.run.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var l instancectl.Launch
+ if err := json.Unmarshal(raw, &l); err != nil {
+ t.Fatal(err)
+ }
+ if l.Schema != 1 || l.Host != rt.Host || len(l.Argv) != 5 || l.Argv[0] != filepath.Join(b.OptDir, "live", rt.Host) || l.Argv[2] != rt.Socket || l.Argv[4] != rt.DBPath {
+ t.Errorf("launch = %+v", l)
+ }
+ seenCtl := false
+ for _, kv := range l.Env {
+ if strings.HasPrefix(kv, "CARLOS_CONTROL_SOCKET=") {
+ seenCtl = true
+ }
+ if strings.HasPrefix(kv, "CARLOS_ADMIN_TOKEN=") && len(kv) < 30 {
+ t.Errorf("token missing from env: %s", kv)
+ }
+ }
+ if !seenCtl {
+ t.Error("CARLOS_CONTROL_SOCKET not in the launch env")
+ }
+ if !strings.Contains(ctl.joined(), "start h.example.com run") {
+ t.Errorf("control calls: %s", ctl.joined())
+ }
+ if p.Exited() {
+ t.Error("exited before the unit went inactive")
+ }
+ // Termination: terminal state plus empty cgroup.
+ unit.set("inactive", "inv1", false, false)
+ waitFor(t, func() bool { return p.Exited() }, 2*time.Second)
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ if err := p.Stop(ctx); err != nil {
+ t.Errorf("Stop after exit: %v", err)
+ }
+}
+
+func TestUnitProcDoesNotExitOnTerminalStateWhileTheCgroupIsPopulated(t *testing.T) {
+ ctl := &fakeControl{inv: "inv1"}
+ unit := &fakeUnit{}
+ b, rt := nestedBackend(t, ctl, unit)
+ stopServing := serveOK(t, rt.Socket)
+ defer stopServing()
+ unit.set("active", "inv1", true, true)
+ p, _, err := b.Start(context.Background(), rt)
+ if err != nil {
+ t.Fatal(err)
+ }
+ unit.set("failed", "inv1", true, true) // a process survived SIGKILL
+ time.Sleep(150 * time.Millisecond)
+ if p.Exited() {
+ t.Fatal("exited while the cgroup is still populated (codex R2-5)")
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
+ defer cancel()
+ if err := p.Stop(ctx); !errors.Is(err, ErrStillRunning) {
+ t.Fatalf("Stop = %v, want ErrStillRunning", err)
+ }
+ unit.set("failed", "inv1", false, false)
+ waitFor(t, func() bool { return p.Exited() }, 2*time.Second)
+}
+
+func TestUnitProcStopsAForeignInvocation(t *testing.T) {
+ ctl := &fakeControl{inv: "inv1"}
+ unit := &fakeUnit{}
+ b, rt := nestedBackend(t, ctl, unit)
+ stopServing := serveOK(t, rt.Socket)
+ defer stopServing()
+ unit.set("active", "inv1", true, true)
+ p, _, err := b.Start(context.Background(), rt)
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctl.stopHook = func(string, instancectl.Role) { unit.set("inactive", "", false, false) }
+ unit.set("active", "inv2", true, true) // someone restarted it outside the activator
+ waitFor(t, func() bool { return p.Exited() }, 2*time.Second)
+ if !strings.Contains(ctl.joined(), "stop h.example.com run") {
+ t.Errorf("foreign invocation was not stopped: %s", ctl.joined())
+ }
+}
+
+func TestStartUnitRefusesWhenTheBoxIsNotReadyOrFenced(t *testing.T) {
+ ctl := &fakeControl{inv: "inv1"}
+ unit := &fakeUnit{}
+ b, rt := nestedBackend(t, ctl, unit)
+ b.NestedOK = func() error { return errors.New("probe: pid1 fail") }
+ if _, _, err := b.Start(context.Background(), rt); err == nil || !strings.Contains(err.Error(), "pid1") {
+ t.Fatalf("not-ready wake = %v", err)
+ }
+ if strings.Contains(ctl.joined(), "start") {
+ t.Error("a start was issued on a box that is not ready")
+ }
+ b.NestedOK = nil
+ b.Layout.Journal = func(string) bool { return true }
+ if _, _, err := b.Start(context.Background(), rt); err == nil || !strings.Contains(err.Error(), "migration in flight") {
+ t.Fatalf("fenced wake = %v", err)
+ }
+}
+
+func TestSeedUnitCleansUpOnlyItsOwnDBOnFailure(t *testing.T) {
+ ctl := &fakeControl{seed: instancectl.SeedResponse{InvocationID: "s1", Result: "exit-code", Status: "3", Code: "exited"}}
+ unit := &fakeUnit{}
+ b, rt := nestedBackend(t, ctl, unit)
+ rt.Seed = `["seed","--fast"]`
+ unit.set("inactive", "", false, false)
+ err := b.Seed(context.Background(), rt)
+ if err == nil || !strings.Contains(err.Error(), "exit 3") {
+ t.Fatalf("Seed = %v", err)
+ }
+ raw, err := os.ReadFile(filepath.Join(provision.InstanceDir(b.DataDir, rt.Host), "launch.seed.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var l instancectl.Launch
+ _ = json.Unmarshal(raw, &l)
+ if len(l.Argv) != 5 || l.Argv[1] != "seed" || l.Argv[2] != "--fast" || l.Argv[3] != "--db" || l.Argv[4] != rt.DBPath {
+ t.Errorf("seed argv = %v", l.Argv)
+ }
+ for _, kv := range l.Env {
+ if strings.HasPrefix(kv, "CARLOS_CONTROL_SOCKET=") || strings.HasPrefix(kv, "CARLOS_PURGE=") {
+ t.Errorf("seed env carries %s", kv)
+ }
+ }
+ if _, err := os.Stat(rt.DBPath); !os.IsNotExist(err) {
+ t.Errorf("failed seed left its database: %v", err)
+ }
+}
+```
+
+Add helpers to the same file:
+
+```go
+// serveOK binds a unix socket at path and answers 200 to every request, so
+// waitHealthyCtx passes without a real unit.
+func serveOK(t *testing.T, path string) func() {
+ t.Helper()
+ if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
+ t.Fatal(err)
+ }
+ ln, err := net.Listen("unix", path)
+ if err != nil {
+ t.Skipf("cannot bind here (%v); run with TMPDIR=/tmp/g outside the sandbox", err)
+ }
+ srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })}
+ go srv.Serve(ln) //nolint:errcheck
+ return func() { srv.Close(); ln.Close() }
+}
+
+func waitFor(t *testing.T, cond func() bool, d time.Duration) {
+ t.Helper()
+ deadline := time.Now().Add(d)
+ for !cond() {
+ if time.Now().After(deadline) {
+ t.Fatal("condition not met in time")
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+}
+```
+
+(imports `net`, `net/http` in the test file.)
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/activator -run 'TestStartUnit|TestUnitProc|TestSeedUnit' -count=1`
+Expected: build failure.
+
+- [ ] **Step 3: Write `prepare.go`**
+
+```go
+package activator
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "github.com/carlosframework/platform/internal/edge"
+ "github.com/carlosframework/platform/internal/instancectl"
+ "github.com/carlosframework/platform/internal/instancefs"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/schedule"
+)
+
+// prepare is everything a nested role needs before its unit starts
+// (design §6.1): the two directories, the admin token and merged env (for
+// run and seed), the control listener (run only), the role's log listener,
+// and stale records cleared. It returns the listeners so the caller can
+// hand them to the proc or close them on a failed start.
+func (b *ExecBackend) prepare(rt registry.Route, role instancectl.Role) (instancePaths, *edge.ControlListener, *logListener, map[string]string, error) {
+ p := b.pathsFor(rt)
+ if err := os.MkdirAll(p.dataDir, 0o700); err != nil {
+ return p, nil, nil, nil, err
+ }
+ if err := os.MkdirAll(p.runDir, 0o750|os.ModeSetgid); err != nil {
+ return p, nil, nil, nil, err
+ }
+ if err := os.MkdirAll(filepath.Dir(b.logSocket(rt.Host, role)), 0o700); err != nil {
+ return p, nil, nil, nil, err
+ }
+ var env map[string]string
+ if role == instancectl.RoleRun || role == instancectl.RoleSeed {
+ if err := EnsureAdminToken(p.envFile); err != nil {
+ return p, nil, nil, nil, fmt.Errorf("admin token for %s: %w", rt.Host, err)
+ }
+ var err error
+ env, err = b.instanceEnv(rt)
+ if err != nil {
+ return p, nil, nil, nil, err
+ }
+ } else {
+ var err error
+ env, err = readEnvFileBeneath(p.dataDir, filepath.Base(p.sidecarEnv))
+ if err != nil {
+ return p, nil, nil, nil, err
+ }
+ }
+ // Stale exit records from earlier invocations of this role.
+ if entries, err := os.ReadDir(p.runDir); err == nil {
+ for _, e := range entries {
+ if strings.HasPrefix(e.Name(), "exit."+string(role)+".") {
+ _ = os.Remove(filepath.Join(p.runDir, e.Name()))
+ }
+ }
+ }
+ var ctl *edge.ControlListener
+ if role == instancectl.RoleRun {
+ _ = os.Remove(rt.Socket) // nothing is running; a stale socket file would refuse the app's bind
+ ctl = b.listenControl(rt, p.envFile)
+ }
+ logs, err := listenLogs(b.logSocket(rt.Host, role), b.logEmitter(rt))
+ if err != nil {
+ if ctl != nil {
+ ctl.Close()
+ }
+ return p, nil, nil, nil, fmt.Errorf("log listener for %s %s: %w", rt.Host, role, err)
+ }
+ return p, ctl, logs, env, nil
+}
+
+// listenControl binds the control socket exactly as Start does for an exec
+// child (the tokenReader and schedule mount are unchanged); a bind failure
+// is logged and the instance runs without its control verbs, as today.
+func (b *ExecBackend) listenControl(rt registry.Route, envPath string) *edge.ControlListener {
+ tokenReader := func() (string, error) {
+ env, err := readEnvFileBeneath(filepath.Dir(envPath), filepath.Base(envPath))
+ if err != nil {
+ return "", err
+ }
+ return env["CARLOS_ADMIN_TOKEN"], nil
+ }
+ var mount func(*http.ServeMux)
+ if b.Timers != nil {
+ mount = func(mux *http.ServeMux) { schedule.Mount(mux, rt.Host, b.Timers, tokenReader, time.Now) }
+ }
+ ctl, err := edge.ListenControl(rt.Socket+".control", rt.Host, b.Cache, mount)
+ if err != nil {
+ log.Printf("activator: control listener for %s: %v (continuing without it)", rt.Host, err)
+ return nil
+ }
+ return ctl
+}
+
+// logSocket is the role's log socket path, honouring the test override of
+// the run directory.
+func (b *ExecBackend) logSocket(host string, role instancectl.Role) string {
+ if b.RunDirOverride != "" {
+ return filepath.Join(b.RunDirOverride, "logs", provision.InstanceID(host)+"."+string(role)+".sock")
+ }
+ return provision.LogSocket(host, string(role))
+}
+
+// logEmitter is the same sink teeStd feeds, or nil when Logs is unset.
+func (b *ExecBackend) logEmitter(rt registry.Route) func(string) {
+ if b.Logs == nil {
+ return nil
+ }
+ return func(line string) { b.Logs.App(release.RouteAccount(rt), rt.App, rt.Host, line) }
+}
+
+// writeLaunch writes launch.<role>.json atomically into the instance
+// directory (design §6.2). The env is childEnv's deduplicated list; the
+// trampoline execs it as is.
+func (b *ExecBackend) writeLaunch(p instancePaths, role instancectl.Role, host string, argv, env []string) error {
+ raw, err := json.MarshalIndent(instancectl.Launch{Schema: instancectl.LaunchSchema, Host: host, Argv: argv, Env: env}, "", " ")
+ if err != nil {
+ return err
+ }
+ return instancefs.WriteAtomic(p.dataDir, instancectl.LaunchFileName(role), append(raw, '\n'), 0o600)
+}
+
+// readEnvFileBeneath is readEnvFile through instancefs: a nested host's env
+// files are read without following a symlink (design §3.2). A missing file
+// is an empty env, as readEnvFile treats it.
+func readEnvFileBeneath(dir, name string) (map[string]string, error) {
+ b, err := instancefs.ReadBeneathLimit(dir, name, 1<<20)
+ if errors.Is(err, os.ErrNotExist) {
+ return map[string]string{}, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ return parseEnvBytes(b), nil
+}
+```
+
+Check how `b.Logs` is called in `teeStd` (`b.Logs.App(...)` or via `emitEvent`) and mirror it exactly; the sweep says `Logs EventSink` with `Event(account, app, host, level, msg)` for events and `teeStd` feeding `Logs.App(acct, app, rt.Host, line)`. Read `teeStd` and use the same call. `readEnvFile`/`parseEnvBytes` exist in `backend_exec.go`/`admintoken.go`; for a flat host `instanceEnv` keeps calling `readEnvFile`; for a nested host it calls `readEnvFileBeneath` (dispatch inside `instanceEnv` on `p.layout`).
+
+- [ ] **Step 4: Write `unitproc.go`**
+
+```go
+package activator
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log"
+ "path/filepath"
+ "sync/atomic"
+ "time"
+
+ "github.com/carlosframework/platform/internal/edge"
+ "github.com/carlosframework/platform/internal/instancectl"
+ "github.com/carlosframework/platform/internal/instancefs"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// ErrStillRunning is Stop's answer when ctx expired before termination was
+// proven (design §6.3). The activator then keeps the lease and the entry
+// in stopping, and the sweeper retries; the lease is never released over a
+// writer that may still exist.
+var ErrStillRunning = errors.New("activator: stop unproven; processes remain")
+
+// unitProc is a nested host's Proc (design §6.3): the unit the control
+// service started, the listeners the agent holds for it, and the observer
+// that proves termination.
+type unitProc struct {
+ b *ExecBackend
+ rt registry.Route
+ role instancectl.Role
+ unit, slice string
+ invocation string
+ runDir string
+ ctl *edge.ControlListener
+ logs *logListener
+ exited atomic.Bool
+ done chan struct{}
+ closeOnce atomic.Bool
+}
+
+func (b *ExecBackend) newUnitProc(rt registry.Route, role instancectl.Role, p instancePaths, inv string, ctl *edge.ControlListener, logs *logListener) *unitProc {
+ up := &unitProc{b: b, rt: rt, role: role, unit: instancectl.UnitName(role, rt.Host), slice: instancectl.SliceName(rt.Host),
+ invocation: inv, runDir: p.runDir, ctl: ctl, logs: logs, done: make(chan struct{})}
+ go b.observe(up)
+ return up
+}
+
+func (p *unitProc) Exited() bool { return p.exited.Load() }
+
+// Stop asks the control service to stop the unit and waits for termination
+// to be proven. The control listener and the log listener are closed only
+// after that, preserving childProc's ordering (signal first, close the
+// listener after the process is gone).
+func (p *unitProc) Stop(ctx context.Context) error {
+ if !p.Exited() {
+ sctx, cancel := context.WithTimeout(ctx, instancectl.DeadlineControl)
+ err := p.b.Control.Stop(sctx, p.rt.Host, p.role)
+ cancel()
+ if err != nil {
+ log.Printf("activator: stop %s %s: %v", p.rt.Host, p.role, err)
+ }
+ }
+ select {
+ case <-p.done:
+ p.closeListeners()
+ return nil
+ case <-ctx.Done():
+ return ErrStillRunning
+ }
+}
+
+func (p *unitProc) closeListeners() {
+ if p.closeOnce.Swap(true) {
+ return
+ }
+ if p.ctl != nil {
+ p.ctl.Close()
+ }
+ if p.logs != nil {
+ p.logs.Close()
+ }
+}
+
+// observe is the goroutine of design §6.5. It proves termination from two
+// facts the tenant cannot forge: systemd's unit state and cgroup emptiness.
+// The exit record decides the wording of the event line and nothing else.
+func (b *ExecBackend) observe(p *unitProc) {
+ poll := b.ObservePoll
+ if poll <= 0 {
+ poll = time.Second
+ }
+ state := b.UnitState
+ if state == nil {
+ state = systemctlUnitState
+ }
+ events := b.CgroupEvents
+ if events == nil {
+ events = cgroupEventsUnder(b.cgroupRoot())
+ }
+ foreignStopped := false
+ lastRemain := time.Time{}
+ for {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ active, inv, err := state(ctx, p.unit)
+ cancel()
+ if err != nil {
+ log.Printf("activator: %s: unit state: %v", p.rt.Host, err)
+ }
+ terminal := false
+ foreign := false
+ switch {
+ case err != nil:
+ case inv != "" && inv != p.invocation:
+ foreign = true
+ case active == "inactive" || active == "failed":
+ terminal = true
+ }
+ if foreign && !foreignStopped {
+ log.Printf("activator: %s: unit restarted outside the activator (invocation %s, ours %s); stopping it", p.rt.Host, inv, p.invocation)
+ sctx, scancel := context.WithTimeout(context.Background(), instancectl.DeadlineControl)
+ if err := b.Control.Stop(sctx, p.rt.Host, p.role); err != nil {
+ log.Printf("activator: %s: stop foreign invocation: %v", p.rt.Host, err)
+ }
+ scancel()
+ foreignStopped = true
+ }
+ if terminal || (foreign && foreignStopped) {
+ exists, populated, eerr := events(p.slice, p.unit)
+ if eerr != nil {
+ log.Printf("activator: %s: cgroup.events: %v", p.rt.Host, eerr)
+ }
+ if !exists || !populated {
+ if terminal {
+ emitEvent(b.Logs, p.rt, exitLevel(p), exitLine(p))
+ } else {
+ emitEvent(b.Logs, p.rt, "warn", "instance exited (status unknown: restarted outside the activator)")
+ }
+ p.exited.Store(true)
+ close(p.done)
+ if p.Exited() {
+ // Stop closes the listeners when it observes done; a
+ // crash nobody is stopping yet leaves them for the
+ // sweeper's stand-down, which calls Stop.
+ }
+ return
+ }
+ if time.Since(lastRemain) > 2*time.Second {
+ log.Printf("activator: %s: processes remain after %s; not releasing", p.rt.Host, active)
+ lastRemain = time.Now()
+ }
+ }
+ time.Sleep(poll)
+ }
+}
+
+// exitLine reads the advisory record for this invocation and words the
+// event line as childProc does: a non-zero status is a warn line, a clean
+// exit is info, and no record is unknown.
+func exitLine(p *unitProc) string {
+ rec, ok := p.readRecord()
+ switch {
+ case !ok:
+ return "instance exited (status unknown: no exit record)"
+ case rec.Result == "success" || (rec.Code == "exited" && rec.Status == "0"):
+ return "instance exited (exit 0)"
+ case rec.Code == "killed" || rec.Code == "dumped":
+ return "instance exited: signal " + rec.Status
+ default:
+ return fmt.Sprintf("instance exited: exit %s (%s)", rec.Status, rec.Result)
+ }
+}
+
+func exitLevel(p *unitProc) string {
+ if rec, ok := p.readRecord(); ok && (rec.Result == "success" || (rec.Code == "exited" && rec.Status == "0")) {
+ return "info"
+ }
+ return "warn"
+}
+
+func (p *unitProc) readRecord() (instancectl.ExitRecord, bool) {
+ raw, err := instancefs.ReadBeneathLimit(p.runDir, instancectl.ExitRecordName(p.role, p.invocation), 4096)
+ if err != nil {
+ return instancectl.ExitRecord{}, false
+ }
+ var rec instancectl.ExitRecord
+ if err := json.Unmarshal(raw, &rec); err != nil {
+ return instancectl.ExitRecord{}, false
+ }
+ return rec, true
+}
+
+func (b *ExecBackend) cgroupRoot() string {
+ if b.CgroupRoot != "" {
+ return b.CgroupRoot
+ }
+ return "/sys/fs/cgroup"
+}
+```
+
+Remove the empty `if p.Exited() { ... }` block in `observe` before committing; it is shown only to carry its comment, which belongs above `close(p.done)`. Match `emitEvent`'s actual signature (`emitEvent(sink EventSink, rt registry.Route, level, msg string)`).
+
+- [ ] **Step 5: The dispatch in `backend_exec.go`**
+
+Rename the current `Start` body to `startChild`, `Seed`'s to `seedChild`, `StartSidecar`'s to `startSidecarChild` (signatures unchanged), and write the dispatchers:
+
+```go
+func (b *ExecBackend) Start(ctx context.Context, rt registry.Route) (Proc, string, error) {
+ p := b.pathsFor(rt)
+ switch p.layout {
+ case LayoutNested:
+ return b.startUnit(ctx, rt)
+ case LayoutFenced, LayoutConflict:
+ return nil, "", fmt.Errorf("%s: refusing to wake: %s", rt.Host, p.reason)
+ }
+ return b.startChild(ctx, rt)
+}
+```
+
+(and the same shape for `Seed` and `StartSidecar`; `HasSidecar` stats `pathsFor(rt).sidecarEnv`.)
+
+`startUnit`:
+
+```go
+func (b *ExecBackend) startUnit(ctx context.Context, rt registry.Route) (Proc, string, error) {
+ if b.Control == nil {
+ return nil, "", fmt.Errorf("%s: instance control channel not configured", rt.Host)
+ }
+ if b.NestedOK != nil {
+ if err := b.NestedOK(); err != nil {
+ return nil, "", fmt.Errorf("%s: refusing to wake an isolated instance: %w", rt.Host, err)
+ }
+ }
+ if err := b.ensureLiveLink(ctx, rt.Host); err != nil {
+ return nil, "", err
+ }
+ p, ctl, logs, fileEnv, err := b.prepare(rt, instancectl.RoleRun)
+ if err != nil {
+ return nil, "", err
+ }
+ fail := func(err error) (Proc, string, error) {
+ if ctl != nil {
+ ctl.Close()
+ }
+ logs.Close()
+ return nil, "", err
+ }
+ childVars := b.agentOwnedVars(rt, fileEnv, ctl != nil) // the same withVar/withoutVars block Start uses today, extracted so both paths share it
+ argv := []string{b.liveLink(rt.Host), "--socket", rt.Socket, "--db", p.dbPath}
+ if err := b.writeLaunch(p, instancectl.RoleRun, rt.Host, argv, childEnv(childVars)); err != nil {
+ return fail(err)
+ }
+ spawnVer, _ := b.LiveVersion(rt.Host)
+ inv, err := b.Control.Start(ctx, rt.Host, instancectl.RoleRun, b.tier(rt).MemoryMax)
+ if err != nil {
+ return fail(fmt.Errorf("%s: start unit: %w", rt.Host, err))
+ }
+ log.Printf("activator: %s: started %s memory.max=%d", rt.Host, instancectl.UnitName(instancectl.RoleRun, rt.Host), b.tier(rt).MemoryMax)
+ up := b.newUnitProc(rt, instancectl.RoleRun, p, inv, ctl, logs)
+ if b.Cache != nil {
+ if n := b.Cache.PurgeHost(rt.Host); n > 0 {
+ log.Printf("activator: purged %d cached response(s) for %s on spawn", n, rt.Host)
+ }
+ }
+ if err := waitHealthyCtx(ctx, rt.Socket, spawnHealthTimeout); err != nil {
+ stopCtx, cancel := context.WithTimeout(context.Background(), actStopTimeout)
+ defer cancel()
+ _ = up.Stop(stopCtx)
+ return nil, "", err
+ }
+ b.normalizeSocketMode(rt, up) // the existing 0660 block, extracted
+ return up, spawnVer, nil
+}
+```
+
+Extract the `childVars` composition (the three `withVar`/`withoutVars` pairs) into `agentOwnedVars(rt, fileEnv, haveControl bool) map[string]string` and the post-health `0660` block into `normalizeSocketMode(rt, proc)`, and call both from `startChild` too, so the two paths cannot drift. `seedUnit`: prepare for `RoleSeed`; argv = the same `seedArgs` parse plus `--db p.dbPath` that `seedChild` builds (extract the parse into `seedArgv(rt, dbPath) ([]string, error)`); env `childEnv(withoutVars(fileEnv, "CARLOS_CONTROL_SOCKET", "CARLOS_PURGE"))`; `seedOwnsDB` stat before as today; `res, err := b.Control.Seed(ctx, rt.Host, b.tier(rt).MemoryMax)`; then, to honour §6.2, wait for the seed unit's cgroup to be empty through `CgroupEvents` (up to `actStopTimeout`, polling `ObservePoll`) before deciding; a non-success `res.Result` returns `fmt.Errorf("seed for %s failed: exit %s (%s)", host, res.Status, res.Result)` after the same cleanup `seedChild` does when `seedOwnsDB`; close the seed's log listener afterwards. `startSidecarUnit`: prepare for `RoleSidecar` (env from the sidecar file only); argv `[liveLink, "sidecar", "run"]`; `Control.Start(..., RoleSidecar, ...)`; return `newUnitProc` with `ctl == nil`.
+
+- [ ] **Step 6: Run the tests to verify they pass**
+
+Run: `TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/activator -count=1` (outside the sandbox)
+Expected: PASS, including every pre-existing test unchanged.
+
+- [ ] **Step 7: gofmt and commit**
+
+```bash
+gofmt -l internal/activator
+git add internal/activator
+git commit -m "activator: the nested spawn path: prepare, launch file, unitProc and the exit observer (plan 3, task 4)"
+```
+
+---
+
+### Task 5: The three `activator.go` changes
+
+**Files:**
+- Modify: `internal/activator/activator.go` (`stop`, `sweepOnce`, `doWake`, `Service` gains `Routes`)
+- Test: `internal/activator/activator_test.go` (append three tests)
+
+**Interfaces:**
+- Produces:
+ ```go
+ // RouteLookup is the registry read doWake performs before Acquire (design §6.8); *registry.Registry satisfies it with Get.
+ type RouteLookup interface { Get(host string) (*registry.Route, error) }
+ // Service gains: Routes RouteLookup (nil: the wake runs on the copy the edge supplied, today's behaviour)
+ ```
+ `actEntry` gains `pendingStop bool`, `sidecarStarting bool`, `wakeGen uint64`.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `internal/activator/activator_test.go`. The existing helpers are: `testActivator(t, idle) (*Service, *fakeBackend, *fakeClock, *registry.Registry)`, `testRoute(host) registry.Route`, `newTestServiceAwake(t, host)`, `fakeBackend` (fields `procs []*fakeProc`, `sidecars []*fakeProc`, `sidecarStaged bool`, `startGate chan struct{}`, `releases` counter via `releaseCount()`, `lastProc()`, `lastSidecar()`), `fakeProc`, `fakeClock`. A wake is driven the way the file's other tests do it: `a.beginRequest(ctx, rt)` then `release`; a stop by advancing the fake clock past `idle` and calling `a.sweepOnce()`, or `a.Bounce(host)`. Read the two nearest tests (an idle-hibernate test and a sidecar-restart test) and copy their driving code into the three tests below where a comment says `// drive`.
+
+```go
+// A Proc whose Stop reports ErrStillRunning must not have its lease
+// released: stop() keeps the entry in stopping and the sweeper finishes it
+// once the proc reports exited (design §6.3, codex R2-5).
+func TestStopDoesNotReleaseTheLeaseOverAnUnprovenStop(t *testing.T) {
+ a, b, clk, _ := testActivator(t, time.Minute)
+ b.procStuck = true // Start returns a fakeProc whose Stop reports ErrStillRunning until exit()
+ rt := testRoute("h.example.com")
+ // drive: wake rt and release the request, as the idle-hibernate test does
+ clk.advance(2 * time.Minute)
+ a.sweepOnce() // idle: stop() runs, Stop reports ErrStillRunning
+ p := b.lastProc()
+ waitFor(t, func() bool { return p.stopCalls() >= 1 }, 2*time.Second)
+ if n := b.releaseCount(); n != 0 {
+ t.Fatalf("lease released %d times while the stop is unproven", n)
+ }
+ p.exit()
+ a.sweepOnce()
+ waitFor(t, func() bool { return b.releaseCount() == 1 }, 2*time.Second)
+}
+
+// The sidecar restart must not hold the activator mutex across the start
+// call (design §6.7, codex R2-6): a stalled sidecar start does not block
+// another host's wake.
+func TestSidecarRestartDoesNotHoldTheLockAcrossTheStart(t *testing.T) {
+ a, b, clk, _ := testActivator(t, time.Hour)
+ b.sidecarStaged = true
+ release := make(chan struct{})
+ b.sidecarGate = release // StartSidecar blocks on this channel when non-nil
+ // drive: wake a.example.com with the sidecar gate open, then close the gate
+ b.lastSidecar().exit() // the sweeper now wants a restart
+ clk.advance(time.Minute)
+ go a.sweepOnce() // blocks inside StartSidecar until release is closed
+ time.Sleep(50 * time.Millisecond)
+ done := make(chan struct{})
+ go func() {
+ // drive: wake b.example.com
+ close(done)
+ }()
+ select {
+ case <-done:
+ case <-time.After(2 * time.Second):
+ t.Fatal("a second host's wake blocked behind a stalled sidecar start")
+ }
+ close(release)
+}
+
+// doWake runs on the committed row, not the copy the edge supplied
+// (design §6.8, codex R8-1).
+func TestDoWakeRefreshesTheRouteFromTheRegistry(t *testing.T) {
+ a, b, _, reg := testActivator(t, time.Hour)
+ a.Routes = reg
+ old := testRoute("h.example.com")
+ fresh := old
+ fresh.DBPath = "/data/instances/h.example.com/h.example.com.db"
+ fresh.Socket = "/run/carlos/instances/h.example.com/app.sock"
+ // insert fresh into reg the way the file's registry-backed tests do
+ // drive: wake with old (the edge's stale copy)
+ if got := b.lastStartedRoute().DBPath; got != fresh.DBPath {
+ t.Fatalf("Start saw DBPath %q, want the registry's %q", got, fresh.DBPath)
+ }
+}
+```
+
+Add to `fakeBackend`: `procStuck bool` (when set, `Start` returns a `fakeProc` with `stuck` true), `sidecarGate chan struct{}` (`StartSidecar` blocks on it when non-nil), `lastStartedRoute() registry.Route` (recorded in `Start`); to `fakeProc`: `stuck` handling in `Stop` (return `ErrStillRunning` while stuck and not exited, counting calls in `stopCalls()`), `exit()` if absent. `waitFor` is the helper Task 4 added in `unitproc_test.go` (same package). `fakeClock.advance` may be spelled differently; use the file's name.
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/activator -run 'TestStopDoesNotRelease|TestSidecarRestartDoesNotHold|TestDoWakeRefreshes' -count=1`
+Expected: FAIL (`Routes` undefined; the lease is released today; the lock is held today).
+
+- [ ] **Step 3: Make the three changes**
+
+In `stop(e *actEntry)`, after `err = e.proc.Stop(procCtx)`:
+
+```go
+ if errors.Is(err, ErrStillRunning) {
+ // The lease is never released over an unproven termination (design
+ // §6.3). Keep the entry in stopping; sweepOnce finishes the stop
+ // once the proc reports exited.
+ log.Printf("activator: stop %s: %v; keeping the lease until termination is proven", e.host, err)
+ a.mu.Lock()
+ e.pendingStop = true
+ a.mu.Unlock()
+ return
+ }
+```
+
+and in `sweepOnce`, under `a.mu`, before the idle pass:
+
+```go
+ for _, e := range a.entries {
+ if e.pendingStop && e.proc != nil && e.proc.Exited() {
+ e.pendingStop = false
+ go a.finishStop(e) // rep.Close, delete, close(e.stopped): the tail of stop()
+ }
+ }
+```
+
+with `finishStop` being the tail of today's `stop()` from `rep.Close` onward, extracted so `stop()` calls it too.
+
+In `sweepOnce`'s sidecar block, replace the synchronous `a.backend.StartSidecar(...)` with:
+
+```go
+ if !e.sidecarStarting {
+ e.sidecarStarting = true
+ gen := e.wakeGen
+ host := e.host
+ route := e.route
+ go func() {
+ ctx, cancel := context.WithTimeout(context.Background(), instancectl.DeadlineControl)
+ sc, err := a.backend.StartSidecar(ctx, route)
+ cancel()
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ e := a.entries[host]
+ if e == nil || e.state != actAwake || e.wakeGen != gen {
+ if sc != nil {
+ go sc.Stop(context.Background()) //nolint:errcheck
+ }
+ return
+ }
+ e.sidecarStarting = false
+ // install exactly as the synchronous code did
+ ...
+ }()
+ }
+```
+
+(`e.wakeGen` increments in `doWake` each time an entry becomes awake; the install branch is the existing `if err != nil { ... } else { e.sidecar = sc; ... }` block moved inside the goroutine.) The existing `stop()` waits for `sidecarStarting` to clear or stops the installed sidecar, whichever it finds: add, at the top of `stop()`'s sidecar section, a bounded wait (`sidecarStopTimeout`) on `sidecarStarting` becoming false.
+
+In `doWake`, at the top, before `Acquire`:
+
+```go
+ if a.Routes != nil {
+ fresh, err := a.Routes.Get(rt.Host)
+ if err != nil {
+ return nil, fmt.Errorf("%s: route lookup: %w", rt.Host, err)
+ }
+ if fresh == nil {
+ return nil, fmt.Errorf("%s: no route", rt.Host)
+ }
+ if fresh.DBPath != rt.DBPath || fresh.Socket != rt.Socket {
+ log.Printf("activator: %s: route refreshed: db %q → %q socket %q → %q", rt.Host, rt.DBPath, fresh.DBPath, rt.Socket, fresh.Socket)
+ }
+ rt = *fresh
+ a.mu.Lock()
+ if e := a.entries[rt.Host]; e != nil {
+ e.route = rt
+ }
+ a.mu.Unlock()
+ }
+```
+
+(Use a plain `->` in the log line, not an arrow glyph: the copy rule.)
+
+- [ ] **Step 4: Run the whole package**
+
+Run: `TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/activator -race -count=1` (outside the sandbox)
+Expected: PASS, race-clean.
+
+- [ ] **Step 5: gofmt and commit**
+
+```bash
+gofmt -l internal/activator
+git add internal/activator
+git commit -m "activator: unproven stops keep the lease, sidecar restarts leave the lock, wakes run on the committed row (plan 3, task 5)"
+```
+
+---
+
+### Task 6: Consumers of the shape and the agent wiring
+
+**Files:**
+- Modify: `internal/appconfig/syncexec.go` (writer path; `reapConfigEnv`)
+- Modify: `internal/schedule/runner.go`, `internal/host/schedule_wiring.go` (token path)
+- Modify: `internal/host/movespass.go` (DB path from the row; fence)
+- Modify: `internal/ops/litestream.go` (glob)
+- Modify: `internal/host/agent.go` (wiring, startup sweep, gate)
+- Tests in each package
+
+**Interfaces:**
+- Consumes: `activator.Classify`, `activator.LayoutInputs`, `activator.ActivatorManaged`, `provision.InstanceDir`, `provision.LayoutJournalPath`, `instancectl.NewClient`, `controlsock.Path`, `instancefs.MountNoSymfollow`.
+- Produces: `appconfig.ConfigEnvPath(dataDir string, rt registry.Route) string` (nested for a nested row, flat otherwise); `schedule.TokenFromEnvFileFor(dataDir string, paths func(host string) string)` or the equivalent shape the runner already has (read it: the runner's `Token` seam takes a host; give it a `func(host) string` that resolves the env file through the same classification the backend uses, built in `schedule_wiring.go` from the registry).
+
+- [ ] **Step 1: Write the failing tests**
+
+`internal/appconfig/syncexec_test.go` (append):
+
+```go
+func TestSyncExecWritesANestedRouteIntoItsInstanceDir(t *testing.T) {
+ // Build the fixture exactly as TestSyncExecWritesConfigFileAndBouncesOnChange
+ // does, but with the route's DBPath and Socket at the nested defaults,
+ // and assert the file lands at <dataDir>/instances/<host>/<host>.config.env
+ // and NOT at <dataDir>/<host>.config.env.
+}
+
+func TestReapConfigEnvWalksInstanceDirs(t *testing.T) {
+ // A stale <dataDir>/instances/gone.example.com/gone.example.com.config.env
+ // with no route is removed; a live nested route's file is kept.
+}
+```
+
+Write both bodies by copying the closest existing test in that file and changing the route's two columns and the expected path.
+
+`internal/ops/ops_test.go` (append):
+
+```go
+func TestFleetDBsDiscoversNestedDatabases(t *testing.T) {
+ // A registry with a non-hibernating exec route whose DBPath is
+ // <dataDir>/instances/<host>/<host>.db and the file present: FleetDBs
+ // returns it. Copy the shape of the existing flat discovery test.
+}
+```
+
+`internal/host/movespass_test.go` (append): a cutting move for a host whose journal exists at `provision.LayoutJournalPath(cfg.DataDir, host)` does nothing and logs the fence; a cutting move for a nested row checkpoints `rt.DBPath`, not `<DataDir>/<host>.db`.
+
+`internal/host` (new test in `agent_isolated_test.go`): `nestedGate(mounts func(string) (bool, error), ready func(ctx) error) func() error` returns an error naming the first mount without `nosymfollow`, then the `/ready` error, else nil.
+
+- [ ] **Step 2: Run the new tests to verify they fail**
+
+Run each package's new tests; expected: FAIL.
+
+- [ ] **Step 3: Make the changes**
+
+`syncexec.go`: replace `path := filepath.Join(dataDir, rt.Host+configEnvSuffix)` (the writer and the addr warning path) with `path := ConfigEnvPath(dataDir, rt)`:
+
+```go
+// ConfigEnvPath is where a route's rendered bundle lands: inside the
+// instance directory for a nested row (isolated-exec design §7), flat for
+// everything else, including opted-in addr routes whose supervisor reads
+// the flat path.
+func ConfigEnvPath(dataDir string, rt registry.Route) string {
+ if rt.DBPath == provision.NestedDBPath(dataDir, rt.Host) && rt.Socket == provision.NestedSocket(rt.Host) {
+ return filepath.Join(provision.InstanceDir(dataDir, rt.Host), rt.Host+configEnvSuffix)
+ }
+ return filepath.Join(dataDir, rt.Host+configEnvSuffix)
+}
+```
+
+The writer's `os.WriteFile(tmp, ...)` + `os.Rename` becomes `instancefs.WriteAtomic(filepath.Dir(path), filepath.Base(path), rendered, 0o600)` for a nested path (the `O_EXCL` temp of §3.2) and stays as is for a flat one. `reapConfigEnv` additionally walks `filepath.Join(dataDir, "instances")` one level down, applying the same "host has no live route" rule to each `*.config.env` it finds there (the host is the file name minus the suffix); the flat directory's `x.config` ambiguity guard stays for the flat directory only.
+
+`schedule/runner.go`: add `TokenFromEnvPaths(envPath func(host string) string) func(host string) (string, error)` holding today's body of `TokenFromEnvFile` with the path taken from `envPath(host)`; `TokenFromEnvFile(dataDir)` becomes `TokenFromEnvPaths(func(host string) string { return filepath.Join(dataDir, host+".env") })`. `schedule_wiring.go`: `scheduleToken(dd, svc)` gains a `reg *registry.Registry` parameter and an `envPathFor(dd, reg) func(host string) string` that reads `reg.Get(host)`, classifies it with `activator.Classify(rt, activator.LayoutInputs{DataDir: dd})` and returns `filepath.Join(provision.InstanceDir(dd, host), host+".env")` for `LayoutNested`, else the flat path; both `schedule.TokenFromEnvPaths(envPathFor(...))` and the `EnsureAdminToken` prepare step use it. (A fenced host's token read keeps working: fenced is a refusal to wake, not to read.)
+
+`movespass.go`: `moveCfg` gains `Route func(host string) (*registry.Route, error)` (wired in `armMovesPass` as `reg.Get`) and the checkpoint block becomes:
+
+```go
+ if _, err := os.Stat(provision.LayoutJournalPath(cfg.DataDir, rec.Host)); err == nil {
+ log.Printf("moves: %s: layout migration in flight; leaving the move for the next pass", rec.Host)
+ continue // or return nil, matching how the loop skips a host today
+ }
+ dbPath := filepath.Join(cfg.DataDir, rec.Host+".db")
+ if cfg.Route != nil {
+ if rt, err := cfg.Route(rec.Host); err == nil && rt != nil && rt.DBPath != "" {
+ dbPath = rt.DBPath
+ }
+ }
+```
+
+`litestream.go:354`: `matches, err := filepath.Glob(filepath.Join(dataDir, "*.db"))` gains a second glob `filepath.Join(dataDir, "instances", "*", "*.db")` appended to `matches`.
+
+`agent.go`: after `eb := &activator.ExecBackend{...}`:
+
+```go
+ eb.Control = instancectl.NewClient(controlsock.Path)
+ eb.Layout = activator.LayoutInputs{
+ Journal: func(host string) bool { _, err := os.Stat(provision.LayoutJournalPath(dd, host)); return err == nil },
+ Peers: reg.List,
+ }
+ eb.NestedOK = nestedGate(instancefs.MountNoSymfollow, eb.Control.Ready)
+ act.Routes = reg
+```
+
+and before the activator's `Start()`:
+
+```go
+ if sw, err := eb.Control.Sweep(ctx); err != nil {
+ log.Printf("agent: instance sweep: %v (nested wakes refuse until the channel answers)", err)
+ } else if len(sw.Units) > 0 {
+ log.Printf("agent: swept %d instance unit(s) left from a previous agent: %v", len(sw.Units), sw.Units)
+ }
+```
+
+`nestedGate`:
+
+```go
+// nestedGate is what an isolated wake requires of the box (design §8.5,
+// §8.6): the three nosymfollow mounts, and a control service that reports
+// ready. The answer is recomputed per wake (the mounts are cheap to read;
+// /ready is one short request), so a box that becomes ready needs no
+// restart and a box that stops being ready refuses the next wake.
+func nestedGate(mounts func(string) (bool, error), ready func(context.Context) error) func() error {
+ return func() error {
+ for _, m := range []string{"/data/instances", "/run/carlos/instances", "/run/carlos/logs"} {
+ ok, err := mounts(m)
+ if err != nil {
+ return fmt.Errorf("%s: %v", m, err)
+ }
+ if !ok {
+ return fmt.Errorf("%s is not a nosymfollow mount", m)
+ }
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), instancectl.DeadlineReady)
+ defer cancel()
+ return ready(ctx)
+ }
+}
+```
+
+(`reg.List` must match `func() ([]registry.Route, error)`; if the registry's list method has a different name or signature, wrap it.)
+
+- [ ] **Step 4: Full gate**
+
+```bash
+export GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off TMPDIR=/tmp/g
+go build ./... && go vet ./... && gofmt -l . && go test ./... -count=1
+GOOS=darwin go build ./... && GOOS=windows go build ./... && GOARCH=arm64 go build ./...
+```
+
+Expected: green.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/appconfig internal/schedule internal/host internal/ops
+git commit -m "isolated exec: consumers follow the layout; the agent wires the control client, the gate and the startup sweep (plan 3, task 6)"
+```
+
+---
+
+## Self-review
+
+**Spec coverage.** §6.1 prepare and Start order: Task 4. §6.2 launch file, dedupe, seed and sidecar argv: Tasks 3, 4. §6.3 `unitProc.Stop`, `ErrStillRunning`, `stop()` not releasing: Tasks 4, 5. §6.4 per-role log listeners, one connection, EOF is not exit: Task 3. §6.5 termination from unit state and cgroup, foreign invocation, record as wording, stale record removal: Task 4. §6.6 tier via `/start`: Task 4 (sampler roots landed in plan 1's fix wave; the edge key and per-host key are unchanged). §6.7 startup sweep and the sidecar restart: Tasks 5, 6. §6.8 route refresh: Task 5. §7 config delivery: Task 6. §8.1 classification and refusals: Task 2. §8.5/§8.6 the gate and the "channel absent" refusal: Tasks 4, 6. §9 consumers: Task 6 (syncexec, schedule token, movespass, litestream glob); status `layout` field and the console are plan 4.
+
+**Placeholder scan.** Task 6's three test stubs describe the fixture to copy and the assertion; the implementer writes the bodies from the named neighbouring tests. Everything else carries code. The activator.go edits (Task 5) name the exact existing functions and fields; where the current test file's helper names may differ, the task says to read and adapt the new tests, not the code.
+
+**Type consistency.** `Layout`, `LayoutInputs{DataDir, SocketDir, Journal, Peers, SameID}`, `Classify`, `ActivatorManaged`, `instancePaths`, `pathsFor`, `Control`, `UnitState`, `CgroupEvents`, `logListener`/`listenLogs`, `unitProc`, `ErrStillRunning`, `prepare`, `writeLaunch`, `startUnit`, `seedUnit`, `startSidecarUnit`, `observe`, `RouteLookup`, `Routes`, `nestedGate`, `ConfigEnvPath` are spelled identically across tasks. `ExecBackend` new fields: `Layout`, `Control`, `UnitState`, `CgroupEvents`, `CgroupRoot`, `NestedOK`, `ObservePoll`, `RunDirOverride`.