Skip to content

GH-3519: Isolate agent start failures so one wedged agent doesn't skip its siblings - #3535

Merged
jeremydmiller merged 1 commit into
mainfrom
fix/3519-isolate-agent-start-failures
Jul 20, 2026
Merged

GH-3519: Isolate agent start failures so one wedged agent doesn't skip its siblings#3535
jeremydmiller merged 1 commit into
mainfrom
fix/3519-isolate-agent-start-failures

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Contributes to #3519

Scope

This PR contains the blast radius of the event-subscription startup race described in #3519. It does not claim to fix the underlying race (see "What this does not fix" below).

The bug

In DurabilityMode.Solo, NodeAgentController.startAllAgentsAsync iterated each agent family's agents and awaited StartAgentAsync per agent inside a single per-family try/catch:

foreach (var controller in _agentFamilies.Values)
{
    try
    {
        var allAgents = await controller.AllKnownAgentsAsync();
        foreach (var uri in allAgents)
        {
            await StartAgentAsync(uri);   // one throw here...
        }
    }
    catch (Exception e) { /* ...aborts the whole family loop */ }
}

When one agent's start throws — e.g. an event-subscription shard that loses a first-assignment startup race with high-water detection, surfacing as AgentStartingExceptionSystem.Exception: Unable to start a subscription agent for Identity: … — the throw breaks out of the family loop. Every sibling agent listed after the wedged one is never started that tick, and because the 30s reevaluation re-runs the same ordering, those siblings stay dead for the life of the process.

This matches the field report in #3519: "two of three shards dead on that boot; only one ran … its progression frozen while its store's high-water climbed thousands of events."

The fix

Isolate each agent's start in its own try/catch (log + continue) so a single failing agent only blocks itself:

  • AllKnownAgentsAsync() failure still logs and skips to the next family (unchanged semantics).
  • Each StartAgentAsync(uri) now catches independently — a wedged agent is logged and skipped, and its healthy siblings in the same family still start.
  • The wedged agent is still retried on the next reevaluation tick exactly as before; it just no longer takes its siblings down with it.

Test

CoreTests/Runtime/Agents/agent_start_isolation.cs builds a Solo-mode NodeAgentController with a fake family of three agents where the middle one throws on start, and asserts the sibling listed after the failing one still lands in Agents. Verified red before the change (fails on exactly that assertion) and green after.

What this does not fix

The reason the wedged shard fails to start in the first place is a startup race inside the JasperFx projection daemon (high-water detection not yet primed for a SubscribeFromPresent shard during the first assignment evaluation). That is tracked separately and still requires the CritterWatch multi-store repro to validate a root-cause fix. This PR is the defensive, independently-valuable half: the race can no longer wedge unrelated sibling agents on the node.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FKAxzuZ36VP6UPcTQf3MUs

…p its siblings

In DurabilityMode.Solo, startAllAgentsAsync iterated each family's agents and
awaited StartAgentAsync per agent inside a single per-family try/catch. When one
agent threw (e.g. an event-subscription shard that loses a first-assignment
startup race with high-water detection), the throw broke out of the family loop,
so every sibling agent listed after the wedged one was never started that tick --
and it repeated every 30s reevaluation. In the wild this showed up as "two of
three projection shards dead on that boot; only one ran."

Wrap each agent's StartAgentAsync in its own try/catch (log + continue) so a
single failing agent only blocks itself. The wedged agent is still retried on the
next tick; it just no longer takes healthy siblings down with it. Regression test
in CoreTests asserts a sibling listed after a failing agent still starts.

This contains the blast radius of the startup race; the underlying JasperFx
high-water start race (the reason the wedged shard fails at all) is tracked
separately and still requires the CritterWatch repro to validate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKAxzuZ36VP6UPcTQf3MUs
@jeremydmiller
jeremydmiller merged commit cb72ca0 into main Jul 20, 2026
30 checks passed
jeremydmiller added a commit that referenced this pull request Jul 20, 2026
… it (#3550)

Follow-up to GH-3535 (start-failure isolation) and GH-3520 (rebuild/rewind
resume). On multi-store, Wolverine-managed Marten hosts a projection shard can
start successfully and then die underneath its EventSubscriptionAgent wrapper --
a lost first-assignment start race that wedged daemon-side, a daemon-side stop,
or an execution-loop fault. Two gaps kept it dead for the process lifetime:

1. EventSubscriptionAgent.Status latched Running once started, so a shard that
   stopped underneath the wrapper kept reporting Running. NodeAgentController
   only restarts agents it can see are non-Running, so it saw nothing to fix.

2. NodeAgentController.StartAgentAsync short-circuited on a bare
   Agents.ContainsKey, treating any registered agent as healthy forever. The
   recurring Solo reevaluation therefore never resurrected a registered-but-dead
   agent -- it just sat in the observable 30s retry loop reporting a stale
   Running while its high-water climbed.

Fix both: Status now delegates to the live inner daemon agent once started (and
still reads Stopped before start / after an explicit stop), and StartAgentAsync
re-drives a registered agent whose shard has Stopped -- stopping it first to
release any lingering daemon-side shard state -- while leaving genuinely Running
and deliberately Paused (error backoff / blue-green gate) agents untouched.

Red-green regression test agent_restart_when_wedged in CoreTests. Also bumps to
6.22.0-alpha.3.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

1 participant