✨ 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

+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)