fix(client): only reuse workspace-member blank sessions for New Session

The blank-session reuse scan in connectWorkspace matched on cwd alone, so a
live blank session the CLI/TUI birthed at the host cwd (never accounted to
any workspace) was hijacked when + was clicked on a workspace registered at
that path, opening a session the sidebar shows under Ungrouped instead of
the clicked workspace. Reuse now also requires membership in the workspace's
sessionIds (the host's own membership rule); cwd-only matches fall through
to session.create({workspaceId}).

Fixes #1647
This commit is contained in:
_Kerman
2026-08-05 11:37:57 +08:00
parent c7f693aa40
commit add1674ac4
6 changed files with 89 additions and 9 deletions

View File

@@ -20,7 +20,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
## New Session and the blank mirror
`WorkspacesService.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path`) or calls `session.create({workspaceId})`, returning the session id for the caller to open. `SessionSummary.blank` mirrors the host's derived empty-log bit and only ever lowers on the client: seeded by `session.list` / the `host/session-added` frame, flipped false by the first ACCEPTED local `prompt()` (on the RPC success response — acceptance proves the user message is in the host log; a rejected first prompt keeps the session blank and reusable) and by any `running: true` status frame, re-aligned by every list re-pull. List surfaces hide blank rows; the store carries every row. `SessionsService.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` (carrying `requestedSessionId`) on failure.
`WorkspacesService.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path && sessionIds.includes(id)` — the host's own membership rule, never cwd alone, so a cwd-matching unaccounted blank session is never hijacked) or calls `session.create({workspaceId})`, returning the session id for the caller to open. `SessionSummary.blank` mirrors the host's derived empty-log bit and only ever lowers on the client: seeded by `session.list` / the `host/session-added` frame, flipped false by the first ACCEPTED local `prompt()` (on the RPC success response — acceptance proves the user message is in the host log; a rejected first prompt keeps the session blank and reusable) and by any `running: true` status frame, re-aligned by every list re-pull. List surfaces hide blank rows; the store carries every row. `SessionsService.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` (carrying `requestedSessionId`) on failure.
## Pending queue projection

View File

@@ -94,15 +94,19 @@ export class WorkspacesService implements IWorkspaces {
// would miss the reuse scan and mint another hidden blank session.
const inflight = this.connecting.get(workspaceId)
if (inflight !== undefined) return inflight
// Reuse: blank && same canonical cwd (workspace.path is the host realpath
// canon; summary cwd is the session header passthrough of the same canon).
// An archived blank is never reused: reuse would open a session no
// grouping surface can show, so New Session mints a fresh one instead.
// Reuse requires workspace membership (id in sessionIds AND same
// canonical cwd the host's own membership rule), never cwd alone:
// a cwd match can belong to no account (sessions the CLI/TUI birthed at
// the host cwd, or a deleted/recreated registration) and reusing it
// would open a session no grouping surface shows under this workspace.
// An archived blank is never reused either: reuse would open a session
// no grouping surface can show, so New Session mints a fresh one instead.
const archived = this.list.getSnapshot().archivedSessionIds
const sessions = this.sessions.list.getSnapshot()
for (const id of sessions.ids) {
const summary = sessions.byId[id]
if (summary !== undefined && summary.blank && summary.cwd === workspace.path
&& workspace.sessionIds.includes(summary.id)
&& !archived.includes(summary.id)) return summary.id
}
const attempt = this.sessions.create({ workspaceId })

View File

@@ -149,20 +149,26 @@ describe('WorkspacesService', () => {
expect(workspaces.list.getSnapshot().items.map(item => item.workspaceId)).toEqual(['stable-first', 'active'])
})
it('connectWorkspace reuses the workspace-matched blank session and creates otherwise', async () => {
it('connectWorkspace reuses the workspace-member blank session and creates otherwise', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const workspaces = new WorkspacesService(ctx, api, sessions)
api.onWorkspaceList = () => Promise.resolve(ok({
items: [workspace('alpha'), workspace('beta')] as never[],
items: [workspace('alpha', [sid('s-blank')]), workspace('beta'), workspace('gamma')] as never[],
}))
api.onList = () => Promise.resolve(ok({
items: [
// Blank session already parked in alpha (cwd == workspace path canon).
// Blank session already parked in alpha (cwd == workspace path canon
// AND accounted under alpha): the reuse hit.
{ sessionId: sid('s-blank'), updatedAt: 2, running: false, blank: true, cwd: '/w/alpha' },
// Non-blank sibling in beta must never be reused.
{ sessionId: sid('s-active'), updatedAt: 3, running: false, blank: false, cwd: '/w/beta' },
// Stray blank at gamma's path but NOT accounted under gamma (a CLI
// session birthed at the host cwd): cwd alone must not hijack it —
// reuse would open a session gamma cannot show, so New Session mints
// a fresh accounted one instead.
{ sessionId: sid('s-stray'), updatedAt: 4, running: false, blank: true, cwd: '/w/gamma' },
] as never[],
}))
await Promise.all([workspaces.refresh(), sessions.refresh()])
@@ -181,6 +187,12 @@ describe('WorkspacesService', () => {
// Same guarantee on the create arm (draft hand-off writes the machine pre-open).
expect(sessions.binding(sid('s-fresh'))).toBeDefined()
// Miss: the stray blank matches gamma's path but is not a gamma member →
// never reused, a fresh accounted session is created instead.
api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-fresh-3') }))
await expect(workspaces.connectWorkspace(wid('gamma'))).resolves.toBe('s-fresh-3')
expect(api.callsOf('session.create')).toEqual([{ workspaceId: 'beta' }, { workspaceId: 'gamma' }])
// Unknown workspace fails loud instead of silently creating in nowhere.
await expect(workspaces.connectWorkspace(wid('ghost'))).rejects.toThrow(/unknown workspace ghost/)
@@ -196,7 +208,7 @@ describe('WorkspacesService', () => {
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const workspaces = new WorkspacesService(ctx, api, sessions)
api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha')] as never[] }))
api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha', [sid('s-blank')])] as never[] }))
api.onList = () => Promise.resolve(ok({
items: [{ sessionId: sid('s-blank'), updatedAt: 2, running: false, blank: true, cwd: '/w/alpha' }] as never[],
}))