diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.i18n.yaml new file mode 100644 index 0000000000..7514000344 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md +2026-08-05-workspace-blank-session-reuse-membership.md: 3f60c8d803bb09d6421e09886ec143296ed2b763 +2026-08-05-workspace-blank-session-reuse-membership.zh.md: 4932e85ef71a3cb2d5cad3cf9cf873460842e046 diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md new file mode 100644 index 0000000000..3f60c8d803 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md @@ -0,0 +1,29 @@ +# Agent Note: Workspace New Session reuse hijacked cwd-matching unaccounted blank sessions + +Status: implemented + +English | [中文](2026-08-05-workspace-blank-session-reuse-membership.zh.md) + +## Problem + +Clicking the `+` on a Workspace group in the sidebar sometimes opened a session that the sidebar showed under Ungrouped instead of under the clicked Workspace — "entered a new session but the Workspace was not selected". The failure was specific to Workspaces registered at the directory the CLI runs from (in practice the harness checkout itself, i.e. `defaults.cwd = process.cwd()`), and appeared once a CLI-born blank session existed there. + +Root cause: `connectWorkspace`'s blank-session reuse scanned the session list mirror on `cwd` equality alone. The host's own membership rule requires **both** an id in the Workspace account (`sessionIds`) **and** a session header whose canonical cwd equals the Workspace path ([Workspace UI product flow](2026-07-25-workspace-ui-product-flow.md)); a cwd match without the account slot is exactly the Ungrouped case. The reuse scan ignored the account slot, so any **live blank** session whose cwd matched qualified — including `main-session-*` sessions the CLI/TUI/headless entry points birth at the host cwd (`session.create({})` falls back to `defaults.cwd` and never attaches to a Workspace). When such a session was live and blank (no `turn/start` yet), the next `+` click on a Workspace registered at that path reused it and navigation opened a session no grouping surface can show under that Workspace. Workspaces at other paths were unaffected because no unaccounted blank sessions accumulate there; the host-cwd Workspace accumulated one per CLI run. + +## Decision + +The reuse scan now requires workspace membership: `blank` AND `summary.cwd === workspace.path` AND `workspace.sessionIds.includes(summary.id)` AND not archived. A cwd-only match falls through to `session.create({ workspaceId })`, which attaches the fresh session so the Workspace owns it — the same arm the flow already used for "no blank session exists". + +## Alternatives considered + +**Adopt the stray instead of minting.** `session.create({ workspaceId })` could attach a cwd-matching unaccounted blank session. Rejected: silently attaching CLI-born sessions to a Workspace crosses the account boundary by surprise, and the client cannot distinguish "stray" from "the Workspace's own blank" without the membership view — which is the fix itself. + +**Attach on reuse via a new wire operation.** Requires a `workspace.attachSession` RPC in the navigation hot path and would still render the session under Ungrouped for a frame; no product need justifies the surface. + +## Consequences + +Stray blank sessions remain visible in Ungrouped (the user can still open them) but are never hijacked by a Workspace's New Session flow. A stale client mirror (Workspace frame lagging the account) can miss a reuse window and create instead — the pre-existing race behavior, already coalesced per Workspace. No host, wire, or durable-format change. + +## Testing + +`packages/client/runtime/tests/workspaces-service.spec.ts` covers the four outcomes: a member blank session is reused (no create RPC); a stray blank with matching cwd is **not** reused and a fresh accounted session is created (regression case); an archived blank is not reused; a rejected first prompt keeps a member blank eligible. The full client suite (`pnpm run test:gui`) stays green. diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.zh.md b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.zh.md new file mode 100644 index 0000000000..4932e85ef7 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.zh.md @@ -0,0 +1,29 @@ +# Agent Note:工作区新建会话复用了 cwd 匹配但未入账的空白会话 + +状态:已实现 + +[English](2026-08-05-workspace-blank-session-reuse-membership.md) | 中文 + +## 问题 + +在侧边栏某个工作区分组的 `+` 上创建会话时,有时会进入一个新会话,但侧边栏把它显示在「未分组」而不是点击的那个工作区下——「进入了新会话,但工作区没有被选中」。故障只出现在注册在 CLI 运行目录(即 `defaults.cwd = process.cwd()`,实际场景里就是 harness 检出目录本身)上的工作区,并且一旦该目录下存在 CLI 创建的空白会话就会出现。 + +根因:`connectWorkspace` 的空白会话复用扫描只按 `cwd` 相等匹配会话列表镜像。host 自己的成员规则要求**同时**满足:会话 id 在工作区账户(`sessionIds`)中,**且**会话 header 的规范化 cwd 等于工作区路径([Workspace UI product flow](2026-07-25-workspace-ui-product-flow.md));只有 cwd 匹配而没有账户槽位的恰恰就是「未分组」的情形。复用扫描忽略了账户槽位,因此任何 cwd 匹配的**在线空白**会话都会被选中——包括 CLI/TUI/headless 入口在 host cwd 创建的 `main-session-*` 会话(`session.create({})` 回退到 `defaults.cwd`,从不挂到任何工作区)。当这样的会话在线且空白(尚无 `turn/start`)时,下一次在该路径注册的工作区上点击 `+` 就会复用它,导航打开的是一个任何分组表面都无法显示在该工作区下的会话。其他路径的工作区不受影响,因为那里不会积累未入账的空白会话;而 host-cwd 工作区每次 CLI 运行都会积累一个。 + +## 决定 + +复用扫描现在要求工作区成员关系:`blank` 且 `summary.cwd === workspace.path` 且 `workspace.sessionIds.includes(summary.id)` 且未归档。仅 cwd 匹配的情况落到 `session.create({ workspaceId })`,创建并挂接新会话,使工作区拥有它——这与流程中「不存在空白会话」时的既有分支完全相同。 + +## 曾考虑的替代方案 + +**收养游离会话而不是新建。** 让 `session.create({ workspaceId })` 挂接一个 cwd 匹配但未入账的空白会话。否决:静默地把 CLI 创建的会话挂到工作区上,越过了账户边界,令人意外;而且客户端没有成员视图就无法区分「游离会话」与「工作区自己的空白会话」——而成员视图本身就是本次修复。 + +**复用时就地挂接,新增一条 wire 操作。** 需要在导航热路径上新增 `workspace.attachSession` RPC,并且会话仍会有一帧显示在「未分组」;没有产品需求值得新增这个表面。 + +## 后果 + +游离空白会话仍显示在「未分组」(用户仍可手动打开),但不再被某个工作区的新建会话流程劫持。客户端镜像滞后(工作区帧落后于账户)时可能错过复用窗口而改为新建——这是既有的竞态行为,已按工作区合并。无 host、wire 或持久化格式变更。 + +## 测试 + +`packages/client/runtime/tests/workspaces-service.spec.ts` 覆盖四种结果:成员空白会话被复用(无 create RPC);cwd 匹配但非成员的游离空白会话**不被**复用、改为创建全新入账会话(回归用例);已归档空白会话不被复用;首次 prompt 被拒后成员空白会话仍可复用。完整客户端套件(`pnpm run test:gui`)保持绿色。 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index dd780369a1..adcc9595bb 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -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 diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index a0a76670f2..69910fd0c4 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -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 }) diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts index 4323d7ffce..82357a1045 100644 --- a/packages/client/runtime/tests/workspaces-service.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.spec.ts @@ -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[], }))