Repository navigation
get_investigation_reports says when its window was cut, and the brief's field cut keeps emoji whole (#5834) - #5850
Conversation
…'s field cut keeps emoji whole (#5834) get_investigation_reports and list_investigation_requests read one row past their page and say truncated when the window held more (journal_truncated for the journal); request_count and count become requests_returned. The web Investigations panel says when a read was cut. The brief composer's field cut uses the text-element cut, so it never splits a surrogate pair into U+FFFD.
| /* #5834, the #3699 shape: read one row past the limit, return the page, and say whether more matched. */ | ||
| var fetched = await Store(postgres).ListAsync(state, serverId, family, limit + 1, cancellationToken); | ||
| var truncated = fetched.Count > limit; | ||
| IReadOnlyList<InvestigationRequestRow> rows = truncated ? fetched.Take(limit).ToList() : fetched; |
There was a problem hiding this comment.
Non-blocking (reuse): these three lines are McpHelpers.BoundPage re-spelled — McpHelpers.cs:660-665 is
var truncated = fetched.Count > limit;
IReadOnlyList<T> page = truncated ? fetched.Take(limit).ToList() : fetched;identical, down to the .Take(limit).ToList() on the truncated branch only. The body calls this "the #3699 shape", and #3699 bound its sites through that helper (McpPageContractTests.PgPagedToolsThroughTheHelper, and McpPayloadContractCensusTests' own failure message says "fetch cap + 1, bind through McpHelpers.BoundPage and publish truncated"). Behaviour is the same today, so this is a cleanup, not a defect:
| /* #5834, the #3699 shape: read one row past the limit, return the page, and say whether more matched. */ | |
| var fetched = await Store(postgres).ListAsync(state, serverId, family, limit + 1, cancellationToken); | |
| var truncated = fetched.Count > limit; | |
| IReadOnlyList<InvestigationRequestRow> rows = truncated ? fetched.Take(limit).ToList() : fetched; | |
| /* #5834, the #3699 shape: read one row past the limit, return the page, and say whether more matched. */ | |
| var (rows, truncated) = McpHelpers.BoundPage( | |
| await Store(postgres).ListAsync(state, serverId, family, limit + 1, cancellationToken), limit); |
| "THE WINDOW IS BOUNDED AT 100 REQUESTS, newest first: requests_returned is how many you got, truncated says the " + | ||
| "window held more (the reports and journal are those of the returned requests only; narrow hours_back or pass " + | ||
| "server_name or request_id), and journal_truncated says their journal held more than 200 rows. " + |
There was a problem hiding this comment.
Non-blocking (wording an agent acts on): the remedy list reads "narrow hours_back or pass server_name or request_id", and one of the three cannot reach what was cut.
ReportsRequestsSql is … AND r.opened_at >= $4 ORDER BY r.opened_at DESC, r.request_id LIMIT $5 (InvestigationRequestStore.cs:411-413), so the page keeps the newest 100 and the rows it dropped are the oldest in the window. Narrowing hours_back removes more of the old end: it clears truncated without ever showing the requests the flag was about. server_name genuinely partitions the set, and request_id addresses a row directly — those two work. And since the tool declares no limit parameter, the "raise limit" escape the list tool's sentence offers (:91-92) does not exist here.
The sentence is defensible read as "here is how to get an answer that is complete for what you asked", which all three do; it is misleading read as "here is how to see the rest". Since there is no cursor on this read, saying so outright is probably the honest version — something like "the dropped requests are the oldest in the window and no argument pages to them; pass server_name to split the window, or request_id for one request".
| if (reports.kind !== "error" && reports.data && reports.data.truncated) { | ||
| parts.unshift(noticeStrip(`The last ${INVESTIGATION_HOURS} hours held more requests than one reports read returns, so older requests may show no verdict.`)); | ||
| } |
There was a problem hiding this comment.
Non-blocking — the wording check you asked for in the PR body. All three notices are accurate about their own read:
- the list notice (
:806) matchesListSql'sORDER BY s.opened_at DESC+LIMIT, so "the newest N; older ones are not listed" is exactly what was served; - the journal notice (
:772) matchesReportsJournalSql'sORDER BY event_time DESC, journal_id DESC; - this one correctly attributes a missing verdict to the reports page being cut.
One imprecision worth a word, pre-existing and only surfaced by this notice: the two reads do not cover the same set. The list read (:793) passes limit: 100 and no window — it is the newest 100 requests of any age — while the reports read (:794) is windowed at INVESTIGATION_HOURS (168). So a listed request older than seven days shows no verdict whether or not truncated fired, and nothing on the panel says why. With this notice present, a reader who sees a verdict-less row and no notice will conclude the request has no report; with the notice present, they will attribute an older row's blank verdict to the cut. Either way the 168-hour edge is the real cause and is undisclosed.
Cheapest honest fix is to say the window in the un-cut case too (the panel already knows INVESTIGATION_HOURS), e.g. a standing line that verdicts are read over the last 168 hours — or to window the list read to the same span so the two sets agree. Your call; not blocking this PR.
Note:
|
There was a problem hiding this comment.
Verdict: LGTM
Claims
- VERIFIED — the store reads
limit + 1requests andjournalLimit + 1journal rows, trims each, returns two new optional record members defaulting to false —InvestigationRequestStore.cs:848,:868-872,:893,:905-911, record:154-159. - VERIFIED — reports and journal still scoped to the returned requests, no second statement, no
COUNT(*)—idstaken after the trim (:874), both statements filterrequest_id = ANY($1), all three unchanged. - VERIFIED —
get_investigation_reportsanswersrequests_returned(wasrequest_count),truncated,journal_truncated, with the new prose after the guide marker so the 300-char head is unchanged —DarlingMcpInvestigationTools.cs:683-685, marker:540, text:547-549; the pins inMcpToolGuideHeadsInvestigationsTestsare untouched. - VERIFIED —
list_investigation_requestsreadslimit + 1, answersrequests_returned(wascount) andtruncated, both named in the description —:119-128,:91-92. - VERIFIED — no reader of the old keys existed outside the tool — grepped C#,
wwwroot/js, the.mjsharness,README.md,AGENTS.example.md; otherrequest_counthits are the unrelatedCpuSchedulerStatsCollectorcolumns. - VERIFIED — the panel says when the list, the 168-hour reports read or a journal was cut —
alerts.js:806,:807-809,:772, pinned byTheInvestigationsPanel_SaysWhenAReadWasCut_AndNothingWhenNoneWas. - VERIFIED — the viewer reads its own SQL and the instructions name neither field —
ViewerDataService.Investigations.cshas its ownLIMIT 500/200and calls neither tool. - VERIFIED —
CapkeepsTextElementCutLengthunits, drops a straddling element whole, keeps one ending at the cut, counts in the caps' UTF-16 unit —InvestigationBriefComposer.cs:423-425, unit:413; both boundaries run byFieldCut_NeverSplitsASurrogatePair, and the older exact-FieldCharCappin still holds (its text is a run ofx). - VERIFIED — Lite has nothing to mirror — nothing under
Lite/names these tools,ReadReportsAsyncorFieldCharCap;McpToolGuideHeads.Investigations.cs:18says so too. - VERIFIED — behaviour tests only — the four new tests execute the composer, the answer builder, the live store and the page module; no census or roster added.
- UNVERIFIED — the gate and targeted figures (
259, Darling4157, Lite1134, GATE GREEN) — needs a build; no .NET SDK here. - UNVERIFIED — the live-rig figures (
24,11) — needs PostgreSQL at rung V184. - UNVERIFIED — the two mutation checks — need a run on a reverted tree; both plausible from source.
Riskiest lines
InvestigationRequestStore.cs:848—P(limit + 1)is on the statement the single-request arm shares, and the trim at:871runs on the list the brief was read beside (:861-864); both arms hold only becauserequestId is not nullbounds the result at one row — pinned byReportsAndList_SayWhenTheWindowHeldMoreThanThePageand byInvestigationRequestStoreLiveTestsline 352 for the request-id arm.InvestigationBriefComposer.cs:423-425— kept length and omission count are two quantities from one call; pair them wrong and the marker overrunsFieldCharCaporcharslies — pinned byFieldCut_NeverSplitsASurrogatePair.DarlingMcpInvestigationTools.cs:683-685— the wire rename; an agent that readrequest_countsees a missing key — pinned byReportsAnswer_SaysWhenTheWindowOrTheJournalHeldMore, which asserts the old key is gone.
Traps near this diff
- "Truncation inferred from a full page … Fetch
limit + 1; the flag isfetched.Count > limit." — applies, and is what the diff implements (:120-121, store:868,:905). - "A
total_*assigned from a.Countis a count of the page … Otherwise it is*_returned." — applies: both page counts moved torequests_returnedbesidetruncated. - "A count taken off the page and named as the population's." — applies, honoured the stricter way: no population count is published at all, so none sits above the
LIMITto get wrong. - "A text cut at a UTF-16 index can land between the halves of a surrogate pair … Cut on
StringInfotext elements and count omitted characters in the same unit." — applies, and is the second half of the fix; the count is UTF-16 units, the unit the caps measure and the section budget spends (:204). - "
hours_back,limit,top,days_backare refused when out of range, never clamped." — does not apply:limitrefused at:105-106,hours_backat:578; the+ 1is on the fetch, after validation. - "
ORDER BY 3 + 4 DESCis not "ordinal 3 plus ordinal 4" … Sort by named columns." — does not apply: named columns with a tiebreak at store:355,:412,:425— which matters more now, since without themlimit + 1could return a different 100. - "Lite and Darling ship in parity … without the counterpart's is a finding, not a nit." — does not apply: the counterpart does not exist (
McpToolGuideHeads.Investigations.cs:18). - "Two copies of one definition pinned EQUAL to each other fail only after BOTH have drifted. Alias, do not copy." — not the trap, but the smell:
:121-122re-spellsMcpHelpers.BoundPage(McpHelpers.cs:660-665) line for line. Nothing pins them equal; reuse cleanup, commented inline, non-blocking. - "Ruling (#5833) … A trap is answered with a behaviour test that fails on the defect." — honoured: both tools sit outside
McpPageContractTests.PagedTools(no Lite twin) and carry behaviour tests instead. Where the censuses read thinner than they look isjournal_truncated— separate note, not a request for a pin.
Divergence (blind read vs PR body)
Three, none a behaviour difference; all posted as non-blocking comments.
- "what to do when
truncatedis true" overstates one of its three options.:547-549offers "narrowhours_backor passserver_nameorrequest_id". The page keeps the newest 100, so the dropped rows are the oldest: narrowinghours_backremoves more of that end and clears the flag without showing what it was about. The other two reach those rows;hours_backcannot, and there is nolimitto raise. - "the #3699 shape" without #3699's helper — the list arm hand-rolls
BoundPage; same behaviour, but the census's failure text names the helper as the binding. - The reports notice is not the only cause of a blank verdict — the list read is unwindowed (
alerts.js:793), the reports read is 168 hours (:794), so a request older than seven days shows no verdict either way and nothing says so. Pre-existing, made noticeable by the new notice.
Noted, not divergence: reports is the one array here with no bound of its own (ReportsSql, no LIMIT). Honest — an uncut array needs no flag — but the new enumeration of bounds is the whole disclosure while size is driven by up to 300 reports.
Reviewer checklist
- Build and run the suite: the test-plan figures and both mutation checks could not be executed here.
- Run the new live test against a real V184 store — the insert reads sound by eye, but only a live run proves the 100/101 and 200/201 boundaries.
- Page a busy window (
hours_back: 168, 100+ requests with reports) and measure the answer's size. - View each notice live and judge the wording in place, including the 168-hour edge.
- Decide whether the viewer's Investigations window owes the same disclosure (
LIMIT 500/200, no flag).
…ound (#5864) (#5866) Fixes #5864. The Query Store read-budget test for a cut that received nothing no longer requires the cut-off fetch to use its whole budget - the fetch only gets the time left in the budget and its cancel timer can fire a tick early, so the assertion had 4 to 14 ms to spare locally and none on a CI runner (it failed on dev's own Build after #5850 and on PR #5853's run). The test now checks what the code promises: a cut that received nothing is a fetch failure and the next pass asks for at most half as many plans. It still fails when the halving is broken.
Fixes #5834
Claude posting for Erik Darling.
Closes #5834.
Why
Two findings from PR #5819's third review round, both ruled real.
get_investigation_reportsread its window withLIMIT 100and answeredrequest_count: read.Requests.Count. A window holding 140 requests answeredrequest_count: 100, and nothing in the answer said the window held more. The reports and the journal are scoped to those 100 ids, so the omission was silent in three places. The journal had the same shape under its 200-row limit, andlist_investigation_requestshad the milder form (count = rows.Countunder the caller'slimit, no flag).InvestigationBriefComposer.CutCounter.Capcut each field at a raw UTF-16 index (value.AsSpan(0, FieldCharCap - CutMarker.Length)). When the cut landed between the two halves of an emoji's surrogate pair, the kept lone high surrogate serialized as U+FFFD, so the agent read a replacement glyph in the field it was briefed on.What changes
Commit d124514.
Finding 1, the #3699 shape (fetch limit + 1, return the page, flag the rest):
InvestigationRequestStore.ReadReportsAsyncreadslimit + 1requests andjournalLimit + 1journal rows, trims each to its page, and returnsRequestsTruncatedandJournalTruncatedonInvestigationReportsRead(two new optional record members, default false). The reports and the journal are still read for the returned requests only. No second statement and noCOUNT(*): the issue asks for the flag, not the window's true count.get_investigation_reportsanswersrequests_returned(renamed fromrequest_count),truncatedandjournal_truncated. Its description, after the guide marker so the 300-character head is unchanged, names the 100-request bound and what to do whentruncatedis true.list_investigation_requestsreadslimit + 1and answersrequests_returned(renamed fromcount) andtruncated. Its description names both.request_countor the list'scountexisted outside the tool (git grep). The web Investigations panel (wwwroot/js/pages/alerts.js) now shows a notice when the list, the 168-hour reports read, or a request's journal was cut. The desktop viewer reads its own SQL (ViewerDataService.Investigations.cs), not these answers, so it had nothing to move. The served instructions name neither field.Finding 2:
CutCounter.CapkeepsMcpHelpers.TextElementCutLength(value, FieldCharCap - CutMarker.Length)units, the #3625 rule already inPerformanceMonitor.Common. An emoji that straddles the cut is dropped whole, and one that ends at the cut is kept whole.truncated.charscounts the units not kept. That is the unitFieldCharCapandTotalCharCapcount (UTF-16 length), so the count matches what was dropped, including a dropped emoji's two units.Lite parity:
git grep -n 'FieldCharCap\|ReadReportsAsync\|ReportsRequestsSql' -- Liteprints nothing. Lite has no investigation tools and no brief composer, so there was nothing to mirror.Test plan
Behavior tests only, no source-text pins.
InvestigationToolsLiveTests.ReportsAndList_SayWhenTheWindowHeldMoreThanThePage(live, own scratch store): 101 requests in the window answerrequests_returned100 andtruncatedtrue on both tools; 100 answer 100 and false. 200 journal rows on the page's requests answer 200 andjournal_truncatedfalse, and 201 answer 200 and true. With the store'slimit + 1reverted tolimit, it fails (Assert.True() Failure, Actual: False).InvestigationBriefComposerTests.FieldCut_NeverSplitsASurrogatePair(two rows): with the cut index inside the emoji, the note composes with no U+FFFD and the emoji dropped whole. With the emoji ending at the cut index, it is kept whole.fields_cutis 1, andcharsis the units not kept. With the raw cut restored, the straddle row fails (Failed: 1of 20).DarlingMcpInvestigationToolsTests.ReportsAnswer_SaysWhenTheWindowOrTheJournalHeldMore(Guard): the answer carriesrequests_returnedand both flags, and norequest_count.AlertHistoryRangePageTests.TheInvestigationsPanel_SaysWhenAReadWasCut_AndNothingWhenNoneWas(node harness): the three notices appear when the reads saytruncated/journal_truncated, and none appears when they do not.InvestigationBriefComposerTests,DarlingMcpInvestigationToolsTests,AlertHistoryRangePageTests,EffectiveReadWindow*,McpToolGuideHeads*,McpHelpers*,InvestigationDispatcherTests):Total: 259, Errors: 0, Failed: 0, Skipped: 0.InvestigationToolsLiveTests,InvestigationRequestStoreLiveTests,InvestigationContractLiveTests:Total: 24, Errors: 0, Failed: 0, Skipped: 0.InvestigationToolsLiveTests,ViewerInvestigationsLiveTests,PgInvestigationBriefSourceReaderLiveTests:Total: 11, Failed: 0, Skipped: 0..github/scripts/pre-push.sh): GATE GREEN. Guard: DarlingTotal: 4157, Failed: 0, LiteTotal: 1134, Failed: 0. Selected classes: DarlingTotal: 2275, Failed: 0, Skipped: 84(live classes; the gate ran without a rig), LiteTotal: 701, Failed: 0.For the coordinator
request_count(reports) andcount(list) are renamed torequests_returned, as the issue asks. An external agent that read either key sees a missing key.AGENTS.example.mdreads onlystatus.Not done
Nothing from the brief.
SECTION: Fixed
ENTRY: - get_investigation_reports says when its window was cut, and the brief's field cut keeps emoji whole ([#PR]) - get_investigation_reports reported the 100-request page as the window's request count, and nothing said the window held more; list_investigation_requests and the journal had the same gap. Both tools now answer requests_returned with a truncated flag (journal_truncated for the journal), and the web Investigations panel says when a read was cut. A brief field cut could also split an emoji into a replacement glyph, and now keeps or drops it whole.