🐛 fix(vscode): 别的机器上开的会话在本机没有会话文件时,会话 id 行置灰不可双击,不再 resume 报错

This commit is contained in:
cruldra committed 2026-09-24 19:22:29 +08:00
1 parent 2b1e660cc8
commit bf4661e364
20 files changed
+508 -234

No files matched your search

+9 -12
View File
@@ -153,12 +153,14 @@ describe('payloads', () => {
profiles: {},
worktreePath: '/wt',
worktreeExists: true,
missingSessionIds: [],
})).toEqual({
handoffAttachmentId: null,
handoffFrom: null,
sessionId: 's',
worktreePath: '/wt',
worktreeExists: true,
missingSessionIds: [],
})
})
it('接管 UI patch:无 worktreePath 时显式置 null,不留旧值', () => {
@@ -166,35 +168,30 @@ describe('payloads', () => {
sessions: { sessionId: 's' },
profiles: {},
worktreeExists: false,
missingSessionIds: [],
})).toEqual({
handoffAttachmentId: null,
handoffFrom: null,
sessionId: 's',
worktreePath: null,
worktreeExists: false,
missingSessionIds: [],
})
})
it('接管 UI patch:带 reviewSessionFileExists 时一并下发,UI 不用等下次刷新才能点链接', () => {
it('接管 UI patch:缺失的会话 id 原样下发,UI 立刻知道哪几行不能 resume', () => {
expect(handoffAcceptedUiPatch({
sessions: { reviewSessionId: 'r' },
sessions: { sessionId: 's', reviewSessionId: 'r' },
profiles: {},
worktreeExists: false,
reviewSessionFileExists: true,
missingSessionIds: ['r'],
})).toEqual({
handoffAttachmentId: null,
handoffFrom: null,
sessionId: 's',
reviewSessionId: 'r',
worktreePath: null,
worktreeExists: false,
reviewSessionFileExists: true,
missingSessionIds: ['r'],
})
})
it('接管 UI patch:reviewSessionFileExists 未传时不出现在 patch 里', () => {
const patch = handoffAcceptedUiPatch({
sessions: { sessionId: 's' },
profiles: {},
worktreeExists: false,
})
expect(patch).not.toHaveProperty('reviewSessionFileExists')
})
})
+3 -2
View File
@@ -110,7 +110,7 @@ export function handoffAcceptedUiPatch(local: {
profiles: HandoffProfiles
worktreePath?: string
worktreeExists: boolean
reviewSessionFileExists?: boolean
missingSessionIds: string[]
}): Record<string, unknown> {
return {
handoffAttachmentId: null,
@@ -121,6 +121,7 @@ export function handoffAcceptedUiPatch(local: {
// 与 worktreeExists: false 对不上。
worktreePath: local.worktreePath ?? null,
worktreeExists: local.worktreeExists,
...(local.reviewSessionFileExists !== undefined ? { reviewSessionFileExists: local.reviewSessionFileExists } : {}),
// 同理空数组也要发:webview 里留着接管前(会话文件还没装进本机)算的缺失列表。
missingSessionIds: local.missingSessionIds,
}
}
+5 -7
View File
@@ -28,7 +28,7 @@ import {
uploadIssueAttachment,
} from '../../gitea/api'
import { logger } from '../../logging/logger'
import { defaultCodexSessionsDir } from '../../sessions/codexSessions'
import { annotateMissingSessionIds, defaultCodexSessionsDir } from '../../sessions/sessionFiles'
import { getSettings } from '../../settings/store'
import { makeNonce } from '../KanbanPanel'
import { cleanupFeatureBranch } from './branchCleanup'
@@ -464,11 +464,9 @@ export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber
})
// ④ 本机字段 + 清共享字段;⑤ 删附件(失败只 warn)
// reviewSessionFileExists 是计算字段,board 只在启动时算一次(annotateReviewSessionFileExists);
// 接管刚把 codex 会话文件装进本机,这里顺手算好一起下发,UI 不用等下次刷新才能点「审查会话」链接。
const reviewSessionId = manifest.sessions.reviewSessionId
const reviewSessionFileExists = !!reviewSessionId
&& manifest.codex.some(c => c.id.toLowerCase() === reviewSessionId.toLowerCase())
// missingSessionIds 是计算字段,board 只在加载时算;会话文件刚装进本机,
// 这里按真实文件重算一起下发,UI 不用等下次刷新就能双击 resume。
const [{ missingSessionIds = [] }] = await annotateMissingSessionIds([manifest.sessions])
const local = { sessions: manifest.sessions, profiles: manifest.profiles, worktreePath: worktreeAbs }
await panel.mergeIssueState(issueNumber, handoffAcceptedStateExtra(local))
try {
@@ -482,7 +480,7 @@ export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber
panel.postMessage({
type: 'issue/patch',
issueNumber,
patch: handoffAcceptedUiPatch({ ...local, worktreeExists: !!worktreeAbs, reviewSessionFileExists }),
patch: handoffAcceptedUiPatch({ ...local, worktreeExists: !!worktreeAbs, missingSessionIds }),
})
toast(panel, 'success', `已接管 #${issueNumber}(来自 ${manifest.from})${worktreeAbs ? `,worktree:${worktreeAbs}` : ''}`)
logger.add({ level: 'info', source: 'panel', message: `接管 #${issueNumber} 完成`, details: JSON.stringify({ manifest, worktreeAbs, pathRewrite }) })
+52 -18
View File
@@ -4,7 +4,6 @@ import type { ResumeKind } from './resumeTarget'
import { execFile } from 'node:child_process'
import * as fs from 'node:fs'
import { promises as fsp } from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { window, workspace } from 'vscode'
import { loginForToken } from '../../auth/identity'
@@ -20,6 +19,7 @@ import { ensureWorktree, resolveWorktreePath } from '../../git/worktree'
import { DEFAULT_WORKTREE_TEMPLATE, deriveSlug, resolveWorktreeDir } from '../../git/worktreePath'
import { updateIssueAssignees } from '../../gitea/api'
import { logger } from '../../logging/logger'
import { claudeSessionFileExists, codexSessionFileExists, defaultCodexSessionsDir } from '../../sessions/sessionFiles'
import { getSettings } from '../../settings/store'
import { webhookCoordinator } from '../../webhook/coordinator'
import { decideAutoReviewPath } from '../autoReviewDecision'
@@ -57,6 +57,26 @@ function nonEmptyString(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined
}
/**
* 会话 id 来自 Gitea 共享状态,别的机器上开的会话本机没有会话文件,resume
* 必然报「No conversation found」。各 resume 入口在开新终端前查一次,没有就
* 用这条 toast 拦下(已开着的 tab 直接复用,不受影响)。
*/
function rejectMissingSessionFile(panel: KanbanWebviewPanel, sessionId: string, issueNumber?: number): void {
logger.add({
level: 'warn',
source: 'panel',
message: `拒绝 resume #${issueNumber ?? '?'}:本机没有会话 ${sessionId} 的会话文件`,
})
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'error',
message: `本机没有会话 ${sessionId} 的会话文件(可能在别的机器上创建),无法 resume`,
dismissOnTimer: 8000,
})
}
export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId: string, sessionKind: ResumeKind, profilePath?: string, relCwd?: string, issueNumber?: number): Promise<void> {
// In-flight 锁:防 impl-tab-pre-create 钩子 await(pgrep pycharm 最多 15s)
// 期间用户重复点同一会话 id 触发重复 createTerminal。issueNumber 为空时
@@ -162,6 +182,10 @@ export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId:
existingByName.show(false)
return
}
if (!(await claudeSessionFileExists(sessionId))) {
rejectMissingSessionFile(panel, sessionId, issueNumber)
return
}
// 实施 resume 走新建 terminal 路径且 worktree 还在时触发 impl-tab-pre-create
// 钩子(启动 pycharm 等)。existingByName fast path 不调——pycharm 应该已
// 在跑,重复启会冲突;worktree 没了也不调——钩子拿 WORKTREE_PATH 开
@@ -263,6 +287,21 @@ export async function handleResumeReviewSession(panel: KanbanWebviewPanel, sessi
existing.show(false)
return
}
// Scan live terminals before creating a new one — survives panel
// reload / webview rebuild where `this.reviewTerminals` got wiped but
// the tab is still open.
const reviewTerminalName = `issue-${issueNumber}-审查`
const existingByName = panel.findExistingTerminal(reviewTerminalName)
if (existingByName) {
panel.reviewTerminals.set(sessionId, existingByName)
panel.trackSessionTerminal(existingByName, issueNumber, 'review')
existingByName.show(false)
return
}
if (!(await codexSessionFileExists(sessionId))) {
rejectMissingSessionFile(panel, sessionId, issueNumber)
return
}
// codex resume 只依赖 ~/.codex/sessions 下的 rollout 文件,与 worktree
// 无关:worktree 在则用它当 cwd,否则退回工作区根目录(与
// handleResumeSession 同款回退)。
@@ -289,17 +328,6 @@ export async function handleResumeReviewSession(panel: KanbanWebviewPanel, sessi
dismissOnTimer: 5000,
})
}
// Scan live terminals before creating a new one — survives panel
// reload / webview rebuild where `this.reviewTerminals` got wiped but
// the tab is still open.
const reviewTerminalName = `issue-${issueNumber}-审查`
const existingByName = panel.findExistingTerminal(reviewTerminalName)
if (existingByName) {
panel.reviewTerminals.set(sessionId, existingByName)
panel.trackSessionTerminal(existingByName, issueNumber, 'review')
existingByName.show(false)
return
}
// VS Code's default editor-tab icon (`>` arrow) does NOT honor the
// `color` option. Force a `circle-filled` codicon so the tab actually
// shows the issue color; keep `color` too for panel-view fallback.
@@ -547,9 +575,8 @@ async function triggerAutoReviewTabUnlocked(panel: KanbanWebviewPanel, opts: {
// ~/.codex/sessions/YYYY/MM/DD/ on every run; we extract thread_id from
// its name and persist it as reviewSessionId so the detail panel's
// resume button works after the user closes this tab.
const codexSessionsRoot = path.join(os.homedir(), '.codex', 'sessions')
const watcherPromise = watchForNewCodexSession({
baseDir: codexSessionsRoot,
baseDir: defaultCodexSessionsDir(),
timeoutMs: 120_000,
})
@@ -599,12 +626,10 @@ async function triggerAutoReviewTabUnlocked(panel: KanbanWebviewPanel, opts: {
if (!token)
return
await panel.mergeIssueState(opts.issueNumber, { reviewSessionId: threadId })
// reviewSessionFileExists 是计算字段不持久化;rollout 文件刚在本机
// 落盘,patch 里直接置 true 让 resume 链接立即可点。
panel.postMessage({
type: 'issue/patch',
issueNumber: opts.issueNumber,
patch: { reviewSessionId: threadId, reviewSessionFileExists: true },
patch: { reviewSessionId: threadId },
})
}
catch (err) {
@@ -990,7 +1015,8 @@ export async function handleContinueBrainstorm(panel: KanbanWebviewPanel, issueN
// 新建工单流程的 tab 叫 issue-new-{nonce}-规划,只能靠 newIssueTerminals 反查。
const linked = panel.newIssueTerminals.get(issueNumber)
const live = linked && linked.exitStatus === undefined ? linked : panel.findExistingTerminal(terminalName)
const continuePath = pickBrainstormContinuePath({ liveTerminal: !!live, sessionId })
const sessionFileExists = !!sessionId && await claudeSessionFileExists(sessionId)
const continuePath = pickBrainstormContinuePath({ liveTerminal: !!live, sessionId, sessionFileExists })
if (continuePath === 'inject' && live) {
panel.trackSessionTerminal(live, issueNumber, 'brainstorm')
@@ -1390,6 +1416,10 @@ export async function handleResumeTestSession(panel: KanbanWebviewPanel, session
existingByName.show(false)
return
}
if (!(await claudeSessionFileExists(sessionId))) {
rejectMissingSessionFile(panel, sessionId, issueNumber)
return
}
// 测试会话 resume 新建终端时也要触发 pre-create,与实施 resume / 首次
// startTestSession 对称(开 pycharm 等);复用已有终端的 fast path 已在
@@ -1668,6 +1698,10 @@ export async function handleResumePrototypeSession(panel: KanbanWebviewPanel, se
existingByName.show(false)
return
}
if (!(await claudeSessionFileExists(sessionId))) {
rejectMissingSessionFile(panel, sessionId, issueNumber)
return
}
// 回退主 worktree 时不触发 pre-create——主 worktree 不该被 pycharm 指向,
// 与实施 / 测试 resume 一致。