Skip to content

fix(easd): unblock runs stranded in an abandoned Coding session - #16

Open
Wyn2004 wants to merge 2 commits into
mainfrom
fix/easd-run-orphaned-session-rebind
Open

fix(easd): unblock runs stranded in an abandoned Coding session#16
Wyn2004 wants to merge 2 commits into
mainfrom
fix/easd-run-orphaned-session-rebind

Conversation

@Wyn2004

@Wyn2004 Wyn2004 commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Problem

An EASD run is bound to the session_id of whatever Coding session started its first lifecycle phase, and every later phase call re-asserts that exact binding. Once the user moves to a new Coding session the run is unusable — start/retry authoring, planning, review, verification and recovery all reject it — with no recovery short of a manual UPDATE trace_runs SET session_id = … against SQLite.

Reported symptom: EASD authoring run belongs to another Coding session.

Root cause

The ownership assertion was duplicated ten times across the lifecycle functions in app/services/trace_service.py (submit_authored_specification, submit_authored_plan, start_plan_authoring_in_session, retry_plan_authoring_in_session, start_run_in_session, start_spec_authoring_in_session, retry_spec_authoring_in_session, start_review_in_session, start_verification_in_session, recover_run_in_session) rather than living in the shared _session_for_run helper those functions already call. That helper only validated the target session's project/workspace — never ownership — so the gate could not be relaxed in one place, and no rebind mechanism existed anywhere in the backend or the frontend.

ChatSession has no ended_at/status column, so "the old session is closed" is not a fact the system can observe. The fix therefore treats the user's explicit adoption as the only signal, rather than inferring abandonment.

What changed

Backend

  • Fold the ownership assertion into _session_for_run, raising a structured TraceSessionMismatch (carrying run_id + current_session_id), and delete the ten duplicated checks. _resolve_target_session keeps the ownership-free project/workspace validation for callers that legitimately accept a foreign session.
  • Add rebind_run_session, which moves a non-terminal run to the caller's session. It refuses terminal runs, and refuses a target session another run already owns — uq_trace_runs_active_session is a partial unique index, so without that check the move surfaces as an unhandled IntegrityError (500) instead of a conflict.
  • Surface the mismatch as 409 {"code": "easd_session_mismatch", …} via _raise_easd, and expose POST /api/easd/runs/{run_id}/rebind.

Frontend

  • EasdSessionMismatchApiError + detection in easdResponse, rebindEasdRun, useRebindEasdRunMutation.
  • An inline "Adopt run" banner in the Run header. Every phase action hands off to the run's own session, so a stranded run never actually produces the rejection — the banner is therefore derived from the run state (run.session_id !== openSessionId) with the rejected-call path kept as a fallback. Adoption does not auto-replay the phase action; the existing Start/Retry button re-enables once the run follows the user.

Checklist

  • Root cause identified and verified against the code (not the nearest symptom)
  • Backend fix + structured error + new route
  • Frontend affordance wired to the new endpoint
  • Regression test reproducing the exact reported scenario
  • Conflict case covered (target session already owned — the IntegrityError/500 path)
  • Terminal-run rejection covered
  • Backend: ruff check / ruff format --check / ty check clean
  • Backend: pytest green (44 passed)
  • Frontend: lint / typecheck clean, test:unit green (444 passed)
  • Frontend: production build succeeds
  • Before/after evidence captured with identical repro steps

Evidence

Backend — before the fix

The reproduction, run against pristine main in a throwaway worktree:

[BUG] phase start in new session raised: EASD authoring run belongs to another Coding session
[BUG] trace_service has no rebind/transfer function at all
[BUG] the run is unusable without a manual SQL UPDATE

The new tests, run against the same pristine main:

FAILED tests/services/test_trace_service.py::test_rebind_recovers_a_run_abandoned_in_a_previous_session
FAILED tests/services/test_trace_service.py::test_rebind_rejects_a_session_already_owned_by_another_run
FAILED tests/services/test_trace_service.py::test_rebind_moves_orphaned_run_to_new_session
FAILED tests/services/test_trace_service.py::test_phase_start_with_mismatched_session_raises_session_mismatch
FAILED tests/services/test_trace_service.py::test_rebind_rejects_a_converged_run
5 failed, 15 deselected

