✨ 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(() => {})
}
}