fix(workspace): a client no longer sees loop runs it cannot open or explain (#2423) - #2428
Conversation
obasilakis
left a comment
There was a problem hiding this comment.
One blocker, plus a process item the issue itself asked for.
Blocker
src/backend/client_portal/agent_page.py:334-340 — the filter is applied after the SQL LIMIT, so the client list starves.
db.get_agent_executions_summary(agent_name, limit=20) limits in SQL (executions.py:558-559, .order_by(started_at desc).limit(limit)), and loop rows are then dropped in Python. On this PR's head, an agent whose 20 newest rows are all loops yields zero rows from _recent_work(is_platform=False), and PortalAgentPage.vue:174/346 renders "Nothing yet." / "No activity in this window." while the operator sees 20. That is precisely the loop-heavy agent ent#458 describes — its own repro is "17 rows, mostly loops", which already leaves roughly five survivors. It trades "12 rows I can't explain" for a false claim of no activity. Over-fetch (limit * N, or effectively unbounded) and slice to limit after filtering.
Comments
- The product call the issue asked for was never recorded. #2423 says the direction "shouldn't be settled by whoever picks the issue up — either answer is defensible" and lists two contradicting options; the issue has zero comments, and this PR picks the hide direction and argues in the body that it isn't a new call. That flag is the author's own note on his own issue, self-answered with a documented rationale (the
agent_pagedocstring's subtractive rule), so nobody's stated plan is being overruled — but it still wants a second party's sign-off before merge, since that is what the issue asked for. _recent_workpost-filters while neithersuccess_ratenorfirst_tryis recomputed. The body documents thesuccess_ratedecision explicitly but is silent onfirst_try:client_portal/db.py:775-783counts all terminal rows, so a client's first-try rate keeps a loop-inclusive denominator. Consistent with the stated choice, just undocumented.- Issue #2423 carries
status-in-devwhile it is open and this PR is unmerged (set at PR-open). Should bestatus-in-progress; the merge automation setsstatus-in-dev. - Head is three commits behind
origin/dev(#2422, #2425/#2424). No file overlap and stillMERGEABLE.
Docs
docs/memory/feature-flows/workspace-agent-page.md is not updated, and this PR's subject is that file's subject:
:26-38, the "Exclusion by projection, not by template" table enumeratingrecent_workexclusions, does not mention the new principal-split loop exclusion.:217and:531are now factually false for a client — "a chat, loop or reminder row … keep trigger, duration and time", when a loop row no longer reaches a client at all.:228already anticipated this ("Gating it onprincipal.is_platformis one line away"), so the flow predicted the change and wasn't amended when it landed.
Also: docs/memory/requirements/core-agent.md:478's projection list is now incomplete, and there's no docs/memory/feature-flows.md recent-changes row (cf. the #2258 and #2424 rows). architecture.md is correctly untouched — no endpoint or schema change.
What's clean
Security greps all clean, base dev, three files, one conventional commit, Closes #2423 resolves same-repo so auto-promotion will fire, CI 20/20.
Tests are strong and I mutation-checked them myself: dropping the row filter turns two red, stale day totals one, stale headline one, and a fourth mutation I added (default flipped to is_platform=True) turns one red, so the fail-closed default is pinned. _without_hidden_buckets is sound — _BUCKET_ORDER (db/schedules/analytics.py:62-68) has an Other catch-all, so every execution lands in a bucket and sum(kept_totals) == total_executions; both by_type shapes are handled and hasChartActivity/bucketsForChart degrade correctly.
The pre-existing-failure claim also checks out: test_ent457_portal_turn_kwargs and test_ent457_portal_completion_report fail on origin/dev itself in a clean worktree, so this PR neither causes nor fixes them.
97f73d9 to
eef247c
Compare
Addressed —
|
obasilakis
left a comment
There was a problem hiding this comment.
Re-validated on eef247cc. Nine of the eleven items from the last pass are cleanly fixed — the flow's exclusion table and its :217/:228 claims, the requirements projection list, the index changelog row, the first_try denominator note, the issue's status-in-progress label, and the rebase (0 behind dev). The over-fetch itself is correctly implemented and properly pinned: reverting fetch = limit turns test_a_loop_heavy_agent_still_shows_its_other_work red, and dropping the [:limit] slice turns test_the_client_list_is_still_bounded red.
Direction 2 has my sign-off. Thanks for recording the call on the issue with the case against it — that is what #2423 asked for. The subtractive rule is this module's own, and direction 1 is genuinely a separate feature rather than the other half of this one.
Two things before it merges.
1. agent_page.py:354,366 — the starvation is narrowed, not closed, and the boundary is exact
fetch = limit if is_platform else limit * _CLIENT_OVERFETCH with MAX_RECENT_WORK = 20 and _CLIENT_OVERFETCH = 5 moves the threshold from 20 consecutive loop rows to 100. Above 100 the client list is not shorter, it is empty, and PortalAgentPage.vue:174/346 renders "Nothing yet." / "No activity in this window." again. Verified on head with a 150-loop / 5-chat fixture: client 0 rows, operator 20.
The reason I do not think a larger multiplier is the answer: models.py:2768 sets MAX_RUNS_LIMIT = 100, so a single loop at the documented maximum produces exactly 100 rows and exactly exhausts the over-fetch window. The constant lands precisely on the ceiling the loop feature ships with, and ent#458 rooms multiply that by participant. These are the agents the fix exists for, not a contrived depth.
The comment at :346-352 calls the outcome "honest — it is genuinely what that agent has been doing". That reasoning is right for a truncated list and does not carry to an empty one, which asserts something false.
Pushing the predicate into SQL — an exclude_triggers argument on get_agent_executions_summary, i.e. WHERE triggered_by NOT IN (:x) — closes this and the read amplification below in one move. Failing that, fetch iteratively until limit survivors or exhaustion.
Related, and the same change fixes it: the client path now pulls 100 rows of a ~21-column select that includes message, the full prompt text (db/schedules/executions.py:541), and portal_agent_page (router.py:590) carries no rate limit, unlike the sibling report-rows route. The comment at :341 says "it is a cap on the READ, so the cost is bounded either way" — bounded, but five times the bytes.
2. workspace-agent-page.md:544 — the Known Limitations line I cited by number is still there
Byte-identical to dev:
A chat, loop or reminder row has no equivalent safe label … so those rows keep trigger, duration and time.
For a client a loop row no longer reaches the page at all. This is the table a reader consults last and trusts most.
Three further sites the flow's own structure requires:
:472-536Tests — enumerates each test file and what it pins (test_ent360_*at:474,test_2161_*at:486,test_2162_*at:501).tests/unit/test_2423_client_loop_visibility.pyis absent.:446-471Files — carries a per-issue row for #2161 and #2162. There is no #2423 row foragent_page.py's new_without_hidden_buckets/_bucket_of/_total_ofand two constants, nor forrouter.py.:97-111The two AC #3 metrics — the section that exists to explainsuccess_rateagainstfirst_tryis where the now-divergent denominator belongs. Recording it only in the index changelog row puts it where nobody reading those metrics will look.
Worth deciding, not blocking: the strip now contradicts itself
_stats re-derives total_executions (agent_page.py:142) but leaves success_rate (:216) and first_try (:228) over the unfiltered set, and PortalAgentPage.vue:50/54/58 renders all three side by side. On a loops-only agent that reads:
0 tasks · 100% completed · 100% first try
Before this PR it read 150 / 100% / 100% — wrong in a different way, but at least internally consistent. This is the PR's own objection ("a number with nothing behind it") re-created one tile over, now as percentages.
Filtered numerator over unfiltered denominator is correctly rejected in the body. The third option was not considered: render — for both rates when the client-visible count is 0. pct() already renders — for null (PortalAgentPage.vue:578), so it is a null rather than a UI change.
Same class one field further along: agent_page.py:493 _last_active reads the newest execution unfiltered, so a loops-only agent shows a client "Last active 2 minutes ago" above "Nothing yet."
Smaller note
agent_page.py:146-158 _bucket_of — for the dict-shaped by_type it says it tolerates, the iteration walks keys, _total_of returns 0 for each, and the result is total_executions: 0 above a chart summing 5. That is precisely the headline-disagrees-with-the-chart inconsistency _without_hidden_buckets exists to prevent, arriving through the branch meant to be defensive. The accessor emits the list shape today (db/schedules/analytics.py:642) so it is latent — either handle the dict explicitly or drop the tolerance and let the existing #2161 test pin the shape.
What is clean
The filter is in the service (agent_page.py:366, :212) rather than in Vue, which is this module's stated principle; it is keyed on principal.is_platform, the same flag _require_roster and get_agent_card already use; the default is is_platform=False and is pinned by test_the_client_view_is_the_default; hiding produces absence rather than a distinguishable 403, so Invariant #8 is intact; _schedule_names receives already-filtered rows so no schedule-id lookups leak for hidden rows; and there is a single call site, with no portal loops endpoint to become a second surface. Security greps all clean, Closes #2423 resolves same-repo so promotion will fire, CI 20/20.
/review — fresh pass on
|
obasilakis
left a comment
There was a problem hiding this comment.
Re-reviewed on c103eec7. Both blockers are closed, and the first one is closed better than I asked for.
1. The starvation is gone, not narrowed. exclude_triggers on get_agent_executions_summary puts the predicate in the WHERE, so the LIMIT applies to rows that already survived it — no multiplier to outgrow, and the client page drops back to fetching MAX_RECENT_WORK rows instead of 100 of a 21-column select carrying message. _last_active carries the same exclusion, which closes the "active 2 minutes ago" above "Nothing yet." I raised separately. Not excluding NULL triggered_by is the right call and the reason is correctly stated at the accessor: NOT IN yields NULL for a NULL left side, and an unclassified row is not a hidden one.
tests/unit/test_2423_executions_summary_exclude.py is the test this needed — a real SQLite through the real engine, because no Python stub can prove WHERE precedes LIMIT, and the 100-loop case is MAX_RUNS_LIMIT rather than a contrived fixture. The newest-surviving-rows and existing-callers-unchanged cases matter as much as the headline one.
2. Docs. All four sites landed — the Known Limitations row removed, the Tests section carrying both new files, the Files table carrying the #2423 rows, and the two-metrics section explaining the withholding. The rating-tally correction is a good catch that was not asked for; that paragraph had been false since ent#366.
3. The self-contradicting strip. Withholding both rates at exactly zero visible executions is the third option and the right one — pct() already renders null as an em-dash (PortalAgentPage.vue:595), so it is a null rather than a UI change, and moving first_try_stats below the gate saves the round-trip on the one case that discards it.
4. The drift guard. test_the_hidden_trigger_and_the_hidden_bucket_cannot_drift derives {_TRIGGER_BUCKETS[t] for t in _CLIENT_HIDDEN_TRIGGERS} from the real map, so it reds on a rename in either file rather than pinning a second copy of the literal. That is the guard the comment was asking for, and the learnings.md entry generalises it correctly.
_bucket_of's tolerance is now justified by what it is actually for — an unparseable row must still reach _total_of and be counted, so keeping it is not defensive padding.
One thing before it merges
docs/memory/feature-flows/workspace-agent-page.md — the prose block sits inside the "What must not ship" table, and it breaks it. Lines 47–51 on head:
NULL `triggered_by` is explicitly NOT excluded. ... an unclassified row is not a hidden one.
| `asks` | `context`, and `alert`-type items | ... |
| report detail | any report in the install | ... |
A pipe line immediately after a paragraph line is not a table — GFM needs a header plus delimiter row, and there is no blank line either — so the asks and report detail rows render as literal text with pipes in them. That is the table documenting the context credential-leak exclusion (canary G-04) and the report-id ownership check, i.e. the two entries a reader is most likely to consult. It predates this pass (it arrived with eef247cc) but it is this PR's, and the fix is to move the whole prose block below the last table row.
Non-blocking
_stats' withheld branch returns"buckets": []and"by_type": []while the normal path returnsa.get(...). Equivalent today becauseais already filtered, but the two shapes are written differently, so a future change to_without_hidden_bucketsonly lands on one of them.- The zero-gate fires on a genuinely empty window too (a brand-new agent), which is the same em-dash it already showed there. Worth a sentence in the flow doc so nobody later reads the branch as loops-specific.
Status
CI is still running on head — six pytest matrix jobs IN_PROGRESS as of this comment, everything else green. Approving on the doc fix plus a green matrix.
obasilakis
left a comment
There was a problem hiding this comment.
Approved.
Both blockers are closed — the exclusion is in SQL ahead of the LIMIT, _last_active carries it too, the rates are withheld rather than zeroed, and the trigger/bucket correspondence is derived from _TRIGGER_BUCKETS rather than restated. The real-SQLite accessor test is the right proof and the 100-row case is MAX_RUNS_LIMIT, not a contrived fixture.
Before merge, please push: docs/memory/feature-flows/workspace-agent-page.md — the prose block sits inside the "What must not ship" table, so the asks and report detail rows follow a paragraph line with no header and render as literal pipe text. Move the prose below the last table row. That table is where the context credential-leak exclusion and the report-id ownership check are documented.
Also confirm the pytest matrix goes green — six jobs were still IN_PROGRESS when I reviewed.
…30 review) The reviewer's one condition before merge. `architecture.md`'s 'Two callers, one rule' bullet said the CAS-win rule was 'guarded now by enumerating every caller rather than the one route ent#329 knew about, so a third site inherits the rule instead of re-losing it'. No such guard existed: `test_dispatch_hangs_off_the_cas_win_only` read exactly one hardcoded file, `routers/operator_queue.py` — so `client_portal/asks/service.py`, the caller this PR adds and the one that LOST the rule, was outside its reach. A sentence claiming protection that is not there is worse than no sentence: the next person adding a dispatch site reads it and stops looking. This is the shape #2428 filed a learnings entry about this morning — a comment that names a failure mode is a request for a guard — so it lands the same way. DISCOVERED, NOT LISTED. `_dispatch_call_sites` walks the backend tree for callers, because a hardcoded list structurally cannot catch the case that matters: the file it would need to check is the one being added. ASSERTED AGAINST CODE, NOT FILE TEXT — and this is the part I got wrong first. The initial version tested `"_status_conflict" in source` against the raw file and MUTATION PROVED IT BLIND: deleting the check from the `if` still passed, because the long comment above it explaining the race still contained the string. A source-substring guard cannot tell a check from a paragraph about the check — the same defect the guard exists to prevent, inside the guard. It now parses each dispatching function and compares `ast.unparse` output, where comments do not survive. Verified by three mutations, each caught: 1. delete the check in asks/service.py, keep the comment -> FAIL 2. neuter the check in routers/operator_queue.py -> FAIL 3. add a brand-new third caller with no check at all -> FAIL and all 23 pass on the real tree. `test_the_discovery_walk_finds_both_known_callers` pins the floor, so a rename of the helper cannot leave the loop iterating an empty list and passing in silence — the failure a discovery guard trades for the one it fixes. ALSO (non-blocking, from the same review): `WorkspaceAsk.status`'s comment still read 'pending | expired (terminal ones are not listed)' after `_status_of` gained a third value. Corrected to say where each value is reachable from. The remaining non-blocking item — `resume_requested` and the new `answered` status are unconsumed by any surface — is deliberately NOT in this commit. It is a product decision about where a transient confirmation lives, and it is filed so it stays a decision rather than becoming an oversight. Related to Abilityai/trinity-enterprise#430 Related to Abilityai/trinity-enterprise#329 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
c103eec to
88f00bc
Compare
…xplain (#2423) The Workspace told a client its agent had run 12 loops — a `Loops 12` legend entry and a run of rows saying `Loop`, with per-run durations — and gave it nowhere to go. No way to open a loop, see what one produced, or start and stop one. Verified against a real client session, not inferred: client-visible by_type: {'Public': 4, 'Loops': 12, 'Chat/Tasks': 1} recent_work : 17 rows, mostly triggered_by='loop' Four reasons there was nowhere to follow up: the loops strip is `isPlatformSession`-gated (ent#458, correctly — loops are an operator capability); even for an operator that strip shows status and Stop, never output; the Workspace agent page has no Loops tab; and per-run results live only on operator Agent Detail and Operations. So the loop COUNT was client-visible while the loop OUTPUT was operator-only. THE DIRECTION IS NOT A NEW PRODUCT CALL. The issue left "show it" vs "hide it" open deliberately. `agent_page`'s own docstring already answers it: "It reports; it does not configure ... The viewer may be an external client, not an operator." The module is subtractive by design — it projects away `message`, `cost`, `model_used`, `source_user_email`, and already drops `alert` asks as "operations telemetry, not something the agent is asking a person". A loop run is the same kind of thing. This follows that rule rather than inventing a second one. NOT SUBTRACTIVE FOR EVERYONE. The same page serves a platform user, who CAN click through to Agent Detail -> Loops and read every run, so hiding it from them removes real signal and fixes nothing. Split by principal, using the `is_platform` the route already resolves for `get_agent_card` — the same pattern as the roster and `_require_roster`. The client view is the DEFAULT, so a caller that forgets to say who is looking gets the projection that leaks least. Both halves or neither: the rows and the chart are filtered together. Removing the rows and leaving `Loops 12` in the legend would be worse than doing nothing — a number with nothing behind it. Day totals and the headline are RE-DERIVED, because a bar labelled 13 whose segments sum to 1 reports its own filtering as missing data. `success_rate` is deliberately NOT recomputed: it is a ratio over terminal rows this function cannot see, and a filtered numerator over an unfiltered denominator would be worse than a figure that is merely broad. One trap worth recording: the two `by_type` fields have DIFFERENT shapes under one name — the top-level total is a LIST of `{"bucket","total"}` rows while each timeline day carries a DICT of `{bucket: count}`. The first draft handled only the dict and crashed the whole page on a real payload; my own test fixture had the same wrong shape, so it passed against something the accessor never emits. `test_2161_agent_page_ux` caught it. Both are corrected, and the helper is now tolerant of either form with unrecognised rows KEPT — silently hiding a row we failed to parse would be the opposite of this function's job. Verification: 410 passed on the portal/agent-page selection. Mutation-checked — dropping the row filter, leaving day totals stale, and leaving the headline stale each turn the suite red. Pre-existing and NOT from this branch: `test_ent457_portal_turn_kwargs` and `test_both_portal_row_creation_sites_name_the_chat` fail on `dev` today (backend-unit-test is red there); both are fixed in #2427. Closes #2423 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…client list (#2423) The blocker is real and is worse than the bug this PR fixes. `get_agent_executions_summary` limits in SQL; the loop filter runs in Python. So an agent whose newest 20 rows are all loops yielded ZERO rows and the page rendered "Nothing yet." / "No activity in this window." while the operator saw twenty. Trading "rows I cannot explain" for "this agent has done nothing" swaps a confusing surface for a false one. And it is not an edge: ent#458's own repro is "17 rows, mostly loops", which already leaves about five survivors. It is the normal shape for exactly the agents this feature exists for. Fixed by over-fetching on the side that filters (`MAX_RECENT_WORK * _CLIENT_OVERFETCH`) and slicing back to `MAX_RECENT_WORK` after. Three properties: the operator side does NOT over-fetch (nothing is filtered there, so the extra read is pure waste); the cap MOVES rather than disappearing, so one client's page cannot cost a whole agent history; and past that multiplier the list is genuinely shorter, which is honest — it is what that agent has been doing, and the chart beside it carries the same window. Three tests, mutation-checked: reverting to limit-then-filter turns two red, and over-fetching without the slice turns the bound red. Also from the review: * `first_try` is not recomputed either, and the body documented only the `success_rate` decision. Same reasoning, now stated in both the flow doc and the changelog row: a filtered numerator over an unfiltered denominator is worse than a figure that is merely broad. * `workspace-agent-page.md` updated at all three sites named — the exclusion table gains the principal-split row plus the LIMIT interaction, `:217`'s "chat, loop, reminder" no longer claims loop rows reach a client, and `:228`'s "gating it on `principal.is_platform` is one line away" now records that #2423 took exactly that route. * `requirements/core-agent.md`'s projection list gains the exclusion. * `feature-flows.md` gains a recent-changes row, in the #2258/#2424 shape. Related to #2423 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…front of it (#2423) Review pass 2, both blockers plus the three non-blocking findings. BLOCKER 1 — the filter still ran after the LIMIT. The first fix over-fetched `MAX_RECENT_WORK * 5 = 100` rows and filtered in Python. That does not remove the starvation, it moves it from 20 rows to 100 — and the constant loses, because `models.MAX_RUNS_LIMIT` is 100: ONE loop at its documented maximum emits exactly 100 consecutive rows and fills the entire over-fetch window. The client page then reads 'Nothing yet.' for an agent that has been working all day. A reviewer picks the multiplier; the product picks the run length. `get_agent_executions_summary` now takes `exclude_triggers` and adds it as a WHERE, so the LIMIT applies to rows that already survived the filter. Starvation is gone at ANY run length, and so is the extra read — the client page fetches exactly `MAX_RECENT_WORK` rows, like the operator page, instead of five times as many to discard most. NULL `triggered_by` is explicitly not excluded: the column is NOT NULL, but SQL `NOT IN` yields NULL for a NULL left side and the row would silently vanish. An unclassified row is not a hidden one. `_last_active` carries the same exclusion. Reading the newest row unconditionally reported a loop run's timestamp to a client for whom that row does not exist — a header saying 'active 2 minutes ago' above a list whose newest entry is yesterday's. At `limit=1` no over-fetch is even conceivable, which is what makes it the clearest case for the SQL filter. BLOCKER 2 — the docs. Seven sites, not the four the review found: - the LIMIT paragraph (now SQL, with why the multiplier could not work) - `feature-flows.md` index line (still said 'over-fetches and slices back') - the Files table and the Tests section (no entry for any of this work) - and FOUR copies of a claim that has been false since ent#366 shipped — the flow doc, its Known Limitations table, `requirements/core-agent.md`, and `build_page`'s own docstring all said 'there is no rating, thumbs or feedback mechanism anywhere in Trinity', while this file's own `_rating_tally` reads `agent_evaluations` twelve lines away. All four corrected, each noting it claimed the opposite for two releases. NON-BLOCKING. The stats strip contradicted itself: `success_rate` and `first_try` are deliberately not re-derived over the filtered set (a filtered numerator over an unfiltered denominator is worse than a figure that is merely broad) — but that argument holds only while there is visible work to be broad ABOUT. On an agent whose window is entirely loops it read '0 executions - 89% success - 33/37 first try': three numbers describing work the same strip says did not happen. Both are now WITHHELD at exactly zero (null, which the UI renders as an em-dash), never zeroed — 0% reads as 'it fails every time'. One surviving row keeps the broad figures; operators are never subject to it. `_bucket_of`'s docstring justified its tolerance partly by 'a test double has used a bare mapping'. Production shape is not a test artifact: the real reason is that `_without_hidden_buckets` re-derives `total_executions` from what survives the call, so an unparseable row must still reach `_total_of` and be counted. TESTS. `test_2423_executions_summary_exclude.py` is new and drives the REAL accessor against a real SQLite through the real engine — the existing file can only prove `_recent_work` asks for the exclusion, and no Python stub can prove the WHERE precedes the LIMIT, which is the entire fix. Its load-bearing case inserts 100 loop rows (one loop at `MAX_RUNS_LIMIT`, not a pathological fixture) and was verified to FAIL when the filter is moved back after the limit. Plus: newest-not-oldest, NULL triggers, multiple excluded triggers, agent scope, and that every existing caller passing nothing sees exactly what it saw. Schema is derived from the same metadata the accessor selects from, per the #918 fixture lesson. The existing file's stubs all now model SQL faithfully through one `_sql_like` helper — a stub that limits first and filters second IS the bug under test and would pass against the broken implementation. Related to #2423 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…part (#2423) Self-review findings, both non-blocking. THE DRIFT GAP. `_CLIENT_HIDDEN_TRIGGERS = {"loop"}` and `_CLIENT_HIDDEN_BUCKETS = {"Loops"}` are two vocabularies for one decision — a `triggered_by` value and the display label `db/schedules/analytics.py::_TRIGGER_BUCKETS` maps it to. The comment beside them correctly says a single constant 'would hide that a rename on either side breaks the pair', and then left nothing to notice the break: no test related the two. Rename that label to 'Agent loops' and this change half-reverts in silence — the chart shows loop counts to a client again while `_recent_work` still hides the rows, which is exactly the 'legend reads 12 above a list that says nothing' contradiction the whole change exists to remove. Nothing anywhere fails. Guarded by DERIVING the expected bucket set from `_TRIGGER_BUCKETS` rather than writing the literal a second time, so it fails on a rename in either file. It also asserts every hidden trigger IS a mapped trigger, catching a row type hidden with no bucket corresponding to it. Mutation-verified: renaming the label fails the guard, reverting passes. The constants stay separate, which was the right call — the guard removes the drift without collapsing two genuinely different vocabularies into one name. A WASTED QUERY. `first_try_stats` was computed above the zero-suppression gate and the withheld branch returns a hardcoded zero dict, so a client viewing a fully-hidden agent paid one DB round-trip for a value that could not be used. Moved below the gate. Related to #2423 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rd, not a comment (#2423) The comment beside the two constants named the exact failure mode — a rename on either side breaks the pair — and left nothing to notice it. The durable rule is that the split is fine but must be paired with a test deriving one constant from the other through the real mapping, so a rename in either file reds; restating the literal in a test only pins the file it lives in. Related to #2423 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
88f00bc to
c845f01
Compare
|
Resolve by running |
Journey Impact: none: narrows what one existing surface reports to one principal kind — no user-facing promise is added or extended.
Closes #2423
The problem
The Workspace told a client its agent had run 12 loops — a
Loops 12legend entry and a run of rows sayingLoopwith per-run durations — and gave it nowhere to go.Verified against a real client session, not inferred:
Four reasons there was nowhere to follow up:
isPlatformSession-gated (ent#458, correctly — loops are an operator capability)So the loop count was client-visible while the loop output was operator-only.
The direction is not a new product call
The issue left show it vs hide it open on purpose.
agent_page's own docstring already answers it:The module is subtractive by design — it projects away
message,cost,model_used,source_user_email, and already dropsalertasks as "operations telemetry, not something the agent is asking a person." A loop run is the same kind of thing. This follows that rule rather than inventing a second one.But not subtractive for everyone
The same page serves a platform user, who can click through to Agent Detail → Loops and read every run — so hiding it from them removes real signal and fixes nothing.
Split by principal, using the
is_platformthe route already resolves forget_agent_cardone line above. Same pattern as the roster and_require_roster. The client view is the default, so a caller that forgets to say who is looking gets the projection that leaks least.Both halves or neither
Rows and chart are filtered together. Removing the rows and leaving
Loops 12in the legend would be worse than doing nothing — a number with nothing behind it.Day totals and the headline are re-derived: a bar labelled 13 whose segments sum to 1 reports its own filtering as missing data.
success_rateis deliberately not recomputed — it's a ratio over terminal rows this function cannot see, and a filtered numerator over an unfiltered denominator would be worse than a figure that is merely broad.A trap worth recording
The two
by_typefields have different shapes under one name:My first draft handled only the dict and crashed the whole page on a real payload — and my own test fixture had the same wrong shape, so it passed against something the accessor never emits.
test_2161_agent_page_ux::test_stats_forward_the_canonical_bucket_ordercaught it.Both are corrected. The helper now tolerates either form, and an unrecognised row is kept — silently hiding a row we failed to parse would be the opposite of this function's job.
Verification
Mutation-checked — each turns the suite red on its own:
Not from this branch
test_ent457_portal_turn_kwargsandtest_both_portal_row_creation_sites_name_the_chatfail ondevtoday —backend-unit-testis red there. Both are repaired in #2427; this branch neither causes nor fixes them.