✨ feat(vscode): 移交时显示步骤,会话包超限先停
This commit is contained in:
1 parent
9220bad599
commit
162134dcc0
6 files changed
+412
-32
No files matched your search
@@ -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])
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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(() => {})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user