Skip to content

fix(worker): keep a task's session standing after it answers - #204

Merged
sanil-23 merged 2 commits into
tinyhumansai:mainfrom
sanil-23:feat/keep-task-session
Aug 5, 2026
Merged

fix(worker): keep a task's session standing after it answers#204
sanil-23 merged 2 commits into
tinyhumansai:mainfrom
sanil-23:feat/keep-task-session

Conversation

@sanil-23

@sanil-23 sanil-23 commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

What

A dispatched session was torn down the moment its reply went out, so a task that had just finished read as one that had vanished.

The pane picks what to draw in priority order — live local PTY → watched worker screen → transcript (transcript.rs:41). Closing the PTY dropped it to the third. Nothing switched views; there was no longer a process to render.

SessionClass::Bounded says it outright: "created for one task frame, runs exactly one turn, and is torn down when the reply is sent."

What changed

finish_turn retains and frees the session instead of closing it. The harness that did the work is still there to look at, and the operator can take it and keep going in the context it built.

Why retention is not takeover

This is the part worth reading before simplifying it back.

Marking these sessions SessionControl::User is the smaller diff and deadlocks dispatch. checkout_writer reads any user-held session in a directory as the writer holding that checkout:

self.sessions.sessions_in(cwd).into_iter()
    .find(|row| row.control == SessionControl::User)

and session_for queues behind it. So the first task to finish in a workspace would hold that checkout forever, and every task dispatched there afterwards would queue until its budget expired, then fail.

Retained sessions therefore stay Orchestrator and carry a separate lifecycle flag. try_claim refuses them — bounded dispatches never consult the idle pool, so that guard is what keeps the rule true if they ever do. Taking a session clears it: it stops being the leftover screen of finished work and becomes somewhere a person is typing.

The flag sits beside busy and operator_held as an atomic for the same reason those are (tested per session on every dispatch), and reaches the UI on SessionRow, since retention the rail cannot see is retention nothing can show.

What this does not do

It does not guarantee the finished session is on screen. Task rows carry no local PTY (local: None), and own_session_rows lists only sessions that are the operator's — so a retained one reaches the rail through its task or not at all. The hub does cache the last WatchedScreen per (worker, task_id), so it may already render; I have not observed it against a real dispatch and am not going to claim it.

Keeping the process alive is the half that has to come first. What the pane then shows wants observing rather than reasoning about, and is left to a follow-up — likely threading the session id onto the task row so session_id() resolves, rather than double-listing the session, since "a task is an agent session" is the model the rail already commits to.

Resource note

Retained sessions are live harness processes and nothing closes them automatically — K kills one, and taking it makes it an ordinary operator session. This is intentional for now: because they are never checkout writers, they cannot block work, so the cost is memory and PTYs rather than correctness. A cap or a close-when-the-cycle-ends rule is the obvious follow-up if it bites.

Validation

  • cargo clippy --all-targets -- -D warnings — clean
  • cargo fmt --check — clean
  • cargo test -p medulla-tui --lib worker::pty::tests::control16 passed, 0 failed

4 new tests: a retained session is never dispatched into; it reports itself on the row while staying Orchestrator (that assertion carries the checkout_writer reasoning in its failure message); taking it clears the flag; retaining an unknown id is not an error.

⚠️ worker::pty::tests::session has 3 pre-existing failuresa_launch_root_preserves_trailing_whitespace, a_session_snapshots_head_before_the_harness_can_commit, an_unborn_repository_records_its_root_without_a_launch_commit. All three are /private/var vs /var macOS symlink assertions and fail identically on a clean upstream/main. Not touched by this PR.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Completed task sessions remain available for review instead of closing immediately.
    • Retained sessions stay visible and selectable in the session list.
    • Taking over a retained session clears its retained status.
  • Bug Fixes
    • Retained sessions are excluded from new task dispatches.
    • Failed tasks continue to close normally.
  • Tests
    • Added coverage for retained session visibility, selection, takeover, and lifecycle behavior.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

sanil-23 has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Completed bounded orchestrator sessions are retained instead of closed. Retention appears in session rows, prevents dispatch, supports rail selection, and clears when an operator takes over the session.

Changes

Retained session lifecycle

Layer / File(s) Summary
Retention state and PTY control
src/tui/src/worker/pty/...
PTY sessions now track retention. Retained sessions cannot be claimed for dispatch, and user takeover clears retention.
Bounded task completion
src/tui/src/worker/executor/...
Successful bounded turns retain completed sessions and preserve mapper context. Failed turns still close sessions.
Rail visibility and session resolution
src/tui/src/ui/app/...
Retained sessions remain in the rail, selectable, and resolvable to their PTYs. Tests and fixtures include the new field.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant BoundedTask
  participant finish_turn
  participant PtyManager
  participant SessionHandle
  participant Rail
  BoundedTask->>finish_turn: settle successful bounded turn
  finish_turn->>PtyManager: retain session
  PtyManager->>SessionHandle: mark session retained
  SessionHandle-->>Rail: project retained session row
  Rail-->>SessionHandle: resolve selected session to PTY
