🐛 fix(vscode): 接管时把会话文件里的发送方路径重写为本机路径

Claude-Session: https://claude.ai/code/session_011cEyL6k351U2BzX1Qmygph
This commit is contained in:
2026-08-26 19:04:09 +08:00
parent 07c40897e2
commit 71a91fe7ec
7 changed files with 151 additions and 10 deletions
+41
View File
@@ -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)
}
})
})
+18
View File
@@ -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<void> {
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
+7
View File
@@ -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')
})
})
+2
View File
@@ -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,
+40
View File
@@ -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)
})
})
+32
View File
@@ -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<number> {
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<number> {
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
}
+11 -10
View File
@@ -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)