Skip to content

fix(resync): classify terminal refusals instead of retrying them - #440

Merged
schickling merged 1 commit into
schickling-assistant/2026-09-04-2026-09-04-resync-refusal-lock-starvationfrom
schickling-assistant/2026-09-04-resync-refusal-classification
Sep 4, 2026
Merged

fix(resync): classify terminal refusals instead of retrying them#440
schickling merged 1 commit into
schickling-assistant/2026-09-04-2026-09-04-resync-refusal-lock-starvationfrom
schickling-assistant/2026-09-04-resync-refusal-classification

Conversation

@schickling-assistant

@schickling-assistant schickling-assistant commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Stacked on #437. Its two commits are the first two in this branch; review only the third,
fix(resync): classify terminal refusals instead of retrying them. This does not compile without
#437 — the classified outcome travels the publication handoff that PR introduces.

Problem

emit_resync collapsed every publication error into one retryable failure:

Ok(_) => true,
Err(error) => { eprintln!("st2: resync emit for '{}' failed: {error:#}", ...); false }

The caller treats false as uniformly retryable, so it re-arms the reservation on the carrier's
class window — 500 ms for an immediate carrier — forever.

Several refusals are not transient. Eligibility is resolved under the shared catalog-authoring lock
precisely so that a suspension edit and a publication cannot interleave, which means a refusal
follows from the declaration rather than from timing. Retrying one is pure cost: emit_admitted
takes the shared lock and re-resolves the entire catalog through discover_strict before it
reaches the refusal, then discards the work. On the affected host in #431 that was ~2010 attempts
for each of three seats, against 4 for a seat whose task had already exited.

Goal

A refused publication is classified by what could admit it later, and only a refusal something
could still admit is retried.

Decisions

  • resolve_stream returns a typed refusal, so the classification comes from the reason rather
    than from matching a message. Every existing diagnostic keeps its exact text — several tests
    assert on those strings, and they are what an operator greps for.
  • Two kinds, not four. The distinction that matters to the worker is whether anything could
    admit the publication later. A not-running recipient can resume; ambiguity, a foreign owning
    host, and an undeclared stream cannot be resolved from the publisher's side.
  • A not-running recipient parks its reservation rather than dropping it. Dropping it would lose
    a resync the agent should see on resume. It is attempted once, then captures nothing and
    schedules nothing.
  • The reservation outlives its subscription. A refresh drops a suspended recipient's
    subscription entirely, so a reservation held only inside the subscription would not survive to
    the resume. It is retained per (recipient, binding) alongside the existing occurrence-sequence
    floor, which is the same worker-lifetime, bounded-cardinality retention.
  • Re-arming is driven by the refresh, not by a desired-state read. Every recipient a refresh
    carries is running by construction, so that is exactly the moment a parked reservation may be
    attempted again. The worker needs no new view of desired state.
  • A permanent refusal advances the carrier baseline. Otherwise "drop" would only mean
    "re-capture the same transition on the next observation and refuse again", which is the loop with
    extra steps.
  • An absent declaration stays retryable. A declaration being replaced by rename is briefly
    absent, and a catalog mid-edit is a transient state; dropping a reservation there would lose a
    resync nothing was wrong with. This is a deliberate exclusion from the permanent set, and the
    mutation matrix below covers it.

Verification

Fail-before — the assertions describe state this change introduces (a parked subscription and
its retained reservation), so fail-before is shown by mutating the classification away rather than
by checking out the parent commit. C1_suspended_refusal_untyped restores exactly the previous
behaviour — the refusal reaches emit_resync unclassified and takes the retry path — and three
tests fail. The behavioural assertion inside them, "attempted exactly once", is the pre-change
symptom directly: before this change the reservation is re-captured and handed off on every window.

Mutation — one shipped clause removed at a time, no survivors, no unrun mutants:

mutant caught by
suspended refusal untyped (the previous behaviour) a_not_running_recipient_parks… + 2
ambiguous refusal untyped a_permanently_refused…, refusals_are_classified…
outcome does not park a_not_running_recipient_parks…
permanent refusal not dropped a_permanently_refused…
flush does not skip a parked subscription a_not_running_recipient_parks…
polling does not skip a parked subscription a_not_running_recipient_parks…
mutation wakeup does not skip a parked subscription a_not_running_recipient_parks…
refresh does not restore the reservation a_parked_reservation_re_arms…
absent recipient classified as permanent refusals_are_classified… + 5

