From 2dc547e916780626745effd21e0442a55162734d Mon Sep 17 00:00:00 2001 From: xu4wang Date: Mon, 27 Jul 2026 21:32:05 +0700 Subject: [PATCH 1/2] =?UTF-8?q?fix(usage):=20=E6=B2=99=E7=9B=92=20bot=20?= =?UTF-8?q?=E7=9A=84=20token=20=E7=BB=9F=E8=AE=A1/=E6=88=90=E6=9C=AC?= =?UTF-8?q?=E5=8F=B0=E8=B4=A6/insight=20=E8=AF=BB=E4=B8=8D=E5=88=B0?= =?UTF-8?q?=E9=87=8D=E5=AE=9A=E5=90=91=20transcript?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 沙盒(CLI 数据重定向)bot 的 Claude 被 worker 注入 CLAUDE_CONFIG_DIR=/bots//claude,transcript 全部写在 BOT_HOME 下;而 daemon 侧 resolveSessionTranscriptPath 对 claude-family 硬编码全局 ~/.claude(seed/relay 为 adapter dataDir),永远 miss → dashboard TOKEN IN/OUT 恒显示 -,usage-ledger 静默漏记,insight 报 transcript_missing。 修法:TranscriptPathQuery 增加 larkAppId,claude-family 分支在主 dataDir miss 后回落 /bots//claude(botmuxHome 与 worker 同源 = dirname(SESSION_DATA_DIR))。刻意用「按存在性回落」而非在 daemon 侧复刻 重定向判定(sandbox × adapter 能力 × wrapper),任何组合下都正确、不会与 worker.ts 漂移。app id 过 assertSafeAppId,不安全值不构造路径。 调用点接线:dashboard-rows / usage-ledger / insight IPC 三路由 / /insight 命令传入会话已有的 larkAppId;InsightOverviewSessionInput 的 larkAppId 上移进 InsightReportQuery。 真机实测(沙盒 bot cli_aace4eb402785be8 的真实 transcript): 修复前 getSessionTokenUsage=null,修复后 {in:378840,out:2249,model:claude-opus-5,turns:9}。 --- src/core/command-handler.ts | 1 + src/core/cost-calculator.ts | 3 + src/core/dashboard-ipc-server.ts | 3 + src/core/dashboard-rows.ts | 1 + src/services/insight/types.ts | 4 +- src/services/transcript-resolver.ts | 42 +++++++- src/services/usage-ledger.ts | 1 + test/transcript-resolver-bot-home.test.ts | 114 ++++++++++++++++++++++ test/usage-ledger.test.ts | 1 + 9 files changed, 165 insertions(+), 5 deletions(-) create mode 100644 test/transcript-resolver-bot-home.test.ts diff --git a/src/core/command-handler.ts b/src/core/command-handler.ts index d188c1489..a9bd09af1 100644 --- a/src/core/command-handler.ts +++ b/src/core/command-handler.ts @@ -1313,6 +1313,7 @@ export async function handleCommand( sessionId: ds.session.sessionId, cliSessionId: ds.session.cliSessionId, cwd: ds.session.workingDir, + larkAppId: ds.larkAppId ?? ds.session.larkAppId, }, { detail: 'summary' }); await sessionReply(rootId, formatInsightCard(report, loc)); break; diff --git a/src/core/cost-calculator.ts b/src/core/cost-calculator.ts index 76058df3b..df4d189b7 100644 --- a/src/core/cost-calculator.ts +++ b/src/core/cost-calculator.ts @@ -33,6 +33,9 @@ export interface SessionTokenUsageQuery { sessionId: string; cliSessionId?: string; cwd?: string; + /** Owning bot's Lark app id — lets the transcript resolver find sandboxed + * (CLI-data-redirected) bots' transcripts under BOT_HOME. */ + larkAppId?: string; /** Bypass the reparse throttle (stat short-circuit and incremental folding * still apply). Use at low-frequency exact points like ledger snapshots. */ fresh?: boolean; diff --git a/src/core/dashboard-ipc-server.ts b/src/core/dashboard-ipc-server.ts index 2f22e5f4c..8ea5e4193 100644 --- a/src/core/dashboard-ipc-server.ts +++ b/src/core/dashboard-ipc-server.ts @@ -1083,6 +1083,7 @@ ipcRoute('GET', '/api/sessions/:sessionId/insight', (req, res, params) => { sessionId: session.sessionId, cliSessionId: session.cliSessionId, cwd: session.workingDir, + larkAppId: session.larkAppId, }, { offset, limit, @@ -1101,6 +1102,7 @@ ipcRoute('GET', '/api/sessions/:sessionId/insight', (req, res, params) => { sessionId: session.sessionId, cliSessionId: session.cliSessionId, cwd: session.workingDir, + larkAppId: session.larkAppId, }, { detail }); jsonRes(res, 200, { ok: true, report }); } catch (err: any) { @@ -1120,6 +1122,7 @@ ipcRoute('GET', '/api/sessions/:sessionId/insight/turn/:turnIndex', (req, res, p sessionId: session.sessionId, cliSessionId: session.cliSessionId, cwd: session.workingDir, + larkAppId: session.larkAppId, }, parseInt(params.turnIndex, 10) || 0, { offset, limit }); jsonRes(res, 200, { ok: true, turn }); } catch (err: any) { diff --git a/src/core/dashboard-rows.ts b/src/core/dashboard-rows.ts index c5d1d0312..d529f5936 100644 --- a/src/core/dashboard-rows.ts +++ b/src/core/dashboard-rows.ts @@ -111,6 +111,7 @@ function sessionTokenUsage(s: Session, workingDir?: string): SessionTokenUsage | sessionId: s.sessionId, cliSessionId: s.cliSessionId, cwd: workingDir ?? s.workingDir, + larkAppId: s.larkAppId, }); } diff --git a/src/services/insight/types.ts b/src/services/insight/types.ts index 09a26fcd9..e2c8927c5 100644 --- a/src/services/insight/types.ts +++ b/src/services/insight/types.ts @@ -166,7 +166,6 @@ export interface SafeInsightOverview { export interface InsightOverviewSessionInput extends InsightReportQuery { title?: string; botName?: string; - larkAppId?: string; workingDir?: string; status?: string; lastMessageAt?: number; @@ -419,6 +418,9 @@ export interface InsightReportQuery { sessionId: string; cliSessionId?: string; cwd?: string; + /** Owning bot's Lark app id — lets the transcript resolver find sandboxed + * (CLI-data-redirected) bots' transcripts under BOT_HOME. */ + larkAppId?: string; } export interface RawInsightSpan { diff --git a/src/services/transcript-resolver.ts b/src/services/transcript-resolver.ts index a9de9426c..1d6bbaed4 100644 --- a/src/services/transcript-resolver.ts +++ b/src/services/transcript-resolver.ts @@ -1,7 +1,8 @@ import { existsSync, realpathSync } from 'node:fs'; -import { join, resolve } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; import { homedir } from 'node:os'; import type { CliId } from '../adapters/cli/types.js'; +import { botHomePath } from '../adapters/cli/read-isolation.js'; import { createCliAdapterSync } from '../adapters/cli/registry.js'; import { expandHome } from '../core/working-dir.js'; import { findCodexRolloutBySessionId, findCodexSessionIdByBotmuxSessionId } from './codex-transcript.js'; @@ -16,6 +17,10 @@ export interface TranscriptPathQuery { sessionId: string; cliSessionId?: string; cwd?: string; + /** Owning bot's Lark app id. Enables the BOT_HOME fallback for sandboxed + * (CLI-data-redirected) bots whose transcripts live under + * `/bots//claude` instead of the global data dir. */ + larkAppId?: string; /** Bypass a cached miss for lazily-created transcripts. */ fresh?: boolean; } @@ -96,20 +101,49 @@ function claudeForkDataDir(cliId: 'seed' | 'relay'): string { return dir; } +/** The redirected Claude data dir of a sandboxed bot: `/bots//claude`. + * Sandboxed bots with CLI-data redirect get CLAUDE_CONFIG_DIR pointed here by the + * worker, so their transcripts never appear under the global data dir and every + * daemon-side reader (dashboard token column, usage ledger, insight) must fall + * back to this dir on a global miss. botmuxHome is derived exactly like the + * worker derives BOT_HOME (`dirname(SESSION_DATA_DIR)`); no SESSION_DATA_DIR + * means no redirect ever happened, so no fallback. Deliberately existence-driven + * rather than re-deriving the redirect decision (sandbox × adapter capability × + * wrapper): probing global-then-BOT_HOME is correct in every combination and + * can't drift from worker.ts. */ +function botHomeClaudeDataDir(larkAppId: string | undefined): string | null { + if (!larkAppId) return null; + const sessionDataDir = process.env.SESSION_DATA_DIR; + if (!sessionDataDir) return null; + try { + return join(botHomePath(dirname(sessionDataDir), larkAppId), 'claude'); + } catch { + return null; // unsafe app id — never build a path from it + } +} + +function claudeJsonlWithBotHomeFallback(sid: string, q: TranscriptPathQuery, primaryDataDir: string): string | null { + if (!q.cwd) return null; + const path = getClaudeSessionJsonlPath(sid, q.cwd, primaryDataDir); + if (path) return path; + const botHomeDir = botHomeClaudeDataDir(q.larkAppId); + return botHomeDir ? getClaudeSessionJsonlPath(sid, q.cwd, botHomeDir) : null; +} + export function resolveSessionTranscriptPath(q: TranscriptPathQuery): ResolvedTranscriptPath | null { const sid = q.cliSessionId || q.sessionId; switch (q.cliId) { case 'claude-code': { - const path = q.cwd ? getClaudeSessionJsonlPath(sid, q.cwd, join(homedir(), '.claude')) : null; + const path = claudeJsonlWithBotHomeFallback(sid, q, join(homedir(), '.claude')); return path ? { path, kind: 'claude' } : null; } case 'aiden': { - const path = q.cwd ? getClaudeSessionJsonlPath(sid, q.cwd, join(homedir(), '.claude')) : null; + const path = claudeJsonlWithBotHomeFallback(sid, q, join(homedir(), '.claude')); return path ? { path, kind: 'claude' } : null; } case 'seed': case 'relay': { - const path = q.cwd ? getClaudeSessionJsonlPath(sid, q.cwd, claudeForkDataDir(q.cliId)) : null; + const path = claudeJsonlWithBotHomeFallback(sid, q, claudeForkDataDir(q.cliId)); return path ? { path, kind: 'claude' } : null; } case 'codex': { diff --git a/src/services/usage-ledger.ts b/src/services/usage-ledger.ts index 69b5544e7..e4de7f08f 100644 --- a/src/services/usage-ledger.ts +++ b/src/services/usage-ledger.ts @@ -451,6 +451,7 @@ function ledgerArgsForDaemonSession(ds: DaemonSession): Omit/bots//claude, so their transcripts never +// appear under the global ~/.claude. Daemon-side readers (dashboard token +// column, usage ledger, insight) resolved ONLY against the global dir → token +// usage silently showed "-" for every sandboxed bot. The resolver must fall +// back to the BOT_HOME dir when the query carries the owning bot's app id. + +// Point homedir at a controllable fake so the "global" ~/.claude is test-owned. +// vi.hoisted: the mock factory runs at import time, before module-level lets. +const fake = vi.hoisted(() => ({ home: '/nonexistent-home' })); +vi.mock('node:os', async (importOriginal) => ({ + ...(await importOriginal()), + homedir: () => fake.home, +})); + +import { resolveSessionTranscriptPath } from '../src/services/transcript-resolver.js'; + +const APP_ID = 'cli_testbot0001'; + +function projectKey(cwd: string): string { + return realpathSync(cwd).replace(/[^A-Za-z0-9-]/g, '-'); +} + +describe('resolveSessionTranscriptPath — sandboxed-bot BOT_HOME fallback', () => { + const trash: string[] = []; + let base: string; + let cwd: string; + let savedSessionDataDir: string | undefined; + + beforeEach(() => { + savedSessionDataDir = process.env.SESSION_DATA_DIR; + base = mkdtempSync(join(tmpdir(), 'botmux-bot-home-')); + trash.push(base); + fake.home = join(base, 'home'); + cwd = join(base, 'work'); + mkdirSync(cwd, { recursive: true }); + // botmuxHome = /.botmux, exactly like ~/.botmux with data dir inside. + process.env.SESSION_DATA_DIR = join(base, '.botmux', 'data'); + }); + + afterEach(() => { + if (savedSessionDataDir === undefined) delete process.env.SESSION_DATA_DIR; + else process.env.SESSION_DATA_DIR = savedSessionDataDir; + for (const d of trash.splice(0)) rmSync(d, { recursive: true, force: true }); + }); + + function writeBotHomeTranscript(sid: string): string { + const dir = join(base, '.botmux', 'bots', APP_ID, 'claude', 'projects', projectKey(cwd)); + mkdirSync(dir, { recursive: true }); + const p = join(dir, `${sid}.jsonl`); + writeFileSync(p, '{}'); + return p; + } + + function writeGlobalTranscript(sid: string): string { + const dir = join(fake.home, '.claude', 'projects', projectKey(cwd)); + mkdirSync(dir, { recursive: true }); + const p = join(dir, `${sid}.jsonl`); + writeFileSync(p, '{}'); + return p; + } + + it('falls back to /bots//claude when the global dir misses', () => { + const expected = writeBotHomeTranscript('sb-1'); + const resolved = resolveSessionTranscriptPath({ + cliId: 'claude-code', sessionId: 'sb-1', cwd, larkAppId: APP_ID, + }); + expect(resolved).toEqual({ path: expected, kind: 'claude' }); + }); + + it('prefers the global dir when the transcript exists there (non-redirected bot)', () => { + const global = writeGlobalTranscript('sb-2'); + writeBotHomeTranscript('sb-2'); + const resolved = resolveSessionTranscriptPath({ + cliId: 'claude-code', sessionId: 'sb-2', cwd, larkAppId: APP_ID, + }); + expect(resolved?.path).toBe(global); + }); + + it('returns null without larkAppId (no fallback target)', () => { + writeBotHomeTranscript('sb-3'); + expect(resolveSessionTranscriptPath({ cliId: 'claude-code', sessionId: 'sb-3', cwd })).toBeNull(); + }); + + it('returns null without SESSION_DATA_DIR (no redirect ever happened)', () => { + writeBotHomeTranscript('sb-4'); + delete process.env.SESSION_DATA_DIR; + expect(resolveSessionTranscriptPath({ + cliId: 'claude-code', sessionId: 'sb-4', cwd, larkAppId: APP_ID, + })).toBeNull(); + }); + + it('never builds a path from an unsafe app id (returns null instead of throwing)', () => { + writeBotHomeTranscript('sb-5'); + for (const evil of ['../evil', 'a/b', '..', '']) { + expect(resolveSessionTranscriptPath({ + cliId: 'claude-code', sessionId: 'sb-5', cwd, larkAppId: evil, + })).toBeNull(); + } + }); + + it('applies the same fallback for aiden claude-format transcripts', () => { + const expected = writeBotHomeTranscript('sb-6'); + const resolved = resolveSessionTranscriptPath({ + cliId: 'aiden', sessionId: 'sb-6', cwd, larkAppId: APP_ID, + }); + expect(resolved).toEqual({ path: expected, kind: 'claude' }); + }); +}); diff --git a/test/usage-ledger.test.ts b/test/usage-ledger.test.ts index 00ebfc78a..fd30beec5 100644 --- a/test/usage-ledger.test.ts +++ b/test/usage-ledger.test.ts @@ -599,6 +599,7 @@ describe('daemon-session wrappers', () => { sessionId: 'sess-1', cliSessionId: 'cli-sess-1', cwd: '/live-repo', + larkAppId: 'cli_app', fresh: true, }); expect(rec).toMatchObject({ From 37ab692fe92f8f0bb9b52bd9b9c4b412d02a1d74 Mon Sep 17 00:00:00 2001 From: xu4wang Date: Mon, 27 Jul 2026 21:35:34 +0700 Subject: [PATCH 2/2] =?UTF-8?q?fix(usage):=20=E9=87=87=E7=BA=B3=20codex=20?= =?UTF-8?q?review=E2=80=94=E2=80=94=E5=8F=8C=E4=BE=A7=20transcript=20?= =?UTF-8?q?=E5=B9=B6=E5=AD=98=E6=97=B6=E6=8C=89=20mtime=20=E5=8F=96?= =?UTF-8?q?=E6=96=B0=EF=BC=8C=E4=B8=8D=E5=86=8D=E5=9B=BA=E5=AE=9A=E5=85=A8?= =?UTF-8?q?=E5=B1=80=E4=BC=98=E5=85=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 存量持久会话跨沙盒开关切换时(session id 不变、数据目录变),旧目录的 transcript 停止增长、新目录持续写入;固定「全局优先」会把 token/成本/ insight 永远冻结在切换点。改为两侧都存在时 statSync 比 mtime 取新者, 开↔关两个方向都正确;恰好相等(字节级拷贝)保持全局路径(stock 语义)。 新增沙盒翻转 ON/OFF/平手三个回归测试。 --- src/services/transcript-resolver.ts | 23 +++++++++++--- test/transcript-resolver-bot-home.test.ts | 38 +++++++++++++++++++++-- 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/src/services/transcript-resolver.ts b/src/services/transcript-resolver.ts index 1d6bbaed4..4b748bd41 100644 --- a/src/services/transcript-resolver.ts +++ b/src/services/transcript-resolver.ts @@ -1,4 +1,4 @@ -import { existsSync, realpathSync } from 'node:fs'; +import { existsSync, realpathSync, statSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { homedir } from 'node:os'; import type { CliId } from '../adapters/cli/types.js'; @@ -124,10 +124,25 @@ function botHomeClaudeDataDir(larkAppId: string | undefined): string | null { function claudeJsonlWithBotHomeFallback(sid: string, q: TranscriptPathQuery, primaryDataDir: string): string | null { if (!q.cwd) return null; - const path = getClaudeSessionJsonlPath(sid, q.cwd, primaryDataDir); - if (path) return path; + const globalPath = getClaudeSessionJsonlPath(sid, q.cwd, primaryDataDir); const botHomeDir = botHomeClaudeDataDir(q.larkAppId); - return botHomeDir ? getClaudeSessionJsonlPath(sid, q.cwd, botHomeDir) : null; + const botHomeJsonl = botHomeDir ? getClaudeSessionJsonlPath(sid, q.cwd, botHomeDir) : null; + // Both exist when a persistent session straddles a sandbox flip (the CLI kept + // its session id but moved data dirs — either direction). The stale copy stops + // growing while the live one keeps its mtime fresh, so newest-wins tracks the + // file the CLI is actually writing; a fixed preference would freeze usage at + // the flip point forever. + if (globalPath && botHomeJsonl) return newerFile(globalPath, botHomeJsonl); + return globalPath ?? botHomeJsonl; +} + +/** Ties (e.g. a byte-identical copy) keep `a` — the global/stock path. */ +function newerFile(a: string, b: string): string { + try { + return statSync(b).mtimeMs > statSync(a).mtimeMs ? b : a; + } catch { + return a; + } } export function resolveSessionTranscriptPath(q: TranscriptPathQuery): ResolvedTranscriptPath | null { diff --git a/test/transcript-resolver-bot-home.test.ts b/test/transcript-resolver-bot-home.test.ts index 8d11a799b..fa95c6253 100644 --- a/test/transcript-resolver-bot-home.test.ts +++ b/test/transcript-resolver-bot-home.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync, realpathSync } from 'node:fs'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, realpathSync, utimesSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; @@ -73,15 +73,47 @@ describe('resolveSessionTranscriptPath — sandboxed-bot BOT_HOME fallback', () expect(resolved).toEqual({ path: expected, kind: 'claude' }); }); - it('prefers the global dir when the transcript exists there (non-redirected bot)', () => { + it('resolves the global dir when only it has the transcript (non-redirected bot)', () => { const global = writeGlobalTranscript('sb-2'); - writeBotHomeTranscript('sb-2'); const resolved = resolveSessionTranscriptPath({ cliId: 'claude-code', sessionId: 'sb-2', cwd, larkAppId: APP_ID, }); expect(resolved?.path).toBe(global); }); + // A persistent session that straddles a sandbox flip keeps its session id but + // moves data dirs — the stale copy stops growing, the live one stays fresh. + it('picks the newer file when both dirs have the transcript (sandbox flipped ON)', () => { + const global = writeGlobalTranscript('sb-flip'); + const botHome = writeBotHomeTranscript('sb-flip'); + utimesSync(global, new Date('2026-01-01'), new Date('2026-01-01')); + utimesSync(botHome, new Date('2026-01-02'), new Date('2026-01-02')); + expect(resolveSessionTranscriptPath({ + cliId: 'claude-code', sessionId: 'sb-flip', cwd, larkAppId: APP_ID, + })?.path).toBe(botHome); + }); + + it('picks the newer file when both dirs have the transcript (sandbox flipped OFF)', () => { + const global = writeGlobalTranscript('sb-flop'); + const botHome = writeBotHomeTranscript('sb-flop'); + utimesSync(global, new Date('2026-01-02'), new Date('2026-01-02')); + utimesSync(botHome, new Date('2026-01-01'), new Date('2026-01-01')); + expect(resolveSessionTranscriptPath({ + cliId: 'claude-code', sessionId: 'sb-flop', cwd, larkAppId: APP_ID, + })?.path).toBe(global); + }); + + it('keeps the global path on an exact mtime tie (byte-identical copy)', () => { + const global = writeGlobalTranscript('sb-tie'); + const botHome = writeBotHomeTranscript('sb-tie'); + const t = new Date('2026-01-01'); + utimesSync(global, t, t); + utimesSync(botHome, t, t); + expect(resolveSessionTranscriptPath({ + cliId: 'claude-code', sessionId: 'sb-tie', cwd, larkAppId: APP_ID, + })?.path).toBe(global); + }); + it('returns null without larkAppId (no fallback target)', () => { writeBotHomeTranscript('sb-3'); expect(resolveSessionTranscriptPath({ cliId: 'claude-code', sessionId: 'sb-3', cwd })).toBeNull();