🐛 fix(vscode): 别的机器上开的会话在本机没有会话文件时,会话 id 行置灰不可双击,不再 resume 报错
This commit is contained in:
1 parent
2b1e660cc8
commit
bf4661e364
20 files changed
+508
-234
No files matched your search
@@ -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
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 }) })
|
||||
|
||||
@@ -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 一致。
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user