fix(workspace): the sync portal turn never carried its session, so report-back could not fire (#2426) - #2427
Conversation
…port-back could not fire (#2426) ent#457 gave the Workspace a report-back: an agent that delegates during a chat turn gets the completion posted into that thread. It could not fire on the SYNCHRONOUS path, because the parent execution never received the session binding the report needs. `report_completion` gates on `if not source_channel_chat_id`, and there the field was NULL. Measured on a dev instance — 5 of 8 portal rows NULL, split exactly by path: 07:55 -> 09:04 chat=7d27744d... browser, streaming path 09:06 -> 09:09 chat=NULL POST .../chat, synchronous path TWO CORRECT CHANGES THAT COLLIDE. ent#457 passes the binding down, and `execute_task` persists it — but only inside `if not execution_id:`. ent#365's `_precreate_sync_execution` has already created the row and handed the id over, so that branch never runs, and the pre-create stamped only `source_channel`. Its own docstring named the invariant it broke: "Mirrors `start_portal_turn`'s creation exactly ... so the two paths produce indistinguishable rows and a report published from either can be joined back to its chat." The sibling comment in `start_portal_turn` says "both creation sites or the stamp is a coin flip depending on which path made the row" — ent#457 covered the two sites that existed when it was written; ent#365 had added a third. Fix: stamp `source_channel_chat_id` + `source_channel_client` in the pre-create. `session_id` is a REQUIRED parameter, not an optional one — the value is in scope at the only call site, and a default would let a future caller silently reintroduce the inert row. Rejected: teaching `execute_task` to UPDATE an adopted row, which widens a hot path used by every trigger to repair one caller's omission. ALSO REPAIRS TWO GUARDS THAT WERE RED ON `dev`. `backend-unit-test` is failing on dev right now; both failures are in this feature area and both are guards that had gone inert, so they are fixed here rather than left for the next PR to trip over. Frontend-only PRs pass because the `changes` job path-filters the backend suite away, which is why this went unnoticed. * `test_both_portal_row_creation_sites_name_the_chat` asserted a literal census of `== 2` sites. It went red the moment the third site appeared — the guard WORKING — and the bug it names shipped anyway. Now asserts the rule instead of the count: every site that stamps the surface must also stamp the destination. Census-proof. * `test_portal_turn_kwargs_bind_against_execute_task` parsed `portal_chat` for a literal `run_resumable_turn(...)` call. That call had moved into `_run_sync_turn_and_clear_marker`, where it is `run_resumable_turn(**kwargs)` — a splat, which names nothing — so the walk found no keywords and the guard asserted itself dead. Now reads the keywords where they are actually named (the wrapper's call site), scanning both entry names and subtracting the wrapper's own consumed parameters. Neither rewrite loses coverage; both now fail for the reason their docstring gives rather than because a number or a call site moved. WHY THE BUG SURVIVED ITS TESTS. ent#457's mock the engine and assert the kwargs are passed (they are). ent#365's assert no orphan `running` row (still true). Nothing asserted the PERSISTED ROW, which is the only place the two meet — the same lesson `test_ent457_portal_turn_kwargs.py` states about itself. The new suite asserts at that layer, and adds a derived parity check so a fourth channel field added to one writer and forgotten in another fails here instead of shipping as another silently-inert report path. Verification: 402 passed on the portal/ent457/ent365 selection (was 2 failed before this branch). Mutation-checked: removing the stamp turns 4 red; feeding `execute_task` an unknown kwarg turns the repaired binding guard red. Closes #2426 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
obasilakis
left a comment
There was a problem hiding this comment.
Approving. No code blockers on any axis, and I confirmed the headline claim empirically rather than by reading.
Verified
Exported the trees read-only via git archive and ran the suites:
| tree | selection | result |
|---|---|---|
PR base 8d9af18b |
the two files | 2 failed, 27 passed |
PR base 8d9af18b |
-k "portal or ent457 or ent365" |
2 failed, 395 passed |
origin/dev 8df57509 |
same + 2426 |
2 failed, 395 passed |
| base + this diff | same | 402 passed |
The failures are exactly assert 3 == 2 and "portal_chat no longer calls run_resumable_turn by that name / assert set()". dev is red right now, and this PR is what makes it green — the claimed 402 matches exactly.
Non-vacuity checks out too: applying only the test half of the diff to the unfixed base leaves the census test and all four new test_2426 tests red, so it is green for the right reason. The wrapper-parameter subtraction is not over-broad — wrapper ∩ run_resumable_turn and wrapper ∩ execute_task are both empty, the masked set is empty, and passed - rrt and passed - (rrt | wrapper) are identical. Post-change, forwarded is 8 kwargs including source_channel_client, i.e. the exact ent#457 regression, all accepted by execute_task. Neither rewritten guard can silently pass over a genuine defect.
Comments (non-blocking)
- The census test's premise is wrong, not merely loosely worded.
surface = 3countsservice.py:1523/1548—portal_chat's kwarg forwarded to_run_sync_turn_and_clear_marker— which is not a row-creation site, while the actual third creation site (task_execution_service.py:1103) lives in another file and is never counted. Sotest_both_portal_row_creation_sites_name_the_chatcounts two creation sites plus one forwarding site. The pairwise equality it enforces is a real property; the name and comment assert something the test does not check. test_portal_turn_kwargs_bind_against_execute_taskwas dead, not wrong — it passes at base with only the test rewrite applied, so its repair is a resurrection rather than a second live defect fixed. The body's "two guards that were red" is accurate but shouldn't be read as two bugs.== 2→>= 2trades away a tripwire. The original census loudly announced "a new creation site appeared, go audit it", which is exactly what it did when ent#365 landed — nobody acted, and the bug shipped. The replacement enforces the pairing invariant, which is net stronger, but the "count moved" signal is gone.- The string-literal census stays brittle: a future site stamping from a variable not named
session_idgoes red spuriously. Fail-loud direction, so acceptable.
Note for sequencing: #2430 edits the same client_portal/service.py regions (_precreate_sync_execution, start_portal_turn). Land this one first.
…ee latent desyncs (ent#451) Blocker 1 was real and I had not seen it. `resolveAgentQuery` passed `forceNew` to `resolveAgentLanding` and set `pendingSession = null`, but never raised `startingNewChat` — so `/workspace?agent=X&new=1` rendered an empty conversation and then sent `new_thread: false`, resuming the thread the user asked to leave. The reported bug, intact on the documented `?new=1` contract, in the PR that exists to fix it. The cause is the one this PR is about, one level up: `route.query.new` was read in two places for two different decisions — WHICH THREAD to land on and WHAT THE FIRST SEND ASKS FOR — and only the first honoured it. Now read ONCE into a local that feeds both, so they cannot drift again. AND-ed with the landing result, so a `?new=1` that still resolved a thread never claims a fresh start. Blocker 2: a frontend test, which the change genuinely had none of — the `1497 passed` in the body was the pre-existing suite, as the review says. `workspaceNewChat.spec.js` (9 tests) covers the deep link, the watcher branch ORDER, the first-paint guard, both send conjunctions, and the settle-everywhere rule, using the two established patterns (pure function + source assertion in the `portalLeaveSpecificRoute.spec.js` shape) since vitest runs `environment: 'node'` with no mount harness. Mutation-checked, and M1 is the reviewer's own blocker: reverting it turns the suite red. Blocker 3: `test_history_without_a_session_is_unchanged` cited "the spec in tests/unit/... frontend suite" — a dangling reference asserting coverage that did not exist. It now names the real file. Comments addressed: * Three more sites nulled `pendingSession` without settling the intent — the deep-link watcher (the commonest way in), `openRoom`, `openAgentPage`, plus the unreachable-agent branch. Latent because both consumers AND on "no session yet", but a flag that is only correct because of a second variable is one refactor from being wrong, and the declaration claims it is cleared the moment a real thread exists. Now true. * `test_both_turn_entry_points_forward_it` was `getsource` + a substring, so a comment or a misspelled kwarg satisfied it. It now BINDS the keyword against each service signature and asserts the routes forward `body.new_thread` through a comment-stripped source — verified by mutation. * `workspace-absorbs-session.md` updated at both seams the change touches (`resolveAgentLanding`'s landing rule and `_resolve_session_id`'s three states), and `architecture.md`'s Workspace section documents the new public `new_thread` field on the ent#83 headless surface. * Gating stated rather than inferred: "OSS-core by decision (ent#451)", matching the ent#326/#384/#392 convention. ONE CORRECTION, offered with evidence rather than silently applied. The review says "`test_ent457_portal_turn_kwargs.py` doesn't exist on `dev`, #2427 introduces it". It does exist on `dev` — added by d6a4bc1 (ent#457) — and #2427 modifies it. `git cat-file -e origin/dev:tests/unit/test_ent457_portal_turn_kwargs.py` succeeds, and `backend-unit-test` is failing on `dev` independently of any PR. So the body's "fails on dev today" stands. Everything else in the review is accepted as written. Verification: frontend 1497 -> 1506 (+9). Backend 392 passed on the portal selection, the same 2 pre-existing dev failures unchanged. Related to ent#451 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…xplain (#2423) The Workspace told a client its agent had run 12 loops — a `Loops 12` legend entry and a run of rows saying `Loop`, with per-run durations — and gave it nowhere to go. No way to open a loop, see what one produced, or start and stop one. Verified against a real client session, not inferred: client-visible by_type: {'Public': 4, 'Loops': 12, 'Chat/Tasks': 1} recent_work : 17 rows, mostly triggered_by='loop' Four reasons there was nowhere to follow up: the loops strip is `isPlatformSession`-gated (ent#458, correctly — loops are an operator capability); even for an operator that strip shows status and Stop, never output; the Workspace agent page has no Loops tab; and per-run results live only on operator Agent Detail and Operations. So the loop COUNT was client-visible while the loop OUTPUT was operator-only. THE DIRECTION IS NOT A NEW PRODUCT CALL. The issue left "show it" vs "hide it" open deliberately. `agent_page`'s own docstring already answers it: "It reports; it does not configure ... The viewer may be an external client, not an operator." The module is subtractive by design — it projects away `message`, `cost`, `model_used`, `source_user_email`, and already drops `alert` asks as "operations telemetry, not something the agent is asking a person". A loop run is the same kind of thing. This follows that rule rather than inventing a second one. NOT SUBTRACTIVE FOR EVERYONE. The same page serves a platform user, who CAN click through to Agent Detail -> Loops and read every run, so hiding it from them removes real signal and fixes nothing. Split by principal, using the `is_platform` the route already resolves for `get_agent_card` — the same pattern as the roster and `_require_roster`. The client view is the DEFAULT, so a caller that forgets to say who is looking gets the projection that leaks least. Both halves or neither: the rows and the chart are filtered together. Removing the rows and leaving `Loops 12` in the legend would be worse than doing nothing — a number with nothing behind it. Day totals and the headline are RE-DERIVED, because a bar labelled 13 whose segments sum to 1 reports its own filtering as missing data. `success_rate` is deliberately NOT recomputed: it is a ratio over terminal rows this function cannot see, and a filtered numerator over an unfiltered denominator would be worse than a figure that is merely broad. One trap worth recording: the two `by_type` fields have DIFFERENT shapes under one name — the top-level total is a LIST of `{"bucket","total"}` rows while each timeline day carries a DICT of `{bucket: count}`. The first draft handled only the dict and crashed the whole page on a real payload; my own test fixture had the same wrong shape, so it passed against something the accessor never emits. `test_2161_agent_page_ux` caught it. Both are corrected, and the helper is now tolerant of either form with unrecognised rows KEPT — silently hiding a row we failed to parse would be the opposite of this function's job. Verification: 410 passed on the portal/agent-page selection. Mutation-checked — dropping the row filter, leaving day totals stale, and leaving the headline stale each turn the suite red. Pre-existing and NOT from this branch: `test_ent457_portal_turn_kwargs` and `test_both_portal_row_creation_sites_name_the_chat` fail on `dev` today (backend-unit-test is red there); both are fixed in #2427. Closes #2423 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…xplain (#2423) The Workspace told a client its agent had run 12 loops — a `Loops 12` legend entry and a run of rows saying `Loop`, with per-run durations — and gave it nowhere to go. No way to open a loop, see what one produced, or start and stop one. Verified against a real client session, not inferred: client-visible by_type: {'Public': 4, 'Loops': 12, 'Chat/Tasks': 1} recent_work : 17 rows, mostly triggered_by='loop' Four reasons there was nowhere to follow up: the loops strip is `isPlatformSession`-gated (ent#458, correctly — loops are an operator capability); even for an operator that strip shows status and Stop, never output; the Workspace agent page has no Loops tab; and per-run results live only on operator Agent Detail and Operations. So the loop COUNT was client-visible while the loop OUTPUT was operator-only. THE DIRECTION IS NOT A NEW PRODUCT CALL. The issue left "show it" vs "hide it" open deliberately. `agent_page`'s own docstring already answers it: "It reports; it does not configure ... The viewer may be an external client, not an operator." The module is subtractive by design — it projects away `message`, `cost`, `model_used`, `source_user_email`, and already drops `alert` asks as "operations telemetry, not something the agent is asking a person". A loop run is the same kind of thing. This follows that rule rather than inventing a second one. NOT SUBTRACTIVE FOR EVERYONE. The same page serves a platform user, who CAN click through to Agent Detail -> Loops and read every run, so hiding it from them removes real signal and fixes nothing. Split by principal, using the `is_platform` the route already resolves for `get_agent_card` — the same pattern as the roster and `_require_roster`. The client view is the DEFAULT, so a caller that forgets to say who is looking gets the projection that leaks least. Both halves or neither: the rows and the chart are filtered together. Removing the rows and leaving `Loops 12` in the legend would be worse than doing nothing — a number with nothing behind it. Day totals and the headline are RE-DERIVED, because a bar labelled 13 whose segments sum to 1 reports its own filtering as missing data. `success_rate` is deliberately NOT recomputed: it is a ratio over terminal rows this function cannot see, and a filtered numerator over an unfiltered denominator would be worse than a figure that is merely broad. One trap worth recording: the two `by_type` fields have DIFFERENT shapes under one name — the top-level total is a LIST of `{"bucket","total"}` rows while each timeline day carries a DICT of `{bucket: count}`. The first draft handled only the dict and crashed the whole page on a real payload; my own test fixture had the same wrong shape, so it passed against something the accessor never emits. `test_2161_agent_page_ux` caught it. Both are corrected, and the helper is now tolerant of either form with unrecognised rows KEPT — silently hiding a row we failed to parse would be the opposite of this function's job. Verification: 410 passed on the portal/agent-page selection. Mutation-checked — dropping the row filter, leaving day totals stale, and leaving the headline stale each turn the suite red. Pre-existing and NOT from this branch: `test_ent457_portal_turn_kwargs` and `test_both_portal_row_creation_sites_name_the_chat` fail on `dev` today (backend-unit-test is red there); both are fixed in #2427. Closes #2423 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…xplain (#2423) The Workspace told a client its agent had run 12 loops — a `Loops 12` legend entry and a run of rows saying `Loop`, with per-run durations — and gave it nowhere to go. No way to open a loop, see what one produced, or start and stop one. Verified against a real client session, not inferred: client-visible by_type: {'Public': 4, 'Loops': 12, 'Chat/Tasks': 1} recent_work : 17 rows, mostly triggered_by='loop' Four reasons there was nowhere to follow up: the loops strip is `isPlatformSession`-gated (ent#458, correctly — loops are an operator capability); even for an operator that strip shows status and Stop, never output; the Workspace agent page has no Loops tab; and per-run results live only on operator Agent Detail and Operations. So the loop COUNT was client-visible while the loop OUTPUT was operator-only. THE DIRECTION IS NOT A NEW PRODUCT CALL. The issue left "show it" vs "hide it" open deliberately. `agent_page`'s own docstring already answers it: "It reports; it does not configure ... The viewer may be an external client, not an operator." The module is subtractive by design — it projects away `message`, `cost`, `model_used`, `source_user_email`, and already drops `alert` asks as "operations telemetry, not something the agent is asking a person". A loop run is the same kind of thing. This follows that rule rather than inventing a second one. NOT SUBTRACTIVE FOR EVERYONE. The same page serves a platform user, who CAN click through to Agent Detail -> Loops and read every run, so hiding it from them removes real signal and fixes nothing. Split by principal, using the `is_platform` the route already resolves for `get_agent_card` — the same pattern as the roster and `_require_roster`. The client view is the DEFAULT, so a caller that forgets to say who is looking gets the projection that leaks least. Both halves or neither: the rows and the chart are filtered together. Removing the rows and leaving `Loops 12` in the legend would be worse than doing nothing — a number with nothing behind it. Day totals and the headline are RE-DERIVED, because a bar labelled 13 whose segments sum to 1 reports its own filtering as missing data. `success_rate` is deliberately NOT recomputed: it is a ratio over terminal rows this function cannot see, and a filtered numerator over an unfiltered denominator would be worse than a figure that is merely broad. One trap worth recording: the two `by_type` fields have DIFFERENT shapes under one name — the top-level total is a LIST of `{"bucket","total"}` rows while each timeline day carries a DICT of `{bucket: count}`. The first draft handled only the dict and crashed the whole page on a real payload; my own test fixture had the same wrong shape, so it passed against something the accessor never emits. `test_2161_agent_page_ux` caught it. Both are corrected, and the helper is now tolerant of either form with unrecognised rows KEPT — silently hiding a row we failed to parse would be the opposite of this function's job. Verification: 410 passed on the portal/agent-page selection. Mutation-checked — dropping the row filter, leaving day totals stale, and leaving the headline stale each turn the suite red. Pre-existing and NOT from this branch: `test_ent457_portal_turn_kwargs` and `test_both_portal_row_creation_sites_name_the_chat` fail on `dev` today (backend-unit-test is red there); both are fixed in #2427. Closes #2423 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Journey Impact: none: repairs an existing surface's report-back on one code path — no user-facing promise is added or extended.
Closes #2426
The bug
ent#457 gave the Workspace a report-back: an agent that delegates during a chat turn gets the completion posted into that thread. It cannot fire on the synchronous chat path — the parent execution never receives the session binding, so
report_completiondrops every report at its ownif not source_channel_chat_idgate.Measured on a dev instance, split cleanly by path:
Two correct changes that collide
ent#457 passes the binding down, and
execute_taskpersists it — but only when it creates the row:ent#365's
_precreate_sync_executionhas already created the row and handed the id over, soexecute_taskadopts it and skips that branch. And the pre-create stamped only the surface:Its own docstring named the invariant it broke:
And
start_portal_turn's sibling comment says "both creation sites or the stamp is a coin flip depending on which path made the row." ent#457 covered the two sites that existed when it was written; ent#365 had added a third.The fix
Stamp both fields in the pre-create.
session_idis a required parameter, not optional — the value is in scope at the only call site, and a default would let a future caller silently reintroduce the inert row.Rejected: teaching
execute_taskto UPDATE an adopted row. That widens a hot path used by every trigger to repair one caller's omission; the adopted row is the caller's to get right.Two guards that were red on
devbackend-unit-testis failing ondevright now. Both failures are in this feature area and both are guards that had gone inert, so they're repaired here rather than left for the next PR to trip over. Frontend-only PRs stay green because thechangesjob path-filters the backend suite away — which is why nobody saw it.test_both_portal_row_creation_sites_name_the_chatasserted a literal census,== 2sites. It went red the moment the third site appeared — the guard working — and the bug it names shipped anyway. It now asserts the rule instead of the count:test_portal_turn_kwargs_bind_against_execute_taskparsedportal_chatfor a literalrun_resumable_turn(...)call. That call had moved into_run_sync_turn_and_clear_marker, where it isrun_resumable_turn(**kwargs)— a splat, which names nothing — so the walk found no keywords and the guard asserted itself dead. It now reads the keywords where they're actually named (the wrapper's call site), scans both entry names, and subtracts the wrapper's own consumed parameters.Neither rewrite loses coverage; both now fail for the reason their docstring gives rather than because a number or a call site moved. Say so if you disagree — I edited guards I didn't write.
Why the bug survived its own tests
Both features have passing tests:
runningrow — still true.Nothing asserted the persisted row, the only place the two meet. That is verbatim the lesson
test_ent457_portal_turn_kwargs.pystates about itself: "the only place the two signatures meet is here." The new suite asserts at that layer and adds a derived parity check, so a fourth channel field added to one writer and forgotten in another fails here rather than shipping as another silently-inert report path.Verification
Mutation-checked:
execute_taskan unknown kwargImpact
Client-facing effect is a missing notification, not a wrong one: a delegated job finishes and nobody is told — precisely the scenario ent#457 was written for. No schema change, no migration, no API change.