slice 10A: activate the control envelope (Down channel) + hub-backed desired provider (v0.15.0)

The control envelope becomes live: the agent caches the hub's desired-state +
generation and re-fetches GET /hosts/{id}/desired-state only when the
generation advances. A new internal/desired Syncer maps the wire shape into a
reconcile.CachingProvider feeding the engine; benign deltas reconcile, an
explicit guest decommission is gated pending_signature (exec is 10B). Adds the
DesiredStateResponse/WireDesiredState wire types + Client.FetchDesiredState +
the loop EnvelopeObserver seam. Cross-repo golden (envelope + desired-state)
byte-identical with the hub.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 19:02:59 +02:00
parent aa4dfb75ea
commit 8ecf8929fb
19 changed files with 836 additions and 59 deletions
+41
View File
@@ -35,6 +35,47 @@ func (r *fakeReporter) Report(ctx context.Context, _ *HostReport) (*ControlEnvel
return r.env, nil
}
// recordingObserver records the envelopes the loop hands it (slice 10A EnvelopeObserver seam).
type recordingObserver struct{ envs []*ControlEnvelope }
func (o *recordingObserver) OnEnvelope(_ context.Context, e *ControlEnvelope) { o.envs = append(o.envs, e) }
// The loop notifies the EnvelopeObserver once per successful cycle (with the envelope) AND still
// adopts PollIntervalSeconds — the two are independent.
func TestLoop_CycleNotifiesObserverAndAdoptsInterval(t *testing.T) {
var cn, rn int32
env := &ControlEnvelope{DesiredGeneration: 3, HasSignedOps: true, PollIntervalSeconds: intPtr(120)}
loop := NewLoop(
&fakeCollector{report: &HostReport{}, n: &cn},
&fakeReporter{env: env, n: &rn},
900*time.Second, quietLogger())
obs := &recordingObserver{}
loop.SetEnvelopeObserver(obs)
got := loop.cycle(context.Background(), 900*time.Second)
if len(obs.envs) != 1 || obs.envs[0].DesiredGeneration != 3 || !obs.envs[0].HasSignedOps {
t.Fatalf("observer envelopes = %+v, want 1 with gen 3 + has_signed_ops", obs.envs)
}
if got != 120*time.Second {
t.Errorf("poll interval = %v, want 120s adopted alongside the observer notify", got)
}
}
// On a report failure the observer is NOT notified (there is no envelope to act on).
func TestLoop_ReportErrorSkipsObserver(t *testing.T) {
var cn, rn int32
loop := NewLoop(
&fakeCollector{report: &HostReport{}, n: &cn},
&fakeReporter{env: &ControlEnvelope{}, errSeq: []error{errors.New("hub 5xx")}, n: &rn},
900*time.Second, quietLogger())
obs := &recordingObserver{}
loop.SetEnvelopeObserver(obs)
loop.cycle(context.Background(), 900*time.Second)
if len(obs.envs) != 0 {
t.Errorf("observer notified on a report error: %+v", obs.envs)
}
}
func TestClampInterval(t *testing.T) {
cases := []struct {
in int