Skip to content

feat(operator-queue): ask endings — one sink, endings ledger, person-only endings, wake on any ending, self-readback (abilityai/trinity-enterprise#611, PR A of 2) - #3023

Merged
vybe merged 7 commits into
devfrom
feature/ent611-ask-object
Sep 27, 2026
Merged

vybe merged 7 commits into
devfrom
feature/ent611-ask-object

Conversation

@webmixgamer

@webmixgamer webmixgamer commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Description

An ask ends in exactly one of three ways — answered, cancelled or expired. This PR records every ending, audits it once, delivers it to the agent that raised it, and shows it with who and when. It is PR A of the ent#611 ask object; PR B adds the native create path (ask_operator over MCP).

  • One sink. Every ending goes through services/ask_service.py: the operator answer, the Workspace answer, single cancel, bulk cancel and the poller's expiry. Before this there were five write sites, each doing its own subset: single cancel did none of audit, broadcast or wake; respond audited nothing; expiry never broadcast. Each ending now runs the same four steps:
    1. a compare-and-set UPDATE that also writes the endings ledger (disposition, disposed_at, disposed_by ∈ {person, timeout}, disposed_by_email, disposition_reason, batch_id);
    2. one audit row per transition, ids and enums only;
    3. one thin /ws trigger;
    4. the registered ending observers, which receive only the rows this call won.
  • Only a person ends an ask. respond, cancel and bulk-cancel, and the Workspace answer, refuse agent-, system- and every other non-person key with 403 person_required. It is an allowlist over mcp_scope, and it runs before the row is read. Before this, an agent's own key resolved to its owner and could answer its own approval.
  • No answer after the deadline: 409 expired, even before the poller sweeps the row. expires_at is stored ISO-Z at ingest (Invariant #16).
  • Wake on any ending (the ent#329 opt-in).
    • An answer keeps its operator_response resume.
    • A cancel or an expiry wakes the filer once per agent per event (trigger operator_ending). An expiry carries the rider verbatim: "Denied by timeout; do not re-ask the same action without new information." The operator's reason is framed as data.
    • Platform alarms, rows the agent already closed, and stopped agents are skipped; the skip is audited.
  • The agent reads its ending back.
    • GET /api/agents/{name}/operator-queue/{request_id} and MCP get_my_ask: self-only (identity comes from the key), a redacted projection, and still readable after Clear All.
    • An "Ended asks (last 24 h)" line in every composed Execution Context, carrying ids, disposition and time only.
  • Surfaces.
    • The Operations Resolved card shows who ended the ask and when; the feed sorts by ending time (Credential-sync workflow missing for existing agents #627 AC6).
    • /m gets a "Recently ended" strip.
    • The Workspace lists asks that ended in the last 7 days, with a coarse who (you / the operator / timeout) and never an operator's email or reason. The sidebar count and "Waiting on you" stay pending-only.
    • The Clear-All and wake opt-in copy say only what the platform delivers.
  • Migration pair: operator_queue_ask_object (SQLite) and Alembic 0076_operator_queue_ask_object on 0075_auto_sync_enabled_backfill. Twelve nullable TEXT columns, no backfill. Six serve the endings ledger; six serve the agent-raised ask that PR B writes.

Design decisions beyond the approved plan

  • Opt-in pre-read in the default observer (ask_service._opted_in, one flag read per agent per event). An agent that has not opted in is never handed to a spawner, which keeps ent#430's rule that an opted-out agent is not dispatched at all. The spawned work re-reads the flag at spend time, and that read is the authority.
  • The bug: POST /api/operator-queue/{id}/respond accepts any string as an approval decision — no layer checks response ∈ options #2376 offered-option check moved into the sink (answer() validates first). The sink is the one writer of an answer, so no entry point can skip the check. The routes map ResponseNotOfferedError to their existing 422 shapes, and the bug: POST /api/operator-queue/{id}/respond accepts any string as an approval decision — no layer checks response ∈ options #2376 AST guard now names services/ask_service.py.
  • Thin triggers.
    • operator_queue_responded and the new operator_queue_cancelled carry {id, agent_name} only. operator_queue_responded used to broadcast the answer text and the responder's email.
    • The sink's bulk operator_queue_cleared drops cleared_by; it is allowlisted in the ent#467 guard with its reason.
    • Expiry sends no trigger of its own: the poll cycle's one operator_queue_sync announces it, even when Docker is unreadable.
    • _broadcast_payload is a named identity function so that the ent#467 guard, which follows a *broadcast*(…) call back to its dict literal, can read every payload.
  • Workspace.
    • answerAsk replaces the row with the server's answered projection instead of removing it.
    • PortalAsks hides the answer controls and the sync badge on ended asks.
    • PortalWork passes pending-only.
  • Trigger operator_ending is registered wherever operator_response is: the Executions filter, the analytics bucket, _AUTONOMOUS_TRIGGERS, PULL_REACHABLE_TRIGGERS, canvas_service._OPERATOR_SIDE_TRIGGERS, and the frontend KNOWN_TRIGGERS. operator_response was also missing from KNOWN_TRIGGERS: at 17 characters it exceeds the 16-character fallback and was folding into "other".
  • The wake setting was renamed to "Wake this agent when its asks end". The user-doc anchor moved to #wake-when-an-ask-ends, and every live link was updated.

Fixed during review and /cso --diff (each with a failing test first and a red mutation)

  • An extreme-offset deadline (past year 9999 once moved to UTC) raised at ingest, so the poller quarantined the ask instead of showing it. It is now kept verbatim, like unparseable text.
  • The wake's container-state read was a blocking Docker call on the event loop. It now goes through the bug: Workspace roster lists agents with no container (DB-sourced roster vs Docker-as-truth) #2196 executor wrapper.
  • An operator's Clear All made a client's ended ask vanish from the Workspace inside the 7-day window. The Workspace list now reads past cleared_at, which is the operator's list hygiene, just as the agent's readback does.
  • The Clear-All confirmation promised a wake to opted-in agents that are stopped. It now says running agents.
  • CSO F1 (MEDIUM, independently verified): a system-scoped key could still answer an ask through the Workspace answer route, recorded as disposed_by='person'. PortalPrincipal now carries is_person, and the answer route refuses a non-person with the same 403. The Workspace's read breadth for system keys (bug: Agent Detail and Workspace over-fetch on load (21 redundant requests, N+1 roster) #2198) is unchanged. Report: docs/security-reports/cso-diff-2026-09-25-ent611-ask-endings.md.

Related Issue

Part of abilityai/trinity-enterprise#611 (PR A of 2). The issue moves to status-in-dev only when PR B lands.

This PR has no closing keyword, on purpose. It is PR A of 2, and the issue closes with PR B. Adding one at merge time would mark the issue done before its second half ships.

Journey Impact

Journey Impact: extends: J05

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Behaviour change worth naming: agent- and system-scoped keys can no longer answer or cancel asks. An agent answering its own approval was self-approval; the MCP respond_to_operator_queue description says so.

Testing

  • I have tested this locally
  • New tests added (if applicable)
  • All existing tests pass
  • Every new test executes the changed path

Backend. tests/unit/test_ent611_ask_endings.py, 162 tests, real SQLite for every ledger writer. Neighbour suites were updated where the sink moved a call site: ent#329, ent#430 ×2, #2915, #2376, ent#467, ent#499, #2048, #2198. The full unit island ran sequentially on the merged tree (669f585a1): 18,209 passed and 40 failed. The 40 are the same order-dependent set as a clean cec2a64be run: test_ent666_objective_join ×21, test_ent477_* ×15 and test_retention_floor ×4. Each passes in isolation. They predate this PR and are now registered as debt.

Frontend. operatorQueueEnding.spec.js, portalAskEndings.spec.js, mobileAdminRecentlyEnded.spec.js (mounted, jsdom) and workspaceAsks.spec.js. The raw-colour, loading-gate and source-text ratchets are green with no baseline edits: the new UI reuses each file's existing class strings.

MCP. operator_queue.test.ts (the get_my_ask stub client) and access-wiring.test.ts (transport: identity from the key; a user-scoped key refused).

Guards. check_alembic_heads (one head, 0076) and check_alembic_parity both PASS. The enterprise-docs guard has 0 hits, with a positive control.

Mutation:

  • answer() drops its _status_conflict refusal → TestSink lost/late-answer tests and the tightened G2 guard (test_ent329_operator_resume.py) go red.
  • bulk_cancel hands observers the REQUESTED rows → TestTheWakeGetsOnlyWhatEnded::test_pending_a_and_already_cancelled_b_wake_for_a_only goes red.
  • The wake drops the platform-alarm skip → TestWakeObserverRouting and test_ent499_problem_report.py go red.
  • The get_my_ask call site uses a fixed agent → operator_queue.test.ts and access-wiring.test.ts go red.
  • The ent#467 allowlist entry is removed → the guard names services/ask_service.py.
  • Without OverflowError → TestCreate::test_expires_at_is_stored_as_iso_z[9999…] and test_the_stored_deadline_never_reads_as_a_rewrite[9999…] go red.
  • A sync Docker read on the loop → TestEndingWake::test_the_running_check_never_blocks_the_event_loop goes red.
  • include_cleared dropped → TestWorkspaceListing::test_an_operator_clear_all_never_hides_an_ending_from_the_person_it_was_for goes red.
  • The Workspace refusal removed → TestWorkspaceAnswerIsPersonOnly::test_a_non_person_platform_principal_is_refused_before_the_row_is_read goes red.
  • is_person never computed → TestWorkspaceAnswerIsPersonOnly::test_the_platform_path_carries_whether_the_caller_is_a_person[system-False] goes red.

Each file was restored byte-identically.

What CI does not run

  • The live-stack walk-through, done on a local instance before this PR:
    • answer, cancel with a reason, bulk cancel (2 cancelled and 1 skipped, one batch_id on exactly the flipped rows), and an expiry swept about 2 s after its deadline, each ledgered;
    • audit rows answered / cancelled (has_reason, no text) / bulk_cancel / expired;
    • on an opted-in agent: 1 operator_response and 2 operator_ending wakes, all successful, with the expiry wake carrying the rider verbatim. A not-opted-in agent was never woken;
    • the agent's own key refused on respond (403 person_required);
    • get_my_ask over MCP returning the redacted projection;
    • the surfaces checked in both themes: the Resolved card, the Clear-All and Reliability copy, the /m strip, and the Workspace flow as a client.
  • PostgreSQL. The 0076 upgrade on real PostgreSQL runs only in the path-filtered pg-migrations job.
  • The pull-worker path does not compose the platform prompt (bug: pull-claimed turns bypass platform system prompt composition — platform contract never delivered on the pull path #1629), so pull-claimed turns do not get the "Ended asks" line.

Merge notes

  • Migration. 0076_operator_queue_ask_object parents on 0075_auto_sync_enabled_backfill. It was 0075 on 0074 until fix(git-sync): the auto-sync toggle is authoritative and live (#3010) #3017 landed its own 0075 on the same parent; it was re-parented and renumbered then (the two touch disjoint tables). If another revision lands on dev first, re-parent down_revision onto the new head at merge; alembic-head-watch will comment.
  • Lane C (dependencies.py, client_portal/portal_auth.py): journey-smoke and frontend-e2e must be green on this PR.
  • Registered, not fixed here:
    • agent keys still read the pre-existing responded_by_email / addressed_to_email on queue reads;
    • the wake is not durable across a crash;
    • the expiry wake is an uncapped agent-driven self-trigger (independent verification: no worse than the uncapped self-scheduling that already exists; bounded by max_parallel_tasks).
  • Separate PR: the pre-existing bug(operator-queue): a pending approval can sit unseen for days — the human's card goes stale while the container↔platform sync stays silent #2915 stale_id mis-flag, where every ended ask reads "Re-used id" on the Resolved card.

Checklist

  • My code follows the project's style guidelines
  • I have updated the documentation (if applicable)
    • requirements security.md §26.6 and new §26.9 OPS-001-ENDINGS;
    • feature flows operating-room.md (Endings), execution-context-injection.md, mobile-admin-pwa.md, workspace-work.md, workspace-agents-at-the-centre.md, workspace-rail.md;
    • architecture api-endpoints.md, database.md, mcp-server.md, background-services.md, workspace.md;
    • user docs (approvals, the wake setting, the FAQs, executions);
    • three learnings fragments.
  • I have not committed any sensitive data (API keys, credentials, etc.)
  • I have added appropriate logging for new functionality

🤖 Generated with Claude Code

webmixgamer and others added 3 commits September 25, 2026 14:28
…only endings, wake on any ending, self-readback (Abilityai/trinity-enterprise#611, PR A of 2)

Every way an ask ends goes through one sink, services/ask_service.py: the
operator answer, the Workspace answer, single cancel, bulk cancel, and the
poller's expiry. Each ending runs the same four steps:

- a compare-and-set that also writes the endings ledger (disposition,
  disposed_at, disposed_by person|timeout, disposed_by_email,
  disposition_reason, batch_id);
- one audit row per transition;
- one thin /ws trigger;
- the registered ending observers, which receive only the rows this call won.

What changes:

- Only a person ends an ask. respond / cancel / bulk-cancel and the Workspace
  answer refuse agent-, system- and every other non-person key with 403
  person_required (an allowlist).
- An answer after the deadline is a 409 expired, even before the sweep.
- The ent#329 wake now fires on any ending, under the same opt-in. A cancel or
  an expiry wakes the agent once per agent per event (trigger
  operator_ending); an expiry is framed as "Denied by timeout".
- An agent reads back its own ask by request_id: new
  GET /api/agents/{name}/operator-queue/{request_id}, and MCP get_my_ask.
- Every composed turn gets an "Ended asks" Execution Context line.
- Endings show with who and when in the Operations Resolved feed, a /m
  "Recently ended" strip, and the Workspace (ended asks listed for 7 days
  with a coarse who).
- Migration pair operator_queue_ask_object (SQLite) / 0075 (Alembic):
  12 nullable columns.

Review and CSO fixes, each with a failing test first and a red mutation:

- an out-of-range deadline no longer fails ingest;
- the wake's container check runs off the event loop;
- an operator's Clear All no longer hides a client's ended asks;
- the Clear-All confirmation promises a wake only to running agents;
- a system-scoped key can no longer answer an ask through the Workspace
  answer route.

Part of Abilityai/trinity-enterprise#611 (PR A of 2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, Clear-All read-through, deadline overflow and the off-loop running check

Part of Abilityai/trinity-enterprise#611 (PR A of 2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@webmixgamer webmixgamer added the ui PR touches the frontend UI — triggers Playwright e2e tests label Sep 25, 2026
@webmixgamer

Copy link
Copy Markdown
Contributor Author

The two red checks (pg-migrations, schema-parity) are the SQLAlchemy 2.1.0 test-environment breakage, not this PR.

  • tests/requirements-test.txt sets only a floor, sqlalchemy>=2.0.0, so CI resolved the new 2.1.0.
  • 2.1.0 makes psycopg 3 the default driver for postgresql://, but these jobs install psycopg2.
  • So alembic upgrade head fails at import_dbapi (ModuleNotFoundError: No module named 'psycopg') before any revision runs.

The same failure appears on other open PRs; #3015 caps the test pin. Production images pin sqlalchemy==2.0.36 and are unaffected.

I'll re-run once #3015 lands. Everything else is green, including journey-smoke, e2e, the regression diff and pytest (head).

@webmixgamer

Copy link
Copy Markdown
Contributor Author

/review Report

Branch: feature/ent611-ask-object → dev. Merge-base is cec2a64be; dev's one later commit, #3012, came in by the merge 669f585a1.
Files Changed: 82 (+4903 / −520)
Scope: CLEAN. The intent is PR A of abilityai/trinity-enterprise#611: how an ask ends. The diff delivers one transition sink, the endings ledger, person-only endings, a wake on any ending, the agent's own readback, the Execution Context line and the surfaces. PR B's create path is untouched.
Plan Completion: every PR A item of the approved plan is done, with no gaps.

Execution coverage

changed symbol executed by live consumer verdict
ask_service.answer / cancel / bulk_cancel / expire TestSink, TestEndingRoutes (real SQLite) the operator routes, the Workspace answer_ask, the poll cycle ✅ executed
_wake_filer → maybe_dispatch_ending TestEndingWake, TestWakeObserverRouting, route-level TestTheWakeGetsOnlyWhatEnded the sink's _ended ✅ executed
is_person_principal, reject_non_person_principal, get_self_acting_agent, PortalPrincipal.is_person TestPersonGate*, TestSelfReadback, TestWorkspaceAnswerIsPersonOnly, each principal kind separately respond, cancel and bulk-cancel; the readback route; the Workspace answer ✅ executed
the G2, #2376, ent#430 and ent#467 AST guards CI route and sink source 🛡 guard, backed by the behavioural tests above

Fix mutations: 10 in total, all red, each on the line that applies the fix and restored byte-identically. They are listed in the PR body under Mutation.

Critical Findings

None.

Informational Findings — all fixed on this branch, each with a failing test first

  • [I1] Error handling. db/operator_queue.py::_iso_z_deadline let an OverflowError escape for a deadline past year 9999 once moved to UTC. The create then raised, and the poller quarantined the ask instead of showing it. Its fingerprint twin, _normalise_expires, already caught that error. The value is now kept verbatim.
  • [I2] Event-loop blocking. maybe_dispatch_ending called the synchronous Docker read inside a coroutine. It now uses docker_utils.agent_container_state_async, the bug: Workspace roster lists agents with no container (DB-sourced roster vs Docker-as-truth) #2196 executor wrapper, and still never raises.
  • [I3] Requirement gap (C2). The Workspace list now carries ended asks, but it had inherited include_cleared=False. So an operator's Clear All hid a client's ended ask inside the 7-day window. It is now include_cleared=include_ended, the rule the agent's own readback already follows.
  • [I4] Copy vs. the issue's AC. The Clear-All confirmation promised a wake to opted-in agents that are stopped, but those are skipped. It now says "running agents".

Security (/cso --diff)

One MEDIUM finding, independently verified and fixed here. A system-scoped key could answer an ask through the Workspace answer route, and the answer was recorded as disposed_by='person'. The report is in docs/security-reports/cso-diff-2026-09-25-ent611-ask-endings.md.

Clean categories

  • SQL: parameterised throughout. The only f-string SQL is the migrations' constant column tuple.
  • Races: every ending is a compare-and-set. Bulk cancel re-selects its winners by batch_id, and expiry is a per-id compare-and-set.
  • Auth: the gate is an allowlist over mcp_scope and fails closed on a missing attribute. The readback checks identity first and answers with a uniform 403.
  • Credentials: none found in the added lines.
  • Enterprise-docs guard: 0 hits, and the positive control fires.
  • Enum completeness: every reader of operator_response also knows operator_ending, and derive_mode maps it to task.
  • Docs: requirements, feature flows and the architecture area files are updated to match.

Residuals registered, not changed here

  • Agent keys still read the pre-existing responded_by_email / addressed_to_email on queue reads.
  • The wake is not durable across a crash. The readback and the context line still carry the ending.
  • The expiry wake is an uncapped agent-driven self-trigger. It is no worse than the self-scheduling that already exists, and max_parallel_tasks bounds both.

Summary

@github-actions

github-actions Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

✅ Alembic head check clear — merging this PR into dev leaves one head (0075_auto_sync_enabled_backfill).

Previously flagged; resolved.

Advisory — this check does not block merge. · head_sha: dfcb7c07bfddb434cf62887d3d74a10d5e71121e · run

webmixgamer and others added 3 commits September 25, 2026 17:48
# Conflicts:
#	src/backend/db/migrations.py
#	tests/registry.json
…0076_operator_queue_ask_object

#3017 landed 0075_auto_sync_enabled_backfill on 0074, the same parent as
0075_operator_queue_ask_object, which made two heads: upgrade head would
then apply zero revisions (#2068). Renumber ours to 0076 and parent it on
dev's 0075. The two revisions touch disjoint tables (agent git config vs
operator_queue), so the order carries no design decision. The SQLite
migration keeps its name (operator_queue_ask_object), so a database that
already applied it does not run it again; its list entry follows dev's.

Point-in-time security reports keep the number they audited.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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

Validated at lane C + schema, head dfcb7c0: READY. No criticals. All checks green on this head. The four terminal writes are CAS-gated on status == 'pending' with the ledger in the same UPDATE; the person gate is an allowlist that runs before the row read; new /ws triggers carry identifiers only; the three schema sources agree column for column and the tree resolves to one Alembic head (0076).

Follow-ups, none blocking, all for your call:

  1. Ended asks above the composer are unbounded. PortalConversation.vue:435-443 mounts PortalAsks without pending-only, and fetchAsks now sends include_ended: true, so the shrink-0 block above the composer renders every ask that ended in the last 7 days (server limit 200) with no max-height or scroll. On a phone a handful of ended asks takes most of the screen. It needs a bound or a collapsed form; which one is a design choice.
  2. Cancel reason has no in-product producer: stores/operatorQueue.js:209 sends { ids } only, so the field is reachable by raw API alone.
  3. to_role, resolved_to, proposal and supersedes_expired have no writer until #3028 lands.
  4. The expiry wake is agent-drivable: an opted-in agent can file short-deadline asks and be woken each poll cycle (operator_resume_service.py, maybe_dispatch_ending). Bounded by the opt-in and max_parallel_tasks, but uncapped.
  5. Machine keys are denied disposed_by_email but still receive responded_by_email, which holds the same value on answered rows.
  6. client_portal/asks/service.py answer_ask reports resume_requested from the opt-in alone, so a platform-minted row whose observer skips dispatch still reports true.

The branch is left in place because #3028 is stacked on it.

@vybe
vybe merged commit f6dfb00 into dev Sep 27, 2026
30 checks passed
vybe pushed a commit that referenced this pull request Sep 27, 2026
… fields; keep-both elsewhere

dev (#3023) and this branch each added a third field to PortalPrincipal.
dev's is_person stays third, since code on dev already constructs it
positionally; is_admin goes last, which is what its own comment asks for.
get_portal_principal passes both. The one positional construction that
meant is_admin (test_ent465_suggestions.py) now names it.

migrations.py tail and tests/registry.json: both sides kept, dev's first;
the registry is rebuilt from the merge stages, not spliced.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
webmixgamer added a commit that referenced this pull request Sep 27, 2026
…ipts, re-asks (Abilityai/trinity-enterprise#611, PR B)

An agent raises an ask through ONE platform call instead of appending to
~/.trinity/operator-queue.json: POST /api/agents/{name}/operator-queue and
the MCP tool ask_operator, both self-acting (the agent comes from the key;
any other principal gets 403 agent_identity_required).

- ask_service.raise_ask is the create entry point of the ask sink, the seam
  the gate path (trinity-enterprise#164) calls with raised_by="gate". Static
  validation with named 422s; replay BEFORE every time-dependent check; the
  15-minute deadline floor; the own-expired-predecessor link and the
  reask_requires_link refusal; role addressing; the #1632 rate buckets; an
  atomic per-agent create (replay, depth cap and insert under one lock: a PG
  advisory transaction lock, SQLite BEGIN IMMEDIATE); one `raised` audit row
  and a thin operator_queue_new trigger.
- The receipt names the role, never an email. A replay returns the first
  receipt with `differs`; `wakes_on_ending` says whether the owner's wake
  opt-in is on.
- Native rows sit outside the file contract everywhere. The poller skips a
  file entry that re-uses a native id before any branch reads it (logged
  once), creates through create_operator_queue_item_with_outcome and counts
  only what it inserted, and names the file channel's deprecation once per
  agent per process.
- The Operator Communication prompt leads with ask_operator by its bare
  name; the file is the fallback for two releases (the meta-prompt copy and
  the agent guide mirror it).
- The Operations cards link a re-ask and the expired ask it re-raises.

Rebuilt on dev after #3023 squash-merged (f6dfb00): this commit is the net
delta of the former PR B commits 19b2e53..3a2dffd (the feat above, the
two review fixes: the `raised` audit row is the agent's, and reads never
name a native ask's resolved person; plus the docs), replayed onto dev
unchanged. Same 47 files and the same added/removed lines as B's old diff
against #3023's final head dfcb7c0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@webmixgamer
webmixgamer deleted the feature/ent611-ask-object branch September 27, 2026 14:04
vybe pushed a commit that referenced this pull request Sep 29, 2026
…ipts, re-asks (Abilityai/trinity-enterprise#611, PR B) (#3028)

* feat(operator-queue): agents raise asks natively — ask_operator, receipts, re-asks (Abilityai/trinity-enterprise#611, PR B)

An agent raises an ask through ONE platform call instead of appending to
~/.trinity/operator-queue.json: POST /api/agents/{name}/operator-queue and
the MCP tool ask_operator, both self-acting (the agent comes from the key;
any other principal gets 403 agent_identity_required).

- ask_service.raise_ask is the create entry point of the ask sink, the seam
  the gate path (trinity-enterprise#164) calls with raised_by="gate". Static
  validation with named 422s; replay BEFORE every time-dependent check; the
  15-minute deadline floor; the own-expired-predecessor link and the
  reask_requires_link refusal; role addressing; the #1632 rate buckets; an
  atomic per-agent create (replay, depth cap and insert under one lock: a PG
  advisory transaction lock, SQLite BEGIN IMMEDIATE); one `raised` audit row
  and a thin operator_queue_new trigger.
- The receipt names the role, never an email. A replay returns the first
  receipt with `differs`; `wakes_on_ending` says whether the owner's wake
  opt-in is on.
- Native rows sit outside the file contract everywhere. The poller skips a
  file entry that re-uses a native id before any branch reads it (logged
  once), creates through create_operator_queue_item_with_outcome and counts
  only what it inserted, and names the file channel's deprecation once per
  agent per process.
- The Operator Communication prompt leads with ask_operator by its bare
  name; the file is the fallback for two releases (the meta-prompt copy and
  the agent guide mirror it).
- The Operations cards link a re-ask and the expired ask it re-raises.

Rebuilt on dev after #3023 squash-merged (f6dfb00): this commit is the net
delta of the former PR B commits 19b2e53..3a2dffd (the feat above, the
two review fixes: the `raised` audit row is the agent's, and reads never
name a native ask's resolved person; plus the docs), replayed onto dev
unchanged. Same 47 files and the same added/removed lines as B's old diff
against #3023's final head dfcb7c0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(config): wire OPERATOR_QUEUE_PROPOSAL_MAX_BYTES like its siblings (Abilityai/trinity-enterprise#611, PR B)

The native ask path reads OPERATOR_QUEUE_PROPOSAL_MAX_BYTES (default 8192),
but no compose file passed it into the backend container, so setting it in
.env had no effect on a deployed instance. The 14 sibling operator-queue caps
are all in the three compose files and .env.example; this one now is too.

It sits right after OPTIONS_MAX_BYTES, the same position as the constant in
operator_queue_service.py. So prod and hosted keep identical environment
lists, and the #2280 parity guard compares them in order. The deploy guide's
env table and requirement §26.10 name it as well.

Found by /validate-pr §4.9.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(operator-queue): review round on the ask sink — the gate's reserved prefix, raised_by on the receipt, primary's empty answer, rate check first (Abilityai/trinity-enterprise#611, PR B)

From the changes-requested review and the merge-train note on #3028:

- `gate-` is a platform-reserved id prefix. A gate raise (`raised_by="gate"`,
  `channel="gate"`) must use it, and an agent's raise may not. So an agent
  can no longer pre-create the gate's id and have the gate's raise answered
  as a replay of the agent's own proposal. And it is no longer woken to redo
  an action the gate resumes itself, because `is_platform_minted` keys on the
  reserved tuple and gates both wakes. The receipt now says who raised the
  ask (`raised_by`), and an unknown raiser or channel is a ValueError.
- A provider that answers "nobody" (`[]`) for `primary` now sends the ask to
  the operators with `resolved: false` (the ent#606 fallback). The owner
  stands in only when no provider answers at all.
- The rate check runs right after the replay, before the re-ask scan and the
  owner lookup read the database. A refused ask now spends a token; a replay
  still spends none.
- The native insert takes ON CONFLICT DO NOTHING. The file poller does not
  take the per-agent lock, so on PostgreSQL a file entry re-using the id can
  land between the replay check and the insert. That now comes back as a
  replay of the file row, not a 500.
- `get_my_ask` names a re-ask's predecessor by request_id, as the receipt
  does. The row still stores its uuid.
- Docstrings: a gate raise shares the agent's rate and depth budget (it fails
  closed), and ending observers run in-process and at most once.
  `channel` is documented as file|mcp|gate.

Tests came first: 18 went red before their fixes. New tests also run the
real owner lookup, and a real minted key through the route: key resolution,
the identity check and the ephemeral fence. Each of 13 mutations of the
fixed lines turns a test red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(operator-queue): an approval shows its proposal wherever the decision is made (Abilityai/trinity-enterprise#611, PR B)

The prompt tells agents to put the exact action in `proposal` "so the
operator can verify what they are approving", but nothing rendered it. An
agent that followed the prompt moved the one thing an approver must check
out of view (review on #3028).

- `components/operator/QueueProposal.vue` renders it read-only. The rows
  come from `utils/operatorQueue.js::proposalRows`: top-level fields in the
  agent's order, text verbatim, anything else as compact JSON. It is
  agent-authored, so the values are text only (never v-html). The component
  uses gray tokens only, and the list scrolls inside its own box.
- It appears on every card that offers the decision: the open Operations
  card (above the collapsed context, never behind "Show details"), the /m
  ops card and the Workspace ask.
- The Workspace projection (`WorkspaceAsk`) now includes `proposal`;
  `context` stays out. An ended ask keeps its proposal, so the person sees
  what was decided.
- `QueueItemDetail.vue` has no importer and reads store state that no
  longer exists, so it is left alone.

Mounted specs cover the three surfaces, the rule and the text-only
rendering (12 tests), with two projection tests beside them. Each of 7
mutations of the wiring turns a test red. Checked by eye on a local
instance in light and dark.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(operator-queue): the approval's second review pass — order-safe test, gate rows' receipts, replay and re-asks stay with their raiser, proposal on Resolved (Abilityai/trinity-enterprise#611, PR B)

From the approving review on #3028:

- `test_an_agent_may_not_author_a_gate_id` now has its own agent and ids.
  A sibling test creates `gate-call-abc` on the same agent, and the unit
  database keeps rows between tests. The test failed on 2 of the 3 nightly
  seeds and passed on the one seed PR CI uses. It now passes under 20 seeds,
  the nightly three included.
- A gate row's receipt no longer promises a wake: `wakes_on_ending` is false
  on a platform-minted row, which `_wake_filer` skips.
- `channel="gate"` if and only if `raised_by="gate"`; anything else is a
  ValueError.
- A gate raise is never answered as a replay of a row it did not raise, at
  either replay site (409 `request_id_taken`). Example: a file row named
  `gate-…` written before the prefix was reserved.
- The re-ask guard (C6) and the re-ask link compare with the SAME raiser's
  expired asks. An agent's expired ask no longer blocks, or gets linked by,
  a gate raise, and the reverse. `list_expired_proposals_for_agent` gains a
  `raised_by` filter. `_raiser_of` treats a row older than the column as the
  agent's own, and a platform alarm as nobody's.
- The Operations Resolved card keeps the proposal too, so the record says
  what was decided (the Workspace already did).

Tests came first: 9 went red before their fixes, and 2 more pin the legacy
rows. Each of 12 mutations of the fixed lines, both replay call sites
included, turns a test red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(operator-queue): the race tests' holds patch the running method's globals (Abilityai/trinity-enterprise#611, PR B)

The full unit island at nightly seed 67890 failed the staged-race test on
PostgreSQL's sqlite leg with `created` where `replayed` was expected.

The holds were installed with `monkeypatch.setattr(dbq, "make_insert", …)`
after `import db.operator_queue as dbq`. In a full-suite run a sibling test
can leave a stand-in for `db.operator_queue` that re-exports the real
class. The patch lands on the stand-in, while the real method reads its own
module dict, so the hold never fires. Reproduced with a plugin that installs
such a stand-in:
- the committed staged-race tests fail;
- the concurrent-cap test passes even with the lock removed.

All four holds now go through `create_native_item.__globals__` (test_1632's
`_patch_engine`), and the table object comes from the same dict. With the
stand-in installed, the fixed tests pass, and removing the lock turns the
cap test red again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
dolho added a commit that referenced this pull request Sep 30, 2026
…ent#610 stopgap)

Since #3023 the Workspace asks list carries asks that ended in the last 7
days, and the strip above the composer rendered all of them. Answered
approval tiles piled up in every chat with the agent. Per the ent#610
ruling, an ended ask belongs to history, not to the strip.

The strip now hosts PortalAsks `pending-only`, like the Work tab. It is no
longer gated on a count in the conversation: PortalAsks decides its own
visibility, including the ent#468 "sent" confirmation that must outlive the
last pending ask. A parent v-if on the pending count would unmount it the
instant it appears. The top gap follows content via :has().

Mount tests cover:
- the strip passes pending-only;
- a pending ask shows and an answered one never does;
- only ended asks renders nothing;
- answering the last ask keeps the confirmation;
- without pending-only, history still lists ended asks.

Three source-text pins that asserted the old gating shape now assert the new
one, with the same intent. Mutation: dropping pending-only turns the wiring
test red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

2 participants