Repository navigation
Agent changes list with one-click undo and the switch (#5917) - #5936
Conversation
| for (var i = 0; i < changes.Count; i++) | ||
| { | ||
| if (changes[i].Kind == AgentChangeKinds.Mute && changes[i].Result == AgentChangeResult.Done) | ||
| changes[i] = changes[i] with { MutedAlerts = (await MutedAlertsAsync(connection, changes[i], 1000, cancellationToken)).Count }; |
There was a problem hiding this comment.
muted_alerts is a count off a page, presented as the population's count.
MutedAlertsAsync(connection, changes[i], 1000, …) runs MutedAlertsSql, which ends LIMIT $4 (line 157), and the count is .Count of the rows it fetched. A mute rule that suppressed 4,000 alerts reports exactly 1000, and both surfaces present that as the fact: the web body puts it on muted_alerts (DarlingWebEndpoints.cs, the list body), the page prints String(c.muted_alerts) + " alerts" (admin-agent-changes.js), and the Viewer prints n + " alerts muted since" (AgentChangesViewModel.cs). There is no *_returned naming and no truncation flag to say the number is a page.
This is the .github/REVIEW-TRAPS.md shape verbatim: "A count taken off the page and named as the population's: non_default_count = rows.Count(!IsDefault) over the rows fetched reported the limit as a fact about the snapshot (#3679). The population's count belongs on the statement above the LIMIT; the page's count is *_returned." The sibling route gets it right — /api/agent-changes/{id}/muted-alerts answers alerts_returned.
Two things ride on the same line:
- The count should come from a
count(*)statement, not from fetching rows. A separateSELECT count(*) FROM config.config_alert_log AS a WHERE a.muted AND a.alert_time >= $1 AND (…)gives the true number and transfers one row. - It is an N+1 with a 1000-row body. The loop runs one query per mute-kind
Donerow, up toAgentChangesLimit = 200of them, each materialising up to 1,000config_alert_logrows only to call.Count— so one load of the Agent changes tab can pull 200,000 rows it throws away. The Viewer pays it too:InvestigationsWindow.LoadAllAsynccallsAgentChangesView.LoadAsync()on every open of the window.
AMuteRow_LinksToTheAlertsMutedSinceTheChange asserts row.MutedAlerts == 1 against four seeded alerts, so nothing pins the cap.
| internal const string RefusalsSql = @" | ||
| SELECT journal_id, event_time, request_id, server_id, actor, reason, detail->>'tool' | ||
| FROM config.investigation_journal | ||
| WHERE event = 'refused' AND actor LIKE 'agent:%' AND event_time >= $1 | ||
| ORDER BY event_time DESC, journal_id DESC | ||
| LIMIT $2"; |
There was a problem hiding this comment.
The refusals list is every agent refusal in the journal, not every refused agent change.
event = 'refused' AND actor LIKE 'agent:%' admits far more than the Level 2 gate's refusals. InvestigationRequestStore.RecordRefusalAsync writes the same refused event with the same agent:<id> actor for Level 1 request work:
InvestigationRequestStore.cs:528-529— claim refusals,reason = "claim:" + reason(claim:not_found,claim:lease_held, …),detailnull.InvestigationRequestStore.cs:632— report-submit refusals,reason = "submit:" + reason.InvestigationRequestStore.cs:734-735— the generic recorder,reason = operation + ":" + reason; its callers inDarlingMcpInvestigationTools.cs:354,361,395,492,505,520are allclaimandsubmit.
Those rows land in the same list, and both surfaces label them as refused changes: the web tab draws them under el("h3", { text: "Refusals" }) with emptyStrip("No agent change was refused.") (admin-agent-changes.js), and the Viewer grid is AutomationProperties.Name="Refused agent changes" (AgentChangesTab.xaml). On a fleet running Level 1 only — no Level 2 route anywhere, so no agent change is even possible — the Agent changes tab shows an empty changes grid above a Refusals grid full of claim:unsupported_format and submit:too_large rows with a blank Tool column, which reads as "agents keep being refused changes here."
The discriminator is already in the SELECT list: a gate refusal always carries detail.tool (InvestigationRequestStore.AgentAction.cs:346 and DarlingAgentActionGate.cs:69), and the Level 1 recorders pass null detail or a detail with no tool. Requiring it narrows the list to what the tab says it holds:
| internal const string RefusalsSql = @" | |
| SELECT journal_id, event_time, request_id, server_id, actor, reason, detail->>'tool' | |
| FROM config.investigation_journal | |
| WHERE event = 'refused' AND actor LIKE 'agent:%' AND event_time >= $1 | |
| ORDER BY event_time DESC, journal_id DESC | |
| LIMIT $2"; | |
| internal const string RefusalsSql = @" | |
| SELECT journal_id, event_time, request_id, server_id, actor, reason, detail->>'tool' | |
| FROM config.investigation_journal | |
| WHERE event = 'refused' AND actor LIKE 'agent:%' AND detail->>'tool' IS NOT NULL AND event_time >= $1 | |
| ORDER BY event_time DESC, journal_id DESC | |
| LIMIT $2"; |
TheList_CarriesCallsAndRefusals_AndRefusesABadLogin seeds one refused row that does carry detail.tool and asserts Assert.Single(read.Refusals), so it passes either way and pins nothing here.
| string? UndoneBy, | ||
| int? MutedAlerts) | ||
| { | ||
| /// <summary>The kind word the tool journaled (<c>mute</c>, <c>rule</c> or <c>override</c>).</summary> |
There was a problem hiding this comment.
Non-blocking (doc only): this names the pre-rename kind words. The PR body says it plainly — "kind is mute, custom_rule or threshold_override (the PR had rule and override)" — and AgentChangeKinds (AgentChangeImages.cs) is the authority, so a reader who trusts this line will grep for a kind that no tool ever journals.
| /// <summary>The kind word the tool journaled (<c>mute</c>, <c>rule</c> or <c>override</c>).</summary> | |
| /// <summary>The kind word the tool journaled (<c>mute</c>, <c>custom_rule</c> or <c>threshold_override</c>).</summary> |
The same two stale words survive in the class summary below (lines 82-84: "rule: custom_alert_rules" and "override: config_threshold_overrides"), and the <para> there also still says fields enabled, expires_at_utc, … without external_source and client_app_pattern, which MuteRuleImage does carry as [ImageColumn]s.
Blind-read notes: the small things, none of them blockingPosted separately from the two findings so the verdict stays readable. Everything here I'd take or leave. The window is actually backed by the store. Worth saying out loud because it is the kind of thing that usually isn't:
Two dead handoffs in the web tab.
The degenerate-image boundary.
ui-smoke: the un-re-rendered baseline will not red the workflow. The PR body flags the shift and leaves the baseline alone, which is the right call: |
There was a problem hiding this comment.
Verdict: CHANGES REQUESTED
Two findings, both inline on AgentChangeUndo.cs; non-blocking notes are in a separate top-level comment.
Claims
- VERIFIED — undo is one transaction that compares current fields to the after image, restores only if equal, journals
action_undoneasperson:<login>, else refuses "changed since" —AgentChangeUndo.cs:291,:365,:390,:399,:646. - VERIFIED — a deleted rule is re-inserted with
OVERRIDING SYSTEM VALUE, a name clash refusesname_taken, an update restore bumpsversion—:597,:382-384,:568; legal becausecustom_alert_rules.idisGENERATED ALWAYS AS IDENTITY(PgMigrations.cs:1933). - VERIFIED — equality never looks at
updated_at,updated_by,version— thoseCustomAlertRuleImageproperties carry no[ImageColumn], andImageColumns.Of<T>()keeps only attributed ones, so they are absent from the SELECT, the compare and the SET list. - VERIFIED — "Window 365 days", and the journal can keep it —
:93,:317-318;DarlingRetention.cs:261retains the journal 365 days, slice at:1033-1034. - VERIFIED — a call with no
action_done/action_failedrow is "result unknown" with no undo —:202,:48,:325-326. - VERIFIED — a second click finds
already_undonevia an advisory lock because the journal is append-only —:299then:322-323; READ COMMITTED makes the post-lock read see the committed row, as:297-298claims. - VERIFIED — "The JSON they produce is unchanged field for field" —
MuteRuleImage's fifteen[JsonPropertyName]s and their order match the deleted anonymous initialiser inBuildMuteRulePayloadone for one;CustomAlertRuleImagematches the deletedBuildFullRuleNodethe same way. - VERIFIED — the
already_existshole was real and is fixed — refusal at:345-346;DarlingMcpAlertTools.cs:2004-2012emits the status andDarlingMcpAgentActions.cs:171accepted it as success. - VERIFIED — times now compare within one microsecond —
:512-515, the same tolerance as the mute tools' ownSameExpiry(DarlingMcpAlertTools.cs:1997-2000). - VERIFIED — a read-only Viewer seat gets the list and no write controls —
AgentChangesTab.xaml.csInitializecollapsesUndoPanelandSwitchButton;SwitchCommandAsyncre-checks_readOnly. - VERIFIED — "No migration, no Lite write path" — no
PgMigrations.cshunk, no Lite file among the 24 changed paths; the store is Darling-only (PgMigrations.cs:1424). - VERIFIED — "Census edits are additive … No new census or source-text test" — four route rows, one
FixedWritersrow, two grid rows; the threeMuteRule*pins swap sourceAssert.Containsfor reflection overMuteRuleImage. - VERIFIED — the counts the body gives — 11 / 13 / 2
[Fact]s in the live classes, 4 cases inAgentChangesViewerTests, eight entries inTABS. - UNVERIFIED — the viewer role answering 403 and changing nothing — needs
DARLING_TEST_PGandViewerGrantReplay; which statement first raises 42501 (SELECT … FOR UPDATEat:461, the write, or the journalINSERT) is not readable from the tree. - UNVERIFIED — "2863 tests, 0 failed, 2 skipped … port 55936" and "691 run" — every live class is
Assert.SkipWhenonDARLING_TEST_PG, so the undo, web and tool-undo surfaces are unexercised here. - UNVERIFIED — "0 Warning(s), 0 Error(s)" and "
node --checkpasses" — no build or node run from a read-only tree.
Riskiest lines
Darling/PerformanceMonitor.Darling.Storage/AgentChangeUndo.cs:171—.Countover aLIMIT 1000page shown as the alerts the rule muted, and an N+1 that can materialise 200,000 discarded rows per load — UNPINNED (AMuteRow_LinksToTheAlertsMutedSinceTheChangeasserts== 1over four seeded alerts).Darling/PerformanceMonitor.Darling.Storage/AgentChangeUndo.cs:143—event = 'refused' AND actor LIKE 'agent:%'admits Level 1claim:*/submit:*refusals, surfaced as "Refused agent changes" — UNPINNED (TheList_CarriesCallsAndRefusals_AndRefusesABadLoginseeds a row that does carrydetail.tool, so it passes either way).Darling/PerformanceMonitor.Darling.Storage/AgentChangeUndo.cs:365—if (!Equal(spec, current, after))is the whole safety property; every write below runs only because it returned true, and it skips any field the after image does not name — pinned byAgentChangeToolUndoLiveTests.AMuteAPersonEditedAfterTheAgent_IsKept_AndTheUndoSaysSoandAnOverrideAPersonChangedAfterTheAgent_IsKept, both live and skipped without a rig.
Traps near this diff
- "Two copies of one definition pinned EQUAL to each other fail only after BOTH have drifted (#3684). Alias, do not copy." — does not apply; the diff is the fix: the specs read their columns off the image records by reflection (
:101-103) and the tools write those same records (DarlingMcpAlertTools.cs:915,BuildFullRuleNode,DarlingMcpThresholdOverrideTools.cs:137,152). - "A count taken off the page and named as the population's … The population's count belongs on the statement above the
LIMIT; the page's count is*_returned(#3679)." — applies, at:171against theLIMIT $4at:157. The sibling route gets it right (alerts_returned), which makes the baremuted_alertsthe outlier. - "Truncation inferred from a full page:
truncated = rows.Count >= limit… the flag isfetched.Count > limit(#3679)." — does not apply:AgentChangesBodyemits{enabled, can_decide, changes, refusals}and no truncation flag. - "
hours_back,limit,top,days_backare refused when out of range, never clamped — a clamp is a silent lie about the window (#3699)." — does not apply: the clamps at:162and:217are unreachable from input; both web routes pass constants (AgentChangesLimit= 200 and a literal200), the Viewer a literal200, and neither route declares alimitparameter. - "A JSON number above 2^53 is rounded by every JavaScript client … Emit 64-bit ids as strings (#3487)." — does not apply:
called_idisinvestigation_journal.journal_id, an identity starting at 1 (InvestigationTables.cs:176), and the family's shape is a number —NoteDecisionBodyemits["note_id"]the same way (DarlingWebEndpoints.cs:1051).request_idis correctly a string. - "Lite and Darling ship in parity (CONTRIBUTING's two-store rule): a fix to one SKU's reader, tool, alert or description without the counterpart's is a finding, not a nit (#3594)." — does not apply: this stands on
investigation_journalandconfig_service.agent_actions_enabled, PostgreSQL-only; V191 says so (PgMigrations.cs:1424), and Lite has no agent-action gate. - "Ruling (#5833): tests are load-bearing. … never answer a trap with a test that reads source text or asserts how code is spelled." — does not apply as a violation: the diff removes source reading (
MuteRulesPageTestsdropsReadRepoFile(… "DarlingMcpAlertTools.cs")and itsBuildMuteRulePayloadwindow), adds no census class, and the census files gain rows only. The two reflection asserts remain spelling pins, but pre-existing ones kept green.
Divergence (blind read vs PR body)
- The muted-alerts caveat is in the wrong dimension. The coordinator note says the link is "by the rule's server and metric … the alerts the rule could have muted" — honest about which alerts, silent about how many. The number is
.Countof a 1000-row page (:171/:157) printed as a fact ("N alerts", "N alerts muted since"). That caveat leads a reader to check the predicate and stop, which is the blind spot #3679 names. - The refusals list reaches wider than the body describes. The body says "refusals" and the README says "the code it was told (for example
actions_off)", a gate code. The query has no discriminator beyond event and actor, andRecordRefusalAsyncwrites the same pair for Level 1 claim and submit refusals (InvestigationRequestStore.cs:528,:632,:734). "Refused agent changes" therefore holds rows that are not agent changes, on a fleet where no agent change is possible.
Elsewhere the body matched the blind read closely, including both seam defects it claims to have fixed — each was a real hole.
Reviewer checklist
- With
DARLING_TEST_PG, runAgentChangesWebLiveTestsand confirm which statement raises 42501 under the replayedviewergrant — if it isSELECT … FOR UPDATE(:461), the route answers 403 beforechanged_sincecould be computed. - Seed one mute matching more than 1,000 muted
config_alert_logrows, open the tab in both surfaces, and check the number shown and the page timing. - On a Level 1-only store (no route at
agent_level = 2, so noaction_calledrow), read the Refusals grid: doclaim:*/submit:*rows appear under "Refused agent changes"? - Browser check of the new Admin tab — the body's one unchecked item. Exercise the Undo column's
renderundercan_decide: trueand the 403 strip under the viewer role. - Open the Viewer's Investigations window against a store with agent changes: confirm the new
LoadAsync, now awaited last inLoadAllAsync, does not stall the window.
… refusals, catalog test reads image types (#5917)
|
Follow-up review (commits 1. 2. Refusals list excludes Level-1 refusals ( 3. Catalog test fallback for image-typed reads ( One minor, non-blocking nit: the PR body's item 4 says the doc fix added " No issues found in the three commits. Lite has no equivalent agent-action-gate journal ( |
There was a problem hiding this comment.
Verdict: LGTM
Claims
- VERIFIED — "
muted_alertswas a count off a LIMIT page, plus an N+1 ... now counts withMutedAlertsCountSql... no LIMIT" —AgentChangeUndo.cs:163-169has noLIMIT; old call site usedMutedAlertsAsync(..., 1000, ...)).Count(diff hunk atAgentChangeUndo.cs:183). - VERIFIED — "The refusals list admitted Level 1 request refusals ...
RefusalsSqlnow also requiresdetail->>'tool' IS NOT NULL, which a gate refusal always carries" — confirmedJournalRefusedAsync(InvestigationRequestStore.AgentAction.cs:347) always sets{"tool": tool}, while everyRecordRefusalAsynccaller (Lite and DarlingMcp*InvestigationTools.cs) passesnullor a{fields:...}detail with notoolkey. - VERIFIED — "The test now checks a read's catalog keys against the shared image record's
[JsonPropertyName]names by reflection when the read's rows are an image type (get_mute_rules->MuteRuleImage)" —AlertNotebookTableCatalogTests.cs:332-342, andread-fields.js'sget_mute_rulescatalog columns all matchMuteRuleImage'sJsonPropertyNames. - VERIFIED — "Pinned by
AgentChangeUndoLiveTests.AMuteRow_CountsThePopulationOfMutedAlerts_NotThePageTheAlertsRouteReturns(1005 muted alerts seeded: the row says 1005,ReadMutedAlertsAsync(…, 2)returns 2)" — test present and asserts exactly that (AgentChangeUndoLiveTests.cs:114-129). - UNVERIFIED — "It fails on the old code (reported 1000)" / "It fails on the old code" (refusals) — requires actually running the tests against the pre-fix SQL; not checkable from a static tree, though the logic (bare
LIMIT 1000fetch, missingdetail->>'tool'filter) supports it.
Riskiest lines
Darling/PerformanceMonitor.Darling.Storage/AgentChangeUndo.cs:145— the refusal-filter predicate; too broad swallows real gate refusals, too narrow lets Level-1 noise back in — pinned byAgentChangeUndoLiveTests.TheList_CarriesCallsAndRefusals_AndRefusesABadLoginDarling/PerformanceMonitor.Darling.Storage/AgentChangeUndo.cs:244-251— the count-vs-page semantics formuted_alerts, user-visible viaAgentChangesViewModel.MutedDisplayand the webmuted_alertsfield — pinned byAgentChangeUndoLiveTests.AMuteRow_CountsThePopulationOfMutedAlerts_NotThePageTheAlertsRouteReturnsDarling/Darling.Tests/AlertNotebookTableCatalogTests.cs:336-342—ImageWireNamesreflection fallback; a wrong mapping here would silently stop catching a real catalog/payload mismatch forget_mute_rules— pinned byEveryFieldKeyInTheCatalog_IsAFieldTheReadsOwnPayloadBuilds(fails if a catalog key isn't found by any path)
Traps near this diff
- "A count taken off the page and named as the population's... The population's count belongs on the statement above the
LIMIT; the page's count is*_returned." — applies, and is the defect this follow-up fixes:AgentChangeUndo.cs:244-251now counts above theLIMITand the paged route (DarlingWebEndpoints.cs:1382) is namedalerts_returned. - "Ruling (#5833): tests are load-bearing. ... never add one ... that reads source text or asserts how code is spelled ... A trap is answered with a behaviour test" — does not apply as a new violation:
AlertNotebookTableCatalogTests.csis a pre-existing source-reading census (predates this follow-up), and the new commit only patches it (plus a reflection fallback) to stay accurate after an unrelated refactor moved field names out of literal text — no new census/pin/roster was added. - "Lite and Darling ship in parity... a fix to one SKU's reader, tool, alert or description without the counterpart's is a finding" — does not apply: Lite has no
action_called/RunAgentActionAsyncgate journal at all (grepforaction_called,AgentActionHooksunderLite/returns nothing), soAgentChangeUndois Darling-only by design, not a parity gap.
Divergence (blind read vs PR body)
Minor, non-blocking: the PR body's item 4 ("the mute field list now has external_source and client_app_pattern") implies both were newly added by this doc fix, but client_app_pattern was already present before this follow-up range (confirmed against the pre-image at 6c2c6190b); only external_source and the rule→custom_rule/override→threshold_override kind renames are new here. Doesn't touch any code path.
Reviewer checklist
- Run
AgentChangeUndoLiveTestsandAlertNotebookTableCatalogTestsagainst a live PostgreSQL rig to confirm the two new pins actually fail on the pre-fix SQL/test code as the PR body claims, and pass after.
Stale: the round-1 findings were fixed in ab9e6ba and round 2 approved that commit.
Claude posting for Erik Darling.
Fixes #5917.
Part of #5632, Phase B wave 3.
Why
Anything an agent changes must be easy to see and easy to take back. This gives an operator a list of agent changes (calls, refusals, and calls with no known result), a one-click undo that is safe when someone else has since changed the setting, and the switch that turns agent changes off.
What changes
AgentChangeUndo(Darling/PerformanceMonitor.Darling.Storage/AgentChangeUndo.cs). The list (calls with their result, refusals, the switch),UndoAsync,SetEnabledAsync,CanDecideAsync, and the alerts muted under a mute change. An undo runs in one transaction: it compares the target's current agent-settable fields with the journal's after image and, only if equal, restores the before image and journalsaction_undonewith actorperson:<login>; otherwise it refuses "changed since". Per kind: a mute rule (config_mute_rules), a custom alert rule (custom_alert_rules; a delete is undone withOVERRIDING SYSTEM VALUE, so the rule keeps itsCustom:<id>; a name clash refusesname_taken; an update restore bumpsversion), a threshold override (compared per field). Equality never looks atupdated_at,updated_by,versionor theconfig_versionbeacon. Window 365 days. A call with neither anaction_donenor anaction_failedrow is "result unknown" and has no undo. A second click findsalready_undone(an advisory lock on the call, because the journal is append-only and the admin role has no UPDATE on it).AgentChangesTab.xaml/.cs,AgentChangesViewModel.cs(view rows and theViewerDataServicepartial). Calls, refusals, "result unknown", Undo, the switch. The Viewer reads as the store admin; a read-only seat gets the list and no write controls.wwwroot/js/pages/admin-agent-changes.js(admin.js gets the import, the tab entry and the builder entry). Routes inDarlingWebEndpoints.cs:GET /api/agent-changes,GET /api/agent-changes/{id}/muted-alerts,PUT /api/agent-changes/{id}/undo,PUT /api/agent-changes/switch. Decides only under the admin store login; under the viewer role it answers 403 with the exact text from the issue, and the list body carriescan_decideso the page copy says it is list-only. Journal text goes throughInvestigationFieldMasking.JournalDetailat the web's level.AgentChangeUndoLiveTestsjournals rows by hand in the tools' shapes;AgentChangeToolUndoLiveTestsdrives the real tools. Guide text in its own README section "Agent Changes: List, Undo and the Switch".No migration, no Lite write path, nothing under
deprecated/, no MCP tool for the switch, CHANGELOG.md not edited.Image contract
One record per kind describes the journaled image, in
Darling/PerformanceMonitor.Darling.Storage/AgentChangeImages.cs. The tool that writes it and the undo that reads it use the same type (#3684):MuteRuleImage(AgentChangeImages.cs:44) forkind = "mute". Column fields carry[ImageColumn(type)]; the column name is the[JsonPropertyName].id,match_summaryare wire-only.CustomAlertRuleImage(AgentChangeImages.cs:66) forkind = "custom_rule".ThresholdOverrideImage(AgentChangeImages.cs:82) forkind = "threshold_override".AgentChangeKinds(AgentChangeImages.cs:36) holds the threekindstrings; the tools and the undo both use it.ImageColumns, AgentChangeImages.cs:90), so no second field list exists.The tools' call sites:
DarlingMcpAlertTools.BuildMuteRulePayload(DarlingMcpAlertTools.cs:915) now returns aMuteRuleImage(still themute_rulewire object the read tools return);DarlingWebEndpoints.BuildFullRuleNodebuilds aCustomAlertRuleImage(still the single-rule wire shape);DarlingMcpThresholdOverrideToolsjournals aThresholdOverrideImagefor both its before and after;DarlingMcpAgentActionspassesMuteRuleImage.KindandCustomAlertRuleImage.Kindto the gate.Seam with #5934 and #5933
What the tools journal (the gate writes
action_called.detail = {tool, kind, target:{id, server_id, database}, claim_no, evidence, before}andaction_done.detail = {called_id, after};afteris the tool's own answer):muteMuteRuleImage, null on create{status, mute_rule: MuteRuleImage}; a delete answers{status: "deleted", rule_id}custom_ruleCustomAlertRuleImage, null on create{status: "deleted", rule_id}on deletethreshold_override{value}of the previous override, null when there was none{value}, null when clearedserver_idanddatabasein the target)What changed on each side:
{value}(override). The JSON they produce is unchanged field for field, so the read tools, the web routes and the tool answers are the same. The override tool already journaled both images; the mute and rule tools already journaled a before image and the rule in the answer, so no tool needed a second journal row or a new image field.kindismute,custom_ruleorthreshold_override(the PR hadruleandoverride). The after image is read out of the tool's answer (mute_rulefor a mute, the node itself for a rule,status = deletedmeans no after image). Created is before null (undo deletes by the id in the after image); deleted is after null (undo inserts the before image back, a rule with its own id and a mute with its own id and created time); updated restores the before image. The undo still runs on its own connection and transaction, journalsaction_undoneasperson:<login>with the undone row's id, and refuses with a sentence when the row no longer matches the after image.already_existsand the gate journals it as done; undoing it would have deleted the rule the agent never created. The undo now refuses any call whose answer saysunchangedoralready_exists("That call changed nothing"). (2) A tool's .NET time has 100 ns ticks and PostgreSQL keeps microseconds, so a freshly created mute never matched its own after image ("changed since"). Times now compare within one microsecond.Not blocked: all four paths (mute, custom rule, override created or updated or deleted) are determined by the shapes above. Nothing for the coordinator on that.
Merge of dev
git merge origin/devinto the PR branch: conflicts inDarlingWebEndpoints.cs(route mapping: keptMapThresholdOverridesandMapAgentChanges, both) andadmin.js(kept all eight tabs, Agent changes last, inTABSand inBUILDERS; the threshold overrides section and the agent changes context both stay). Auto-merged and checked:InvestigationsWindow.xamland.xaml.cs(Practice tab and Agent changes tab),Darling/README.md(both sections, no duplicated heading),McpServiceParameterDiSeatCensusTestsandReadAdmissionCoverageCensusTests(every entry from both sides; the four agent-changes web routes are in the read-admission census).node --checkpasses onadmin.jsandadmin-agent-changes.js.Review round 1: findings and fixes
AlertNotebookTableCatalogTests.EveryFieldKeyInTheCatalog_IsAFieldTheReadsOwnPayloadBuilds. The test now checks a read's catalog keys against the shared image record's[JsonPropertyName]names by reflection when the read's rows are an image type (get_mute_rules->MuteRuleImage); every other read is checked as before.Darling/Darling.Tests/AlertNotebookTableCatalogTests.cs; the test passes.muted_alertswas a count off a LIMIT page, plus an N+1 of 1000-row fetches. The list now counts withMutedAlertsCountSql(SELECT count(*), the same predicate asMutedAlertsSql, no LIMIT): one row per mute change instead of up to 1000. The/api/agent-changes/{id}/muted-alertsroute still pages and reportsalerts_returned. Pinned byAgentChangeUndoLiveTests.AMuteRow_CountsThePopulationOfMutedAlerts_NotThePageTheAlertsRouteReturns(1005 muted alerts seeded: the row says 1005,ReadMutedAlertsAsync(…, 2)returns 2). It fails on the old code (reported 1000).RefusalsSqlnow also requiresdetail->>'tool' IS NOT NULL, which a gate refusal always carries.TheList_CarriesCallsAndRefusals_AndRefusesABadLoginnow seeds a claim refusal (null detail) and a submit refusal (detail withouttool) beside the gate refusal and asserts the list holds only the gate one. It fails on the old code.AgentChangeUndo's XML docs namemute,custom_ruleandthreshold_override, and the mute field list now hasexternal_sourceandclient_app_pattern.The CHANGELOG entry still holds: it says nothing about counts or which refusals show.
Test plan
AgentChangeUndoLiveTests(11, live): unchanged mute restored and journaled byperson:erik; changed since refused and left alone; agent-created mute deleted; mute links to alerts muted since; deleted rule returns with its own id, name clash refused; updated rule restored with version moved; override compared per field (restore, refuse, delete); result unknown or failed has no undo; 365-day window; switching off makes the gate refuseactions_off, on restores it.AgentChangesWebLiveTests(2, live, real viewer and admin grant replay): viewer role lists,can_decidefalse, undo and switch answer 403 with the exact sentence and change nothing; admin role undoes, a second click is 409, unknown id 404, switch off, bad body 400; "unknown" in the list body.AgentChangesViewerTests(4): the "result unknown" display and when Undo is offered.*Census*and*Ratchet*class, WebExceptionTextCensusTests, DocCommentHygiene: 691 run, then the one failure (WebExceptionTextCensusTests, it flaggedresult.Message) fixed by renaming the property toWords; rerun green (70). Full suite not run (laptop); CI decides.node --checkand the Admin page tests pass.Census edits are additive (
ReadAdmissionCoverageCensusTestsfour route rows;WebRouteStatementCensusTestsone fixed-writer row;ViewerGridTimeColumnSortMemberTeststwo rows). No new census or source-text test.AlertNotebookTableCatalogTests, everyAgentChange*class, every*Census*,*Ratchet*and*Pin*class on the throwaway PostgreSQL rig (DARLING_TEST_PGset): 1168 run, 0 failed, 2 skipped. The two new behaviour pins fail on the pre-fixAgentChangeUndo.cs(2 of 12 inAgentChangeUndoLiveTests) and pass after. Build: 0 warnings, 0 errors.Test plan (seam lane)
AgentChangeToolUndoLiveTests(13, live on the throwaway PostgreSQL rig, the real tools through the gate, then the undo, then the table row): mute create then undo deletes it; create repeated (already_exists) then undo refuses and keeps the rule; mute update then undo restores the before row; mute turned off then undo turns it on; mute delete then undo brings it back with its id and created time; a person's edit after the agent then undo refuseschanged_since; custom rule create then undo deletes; rule disabled then undo re-enables (version moves forward); rule delete then undo re-inserts it with its id and definition; override set over a previous one then undo restores the previous; override set with none then undo deletes; override cleared then undo puts it back; a person's change then undo refuses.AgentChangeUndoLiveTestsjournalscustom_ruleandthreshold_overridekinds and wraps the after image the way the tools do;AgentChangesWebLiveTestsjournals a mute answer withmute_rule.MuteRulesPageTests.EveryRuleFieldThePageReads_IsOneTheToolReturns,MuteRuleExternalSourceSurfaceTests,MuteRuleClientAppTests) now read the field names offMuteRuleImageby reflection; their rules (the page reads only fields the tool returns; the payload carries external_source and client_app_pattern) still hold. No census, roster or source-text test was added.*AgentChange*,*AgentAction*,*AgentLevel2*,*ThresholdOverride*,*Investigation*(the Investigations window and the Viewer tests),*Admin*(the admin page tests and the Node harness),*Census*,*Ratchet*,*Pin*,*CustomAlert*,*MuteRule*,*Guard*, withDARLING_TEST_PGon the rig (port 55936, stopped afterwards). SlowClassCensusTests passed in that run (no ci-select timeout).node --checkonadmin.jsandadmin-agent-changes.js.UI smoke
tools/ui-smokebaselines were not re-rendered. The admin-servers baseline shifts by one more tab button (the Admin page now has eight tabs: Servers, Notification Routes, Alert Settings, Report Feedback, Practice Scores, Pending Notes, Threshold Overrides, Agent changes).CHANGELOG entry
SECTION: Added
ENTRY: - Agent changes list with one-click undo and an off switch ([#5936]) - The Viewer's Investigations window and the web Admin page have an Agent changes tab: every change an agent made, what it was refused, and calls with no known result shown as "result unknown". Undo puts a mute rule, custom alert rule or threshold override back the way it was before the agent's change, but only if nobody has changed it since (otherwise it says "changed since" and leaves it); a deleted rule comes back with its own Custom id. A switch turns every agent change off at once. The web decides only under the admin store login; on managed and compose stores it lists only.
REF: [#5936]: #5936
What the coordinator should double-check
AgentChangeImages.cs. The tools' JSON is unchanged field for field; the read-tool and web tests that run over the mute payload and the rule node passed.Left for a successor
Nothing from the four parts. A browser check of the web tab is the one unchecked item.