✨ feat(vscode): 移交时显示步骤,会话包超限先停

This commit is contained in:
cruldra committed 2026-09-27 18:36:01 +08:00
1 parent 9220bad599
commit 162134dcc0
6 files changed
+412 -32

No files matched your search

+97 -6
View File
@@ -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<void> {
/** 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<void> {
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<void> {
})
}
export async function packHandoff(stagingDir: string, outFile: string): Promise<void> {
export async function packHandoff(
stagingDir: string,
outFile: string,
opts?: { maxBytes?: number, onProgress?: (packedBytes: number) => void },
): Promise<void> {
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<void>((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<typeof setInterval> | 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<void> {
await fsp.mkdir(dstDir, { recursive: true })
await tar(['-xzf', archive, '-C', dstDir])
await tarUnpack(['-xzf', archive, '-C', dstDir])
}
+29 -4
View File
@@ -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<void> {
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<void> {
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 })
}
}
+112
View File
@@ -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)
})
})
+111
View File
@@ -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<number> {
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<HandoffSourceSize[]> {
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<HandoffSourceSize[]> {
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(() => {})
}
}
+52 -14
View File
@@ -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<void>(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)