🐛 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

+5 -4
View File
@@ -98,11 +98,12 @@ export interface Issue {
*/
reviewSessionId?: string
/**
* Whether the codex session file (`~/.codex/sessions/YYYY/MM/DD/rollout-...-<id>.jsonl`)
* for `reviewSessionId` exists on this machine; computed on load, never
* persisted. codex resume 只依赖这个文件,与 worktree 无关。
* 本工单引用的会话 id(sessionId / implementSessionId / testSessionId /
* prototypeSessionId 走 claude,reviewSessionId 走 codex)里本机没有会话
* 文件的那些;加载时计算,不持久化。会话 id 来自共享状态,别的机器上开的
* 会话本机 resume 不了。缺省或空 = 全部能 resume。
*/
reviewSessionFileExists?: boolean
missingSessionIds?: string[]
/**
* Session id of the test conversation (a Claude Code session id). Set the
* first time the user manually starts a test session after the issue's PR
+4 -4
View File
@@ -25,7 +25,7 @@ import { loadIssues } from '../gitea/issueLoader'
import { overlayLocalIssueState } from '../issues/localState'
import { closeIssueByRef, mergeIssueState, readIssueState } from '../issues/stateRouter'
import { logger } from '../logging/logger'
import { annotateReviewSessionFileExists } from '../sessions/codexSessions'
import { annotateMissingSessionIds } from '../sessions/sessionFiles'
import { getEffectiveCommitProfilePath, getEffectivePrDiffSummaryProfilePath, getSettings } from '../settings/store'
import { webhookCoordinator } from '../webhook/coordinator'
import { loadYouTrackIssues } from '../youtrack/issueLoader'
@@ -1048,10 +1048,10 @@ export class KanbanWebviewPanel {
loadIssues({ host, token, owner, repo, workspaceRoot, scope }),
this.loadYouTrackSafe(),
])
// 本机记录(会话 id / worktree 等)叠加在共享状态之上,附加本机 codex
// 会话文件存在性,再补 live terminal 态。
// 本机记录(会话 id / worktree 等)叠加在共享状态之上,标出本机没有
// 会话文件的会话 id,再补 live terminal 态。
const issues = this.withLiveTerminalTabState(
await annotateReviewSessionFileExists(
await annotateMissingSessionIds(
overlayLocalIssueState(this.context, [...giteaIssues, ...youtrackList], workspaceRoot),
),
)
+4 -3
View File
@@ -1,14 +1,15 @@
/**
* 「续聊」按钮的三条路径。终端还活着就直接把提示词打进去(不管有没有
* 记录到 sessionId,新建工单流程里 id 可能还没落盘);终端没了但有
* sessionId 就 `--resume` 并带上提示词;两者都没有才新开会话。
* sessionId 且本机有它的会话文件就 `--resume` 并带上提示词;否则新开会话
* ——会话 id 来自共享状态,别的机器上开的会话本机 resume 不了。
*/
export type BrainstormContinuePath = 'inject' | 'resume' | 'fresh'
export function pickBrainstormContinuePath(opts: { liveTerminal: boolean, sessionId: string | undefined }): BrainstormContinuePath {
export function pickBrainstormContinuePath(opts: { liveTerminal: boolean, sessionId: string | undefined, sessionFileExists: boolean }): BrainstormContinuePath {
if (opts.liveTerminal)
return 'inject'
if (opts.sessionId)
if (opts.sessionId && opts.sessionFileExists)
return 'resume'
return 'fresh'
}
+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 一致。
+1 -1
View File
@@ -52,7 +52,7 @@ export type ExtensionToWebview
| { type: 'issues/loaded' }
| { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string, avatars?: Record<string, string> }
| { type: 'issues/error', message: string }
| { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prototypeFile?: string, prDiffFile?: string | null, sessionId?: string | null, implementSessionId?: string | null, reviewSessionId?: string | null, reviewSessionFileExists?: boolean, testSessionId?: string | null, prototypeSessionId?: 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, prototypeTabOpen?: boolean, profilePath?: string | null, brainstormProfilePath?: string | null, testProfilePath?: string | null, prototypeProfilePath?: string | null, assignees?: string[], handoffAttachmentId?: string | null, handoffFrom?: string | null } }
| { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prototypeFile?: string, prDiffFile?: string | null, sessionId?: string | null, implementSessionId?: string | null, reviewSessionId?: string | null, missingSessionIds?: string[], testSessionId?: string | null, prototypeSessionId?: 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, prototypeTabOpen?: boolean, profilePath?: string | null, brainstormProfilePath?: string | null, testProfilePath?: string | null, prototypeProfilePath?: string | null, assignees?: string[], handoffAttachmentId?: string | null, handoffFrom?: string | null } }
/** 某个会话 id 的开 tab 请求已处理完(开成、复用或被拒都算),webview 据此停掉行内 spinner。 */
| { type: 'session/tab-settled', sessionId: string }
| { type: 'issue/pr-diff-summary-done', issueNumber: number }
-85
View File
@@ -1,85 +0,0 @@
import type { Issue } from '../gitea/types'
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 { annotateReviewSessionFileExists, listCodexSessionIds } from './codexSessions'
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 UUID_A = '019dcb27-58a6-70a1-a5d1-bfc7f3ed9d0a'
const UUID_B = '11111111-2222-4333-8444-555555555555'
function writeRollout(baseDir: string, ymd: [string, string, string], uuid: string): void {
const dayDir = path.join(baseDir, ...ymd)
mkdirSync(dayDir, { recursive: true })
writeFileSync(path.join(dayDir, `rollout-2026-04-27T02-57-26-${uuid}.jsonl`), '{}\n')
}
describe('listCodexSessionIds', () => {
it('扫年/月/日三层目录,从文件名尾部提取全部会话 id', async () => {
const base = tempDir('codex-sessions-')
writeRollout(base, ['2026', '04', '27'], UUID_A)
writeRollout(base, ['2026', '05', '01'], UUID_B)
const ids = await listCodexSessionIds(base)
expect(ids).toEqual(new Set([UUID_A, UUID_B]))
})
it('忽略不符合 rollout-*-<uuid>.jsonl 的文件', async () => {
const base = tempDir('codex-sessions-')
const dayDir = path.join(base, '2026', '04', '27')
mkdirSync(dayDir, { recursive: true })
writeFileSync(path.join(dayDir, 'notes.txt'), 'x')
writeFileSync(path.join(dayDir, 'rollout-2026-04-27T02-57-26-not-a-uuid.jsonl'), '{}\n')
writeRollout(base, ['2026', '04', '27'], UUID_A)
const ids = await listCodexSessionIds(base)
expect(ids).toEqual(new Set([UUID_A]))
})
it('目录不存在返回空 Set', async () => {
const ids = await listCodexSessionIds(path.join(tempDir('codex-sessions-'), 'nope'))
expect(ids.size).toBe(0)
})
})
describe('annotateReviewSessionFileExists', () => {
const baseIssue = { number: 1, title: 't', column: 'review', htmlUrl: '' } as unknown as Issue
it('有会话文件的 issue 标 true,没有的标 false,无 reviewSessionId 的不加字段', async () => {
const base = tempDir('codex-sessions-')
writeRollout(base, ['2026', '04', '27'], UUID_A)
const issues: Issue[] = [
{ ...baseIssue, number: 1, reviewSessionId: UUID_A },
{ ...baseIssue, number: 2, reviewSessionId: UUID_B },
{ ...baseIssue, number: 3 },
]
const out = await annotateReviewSessionFileExists(issues, base)
expect(out[0].reviewSessionFileExists).toBe(true)
expect(out[1].reviewSessionFileExists).toBe(false)
expect('reviewSessionFileExists' in out[2]).toBe(false)
})
it('id 大小写不影响判定', async () => {
const base = tempDir('codex-sessions-')
writeRollout(base, ['2026', '04', '27'], UUID_A)
const out = await annotateReviewSessionFileExists(
[{ ...baseIssue, reviewSessionId: UUID_A.toUpperCase() }],
base,
)
expect(out[0].reviewSessionFileExists).toBe(true)
})
})
-55
View File
@@ -1,55 +0,0 @@
/**
* 枚举本机 codex 会话文件,判定「审查会话id」能否 resume。
*
* codex 把每个会话的 transcript 全局存在
* `~/.codex/sessions/YYYY/MM/DD/rollout-<ISO>-<uuid>.jsonl`,resume 只依赖
* 这个文件,与 worktree 无关——多人协作下 assignee 换机后,只要把会话文件
* 拷到目标机器就能 resume。
*/
import type { Issue } from '../gitea/types'
import { promises as fsp } from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { ROLLOUT_UUID_REGEX } from '../cc/codexSessionWatcher'
/** codex 会话根目录(全局,不随工作区变化)。 */
export function defaultCodexSessionsDir(): string {
return path.join(os.homedir(), '.codex', 'sessions')
}
/**
* 扫一遍 sessions 根目录(年/月/日三层),从文件名尾部提取所有会话 id
* 收进 Set(统一小写)。目录不存在或读取失败返回空 Set——调用方按
* 「本机没有会话文件」处理即可。
*/
export async function listCodexSessionIds(baseDir: string = defaultCodexSessionsDir()): Promise<Set<string>> {
const ids = new Set<string>()
let entries: string[]
try {
entries = await fsp.readdir(baseDir, { recursive: true })
}
catch {
return ids
}
for (const entry of entries) {
const m = path.basename(entry).match(ROLLOUT_UUID_REGEX)
if (m)
ids.add(m[1].toLowerCase())
}
return ids
}
/**
* 给带 reviewSessionId 的工单附加计算字段 `reviewSessionFileExists`
* (不落任何持久化)。整批工单只扫一次 sessions 目录;没有任何工单带
* id 时连扫都不扫。
*/
export async function annotateReviewSessionFileExists(issues: Issue[], baseDir?: string): Promise<Issue[]> {
if (!issues.some(i => typeof i.reviewSessionId === 'string' && i.reviewSessionId.length > 0))
return issues
const ids = await listCodexSessionIds(baseDir)
return issues.map(i => (i.reviewSessionId
? { ...i, reviewSessionFileExists: ids.has(i.reviewSessionId.toLowerCase()) }
: i))
}
+159
View File
@@ -0,0 +1,159 @@
import type { Issue } from '../gitea/types'
import { promises as fsp, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
annotateMissingSessionIds,
claudeSessionFileExists,
codexSessionFileExists,
listClaudeSessionIds,
listCodexSessionIds,
} from './sessionFiles'
const tempDirs: string[] = []
afterEach(() => {
vi.restoreAllMocks()
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 UUID_A = '019dcb27-58a6-70a1-a5d1-bfc7f3ed9d0a'
const UUID_B = '11111111-2222-4333-8444-555555555555'
const SID_1 = 'aaaaaaaa-1111-4111-8111-111111111111'
const SID_2 = 'bbbbbbbb-2222-4222-8222-222222222222'
const SID_3 = 'cccccccc-3333-4333-8333-333333333333'
function writeRollout(baseDir: string, ymd: [string, string, string], uuid: string): void {
const dayDir = path.join(baseDir, ...ymd)
mkdirSync(dayDir, { recursive: true })
writeFileSync(path.join(dayDir, `rollout-2026-04-27T02-57-26-${uuid}.jsonl`), '{}\n')
}
function writeTranscript(projectsRoot: string, project: string, sid: string): void {
const dir = path.join(projectsRoot, project)
mkdirSync(dir, { recursive: true })
writeFileSync(path.join(dir, `${sid}.jsonl`), '{}\n')
}
describe('listCodexSessionIds', () => {
it('扫年/月/日三层目录,从文件名尾部提取全部会话 id', async () => {
const base = tempDir('codex-sessions-')
writeRollout(base, ['2026', '04', '27'], UUID_A)
writeRollout(base, ['2026', '05', '01'], UUID_B)
const ids = await listCodexSessionIds(base)
expect(ids).toEqual(new Set([UUID_A, UUID_B]))
})
it('忽略不符合 rollout-*-<uuid>.jsonl 的文件', async () => {
const base = tempDir('codex-sessions-')
const dayDir = path.join(base, '2026', '04', '27')
mkdirSync(dayDir, { recursive: true })
writeFileSync(path.join(dayDir, 'notes.txt'), 'x')
writeFileSync(path.join(dayDir, 'rollout-2026-04-27T02-57-26-not-a-uuid.jsonl'), '{}\n')
writeRollout(base, ['2026', '04', '27'], UUID_A)
const ids = await listCodexSessionIds(base)
expect(ids).toEqual(new Set([UUID_A]))
})
it('目录不存在返回空 Set', async () => {
const ids = await listCodexSessionIds(path.join(tempDir('codex-sessions-'), 'nope'))
expect(ids.size).toBe(0)
})
})
describe('listClaudeSessionIds', () => {
it('收集各 project 目录下的 <sid>.jsonl,统一小写', async () => {
const root = tempDir('claude-projects-')
writeTranscript(root, '-Users-a-proj', SID_1.toUpperCase())
writeTranscript(root, '-Users-b-wt', SID_2)
const ids = await listClaudeSessionIds(root)
expect(ids).toEqual(new Set([SID_1, SID_2]))
})
it('不下钻 <sid>/ 子目录,忽略非 jsonl 文件和根目录下的普通文件', async () => {
const root = tempDir('claude-projects-')
writeTranscript(root, '-Users-a-proj', SID_1)
const subagents = path.join(root, '-Users-a-proj', SID_1, 'subagents')
mkdirSync(subagents, { recursive: true })
writeFileSync(path.join(subagents, `${SID_3}.jsonl`), '{}\n')
writeFileSync(path.join(root, '-Users-a-proj', 'notes.txt'), 'x')
writeFileSync(path.join(root, '.DS_Store'), 'x')
const ids = await listClaudeSessionIds(root)
expect(ids).toEqual(new Set([SID_1]))
})
it('根目录不存在返回空 Set', async () => {
const ids = await listClaudeSessionIds(path.join(tempDir('claude-projects-'), 'nope'))
expect(ids.size).toBe(0)
})
})
describe('单个会话文件判定', () => {
it('claudeSessionFileExists:任一 project 目录下有 transcript 即为 true', async () => {
const root = tempDir('claude-projects-')
writeTranscript(root, '-Users-b-wt', SID_1)
expect(await claudeSessionFileExists(SID_1, root)).toBe(true)
expect(await claudeSessionFileExists(SID_2, root)).toBe(false)
})
it('codexSessionFileExists:按 rollout 文件名尾部 uuid 判定', async () => {
const base = tempDir('codex-sessions-')
writeRollout(base, ['2026', '04', '27'], UUID_A)
expect(await codexSessionFileExists(UUID_A.toUpperCase(), base)).toBe(true)
expect(await codexSessionFileExists(UUID_B, base)).toBe(false)
})
})
describe('annotateMissingSessionIds', () => {
const baseIssue = { number: 1, title: 't', column: 'review', htmlUrl: '' } as unknown as Issue
it('claude + codex 混合:只列出本机没有会话文件的 id,全在本机的不加字段', async () => {
const claudeRoot = tempDir('claude-projects-')
const codexRoot = tempDir('codex-sessions-')
writeTranscript(claudeRoot, '-Users-a-proj', SID_1)
writeTranscript(claudeRoot, '-Users-b-wt', SID_2)
writeRollout(codexRoot, ['2026', '04', '27'], UUID_A)
const issues: Issue[] = [
{ ...baseIssue, number: 1, sessionId: SID_1.toUpperCase(), implementSessionId: SID_3, reviewSessionId: UUID_A },
{ ...baseIssue, number: 2, testSessionId: SID_3, prototypeSessionId: SID_2, reviewSessionId: UUID_B },
{ ...baseIssue, number: 3, sessionId: SID_2, reviewSessionId: UUID_A.toUpperCase() },
{ ...baseIssue, number: 4 },
]
const out = await annotateMissingSessionIds(issues, { claudeRoot, codexRoot })
expect(out[0].missingSessionIds).toEqual([SID_3])
expect(out[1].missingSessionIds).toEqual([SID_3, UUID_B])
expect('missingSessionIds' in out[2]).toBe(false)
expect('missingSessionIds' in out[3]).toBe(false)
})
it('没有任何工单带会话 id 时一个目录都不扫', async () => {
const readdir = vi.spyOn(fsp, 'readdir')
const out = await annotateMissingSessionIds([{ ...baseIssue }], {
claudeRoot: tempDir('claude-projects-'),
codexRoot: tempDir('codex-sessions-'),
})
expect(readdir).not.toHaveBeenCalled()
expect('missingSessionIds' in out[0]).toBe(false)
})
it('只带 claude 会话 id 时不扫 codex 目录', async () => {
const claudeRoot = tempDir('claude-projects-')
const codexRoot = tempDir('codex-sessions-')
const readdir = vi.spyOn(fsp, 'readdir')
const out = await annotateMissingSessionIds([{ ...baseIssue, sessionId: SID_1 }], { claudeRoot, codexRoot })
expect(readdir.mock.calls.some(([p]) => p === codexRoot)).toBe(false)
expect(readdir.mock.calls.some(([p]) => p === claudeRoot)).toBe(true)
expect(out[0].missingSessionIds).toEqual([SID_1])
})
})
+121
View File
@@ -0,0 +1,121 @@
/**
* 枚举本机 claude / codex 会话文件,判定工单上的会话 id 能否 resume。
*
* 会话 id 存在 Gitea 共享状态里,别的机器上开的会话本机没有 transcript,
* resume 必然失败;多人协作换机后只要把会话文件拷到本机就能 resume。
*
* - claude:`~/.claude/projects/<编码后的 cwd>/<sid>.jsonl`。新版 `claude --resume`
* 会搜全部 project 目录,任一 project 目录下有 `<sid>.jsonl` 即可 resume;
* `<sid>/` 子目录放的是 subagent 等附属文件,不用往下扫。
* - codex:`~/.codex/sessions/YYYY/MM/DD/rollout-<ISO>-<uuid>.jsonl`,全局存放,
* 与 worktree 无关。
*/
import type { Issue } from '../gitea/types'
import { promises as fsp } from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { ROLLOUT_UUID_REGEX } from '../cc/codexSessionWatcher'
import { CLAUDE_SESSION_FIELDS } from '../cc/handoffManifest'
import { claudeProjectsRoot, codexRolloutRelPathFor, listClaudeSessionCopies } from '../cc/sessionBundle'
/** codex 会话根目录(全局,不随工作区变化)。 */
export function defaultCodexSessionsDir(): string {
return path.join(os.homedir(), '.codex', 'sessions')
}
/**
* 扫一遍 sessions 根目录(年/月/日三层),从文件名尾部提取所有会话 id
* 收进 Set(统一小写)。目录不存在或读取失败返回空 Set——调用方按
* 「本机没有会话文件」处理即可。
*/
export async function listCodexSessionIds(baseDir: string = defaultCodexSessionsDir()): Promise<Set<string>> {
const ids = new Set<string>()
let entries: string[]
try {
entries = await fsp.readdir(baseDir, { recursive: true })
}
catch {
return ids
}
for (const entry of entries) {
const m = path.basename(entry).match(ROLLOUT_UUID_REGEX)
if (m)
ids.add(m[1].toLowerCase())
}
return ids
}
/**
* 扫 projects 根目录下每个 project 目录(只看这一层),收集 `<sid>.jsonl`
* 的会话 id(统一小写)。根目录不存在返回空 Set。
*/
export async function listClaudeSessionIds(projectsRoot: string = claudeProjectsRoot()): Promise<Set<string>> {
const ids = new Set<string>()
let projects: string[]
try {
projects = await fsp.readdir(projectsRoot)
}
catch {
return ids
}
await Promise.all(projects.map(async (name) => {
let files: string[]
try {
files = await fsp.readdir(path.join(projectsRoot, name))
}
// 根目录下混着的普通文件(.DS_Store 等)readdir 抛 ENOTDIR,跳过
catch {
return
}
for (const file of files) {
if (file.endsWith('.jsonl'))
ids.add(file.slice(0, -'.jsonl'.length).toLowerCase())
}
}))
return ids
}
/** 单个 claude 会话 id 本机是否有 transcript;resume 前的权威判定。 */
export async function claudeSessionFileExists(sid: string, projectsRoot: string = claudeProjectsRoot()): Promise<boolean> {
return (await listClaudeSessionCopies(sid, projectsRoot)).length > 0
}
/** 单个 codex 会话 id 本机是否有 rollout 文件;resume 前的权威判定。 */
export async function codexSessionFileExists(sid: string, codexRoot: string = defaultCodexSessionsDir()): Promise<boolean> {
return (await codexRolloutRelPathFor(sid, codexRoot)) !== undefined
}
type SessionIdFields = Pick<Issue, 'sessionId' | 'implementSessionId' | 'testSessionId' | 'prototypeSessionId' | 'reviewSessionId'>
function claudeIdsOf(item: SessionIdFields): string[] {
return CLAUDE_SESSION_FIELDS
.map(({ field }) => item[field])
.filter((id): id is string => typeof id === 'string' && id.length > 0)
}
function codexIdsOf(item: SessionIdFields): string[] {
return item.reviewSessionId ? [item.reviewSessionId] : []
}
/**
* 给工单附加计算字段 `missingSessionIds`(不落任何持久化):它引用的会话 id
* 里本机没有会话文件的那些;全部能 resume 时不加字段。每种会话目录整批最多
* 扫一次,没有任何工单引用该种 id 时连扫都不扫。
*/
export async function annotateMissingSessionIds<T extends SessionIdFields>(
items: T[],
opts: { claudeRoot?: string, codexRoot?: string } = {},
): Promise<Array<T & { missingSessionIds?: string[] }>> {
const [claudeIds, codexIds] = await Promise.all([
items.some(i => claudeIdsOf(i).length > 0) ? listClaudeSessionIds(opts.claudeRoot) : undefined,
items.some(i => codexIdsOf(i).length > 0) ? listCodexSessionIds(opts.codexRoot) : undefined,
])
return items.map((item) => {
const missing = [
...claudeIdsOf(item).filter(id => !claudeIds?.has(id.toLowerCase())),
...codexIdsOf(item).filter(id => !codexIds?.has(id.toLowerCase())),
]
return missing.length > 0 ? { ...item, missingSessionIds: missing } : item
})
}
+3 -3
View File
@@ -27,7 +27,7 @@ import { getLocalIssueState, mergeLocalIssueState, overlayLocalIssueState } from
import { logger } from '../logging/logger'
import { hasLiveIssueSessionTerminal } from '../panel/handlers/terminals'
import { DEFAULT_KANBAN_SCOPE, KANBAN_SCOPE_KEY } from '../panel/kanbanScope'
import { annotateReviewSessionFileExists } from '../sessions/codexSessions'
import { annotateMissingSessionIds } from '../sessions/sessionFiles'
import { getSettings } from '../settings/store'
import { decideEventOwnership } from './eventOwnership'
import { decideIssueAppend } from './issueAppend'
@@ -686,7 +686,7 @@ class WebhookCoordinator {
workspaceRoot: ws,
issueNumber: event.issueNumber,
})
const issue = loaded ? (await annotateReviewSessionFileExists(overlayLocalIssueState(this.ctx, [loaded], ws)))[0] : null
const issue = loaded ? (await annotateMissingSessionIds(overlayLocalIssueState(this.ctx, [loaded], ws)))[0] : null
if (issue) {
this.activePanel.noteIssueOwnership(issue)
this.activePanel.postMessage({ type: 'issue/append', issue, select: pending ? true : undefined })
@@ -803,7 +803,7 @@ class WebhookCoordinator {
workspaceRoot: ws,
issueNumber,
})
const issue = loaded ? (await annotateReviewSessionFileExists(overlayLocalIssueState(this.ctx, [loaded], ws)))[0] : null
const issue = loaded ? (await annotateMissingSessionIds(overlayLocalIssueState(this.ctx, [loaded], ws)))[0] : null
// 前端对 issue/append 按 number 去重原位替换:已在板上的卡就地刷新
// (含所在列),不在板上的新增——指派给我的工单借此实时上卡。
if (issue) {