Skip to content

fix: model cross-process session ownership - #48

Open
leoncheng57 wants to merge 3 commits into
mainfrom
fix/cross-process-session-ownership
Open

fix: model cross-process session ownership#48
leoncheng57 wants to merge 3 commits into
mainfrom
fix/cross-process-session-ownership

Conversation

@leoncheng57

Copy link
Copy Markdown
Owner

Summary

  • replace the ambiguous running boolean with an explicit current-server versus unknown/external runtime model
  • retain only process-lifetime ownership evidence and require confirmation before prompting unknown sessions
  • expose Stop only for process-local abortable work and return typed conflicts otherwise
  • suppress false interrupted states and show neutral status-unavailable UI across conversations, Hub, recents, and palette

Testing

  • npm run typecheck
  • npm test -- --run (299 passed)
  • npm run test:e2e (160 passed, 1 screenshot-runner test skipped)

Addresses blockers G2/G5.

@github-actions

Copy link
Copy Markdown
Contributor

PR screenshots

No screenshots requested. Add this to the PR description:

```screenshots
/?directory=/tmp/mock-project
full:/sessions/ses_mock_done?directory=/tmp/mock-project

_Captured from the production app against deterministic mock OpenCode fixtures only._

@leoncheng57 leoncheng57 added the priority:high Highest-priority work to do now label Aug 22, 2026
@leoncheng57

Copy link
Copy Markdown
Owner Author

Status: work stopped mid-redesign — nothing pushed

Stopped at the user's request. The remote branch and this PR are unchanged. Everything
below exists only as uncommitted changes in the local worktree
custom-dca-opencode.worktrees/cross-process-session-ownership (13 files, +593/-128).

Done locally

origin/main merged (#45, #47, #49); reminder-picker and auto-permission changes preserved.

1 — durable ownership replaced with run-scoped leases. SessionRuntimeRegistry was a
Set of directory\0sessionID that was never cleared, so a session run here once was
reported current-server / idle forever — including after an external TUI picked it up,
which is the misclassification in the review. It is now a Map of run records with an
explicit state machine, released at every terminal boundary: run end, abort, delete,
startup-grace expiry, TTL, and BFF restart. deleteSession now takes the registry and
releases. Bounded by TTL + a 500-entry cap.

2 — the lease is taken before prompt_async, with a starting state. starting
blocks a duplicate prompt, is abortable (truthful cancellation before any status
corroborates the run), and suppresses interrupted detection. A failed prompt_async
releases the lease so no phantom run blocks retries. prompt() now returns the resulting
runtime, and POST /sessions with an initial prompt returns starting instead of idle.

3 — E2E status mock corrected. It emitted {type:"idle"} for every session; 1.18.21
omits idle sessions from /session/status entirely. That fixture is precisely why the
suite never caught any of this.

Semantics worth flagging for review: there is no longer a current-server / idle
state. A completed run yields completed, which is informational only — it enables the
honest interrupted banner but grants no authority, so prompting still requires an explicit
confirmContinue (renamed from claimUnknown). The UI makes that one click: the send
button reads "Continue here" with a reason whenever ownership is unproven. Consequence
to accept or reject: every follow-up turn now goes through the continue path, because
the moment our run ends we cannot prove another process has not taken the session.

Also rejected explicitly, with a comment in the source so it is not re-proposed: keeping a
durable claim and invalidating it when time.updated advances without us. That detects an
external run only after it has written — exactly the window a concurrent prompt corrupts.

Not done

  • E2E specs not updated: 19 failing, 145 passing. All fallout from the corrected mock —
    fixtures that used to read idle now read unknown, so the composer renders
    "Continue here" and POST /prompt returns 409 without confirmContinue. The specs still
    assert the old contract. This is unfinished work, not a product regression.
  • Nothing committed, nothing pushed, CI not exercised.
  • AGENTS.md decision feat: add read-only Plan/Build mode #5 still describes the old ownership model and needs rewriting.

Green locally

npm run typecheck clean. npm test 319/319, including new coverage for absent status,
the startup gap, duplicate rejection during starting, typed abort conflicts, abort during
starting, external reuse after a prior local run, delete cleanup, lease release on
prompt_async failure, BFF-restart unknown state, and directory scoping.

Do not merge.

@leoncheng57 leoncheng57 added priority:low Lower-priority backlog work and removed priority:high Highest-priority work to do now labels Aug 22, 2026
The runtime registry kept a claim for every session it had ever seen and
never cleared it, so a session run here once reported current-server/idle
forever. When that session later ran in an external TUI its absence from
this server's process-local /session/status was read as proof of idleness
and a concurrent prompt was accepted. Delete leaked claims too.

Ownership is now run-scoped. A lease is taken for one run and released at
every terminal boundary: run end, abort, delete, startup-grace expiry, TTL
and BFF restart. Absent status is never evidence of idleness again.

The lease is taken BEFORE prompt_async rather than after. prompt_async can
return before the loop reports busy, and the old post-return claim left a
window where a duplicate prompt was accepted, Stop was disabled and the
interrupted banner fired on a run that was about to start. The new
`starting` state closes that window and a failed prompt_async releases the
lease so no phantom run blocks retries.

There is no longer a current-server/idle state. A finished run yields
`completed`, which is informational only: it enables the honest interrupted
banner but grants no authority, so prompting still needs an explicit
confirmContinue (renamed from claimUnknown). The composer surfaces this as
a one-click "Continue here" with the reason, because the instant our run
ends another process could have taken the session.

Rejected, and noted in the source so it is not re-proposed: keeping a
durable claim and invalidating it when time.updated advances without us.
That detects an external run only after it has written, which is exactly
the window a concurrent prompt corrupts.

The E2E mock listed every session as {type:"idle"}; 1.18.21 omits idle
sessions from /session/status entirely. That fixture is why the suite never
caught any of this.
@leoncheng57

Copy link
Copy Markdown
Owner Author

Pushed as 0f16de0 — supersedes the "nothing pushed" note above

The redesign described in my previous comment is now on the branch. Correcting that
comment: it is committed and pushed. Still do not merge — see the failing E2E below.

What landed

Review item State
1 — durable ownership → run-scoped leases Done
2 — lease before prompt_async, starting state Done
3 — E2E mock status semantics Mock fixed; dependent specs not updated
4 — merge origin/main (#45, #47, #49) Done, clean

State machine

                        ┌─────────────┐
                        │   unknown   │ ◀── resting state; also every session
                        └─────────────┘     after a BFF restart
                               │
                begin()  ──────┤  requires confirmContinue
                               ▼
                        ┌─────────────┐  grace expires, no busy ever seen
                        │  starting   │ ─────────────────────────▶ unknown
                        └─────────────┘  abort ──▶ completed
                               │ status busy/retry
                               ▼
                     ┌──────────────────┐
                     │ running/retrying │  abort ──▶ completed
                     └──────────────────┘
                               │ status absent after busy was seen
                               ▼
                        ┌─────────────┐  TTL expires, or delete
                        │  completed  │ ─────────────────────────▶ unknown
                        └─────────────┘

abortable is true for starting, running, retrying. Prompting without
confirmContinue is permitted for none of these states — a live lease means a run is
already in flight, so that path returns SESSION_ALREADY_RUNNING instead.

completed grants no authority. It exists so the interrupted banner can fire on a
terminal boundary we personally witnessed, rather than on absent status, which may mean an
external TUI is mid-turn.

CI will be red, and it is my unfinished work

E2E: 19 failing / 145 passing. All fallout from fixing the mock — fixtures that used to
report idle now report unknown, so the composer renders "Continue here" and
POST /prompt returns 409 without confirmContinue, while the specs still assert the old
contract. Not a product regression; the specs need rewriting against the new union.

Green: npm run typecheck, npm test 319/319, npm run build, git diff --check.

New unit coverage: absent status, startup gap, duplicate rejection during starting,
abort during starting, typed abort conflicts, external reuse after a prior local run,
delete cleanup, lease release on prompt_async failure, BFF-restart unknown state,
directory scoping, grace expiry, TTL expiry.

Two open decisions for the reviewer

  1. Every follow-up turn now goes through the continue path. Taking "released at
    terminal boundaries" literally means we cannot prove ownership the instant our own run
    ends. I made it one click rather than a modal, but it is a real UX change. The looser
    alternative is to let completed carry prompt authority for a bounded TTL — less
    honest (a TUI opened seconds after your run ends still slips through), more convenient.
  2. AGENTS.md decision feat: add read-only Plan/Build mode #5 still documents the old ownership model and now contradicts
    the code. Not touched in this commit.

Remaining before this is mergeable

@leoncheng57

Copy link
Copy Markdown
Owner Author

Pushed — supersedes my previous comment

0f16de0 is on the branch. My earlier comment said nothing was pushed; that is no longer
true. origin/main (#45, #47, #49) is merged in via 745280c, reminder-picker and
auto-permission changes preserved.

CI will be red. That is expected and it is unfinished work, not a product regression.
See "Not done" below.

1 — durable ownership replaced with run-scoped leases

SessionRuntimeRegistry was a Set of directory\0sessionID that was never cleared, so a
session run here once reported current-server / idle forever — including after an
external TUI picked it up, which is the misclassification in the review. Delete leaked
claims too.

It is now a Map of run records with one explicit state machine (observe), released at
every terminal boundary: run end, abort, delete, startup-grace expiry, TTL, and BFF
restart. deleteSession takes the registry and releases. Bounded by TTL plus a 500-entry
cap, so a long-lived BFF cannot leak.

2 — the lease is taken before prompt_async, with a starting state

prompt_async can return before the loop reports busy. The old post-return claim left a
window where a duplicate prompt was accepted, Stop was disabled, and the interrupted
banner fired on a run that was about to start. starting closes it: blocks duplicates,
is abortable for truthful cancellation, suppresses interrupted detection. A failed
prompt_async releases the lease so no phantom run blocks retries. prompt() returns the
resulting runtime, and POST /sessions with an initial prompt now reports starting
instead of idle.

3 — E2E status mock corrected

It emitted {type:"idle"} for every session; 1.18.21 omits idle sessions from
/session/status entirely. That fixture is exactly why the suite never caught any of this.

Semantics that need an accept-or-reject

There is no longer a current-server / idle state. A finished run yields completed,
which is informational only — it enables the honest interrupted banner and grants no
authority, so prompting still requires an explicit confirmContinue (renamed from
claimUnknown). The composer makes that one click: the send button reads "Continue
here"
with the reason whenever ownership is unproven.

Consequence to accept or reject: every follow-up turn now goes through the continue
path
, because the instant our run ends we cannot prove another process has not taken the
session. This follows the review literally ("released or invalidated at terminal
boundaries" plus "require an explicit honest takeover"). If the friction is unacceptable,
the alternative is letting completed grant prompt authority for a bounded TTL — weaker
and knowingly dishonest in a narrow window (a TUI opened seconds after a run ends still
slips through).

Also rejected, with a comment in the source so it is not re-proposed: keeping a durable
claim and invalidating it when time.updated advances without us. That detects an
external run only after it has written, which is precisely the window a concurrent prompt
corrupts.

Green

npm run typecheck, npm run build, git diff --check all clean. npm test 319/319,
with new coverage for: absent status never reading as idle, the full
starting -> running -> completed walk, startup-grace expiry, completed-TTL expiry,
external reuse after a prior local run, completed granting no authority, duplicate
rejection during starting, abort during starting, lease release on prompt_async
failure, delete cleanup, BFF-restart unknown state, and directory scoping.

Not done

  • E2E: 19 failing / 145 passing. All fallout from the corrected mock — fixtures that
    used to read idle now read unknown, so the composer renders "Continue here" and
    POST /prompt returns 409 without confirmContinue. The specs still assert the old
    contract and need updating to the new one.
  • AGENTS.md decision feat: add read-only Plan/Build mode #5 still documents the old ownership model and now contradicts the
    code.

Do not merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

priority:low Lower-priority backlog work

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant