feat(workspace): an answer given in the Workspace resumes the agent (ent#430) - #2429
Conversation
…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>
obasilakis
left a comment
There was a problem hiding this comment.
The structure is right and that is the hard part: one call into operator_resume_service, no second dispatch surface, no re-decision of the opt-in, the idempotency key, the audit row or the failure handling. AC #2 is genuinely satisfied by reuse, and the CAS-win framing in the comment at asks/service.py:216-231 is the correct rule.
The wiring is what does not work. Three findings, all verified by direct read of 63f3456a, and all invisible to the 24 green checks because every test monkeypatches the one call whose runtime context is the defect.
Blocking
1. The dispatch structurally cannot run — the feature is inert in production
client_portal/asks/router.py:53 declares the endpoint as a sync def:
@router.post("/{item_id}/answer", response_model=WorkspaceAsk)
def answer_ask(...):FastAPI runs a non-coroutine endpoint through run_in_threadpool, i.e. a worker thread with no running event loop. spawn_resume_dispatch (services/operator_resume_service.py:215) is asyncio.create_task(...), which calls get_running_loop() and raises RuntimeError: no running event loop there.
The try/except Exception you added at asks/service.py:232-244 then swallows it and logs. So on every client answer the answer is recorded, logger.exception fires, and nothing is dispatched — which is byte-for-byte the behaviour this PR exists to remove. AC #1 is not met.
The operator route works only because routers/operator_queue.py:199 is async def. main.py's mount of portal_asks_router is plain — no custom route_class — so standard dispatch applies, and service.answer_ask has exactly one production caller.
Note this is not a one-line flip to async def: answer_ask does blocking DB I/O at service.py:153 and :193, so that alone moves those onto the loop. It needs either a loop handle captured at request time (anyio.from_thread, or run_coroutine_threadsafe against the main loop) or an async def route that runs the sync service via run_in_threadpool and spawns afterwards.
2. A lost race dispatches a paid execution for an answer that was never recorded — and under a different idempotency key
db/operator_queue.py:389-404: respond_to_operator_queue_item returns None only when the row does not exist. When the row exists but has left pending — the actual lost race — it returns a truthy dict carrying _status_conflict = True, with no write performed.
asks/service.py:200 checks only if not updated: and never pops that flag. The operator route does, at routers/operator_queue.py:229, before the spawn at :240. So on this path the race loser falls through and dispatches.
Two consequences, the second worse than the first:
- money is spent on an answer that is not in the database, and the framed message carries the losing answer as though it were the recorded one;
- the key is
operator_resume:{item_id}:{sha256(item_id, response, response_text)}(operator_resume_service.py:80-90), so the loser's differing text yields a different digest and two dispatches fire for one queue item. The body's "two people answering at once produce one resume" does not hold here.
ent#329's own suite already encodes this rule — tests/unit/test_ent329_operator_resume.py:367-379 asserts _status_conflict is consumed before spawn_resume_dispatch — but it hardcodes routers/operator_queue.py, so a second dispatch site is outside its reach. Worth extending that guard to enumerate all callers so the next site inherits the rule instead of re-losing it.
Before this PR the missing check was a wrong 200. This PR converts it into a spend.
3. resume_requested: true is returned when the spawn failed, which is the AC #5 over-claim
asks/service.py:249 computes resume_requested after the swallowed spawn and reads only db.get_operator_resume_enabled. The except branch sets nothing, so a spawn that raised still answers true.
The docstring at :252-264 says it "Fails CLOSED … over-claiming is precisely the failure AC #5 names — an ask that reads as acted upon while nothing happened." That is the right rule; the code does not implement it for the failure path the same commit introduces. Given finding 1, this is not an edge case — it is every production answer on an opted-in agent. Gate the flag on the spawn actually having been scheduled.
Tests
The reason all three survive a fully green CI is that the tests stub the defect out:
- every test replaces
spawn_resume_dispatchwith a synchronous lambda (:56-59,:113-114,:211); test_a_lost_race_dispatches_nothing:94-121stubsrespond_to_operator_queue_itemto returnNone(:106-107), which is "item does not exist", not the lost race. The_status_conflicttruthy-dict shape — the one that actually occurs — is untested, so the docstring's claim to prove "two people answering at once must not produce two resumes" is not supported;test_a_dispatch_failure_never_loses_the_answer:130-158usesRuntimeError("event loop is closed")at:153as its fixture. That is the production failure mode, asserted as an acceptable degraded path rather than caught as the bug.
A test that drives service.answer_ask through anyio.to_thread.run_sync (or a TestClient POST) with the real spawn_resume_dispatch fails today, and is the test this change most needs.
Non-blocking
Closes ent#430does not resolve — it names no repository, renders as plain text rather than a link, and does not match the trigger regex in.github/workflows/issue-status-on-merge.yml. UseCloses abilityai/trinity-enterprise#430. Cross-repo still needs a manualstatus-in-devbump on ent#430 at merge either way.docs/memory/architecture.md:1246— the "Respond → Resume Dispatch (ent#329)" section describes a single caller and states the CAS-win property that finding 2 breaks on the new one; the "Addressed asks (ent#364)" paragraph says answering "answers through the OSS respond path" with no mention of resume dispatch.WorkspaceAskalso gains a client-visible field (models.py:29-34). Per the tiered rule one of architecture or a flow doc needs a paragraph — and there is still nofeature-flows/workspace-asks.mdfor the ent#364 chain at all.service.py:267andoperator_resume_service.py:118are separate reads of the opt-in separated by a task hop. The docstring's "Read from the SAME accessor … so the two cannot disagree" is true of the accessor and not of the instant; they can disagree, and it costs an extra read per answer.service.py:74—_projectmaps status topending/expiredonly, so the response to a just-answered ask returnsstatus: "pending"besideresume_requested: true. Pre-existing from ent#428, but the new field makes the pairing actively misleading, and the test at:158papers over it withassert out.status in ("pending", "expired").models.py:34—resume_requestedon the sharedWorkspaceAskmeansGET /asksnow serializes"resume_requested": nullon every row. Deliberate per the comment; a distinct answer-response model, orresponse_model_exclude_none, would keep the list contract unchanged.- Once finding 1 is fixed, keep the
logger.exceptionat:242. As written today it fires on every single client answer. - AC #4 is unverified end to end. The only live-instance measurement in the body is pre-change, and per finding 1 a post-change one could not have succeeded. Deferring the flag flip to an operator is reasonable on its own terms, but nothing here demonstrates the path works.
Happy to re-review as soon as the dispatch actually runs from this route.
…ed 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>
Tested against a live local instance — and it found a blocker for this very PRBooted a real backend from 🔴
|
| before | after | |
|---|---|---|
| answer / respond | 200 | 200 |
| executions created | 0 | 1 |
triggered_by |
— | operator_response |
operator_resume_dispatch audit |
absent | written, with the execution id |
ImportError in log |
present | 0 |
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.
Fixed in 53bc8601, with a guard that parses the real module source (ast, never the stubbed sys.modules entry — that's what made this invisible) and asserts every from services.X import Y resolves. Mutation-checked: reverting the import turns 11 tests red.
Everything else verified on the same instance
| feature | result |
|---|---|
| ent#428/429 asks | Client sees exactly their addressed ask, not the operator's; context not in the projection ✓ |
| ent#364 addressed asks | addressed_to_email persisted and roster-validated ✓ |
| ent#329 switch | GET default false; PUT true; portal token on the operator route → 401 ✓ |
| ent#430 client path | Answer recorded, responded_by_email set, status responded ✓ — dispatch now fires with 53bc8601 |
| ent#366 ratings | Unknown target → 404 (checked against the reader, not global ids) ✓ |
| ent#365 deliverables | Portal /reports → 200 ✓ |
| ent#155 cancel | Foreign execution → 404 uniform ✓ |
| ent#457 agent page | 200 with asks/ratings/recent_work/stats; work rows carry no agent-authored fields — no context, payload, execution_log or response ✓ |
| #2128 capability channel | multi_agent_chat_available: true on the roster payload (rooms are OSS-core since ent#443) ✓ |
| #2196 availability | availability: "unavailable" for a containerless agent — the row still appears, which is the point ✓ |
Skipped, honestly
- ent#438 (merge + canvas) and ent#425 (hosted deliverable pages) are not on
dev— no code to test. - ent#440 (voice conversation) is present but needs a browser + microphone; the backend
/sttand/ttsgates were reachable, the loop itself was not exercised. - ent#253 and ent#458 are frontend-dominant; I verified their backend surfaces only.
- Anything behind
requires_entitlementwas untestable here: this is an OSS-only build (edition: oss,enterprise_features: []) — the private submodule isn't mounted. - No live agent container, so no real turn ran end to end. That is the honest ceiling of this pass.
…ag 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>
…e caller (ent#430) Non-blocking findings from review pass 2. The three blockers landed in d11956a. 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>
/review — fresh pass on
|
obasilakis
left a comment
There was a problem hiding this comment.
Re-reviewed on ced40543. All three blockers from the last pass are closed, and closed the right way — with tests that drive the real function instead of stubbing the thing under test.
- The dispatch runs.
spawn_resume_dispatchnow detects the no-loop case and hops back to the host loop viaanyio.from_thread.run_sync. Fixing it in the spawn rather than by making the routeasync defis the right call for both reasons you give — the route does blocking DB I/O, and ent#430's stated shape is one dispatch site.test_the_spawn_works_from_a_worker_threaddrives it throughanyio.to_thread.run_sync, which is the same threadpool Starlette'srun_in_threadpooluses, and asserts the premise (no running loop on that thread) rather than assuming it. The non-anyio-thread branch re-raises with the cause named instead of degrading to the silent no-op — correct, since a swallowedRuntimeErroris what made the original invisible. - The lost race.
if not updated or updated.pop("_status_conflict", False)matchesrouters/operator_queue.py:229, popped rather than read so the sentinel cannot reach_project, andtest_the_lost_race_shape_that_actually_happens_dispatches_nothinguses the truthy-dict shape that actually occurs rather than theNonethe old test used. resume_requestedreports what was scheduled. Set on the line after the spawn returns, so the failure path answersfalse; the opt-in gate moved ahead of the spawn so an opted-out agent is not dispatched at all. Three tests cover raised / opted-out / real schedule.
The separate get_task_execution_service fix is a real find, and test_the_names_this_service_imports_actually_exist_on_the_real_modules is the right guard for it — parsing the real module with ast rather than importing, so a leaked sys.modules stub cannot satisfy it. _status_of returning answered before checking expiry is also right: an answer that landed is a fact a later expires_at does not undo.
One thing before it merges
docs/memory/architecture.md (Respond → Resume, the new "Two callers, one rule" bullet) says:
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.
That guard does not exist. tests/unit/test_ent329_operator_resume.py::test_dispatch_hangs_off_the_cas_win_only is unchanged and still reads exactly one file:
source = _read("routers/operator_queue.py")
conflict = source.index("_status_conflict")
dispatch = source.index("spawn_resume_dispatch")
assert conflict < dispatchclient_portal/asks/service.py — the caller this PR adds, and the one that lost the rule — is outside its reach, so the third site inherits nothing. The sentence claims the protection that would prevent a recurrence of the defect this PR is fixing, which makes it worse than no sentence: the next person adding a dispatch site reads it and stops looking.
Either write the guard or correct the sentence; I'd take the guard, since the callers are already enumerable (grep -l spawn_resume_dispatch, or a literal two-entry list with the ordering assertion applied per file). This is the shape the sibling PR #2428 wrote a learnings.md entry about this morning — a comment that names a failure mode is a request for a guard — so it may as well land the same way here.
I'll approve on that push.
Non-blocking
client_portal/asks/models.py:24— the field comment still reads# pending | expired (terminal ones are not listed)._status_ofnow emits a third value on the answer response.resume_requestedand the newansweredstatus are still unconsumed:stores/clientPortal.js::answerAskdrops the ask from the list and discards the response body, so nothing renders either. AC #5's "a surface can say answered without implying the agent started working" is satisfied in the payload and not yet on a surface. Fine as a follow-up — flagging it so it is a decision rather than an oversight.- The two-read window on the opt-in is now documented honestly, which is the right resolution — thanks for correcting the earlier "cannot disagree".
- CI is 23 green / 4 skipped on head.
obasilakis
left a comment
There was a problem hiding this comment.
Approved.
The three blockers are closed and the code is right; the one item I raised is documentation, so it is not worth holding the branch for.
Before merge, please push: docs/memory/architecture.md (Respond → Resume, "Two callers, one rule") says the CAS rule is "Guarded now by enumerating every caller". test_dispatch_hangs_off_the_cas_win_only still reads only routers/operator_queue.py, so client_portal/asks/service.py is outside it. Either widen the guard to both callers or drop that clause — as written it tells the next person adding a dispatch site that they are covered when they are not.
Smaller, whenever: client_portal/asks/models.py:24 still comments the status field as pending | expired, and resume_requested / answered are not consumed by any surface yet (answerAsk discards the response body).
…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>
|
Pushed The guard exists now, and writing it caught the same defect one level upYou were right that the sentence was worse than nothing. The first version was blind, and a mutation proved it. I wrote it as It now parses each dispatching function and compares
23 pass on the real tree.
The unconsumed fields — filed as a decision
One thing you should know before merging: ent#329 is inert on
|
Journey Impact: extends: J05
Closes abilityai/trinity-enterprise#430
The gate
Slice 5 of ent#364, and the one that makes the rest true. Until now the client route recorded an answer and returned:
So an ask addressed to a Workspace client — the entire point of ent#364/#428/#429 — was recorded, reached the agent's queue file, and re-triggered nothing.
Measured on a live instance before this change: answered from the Workspace,
operator-queue.jsonflipped torespondedcarrying the answer in under 3 seconds, and no execution followed.Unblocked because ent#329 is now 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, not re-implementation — which is why the tests assert the call rather than re-testing what it does.Four properties, each load-bearing
updated, neveritem. The pre-answer read still sayspending; a resume handed that row acts on an ask that doesn't yet carry its answer. Looks identical in a green test, so there's one for it.AC #5 — and its honest limit
resume_requestedrides the answer response, read from the same accessor the dispatch gates on so the two can't disagree. It reports intent, not success: the dispatch is backgrounded, so the only honest claim at that moment is whether it will be attempted. Fails closed — an unreadable flag claims nothing, because over-claiming is exactly what AC #5 forbids.Residual, stated rather than implied: a dispatch that fails after this point surfaces as a FAILED execution row plus an
operator_resume_dispatchaudit entry (ent#329) — both operator-visible, and a client can see neither.The client half of AC #5 is currently satisfied negatively: the ask surface says nothing about work starting, so it cannot mis-claim.
resume_requestedis the field a surface needs to say something true; consuming it is an ent#429 UI change and is deliberately not here.The flag default is unchanged
operator_resume_enabledstays OFF, per-agent, owner-only."Turn the flag on" is an operator action per agent, not a code default. Flipping the default would hand every shared agent's client a spend button — the one thing AC #3 rules out ("so hosting asks does not hand a client a spend button").
Verification
Mutation-checked:
ACs