Backend — after the fix

PASSED tests/services/test_trace_service.py::test_rebind_rejects_a_converged_run
PASSED tests/services/test_trace_service.py::test_rebind_moves_orphaned_run_to_new_session
PASSED tests/services/test_trace_service.py::test_rebind_rejects_a_session_already_owned_by_another_run
PASSED tests/services/test_trace_service.py::test_phase_start_with_mismatched_session_raises_session_mismatch
PASSED tests/services/test_trace_service.py::test_rebind_recovers_a_run_abandoned_in_a_previous_session

44 passed in 10.31s

API — live repro against a real run

Run 06a95505… created and bound to session 06a8407e…, then driven from a second Coding session 06a95506… in the same project:

### phase start from the non-owning session
$ curl -X POST /api/easd/runs/{run}/authoring/start -d '{"session_id":"<session B>"}'
{"detail":{"code":"easd_session_mismatch",
           "run_id":"06a95505-a2cd-79e8-8000-198e0e319343",
           "current_session_id":"06a8407e-a7a7-72b8-8000-4a2d5cbc4ed4"}}
HTTP 409

### after clicking "Adopt run" in the UI
 session_id = 06a95506-c768-771a-8000-1dd9348fa8ad   (moved to session B)
 status     = authoring                              (history preserved)

### the same phase action that used to 409
 HTTP 200

UI — before

Same run, same Coding session, pre-fix frontend served from a pristine main worktree. The run header offers Open drafting chat / Retry drafting, both of which hand off to the original session — there is no indication the run belongs elsewhere and no way to continue it here.

before

UI — after

Identical navigation. The header now states the mismatch and offers Adopt run; after adopting, the banner disappears, the run's session_id follows the user, and the phase actions work in place.

after

Screenshots are hosted on pr-evidence/easd-session-rebind, following the existing pr-evidence-host convention — that branch is not meant to be merged into a product branch.

Notes for the reviewer

  • fix(easd): allow runs to continue in a new Coding session #15 targets the same bug from the Recovery-action angle. This branch takes the shared-gate route instead: it removes all ten duplicated ownership checks so every lifecycle entry point is unblocked by one change, and puts the affordance inline in the Run header rather than behind the Recovery panel. The two are alternatives, not complements — worth picking one deliberately.
  • Rebind stays refused for converged/cancelled runs, and for a session another run already owns.

An EASD run was bound to the session_id of whatever Coding session started
its first lifecycle phase, and every later phase call re-asserted that exact
binding. Once the user moved to a new session the run was unusable, with no
recovery short of a manual SQL UPDATE.

The ownership assertion was duplicated ten times across the lifecycle
functions instead of living in the shared _session_for_run helper, so the
gate could not be relaxed in one place. Fold it into that helper, raising a
structured TraceSessionMismatch, and drop the ten copies. Add
rebind_run_session so a user can explicitly move a non-terminal run to the
session they are in, refusing the move when another run already owns the
target session (uq_trace_runs_active_session). Surface the mismatch as a
409 easd_session_mismatch payload and expose POST /easd/runs/{id}/rebind.

The frontend turns that payload into an inline "Adopt run" affordance
instead of an opaque error line, leaving the user to re-trigger the phase
once the run follows them.
Every phase action hands the run off to its own session_id, so a run
stranded in an abandoned session never produced the mismatch rejection the
adoption banner was keyed on — the affordance was unreachable in practice.

Derive the mismatch from the run and the open session directly, keeping the
rejected-call path as a fallback for callers that do pass a foreign session.
@Wyn2004
Wyn2004 force-pushed the fix/easd-run-orphaned-session-rebind branch from d0ba1e4 to aee5622 Compare August 31, 2026 11:29
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.

1 participant