diff --git a/docs-site/docs/en/slash-commands.md b/docs-site/docs/en/slash-commands.md index 217bdd180..ba2d3002d 100644 --- a/docs-site/docs/en/slash-commands.md +++ b/docs-site/docs/en/slash-commands.md @@ -14,6 +14,7 @@ Just send these commands directly in a topic, and the daemon intercepts and hand | `/retry` | Retry the most recent failed or interrupted turn (10s cooldown) | | `/restart` | Restart the CLI process (preserving the session context) | | `/close` | Close the session and send a recoverable card (including the CLI's own resume command) | +| `/cleanup-wt ` | Retry a persisted worktree cleanup after a final removal failure; revalidates authorization, active sessions, worktree identity, and safety state before deleting | | `/fork ` | Fork the current session with full context into a new sub-topic of the same topic group; the source session keeps running untouched (Claude family / Codex terminal only) | | `/forklist` | Re-post the current session's forked-task panel with live/closed status and links to the child topics | | `/fork --create ` | Clone the current session into a freshly-created group instead of a sub-topic | diff --git a/docs-site/docs/zh/slash-commands.md b/docs-site/docs/zh/slash-commands.md index 463c5652a..17834dc97 100644 --- a/docs-site/docs/zh/slash-commands.md +++ b/docs-site/docs/zh/slash-commands.md @@ -14,6 +14,7 @@ | `/retry` | 重试最近一个失败或被中断的 turn(10s 冷却) | | `/restart` | 重启 CLI 进程(保留 session 上下文) | | `/close` | 关闭会话并发送可恢复卡片(含 CLI 自身 resume 命令) | +| `/cleanup-wt ` | worktree 最终删除失败后重试已持久化的清理任务;删除前会重新校验权限、活动会话、worktree 身份和安全状态 | | `/fork <任务>` | 继承当前会话的完整上下文,在同一话题群新建并行子话题;源会话原样继续(仅 Claude 系 / Codex 终端模式) | | `/forklist` | 重发当前会话的分身任务面板,显示运行/结束状态和子话题链接 | | `/fork --create <群名>` | 不建子话题,改为把当前会话分身到一个新建群 | diff --git a/src/cli/pm2-readonly-client.ts b/src/cli/pm2-readonly-client.ts index 31e7f78ab..9af8880c6 100644 --- a/src/cli/pm2-readonly-client.ts +++ b/src/cli/pm2-readonly-client.ts @@ -83,8 +83,9 @@ pm2.Client.pingDaemon((alive: boolean) => { if (mode === 'jlist') { pm2.list((error: Error | null | undefined, list: unknown[]) => { if (error) fail(`PM2 read-only jlist failed: ${error.message}`); - process.stdout.write(JSON.stringify(Array.isArray(list) ? list : [])); - pm2.disconnect(() => process.exit(0)); + process.stdout.write(JSON.stringify(Array.isArray(list) ? list : []), () => { + pm2.disconnect(() => process.exit(0)); + }); }); return; } diff --git a/src/core/command-handler.ts b/src/core/command-handler.ts index 7fb01a6b8..bb5aa7697 100644 --- a/src/core/command-handler.ts +++ b/src/core/command-handler.ts @@ -3,6 +3,7 @@ * Extracted from daemon.ts for modularity. */ import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; import { join, resolve, basename } from 'node:path'; import { config } from '../config.js'; import { buildTerminalUrl } from './terminal-url.js'; @@ -11,12 +12,12 @@ import { unauthorizedOutcomeFor, triggerUserAuthApplies } from '../services/trig import { beginBytedcliLogin, completeBytedcliLogin, pendingBytedcliChallenge, hasBytedcliHome } from '../services/bytedcli-auth.js'; import { isKnownLarkUserScope } from '../utils/lark-scope-catalog.js'; import { readGlobalConfig, repoPickerScanOptions, isWorkflowFeatureEnabled } from '../global-config.js'; -import { closeResidualIsLocal, describeCloseResidual } from './close-residual.js'; +import { closeResidualIsLocal, describeCloseResidual, parseCloseResidual } from './close-residual.js'; import * as sessionStore from '../services/session-store.js'; import * as scheduleStore from '../services/schedule-store.js'; import * as scheduler from './scheduler.js'; import { scanProjects, scanMultipleProjects, describeProjectDir } from '../services/project-scanner.js'; -import { createRepoWorktree, pushWorktreeBranch } from '../services/git-worktree.js'; +import { createRepoWorktree, pushWorktreeBranch, isLinkedWorktree, mainWorktreeFor, removeRepoWorktree, withWorktreeTargetLock, worktreeRootFor, worktreeSafetyStatus } from '../services/git-worktree.js'; import { worktreeSlugFromContextAI } from '../services/worktree-slug-ai.js'; import { isRemoteBackendSession, resolvePairedSpawnBackendType } from './persistent-backend.js'; import { buildRepoSelectCard, buildAdoptSelectCard, buildCodexAppThreadSelectCard, buildSlashListCard, getCliDisplayName, buildConfigCard, buildForkPanelCard, buildAdoptBlockedCard } from '../im/lark/card-builder.js'; @@ -105,6 +106,7 @@ import type { DaemonSession } from './types.js'; import { t, localeForBot, type Locale } from '../i18n/index.js'; import { runSkillsImCommand } from './skills/im-command.js'; import { fetchDaemonIpc } from './daemon-ipc-auth.js'; +import { findOnlineDaemon } from '../utils/daemon-discovery.js'; import { updateSessionTitle } from './session-title.js'; import { requestAgentSessionRename } from './session-rename.js'; import { hasProtectedSessionMutationOwnership } from './session-mutation-guard.js'; @@ -120,7 +122,11 @@ import { resumeStartsFresh } from '../services/resume-fresh-policy.js'; import { retryCooldownRemaining, markRetryAttempt } from '../services/failed-turn-retry.js'; import { readGroupCollaborationMode, writeGroupCollaborationMode } from '../services/group-collaboration-mode-store.js'; import { readProjectGroup } from '../services/project-group-store.js'; +import { getBotUnionId } from '../services/bot-union-ids-store.js'; +import { isTeamBot } from '../services/team-bots-store.js'; +import { isPlatformTeamBot } from '../services/platform-team-store.js'; import { projectCoordinator } from '../services/project-coordinator-runtime.js'; +import { deleteWorktreeCleanupJob, getWorktreeCleanupJob, putWorktreeCleanupJob } from '../services/worktree-cleanup-store.js'; import { runProjectGroupSlashCommand } from './project-group-command.js'; // ─── Exported constants ────────────────────────────────────────────────────── @@ -141,7 +147,7 @@ export { DAEMON_COMMANDS, PASSTHROUGH_COMMANDS }; * card buttons routable, but for these that record is a phantom conversation * that pollutes the dashboard's session list. Handle them without a session. */ -export const SESSIONLESS_DAEMON_COMMANDS = new Set(['/group', '/g', '/project', '/list-slash-command', '/slash', '/botconfig', '/dashboard', '/sessions', '/skills', '/vc-auth', '/watch-comment', '/issue']); +export const SESSIONLESS_DAEMON_COMMANDS = new Set(['/group', '/g', '/project', '/list-slash-command', '/slash', '/botconfig', '/dashboard', '/sessions', '/skills', '/vc-auth', '/watch-comment', '/issue', '/cleanup-wt']); const SLASH_GROUP_NAME_MAX_UTF16_LENGTH = 50; @@ -304,6 +310,169 @@ const MULTILINE_COMMANDS = new Set(['/schedule', '/role', '/fork']); // import without the daemon graph); re-exported here for existing callers. export { validateWorkingDir }; +function resolveCurrentChatWorkingDirForRepo(ds: DaemonSession | undefined, loc: ReturnType): string | undefined { + const current = ds?.workingDir ? validateWorkingDir(ds.workingDir, loc) : undefined; + if (current?.ok) return current.resolvedPath; + const oncall = ds ? findOncallChat(ds.larkAppId, ds.chatId)?.workingDir : undefined; + const resolvedOncall = oncall ? validateWorkingDir(oncall, loc) : undefined; + if (resolvedOncall?.ok) return resolvedOncall.resolvedPath; + if (!ds) return undefined; + const peers = sessionStore.findActiveChatScopeSessionsByChat(ds.chatId); + for (const peer of peers) { + if (!peer.workingDir) continue; + const resolved = validateWorkingDir(peer.workingDir, loc); + if (resolved.ok) return resolved.resolvedPath; + } + return undefined; +} + + +/** One row per session for the confirm-card table. Reuses botDisplayName so the + * peer-name fallback (own config → bots-info.json → appId) still applies; the + * current session is tagged so the user can tell it apart. */ +function closeWorktreeSessionRow( + s: import('../types.js').Session, + isCurrent: boolean, + loc: Locale, +): { bot: string; task: string } { + const botName = s.larkAppId ? botDisplayName(s.larkAppId) : t('cmd.close.worktree_bot_unknown', undefined, loc); + const preview = (s.currentTurnTitle || s.lastUserPrompt || s.title || s.sessionId || '—') + .replace(/\s*\n+\s*/g, ' ') + .slice(0, 60) || '—'; + return { + bot: isCurrent ? `${botName} ${t('cmd.close.worktree_current_tag', undefined, loc)}` : botName, + task: preview, + }; +} + +/** Compact inline detail cell for dirty files / unpushed commits (backticked, + * space-separated, clipped). Only rendered when the list is non-empty. */ +function closeWorktreeInlineDetail(items: string[], limit = 6): string { + const visible = items.slice(0, limit).map(item => `\`${item}\``); + if (items.length > limit) visible.push(`… +${items.length - limit}`); + return ` ${visible.join(' ')}`; +} + +function trustedTeamBotApp(larkAppId: string): boolean { + const unionId = getBotUnionId(config.session.dataDir, larkAppId); + return !!unionId && ( + isTeamBot(config.session.dataDir, unionId) + || isPlatformTeamBot(config.session.dataDir, unionId) + ); +} + +function closeWorktreeConfirmationState(args: { + sessionId: string; + worktreeDir: string; + siblingSessionIds: string[]; + safetyFingerprint: string; + invokerOpenId: string; +}): string { + return createHash('sha256') + .update(JSON.stringify({ + sessionId: args.sessionId, + worktreeDir: resolve(args.worktreeDir), + siblingSessionIds: [...args.siblingSessionIds].sort(), + safetyFingerprint: args.safetyFingerprint, + invokerOpenId: args.invokerOpenId, + })) + .digest('hex'); +} + +function buildCloseWorktreeConfirmCard(args: { + rootId: string; + sessionId: string; + worktreeDir: string; + sessions: import('../types.js').Session[]; + dirty: boolean; + dirtyCount: number; + dirtyFiles: string[]; + ahead: number; + unpushedCommits: string[]; + invokerOpenId: string; + confirmationState: string; + loc: Locale; +}): string { + const { loc } = args; + const hasRisk = args.dirty || args.ahead > 0; + + const rows = args.sessions.map((s, i) => closeWorktreeSessionRow(s, i === 0, loc)); + + // Safety checks: one compact line each; a detail line follows only when the + // corresponding list is non-empty, so a clean worktree never prints "none\nnone". + const checkLines: string[] = [ + args.dirty + ? t('cmd.close.worktree_check_dirty_warn', { n: String(args.dirtyCount) }, loc) + : t('cmd.close.worktree_check_dirty_ok', undefined, loc), + ]; + if (args.dirty && args.dirtyFiles.length) checkLines.push(closeWorktreeInlineDetail(args.dirtyFiles)); + checkLines.push( + args.ahead > 0 + ? t('cmd.close.worktree_check_ahead_warn', { n: String(args.ahead) }, loc) + : t('cmd.close.worktree_check_ahead_ok', undefined, loc), + ); + if (args.ahead > 0 && args.unpushedCommits.length) checkLines.push(closeWorktreeInlineDetail(args.unpushedCommits)); + + const elements = [ + { + tag: 'markdown', + content: `**🗂️ ${t('cmd.close.worktree_confirm_path_label', undefined, loc)}**\n\`${args.worktreeDir}\``, + }, + { + tag: 'markdown', + content: `**💬 ${t('cmd.close.worktree_confirm_sessions', { count: String(args.sessions.length) }, loc)}**`, + }, + { + tag: 'table', + page_size: 10, + row_height: 'low', + header_style: { + text_align: 'left', text_size: 'normal', background_style: 'grey', + text_color: 'default', bold: true, lines: 1, + }, + columns: [ + { name: 'bot', display_name: t('cmd.close.worktree_col_bot', undefined, loc), data_type: 'text', width: '140px' }, + { name: 'task', display_name: t('cmd.close.worktree_col_task', undefined, loc), data_type: 'text', width: 'auto' }, + ], + rows, + }, + { tag: 'hr' }, + { + tag: 'markdown', + content: `**🔎 ${t('cmd.close.worktree_checks_label', undefined, loc)}**\n${checkLines.join('\n')}`, + }, + { + tag: 'markdown', + content: `${t(hasRisk ? 'cmd.close.worktree_confirm_effect' : 'cmd.close.worktree_effect_safe', undefined, loc)}`, + }, + { + tag: 'action', + actions: [{ + tag: 'button', + text: { tag: 'plain_text', content: t('cmd.close.worktree_confirm_button', undefined, loc) }, + type: 'danger', + value: { + action: 'close_worktree_confirm', + root_id: args.rootId, + session_id: args.sessionId, + invoker_open_id: args.invokerOpenId, + confirmation_state: args.confirmationState, + }, + }], + }, + ]; + + return JSON.stringify({ + schema: '2.0', + config: { update_multi: true, wide_screen_mode: true }, + header: { + title: { tag: 'plain_text', content: `⚠️ ${t('cmd.close.worktree_confirm_title', undefined, loc)}` }, + template: hasRisk ? 'red' : 'orange', + }, + body: { direction: 'vertical', elements }, + }); +} + // `resolveRepoSelection` now lives in ./repo-selection.js (leaf module the topic // header's spec resolver can import without the daemon graph); re-exported here // for existing callers, same as `validateWorkingDir` above. @@ -312,8 +481,8 @@ export { resolveRepoSelection } from './repo-selection.js'; // 话题指令头解析器住在 ./topic-header.js(leaf,纯函数);这里重新导出,让原本 // 找 `parseForceTopicInvocation` 的调用方在同一个模块面上拿到它的升级版。 // -// `parseForceTopicInvocation` 已由 `parseTopicHeader` 完全取代并删除:它只认「`/t` 必须 -// 在第 0 位」,认不出带标题的头部,留着会让两套判定在路由与 daemon 之间打架。 +// 主路由由 `parseTopicHeader` 负责可读标题与指令头;旧解析器只保留为 +// `/th`、`/tw`、`/t here|worktree` 生命周期兼容面的纯函数与测试入口。 export { parseTopicHeader, isTopicHeader, @@ -327,6 +496,28 @@ export { type TopicHeaderDirective, } from './topic-header.js'; +export type ForceTopicMode = 'default' | 'here' | 'worktree'; + +/** Parse lifecycle aliases retained by the worktree command surface. */ +export function parseForceTopicInvocation(content: string): { prompt: string; mode: ForceTopicMode } | null { + const trimmed = content.trimStart(); + const alias = /^\/(th|tw)(?:\s+([\s\S]*))?$/i.exec(trimmed); + if (alias) return { + prompt: (alias[2] ?? '').trim(), + mode: alias[1]!.toLowerCase() === 'tw' ? 'worktree' : 'here', + }; + const match = /^\/(t|topic)(?:\s+([\s\S]*))?$/i.exec(trimmed); + if (!match) return null; + const rawPrompt = (match[2] ?? '').trim(); + const variant = /^(here|worktree)(?:\s+([\s\S]*))?$/i.exec(rawPrompt); + return variant + ? { + prompt: (variant[2] ?? '').trim(), + mode: variant[1]!.toLowerCase() === 'worktree' ? 'worktree' : 'here', + } + : { prompt: rawPrompt, mode: 'default' }; +} + /** Parse a user-authored slash command after leading @mentions have already * been stripped. Messages that look like command examples or command lists * are intentionally left for the CLI instead of being intercepted by the @@ -390,6 +581,14 @@ function botDisplayName(larkAppId: string): string { const bot = getBot(larkAppId); return bot.botName ?? getCliDisplayName(bot.config.cliId) ?? larkAppId; } catch { + try { + const p = join(config.session.dataDir, 'bots-info.json'); + if (existsSync(p)) { + const entries: Array<{ larkAppId?: string; botName?: string | null; cliId?: string | null }> = JSON.parse(readFileSync(p, 'utf-8')); + const found = entries.find(e => e.larkAppId === larkAppId); + return found?.botName || found?.cliId || larkAppId; + } + } catch { /* fall through */ } return larkAppId; } } @@ -438,6 +637,7 @@ export interface CommandHandlerDeps { sessionReply: (rootId: string, content: string, msgType?: string, larkAppId?: string, turnId?: string, opts?: WorkerSessionReplyOptions) => Promise; getActiveCount: () => number; lastRepoScan: Map; + prepareTurn?: (ds: DaemonSession, turnId: string) => Promise | undefined; /** Immutable Lark placement captured by the daemon for this slash-command * invocation. Unlike session state, it remains valid after close/replace. */ invocationReplyTarget?: FrozenSessionReplyTarget; @@ -1614,11 +1814,75 @@ export async function handleCommand( } break; } + case '/cleanup-wt': { + const appId = larkAppId ?? ds?.larkAppId; + const cleanupId = message.content.replace(/^\/cleanup-wt\s*/i, '').trim(); + if (!appId || !cleanupId) { + await sessionReply(rootId, '用法:`/cleanup-wt `'); + break; + } + if (!canOperate(appId, message.chatId ?? ds?.chatId, message.senderId, message.senderUnionId)) { + await sessionReply(rootId, t('daemon.cmd_allowed_users_only', { cmd: '/cleanup-wt' }, loc)); + break; + } + let job; + try { + job = getWorktreeCleanupJob(config.session.dataDir, cleanupId); + } catch (err) { + await sessionReply(rootId, `⚠️ 无法读取 worktree 清理任务:${err instanceof Error ? err.message : String(err)}`); + break; + } + if (!job || job.larkAppId !== appId) { + await sessionReply(rootId, '未找到该 worktree 清理任务。'); + break; + } + try { + const containingRoot = await worktreeRootFor(job.worktreeDir); + const main = containingRoot ? await mainWorktreeFor(containingRoot) : undefined; + if (!containingRoot || resolve(containingRoot) !== resolve(job.worktreeDir) + || resolve(main ?? '') !== resolve(job.worktreeMain) + || !(await isLinkedWorktree(containingRoot))) { + await sessionReply(rootId, '⚠️ worktree 身份已变化,拒绝重试删除。'); + break; + } + const refusal = await withWorktreeTargetLock(job.worktreeDir, async () => { + const active = sessionStore.findActiveSessionsByWorkingDirStrict(job.worktreeDir); + if (active.length > 0) { + return `⚠️ worktree 仍有 ${active.length} 个活动会话,暂不删除。`; + } + const safety = await worktreeSafetyStatus(job.worktreeDir); + if (safety.fingerprint !== job.safetyFingerprint) { + return '⚠️ worktree 内容在上次确认后发生变化,拒绝重试删除。'; + } + await removeRepoWorktree(job.worktreeMain, job.worktreeDir); + deleteWorktreeCleanupJob(config.session.dataDir, job.id); + return undefined; + }); + if (refusal) { + await sessionReply(rootId, refusal); + break; + } + } catch (err) { + await sessionReply(rootId, `⚠️ worktree 清理重试失败,任务已保留:${err instanceof Error ? err.message : String(err)}`); + break; + } + await sessionReply(rootId, `🧹 已重试并移除 worktree:\`${job.worktreeDir}\``); + break; + } + case '/close': { + const closeArg = message.content.replace(/^\/close\s*/i, '').trim(); + const closeTokens = closeArg.split(/\s+/).filter(Boolean); + const removeWorktree = /^(wt|worktree)$/i.test(closeTokens[0] ?? ''); + const confirmedWorktreeCleanup = removeWorktree && closeTokens.includes('--yes'); + const expectedWorktreeState = closeTokens.find(token => token.startsWith('--state='))?.slice('--state='.length); if (ds) { // Shared adopts never own the source conversation. Keep /close // backwards-compatible as the quick "leave this BotMux share" action, // but never present it as terminating the source App Server / tmux CLI. + // This also deliberately takes precedence over `/close wt`: an adopted + // source may still be using that worktree after BotMux disconnects, so + // deleting it would be unsafe. if (isSharedAdoptSession(ds)) { const targetSessionId = ds.session.sessionId; const detached = await withBotTurnMutation(ds.larkAppId, async () => { @@ -1659,6 +1923,72 @@ export async function handleCommand( logger.info(`[${logTag}] /close treated as shared-adopt disconnect`); break; } + let worktreeDir = ds.workingDir ?? ds.session.workingDir; + let worktreeMain: string | undefined; + let initialWorktreeFingerprint: string | undefined; + let siblingSessions: import('../types.js').Session[] = []; + if (removeWorktree) { + if (ds.scope !== 'thread') { + await sessionReply(rootId, t('cmd.close.worktree_thread_only', undefined, loc)); + break; + } + const containingRoot = worktreeDir ? await worktreeRootFor(worktreeDir) : null; + if (!containingRoot || !(await isLinkedWorktree(containingRoot))) { + await sessionReply(rootId, t('cmd.close.worktree_not_linked', undefined, loc)); + break; + } + worktreeDir = containingRoot; + worktreeMain = await mainWorktreeFor(worktreeDir); + try { + siblingSessions = sessionStore.findActiveSessionsByWorkingDirStrict(worktreeDir) + .filter(s => s.sessionId !== ds.session.sessionId); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + logger.warn(`[${logTag}] worktree cleanup inventory unavailable: ${reason}`); + await sessionReply(rootId, `⚠️ 无法完整读取同 worktree 会话清单,已取消删除:${reason}`); + break; + } + const untrustedSibling = siblingSessions.find(sibling => + sibling.larkAppId + && sibling.larkAppId !== ds.larkAppId + && !trustedTeamBotApp(sibling.larkAppId)); + if (untrustedSibling) { + logger.warn( + `[${logTag}] refusing worktree cleanup across untrusted bot app ${untrustedSibling.larkAppId}`, + ); + await sessionReply(rootId, '⚠️ 同 worktree 中存在不属于可信团队的 Bot 会话,已取消删除。'); + break; + } + const safety = await worktreeSafetyStatus(worktreeDir); + initialWorktreeFingerprint = safety.fingerprint; + const confirmationState = closeWorktreeConfirmationState({ + sessionId: ds.session.sessionId, + worktreeDir, + siblingSessionIds: siblingSessions.map(s => s.sessionId), + safetyFingerprint: safety.fingerprint, + invokerOpenId: message.senderId, + }); + if (!confirmedWorktreeCleanup || expectedWorktreeState !== confirmationState) { + if (confirmedWorktreeCleanup) { + await sessionReply(rootId, t('cmd.close.worktree_state_changed', undefined, loc)); + } + await sessionReply(rootId, buildCloseWorktreeConfirmCard({ + rootId, + sessionId: ds.session.sessionId, + worktreeDir, + sessions: [ds.session, ...siblingSessions], + dirty: safety.dirty, + dirtyCount: safety.dirtyCount, + dirtyFiles: safety.dirtyFiles, + ahead: safety.ahead, + unpushedCommits: safety.unpushedCommits, + invokerOpenId: message.senderId, + confirmationState, + loc, + }), 'interactive'); + break; + } + } const targetSessionId = ds.session.sessionId; const closed = await withBotTurnMutation(ds.larkAppId, async () => { // Re-resolve the exact session after all peer admissions drain. A @@ -1755,15 +2085,126 @@ export async function handleCommand( // 「会话已关闭」卡片优先「仅自己可见」:普通群顶层走 ephemeral 只发给 // 执行 /close 的本人;若本命令从折叠到 chat-scope 的真实话题触发,则 // invocationReplyTarget 让 helper 跳过无 thread 锚点的 ephemeral,回原话题。 - await deliverEphemeralOrReply( - closed.current, - message.senderId, - closed.card, - 'interactive', - () => sessionReply(rootId, closed.card, 'interactive'), - deps.invocationReplyTarget, - ); - logger.info(`[${logTag}] Session closed by /close command`); + try { + await deliverEphemeralOrReply( + closed.current, + message.senderId, + closed.card, + 'interactive', + () => sessionReply(rootId, closed.card, 'interactive'), + deps.invocationReplyTarget, + ); + } catch (err) { + if (!removeWorktree) throw err; + // The session is already durably closed. For an explicitly confirmed + // worktree cleanup, notification delivery must not strand sibling + // sessions or the owned worktree; ordinary /close retains its existing + // outer error handling. + logger.warn(`[${logTag}] closed-session card delivery failed after close: ${err instanceof Error ? err.message : err}`); + } + if (removeWorktree && worktreeMain && worktreeDir) { + let closedSiblings = 0; + const siblingCloseFailures: string[] = []; + for (const sibling of siblingSessions) { + if (!sibling.larkAppId) { + logger.warn(`[${logTag}] sibling session ${sibling.sessionId} has no owning app; blocking worktree removal`); + siblingCloseFailures.push(sibling.sessionId); + continue; + } + if (sibling.larkAppId === ds.larkAppId) { + try { + const result = await closeWorkerPoolSession(sibling.sessionId); + if (result.ok && result.outcome === 'closed') closedSiblings++; + else siblingCloseFailures.push(sibling.sessionId); + } catch (err) { + logger.warn(`[${logTag}] failed to close sibling session ${sibling.sessionId}: ${err instanceof Error ? err.message : err}`); + siblingCloseFailures.push(sibling.sessionId); + } + continue; + } + const daemon = findOnlineDaemon(sibling.larkAppId); + if (!daemon) { + logger.warn(`[${logTag}] sibling session ${sibling.sessionId} owner daemon offline (app=${sibling.larkAppId})`); + siblingCloseFailures.push(sibling.sessionId); + continue; + } + try { + const res = await fetchDaemonIpc(daemon.ipcPort, `/api/sessions/${encodeURIComponent(sibling.sessionId)}/close`, { method: 'POST' }); + const body = await res.json().catch(() => undefined); + const residual = res.ok ? parseCloseResidual(body) : undefined; + if (res.ok && !residual) closedSiblings++; + else { + const reason = residual ? `residual=${describeCloseResidual(residual)}` : `http_${res.status}`; + logger.warn(`[${logTag}] sibling close ${sibling.sessionId} not fully closed: ${reason}`); + siblingCloseFailures.push(sibling.sessionId); + } + } catch (err) { + logger.warn(`[${logTag}] sibling close ${sibling.sessionId} threw: ${err instanceof Error ? err.message : err}`); + siblingCloseFailures.push(sibling.sessionId); + } + } + if (siblingCloseFailures.length > 0) { + await sessionReply(rootId, t('cmd.close.worktree_sibling_close_failed', { + path: worktreeDir, + count: String(siblingCloseFailures.length), + }, loc)); + break; + } + const removal = await withWorktreeTargetLock(worktreeDir, async () => { + const finalSafety = await worktreeSafetyStatus(worktreeDir); + const finalInventory = sessionStore.findActiveSessionsByWorkingDirStrict(worktreeDir); + if (finalSafety.fingerprint !== initialWorktreeFingerprint || finalInventory.length > 0) { + return { + status: 'changed' as const, + contentChanged: finalSafety.fingerprint !== initialWorktreeFingerprint, + safetyFingerprint: finalSafety.fingerprint, + }; + } + try { + await removeRepoWorktree(worktreeMain, worktreeDir); + return { status: 'removed' as const }; + } catch (error) { + return { status: 'failed' as const, error, safetyFingerprint: finalSafety.fingerprint }; + } + }); + if (removal.status === 'changed') { + if (removal.contentChanged) { + const job = putWorktreeCleanupJob(config.session.dataDir, { + larkAppId: ds.larkAppId, + worktreeMain, + worktreeDir, + safetyFingerprint: removal.safetyFingerprint, + error: 'worktree content changed after sessions closed', + }); + await sessionReply( + rootId, + '⚠️ 关闭会话后 worktree 内容发生变化,已取消删除。' + + `请检查后发送 \`/cleanup-wt ${job.id}\` 重试。`, + ); + } else { + await sessionReply(rootId, '⚠️ 关闭会话后仍检测到活动会话,已取消删除。请稍后重试 `/close wt`。'); + } + break; + } + if (removal.status === 'failed') { + const error = removal.error instanceof Error ? removal.error.message : String(removal.error); + const job = putWorktreeCleanupJob(config.session.dataDir, { + larkAppId: ds.larkAppId, + worktreeMain, + worktreeDir, + safetyFingerprint: removal.safetyFingerprint, + error, + }); + await sessionReply( + rootId, + `${t('cmd.close.worktree_remove_failed', { path: worktreeDir, error }, loc)}\n` + + `已保存清理任务,可稍后发送 \`/cleanup-wt ${job.id}\` 重试。`, + ); + } else { + await sessionReply(rootId, t('cmd.close.worktree_removed', { path: worktreeDir, count: closedSiblings }, loc)); + } + } + logger.info(`[${logTag}] Session closed by /close command${removeWorktree ? ' with worktree cleanup' : ''}`); } else { await sessionReply(rootId, t('cmd.no_active_session', undefined, loc)); } @@ -2125,10 +2566,11 @@ export async function handleCommand( // its first turn and must carry the full new-topic opening — see // markInitialUserTurnPending below. const emptyStart = !pendingRawInput && !hasBufferedInput; + if (!emptyStart && pendingTurnId) await deps.prepareTurn?.(current, pendingTurnId); forkWorker( current, pendingRawInput ? '' : (wrappedInput ?? ''), - !pendingRawInput && pendingTurnId ? { turnId: pendingTurnId } : false, + !emptyStart && !pendingRawInput && pendingTurnId ? { turnId: pendingTurnId } : false, ); current.pendingRepo = false; current.pendingRepoCommitInFlight = true; @@ -2488,6 +2930,16 @@ export async function handleCommand( break; } + if (repoArg && /^here$/i.test(repoArg)) { + const currentDir = resolveCurrentChatWorkingDirForRepo(ds, loc); + if (!currentDir) { + await sessionReply(rootId, t('cmd.repo.here_missing', undefined, loc)); + break; + } + await commitRepoSelection(currentDir, basename(currentDir), '/repo here'); + break; + } + // Numeric arg → pick by 1-based index from the last scan. if (repoArg && ds && /^\d+$/.test(repoArg)) { const repoIndex = parseInt(repoArg, 10); @@ -4880,6 +5332,7 @@ export async function handleCommand( const help = [ t('help.heading_session', undefined, loc), t('help.close', { cliName }, loc), + t('help.cleanup_wt', undefined, loc), t('help.restart', { cliName }, loc), t('help.topic', undefined, loc), t('help.cd', { cliName }, loc), diff --git a/src/core/passthrough-commands.ts b/src/core/passthrough-commands.ts index 0461ced91..a09e200a0 100644 --- a/src/core/passthrough-commands.ts +++ b/src/core/passthrough-commands.ts @@ -10,7 +10,7 @@ * chat) rather than relayed to the CLI. Used both for routing and to reject * `customPassthroughCommands` entries that would shadow a daemon command. */ -export const DAEMON_COMMANDS = new Set(['/close', '/restart', '/status', '/retry', '/help', '/cd', '/repo', '/rename', '/schedule', '/role', '/botconfig', '/skills', '/pair', '/login', '/adopt', '/detach', '/disconnect', '/oncall', '/project', '/group', '/g', '/relay', '/quote', '/fork', '/forklist', '/card', '/cot', '/term', '/list-slash-command', '/slash', '/subscribe-lark-doc', '/watch-comment', '/vc', '/insight', '/dashboard', '/sessions', '/vc-auth', '/issue', '/cli']); +export const DAEMON_COMMANDS = new Set(['/close', '/cleanup-wt', '/restart', '/status', '/retry', '/help', '/cd', '/repo', '/rename', '/schedule', '/role', '/botconfig', '/skills', '/pair', '/login', '/adopt', '/detach', '/disconnect', '/oncall', '/project', '/group', '/g', '/relay', '/quote', '/fork', '/forklist', '/card', '/cot', '/term', '/list-slash-command', '/slash', '/subscribe-lark-doc', '/watch-comment', '/vc', '/insight', '/dashboard', '/sessions', '/vc-auth', '/issue', '/cli']); /** * Slash commands that are forwarded verbatim to the underlying CLI (e.g. diff --git a/src/core/pending-repo-journal.ts b/src/core/pending-repo-journal.ts index 740f210c2..fc2ac3f3b 100644 --- a/src/core/pending-repo-journal.ts +++ b/src/core/pending-repo-journal.ts @@ -4,7 +4,10 @@ import * as sessionStore from '../services/session-store.js'; export function stagePendingRepoSetup( ds: DaemonSession, - args: Pick & Partial>, + args: Pick & Partial>, ): void { const prior = { queued: ds.session.queued, @@ -19,6 +22,11 @@ export function stagePendingRepoSetup( ...(ds.pendingRawInput ? { rawInput: ds.pendingRawInput } : {}), ...(args.turnId ? { turnId: args.turnId } : {}), ...(args.baseDir ? { baseDir: args.baseDir } : {}), + ...(args.force !== undefined ? { force: args.force } : {}), + ...(args.worktreePath ? { worktreePath: args.worktreePath } : {}), + ...(args.branch ? { branch: args.branch } : {}), + ...(args.reuseExisting !== undefined ? { reuseExisting: args.reuseExisting } : {}), + ...(args.targetSubdir ? { targetSubdir: args.targetSubdir } : {}), ...(ds.pendingCodexAppText !== undefined ? { codexAppText: ds.pendingCodexAppText } : {}), ...(ds.pendingCodexAppApplicationContext ? { codexAppApplicationContext: ds.pendingCodexAppApplicationContext } diff --git a/src/core/session-manager.ts b/src/core/session-manager.ts index 454860ff4..bb4b68c91 100644 --- a/src/core/session-manager.ts +++ b/src/core/session-manager.ts @@ -113,6 +113,7 @@ function sessionLastMessageAtMs(session: { createdAt?: string; lastMessageAt?: s async function resumeRestoredPendingRepoSetup( ds: DaemonSession, activeSessions: Map, + prepareTurn?: (ds: DaemonSession, turnId: string) => Promise | undefined, ): Promise { const setup = ds.session.pendingRepoSetup; if (!setup || ds.session.queuedActivationPending || !ds.pendingRepo) return; @@ -136,6 +137,12 @@ async function resumeRestoredPendingRepoSetup( operatorOpenId: ds.session.ownerOpenId, activeSessions, notify, + force: setup.force, + worktreePath: setup.worktreePath, + branch: setup.branch, + reuseExisting: setup.reuseExisting, + targetSubdir: setup.targetSubdir, + prepareTurn, }).catch((err) => { // Git/worktree recovery is deliberately detached. A failed publish or // build may not reject daemon startup or erase this durable setup owner. @@ -2097,6 +2104,7 @@ export async function staggeredRecoveryFork( export async function restoreActiveSessions( activeSessions: Map, quarantinedSessionIds: ReadonlySet = new Set(), + options: { prepareTurn?: (ds: DaemonSession, turnId: string) => Promise | undefined } = {}, ): Promise { const sessions = sessionStore.listSessions(); const restorePriority = (session: Session): number => { @@ -2556,7 +2564,7 @@ export async function restoreActiveSessions( announceSessionRow(ds); if (restoredPendingRepo) { try { - await resumeRestoredPendingRepoSetup(ds, activeSessions); + await resumeRestoredPendingRepoSetup(ds, activeSessions, options.prepareTurn); } catch (err) { // One unavailable scan/Lark send/worktree import must not abort the // entire daemon restore. Rebuild volatile buffers from the retained diff --git a/src/core/worker-pool.ts b/src/core/worker-pool.ts index c28570638..4baafceb2 100644 --- a/src/core/worker-pool.ts +++ b/src/core/worker-pool.ts @@ -692,6 +692,8 @@ export interface WorkerPoolCallbacks { ) => Promise; getSessionWorkingDir: (ds?: DaemonSession) => string; getActiveCount: () => number; + /** Prepare trigger-user CLI identity before a delayed raw-input turn. */ + prepareRawInputTurn?: (ds: DaemonSession, turnId: string) => void | Promise; /** Close a stale session (message withdrawn, etc.). `false` means the * authoritative close failed and the active owner must remain retryable. * `void` is retained for older embedders/tests that implement a synchronous @@ -12181,6 +12183,8 @@ function setupWorkerHandlers( const followUpCodexAppInput = followUp?.codexAppInputGateFrozen ? followUp.codexAppInput : codexAppInputForSession(ds, followUp?.codexAppInput); + if (rawTurnId) await requireCallbacks().prepareRawInputTurn?.(ds, rawTurnId); + if (ds.worker !== worker || ds.workerGeneration !== workerGeneration) break; sendWorkerSessionInput(ds, { type: 'raw_input', content: rawInput, diff --git a/src/daemon.ts b/src/daemon.ts index 7e88ac68f..c0fadb11c 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -6,7 +6,7 @@ import { atomicWriteFileSync } from './utils/atomic-write.js'; import { readPeerCrossRef } from './services/peer-cross-ref-store.js'; import { parseBotSteerDirective } from './core/bot-steer-directive.js'; import { readAllowedUsersResolveCache, writeAllowedUsersResolveCache } from './utils/allowed-users-cache.js'; -import { join, dirname } from 'node:path'; +import { join, dirname, basename, isAbsolute, relative } from 'node:path'; import { homedir, loadavg, cpus, totalmem, freemem } from 'node:os'; import type { IncomingMessage, ServerResponse } from 'node:http'; import { fileURLToPath } from 'node:url'; @@ -3604,6 +3604,10 @@ function triggerUserAuthEnabledFor(ds: DaemonSession): boolean { catch { return false; } } +function prepareTurnCliIdentity(ds: DaemonSession, turnId: string): Promise | undefined { + return triggerUserAuthEnabledFor(ds) ? refreshTurnCliIdentity(ds, turnId) : undefined; +} + async function refreshTurnCliIdentity(ds: DaemonSession, turnId: string): Promise { let botConfig; try { botConfig = getBot(ds.larkAppId).config; } catch { return; } @@ -4124,6 +4128,59 @@ async function replyGrantRestrictionIfNeeded( return true; } +async function forceTopicWorktreeTarget(baseDir: string, anchor: string): Promise<{ + worktreePath: string; + branch: string; + targetSubdir?: string; +}> { + const { mainWorktreeFor, worktreeRootFor } = await import('./services/git-worktree.js'); + const containingRoot = await worktreeRootFor(baseDir); + const repoRoot = await mainWorktreeFor(baseDir); + const subdir = containingRoot ? relative(containingRoot, baseDir) : ''; + const short = createHash('sha1').update(anchor).digest('hex').slice(0, 12); + const branch = `wt/botmux-${short}`; + return { + branch, + worktreePath: join(dirname(repoRoot), `${basename(repoRoot)}-wt-botmux-${short}`), + ...((subdir && !subdir.startsWith('..') && !isAbsolute(subdir)) ? { targetSubdir: subdir } : {}), + }; +} + +function validateExplicitForceTopicWorkingDir(dir: string | undefined, loc: ReturnType): string | undefined { + if (!dir) return undefined; + const validation = validateWorkingDir(dir, loc); + if (!validation.ok) return undefined; + return validation.resolvedPath; +} + +function resolveForceTopicCurrentWorkingDir(ctx: { + scope: 'thread' | 'chat'; + anchor: string; + chatId: string; + larkAppId: string; + currentSession?: DaemonSession; +}): string | undefined { + const loc = localeForBot(ctx.larkAppId); + const current = validateExplicitForceTopicWorkingDir(ctx.currentSession?.workingDir, loc); + if (current) return current; + + const oncall = validateExplicitForceTopicWorkingDir(findOncallChat(ctx.larkAppId, ctx.chatId)?.workingDir, loc); + if (oncall) return oncall; + + const peers = ctx.scope === 'chat' + ? sessionStore.findActiveChatScopeSessionsByChat(ctx.chatId) + : [ + ...sessionStore.findActiveSessionsByRoot(ctx.anchor), + ...sessionStore.findActiveChatScopeSessionsByChat(ctx.chatId), + ]; + for (const peer of peers) { + if (!peer.workingDir) continue; + const resolved = validateExplicitForceTopicWorkingDir(peer.workingDir, loc); + if (resolved) return resolved; + } + return undefined; +} + // ─── PID file ──────────────────────────────────────────────────────────────── function getPidFile(): string { @@ -5085,6 +5142,7 @@ const commandDeps: CommandHandlerDeps = { sessionReply, getActiveCount, lastRepoScan, + prepareTurn: (ds, turnId) => prepareTurnCliIdentity(ds, turnId), prewarmDocCommentSession, }; @@ -17092,11 +17150,14 @@ function willAutoWorktree(larkAppId: string, pinnedWorkingDir: string | undefine * (build fails / user /closes) — an early ✋ would be orphaned. The pending dashboard * row is announced inside runAutoWorktreeCommit (one place for all callers). */ function startAutoWorktreePending(ds: DaemonSession, args: { - anchor: string; baseDir: string; title?: string; prompt: string; operatorOpenId?: string; + anchor: string; baseDir: string; title?: string; prompt: string; operatorOpenId?: string; force?: boolean; + worktreePath?: string; branch?: string; reuseExisting?: boolean; targetSubdir?: string; }): void { void runAutoWorktreeCommit({ ds, anchor: args.anchor, larkAppId: ds.larkAppId, baseDir: args.baseDir, - title: args.title, prompt: args.prompt, operatorOpenId: args.operatorOpenId, + title: args.title, prompt: args.prompt, operatorOpenId: args.operatorOpenId, force: args.force, + worktreePath: args.worktreePath, branch: args.branch, reuseExisting: args.reuseExisting, + targetSubdir: args.targetSubdir, activeSessions, notify: (m) => sessionReply(args.anchor, m, 'text', ds.larkAppId), }); @@ -18255,11 +18316,24 @@ async function handleNewTopicAdmitted(data: any, ctx: RoutingContext): Promise { sessionReply, getSessionWorkingDir, getActiveCount, + prepareRawInputTurn: (ds, turnId) => prepareTurnCliIdentity(ds, turnId), closeSession(ds: DaemonSession): Promise { // Route through the dashboard-aware helper so session.exited / session.update // events fire for withdrawn-message / crash / adopt-exit teardown paths too, @@ -23382,7 +23494,9 @@ export async function startDaemon(botIndex?: number): Promise { // Restore active sessions from previous run await restoreSessionsAndScheduleStartupRecovery({ larkAppId: cfg.larkAppId, - restoreSessions: () => restoreActiveSessions(activeSessions, idempotencyQuarantinedSessionIds), + restoreSessions: () => restoreActiveSessions(activeSessions, idempotencyQuarantinedSessionIds, { + prepareTurn: (ds, turnId) => prepareTurnCliIdentity(ds, turnId), + }), // Restore complete → /api/asks may now safely 403 unknown sessions again; a // reconnecting ask hook that raced the restore got retryable 503s until here. markSessionsRestored: () => { diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 39b786d68..545a4da3e 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -361,6 +361,32 @@ export const messages: Record = { 'cmd.restart.terminated': '{cliName} has been terminated; it will auto-resume on your next message.', 'cmd.restart.riff_unsupported': '⚠️ Riff sessions cannot be restarted. Use /close to close the current remote session, then send a new message to create one.', 'cmd.restart.remote_unsupported': '⚠️ Remote-backend sessions (Riff / Mojo) cannot be restarted: destroy-and-respawn would sever or replace the remote lineage. Use /close to close the current remote session, then send a new message to create one.', + 'cmd.close.worktree_thread_only': '⚠️ `/close wt` is only for child topics created by `/tw`. Use `/close` for the top-level chat session.', + 'cmd.close.worktree_not_linked': '⚠️ The current session directory is not a linked worktree; refusing to delete it. Use /close for a normal session.', + 'cmd.close.worktree_removed': '🧹 Closed this session, additionally closed {count} sessions sharing the worktree, and removed worktree: `{path}`', + 'cmd.close.worktree_confirm_title': 'Confirm close topic and remove worktree', + 'cmd.close.worktree_confirm_sessions': 'Sessions to close: {count} including this one', + 'cmd.close.worktree_confirm_effect': 'After removal: uncommitted changes are lost; unpushed commits stay on the local branch, but the worktree directory is removed.', + 'cmd.close.worktree_confirm_button': 'Confirm close and remove', + 'cmd.close.worktree_confirm_no_perm': 'Only a bot operator can close sessions and remove a worktree.', + 'cmd.close.worktree_confirm_not_invoker': 'Only the user who requested this confirmation can execute it.', + 'cmd.close.worktree_confirm_received': 'Request processed. Check the topic for the result.', + 'cmd.close.worktree_state_changed': '⚠️ The worktree changed after this confirmation card was created. No session was closed and no directory was removed. Review the fresh card below and retry.', + 'cmd.close.worktree_confirm_path_label': 'Worktree to remove', + 'cmd.close.worktree_col_bot': 'Bot', + 'cmd.close.worktree_col_task': 'Current task', + 'cmd.close.worktree_current_tag': '·current', + 'cmd.close.worktree_bot_unknown': 'unknown bot', + 'cmd.close.worktree_checks_label': 'Safety checks', + 'cmd.close.worktree_check_dirty_ok': '✅ Uncommitted changes: none', + 'cmd.close.worktree_check_dirty_warn': '⚠️ Uncommitted changes: {n} file(s)', + 'cmd.close.worktree_check_ahead_ok': '✅ Unpushed commits: none', + 'cmd.close.worktree_check_ahead_warn': '⚠️ Unpushed commits: {n}', + 'cmd.close.worktree_effect_safe': 'This worktree is clean; removing it will not lose any code.', + 'cmd.close.worktree_confirm_risky': '⚠️ About to close sessions and remove this worktree:\n- Other active sessions sharing it: {count}\n- Uncommitted changes: {dirty}\n- Unpushed commits: {ahead}\nTo continue, send `/close wt --yes`.', + 'cmd.close.worktree_confirm_shared': '⚠️ {count} active session(s) are also using this worktree. To close them all and remove the worktree, send `/close wt --yes`.', + 'cmd.close.worktree_sibling_close_failed': '⚠️ The current session was closed, but {count} other session(s) using `{path}` could not be closed. The worktree was not removed; retry from a remaining session after its owner daemon recovers.', + 'cmd.close.worktree_remove_failed': '⚠️ Session closed, but failed to remove worktree `{path}`: {error}', 'cmd.cd.riff_unsupported': '⚠️ Riff sessions cannot switch working directory or role in place. Use /close to close the current remote session, then create one from the new directory.', 'cmd.cd.remote_unsupported': '⚠️ Remote-backend sessions (Riff / Mojo) cannot switch working directory or role in place. Use /close to close the current remote session, then create one from the new directory.', 'cmd.takeover.riff_unsupported': '⚠️ Riff sessions cannot adopt or import another session in place. Use /close to safely close the current remote session, then create or import a session.', @@ -380,6 +406,7 @@ export const messages: Record = { 'cmd.repo.no_prior_scan': 'Run `/repo` first to see the project list.', 'cmd.repo.index_out_of_range': 'Index out of range. Valid: 1-{max}', 'cmd.repo.path_not_found': '❌ No such directory or project: `{arg}`\nPass an absolute path, a relative path, or a first-level project name under workingDir.', + 'cmd.repo.here_missing': '⚠️ This chat has no current working directory to reuse. Start a normal chat session first, or bind one with /oncall bind.', 'cmd.repo.selected_in_pending': '✅ Selected {name}', 'cmd.repo.switched_to': '🔄 Switched to {name}', 'cmd.repo.warning_running': '⚠️ A session is already running. Switching repos will close it and start a new one.\nIf that\'s what you want, pick the new repo from the card below.', @@ -732,6 +759,7 @@ export const messages: Record = { // ─── /help ─────────────────────────────────────────────────────────────── 'help.heading_session': '📌 Session management:', 'help.close': '/close - Close current session, kill {cliName}', + 'help.cleanup_wt': '/cleanup-wt - Retry a failed worktree cleanup job', 'help.restart': '/restart - Restart {cliName} (keep session)', 'help.topic': '[title] /t [/repo ] [/model ] [/effort ] [] (alias /topic) - Start a topic in a regular group, declaring title, repo, model, reasoning effort and the first task in one message. Newlines are the same as spaces; the title goes BEFORE /t (Lark shows the raw message in its topic list and the bot cannot rewrite it); quote paths containing spaces; one bad field voids the whole header and replies with a usage error. Bare /t opens setup (repo picker when needed, otherwise waits for the next task or /repo); a bare /repo inside the header (no argument) starts right away in the default working dir, same as the picker card start-directly button. Note that /repo inside the header takes a single token, while a standalone mid-session /repo still takes the rest of the line', 'help.cd': '/cd - Change working dir and restart {cliName}', @@ -1113,6 +1141,7 @@ export const messages: Record = { 'daemon.cmd_activation_pending': '{cmd} cannot be sent yet because the previous turn is still being submitted. Retry shortly.', 'daemon.force_topic_ready': '💬 New topic created. Send a task in this topic, or use /repo first to choose a project.', 'daemon.force_topic_started': '↪️ Moved into a new topic and started working…', + 'daemon.force_topic_current_dir_missing': '⚠️ This chat has no current working directory to reuse. Start a normal chat session first, or bind one with /oncall bind.', // ─── Topic directive header (`[title] /t /repo … /model … body`) rejections ── // Fail closed: one bad field voids the whole header, so this reply is the // user's only feedback and must name the offending field. diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 68aad120f..acf754206 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -360,6 +360,32 @@ export const messages: Record = { 'cmd.restart.terminated': '{cliName} 进程已终止,下次发消息时将自动恢复。', 'cmd.restart.riff_unsupported': '⚠️ Riff 会话不支持重启。请先用 /close 关闭当前远程会话,再发送新消息创建会话。', 'cmd.restart.remote_unsupported': '⚠️ 远程后端会话(Riff / Mojo)不支持重启:销毁并重建会切断或替换远端 lineage。请先用 /close 关闭当前远程会话,再发送新消息创建会话。', + 'cmd.close.worktree_thread_only': '⚠️ `/close wt` 只用于 `/tw` 创建的子话题。普通群顶层会话请使用 `/close`。', + 'cmd.close.worktree_not_linked': '⚠️ 当前会话目录不是 linked worktree;已拒绝删除。普通会话请直接用 /close。', + 'cmd.close.worktree_removed': '🧹 已关闭当前会话、额外关闭 {count} 个同 worktree 会话,并移除 worktree:`{path}`', + 'cmd.close.worktree_confirm_title': '确认关闭话题并删除 worktree', + 'cmd.close.worktree_confirm_sessions': '将关闭会话 {count} 个(含当前)', + 'cmd.close.worktree_confirm_effect': '删除后:未提交改动会丢失;未 push 的提交仍留在本地分支,但 worktree 目录被移除。', + 'cmd.close.worktree_confirm_button': '确认关闭并删除', + 'cmd.close.worktree_confirm_no_perm': '只有机器人操作员才能关闭会话并删除 worktree。', + 'cmd.close.worktree_confirm_not_invoker': '只有发起本次确认的用户可以执行。', + 'cmd.close.worktree_confirm_received': '请求已处理,请查看话题中的结果。', + 'cmd.close.worktree_state_changed': '⚠️ worktree 状态在确认卡生成后发生变化;本次未关闭会话、未删除目录。请检查下方最新确认卡后重试。', + 'cmd.close.worktree_confirm_path_label': '将删除 worktree', + 'cmd.close.worktree_col_bot': '机器人', + 'cmd.close.worktree_col_task': '当前任务', + 'cmd.close.worktree_current_tag': '·当前', + 'cmd.close.worktree_bot_unknown': '未知机器人', + 'cmd.close.worktree_checks_label': '安全检查', + 'cmd.close.worktree_check_dirty_ok': '✅ 未提交改动:无', + 'cmd.close.worktree_check_dirty_warn': '⚠️ 未提交改动:{n} 个文件', + 'cmd.close.worktree_check_ahead_ok': '✅ 未 push 提交:无', + 'cmd.close.worktree_check_ahead_warn': '⚠️ 未 push 提交:{n} 个', + 'cmd.close.worktree_effect_safe': '此 worktree 干净,删除不会丢失代码。', + 'cmd.close.worktree_confirm_risky': '⚠️ 准备关闭并删除这个 worktree:\n- 其它同 worktree 活跃会话:{count} 个\n- 未提交改动:{dirty}\n- 未 push 提交:{ahead} 个\n确认要继续,请发送 `/close wt --yes`。', + 'cmd.close.worktree_confirm_shared': '⚠️ 还有 {count} 个活跃会话也在这个 worktree 下。确认要一起关闭并删除 worktree,请发送 `/close wt --yes`。', + 'cmd.close.worktree_sibling_close_failed': '⚠️ 当前会话已关闭,但使用 `{path}` 的其他 {count} 个会话未能关闭。worktree 未删除;请等所属 daemon 恢复后,从剩余会话重试。', + 'cmd.close.worktree_remove_failed': '⚠️ 会话已关闭,但移除 worktree `{path}` 失败:{error}', 'cmd.cd.riff_unsupported': '⚠️ Riff 会话不支持中途切换工作目录或角色。请先用 /close 关闭当前远程会话,再从新目录创建会话。', 'cmd.cd.remote_unsupported': '⚠️ 远程后端会话(Riff / Mojo)不支持中途切换工作目录或角色。请先用 /close 关闭当前远程会话,再从新目录创建会话。', 'cmd.takeover.riff_unsupported': '⚠️ Riff 会话不支持原地接管或导入其他会话。请先用 /close 安全关闭当前远程会话,再新建或导入会话。', @@ -379,6 +405,7 @@ export const messages: Record = { 'cmd.repo.no_prior_scan': '请先执行 /repo 查看项目列表。', 'cmd.repo.index_out_of_range': '序号超出范围,有效范围:1-{max}', 'cmd.repo.path_not_found': '❌ 找不到目录或项目:`{arg}`\n请传入绝对路径、相对路径,或 workingDir 下的一级项目名。', + 'cmd.repo.here_missing': '⚠️ 当前群聊还没有可复用的工作目录。请先在普通群启动一个会话或使用 /oncall bind 绑定目录。', 'cmd.repo.selected_in_pending': '✅ 已选择 {name}', 'cmd.repo.switched_to': '🔄 已切换到 {name}', 'cmd.repo.warning_running': '⚠️ 当前会话已在运行中,切换仓库将关闭当前会话并创建新会话。\n如需切换,请在下方卡片中选择新仓库。', @@ -730,6 +757,7 @@ export const messages: Record = { // ─── /help ─────────────────────────────────────────────────────────────── 'help.heading_session': '📌 会话管理:', 'help.close': '/close - 关闭当前会话,终止 {cliName} 进程', + 'help.cleanup_wt': '/cleanup-wt - 重试失败的 worktree 清理任务', 'help.restart': '/restart - 重启 {cliName} 进程(保留 session)', 'help.topic': '[标题] /t [/repo 仓库] [/model 模型] [/effort 档位] [首轮任务] (别名 /topic) - 普通群内新开话题,一条消息交代完标题/仓库/模型/推理强度/首轮任务。换行等价于空格;标题写在 /t 之前(飞书话题列表显示的是原消息,bot 改不了);带空格的路径用双引号;指令任一项写错则整条不生效并回一句用法错误。裸 /t 进入话题设置(需选仓则弹卡,否则等待下一条任务或 /repo);头部里裸写 /repo(不带参数)= 直接在默认目录开会话,与选仓卡的「直接开始」一致。注意头部里的 /repo 只吃一个 token,会话中途单发的 /repo 仍吃整行', 'help.cd': '/cd - 切换工作目录并重启 {cliName} 进程', @@ -1111,6 +1139,7 @@ export const messages: Record = { 'daemon.cmd_activation_pending': '{cmd} 暂不能发送:上一条消息仍在提交中,请稍后重试。', 'daemon.force_topic_ready': '💬 新话题已创建。请在话题内发送任务,也可以先用 /repo 选择项目。', 'daemon.force_topic_started': '↪️ 已转入新话题,正在处理…', + 'daemon.force_topic_current_dir_missing': '⚠️ 当前群聊还没有可复用的工作目录。请先在普通群启动一个会话或使用 /oncall bind 绑定目录。', // ─── 话题指令头(`[标题] /t /repo … /model … 正文`)的拒绝文案 ─────────── // fail closed:任一项不合法就整条不生效,所以这条回复是用户唯一的反馈, // 必须指名道姓说清哪一项错了。 diff --git a/src/im/lark/card-handler.ts b/src/im/lark/card-handler.ts index 363bbb1ed..e3676dbd3 100644 --- a/src/im/lark/card-handler.ts +++ b/src/im/lark/card-handler.ts @@ -4,6 +4,7 @@ * Extracted from daemon.ts for modularity. */ import { execSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; import { basename as pathBasename, dirname, join } from 'node:path'; import { closeResidualIsLocal, describeCloseResidual } from '../../core/close-residual.js'; import { config } from '../../config.js'; @@ -110,6 +111,7 @@ import { buildTerminalUrl } from '../../core/terminal-url.js'; import type { ProjectInfo } from '../../services/project-scanner.js'; import { createRepoWorktree, removeRepoWorktree, dirSuffixForBranch, pushWorktreeBranch } from '../../services/git-worktree.js'; import { withCodexAppContext } from '../../utils/codex-app-context.js'; +import { handleCommand } from '../../core/command-handler.js'; import { isRemoteBackendSession, resolvePairedSpawnBackendType } from '../../core/persistent-backend.js'; import { sessionConfiguredRuntimeDisplayName } from '../../core/cli-runtime-display.js'; import { worktreeSlugFromContextAI } from '../../services/worktree-slug-ai.js'; @@ -450,6 +452,7 @@ export async function commitRepoSelection( operatorOpenId?: string; activeSessions: Map; sessionReply: (rid: string, content: string, msgType?: string, turnId?: string) => Promise; + prepareTurn?: (ds: DaemonSession, turnId: string) => Promise | undefined; }, dirPath: string, dirLabel: string, @@ -463,7 +466,7 @@ export async function commitRepoSelection( riffRepoDirs?: string[]; }, ): Promise { - const { ds, rootId, cardMessageId, larkAppId, operatorOpenId, activeSessions, sessionReply } = ctx; + const { ds, rootId, cardMessageId, larkAppId, operatorOpenId, activeSessions, sessionReply, prepareTurn } = ctx; const locTarget = localeForBot(ds.larkAppId); // `/close` deletes the active-map entry without touching sessionId or // pendingRepo — identity against the map is the only tell that the session @@ -629,10 +632,11 @@ export async function commitRepoSelection( // forkWorker's synchronous pre-accept/write-ahead phase. If it throws, // the user can retry this exact selection without losing the first turn. const pendingTurnId = ds.pendingTurnId ?? ds.session.pendingRepoSetup?.turnId; + if (!emptyStart && pendingTurnId) await prepareTurn?.(ds, pendingTurnId); forkWorker( ds, prompt, - !pendingRawInput && pendingTurnId ? { turnId: pendingTurnId } : false, + !emptyStart && !pendingRawInput && pendingTurnId ? { turnId: pendingTurnId } : false, ); ds.pendingRepo = false; // A queued activation owns the route through its adapter-level ACK. Every @@ -898,8 +902,15 @@ export async function runAutoWorktreeCommit(deps: { operatorOpenId?: string; activeSessions: Map; notify: (message: string) => Promise | void; + force?: boolean; + worktreePath?: string; + branch?: string; + reuseExisting?: boolean; + /** Relative directory inside a newly-created worktree to preserve as cwd. */ + targetSubdir?: string; + prepareTurn?: (ds: DaemonSession, turnId: string) => Promise | undefined; }): Promise { - const { ds, anchor, larkAppId, baseDir, title, prompt, operatorOpenId, activeSessions, notify } = deps; + const { ds, anchor, larkAppId, baseDir, title, prompt, operatorOpenId, activeSessions, notify, prepareTurn, force, worktreePath, branch, reuseExisting, targetSubdir } = deps; ds.worktreeCreating = true; // Surface the pending row NOW (all three callers funnel through here, so this is // the single place that guarantees the session is visible on SSE-only dashboards @@ -908,8 +919,26 @@ export async function runAutoWorktreeCommit(deps: { announcePendingRepoSession(ds); try { const { maybeCreateDefaultWorktree } = await import('../../services/default-worktree.js'); + let committedUnderTargetLock = false; + const commitCreated = async (creation: { path: string }) => { + if (!ds.pendingRepo) return; + const targetDir = targetSubdir ? join(creation.path, targetSubdir) : creation.path; + if (targetSubdir && !existsSync(targetDir)) { + throw new Error(`worktree 中不存在原工作目录对应的子目录:${targetSubdir}`); + } + committedUnderTargetLock = await runDetachedBotTurnAdmission(larkAppId, () => commitRepoSelection( + { + ds, rootId: anchor, larkAppId, operatorOpenId, activeSessions, + sessionReply: async () => '', prepareTurn, + }, + targetDir, + pathBasename(targetDir), + { suppressConfirmReply: true }, + )); + }; const wt = await maybeCreateDefaultWorktree(larkAppId, baseDir, { - isBotDefaultDir: true, title, prompt, locale: localeForBot(larkAppId), notify, + isBotDefaultDir: true, title, prompt, locale: localeForBot(larkAppId), notify, force, worktreePath, branch, reuseExisting, + ...(reuseExisting && worktreePath ? { commitCreated } : {}), }); // The pendingRepo placeholder can legitimately be consumed WHILE this // up-to-30s build runs — e.g. the Codex-notifier「继续处理」callback adopts @@ -919,6 +948,7 @@ export async function runAutoWorktreeCommit(deps: { // session. Bail on the late result instead: the takeover already owns the // session. (commitRepoSelection also re-checks pendingRepo under its claim, // but that check runs after an await — fence here before any mutation.) + if (committedUnderTargetLock) return; if (!ds.pendingRepo) { logger.info(`[${tag(ds)}] auto-worktree completion ignored — pendingRepo already consumed (session taken over)`); return; @@ -932,23 +962,30 @@ export async function runAutoWorktreeCommit(deps: { // admission. Re-enter with a fresh lease at the delayed commit/fork edge; // the outer lease may have ended minutes ago and must not authorize this // descendant across a bot-wide config mutation. + const targetDir = targetSubdir ? join(wt.dir, targetSubdir) : wt.dir; + if (targetSubdir && !existsSync(targetDir)) { + throw new Error(`worktree 中不存在原工作目录对应的子目录:${targetSubdir}`); + } await runDetachedBotTurnAdmission(larkAppId, () => commitRepoSelection( { ds, rootId: anchor, larkAppId, operatorOpenId, activeSessions, // Never reached under suppressConfirmReply for a pendingRepo session. sessionReply: async () => '', }, - wt.dir, - pathBasename(wt.dir), + targetDir, + pathBasename(targetDir), { suppressConfirmReply: true }, )); } catch (e) { // No recovery fork here: forking with an empty prompt would DROP the buffered // first turn (pendingPrompt lives only in-memory, not the message queue). Leave - // the session as commitRepoSelection left it — the inbound router's worker=null - // branch re-forks (with the pinned dir) on the user's next message, and a still- - // pending session keeps buffering. Loud log so the rare mid-commit throw is seen. - logger.error(`[${tag(ds)}] auto-worktree commit failed (session recoverable on next message): ${e instanceof Error ? e.message : e}`); + // the session pending and give the user explicit command-based recovery even + // when the forced /tw flow never had a repo picker card. + const error = e instanceof Error ? e.message : String(e); + logger.error(`[${tag(ds)}] auto-worktree commit failed (session recoverable on next message): ${error}`); + if (force && ds.pendingRepo) { + await notify(`⚠️ worktree 创建失败,任务仍在等待中。可发送 \`/tw\` 重试,或发送 \`/repo\` 选择/直接启动仓库。\n${error}`); + } } finally { ds.worktreeCreating = false; } @@ -1372,6 +1409,42 @@ export async function handleCardAction(data: CardActionData, deps: CardHandlerDe return resultCardBody; } + + if (value?.action === 'close_worktree_confirm') { + const rootId = String(value.root_id ?? ''); + const sessionId = String(value.session_id ?? ''); + if (!rootId || !sessionId || !larkAppId || !operatorOpenId) { + return { toast: { type: 'warning', content: t('card.action.session_gone', undefined, localeForBot(larkAppId)) } }; + } + const target = activeSessions.get(sessionKey(rootId, larkAppId)); + if (!target || target.session.sessionId !== sessionId) { + return { toast: { type: 'warning', content: t('card.action.session_gone', undefined, localeForBot(larkAppId)) } }; + } + if (!canOperate(target.larkAppId, target.chatId, operatorOpenId)) { + return { toast: { type: 'error', content: t('cmd.close.worktree_confirm_no_perm', undefined, localeForBot(larkAppId)) } }; + } + const invokerOpenId = String(value.invoker_open_id ?? ''); + if (invokerOpenId && invokerOpenId !== operatorOpenId) { + return { toast: { type: 'error', content: t('cmd.close.worktree_confirm_not_invoker', undefined, localeForBot(larkAppId)) } }; + } + const confirmationState = String(value.confirmation_state ?? ''); + await handleCommand('/close', rootId, { + messageId: cardMessageId ?? `close-wt-confirm-${sessionId}`, + rootId, + senderId: operatorOpenId, + senderType: 'user', + msgType: 'interactive', + content: `/close wt --yes${confirmationState ? ` --state=${confirmationState}` : ''}`, + createTime: String(Date.now()), + }, { + activeSessions, + sessionReply: deps.sessionReply, + lastRepoScan, + getActiveCount: () => activeSessions.size, + }, larkAppId); + return { toast: { type: 'success', content: t('cmd.close.worktree_confirm_received', undefined, localeForBot(larkAppId)) } }; + } + if (isAskCardAction(value?.action)) { return handleAskCardAction(data); } diff --git a/src/services/default-worktree.ts b/src/services/default-worktree.ts index 6a4929c2e..92ebee1d7 100644 --- a/src/services/default-worktree.ts +++ b/src/services/default-worktree.ts @@ -25,7 +25,7 @@ import { getBot } from '../bot-registry.js'; import { config } from '../config.js'; import { resolvePairedSpawnBackendType } from '../core/persistent-backend.js'; -import { createRepoWorktree, isGitWorkTree, pushWorktreeBranch } from './git-worktree.js'; +import { createRepoWorktree, createRepoWorktreeAndCommit, isGitWorkTree, pushWorktreeBranch, type WorktreeCreation } from './git-worktree.js'; import { worktreeSlugFromContextAI } from './worktree-slug-ai.js'; import { t } from '../i18n/index.js'; import type { Locale } from '../i18n/types.js'; @@ -44,6 +44,17 @@ export interface MaybeCreateWorktreeCtx { locale: Locale; /** Best-effort chat notice sink. Omit for silent (e.g. HTTP-virtual sessions). */ notify?: (message: string) => Promise | void; + /** Explicit user command (for example `/tw`) requested a worktree even when + * the bot's default auto-worktree toggle is off. */ + force?: boolean; + /** Deterministic worktree target for sharing one /tw topic across multiple bots. */ + worktreePath?: string; + /** Deterministic branch for `worktreePath`. */ + branch?: string; + /** Reuse an existing linked worktree at `worktreePath`. */ + reuseExisting?: boolean; + /** Keep a deterministic target lock through caller-side admission/publication. */ + commitCreated?: (creation: WorktreeCreation) => Promise; } /** @@ -71,7 +82,7 @@ export async function maybeCreateDefaultWorktree( baseDir: string, ctx: MaybeCreateWorktreeCtx, ): Promise { - if (!ctx.isBotDefaultDir || !botAutoWorktreeEnabled(larkAppId)) { + if (!ctx.force && (!ctx.isBotDefaultDir || !botAutoWorktreeEnabled(larkAppId))) { return { dir: baseDir }; } const notify = async (msg: string) => { @@ -83,15 +94,32 @@ export async function maybeCreateDefaultWorktree( // fallback directly WITHOUT a preceding "creating…" (which would be misleading), // and skip the doomed createRepoWorktree call entirely. if (!(await isGitWorkTree(baseDir))) { + const error = t('worktree.err_not_git', undefined, ctx.locale); + if (ctx.force) { + logger.warn(`[auto-worktree:${larkAppId}] explicit worktree refused: ${baseDir} is not a git work tree`); + await notify(error); + throw new Error(error); + } logger.warn(`[auto-worktree:${larkAppId}] default dir is not a git work tree, using it as-is: ${baseDir}`); - await notify(t('worktree.auto_fallback', { dir: baseDir, error: t('worktree.err_not_git', undefined, ctx.locale) }, ctx.locale)); + await notify(t('worktree.auto_fallback', { dir: baseDir, error }, ctx.locale)); return { dir: baseDir }; } await notify(t('worktree.auto_creating', undefined, ctx.locale)); try { - const slug = await worktreeSlugFromContextAI(ctx.title, ctx.prompt); - const creation = await createRepoWorktree(baseDir, { slug }); + const slug = ctx.branch ? undefined : await worktreeSlugFromContextAI(ctx.title, ctx.prompt); + const createOpts = { + slug, + branch: ctx.branch, + worktreePath: ctx.worktreePath, + reuseExisting: ctx.reuseExisting, + }; + const committed = ctx.commitCreated + ? await createRepoWorktreeAndCommit(baseDir, createOpts, async creation => { + await ctx.commitCreated!(creation); + }) + : undefined; + const creation = committed?.creation ?? await createRepoWorktree(baseDir, createOpts); logger.info(`[auto-worktree:${larkAppId}] ${baseDir} → ${creation.path} (branch ${creation.branch} from ${creation.baseRef})`); // riff:远程沙箱从 origin 克隆,本地新分支必须先推送才能被任务钉住。 // 推送失败不阻塞(会话仍可用,riff 侧回退默认分支并在卡片注入告警)。 @@ -116,6 +144,11 @@ export async function maybeCreateDefaultWorktree( return { dir: creation.path }; } catch (e) { const error = e instanceof Error ? e.message : String(e); + if (ctx.force) { + logger.warn(`[auto-worktree:${larkAppId}] explicit worktree creation failed for ${baseDir}: ${error}`); + await notify(error); + throw e; + } logger.warn(`[auto-worktree:${larkAppId}] failed for ${baseDir}, falling back to base dir: ${error}`); await notify(t('worktree.auto_fallback', { dir: baseDir, error }, ctx.locale)); return { dir: baseDir }; diff --git a/src/services/git-worktree.ts b/src/services/git-worktree.ts index f8862f66b..9e70585c4 100644 --- a/src/services/git-worktree.ts +++ b/src/services/git-worktree.ts @@ -8,10 +8,12 @@ * runs inside the daemon's event loop. */ import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; import { promisify } from 'node:util'; -import { existsSync, mkdirSync } from 'node:fs'; +import { existsSync, lstatSync, mkdirSync } from 'node:fs'; import { basename, dirname, join, resolve } from 'node:path'; import { logger } from '../utils/logger.js'; +import { withFileLock } from '../utils/file-lock.js'; const execFileP = promisify(execFile); @@ -32,6 +34,8 @@ export interface CreateRepoWorktreeOptions { slug?: string; /** Explicit target directory. Used by multi-repo worktree groups. */ worktreePath?: string; + /** Reuse an existing linked worktree at `worktreePath` instead of failing. */ + reuseExisting?: boolean; } async function git(args: string[], cwd: string, timeoutMs = 10_000): Promise { @@ -52,6 +56,16 @@ async function tryGit(args: string[], cwd: string, timeoutMs = 10_000): Promise< } } +async function gitRaw(args: string[], cwd: string, timeoutMs = 10_000): Promise { + try { + const { stdout } = await execFileP('git', args, { cwd, timeout: timeoutMs, encoding: 'utf-8' }); + return stdout.replace(/\r?\n$/, ''); + } catch (e: any) { + const stderr = typeof e?.stderr === 'string' ? e.stderr.trim() : ''; + throw new Error(stderr || e?.message || String(e)); + } +} + async function localBranchExists(repo: string, branch: string): Promise { return (await tryGit(['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], repo)) !== null; } @@ -116,6 +130,45 @@ async function resolveMainWorktree(dir: string): Promise { return first ? first.slice('worktree '.length) : dir; } +async function reuseCompatibleWorktree( + repo: string, + worktreePath: string, + branch: string, +): Promise { + if (!existsSync(worktreePath)) return null; + const sameRepo = await isGitWorkTree(worktreePath) + && resolve(await resolveMainWorktree(worktreePath)) === resolve(repo); + const actualBranch = sameRepo + ? await tryGit(['branch', '--show-current'], worktreePath, 5_000) + : null; + if (!sameRepo || actualBranch !== branch) { + throw new Error( + `worktree target exists but is not ${branch} in the expected repository: ${worktreePath}`, + ); + } + logger.info(`[git-worktree] reusing existing worktree ${worktreePath} on branch ${branch}`); + return { path: worktreePath, branch, baseRef: branch }; +} + +async function addWorktreeOrReuseAfterRace( + repo: string, + worktreePath: string, + branch: string, + args: string[], + reuseExisting: boolean, +): Promise { + try { + await git(args, repo, 60_000); + return null; + } catch (error) { + if (reuseExisting) { + const reused = await reuseCompatibleWorktree(repo, worktreePath, branch); + if (reused) return reused; + } + throw error; + } +} + /** * Create a linked worktree for `repoPath`, as a sibling of the repo's MAIN * checkout (a linked-worktree input is resolved back to the main one first). @@ -131,7 +184,7 @@ async function resolveMainWorktree(dir: string): Promise { * The base ref is fetched first so the worktree starts from the remote's * latest state; fetch failure degrades to the local (possibly stale) ref. */ -export async function createRepoWorktree( +async function createRepoWorktreeUnlocked( repoPath: string, opts: CreateRepoWorktreeOptions = {}, ): Promise { @@ -161,7 +214,7 @@ export async function createRepoWorktree( const slug = branch ? undefined : slugFromWorktreeText(opts.slug); if (branch) { wtPath = explicitPath ?? join(parent, `${repoBase}-${dirSuffixForBranch(branch)}`); - if (existsSync(wtPath)) throw new Error(`worktree target already exists: ${wtPath}`); + if (existsSync(wtPath) && !opts.reuseExisting) throw new Error(`worktree target already exists: ${wtPath}`); } else if (slug) { if (explicitPath) { for (let n = 1;; n++) { @@ -174,7 +227,7 @@ export async function createRepoWorktree( wtPath = explicitPath; break; } - if (existsSync(wtPath)) throw new Error(`worktree target already exists: ${wtPath}`); + if (existsSync(wtPath) && !opts.reuseExisting) throw new Error(`worktree target already exists: ${wtPath}`); } else { for (let n = 1;; n++) { if (n > 1000) throw new Error(`no free wt/${slug} slot under 1000`); @@ -199,7 +252,7 @@ export async function createRepoWorktree( wtPath = explicitPath; break; } - if (existsSync(wtPath)) throw new Error(`worktree target already exists: ${wtPath}`); + if (existsSync(wtPath) && !opts.reuseExisting) throw new Error(`worktree target already exists: ${wtPath}`); } else { let n = 1; for (;; n++) { @@ -213,12 +266,20 @@ export async function createRepoWorktree( } } + if (opts.reuseExisting) { + const reused = await reuseCompatibleWorktree(repo, wtPath, branch); + if (reused) return reused; + } + mkdirSync(dirname(wtPath), { recursive: true }); if (await localBranchExists(repo, branch)) { // Existing branch: check it out as-is (git rejects it if the branch is // already checked out in another worktree — surface that error verbatim). - await git(['worktree', 'add', wtPath, branch], repo, 60_000); + const reused = await addWorktreeOrReuseAfterRace( + repo, wtPath, branch, ['worktree', 'add', wtPath, branch], !!opts.reuseExisting, + ); + if (reused) return reused; logger.info(`[git-worktree] created ${wtPath} on existing branch ${branch}`); return { path: wtPath, branch, baseRef: branch }; } @@ -232,17 +293,69 @@ export async function createRepoWorktree( const remoteRef = `origin/${branch}`; if (await remoteBranchExists(repo, branch)) { - await git(['worktree', 'add', '-b', branch, '--track', wtPath, remoteRef], repo, 60_000); + const reused = await addWorktreeOrReuseAfterRace( + repo, + wtPath, + branch, + ['worktree', 'add', '-b', branch, '--track', wtPath, remoteRef], + !!opts.reuseExisting, + ); + if (reused) return reused; logger.info(`[git-worktree] created ${wtPath} tracking ${remoteRef}`); return { path: wtPath, branch, baseRef: remoteRef }; } } - await git(['worktree', 'add', '-b', branch, wtPath, baseRef], repo, 60_000); + const reused = await addWorktreeOrReuseAfterRace( + repo, + wtPath, + branch, + ['worktree', 'add', '-b', branch, wtPath, baseRef], + !!opts.reuseExisting, + ); + if (reused) return reused; logger.info(`[git-worktree] created ${wtPath} (branch ${branch} from ${baseRef})`); return { path: wtPath, branch, baseRef }; } + +export function withWorktreeTargetLock( + worktreePath: string, + fn: () => Promise, +): Promise { + const target = resolve(worktreePath); + mkdirSync(dirname(target), { recursive: true }); + return withFileLock(target, fn, { maxWaitMs: 180_000 }); +} + +export async function createRepoWorktreeAndCommit( + repoPath: string, + opts: CreateRepoWorktreeOptions, + commit: (creation: WorktreeCreation) => Promise, +): Promise<{ creation: WorktreeCreation; result: T }> { + const run = async () => { + const creation = await createRepoWorktreeUnlocked(repoPath, opts); + return { creation, result: await commit(creation) }; + }; + return opts.reuseExisting && opts.worktreePath + ? withWorktreeTargetLock(opts.worktreePath, run) + : run(); +} + +export async function createRepoWorktree( + repoPath: string, + opts: CreateRepoWorktreeOptions = {}, +): Promise { + if (!opts.reuseExisting || !opts.worktreePath) { + return createRepoWorktreeUnlocked(repoPath, opts); + } + return withWorktreeTargetLock( + opts.worktreePath, + () => createRepoWorktreeUnlocked(repoPath, opts), + ); +} + + /** * Push a freshly created worktree branch to origin (`push -u`). Used by the * riff flow: the remote sandbox clones from origin, so a local-only worktree @@ -255,6 +368,175 @@ export async function pushWorktreeBranch(worktreePath: string, branch: string): logger.info(`[git-worktree] pushed branch ${branch} to origin (${worktreePath})`); } + + +export interface WorktreeSafetyStatus { + dirty: boolean; + dirtyCount: number; + dirtyFiles: string[]; + ahead: number; + unpushedCommits: string[]; + /** Stable snapshot used to reject stale destructive confirmation cards. */ + fingerprint: string; +} + +interface SafetyStatusEntry { + status: string; + /** Path shown to the caller, relative to the top-level worktree. */ + path: string; + /** Repository whose porcelain output produced this entry. */ + repoDir: string; + /** Path relative to repoDir, used for content hashing. */ + localPath: string; +} + +/** Parse porcelain v1's NUL form. Unlike the line form, paths are never + * C-quoted, so non-ASCII, tabs and newlines remain exact filesystem names. */ +function parsePorcelainZ(raw: string, repoDir: string, prefix = ''): SafetyStatusEntry[] { + const records = raw.split('\0'); + const entries: SafetyStatusEntry[] = []; + for (let i = 0; i < records.length; i++) { + const record = records[i]; + if (record.length < 4) continue; + const status = record.slice(0, 2); + const localPath = record.slice(3); + entries.push({ status, localPath, repoDir, path: prefix ? `${prefix}/${localPath}` : localPath }); + // In porcelain v1 -z, rename/copy destinations are followed by the source + // path as a second NUL record. The destination above is the path that exists. + if (/[RC]/.test(status)) i++; + } + return entries; +} + +async function safetyStatusEntries(dir: string): Promise { + const status = await gitRaw([ + 'status', '--porcelain=v1', '-z', '--ignored=matching', '--untracked-files=normal', + ], dir, 10_000); + const entries = parsePorcelainZ(status, dir); + const submodules = await gitRaw([ + 'submodule', 'foreach', '--quiet', '--recursive', 'printf "%s\\0" "$displaypath"', + ], dir, 10_000); + for (const path of submodules.split('\0').filter(Boolean)) { + const submoduleDir = join(dir, path); + const nested = await gitRaw([ + 'status', '--porcelain=v1', '-z', '--ignored=matching', '--untracked-files=normal', + ], submoduleDir, 10_000); + entries.push(...parsePorcelainZ(nested, submoduleDir, path)); + } + return entries; +} + +export async function worktreeSafetyStatus(worktreePath: string): Promise { + const dir = resolve(worktreePath); + const statusEntries = await safetyStatusEntries(dir); + const status = statusEntries.map(entry => `${entry.status} ${entry.path}`).join('\n'); + const allDirtyFiles = statusEntries.map(entry => entry.path); + const dirtyFiles = allDirtyFiles.slice(0, 20); + let ahead = 0; + let unpushedCommits: string[] = []; + const head = await tryGit(['rev-parse', '--verify', 'HEAD'], dir, 5_000) ?? ''; + const upstream = await tryGit(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], dir, 5_000); + if (upstream) { + const count = await tryGit(['rev-list', '--count', `${upstream}..HEAD`], dir, 10_000); + ahead = Number.parseInt(count ?? '0', 10) || 0; + if (ahead > 0) { + const commits = await tryGit(['log', '--oneline', '--max-count=10', `${upstream}..HEAD`], dir, 10_000); + unpushedCommits = commits?.split('\n').map(line => line.trim()).filter(Boolean) ?? []; + } + } else { + const base = await tryGit(['merge-base', 'HEAD', 'origin/HEAD'], dir, 5_000) + ?? await tryGit(['merge-base', 'HEAD', 'origin/main'], dir, 5_000) + ?? await tryGit(['merge-base', 'HEAD', 'origin/master'], dir, 5_000); + if (head && base && head !== base) { + const count = await tryGit(['rev-list', '--count', `${base}..HEAD`], dir, 10_000); + ahead = Number.parseInt(count ?? '0', 10) || 0; + if (ahead > 0) { + const commits = await tryGit(['log', '--oneline', '--max-count=10', `${base}..HEAD`], dir, 10_000); + unpushedCommits = commits?.split('\n').map(line => line.trim()).filter(Boolean) ?? []; + } + } + } + // `git status` records paths and states, not bytes. Hash the current worktree + // content for every dirty entry so an already-dirty file changing between the + // confirmation card and deletion invalidates the confirmation. `git hash-object` + // handles regular files, symlinks and paths inside initialized submodules; a + // directory marker is expanded with the traditional ignored view so ignored + // directory contents participate without changing the compact display list. + const contentRows: string[] = []; + for (const entry of statusEntries) { + const path = entry.path; + const localPath = entry.localPath; + const absolute = join(entry.repoDir, localPath.replace(/\/$/, '')); + if (localPath.endsWith('/')) { + const nestedRaw = await gitRaw([ + 'status', '--porcelain=v1', '-z', '--ignored=traditional', '--untracked-files=all', + ], entry.repoDir, 10_000); + for (const nestedEntry of parsePorcelainZ(nestedRaw, entry.repoDir)) { + if (!nestedEntry.localPath.startsWith(localPath)) continue; + const digest = await git(['hash-object', '--no-filters', join(entry.repoDir, nestedEntry.localPath)], entry.repoDir, 10_000); + const displayPath = path.slice(0, path.length - localPath.length) + nestedEntry.localPath; + contentRows.push(`${displayPath}\0${digest}`); + } + continue; + } + let digest: string; + if (!existsSync(absolute)) { + // A tracked deletion is expected dirty state, not a scan failure. + digest = ''; + } else { + const stat = lstatSync(absolute); + if (stat.isDirectory()) { + const nested = await gitRaw([ + 'status', '--porcelain=v1', '-z', '--ignored=traditional', '--untracked-files=all', + ], absolute, 10_000); + digest = createHash('sha256').update(nested).digest('hex'); + } else { + digest = await git(['hash-object', '--no-filters', absolute], entry.repoDir, 10_000); + } + } + contentRows.push(`${path}\0${digest}`); + } + // `write-tree` rejects unresolved conflicts. `ls-files --stage` serializes + // every index entry (including stages 1/2/3), so it remains content-sensitive + // for both ordinary staged changes and conflicted indexes. + const indexTree = await gitRaw(['ls-files', '--stage', '-z'], dir, 10_000); + const submodulePaths = (await gitRaw([ + 'submodule', 'foreach', '--quiet', '--recursive', 'printf "%s\\0" "$displaypath"', + ], dir, 10_000)).split('\0').filter(Boolean); + const submoduleIndexes: { path: string; head: string; index: string }[] = []; + for (const path of submodulePaths) { + const submoduleDir = join(dir, path); + submoduleIndexes.push({ + path, + head: await gitRaw(['rev-parse', '--verify', 'HEAD'], submoduleDir, 5_000), + index: await gitRaw(['ls-files', '--stage', '-z'], submoduleDir, 10_000), + }); + } + const fingerprint = createHash('sha256') + .update(JSON.stringify({ head, upstream: upstream ?? '', indexTree, submoduleIndexes, status, contentRows, ahead, unpushedCommits })) + .digest('hex'); + return { dirty: status.length > 0, dirtyCount: allDirtyFiles.length, dirtyFiles, ahead, unpushedCommits, fingerprint }; +} + +export async function mainWorktreeFor(dir: string): Promise { + return resolveMainWorktree(resolve(dir)); +} + +/** Root of the specific worktree containing `dir`, not the main checkout. */ +export async function worktreeRootFor(dir: string): Promise { + const root = await tryGit(['rev-parse', '--show-toplevel'], resolve(dir), 5_000); + return root ? resolve(root) : null; +} + +export async function isLinkedWorktree(dir: string): Promise { + const resolved = resolve(dir); + try { + return resolve(await resolveMainWorktree(resolved)) !== resolved; + } catch { + return false; + } +} + /** Remove a worktree created by {@link createRepoWorktree}. Used to roll back the * worktrees already built when a later repo in a multi-repo batch fails — leaves * the branch in place (it may be a pre-existing branch we only checked out, and a diff --git a/src/services/session-store.ts b/src/services/session-store.ts index 606d25471..11e34641b 100644 --- a/src/services/session-store.ts +++ b/src/services/session-store.ts @@ -1,5 +1,5 @@ -import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, readdirSync, unlinkSync, copyFileSync } from 'node:fs'; -import { join, dirname, basename } from 'node:path'; +import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, readdirSync, unlinkSync, copyFileSync, realpathSync } from 'node:fs'; +import { join, dirname, basename, resolve, relative, isAbsolute } from 'node:path'; import { createHash, randomUUID } from 'node:crypto'; import { config } from '../config.js'; import { logger } from '../utils/logger.js'; @@ -546,11 +546,25 @@ function readStoreRowByKey(ref: StoreFileRef, sessionId: string): Session | unde function readStoreActiveRows( ref: StoreFileRef, hint?: { rootMessageId?: string; chatScopeChatId?: string; threadScopeChatId?: string }, + opts: { strict?: boolean } = {}, ): Session[] { if (ref.kind === 'json') { const parsed = JSON.parse(readFileSync(ref.path, 'utf-8')) as unknown; - if (!parsed || typeof parsed !== 'object') return []; - return Object.values(parsed as Record).filter(s => s?.status === 'active'); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + if (opts.strict) throw new Error(`malformed active session store in ${ref.path}`); + return []; + } + const out: Session[] = []; + for (const value of Object.values(parsed as Record)) { + if (!value || typeof value !== 'object' || (value as { status?: unknown }).status !== 'active') continue; + const session = value as Partial; + if (typeof session.sessionId !== 'string') { + if (opts.strict) throw new Error(`malformed active session row in ${ref.path}: invalid session object`); + continue; + } + out.push(session as Session); + } + return out; } const db = openDbForRead(ref.path); try { @@ -571,7 +585,17 @@ function readStoreActiveRows( const rows = db.prepare(sql).all(...params) as { row: string }[]; const out: Session[] = []; for (const r of rows) { - try { out.push(JSON.parse(r.row) as Session); } catch { /* skip unparseable row */ } + try { + const session = JSON.parse(r.row) as Session; + if (!session || typeof session !== 'object' || typeof session.sessionId !== 'string') { + throw new Error('invalid session object'); + } + out.push(session); + } catch (err) { + if (opts.strict) { + throw new Error(`malformed active session row in ${ref.path}: ${err instanceof Error ? err.message : String(err)}`); + } + } } return out; } finally { @@ -2424,6 +2448,35 @@ export function findActiveChatScopeSessionsByChat(chatId: string): Session[] { ); } +export function findActiveSessionsByWorkingDir(workingDir: string): Session[] { + return findActiveSessionsMatching(s => s.workingDir === workingDir); +} + +/** Destructive-worktree inventory: unlike ordinary discovery this is fail-closed. */ +export function findActiveSessionsByWorkingDirStrict(workingDir: string): Session[] { + load(); + if (loadFailure) throw new SessionStoreUnavailableError(loadFailure); + const target = resolve(workingDir); + const matches: Session[] = []; + const targetReal = realpathSync(target); + const matchesDir = (session: Session) => { + if (session.status !== 'active' || !session.workingDir) return false; + let candidate: string; + try { candidate = realpathSync(resolve(session.workingDir)); } + catch { candidate = resolve(session.workingDir); } + const rel = relative(targetReal, candidate); + return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)); + }; + for (const session of sessions.values()) if (matchesDir(session)) matches.push(session); + for (const ref of listStoreRefs(config.session.dataDir, { strict: true })) { + if (ref.appId === currentAppId) continue; + for (const session of readStoreActiveRows(ref, undefined, { strict: true })) { + if (matchesDir(session)) matches.push(session); + } + } + return matches; +} + /** * Cross-store lookup: every active thread-scope session in `chatId`, across * all bots. Backs `schedule add --follow-active`: at fire time the scheduler diff --git a/src/services/worktree-cleanup-store.ts b/src/services/worktree-cleanup-store.ts new file mode 100644 index 000000000..ffd7938a4 --- /dev/null +++ b/src/services/worktree-cleanup-store.ts @@ -0,0 +1,83 @@ +import { createHash } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { atomicWriteFileSync } from '../utils/atomic-write.js'; +import { withFileLockSync } from '../utils/file-lock.js'; + +export interface WorktreeCleanupJob { + id: string; + larkAppId: string; + worktreeMain: string; + worktreeDir: string; + safetyFingerprint: string; + error: string; + createdAt: number; + updatedAt: number; +} + +type Store = Record; + +function storePath(dataDir: string): string { + return join(dataDir, 'worktree-cleanup-jobs.json'); +} + +function readStore(path: string): Store { + if (!existsSync(path)) return {}; + const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('invalid worktree cleanup job store'); + } + return parsed as Store; +} + +function writeStore(path: string, value: Store): void { + atomicWriteFileSync(path, `${JSON.stringify(value, null, 2)}\n`, { + mode: 0o600, + durable: true, + followTargetSymlink: false, + }); +} + +function jobId(worktreeDir: string): string { + return createHash('sha256').update(resolve(worktreeDir)).digest('hex').slice(0, 16); +} + +export function putWorktreeCleanupJob( + dataDir: string, + input: Omit, + now: number = Date.now(), +): WorktreeCleanupJob { + mkdirSync(dataDir, { recursive: true }); + const path = storePath(dataDir); + return withFileLockSync(path, () => { + const store = readStore(path); + const id = jobId(input.worktreeDir); + const job: WorktreeCleanupJob = { + ...input, + id, + worktreeMain: resolve(input.worktreeMain), + worktreeDir: resolve(input.worktreeDir), + createdAt: store[id]?.createdAt ?? now, + updatedAt: now, + }; + store[id] = job; + writeStore(path, store); + return job; + }); +} + +export function getWorktreeCleanupJob(dataDir: string, id: string): WorktreeCleanupJob | undefined { + const path = storePath(dataDir); + return withFileLockSync(path, () => readStore(path)[id]); +} + +export function deleteWorktreeCleanupJob(dataDir: string, id: string): boolean { + const path = storePath(dataDir); + return withFileLockSync(path, () => { + const store = readStore(path); + if (!store[id]) return false; + delete store[id]; + writeStore(path, store); + return true; + }); +} diff --git a/src/types.ts b/src/types.ts index 9779f0d4e..d38ad0a06 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1248,6 +1248,11 @@ export interface PendingRepoSetup { rawInput?: string; turnId?: string; baseDir?: string; + force?: boolean; + worktreePath?: string; + branch?: string; + reuseExisting?: boolean; + targetSubdir?: string; repoCardMessageId?: string; codexAppText?: string; codexAppApplicationContext?: string; diff --git a/test/api-only-mode-wiring.test.ts b/test/api-only-mode-wiring.test.ts index 36aca63a8..754238ce2 100644 --- a/test/api-only-mode-wiring.test.ts +++ b/test/api-only-mode-wiring.test.ts @@ -802,7 +802,10 @@ describe('core-only entrypoint hardening (codex 4 P1s — source lock)', () => { '\n\n // Close CoT thinking bubbles orphaned by the previous daemon generation', ); expect(helperCall).toContain( - 'restoreSessions: () => restoreActiveSessions(activeSessions, idempotencyQuarantinedSessionIds),', + 'restoreSessions: () => restoreActiveSessions(activeSessions, idempotencyQuarantinedSessionIds, {', + ); + expect(helperCall).toContain( + 'prepareTurn: (ds, turnId) => prepareTurnCliIdentity(ds, turnId),', ); expect(helperCall).toContain('markSessionsRestored: () => {'); expect(helperCall).toContain('sessionsRestored = true;'); diff --git a/test/card-handler-repo-select.test.ts b/test/card-handler-repo-select.test.ts index bc969a4da..d8e6bf532 100644 --- a/test/card-handler-repo-select.test.ts +++ b/test/card-handler-repo-select.test.ts @@ -510,7 +510,7 @@ describe('repo select card — plain switch', () => { // next real message gets the full new-topic opening context. it('pendingRepo card selection with nothing buffered boots the CLI idle and marks the first turn pending', async () => { - const ds = makeDs({ pendingRepo: true, pendingPrompt: '', worker: null }); + const ds = makeDs({ pendingRepo: true, pendingPrompt: '', pendingTurnId: 'om_bare_worktree', worker: null }); const { deps } = makeDeps(ds); await handleCardAction(makeSelectEvent('repo_switch', '/repos/alpha'), deps, APP_ID); @@ -1883,6 +1883,49 @@ describe('repo select card — worktree open', () => { expect(vi.mocked(deleteMessage)).not.toHaveBeenCalled(); }); + it('close_worktree_confirm rejects a non-operator before running the destructive command', async () => { + const ds = makeDs({ workingDir: '/repos/alpha-wt-task' }); + const { deps } = makeDeps(ds); + vi.mocked(canOperate).mockReturnValueOnce(false); + + const res = await handleCardAction({ + operator: { open_id: 'ou_stranger' }, + action: { value: { + action: 'close_worktree_confirm', + root_id: ROOT_ID, + session_id: ds.session.sessionId, + invoker_open_id: OWNER, + } }, + context: { open_message_id: 'om_card' }, + }, deps, APP_ID); + + expect(res?.toast?.type).toBe('error'); + expect(res?.toast?.content).toContain('操作员'); + expect(closeWorkerPoolSession).not.toHaveBeenCalled(); + expect(removeRepoWorktree).not.toHaveBeenCalled(); + }); + + it('close_worktree_confirm is pinned to the operator who requested the confirmation', async () => { + const ds = makeDs({ workingDir: '/repos/alpha-wt-task' }); + const { deps } = makeDeps(ds); + + const res = await handleCardAction({ + operator: { open_id: OWNER }, + action: { value: { + action: 'close_worktree_confirm', + root_id: ROOT_ID, + session_id: ds.session.sessionId, + invoker_open_id: 'ou_other_operator', + } }, + context: { open_message_id: 'om_card' }, + }, deps, APP_ID); + + expect(res?.toast?.type).toBe('error'); + expect(res?.toast?.content).toContain('发起本次确认'); + expect(closeWorkerPoolSession).not.toHaveBeenCalled(); + expect(removeRepoWorktree).not.toHaveBeenCalled(); + }); + it('get_write_link 破例:非 operator 点击得到「无操作权限」toast,而非像其它敏感动作那样静默', async () => { // 与上面的 worktree_toggle_mode 对照:敏感门控默认静默 block(仅日志),但 //「获取操作链接」是用户主动点的取权动作,静默会让人以为按钮坏了 —— 破例给提示。 @@ -1925,6 +1968,52 @@ describe('repo select card — worktree open', () => { }); describe('auto-worktree detached commit admission', () => { + it('fails closed when the preserved target subdirectory is absent', async () => { + const ds = makeDs({ pendingRepo: true, pendingPrompt: 'delayed first turn', worker: null }); + const { deps } = makeDeps(ds); + const notify = vi.fn(); + vi.mocked(maybeCreateDefaultWorktree).mockResolvedValueOnce({ dir: '/repos/alpha-wt' }); + + await runAutoWorktreeCommit({ + ds, + anchor: ROOT_ID, + larkAppId: APP_ID, + baseDir: '/repos/alpha/packages/app', + prompt: 'delayed first turn', + activeSessions: deps.activeSessions, + notify, + force: true, + targetSubdir: 'packages/app', + }); + + expect(forkWorker).not.toHaveBeenCalled(); + expect(ds.pendingRepo).toBe(true); + expect(notify).toHaveBeenCalledWith(expect.stringContaining('packages/app')); + }); + + it('keeps an explicit failed worktree start actionable while pending', async () => { + const ds = makeDs({ pendingRepo: true, pendingPrompt: 'delayed first turn', worker: null }); + const { deps } = makeDeps(ds); + const notify = vi.fn(); + vi.mocked(maybeCreateDefaultWorktree).mockRejectedValueOnce(new Error('cannot create worktree')); + + await runAutoWorktreeCommit({ + ds, + anchor: ROOT_ID, + larkAppId: APP_ID, + baseDir: '/repos/alpha', + prompt: 'delayed first turn', + activeSessions: deps.activeSessions, + notify, + force: true, + }); + + expect(ds.pendingRepo).toBe(true); + expect(ds.worker).toBeNull(); + expect(notify).toHaveBeenCalledWith(expect.stringContaining('/repo')); + expect(notify).toHaveBeenCalledWith(expect.stringContaining('/tw')); + }); + it('holds the delayed commit/fork behind a same-bot mutation after the caller lease ended', async () => { const ds = makeDs({ pendingRepo: true, diff --git a/test/close-consumer-matrix.test.ts b/test/close-consumer-matrix.test.ts index 8a2d54909..a6057cbfa 100644 --- a/test/close-consumer-matrix.test.ts +++ b/test/close-consumer-matrix.test.ts @@ -76,10 +76,10 @@ const CONSUMERS: Record = { // ── user surfaces: must render refusal AND residual ────────────────────── 'core/command-handler.ts::handleCommand::closeSession': { category: 'user_surface', - why: '/close plus shared-adopt /detach and /disconnect all branch on ' - + 'refused/residual results; none report an ordinary close/disconnect while ' - + 'cleanup is unproven.', - count: 4, + why: '/close, shared-adopt /detach and /disconnect, and same-daemon /close wt ' + + 'siblings all branch on refused/residual results; none report ordinary ' + + 'success or remove a worktree while cleanup is unproven.', + count: 5, }, 'core/command-handler.ts::commitRepoSelection::closeSession': { category: 'user_surface', @@ -343,6 +343,10 @@ const RESPONSE_CONSUMERS: Record = { why: 'Sessions board card: residual banner on the closed detail card.', mustParse: true, }, + 'core/command-handler.ts::handleCommand::close-route': { + why: 'Cross-daemon /close wt parses residual and refuses worktree removal when remote teardown is incomplete.', + mustParse: true, + }, 'core/dashboard-ipc-server.ts::::close-route': { why: 'Serves the route; replays the residual on the closed-row fast path.', mustParse: false, diff --git a/test/command-handler.test.ts b/test/command-handler.test.ts index 81fd2af62..aad7d9e10 100644 --- a/test/command-handler.test.ts +++ b/test/command-handler.test.ts @@ -169,6 +169,9 @@ vi.mock('../src/services/session-store.js', () => ({ })), updateSession: vi.fn(), getSession: vi.fn(() => undefined), + findActiveChatScopeSessionsByChat: vi.fn(() => []), + findActiveSessionsByWorkingDir: vi.fn(() => []), + findActiveSessionsByWorkingDirStrict: vi.fn(() => []), getOwnedSession: vi.fn(() => undefined), listSessions: vi.fn(() => []), collectBotmuxSessionIdentities: vi.fn(() => new Set()), @@ -200,6 +203,12 @@ vi.mock('../src/services/project-scanner.js', () => ({ vi.mock('../src/services/git-worktree.js', () => ({ createRepoWorktree: vi.fn(), pushWorktreeBranch: vi.fn(async () => {}), + isLinkedWorktree: vi.fn(async () => false), + mainWorktreeFor: vi.fn(async () => '/home/testuser/project'), + worktreeRootFor: vi.fn(async (dir: string) => dir), + withWorktreeTargetLock: vi.fn(async (_path: string, fn: () => Promise) => fn()), + removeRepoWorktree: vi.fn(async () => {}), + worktreeSafetyStatus: vi.fn(async () => ({ dirty: false, dirtyCount: 0, dirtyFiles: [], ahead: 0, unpushedCommits: [], fingerprint: 'clean-state' })), })); vi.mock('../src/services/worktree-slug-ai.js', () => ({ @@ -501,6 +510,24 @@ vi.mock('../src/im/lark/event-dispatcher.js', () => ({ canOperate: vi.fn(() => true), })); +vi.mock('../src/services/bot-union-ids-store.js', () => ({ + getBotUnionId: vi.fn(() => undefined), +})); + +vi.mock('../src/services/team-bots-store.js', () => ({ + isTeamBot: vi.fn(() => false), +})); + +vi.mock('../src/services/platform-team-store.js', () => ({ + isPlatformTeamBot: vi.fn(() => false), +})); + +vi.mock('../src/services/worktree-cleanup-store.js', () => ({ + putWorktreeCleanupJob: vi.fn((_: string, input: any) => ({ ...input, id: 'cleanup-123', createdAt: 1, updatedAt: 1 })), + getWorktreeCleanupJob: vi.fn(() => undefined), + deleteWorktreeCleanupJob: vi.fn(() => true), +})); + vi.mock('../src/services/card-mode-store.js', () => ({ setCardMode: vi.fn(async () => ({ ok: true })), })); @@ -519,7 +546,7 @@ vi.mock('../src/im/lark/cot-message.js', () => ({ // ─── Imports (after mocks) ────────────────────────────────────────────────── -import { DAEMON_COMMANDS, SESSIONLESS_DAEMON_COMMANDS, PASSTHROUGH_COMMANDS, cliHasNoRawPassthroughSurface, resolvePassthroughCommands, resolveAdapterDefaultPassthroughCommands, handleCommand, handleCardCommand, handleCotCommand, handleTermLinkCommand, parseSlashCommandInvocation, parseTopicHeader, isTopicHeader, startAdoptSession, startResumeImportSession, startCodexAppThreadSession, startForkSubtopicSession } from '../src/core/command-handler.js'; +import { DAEMON_COMMANDS, SESSIONLESS_DAEMON_COMMANDS, PASSTHROUGH_COMMANDS, cliHasNoRawPassthroughSurface, resolvePassthroughCommands, resolveAdapterDefaultPassthroughCommands, handleCommand, handleCardCommand, handleCotCommand, handleTermLinkCommand, parseSlashCommandInvocation, parseForceTopicInvocation, parseTopicHeader, isTopicHeader, startAdoptSession, startResumeImportSession, startCodexAppThreadSession, startForkSubtopicSession } from '../src/core/command-handler.js'; import { setCardMode } from '../src/services/card-mode-store.js'; import { setChatStreamingCardPin } from '../src/services/pin-streaming-card-mode-store.js'; import { setCotMode } from '../src/services/cot-mode-store.js'; @@ -541,6 +568,10 @@ import { dashboardEventBus, type DashboardEvent } from '../src/core/dashboard-ev import { publishClosedSessionPatch } from '../src/core/session-activity.js'; import { getOwnerOpenId } from '../src/bot-registry.js'; import { canOperate } from '../src/im/lark/event-dispatcher.js'; +import { deleteWorktreeCleanupJob, getWorktreeCleanupJob, putWorktreeCleanupJob } from '../src/services/worktree-cleanup-store.js'; +import { getBotUnionId } from '../src/services/bot-union-ids-store.js'; +import { isTeamBot } from '../src/services/team-bots-store.js'; +import { isPlatformTeamBot } from '../src/services/platform-team-store.js'; import { getSessionWorkingDir, buildNewTopicPrompt, buildNewTopicCliInput, ensureSessionWhiteboard, getAvailableBots, resumeSession } from '../src/core/session-manager.js'; import * as sessionStore from '../src/services/session-store.js'; import * as scheduleStore from '../src/services/schedule-store.js'; @@ -560,11 +591,12 @@ import { bindOncall } from '../src/services/oncall-store.js'; import { putVcMeetingPreparation } from '../src/services/vc-meeting-preparations-store.js'; import { existsSync, statSync, readFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join, resolve } from 'node:path'; +import { createHash } from 'node:crypto'; import { codexHome } from '../src/services/codex-paths.js'; import { scanMultipleProjects, describeProjectDir } from '../src/services/project-scanner.js'; import { readGlobalConfig, repoPickerScanOptions } from '../src/global-config.js'; -import { createRepoWorktree, pushWorktreeBranch } from '../src/services/git-worktree.js'; +import { createRepoWorktree, pushWorktreeBranch, isLinkedWorktree, mainWorktreeFor, removeRepoWorktree, withWorktreeTargetLock, worktreeRootFor, worktreeSafetyStatus } from '../src/services/git-worktree.js'; import { discoverAdoptableSessions, validateAdoptTarget } from '../src/core/session-discovery.js'; import { listCodexAppThreads } from '../src/services/codex-app-threads.js'; import { discoverSlashCommandsForAdapter } from '../src/core/command-discovery.js'; @@ -608,6 +640,21 @@ function makeSession(overrides: Partial = {}): Session { }; } +function closeWorktreeState( + sessionId: string, + siblingSessionIds: string[] = [], + safetyFingerprint = 'clean-state', + invokerOpenId = 'ou_sender', +): string { + return createHash('sha256').update(JSON.stringify({ + sessionId, + worktreeDir: resolve('/home/testuser/project-wt-task'), + siblingSessionIds: [...siblingSessionIds].sort(), + safetyFingerprint, + invokerOpenId, + })).digest('hex'); +} + function makeDaemonSession(overrides: Partial = {}): DaemonSession { return { session: makeSession(), @@ -682,7 +729,7 @@ function mockCodexAppBot(): void { describe('DAEMON_COMMANDS set', () => { it('should contain all expected commands', () => { - const expected = ['/close', '/restart', '/status', '/retry', '/help', '/cd', '/repo', '/rename', '/schedule', '/role', '/botconfig', '/skills', '/pair', '/login', '/adopt', '/detach', '/disconnect', '/oncall', '/project', '/group', '/g', '/relay', '/quote', '/fork', '/forklist', '/card', '/cot', '/term', '/list-slash-command', '/slash', '/subscribe-lark-doc', '/watch-comment', '/vc', '/insight', '/dashboard', '/sessions', '/vc-auth', '/issue', '/cli']; + const expected = ['/close', '/cleanup-wt', '/restart', '/status', '/retry', '/help', '/cd', '/repo', '/rename', '/schedule', '/role', '/botconfig', '/skills', '/pair', '/login', '/adopt', '/detach', '/disconnect', '/oncall', '/project', '/group', '/g', '/relay', '/quote', '/fork', '/forklist', '/card', '/cot', '/term', '/list-slash-command', '/slash', '/subscribe-lark-doc', '/watch-comment', '/vc', '/insight', '/dashboard', '/sessions', '/vc-auth', '/issue', '/cli']; for (const cmd of expected) { expect(DAEMON_COMMANDS.has(cmd), `Expected DAEMON_COMMANDS to contain ${cmd}`).toBe(true); } @@ -715,10 +762,10 @@ describe('DAEMON_COMMANDS set', () => { }); it('should have the correct size', () => { - // 39 = master 的 36 条 + /quote + /sessions + /project。 + // 40 = master 的 36 条 + /quote + /sessions + /project + /cleanup-wt。 // /fork 与 /issue 仍是一等 daemon 命令;/subscribe-lark-doc 保持原本的 // 按文件 API 订阅命令语义,不做别名。 - expect(DAEMON_COMMANDS.size).toBe(39); + expect(DAEMON_COMMANDS.size).toBe(40); }); it('contains the /list-slash-command lister and its /slash alias', () => { @@ -975,6 +1022,7 @@ describe('SESSIONLESS_DAEMON_COMMANDS set', () => { expect(SESSIONLESS_DAEMON_COMMANDS.has('/project')).toBe(true); expect(SESSIONLESS_DAEMON_COMMANDS.has('/skills')).toBe(true); expect(SESSIONLESS_DAEMON_COMMANDS.has('/sessions')).toBe(true); + expect(SESSIONLESS_DAEMON_COMMANDS.has('/cleanup-wt')).toBe(true); }); it('is a subset of DAEMON_COMMANDS (they are still daemon-handled)', () => { @@ -1592,6 +1640,14 @@ describe('parseTopicHeader(取代 parseForceTopicInvocation 的路由元命令 it('preserves multiline prompt content verbatim after the sentinel', () => { expect(forceTopic('/t line1\nline2\nline3')).toEqual({ prompt: 'line1\nline2\nline3' }); + + }); + + it('retains cwd and worktree lifecycle aliases', () => { + expect(parseForceTopicInvocation('/t here 检查实现')).toEqual({ prompt: '检查实现', mode: 'here' }); + expect(parseForceTopicInvocation('/topic worktree 检查实现')).toEqual({ prompt: '检查实现', mode: 'worktree' }); + expect(parseForceTopicInvocation('/th 检查实现')).toEqual({ prompt: '检查实现', mode: 'here' }); + expect(parseForceTopicInvocation('/tw 检查实现')).toEqual({ prompt: '检查实现', mode: 'worktree' }); }); it('does not match similar prefixes', () => { @@ -1602,6 +1658,7 @@ describe('parseTopicHeader(取代 parseForceTopicInvocation 的路由元命令 it('tolerates leading whitespace', () => { expect(forceTopic(' /t hello')).toEqual({ prompt: 'hello' }); + }); it('returns null for non-slash text', () => { @@ -2318,6 +2375,421 @@ describe('handleCommand', () => { expect(cardJson).toContain('"action":"resume"'); }); + + + it('continues sibling and worktree cleanup when the closed-session card delivery fails', async () => { + const ds = makeDaemonSession({ scope: 'thread', workingDir: '/home/testuser/project-wt-task' }); + ds.session.workingDir = '/home/testuser/project-wt-task'; + const deps = makeDeps(ds); + vi.mocked(isLinkedWorktree).mockResolvedValueOnce(true); + vi.mocked(mainWorktreeFor).mockResolvedValueOnce('/home/testuser/project'); + vi.mocked(deliverEphemeralOrReply).mockRejectedValueOnce(new Error('card delivery unavailable')); + + await handleCommand('/close', ROOT_ID, makeLarkMessage(`/close wt --yes --state=${closeWorktreeState(ds.session.sessionId)}`), deps, LARK_APP_ID); + + expect(removeRepoWorktree).toHaveBeenCalledWith('/home/testuser/project', '/home/testuser/project-wt-task'); + }); + + it('persists a retry job when final worktree removal fails', async () => { + const ds = makeDaemonSession({ scope: 'thread', workingDir: '/home/testuser/project-wt-task' }); + ds.session.workingDir = '/home/testuser/project-wt-task'; + const deps = makeDeps(ds); + vi.mocked(isLinkedWorktree).mockResolvedValueOnce(true); + vi.mocked(mainWorktreeFor).mockResolvedValueOnce('/home/testuser/project'); + vi.mocked(removeRepoWorktree).mockRejectedValueOnce(new Error('worktree busy')); + + await handleCommand('/close', ROOT_ID, makeLarkMessage(`/close wt --yes --state=${closeWorktreeState(ds.session.sessionId)}`), deps, LARK_APP_ID); + + expect(withWorktreeTargetLock).toHaveBeenCalledWith('/home/testuser/project-wt-task', expect.any(Function)); + expect(putWorktreeCleanupJob).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ + larkAppId: LARK_APP_ID, + worktreeMain: '/home/testuser/project', + worktreeDir: '/home/testuser/project-wt-task', + error: 'worktree busy', + })); + const replies = vi.mocked(deps.sessionReply).mock.calls.map(c => c[1]).join('\n'); + expect(replies).toContain('/cleanup-wt cleanup-123'); + }); + + it('does not persist a cleanup job when removal succeeded but its success reply fails', async () => { + const ds = makeDaemonSession({ scope: 'thread', workingDir: '/home/testuser/project-wt-task' }); + ds.session.workingDir = '/home/testuser/project-wt-task'; + const deps = makeDeps(ds); + vi.mocked(isLinkedWorktree).mockResolvedValueOnce(true); + vi.mocked(mainWorktreeFor).mockResolvedValueOnce('/home/testuser/project'); + vi.mocked(deps.sessionReply).mockImplementation(async (_root, content) => { + if (String(content).includes('额外关闭 0 个同 worktree 会话')) throw new Error('reply unavailable'); + }); + + await handleCommand('/close', ROOT_ID, makeLarkMessage(`/close wt --yes --state=${closeWorktreeState(ds.session.sessionId)}`), deps, LARK_APP_ID); + + expect(removeRepoWorktree).toHaveBeenCalledWith('/home/testuser/project', '/home/testuser/project-wt-task'); + expect(putWorktreeCleanupJob).not.toHaveBeenCalled(); + }); + + it('does not report a retained retry job when cleanup succeeded but its success reply fails', async () => { + const deps = makeDeps(undefined); + vi.mocked(isLinkedWorktree).mockResolvedValueOnce(true); + vi.mocked(mainWorktreeFor).mockResolvedValueOnce('/home/testuser/project'); + vi.mocked(getWorktreeCleanupJob).mockReturnValueOnce({ + id: 'cleanup-123', larkAppId: LARK_APP_ID, + worktreeMain: '/home/testuser/project', worktreeDir: '/home/testuser/project-wt-task', + safetyFingerprint: 'clean-state', error: 'busy', createdAt: 1, updatedAt: 1, + }); + vi.mocked(deps.sessionReply).mockImplementation(async (_root, content) => { + if (String(content).includes('已重试并移除 worktree')) throw new Error('reply unavailable'); + }); + + await handleCommand('/cleanup-wt', ROOT_ID, makeLarkMessage('/cleanup-wt cleanup-123'), deps, LARK_APP_ID); + + expect(removeRepoWorktree).toHaveBeenCalled(); + expect(deleteWorktreeCleanupJob).toHaveBeenCalled(); + expect(vi.mocked(deps.sessionReply).mock.calls.map(call => String(call[1])).join('\n')) + .not.toContain('任务已保留'); + }); + + it('retries a durable cleanup job without an active session', async () => { + const deps = makeDeps(undefined); + vi.mocked(isLinkedWorktree).mockResolvedValueOnce(true); + vi.mocked(mainWorktreeFor).mockResolvedValueOnce('/home/testuser/project'); + vi.mocked(getWorktreeCleanupJob).mockReturnValueOnce({ + id: 'cleanup-123', larkAppId: LARK_APP_ID, + worktreeMain: '/home/testuser/project', worktreeDir: '/home/testuser/project-wt-task', + safetyFingerprint: 'clean-state', error: 'busy', createdAt: 1, updatedAt: 1, + }); + + await handleCommand('/cleanup-wt', ROOT_ID, makeLarkMessage('/cleanup-wt cleanup-123'), deps, LARK_APP_ID); + + expect(withWorktreeTargetLock).toHaveBeenCalledWith('/home/testuser/project-wt-task', expect.any(Function)); + expect(removeRepoWorktree).toHaveBeenCalledWith('/home/testuser/project', '/home/testuser/project-wt-task'); + expect(deleteWorktreeCleanupJob).toHaveBeenCalledWith(expect.any(String), 'cleanup-123'); + }); + + it('`/close wt` closes the session and removes a linked worktree', async () => { + const ds = makeDaemonSession({ scope: 'thread', workingDir: '/home/testuser/project-wt-task' }); + ds.session.workingDir = '/home/testuser/project-wt-task'; + const deps = makeDeps(ds); + vi.mocked(isLinkedWorktree).mockResolvedValueOnce(true); + vi.mocked(mainWorktreeFor).mockResolvedValueOnce('/home/testuser/project'); + + await handleCommand('/close', ROOT_ID, makeLarkMessage(`/close wt --yes --state=${closeWorktreeState(ds.session.sessionId)}`), deps, LARK_APP_ID); + + expect(closeSession).toHaveBeenCalledWith(ds.session.sessionId); + expect(removeRepoWorktree).toHaveBeenCalledWith('/home/testuser/project', '/home/testuser/project-wt-task'); + const replies = vi.mocked(deps.sessionReply).mock.calls.map(c => c[1]).join('\n'); + expect(replies).toContain('额外关闭 0 个同 worktree 会话'); + }); + + + + it('`/close wt` fails closed when the cross-store inventory is unavailable', async () => { + const ds = makeDaemonSession({ scope: 'thread', workingDir: '/home/testuser/project-wt-task' }); + ds.session.workingDir = '/home/testuser/project-wt-task'; + const deps = makeDeps(ds); + vi.mocked(isLinkedWorktree).mockResolvedValueOnce(true); + vi.mocked(mainWorktreeFor).mockResolvedValueOnce('/home/testuser/project'); + vi.mocked(sessionStore.findActiveSessionsByWorkingDirStrict) + .mockImplementationOnce(() => { throw new Error('inventory unavailable'); }); + + await handleCommand('/close', ROOT_ID, makeLarkMessage('/close wt --yes'), deps, LARK_APP_ID); + + expect(closeSession).not.toHaveBeenCalled(); + expect(removeRepoWorktree).not.toHaveBeenCalled(); + const replies = vi.mocked(deps.sessionReply).mock.calls.map(c => c[1]).join('\n'); + expect(replies).toContain('inventory unavailable'); + }); + + it('binds worktree confirmation state to the requesting operator', async () => { + const ds = makeDaemonSession({ scope: 'thread', workingDir: '/home/testuser/project-wt-task' }); + ds.session.workingDir = '/home/testuser/project-wt-task'; + const deps = makeDeps(ds); + vi.mocked(isLinkedWorktree).mockResolvedValue(true); + vi.mocked(mainWorktreeFor).mockResolvedValue('/home/testuser/project'); + const state = closeWorktreeState(ds.session.sessionId, [], 'clean-state', 'ou_requester'); + + await handleCommand('/close', ROOT_ID, makeLarkMessage(`/close wt --yes --state=${state}`, { + senderId: 'ou_other_operator', + }), deps, LARK_APP_ID); + + expect(closeSession).not.toHaveBeenCalled(); + expect(removeRepoWorktree).not.toHaveBeenCalled(); + expect(vi.mocked(deps.sessionReply).mock.calls.map(c => c[1]).join('\n')) + .toContain('状态在确认卡生成后发生变化'); + }); + + it('`/close wt` asks for confirmation when other sessions share the worktree', async () => { + const ds = makeDaemonSession({ scope: 'thread', workingDir: '/home/testuser/project-wt-task' }); + ds.session.workingDir = '/home/testuser/project-wt-task'; + const deps = makeDeps(ds); + vi.mocked(isLinkedWorktree).mockResolvedValueOnce(true); + vi.mocked(mainWorktreeFor).mockResolvedValueOnce('/home/testuser/project'); + vi.mocked(sessionStore.findActiveSessionsByWorkingDirStrict).mockReturnValueOnce([ + ds.session, + { ...makeSession({ sessionId: 'sibling-1', larkAppId: LARK_APP_ID }), workingDir: '/home/testuser/project-wt-task' } as any, + ]); + + await handleCommand('/close', ROOT_ID, makeLarkMessage('/close wt'), deps, LARK_APP_ID); + + expect(closeSession).not.toHaveBeenCalled(); + expect(removeRepoWorktree).not.toHaveBeenCalled(); + const replies = vi.mocked(deps.sessionReply).mock.calls.map(c => c[1]).join('\n'); + expect(deps.sessionReply).toHaveBeenCalledWith( + ROOT_ID, + expect.stringContaining('确认关闭话题并删除 worktree'), + 'interactive', + LARK_APP_ID, + 'msg_001', + ); + }); + + it('`/close wt --yes` without a confirmation state never deletes even a clean worktree', async () => { + const ds = makeDaemonSession({ scope: 'thread', workingDir: '/home/testuser/project-wt-task' }); + ds.session.workingDir = '/home/testuser/project-wt-task'; + const deps = makeDeps(ds); + vi.mocked(isLinkedWorktree).mockResolvedValueOnce(true); + vi.mocked(mainWorktreeFor).mockResolvedValueOnce('/home/testuser/project'); + + await handleCommand('/close', ROOT_ID, makeLarkMessage('/close wt --yes'), deps, LARK_APP_ID); + + expect(closeSession).not.toHaveBeenCalled(); + expect(removeRepoWorktree).not.toHaveBeenCalled(); + expect(deps.sessionReply).toHaveBeenCalledWith( + ROOT_ID, + expect.stringContaining('confirmation_state'), + 'interactive', + LARK_APP_ID, + 'msg_001', + ); + }); + + it('`/close wt --yes` refuses to close a cross-bot sibling outside the trusted team', async () => { + const ds = makeDaemonSession({ scope: 'thread', workingDir: '/home/testuser/project-wt-task' }); + ds.session.workingDir = '/home/testuser/project-wt-task'; + const deps = makeDeps(ds); + vi.mocked(isLinkedWorktree).mockResolvedValueOnce(true); + vi.mocked(mainWorktreeFor).mockResolvedValueOnce('/home/testuser/project'); + vi.mocked(sessionStore.findActiveSessionsByWorkingDirStrict).mockReturnValueOnce([ + ds.session, + { ...makeSession({ sessionId: 'sibling-remote', larkAppId: 'app-2' }), workingDir: '/home/testuser/project-wt-task' } as any, + ]); + + await handleCommand('/close', ROOT_ID, makeLarkMessage('/close wt --yes'), deps, LARK_APP_ID); + + expect(closeSession).not.toHaveBeenCalled(); + expect(removeRepoWorktree).not.toHaveBeenCalled(); + const replies = vi.mocked(deps.sessionReply).mock.calls.map(c => c[1]).join('\n'); + expect(replies).toContain('不属于可信团队'); + }); + + it('`/close wt --yes` allows a trusted-team cross-bot sibling', async () => { + const ds = makeDaemonSession({ scope: 'thread', workingDir: '/home/testuser/project-wt-task' }); + ds.session.workingDir = '/home/testuser/project-wt-task'; + const deps = makeDeps(ds); + vi.mocked(isLinkedWorktree).mockResolvedValueOnce(true); + vi.mocked(mainWorktreeFor).mockResolvedValueOnce('/home/testuser/project'); + vi.mocked(sessionStore.findActiveSessionsByWorkingDirStrict).mockReturnValueOnce([ + ds.session, + { ...makeSession({ sessionId: 'sibling-remote', larkAppId: 'app-2' }), workingDir: '/home/testuser/project-wt-task' } as any, + ]); + vi.mocked(getBotUnionId).mockReturnValueOnce('on_team_bot'); + vi.mocked(isTeamBot).mockReturnValueOnce(true); + const dd = await import('../src/utils/daemon-discovery.js'); + vi.mocked(dd.findOnlineDaemon).mockReturnValueOnce({ larkAppId: 'app-2', ipcPort: 9999 }); + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ ok: true, outcome: 'closed' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }))); + + try { + await handleCommand('/close', ROOT_ID, makeLarkMessage(`/close wt --yes --state=${closeWorktreeState(ds.session.sessionId, ['sibling-remote'])}`), deps, LARK_APP_ID); + } finally { + vi.unstubAllGlobals(); + } + + expect(isPlatformTeamBot).not.toHaveBeenCalled(); + expect(removeRepoWorktree).toHaveBeenCalledWith('/home/testuser/project', '/home/testuser/project-wt-task'); + }); + + it('`/close wt --yes` closes sibling sessions before removing the shared worktree', async () => { + const ds = makeDaemonSession({ scope: 'thread', workingDir: '/home/testuser/project-wt-task' }); + ds.session.workingDir = '/home/testuser/project-wt-task'; + const deps = makeDeps(ds); + vi.mocked(isLinkedWorktree).mockResolvedValueOnce(true); + vi.mocked(mainWorktreeFor).mockResolvedValueOnce('/home/testuser/project'); + vi.mocked(sessionStore.findActiveSessionsByWorkingDirStrict).mockReturnValueOnce([ + ds.session, + { ...makeSession({ sessionId: 'sibling-1', larkAppId: LARK_APP_ID }), workingDir: '/home/testuser/project-wt-task' } as any, + ]); + + await handleCommand('/close', ROOT_ID, makeLarkMessage(`/close wt --yes --state=${closeWorktreeState(ds.session.sessionId, ['sibling-1'])}`), deps, LARK_APP_ID); + + expect(closeSession).toHaveBeenCalledWith(ds.session.sessionId); + expect(closeSession).toHaveBeenCalledWith('sibling-1'); + expect(removeRepoWorktree).toHaveBeenCalledWith('/home/testuser/project', '/home/testuser/project-wt-task'); + const replies = vi.mocked(deps.sessionReply).mock.calls.map(c => c[1]).join('\n'); + expect(replies).toContain('额外关闭 1 个同 worktree 会话'); + }); + + it('rejects a stale worktree confirmation card and returns a fresh card', async () => { + const ds = makeDaemonSession({ scope: 'thread', workingDir: '/home/testuser/project-wt-task' }); + ds.session.workingDir = '/home/testuser/project-wt-task'; + const deps = makeDeps(ds); + vi.mocked(isLinkedWorktree).mockResolvedValueOnce(true); + vi.mocked(mainWorktreeFor).mockResolvedValueOnce('/home/testuser/project'); + vi.mocked(worktreeSafetyStatus).mockResolvedValueOnce({ + dirty: true, + dirtyFiles: ['src/new-change.ts'], + ahead: 0, + unpushedCommits: [], + fingerprint: 'new-state', + }); + + await handleCommand('/close', ROOT_ID, makeLarkMessage('/close wt --yes --state=stale-state'), deps, LARK_APP_ID); + + expect(closeSession).not.toHaveBeenCalled(); + expect(removeRepoWorktree).not.toHaveBeenCalled(); + const replies = vi.mocked(deps.sessionReply).mock.calls.map(c => c[1]).join('\n'); + expect(replies).toContain('状态在确认卡生成后发生变化'); + expect(replies).toContain('confirmation_state'); + expect(replies).toContain('src/new-change.ts'); + }); + + it('rechecks worktree content after closing writers and refuses changed state', async () => { + const ds = makeDaemonSession({ scope: 'thread', workingDir: '/home/testuser/project-wt-task' }); + ds.session.workingDir = '/home/testuser/project-wt-task'; + const deps = makeDeps(ds); + vi.mocked(isLinkedWorktree).mockResolvedValueOnce(true); + vi.mocked(mainWorktreeFor).mockResolvedValueOnce('/home/testuser/project'); + vi.mocked(worktreeSafetyStatus) + .mockResolvedValueOnce({ dirty: false, dirtyCount: 0, dirtyFiles: [], ahead: 0, unpushedCommits: [], fingerprint: 'clean-state' }) + .mockResolvedValueOnce({ dirty: true, dirtyCount: 1, dirtyFiles: ['src/late.ts'], ahead: 0, unpushedCommits: [], fingerprint: 'changed-state' }); + + await handleCommand('/close', ROOT_ID, makeLarkMessage(`/close wt --yes --state=${closeWorktreeState(ds.session.sessionId, [], 'clean-state')}`), deps, LARK_APP_ID); + + expect(closeSession).toHaveBeenCalledWith(ds.session.sessionId); + expect(removeRepoWorktree).not.toHaveBeenCalled(); + expect(putWorktreeCleanupJob).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ + worktreeDir: '/home/testuser/project-wt-task', + safetyFingerprint: 'changed-state', + })); + const replies = vi.mocked(deps.sessionReply).mock.calls.map(c => c[1]).join('\n'); + expect(replies).toContain('关闭会话后 worktree 内容发生变化'); + expect(replies).toContain('/cleanup-wt cleanup-123'); + }); + + it('`/close wt --yes` preserves the worktree when a sibling close is refused', async () => { + const ds = makeDaemonSession({ scope: 'thread', workingDir: '/home/testuser/project-wt-task' }); + ds.session.workingDir = '/home/testuser/project-wt-task'; + const deps = makeDeps(ds); + vi.mocked(isLinkedWorktree).mockResolvedValueOnce(true); + vi.mocked(mainWorktreeFor).mockResolvedValueOnce('/home/testuser/project'); + vi.mocked(sessionStore.findActiveSessionsByWorkingDirStrict).mockReturnValueOnce([ + ds.session, + { ...makeSession({ sessionId: 'sibling-1', larkAppId: LARK_APP_ID }), workingDir: '/home/testuser/project-wt-task' } as any, + ]); + vi.mocked(closeSession) + .mockResolvedValueOnce({ ok: true, outcome: 'closed', alreadyClosed: false, known: true }) + .mockResolvedValueOnce({ ok: false, alreadyClosed: false, error: 'remote_close_failed', retryable: true }); + + await handleCommand('/close', ROOT_ID, makeLarkMessage(`/close wt --yes --state=${closeWorktreeState(ds.session.sessionId, ['sibling-1'])}`), deps, LARK_APP_ID); + + expect(closeSession).toHaveBeenCalledWith('sibling-1'); + expect(removeRepoWorktree).not.toHaveBeenCalled(); + const replies = vi.mocked(deps.sessionReply).mock.calls.map(c => c[1]).join('\n'); + expect(replies).toContain('其他 1 个会话未能关闭'); + expect(replies).toContain('worktree 未删除'); + }); + + it('`/close wt --yes` preserves the worktree when a cross-daemon sibling leaves a residual', async () => { + const ds = makeDaemonSession({ scope: 'thread', workingDir: '/home/testuser/project-wt-task' }); + ds.session.workingDir = '/home/testuser/project-wt-task'; + const deps = makeDeps(ds); + vi.mocked(isLinkedWorktree).mockResolvedValueOnce(true); + vi.mocked(mainWorktreeFor).mockResolvedValueOnce('/home/testuser/project'); + vi.mocked(sessionStore.findActiveSessionsByWorkingDirStrict).mockReturnValueOnce([ + ds.session, + { ...makeSession({ sessionId: 'sibling-remote', larkAppId: 'app-2' }), workingDir: '/home/testuser/project-wt-task' } as any, + ]); + vi.mocked(getBotUnionId).mockReturnValueOnce('on_team_bot'); + vi.mocked(isTeamBot).mockReturnValueOnce(true); + const dd = await import('../src/utils/daemon-discovery.js'); + vi.mocked(dd.findOnlineDaemon).mockReturnValueOnce({ larkAppId: 'app-2', ipcPort: 9999 }); + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ + ok: true, + outcome: 'closed_with_residual', + residual: { reason: 'remote_cancel_unverified', taskId: 'remote-task-1' }, + }), { status: 200, headers: { 'content-type': 'application/json' } }))); + + try { + await handleCommand('/close', ROOT_ID, makeLarkMessage(`/close wt --yes --state=${closeWorktreeState(ds.session.sessionId, ['sibling-remote'])}`), deps, LARK_APP_ID); + } finally { + vi.unstubAllGlobals(); + } + + expect(removeRepoWorktree).not.toHaveBeenCalled(); + const replies = vi.mocked(deps.sessionReply).mock.calls.map(c => c[1]).join('\n'); + expect(replies).toContain('其他 1 个会话未能关闭'); + expect(replies).toContain('worktree 未删除'); + }); + + + + + + it('`/close wt` asks for confirmation when the worktree has dirty or unpushed changes', async () => { + const ds = makeDaemonSession({ scope: 'thread', workingDir: '/home/testuser/project-wt-task' }); + ds.session.workingDir = '/home/testuser/project-wt-task'; + const deps = makeDeps(ds); + vi.mocked(isLinkedWorktree).mockResolvedValueOnce(true); + vi.mocked(mainWorktreeFor).mockResolvedValueOnce('/home/testuser/project'); + vi.mocked(worktreeSafetyStatus).mockResolvedValueOnce({ dirty: true, dirtyCount: 2, dirtyFiles: ['src/a.ts', 'README.md'], ahead: 2, unpushedCommits: ['abc123 fix close wt', 'def456 add tests'], fingerprint: 'risky-state' }); + + await handleCommand('/close', ROOT_ID, makeLarkMessage('/close wt'), deps, LARK_APP_ID); + + expect(closeSession).not.toHaveBeenCalled(); + expect(removeRepoWorktree).not.toHaveBeenCalled(); + const replies = vi.mocked(deps.sessionReply).mock.calls.map(c => c[1]).join('\n'); + expect(deps.sessionReply).toHaveBeenCalledWith( + ROOT_ID, + expect.stringContaining('⚠️ 未提交改动:2 个文件'), + 'interactive', + LARK_APP_ID, + 'msg_001', + ); + expect(replies).toContain('⚠️ 未 push 提交:2 个'); + expect(replies).toContain('src/a.ts'); + expect(replies).toContain('abc123 fix close wt'); + expect(replies).toContain('删除后:'); + }); + + it('`/close wt` refuses in a top-level chat-scope session', async () => { + const ds = makeDaemonSession({ scope: 'chat', workingDir: '/home/testuser/project-wt-task' }); + ds.session.scope = 'chat'; + ds.session.workingDir = '/home/testuser/project-wt-task'; + const deps = makeDeps(ds); + + await handleCommand('/close', ROOT_ID, makeLarkMessage('/close wt'), deps, LARK_APP_ID); + + expect(closeSession).not.toHaveBeenCalled(); + expect(removeRepoWorktree).not.toHaveBeenCalled(); + const replies = vi.mocked(deps.sessionReply).mock.calls.map(c => c[1]).join('\n'); + expect(replies).toContain('只用于 `/tw` 创建的子话题'); + }); + + it('`/close wt` refuses to delete a normal checkout', async () => { + const ds = makeDaemonSession({ scope: 'thread', workingDir: '/home/testuser/project' }); + ds.session.workingDir = '/home/testuser/project'; + const deps = makeDeps(ds); + vi.mocked(isLinkedWorktree).mockResolvedValueOnce(false); + + await handleCommand('/close', ROOT_ID, makeLarkMessage('/close wt'), deps, LARK_APP_ID); + + expect(closeSession).not.toHaveBeenCalled(); + expect(removeRepoWorktree).not.toHaveBeenCalled(); + const replies = vi.mocked(deps.sessionReply).mock.calls.map(c => c[1]).join('\n'); + expect(replies).toContain('不是 linked worktree'); + }); + it('keeps the active session and reports a visible failure when teardown is refused', async () => { const ds = makeDaemonSession(); const deps = makeDeps(ds); @@ -3615,6 +4087,67 @@ describe('handleCommand', () => { expect(ds.session.initialUserTurnPending).toBe(true); }); + + + it('`/repo here` starts a pending session in its already-pinned current directory', async () => { + const ds = makeDaemonSession({ + pendingRepo: true, + pendingPrompt: '', + worker: null, + workingDir: '/home/testuser/current-chat-repo', + }); + ds.session.workingDir = '/home/testuser/current-chat-repo'; + const deps = makeDeps(ds); + + await handleCommand('/repo', ROOT_ID, makeLarkMessage('/repo here'), deps, LARK_APP_ID); + + expect(ds.workingDir).toBe('/home/testuser/current-chat-repo'); + expect(forkWorker).toHaveBeenCalledWith(ds, '', false); + expect(scanMultipleProjects).not.toHaveBeenCalled(); + expect(sessionStore.createSession).not.toHaveBeenCalled(); + expect(ds.pendingRepo).toBe(false); + const replyContent = (deps.sessionReply as ReturnType).mock.calls[0][1] as string; + expect(replyContent).toContain('current-chat-repo'); + }); + + + + it('`/repo here` can inherit a sibling chat-scope session directory for a fresh topic', async () => { + vi.mocked(sessionStore.findActiveChatScopeSessionsByChat).mockReturnValueOnce([{ + sessionId: 'peer-chat-session', + chatId: CHAT_ID, + rootMessageId: CHAT_ID, + title: 'peer', + status: 'active', + createdAt: new Date().toISOString(), + larkAppId: 'app-2', + scope: 'chat', + chatType: 'group', + workingDir: '/home/testuser/current-chat-repo', + } as any]); + const ds = makeDaemonSession({ pendingRepo: true, pendingPrompt: '', worker: null }); + const deps = makeDeps(ds); + + await handleCommand('/repo', ROOT_ID, makeLarkMessage('/repo here'), deps, LARK_APP_ID); + + expect(ds.workingDir).toBe('/home/testuser/current-chat-repo'); + expect(forkWorker).toHaveBeenCalledWith(ds, '', false); + expect(scanMultipleProjects).not.toHaveBeenCalled(); + expect(ds.pendingRepo).toBe(false); + }); + + it('`/repo here` reports a clear error when no current directory is pinned', async () => { + const ds = makeDaemonSession({ pendingRepo: true, pendingPrompt: '', worker: null }); + const deps = makeDeps(ds); + + await handleCommand('/repo', ROOT_ID, makeLarkMessage('/repo here'), deps, LARK_APP_ID); + + const replyContent = (deps.sessionReply as ReturnType).mock.calls[0][1] as string; + expect(replyContent).toContain('当前群聊还没有可复用的工作目录'); + expect(forkWorker).not.toHaveBeenCalled(); + expect(scanMultipleProjects).not.toHaveBeenCalled(); + }); + it('should reply path_not_found when the arg resolves to nothing', async () => { vi.mocked(existsSync).mockReturnValue(true); vi.mocked(scanMultipleProjects).mockReturnValue([]); @@ -3905,7 +4438,7 @@ describe('handleCommand', () => { // No buffered message → spawn idle with an empty prompt so the user's NEXT // message becomes the first prompt (not an empty/boilerplate user_message). - expect(forkWorker).toHaveBeenCalledWith(ds, '', { turnId: 'om_repo_command_only' }); + expect(forkWorker).toHaveBeenCalledWith(ds, '', false); expect(buildNewTopicPrompt).not.toHaveBeenCalled(); // …and that NEXT message must still get the full new-topic opening, so the // empty start has to leave a durable, persisted marker behind. diff --git a/test/daemon-rename-route.test.ts b/test/daemon-rename-route.test.ts index 9f1bad220..afe850fab 100644 --- a/test/daemon-rename-route.test.ts +++ b/test/daemon-rename-route.test.ts @@ -23,6 +23,7 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; import { join } from 'node:path'; const mocks = vi.hoisted(() => { @@ -90,6 +91,9 @@ const mocks = vi.hoisted(() => { scanMultipleProjects: vi.fn(() => [] as any[]), getAvailableBots: vi.fn(async () => [] as any[]), downloadResources: vi.fn(async () => ({ attachments: [], needLogin: false })), + runAutoWorktreeCommit: vi.fn(async (deps: any) => { + deps.ds.worktreeCreating = true; + }), }; }); @@ -181,6 +185,12 @@ vi.mock('../src/services/project-scanner.js', async () => { return { ...actual, scanMultipleProjects: mocks.scanMultipleProjects }; }); + +vi.mock('../src/im/lark/card-handler.js', async () => { + const actual = await vi.importActual('../src/im/lark/card-handler.js'); + return { ...actual, runAutoWorktreeCommit: mocks.runAutoWorktreeCommit }; +}); + vi.mock('../src/im/lark/identity-cache.js', async () => { const actual = await vi.importActual('../src/im/lark/identity-cache.js'); return { ...actual, resolveSender: (...args: any[]) => mocks.resolveSender(...args) }; @@ -1402,6 +1412,122 @@ describe('/rename production routing — must not pre-create a session (review P expect(mocks.forkWorker).toHaveBeenCalledTimes(1); }); + + + it('`/t here ` reuses the current chat-scope working directory and skips repo selection', async () => { + const currentDir = makeRepoFixtureDir(); + const bot = registerBot({ + larkAppId: APP, + larkAppSecret: 's', + cliId: 'codex', + allowedUsers: [OWNER], + workingDirs: ['/tmp'], + disableStreamingCard: true, + }); + bot.resolvedAllowedUsers = [OWNER]; + const existing = seedLiveChatSession(); + existing.workingDir = currentDir; + existing.session.workingDir = currentDir; + mocks.sessions.set(existing.session.sessionId, existing.session); + + await handleNewTopic( + makeEventData('om_force_topic_here', '/t here 检查实现'), + makeCtx('om_force_topic_here', 'om_force_topic_here'), + ); + + const ds = activeSessions.get(sessionKey('om_force_topic_here', APP)); + expect(ds?.workingDir).toBe(currentDir); + expect(ds?.pendingRepo).toBe(false); + expect(mocks.scanMultipleProjects).not.toHaveBeenCalled(); + expect(mocks.createSession).toHaveBeenCalledTimes(1); + expect(mocks.forkWorker).toHaveBeenCalledTimes(1); + expect(JSON.stringify(mocks.forkWorker.mock.calls[0]?.[1])).toContain('检查实现'); + }); + + + + it('`/tw ` creates a topic that starts from a worktree of the current chat working directory', async () => { + const repoRoot = makeRepoFixtureDir(); + const currentDir = join(repoRoot, 'packages', 'app'); + mkdirSync(currentDir, { recursive: true }); + execFileSync('git', ['init', '-q'], { cwd: repoRoot }); + const bot = registerBot({ + larkAppId: APP, + larkAppSecret: 's', + cliId: 'codex', + allowedUsers: [OWNER], + workingDirs: ['/tmp'], + disableStreamingCard: true, + }); + bot.resolvedAllowedUsers = [OWNER]; + const existing = seedLiveChatSession(); + existing.workingDir = currentDir; + existing.session.workingDir = currentDir; + mocks.sessions.set(existing.session.sessionId, existing.session); + + await handleNewTopic( + makeEventData('om_force_topic_worktree', '/tw 检查实现'), + makeCtx('om_force_topic_worktree', 'om_force_topic_worktree'), + ); + + const ds = activeSessions.get(sessionKey('om_force_topic_worktree', APP)); + expect(ds?.workingDir).toBe(currentDir); + expect(ds?.pendingRepo).toBe(true); + expect(ds?.initialStartPending).toBe(false); + expect(mocks.forkWorker).not.toHaveBeenCalled(); + expect(mocks.runAutoWorktreeCommit).toHaveBeenCalledWith(expect.objectContaining({ + ds, + anchor: 'om_force_topic_worktree', + baseDir: currentDir, + prompt: expect.stringContaining('检查实现'), + force: true, + targetSubdir: join('packages', 'app'), + })); + }); + + + + it('`/topic here` and `/topic worktree` use the same current-directory variants', async () => { + const currentDir = makeRepoFixtureDir(); + const bot = registerBot({ + larkAppId: APP, + larkAppSecret: 's', + cliId: 'codex', + allowedUsers: [OWNER], + workingDirs: ['/tmp'], + disableStreamingCard: true, + }); + bot.resolvedAllowedUsers = [OWNER]; + const existing = seedLiveChatSession(); + existing.workingDir = currentDir; + existing.session.workingDir = currentDir; + mocks.sessions.set(existing.session.sessionId, existing.session); + + await handleNewTopic( + makeEventData('om_topic_here', '/topic here 检查实现'), + makeCtx('om_topic_here', 'om_topic_here'), + ); + expect(activeSessions.get(sessionKey('om_topic_here', APP))?.workingDir).toBe(currentDir); + expect(mocks.forkWorker).toHaveBeenCalledTimes(1); + + mocks.forkWorker.mockClear(); + mocks.runAutoWorktreeCommit.mockClear(); + activeSessions.delete(sessionKey('om_topic_here', APP)); + + await handleNewTopic( + makeEventData('om_topic_worktree', '/topic worktree 检查实现'), + makeCtx('om_topic_worktree', 'om_topic_worktree'), + ); + const ds = activeSessions.get(sessionKey('om_topic_worktree', APP)); + expect(ds?.pendingRepo).toBe(true); + expect(mocks.forkWorker).not.toHaveBeenCalled(); + expect(mocks.runAutoWorktreeCommit).toHaveBeenCalledWith(expect.objectContaining({ + ds, + baseDir: currentDir, + force: true, + })); + }); + it('card-off pinned cwd + `/t ` immediately seeds the thread and starts work', async () => { const bot = registerBot({ larkAppId: APP, diff --git a/test/daemon-turn-reply-sender-wiring.test.ts b/test/daemon-turn-reply-sender-wiring.test.ts index b02e60e49..a8f4edb01 100644 --- a/test/daemon-turn-reply-sender-wiring.test.ts +++ b/test/daemon-turn-reply-sender-wiring.test.ts @@ -7,6 +7,12 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const daemonSource = readFileSync(join(__dirname, '..', 'src', 'daemon.ts'), 'utf8'); describe('daemon per-turn reply sender + participant wiring', () => { + it('wires delayed raw-input credential preparation into worker-pool startup', () => { + expect(daemonSource).toContain( + 'prepareRawInputTurn: (ds, turnId) => prepareTurnCliIdentity(ds, turnId),', + ); + }); + it('computes a turn window per path and binds participants + incomplete', () => { // passthrough (raw command → sender-only window) expect(daemonSource).toContain('buildTurnParticipants(larkAppId, turn.senderOpenId, turn.senderIsBot, undefined)'); @@ -104,6 +110,9 @@ describe('daemon per-turn reply sender + participant wiring', () => { expect(daemonSource).toMatch(/botSender: isBotSenderType \|\| isForeignBot,\n[\s\S]{0,400}senderIsBot: isBotSenderType \|\| isForeignBot,/); }); + it('keeps the source DM id separate from the generated session-group turn id', () => { + }); + it('does not invent a sender for scheduled or system-created turns', () => { expect(daemonSource).toContain('beginReplyTargetTurn(ds, sharedReplyRootId, sharedReplyRootId, new Date(now).toISOString());'); }); diff --git a/test/dashboard-create-session.test.ts b/test/dashboard-create-session.test.ts index f22fd0a95..4ad8800f8 100644 --- a/test/dashboard-create-session.test.ts +++ b/test/dashboard-create-session.test.ts @@ -563,6 +563,26 @@ describe('spawnDashboardSession — backlog (待办池) parks without starting t expect(active.get(sessionKey('oc_later', APP))?.session.queuedPrompt).toBe('LATER_TASK'); }); + it('prepares trigger-user identity before restored auto-worktree fork', async () => { + const pending: Session = { + sessionId: 'pending-auth-worktree', chatId: CHAT, rootMessageId: CHAT, + scope: 'chat', larkAppId: APP, title: 'restore auth', status: 'active', + createdAt: new Date('2026-01-01T00:00:00Z').toISOString(), + queued: true, queuedPrompt: 'OPENING_N', ownerOpenId: 'ou_owner', + pendingRepoSetup: { + mode: 'auto_worktree', prompt: 'OPENING_N', baseDir: '/tmp', turnId: 'om_original_turn', + force: true, worktreePath: '/tmp/shared-wt', branch: 'wt/shared', reuseExisting: true, + }, + }; + store.set(pending.sessionId, pending); + const prepareTurn = vi.fn(async () => {}); + + const active = new Map(); + await restoreActiveSessions(active, new Set(), { prepareTurn }); + + expect(runAutoWorktreeCommitMock).toHaveBeenCalledWith(expect.objectContaining({ prepareTurn })); + }); + it('contains detached auto-worktree recovery rejection and leaves the setup retryable', async () => { const pending: Session = { sessionId: 'pending-auto-worktree', diff --git a/test/default-worktree.test.ts b/test/default-worktree.test.ts index b796f22b1..6fbbe97e2 100644 --- a/test/default-worktree.test.ts +++ b/test/default-worktree.test.ts @@ -134,6 +134,44 @@ describe('maybeCreateDefaultWorktree', () => { expect(notices).toHaveLength(1); // ONLY the fallback — no misleading "creating…" first }); + it('an explicit force request fails closed for a non-git directory', async () => { + const plain = join(tempRoot, 'forced-not-a-repo'); + mkdirSync(plain); + const { mod } = await loadWithBot(plain, false); + const notices: string[] = []; + + await expect(mod.maybeCreateDefaultWorktree('app_wt', plain, { + isBotDefaultDir: true, + locale: 'zh', + force: true, + notify: (m) => { notices.push(m); }, + })).rejects.toThrow(); + + expect(notices).toHaveLength(1); + expect(notices[0]).not.toContain(`\`${plain}\``); + }); + + it('an explicit force request creates and then reuses the deterministic topic worktree when the toggle is off', async () => { + const repo = makeRepo('forced-topic'); + const { mod } = await loadWithBot(repo, false); + const target = join(tempRoot, 'forced-topic-shared'); + const ctx = { + isBotDefaultDir: true, + locale: 'zh' as const, + force: true, + worktreePath: target, + branch: 'wt/botmux-topic', + reuseExisting: true, + }; + + const first = await mod.maybeCreateDefaultWorktree('app_wt', repo, ctx); + const second = await mod.maybeCreateDefaultWorktree('app_wt', repo, ctx); + + expect(first.dir).toBe(target); + expect(second.dir).toBe(target); + expect(git(target, 'branch', '--show-current')).toBe('wt/botmux-topic'); + }); + it('no-ops (no notice, dir unchanged) when the dir did not come from the bot default', async () => { const repo = makeRepo('proj'); const { mod } = await loadWithBot(repo, true); diff --git a/test/git-worktree.test.ts b/test/git-worktree.test.ts index 4a2e3cdc3..27b33198b 100644 --- a/test/git-worktree.test.ts +++ b/test/git-worktree.test.ts @@ -8,11 +8,11 @@ */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { execFileSync } from 'node:child_process'; -import { mkdtempSync, mkdirSync, rmSync, existsSync, realpathSync } from 'node:fs'; +import { mkdtempSync, mkdirSync, rmSync, existsSync, realpathSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { createRepoWorktree, removeRepoWorktree, slugFromWorktreeText } from '../src/services/git-worktree.js'; +import { createRepoWorktree, removeRepoWorktree, slugFromWorktreeText, worktreeRootFor, worktreeSafetyStatus } from '../src/services/git-worktree.js'; import { localWorktreeSlugFromContext } from '../src/services/worktree-slug-ai.js'; let tempRoot: string; @@ -154,6 +154,36 @@ describe('createRepoWorktree', () => { expect(git(target, 'rev-parse', '--abbrev-ref', 'HEAD')).toBe('feat/group'); }); + it('reuses an existing explicit worktree only when its repo and branch match', async () => { + const upstream = makeUpstream('upstream'); + const repo = makeClone(upstream, 'proj'); + const target = join(tempRoot, 'shared-topic', 'proj'); + const first = await createRepoWorktree(repo, { branch: 'feat/shared', worktreePath: target }); + + const reused = await createRepoWorktree(repo, { + branch: 'feat/shared', + worktreePath: target, + reuseExisting: true, + }); + + expect(reused).toEqual({ path: target, branch: 'feat/shared', baseRef: 'feat/shared' }); + expect(git(repo, 'worktree', 'list').split('\n').filter(line => line.includes(target))).toHaveLength(1); + expect(first.path).toBe(reused.path); + }); + + it('refuses to reuse an explicit path checked out on a different branch', async () => { + const upstream = makeUpstream('upstream'); + const repo = makeClone(upstream, 'proj'); + const target = join(tempRoot, 'shared-topic', 'proj'); + await createRepoWorktree(repo, { branch: 'feat/other', worktreePath: target }); + + await expect(createRepoWorktree(repo, { + branch: 'feat/shared', + worktreePath: target, + reuseExisting: true, + })).rejects.toThrow('not feat/shared in the expected repository'); + }); + it('removeRepoWorktree detaches the worktree dir so the slot is reusable (rollback)', async () => { const upstream = makeUpstream('upstream'); const repo = makeClone(upstream, 'proj'); @@ -253,6 +283,224 @@ describe('createRepoWorktree', () => { }); }); +describe('worktreeRootFor', () => { + it('resolves a nested directory to its containing linked worktree root', async () => { + const upstream = makeUpstream('root-upstream'); + const repo = makeClone(upstream, 'root-proj'); + const wt = await createRepoWorktree(repo); + const nested = join(wt.path, 'nested', 'deep'); + mkdirSync(nested, { recursive: true }); + + expect(await worktreeRootFor(nested)).toBe(wt.path); + expect(await worktreeRootFor(wt.path)).toBe(wt.path); + expect(await worktreeRootFor(repo)).toBe(repo); + }); +}); + +describe('worktreeSafetyStatus', () => { + it('detects ignored files without recursively enumerating ignored directories', async () => { + const repo = makeUpstream('ignored-safety'); + writeFileSync(join(repo, '.gitignore'), 'secret.env\ncache/\n'); + git(repo, 'add', '.gitignore'); + git(repo, 'commit', '-m', 'ignore local data'); + writeFileSync(join(repo, 'secret.env'), 'secret\n'); + mkdirSync(join(repo, 'cache')); + writeFileSync(join(repo, 'cache', 'a.txt'), 'a\n'); + writeFileSync(join(repo, 'cache', 'b.txt'), 'b\n'); + + const status = await worktreeSafetyStatus(repo); + + expect(status.dirty).toBe(true); + expect(status.dirtyFiles).toContain('secret.env'); + expect(status.dirtyFiles).toContain('cache/'); + expect(status.dirtyFiles).not.toContain('cache/a.txt'); + }); + + it('detects untracked files even when git config hides them', async () => { + const repo = makeUpstream('untracked-safety'); + git(repo, 'config', 'status.showUntrackedFiles', 'no'); + writeFileSync(join(repo, 'new.txt'), 'new\n'); + + const status = await worktreeSafetyStatus(repo); + + expect(status.dirtyFiles).toContain('new.txt'); + }); + + it('reports the full dirty count while bounding file examples', async () => { + const repo = makeUpstream('many-dirty-files'); + for (let i = 0; i < 25; i++) writeFileSync(join(repo, `dirty-${i}.txt`), `${i}\n`); + + const status = await worktreeSafetyStatus(repo); + + expect(status.dirtyCount).toBe(25); + expect(status.dirtyFiles).toHaveLength(20); + }); + + it('preserves the full path for an unstaged modification', async () => { + const repo = makeUpstream('safety-status'); + const file = join(repo, 'first-character.ts'); + writeFileSync(file, 'initial\n'); + git(repo, 'add', 'first-character.ts'); + git(repo, 'commit', '-m', 'add file'); + writeFileSync(file, 'changed\n'); + + const status = await worktreeSafetyStatus(repo); + + expect(status.dirtyFiles).toEqual(['first-character.ts']); + }); + + it('fingerprints a tracked deletion without treating the missing path as a scan error', async () => { + const repo = makeUpstream('deleted-fingerprint'); + const file = join(repo, 'deleted.txt'); + writeFileSync(file, 'base\n'); + git(repo, 'add', 'deleted.txt'); + git(repo, 'commit', '-m', 'add tracked file'); + rmSync(file); + + const status = await worktreeSafetyStatus(repo); + + expect(status.dirtyFiles).toEqual(['deleted.txt']); + expect(status.fingerprint).toMatch(/^[a-f0-9]{64}$/); + }); + + it('changes the fingerprint when staged content changes but the worktree bytes stay the same', async () => { + const repo = makeUpstream('index-fingerprint'); + const file = join(repo, 'staged.txt'); + writeFileSync(file, 'base\n'); + git(repo, 'add', 'staged.txt'); + git(repo, 'commit', '-m', 'add tracked file'); + writeFileSync(file, 'first staged value\n'); + git(repo, 'add', 'staged.txt'); + writeFileSync(file, 'same worktree value\n'); + const first = await worktreeSafetyStatus(repo); + + writeFileSync(file, 'second staged value\n'); + git(repo, 'add', 'staged.txt'); + writeFileSync(file, 'same worktree value\n'); + const second = await worktreeSafetyStatus(repo); + + expect(second.dirtyFiles).toEqual(first.dirtyFiles); + expect(second.fingerprint).not.toBe(first.fingerprint); + }); + + it('handles porcelain-quoted non-ASCII paths and fingerprints their content', async () => { + const repo = makeUpstream('quoted-path-fingerprint'); + const file = join(repo, '中文.txt'); + writeFileSync(file, 'first\n'); + const first = await worktreeSafetyStatus(repo); + + writeFileSync(file, 'second\n'); + const second = await worktreeSafetyStatus(repo); + + expect(first.dirtyFiles).toContain('中文.txt'); + expect(second.fingerprint).not.toBe(first.fingerprint); + }); + + it('fingerprints a conflicted index without requiring write-tree', async () => { + const repo = makeUpstream('conflicted-index'); + const file = join(repo, 'conflict.txt'); + writeFileSync(file, 'base\n'); + git(repo, 'add', 'conflict.txt'); + git(repo, 'commit', '-m', 'add conflict file'); + git(repo, 'checkout', '-b', 'other'); + writeFileSync(file, 'other\n'); + git(repo, 'commit', '-am', 'other change'); + git(repo, 'checkout', 'master'); + writeFileSync(file, 'master\n'); + git(repo, 'commit', '-am', 'master change'); + try { git(repo, 'merge', 'other'); } catch { /* expected conflict */ } + + const status = await worktreeSafetyStatus(repo); + + expect(status.dirtyFiles).toContain('conflict.txt'); + expect(status.fingerprint).toMatch(/^[a-f0-9]{64}$/); + }); + + it('changes the fingerprint when an already-dirty file content changes', async () => { + const repo = makeUpstream('content-fingerprint'); + const file = join(repo, 'dirty.txt'); + writeFileSync(file, 'base\n'); + git(repo, 'add', 'dirty.txt'); + git(repo, 'commit', '-m', 'add tracked file'); + writeFileSync(file, 'first value\n'); + const first = await worktreeSafetyStatus(repo); + + writeFileSync(file, 'second value\n'); + const second = await worktreeSafetyStatus(repo); + + expect(second.dirtyFiles).toEqual(first.dirtyFiles); + expect(second.fingerprint).not.toBe(first.fingerprint); + }); + + it('changes the fingerprint when a submodule index changes but its worktree bytes stay the same', async () => { + const subOrigin = makeUpstream('submodule-index-origin'); + const tracked = join(subOrigin, 'tracked.txt'); + writeFileSync(tracked, 'base\n'); + git(subOrigin, 'add', 'tracked.txt'); + git(subOrigin, 'commit', '-m', 'add tracked file'); + + const repo = makeUpstream('submodule-index-parent'); + git(repo, '-c', 'protocol.file.allow=always', 'submodule', 'add', subOrigin, 'vendor/sub'); + git(repo, 'commit', '-m', 'add submodule'); + const nestedFile = join(repo, 'vendor/sub/tracked.txt'); + writeFileSync(nestedFile, 'first staged value\n'); + git(join(repo, 'vendor/sub'), 'add', 'tracked.txt'); + writeFileSync(nestedFile, 'same worktree value\n'); + const first = await worktreeSafetyStatus(repo); + + writeFileSync(nestedFile, 'second staged value\n'); + git(join(repo, 'vendor/sub'), 'add', 'tracked.txt'); + writeFileSync(nestedFile, 'same worktree value\n'); + const second = await worktreeSafetyStatus(repo); + + expect(second.dirtyFiles).toEqual(first.dirtyFiles); + expect(second.fingerprint).not.toBe(first.fingerprint); + }); + + it('changes the fingerprint when an ignored directory file changes inside an initialized submodule', async () => { + const subOrigin = makeUpstream('submodule-ignored-dir-origin'); + writeFileSync(join(subOrigin, '.gitignore'), 'cache/\n'); + git(subOrigin, 'add', '.gitignore'); + git(subOrigin, 'commit', '-m', 'ignore local cache'); + + const repo = makeUpstream('submodule-ignored-dir-parent'); + git(repo, '-c', 'protocol.file.allow=always', 'submodule', 'add', subOrigin, 'vendor/sub'); + git(repo, 'commit', '-m', 'add submodule'); + const cache = join(repo, 'vendor/sub/cache'); + mkdirSync(cache); + const cached = join(cache, 'state.json'); + writeFileSync(cached, '{"value":1}\n'); + const first = await worktreeSafetyStatus(repo); + + writeFileSync(cached, '{"value":2}\n'); + const second = await worktreeSafetyStatus(repo); + + expect(first.dirtyFiles).toContain('vendor/sub/cache/'); + expect(second.fingerprint).not.toBe(first.fingerprint); + }); + + it('detects ignored files inside an initialized submodule', async () => { + const subOrigin = makeUpstream('submodule-origin'); + writeFileSync(join(subOrigin, '.gitignore'), 'secret.env\n'); + git(subOrigin, 'add', '.gitignore'); + git(subOrigin, 'commit', '-m', 'ignore local secret'); + + const repo = makeUpstream('submodule-parent'); + git(repo, '-c', 'protocol.file.allow=always', 'submodule', 'add', subOrigin, 'vendor/sub'); + git(repo, 'commit', '-m', 'add submodule'); + const secret = join(repo, 'vendor/sub/secret.env'); + writeFileSync(secret, 'local\n'); + const status = await worktreeSafetyStatus(repo); + + writeFileSync(secret, 'changed\n'); + const changed = await worktreeSafetyStatus(repo); + + expect(status.dirty).toBe(true); + expect(status.dirtyFiles).toContain('vendor/sub/secret.env'); + expect(changed.fingerprint).not.toBe(status.fingerprint); + }); +}); + describe('worktree semantic slug helpers', () => { it('prefers the title and falls back to the first prompt', () => { expect(localWorktreeSlugFromContext('Fix Repo WT naming!', 'first prompt')).toBe('fix-repo-wt-naming'); diff --git a/test/pending-repo-journal.test.ts b/test/pending-repo-journal.test.ts index 22516495b..ad0fae8d5 100644 --- a/test/pending-repo-journal.test.ts +++ b/test/pending-repo-journal.test.ts @@ -65,6 +65,11 @@ describe('pending repository setup journal', () => { stagePendingRepoSetup(ds, { mode: 'auto_worktree', baseDir: '/repos/base', turnId: 'turn-n', + force: true, + worktreePath: '/repos/base-wt-botmux-abc', + branch: 'wt/botmux-abc', + reuseExisting: true, + targetSubdir: 'packages/app', }); expect(ds.session.queued).toBe(true); @@ -77,6 +82,11 @@ describe('pending repository setup journal', () => { rawInput: '/goal exact raw', turnId: 'turn-n', baseDir: '/repos/base', + force: true, + worktreePath: '/repos/base-wt-botmux-abc', + branch: 'wt/botmux-abc', + reuseExisting: true, + targetSubdir: 'packages/app', codexAppText: 'visible opening', codexAppApplicationContext: 'app', codexAppMessageContext: 'message', diff --git a/test/pin-streaming-card-recovery-wiring.test.ts b/test/pin-streaming-card-recovery-wiring.test.ts index 073033897..09574e132 100644 --- a/test/pin-streaming-card-recovery-wiring.test.ts +++ b/test/pin-streaming-card-recovery-wiring.test.ts @@ -131,7 +131,8 @@ describe('startup restore phase wiring for restored streaming-card Pin recovery' 'if (selfDaemonLarkAppId) {', ); - expect(block).toContain('restoreSessions: () => restoreActiveSessions(activeSessions, idempotencyQuarantinedSessionIds),'); + expect(block).toContain('restoreSessions: () => restoreActiveSessions(activeSessions, idempotencyQuarantinedSessionIds, {'); + expect(block).toContain('prepareTurn: (ds, turnId) => prepareTurnCliIdentity(ds, turnId),'); expect(block).toContain('larkAppId: cfg.larkAppId,'); expect(block).toContain('sessionsRestored = true;'); }); diff --git a/test/session-store.test.ts b/test/session-store.test.ts index 3704f78fb..3233c0718 100644 --- a/test/session-store.test.ts +++ b/test/session-store.test.ts @@ -85,6 +85,7 @@ import { persistActiveRemoteLineageExact, persistActiveRemoteLineagesExactBatch, findActiveSessionsByRoot, + findActiveSessionsByWorkingDirStrict, repairMissingChatScope, loadAllSessionsSnapshot, applySessionCommandUnowned, @@ -136,6 +137,7 @@ function readPersistedRows(dir: string, appId?: string): Record { beforeEach(() => { tempDir = makeTempDir(); fsControl.failSessionWrite = false; + fsControl.failReaddir = false; costCalculatorMock.getSessionTokenUsage.mockReset(); costCalculatorMock.getSessionTokenUsage.mockReturnValue(null); __testOnly_setBeforeRowPersist(undefined); @@ -1355,6 +1357,67 @@ describe('Multi-bot isolation', () => { // ─── findActiveSessionsByRoot() — cross-bot lookup ─────────────────────── +describe('findActiveSessionsByWorkingDirStrict()', () => { + it('finds active sessions across stores by canonical worktree path', () => { + const worktree = join(tempDir, 'repo-wt'); + const nested = join(worktree, 'packages', 'app'); + mkdirSync(nested, { recursive: true }); + const alias = nested; + + init('app-A'); + const sA = createSession('chat1', 'root-a', 'Bot A'); + sA.workingDir = alias; + sA.larkAppId = 'app-A'; + updateSession(sA); + + init('app-B'); + const sB = createSession('chat1', 'root-b', 'Bot B'); + sB.workingDir = worktree; + sB.larkAppId = 'app-B'; + updateSession(sB); + + const found = findActiveSessionsByWorkingDirStrict(worktree); + expect(found.map(s => s.sessionId).sort()).toEqual([sA.sessionId, sB.sessionId].sort()); + }); + + it('fails closed when the cross-store inventory cannot be enumerated', () => { + init('app-A'); + fsControl.failReaddir = true; + + expect(() => findActiveSessionsByWorkingDirStrict(tempDir)) + .toThrow(/simulated readdir denial/); + }); + + it('fails closed when another legacy JSON store has a malformed active row', () => { + init('app-B'); + writeFileSync(join(tempDir, 'sessions-app-A.json'), JSON.stringify({ + broken: { status: 'active', workingDir: tempDir }, + })); + + expect(() => findActiveSessionsByWorkingDirStrict(tempDir)) + .toThrow(/malformed active session row/i); + }); + + it('fails closed when another SQLite store has a malformed active row', () => { + init('app-A'); + const session = createSession('chat1', 'root-a', 'Bot A'); + const dbPath = persistedStorePath(tempDir, 'app-A'); + expect(dbPath?.endsWith('.db')).toBe(true); + const db = new DatabaseSync(dbPath!); + try { + db.prepare("UPDATE sessions SET row = ? WHERE session_id = ?") + .run('{}', session.sessionId); + } finally { + db.close(); + } + + init('app-B'); + + expect(() => findActiveSessionsByWorkingDirStrict(tempDir)) + .toThrow(/malformed active session row/i); + }); +}); + describe('findActiveSessionsByRoot()', () => { it('finds active sessions across per-bot files for the same rootMessageId', () => { // Bot A pins workdir for thread root-x diff --git a/test/worktree-cleanup-store.test.ts b/test/worktree-cleanup-store.test.ts new file mode 100644 index 000000000..996916bad --- /dev/null +++ b/test/worktree-cleanup-store.test.ts @@ -0,0 +1,58 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + deleteWorktreeCleanupJob, + getWorktreeCleanupJob, + putWorktreeCleanupJob, +} from '../src/services/worktree-cleanup-store.js'; + +let dataDir: string; + +beforeEach(() => { + dataDir = mkdtempSync(join(tmpdir(), 'worktree-cleanup-store-')); +}); + +afterEach(() => { + rmSync(dataDir, { recursive: true, force: true }); +}); + +describe('worktree cleanup store', () => { + it('persists one restart-safe job keyed by normalized worktree path', () => { + const first = putWorktreeCleanupJob(dataDir, { + larkAppId: 'app-1', + worktreeMain: '/repo', + worktreeDir: '/repo-wt/../repo-wt', + safetyFingerprint: 'fp-1', + error: 'busy', + }, 100); + + const second = putWorktreeCleanupJob(dataDir, { + larkAppId: 'app-1', + worktreeMain: '/repo', + worktreeDir: '/repo-wt', + safetyFingerprint: 'fp-2', + error: 'still busy', + }, 200); + + expect(second.id).toBe(first.id); + expect(getWorktreeCleanupJob(dataDir, first.id)).toMatchObject({ + worktreeDir: '/repo-wt', + safetyFingerprint: 'fp-2', + error: 'still busy', + createdAt: 100, + updatedAt: 200, + }); + }); + + it('deletes a completed job durably', () => { + const job = putWorktreeCleanupJob(dataDir, { + larkAppId: 'app-1', worktreeMain: '/repo', worktreeDir: '/repo-wt', + safetyFingerprint: 'fp', error: 'busy', + }); + + expect(deleteWorktreeCleanupJob(dataDir, job.id)).toBe(true); + expect(getWorktreeCleanupJob(dataDir, job.id)).toBeUndefined(); + }); +});