🐛 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
|
reviewSessionId?: string
|
||||||
/**
|
/**
|
||||||
* Whether the codex session file (`~/.codex/sessions/YYYY/MM/DD/rollout-...-<id>.jsonl`)
|
* 本工单引用的会话 id(sessionId / implementSessionId / testSessionId /
|
||||||
* for `reviewSessionId` exists on this machine; computed on load, never
|
* prototypeSessionId 走 claude,reviewSessionId 走 codex)里本机没有会话
|
||||||
* persisted. codex resume 只依赖这个文件,与 worktree 无关。
|
* 文件的那些;加载时计算,不持久化。会话 id 来自共享状态,别的机器上开的
|
||||||
|
* 会话本机 resume 不了。缺省或空 = 全部能 resume。
|
||||||
*/
|
*/
|
||||||
reviewSessionFileExists?: boolean
|
missingSessionIds?: string[]
|
||||||
/**
|
/**
|
||||||
* Session id of the test conversation (a Claude Code session id). Set the
|
* 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
|
* 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 { overlayLocalIssueState } from '../issues/localState'
|
||||||
import { closeIssueByRef, mergeIssueState, readIssueState } from '../issues/stateRouter'
|
import { closeIssueByRef, mergeIssueState, readIssueState } from '../issues/stateRouter'
|
||||||
import { logger } from '../logging/logger'
|
import { logger } from '../logging/logger'
|
||||||
import { annotateReviewSessionFileExists } from '../sessions/codexSessions'
|
import { annotateMissingSessionIds } from '../sessions/sessionFiles'
|
||||||
import { getEffectiveCommitProfilePath, getEffectivePrDiffSummaryProfilePath, getSettings } from '../settings/store'
|
import { getEffectiveCommitProfilePath, getEffectivePrDiffSummaryProfilePath, getSettings } from '../settings/store'
|
||||||
import { webhookCoordinator } from '../webhook/coordinator'
|
import { webhookCoordinator } from '../webhook/coordinator'
|
||||||
import { loadYouTrackIssues } from '../youtrack/issueLoader'
|
import { loadYouTrackIssues } from '../youtrack/issueLoader'
|
||||||
@@ -1048,10 +1048,10 @@ export class KanbanWebviewPanel {
|
|||||||
loadIssues({ host, token, owner, repo, workspaceRoot, scope }),
|
loadIssues({ host, token, owner, repo, workspaceRoot, scope }),
|
||||||
this.loadYouTrackSafe(),
|
this.loadYouTrackSafe(),
|
||||||
])
|
])
|
||||||
// 本机记录(会话 id / worktree 等)叠加在共享状态之上,附加本机 codex
|
// 本机记录(会话 id / worktree 等)叠加在共享状态之上,标出本机没有
|
||||||
// 会话文件存在性,再补 live terminal 态。
|
// 会话文件的会话 id,再补 live terminal 态。
|
||||||
const issues = this.withLiveTerminalTabState(
|
const issues = this.withLiveTerminalTabState(
|
||||||
await annotateReviewSessionFileExists(
|
await annotateMissingSessionIds(
|
||||||
overlayLocalIssueState(this.context, [...giteaIssues, ...youtrackList], workspaceRoot),
|
overlayLocalIssueState(this.context, [...giteaIssues, ...youtrackList], workspaceRoot),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* 「续聊」按钮的三条路径。终端还活着就直接把提示词打进去(不管有没有
|
* 「续聊」按钮的三条路径。终端还活着就直接把提示词打进去(不管有没有
|
||||||
* 记录到 sessionId,新建工单流程里 id 可能还没落盘);终端没了但有
|
* 记录到 sessionId,新建工单流程里 id 可能还没落盘);终端没了但有
|
||||||
* sessionId 就 `--resume` 并带上提示词;两者都没有才新开会话。
|
* sessionId 且本机有它的会话文件就 `--resume` 并带上提示词;否则新开会话
|
||||||
|
* ——会话 id 来自共享状态,别的机器上开的会话本机 resume 不了。
|
||||||
*/
|
*/
|
||||||
export type BrainstormContinuePath = 'inject' | 'resume' | 'fresh'
|
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)
|
if (opts.liveTerminal)
|
||||||
return 'inject'
|
return 'inject'
|
||||||
if (opts.sessionId)
|
if (opts.sessionId && opts.sessionFileExists)
|
||||||
return 'resume'
|
return 'resume'
|
||||||
return 'fresh'
|
return 'fresh'
|
||||||
}
|
}
|
||||||
@@ -153,12 +153,14 @@ describe('payloads', () => {
|
|||||||
profiles: {},
|
profiles: {},
|
||||||
worktreePath: '/wt',
|
worktreePath: '/wt',
|
||||||
worktreeExists: true,
|
worktreeExists: true,
|
||||||
|
missingSessionIds: [],
|
||||||
})).toEqual({
|
})).toEqual({
|
||||||
handoffAttachmentId: null,
|
handoffAttachmentId: null,
|
||||||
handoffFrom: null,
|
handoffFrom: null,
|
||||||
sessionId: 's',
|
sessionId: 's',
|
||||||
worktreePath: '/wt',
|
worktreePath: '/wt',
|
||||||
worktreeExists: true,
|
worktreeExists: true,
|
||||||
|
missingSessionIds: [],
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
it('接管 UI patch:无 worktreePath 时显式置 null,不留旧值', () => {
|
it('接管 UI patch:无 worktreePath 时显式置 null,不留旧值', () => {
|
||||||
@@ -166,35 +168,30 @@ describe('payloads', () => {
|
|||||||
sessions: { sessionId: 's' },
|
sessions: { sessionId: 's' },
|
||||||
profiles: {},
|
profiles: {},
|
||||||
worktreeExists: false,
|
worktreeExists: false,
|
||||||
|
missingSessionIds: [],
|
||||||
})).toEqual({
|
})).toEqual({
|
||||||
handoffAttachmentId: null,
|
handoffAttachmentId: null,
|
||||||
handoffFrom: null,
|
handoffFrom: null,
|
||||||
sessionId: 's',
|
sessionId: 's',
|
||||||
worktreePath: null,
|
worktreePath: null,
|
||||||
worktreeExists: false,
|
worktreeExists: false,
|
||||||
|
missingSessionIds: [],
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
it('接管 UI patch:带 reviewSessionFileExists 时一并下发,UI 不用等下次刷新才能点链接', () => {
|
it('接管 UI patch:缺失的会话 id 原样下发,UI 立刻知道哪几行不能 resume', () => {
|
||||||
expect(handoffAcceptedUiPatch({
|
expect(handoffAcceptedUiPatch({
|
||||||
sessions: { reviewSessionId: 'r' },
|
sessions: { sessionId: 's', reviewSessionId: 'r' },
|
||||||
profiles: {},
|
profiles: {},
|
||||||
worktreeExists: false,
|
worktreeExists: false,
|
||||||
reviewSessionFileExists: true,
|
missingSessionIds: ['r'],
|
||||||
})).toEqual({
|
})).toEqual({
|
||||||
handoffAttachmentId: null,
|
handoffAttachmentId: null,
|
||||||
handoffFrom: null,
|
handoffFrom: null,
|
||||||
|
sessionId: 's',
|
||||||
reviewSessionId: 'r',
|
reviewSessionId: 'r',
|
||||||
worktreePath: null,
|
worktreePath: null,
|
||||||
worktreeExists: false,
|
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
|
profiles: HandoffProfiles
|
||||||
worktreePath?: string
|
worktreePath?: string
|
||||||
worktreeExists: boolean
|
worktreeExists: boolean
|
||||||
reviewSessionFileExists?: boolean
|
missingSessionIds: string[]
|
||||||
}): Record<string, unknown> {
|
}): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
handoffAttachmentId: null,
|
handoffAttachmentId: null,
|
||||||
@@ -121,6 +121,7 @@ export function handoffAcceptedUiPatch(local: {
|
|||||||
// 与 worktreeExists: false 对不上。
|
// 与 worktreeExists: false 对不上。
|
||||||
worktreePath: local.worktreePath ?? null,
|
worktreePath: local.worktreePath ?? null,
|
||||||
worktreeExists: local.worktreeExists,
|
worktreeExists: local.worktreeExists,
|
||||||
...(local.reviewSessionFileExists !== undefined ? { reviewSessionFileExists: local.reviewSessionFileExists } : {}),
|
// 同理空数组也要发:webview 里留着接管前(会话文件还没装进本机)算的缺失列表。
|
||||||
|
missingSessionIds: local.missingSessionIds,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -28,7 +28,7 @@ import {
|
|||||||
uploadIssueAttachment,
|
uploadIssueAttachment,
|
||||||
} from '../../gitea/api'
|
} from '../../gitea/api'
|
||||||
import { logger } from '../../logging/logger'
|
import { logger } from '../../logging/logger'
|
||||||
import { defaultCodexSessionsDir } from '../../sessions/codexSessions'
|
import { annotateMissingSessionIds, defaultCodexSessionsDir } from '../../sessions/sessionFiles'
|
||||||
import { getSettings } from '../../settings/store'
|
import { getSettings } from '../../settings/store'
|
||||||
import { makeNonce } from '../KanbanPanel'
|
import { makeNonce } from '../KanbanPanel'
|
||||||
import { cleanupFeatureBranch } from './branchCleanup'
|
import { cleanupFeatureBranch } from './branchCleanup'
|
||||||
@@ -464,11 +464,9 @@ export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber
|
|||||||
})
|
})
|
||||||
|
|
||||||
// ④ 本机字段 + 清共享字段;⑤ 删附件(失败只 warn)
|
// ④ 本机字段 + 清共享字段;⑤ 删附件(失败只 warn)
|
||||||
// reviewSessionFileExists 是计算字段,board 只在启动时算一次(annotateReviewSessionFileExists);
|
// missingSessionIds 是计算字段,board 只在加载时算;会话文件刚装进本机,
|
||||||
// 接管刚把 codex 会话文件装进本机,这里顺手算好一起下发,UI 不用等下次刷新才能点「审查会话」链接。
|
// 这里按真实文件重算一起下发,UI 不用等下次刷新就能双击 resume。
|
||||||
const reviewSessionId = manifest.sessions.reviewSessionId
|
const [{ missingSessionIds = [] }] = await annotateMissingSessionIds([manifest.sessions])
|
||||||
const reviewSessionFileExists = !!reviewSessionId
|
|
||||||
&& manifest.codex.some(c => c.id.toLowerCase() === reviewSessionId.toLowerCase())
|
|
||||||
const local = { sessions: manifest.sessions, profiles: manifest.profiles, worktreePath: worktreeAbs }
|
const local = { sessions: manifest.sessions, profiles: manifest.profiles, worktreePath: worktreeAbs }
|
||||||
await panel.mergeIssueState(issueNumber, handoffAcceptedStateExtra(local))
|
await panel.mergeIssueState(issueNumber, handoffAcceptedStateExtra(local))
|
||||||
try {
|
try {
|
||||||
@@ -482,7 +480,7 @@ export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber
|
|||||||
panel.postMessage({
|
panel.postMessage({
|
||||||
type: 'issue/patch',
|
type: 'issue/patch',
|
||||||
issueNumber,
|
issueNumber,
|
||||||
patch: handoffAcceptedUiPatch({ ...local, worktreeExists: !!worktreeAbs, reviewSessionFileExists }),
|
patch: handoffAcceptedUiPatch({ ...local, worktreeExists: !!worktreeAbs, missingSessionIds }),
|
||||||
})
|
})
|
||||||
toast(panel, 'success', `已接管 #${issueNumber}(来自 ${manifest.from})${worktreeAbs ? `,worktree:${worktreeAbs}` : ''}`)
|
toast(panel, 'success', `已接管 #${issueNumber}(来自 ${manifest.from})${worktreeAbs ? `,worktree:${worktreeAbs}` : ''}`)
|
||||||
logger.add({ level: 'info', source: 'panel', message: `接管 #${issueNumber} 完成`, details: JSON.stringify({ manifest, worktreeAbs, pathRewrite }) })
|
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 { execFile } from 'node:child_process'
|
||||||
import * as fs from 'node:fs'
|
import * as fs from 'node:fs'
|
||||||
import { promises as fsp } from 'node:fs'
|
import { promises as fsp } from 'node:fs'
|
||||||
import * as os from 'node:os'
|
|
||||||
import * as path from 'node:path'
|
import * as path from 'node:path'
|
||||||
import { window, workspace } from 'vscode'
|
import { window, workspace } from 'vscode'
|
||||||
import { loginForToken } from '../../auth/identity'
|
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 { DEFAULT_WORKTREE_TEMPLATE, deriveSlug, resolveWorktreeDir } from '../../git/worktreePath'
|
||||||
import { updateIssueAssignees } from '../../gitea/api'
|
import { updateIssueAssignees } from '../../gitea/api'
|
||||||
import { logger } from '../../logging/logger'
|
import { logger } from '../../logging/logger'
|
||||||
|
import { claudeSessionFileExists, codexSessionFileExists, defaultCodexSessionsDir } from '../../sessions/sessionFiles'
|
||||||
import { getSettings } from '../../settings/store'
|
import { getSettings } from '../../settings/store'
|
||||||
import { webhookCoordinator } from '../../webhook/coordinator'
|
import { webhookCoordinator } from '../../webhook/coordinator'
|
||||||
import { decideAutoReviewPath } from '../autoReviewDecision'
|
import { decideAutoReviewPath } from '../autoReviewDecision'
|
||||||
@@ -57,6 +57,26 @@ function nonEmptyString(value: unknown): string | undefined {
|
|||||||
return typeof value === 'string' && value.length > 0 ? value : 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> {
|
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)
|
// In-flight 锁:防 impl-tab-pre-create 钩子 await(pgrep pycharm 最多 15s)
|
||||||
// 期间用户重复点同一会话 id 触发重复 createTerminal。issueNumber 为空时
|
// 期间用户重复点同一会话 id 触发重复 createTerminal。issueNumber 为空时
|
||||||
@@ -162,6 +182,10 @@ export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId:
|
|||||||
existingByName.show(false)
|
existingByName.show(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!(await claudeSessionFileExists(sessionId))) {
|
||||||
|
rejectMissingSessionFile(panel, sessionId, issueNumber)
|
||||||
|
return
|
||||||
|
}
|
||||||
// 实施 resume 走新建 terminal 路径且 worktree 还在时触发 impl-tab-pre-create
|
// 实施 resume 走新建 terminal 路径且 worktree 还在时触发 impl-tab-pre-create
|
||||||
// 钩子(启动 pycharm 等)。existingByName fast path 不调——pycharm 应该已
|
// 钩子(启动 pycharm 等)。existingByName fast path 不调——pycharm 应该已
|
||||||
// 在跑,重复启会冲突;worktree 没了也不调——钩子拿 WORKTREE_PATH 开
|
// 在跑,重复启会冲突;worktree 没了也不调——钩子拿 WORKTREE_PATH 开
|
||||||
@@ -263,6 +287,21 @@ export async function handleResumeReviewSession(panel: KanbanWebviewPanel, sessi
|
|||||||
existing.show(false)
|
existing.show(false)
|
||||||
return
|
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
|
// codex resume 只依赖 ~/.codex/sessions 下的 rollout 文件,与 worktree
|
||||||
// 无关:worktree 在则用它当 cwd,否则退回工作区根目录(与
|
// 无关:worktree 在则用它当 cwd,否则退回工作区根目录(与
|
||||||
// handleResumeSession 同款回退)。
|
// handleResumeSession 同款回退)。
|
||||||
@@ -289,17 +328,6 @@ export async function handleResumeReviewSession(panel: KanbanWebviewPanel, sessi
|
|||||||
dismissOnTimer: 5000,
|
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
|
// VS Code's default editor-tab icon (`>` arrow) does NOT honor the
|
||||||
// `color` option. Force a `circle-filled` codicon so the tab actually
|
// `color` option. Force a `circle-filled` codicon so the tab actually
|
||||||
// shows the issue color; keep `color` too for panel-view fallback.
|
// 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
|
// ~/.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
|
// its name and persist it as reviewSessionId so the detail panel's
|
||||||
// resume button works after the user closes this tab.
|
// resume button works after the user closes this tab.
|
||||||
const codexSessionsRoot = path.join(os.homedir(), '.codex', 'sessions')
|
|
||||||
const watcherPromise = watchForNewCodexSession({
|
const watcherPromise = watchForNewCodexSession({
|
||||||
baseDir: codexSessionsRoot,
|
baseDir: defaultCodexSessionsDir(),
|
||||||
timeoutMs: 120_000,
|
timeoutMs: 120_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -599,12 +626,10 @@ async function triggerAutoReviewTabUnlocked(panel: KanbanWebviewPanel, opts: {
|
|||||||
if (!token)
|
if (!token)
|
||||||
return
|
return
|
||||||
await panel.mergeIssueState(opts.issueNumber, { reviewSessionId: threadId })
|
await panel.mergeIssueState(opts.issueNumber, { reviewSessionId: threadId })
|
||||||
// reviewSessionFileExists 是计算字段不持久化;rollout 文件刚在本机
|
|
||||||
// 落盘,patch 里直接置 true 让 resume 链接立即可点。
|
|
||||||
panel.postMessage({
|
panel.postMessage({
|
||||||
type: 'issue/patch',
|
type: 'issue/patch',
|
||||||
issueNumber: opts.issueNumber,
|
issueNumber: opts.issueNumber,
|
||||||
patch: { reviewSessionId: threadId, reviewSessionFileExists: true },
|
patch: { reviewSessionId: threadId },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
@@ -990,7 +1015,8 @@ export async function handleContinueBrainstorm(panel: KanbanWebviewPanel, issueN
|
|||||||
// 新建工单流程的 tab 叫 issue-new-{nonce}-规划,只能靠 newIssueTerminals 反查。
|
// 新建工单流程的 tab 叫 issue-new-{nonce}-规划,只能靠 newIssueTerminals 反查。
|
||||||
const linked = panel.newIssueTerminals.get(issueNumber)
|
const linked = panel.newIssueTerminals.get(issueNumber)
|
||||||
const live = linked && linked.exitStatus === undefined ? linked : panel.findExistingTerminal(terminalName)
|
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) {
|
if (continuePath === 'inject' && live) {
|
||||||
panel.trackSessionTerminal(live, issueNumber, 'brainstorm')
|
panel.trackSessionTerminal(live, issueNumber, 'brainstorm')
|
||||||
@@ -1390,6 +1416,10 @@ export async function handleResumeTestSession(panel: KanbanWebviewPanel, session
|
|||||||
existingByName.show(false)
|
existingByName.show(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!(await claudeSessionFileExists(sessionId))) {
|
||||||
|
rejectMissingSessionFile(panel, sessionId, issueNumber)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 测试会话 resume 新建终端时也要触发 pre-create,与实施 resume / 首次
|
// 测试会话 resume 新建终端时也要触发 pre-create,与实施 resume / 首次
|
||||||
// startTestSession 对称(开 pycharm 等);复用已有终端的 fast path 已在
|
// startTestSession 对称(开 pycharm 等);复用已有终端的 fast path 已在
|
||||||
@@ -1668,6 +1698,10 @@ export async function handleResumePrototypeSession(panel: KanbanWebviewPanel, se
|
|||||||
existingByName.show(false)
|
existingByName.show(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!(await claudeSessionFileExists(sessionId))) {
|
||||||
|
rejectMissingSessionFile(panel, sessionId, issueNumber)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 回退主 worktree 时不触发 pre-create——主 worktree 不该被 pycharm 指向,
|
// 回退主 worktree 时不触发 pre-create——主 worktree 不该被 pycharm 指向,
|
||||||
// 与实施 / 测试 resume 一致。
|
// 与实施 / 测试 resume 一致。
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export type ExtensionToWebview
|
|||||||
| { type: 'issues/loaded' }
|
| { type: 'issues/loaded' }
|
||||||
| { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string, avatars?: Record<string, string> }
|
| { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string, avatars?: Record<string, string> }
|
||||||
| { type: 'issues/error', message: 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。 */
|
/** 某个会话 id 的开 tab 请求已处理完(开成、复用或被拒都算),webview 据此停掉行内 spinner。 */
|
||||||
| { type: 'session/tab-settled', sessionId: string }
|
| { type: 'session/tab-settled', sessionId: string }
|
||||||
| { type: 'issue/pr-diff-summary-done', issueNumber: number }
|
| { 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 { logger } from '../logging/logger'
|
||||||
import { hasLiveIssueSessionTerminal } from '../panel/handlers/terminals'
|
import { hasLiveIssueSessionTerminal } from '../panel/handlers/terminals'
|
||||||
import { DEFAULT_KANBAN_SCOPE, KANBAN_SCOPE_KEY } from '../panel/kanbanScope'
|
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 { getSettings } from '../settings/store'
|
||||||
import { decideEventOwnership } from './eventOwnership'
|
import { decideEventOwnership } from './eventOwnership'
|
||||||
import { decideIssueAppend } from './issueAppend'
|
import { decideIssueAppend } from './issueAppend'
|
||||||
@@ -686,7 +686,7 @@ class WebhookCoordinator {
|
|||||||
workspaceRoot: ws,
|
workspaceRoot: ws,
|
||||||
issueNumber: event.issueNumber,
|
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) {
|
if (issue) {
|
||||||
this.activePanel.noteIssueOwnership(issue)
|
this.activePanel.noteIssueOwnership(issue)
|
||||||
this.activePanel.postMessage({ type: 'issue/append', issue, select: pending ? true : undefined })
|
this.activePanel.postMessage({ type: 'issue/append', issue, select: pending ? true : undefined })
|
||||||
@@ -803,7 +803,7 @@ class WebhookCoordinator {
|
|||||||
workspaceRoot: ws,
|
workspaceRoot: ws,
|
||||||
issueNumber,
|
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 去重原位替换:已在板上的卡就地刷新
|
// 前端对 issue/append 按 number 去重原位替换:已在板上的卡就地刷新
|
||||||
// (含所在列),不在板上的新增——指派给我的工单借此实时上卡。
|
// (含所在列),不在板上的新增——指派给我的工单借此实时上卡。
|
||||||
if (issue) {
|
if (issue) {
|
||||||
|
|||||||
@@ -3,18 +3,26 @@ import { pickBrainstormContinuePath } from '../../src/panel/brainstormContinue'
|
|||||||
|
|
||||||
describe('pickBrainstormContinuePath', () => {
|
describe('pickBrainstormContinuePath', () => {
|
||||||
it('types the prompt into the live terminal when the brainstorm tab is still open', () => {
|
it('types the prompt into the live terminal when the brainstorm tab is still open', () => {
|
||||||
expect(pickBrainstormContinuePath({ liveTerminal: true, sessionId: 'abc' })).toBe('inject')
|
expect(pickBrainstormContinuePath({ liveTerminal: true, sessionId: 'abc', sessionFileExists: true })).toBe('inject')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('resumes the recorded session with the prompt when the tab is closed', () => {
|
it('resumes the recorded session with the prompt when the tab is closed', () => {
|
||||||
expect(pickBrainstormContinuePath({ liveTerminal: false, sessionId: 'abc' })).toBe('resume')
|
expect(pickBrainstormContinuePath({ liveTerminal: false, sessionId: 'abc', sessionFileExists: true })).toBe('resume')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('starts a fresh session when the recorded session has no transcript on this machine', () => {
|
||||||
|
expect(pickBrainstormContinuePath({ liveTerminal: false, sessionId: 'abc', sessionFileExists: false })).toBe('fresh')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('starts a fresh session when the issue has no brainstorm session yet', () => {
|
it('starts a fresh session when the issue has no brainstorm session yet', () => {
|
||||||
expect(pickBrainstormContinuePath({ liveTerminal: false, sessionId: undefined })).toBe('fresh')
|
expect(pickBrainstormContinuePath({ liveTerminal: false, sessionId: undefined, sessionFileExists: false })).toBe('fresh')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('prefers the live terminal even when no session id has been recorded yet', () => {
|
it('prefers the live terminal even when no session id has been recorded yet', () => {
|
||||||
expect(pickBrainstormContinuePath({ liveTerminal: true, sessionId: undefined })).toBe('inject')
|
expect(pickBrainstormContinuePath({ liveTerminal: true, sessionId: undefined, sessionFileExists: false })).toBe('inject')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prefers the live terminal even when the session transcript is not on this machine', () => {
|
||||||
|
expect(pickBrainstormContinuePath({ liveTerminal: true, sessionId: 'abc', sessionFileExists: false })).toBe('inject')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -15,6 +15,7 @@ import { useManagedSessions } from './hooks/useManagedSessions'
|
|||||||
import { useProfiles } from './hooks/useProfiles'
|
import { useProfiles } from './hooks/useProfiles'
|
||||||
import { filterIssues } from './lib/issueFilter'
|
import { filterIssues } from './lib/issueFilter'
|
||||||
import { compareIssuesInColumn } from './lib/issueSort'
|
import { compareIssuesInColumn } from './lib/issueSort'
|
||||||
|
import { isSessionMissing } from './lib/missingSession'
|
||||||
import { COLUMN_ORDER } from './types'
|
import { COLUMN_ORDER } from './types'
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
@@ -231,10 +232,17 @@ export function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
const sid = selectedIssue?.implementSessionId || selectedIssue?.sessionId
|
// 本机没有会话文件的 id(别的机器上开的会话)resume 必失败,跳过。
|
||||||
|
const implSid = isSessionMissing(selectedIssue, selectedIssue?.implementSessionId)
|
||||||
|
? undefined
|
||||||
|
: selectedIssue?.implementSessionId
|
||||||
|
const brainstormSid = isSessionMissing(selectedIssue, selectedIssue?.sessionId)
|
||||||
|
? undefined
|
||||||
|
: selectedIssue?.sessionId
|
||||||
|
const sid = implSid || brainstormSid
|
||||||
if (sid) {
|
if (sid) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const isImpl = !!selectedIssue?.implementSessionId
|
const isImpl = !!implSid
|
||||||
const cwd = isImpl ? selectedIssue?.worktreePath : undefined
|
const cwd = isImpl ? selectedIssue?.worktreePath : undefined
|
||||||
// 实施 resume 用 profilePath;头脑风暴 resume 用 brainstormProfilePath。
|
// 实施 resume 用 profilePath;头脑风暴 resume 用 brainstormProfilePath。
|
||||||
const profileForResume = isImpl
|
const profileForResume = isImpl
|
||||||
|
|||||||
@@ -5,11 +5,15 @@ import { ArrowRightLeft, CircleSlash, Download, Eraser, ExternalLink, GitMerge,
|
|||||||
import { useMemo, useRef } from 'react'
|
import { useMemo, useRef } from 'react'
|
||||||
import { isIssueLocked } from '../lib/dependencies'
|
import { isIssueLocked } from '../lib/dependencies'
|
||||||
import { isIssueMine, issueOwnerLogin } from '../lib/issueOwnership'
|
import { isIssueMine, issueOwnerLogin } from '../lib/issueOwnership'
|
||||||
|
import { isSessionMissing } from '../lib/missingSession'
|
||||||
import { toReadOnlySchema } from '../lib/readOnlySchema'
|
import { toReadOnlySchema } from '../lib/readOnlySchema'
|
||||||
import { COLUMN_LABELS, COLUMN_ORDER } from '../types'
|
import { COLUMN_LABELS, COLUMN_ORDER } from '../types'
|
||||||
import { PropertyGrid } from './property-grid'
|
import { PropertyGrid } from './property-grid'
|
||||||
import { Tooltip } from './ui/tooltip'
|
import { Tooltip } from './ui/tooltip'
|
||||||
|
|
||||||
|
/** 会话 id 在共享状态里、本机却没有会话文件时,各会话行的统一说明。 */
|
||||||
|
const MISSING_SESSION_DESC = '本机没有该会话文件(会话在别的机器上创建),无法 resume'
|
||||||
|
|
||||||
/** 跨机绝对路径只比 basename(同名 .json)映射到本机 profiles 列表。 */
|
/** 跨机绝对路径只比 basename(同名 .json)映射到本机 profiles 列表。 */
|
||||||
function matchProfileOption(
|
function matchProfileOption(
|
||||||
current: string | undefined,
|
current: string | undefined,
|
||||||
@@ -194,10 +198,10 @@ export function IssueDetailPanel({
|
|||||||
: undefined
|
: undefined
|
||||||
const prDiffSummaryRunning = issue ? isPrDiffSummaryRunning(issue.number) : false
|
const prDiffSummaryRunning = issue ? isPrDiffSummaryRunning(issue.number) : false
|
||||||
|
|
||||||
// 三个会话 id 行(头脑风暴/实施/审查)共用一个"上次触发时间戳"表,
|
// 各会话 id 行(头脑风暴/实施/审查/测试/原型)共用一个"上次触发时间戳"表,
|
||||||
// 防止用户连续快速双击同一行时触发多次 resume —— extension 端 findExistingTerminal
|
// 防止用户连续快速双击同一行时触发多次 resume —— extension 端 findExistingTerminal
|
||||||
// 在前一个 createTerminal 尚未完成时查不到已存在的终端,会重复开 tab。
|
// 在前一个 createTerminal 尚未完成时查不到已存在的终端,会重复开 tab。
|
||||||
// key 用 sessionId 字符串本身(足够区分三种行 + 不同工单)。
|
// key 用 sessionId 字符串本身(足够区分不同行 + 不同工单)。
|
||||||
const lastFiredAtRef = useRef<Map<string, number>>(new Map())
|
const lastFiredAtRef = useRef<Map<string, number>>(new Map())
|
||||||
const RESUME_THROTTLE_MS = 800
|
const RESUME_THROTTLE_MS = 800
|
||||||
const throttleBySessionId = (sid: string): boolean => {
|
const throttleBySessionId = (sid: string): boolean => {
|
||||||
@@ -242,9 +246,12 @@ export function IssueDetailPanel({
|
|||||||
// - 工单未完成 → 一直有 ▶ 续聊。插件把续聊提示词送进对应会话:
|
// - 工单未完成 → 一直有 ▶ 续聊。插件把续聊提示词送进对应会话:
|
||||||
// 规划终端开着就直接打进去,关了就 --resume 带提示词,没会话就新开。
|
// 规划终端开着就直接打进去,关了就 --resume 带提示词,没会话就新开。
|
||||||
// - brainstormTabOpen → 再加一个 X 关闭 tab
|
// - brainstormTabOpen → 再加一个 X 关闭 tab
|
||||||
|
// - 会话 id 本机没有会话文件 → 只读不可双击,续聊会新开会话
|
||||||
const hasSession = !!(issue?.sessionId && issue.sessionId.length > 0)
|
const hasSession = !!(issue?.sessionId && issue.sessionId.length > 0)
|
||||||
|
const missing = isSessionMissing(issue, issue?.sessionId)
|
||||||
|
const canContinue = issue?.column !== 'done'
|
||||||
const secondaryActions: RowAction[] = []
|
const secondaryActions: RowAction[] = []
|
||||||
if (issue?.column !== 'done') {
|
if (canContinue) {
|
||||||
secondaryActions.push({
|
secondaryActions.push({
|
||||||
icon: <Play className="size-3.5" />,
|
icon: <Play className="size-3.5" />,
|
||||||
title: '续聊:把续聊提示词发给头脑风暴会话',
|
title: '续聊:把续聊提示词发给头脑风暴会话',
|
||||||
@@ -274,6 +281,17 @@ export function IssueDetailPanel({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if (missing) {
|
||||||
|
return {
|
||||||
|
key: 'sessionId',
|
||||||
|
label: '头脑风暴会话id',
|
||||||
|
type: 'action' as const,
|
||||||
|
readOnly: true,
|
||||||
|
description: canContinue ? `${MISSING_SESSION_DESC};点 ▶ 续聊会新开一个规划会话` : MISSING_SESSION_DESC,
|
||||||
|
actionIcon: <Terminal className="size-3.5" />,
|
||||||
|
secondaryActions,
|
||||||
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
key: 'sessionId',
|
key: 'sessionId',
|
||||||
label: '头脑风暴会话id',
|
label: '头脑风暴会话id',
|
||||||
@@ -293,27 +311,14 @@ export function IssueDetailPanel({
|
|||||||
secondaryActions,
|
secondaryActions,
|
||||||
}
|
}
|
||||||
})(),
|
})(),
|
||||||
// 实施会话即使 worktree 已被清掉也保留可双击的 resume 入口:
|
(() => {
|
||||||
// extension 端 handleResumeSession 会在 worktree 路径不存在时退回
|
// 实施会话即使 worktree 已被清掉也保留可双击的 resume 入口:
|
||||||
// 工作区根目录,并通过 toast 告知用户;description 在两种状态下
|
// extension 端 handleResumeSession 会在 worktree 路径不存在时退回
|
||||||
// 给出不同的提示文案。
|
// 工作区根目录,并通过 toast 告知用户;description 在两种状态下
|
||||||
{
|
// 给出不同的提示文案。
|
||||||
key: 'implementSessionId',
|
// 本机没有会话文件时只读且不给 ▶:重新实施会先删远程 feature 分支,
|
||||||
label: '实施会话id',
|
// 会把别的机器上的实施进度一并抹掉。
|
||||||
type: 'action',
|
const secondaryActions: RowAction[] = (() => {
|
||||||
pending: issue?.implementSessionId ? isSessionTabPending(issue.implementSessionId) : false,
|
|
||||||
description: issue?.worktreeExists
|
|
||||||
? '双击在新终端运行 claude --resume <id> 恢复实施对话(cwd 为 worktree)'
|
|
||||||
: 'worktree 已清理,将在工作区根目录恢复(cc 可能提示原 cwd 不存在)',
|
|
||||||
actionIcon: <Terminal className="size-3.5" />,
|
|
||||||
onAction: (v) => {
|
|
||||||
if (typeof v !== 'string' || v.length === 0)
|
|
||||||
return
|
|
||||||
if (!throttleBySessionId(v))
|
|
||||||
return
|
|
||||||
onResumeSession(v, 'implement', issue?.profilePath, issue?.worktreePath, issue?.number)
|
|
||||||
},
|
|
||||||
secondaryActions: (() => {
|
|
||||||
if (issue?.implementTabOpen) {
|
if (issue?.implementTabOpen) {
|
||||||
return [{
|
return [{
|
||||||
icon: <X className="size-3.5" />,
|
icon: <X className="size-3.5" />,
|
||||||
@@ -333,9 +338,38 @@ export function IssueDetailPanel({
|
|||||||
|| locked,
|
|| locked,
|
||||||
onClick: () => onImplement(issue.number, issue.planFile ?? '', issue.profilePath, issue.sessionId),
|
onClick: () => onImplement(issue.number, issue.planFile ?? '', issue.profilePath, issue.sessionId),
|
||||||
}]
|
}]
|
||||||
})(),
|
})()
|
||||||
},
|
if (isSessionMissing(issue, issue?.implementSessionId)) {
|
||||||
issue?.reviewSessionFileExists
|
return {
|
||||||
|
key: 'implementSessionId',
|
||||||
|
label: '实施会话id',
|
||||||
|
type: 'action' as const,
|
||||||
|
readOnly: true,
|
||||||
|
description: `${MISSING_SESSION_DESC};要在本机接着实施,请让对方把工单移交过来`,
|
||||||
|
actionIcon: <Terminal className="size-3.5" />,
|
||||||
|
secondaryActions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
key: 'implementSessionId',
|
||||||
|
label: '实施会话id',
|
||||||
|
type: 'action' as const,
|
||||||
|
pending: issue?.implementSessionId ? isSessionTabPending(issue.implementSessionId) : false,
|
||||||
|
description: issue?.worktreeExists
|
||||||
|
? '双击在新终端运行 claude --resume <id> 恢复实施对话(cwd 为 worktree)'
|
||||||
|
: 'worktree 已清理,将在工作区根目录恢复(cc 可能提示原 cwd 不存在)',
|
||||||
|
actionIcon: <Terminal className="size-3.5" />,
|
||||||
|
onAction: (v: unknown) => {
|
||||||
|
if (typeof v !== 'string' || v.length === 0)
|
||||||
|
return
|
||||||
|
if (!throttleBySessionId(v))
|
||||||
|
return
|
||||||
|
onResumeSession(v, 'implement', issue?.profilePath, issue?.worktreePath, issue?.number)
|
||||||
|
},
|
||||||
|
secondaryActions,
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
issue?.reviewSessionId && !isSessionMissing(issue, issue.reviewSessionId)
|
||||||
? {
|
? {
|
||||||
key: 'reviewSessionId',
|
key: 'reviewSessionId',
|
||||||
label: '审查会话id',
|
label: '审查会话id',
|
||||||
@@ -364,7 +398,7 @@ export function IssueDetailPanel({
|
|||||||
type: 'action',
|
type: 'action',
|
||||||
readOnly: true,
|
readOnly: true,
|
||||||
description: issue?.reviewSessionId
|
description: issue?.reviewSessionId
|
||||||
? '本机没有该 codex 会话文件,无法 resume;点 ▶ 对当前 PR 重新起一个审查会话'
|
? `${MISSING_SESSION_DESC};点 ▶ 对当前 PR 重新起一个审查会话`
|
||||||
: '还没有审查会话;点 ▶ 对当前 PR 启动 codex 审查(等同 PR 事件自动触发的那次)',
|
: '还没有审查会话;点 ▶ 对当前 PR 启动 codex 审查(等同 PR 事件自动触发的那次)',
|
||||||
secondaryActions: issue?.pr
|
secondaryActions: issue?.pr
|
||||||
? [{
|
? [{
|
||||||
@@ -383,8 +417,11 @@ export function IssueDetailPanel({
|
|||||||
// 条件与 handleStartTestSession 一致:worktree 还在就进 worktree 测,
|
// 条件与 handleStartTestSession 一致:worktree 还在就进 worktree 测,
|
||||||
// 代码本就在工作目录,不需要 PR;worktree 已清理才回退主工作区,
|
// 代码本就在工作目录,不需要 PR;worktree 已清理才回退主工作区,
|
||||||
// 那条路径要靠 PR 让 cc 了解合并进来的改动。
|
// 那条路径要靠 PR 让 cc 了解合并进来的改动。
|
||||||
|
// 会话 id 本机没有会话文件 → 只读不可双击,按状态 3 的条件给 ▶ 重新起一个
|
||||||
|
// (启动会覆盖旧 testSessionId)。
|
||||||
const hasSession = !!(issue?.testSessionId && issue.testSessionId.length > 0)
|
const hasSession = !!(issue?.testSessionId && issue.testSessionId.length > 0)
|
||||||
const canStart = !hasSession && (!!issue?.worktreeExists || !!issue?.pr)
|
const missing = isSessionMissing(issue, issue?.testSessionId)
|
||||||
|
const canStart = (!hasSession || missing) && (!!issue?.worktreeExists || !!issue?.pr)
|
||||||
const secondaryActions: RowAction[] = []
|
const secondaryActions: RowAction[] = []
|
||||||
if (hasSession && issue?.testTabOpen) {
|
if (hasSession && issue?.testTabOpen) {
|
||||||
secondaryActions.push({
|
secondaryActions.push({
|
||||||
@@ -406,6 +443,17 @@ export function IssueDetailPanel({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if (missing) {
|
||||||
|
return {
|
||||||
|
key: 'testSessionId',
|
||||||
|
label: '测试会话id',
|
||||||
|
type: 'action' as const,
|
||||||
|
readOnly: true,
|
||||||
|
description: `${MISSING_SESSION_DESC};点 ▶ 新起一个测试会话(worktree 还在就在 worktree 里测,已清理则需要工单有已合并的 PR)`,
|
||||||
|
actionIcon: <Terminal className="size-3.5" />,
|
||||||
|
secondaryActions,
|
||||||
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
key: 'testSessionId',
|
key: 'testSessionId',
|
||||||
label: '测试会话id',
|
label: '测试会话id',
|
||||||
@@ -428,7 +476,10 @@ export function IssueDetailPanel({
|
|||||||
(() => {
|
(() => {
|
||||||
// 原型会话 id 行:与测试行同构的三态,差别是启动不看 PR——原型只照
|
// 原型会话 id 行:与测试行同构的三态,差别是启动不看 PR——原型只照
|
||||||
// 工单正文画界面,任何阶段都能起;前置工单没做完时按钮禁用。
|
// 工单正文画界面,任何阶段都能起;前置工单没做完时按钮禁用。
|
||||||
|
// 会话 id 本机没有会话文件 → 只读不可双击,同样给 ▶ 重新起一个
|
||||||
|
// (启动会覆盖旧 prototypeSessionId)。
|
||||||
const hasSession = !!(issue?.prototypeSessionId && issue.prototypeSessionId.length > 0)
|
const hasSession = !!(issue?.prototypeSessionId && issue.prototypeSessionId.length > 0)
|
||||||
|
const missing = isSessionMissing(issue, issue?.prototypeSessionId)
|
||||||
const secondaryActions: RowAction[] = []
|
const secondaryActions: RowAction[] = []
|
||||||
if (hasSession && issue?.prototypeTabOpen) {
|
if (hasSession && issue?.prototypeTabOpen) {
|
||||||
secondaryActions.push({
|
secondaryActions.push({
|
||||||
@@ -440,7 +491,7 @@ export function IssueDetailPanel({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
else if (!hasSession) {
|
else if (!hasSession || missing) {
|
||||||
secondaryActions.push({
|
secondaryActions.push({
|
||||||
icon: <Play className="size-3.5" />,
|
icon: <Play className="size-3.5" />,
|
||||||
title: locked ? lockTitle : '启动原型会话',
|
title: locked ? lockTitle : '启动原型会话',
|
||||||
@@ -451,6 +502,17 @@ export function IssueDetailPanel({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if (missing) {
|
||||||
|
return {
|
||||||
|
key: 'prototypeSessionId',
|
||||||
|
label: '原型会话id',
|
||||||
|
type: 'action' as const,
|
||||||
|
readOnly: true,
|
||||||
|
description: `${MISSING_SESSION_DESC};点 ▶ 新起一个原型会话`,
|
||||||
|
actionIcon: <Terminal className="size-3.5" />,
|
||||||
|
secondaryActions,
|
||||||
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
key: 'prototypeSessionId',
|
key: 'prototypeSessionId',
|
||||||
label: '原型会话id',
|
label: '原型会话id',
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export type ExtensionToWebview
|
|||||||
| { type: 'issues/loaded' }
|
| { type: 'issues/loaded' }
|
||||||
| { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string, avatars?: Record<string, string> }
|
| { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string, avatars?: Record<string, string> }
|
||||||
| { type: 'issues/error', message: 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。 */
|
/** 某个会话 id 的开 tab 请求已处理完(开成、复用或被拒都算),webview 据此停掉行内 spinner。 */
|
||||||
| { type: 'session/tab-settled', sessionId: string }
|
| { type: 'session/tab-settled', sessionId: string }
|
||||||
| { type: 'issue/pr-diff-summary-done', issueNumber: number }
|
| { type: 'issue/pr-diff-summary-done', issueNumber: number }
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { isSessionMissing } from './missingSession'
|
||||||
|
|
||||||
|
describe('isSessionMissing', () => {
|
||||||
|
it('会话 id 在缺失列表里(忽略大小写)→ true', () => {
|
||||||
|
expect(isSessionMissing({ missingSessionIds: ['ABC'] }, 'abc')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('不在缺失列表、列表缺省、id 为空 → false', () => {
|
||||||
|
expect(isSessionMissing({ missingSessionIds: ['abc'] }, 'def')).toBe(false)
|
||||||
|
expect(isSessionMissing({}, 'abc')).toBe(false)
|
||||||
|
expect(isSessionMissing(undefined, 'abc')).toBe(false)
|
||||||
|
expect(isSessionMissing({ missingSessionIds: ['abc'] }, undefined)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { Issue } from '../types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会话 id 在本机没有会话文件(别的机器上开的会话),resume 必失败。
|
||||||
|
* 详情面板的会话行和看板的键盘 resume 快捷键都据此跳过。
|
||||||
|
*/
|
||||||
|
export function isSessionMissing(issue: Pick<Issue, 'missingSessionIds'> | null | undefined, sid: string | undefined): boolean {
|
||||||
|
return !!sid && !!issue?.missingSessionIds?.some(m => m.toLowerCase() === sid.toLowerCase())
|
||||||
|
}
|
||||||
@@ -71,10 +71,10 @@ export interface Issue {
|
|||||||
/** Backend-agnostic review session id (v1 stores a codex thread id). */
|
/** Backend-agnostic review session id (v1 stores a codex thread id). */
|
||||||
reviewSessionId?: string
|
reviewSessionId?: string
|
||||||
/**
|
/**
|
||||||
* 本机是否存在 reviewSessionId 对应的 codex 会话文件;扩展端加载时计算,
|
* 本工单引用的会话 id 里本机没有会话文件的那些(claude 四种 + codex 审查);
|
||||||
* 不持久化。codex resume 只依赖这个文件,与 worktree 无关。
|
* 扩展端加载时计算,不持久化。缺省或空 = 全部能 resume。
|
||||||
*/
|
*/
|
||||||
reviewSessionFileExists?: boolean
|
missingSessionIds?: string[]
|
||||||
/**
|
/**
|
||||||
* Claude Code session id of the test conversation; manually started after
|
* Claude Code session id of the test conversation; manually started after
|
||||||
* the PR is merged, resumable from the detail panel.
|
* the PR is merged, resumable from the detail panel.
|
||||||
|
|||||||
Reference in new issue
Block a user