🐛 fix(vscode): 接管工单不再用同事机器上的模型配置盖掉本机的,移交出去也不清自己选的配置

This commit is contained in:
cruldra committed 2026-10-17 16:31:32 +11:00
1 parent 7ca2a8db34
commit 1f161c7012
9 files changed
+90 -93

No files matched your search

+33 -23
View File
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'
import { parseHandoffManifest } from '../../cc/handoffManifest'
import {
canAcceptHandoff,
canStartHandoff,
@@ -68,6 +69,13 @@ describe('checkWorktreePushed', () => {
})
})
const PROFILE_FIELDS = ['profilePath', 'brainstormProfilePath', 'testProfilePath', 'prototypeProfilePath'] as const
function expectNoProfileFields(payload: Record<string, unknown>): void {
for (const f of PROFILE_FIELDS)
expect(payload).not.toHaveProperty(f)
}
describe('payloads', () => {
it('移交落盘:只写两个共享字段,本机字段留到本机清理跑完后再墓碑', () => {
expect(handoffStartedStateExtra(KEY, 'chw')).toEqual({
@@ -75,17 +83,13 @@ describe('payloads', () => {
handoffFrom: 'chw',
})
})
it('本机墓碑:keepWorktreePath=false 时本机字段全清', () => {
it('本机墓碑:keepWorktreePath=false 时清会话 id、worktreePath、prDiffFile', () => {
expect(handoffLocalTombstones({ keepWorktreePath: false })).toEqual({
sessionId: '',
implementSessionId: '',
reviewSessionId: '',
testSessionId: '',
prototypeSessionId: '',
profilePath: '',
brainstormProfilePath: '',
testProfilePath: '',
prototypeProfilePath: '',
worktreePath: '',
prDiffFile: '',
})
@@ -99,13 +103,13 @@ describe('payloads', () => {
reviewSessionId: '',
testSessionId: '',
prototypeSessionId: '',
profilePath: '',
brainstormProfilePath: '',
testProfilePath: '',
prototypeProfilePath: '',
prDiffFile: '',
})
})
it('本机墓碑不清 profile 字段:移交出去再接回来,还是自己原来选的模型配置', () => {
expectNoProfileFields(handoffLocalTombstones({ keepWorktreePath: false }))
expectNoProfileFields(handoffLocalTombstones({ keepWorktreePath: true }))
})
it('移交 UI patch 用 null 清字段并更新 assignees', () => {
expect(handoffStartedUiPatch(KEY, 'chw', 'me', { keepWorktreePath: false })).toEqual({
handoffBundleKey: KEY,
@@ -116,10 +120,6 @@ describe('payloads', () => {
reviewSessionId: null,
testSessionId: null,
prototypeSessionId: null,
profilePath: null,
brainstormProfilePath: null,
testProfilePath: null,
prototypeProfilePath: null,
prDiffFile: null,
worktreePath: null,
worktreeExists: false,
@@ -138,31 +138,43 @@ describe('payloads', () => {
reviewSessionId: null,
testSessionId: null,
prototypeSessionId: null,
profilePath: null,
brainstormProfilePath: null,
testProfilePath: null,
prototypeProfilePath: null,
prDiffFile: null,
})
})
it('接管落盘:清两个共享字段,写本机 sid / profile / worktree', () => {
it('移交 UI patch 不碰 profile 字段,卡片上仍显示自己选的模型配置', () => {
expectNoProfileFields(handoffStartedUiPatch(KEY, 'chw', 'me', { keepWorktreePath: false }))
expectNoProfileFields(handoffStartedUiPatch(KEY, 'chw', 'me', { keepWorktreePath: true }))
})
it('接管落盘:清两个共享字段,写本机 sid / worktree', () => {
expect(handoffAcceptedStateExtra({
sessions: { implementSessionId: 'i', reviewSessionId: 'r' },
profiles: { profilePath: '/p.json' },
worktreePath: '/wt',
})).toEqual({
handoffBundleKey: '',
handoffFrom: '',
implementSessionId: 'i',
reviewSessionId: 'r',
profilePath: '/p.json',
worktreePath: '/wt',
})
})
it('接管旧会话包:包里带着同事机器上的 profile 路径,本机 state 和 UI 都不写 profile 字段', () => {
const manifest = parseHandoffManifest(JSON.stringify({
version: 1,
issue: 9,
from: 'chehongweo',
createdAt: '2026-10-08T01:02:03Z',
sessions: { sessionId: 's' },
profiles: { brainstormProfilePath: '/Users/chehongweo/Sources/cruldra-profile/claude-config/profiles/gpt-6.json' },
claude: [],
codex: [],
}), 9)
const local = { sessions: manifest.sessions, worktreePath: '/wt' }
expectNoProfileFields(handoffAcceptedStateExtra(local))
expectNoProfileFields(handoffAcceptedUiPatch({ ...local, worktreeExists: true, missingSessionIds: [] }))
})
it('接管 UI patch', () => {
expect(handoffAcceptedUiPatch({
sessions: { sessionId: 's' },
profiles: {},
worktreePath: '/wt',
worktreeExists: true,
missingSessionIds: [],
@@ -178,7 +190,6 @@ describe('payloads', () => {
it('接管 UI patch:无 worktreePath 时显式置 null,不留旧值', () => {
expect(handoffAcceptedUiPatch({
sessions: { sessionId: 's' },
profiles: {},
worktreeExists: false,
missingSessionIds: [],
})).toEqual({
@@ -193,7 +204,6 @@ describe('payloads', () => {
it('接管 UI patch:缺失的会话 id 原样下发,UI 立刻知道哪几行不能 resume', () => {
expect(handoffAcceptedUiPatch({
sessions: { sessionId: 's', reviewSessionId: 'r' },
profiles: {},
worktreeExists: false,
missingSessionIds: ['r'],
})).toEqual({
+18 -20
View File
@@ -1,7 +1,7 @@
import type { HandoffProfiles, HandoffSessions } from '../../cc/handoffManifest'
import type { HandoffSessions } from '../../cc/handoffManifest'
import type { IssueColumn } from '../../gitea/types'
import { formatByteSize } from '../../cc/handoffArchive'
import { LOCAL_STATE_FIELDS } from '../../issues/localState'
import { LOCAL_PROFILE_FIELDS, LOCAL_STATE_FIELDS } from '../../issues/localState'
export function canStartHandoff(issue: {
source?: 'gitea' | 'youtrack'
@@ -46,30 +46,35 @@ export function checkWorktreePushed(input: {
}
/**
* 共享字段:只写 handoffBundleKey / handoffFrom 两个。本机
* sid/profile/worktreePath 墓碑要等本机清理跑完(成功指派后)才落盘,
* 见 handoffLocalTombstones —— 否则④/⑤失败回滚时本机字段已经被清空,
* 用户会话恢复不了。
* 移交时写进共享 state 的只有 `handoffBundleKey` 和 `handoffFrom` 两个字段,让接管方知道有会话包等着接。
*
* 本机字段的墓碑要等本机清理跑完(指派成功之后)才落盘,见 [handoffLocalTombstones]:否则写状态或指派失败回滚时,
* 本机字段已经清空,用户的会话就恢复不了。
*/
export function handoffStartedStateExtra(bundleKey: string, from: string): Record<string, unknown> {
return { handoffBundleKey: bundleKey, handoffFrom: from }
}
/**
* 本机墓碑:移交流程最后一步(本机清理跑完之后)才调用。worktree 删除失败时
* 保留 worktreePath(keepWorktreePath),别把用户还留在磁盘上的 worktree
* 从 state 里抹掉。
* 移交给别人后,把本机上会话 id、worktree 路径这些到了接管方就没用的字段写成 `''` 墓碑。
*
* 移交流程最后一步(本机清理跑完之后)才调用。[LOCAL_PROFILE_FIELDS] 不清:那是用户在这台机器上选的模型配置,
* 工单以后接回来还照旧用。worktree 删除失败时(`keepWorktreePath`)也保留 `worktreePath`,
* 别把用户还留在磁盘上的 worktree 从 state 里抹掉。
*/
export function handoffLocalTombstones(opts: { keepWorktreePath: boolean }): Record<string, string> {
const kept = new Set<string>(LOCAL_PROFILE_FIELDS)
if (opts.keepWorktreePath)
kept.add('worktreePath')
const extra: Record<string, string> = {}
for (const f of LOCAL_STATE_FIELDS) {
if (f === 'worktreePath' && opts.keepWorktreePath)
continue
extra[f] = ''
if (!kept.has(f))
extra[f] = ''
}
return extra
}
/** 移交成功后发给看板的改动:标上待接管、换负责人、清掉本机会话 id 和 worktree,profile 不动,卡片上照旧是本机选的模型配置。 */
export function handoffStartedUiPatch(bundleKey: string, from: string, to: string, opts: { keepWorktreePath: boolean }): Record<string, unknown> {
return {
handoffBundleKey: bundleKey,
@@ -80,10 +85,6 @@ export function handoffStartedUiPatch(bundleKey: string, from: string, to: strin
reviewSessionId: null,
testSessionId: null,
prototypeSessionId: null,
profilePath: null,
brainstormProfilePath: null,
testProfilePath: null,
prototypeProfilePath: null,
prDiffFile: null,
// worktree 没删掉时(opts.keepWorktreePath)不碰这两个字段,让 UI 继续显示孤儿 worktree。
...(opts.keepWorktreePath ? {} : { worktreePath: null, worktreeExists: false }),
@@ -99,23 +100,21 @@ function definedEntries(o: Record<string, any>): Record<string, string> {
return out
}
/** 接管后写进 state 的字段:清掉两个共享的移交字段,写入会话 id 和本机 worktree 路径;profile 不写,接管方用自己机器上的模型配置。 */
export function handoffAcceptedStateExtra(local: {
sessions: HandoffSessions
profiles: HandoffProfiles
worktreePath?: string
}): Record<string, unknown> {
return {
handoffBundleKey: '',
handoffFrom: '',
...definedEntries(local.sessions),
...definedEntries(local.profiles),
...(local.worktreePath ? { worktreePath: local.worktreePath } : {}),
}
}
export function handoffAcceptedUiPatch(local: {
sessions: HandoffSessions
profiles: HandoffProfiles
worktreePath?: string
worktreeExists: boolean
missingSessionIds: string[]
@@ -124,7 +123,6 @@ export function handoffAcceptedUiPatch(local: {
handoffBundleKey: null,
handoffFrom: null,
...definedEntries(local.sessions),
...definedEntries(local.profiles),
// 显式 null 而非省略:省略会让 webview 侧留着接管前的旧 worktreePath,
// 与 worktreeExists: false 对不上。
worktreePath: local.worktreePath ?? null,
+9 -13
View File
@@ -1,4 +1,4 @@
import type { HandoffManifest, HandoffProfiles, HandoffSessions } from '../../cc/handoffManifest'
import type { HandoffManifest, HandoffSessions } from '../../cc/handoffManifest'
import type { IssueColumn } from '../../gitea/types'
import type { HandoffStore } from '../../oss/handoffStore'
import type { KanbanWebviewPanel } from '../KanbanPanel'
@@ -217,12 +217,6 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
...(str(state.prototypeSessionId) ? { prototypeSessionId: str(state.prototypeSessionId) } : {}),
...(str(state.reviewSessionId) ? { reviewSessionId: str(state.reviewSessionId) } : {}),
}
const profiles: HandoffProfiles = {
...(str(state.profilePath) ? { profilePath: str(state.profilePath) } : {}),
...(str(state.brainstormProfilePath) ? { brainstormProfilePath: str(state.brainstormProfilePath) } : {}),
...(str(state.testProfilePath) ? { testProfilePath: str(state.testProfilePath) } : {}),
...(str(state.prototypeProfilePath) ? { prototypeProfilePath: str(state.prototypeProfilePath) } : {}),
}
const fail = (message: string): HandoffStartResult => {
progress.dismiss()
@@ -292,7 +286,7 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
// ③ 打包 + 上传
say('收集会话')
const handoffSrc = { issueNumber, from: me, branch, ...(worktreeAbs && fs.existsSync(worktreeAbs) ? { worktreePath: worktreeAbs } : {}), sessions, profiles }
const handoffSrc = { issueNumber, from: me, branch, ...(worktreeAbs && fs.existsSync(worktreeAbs) ? { worktreePath: worktreeAbs } : {}), sessions }
const handoffRoots = { projectsRoot: claudeProjectsRoot(), codexRoot: defaultCodexSessionsDir() }
const bundleKey = handoffBundleKey({ owner, repo, issueNumber, at: new Date() })
let manifest: HandoffManifest
@@ -372,9 +366,10 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
await cleanupFeatureBranch({ workspaceRoot, branch, issueNumber, devBranch: settings.devBranch })
}
// ⑦ 本机墓碑:sid/profile/worktreePath 归零。worktree 没删掉时保留
// worktreePath,别把用户还留在磁盘上的 worktree 从 state 里抹掉。写墓碑
// 失败只告警——远端移交已经生效,不回滚。
// ⑦ 本机墓碑:会话 id、worktreePath、prDiffFile 归零,profile 留着——那是
// 本机选的模型配置,工单接回来还照旧用。worktree 没删掉时保留 worktreePath,
// 别把用户还留在磁盘上的 worktree 从 state 里抹掉。写墓碑失败只告警——
// 远端移交已经生效,不回滚。
await panel.mergeIssueState(issueNumber, handoffLocalTombstones({ keepWorktreePath: worktreeRemovalFailed })).catch((err) => {
const message = err instanceof Error ? err.message : String(err)
logger.add({ level: 'warn', source: 'panel', message: `移交 #${issueNumber}:写本机墓碑失败`, details: message })
@@ -583,12 +578,13 @@ export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber
...(pathRewrite ? { pathRewrite } : {}),
})
// ④ 本机字段 + 清共享字段;⑤ 删 OSS 上的会话包(失败只 warn)
// ④ 写本机会话 id 和 worktreePath、清共享字段,profile 不碰(用本机自己的
// 模型配置);⑤ 删 OSS 上的会话包(失败只 warn)
// missingSessionIds 是计算字段,board 只在加载时算;会话文件刚装进本机,
// 这里按真实文件重算一起下发,UI 不用等下次刷新就能双击 resume。
say('写入状态')
const [{ missingSessionIds = [] }] = await annotateMissingSessionIds([manifest.sessions])
const local = { sessions: manifest.sessions, profiles: manifest.profiles, worktreePath: worktreeAbs }
const local = { sessions: manifest.sessions, worktreePath: worktreeAbs }
await panel.mergeIssueState(issueNumber, handoffAcceptedStateExtra(local))
try {
await deleteHandoffBundle(store, bundleKey)