Skip to content

test(e2e): gate the dogfood fixture on runner enrollment - #270

Merged
mattwilkinsonn merged 3 commits into
mainfrom
seal-h8-enroll-gate
Aug 12, 2026
Merged

test(e2e): gate the dogfood fixture on runner enrollment#270
mattwilkinsonn merged 3 commits into
mainfrom
seal-h8-enroll-gate

Conversation

@seal-agent

@seal-agent seal-agent commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

This PR is part of a stack containing 2 PRs:

  1. main
  2. "test(e2e): gate the dogfood fixture on runner enrollment" (this PR)
  3. ci(e2e): wire the dogfood full-stack e2e suite as a per-PR gate #256

stack.Up returns as soon as the compass-runner child is spawned, but the runner enrolls with the server asynchronously over the TLS door after Up returns. A leg that Provisions immediately therefore races that enrollment and fails unavailable: no runner enrolled to serve session. TestHarnessCore only passed because two incidental authed RPCs between NewFixture and its first Provision gave the runner time to enroll — masking the race rather than closing it.

This adds waitRunnerEnrolled, the enrollment counterpart to the stack's own waitReady/waitPostgres: a bounded, event-gated readiness poll that returns the instant the runner is enrolled and errors legibly on a wedged enrollment. Enrollment is a monotonic one-time transition, so the observable cross-process signal is that an enrollment-gated relay stops returning the no-runner error — the probe issues StopAgentSession on a synthetic session id (relays through routerFor exactly as Provision does; an idempotent runner-side no-op once enrolled, with no container or session side effect), treating only that specific CodeUnavailable/no runner enrolled condition as not-yet-ready and surfacing any other error immediately. It is wired into NewFixture's post-Up readiness so every Provisioning leg starts against an enrolled runner. Not a sleep and not a retry-as-sync — the same readiness idiom waitReady/waitPostgres already use.

Placement is the fixture, not stack.Up: the stack has no in-process enrollment signal (no authed CompassService client, and GetServerInfo does not report runner presence), whereas the fixture already holds the authed client. Putting it in the stack would duplicate CA-trust/admin-token/authed-client construction into production CLI code.

Verified against the real agent image (compass-agent:latest, podman 5.8.4): TestLegTwoPrimitives red -> green (was Provision: unavailable: no runner enrolled, now PASS) and TestHarnessCore still green (no regression).

Spec-impact: none.

Co-authored-by: Matt Wilkinson matt@sealedsecurity.com

seal-agent and others added 3 commits August 10, 2026 16:11
`stack.Up` returns as soon as the compass-runner child is spawned, but the runner enrolls with the server asynchronously over the TLS door *after* `Up` returns. A leg that Provisions immediately therefore races that enrollment and fails `unavailable: no runner enrolled to serve session`. `TestHarnessCore` only passed because two incidental authed RPCs between `NewFixture` and its first Provision gave the runner time to enroll — masking the race rather than closing it.

This adds `waitRunnerEnrolled`, the enrollment counterpart to the stack's own `waitReady`/`waitPostgres`: a bounded, event-gated readiness poll that returns the instant the runner is enrolled and errors legibly on a wedged enrollment. Enrollment is a monotonic one-time transition, so the observable cross-process signal is that an enrollment-gated relay stops returning the no-runner error — the probe issues `StopAgentSession` on a synthetic session id (relays through `routerFor` exactly as Provision does; an idempotent runner-side no-op once enrolled, with no container or session side effect), treating only that specific `CodeUnavailable`/`no runner enrolled` condition as not-yet-ready and surfacing any other error immediately. It is wired into `NewFixture`'s post-`Up` readiness so every Provisioning leg starts against an enrolled runner. Not a sleep and not a retry-as-sync — the same readiness idiom `waitReady`/`waitPostgres` already use.

Placement is the fixture, not `stack.Up`: the stack has no in-process enrollment signal (no authed CompassService client, and GetServerInfo does not report runner presence), whereas the fixture already holds the authed client. Putting it in the stack would duplicate CA-trust/admin-token/authed-client construction into production CLI code.

Verified against the real agent image (`compass-agent:latest`, podman 5.8.4): `TestLegTwoPrimitives` red -> green (was `Provision: unavailable: no runner enrolled`, now PASS) and `TestHarnessCore` still green (no regression).

Spec-impact: none.

Co-authored-by: Matt Wilkinson <matt@sealedsecurity.com>
Round-1 review of the enrollment gate (medium + two lows). Additive, no
behavior change on the happy/not-ready paths (re-verified green:
TestLegTwoPrimitives + TestHarnessCore against the real agent image).

- Extract the pure error-classification out of runnerEnrolledProbe into a
  free classifyEnrollProbe(err) (ready, retry bool, cerr error) and unit-test
  its four branches (enrolled / not-yet-enrolled retry / real error surfaced /
  wrong-message CodeUnavailable surfaced-not-retried) without a live client
  (TestClassifyEnrollProbe) — the failure branches were previously reachable
  only through a full real-stack run.
- Add an injectable now func() time.Time clock seam to the Fixture (default
  time.Now), used by waitRunnerEnrolled's deadline, mirroring the sibling
  stack waitReady/waitPostgres s.deps.now() seam so the budget-timeout branch
  is deterministically testable.
- Bound each probe by min(rpcTimeout, remaining-budget) so enrollPollBudget is
  an honest ceiling on waitRunnerEnrolled's total runtime.
- Note the load-bearing "no runner enrolled" cross-package coupling at the
  probe (const-centralization in runnerhub tracked as SEA-1948).

Spec-impact: none

Co-authored-by: Matt Wilkinson <matt@sealedsecurity.com>
…ranch

Round-2 review of the enrollment-probe fix found the injectable clock
seam was only half-wired: waitRunnerEnrolled computed the deadline and
its budget check through f.now(), but runnerEnrolledProbe still bounded
each probe with time.Until(deadline) (the real wall clock). A test that
fast-forwards the fake clock past the deadline would diverge from
time.Until, drive the per-probe budget to the 1ms floor, and cut off a
live StopAgentSession probe with a DeadlineExceeded surfaced as a fatal
error — instead of the clean "did not enroll within" budget message the
seam exists to make testable.

- Wire the seam into the per-probe bound: deadline.Sub(f.now()) in place
  of time.Until(deadline), so the probe honors the injected clock exactly
  as the loop does.
- Reorder waitRunnerEnrolled to check the budget at the top of the loop
  before probing. The budget is now reported without a final, doomed
  probe firing against an expired deadline (the round-2 LOW), and the
  timeout branch is reachable through the fake clock with no live client.
  Live semantics are preserved: at t~0 the budget check trivially passes
  so the first probe still fires immediately; enrolled and not-ready paths
  are unchanged.
- Add TestWaitRunnerEnrolledBudgetTimeout: drives the budget-timeout
  branch through the now seam on a bare Fixture and asserts the canonical
  message — the missing timeout-branch coverage.
- Add a context.DeadlineExceeded case to TestClassifyEnrollProbe pinning
  that a non-connect error is surfaced-not-retried with the probe prefix.

Co-authored-by: Matt Wilkinson <matt@sealedsecurity.com>
@seal-agent
seal-agent force-pushed the seal-h8-enroll-gate branch from 95f3110 to dc1a4c7 Compare August 10, 2026 20:12
@mattwilkinsonn
mattwilkinsonn merged commit 062ce01 into main Aug 12, 2026
2 of 3 checks passed
@mattwilkinsonn
mattwilkinsonn deleted the seal-h8-enroll-gate branch August 12, 2026 19:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants