Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
459 changes: 323 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 @@ -2427,7 +2427,7 @@ function buildAsyncTriggerLookupResponse(sessionId: string, triggerId?: string):
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