Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
1f5e8f7
docs(session): 升级窗口改为手动重启加 fail closed 提示,按 master 基线重写现状差距与收尾分解
LucasIcarus Sep 8, 2026
26e2815
docs(session): 三项待定项落定为决策,补阅读指引、决策记录与验收标准
LucasIcarus Sep 8, 2026
37d015c
docs(session): 会话内定位改为先问活着的 daemon 再读库,常态可见性提前进前置 PR
LucasIcarus Sep 8, 2026
4125620
docs(session): 撤回 daemon 内部 updateSession 命令化,类型化命令只用于进程边界与多字段原子转换
LucasIcarus Sep 9, 2026
82acc44
docs(session): 裁定实施侧三处取舍——A-8 拆独立 PR、停写 supervisorShutdownProtocol 并删…
LucasIcarus Sep 9, 2026
cb7db0d
feat(session): 收口跨进程会话库协议并补齐升级可见性
LucasIcarus Sep 9, 2026
21ec09d
fix(test): bun mock 补齐 hostOccupancyLeaseHeld 导出
LucasIcarus Sep 9, 2026
9d7551d
fix(session): 无 appId 枚举 daemon 时非属主 404 不再短路读库,并修正评审指出的测试可移植性问题
LucasIcarus Sep 9, 2026
bce3510
fix(session): unmigrated 只对仍然存在的 bot 判定,移出 bots.json 的残留 JSON 不再提示重启
LucasIcarus Sep 9, 2026
bd2836b
fix(session): 按 A+B 协议改写 readBotSessionsStrict
LucasIcarus Sep 11, 2026
945b0db
fix(session): 工作区回收的严格清单对未迁移的已知 bot 会话库 fail closed
LucasIcarus Sep 14, 2026
cc4b0d4
fix(test): cost-calculator 套件 stub 会话库,消除与 #1333 的 mock 碰撞
LucasIcarus Sep 14, 2026
8b55171
docs(session): 记录 worktree 回收清单与编译态 dashboard 版本提示的裁定
LucasIcarus Sep 14, 2026
4aefaee
fix(dashboard): 编译态下「运行中 daemon vs 磁盘」版本提示按无法判定处理
LucasIcarus Sep 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
461 changes: 325 additions & 136 deletions docs/design/2026-08-12-session-restage-store-first.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -272,4 +272,5 @@ else
printf ' %s\n' "echo 'export PATH=\"$INSTALL_DIR:\$PATH\"' >> ~/.profile && . ~/.profile"
fi

printf '\n%s\n' "若 daemon 正在运行,请执行 botmux restart 应用新版本"
printf '\n%s\n' "Next: botmux setup"
2 changes: 2 additions & 0 deletions scripts/postinstall-bin.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -386,3 +386,5 @@ try {
console.log(`[botmux] add ${binDir} to your PATH so this launcher is the \`botmux\` your shell finds`);
}
}

