Skip to content

fix(runnerhub): ignore lifecycle frames that arrive after ERRORED (RIG-4452) - #1774

Open
rigel-mintaka wants to merge 9 commits into
mainfrom
compass-runner/4452-errored-terminal-guard
Open

rigel-mintaka wants to merge 9 commits into
mainfrom
compass-runner/4452-errored-terminal-guard

Conversation

@rigel-mintaka

@rigel-mintaka rigel-mintaka commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

This PR is part of a stack containing 2 PRs:

  1. main
  2. "fix(runnerhub): ignore lifecycle frames that arrive after ERRORED (RIG-4452)" (this PR)
  3. fix(runnerhub): ERRORED cleanup releases only the binding it saw (RIG-4742) #1810

Summary

Server-side terminal guard for RIG-4452 (option B), keyed on RunnerSeq per RIG-4673 (option A).

  • Per-session lifecycle watermark: Hub.deliverSession keeps the highest accepted lifecycle RunnerSeq for each session, in a bounded LRU purged on re-enroll. Any lifecycle frame at or below it is dropped, ERRORED included. A frame buffered on a cancelled shared PublishEvents stream cannot republish an ERRORED session as live. A delayed old ERRORED cannot kill a resumed lifetime. Trace frames still relay.
  • Ordered admission: a lifecycle frame holds its session's lock from before recordSeq through publication. A lower seq therefore cannot overtake a higher one that is still mid-delivery. The lock is per session, so a slow binding lookup stalls only that session.
  • Runner-wide RunnerSeq: container Gateways now share one gateway.SeqCounter (Deps.Seq, owned by agentHost). Before this, a resumed session's new socket restarted at 1, which broke the proto's per-Runner contract.
  • Enrollment fence: Hub.enrollMu serializes delivery against enroll, and generations start at 1. PublishEvents and Sessions stamp the generation at stream open, and Sessions reads its router and generation as one pair. Frames from an older or pre-enroll stream are dropped. ERRORED's lost-session cleanup skips if a re-enroll has happened since.
  • Gap tracking: separate streams can deliver shared seqs out of order, so recordSeq tracks skipped seqs (bounded; overflow stays a gap until re-enroll). A late arrival closes its gap.

Rebase onto #1756

Hub.enroll now takes enrollMu, then bindingWriteMu (from #1756), then mu. enrollMu is released once the router is installed; bindingWriteMu stays held through the durable reap. Lock order: enrollMu, bindingWriteMu, a session lock, lifecycleMu, mu. Promotion never runs under a session lock, so the order holds.

Follow-up

  • RIG-4742: ERRORED cleanup can still unbind a resume re-bound in the same enrollment. This bug already exists on main.

Verification

  • Every new test was checked to fail with its fix removed:
    • TestStaleStateAfterErroredIsIgnored, which also checks the settle and presence edges.
    • TestNewLifetimeStateAfterErroredPublishes
    • TestErroredOlderThanNewLifetimeIsIgnored
    • TestLowerSeqCannotOvertakeHigherSeqMidDelivery
    • TestSlowBindingLookupStallsOnlyItsSession
    • TestReenrollClearsErroredBoundary
    • TestReenrollWaitsForInFlightErrored
    • TestLostSessionCleanupFromOldEnrollmentKeepsRebinding
    • TestFramesFromStreamBeforeReenrollAreDropped
    • TestSeamPublishEventsOpenedBeforeEnrollIsFenced
    • TestDeliverSequenceGapDetection (late-arrival, re-enroll and overflow subtests)
    • TestSequenceSharedAcrossGateways
  • The guard tests pass at -count=100.
  • go test -race passes for ./server/, ./internal/runner/... and ./internal/runnerhub/.
  • golangci-lint reports 0 issues for internal/runnerhub and internal/runner/....

Spec-impact: none (server-internal ordering and diagnostics; no API or documented state change).
Ledger-impact: none

@trunk-io

trunk-io Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

❌ This stack could not start testing because there was a merge conflict. See more details here.

  • To merge this pull request, check the box to the left or comment /trunk merge below.

After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here

@linear-code

linear-code Bot commented Oct 6, 2026

Copy link
Copy Markdown

RIG-4452

@github-actions

github-actions Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Compass engineering docs preview: https://compass-runner-4452-errored.compass-eng-docs.pages.dev

Deployed from compass-runner/4452-errored-terminal-guard at ca21ef3.

@rigel-mintaka
rigel-mintaka force-pushed the compass-runner/4452-errored-terminal-guard branch from cb0334a to 52241ed Compare October 6, 2026 20:02
@rigel-mintaka
rigel-mintaka marked this pull request as ready for review October 6, 2026 22:35
@mattwilkinsonn
mattwilkinsonn added this pull request to stack #1824 October 7, 2026 00:25
@rigel-mintaka
rigel-mintaka force-pushed the compass-runner/4452-errored-terminal-guard branch from 73feb00 to eb4718e Compare October 7, 2026 03:00
rigel-mintaka and others added 9 commits October 6, 2026 23:50
…G-4452)

When the Runner's shared-stream ERRORED send stalls, it falls back to a
one-shot stream. Frames still buffered on the cancelled shared stream can
then be delivered after ERRORED and republish the session as live.

The hub now marks a session ERRORED and drops its later lifecycle frames.
A lifecycle lock serializes the guard with the status publish, so an
in-flight frame cannot publish after ERRORED. A recovery command (resume
Start, Reload) clears the mark under the same lock as the command's queue
admission, so a command that never reached the Runner leaves it set. A
re-enroll clears all marks. Trace frames still relay.

Co-authored-by: Matt Wilkinson <matt@rigel.build>
…ssion (RIG-4673)

A recovery command clearing the guard at admission left a window before the
Runner ran it, in which a dead-lifetime frame could still publish. The hub now
records ERRORED's RunnerSeq per session and drops lifecycle frames at or below
it. RunnerSeq is Runner-wide and monotonic, so new-lifetime frames pass on
their own; re-enroll resets the counter and clears the map. The recovery-
admission path (relayRecovery, router admit) is removed.

Co-authored-by: Matt Wilkinson <matt@rigel.build>
…ollment (RIG-4673)

Each container socket built its own Gateway counter, so a resumed session's
new socket restarted RunnerSeq at 1 and fell under the hub's ERRORED
boundary. agentHost now passes one SeqCounter to every Gateway, matching the
proto's per-Runner contract. The hub also stamps an enrollment generation, so
an ERRORED delivery paused across a re-enroll cannot reinstall a boundary the
re-enroll cleared. The stale-frame test now covers settle and presence edges.

Co-authored-by: Matt Wilkinson <matt@rigel.build>
…eqs close gaps (RIG-4673)

PublishEvents now stamps the hub's enrollment generation when the stream
opens. A session frame from an older stream is dropped before the tail relay,
lifecycle edges, and ERRORED's lost-session cleanup, so a resumed session
cannot be unbound by its dead process's late ERRORED.

Container Gateways share one RunnerSeq counter on separate streams, so a lower
seq can arrive after a higher one. The hub now tracks skipped seqs (bounded)
and SeenGap reports only those still unseen.

Co-authored-by: Matt Wilkinson <matt@rigel.build>
…cking per enrollment (RIG-4673)

An RWMutex now fences session-frame delivery (read) against enroll (write), so
the generation check, tail relay, and lifecycle edges cannot straddle a
re-enroll. ERRORED's detached cleanup carries its enrollment generation and
skips if a re-enroll has happened, so it cannot unbind a re-bound session.
Re-enroll resets the RunnerSeq gap tracker, and stale-stream events no longer
feed it.

Co-authored-by: Matt Wilkinson <matt@rigel.build>
…est (RIG-4673)

Separate streams can deliver one session's frames out of seq order, so an
ERRORED at seq 5 could land after the resumed READY at 6 and retire it. The
hub now keeps the highest accepted lifecycle seq per session and drops any
older lifecycle frame, ERRORED included, before the tail relay. Deliver holds
the enrollment read lock for the whole event, so a re-enroll cannot reset
sequence state between the generation check and its use.

Co-authored-by: Matt Wilkinson <matt@rigel.build>
Co-authored-by: Matt Wilkinson <matt@rigel.build>
…generation (RIG-4673)

A lifecycle frame now takes lifecycleMu before its seq is recorded, so a lower
seq cannot overtake a higher one that is still mid-delivery. Sessions reads its
router and enrollment generation as one pair, and enroll holds the write lock
until the new router is installed. Generations start at 1, so a PublishEvents
stream opened before the first Enroll is fenced too. lifecycleSeqs becomes a
bounded LRU. ERRORED cleanup's generation check is a synchronous helper with
its own test.

Co-authored-by: Matt Wilkinson <matt@rigel.build>
…(RIG-4673)

A cold binding lookup ran under the hub-wide lifecycleMu, so one slow store
read stalled lifecycle delivery for every session. Each session now has its
own refcounted lock held from seq record through publication. lifecycleMu
only guards the seq LRU and the lock map.

Co-authored-by: Matt Wilkinson <matt@rigel.build>
@rigel-mintaka
rigel-mintaka force-pushed the compass-runner/4452-errored-terminal-guard branch from eb4718e to ca21ef3 Compare October 7, 2026 03:56
@trunk-io

trunk-io Bot commented Oct 8, 2026

Copy link
Copy Markdown

This pull request is queued for merge as part of 1810, which will merge 1774, 1810.

@trunk-io

trunk-io Bot commented Oct 8, 2026

Copy link
Copy Markdown

Stacked PR 1810 failed testing in the merge queue. Please investigate the failure and re-submit the stack.

This branch has not been deployed

No deployments
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