carlos / platform Public

host: an unstaged route waiting for its proof keeps the 30 s miss cadence

fastTick's unstaged backoff doubled past ProofMissTTL for a listed route with no live link whose wait each sweep renewed, so the fast set swept it at 64 s, 128 s, up to 5 minutes, against spec §4.5's 30 s. When the sweep renewed the host's wait, its step is capped at ProofMissTTL. The stale-wait backoff is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 51f641a73b83c2fa35603aea1e293bd6aeb12949 parent 13400c9
2 files changed, +67 −0
  • internal/host/agent.go +23 −0
  • internal/host/proofask_test.go +44 −0
diff --git a/internal/host/agent.go b/internal/host/agent.go
index c4719edd7..cf1d9a6a2 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -1628,6 +1628,21 @@ func (d *fastDeps) noteUnstaged(host string, now time.Time) {
d.notBefore[host] = now.Add(s)
}
+// proofWaitRenewed reports whether host is on the proof gate's waiting list
+// with a cached miss still running: the sweep just asked the gate about it
+// and the gate held it again.
+func (d *fastDeps) proofWaitRenewed(host string) bool {
+ if d.proof.WaitingDue(host) {
+ return false
+ }
+ for _, w := range d.proof.Waits() {
+ if w.Host == host {
+ return true
+ }
+ }
+ return false
+}
+
// backoffFor reports host's current backoff step (zero when none recorded).
func (d *fastDeps) backoffFor(host string) time.Duration { return d.step[host] }
@@ -1930,6 +1945,14 @@ func fastTick(ctx context.Context, d *fastDeps, now time.Time) {
switch v, err := release.LiveVersion(d.optDir, h); {
case err != nil || v == "":
d.noteUnstaged(h, now)
+ // An unstaged route whose wait this sweep renewed is waiting
+ // for its proof, not failing to stage: spec §4.5 has it rejoin
+ // the sweep each time its 30 s miss runs out, so its backoff
+ // stops doubling there.
+ if d.proofWaitRenewed(h) && d.step[h] > release.ProofMissTTL {
+ d.step[h] = release.ProofMissTTL
+ d.notBefore[h] = now.Add(release.ProofMissTTL)
+ }
case d.proof.WaitingDue(h):
// The sweep neither renewed this host's proof wait (which would
// cache a fresh 30 s miss) nor cleared it: adoptHost returned
diff --git a/internal/host/proofask_test.go b/internal/host/proofask_test.go
index d1fa0488b..284d29862 100644
--- a/internal/host/proofask_test.go
+++ b/internal/host/proofask_test.go
@@ -188,6 +188,50 @@ func TestFastMembersKeepsAWaitingRoute(t *testing.T) {
}
}
+// Spec §4.5 for a route with no live link: a new instance of a listed app
+// whose head is unproven waits like a staged one, and rejoins the sweep each
+// time its 30 s miss runs out. Its unstaged backoff must not stretch that.
+func TestFastMembersKeepsAnUnstagedWaitingRouteAtTheMissCadence(t *testing.T) {
+ f := newFastFixture(t)
+ ctx := context.Background()
+ host := "new.example.com"
+ f.addRoute(t, host, "edge")
+ plat := release.AdoptPlatform()
+ m := release.Manifest{App: "fastapp", Version: "v2", Kind: "binary", Artifacts: map[string]release.Artifact{plat: {}}}
+ if err := release.Ship(ctx, f.store, "bab", m, map[string][]byte{plat: []byte("build two")}); err != nil {
+ t.Fatal(err)
+ }
+ if err := release.Promote(ctx, f.store, f.priv, "bab", "fastapp", "edge", "v2"); err != nil {
+ t.Fatal(err)
+ }
+ _, pubB64, err := release.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ policy := filepath.Join(t.TempDir(), "proof-policy.json")
+ if err := os.WriteFile(policy, []byte(fmt.Sprintf(`{"schema":1,"require":[{"app":"fastapp","prover":"dev","key":%q}]}`, pubB64)), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ clock := time.Now()
+ gate := release.NewProofGate(policy, f.dataDir).WithClock(func() time.Time { return clock })
+ d := f.deps(nil)
+ d.passOpts = append(d.passOpts, release.WithProofGate(gate))
+ d.proof = gate
+ for i := 0; i < 8; i++ {
+ if got := d.fastMembers(clock); len(got) != 1 || got[0] != host {
+ t.Fatalf("cycle %d: members = %v, want the waiting unstaged route (step %v)", i, got, d.backoffFor(host))
+ }
+ fastTick(ctx, d, clock)
+ if w := gate.Waits(); len(w) != 1 || w[0].Host != host {
+ t.Fatalf("cycle %d: waits = %+v, want %s waiting", i, w, host)
+ }
+ if v, _ := release.LiveVersion(f.optDir, host); v != "" {
+ t.Fatalf("cycle %d: live = %q, want nothing while unproven", i, v)
+ }
+ clock = clock.Add(release.ProofMissTTL + time.Second)
+ }
+}
+
// Ruling 5's purpose, seen from the fast loop: a wait the sweep neither
// renews nor clears (here the route moved to a channel with no pointer, so
// adoptHost returns before the gate) must not keep its host in the sweep