console.log('[botmux] 若 daemon 正在运行,请执行 botmux restart 应用新版本');
9 changes: 0 additions & 9 deletions src/adapters/cli/fs-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -817,13 +817,7 @@ export function buildFsPolicy(ctx: FsPolicyContext): FsPolicy {
// would keep reading the dead WAL forever. A directory bind resolves names
// live. Sibling bots' store dirs stay uncovered (deny-by-default).
//
// The pre-SQLite `sessions-<appId>.json` is granted too, and stays granted
// until the upgrade window is provably closed: while the owning daemon still
// runs a pre-SQLite build there is no `.db` at all, and a sandboxed
// `botmux send` that cannot even stat that file reports "session not found"
// — i.e. the agent silently loses the ability to reply.
push([
`${ctx.sessionDataDir}/sessions-${ctx.currentAppId}.json`,
`${ctx.sessionDataDir}/session-stores/${ctx.currentAppId}`,
], 'readOnly', 'internal');
// Own upload bucket — readWRITE: `botmux quoted` / downloadResources writes the
Expand Down Expand Up @@ -1001,9 +995,6 @@ export function buildFsPolicy(ctx: FsPolicyContext): FsPolicy {
// own-app-scoped — NOT the shared secret/port table).
push([
`${ctx.sessionDataDir}/bots-info.json`, // display names for <available_bots> (public-ish)
`${ctx.sessionDataDir}/sessions-${ctx.currentAppId}.json`,
// Own SQLite store DIRECTORY (see the larkTransport grant above for why
// a dir, not the three files, and why the JSON is still granted).
`${ctx.sessionDataDir}/session-stores/${ctx.currentAppId}`,
`${ctx.sessionDataDir}/bot-openids-${ctx.currentAppId}.json`,
// Core-only writes its `botmux` wrapper into <dataDir>/bin (dedicated, NOT
Expand Down
214 changes: 137 additions & 77 deletions src/cli.ts

Large diffs are not rendered by default.

188 changes: 188 additions & 0 deletions src/cli/resolve-session-by-id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
/**
* Locate one session by id: ask a live daemon first, read the store only
* when no daemon answered.
*
* A 404 from the OWNING daemon (the one `BOTMUX_LARK_APP_ID` names) is
* authoritative absence — never fall back to the store. Without an appId the
* resolver enumerates every online daemon; each answers only for its own bot,
* so a 404 there says nothing about other bots' stores and the store is still
* read afterwards. Connection failure / no descriptor / unread secret
* (isolated CLI) fall back to the snapshot, including the unmigrated probe.
*/
import { fetchDaemonIpc, loadDaemonIpcSecret } from '../core/daemon-ipc-auth.js';
import {
classifyStorePresence,
loadAllSessionsSnapshot,
type SessionsSnapshot,
} from '../services/session-store.js';
import type { Session } from '../types.js';
import { formatUnmigratedMessage, isSessionScopedCliProcess } from '../services/session-store-copy.js';
import { knownBotAppIds } from '../services/known-bot-app-ids.js';
import { findOnlineDaemon, listOnlineDaemons, type OnlineDaemonInfo } from '../utils/daemon-discovery.js';

export type ResolveSessionByIdOk = { ok: true; session: Session; source: 'daemon' | 'store' };
export type ResolveSessionByIdErr = {
ok: false;
reason: 'not_found' | 'unmigrated' | 'app_id_mismatch' | 'session_id_mismatch';
message: string;
};
export type ResolveSessionByIdResult = ResolveSessionByIdOk | ResolveSessionByIdErr;

export type ResolveSessionByIdDeps = {
dataDir: string;
env?: NodeJS.ProcessEnv;
findDaemon?: typeof findOnlineDaemon;
listDaemons?: typeof listOnlineDaemons;
fetchIpc?: typeof fetchDaemonIpc;
loadSecret?: typeof loadDaemonIpcSecret;
loadSnapshot?: typeof loadAllSessionsSnapshot;
/** Bots that still exist (configured / online / own); leftover JSON of any
* other app id is abandoned data, not `unmigrated`. Computed when omitted. */
knownAppIds?: ReadonlySet<string>;
};

function asSession(row: unknown): Session | undefined {
if (!row || typeof row !== 'object' || Array.isArray(row)) return undefined;
const session = row as Session;
if (typeof session.sessionId !== 'string' || !session.sessionId) return undefined;
return session;
}

function mismatch(
reason: 'app_id_mismatch' | 'session_id_mismatch',
message: string,
): ResolveSessionByIdErr {
return { ok: false, reason, message };
}

function checkReturnedRow(
session: Session,
sessionId: string,
envAppId: string | undefined,
): ResolveSessionByIdErr | undefined {
if (session.sessionId !== sessionId) {
return mismatch('session_id_mismatch', `daemon 返回的 sessionId 与请求不一致`);
}
if (envAppId && session.larkAppId && session.larkAppId !== envAppId) {
return mismatch('app_id_mismatch', `daemon 返回的 larkAppId 与 BOTMUX_LARK_APP_ID 不一致`);
}
return undefined;
}

async function askDaemon(
daemon: OnlineDaemonInfo,
sessionId: string,
deps: ResolveSessionByIdDeps,
): Promise<
| { status: 'ok'; session: Session }
| { status: 'not_found' }
| { status: 'unreachable' }
| { status: 'rejected'; err: ResolveSessionByIdErr }
> {
let secret: string;
try {
secret = (deps.loadSecret ?? loadDaemonIpcSecret)();
} catch {
return { status: 'unreachable' };
}
let res: Response;
try {
res = await (deps.fetchIpc ?? fetchDaemonIpc)(
daemon.ipcPort,
`/api/sessions/${encodeURIComponent(sessionId)}`,
{ method: 'GET' },
secret,
);
} catch {
return { status: 'unreachable' };
}
if (res.status === 404) return { status: 'not_found' };
if (!res.ok) return { status: 'unreachable' };
let body: unknown;
try { body = await res.json(); } catch { return { status: 'unreachable' }; }
const row = asSession(
body && typeof body === 'object' && 'session' in body
? (body as { session: unknown }).session
: body,
);
if (!row) return { status: 'unreachable' };
const bad = checkReturnedRow(row, sessionId, deps.env?.BOTMUX_LARK_APP_ID);
if (bad) return { status: 'rejected', err: bad };
return { status: 'ok', session: row };
}

function readFromStore(
sessionId: string,
deps: ResolveSessionByIdDeps,
): ResolveSessionByIdResult {
const env = deps.env ?? process.env;
const envAppId = env.BOTMUX_LARK_APP_ID;
if (envAppId && classifyStorePresence(envAppId, deps.dataDir) === 'unmigrated') {
return {
ok: false,
reason: 'unmigrated',
message: formatUnmigratedMessage({ sessionScoped: isSessionScopedCliProcess(env) }),
};
}
const snapshot: SessionsSnapshot = (deps.loadSnapshot ?? loadAllSessionsSnapshot)({
dataDir: deps.dataDir,
fallbackAppId: envAppId,
knownAppIds: deps.knownAppIds ?? knownBotAppIds({ dataDir: deps.dataDir, env }),
});
const hit = snapshot.get(sessionId);
if (hit) {
const bad = checkReturnedRow(hit, sessionId, envAppId);
if (bad) return bad;
return { ok: true, session: hit, source: 'store' };
}
const unmigrated = snapshot.unmigratedAppIds ?? [];
if (unmigrated.length > 0 && !hit) {
// No row in ready stores, and at least one bot is still on JSON — the
// session may live there. Fail with unmigrated rather than a silent miss.
if (!envAppId || unmigrated.includes(envAppId) || unmigrated.includes('')) {
return {
ok: false,
reason: 'unmigrated',
message: formatUnmigratedMessage({ sessionScoped: isSessionScopedCliProcess(env) }),
};
}
}
return { ok: false, reason: 'not_found', message: `未找到 session ${sessionId}` };
}

export async function resolveSessionById(
sessionId: string,
deps: ResolveSessionByIdDeps,
): Promise<ResolveSessionByIdResult> {
const env = deps.env ?? process.env;
const findDaemon = deps.findDaemon ?? findOnlineDaemon;
const listDaemons = deps.listDaemons ?? listOnlineDaemons;
const envAppId = env.BOTMUX_LARK_APP_ID;

const candidates: OnlineDaemonInfo[] = [];
if (envAppId) {
try {
const one = findDaemon(envAppId, deps.dataDir);
if (one) candidates.push(one);
} catch { /* unreadable registry → store fallback */ }
} else {
try { candidates.push(...listDaemons(deps.dataDir)); } catch { /* store fallback */ }
}

let sawAuthoritativeMiss = false;
for (const daemon of candidates) {
const asked = await askDaemon(daemon, sessionId, { ...deps, env });
if (asked.status === 'ok') return { ok: true, session: asked.session, source: 'daemon' };
if (asked.status === 'rejected') return asked.err;
if (asked.status === 'not_found') {
// Only the owning daemon's 404 is authoritative. A 404 from a daemon
// reached by enumeration covers just that bot's store — keep asking,
// then read the store (an offline bot's rows live only on disk).
if (envAppId) { sawAuthoritativeMiss = true; break; }
}
}
if (sawAuthoritativeMiss) {
return { ok: false, reason: 'not_found', message: `未找到 session ${sessionId}` };
}
return readFromStore(sessionId, { ...deps, env });
}
2 changes: 1 addition & 1 deletion src/core/current-turn-provenance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ function readPersistedSession(dataDir: string, sessionId: string): PersistedTurn
// a duplicated row must not be used to infer the caller — stays here.
let matches: readonly unknown[];
try {
matches = readSessionRowCopiesAcrossStores(sessionId, dataDir);
matches = readSessionRowCopiesAcrossStores(sessionId, dataDir).matches;
} catch (err) {
throw new CurrentTurnProvenanceError(
`无法读取 botmux session store:${err instanceof Error ? err.message : String(err)}`,
Expand Down
2 changes: 1 addition & 1 deletion src/core/dashboard-ipc-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,7 @@

export function jsonRes(res: ServerResponse, status: number, body: unknown): void {
res.writeHead(status, { 'content-type': 'application/json' });
res.end(JSON.stringify(body));

Check warning

Code scanning / CodeQL

Information exposure through a stack trace Medium

This information exposed to the user depends on
stack trace information
.
This information exposed to the user depends on stack trace information.
This information exposed to the user depends on stack trace information.
This information exposed to the user depends on stack trace information.
This information exposed to the user depends on stack trace information.
This information exposed to the user depends on stack trace information.
This information exposed to the user depends on stack trace information.
This information exposed to the user depends on stack trace information.
This information exposed to the user depends on stack trace information.
This information exposed to the user depends on stack trace information.
This information exposed to the user depends on stack trace information.
This information exposed to the user depends on stack trace information.
This information exposed to the user depends on stack trace information.
This information exposed to the user depends on stack trace information.
This information exposed to the user depends on stack trace information.
This information exposed to the user depends on stack trace information.
This information exposed to the user depends on stack trace information.
This information exposed to the user depends on stack trace information.
This information exposed to the user depends on stack trace information.
This information exposed to the user depends on stack trace information.
This information exposed to the user depends on stack trace information.
This information exposed to the user depends on stack trace information.
This information exposed to the user depends on stack trace information.
This information exposed to the user depends on stack trace information.
This information exposed to the user depends on stack trace information.
This information exposed to the user depends on stack trace information.
This information exposed to the user depends on stack trace information.
This information exposed to the user depends on stack trace information.
This information exposed to the user depends on stack trace information.
This information exposed to the user depends on stack trace information.
This information exposed to the user depends on stack trace information.
This information exposed to the user depends on stack trace information.
This information exposed to the user depends on stack trace information.
This information exposed to the user depends on stack trace information.
This information exposed to the user depends on stack trace information.
This information exposed to the user depends on stack trace information.
This information exposed to the user depends on stack trace information.
This information exposed to the user depends on stack trace information.
This information exposed to the user depends on stack trace information.
This information exposed to the user depends on stack trace information.
This information exposed to the user depends on stack trace information.
This information exposed to the user depends on stack trace information.
This information exposed to the user depends on stack trace information.
This information exposed to the user depends on stack trace information.
This information exposed to the user depends on stack trace information.
This information exposed to the user depends on stack trace information.
This information exposed to the user depends on stack trace information.
This information exposed to the user depends on stack trace information.
This information exposed to the user depends on stack trace information.
This information exposed to the user depends on stack trace information.
This information exposed to the user depends on stack trace information.
This information exposed to the user depends on [stack trace information](52
}

function rejectProtectedSessionMutation(
Expand Down Expand Up @@ -2429,7 +2429,7 @@
const persistedRaw = asyncTriggerStore.lookup(sessionId, triggerId);

// Cross-bot isolation (fail-closed / positive-proof) — see decideAsyncOwnership.
// Both sessionStore.getSession() (cross-scans every bot's sessions-*.json) and
// Both sessionStore.getSession() (cross-scans every bot's SQLite store) and
// the async store (machine-wide shared dir) can surface another bot's data for
// a sessionId routed to THIS daemon; keep only sources positively proven ours.
const decision = decideAsyncOwnership({
Expand Down
28 changes: 27 additions & 1 deletion src/core/dashboard-rows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
// module so worker-pool can import the composer without pulling in the IPC
// server (which itself imports worker-pool — that would be a cycle).
import type { DaemonSession } from './types.js';
import type { Session, StreamStatus } from '../types.js';
import type { CodexAppDispatchLedgerEntry, ReplyTargetEntry, Session, StreamStatus } from '../types.js';
import type { CliId } from '../adapters/cli/types.js';
import { basename } from 'node:path';
import { getTerminalAdvertisedPort } from './terminal-url.js';
Expand Down Expand Up @@ -128,6 +128,29 @@ export interface SessionRow extends SessionMessagePreview {
repoName?: string;
/** Current branch of workingDir; absent for detached HEAD / non-repo. */
gitBranch?: string;
/** Per-turn reply anchors — `botmux send` prefers these over the topic root. */
replyTargets?: Record<string, ReplyTargetEntry>;
currentReplyTarget?: Session['currentReplyTarget'];
quoteTargetId?: string;
quoteTargetSenderOpenId?: string;
codexAppDispatchLedger?: CodexAppDispatchLedgerEntry[];
}

function composeSendRoutingFields(
s: Session,
runtimeCurrentReplyTarget?: Session['currentReplyTarget'],
): Pick<
SessionRow,
'replyTargets' | 'currentReplyTarget' | 'quoteTargetId' | 'quoteTargetSenderOpenId' | 'codexAppDispatchLedger'
> {
const currentReplyTarget = runtimeCurrentReplyTarget ?? s.currentReplyTarget;
return {
...(s.replyTargets ? { replyTargets: s.replyTargets } : {}),
...(currentReplyTarget ? { currentReplyTarget } : {}),
...(s.quoteTargetId ? { quoteTargetId: s.quoteTargetId } : {}),
...(s.quoteTargetSenderOpenId ? { quoteTargetSenderOpenId: s.quoteTargetSenderOpenId } : {}),
...(s.codexAppDispatchLedger ? { codexAppDispatchLedger: s.codexAppDispatchLedger } : {}),
};
}

export function feishuChatLink(chatId: string, brand: Brand = 'feishu'): string {
Expand Down Expand Up @@ -314,6 +337,7 @@ export function composeRowFromActive(ds: DaemonSession, opts?: DashboardRowOptio
...(ds.worker?.pid !== undefined ? { workerPid: ds.worker.pid } : {}),
...(ds.adoptedFrom?.originalCliPid !== undefined ? { adoptCliPid: ds.adoptedFrom.originalCliPid } : {}),
...buildSessionMessagePreview(ds.session),
...composeSendRoutingFields(ds.session, ds.currentReplyTarget),
};
}

Expand Down Expand Up @@ -357,6 +381,7 @@ export function composeRowFromClosed(s: Session, opts?: DashboardRowOptions): Se
...(topicLink ? { feishuThreadLink: topicLink } : {}),
tokenUsage: maybeSessionTokenUsage(s, undefined, opts, { usePersistedSnapshot: true }),
...buildSessionMessagePreview(s),
...composeSendRoutingFields(s),
};
}

Expand Down Expand Up @@ -409,5 +434,6 @@ export function composeRowFromPersistedActive(s: Session, opts?: DashboardRowOpt
quarantined: !!s.restoreQuarantinedAt,
tokenUsage: maybeSessionTokenUsage(s, undefined, opts),
...buildSessionMessagePreview(s),
...composeSendRoutingFields(s),
};
}
3 changes: 2 additions & 1 deletion src/core/fleet-supervisor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
} from './fleet-supervisor-policy.js';
import { mutateFleetState, readFleetState } from './fleet-state-store.js';
import type { FleetCommand } from './fleet-command-queue.js';
import { FLEET_DAEMON_KILL_TIMEOUT_MS } from './shutdown-budgets.js';

export interface FleetBotSpec {
/** botmux-<index> process name (or 'botmux-dashboard' for the dashboard). */
Expand Down Expand Up @@ -135,7 +136,7 @@ export class FleetSupervisor {

constructor(private readonly opts: FleetSupervisorOptions) {
this.policy = opts.policy ?? DEFAULT_RESTART_POLICY;
this.killTimeoutMs = opts.killTimeoutMs ?? 8000;
this.killTimeoutMs = opts.killTimeoutMs ?? FLEET_DAEMON_KILL_TIMEOUT_MS;
this.log = opts.log ?? ((m) => console.error(`[fleet-supervisor] ${m}`));
}

Expand Down
43 changes: 19 additions & 24 deletions src/core/mojo-containment-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,30 +65,24 @@ const USAGE = `用法:
* unavailable"), which does NOT block the revoke but IS surfaced to the
* operator; only a definite `status === 'active'` blocks.
*/
export async function defaultIsSessionActive(sessionId: string): Promise<boolean | undefined> {
export async function defaultIsSessionActive(
sessionId: string,
dataDir?: string,
): Promise<boolean | undefined> {
try {
const [{ config }, { readdirSync, readFileSync }, { join }] = await Promise.all([
import('../config.js'),
import('node:fs'),
import('node:path'),
]);
const dir = config.session.dataDir;
let sawUnreadable = false;
for (const file of readdirSync(dir)) {
if (!/^sessions(-[^.]+)?\.json$/.test(file)) continue;
try {
const data = JSON.parse(readFileSync(join(dir, file), 'utf-8')) as
Record<string, { status?: string } | undefined>;
const row = data[sessionId];
if (row) return row.status === 'active';
} catch {
// A corrupt file may be the very one hiding this row: unknown, never
// "proven inactive".
sawUnreadable = true;
}
}
return sawUnreadable ? undefined : false;
} catch {
const { config } = await import('../config.js');
const {
readSessionRowCopiesAcrossStores,
SessionStoreSqliteUnavailableError,
} = await import('../services/session-store.js');
const dir = dataDir ?? config.session.dataDir;
const { matches, unreadableStores } = readSessionRowCopiesAcrossStores(sessionId, dir);
if (matches.some(s => s.status === 'active')) return true;
if (unreadableStores > 0) return undefined;
return false;
} catch (err) {
const { SessionStoreSqliteUnavailableError } = await import('../services/session-store.js');
if (err instanceof SessionStoreSqliteUnavailableError) return undefined;
return undefined;
}
}
Expand Down Expand Up @@ -214,7 +208,8 @@ export async function runMojoContainmentCommand(
}
}
}
const active = await (deps.isSessionActive ?? defaultIsSessionActive)(sessionId);
const active = await (deps.isSessionActive
?? ((id: string) => defaultIsSessionActive(id, deps.dataDir)))(sessionId);
if (active === true) {
liveBlockers.push(`session ${sessionId} 的会话行仍处于 active(先 /close 它)`);
} else if (active === undefined) {
Expand Down
Loading
Loading