Skip to content

fix(workspace): the sync portal turn never carried its session, so report-back could not fire (#2426) - #2427

Merged
dolho merged 1 commit into
devfrom
fix/2426-portal-sync-session-binding
Aug 28, 2026
Merged

fix(workspace): the sync portal turn never carried its session, so report-back could not fire (#2426)#2427
dolho merged 1 commit into
devfrom
fix/2426-portal-sync-session-binding

Conversation

@dolho

@dolho dolho commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

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_completion drops every report at its own if not source_channel_chat_id gate.

Measured on a dev instance, split cleanly by path:

portal rows total: 8
missing chat_id : 5

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 when it creates the row:

# task_execution_service.py:1095
if not execution_id:
    execution = db.create_task_execution(..., source_channel_chat_id=..., source_channel_client=...)

ent#365's _precreate_sync_execution has already created the row and handed the id over, so execute_task adopts it and skips that branch. And the pre-create stamped only the surface:

core_db.create_task_execution(..., source_channel=PORTAL_SOURCE_CHANNEL)   # no chat_id, no client

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.

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_id is 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_task to 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 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're repaired here rather than left for the next PR to trip over. Frontend-only PRs stay green because the changes job path-filters the backend suite away — which is why nobody saw it.

test_both_portal_row_creation_sites_name_the_chat asserted a literal census, == 2 sites. 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:

assert destination == surface, "…a row created by the unstamped one can never be joined back to its chat"

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

  • 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, the only place the two meet. That is verbatim the lesson test_ent457_portal_turn_kwargs.py states 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

pytest -k "portal or ent457 or ent365 or 2426"
  before this branch:   2 failed, 400 passed     ← both pre-existing on dev
  after:              402 passed, 1 skipped

Mutation-checked:

Mutation Result
Remove the new stamp 4 failed (new suite + the repaired census guard)
Feed execute_task an unknown kwarg repaired binding guard fires

Impact

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.

…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 obasilakis left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. 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 = 3 counts service.py:1523/1548portal_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. So test_both_portal_row_creation_sites_name_the_chat counts 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_task was 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>= 2 trades 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_id goes 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.

@dolho
dolho merged commit 135248e into dev Aug 28, 2026
24 checks passed
dolho added a commit that referenced this pull request Aug 28, 2026
…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>
dolho added a commit that referenced this pull request Aug 28, 2026
…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>
dolho added a commit that referenced this pull request Aug 31, 2026
…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>
dolho added a commit that referenced this pull request Aug 31, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants