Skip to content

Lite re-fires a standing AG replica disconnect (#2426) - #2440

Merged
erikdarlingdata merged 2 commits into
devfrom
feature/2426-lite-ag-disconnect-refire
Aug 21, 2026
Merged

erikdarlingdata merged 2 commits into
devfrom
feature/2426-lite-ag-disconnect-refire

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Aug 21, 2026 •

Copy link
Copy Markdown
Owner

Closes #2426.

AG Replica Disconnected is a pure edge in Lite, so a replica that goes down and stays down is announced exactly once. A week-long outage reads identically to a momentary blip in the alert history, and no setting in the app changes that. Darling has had ag_disconnect_refire_minutes since #1696; #1726 mirrored three of Darling's four AG knobs into Lite and stopped, correctly at the time, because Darling's fourth landed later.

This was not supply-a-setting

AgAlertPolicy is already shared, which makes it tempting to read the gap as a missing config key. It is not. The file's own discipline is that pure decisions live in the policy while each app owns its edge STATE and its delivery, and a re-fire is made of exactly the two halves Lite did not have: a clock per ag+replica, and something that consumes it. The setting is the small part.

What moved into the shared policy, and why

Darling computed "still disconnected" as a five-clause expression sitting next to its dictionaries. Writing Lite's copy of that expression would have been the second one, and the clauses with sharp corners are not the arithmetic — they are that a re-fire must never double up with the edge that just fired, and that an unrecognized connected_state_desc must not become a standing page any more than it may become a single one. Both apps now call one DecideConnection overload that returns StillDisconnected; what stays app-side is what genuinely is app-shaped, the stamp and the delivery it is stamped on.

That is also the answer to the ConnectionRefireMinutes question the issue left open. The two re-fires are not one mechanism and are not merged — one is per server off a live connection probe, the other per ag+replica off a collected snapshot. But the WINDOW is the same three-line predicate carrying the same two rules that are easy to misremember: a non-positive interval is off, and a missing stamp is due now rather than never-announced-so-stay-quiet. That second rule is what carries a standing outage across a restart, and it was about to be written a third time, so it is AlertRefireWindow and ConnectionAlertPolicy calls it too.

The one deliberate departure from rule 1

With re-fire ON, a replica already disconnected the first time it is seen announces rather than baselining silently. Both apps hold this state in memory, so every restart re-baselines; under the silent-baseline rule alone, a restart during a week-long outage would silence it permanently — the opt-in would fail in precisely the case it exists for. Darling has behaved this way since #1696. Stating it in the shared policy is the change, not the behavior.

Delivery-stamped, on purpose

Lite's stamp is the caller's, beside the send, matching the split its connection re-fire already uses. Lite evaluates AG health on every sweep and skips delivery while a server is acknowledged or silenced, so stamping at decision time would spend window after window on alerts nobody received, and the operator would come back from an acknowledgement to silence rather than to a re-announcement.

The #2417 exemption comes out with it

ag.disconnect_refire_minutes was exempted by name in McpAlertSettingsKeyTests with a test asserting both halves of the omission. Both halves are now asserted the other way round: Darling still emits it, and Lite emits it too, carrying its own live value rather than the constant 0 that was rejected for telling an agent it can tune something the app cannot. LiteOmittedMembers is empty, and the emptiness is asserted in the parity test rather than merely being true, so the next exemption still has to be justified in that file.

Verification

The Windows suites cannot run on macOS, so the real test files were compiled into a net10.0 xUnit-shim harness against the real projects.

  • Most of the new tests cannot be expressed against dev at all. Compiling them against dev's sources fails naming StillDisconnected, the five-argument DecideConnection, the three-argument EvaluateReplicas and NoteDelivered as missing.
  • One IS expressible and is red on dev: the review-driven assertion that Darling's re-fire says so in the DetailText it actually stores. Against dev that reads the generic disconnect notice, byte-identical to the first announcement.
  • 66 cases pass against this branch (AgAlertPolicyTests, AgAlertEvaluatorTests, ConnectionAlertPolicyTests), plus Darling's 36 AG self-alert tests.
  • Both behavior-preservation claims were checked by running the UNCHANGED suites on each branch through the same harness. Darling's 36 AG self-alert tests (DarlingSelfAlertTests) and the 11 ConnectionAlertPolicy tests produce byte-identical results either side of the refactor.

SettingsSampleTests is satisfied: ag_disconnect_refire_minutes is documented in Lite/config/settings.sample.json beside the AG knobs it belongs with.

Review round

claude[bot] found a parity drift this PR introduced, and it was real. Darling has branched only its shortMessage on the AG re-fire since #1696, and shortMessage is the interactive toast body — DarlingAlertDeliverer forwards DetailText into SendAndRecordAsync, and EmailTemplateBuilder never consumes ShortMessage — so the history row and the email for the sixth re-announcement of a week-long outage read byte-identical to the first notice. Lite's new evaluator puts it in the detail because the detail is all Lite has, so the two SKUs would have disagreed about the same condition under the same metric name. Darling's own connection re-fire has baked it into detail since #1659, so the AG side was inconsistent with its own sibling too. Fixed, worded to match Lite word for word so both apps store identical text, and pinned in both directions.

🤖 Generated with Claude Code

@claude

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown

Reviewed. This is a clean, well-tested piece of work — traced the new logic carefully and didn't find a correctness or parity bug worth flagging.

Specifically verified:

  • AgAlertPolicy.DecideConnection (5-arg overload) against its own test matrix: rule 1 (silent first-sighting) still holds at the shipped default (refire off), a real edge always outranks a refire, IsDisconnected stays an exact match so an unrecognized state string can't become a standing page, and the "first sighting while already down announces with re-fire ON" departure is deliberate and consistent with ConnectionAlertPolicy's existing AlreadyDownAtFirstSight precedent.
  • AlertRefireWindow extraction — behaviorally identical to the inline predicate it replaces in ConnectionAlertPolicy.Decide (non-positive interval = off, null stamp = due now).
  • Darling wiring (DarlingSelfAlertEvaluator.cs) — stamps _lastAgDisconnectAlert on delivery (after FireAsync), clears it on Reconnected. Matches the "stamp on delivery, not decision" discipline stated in the new shared code.
  • Lite wiring (AgAlertEvaluator.cs + MainWindow.AlertEngine.cs) — the RefireStampKey/NoteDelivered split correctly opens the window only on actual delivery; the suppressed (acknowledged/silenced) path skips NoteDelivered entirely for every alert in the batch, so an acknowledged server can't have its window silently consumed. Clearing the clock at the Reconnected decision rather than at delivery is safe because the next disconnect after a reconnect is always a fresh Disconnected edge, which never consults the window.
  • Settings clamp parity — Lite's new 0-1440 clamp in App.xaml.cs/SettingsWindow.xaml.cs matches Darling's DarlingAlertSettings.AgDisconnectRefireMinutes clamp exactly, and the "unparseable box keeps the current value" behavior matches Lite's existing convention for the sibling AgLagAlertSecondsBox/AgRedoQueueAlertKbBox fields (Darling's own convention differs — falls back to the shipped default — but that split predates this PR).
  • McpAlertSettingsKeyTests — the inverted exemption (LiteOmittedMembers now empty, asserted empty) correctly closes out the Darling's MCP alert payload drops six settings it reads, and emits one key its writer rejects #2417 gap, and the new value-assertion test (GetAlertSettings_ReportsAgDisconnectRefire_WithLitesOwnValue) checks the live value round-trips rather than just key presence.
  • Muted-alert delivery still stamps the refire clock in both apps (mute suppresses channels, not the FireAsync/SendAgAlert call) — this is pre-existing behavior in both apps (Darling's FireAsync already worked this way), not a new asymmetry introduced here.

No missing-index-DMV concerns (none present), no T-SQL in this diff, no secrets/injection surface touched.

"AG Replica Disconnected" is a pure edge in Lite, so a replica that goes down
and stays down is announced exactly once. In an alert history a week-long
outage then reads identically to a momentary blip, and there is no setting
anywhere in the app that changes that. Darling has had the answer since #1696
-- ag_disconnect_refire_minutes, 0 = off, clamped 0-1440 -- and #1726 mirrored
three of Darling's four AG knobs into Lite and stopped, which was correct at
the time because Darling's fourth landed later.

The AG POLICY is already shared, so it would be easy to read this as
supply-a-setting. It is not. AgAlertPolicy holds the pure decisions and each
app deliberately owns its own edge STATE and its own delivery, and a re-fire
is made of exactly the two halves Lite did not have: a clock per ag+replica,
and something that consumes it. The setting is the small part; the state is
the feature.

The decision itself belongs in the shared policy rather than beside each app's
state, and that is the one structural change here. Darling computed "still
disconnected" as a five-clause expression next to its dictionaries; Lite's
copy of that expression would have been the second one, and the clauses with
sharp corners are not the arithmetic -- they are that a re-fire must never
double up with the edge that just fired, and that an unrecognized
connected_state_desc must not become a standing page any more than it may
become a single one. Both apps now call one DecideConnection overload
returning StillDisconnected, and what stays app-side is what genuinely is
app-shaped: the stamp, and the delivery it is stamped on.

That is also the answer to the question the issue left open about
ConnectionRefireMinutes. The two re-fires are not one mechanism and should not
be merged -- one is per server off a live connection probe, the other per
ag+replica off a collected snapshot -- but the WINDOW is the same three-line
predicate carrying the same two rules that are easy to misremember: a
non-positive interval is off, and a missing stamp is due NOW rather than
never-announced-so-stay-quiet. The second is what carries a standing outage
across a restart, and it was about to be written a third time, so it is
AlertRefireWindow now and ConnectionAlertPolicy calls it too.

One departure from the AG family's first-sighting rule is deliberate, and is
documented where the rule itself is stated: with re-fire ON, a replica already
disconnected the first time it is seen announces rather than baselining
silently. Both apps hold this state in memory, so every restart re-baselines,
and under the silent-baseline rule alone a restart during a week-long outage
would silence it permanently -- the opt-in would fail in precisely the case it
exists for. Darling has behaved this way since #1696; stating it in the shared
policy is the change, not the behavior.

Lite's stamp is the caller's, beside the send, the same split its connection
re-fire already uses. That is load-bearing rather than stylistic: Lite
evaluates AG health on every sweep and skips delivery while a server is
acknowledged or silenced, so stamping at decision time would spend window
after window on alerts nobody received, and the operator would come back from
an acknowledgement to silence rather than to a re-announcement.

The #2417 exemption comes out with it. ag.disconnect_refire_minutes was
exempted by name in McpAlertSettingsKeyTests with a test asserting both halves
of the omission; both halves are now asserted the other way round -- Darling
still emits it, and Lite emits it too, carrying its own live value rather than
the constant 0 that was rejected for telling an agent it can tune something
the app cannot. LiteOmittedMembers is empty, and the emptiness is asserted
rather than merely true, so the next exemption still has to be justified here.

Verified by compiling the real test files into a net10.0 xUnit-shim harness.
The new tests cannot be expressed against dev at all -- the compiler names
StillDisconnected, the five-argument DecideConnection, the three-argument
EvaluateReplicas and NoteDelivered as missing -- which is the stronger
statement. 66 cases pass against this branch. Both behavior-preservation
claims were checked by running the UNCHANGED suites on each branch through the
same harness: Darling's 36 AG self-alert tests and the 11 ConnectionAlertPolicy
tests produce byte-identical results either side of the refactor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@erikdarlingdata
erikdarlingdata force-pushed the feature/2426-lite-ag-disconnect-refire branch from 9326309 to e21f26f Compare August 21, 2026 14:54
_utcNow());
_agReplicaConnectedState[key] = replica.ConnectedStateDesc;

