From 162134dcc01d30d4286066ba7a9a3270378fa2b0 Mon Sep 17 00:00:00 2001 From: cruldra Date: Sun, 27 Sep 2026 18:36:01 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20=E7=A7=BB=E4=BA=A4?= =?UTF-8?q?=E6=97=B6=E6=98=BE=E7=A4=BA=E6=AD=A5=E9=AA=A4=EF=BC=8C=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E5=8C=85=E8=B6=85=E9=99=90=E5=85=88=E5=81=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/specs/issue-handoff/spec.md | 19 ++-- vscode/src/cc/handoffArchive.ts | 103 ++++++++++++++++- vscode/src/cc/handoffBundle.ts | 33 +++++- vscode/src/cc/handoffSize.test.ts | 112 +++++++++++++++++++ vscode/src/cc/handoffSize.ts | 111 ++++++++++++++++++ vscode/src/panel/handlers/handoffFlow.ts | 66 ++++++++--- 6 files changed, 412 insertions(+), 32 deletions(-) create mode 100644 vscode/src/cc/handoffSize.test.ts create mode 100644 vscode/src/cc/handoffSize.ts diff --git a/docs/superpowers/specs/issue-handoff/spec.md b/docs/superpowers/specs/issue-handoff/spec.md index 93ebfc9..504b455 100644 --- a/docs/superpowers/specs/issue-handoff/spec.md +++ b/docs/superpowers/specs/issue-handoff/spec.md @@ -79,15 +79,18 @@ codex/YYYY/MM/DD/rollout-*.jsonl 审查会话(存在才有) 1. 前置:workspaceRoot / `detectRepo` / token / `loginForToken` 得 `me`。读 `readIssueState`,取本机字段(四个 sid、三个 profile、`worktreePath`)与共享 `branch`。 2. **worktree 守卫**(有 `worktreePath` 且目录存在时):`git status --porcelain` 必须为空;`git fetch origin ` 后 `HEAD` 必须等于 `origin/`。否则 toast「有未提交/未推送改动」并终止,什么都不改。 -3. 关闭该工单全部终端(`terminalOrigin` 同 issueNumber + 名称匹配 `issue-N-冲突解决`),等 600ms 让 jsonl 刷盘。 -4. 在 scratch 临时目录收集会话包:每个 sid 在 `~/.claude/projects/*/` 里找 `.jsonl`(多份取最新 mtime),连同 `/` 一起拷入 `claude/`;`reviewSessionId` 在 `~/.codex/sessions` 里按文件名尾部 uuid 找到 rollout 拷入 `codex/`。找不到的会话不进清单,只 log。写 `handoff.json`,`tar -czf`。 -5. 若工单已有同名旧附件(重复移交)先删。`POST …/assets?name=spx-handoff-issue-N.tgz` 上传。 -6. `mergeIssueState(issueNumber, { handoffAttachmentId, handoffFrom: me })`。 -7. `updateIssueAssignees(… [to])`。 -8. 本机清理(失败只 warn,不回滚——远端移交已生效):`pre-remove` 钩子 → `killProcessesUsingWorktree` → `removeWorktreeDir`;`cleanupFeatureBranch` **不传 remote/token**(只删本地分支,远端分支必须保留给接管方);本机字段全部写 `''` 墓碑。发送方磁盘上的会话文件保留。 -9. `issue/patch { handoffAttachmentId, handoffFrom, worktreePath: null, worktreeExists: false, …sid: null }` + 成功 toast。 +3. **会话大小**(关终端之前):统计将打进包的 jsonl、会话目录和 codex rollout。原始总字节不超过 Gitea 附件上限 100MB 则通过。超过则先压缩,压缩产出一旦超过 100MB 就停,toast 写明哪个会话多大,不关终端、不上传、不改指派。正式打包再卡一次同一上限;那次失败发生在关终端之后,按打包失败处理。 +4. 关闭该工单全部终端(`terminalOrigin` 同 issueNumber + 名称匹配 `issue-N-冲突解决`),等 600ms 让 jsonl 刷盘。 +5. 在 scratch 临时目录收集会话包:同一卷上用硬链接收文件,跨卷才复制。每个 sid 在 `~/.claude/projects/*/` 里找 `.jsonl`(多份取最新 mtime),连同 `/` 一起拷入 `claude/`;`reviewSessionId` 在 `~/.codex/sessions` 里按文件名尾部 uuid 找到 rollout 拷入 `codex/`。找不到的会话不进清单,只 log。写 `handoff.json`,`tar -czf`。 +6. 若工单已有同名旧附件(重复移交)先删。`POST …/assets?name=spx-handoff-issue-N.tgz` 上传。 +7. `mergeIssueState(issueNumber, { handoffAttachmentId, handoffFrom: me })`。 +8. `updateIssueAssignees(… [to])`。 +9. 本机清理(失败只 warn,不回滚——远端移交已生效):`pre-remove` 钩子 → `killProcessesUsingWorktree` → `removeWorktreeDir`;`cleanupFeatureBranch` **不传 remote/token**(只删本地分支,远端分支必须保留给接管方);本机字段全部写 `''` 墓碑。发送方磁盘上的会话文件保留。 +10. `issue/patch { handoffAttachmentId, handoffFrom, worktreePath: null, worktreeExists: false, …sid: null }` + 成功 toast。 -回滚:5 失败 → toast 终止(终端已关,无其他改动);6 失败 → 删附件;7 失败 → 清回 6 的两个字段、删附件。全程 spinner toast,结束 `toast/dismiss`。 +进度 toast 从核对分支开始,同一条提示随步骤改文案(核对分支、检查会话大小、压缩检查、关闭终端、收集会话、打包、上传、写入状态、指派、清理本机)。移交结束前不自动消失。 + +回滚:大小检查失败 → 什么都不改;打包/上传失败 → toast 终止(终端已关,无其他改动);写状态失败 → 删附件;指派失败 → 清回两个共享字段、删附件。结束 `toast/dismiss`。 ## 接管方:接管 diff --git a/vscode/src/cc/handoffArchive.ts b/vscode/src/cc/handoffArchive.ts index c1cb29b..66b6857 100644 --- a/vscode/src/cc/handoffArchive.ts +++ b/vscode/src/cc/handoffArchive.ts @@ -1,8 +1,37 @@ -import { execFile } from 'node:child_process' -import { promises as fsp } from 'node:fs' +import { execFile, spawn } from 'node:child_process' +import { promises as fsp, statSync } from 'node:fs' import * as path from 'node:path' -function tar(args: string[]): Promise { +/** Gitea 工单附件上限。压缩后的 tgz 超过这个值,服务器会拒收。 */ +export const HANDOFF_ATTACHMENT_MAX_BYTES = 100 * 1024 * 1024 + +export function formatByteSize(bytes: number): string { + const mb = bytes / (1024 * 1024) + if (mb >= 10) + return `${Math.round(mb)}MB` + if (mb >= 1) + return `${mb.toFixed(1)}MB` + const kb = bytes / 1024 + if (kb >= 10) + return `${Math.round(kb)}KB` + if (kb >= 1) + return `${kb.toFixed(1)}KB` + return `${bytes}B` +} + +export class HandoffArchiveTooLargeError extends Error { + readonly packedBytes: number + readonly limitBytes: number + + constructor(packedBytes: number, limitBytes: number) { + super(`压缩后超过 Gitea 附件上限 ${formatByteSize(limitBytes)}`) + this.name = 'HandoffArchiveTooLargeError' + this.packedBytes = packedBytes + this.limitBytes = limitBytes + } +} + +function tarUnpack(args: string[]): Promise { return new Promise((resolve, reject) => { execFile('tar', args, { timeout: 120_000 }, (err, _stdout, stderr) => { if (err) { @@ -14,12 +43,74 @@ function tar(args: string[]): Promise { }) } -export async function packHandoff(stagingDir: string, outFile: string): Promise { +export async function packHandoff( + stagingDir: string, + outFile: string, + opts?: { maxBytes?: number, onProgress?: (packedBytes: number) => void }, +): Promise { + const maxBytes = opts?.maxBytes ?? HANDOFF_ATTACHMENT_MAX_BYTES await fsp.mkdir(path.dirname(outFile), { recursive: true }) - await tar(['-czf', outFile, '-C', stagingDir, '.']) + await new Promise((resolve, reject) => { + const child = spawn('tar', ['-czf', outFile, '-C', stagingDir, '.'], { stdio: ['ignore', 'ignore', 'pipe'] }) + const decode = new TextDecoder() + let stderr = '' + let settled = false + let poll: ReturnType | undefined + child.stderr?.on('data', (chunk: Uint8Array) => { + stderr += decode.decode(chunk, { stream: true }) + }) + const finish = (err?: Error): void => { + if (settled) + return + settled = true + if (poll) + clearInterval(poll) + if (err) + reject(err) + else + resolve() + } + const checkSize = (): void => { + let size = 0 + try { + size = statSync(outFile).size + } + catch { + return + } + opts?.onProgress?.(size) + // 产出一超过上限就杀掉。等 tar 自己打完再失败,大会话会卡很久。 + if (size > maxBytes) { + child.kill('SIGKILL') + finish(new HandoffArchiveTooLargeError(size, maxBytes)) + } + } + poll = setInterval(checkSize, 200) + child.on('error', err => finish(err)) + child.on('close', (code) => { + if (settled) + return + let size = 0 + try { + size = statSync(outFile).size + } + catch { + // 包还没写出来 + } + opts?.onProgress?.(size) + if (size > maxBytes) { + finish(new HandoffArchiveTooLargeError(size, maxBytes)) + return + } + if (code === 0) + finish() + else + finish(new Error(`tar -czf 失败: ${stderr.trim() || `exit ${code}`}`)) + }) + }) } export async function unpackHandoff(archive: string, dstDir: string): Promise { await fsp.mkdir(dstDir, { recursive: true }) - await tar(['-xzf', archive, '-C', dstDir]) + await tarUnpack(['-xzf', archive, '-C', dstDir]) } diff --git a/vscode/src/cc/handoffBundle.ts b/vscode/src/cc/handoffBundle.ts index b11f986..e1b9d5b 100644 --- a/vscode/src/cc/handoffBundle.ts +++ b/vscode/src/cc/handoffBundle.ts @@ -4,7 +4,6 @@ import * as path from 'node:path' import { CLAUDE_SESSION_FIELDS, HANDOFF_MANIFEST_FILE } from './handoffManifest' import { codexRolloutRelPathFor, - copyClaudeSessionFiles, findClaudeSessionFiles, installClaudeSession, installCodexSession, @@ -27,6 +26,31 @@ export interface HandoffRoots { } /** 把本机会话文件收进 stagingDir(claude/、codex/、handoff.json),返回清单。找不到的会话只从文件列表缺席,id 仍记录。 */ +async function placeFile(src: string, dst: string): Promise { + await fsp.mkdir(path.dirname(dst), { recursive: true }) + try { + await fsp.link(src, dst) + } + catch (err) { + const code = (err as NodeJS.ErrnoException).code + // 跨卷硬链接会失败,这时才真正复制。 + if (code !== 'EXDEV' && code !== 'EPERM' && code !== 'ENOTSUP') + throw err + await fsp.copyFile(src, dst) + } +} + +async function placeTree(src: string, dst: string): Promise { + const st = await fsp.lstat(src) + if (st.isDirectory()) { + await fsp.mkdir(dst, { recursive: true }) + for (const name of await fsp.readdir(src)) + await placeTree(path.join(src, name), path.join(dst, name)) + return + } + await placeFile(src, dst) +} + export async function stageHandoffBundle( src: HandoffSource, stagingDir: string, @@ -46,7 +70,9 @@ export async function stageHandoffBundle( const files = await findClaudeSessionFiles(id, roots.projectsRoot) if (!files) continue - await copyClaudeSessionFiles(files, claudeDir) + await placeFile(files.jsonl, path.join(claudeDir, path.basename(files.jsonl))) + if (files.dir) + await placeTree(files.dir, path.join(claudeDir, path.basename(files.dir))) claude.push({ id, kind }) } @@ -55,8 +81,7 @@ export async function stageHandoffBundle( const relPath = await codexRolloutRelPathFor(src.sessions.reviewSessionId, roots.codexRoot) if (relPath) { const dst = path.join(codexDir, relPath) - await fsp.mkdir(path.dirname(dst), { recursive: true }) - await fsp.copyFile(path.join(roots.codexRoot, relPath), dst) + await placeFile(path.join(roots.codexRoot, relPath), dst) codex.push({ id: src.sessions.reviewSessionId, relPath }) } } diff --git a/vscode/src/cc/handoffSize.test.ts b/vscode/src/cc/handoffSize.test.ts new file mode 100644 index 0000000..1f1ac88 --- /dev/null +++ b/vscode/src/cc/handoffSize.test.ts @@ -0,0 +1,112 @@ +import { Buffer } from 'node:buffer' +import { randomBytes } from 'node:crypto' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { formatByteSize, HANDOFF_ATTACHMENT_MAX_BYTES, HandoffArchiveTooLargeError, packHandoff } from './handoffArchive' +import { ensureHandoffFits, HandoffSourcesTooLargeError, measureHandoffSources } from './handoffSize' + +const tempDirs: string[] = [] +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop() + if (dir) + rmSync(dir, { recursive: true, force: true }) + } +}) + +function tempDir(prefix: string): string { + const dir = mkdtempSync(path.join(os.tmpdir(), prefix)) + tempDirs.push(dir) + return dir +} + +const BRAIN = 'aaaaaaaa-0000-4000-8000-000000000001' +const IMPL = 'bbbbbbbb-0000-4000-8000-000000000002' +const REVIEW = '019dcb27-58a6-70a1-a5d1-bfc7f3ed9d0a' + +function fixture(implBytes: Buffer): { projectsRoot: string, codexRoot: string } { + const projectsRoot = tempDir('handoff-size-projects-') + const codexRoot = tempDir('handoff-size-codex-') + const proj = path.join(projectsRoot, '-Users-chw-proj') + const wt = path.join(projectsRoot, '-Users-chw-wt-x') + mkdirSync(proj, { recursive: true }) + mkdirSync(path.join(wt, IMPL, 'subagents'), { recursive: true }) + writeFileSync(path.join(proj, `${BRAIN}.jsonl`), 'brain\n') + writeFileSync(path.join(wt, `${IMPL}.jsonl`), implBytes) + writeFileSync(path.join(wt, IMPL, 'subagents', 'agent-1.jsonl'), '{}\n') + const day = path.join(codexRoot, '2026', '08', '26') + mkdirSync(day, { recursive: true }) + writeFileSync(path.join(day, `rollout-2026-08-26T01-02-03-${REVIEW}.jsonl`), 'review\n') + return { projectsRoot, codexRoot } +} + +function source(roots: { projectsRoot: string, codexRoot: string }) { + return { + src: { + issueNumber: 448, + from: 'chw', + sessions: { + sessionId: BRAIN, + implementSessionId: IMPL, + testSessionId: 'missing-0000-4000-8000-000000000000', + reviewSessionId: REVIEW, + }, + profiles: {}, + }, + roots, + } +} + +describe('formatByteSize', () => { + it('100MB 上限按整数 MB 显示', () => { + expect(formatByteSize(HANDOFF_ATTACHMENT_MAX_BYTES)).toBe('100MB') + }) +}) + +describe('measureHandoffSources', () => { + it('jsonl、会话目录、codex 加在一起,缺失的会话不出现', async () => { + const roots = fixture(Buffer.from('impl\n')) + const { src } = source(roots) + const items = await measureHandoffSources(src, roots) + expect(items).toEqual([ + { role: '头脑风暴', bytes: Buffer.byteLength('brain\n') }, + { role: '实施', bytes: Buffer.byteLength('impl\n') + Buffer.byteLength('{}\n') }, + { role: '审查', bytes: Buffer.byteLength('review\n') }, + ]) + }) +}) + +describe('ensureHandoffFits', () => { + it('原始大小不超过上限时直接通过', async () => { + const roots = fixture(Buffer.from('impl\n')) + const { src } = source(roots) + const items = await ensureHandoffFits(src, roots, { maxBytes: 1024 * 1024 }) + expect(items.map(item => item.role)).toEqual(['头脑风暴', '实施', '审查']) + }) + + it('压缩后超过上限就停,并说明还没开始移交', async () => { + const roots = fixture(randomBytes(80 * 1024)) + const { src } = source(roots) + await expect(ensureHandoffFits(src, roots, { maxBytes: 1024 })).rejects.toBeInstanceOf(HandoffSourcesTooLargeError) + try { + await ensureHandoffFits(src, roots, { maxBytes: 1024 }) + } + catch (err) { + expect(err).toBeInstanceOf(HandoffSourcesTooLargeError) + expect((err as Error).message).toContain('没有开始移交') + expect((err as Error).message).toContain('实施') + expect((err as Error).message).toContain('1.0KB') + } + }) +}) + +describe('packHandoff', () => { + it('产出超过 maxBytes 就失败,不等到打完', async () => { + const staging = tempDir('handoff-pack-') + writeFileSync(path.join(staging, 'big.bin'), randomBytes(80 * 1024)) + const out = path.join(tempDir('handoff-pack-out-'), 'bundle.tgz') + await expect(packHandoff(staging, out, { maxBytes: 1024 })).rejects.toBeInstanceOf(HandoffArchiveTooLargeError) + }) +}) diff --git a/vscode/src/cc/handoffSize.ts b/vscode/src/cc/handoffSize.ts new file mode 100644 index 0000000..ae20dea --- /dev/null +++ b/vscode/src/cc/handoffSize.ts @@ -0,0 +1,111 @@ +import type { HandoffRoots, HandoffSource } from './handoffBundle' +import { promises as fsp } from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { formatByteSize, HANDOFF_ATTACHMENT_MAX_BYTES, HandoffArchiveTooLargeError, packHandoff } from './handoffArchive' +import { stageHandoffBundle } from './handoffBundle' +import { CLAUDE_SESSION_FIELDS } from './handoffManifest' +import { codexRolloutRelPathFor, findClaudeSessionFiles } from './sessionBundle' + +const ROLE_LABEL = { + brainstorm: '头脑风暴', + implement: '实施', + test: '测试', + prototype: '原型', + review: '审查', +} as const + +export interface HandoffSourceSize { + role: string + bytes: number +} + +export class HandoffSourcesTooLargeError extends Error { + readonly items: HandoffSourceSize[] + readonly limitBytes: number + + constructor(items: HandoffSourceSize[], limitBytes: number) { + super(formatHandoffTooLarge(items, limitBytes)) + this.name = 'HandoffSourcesTooLargeError' + this.items = items + this.limitBytes = limitBytes + } +} + +export function formatHandoffTooLarge(items: HandoffSourceSize[], limitBytes: number): string { + const parts = items.filter(item => item.bytes > 0).map(item => `${item.role} ${formatByteSize(item.bytes)}`) + const total = items.reduce((sum, item) => sum + item.bytes, 0) + return `会话太大,没有开始移交:${parts.join('、')},合计 ${formatByteSize(total)}。压缩后超过 Gitea 附件上限 ${formatByteSize(limitBytes)}` +} + +async function treeBytes(abs: string): Promise { + const st = await fsp.lstat(abs) + if (st.isSymbolicLink()) + return 0 + if (!st.isDirectory()) + return st.size + let total = 0 + for (const name of await fsp.readdir(abs)) + total += await treeBytes(path.join(abs, name)) + return total +} + +/** 统计即将打进会话包的文件。缺失的会话不出现。 */ +export async function measureHandoffSources(src: HandoffSource, roots: HandoffRoots): Promise { + const items: HandoffSourceSize[] = [] + for (const { field, kind } of CLAUDE_SESSION_FIELDS) { + const id = src.sessions[field] + if (!id) + continue + const files = await findClaudeSessionFiles(id, roots.projectsRoot) + if (!files) + continue + let bytes = await treeBytes(files.jsonl) + if (files.dir) + bytes += await treeBytes(files.dir) + items.push({ role: ROLE_LABEL[kind], bytes }) + } + if (src.sessions.reviewSessionId) { + const relPath = await codexRolloutRelPathFor(src.sessions.reviewSessionId, roots.codexRoot) + if (relPath) { + items.push({ + role: ROLE_LABEL.review, + bytes: await treeBytes(path.join(roots.codexRoot, relPath)), + }) + } + } + return items +} + +/** + * 关终端、上传之前调用。 + * 原始大小不超过上限时直接过(压缩只会更小,最多多出可忽略的包头)。 + * 超过才真正压缩,产出一过上限就停。 + */ +export async function ensureHandoffFits( + src: HandoffSource, + roots: HandoffRoots, + opts?: { maxBytes?: number, onProgress?: (packedBytes: number) => void }, +): Promise { + const maxBytes = opts?.maxBytes ?? HANDOFF_ATTACHMENT_MAX_BYTES + const items = await measureHandoffSources(src, roots) + const total = items.reduce((sum, item) => sum + item.bytes, 0) + if (total <= maxBytes) + return items + const staging = await fsp.mkdtemp(path.join(os.tmpdir(), 'spx-handoff-size-')) + const outDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'spx-handoff-size-out-')) + try { + await stageHandoffBundle(src, staging, roots) + await packHandoff(staging, path.join(outDir, 'probe.tgz'), { maxBytes, onProgress: opts?.onProgress }) + return items + } + catch (err) { + if (err instanceof HandoffArchiveTooLargeError) + throw new HandoffSourcesTooLargeError(items, maxBytes) + throw err + } + finally { + await fsp.rm(staging, { recursive: true, force: true }).catch(() => {}) + await fsp.rm(outDir, { recursive: true, force: true }).catch(() => {}) + } +} diff --git a/vscode/src/panel/handlers/handoffFlow.ts b/vscode/src/panel/handlers/handoffFlow.ts index b5c2ade..98a15f6 100644 --- a/vscode/src/panel/handlers/handoffFlow.ts +++ b/vscode/src/panel/handlers/handoffFlow.ts @@ -9,9 +9,10 @@ import * as path from 'node:path' import { window, workspace } from 'vscode' import { loginForToken } from '../../auth/identity' import { getToken } from '../../auth/secrets' -import { packHandoff, unpackHandoff } from '../../cc/handoffArchive' +import { formatByteSize, HandoffArchiveTooLargeError, packHandoff, unpackHandoff } from '../../cc/handoffArchive' import { installHandoffBundle, stageHandoffBundle } from '../../cc/handoffBundle' import { HANDOFF_MANIFEST_FILE, handoffAttachmentName, parseHandoffManifest } from '../../cc/handoffManifest' +import { ensureHandoffFits, HandoffSourcesTooLargeError } from '../../cc/handoffSize' import { claudeProjectsRoot } from '../../cc/sessionBundle' import { projectsDirFor } from '../../cc/sessionWatcher' import { detectRepo } from '../../git/remote' @@ -191,27 +192,34 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: ...(str(state.prototypeProfilePath) ? { prototypeProfilePath: str(state.prototypeProfilePath) } : {}), } + const say = (step: string): string => toast(panel, 'info', `正在移交 #${issueNumber} 给 ${to}:${step}`, { id: spinner, spinner: true, dismissOnTimer: 0 }) + const fail = (message: string): void => { + if (spinner) + dismiss(panel, spinner) + toast(panel, 'error', message) + } // ① worktree 守卫:干净 + 与远端一致。worktree 存在但 state 没有 branch 时 // 没法做 push 校验——不能放行,否则 ⑥ 清理会在没校验过的情况下把它删掉。 // 没有 worktree 但本地还留着同名分支时,⑥ 会直接 `git branch -D` 掉它—— // 分支若领先远端(未 push)就会丢工作,必须在这里单独拦一次。 + spinner = say('核对分支是否已推送') const worktreeAbs = worktreePath ? resolveWorktreePath(worktreePath, workspaceRoot) : undefined if (worktreeAbs && fs.existsSync(worktreeAbs)) { if (!branch) { - toast(panel, 'error', `无法移交 #${issueNumber}:state 缺少 branch,无法校验是否已 push`) + fail(`无法移交 #${issueNumber}:state 缺少 branch,无法校验是否已 push`) return } const status = await git(worktreeAbs, ['status', '--porcelain']) const fetched = await git(worktreeAbs, ['fetch', 'origin', branch]) if (!status.ok || !fetched.ok) { - toast(panel, 'error', `检查 worktree 失败:${(status.stderr || fetched.stderr).trim()}`) + fail(`检查 worktree 失败:${(status.stderr || fetched.stderr).trim()}`) return } const localHead = await git(worktreeAbs, ['rev-parse', 'HEAD']) const remoteHead = await git(worktreeAbs, ['rev-parse', `origin/${branch}`]) const check = checkWorktreePushed({ statusPorcelain: status.stdout, localHead: localHead.stdout, remoteHead: remoteHead.stdout }) if (!check.ok) { - toast(panel, 'error', `无法移交 #${issueNumber}:${check.reason}`) + fail(`无法移交 #${issueNumber}:${check.reason}`) return } } @@ -223,15 +231,39 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: const localHead = await git(workspaceRoot, ['rev-parse', `refs/heads/${branch}`]) const remoteHead = await git(workspaceRoot, ['rev-parse', `refs/remotes/origin/${branch}`]) if (!fetched.ok || localHead.stdout.trim() !== remoteHead.stdout.trim()) { - toast(panel, 'error', `无法移交 #${issueNumber}:本地分支 ${branch} 与远端不一致,请先 push`) + fail(`无法移交 #${issueNumber}:本地分支 ${branch} 与远端不一致,请先 push`) return } } } - spinner = toast(panel, 'info', `正在移交 #${issueNumber} 给 ${to}…`, { spinner: true, dismissOnTimer: 120_000 }) + // 关终端之前查大小。太大就停,避免白关终端再打包很久。 + const handoffSrc = { issueNumber, from: me, branch, ...(worktreeAbs && fs.existsSync(worktreeAbs) ? { worktreePath: worktreeAbs } : {}), sessions, profiles } + const handoffRoots = { projectsRoot: claudeProjectsRoot(), codexRoot: defaultCodexSessionsDir() } + let packedMbShown = -1 + const reportPacked = (label: string, withLimit: boolean) => (packedBytes: number): void => { + const mb = Math.floor(packedBytes / (1024 * 1024)) + if (mb === packedMbShown) + return + packedMbShown = mb + say(withLimit ? `${label} ${mb}MB / 100MB` : `${label} ${mb}MB`) + } + say('检查会话大小') + try { + await ensureHandoffFits(handoffSrc, handoffRoots, { onProgress: reportPacked('压缩检查', true) }) + } + catch (err) { + if (err instanceof HandoffSourcesTooLargeError) { + fail(`#${issueNumber} ${err.message}`) + return + } + const message = err instanceof Error ? err.message : String(err) + fail(`移交失败(检查会话大小):${message}`) + return + } // ② 关终端,等 jsonl 刷盘 + say('关闭终端') let disposedAny = false for (const [terminal, origin] of panel.terminalOrigin) { if (origin.issueNumber === issueNumber) { @@ -257,20 +289,19 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: await new Promise(r => setTimeout(r, 600)) // ③ 打包 + 上传 + say('收集会话') let attachmentId: number let manifest: HandoffManifest // tgz 必须放在 staging 之外,否则 tar 会把半成品包也打进去。 const staging = await scratchDir('spx-handoff-') const outDir = await scratchDir('spx-handoff-out-') try { - manifest = await stageHandoffBundle( - { issueNumber, from: me, branch, ...(worktreeAbs && fs.existsSync(worktreeAbs) ? { worktreePath: worktreeAbs } : {}), sessions, profiles }, - staging, - { projectsRoot: claudeProjectsRoot(), codexRoot: defaultCodexSessionsDir() }, - ) + manifest = await stageHandoffBundle(handoffSrc, staging, handoffRoots) const name = handoffAttachmentName(issueNumber) const archive = path.join(outDir, name) - await packHandoff(staging, archive) + packedMbShown = -1 + await packHandoff(staging, archive, { onProgress: reportPacked('打包', false) }) + say(`上传 ${formatByteSize((await fsp.stat(archive)).size)}`) for (const old of await listIssueAttachments({ host, token, owner, repo, index: issueNumber })) { if (old.name === name) await deleteIssueAttachment({ host, token, owner, repo, index: issueNumber, attachmentId: old.id }) @@ -280,10 +311,13 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: logger.add({ level: 'info', source: 'panel', message: `移交 #${issueNumber}:会话包已上传 (attachment ${attachmentId}, ${uploaded.size} bytes)`, details: JSON.stringify(manifest) }) } catch (err) { - const message = err instanceof Error ? err.message : String(err) + const message = err instanceof HandoffArchiveTooLargeError + ? err.message + : (err instanceof Error ? err.message : String(err)) + const prefix = err instanceof HandoffArchiveTooLargeError ? '打包' : '打包/上传' logger.add({ level: 'error', source: 'panel', message: `移交 #${issueNumber}:打包/上传失败`, details: message }) dismiss(panel, spinner) - toast(panel, 'error', `移交失败(打包/上传):${message}`) + toast(panel, 'error', `移交失败(${prefix}):${message}`) return } finally { @@ -294,6 +328,7 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: // ④ 写共享字段(只 handoffAttachmentId / handoffFrom,本机墓碑留到 ⑦); // ⑤ 重新指派。失败回滚远端改动。 + say('写入状态') try { await panel.mergeIssueState(issueNumber, handoffStartedStateExtra(attachmentId, me)) } @@ -305,6 +340,7 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: toast(panel, 'error', `移交失败(写状态):${message}`) return } + say('指派') try { await updateIssueAssignees({ host, token, owner, repo, index: issueNumber, assignees: [to] }) } @@ -320,6 +356,8 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: // ⑥ 本机清理:worktree + 本地分支(远端分支留给接管方)。失败只告警—— // 远端移交已经生效,这里不回滚。 + if ((worktreeAbs && fs.existsSync(worktreeAbs)) || branch) + say('清理本机') let worktreeRemovalFailed = false if (worktreeAbs && fs.existsSync(worktreeAbs)) { const settings = getSettings(panel.context)