Skip to content

fix(workspace): the asks read fails loud, never an empty list (ent#610 PR A0) - #3049

Merged
vybe merged 13 commits into
devfrom
AndriiPasternak31/ent610-a0
Sep 29, 2026
Merged

vybe merged 13 commits into
devfrom
AndriiPasternak31/ent610-a0

Conversation

@AndriiPasternak31

@AndriiPasternak31 AndriiPasternak31 commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Refs trinity-enterprise#610

Ready for review. Review, security and verification are done (evidence below), and Tier 1 CI is green on the head. It will be reviewed and merged by someone other than the author (SOC 2).

What

PR A0 of #610 (asks honesty). The Workspace asks read used to answer [] whenever the operator queue or the agent roster couldn't be read, so a DB fault looked exactly like "nothing needs you". Now it fails loudly and the UI keeps the last good answer.

Backend (client_portal/asks/)

  • list_asks raises AsksUnavailable when the queue read fails, instead of returning [].
  • _on_roster(strict=True) (used by list) raises on a roster-read fault. An agent that is cleanly not on the roster is still dropped.
  • The router maps AsksUnavailable → 503 {"code": "asks_unavailable", "message": ...}, the same {code, message} shape as the asks router's AskError.
  • answer_ask is unchanged: non-strict, uniform 404 (Invariant security: implement safe tar extraction with symlink/hardlink validation #8).
  • The only other list_asks caller, suggestions/service.py, is wrapped in attempt(..., []) and still degrades.

Frontend (stores/clientPortal.js)

  • fetchAsks: 403/404 is absence, the same as today, and it now also resets asksLoaded.
  • Any other error sets asksFailed. The last good asks list and asksAvailable are kept.
  • A success clears asksFailed and stamps asksLoadedAt.
  • signOut() now clears the list and all three flags, so the next client on the same browser never inherits the previous client's asks.

What A0 ships on its own:

  • During an outage the Workspace keeps showing the last known asks and badge instead of going blank. The asks panel and badge render from asksAvailable + asks, which are live today.
  • The "couldn't refresh" marker that reads asksFailed/asksLoadedAt arrives in the next PR of the stack (PR A, the Inbox). Until then the kept list carries no stale marker.

Docs:

Evidence

Review and security

  • /review: no blockers. One should-fix: signOut didn't clear the now-kept list, so on a shared browser the next client's first failed read showed the previous client's asks. Fixed in 9b74ea6. Also fixed: a 404 now resets asksLoaded, and the suggestions test is relabelled as a guard, not a regression test (cb0c28d). Out of scope: the suggestions payload could also surface asks_unavailable later.
  • /cso --diff: it found the same sign-out issue (medium, fixed). It also reports:
    • no disclosure in the 503 body or logs (fixed {code, message}, no exception text);
    • the caller can't trigger strict mode;
    • no authorization decision is taken on a failed read;
    • no enumeration oracle: the roster read depends only on the principal's own email, and nonexistent and inaccessible agents both still get 200 [].
  • /validate-pr: the stale Files/Tests lines in requirements were fixed in 49cb1fc.

Mutations. Each change is reverted and the named tests must go red:

Mutation Red
queue raise → return [] 2
_on_roster strict raise → swallow 3 (incl. the reversed test_ent428 pin)
answer_ask → strict=True 1 (uniform-404 test)
router except AsksUnavailable removed 2
store reverted to origin/dev 4
signOut reset removed 1
404 asksLoaded reset removed 1

Frontend gates (on the head):

  • npm run test:unit: 175 files, 3684/3684. This includes rawColorRatchet and loadingGateRatchet, and no per-file count grew.
  • npm run build: OK.
  • npm run check:tokens: OK.

Browser, both themes.

  • Setup: a throwaway harness mounted the real PortalSidebar and PortalAsks with the real store and axios. Playwright answered /api/enterprise/client-portal/asks with 200 (2 asks), then 503 asks_unavailable.
light: after 200 → after 503 dark: after 200 → after 503
this branch badge 2, 2 asks rendered → badge 2, 2 asks rendered, asksFailed=true same
control (origin/dev store) badge 2, 2 asks → no badge, 0 asks same
  • Not covered by the harness: the component on the real Portal.vue page and the real backend round trip. The route-level 503 is covered by test_ent610_asks_unavailable.py through TestClient.

verify-local (python3.13, --skip-agent because this diff doesn't touch docker/base-image):

  • preflight ✅
  • build + import-smoke ✅
  • boot + health ✅
  • integration ✅: 70 passed, 13 skipped

Unit island:

  • verify-local's randomised unit stage failed 40 tests, all outside this diff: test_ent477_definitions_endpoint (5), test_ent477_git_refresh_hook (10), test_ent666_objective_join (21), test_retention_floor (4).
  • They are attributed to dev by running the full island deterministically (-p no:randomly, same venv) on both trees:
tree result
this branch 40 failed, 18518 passed
pristine origin/dev @ ebc2bfb4f 40 failed, 18509 passed
  • The two failure sets are identical (comm shows zero difference). The +9 passes are this PR's new tests.
  • All four files pass in isolation on both trees, so this is test-order pollution that already exists on dev, not caused by this PR.

🤖 Generated with Claude Code

…PR A0)

The requirement (security.md §26.8), the endpoint note and the agent-page
flow state the new contract before the code: 503 asks_unavailable on a queue
or roster read fault, and a store that keeps the last good list.
…er [] (ent#610 PR A0)

list_asks caught every error and returned [], and _on_roster turned an
unreadable roster into "not on the roster" — both made the Workspace say
"nothing needs you" during an outage (the #2915 class). The list now raises
AsksUnavailable (strict roster mode in list only) and the route answers 503.
A clean off-roster agent is still dropped; answer_ask keeps its uniform 404.

test_ent428's unreadable-roster case pinned the old == [] and is reversed
deliberately.
…so (ent#610 PR A0)

fetchAsks treated every error as absence: it cleared the list and set
asksAvailable=false, blanking every PortalAsks surface and zeroing the badge
on a 5xx. Now only 404/403 are absence; any other failure sets asksFailed,
keeps the list and leaves asksAvailable alone. asksLoaded latches on the first
success and asksLoadedAt feeds a stale banner.

workspaceAsks.spec.js's "clears the list rather than showing stale asks"
pinned the old behaviour and is reversed deliberately (ent#253).
@AndriiPasternak31 AndriiPasternak31 added the ui PR touches the frontend UI — triggers Playwright e2e tests label Sep 28, 2026
Keeping the last good list on a failed read made it session-scoped state,
but signOut() never cleared it, so on a shared browser the next client's
first failed read showed the previous client's asks and badge. signOut now
resets asks, asksAvailable, asksLoaded, asksFailed and asksLoadedAt. A 404
also resets asksLoaded: a surface that no longer exists has no verdict.

Found by /cso and /review. Mutations (drop the sign-out reset; drop the 404
reset) each turn one spec red.
…ent#610 PR A0)

It passes on the pre-fix code too, since the old list_asks returned [].
The flow doc, registry entry and docstring now say so. The flow doc also
names the sign-out reset.
…(ent#610 PR A0)

The #2915 entry's Files and Tests lines now name the asks router, the
store, and both A0 test files. The A0 bullet also names the sign-out reset.
Found by /validate-pr.

@dolho dolho 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.

Early look, as requested. This is a comment rather than an approval since the PR is still a draft, but it looks approvable as is.

Verification. In a worktree, backend test_ent610_asks_unavailable.py + test_ent428_workspace_asks.py gave 35 passed, and frontend workspaceAsks.spec.js gave 34 passed.

What I checked

  • Strict/non-strict split in _on_roster: this is the right shape. The list read now fails loud, and the answer path keeps its uniform 404 (Invariant #8).
  • The strict path is reachable: agent_on_roster → roster_agent_names doesn't catch its own errors, so a DB fault really does reach the new except.
  • 503 body: a fixed {code, message} with no exception text. The details go only to the server log.
  • suggestions/service._gather: still falls back to [] via attempt(..., []).
  • Frontend callers: Portal.vue's poll and PortalAsks.vue's refetch ignore the return value, so keeping the list on failure is safe for them.
  • Tests: the mutation table in the PR body matches the tests.

Findings

  1. Minor: stores/clientPortal.js:2073-2081. An outage on the first read of a page load still renders as "nothing needs you". With no earlier success, asksAvailable stays false and asks stays []. That is the likeliest outage case, someone reloading during an incident. The PR body says PR A fixes it; please link that follow-up here so the gap has an owner.
  2. Minor: stores/clientPortal.js:2073. A 401 now counts as asksFailed and keeps the list. That's only safe because the portal's 401 handler ends the session, and signOut() clears the list. Either handle 401 explicitly or add a test that pins that dependency.
  3. Nit: fetchAsks (existing behaviour). A request still in flight at sign-out that succeeds afterwards can write the previous client's asks back into the store. Now that the list is kept across failures, a cheap guard is worth it: capture portalToken before the await and drop the result if it changed.
  4. Nit: client_portal/asks/router.py:62. A Retry-After header on the 503 would let clients and proxies back off.
  5. Nit: tests/unit/test_ent610_asks_unavailable.py:88-122. The module-global _APP/_PRINCIPAL cache would read better, and be safer under random test order, as a module-scoped fixture.

@vybe

vybe commented Sep 29, 2026

Copy link
Copy Markdown
Contributor

merge-train: not on train #3070 — rides the next train once fixed.

  1. dolho's review (2026-09-29 06:17) has five points with no reply or commit after it.
  2. Behaviour regression on token expiry: src/frontend/src/stores/clientPortal.js:2073-2081 — a 401 now sets asksFailed and keeps the stale list. For a portal-token client nothing ends the session: the portalHttp 401 handler only acts when isPlatformSession, and endSession is only reached from the roster fetch (:2198), while the 20s poll (Portal.vue:2118) runs fetchAsks/refreshThreads, not the roster. Before this PR the list cleared. Suggest routing 401 through the absent branch or calling endSession({expired: true}) as the roster fetch does, with a test driving that path.

Otherwise validated clean (backend + store tests execute the real paths; Refs is right for an A0 slice).

@dolho dolho 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.

Approving now that this is out of draft. The head is unchanged (c994c6711) since my /review this morning, which found no critical findings. CI is green (22 pass) and it merges cleanly with dev.

The informational items from that review still stand for this PR or its follow-up:

  1. A first-load outage still reads as "nothing needs you". Link the PR A follow-up that fixes it.
  2. The 401 path relies on signOut() clearing the list. Handle it explicitly or pin it with a test.
  3. A fetchAsks that resolves after sign-out can write back the previous client's list. Guard it with a portalToken check.

One more thing to know: #3077 (the asks read's X-Total-Count / X-Next-Cursor paging) touches list_asks in the same route and service. Whichever lands second resolves a small overlap.

@github-actions

Copy link
Copy Markdown

⚠️ Live-instance suite skipped — merge conflict against dev.

Resolve by merging dev locally and pushing the result; the next nightly re-tests.

…10-a0

# Conflicts:
#	src/backend/client_portal/asks/router.py
#	src/backend/client_portal/asks/service.py
#	tests/registry.json
…ueue read (ent#610 PR A0)

The merge with dev brought #3059's paging, which splits the list into three
queue reads (agent set, count, page). The fault tests patched only the page
read with no ask seeded, so they no longer reached it. They now seed an ask
and parametrize over all three reads.

The 503 asks_unavailable now carries Retry-After: 20, the Workspace's asks
poll interval. The test app moves from module globals to a module-scoped
fixture.
A 401 was counted as asksFailed and the list was kept. For a portal-token
client nothing else ends the session: the 20s poll never re-reads the roster,
and portalHttp's 401 interceptor acts only for a platform session. So an
expired client's asks stayed on screen. fetchAsks now calls
endSession({expired: true, resumePath}), the roster fetch's own handling,
which clears the list through signOut().

A read that resolves after its session ended (sign-out, expiry, another
client signing in) is now dropped instead of writing the old list back.

@vybe vybe 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.

merge-train: batch validated on train/20260929-1807 (#3093)

@vybe
vybe merged commit 3086258 into dev Sep 29, 2026
27 checks passed
AndriiPasternak31 added a commit that referenced this pull request Sep 29, 2026
dev carried PR A0's final commits (#3049: the 503 Retry-After, a 401 ends the
session, a late read is dropped) that this branch predated. Resolved as the
union: A0-final plus the Inbox's asksAbsent verdict, excludeIds/askIds/
testidPrefix props and the session-list sign-out reset. The A0 unit test
takes dev's parametrized every-queue-read version.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ui PR touches the frontend UI — triggers Playwright e2e tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants