✨ feat(vscode): 移交时显示步骤,会话包超限先停
This commit is contained in:
1 parent
9220bad599
commit
162134dcc0
6 files changed
+412
-32
No files matched your search
@@ -79,15 +79,18 @@ codex/YYYY/MM/DD/rollout-*.jsonl 审查会话(存在才有)
|
|||||||
|
|
||||||
1. 前置:workspaceRoot / `detectRepo` / token / `loginForToken` 得 `me`。读 `readIssueState`,取本机字段(四个 sid、三个 profile、`worktreePath`)与共享 `branch`。
|
1. 前置:workspaceRoot / `detectRepo` / token / `loginForToken` 得 `me`。读 `readIssueState`,取本机字段(四个 sid、三个 profile、`worktreePath`)与共享 `branch`。
|
||||||
2. **worktree 守卫**(有 `worktreePath` 且目录存在时):`git status --porcelain` 必须为空;`git fetch origin <branch>` 后 `HEAD` 必须等于 `origin/<branch>`。否则 toast「有未提交/未推送改动」并终止,什么都不改。
|
2. **worktree 守卫**(有 `worktreePath` 且目录存在时):`git status --porcelain` 必须为空;`git fetch origin <branch>` 后 `HEAD` 必须等于 `origin/<branch>`。否则 toast「有未提交/未推送改动」并终止,什么都不改。
|
||||||
3. 关闭该工单全部终端(`terminalOrigin` 同 issueNumber + 名称匹配 `issue-N-冲突解决`),等 600ms 让 jsonl 刷盘。
|
3. **会话大小**(关终端之前):统计将打进包的 jsonl、会话目录和 codex rollout。原始总字节不超过 Gitea 附件上限 100MB 则通过。超过则先压缩,压缩产出一旦超过 100MB 就停,toast 写明哪个会话多大,不关终端、不上传、不改指派。正式打包再卡一次同一上限;那次失败发生在关终端之后,按打包失败处理。
|
||||||
4. 在 scratch 临时目录收集会话包:每个 sid 在 `~/.claude/projects/*/` 里找 `<sid>.jsonl`(多份取最新 mtime),连同 `<sid>/` 一起拷入 `claude/`;`reviewSessionId` 在 `~/.codex/sessions` 里按文件名尾部 uuid 找到 rollout 拷入 `codex/<relPath>`。找不到的会话不进清单,只 log。写 `handoff.json`,`tar -czf`。
|
4. 关闭该工单全部终端(`terminalOrigin` 同 issueNumber + 名称匹配 `issue-N-冲突解决`),等 600ms 让 jsonl 刷盘。
|
||||||
5. 若工单已有同名旧附件(重复移交)先删。`POST …/assets?name=spx-handoff-issue-N.tgz` 上传。
|
5. 在 scratch 临时目录收集会话包:同一卷上用硬链接收文件,跨卷才复制。每个 sid 在 `~/.claude/projects/*/` 里找 `<sid>.jsonl`(多份取最新 mtime),连同 `<sid>/` 一起拷入 `claude/`;`reviewSessionId` 在 `~/.codex/sessions` 里按文件名尾部 uuid 找到 rollout 拷入 `codex/<relPath>`。找不到的会话不进清单,只 log。写 `handoff.json`,`tar -czf`。
|
||||||
6. `mergeIssueState(issueNumber, { handoffAttachmentId, handoffFrom: me })`。
|
6. 若工单已有同名旧附件(重复移交)先删。`POST …/assets?name=spx-handoff-issue-N.tgz` 上传。
|
||||||
7. `updateIssueAssignees(… [to])`。
|
7. `mergeIssueState(issueNumber, { handoffAttachmentId, handoffFrom: me })`。
|
||||||
8. 本机清理(失败只 warn,不回滚——远端移交已生效):`pre-remove` 钩子 → `killProcessesUsingWorktree` → `removeWorktreeDir`;`cleanupFeatureBranch` **不传 remote/token**(只删本地分支,远端分支必须保留给接管方);本机字段全部写 `''` 墓碑。发送方磁盘上的会话文件保留。
|
8. `updateIssueAssignees(… [to])`。
|
||||||
9. `issue/patch { handoffAttachmentId, handoffFrom, worktreePath: null, worktreeExists: false, …sid: null }` + 成功 toast。
|
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`。
|
||||||
|
|
||||||
## 接管方:接管
|
## 接管方:接管
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,37 @@
|
|||||||
import { execFile } from 'node:child_process'
|
import { execFile, spawn } from 'node:child_process'
|
||||||
import { promises as fsp } from 'node:fs'
|
import { promises as fsp, statSync } from 'node:fs'
|
||||||
import * as path from 'node:path'
|
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) => {
|
return new Promise((resolve, reject) => {
|
||||||
execFile('tar', args, { timeout: 120_000 }, (err, _stdout, stderr) => {
|
execFile('tar', args, { timeout: 120_000 }, (err, _stdout, stderr) => {
|
||||||
if (err) {
|
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 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> {
|
export async function unpackHandoff(archive: string, dstDir: string): Promise<void> {
|
||||||
await fsp.mkdir(dstDir, { recursive: true })
|
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 { CLAUDE_SESSION_FIELDS, HANDOFF_MANIFEST_FILE } from './handoffManifest'
|
||||||
import {
|
import {
|
||||||
codexRolloutRelPathFor,
|
codexRolloutRelPathFor,
|
||||||
copyClaudeSessionFiles,
|
|
||||||
findClaudeSessionFiles,
|
findClaudeSessionFiles,
|
||||||
installClaudeSession,
|
installClaudeSession,
|
||||||
installCodexSession,
|
installCodexSession,
|
||||||
@@ -27,6 +26,31 @@ export interface HandoffRoots {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 把本机会话文件收进 stagingDir(claude/、codex/、handoff.json),返回清单。找不到的会话只从文件列表缺席,id 仍记录。 */
|
/** 把本机会话文件收进 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(
|
export async function stageHandoffBundle(
|
||||||
src: HandoffSource,
|
src: HandoffSource,
|
||||||
stagingDir: string,
|
stagingDir: string,
|
||||||
@@ -46,7 +70,9 @@ export async function stageHandoffBundle(
|
|||||||
const files = await findClaudeSessionFiles(id, roots.projectsRoot)
|
const files = await findClaudeSessionFiles(id, roots.projectsRoot)
|
||||||
if (!files)
|
if (!files)
|
||||||
continue
|
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 })
|
claude.push({ id, kind })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,8 +81,7 @@ export async function stageHandoffBundle(
|
|||||||
const relPath = await codexRolloutRelPathFor(src.sessions.reviewSessionId, roots.codexRoot)
|
const relPath = await codexRolloutRelPathFor(src.sessions.reviewSessionId, roots.codexRoot)
|
||||||
if (relPath) {
|
if (relPath) {
|
||||||
const dst = path.join(codexDir, relPath)
|
const dst = path.join(codexDir, relPath)
|
||||||
await fsp.mkdir(path.dirname(dst), { recursive: true })
|
await placeFile(path.join(roots.codexRoot, relPath), dst)
|
||||||
await fsp.copyFile(path.join(roots.codexRoot, relPath), dst)
|
|
||||||
codex.push({ id: src.sessions.reviewSessionId, relPath })
|
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 { window, workspace } from 'vscode'
|
||||||
import { loginForToken } from '../../auth/identity'
|
import { loginForToken } from '../../auth/identity'
|
||||||
import { getToken } from '../../auth/secrets'
|
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 { installHandoffBundle, stageHandoffBundle } from '../../cc/handoffBundle'
|
||||||
import { HANDOFF_MANIFEST_FILE, handoffAttachmentName, parseHandoffManifest } from '../../cc/handoffManifest'
|
import { HANDOFF_MANIFEST_FILE, handoffAttachmentName, parseHandoffManifest } from '../../cc/handoffManifest'
|
||||||
|
import { ensureHandoffFits, HandoffSourcesTooLargeError } from '../../cc/handoffSize'
|
||||||
import { claudeProjectsRoot } from '../../cc/sessionBundle'
|
import { claudeProjectsRoot } from '../../cc/sessionBundle'
|
||||||
import { projectsDirFor } from '../../cc/sessionWatcher'
|
import { projectsDirFor } from '../../cc/sessionWatcher'
|
||||||
import { detectRepo } from '../../git/remote'
|
import { detectRepo } from '../../git/remote'
|
||||||
@@ -191,27 +192,34 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
|
|||||||
...(str(state.prototypeProfilePath) ? { prototypeProfilePath: str(state.prototypeProfilePath) } : {}),
|
...(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 时
|
// ① worktree 守卫:干净 + 与远端一致。worktree 存在但 state 没有 branch 时
|
||||||
// 没法做 push 校验——不能放行,否则 ⑥ 清理会在没校验过的情况下把它删掉。
|
// 没法做 push 校验——不能放行,否则 ⑥ 清理会在没校验过的情况下把它删掉。
|
||||||
// 没有 worktree 但本地还留着同名分支时,⑥ 会直接 `git branch -D` 掉它——
|
// 没有 worktree 但本地还留着同名分支时,⑥ 会直接 `git branch -D` 掉它——
|
||||||
// 分支若领先远端(未 push)就会丢工作,必须在这里单独拦一次。
|
// 分支若领先远端(未 push)就会丢工作,必须在这里单独拦一次。
|
||||||
|
spinner = say('核对分支是否已推送')
|
||||||
const worktreeAbs = worktreePath ? resolveWorktreePath(worktreePath, workspaceRoot) : undefined
|
const worktreeAbs = worktreePath ? resolveWorktreePath(worktreePath, workspaceRoot) : undefined
|
||||||
if (worktreeAbs && fs.existsSync(worktreeAbs)) {
|
if (worktreeAbs && fs.existsSync(worktreeAbs)) {
|
||||||
if (!branch) {
|
if (!branch) {
|
||||||
toast(panel, 'error', `无法移交 #${issueNumber}:state 缺少 branch,无法校验是否已 push`)
|
fail(`无法移交 #${issueNumber}:state 缺少 branch,无法校验是否已 push`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const status = await git(worktreeAbs, ['status', '--porcelain'])
|
const status = await git(worktreeAbs, ['status', '--porcelain'])
|
||||||
const fetched = await git(worktreeAbs, ['fetch', 'origin', branch])
|
const fetched = await git(worktreeAbs, ['fetch', 'origin', branch])
|
||||||
if (!status.ok || !fetched.ok) {
|
if (!status.ok || !fetched.ok) {
|
||||||
toast(panel, 'error', `检查 worktree 失败:${(status.stderr || fetched.stderr).trim()}`)
|
fail(`检查 worktree 失败:${(status.stderr || fetched.stderr).trim()}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const localHead = await git(worktreeAbs, ['rev-parse', 'HEAD'])
|
const localHead = await git(worktreeAbs, ['rev-parse', 'HEAD'])
|
||||||
const remoteHead = await git(worktreeAbs, ['rev-parse', `origin/${branch}`])
|
const remoteHead = await git(worktreeAbs, ['rev-parse', `origin/${branch}`])
|
||||||
const check = checkWorktreePushed({ statusPorcelain: status.stdout, localHead: localHead.stdout, remoteHead: remoteHead.stdout })
|
const check = checkWorktreePushed({ statusPorcelain: status.stdout, localHead: localHead.stdout, remoteHead: remoteHead.stdout })
|
||||||
if (!check.ok) {
|
if (!check.ok) {
|
||||||
toast(panel, 'error', `无法移交 #${issueNumber}:${check.reason}`)
|
fail(`无法移交 #${issueNumber}:${check.reason}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -223,15 +231,39 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
|
|||||||
const localHead = await git(workspaceRoot, ['rev-parse', `refs/heads/${branch}`])
|
const localHead = await git(workspaceRoot, ['rev-parse', `refs/heads/${branch}`])
|
||||||
const remoteHead = await git(workspaceRoot, ['rev-parse', `refs/remotes/origin/${branch}`])
|
const remoteHead = await git(workspaceRoot, ['rev-parse', `refs/remotes/origin/${branch}`])
|
||||||
if (!fetched.ok || localHead.stdout.trim() !== remoteHead.stdout.trim()) {
|
if (!fetched.ok || localHead.stdout.trim() !== remoteHead.stdout.trim()) {
|
||||||
toast(panel, 'error', `无法移交 #${issueNumber}:本地分支 ${branch} 与远端不一致,请先 push`)
|
fail(`无法移交 #${issueNumber}:本地分支 ${branch} 与远端不一致,请先 push`)
|
||||||
return
|
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 刷盘
|
// ② 关终端,等 jsonl 刷盘
|
||||||
|
say('关闭终端')
|
||||||
let disposedAny = false
|
let disposedAny = false
|
||||||
for (const [terminal, origin] of panel.terminalOrigin) {
|
for (const [terminal, origin] of panel.terminalOrigin) {
|
||||||
if (origin.issueNumber === issueNumber) {
|
if (origin.issueNumber === issueNumber) {
|
||||||
@@ -257,20 +289,19 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
|
|||||||
await new Promise<void>(r => setTimeout(r, 600))
|
await new Promise<void>(r => setTimeout(r, 600))
|
||||||
|
|
||||||
// ③ 打包 + 上传
|
// ③ 打包 + 上传
|
||||||
|
say('收集会话')
|
||||||
let attachmentId: number
|
let attachmentId: number
|
||||||
let manifest: HandoffManifest
|
let manifest: HandoffManifest
|
||||||
// tgz 必须放在 staging 之外,否则 tar 会把半成品包也打进去。
|
// tgz 必须放在 staging 之外,否则 tar 会把半成品包也打进去。
|
||||||
const staging = await scratchDir('spx-handoff-')
|
const staging = await scratchDir('spx-handoff-')
|
||||||
const outDir = await scratchDir('spx-handoff-out-')
|
const outDir = await scratchDir('spx-handoff-out-')
|
||||||
try {
|
try {
|
||||||
manifest = await stageHandoffBundle(
|
manifest = await stageHandoffBundle(handoffSrc, staging, handoffRoots)
|
||||||
{ issueNumber, from: me, branch, ...(worktreeAbs && fs.existsSync(worktreeAbs) ? { worktreePath: worktreeAbs } : {}), sessions, profiles },
|
|
||||||
staging,
|
|
||||||
{ projectsRoot: claudeProjectsRoot(), codexRoot: defaultCodexSessionsDir() },
|
|
||||||
)
|
|
||||||
const name = handoffAttachmentName(issueNumber)
|
const name = handoffAttachmentName(issueNumber)
|
||||||
const archive = path.join(outDir, name)
|
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 })) {
|
for (const old of await listIssueAttachments({ host, token, owner, repo, index: issueNumber })) {
|
||||||
if (old.name === name)
|
if (old.name === name)
|
||||||
await deleteIssueAttachment({ host, token, owner, repo, index: issueNumber, attachmentId: old.id })
|
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) })
|
logger.add({ level: 'info', source: 'panel', message: `移交 #${issueNumber}:会话包已上传 (attachment ${attachmentId}, ${uploaded.size} bytes)`, details: JSON.stringify(manifest) })
|
||||||
}
|
}
|
||||||
catch (err) {
|
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 })
|
logger.add({ level: 'error', source: 'panel', message: `移交 #${issueNumber}:打包/上传失败`, details: message })
|
||||||
dismiss(panel, spinner)
|
dismiss(panel, spinner)
|
||||||
toast(panel, 'error', `移交失败(打包/上传):${message}`)
|
toast(panel, 'error', `移交失败(${prefix}):${message}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
@@ -294,6 +328,7 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
|
|||||||
|
|
||||||
// ④ 写共享字段(只 handoffAttachmentId / handoffFrom,本机墓碑留到 ⑦);
|
// ④ 写共享字段(只 handoffAttachmentId / handoffFrom,本机墓碑留到 ⑦);
|
||||||
// ⑤ 重新指派。失败回滚远端改动。
|
// ⑤ 重新指派。失败回滚远端改动。
|
||||||
|
say('写入状态')
|
||||||
try {
|
try {
|
||||||
await panel.mergeIssueState(issueNumber, handoffStartedStateExtra(attachmentId, me))
|
await panel.mergeIssueState(issueNumber, handoffStartedStateExtra(attachmentId, me))
|
||||||
}
|
}
|
||||||
@@ -305,6 +340,7 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
|
|||||||
toast(panel, 'error', `移交失败(写状态):${message}`)
|
toast(panel, 'error', `移交失败(写状态):${message}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
say('指派')
|
||||||
try {
|
try {
|
||||||
await updateIssueAssignees({ host, token, owner, repo, index: issueNumber, assignees: [to] })
|
await updateIssueAssignees({ host, token, owner, repo, index: issueNumber, assignees: [to] })
|
||||||
}
|
}
|
||||||
@@ -320,6 +356,8 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
|
|||||||
|
|
||||||
// ⑥ 本机清理:worktree + 本地分支(远端分支留给接管方)。失败只告警——
|
// ⑥ 本机清理:worktree + 本地分支(远端分支留给接管方)。失败只告警——
|
||||||
// 远端移交已经生效,这里不回滚。
|
// 远端移交已经生效,这里不回滚。
|
||||||
|
if ((worktreeAbs && fs.existsSync(worktreeAbs)) || branch)
|
||||||
|
say('清理本机')
|
||||||
let worktreeRemovalFailed = false
|
let worktreeRemovalFailed = false
|
||||||
if (worktreeAbs && fs.existsSync(worktreeAbs)) {
|
if (worktreeAbs && fs.existsSync(worktreeAbs)) {
|
||||||
const settings = getSettings(panel.context)
|
const settings = getSettings(panel.context)
|
||||||
|
|||||||
Reference in new issue
Block a user