Two of these took a second pass and it is worth recording why, because both first attempts were
mutants that could not have failed. One left the park body attached to the mutated match arm, so it
still parked; the other did not compile, which the harness reported as UNRAN rather than as a
survivor. The third, "mutation wakeup does not skip a parked subscription", was a genuine survivor:
the test only observed the publication count, which the flush-path skip already keeps at one. It
now asserts the property the clause actually carries — a parked subscription schedules nothing —
checked separately at each wakeup source.

Suitecargo test -p st2 --lib: 684 passed, 0 failed. The exact Nix-check test selection
(--lib --bins --test discovery --test codex_hooks --test hooks --test run --test driver_expansion, --test-threads=1) is green, every target ok. --test resync,
--test event_e2e, --test resync_notify_chain, --test reconcile green, including the existing
tests that assert the refusal message text.

Complexity

One two-variant classification, one boolean on a subscription, and one retained-reservation map
that mirrors the existing occurrence-sequence retention next to it. No new dependency, no new
thread, no change to the publication path's locking or ordering.

Concerns

  • A parked subscription is silent until its recipient is carried by a refresh again. That is
    intended, and it is strictly better than the previous behaviour, where the same subscription was
    attempted twice a second and its reservation was dropped by the next refresh anyway. It does mean
    the reservation's delivery is bounded by the resume, not by a deadline.
  • The retained reservations are worker-lifetime state, bounded by the identities the worker has
    observed, like the occurrence-sequence floor beside them. A supervisor restart discards them,
    which is the same horizon RESYNC-T03 already sets for carrier state.
  • no agent found stays retryable on purpose. If a declaration is genuinely gone rather than
    mid-rename, its reservation is retried until the refresh drops the subscription. That is the
    pre-existing behaviour and this PR does not change it.
  • This does not restore reconcile-pass completion by itself and is not claimed to. fix(resync): publish off the reconcile pass's thread #437 owns
    that property; whether the pass stall of Terminal resync refusal is retried at 500 ms forever, re-resolving the catalog under the shared authoring lock and starving all writers #431 is fully explained remains open there.

Friction & bottlenecks

Follow-ups

References

Refs #431. Stacked on #437.

Posted on behalf of @schickling
field value
agent_identity dev3.compoundingtech.st2.resync-lock.worker
session dev3.1788d1eb
agent_persona worker
agent_supervisor dev3.compoundingtech-lead
agent_tool OMP
agent_tool_version 18.1.2
agent_runtime OMP 18.1.2
tooling_profile dotfiles@7534055

`emit_resync` collapsed every publication error into one retryable failure, so
a refusal the catalog will keep giving was re-armed on the carrier's class
window forever — 500 ms for an immediate carrier. Eligibility is resolved under
the shared catalog-authoring lock and a refusal follows from the declaration,
not from timing, so each attempt re-resolved the whole catalog to reach the same
answer and threw it away. On the affected host that was ~2010 attempts per seat
and a continuous stream of new shared lock acquisitions.

A refusal is now classified by what could admit it later. `resolve_stream`
returns a typed refusal, so the classification comes from the reason rather than
from matching a message.

A recipient that is declared but not running parks its reservation: attempted
once, then it captures nothing and schedules nothing. The reservation is
retained beyond the subscription, which the next refresh drops, and re-arms with
its exact reserved bytes when a refresh carries that recipient again — which
happens only while it is running. Dropping it at the refusal would lose a resync
the agent should see on resume.

An ambiguous recipient, a recipient owned by another host, and a recipient that
does not declare the stream are permanently refused: the reservation is dropped
after one diagnostic and the carrier baseline advances, so the same transition
is not captured again on the next observation. Everything else stays retryable,
including an absent declaration — a declaration being replaced by rename is
briefly absent, and dropping its reservation would lose a resync nothing was
wrong with.

This is the secondary hardening for #431. It removes the CPU burn and the
unbounded retry; the pass-completion property it used to deny is fixed
separately and independently.

Refs #431

agent-identity: dev3.compoundingtech.st2.resync-lock.worker
agent-persona: worker
agent-supervisor: dev3.compoundingtech-lead
agent-tool: OMP
agent-tool-version: 18.1.2
agent-runtime: OMP 18.1.2
tooling-profile: dotfiles@7534055
@schickling-assistant schickling-assistant changed the title schickling assistant/2026 09 04 resync refusal classification fix(resync): classify terminal refusals instead of retrying them Sep 4, 2026
@schickling-assistant
schickling-assistant marked this pull request as ready for review September 4, 2026 10:44
@schickling
schickling merged commit 9f6575d into main Sep 4, 2026
2 checks passed
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