host: arm the runs worker; reconcile notes runs, the passes act, a boot pass at start
The runs passes do nothing until the boot pass (which sweeps every .carlos-run directory) has finished, so no run is launched into a directory about to be swept. follow's timer is capped at the run's own deadline, so slow staging does not buy a full extra timeout.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5 files changed,
+197
−2
internal/host/agent.go+10 −0internal/host/runexec.go+7 −1internal/host/runexec_linux_test.go+23 −0internal/host/runs.go+50 −1internal/host/runs_test.go+107 −0
CI passed — run details
diff --git a/internal/host/agent.go b/internal/host/agent.go| index c713732d2..533e2a307 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -408,6 +408,7 @@ func runAgent(args []string, buildClaims ClaimFactory) { |
| eb.Mirror = m |
| } |
| armMovesPass(*cfg, dd, reg, svc, eb, claims.Writes) |
| + armRunsPass(svc, reg, dd) |
| // The startup sweep (design §6.7): a leftover instance unit from a |
| // previous agent process, one this box's registry no longer holds |
| // a live wake for, is stopped before this process starts spawning |
| @@ -1144,6 +1145,9 @@ func reconcileSerialized(ctx context.Context, s release.Store, reg *registry.Reg |
| // Move holds (moves spec §9.6): participant rows minted held and kept. |
| // Unchanged with the Phase 1 switch off. |
| cfg = withMovesHolds(cfg) |
| + // Runs ride this pass's record reads (run spec §5.2): the worker notes |
| + // every in-scope record with a run, and acts after the pass. |
| + cfg = withRunsHook(cfg) |
| return errors.Join( |
| provision.Reconcile(ctx, s, reg, cfg), |
| provision.ReconcileSidecars(ctx, s, reg, cfg), |
| @@ -2209,6 +2213,9 @@ func fastConvergeLoop(reg *registry.Registry, svc *activator.Service, sd *schedu |
| // runs before the first moves pass, and an unbuilt view freezes every |
| // host (moves spec §9.4). Nothing with the Phase 1 switch off. |
| refreshMovesHolds(ctx, store) |
| + // carlos run's boot pass (run spec §5.2, index item 2): reconcile's |
| + // first pass is a minute away and the CLI expires a run at 60 s. |
| + runRunsBootPass(ctx) |
| // The schedule converge's own seed, on restartSeed's precedent and for the |
| // same shape of reason: the leg below runs only on a serial MOVE, so |
| // without one pass here a box that booted after the bump would carry no |
| @@ -2250,6 +2257,8 @@ func fastConvergeLoop(reg *registry.Registry, svc *activator.Service, sd *schedu |
| mark("telemetry") |
| runMovesPass(ctx, store) |
| mark("moves") |
| + runRunsPass(ctx) |
| + mark("runs") |
| // Folded once per pass and handed in, never re-read inside the |
| // loop: this leg runs on every serial bump (a seconds cadence), |
| // and the fold is a LoadBoxes plus a List plus one Get per edges |
| @@ -2823,6 +2832,7 @@ func instancesLoop(reg *registry.Registry, writes hostwrite.Config, configure .. |
| } |
| telemetryPass(ctx, store, reg) |
| runMovesPass(ctx, store) |
| + runRunsPass(ctx) |
| deploymentRoutes := edgesfile.DeploymentRoutes(ctx, store, time.Now()) |
| if err := domains.ReconcileAliases(ctx, store, reg, time.Now, deploymentRoutes, &aliasClaimCache); err != nil { |
| log.Printf("agent: instances alias pass: %v", err) |
diff --git a/internal/host/runexec.go b/internal/host/runexec.go| index d0c83a5f5..10f22c2b2 100644 |
| --- a/internal/host/runexec.go |
| +++ b/internal/host/runexec.go |
| @@ -311,7 +311,13 @@ func (w *runsWorker) follow(ctx context.Context, rec provision.Record, req runs. |
| } |
| } |
| }() |
| - timeout := time.NewTimer(req.Timeout()) |
| + // The timeout is the run's own deadline, set at pickup: staging that |
| + // ran long leaves the command less, not a full timeout from launch. |
| + limit := req.Timeout() |
| + if r := rec.ActiveRun; r != nil && !r.Deadline.IsZero() { |
| + limit = min(limit, r.Deadline.Sub(w.now())) |
| + } |
| + timeout := time.NewTimer(limit) |
| defer timeout.Stop() |
| recheck := time.NewTicker(runRecheckEvery) |
| defer recheck.Stop() |
diff --git a/internal/host/runexec_linux_test.go b/internal/host/runexec_linux_test.go| index 43d965c58..6749ce62c 100644 |
| --- a/internal/host/runexec_linux_test.go |
| +++ b/internal/host/runexec_linux_test.go |
| @@ -252,6 +252,29 @@ func TestRunTimesOut(t *testing.T) { |
| } |
| } |
| +// The run's deadline is set at pickup, so slow staging does not buy a full |
| +// extra timeout: a run whose deadline is nearer than its timeout times out |
| +// at the deadline, or the console sweep marks a still-running run lost. |
| +func TestRunTimesOutAtItsDeadlineNotAFullTimeoutLater(t *testing.T) { |
| + s := ctxStore{release.NewDirStore(t.TempDir())} |
| + rec, id := queueRun(t, s, time.Now().Add(time.Minute)) |
| + req, err := runs.ReadRequest(context.Background(), s, rec.Account, rec.App, id) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + req.TimeoutS = 60 |
| + got, err := provision.SetRecordRun(context.Background(), s, rec.Account, rec.App, rec.Host, provision.RunPick(id, "box-1", "boot-1", time.Now().Add(1500*time.Millisecond))) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + h := &fakeHandle{polls: make(chan instancectl.CmdStatus)} |
| + start := time.Now() |
| + res := runWith(t, s, &fakeLauncher{handle: h, out: "slow\n"}, *got, *req, newRunHandle()) |
| + if !res.TimedOut || time.Since(start) > 10*time.Second || h.stops == 0 { |
| + t.Fatalf("result %+v after %s, stops %d: a run times out at its deadline, not %ds after launch", res, time.Since(start), h.stops, req.TimeoutS) |
| + } |
| +} |
| + |
| // Review Focus 2: a cancel after the pickup CAS and before the unit starts |
| // ends the run cancelled with no unit started. |
| func TestRunCancelledBeforeLaunchNeverStarts(t *testing.T) { |
diff --git a/internal/host/runs.go b/internal/host/runs.go| index c906e77ce..e98ed4098 100644 |
| --- a/internal/host/runs.go |
| +++ b/internal/host/runs.go |
| @@ -51,6 +51,10 @@ type runsWorker struct { |
| // start runs a picked run's exec: on its own goroutine in the agent, |
| // inline in tests that count launches. |
| start func(func()) |
| + // booted is set when the boot pass has finished. The boot pass deletes |
| + // every .carlos-run directory, so until then pass does nothing: a run |
| + // it launched would have its directory swept from under it. |
| + booted atomic.Bool |
| mu sync.Mutex |
| noted map[string]provision.Record // host -> the record reconcile read |
| @@ -86,8 +90,12 @@ func (w *runsWorker) note(rec provision.Record) { |
| w.mu.Unlock() |
| } |
| -// pass acts on every record noted since the last pass. |
| +// pass acts on every record noted since the last pass, once the boot pass |
| +// has finished; before that the notes wait. |
| func (w *runsWorker) pass(ctx context.Context) { |
| + if !w.booted.Load() { |
| + return |
| + } |
| w.mu.Lock() |
| noted := w.noted |
| w.noted = map[string]provision.Record{} |
| @@ -102,6 +110,9 @@ func (w *runsWorker) pass(ctx context.Context) { |
| // .carlos-run directory first (run spec §5.8: no run is open on the box |
| // yet), then reads every record once and acts on each run. |
| func (w *runsWorker) bootPass(ctx context.Context) { |
| + // Opens pass, whether or not the records could be read: runs the boot |
| + // pass did not see are the next passes' to pick. |
| + defer w.booted.Store(true) |
| if err := runs.SweepRunDirs(w.dataDir); err != nil { |
| log.Printf("agent: runs: boot sweep of .carlos-run: %v", err) |
| } |
| @@ -289,3 +300,41 @@ func (w *runsWorker) closeInterrupted(ctx context.Context, rec provision.Record) |
| } |
| log.Printf("agent: runs: %s: run %s was picked before this agent started; ended as interrupted", rec.Host, id) |
| } |
| + |
| +// armRunsPass arms the runs worker on a box with an activator. Thin, like |
| +// armMovesPass: the behaviour is the worker's, tested there. |
| +func armRunsPass(svc *activator.Service, reg *registry.Registry, dataDir string) { |
| + if svc == nil || reg == nil { |
| + return |
| + } |
| + store, err := openDeployStore(context.Background()) |
| + if err != nil { |
| + log.Printf("agent: runs disabled: %v", err) |
| + return |
| + } |
| + w := newRunsWorker(store, reg.Get, svc, dataDir, BoxLabel(), newBootID()) |
| + armedRuns.Store(w) |
| + log.Printf("agent: runs armed (box %s, boot %s)", w.box, w.boot) |
| +} |
| + |
| +// withRunsHook gives reconcile the worker's note (run spec §5.2). |
| +func withRunsHook(cfg provision.ReconcileConfig) provision.ReconcileConfig { |
| + if w := armedRuns.Load(); w != nil && cfg.RunSeen == nil { |
| + cfg.RunSeen = w.note |
| + } |
| + return cfg |
| +} |
| + |
| +// runRunsPass acts on what the last reconcile pass noted. |
| +func runRunsPass(ctx context.Context) { |
| + if w := armedRuns.Load(); w != nil { |
| + w.pass(ctx) |
| + } |
| +} |
| + |
| +// runRunsBootPass is the worker's one pass at agent start. |
| +func runRunsBootPass(ctx context.Context) { |
| + if w := armedRuns.Load(); w != nil { |
| + w.bootPass(ctx) |
| + } |
| +} |
diff --git a/internal/host/runs_test.go b/internal/host/runs_test.go| index d6b0e8c9f..2282f0d2c 100644 |
| --- a/internal/host/runs_test.go |
| +++ b/internal/host/runs_test.go |
| @@ -7,6 +7,7 @@ import ( |
| "errors" |
| "log" |
| "os" |
| + "path/filepath" |
| "strings" |
| "sync" |
| "testing" |
| @@ -73,6 +74,7 @@ func testWorker(s release.Store, routes func(string) (*registry.Route, error), b |
| w := newRunsWorker(s, routes, nil, "/data", box, boot) |
| w.exec = log.exec |
| w.start = func(f func()) { f() } |
| + w.booted.Store(true) // a test of the boot gate unsets it |
| return w |
| } |
| @@ -411,3 +413,108 @@ func TestRunsWorkerForgetsAPickupThatDidNotLand(t *testing.T) { |
| t.Fatalf("execs %d, run %+v, active %d", l.count(), currentRun(t, dir), len(w.active)) |
| } |
| } |
| + |
| +func TestRunsHookIsOnlyThereWhenArmed(t *testing.T) { |
| + armedRuns.Store(nil) |
| + if cfg := withRunsHook(provision.ReconcileConfig{}); cfg.RunSeen != nil { |
| + t.Fatal("an unarmed box must not hook reconcile") |
| + } |
| + runRunsPass(context.Background()) // no-op, no panic |
| + runRunsBootPass(context.Background()) // ditto |
| + var l execLog |
| + w := testWorker(release.NewDirStore(t.TempDir()), noRoutes, "box-1", "boot-1", &l) |
| + armedRuns.Store(w) |
| + t.Cleanup(func() { armedRuns.Store(nil) }) |
| + cfg := withRunsHook(provision.ReconcileConfig{}) |
| + if cfg.RunSeen == nil { |
| + t.Fatal("an armed box hooks reconcile") |
| + } |
| + cfg.RunSeen(runRecord()) |
| + if len(w.noted) != 1 { |
| + t.Fatal("the hook does not reach the worker") |
| + } |
| +} |
| + |
| +// listGate holds every List until released, so a test can act while the |
| +// boot pass is part-way through. |
| +type listGate struct { |
| + release.Store |
| + entered chan struct{} |
| + proceed chan struct{} |
| + once sync.Once |
| +} |
| + |
| +func (g *listGate) List(ctx context.Context, prefix string) ([]string, error) { |
| + g.once.Do(func() { close(g.entered) }) |
| + <-g.proceed |
| + return g.Store.List(ctx, prefix) |
| +} |
| + |
| +// The boot pass deletes every .carlos-run directory (run spec §5.8), so no |
| +// reconcile-driven pass may pick or launch anything before it has finished: |
| +// a run it launched would have its directory swept from under it. |
| +func TestRunsPassDoesNothingUntilTheBootPassHasFinished(t *testing.T) { |
| + dir := release.NewDirStore(t.TempDir()) |
| + rec, _ := queueRun(t, dir, time.Now().Add(time.Minute)) |
| + gate := &listGate{Store: dir, entered: make(chan struct{}), proceed: make(chan struct{})} |
| + var l execLog |
| + w := testWorker(gate, runOwnedRoute(runHost), "box-1", "boot-1", &l) |
| + w.booted.Store(false) |
| + w.dataDir = t.TempDir() |
| + stale := filepath.Join(provision.InstanceDir(w.dataDir, runHost), runs.DirName, "run_old") |
| + if err := os.MkdirAll(stale, 0o700); err != nil { |
| + t.Fatal(err) |
| + } |
| + armedRuns.Store(w) |
| + t.Cleanup(func() { armedRuns.Store(nil) }) |
| + |
| + // Reconcile ran before the boot pass: the hook notes, the pass waits. |
| + withRunsHook(provision.ReconcileConfig{}).RunSeen(rec) |
| + runRunsPass(context.Background()) |
| + if l.count() != 0 || currentRun(t, dir).State != provision.RunQueued { |
| + t.Fatal("a pass picked a run before the boot pass ran") |
| + } |
| + |
| + done := make(chan struct{}) |
| + go func() { runRunsBootPass(context.Background()); close(done) }() |
| + <-gate.entered // the sweep is done, the boot pass is reading records |
| + if _, err := os.Stat(stale); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("the boot pass did not sweep first: %v", err) |
| + } |
| + runRunsPass(context.Background()) |
| + if l.count() != 0 || currentRun(t, dir).State != provision.RunQueued { |
| + t.Fatal("a pass picked a run while the boot pass was still running") |
| + } |
| + close(gate.proceed) |
| + <-done |
| + if l.count() != 1 { |
| + t.Fatalf("execs %d, want 1: the boot pass picks the run queued before start", l.count()) |
| + } |
| +} |
| + |
| +// A boot pass that cannot read the records still opens the gate: the runs |
| +// it could not see are the next passes' to pick. |
| +func TestRunsPassActsAfterABootPassThatFailed(t *testing.T) { |
| + dir := release.NewDirStore(t.TempDir()) |
| + rec, _ := queueRun(t, dir, time.Now().Add(time.Minute)) |
| + var l execLog |
| + w := testWorker(listFails{dir}, runOwnedRoute(runHost), "box-1", "boot-1", &l) |
| + w.booted.Store(false) |
| + w.dataDir = t.TempDir() |
| + w.note(rec) |
| + w.pass(context.Background()) |
| + if l.count() != 0 { |
| + t.Fatal("a pass acted before the boot pass") |
| + } |
| + w.bootPass(context.Background()) |
| + w.pass(context.Background()) |
| + if l.count() != 1 { |
| + t.Fatalf("execs %d, want 1", l.count()) |
| + } |
| +} |
| + |
| +type listFails struct{ release.Store } |
| + |
| +func (listFails) List(context.Context, string) ([]string, error) { |
| + return nil, errors.New("store down") |
| +} |