🐛 fix(vscode): 移交终审修正:schema 墓碑、顶层兜底、前置复查、分支保护、清单校验

Claude-Session: https://claude.ai/code/session_011cEyL6k351U2BzX1Qmygph
This commit is contained in:
2026-08-26 17:09:39 +08:00
parent aa98dfb4e1
commit 0acfd04b54
10 changed files with 299 additions and 220 deletions
+3 -2
View File
@@ -23,7 +23,7 @@
4. **测试**:工单面板里 ▶ 开 `issue-N-测试` 会话(`/pr-acceptance-testing`)。 4. **测试**:工单面板里 ▶ 开 `issue-N-测试` 会话(`/pr-acceptance-testing`)。
5. **完成**:拖到「完成」列。有 PR 就由插件合并(冲突时开 `issue-N-冲突解决` 会话,解决后再拖一次),然后关工单、把会话记录拷回主仓库、删 worktree、删 feature 分支。 5. **完成**:拖到「完成」列。有 PR 就由插件合并(冲突时开 `issue-N-冲突解决` 会话,解决后再拖一次),然后关工单、把会话记录拷回主仓库、删 worktree、删 feature 分支。
6. **回退**:进行中 / 审查中的卡可「重置为待办」,关 PR、删 worktree、清分支和实施痕迹,spec / plan 保留。 6. **回退**:进行中 / 审查中的卡可「重置为待办」,关 PR、删 worktree、清分支和实施痕迹,spec / plan 保留。
7. **移交**:工单 tab 头部「移交」→ 选同事。插件校验 worktree 已全部 push,关掉该工单的终端,把头脑风暴 / 实施 / 测试的 claude 会话(jsonl + 子目录)和 codex 审查会话打成 `spx-handoff-issue-N.tgz` 挂到工单附件,写 `handoffAttachmentId` / `handoffFrom`,把工单指派给对方,最后删本机 worktree 与本地分支(远端分支保留)。对方看板上该卡显示「待接管」,工单 tab 点「接管」:从 origin 重建 worktree`git worktree add -B <branch> … origin/<branch>`)、把会话文件装到本机 `~/.claude/projects/` 对应目录(先清同 sid 副本)与 `~/.codex/sessions/`、写本机字段、清移交字段并删附件。之后 resume / 合并 / 冲突解决 / 测试与本机实施的工单无异。 7. **移交**前置条件:两台机器的 spx 都已 `cd cli && make install` 到含 `handoffAttachmentId` 的版本,否则移交期间旧 spx 的 state 写入会被 schema 拒绝。工单 tab 头部「移交」→ 选同事。插件校验 worktree 已全部 push,关掉该工单的终端,把头脑风暴 / 实施 / 测试的 claude 会话(jsonl + 子目录)和 codex 审查会话打成 `spx-handoff-issue-N.tgz` 挂到工单附件,写 `handoffAttachmentId` / `handoffFrom`,把工单指派给对方,最后删本机 worktree 与本地分支(远端分支保留)。对方看板上该卡显示「待接管」,工单 tab 点「接管」:从 origin 重建 worktree`git worktree add -B <branch> … origin/<branch>`)、把会话文件装到本机 `~/.claude/projects/` 对应目录(先清同 sid 副本)与 `~/.codex/sessions/`、写本机字段、清移交字段并删附件。之后 resume / 合并 / 冲突解决 / 测试与本机实施的工单无异。
其他看板规则: 其他看板规则:
@@ -38,12 +38,13 @@
| 工具 | 用途 | | 工具 | 用途 |
|---|---| |---|---|
| `git` | worktree、分支、fetch / merge、tar(移交打包) | | `git` | worktree、分支、fetch / merge |
| `claude` | 规划、实施、测试、冲突解决、提交、PR 摘要(找不到时会提示常见位置 `~/.local/bin` | | `claude` | 规划、实施、测试、冲突解决、提交、PR 摘要(找不到时会提示常见位置 `~/.local/bin` |
| `codex` | 审查会话(开启自动审查时必需) | | `codex` | 审查会话(开启自动审查时必需) |
| `tea` | `spx``~/.config/tea/config.yml` 读取 Gitea host + token;审查 prompt 也用 `tea pulls` 看 PR | | `tea` | `spx``~/.config/tea/config.yml` 读取 Gitea host + token;审查 prompt 也用 `tea pulls` 看 PR |
| `spx` | 本仓库 `cli/` 里的 Go CLI,见下文 | | `spx` | 本仓库 `cli/` 里的 Go CLI,见下文 |
| `opencli` | prompts 里统一以 `opencli spx ...` 调用 spx | | `opencli` | prompts 里统一以 `opencli spx ...` 调用 spx |
| `tar` | 移交时打包 / 解包会话文件 |
| `bash` | 执行钩子脚本 | | `bash` | 执行钩子脚本 |
- 仅支持 Gitea`https://<host>/api/v1`),不支持 GitHub。 - 仅支持 Gitea`https://<host>/api/v1`),不支持 GitHub。
+2 -4
View File
@@ -104,13 +104,11 @@
}, },
"handoffAttachmentId": { "handoffAttachmentId": {
"type": "string", "type": "string",
"description": "移交会话包在 Gitea 的附件 id;存在即「待接管」,接管完成后清空。", "description": "移交会话包在 Gitea 的附件 id;存在即「待接管」,接管完成后清空;清空时写空串。"
"minLength": 1
}, },
"handoffFrom": { "handoffFrom": {
"type": "string", "type": "string",
"description": "发起移交的 Gitea login。", "description": "发起移交的 Gitea login;清空时写空串。"
"minLength": 1
} }
} }
} }
+2 -4
View File
@@ -104,13 +104,11 @@
}, },
"handoffAttachmentId": { "handoffAttachmentId": {
"type": "string", "type": "string",
"description": "移交会话包在 Gitea 的附件 id;存在即「待接管」,接管完成后清空。", "description": "移交会话包在 Gitea 的附件 id;存在即「待接管」,接管完成后清空;清空时写空串。"
"minLength": 1
}, },
"handoffFrom": { "handoffFrom": {
"type": "string", "type": "string",
"description": "发起移交的 Gitea login。", "description": "发起移交的 Gitea login;清空时写空串。"
"minLength": 1
} }
} }
} }
+10 -2
View File
@@ -1,6 +1,8 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { handoffAttachmentName, parseHandoffManifest } from './handoffManifest' import { handoffAttachmentName, parseHandoffManifest } from './handoffManifest'
const UUID = '11111111-1111-4111-8111-111111111111'
const good = { const good = {
version: 1, version: 1,
issue: 42, issue: 42,
@@ -9,7 +11,7 @@ const good = {
branch: 'feature/x', branch: 'feature/x',
sessions: { implementSessionId: 'a' }, sessions: { implementSessionId: 'a' },
profiles: {}, profiles: {},
claude: [{ id: 'a', kind: 'implement' }], claude: [{ id: UUID, kind: 'implement' }],
codex: [], codex: [],
} }
@@ -30,7 +32,13 @@ describe('parseHandoffManifest', () => {
expect(() => parseHandoffManifest(JSON.stringify({ ...good, version: 2 }), 42)).toThrow(/version/) expect(() => parseHandoffManifest(JSON.stringify({ ...good, version: 2 }), 42)).toThrow(/version/)
}) })
it('claude 条目 kind 非法 → 抛错', () => { it('claude 条目 kind 非法 → 抛错', () => {
expect(() => parseHandoffManifest(JSON.stringify({ ...good, claude: [{ id: 'a', kind: 'x' }] }), 42)).toThrow(/claude/) expect(() => parseHandoffManifest(JSON.stringify({ ...good, claude: [{ id: UUID, kind: 'x' }] }), 42)).toThrow(/claude/)
})
it('claude 条目 id 非 UUID(路径穿越)→ 抛错', () => {
expect(() => parseHandoffManifest(JSON.stringify({ ...good, claude: [{ id: '../x', kind: 'implement' }] }), 42)).toThrow(/claude/)
})
it('codex 条目 id 非 UUID → 抛错', () => {
expect(() => parseHandoffManifest(JSON.stringify({ ...good, codex: [{ id: 'not-a-uuid', relPath: '2026/08/26/rollout-x.jsonl' }] }), 42)).toThrow(/codex/)
}) })
it('不是 JSON → 抛错', () => { it('不是 JSON → 抛错', () => {
expect(() => parseHandoffManifest('nope', 42)).toThrow() expect(() => parseHandoffManifest('nope', 42)).toThrow()
+5 -2
View File
@@ -39,6 +39,9 @@ export function handoffAttachmentName(issueNumber: number): string {
const KINDS: ReadonlySet<string> = new Set<ClaudeSessionKind>(['brainstorm', 'implement', 'test']) const KINDS: ReadonlySet<string> = new Set<ClaudeSessionKind>(['brainstorm', 'implement', 'test'])
// Claude / Codex 会话 id 都是 UUID;顺手挡掉路径穿越(如 `../x`)之类的伪造条目。
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
function optString(v: unknown): string | undefined { function optString(v: unknown): string | undefined {
return typeof v === 'string' && v.length > 0 ? v : undefined return typeof v === 'string' && v.length > 0 ? v : undefined
} }
@@ -63,7 +66,7 @@ export function parseHandoffManifest(json: string, expectedIssue: number): Hando
const o = e as Record<string, unknown> const o = e as Record<string, unknown>
const id = optString(o.id) const id = optString(o.id)
const kind = optString(o.kind) const kind = optString(o.kind)
if (!id || !kind || !KINDS.has(kind)) if (!id || !kind || !KINDS.has(kind) || !UUID_RE.test(id))
throw new Error(`handoff.json claude 条目非法:${JSON.stringify(e)}`) throw new Error(`handoff.json claude 条目非法:${JSON.stringify(e)}`)
return { id, kind: kind as ClaudeSessionKind } return { id, kind: kind as ClaudeSessionKind }
}) })
@@ -71,7 +74,7 @@ export function parseHandoffManifest(json: string, expectedIssue: number): Hando
const o = e as Record<string, unknown> const o = e as Record<string, unknown>
const id = optString(o.id) const id = optString(o.id)
const relPath = optString(o.relPath) const relPath = optString(o.relPath)
if (!id || !relPath || relPath.startsWith('/') || relPath.includes('..')) if (!id || !relPath || relPath.startsWith('/') || relPath.includes('..') || !UUID_RE.test(id))
throw new Error(`handoff.json codex 条目非法:${JSON.stringify(e)}`) throw new Error(`handoff.json codex 条目非法:${JSON.stringify(e)}`)
return { id, relPath } return { id, relPath }
}) })
+23 -1
View File
@@ -87,7 +87,7 @@ describe('payloads', () => {
}) })
}) })
it('移交 UI patch 用 null 清字段并更新 assignees', () => { it('移交 UI patch 用 null 清字段并更新 assignees', () => {
expect(handoffStartedUiPatch(9, 'chw', 'me')).toEqual({ expect(handoffStartedUiPatch(9, 'chw', 'me', { keepWorktreePath: false })).toEqual({
handoffAttachmentId: '9', handoffAttachmentId: '9',
handoffFrom: 'chw', handoffFrom: 'chw',
assignees: ['me'], assignees: ['me'],
@@ -95,10 +95,32 @@ describe('payloads', () => {
implementSessionId: null, implementSessionId: null,
reviewSessionId: null, reviewSessionId: null,
testSessionId: null, testSessionId: null,
profilePath: null,
brainstormProfilePath: null,
testProfilePath: null,
prDiffFile: null,
worktreePath: null, worktreePath: null,
worktreeExists: false, worktreeExists: false,
}) })
}) })
it('移交 UI patchkeepWorktreePath=true 时省略 worktreePath / worktreeExists', () => {
const patch = handoffStartedUiPatch(9, 'chw', 'me', { keepWorktreePath: true })
expect(patch).not.toHaveProperty('worktreePath')
expect(patch).not.toHaveProperty('worktreeExists')
expect(patch).toEqual({
handoffAttachmentId: '9',
handoffFrom: 'chw',
assignees: ['me'],
sessionId: null,
implementSessionId: null,
reviewSessionId: null,
testSessionId: null,
profilePath: null,
brainstormProfilePath: null,
testProfilePath: null,
prDiffFile: null,
})
})
it('接管落盘:清两个共享字段,写本机 sid / profile / worktree', () => { it('接管落盘:清两个共享字段,写本机 sid / profile / worktree', () => {
expect(handoffAcceptedStateExtra({ expect(handoffAcceptedStateExtra({
sessions: { implementSessionId: 'i', reviewSessionId: 'r' }, sessions: { implementSessionId: 'i', reviewSessionId: 'r' },
+7 -3
View File
@@ -59,7 +59,7 @@ export function handoffLocalTombstones(opts: { keepWorktreePath: boolean }): Rec
return extra return extra
} }
export function handoffStartedUiPatch(attachmentId: number, from: string, to: string): Record<string, unknown> { export function handoffStartedUiPatch(attachmentId: number, from: string, to: string, opts: { keepWorktreePath: boolean }): Record<string, unknown> {
return { return {
handoffAttachmentId: String(attachmentId), handoffAttachmentId: String(attachmentId),
handoffFrom: from, handoffFrom: from,
@@ -68,8 +68,12 @@ export function handoffStartedUiPatch(attachmentId: number, from: string, to: st
implementSessionId: null, implementSessionId: null,
reviewSessionId: null, reviewSessionId: null,
testSessionId: null, testSessionId: null,
worktreePath: null, profilePath: null,
worktreeExists: false, brainstormProfilePath: null,
testProfilePath: null,
prDiffFile: null,
// worktree 没删掉时(opts.keepWorktreePath)不碰这两个字段,让 UI 继续显示孤儿 worktree。
...(opts.keepWorktreePath ? {} : { worktreePath: null, worktreeExists: false }),
} }
} }
+62 -17
View File
@@ -1,4 +1,5 @@
import type { HandoffManifest, HandoffProfiles, HandoffSessions } from '../../cc/handoffManifest' import type { HandoffManifest, HandoffProfiles, HandoffSessions } from '../../cc/handoffManifest'
import type { IssueColumn } from '../../gitea/types'
import type { KanbanWebviewPanel } from '../KanbanPanel' import type { KanbanWebviewPanel } from '../KanbanPanel'
import { execFile } from 'node:child_process' import { execFile } from 'node:child_process'
import * as fs from 'node:fs' import * as fs from 'node:fs'
@@ -19,6 +20,7 @@ import { DEFAULT_WORKTREE_TEMPLATE, resolveWorktreeDir, slugFromBranch } from '.
import { import {
deleteIssueAttachment, deleteIssueAttachment,
downloadAttachment, downloadAttachment,
getIssue,
getIssueAttachment, getIssueAttachment,
listIssueAttachments, listIssueAttachments,
listRepoAssignees, listRepoAssignees,
@@ -31,6 +33,8 @@ import { getSettings } from '../../settings/store'
import { makeNonce } from '../KanbanPanel' import { makeNonce } from '../KanbanPanel'
import { cleanupFeatureBranch } from './branchCleanup' import { cleanupFeatureBranch } from './branchCleanup'
import { import {
canAcceptHandoff,
canStartHandoff,
checkWorktreePushed, checkWorktreePushed,
handoffAcceptedStateExtra, handoffAcceptedStateExtra,
handoffAcceptedUiPatch, handoffAcceptedUiPatch,
@@ -121,19 +125,22 @@ export async function handleHandoffUsers(panel: KanbanWebviewPanel, issueNumber:
} }
/** /**
* 发送方:校验 worktree 已 pushworktree 存在但 state 缺 branch 直接拒绝)→ * 发送方:服务端复查 canStartHandoffwebview 按钮的显示条件可能过期)→
* 校验 worktree 已 pushworktree 存在但 state 缺 branch 直接拒绝;没有
* worktree 时改查本地分支是否已 push,避免 ⑥ 误删未推送的分支)→
* 关终端 → 打包上传 → 写共享字段 → 重新指派 → 本机清理 → 写本机墓碑。 * 关终端 → 打包上传 → 写共享字段 → 重新指派 → 本机清理 → 写本机墓碑。
* 远端三步(上传 / 写状态 / 指派)任一失败都回滚前面的远端改动;本机清理 * 远端三步(上传 / 写状态 / 指派)任一失败都回滚前面的远端改动;本机清理
* 与墓碑写入失败只告警,移交在远端已经生效。 * 与墓碑写入失败只告警,移交在远端已经生效。顶层 try/catch/finally 兜住
* 未预期异常并保证 `handoff/done` 一定发出去。
*/ */
export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: number, to: string): Promise<void> { export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: number, to: string): Promise<void> {
// finish 要在 ctx 解析之前就能用:resolveRepoCtx 失败也得让弹窗的 spinner 收掉。
const finish = (): void => panel.postMessage({ type: 'handoff/done', issueNumber }) const finish = (): void => panel.postMessage({ type: 'handoff/done', issueNumber })
// spinner 声明在 try 外面:outer catch 兜住未预期异常时也要能把它收掉。
let spinner: string | undefined
try {
const ctx = await resolveRepoCtx(panel) const ctx = await resolveRepoCtx(panel)
if (!ctx) { if (!ctx)
finish()
return return
}
const { workspaceRoot, host, owner, repo, token, me } = ctx const { workspaceRoot, host, owner, repo, token, me } = ctx
let state: Record<string, unknown> let state: Record<string, unknown>
@@ -142,9 +149,18 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
} }
catch (err) { catch (err) {
toast(panel, 'error', `读取工单 #${issueNumber} 状态失败:${err instanceof Error ? err.message : String(err)}`) toast(panel, 'error', `读取工单 #${issueNumber} 状态失败:${err instanceof Error ? err.message : String(err)}`)
finish()
return return
} }
// 前置复查:webview 按钮的显示条件可能已经过期(并发移交、工单已被拖到
// 完成),用刚读到的最新 state 再判一次 canStartHandoff,避免重复移交。
const knownColumns: IssueColumn[] = ['todo', 'in-progress', 'review', 'done']
const column = knownColumns.includes(state.column as IssueColumn) ? (state.column as IssueColumn) : 'todo'
if (!canStartHandoff({ source: 'gitea', column, handoffAttachmentId: str(state.handoffAttachmentId) })) {
toast(panel, 'error', `#${issueNumber} 已在移交中或已完成,不能再次移交`)
return
}
const branch = str(state.branch) const branch = str(state.branch)
const worktreePath = str(state.worktreePath) const worktreePath = str(state.worktreePath)
const sessions: HandoffSessions = { const sessions: HandoffSessions = {
@@ -161,18 +177,18 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
// ① worktree 守卫:干净 + 与远端一致。worktree 存在但 state 没有 branch 时 // ① worktree 守卫:干净 + 与远端一致。worktree 存在但 state 没有 branch 时
// 没法做 push 校验——不能放行,否则 ⑥ 清理会在没校验过的情况下把它删掉。 // 没法做 push 校验——不能放行,否则 ⑥ 清理会在没校验过的情况下把它删掉。
// 没有 worktree 但本地还留着同名分支时,⑥ 会直接 `git branch -D` 掉它——
// 分支若领先远端(未 push)就会丢工作,必须在这里单独拦一次。
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`) toast(panel, 'error', `无法移交 #${issueNumber}state 缺少 branch,无法校验是否已 push`)
finish()
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()}`) toast(panel, 'error', `检查 worktree 失败:${(status.stderr || fetched.stderr).trim()}`)
finish()
return return
} }
const localHead = await git(worktreeAbs, ['rev-parse', 'HEAD']) const localHead = await git(worktreeAbs, ['rev-parse', 'HEAD'])
@@ -180,12 +196,24 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
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}`) toast(panel, 'error', `无法移交 #${issueNumber}${check.reason}`)
finish()
return return
} }
} }
else if (branch) {
const localRef = await git(workspaceRoot, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`])
// 本地没有这个分支就没什么可丢的,直接放行。
if (localRef.ok) {
const fetched = await git(workspaceRoot, ['fetch', 'origin', branch])
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`)
return
}
}
}
const spinner = toast(panel, 'info', `正在移交 #${issueNumber}${to}`, { spinner: true, dismissOnTimer: 120_000 }) spinner = toast(panel, 'info', `正在移交 #${issueNumber}${to}`, { spinner: true, dismissOnTimer: 120_000 })
// ② 关终端,等 jsonl 刷盘 // ② 关终端,等 jsonl 刷盘
let disposedAny = false let disposedAny = false
@@ -240,7 +268,6 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
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', `移交失败(打包/上传):${message}`)
finish()
return return
} }
finally { finally {
@@ -260,7 +287,6 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
await deleteIssueAttachment({ host, token, owner, repo, index: issueNumber, attachmentId }).catch(() => {}) await deleteIssueAttachment({ host, token, owner, repo, index: issueNumber, attachmentId }).catch(() => {})
dismiss(panel, spinner) dismiss(panel, spinner)
toast(panel, 'error', `移交失败(写状态):${message}`) toast(panel, 'error', `移交失败(写状态):${message}`)
finish()
return return
} }
try { try {
@@ -273,7 +299,6 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
await deleteIssueAttachment({ host, token, owner, repo, index: issueNumber, attachmentId }).catch(() => {}) await deleteIssueAttachment({ host, token, owner, repo, index: issueNumber, attachmentId }).catch(() => {})
dismiss(panel, spinner) dismiss(panel, spinner)
toast(panel, 'error', `移交失败(指派 ${to}):${message}`) toast(panel, 'error', `移交失败(指派 ${to}):${message}`)
finish()
return return
} }
@@ -315,13 +340,24 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
}) })
dismiss(panel, spinner) dismiss(panel, spinner)
panel.postMessage({ type: 'issue/patch', issueNumber, patch: handoffStartedUiPatch(attachmentId, me, to) }) panel.postMessage({ type: 'issue/patch', issueNumber, patch: handoffStartedUiPatch(attachmentId, me, to, { keepWorktreePath: worktreeRemovalFailed }) })
toast(panel, 'success', `#${issueNumber} 已移交给 ${to}${manifest.claude.length} 个 claude 会话,${manifest.codex.length} 个 codex 会话)`) toast(panel, 'success', `#${issueNumber} 已移交给 ${to}${manifest.claude.length} 个 claude 会话,${manifest.codex.length} 个 codex 会话)`)
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.add({ level: 'error', source: 'panel', message: `移交 #${issueNumber}:未预期错误`, details: message })
toast(panel, 'error', `移交失败:${message}`)
if (spinner)
dismiss(panel, spinner)
}
finally {
finish() finish()
} }
}
/** /**
* 接管方:下载解包 → 从远端重建 worktree → 装会话文件 → 写本机字段 * 接管方:服务端复查 canAcceptHandoffwebview 按钮的显示条件可能过期)
* 下载解包 → 从远端重建 worktree → 装会话文件 → 写本机字段 →
* 清共享字段 + 删附件。前几步失败时附件还在,按钮仍显示,可重试。 * 清共享字段 + 删附件。前几步失败时附件还在,按钮仍显示,可重试。
*/ */
export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> { export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
@@ -332,7 +368,7 @@ export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber
finish() finish()
return return
} }
const { workspaceRoot, host, owner, repo, token } = ctx const { workspaceRoot, host, owner, repo, token, me } = ctx
let state: Record<string, unknown> let state: Record<string, unknown>
try { try {
@@ -350,6 +386,15 @@ export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber
return return
} }
// 前置复查:webview 按钮的显示条件(assignees 含 me)可能已经过期(工单被
// 重新指派),用刚从 Gitea 拉到的最新 issue 再判一次 canAcceptHandoff。
const issue = await getIssue({ host, token, owner, repo, index: issueNumber })
if (!issue || !canAcceptHandoff({ handoffAttachmentId: String(attachmentId), assignees: (issue.assignees ?? []).map(a => a.login) }, me)) {
toast(panel, 'error', `#${issueNumber} 未指派给你,不能接管`)
finish()
return
}
const spinner = toast(panel, 'info', `正在接管 #${issueNumber}`, { spinner: true, dismissOnTimer: 120_000 }) const spinner = toast(panel, 'info', `正在接管 #${issueNumber}`, { spinner: true, dismissOnTimer: 120_000 })
const extracted = await scratchDir('spx-takeover-') const extracted = await scratchDir('spx-takeover-')
try { try {
+1 -1
View File
@@ -42,7 +42,7 @@ export type ExtensionToWebview
= | { type: 'issues/loading' } = | { type: 'issues/loading' }
| { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string } | { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string }
| { type: 'issues/error', message: string } | { type: 'issues/error', message: string }
| { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prDiffFile?: string, sessionId?: string | null, implementSessionId?: string | null, reviewSessionId?: string | null, reviewSessionFileExists?: boolean, testSessionId?: string | null, pr?: string | null, implementStatus?: 'running' | 'done' | 'failed' | null, column?: IssueColumn, worktreePath?: string | null, prMerged?: boolean, prMergedAt?: string | null, branch?: string | null, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, profilePath?: string, brainstormProfilePath?: string, testProfilePath?: string, assignees?: string[], handoffAttachmentId?: string | null, handoffFrom?: string | null } } | { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prDiffFile?: string | null, sessionId?: string | null, implementSessionId?: string | null, reviewSessionId?: string | null, reviewSessionFileExists?: boolean, testSessionId?: string | null, pr?: string | null, implementStatus?: 'running' | 'done' | 'failed' | null, column?: IssueColumn, worktreePath?: string | null, prMerged?: boolean, prMergedAt?: string | null, branch?: string | null, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, profilePath?: string | null, brainstormProfilePath?: string | null, testProfilePath?: string | null, assignees?: string[], handoffAttachmentId?: string | null, handoffFrom?: string | null } }
| { type: 'issue/pr-diff-summary-done', issueNumber: number } | { type: 'issue/pr-diff-summary-done', issueNumber: number }
| { type: 'handoff/users-result', issueNumber: number, users: string[] } | { type: 'handoff/users-result', issueNumber: number, users: string[] }
| { type: 'handoff/done', issueNumber: number } | { type: 'handoff/done', issueNumber: number }
+1 -1
View File
@@ -57,7 +57,7 @@ export type ExtensionToWebview
= | { type: 'issues/loading' } = | { type: 'issues/loading' }
| { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string } | { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string }
| { type: 'issues/error', message: string } | { type: 'issues/error', message: string }
| { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prDiffFile?: string, sessionId?: string | null, implementSessionId?: string | null, reviewSessionId?: string | null, reviewSessionFileExists?: boolean, testSessionId?: string | null, pr?: string | null, implementStatus?: 'running' | 'done' | 'failed' | null, column?: IssueColumn, worktreePath?: string | null, prMerged?: boolean, prMergedAt?: string | null, branch?: string | null, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, profilePath?: string, brainstormProfilePath?: string, testProfilePath?: string, assignees?: string[], handoffAttachmentId?: string | null, handoffFrom?: string | null } } | { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prDiffFile?: string | null, sessionId?: string | null, implementSessionId?: string | null, reviewSessionId?: string | null, reviewSessionFileExists?: boolean, testSessionId?: string | null, pr?: string | null, implementStatus?: 'running' | 'done' | 'failed' | null, column?: IssueColumn, worktreePath?: string | null, prMerged?: boolean, prMergedAt?: string | null, branch?: string | null, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, profilePath?: string | null, brainstormProfilePath?: string | null, testProfilePath?: string | null, assignees?: string[], handoffAttachmentId?: string | null, handoffFrom?: string | null } }
| { type: 'issue/pr-diff-summary-done', issueNumber: number } | { type: 'issue/pr-diff-summary-done', issueNumber: number }
| { type: 'handoff/users-result', issueNumber: number, users: string[] } | { type: 'handoff/users-result', issueNumber: number, users: string[] }
| { type: 'handoff/done', issueNumber: number } | { type: 'handoff/done', issueNumber: number }