From b04eb35e9c8f9f7fc98f36df066c15f6be65260b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A0=9A=E7=A0=9A?= Date: Wed, 24 Jun 2026 15:23:10 +0800 Subject: [PATCH] fix: scope OpenCode resume sessions by workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: OpenCode --session can carry stale workspace/tool state across repositories, so resume must be gated by the current thread workspace. Records the workingDirectory on SessionRecord and only passes an OpenCode session id when the active record matches both cliSessionId and normalized workspace. Covers matching, missing, and mismatched workspace resume paths plus memory and Redis store persistence. [็ š็ š/gpt-5.5๐Ÿพ] --- .../agents/invocation/invoke-single-cat.ts | 45 +++++ .../stores/ports/SessionChainStore.ts | 4 + .../stores/redis/RedisSessionChainStore.ts | 12 +- packages/api/test/invoke-single-cat.test.js | 176 ++++++++++++++++++ .../test/redis-session-chain-store.test.js | 16 +- packages/api/test/session-chain-store.test.js | 13 +- packages/shared/src/types/session.ts | 2 + 7 files changed, 265 insertions(+), 3 deletions(-) diff --git a/packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts b/packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts index d1b945da1e..3cdb5cadc1 100644 --- a/packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts +++ b/packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts @@ -376,6 +376,17 @@ function isUserVisibleSessionOutput(msg: AgentMessage): boolean { return msg.type === 'text' || msg.type === 'tool_use' || msg.type === 'tool_result'; } +function normalizeSessionWorkspace(path: string | undefined): string | undefined { + const trimmed = path?.trim(); + return trimmed ? resolve(trimmed) : undefined; +} + +function sessionWorkspaceMatches(record: SessionRecord, workingDirectory: string | undefined): boolean { + const expected = normalizeSessionWorkspace(workingDirectory); + const actual = normalizeSessionWorkspace(record.workingDirectory); + return Boolean(expected && actual && expected === actual); +} + async function syncAntigravityRuntimeMetadata(input: { runtimeSessionStore: IRuntimeSessionStore; sessionChainStore: ISessionChainStore; @@ -1071,6 +1082,32 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP } const workingProjectRoot = workingDirectory ? findMonorepoRoot(workingDirectory) : undefined; + if (provider === 'opencode' && sessionId && deps.sessionChainStore && sessionChainActive && !isBgCarrier) { + try { + const activeRec = await preflightRace( + Promise.resolve(deps.sessionChainStore.getActive(catId, threadId)), + 'getActive:workspaceResumeGuard', + signal, + ); + if (!activeRec || activeRec.cliSessionId !== sessionId || !sessionWorkspaceMatches(activeRec, workingDirectory)) { + log.debug( + { + catId, + threadId, + invocationId, + cliSessionId: sessionId, + recordWorkingDirectory: activeRec?.workingDirectory ?? null, + workingDirectory: workingDirectory ?? null, + }, + 'OpenCode resume session discarded because stored workspace does not match thread workspace', + ); + sessionId = undefined; + } + } catch { + sessionId = undefined; + } + } + // Shared-state preflight โ€” covers ALL cats (Claude/Codex/Gemini), vendor-agnostic. // Three-layer defense model (shared-rules ยง14): // L1 .githooks/pre-commit = hard block (prevents committing on wrong branch) @@ -2171,6 +2208,7 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP if (bgRec.cliSessionId !== msg.sessionId) { await deps.sessionChainStore.update(bgRec.id, { cliSessionId: msg.sessionId, + ...(workingDirectory ? { workingDirectory } : {}), ...(params.continuityCapsule ? { continuityCapsule: params.continuityCapsule } : {}), updatedAt: Date.now(), }); @@ -2182,6 +2220,7 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP } else { const newRec = await deps.sessionChainStore.create({ cliSessionId: msg.sessionId, + ...(workingDirectory ? { workingDirectory } : {}), threadId, catId, userId, @@ -2206,6 +2245,7 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP // This is normal โ€” NOT a "session replaced" event. Just update the tracked ID. await deps.sessionChainStore.update(existing.id, { cliSessionId: msg.sessionId, + ...(workingDirectory ? { workingDirectory } : {}), ...(params.continuityCapsule ? { continuityCapsule: params.continuityCapsule } : {}), updatedAt: Date.now(), }); @@ -2280,6 +2320,7 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP const inheritedFailures = existing.consecutiveRestoreFailures ?? 0; const newRec = await deps.sessionChainStore.create({ cliSessionId: msg.sessionId, + ...(workingDirectory ? { workingDirectory } : {}), threadId, catId, userId, @@ -2298,13 +2339,17 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP } } else if (params.continuityCapsule) { await deps.sessionChainStore.update(existing.id, { + ...(workingDirectory ? { workingDirectory } : {}), continuityCapsule: params.continuityCapsule, }); + } else if (workingDirectory && existing.workingDirectory !== workingDirectory) { + await deps.sessionChainStore.update(existing.id, { workingDirectory }); } } else { // No active session (first invocation or previous was sealed) const newRec = await deps.sessionChainStore.create({ cliSessionId: msg.sessionId, + ...(workingDirectory ? { workingDirectory } : {}), threadId, catId, userId, diff --git a/packages/api/src/domains/cats/services/stores/ports/SessionChainStore.ts b/packages/api/src/domains/cats/services/stores/ports/SessionChainStore.ts index 63eee79ecc..93d80da19a 100644 --- a/packages/api/src/domains/cats/services/stores/ports/SessionChainStore.ts +++ b/packages/api/src/domains/cats/services/stores/ports/SessionChainStore.ts @@ -11,6 +11,7 @@ import type { CatId, SessionRecord } from '@cat-cafe/shared'; export interface CreateSessionInput { cliSessionId: string; + workingDirectory?: string; threadId: string; catId: CatId; userId: string; @@ -28,6 +29,7 @@ export type SessionRecordPatch = Partial< Pick< SessionRecord, | 'cliSessionId' + | 'workingDirectory' | 'status' | 'contextHealth' | 'lastUsage' @@ -114,6 +116,7 @@ export class SessionChainStore implements ISessionChainStore { const record: SessionRecord = { id, cliSessionId: input.cliSessionId, + ...(input.workingDirectory ? { workingDirectory: input.workingDirectory } : {}), threadId: input.threadId, catId: input.catId, userId: input.userId, @@ -191,6 +194,7 @@ export class SessionChainStore implements ISessionChainStore { record.cliSessionId = patch.cliSessionId; this.cliIndex.set(patch.cliSessionId, id); } + if (patch.workingDirectory !== undefined) record.workingDirectory = patch.workingDirectory; if (patch.status !== undefined) { record.status = patch.status; const key = this.catThreadKey(record.catId, record.threadId); diff --git a/packages/api/src/domains/cats/services/stores/redis/RedisSessionChainStore.ts b/packages/api/src/domains/cats/services/stores/redis/RedisSessionChainStore.ts index 66eaa6f5ac..fac73c7843 100644 --- a/packages/api/src/domains/cats/services/stores/redis/RedisSessionChainStore.ts +++ b/packages/api/src/domains/cats/services/stores/redis/RedisSessionChainStore.ts @@ -32,7 +32,8 @@ const DEFAULT_TTL_SECONDS = 0; // persistent โ€” set >0 via env to enable expiry * KEYS[4] = cli key, KEYS[5] = chainKey index key (F198; dummy when no chainKey) * ARGV[1] = id, ARGV[2] = cliSessionId, ARGV[3] = threadId, ARGV[4] = catId, * ARGV[5] = userId, ARGV[6] = now, ARGV[7] = reuseExistingCliSession flag, - * ARGV[8] = chainKey value ('' = none, KEYS[5] left untouched) + * ARGV[8] = chainKey value ('' = none, KEYS[5] left untouched), + * ARGV[9] = workingDirectory value ('' = unknown) * * Returns: {'existing', existingId} when cliSessionId is already claimed, * {'created', id, seq} when a new record is created. @@ -52,6 +53,9 @@ if ARGV[8] ~= '' then redis.call('HSET', KEYS[3], 'chainKey', ARGV[8]) ${DEFAULT_TTL_SECONDS > 0 ? `redis.call('SET', KEYS[5], ARGV[1], 'EX', ${DEFAULT_TTL_SECONDS})` : `redis.call('SET', KEYS[5], ARGV[1])`} end +if ARGV[9] ~= '' then + redis.call('HSET', KEYS[3], 'workingDirectory', ARGV[9]) +end ${DEFAULT_TTL_SECONDS > 0 ? `redis.call('EXPIRE', KEYS[3], ${DEFAULT_TTL_SECONDS})` : '-- persistent mode: no EXPIRE'} redis.call('ZADD', KEYS[2], seq, ARGV[1]) ${DEFAULT_TTL_SECONDS > 0 ? `redis.call('EXPIRE', KEYS[2], ${DEFAULT_TTL_SECONDS})` : '-- persistent mode: no EXPIRE'} @@ -112,6 +116,7 @@ export class RedisSessionChainStore implements ISessionChainStore { now, input.reuseExistingCliSession ? '1' : '0', input.chainKey ?? '', + input.workingDirectory ?? '', )) as [string, string, string?]; const [status, recordId, seqRaw] = result; @@ -126,6 +131,7 @@ export class RedisSessionChainStore implements ISessionChainStore { return { id: recordId, cliSessionId: input.cliSessionId, + ...(input.workingDirectory ? { workingDirectory: input.workingDirectory } : {}), threadId: input.threadId, catId: input.catId as CatId, userId: input.userId, @@ -229,6 +235,9 @@ export class RedisSessionChainStore implements ISessionChainStore { } pairs.push('cliSessionId', patch.cliSessionId); } + if (patch.workingDirectory !== undefined) { + pairs.push('workingDirectory', patch.workingDirectory); + } if (patch.status !== undefined) { pairs.push('status', patch.status); @@ -354,6 +363,7 @@ export class RedisSessionChainStore implements ISessionChainStore { return { id: data.id!, cliSessionId: data.cliSessionId!, + ...(data.workingDirectory ? { workingDirectory: data.workingDirectory } : {}), threadId: data.threadId!, catId: data.catId as CatId, userId: data.userId!, diff --git a/packages/api/test/invoke-single-cat.test.js b/packages/api/test/invoke-single-cat.test.js index 183b316fc0..266daf199a 100644 --- a/packages/api/test/invoke-single-cat.test.js +++ b/packages/api/test/invoke-single-cat.test.js @@ -6835,6 +6835,182 @@ describe('invokeSingleCat audit events (P1 fix)', () => { assert.equal(optionsSeen[0]?.workingDirectory, projectRoot); }); + it('does not resume OpenCode when active session has no stored workspace', async () => { + const projectRoot = await realpath(join(__dirname, '..', '..', '..')); + const optionsSeen = []; + const service = { + l0CompilerFn: dummyL0CompilerFn, + async *invoke(_prompt, options) { + optionsSeen.push(options ?? {}); + yield { type: 'done', catId: 'opencode', timestamp: Date.now() }; + }, + }; + const activeRecord = { + id: 'rec-opencode-no-workspace', + seq: 0, + status: 'active', + cliSessionId: 'stale-opencode-session', + catId: 'opencode', + threadId: 'thread-opencode-no-workspace-resume', + userId: 'user1', + messageCount: 1, + createdAt: Date.now() - 1000, + updatedAt: Date.now() - 1000, + }; + const deps = { + ...makeDeps(), + threadStore: { + get: async () => ({ projectPath: projectRoot, createdBy: 'user1' }), + updateParticipantActivity: async () => {}, + }, + sessionManager: { + get: async () => 'stale-opencode-session', + store: async () => {}, + delete: async () => {}, + }, + sessionChainStore: { + getChain: async () => [activeRecord], + getActive: async () => activeRecord, + get: async () => activeRecord, + create: async () => activeRecord, + update: async () => activeRecord, + }, + }; + + await collect( + invokeSingleCat(deps, { + catId: 'opencode', + service, + prompt: 'test stale workspace resume', + userId: 'user1', + threadId: 'thread-opencode-no-workspace-resume', + isLastCat: true, + }), + ); + + assert.equal(optionsSeen.length, 1); + assert.equal(optionsSeen[0]?.sessionId, undefined, 'OpenCode must not resume sessions without workspace metadata'); + assert.equal(optionsSeen[0]?.workingDirectory, projectRoot); + }); + + it('does not resume OpenCode when active session workspace differs from thread workspace', async () => { + const projectRoot = await realpath(join(__dirname, '..', '..', '..')); + const optionsSeen = []; + const service = { + l0CompilerFn: dummyL0CompilerFn, + async *invoke(_prompt, options) { + optionsSeen.push(options ?? {}); + yield { type: 'done', catId: 'opencode', timestamp: Date.now() }; + }, + }; + const activeRecord = { + id: 'rec-opencode-wrong-workspace', + seq: 0, + status: 'active', + cliSessionId: 'stale-opencode-session', + workingDirectory: '/tmp/other-workspace', + catId: 'opencode', + threadId: 'thread-opencode-wrong-workspace-resume', + userId: 'user1', + messageCount: 1, + createdAt: Date.now() - 1000, + updatedAt: Date.now() - 1000, + }; + const deps = { + ...makeDeps(), + threadStore: { + get: async () => ({ projectPath: projectRoot, createdBy: 'user1' }), + updateParticipantActivity: async () => {}, + }, + sessionManager: { + get: async () => 'stale-opencode-session', + store: async () => {}, + delete: async () => {}, + }, + sessionChainStore: { + getChain: async () => [activeRecord], + getActive: async () => activeRecord, + get: async () => activeRecord, + create: async () => activeRecord, + update: async () => activeRecord, + }, + }; + + await collect( + invokeSingleCat(deps, { + catId: 'opencode', + service, + prompt: 'test wrong workspace resume', + userId: 'user1', + threadId: 'thread-opencode-wrong-workspace-resume', + isLastCat: true, + }), + ); + + assert.equal(optionsSeen.length, 1); + assert.equal(optionsSeen[0]?.sessionId, undefined, 'OpenCode must not resume sessions from a different workspace'); + assert.equal(optionsSeen[0]?.workingDirectory, projectRoot); + }); + + it('resumes OpenCode when active session workspace matches thread workspace', async () => { + const projectRoot = await realpath(join(__dirname, '..', '..', '..')); + const optionsSeen = []; + const service = { + l0CompilerFn: dummyL0CompilerFn, + async *invoke(_prompt, options) { + optionsSeen.push(options ?? {}); + yield { type: 'done', catId: 'opencode', timestamp: Date.now() }; + }, + }; + const activeRecord = { + id: 'rec-opencode-matching-workspace', + seq: 0, + status: 'active', + cliSessionId: 'live-opencode-session', + workingDirectory: projectRoot, + catId: 'opencode', + threadId: 'thread-opencode-matching-workspace-resume', + userId: 'user1', + messageCount: 1, + createdAt: Date.now() - 1000, + updatedAt: Date.now() - 1000, + }; + const deps = { + ...makeDeps(), + threadStore: { + get: async () => ({ projectPath: projectRoot, createdBy: 'user1' }), + updateParticipantActivity: async () => {}, + }, + sessionManager: { + get: async () => 'live-opencode-session', + store: async () => {}, + delete: async () => {}, + }, + sessionChainStore: { + getChain: async () => [activeRecord], + getActive: async () => activeRecord, + get: async () => activeRecord, + create: async () => activeRecord, + update: async () => activeRecord, + }, + }; + + await collect( + invokeSingleCat(deps, { + catId: 'opencode', + service, + prompt: 'test matching workspace resume', + userId: 'user1', + threadId: 'thread-opencode-matching-workspace-resume', + isLastCat: true, + }), + ); + + assert.equal(optionsSeen.length, 1); + assert.equal(optionsSeen[0]?.sessionId, 'live-opencode-session'); + assert.equal(optionsSeen[0]?.workingDirectory, projectRoot); + }); + it('fails loud for OpenCode when thread projectPath is default', async () => { let invokedService = false; const service = { diff --git a/packages/api/test/redis-session-chain-store.test.js b/packages/api/test/redis-session-chain-store.test.js index 7866ba0df1..67144fb310 100644 --- a/packages/api/test/redis-session-chain-store.test.js +++ b/packages/api/test/redis-session-chain-store.test.js @@ -88,10 +88,11 @@ describe('RedisSessionChainStore', { skip: redisIsolationSkipReason(REDIS_URL) } }); it('create() returns SessionRecord with correct initial state', async () => { - const record = await store.create(BASE_INPUT); + const record = await store.create({ ...BASE_INPUT, workingDirectory: '/tmp/worktree-a' }); assert.ok(record.id.length > 0); assert.equal(record.cliSessionId, 'cli-sess-1'); + assert.equal(record.workingDirectory, '/tmp/worktree-a'); assert.equal(record.threadId, 'thread-1'); assert.equal(record.catId, 'opus'); assert.equal(record.userId, 'user-1'); @@ -218,6 +219,19 @@ describe('RedisSessionChainStore', { skip: redisIsolationSkipReason(REDIS_URL) } assert.deepEqual(updated.contextHealth, health); }); + it('update() stores workingDirectory', async () => { + const record = await store.create(BASE_INPUT); + + const updated = await store.update(record.id, { workingDirectory: '/tmp/worktree-b' }); + + assert.ok(updated); + assert.equal(updated.workingDirectory, '/tmp/worktree-b'); + const byId = await store.get(record.id); + assert.equal(byId.workingDirectory, '/tmp/worktree-b'); + const byCli = await store.getByCliSessionId('cli-sess-1'); + assert.equal(byCli.workingDirectory, '/tmp/worktree-b'); + }); + it('update() persists continuityCapsule across hydrated lookup paths', async () => { const record = await store.create(BASE_INPUT); const capsule = { diff --git a/packages/api/test/session-chain-store.test.js b/packages/api/test/session-chain-store.test.js index 1977826052..433bef8e52 100644 --- a/packages/api/test/session-chain-store.test.js +++ b/packages/api/test/session-chain-store.test.js @@ -21,10 +21,11 @@ describe('SessionChainStore', () => { test('create() returns SessionRecord with correct initial state', async () => { const store = await createStore(); - const record = store.create(BASE_INPUT); + const record = store.create({ ...BASE_INPUT, workingDirectory: '/tmp/worktree-a' }); assert.ok(record.id.length > 0, 'should have an id'); assert.equal(record.cliSessionId, 'cli-sess-1'); + assert.equal(record.workingDirectory, '/tmp/worktree-a'); assert.equal(record.threadId, 'thread-1'); assert.equal(record.catId, 'opus'); assert.equal(record.userId, 'user-1'); @@ -176,6 +177,16 @@ describe('SessionChainStore', () => { assert.equal(store.getByCliSessionId('cli-sess-1'), null, 'old CLI session ID should be unlinked'); }); + test('update() stores workingDirectory', async () => { + const store = await createStore(); + const record = store.create(BASE_INPUT); + + const updated = store.update(record.id, { workingDirectory: '/tmp/worktree-b' }); + + assert.equal(updated.workingDirectory, '/tmp/worktree-b'); + assert.equal(store.get(record.id).workingDirectory, '/tmp/worktree-b'); + }); + test('update() returns null for non-existent id', async () => { const store = await createStore(); assert.equal(store.update('non-existent', { status: 'sealed' }), null); diff --git a/packages/shared/src/types/session.ts b/packages/shared/src/types/session.ts index 9b08258e76..00d97cc681 100644 --- a/packages/shared/src/types/session.ts +++ b/packages/shared/src/types/session.ts @@ -17,6 +17,8 @@ export interface SessionRecord { readonly id: string; /** CLI-reported session ID (from session_init event) */ cliSessionId: string; + /** Filesystem workspace that the CLI session was created for, when known. */ + workingDirectory?: string; readonly threadId: string; readonly catId: CatId; readonly userId: string;