diff --git a/vscode/src/cc/handoffBundle.test.ts b/vscode/src/cc/handoffBundle.test.ts index d90b8c9..c1d1c70 100644 --- a/vscode/src/cc/handoffBundle.test.ts +++ b/vscode/src/cc/handoffBundle.test.ts @@ -106,4 +106,45 @@ describe('pack → unpack → install', () => { await installHandoffBundle(staging, manifest, { projectsRoot, codexRoot: tempDir('receiver-codex-'), workspaceProjectsDir }) expect(existsSync(path.join(workspaceProjectsDir, `${IMPL}.jsonl`))).toBe(true) }) + + it('带 worktreePath + pathRewrite:装机文件里的发送方路径全部改写成接收方路径', async () => { + const WORKTREE_FROM = '/Users/chw/wt/x' + const WORKTREE_TO = '/home/me/wt/x' + const roots = senderRoots() + // sender 会话文件里嵌入发送方的 worktree 路径(plain / file:// 两种形式) + const wt = path.join(roots.projectsRoot, '-Users-chw-wt-x') + writeFileSync(path.join(wt, `${IMPL}.jsonl`), `{"cwd":"${WORKTREE_FROM}"}\n{"cwd":"file://${WORKTREE_FROM}"}\n`) + writeFileSync(path.join(wt, IMPL, 'subagents', 'agent-1.jsonl'), `{"cwd":"${WORKTREE_FROM}"}\n`) + const day = path.join(roots.codexRoot, '2026', '08', '26') + writeFileSync(path.join(day, `rollout-2026-08-26T01-02-03-${REVIEW}.jsonl`), `{"cwd":"${WORKTREE_FROM}"}\n{"cwd":"file://${WORKTREE_FROM}"}\n`) + + const staging = tempDir('staging-') + const manifest = await stageHandoffBundle({ + issueNumber: 7, + from: 'chw', + worktreePath: WORKTREE_FROM, + sessions: { implementSessionId: IMPL, reviewSessionId: REVIEW }, + profiles: {}, + }, staging, roots) + expect(manifest.worktreePath).toBe(WORKTREE_FROM) + + const projectsRoot = tempDir('receiver-projects-') + const codexRoot = tempDir('receiver-codex-') + const worktreeProjectsDir = path.join(projectsRoot, '-home-me-wt-x') + await installHandoffBundle(staging, manifest, { + projectsRoot, + codexRoot, + workspaceProjectsDir: path.join(projectsRoot, '-home-me-proj'), + worktreeProjectsDir, + pathRewrite: { from: WORKTREE_FROM, to: WORKTREE_TO }, + }) + + const installedJsonl = readFileSync(path.join(worktreeProjectsDir, `${IMPL}.jsonl`), 'utf8') + const installedSubagent = readFileSync(path.join(worktreeProjectsDir, IMPL, 'subagents', 'agent-1.jsonl'), 'utf8') + const installedRollout = readFileSync(path.join(codexRoot, manifest.codex[0].relPath), 'utf8') + for (const content of [installedJsonl, installedSubagent, installedRollout]) { + expect(content).toContain(WORKTREE_TO) + expect(content).not.toContain(WORKTREE_FROM) + } + }) }) diff --git a/vscode/src/cc/handoffBundle.ts b/vscode/src/cc/handoffBundle.ts index 60a7c05..b11f986 100644 --- a/vscode/src/cc/handoffBundle.ts +++ b/vscode/src/cc/handoffBundle.ts @@ -8,12 +8,15 @@ import { findClaudeSessionFiles, installClaudeSession, installCodexSession, + rewritePathInFile, + rewritePathInTree, } from './sessionBundle' export interface HandoffSource { issueNumber: number from: string branch?: string + worktreePath?: string sessions: HandoffSessions profiles: HandoffProfiles } @@ -64,6 +67,7 @@ export async function stageHandoffBundle( from: src.from, createdAt: now.toISOString(), ...(src.branch ? { branch: src.branch } : {}), + ...(src.worktreePath ? { worktreePath: src.worktreePath } : {}), sessions: src.sessions, profiles: src.profiles, claude, @@ -76,6 +80,8 @@ export async function stageHandoffBundle( export interface HandoffInstallTargets extends HandoffRoots { workspaceProjectsDir: string worktreeProjectsDir?: string + /** 发送方与接收方的 worktree 路径不同:安装前先把会话文件里的旧路径重写成新路径。 */ + pathRewrite?: { from: string, to: string } } export async function installHandoffBundle( @@ -84,6 +90,18 @@ export async function installHandoffBundle( targets: HandoffInstallTargets, ): Promise { const claudeDir = path.join(extractedDir, 'claude') + const rewrite = targets.pathRewrite + // 发送方 worktree 在接收方上不存在:装文件前先把 cwd 类路径全部改写, + // 否则 `codex resume` / MCP 起进程时照着旧路径找目录会直接 ENOENT。 + if (rewrite && rewrite.from !== rewrite.to) { + for (const { id } of manifest.claude) { + await rewritePathInFile(path.join(claudeDir, `${id}.jsonl`), rewrite.from, rewrite.to) + await rewritePathInTree(path.join(claudeDir, id), rewrite.from, rewrite.to) + } + for (const { relPath } of manifest.codex) + await rewritePathInFile(path.join(extractedDir, 'codex', relPath), rewrite.from, rewrite.to) + } + for (const { id, kind } of manifest.claude) { const dstProjectsDir = kind === 'brainstorm' ? targets.workspaceProjectsDir diff --git a/vscode/src/cc/handoffManifest.test.ts b/vscode/src/cc/handoffManifest.test.ts index 73d4b8a..303f69f 100644 --- a/vscode/src/cc/handoffManifest.test.ts +++ b/vscode/src/cc/handoffManifest.test.ts @@ -43,4 +43,11 @@ describe('parseHandoffManifest', () => { it('不是 JSON → 抛错', () => { expect(() => parseHandoffManifest('nope', 42)).toThrow() }) + it('带 worktreePath → 原样带出', () => { + const withPath = { ...good, worktreePath: '/Users/chw/wt/x' } + expect(parseHandoffManifest(JSON.stringify(withPath), 42)).toEqual(withPath) + }) + it('不带 worktreePath → 结果里没有这个 key', () => { + expect(parseHandoffManifest(JSON.stringify(good), 42)).not.toHaveProperty('worktreePath') + }) }) diff --git a/vscode/src/cc/handoffManifest.ts b/vscode/src/cc/handoffManifest.ts index c8058d9..3399646 100644 --- a/vscode/src/cc/handoffManifest.ts +++ b/vscode/src/cc/handoffManifest.ts @@ -25,6 +25,7 @@ export interface HandoffManifest { from: string createdAt: string branch?: string + worktreePath?: string sessions: HandoffSessions profiles: HandoffProfiles claude: Array<{ id: string, kind: ClaudeSessionKind }> @@ -96,6 +97,7 @@ export function parseHandoffManifest(json: string, expectedIssue: number): Hando from, createdAt, ...(optString(raw.branch) ? { branch: optString(raw.branch) } : {}), + ...(optString(raw.worktreePath) ? { worktreePath: optString(raw.worktreePath) } : {}), sessions, profiles, claude, diff --git a/vscode/src/cc/sessionBundle.test.ts b/vscode/src/cc/sessionBundle.test.ts index 6dd5c53..9bd1d86 100644 --- a/vscode/src/cc/sessionBundle.test.ts +++ b/vscode/src/cc/sessionBundle.test.ts @@ -8,6 +8,8 @@ import { installClaudeSession, installCodexSession, listClaudeSessionCopies, + rewritePathInFile, + rewritePathInTree, } from './sessionBundle' const tempDirs: string[] = [] @@ -123,3 +125,41 @@ describe('codex', () => { expect(readFileSync(path.join(codexRoot, rel), 'utf8')).toBe('new\n') }) }) + +describe('rewritePathInFile / rewritePathInTree', () => { + it('文件里出现 3 次 → 返回 3 且内容被替换', async () => { + const dir = tempDir('rewrite-') + const file = path.join(dir, 'a.jsonl') + writeFileSync(file, '{"cwd":"/Users/chw/wt/x"}\n{"cwd":"file:///Users/chw/wt/x"}\n/Users/chw/wt/x\n') + const count = await rewritePathInFile(file, '/Users/chw/wt/x', '/home/me/wt/x') + expect(count).toBe(3) + const content = readFileSync(file, 'utf8') + expect(content).not.toContain('/Users/chw/wt/x') + expect(content).toBe('{"cwd":"/home/me/wt/x"}\n{"cwd":"file:///home/me/wt/x"}\n/home/me/wt/x\n') + }) + + it('文件里没出现 → 返回 0 且内容不变', async () => { + const dir = tempDir('rewrite-') + const file = path.join(dir, 'a.jsonl') + writeFileSync(file, '{"cwd":"/other/path"}\n') + const count = await rewritePathInFile(file, '/Users/chw/wt/x', '/home/me/wt/x') + expect(count).toBe(0) + expect(readFileSync(file, 'utf8')).toBe('{"cwd":"/other/path"}\n') + }) + + it('递归重写目录下所有文件,返回总替换次数', async () => { + const dir = tempDir('rewrite-tree-') + writeFileSync(path.join(dir, 'a.jsonl'), '/Users/chw/wt/x\n') + mkdirSync(path.join(dir, 'sub', 'subagents'), { recursive: true }) + writeFileSync(path.join(dir, 'sub', 'subagents', 'agent-1.jsonl'), '/Users/chw/wt/x /Users/chw/wt/x\n') + writeFileSync(path.join(dir, 'sub', 'unrelated.txt'), 'no match here\n') + const total = await rewritePathInTree(dir, '/Users/chw/wt/x', '/home/me/wt/x') + expect(total).toBe(3) + expect(readFileSync(path.join(dir, 'a.jsonl'), 'utf8')).toBe('/home/me/wt/x\n') + expect(readFileSync(path.join(dir, 'sub', 'subagents', 'agent-1.jsonl'), 'utf8')).toBe('/home/me/wt/x /home/me/wt/x\n') + }) + + it('目录不存在 → 返回 0', async () => { + expect(await rewritePathInTree('/nonexistent/dir', '/a', '/b')).toBe(0) + }) +}) diff --git a/vscode/src/cc/sessionBundle.ts b/vscode/src/cc/sessionBundle.ts index b32d227..6f0f16d 100644 --- a/vscode/src/cc/sessionBundle.ts +++ b/vscode/src/cc/sessionBundle.ts @@ -120,3 +120,35 @@ export async function installCodexSession(relPath: string, srcFile: string, code await fsp.mkdir(path.dirname(dst), { recursive: true }) await fsp.copyFile(srcFile, dst) } + +/** + * 迁移会话里发送方的 worktree 路径重写成接收方的路径。 + * split/join 而非正则替换——路径里的 `.` `(` 等字符不用转义,出现次数就是替换次数。 + */ +export async function rewritePathInFile(file: string, from: string, to: string): Promise { + const content = await fsp.readFile(file, 'utf8') + const parts = content.split(from) + const count = parts.length - 1 + if (count > 0) + await fsp.writeFile(file, parts.join(to)) + return count +} + +export async function rewritePathInTree(dir: string, from: string, to: string): Promise { + let entries: Array<{ name: string, isDirectory: () => boolean, isFile: () => boolean }> + try { + entries = await fsp.readdir(dir, { withFileTypes: true }) + } + catch { + return 0 + } + let total = 0 + for (const entry of entries) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) + total += await rewritePathInTree(full, from, to) + else if (entry.isFile()) + total += await rewritePathInFile(full, from, to) + } + return total +} diff --git a/vscode/src/panel/handlers/handoffFlow.ts b/vscode/src/panel/handlers/handoffFlow.ts index a8889a2..3b3bb5b 100644 --- a/vscode/src/panel/handlers/handoffFlow.ts +++ b/vscode/src/panel/handlers/handoffFlow.ts @@ -171,11 +171,7 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: const column = knownColumns.includes(state.column as IssueColumn) ? (state.column as IssueColumn) : 'todo' const assignees = (issue.assignees ?? []).map(a => a.login) if (!canStartHandoff({ source: 'gitea', column, handoffAttachmentId: str(state.handoffAttachmentId), assignees }, me)) { - // 区分「不是我的工单」和「已在移交中/已完成」两种拒绝原因,措辞不同。 - const notMine = assignees.length > 0 && !(me !== undefined && assignees.includes(me)) - toast(panel, 'error', notMine - ? `#${issueNumber} 不是你负责的工单,不能移交` - : `#${issueNumber} 已在移交中或已完成,不能再次移交`) + toast(panel, 'error', `#${issueNumber} 不是你负责的工单,或已在移交中 / 已完成,不能移交`) return } @@ -266,7 +262,7 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: const outDir = await scratchDir('spx-handoff-out-') try { manifest = await stageHandoffBundle( - { issueNumber, from: me, branch, sessions, profiles }, + { issueNumber, from: me, branch, ...(worktreeAbs && fs.existsSync(worktreeAbs) ? { worktreePath: worktreeAbs } : {}), sessions, profiles }, staging, { projectsRoot: claudeProjectsRoot(), codexRoot: defaultCodexSessionsDir() }, ) @@ -453,19 +449,24 @@ export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber }) } - // ③ 装会话文件 + // ③ 装会话文件;发送方与接收方的 worktree 路径不同就顺带把会话里的 + // cwd 类路径重写成接收方路径,否则 `codex resume` / MCP 起进程会照着 + // 发送方那条本机不存在的路径找目录,直接 ENOENT。 + const pathRewrite = manifest.worktreePath && worktreeAbs ? { from: manifest.worktreePath, to: worktreeAbs } : undefined await installHandoffBundle(extracted, manifest, { projectsRoot: claudeProjectsRoot(), codexRoot: defaultCodexSessionsDir(), workspaceProjectsDir: projectsDirFor(workspaceRoot), ...(worktreeAbs ? { worktreeProjectsDir: projectsDirFor(worktreeAbs) } : {}), + ...(pathRewrite ? { pathRewrite } : {}), }) // ④ 本机字段 + 清共享字段;⑤ 删附件(失败只 warn) // reviewSessionFileExists 是计算字段,board 只在启动时算一次(annotateReviewSessionFileExists); // 接管刚把 codex 会话文件装进本机,这里顺手算好一起下发,UI 不用等下次刷新才能点「审查会话」链接。 - const reviewSessionFileExists = !!manifest.sessions.reviewSessionId - && manifest.codex.some(c => c.id.toLowerCase() === manifest.sessions.reviewSessionId!.toLowerCase()) + const reviewSessionId = manifest.sessions.reviewSessionId + const reviewSessionFileExists = !!reviewSessionId + && manifest.codex.some(c => c.id.toLowerCase() === reviewSessionId.toLowerCase()) const local = { sessions: manifest.sessions, profiles: manifest.profiles, worktreePath: worktreeAbs } await panel.mergeIssueState(issueNumber, handoffAcceptedStateExtra(local)) try { @@ -482,7 +483,7 @@ export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber patch: handoffAcceptedUiPatch({ ...local, worktreeExists: !!worktreeAbs, reviewSessionFileExists }), }) toast(panel, 'success', `已接管 #${issueNumber}(来自 ${manifest.from})${worktreeAbs ? `,worktree:${worktreeAbs}` : ''}`) - logger.add({ level: 'info', source: 'panel', message: `接管 #${issueNumber} 完成`, details: JSON.stringify({ manifest, worktreeAbs }) }) + logger.add({ level: 'info', source: 'panel', message: `接管 #${issueNumber} 完成`, details: JSON.stringify({ manifest, worktreeAbs, pathRewrite }) }) } catch (err) { const message = err instanceof Error ? err.message : String(err)