Skip to content

fix(workspace): a client no longer sees loop runs it cannot open or explain (#2423) - #2428

Merged
dolho merged 5 commits into
devfrom
fix/2423-client-loop-visibility
Sep 1, 2026
Merged

dolho merged 5 commits into
devfrom
fix/2423-client-loop-visibility

Conversation

@dolho

@dolho dolho commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

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 12 legend entry and a run of rows saying Loop with per-run durations — and gave it nowhere to go.

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, run count and Stop — never output
  • the Workspace agent page has no Loops tab
  • per-run results live only on operator Agent Detail → Loops and Operations → Executions

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:

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.

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_platform the route already resolves for get_agent_card one 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 12 in 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_rate is 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_type fields have different shapes under one name:

"by_type": by_type_totals,   # top level: LIST of {"bucket", "total"}
"by_type": by_type,          # per day:   DICT  {bucket: count}

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_order caught 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

pytest -k "portal or 2423 or 2160 or 2161 or 2169 or ent360"   → 410 passed
tests/unit/test_2423_client_loop_visibility.py                 → 11 passed

Mutation-checked — each turns the suite red on its own:

Mutation Result
Drop the row filter 2 failed
Leave day totals stale 1 failed
Leave the headline stale 1 failed

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 repaired in #2427; this branch neither causes nor fixes them.

@obasilakis obasilakis left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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_page docstring'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_work post-filters while neither success_rate nor first_try is recomputed. The body documents the success_rate decision explicitly but is silent on first_try: client_portal/db.py:775-783 counts 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-dev while it is open and this PR is unmerged (set at PR-open). Should be status-in-progress; the merge automation sets status-in-dev.
  • Head is three commits behind origin/dev (#2422, #2425/#2424). No file overlap and still MERGEABLE.

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 enumerating recent_work exclusions, does not mention the new principal-split loop exclusion.
  • :217 and :531 are 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.
  • :228 already anticipated this ("Gating it on principal.is_platform is 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.

@dolho
dolho force-pushed the fix/2423-client-loop-visibility branch from 97f73d9 to eef247c Compare August 28, 2026 13:29
@dolho

dolho commented Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

Addressed — eef247cc, rebased onto dev

The blocker is real, and worse than the bug I fixed

get_agent_executions_summary limits in SQL; my filter ran in Python. An agent whose newest 20 rows are all loops yielded zero rows and rendered "Nothing yet." while the operator saw twenty — trading rows I can't explain for this agent has done nothing. The first is confusing; the second is false.

And you're right that it isn't an edge: ent#458's own repro ("17 rows, mostly loops") already leaves ~5 survivors. It's the normal shape for exactly the agents this feature exists for.

Fixed by over-fetching on the side that filters and slicing back:

fetch = limit if is_platform else limit * _CLIENT_OVERFETCH   # 5x
...
rows = [r for r in rows if r.get("triggered_by") not in _CLIENT_HIDDEN_TRIGGERS][:limit]

Three properties I'd call out: the operator side does not over-fetch (nothing is filtered there, so it'd be pure waste); the cap moves rather than disappears, so one client's page can't cost a whole agent history; and past the multiplier the list is genuinely shorter — which is honest, since that is what the 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, over-fetching without the slice turns the bound red.

first_try

Correct — same reasoning as success_rate, and I documented only one of them. Now stated in both the flow doc and the changelog row: a filtered numerator over an unfiltered denominator is worse than a figure that's merely broad.

Docs

All the sites you named:

Label

Fixed → status-in-progress. That was my error at PR-open.

Rebase

Done — and #2427 merged in the meantime, so the two pre-existing failures are gone. This branch is now 428 passed, 0 failed on the portal + ent#457 selection.


The product call — you're right to hold it

The issue asked for a second party's sign-off and I self-answered. My reasoning is in the PR body: agent_page's own docstring already decides it ("it reports; it does not configure … the viewer may be an external client"), and it already drops alert asks as "operations telemetry, not something the agent is asking a person" — so hiding follows the existing rule where showing would need a new one. The operator split is what keeps it from being a pure subtraction.

But that's me agreeing with me. @obasilakis — does the hide direction sit right with you, or would you rather loop output reached clients through deliverables (ent#365/#425)? Happy to hold the merge on that.

Thank you for mutation-checking the tests independently, and for the extra is_platform=True-default mutation — that's a better pin than the one I wrote.

@obasilakis obasilakis left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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-536 Tests — 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.py is absent.
  • :446-471 Files — carries a per-issue row for #2161 and #2162. There is no #2423 row for agent_page.py's new _without_hidden_buckets / _bucket_of / _total_of and two constants, nor for router.py.
  • :97-111 The two AC #3 metrics — the section that exists to explain success_rate against first_try is 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.

@dolho

dolho commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

/review — fresh pass on a4064a23

Branch: fix/2423-client-loop-visibility → dev · +865/-34 across 9 files
Scope: CLEAN — service, its facade, one db accessor, the router flag, two test files, three docs
Base: merge-base 135248e9

Re-reviewing my own fix commit. Both pass-2 blockers are closed, and I checked the load-bearing one by mutation rather than by reading:

  • The filter is in SQL. get_agent_executions_summary(..., exclude_triggers=) adds a WHERE, so the LIMIT applies to rows that already survived it. Moving the filter back after the limit makes test_one_max_length_loop_does_not_hide_everything_else fail — verified, not assumed. Starvation is gone at any run length, and the extra read went with it (client and operator both fetch exactly MAX_RECENT_WORK).
  • The docs. Seven sites, including four repeating a rating-tally claim false since ent#366 — one of them build_page's own docstring, twelve lines from _rating_tally.

One new finding, fixed in the same push, plus one minor.


I1 → fixed: the two halves of one decision had nothing connecting them (Confidence 9/10)

_CLIENT_HIDDEN_TRIGGERS = frozenset({"loop"})    # a triggered_by value
_CLIENT_HIDDEN_BUCKETS  = frozenset({"Loops"})   # a display label

The comment beside them says a single constant "would hide that a rename on either side breaks the pair" — correct, and then it left nothing to notice the break. "Loops" is produced by db/schedules/analytics.py::_TRIGGER_BUCKETS["loop"], and I grepped: no test relates the two.

Rename that label to "Agent loops" and this PR half-reverts in silence — the chart shows loop counts to clients again while _recent_work still hides the rows, which is exactly the "legend reads 12 above a list that says nothing" contradiction the change exists to remove. Nothing anywhere fails.

Fixed with a guard derived from the real map rather than a second copy of the literal:

expected = {_TRIGGER_BUCKETS[t] for t in page._CLIENT_HIDDEN_TRIGGERS}
assert page._CLIENT_HIDDEN_BUCKETS == expected

It also asserts every hidden trigger is a mapped trigger, so hiding a row type with no corresponding bucket is caught too. Mutation-verified: renaming the label fails the guard; reverting passes.

I2 → fixed: a DB round-trip on the path that discards it (Confidence 8/10)

first_try_stats(agent_name, hours) 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 for a query whose result could not be used. Moved below the gate.


Clean, with what was checked

  • Index — idx_executions_agent_started ON schedule_executions(agent_name, started_at DESC) (db/schema.py:1609) still drives the ordering; the new triggered_by predicate is a filter applied while walking it, so a loop-heavy agent reads more index entries but does not lose the index. This was the thing worth checking before moving a filter into SQL.
  • .where() after .limit() — reads like a pipeline, isn't one: SQLAlchemy composes a statement, so the emitted SQL is WHERE … ORDER BY … LIMIT. Proven by test_one_max_length_loop_does_not_hide_everything_else against real SQLite, and by the mutation.
  • NULL triggered_by — explicitly admitted via or_(is_(None), notin_(...)). NOT IN yields NULL for a NULL left side and the row would vanish; the column is NOT NULL, so this is defence, and it is tested.
  • Every existing caller — routers/schedules.py:825 and _last_active pass no exclude_triggers; if exclude_triggers: treats None and frozenset() alike, so no WHERE is added and behaviour is byte-identical. Pinned by test_no_exclusion_is_the_unchanged_behaviour.
  • Fail-closed default — is_platform=False on _stats, _recent_work, _last_active and build_page; a caller that forgets gets the projection that leaks least, and test_the_client_view_is_the_default pins it. router.py:615 is the only production call site.
  • Auth — no gate changed. The split is a projection inside an already-roster-gated route, on the same principal.is_platform get_agent_card keys on.
  • Withheld ≠ zeroed — the zero branch returns rate: None (UI em-dash), never 0, and unavailable: False because the read succeeded. Both asserted.
  • Test stubs model SQL — every stub in the older file routes through one _sql_like helper that filters then limits. A stub doing it the other way round is the bug under test and would pass against the broken implementation; that is why the accessor gets its own real-SQLite file.

Summary

  • Critical: 0
  • Informational: 2 — both fixed in this push
  • Scope: clean

@obasilakis obasilakis left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 returns a.get(...). Equivalent today because a is already filtered, but the two shapes are written differently, so a future change to _without_hidden_buckets only 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 obasilakis left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

dolho added a commit that referenced this pull request Aug 31, 2026
…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>
@dolho
dolho force-pushed the fix/2423-client-loop-visibility branch from c103eec to 88f00bc Compare August 31, 2026 12:43
dolho and others added 5 commits August 31, 2026 16:26
…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>
@dolho
dolho force-pushed the fix/2423-client-loop-visibility branch from 88f00bc to c845f01 Compare August 31, 2026 13:27
@github-actions

Copy link
Copy Markdown

⚠️ Nightly unit-suite check skipped — merge conflict against dev.

Resolve by running git merge dev locally and pushing the result. The next nightly run will re-test once the conflict is gone.

@dolho
dolho merged commit a1f3a6e into dev Sep 1, 2026
28 checks passed
vybe added a commit that referenced this pull request Sep 17, 2026
* fix(workspace): the ask badge said 2 and gave you no way to find them (#2424)

The sidebar advertised "2 asks are waiting on your answer" and then stranded
you: the agent that raised them carried no badge, its tooltip did not mention
them, and it could be collapsed out of the roster entirely. The only way to
locate a blocked agent was to open agents one at a time.

Observed on a 12-agent roster with two asks on ws-sage (11th of 12), so on a
fresh load the one row that mattered was behind the "show more" toggle.

Three failures, fixed together because separately each is a half-measure — a
badge with no destination, or a destination nobody can see.

1. The unit. `askCount` is `openAsks.length`, and the tooltip said "agents":
   two asks on ONE agent rendered as "2 agents are waiting on your answer". The
   number was right, the noun was wrong, and they only diverge when a single
   agent raises more than one ask — which is why it went unnoticed. Resolved
   toward ASKS rather than agents, because the row badges added here now answer
   "which agent", leaving the header to answer "how many decisions".

2. The row. `PortalSidebar.vue:139` renders a per-agent badge from
   `unreadByAgent` — unread REPLIES. Keeping asks out of that count is
   deliberate and documented at line 9 ("one is waiting on you to decide, the
   other on you to read"), and is preserved: the ask gets the *own badge* that
   comment promised, in `status-urgent` — the token the operator NavBar's
   pending-operator-queue badge already uses, so the two surfaces agree — and
   visually distinct from the indigo unread pill beside it. `agentRowTitle` had
   the same hole, so this is an accessibility fix too: a blocked agent's
   accessible name was the bare "Open ws-sage".

3. The collapse. #2159 capped the roster at five for a good reason (a long
   fleet pushed chats below the fold), but the slice is plain roster order with
   no ask weighting. Ask-bearing agents are now never hidden — appended, NOT
   floated to the top, because re-sorting on a transient count moves rows under
   the cursor between refreshes, the same reason the roster is not re-sorted by
   availability.

Not a regression: every piece shipped in its intended form; the gap was between
them.

Everything decidable moved into `portalUtils` (`asksByAgent`, `askBadgeTitle`,
`agentRowTitle`, `visibleAgentRows`, `AGENT_COLLAPSE_LIMIT`) because vitest runs
`environment: 'node'` with no mount harness — a rule inside the SFC is one no
test can reach, which is how all three of these shipped. Mutation-checked:
reverting the noun, dropping asks from the title, and restoring the plain slice
each turn the suite red.

`bg-amber-500` -> `bg-status-urgent-500` is required, not drive-by: new code must
be at zero raw palette classes, so the new badge needed a token, and the header
had to match it or the two ask indicators would differ. Amber maps to
`state-autonomous` (an operating mode), which is the wrong claim. PortalSidebar
is now at zero non-gray raw classes.

Two pre-existing guards asserted the moved expressions as source strings and are
rewritten to assert the properties behaviourally — strictly stronger, since they
now fail on a broken bound or a dropped chip title, not only on a reworded one:
- portalRosterRow #2159 "shows a fixed number by default"
- portalAvailabilityChip #2196 "row title carries the state"

Verification: 1518/1518 frontend unit tests, raw-color ratchet exit 0,
production build clean.

Closes #2424

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(workspace): the sync portal turn never carried its session, so report-back could not fire (#2426)

ent#457 gave the Workspace a report-back: an agent that delegates during a chat
turn gets the completion posted into that thread. It could not fire on the
SYNCHRONOUS path, because the parent execution never received the session
binding the report needs. `report_completion` gates on
`if not source_channel_chat_id`, and there the field was NULL.

Measured on a dev instance — 5 of 8 portal rows NULL, split exactly by path:

    07:55 -> 09:04   chat=7d27744d...   browser, streaming path
    09:06 -> 09:09   chat=NULL          POST .../chat, synchronous path

TWO CORRECT CHANGES THAT COLLIDE. ent#457 passes the binding down, and
`execute_task` persists it — but only inside `if not execution_id:`. ent#365's
`_precreate_sync_execution` has already created the row and handed the id over,
so that branch never runs, and the pre-create stamped only `source_channel`.
Its own docstring named the invariant it broke: "Mirrors `start_portal_turn`'s
creation exactly ... so the two paths produce indistinguishable rows and a
report published from either can be joined back to its chat."

The sibling comment in `start_portal_turn` says "both creation sites or the
stamp is a coin flip depending on which path made the row" — ent#457 covered the
two sites that existed when it was written; ent#365 had added a third.

Fix: stamp `source_channel_chat_id` + `source_channel_client` in the pre-create.
`session_id` is a REQUIRED parameter, not an optional one — the value is in
scope at the only call site, and a default would let a future caller silently
reintroduce the inert row. Rejected: teaching `execute_task` to UPDATE an
adopted row, which widens a hot path used by every trigger to repair one
caller's omission.

ALSO REPAIRS TWO GUARDS THAT WERE RED ON `dev`. `backend-unit-test` is failing
on dev right now; both failures are in this feature area and both are guards
that had gone inert, so they are fixed here rather than left for the next PR to
trip over. Frontend-only PRs pass because the `changes` job path-filters the
backend suite away, which is why this went unnoticed.

  * `test_both_portal_row_creation_sites_name_the_chat` asserted a literal
    census of `== 2` sites. It went red the moment the third site appeared —
    the guard WORKING — and the bug it names shipped anyway. Now asserts the
    rule instead of the count: every site that stamps the surface must also
    stamp the destination. Census-proof.

  * `test_portal_turn_kwargs_bind_against_execute_task` parsed `portal_chat`
    for a literal `run_resumable_turn(...)` call. That call had moved into
    `_run_sync_turn_and_clear_marker`, where it is `run_resumable_turn(**kwargs)`
    — a splat, which names nothing — so the walk found no keywords and the
    guard asserted itself dead. Now reads the keywords where they are actually
    named (the wrapper's call site), scanning both entry names and subtracting
    the wrapper's own consumed parameters.

Neither rewrite loses coverage; both now fail for the reason their docstring
gives rather than because a number or a call site moved.

WHY THE BUG SURVIVED ITS TESTS. ent#457's mock the engine and assert the kwargs
are passed (they are). ent#365's assert no orphan `running` row (still true).
Nothing asserted the PERSISTED ROW, which is the only place the two meet — the
same lesson `test_ent457_portal_turn_kwargs.py` states about itself. The new
suite asserts at that layer, and adds a derived parity check so a fourth
channel field added to one writer and forgotten in another fails here instead
of shipping as another silently-inert report path.

Verification: 402 passed on the portal/ent457/ent365 selection (was 2 failed
before this branch). Mutation-checked: removing the stamp turns 4 red; feeding
`execute_task` an unknown kwarg turns the repaired binding guard red.

Closes #2426

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(subscriptions): auto-switch ranks alternatives by cached headroom, never by load alone (#2409) (#2422)

## Summary
- `select_best_alternative_subscription` returned the **first** survivor of the 2h failure filter in `agent_count ASC` order and read no headroom — SUB-003 could move an agent onto a subscription at 99% of its weekly window, and an *unused dead-token* subscription (no agents ⇒ no failure rows) sorted **first**.
- Now: **filter in the db, rank in the service, never a probe.** The db lists survivors (kind-blind 2h filter unchanged and first, #444/#2352, `agent_count ASC, name ASC`); the service ranks them over the cached provider snapshot (one `MGET`) furthest-from-the-nearest-wall first (the fuller of the 5h/7d windows — the #792 retry lands on the destination immediately), in 10-point bands so load still spreads a storm; a **fresh** provider refusal is dropped; anything unusable sorts in today's order; any failure of the ranking half falls back to today's pick **with a warning**.
- `classify_headroom` (ent#434) and the ranker share one usability gate (`headroom_reading`) — verdicts byte-identical, pinned by a differential test against a frozen copy. New-agent auto-assign (#74) rides the same ranker. The switch now records **why** (`destination_headroom` + one notification clause).
- Approved deviations from the literal AC, recorded on the issue: nearest-wall key instead of 7d-only; fresh refusals filtered instead of ranked last.

## Changes
- `src/backend/services/subscription_headroom_service.py` — gate, MGET reader, threshold-free ranker, `MAX_READING_AGE_SECONDS` (owned here now); `classify_headroom` becomes policy over the gate
- `src/backend/services/subscription_auto_switch.py` — service-layer selector (`asyncio.to_thread` under the agent lock), `destination_headroom` on activity / notification / result
- `src/backend/services/subscription_service.py` — `select_subscription_for_new_agent`
- `src/backend/db/subscriptions.py` + `database.py` — `list_viable_alternative_subscriptions` / `list_assignable_subscriptions` (filter only); first-match selectors retired
- `src/backend/services/agent_service/crud.py` — call site; `subscription_headroom_alerts.py` — constant re-export + docstring
- Tests: new `tests/unit/test_2409_headroom_ranked_switch.py`; pingpong / 2352 / concurrency / 1484 / 1759 adapted to the list form (assertions kept)
- Docs: `architecture.md`, `subscription-auto-switch.md` (+ management, usage-tracking), requirements §20.4, `learnings.md` (2 entries), CSO diff report

## Test Plan
- [x] New suite: `pytest tests/unit/test_2409_headroom_ranked_switch.py` — 83 passed; **80/81 fail on the unmodified source**
- [x] Full `tests/unit`: 12,718 passed / 30 skipped / 1 pre-existing failure (`test_1920`, private submodule, untouched)
- [x] API integration (`test_subscription_auto_switch`, `test_subscriptions`, `test_subscription_usage`): 36 passed
- [x] Live: a real switch chose the 18%/9% subscription over the 0-agent 88%/60% one; every-survivor-refused → no switch + WARNING; no snapshot → today's order
- [x] `/review` clean (informational findings fixed in-review); `/cso --diff` no findings
- Follow-ups filed while testing: #2419 (parser overage), #2420 (destructive integration suite), #2421 (subscription audit gap)

Fixes #2409

🤖 Generated with [Claude Code](https://claude.com/claude-code)

* feat(workspace): an answer given in the Workspace resumes the agent (ent#430)

Slice 5 of ent#364, and the gate: until now the client route recorded an answer
and returned. The operator route called `spawn_resume_dispatch`; this one did
not. So an ask addressed to a Workspace client — the entire point of
ent#364/#428/#429 — was recorded, reached the agent's queue file in about three
seconds, and re-triggered nothing.

Measured on a live instance before this change: answered from the Workspace,
`operator-queue.json` flipped to `responded` with the answer in under 3s, and no
execution followed.

Unblocked because ent#329 is in dev.

WHAT THIS ADDS: one call. ent#430's body rules out the alternative — "a second
dispatch surface for the same event is how the cost, trigger-label and
loop-prevention questions get answered twice, differently" — so the per-agent
opt-in, the idempotency key, the audit row and the failure handling all stay
inside `maybe_dispatch_resume`. AC #2 and AC #3 are satisfied by REUSE rather
than by re-implementation, and the tests assert the CALL for that reason.

Four properties, each load-bearing:

* Hung off the CAS WIN only, like the operator route. The 409 above already
  returned for a lost race, so reaching the dispatch means this answer is the
  one that landed — two people answering at once produce one resume.
* `updated`, never `item`. The pre-answer read still says `pending`; a resume
  handed that row acts on an ask that does not yet carry its answer. Looks
  identical in a green test, which is why there is one for it.
* The spawn is wrapped. It is fire-and-forget, but a raise ON THE CALLING LINE
  would still propagate, and a 500 after the CAS landed would tell the client
  their answer failed while it is committed and already on its way to the agent.
  The answer is the thing that must not be lost.
* #2376's choice validator runs first, so an answer that was never offered
  cannot spend.

AC #5 — `resume_requested` on the answer response, read from the SAME accessor
the dispatch gates on, so the two cannot disagree about what is about to happen.
It reports INTENT, not success: the dispatch is backgrounded, so at that moment
the only honest claim is whether it will be attempted. Fails CLOSED — an
unreadable flag claims nothing, because over-claiming is exactly the failure
AC #5 names ("the ask does not read as resolved while nothing happened").

RESIDUAL, stated rather than implied: a dispatch that fails AFTER this point
surfaces as a FAILED execution row plus an `operator_resume_dispatch` audit
entry (ent#329) — operator-visible, and a client cannot see either. The client
half of AC #5 is satisfied negatively for now: the ask surface says nothing
about work starting, so it cannot mis-claim. `resume_requested` is the field a
surface needs to say something true; consuming it is an ent#429 UI change and is
deliberately not in this PR.

The per-agent flag DEFAULT IS UNCHANGED (`operator_resume_enabled`, OFF,
owner-only). "Turn the flag on" is an operator action per agent, not a code
default: flipping it would hand every shared agent's client a spend button,
which is the one thing AC #3 rules out.

Verification: 145 passed across the asks/ent#329/ent#364/#428/#429/#2376
selection. Mutation-checked — removing the dispatch (4 red), passing the
pre-answer row (1 red), and making the opt-in read fail open (1 red).

Closes ent#430

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(workspace): New chat means a new chat (ent#451 — the fresh-thread slice)

Reported: pressing New chat in the Workspace drops you back into the existing
conversation with that agent. Decided at the 2026-08-21 weekly.

ONE VALUE CARRYING TWO MEANINGS. An absent `session_id` meant both "I don't know
which thread" and "I want a fresh one", and the platform resolved it as the
first, in both readers:

    _resolve_session_id(..., None)  -> resume the client's latest
    get_history(..., None)          -> return the most-recent thread

Both readings are RIGHT for the case they were written for — a deep link, a
refresh, an API caller that never held a session id — so neither could be
inverted. The intent had to become sayable: `new_thread` on the request,
`newChat` on the component, checked before the resume.

The frontend tell was an asymmetry: New chat with the agent you were ALREADY on
started fresh, while New chat with a different agent resumed. The watcher read a
changed agent as "load that agent's history" and called `fetchHistory(name,
null)`, discarding the `pendingSession = null` that `newChatWithAgent` had just
set to mean the opposite.

MOST OF ent#451 TURNED OUT TO BE BUILT. Recorded because the issue is
complexity-high and this PR is not:

* the data model already allows many sessions per (agent, client) — no UNIQUE
  constraint, a `title` column, an index on
  `(agent_name, client_email, last_message_at)`, and auto-titling. AC #4's
  "migrates cleanly" is nothing to migrate.
* AC #2's list is the existing sidebar: titles, recency, starred lifted out,
  search, per-agent avatars.
* AC #3's landing rule is already decided and documented in
  `ensure_thread_for_ask` — reuse the latest thread so asks do not accumulate
  beside the conversation. UNCHANGED here, and pinned by a test so this cannot
  move it silently. It matters MORE once several chats exist, not less.

So what was missing is AC #1, and it is two bits rather than a data model.

Four properties:

* An explicit `session_id` WINS over the flag. A caller sending both contradicts
  itself; the id is a fact, the flag an intent, and abandoning a named thread
  would strand a turn meant for a conversation the caller could see.
* The ownership check runs first either way — the flag is never a route past it.
* BOTH turn entry points carry it. The Workspace uses the streaming path and
  falls back to the synchronous one, so a flag honoured by only one brings the
  bug back exactly when streaming fails.
* The intent is spent on adoption. The send guard already ANDs on "no session
  yet", so a second turn was never going to open a third thread; clearing it in
  `onSessionAdopted` keeps the two bits from disagreeing after a navigation.

Test doubles updated, not worked around: seven `_resolve_session_id` lambdas and
four `_fake_chat` stubs did not accept the new keyword. They take `**kw` now — a
stub that must be edited for every new parameter is a second signature — and one
hand-rolled `_Body` model double gained the field. All are stale stubs rather
than behaviour changes.

Verification: 392 passed across the portal/ent#286/#287/#358/#429/#430/#451
selection; 1497 frontend unit tests. Mutation-checked: making the flag inert, and
letting it override an explicit session id, each turn the suite red. The full
backend suite exceeds a local foreground run and is left to CI.

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; both are
fixed in #2427.

Related to ent#451

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(review): the ?new=1 deep link, the missing frontend test, and three latent desyncs (ent#451)

Blocker 1 was real and I had not seen it. `resolveAgentQuery` passed `forceNew`
to `resolveAgentLanding` and set `pendingSession = null`, but never raised
`startingNewChat` — so `/workspace?agent=X&new=1` rendered an empty conversation
and then sent `new_thread: false`, resuming the thread the user asked to leave.
The reported bug, intact on the documented `?new=1` contract, in the PR that
exists to fix it.

The cause is the one this PR is about, one level up: `route.query.new` was read
in two places for two different decisions — WHICH THREAD to land on and WHAT THE
FIRST SEND ASKS FOR — and only the first honoured it. Now read ONCE into a local
that feeds both, so they cannot drift again. AND-ed with the landing result, so
a `?new=1` that still resolved a thread never claims a fresh start.

Blocker 2: a frontend test, which the change genuinely had none of — the
`1497 passed` in the body was the pre-existing suite, as the review says.
`workspaceNewChat.spec.js` (9 tests) covers the deep link, the watcher branch
ORDER, the first-paint guard, both send conjunctions, and the settle-everywhere
rule, using the two established patterns (pure function + source assertion in
the `portalLeaveSpecificRoute.spec.js` shape) since vitest runs
`environment: 'node'` with no mount harness. Mutation-checked, and M1 is the
reviewer's own blocker: reverting it turns the suite red.

Blocker 3: `test_history_without_a_session_is_unchanged` cited "the spec in
tests/unit/... frontend suite" — a dangling reference asserting coverage that
did not exist. It now names the real file.

Comments addressed:

* Three more sites nulled `pendingSession` without settling the intent — the
  deep-link watcher (the commonest way in), `openRoom`, `openAgentPage`, plus
  the unreachable-agent branch. Latent because both consumers AND on "no session
  yet", but a flag that is only correct because of a second variable is one
  refactor from being wrong, and the declaration claims it is cleared the moment
  a real thread exists. Now true.
* `test_both_turn_entry_points_forward_it` was `getsource` + a substring, so a
  comment or a misspelled kwarg satisfied it. It now BINDS the keyword against
  each service signature and asserts the routes forward `body.new_thread`
  through a comment-stripped source — verified by mutation.
* `workspace-absorbs-session.md` updated at both seams the change touches
  (`resolveAgentLanding`'s landing rule and `_resolve_session_id`'s three
  states), and `architecture.md`'s Workspace section documents the new public
  `new_thread` field on the ent#83 headless surface.
* Gating stated rather than inferred: "OSS-core by decision (ent#451)", matching
  the ent#326/#384/#392 convention.

ONE CORRECTION, offered with evidence rather than silently applied. The review
says "`test_ent457_portal_turn_kwargs.py` doesn't exist on `dev`, #2427
introduces it". It does exist on `dev` — added by d6a4bc10 (ent#457) — and #2427
modifies it. `git cat-file -e origin/dev:tests/unit/test_ent457_portal_turn_kwargs.py`
succeeds, and `backend-unit-test` is failing on `dev` independently of any PR.
So the body's "fails on dev today" stands. Everything else in the review is
accepted as written.

Verification: frontend 1497 -> 1506 (+9). Backend 392 passed on the portal
selection, the same 2 pre-existing dev failures unchanged.

Related to ent#451

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(resume): the respond→resume dispatch never ran — bad import, masked by its own stub (ent#329)

Found by testing this PR's feature against a live local instance. ent#430 wires
a Workspace answer to `spawn_resume_dispatch`, so this PR is dead on arrival
without it — the client path would have hit the same wall the operator path has
been hitting since ent#329 merged.

THE BUG. `operator_resume_service.maybe_dispatch_resume` did:

    from services.task_execution_service import task_execution_service

That name has never existed on that module; it exports
`get_task_execution_service()`. The import sits on the FIRST line of the
function, above the try, so every dispatch raised ImportError before it even
read the opt-in.

WHY NOBODY NOTICED, twice over:

* the call is fire-and-forget, so the traceback surfaces only as asyncio's
  "Task exception was never retrieved" — nothing fails, nothing 500s, the
  answer is recorded and the config audit row is written. It looks like it
  worked.
* the ent#329 unit test stubbed `services.task_execution_service` with
  `SimpleNamespace(task_execution_service=recorder)` — MANUFACTURING the very
  symbol whose absence was the bug. 21 tests green, feature dead.

MEASURED on a live instance, opt-in ON:

  before: answer 200, audit row written, executions 0->0, log carries
          "cannot import name 'task_execution_service'"
  after : answer 200, executions 0->1, triggered_by=operator_response,
          audit `operator_resume_dispatch` with the execution id, 0 ImportErrors

(The dispatched run then failed on a missing AGENT_AUTH_SECRET — a limitation of
the test box, and correctly recorded as an honest FAILED row, which is ent#329's
"never silent" requirement doing its job.)

THE GUARD is the durable part, because the stub is the real lesson: a stub that
invents an API the real module lacks converts a production crash into a green
suite. `test_the_names_this_service_imports_actually_exist_on_the_real_modules`
parses the REAL module source with `ast` — never the stubbed `sys.modules`
entry, which is what made this invisible — and asserts every
`from services.X import Y` resolves. Mutation-checked: reverting the import
turns 11 tests red.

Related to ent#430, ent#329

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(review): the dispatch could not run, the race loser spent, the flag over-claimed (ent#430)

All three blockers from the review, each verified rather than argued.

1. THE FEATURE WAS INERT. `client_portal/asks/router.py` declares `answer_ask`
as a plain `def`, so FastAPI runs it through `run_in_threadpool` — a worker
thread with no event loop — and `asyncio.create_task` raises
`RuntimeError: no running event loop` there. The `except` swallowed it, so every
client answer recorded the answer and dispatched nothing: byte-for-byte the
behaviour this PR exists to remove.

Fixed in `spawn_resume_dispatch` rather than by flipping the route to
`async def`, for the two reasons the review names: the route does blocking DB
I/O, so `async def` alone would move it onto the loop; and ent#430's stated
shape is ONE dispatch site, which moving the spawn back out to the caller would
undo. It now detects the absence of a loop and hops back via
`anyio.from_thread.run_sync` — Starlette's threadpool is anyio's, so the portal
is always there on this path. Any future sync caller inherits the fix.

A thread anyio does not own reaches neither branch. That is not a production
shape, but it must not become the silent no-op this change removes, so it raises
with the cause named instead.

2. THE RACE LOSER SPENT MONEY. `respond_to_operator_queue_item` returns None
only when the row is GONE; when the row exists and has left `pending` — the race
that actually happens — it returns a TRUTHY dict carrying `_status_conflict`,
having written nothing. `if not updated` fell straight through it. The loser
then dispatched a paid execution for an answer not in the database, and because
the idempotency key hashes the response text, the loser's differing text yields
a different digest: one queue item, two paid dispatches. `routers/operator_queue.py`
already pops that flag before its own spawn; this is that rule, not a new one.
Popped, not read, so the sentinel cannot serialize to the client.

3. `resume_requested` OVER-CLAIMED. It was computed after the swallowed spawn
from the opt-in flag alone, so a spawn that raised still answered `true` — the
exact failure AC #5 names, and given (1) that was EVERY production answer on an
opted-in agent. It now reports what was actually scheduled.

TESTS — the reason all three survived 24 green checks is that every existing test
replaced `spawn_resume_dispatch` with a synchronous lambda, stubbing out the one
call whose runtime context was the defect. `test_ent430_dispatch_actually_runs.py`
drives the REAL spawn from a REAL anyio worker thread (the production context,
not an approximation) and asserts the premise before the behaviour. The lost-race
test uses the truthy `_status_conflict` shape that actually occurs, not the
`None` shape that does not. Mutation-checked: reverting fix 1 turns 1 red, fix 2
turns 3 red, fix 3 turns 2 red.

Writing those tests also caught a stubbing bug of my own, worth recording because
it is the trap that hid the original: patching only `sys.modules` leaves
`from services import operator_resume_service` resolving the PACKAGE ATTRIBUTE,
so the real function ran anyway. Both paths are patched now.

Related to ent#430

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(review): the answered ask said pending, and the docs described one caller (ent#430)

Non-blocking findings from review pass 2. The three blockers landed in d11956a8.

STATUS. `_project` mapped every row to pending/expired, so the response to a
just-recorded answer read `status: "pending"` beside `resume_requested: true` —
one row reporting both that nobody has answered it and that answering it started
work. Harmless while the second field did not exist; contradictory once it did.
`_status_of` adds `answered` (`responded`/`acknowledged`), reachable only from
the answer response since the listing carries neither. Answered is checked
BEFORE expiry — an answer that landed is a fact, and an `expires_at` that has
since passed does not un-answer it; the obvious refactor is to test expiry first,
which would make a slow client's own answer vanish, so the ordering is pinned.

The existing test asserted `out.status in ("pending", "expired")` with the
comment 'the point is it returned at all' — it was papering over exactly this.
It now asserts `answered` and, on the spawn-failure path it covers, that
`resume_requested` is False.

THE TWO READS. `_resume_requested`'s docstring claimed it read 'the SAME
accessor … so the two cannot disagree'. True of the accessor, false of the
instant: it is a second read a task hop earlier, and an owner disabling the
opt-in in between gets `true` and no resume. Collapsing them is not the fix —
they answer different questions (one must produce a value for THIS response, the
other is the authority at the moment it would spend), so the window is stated,
with AC #5's own remedy named, rather than described away.

DOCS. architecture.md's ent#329 section described a single caller and stated the
CAS-win property the second caller broke. It now carries the second caller, the
truthy-`_status_conflict` shape that defeated `if not updated`, the
sync-endpoint/no-loop defect and its `anyio.from_thread.run_sync` fix, and what
`resume_requested` actually reports.

Related to abilityai/trinity-enterprise#430

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(enterprise): bump the submodule pointer to main (a419812 -> 90f2f2c) (#2440)

dev's pointer was OLDER than main's — an inversion, not just staleness. The
next dev -> main release merge would have carried it backwards and undone
ent#443's enterprise-side removal:

  OSS main -> 2a5def3   (ent#443: shared_sessions removed from enterprise)
  OSS dev  -> a419812   (4 behind enterprise main, 2026-08-19)
  ENT main -> 90f2f2c

90f2f2c is a fast-forward from BOTH (a419812...main = ahead 0 / behind 4;
2a5def3...main = ahead 3 / behind 0), so nothing is being rewound.

WHAT THE FOUR COMMITS ARE

  2a5def3  refactor(rooms): remove shared_sessions — it now lives in OSS core (ent#443)
  ff0a4f1  fix(security): guard the enterprise system_settings sinks against
           cleartext credentials (ent#435) — the private twin of the OSS sink
           guard architecture.md already records as "the private submodule owns
           its twin"
  6d82a3f  docs(workspace): agent-initiated asks — design of record
  90f2f2c  feat(credential-vault): governed system credential vault module (ent#279)

WHY IT MATTERS RATHER THAN BEING HOUSEKEEPING. ent#443 moved rooms into OSS
core, and dev has that. With the stale pin an entitled dev install mounts the
OSS rooms routers AND the enterprise shared_sessions module, and relies on
main.py's include-order (OSS before register_enterprise) to decide which one
serves. architecture.md documents that ordering as the transition safety net —
this bump is the follow-through that ends the transition.

VERIFIED BY BOOTING BOTH POINTERS against dev, same box, same DB shape:

  a419812 (today):  17 modules | shared_sessions registered: True  | 6 room paths | 0 errors
  90f2f2c (this):   17 modules | shared_sessions registered: False | 6 room paths | 0 errors

Both boot clean and log "Trinity Enterprise modules registered" — the line
deploy-dev greps. Module count is unchanged because shared_sessions leaves as
credential_vault arrives. No duplicate room paths in either, confirming the
ordering net held; after the bump there is nothing to net.

Gitlink only — no OSS source changes, so public CI (which never checks the
submodule out) is unaffected.

Related to ent#443, ent#435, ent#279

* chore(metrics): code-health dashboard 2026-08-31 @ 135248e9 (#2438)

Co-authored-by: Trinity Agent (trinity) <trinity-agent@ability.ai>

* chore(deps): bump node (#2400)

Bumps the docker-base-images group with 1 update in the /docker/frontend directory: node.


Updates `node` from 24-alpine to 26-alpine

---
updated-dependencies:
- dependency-name: node
  dependency-version: 26-alpine
  dependency-type: direct:production
  dependency-group: docker-base-images
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* fix(files): shared links were unopenable on mobile — Range, disposition, MIME, CORP (trinity-enterprise#461) (#2439)

* fix(files): shared links were unopenable on mobile — Range, disposition, MIME, CORP (trinity-enterprise#461)

The bytes were never wrong. Verified from the Cloudflare edge, the object
returned HTTP 200 with correct content-length and correct WAV bytes for every
user-agent tried, and the signature check worked. The RESPONSE SHAPE was wrong
in four ways at once, and each one alone is enough to break playback in an iOS
in-app browser:

* no Range support — `Range: bytes=0-1023` returned 200 with the whole 2 MB
  body and no `accept-ranges`. iOS Safari and Telegram's player require a 206
  to start audio at all, so this alone made the file unplayable.
* `content-disposition: attachment` — a forced 2 MB download inside Telegram's
  iOS browser is a blank screen.
* `audio/x-wav` under `nosniff` — unregistered type, so a strict player
  declines it and the browser is forbidden from guessing better.
* `cross-origin-resource-policy: same-origin` on a link whose entire purpose is
  to be opened from another platform.

Plus `cache-control: no-store`, which forbids the in-app browser from buffering
media it will not play without buffering.

THE INLINE CHANGE IS A NARROWING, NOT A REVERSAL. The old code forced
`attachment` on everything with the note 'defense against XSS via
agent-uploaded HTML', and that reasoning is still correct — this route serves
agent-authored bytes from the same origin as public chat. So inline is an
ALLOWLIST (`_INLINE_SAFE_TYPES`: audio, video, image, PDF) and `text/html`,
`application/xhtml+xml` and `image/svg+xml` stay attachments. SVG is called out
because it is the one a reviewer waves through: it is an image by name and a
script host in fact. The type is python-magic-detected from the file's own bytes
at share time, never agent-supplied, and its unavailable-fallback
(`application/octet-stream`) sits outside the allowlist, so the failure
direction is `attachment`. `nosniff` is kept and matters more now, not less.

TWO THINGS THE ISSUE DID NOT ASK FOR, both found while implementing:

* `Content-Length` came from the DB's `size_bytes`, written at share time. Any
  drift from the file on disk is unrecoverable for the client — too small
  truncates, too large hangs — and Range math against a wrong total produces a
  `Content-Range` that contradicts the body. It now comes from
  `os.path.getsize`, with a WARNING on divergence.
* a media player fetches one file as MANY ranged requests. Counting each as a
  download would turn one play into dozens and write an audit row per chunk, so
  the counter and the audit fire only on the transfer START (a plain GET, or a
  range beginning at byte 0).

VERIFIED end-to-end against the real route, not just the parsers:

  full GET      : 200 | type audio/wav | disp inline | ranges bytes
                | corp cross-origin | cc private, max-age=3600
  range 0-1023  : 206 | body 1024 | bytes 0-1023/2048000 | bytes ok
  suffix -500   : 206 | bytes 2047500-2047999/2048000 | bytes ok
  unsatisfiable : 416 | bytes */2048000
  HEAD          : 200 | accept-ranges bytes | content-length 2048000
  no sig / bad sig / unknown id / expired : 401 / 401 / 404 / 410
  html file / svg file : attachment

That covers the issue's Definition of Done line by line, including that the
signature check still rejects unsigned and expired requests.

46 new unit tests, weighted to the allowlist and to the range parser's
silent-corruption case (`bytes=-500` is the LAST 500 bytes; reading it as
start=0 serves the wrong bytes under a 206, which no client can detect).

Related to trinity-enterprise#461

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(files): the CORP header was inert — the security middleware clobbered it (trinity-enterprise#461)

Found by testing the PR against a real local instance rather than a TestClient.

`main.add_security_headers` runs after EVERY route and set
`Cross-Origin-Resource-Policy` with a plain assignment. So the `cross-origin`
policy the file-download route sets — one of the four fixes in this PR, and the
one that decides whether Telegram, Slack or WhatsApp can embed or preview the
link at all — was silently overwritten back to `same-origin` on its way out.

The fix shipped INERT and every test passed, because a bare `FastAPI()` +
router harness has no middleware. Measured on the running server:

  before: cross-origin-resource-policy: same-origin
  after : cross-origin-resource-policy: cross-origin   (file route)
          cross-origin-resource-policy: same-origin    (/health, unchanged)

`setdefault` rather than a route allowlist: absence still resolves to the strict
default, so every other route keeps today's behaviour and a new route has to opt
out deliberately rather than inherit an exception.

Pinned by a source assertion — asserting it end-to-end needs a live stack, and
what must not regress is the `setdefault`; an edit back to `=` would re-break it
invisibly.

Related to trinity-enterprise#461

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(main): lifespan is an orchestrator, not a 580-line procedure (#1028) (#2437)

* refactor(main): lifespan is an orchestrator, not a 580-line procedure (#1028)

`main.py::lifespan` was 580 lines at cyclomatic complexity 109 — the longest
function in the backend and the first item the 2026-06-02 refactor audit named.
It is now 25 lines at CC 1: a flat list of `await _phase()` calls over twelve
startup helpers and four shutdown helpers.

WHAT MOVED, AND THE PROOF THAT NOTHING ELSE DID. Every body is verbatim. That
is asserted mechanically rather than claimed: extracting all non-blank lines
from the sixteen helpers in call order and diffing against the original
`lifespan` body gives 518 = 518, identical. The only relocated line is `yield`.
No behaviour change, no logic touched, no try/except reshaped — each phase keeps
its own guard, because a failing phase must not take the boot down, which is
what the original did.

WHY THE TEST IS THE POINT. Splitting the function is easy; keeping it split is
not, and the thing worth guarding is not the line count — it is THE ORDER.
Boot ordering is load-bearing in ways invisible at the call site: a reviewer
looking at sixteen await lines cannot see that moving one breaks something,
because the coupling lives in the bodies. Before this it was implicit in a
function nobody could read in one sitting; after it, it is a list — which is an
improvement only if something enforces the list.

So `tests/unit/test_1028_lifespan_phases.py` pins the sequence WITH the reason
for each constrained pair recorded beside it: logging first so a later hang
cannot swallow the boot log (#858); the event bus before any WebSocket client
needs a live dispatcher (#306); Docker/system-agent before the fleet sweepers;
startup recovery before the channel transports, so inbound traffic cannot create
an execution that races the reconcile; the event-bus drain LAST on shutdown so
late broadcasts still land. A reorder now fails with the reason attached instead
of surfacing weeks later as a boot bug nobody connects to this commit.

It also pins the `yield` split (a phase appended after it silently becomes
shutdown work), the per-helper thresholds the issue asked for (<100 lines,
CC <20), and orphan/double calls. Mutation-checked five ways — recovery moved
after the transports, event bus after Docker, a dropped shutdown phase, the
drain no longer last, a phase pushed past `yield` — all caught.

ONE DEFECT THIS FOUND IN ITSELF, worth recording because it is the failure this
refactor's shape invites. The extraction moved `@asynccontextmanager` by one
definition: it landed on the first phase helper and `lifespan` was left a bare
async generator, which FastAPI cannot use as a lifespan. Boot-breaking — and the
entire 12,900-test unit suite stayed green, because nothing in it imports `main`
and asks what shape `lifespan` is. It surfaced only from an explicit import
check (`iscoroutinefunction` on each helper returned False for one, with
`co_filename` pointing into contextlib). Fixed, and pinned by its own test.

Docs: the `main.py` row in architecture.md now records that the order is the
contract and where the constraints are, so the next person to add a startup step
knows it belongs in a phase helper.

Scope: one file per the issue's own recommendation. The remaining ACs
(`routers/settings.py`, `routers/ops.py`, `services/git_service.py`,
`services/agent_client.py`, `routers/public.py::public_chat`) stay open on #1028.

Related to #1028

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(review): three phase helpers used locals the split left behind (#1028)

/review's scope pass caught a real runtime break in my own extraction, and the
interesting part is that three separate verifications had already passed on it.

`main.py` never imports `database` at module level. The old `lifespan` did
`from database import db as _db` once near the top, and three blocks 100+ lines
later used it through the enclosing function scope: the system-agent
`setup_completed` gate, the Telegram transport and the WhatsApp transport.
`message_router` was the same shape — imported in the Slack block, used by the
Telegram one. After the split all four are NameError.

The severity is in the swallow. Every one of those use sites sits inside
`try/except Exception`, so the boot SUCCEEDS: the log carries "Error starting
Telegram transport: name '_db' is not defined" and the Telegram and WhatsApp
integrations are simply never wired. A silently dead integration, not a failed
boot — and the per-phase guard that makes each phase fail-open is exactly what
hides the extraction bug.

Why the existing checks missed it, all three: the AST equivalence proof compares
LINES and the lines are identical; the structural pin asserts order, thresholds
and decorators, not name resolution; and the `import main` smoke never RUNS
`lifespan`, so nothing resolves those names at import time.

Fixed by re-materialising each import in the helper that needs it — the
original's own idiom — with a comment saying why it is there, so a later reader
does not "tidy" it back out.

CORRECTION TO THE CLAIM: the bodies are no longer byte-identical. They are
verbatim EXCEPT these four re-materialised imports, which is now what the PR
body and the docstrings say. A verbatim claim stops being true the moment a
leaked name has to be restored, and quietly keeping the claim is worse than the
bug.

Also pinned, because this gets more likely with every future split of the same
function: test_no_phase_helper_depends_on_another_phases_locals asserts, per
helper, that `names_loaded - names_bound - module_globals` is empty. Mutation-
checked by deleting the restored `_db` import — reproduces the shipped bug and
turns the suite red.

Also: the phase-count docstrings said "of 10" in 9 helpers; the transports were
split into three after that text was written, so it is 12.

learnings.md gains the class: extract-method has a failure mode the diff cannot
show and an import smoke cannot reach.

Related to #1028

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(review): the verbatim claim survived in three docstrings (#1028)

Re-review finding. The previous commit corrected "bodies are byte-identical"
in the PR body but left the same claim standing in the code, where it is more
likely to be believed: the three helpers that gained a re-materialised import
still said "the body below is unchanged".

A stale claim next to the exact line that falsifies it is worse than no claim —
it is the thing a future reader checks against before deciding the import looks
redundant. Now each says verbatim EXCEPT the restored import, and points at the
comment explaining why it is there.

Related to #1028

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(tests): two pre-existing lifespan guards read a function the split emptied (#1028)

CI's regression diff caught 4 new failures, deterministic across all three
seeds. Both guards assert properties of `lifespan`'s SOURCE, and #1028 moved
that source into phase helpers — so they were asserting things about a function
that is now 25 lines of `await` calls.

The properties still hold. The guards had stopped being able to see them, which
is the worse failure: a guard that silently stops covering its subject reads
identically to one that passes.

test_1267_lifespan_db_alias — and this one is pointed: #1267 IS the bug class
/review caught in this branch. It fired when the transport blocks called a bare
`db` while only `_db` was in scope, NameError swallowed by the surrounding
try/except and surfaced as a misleading "Error starting Telegram transport".
The split re-introduced the same class in a new form (`_db` bound in phase 1,
read in three later helpers), and this guard could not see it because it only
ever looked inside `lifespan`.

So it now follows the calls: `_lifespan_surface()` returns `lifespan` plus the
helpers it awaits, and every check scans all of them. The alias check is
STRENGTHENED rather than merely relocated — binding `_db` somewhere on the
surface is no longer sufficient, because after the split each helper is its own
scope, so every function that READS `_db` must bind it. That assertion fails on
the exact defect this branch shipped.

test_858_dockerfile_unbuffered — the #858 invariant is an ORDERING one
(setup_logging -> first-run notice -> event_bus.start), and after the split
those three sit in three different functions. `_lifespan_body()` now flattens
the phases inline in call order, so the existing index comparisons keep meaning
what they meant. An unresolvable helper is left as the bare `await` rather than
skipped, so a phase this cannot expand can never silently drop the statements
it contains.

No production code changed.

Related to #1028

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(retention): every install writes its own retention rows, not just fresh ones (#2085) (#2432)

#1645 closed #1638 by reverting OPS_SETTINGS_DEFAULTS to the wide historical
values and applying the #1039 community floor through explicit system_settings
rows seeded on FRESH installs only. Every install that has ever upgraded rather
than been created fresh therefore had no rows at all, so cleanup_service
resolved all 11 windows at prune time from a dict that ships inside the backend
image and is replaced on every rebuild. The only thing between a future edit to
that dict and the #1638 failure mode — a silent hard-DELETE of existing data
seconds after the next boot, green /health, no error — was a code comment.

database._seed_retention_windows{,_engine} now writes an explicit row for every
RETENTION_OPS_KEYS member that has none, at the value already in force, on every
boot and regardless of install age. Behaviourally inert: it writes the number the
prune already used, so nothing prunes differently the day it runs.

Three properties are load-bearing:

* The key set is DERIVED from RETENTION_OPS_KEYS, never a second hand-written
  list, so a window added later is covered the day it ships instead of quietly
  inheriting the image default forever (the issue text said "eight windows"; it
  was 11 by the time this landed — ent#433 added two, #2216 a third).

* Ordering. It MUST run after _seed_fresh_install_retention. Both writers are
  insert-or-ignore, so the first to reach a key wins: reversed, a fresh install
  silently gets the wide defaults instead of the #1039 floor — the community
  floor deleted by the change meant to protect retention. Pinned behaviourally
  and by a source-order guard on both the SQLite and engine arms.

* It must actually run. The first cut imported the constants from
  services.settings_service, which has a module-level `from database import db`.
  init_database() is called from DatabaseManager.__init__, i.e. while database.py
  is still executing its own module body, so database.db does not exist yet and
  that import raises ImportError — which this seed's fail-safe contract then
  SWALLOWS. The feature was dead on every boot with a fully green unit suite,
  because every in-process test calls the function after database has finished
  importing. RETENTION_OPS_KEYS, OPS_SETTINGS_DEFAULTS and
  NON_ROW_RETENTION_OPS_KEYS therefore move to config.py (a leaf, already home to
  OPS_SETTINGS_VALIDATION and validate_ops_setting for these same keys) and are
  re-exported from settings_service, extending the pattern
  COMMUNITY_FRESH_INSTALL_SEED already used for exactly this reason. Two tests
  now pay for a real subprocess import; both fail if the import is reverted.

Stated tradeoff: a seeded install stops inheriting later changes to the code
default in EITHER direction, so widening a window for existing installs becomes a
deliberate migration rather than something that arrives silently with an image.
That is the intended consequence — retention becomes explicit per-install config
instead of implicit inheritance from whatever image happens to be running,
symmetric with the rule the OPS_SETTINGS_DEFAULTS comment already imposes on
narrowing.

backup_retention_days is seeded too. That makes OPS_SETTINGS_DEFAULTS' value the
one that lands in the DB for a key whose private reader
(db_backup_service.effective_backup_retention_days, inverted coercion) falls back
to its own module constant; the two are now parity-tested.

No schema change, no migration — row inserts at boot, same as the #1638 seed.

Not fixed here: generic DELETE /api/settings/{key} carries no RETENTION_OPS_KEYS
guard (only PUT does), so an admin can still delete a window row. After this it is
transient — the next boot re-seeds it — but the asymmetry with PUT remains.

Unblocks ops#300 once deployed: with rows on every install, /update step 8e can
drop its source-text guessing for a plain assertion over stored values.

Closes #2085

* fix(watchdog): stop false-orphaning executions parked before the agent spawns them (abilityai/trinity#2433) (#2435)

* fix(watchdog): stop false-orphaning executions parked before the agent spawns them

The cleanup watchdog's proof-of-life (GET agent/api/executions/running:
running ∪ recently-completed) could not see an admitted execution that was
waiting in the backend's global agent-call queue, in the agent's CPU-sized
default thread pool, behind the agent chat lock, or in the post-exit drain
before unregister(). After the 60s grace it wrote a false `failed`
("completed on agent but status not reported"), released the slot, and the
parked call then ran anyway — billed, overbooked, its late 200 silently
overwriting the row (#378). Reproduced twice locally; three mechanisms, one
string.

Orphan now means: the agent does not know the execution AND no live backend
dispatcher owns it.

- agent server: /api/executions/running gains `pending_ids` (accepted at
  /api/task, /api/chat and the #1083 async spawn but not yet spawned; lazily
  expired) and `recently_completed_ids` covers exited-but-registered handles.
  Cancel-while-pending is consumed by register() (SIGKILL at spawn, #679 marker
  kept); the pre-spawn 409 is only an optimisation. Headless runs use a
  dedicated 32-thread pool pinned to MAX_PARALLEL_TASKS_CEILING_MAX; the Gemini
  runtime now registers its subprocess at both Popen sites (it never did).
- backend: every outbound agent call is registered for its whole lifetime
  (track_inflight_dispatch — queue wait, connect retries, POST) in an
  in-process registry plus a cross-worker Redis liveness marker
  execution:inflight:{id} (60s TTL, one refresher task per process, 15s tick).
  The watchdog reads a tri-state verdict (alive / absent / unknown) and
  withholds recovery on `alive`, and on `unknown` only while a dispatcher could
  still own the row; a process with no Redis reads `absent` (its own registry
  is the whole truth). CleanupReport.dispatch_inflight_skipped counts withheld
  rows; the orphan error string states what was observed.
- a park no longer spends the run's budget: at grant, a park ≥ 5s restamps
  started_at (admission kept in queued_at, the drained-backlog shape, CAS on
  RUNNING + NULL lease) and renews the slot lease (ZADD XX + EXPIRE together);
  the refresher renews the slot every tick while parked.
- parked rows are cancellable and agent-scoped: terminate consults the
  in-process registry, then the cross-worker cancel key; a parked phase is
  finalized CANCELLED and the grant raises BackendAgentCallCancelled, where the
  dispatcher writes CANCELLED itself (never FAILED; the /chat arm answers 409).
- terminate_execution gains ONE agent-scope gate at its entry for all three
  arms: the row behind the caller-supplied task_execution_id must belong to the
  agent the route proved (uniform 404; an unreadable row fails closed with
  503). The proxy arm's 404 scoped only execution_id while the CANCELLED CAS
  was keyed on task_execution_id, so a caller authorised on agent A could flip
  agent B's running row (found by the /cso --diff verifier; report under
  docs/security-reports/).
- packaging: BACKEND_AGENT_CALL_LIMIT / BACKEND_AGENT_CALL_QUEUE_TIMEOUT_S
  forwarded in prod + hosted compose and documented in .env.example; the >5s
  queue-wait warning fires on both acquire branches.

Verified: full unit suite under CI conditions 12969 passed / 0 failed
(baseline origin/dev 12863 / 0); Repro A 10/10 success (2 parked 485s,
withheld at both watchdog cycles, re-anchored at dispatch); Repro B 8/8
success (5 parked, two waves); live pending_ids probe on the agent. The
agent-side half needs a rebuilt base image; the backend half alone covers old
images through the whole-call marker.

Fixes abilityai/trinity#2433

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

* fix(watchdog): close the cross-worker cancel race and bound the exited-but-registered set (#2435 review)

Review of the #2433 fix found that it reintroduced the #378 symptom in a
narrower window and turned a pre-existing registry leak into a permanent one.

1. Cross-worker cancel acted on a marker phase that predated its own write.
   `entry.phase` flipped parked->calling in memory only; the marker was
   rewritten by the 15s refresher, so `execution:inflight:{id}` advertised
   `parked` for up to a full tick after the POST had begun. Under --workers 2
   about half of all cancels are served by the worker that does NOT own the
   coroutine and therefore read it: the row was finalized CANCELLED and its
   slot released while the agent ran the turn to a billed completion whose
   SUCCESS then lost the CAS. Closed by ordering, not by narrowing — the owner
   publishes the transition in the SAME round-trip that reads the cancel key
   (`_publish_calling_and_check_cancel_sync`), and the remote sets the cancel
   key BEFORE re-reading the phase (`_set_cancel_then_reread_phase_sync`), so
   an observed `parked` gives W_remote(cancel) < R_remote(marker) <
   W_owner(marker) < R_owner(cancel) and the grant is guaranteed to see the
   key. Neither side pays an extra round-trip. The owner gates the publish on
   the ENTRY's age rather than this attempt's park, because
   `track_inflight_dispatch` wraps the whole retry loop and a retry can grant
   instantly under a marker a tick left saying `parked`; the remote's scope
   check stays on its first read, so no key is written for a foreign agent.

2. `list_recently_completed_ids` reported exited-but-registered ids with no
   age bound, so a leaked entry was agent-known forever and the watchdog never
   recovered that row — a regression against pre-#2433, where `list_running()`
   self-healed it. Now bounded by the same 300s TTL as the buffer, measured
   from when the exit was first OBSERVED (not `started_at`, which would drop a
   long turn the moment it entered its drain). The leak is also closed at
   source: `register()` SIGKILLs the group for a cancel that arrived while
   pending, so the following `stdin.write` can raise BrokenPipeError — all
   three prompt-writing runtimes (claude_code, gemini x2) now pair that write
   with `unregister()` on failure.

3. `restamp_execution_dispatch` is a sync sqlite write and ran on the event
   loop, while both semaphores are held and the queue is by definition
   congested. Now `asyncio.to_thread`, like the slot renewal beside it.

Smaller items from the same review:
- /api/chat sizes its pending entry to PENDING_CHAT_TIMEOUT_SECONDS (7200s):
  `ChatRequest` carries no timeout and a chat can wait on the execution lock
  for the agent's whole budget, so the /api/task default evicted the entry
  mid-wait. Its discard now wraps the lock acquisition, so a request cancelled
  while waiting (client disconnect) cannot leak one.
- Phase 3 batches its in-flight verdict read (one MGET per cycle, not per row),
  matching Phase 0.
- `renew_slot` refuses, score untouched, when the metadata hash has already
  expired: `ZADD XX` succeeds while `EXPIRE` no-ops, so it used to report a
  renewal it had not performed and re-anchor exactly the ZSET-without-hash
  state canary S-03 calls `missing`.
- `register_pending` logs at DEBUG (it fires on every /api/task and /api/chat).
- Documented that the in-flight marker is not eviction-proof under the prod
  `allkeys-lru` policy.

Tests: tests/unit/test_2433_review_fixes.py (15) — 11 of them fail against
cfc2cfef, verified in a worktree. Full unit suite under CI conditions
(clean origin/dev worktree, no submodules): 12985 passed, 0 failed.

Refs abilityai/trinity#2433

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* test(resume): the guard architecture.md promised did not exist (ent#430 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>

* fix(systems): the four post-deploy endpoints — a nonexistent DB call, an ungated restart, a broken export round-trip, and prefix-collision membership (#2373)

The deploy half has been hardened by every commit since ent#124; the four
post-deploy endpoints were essentially untouched since 2025.

## Membership is now ONE predicate

`get_system`, `restart_system` and `export_manifest` each matched
`startswith(f"{system_name}-")`, so an operation on `acme` also captured every
agent of a system named `acme-extra` — including `restart`, which stops and
starts containers. Three copies of a wrong rule.

`system_service.system_member_names` is the one rule, and it prefers TAGS:
`configure_tags` already applies the system name to every member, so a tag is a
RECORD of membership where a prefix is an inference from a naming convention.
The prefix survives only as a fallback for pre-tag deployments, narrowed so an
agent claimed by another system's own tag is excluded — a tagged `acme-extra`
agent is never captured by `acme` even there. A failing tag read degrades to the
prefix rather than 500ing.

Residual, stated rather than hidden: two systems deployed BEFORE tagging where
one name is a prefix of the other remain ambiguous, because nothing distinguishes
them. This is also the prerequisite for the teardown verb, where the same
collision would delete rather than restart.

## GET /{name} returns real schedules

It called `db.get_agent_schedules`, which does not exist — the facade exposes
`list_agent_schedules` and `database.py` deliberately has no `__getattr__`
fallback. The AttributeError was swallowed by the surrounding `except
Exception`, so every response omitted `schedules` for every agent and logged one
warning each, while `tests/test_systems.py` never asserted on the key. Exactly
the failure mode the db facade's own comment warns about — so the test also pins
that the fallback stays absent, since adding one would turn the next typo into a
silent Mock.

## POST /{name}/restart is creator-gated

It was bare `get_current_user` — below `POST /deploy` and below even the
READ-ONLY bundled-catalog routes — so any authenticated principal, including
`role: user`, could stop and start every container in a system whose agents it
could see. A mutating fleet-wide verb under a lighter gate than the catalog it
reads is an oversight, not a decision. `require_role` also rejects agent
principals (#1890), which matters because an agent-scoped MCP key resolves to
its owner carrying the owner's role.

## Export round-trips

The non-full-mesh permissions branch sliced `target_agent[len(name)+1:]` with no
membership filter — the sibling branch had one — so an edge pointing outside the
system exported as a blind-sliced garbage short name that then failed
`validate_manifest`'s unknown-agent check on re-deploy. The export broke its own
round trip. Both branches now test membership.

And the export no longer embeds the instance-global `trinity_prompt` as the
manifest's `prompt:`. Deploying that manifest elsewhere overwrote THAT
instance's platform-wide prompt — a fleet-wide side effect from what reads like
a copy of one system. Nothing records whether the source system ever set a
prompt, so there is no honest way to distinguish it from whatever the instance
happens to have configured, and the only correct export of an unknown is to
omit it.

## Two preview hardenings

Unknown PER-AGENT keys now warn like top-level ones (ent#126): `credentials:`,
`skills:` and `display_label:` are the fields people try first and they vanished
in silence.

Preview and deploy now resolve the identical resource default. Deploy hardcoded
`{"cpu": "2", "memory": "4g"}` while `_preflight_template` validated against the
admin-configurable `get_agent_default_resources()`, so the two disagreed the
moment an admin moved the fleet default — the one spot that escaped ent#126's
pure-resolver no-drift pattern.

## Verification

14 unit tests, one per defect plus the exempt shapes. Two mutation-checked: the
restart gate and the tag-first membership each turn a test red when reverted.
414 pass across the system/manifest/ent#126/#1884 suites.

`tests/test_systems.py` is live-backend tier and…
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.

2 participants