bool stillDisconnected = connection == AgConnectionDecision.StillDisconnected;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lite/Darling parity drift introduced by this PR, even though the lines it lands in (868-880, just below this hunk) aren't themselves touched here.

Lite's new AgAlertEvaluator.EvaluateReplicas branches DetailText on decision == AgConnectionDecision.StillDisconnected, embedding "STILL DISCONNECTED ... (re-alerting every N min)" directly in the text that gets persisted to alert history and shown in the Alert Detail window.

Darling's FireAsync call just below only branches shortMessage on stillDisconnected (line ~875) — detail (line ~868) stays the generic "is DISCONNECTED from the primary..." string regardless of whether this is the first edge or the 6th re-fire. And shortMessage never reaches persisted storage: DarlingAlertDeliverer.DeliverAsync forwards outcome.DetailText (not ShortMessage) into SendAndRecordAsync → PgAlertHistoryStore, and EmailTemplateBuilder.BuildAlertEmail doesn't consume ShortMessage either (it's the interactive-host toast body per its own doc comment).

Net effect: an operator opening Darling's Alert Detail window for a re-fired "AG Replica Disconnected" alert sees byte-identical text to the very first disconnect notice — no indication it's a re-fire, no interval — while Lite's equivalent view now says so explicitly. That's exactly the "week-long outage reads identically to a momentary blip" problem this PR set out to fix, still open on the Darling side of the exact same feature.

Worth noting this is also inconsistent with Darling's own established pattern for the connection re-fire a few dozen lines above (ConnectionAlertDecision.StillDown => $"Still unreachable (re-alerting every {_connectionRefireMinutes()} min): {reason}"), which does bake the re-fire status into detail.

Suggest branching detail here the same way, e.g. appending $" (re-alerting every {refireMinutes} min)" when stillDisconnected is true.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified all three legs and you are right on each: DeliverAsync forwards outcome.DetailText (never ShortMessage) into SendAndRecordAsync, EmailTemplateBuilder does not consume ShortMessage at all, and the connection re-fire a few hundred lines up already bakes it into detail. So Darling has been storing a re-fire that reads identically to the first notice since #1696, in the one view an incident actually gets reconstructed from — and this PR would have made Lite say it while Darling did not.

Fixed in the latest commit. The opening sentence now branches, worded to match Lite word for word, and the guidance after it is unchanged — so both SKUs store identical text for the edge and for the re-fire. shortMessage keeps its own wording.

Pinned in DarlingSelfAlertTests in both directions (the re-fire says it and names the interval; the opening edge does not), and that assertion is expressible against dev and red there: the re-fire's DetailText on dev is the generic disconnect notice.

@claude

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown

Review summary

Reviewed the AG-disconnect-refire feature (Lite catching up to Darling's #1696 knob, shared decision moved into AgAlertPolicy/new AlertRefireWindow). No T-SQL in this diff, so the T-SQL style section doesn't apply.

What checks out:

  • The new AgAlertPolicy.DecideConnection(previous, current, refireInterval, lastAlertUtc, now) overload correctly preserves the 2-arg overload's edge-only behavior at refireInterval null/zero/negative (tested), correctly treats a real edge as outranking a due re-fire, correctly treats an unrecognized connected_state_desc as never "due," and correctly departs from the silent-first-sighting rule only when re-fire is on (all covered by matching test matrices added to both Darling.Tests and Lite.Tests).
  • AlertRefireWindow.IsDue extraction is a faithful, behavior-preserving refactor of ConnectionAlertPolicy's inline predicate (verified against the pre-refactor logic line by line).
  • Lite's NoteDelivered/RefireStampKey split (stamp on delivery, not on decision) correctly mirrors the existing connection-re-fire delivery split, and Forget/Reconnected clear the clock correctly so a later outage doesn't inherit a stale window.
  • Clamping (0-1440), settings.json round-trip, Settings window wiring, and the McpAlertSettingsKeyTests/McpAlertTools payload-shape update to close the #2417 exemption all look correct and consistent with Darling's shape.
  • The one place I found where "delivered" and "actually sent" diverge (a per-metric mute rule suppressing the email/webhook while still stamping the re-fire window) is a pre-existing pattern this PR mirrors rather than introduces — Darling's own connection-alert and self-alert FireAsync do the same thing, so I didn't flag it as new.

One parity issue flagged inline: Lite's new code embeds the "STILL DISCONNECTED (re-alerting every N min)" wording into DetailText, which is what actually gets persisted to alert history / shown in the Alert Detail dialog. Darling's equivalent FireAsync call only branches the display-only shortMessage (which never reaches storage — DarlingAlertDeliverer forwards DetailText, not ShortMessage), leaving detail generic for both first-disconnect and every re-fire. So on the Darling side, this PR's own goal ("a week-long outage doesn't read like a blip in the alert history") isn't actually achieved yet — see the inline comment on DarlingSelfAlertEvaluator.cs.

Darling's AG disconnect re-fire has branched its shortMessage since #1696 and
its detail never. ShortMessage is the interactive toast body: it reaches
neither the history row nor the email, because DarlingAlertDeliverer forwards
DetailText into SendAndRecordAsync and EmailTemplateBuilder does not consume
ShortMessage at all. So an operator opening Alert Detail on the sixth
re-announcement of a week-long outage read text byte-identical to the first
notice - which is exactly the "a week-long outage reads like a momentary blip"
problem the knob exists to end, still standing in the one view an incident is
actually reconstructed from.

Review found it as a parity drift this PR introduced, and that is the fairer
framing: Lite's new evaluator puts it in the detail because the detail is all
Lite has, so the two SKUs' history rows would have disagreed about the same
condition under the same metric name. Darling's own CONNECTION re-fire has
baked it into detail since #1659 ("Still unreachable (re-alerting every N
min)"), so the AG side was also inconsistent with its own sibling.

The opening sentence now branches, worded to match Lite's word for word, and
the guidance after it is unchanged - so the two apps' stored text is identical
for both the edge and the re-fire. The assertion that pays for it is in
DarlingSelfAlertTests and is expressible against dev, where it is red: the
re-fire's DetailText there is the generic disconnect notice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@claude

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown

Reviewed the diff against CONTRIBUTING.md (no T-SQL here, so the style-guide sections don't apply) with a focus on correctness, Lite/Darling parity, and security/perf. Summary: this looks solid — no blocking issues found.

What I checked closely:

  • Shared policy correctness (AgAlertPolicy.DecideConnection 5-arg overload, AlertRefireWindow.IsDue): traced every branch — real edges (Disconnected/Reconnected) always win over a due refire window, an unrecognized connected_state_desc never becomes a standing re-fire, a non-positive interval is byte-identical to the old 2-arg edge-only behavior, and a null last-alert stamp correctly reads as "due now" rather than "never announce." Matches the extensive AgAlertPolicyTests matrix in both Darling.Tests and Lite.Tests (diffed the two files — identical except namespace/doc self-references).
  • Stamp-on-delivery discipline: both DarlingSelfAlertEvaluator (_lastAgDisconnectAlert[key] = _utcNow() after FireAsync) and Lite's AgAlertEvaluator.NoteDelivered/MainWindow.AlertEngine (NoteDelivered called after SendAgAlert, but only reached when not suppressed) correctly avoid consuming a re-fire window on an alert that was suppressed by the master switch/ack/silence. The Reconnect clock-clear happening at decision-time rather than delivery-time in Lite is intentional and documented, and is safe because the next Disconnected edge always announces regardless of window state.
  • Parity surface: App.AgDisconnectRefireMinutes is wired through settings.json load/save (clamped 0–1440, matching Darling's clamp), the Settings window textbox (load/save), settings.sample.json, and get_alert_settings's ag group — all consistent with the sibling ag_lag_alert_seconds/ag_redo_queue_alert_kb wiring. McpAlertSettingsKeyTests correctly flips from an omission-exemption to an equality assertion, and pins LiteOmittedMembers empty.
  • The review-round fix already in this PR (Darling's DetailText now says "STILL DISCONNECTED"/"re-alerting every N min" instead of only ShortMessage) is correct and the wording is byte-identical to Lite's, which is what keeps the two SKUs' history rows readable the same way.
  • Confirmed the Full Dashboard has no equivalent AG self-alert re-fire concept, so no third app needed updating.

Nothing else stood out — no null-handling gaps, no thread-safety issues (Darling's _lastAgDisconnectAlert is a ConcurrentDictionary, matching its concurrent fleet-sweep model), and no security/injection surface here (pure in-memory alert-decision logic, no new SQL/network/file/process code).

@erikdarlingdata
erikdarlingdata merged commit 2060ab7 into dev Aug 21, 2026
7 checks passed
@erikdarlingdata
erikdarlingdata deleted the feature/2426-lite-ag-disconnect-refire branch September 12, 2026 20:32
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