Repository navigation
fix(operator-queue): a broad list under an agent key filters before the limit and says its total - #3303
Conversation
…, and the list says its total (Abilityai/trinity-enterprise#815) The platform's heads-ups about a person were dropped for every machine key AFTER the SQL limit, so a page of them could come back empty while the caller's own rows sat just below the cut. The exclusion is now a condition in `_list_conditions` (case-insensitive, leading-whitespace-tolerant, NULL request_id kept) on both list routes, and the Python filter stays as a belt. `GET /api/operator-queue` now returns `total` (a COUNT over the same WHERE), `has_more` (read off a `limit + 1` page, never the count) and `next_offset`; a belt drop nulls `total` and adds a `warnings` entry. The router delegates to `operator_queue_service.list_for_principal`. Refs Abilityai/trinity-enterprise#815
…∪ permitted before the limit (Abilityai/trinity-enterprise#815) An agent-scoped key resolves to its owner, so its broad read ranked the owner's whole fleet, cut it at `limit`, and left the MCP to drop the rows the agent may not see — its own and its permitted peers' rows could sit below the cut. `narrow_to_agent_key` now narrows the SQL set to `({self} ∪ agent_permissions) ∩ accessible` for page, total and flags; it is pure DB and keyed on `mcp_scope`, never `acting_agent_name()`, so a system key is not narrowed. An agent-scoped principal with no agent name is a 403. `agent_names` (repeatable, at most 500, no blanks) only ever narrows, so the MCP can pass the exact set it delivers. `GET /api/agents/{name}/permissions` gains `strict=true`: one tri-state Docker snapshot, 503 when Docker cannot be read, never the fail-silent helpers that answered "200, no peers". Narrowing for completeness, not an authorization change: the MCP gate stays the authorization point (ent#629 owns enforcement on the raw routes). Refs Abilityai/trinity-enterprise#815
…ts whether it is complete (Abilityai/trinity-enterprise#815) On a broad agent-key read the tool now reads its permits FIRST, through the strict permissions read, and sends them as `agent_names`, so the backend cuts the page over exactly the rows the tool delivers. A failed permissions read is `permissions_unavailable` (retryable) — never a silent self-only view, and its fix text says a self-scoped read cannot answer for peers. A permit set over 500 names or an 8 KB request target is `permit_set_too_large`, never an unfiltered read. The output always carries count, total, has_more, next_cursor, next_offset and items; a field an older backend did not send is null with a warning, and backend warnings pass through. The post-filter stays as a belt: if it drops a row, total is null and the warning says so. The description states the completeness contract (≤ 1,800 chars). Refs Abilityai/trinity-enterprise#815
…ps a row while the queue changes (Abilityai/trinity-enterprise#815) `GET /api/operator-queue?cursor=start`, then each page's `next_cursor`. Within one walk no id is returned twice, and every row that matches when the walk starts and when the walk reaches it is returned exactly once, under concurrent inserts, endings and platform-alert priority changes. Offset mode (no cursor) is unchanged, and its order is today's. - The walk sorts "as of" a watermark W = start - 300 s: every pending -> ended writer stamps disposed_at in the same UPDATE, so a row ended after W keeps its pending-section key for the whole walk. - A pending platform alert's priority (#3246 `_touch`) is snapshotted in Redis at cursor=start under a walk id the token carries (TTL 1 h); the walk orders those rows by the snapshot, and an alert raised mid-walk ranks 4. An expired walk is a 410 "restart with cursor=start". - The bound: an ending must commit within the margin of its own stamp. The five writers call `_note_commit_lag` after commit and log a breach at error. - The token is opaque base64url JSON, unsigned (it carries no authority: every page re-applies visibility), strictly decoded (422 naming `cursor`), and bound to the caller's filters by a fingerprint; `agent_names` is not in it, so a permission change mid-walk does not break the walk. - A row whose key cannot be carried (a legacy oversized id) ends the page with has_more true, next_cursor null and a warning, never a truncated key. Refs Abilityai/trinity-enterprise#815
…ityai/trinity-enterprise#815) `cursor: "start"` begins a keyset walk and each page's `next_cursor` continues it; within one walk no item is returned twice or skipped while the queue changes. Opt-in: with no cursor the tool is today's offset mode, `offset` default 0 included, so an existing offset caller keeps one order. A broad agent-key walk re-reads the permits (strict) on every page and re-sends them as `agent_names`, so a permission change mid-walk is seen. The 8 KB request guard counts the cursor. The description teaches the walk as the way to page; `offset` is described as legacy. Refs Abilityai/trinity-enterprise#815
…cursor walk (Abilityai/trinity-enterprise#815) Requirements §26.3 / §26.6 state the completeness guarantee, the walk's guarantee with its commit-lag bound, the null + warnings contract and the fail-loud permissions read. Architecture: security §5 names the readers of `current_user.agent_name` (the queue list narrows an agent key for completeness; the MCP gate stays the authorization point), the list rows in api-endpoints, the strict permissions read in agent-lifecycle and the operator_queue.ts row in mcp-server. The operating-room flow, its test list and revision history, and the user docs (an "is anything already pending?" recipe) follow. Refs Abilityai/trinity-enterprise#815
…ule, so total is exact (Abilityai/trinity-enterprise#815) The machine exclusion ran twice: in SQL before the limit, and in Python as a belt over the page. The SQL trimmed only ASCII whitespace and SQLite's lower() is ASCII-only, so a legacy row led by U+001C-U+001F or a Unicode space, or spelling "workspace-problem-" with the KELVIN SIGN, was kept by SQL and counted in total while Python withheld it. When that row sat off the current page the belt never saw it and total overcounted with no warning (and has_more lied). PostgreSQL had the reverse hole: glibc lower('U+0130') is a bare 'i', which Python is not. The SQL now trims exactly str.isspace(), rewrites U+212A to 'k' and U+0130 to Python's 'i' + U+0307 before lower(): plain portable functions, no dialect branch. The Python belt stays as defence; B14 now forces its drop through the real route instead of relying on the old gap.
…ecoder (Abilityai/trinity-enterprise#815) encode_cursor checked each key field's character count, but the decoder caps the decoded JSON at CURSOR_MAX_BYTES. json.dumps escapes a non-ASCII character to six bytes and a quote or backslash to two, so a legacy boundary key of 700 accented characters (or a quote-heavy created_at with a backslash-heavy id) passed the field check, was issued as next_cursor, and the very next request was a 422 "not a token this server issued" - a walk that dies mid-way. encode_cursor now returns None when the encoded JSON is over the cap; the caller already turns that into has_more true, next_cursor null and the oversized-key warning.
…rned once (Abilityai/trinity-enterprise#815) The walk orders a platform alert raised after cursor=start at a fixed rank 4, because it is not in the walk's priority snapshot. Nothing pinned that arm: replacing the subject test with one that never matches left every walk test green. The new test raises an alert after page 0 and lowers it after it is returned; ordered by its live priority instead, a critical one is missed and a high one is returned twice, and the test now fails on both.
…yte cap is tested alone (Abilityai/trinity-enterprise#815) Nothing exercised the walk's Redis failure paths. The new test covers the accessor returning None and the client raising on hset, pipeline execute and hgetall: the start and a continuation answer 503 pointing at offset paging, and offset paging itself still answers 200 with Redis down. K6's "over 4,096 bytes" case added an extra key, so the key-set check refused it even with the size check deleted. It now pads a valid token's JSON with whitespace to one byte over the cap, inside the base64 length precheck, so only the decoded-bytes check can refuse it.
…I-only (Abilityai/trinity-enterprise#815) The requirement and the operating-room flow still described the SQL exclusion as trimming leading ASCII whitespace, which left a documented gap where the Python belt was expected to catch the rest. The SQL now equals is_about_a_person on every dialect, so the docs say that and keep the belt as defence only.
…a PostgreSQL-style lower (Abilityai/trinity-enterprise#815) B17 passed with or without the U+0130 rewrite in _request_id_not_prefixed_ci, because SQLite's built-in lower is ASCII-only and CI runs SQLite only. The rewrite exists for PostgreSQL, whose glibc lower turns U+0130 into a bare i. B17 now also runs with SQLite's lower replaced, on the route's own engine, by a glibc-style per-character mapping (U+0130 -> i). The listener is removed and the engine disposed afterwards, so nothing leaks to other tests. With the rewrite removed, both glibc cases go red; the plain-SQLite cases stay as they were.
…-8, non-Turkic PostgreSQL (Abilityai/trinity-enterprise#815) The docstring and both docs said the SQL exclusion equals the Python rule on every dialect. That holds on SQLite and on a UTF-8 PostgreSQL database with a non-Turkic collation, which is the supported deployment. Under a Turkic collation (tr/az) lower('I') is a dotless i, so a legacy upper-case PORTAL-INBOX-... row passes the SQL exclusion; the Python belt then drops it and the response carries total: null with a warning. It is never wider and never narrower, so the wording is scoped rather than the code changed.
The two backend unit files (broad list agent scope, cursor walk) and the MCP list_operator_queue completeness file, so the registry indexes what the branch tests (Abilityai/trinity-enterprise#815).
…ows for trinity-enterprise#815
|
@vybe, one design call in this PR that I want you to see explicitly. It is narrowing for completeness, not authorization: the MCP's Please confirm or object. If you object, the backend half comes out cleanly: the MCP half stands on its own. |
/review ReportBranch: Intent: a broad
Execution coverage (Step 2.5)
The source-text grep over the changed test files found no hits in the new tests. The only hits are in pre-existing Fix mutation: I ran these locally, reverting each fix in a scratch working tree and then restoring it.
Local runs: both new pytest files gave 58 passed. The MCP file gave 20/20 passed, and Critical Findings (block merge)None. Informational Findings (review required)[I1] Dead code & consistency: an existing JSDoc block now documents the wrong symbol (Confidence: 9/10) [I2] Performance: every list call on the polled route now runs one extra query (Confidence: 6/10) Clean Categories
Low confidence (appendix)
Summary
🤖 Generated with Claude Code |
dolho
left a comment
There was a problem hiding this comment.
Approved — /review found no blocking findings; see review comment above.
|
merge-train (2026-10-07): changed the body's |
…oth on append-only files
Four user-docs files conflicted with this train's siblings (#3294, #3297, #3299, #3303), which documented their own changes on the same lines. Each hunk keeps both sides' facts once: dev's new text, plus this sync's additions (wired-boundaries list, receipt contract, new FAQ entries, bound-widget field rules). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
merge-train (2026-10-07): merged as part of train #3329 (green). Before the squash, |
Fixes abilityai/trinity-enterprise#815
Why
An agent checks whether it, or a permitted peer, already asked a person about something by calling
list_operator_queuewith noagent_name. Under an agent-scoped key the backend ranked the key owner's whole fleet (pending first, then priority, then newest), cut it atlimit, and only then did the MCP drop rows from agents outside{self} ∪ permitted. The caller's own rows and its peers' rows could sit below the cut, socount < limitread as "that's everything" when it was not. A second filter had the same flaw: a machine key's "about a person" rows (gate-,workspace-problem-,portal-inbox-collision-) were dropped after the limit too, which could empty a machine's page entirely (system key included). An agent that concludes "never asked" asks again, and the person gets two approvals for one decision.What changes
The rule is #3059's: every visibility filter runs in SQL, in the shared WHERE builder (
_list_conditions), beforeLIMIT, so the page,totaland the flag counts describe the same rows.Backend —
GET /api/operator-queuemcp_scope == "agent"key is narrowed to{self} ∪ agent_permissions(∩ the owner's accessible set) in SQL — pure DB, keyed onmcp_scope, neveracting_agent_name(), so a system key is not narrowed. An agent scope with no agent name is 403. This is narrowing for completeness, not an authorization point: the MCP'scheckAgentAccessstays the gate;/stats,/{id}and/agents/{name}are not narrowed; enforcement on the raw routes stays with trinity-enterprise#629.agent_names(new, repeatable, ≤ 500, no blanks → 422): narrowing only — intersected with whatever the caller may see. Applies to the page,totaland the flags./agents/{name}too, for parity). It equals the Python rule (is_about_a_person): case-insensitive, leading whitespace stripped exactly asstr.strip()does (all 29str.isspace()characters), U+212A and U+0130 rewritten to Python'slower()form first, a NULLrequest_idkept. That holds on SQLite and on a UTF-8, non-Turkic PostgreSQL; under atr/azcollation a legacy upper-case row can reach the Python projection, which stays as a belt — a belt drop nullstotaland adds a warning.total(aCOUNTover the same WHERE),has_more(read off alimit + 1page, never the count),next_offset,next_cursor, pluswarningsonly when something is degraded. Additive —items,countand the flag counts are unchanged.cursor=start, then each page'snext_cursor. Within one walk no item is returned twice, and every item that matches when the walk starts and when the walk reaches it is returned exactly once, under concurrent inserts, endings and permission changes. Rows are ordered "as of" a watermarkW= walk start − 300 s, so a row answered mid-walk keeps its pending-section key; pending platform alerts are ordered by a per-walk priority snapshot in Redis (TTL 1 h; an alert raised mid-walk ranks last), so a re-prioritised alert cannot repeat or be skipped. The token is opaque, unsigned and carries no authority (every page re-applies the caller's visibility). Refusals: malformed token → 422 namingcursor;cursorwith a non-zerooffset→ 422; a token reused with different filters → 422; expired walk → 410 "restart with cursor=start"; Redis unreachable or failing → 503. A sort key too long to carry ends the page withhas_more: true,next_cursor: nulland a warning. Withoutcursorthe route is in offset mode, ordered exactly as before — the Operations UI and every existing caller see no change.Backend —
GET /api/agents/{name}/permissions?strict=true(new, opt-in): built from ONEagent_container_states()snapshot; 503 when Docker cannot be read, instead of the lenient path's "200, no peers". Without the flag the endpoint is unchanged (frontend and the other MCP callers).MCP —
list_operator_queueagent_names, so the page covers exactly what the tool delivers. A failed permissions read is{error: "permissions_unavailable", retryable: true}— never a silent self-only answer; over 500 names or an 8 KB request target ispermit_set_too_large, never an unfiltered read.count, total, has_more, next_cursor, next_offset, items. A field an older backend does not send becomesnullwith "completeness not verified" (version skew). The post-filter stays as a belt; if it ever drops a row,totalbecomesnullwith a warning.cursorparameter; a walk re-reads the permits on every page. The description states the contract (1,432 characters; the fix(mcp): fit deploy_local_agent and get_objectives inside Claude Code's 2,048-char description cap (#3234) #3238 budget census stays green).Behaviour changes to note
{self} ∪ permitted, not the owner's whole queue; an explicitagent_namefor a non-permitted agent returnsitems: [], total: 0(the same answer whether the agent exists or not). Narrower, never wider.nullpaging fields with a warning; an older MCP ignores the new fields.Tests
tests/unit/test_ent815_broad_list_agent_scope.py(28) — the real router over a real SQLite file and realagent_permissionsrows: the window test (stranger rows saturatinglimit=10cannot hide the caller's or a permitted peer's rows; admin and non-admin owner), persons / user / system keys not narrowed, strangeragent_name→ empty page, flags narrowed,agent_namesonly narrows, the SQL exclusion incl. everyisspace()lead and the two code-point rewrites (also under a PostgreSQL-stylelower),has_morefrom the page, a forced belt drop, strict permissions, dialect compile.tests/unit/test_ent815_queue_walk.py(30) — inserts ahead of / behind the cursor, all five pending→ended writers between pages, astatus=pendingwalk, a permission change, alert priority changes and an alert raised mid-walk, a hypothesis property test over random interleavings, the commit-lag bound and its error log, the strict codec (422/410/503), a forged token never widening, over-cap keys, offset order unchanged, shared expressions on both dialects.src/mcp-server/src/operator_queue_list.test.ts(20).npm test740/740 andnpm run buildclean;lint_sys_modules/lint_root_test_placementclean. Full suites run in CI.Not verified
ltrim, collation of tie order) is unproven.max(length(id)),max(length(created_at))) was run. A key too long for a cursor is loud by design (has_more: true,next_cursor: null, a warning), so this only decides how often that warning can appear.Sequencing
#3255 has merged and is in this branch's base, so no rebase is expected for it. This does not stack on #3256; the MCP tests are in a new file so the two cannot conflict textually.
Out of scope (seen, not done here)
list_reportsfilters after the limit → fix(mcp): a broad list_reports under an agent key filters after the backend's limit #3300list_recent_executionsfilters status after the limit → fix(mcp): list_recent_executions applies its status filter after the limit #3301/stats,/{id}and/agents/{name}(trinity-enterprise#629)_not_prefixedused by the sweeps (unchanged on purpose)status/type/priority(a typo silently returns an empty list)limit/agents/{name}(nooffsetthere today, no machine caller)