Loading

Suggested reviewers: senamakel

Poem

A rabbit hops past a quiet shell,
Its finished tale remains to tell.
The rail keeps one bright thread in sight,
Until a paw takes hold just right.
Then retention fades, and paths unite.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: retaining a task session after it successfully answers.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

A dispatched session was torn down the moment its reply went out. The pane
behind it picks what to draw in priority order — live local PTY, watched worker
screen, transcript — so closing the PTY dropped it to the third, and a task that
had just finished read as one that had vanished. Nothing switched views; there
was simply no longer a process to render.

A turn that answered now retains its session instead. The harness that did the
work is still there to look at, and the operator can take it and carry on in the
context it built.

A turn that *failed* still closes, exactly as before. A bounded turn fails when
its prompt could not be injected — the harness is sitting on whatever blocked it
— so there is nothing on that screen worth keeping, and leaving it would strand
a wedged process. That is why retention is gated on the turn settling rather
than on the session class alone.

Retention is deliberately not takeover, and the distinction is load-bearing.
Marking these sessions `User` is the smaller diff and deadlocks dispatch:
`checkout_writer` reads any user-held session in a directory as the writer
holding that checkout, so the first task to finish in a workspace would queue
every task dispatched there after it until their budgets ran out. They stay the
orchestrator's and carry a separate lifecycle flag, which `try_claim` refuses —
bounded dispatches never consult the idle pool, so that guard is what keeps the
rule true if they ever do. Taking a session clears it: it stops being the
leftover screen of finished work and becomes somewhere a person is typing.

The flag rides beside `busy` and `operator_held` for the reason those are
atomics — it is tested per session on every dispatch — and reaches the UI on
`SessionRow`, since retention the rail cannot see is retention nothing can show.

Three existing tests asserted the old teardown. The injection-failure one is
unchanged and still passing, because that path still closes. The two that
asserted closure on the *success* path now assert retention, and their subject
is unchanged: what stops a second task landing in the first's session is the
retention flag rather than the teardown that used to do it.

What this does not do is guarantee the finished session is on screen. Task rows
carry no local PTY (`local: None`) and `own_session_rows` lists only the
operator's, so a retained one reaches the rail through its task or not at all.
Keeping the process alive is the half that has to come first; what the pane then
shows wants observing against a real dispatch rather than reasoning about, and
is left to a follow-up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sanil-23
sanil-23 force-pushed the feat/keep-task-session branch from 306d392 to e3d6d90 Compare August 5, 2026 19:09

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

sanil-23 has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.

@sanil-23

sanil-23 commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed e3d6d901 — CI failures were mine, and one of them caught a real bug in the first fix.

Rust SDK / Coverage failed on two tests asserting the old teardown. They turned out to be guarding different things:

  • a_bounded_task_whose_prompt_cannot_be_injected_closes_its_session — the harness is wedged on a modal and the prompt never landed. That teardown is a leak guard, not the behaviour this PR changes. I had broken it by retaining unconditionally.
  • an_unattributed_task_is_bounded_and_leaves_no_session_behind — the success path, which is exactly what this PR changes.

The bug in my first attempt: gating retention on settled alone made the failure case fall through to the else arm, which hands the session to the operator. That would have left a wedged harness standing and marked User — the precise checkout_writer deadlock this design exists to avoid. The test caught it; my reasoning had not.

finish_turn now has three outcomes, each for a stated reason:

case outcome vs. before this PR
bounded turn answered retain + release changed — the point of the PR
bounded turn failed close unchanged
unbound / operator-held settle or hand over unchanged

On the edited tests, since a contributor rewriting the tests that failed deserves scrutiny: none were deleted or weakened. The injection-failure one is untouched and still passing. The two success-path ones assert the same property through the new mechanism — what stops a second task landing in the first's session is now the retention flag rather than the teardown that used to do it. Both still assert one session per bounded task.

Local: clippy --all-targets -D warnings clean, fmt --check clean, 306 passed in worker:: and 2323 in the SDK. Remaining local failures are the pre-existing ones noted in the description (3 × /private/var symlink, 2 × daemon::providers harness-spawn flakes), all reproducing identically on clean main.

Retaining the session kept the harness alive and left it reachable by nothing,
which from the operator's seat is the same as having closed it: the interactive
screen went away and the pane fell back to the transcript.

Two lookups go dead the moment a task settles, and both have to be understood
together. `session_for_task` resolves through the daemon's *running* map, and
the admission guard drops that record when the task finishes — so the streamed
screen stops arriving. And a task's rail row carries no local session
(`local: None`), so the cursor on one resolves no pty: nothing to draw a live
screen from, nothing to attach the keyboard to. A retained session matched
neither clause of `own_session_rows` — it is dispatched, not the operator's, and
deliberately still the orchestrator's — so it had no row of its own either.

Listing retained sessions fixes both at once, and by the better path. The row
carries a real pty id, which is the pane's *first* branch: it draws the local
harness directly instead of the hub's screen stream, so it does not depend on
the task lookup that just went away. Put the cursor on it and it is a live
harness to work in.

The cost is the double listing the old comment warned about: while a task runs
it shows as a task row, and once it finishes there is a session row for the same
harness beside it. That is cosmetic, and it is the row the operator can actually
type in. Merging the two wants a task-to-session mapping `TaskState` does not
carry today, and is left alone here.

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

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

sanil-23 has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.

@sanil-23

sanil-23 commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 6f50576b — the missing half. Verified against a live run rather than reasoned about.

Retention was working; nothing could reach it. On a running instance, four claude harnesses were alive as children of the TUI, outliving tasks that had finished minutes earlier. Before this branch they'd have been killed on reply. But the interactive screen still vanished and the pane fell back to the transcript, because two lookups go dead the moment a task settles:

lookup why it fails consequence
session_for_task reads the daemon's running map, cleared by AdmissionGuard::drop on completion the streamed screen stops → pane falls to the transcript
own_session_rows filters to origin.is_user() || control == User; a retained session is neither no rail row → nothing to select or attach to

So the harness was alive and reachable by nothing — which from the operator's seat is indistinguishable from having closed it.

Listing retained sessions fixes both, and by the better path: the row carries a real pty id, which is the pane's first branch (draw_local_harness). That draws the local harness directly instead of going through the hub's screen stream, so it does not depend on the task lookup that just disappeared.

Known cosmetic cost, called out because the existing comment warned about it: while a task runs it shows as a task row, and once it finishes a session row for the same harness appears beside it. Merging them needs a task→session mapping TaskState doesn't carry, so it's left as a follow-up. The session row is the one you can type in.

Local: fmt --check clean, clippy --all-targets -D warnings clean, -p medulla-tui --lib 986 passed, 3 failed — the pre-existing /private/var symlink tests noted in the description, unchanged on clean main.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/tui/src/worker/pty/handle/control.rs (1)

124-140: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Serialize the retention check with the claim.

A claimant can read retained == false at Line 125 while the completed turn is still busy. finish_turn can then retain the session and release busy. The claimant can win the CAS and return true because Lines 136-140 recheck only control.

Hold cold across the final control and retention checks and the busy CAS, after finish_completion_grace. Add a regression test that pauses a claim between its eligibility check and the completion release.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/tui/src/worker/pty/handle/control.rs` around lines 124 - 140, Update
try_claim to hold cold after finish_completion_grace while rechecking control
and retention state and performing the busy CAS, so completion retention cannot
race with claiming; preserve release behavior on failed control validation. Add
a regression test that pauses claiming between the initial eligibility check and
completion release, verifying the retained session cannot be claimed.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/tui/src/ui/app/rail/tests.rs`:
- Around line 205-213: Update the test around the session setup to create the
session through executor dispatch instead of harnesses.open_unmanaged, ensuring
it has a non-user origin. Assert the dispatched session’s origin before calling
sessions.retain, while preserving the existing retained-session assertion.

---

Outside diff comments:
In `@src/tui/src/worker/pty/handle/control.rs`:
- Around line 124-140: Update try_claim to hold cold after
finish_completion_grace while rechecking control and retention state and
performing the busy CAS, so completion retention cannot race with claiming;
preserve release behavior on failed control validation. Add a regression test
that pauses claiming between the initial eligibility check and completion
release, verifying the retained session cannot be claimed.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: fd9b8150-15a6-4855-85d8-17b2fc632caf

📥 Commits

Reviewing files that changed from the base of the PR and between d795f34 and 6f50576.

📒 Files selected for processing (17)
  • src/tui/src/ui/app/changes/baseline_tests.rs
  • src/tui/src/ui/app/rail/mod.rs
  • src/tui/src/ui/app/rail/resolve.rs
  • src/tui/src/ui/app/rail/tests.rs
  • src/tui/src/ui/app/render/agents/rail/tests.rs
  • src/tui/src/ui/app/render/settings/status_line.rs
  • src/tui/src/worker/executor/run.rs
  • src/tui/src/worker/executor_tests/basic.rs
  • src/tui/src/worker/executor_tests/sessions.rs
  • src/tui/src/worker/pty/handle/control.rs
  • src/tui/src/worker/pty/handle/lifecycle.rs
  • src/tui/src/worker/pty/handle/state.rs
  • src/tui/src/worker/pty/handle/types.rs
  • src/tui/src/worker/pty/manager/session.rs
  • src/tui/src/worker/pty/tests/control.rs
  • src/tui/src/worker/pty/tests/types.rs
  • src/tui/src/worker/pty/types.rs

Comment thread src/tui/src/ui/app/rail/tests.rs
@sanil-23
sanil-23 merged commit c533b5d into tinyhumansai:main Aug 5, 2026
6 checks passed
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