Skip to content
46 changes: 26 additions & 20 deletions docs/design/2026-08-12-session-restage-store-first.md

Large diffs are not rendered by default.

200 changes: 119 additions & 81 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,6 @@ import type { CodexAppDispatchLedgerEntry } from './types.js';
import {
validateCodexAppManagedSendOrigin,
} from './utils/codex-app-dispatch-ledger.js';
import { hasProtectedSessionMutationOwnership } from './core/session-mutation-guard.js';
import type { BackendType, PersistentBackendTarget, SessionProbe } from './adapters/backend/types.js';
import { logger } from './utils/logger.js';
import { reapLegacyPm2, liveGodAt } from './core/legacy-pm2-reaper.js';
Expand Down Expand Up @@ -217,6 +216,7 @@ import { DISPATCH_REPORT_REGISTER_ROUTE } from './core/dispatch-report-binding.j
import { isRetryableAskHttpStatus } from './core/ask-types.js';
import {
hasManagedOriginIsolationMarker,
isIsolatedCliProcess,
managedOriginDataRootProbeAccess,
managedOriginIsolationSentinelAccess,
managedOriginLegacyIsolationProbeAccess,
Expand Down Expand Up @@ -286,7 +286,9 @@ import {
writeRestartAttemptIntentTo,
} from './services/restart-intent-store.js';
import { loadAllSessionsSnapshot } from './services/session-store.js';
import { isOccupancyHeld, mutateSessionRowWhenUnowned } from './services/session-offline-write.js';
import { applySessionCommandAsHost, isOccupancyHeld, readSessionRowAsHost, type UnownedRowApply } from './services/session-command-host.js';
import { bindSessionWhiteboard as persistThenRememberWhiteboard, whiteboardBindFailedMessage } from './services/session-whiteboard-bind.js';
import type { HostSessionCommand } from './services/session-commands.js';
import {
evaluateVcMeetingManagedSend,
isTrustedVcMeetingHostRelayParent,
Expand Down Expand Up @@ -3614,22 +3616,58 @@ function loadSessions(): Map<string, SessionData> {
}) as unknown as Map<string, SessionData>;
}

/** Offline-only narrow session mutation. Callers must prefer the owning daemon
* while it is available; the shared helper rereads the exact row under the
* store's write exclusion (so a stale CLI snapshot can never be written back)
* and re-evaluates occupancy inside that exclusion. */
function mutateSessionOffline(
session: SessionData,
mutate: (current: SessionData) => boolean,
): SessionData | undefined {
/** Host-side offline session commands. Callers must prefer the owning daemon
* while it is available; the shared host module rereads the exact row under
* the store's write exclusion (so a stale CLI snapshot can never be written
* back), re-evaluates occupancy inside that exclusion, and runs the ONE
* command apply the daemon also uses (services/session-commands.ts). */
function hostTarget(session: SessionData): { sessionId: string; larkAppId?: string } {
const larkAppId = session.larkAppId;
return mutateSessionRowWhenUnowned(
{ sessionId: session.sessionId, ...(larkAppId ? { larkAppId } : {}) },
current => mutate(current as unknown as SessionData),
{ dataDir: resolveDataDir() },
) as unknown as SessionData | undefined;
return { sessionId: session.sessionId, ...(larkAppId ? { larkAppId } : {}) };
}

type OfflineRowRead =
| { ok: true; current: SessionData }
| { ok: false; error: string };

function offlineBlockedError(outcome: 'owned' | 'missing' | 'contended'): string {
switch (outcome) {
case 'owned': return 'owning_daemon_became_available';
case 'missing': return 'session_row_missing';
case 'contended': return 'session_store_busy';
}
}

/** Exclusion-ordered fresh read that yields while a daemon holds the store. */
function readSessionOffline(session: SessionData): OfflineRowRead {
const read = readSessionRowAsHost(hostTarget(session), { dataDir: resolveDataDir() });
if (read.outcome === 'ok') return { ok: true, current: read.row as unknown as SessionData };
return { ok: false, error: offlineBlockedError(read.outcome) };
}

function applySessionOffline(
session: SessionData,
command: HostSessionCommand,
options: { expectAdopted?: boolean } = {},
): UnownedRowApply {
return applySessionCommandAsHost(hostTarget(session), command, { dataDir: resolveDataDir(), ...options });
}

/** True inside a sandboxed / read-isolated / credential-only pane: such a
* process can only send commands to the owning daemon and never becomes a
* store host (design §1). Classified by positive signals (sandbox outbox env,
* host-stamped isolation env, host-stamped origin channel, kernel denial on a
* probe inode) — never by a missing secret file, so a host shell on a machine
* whose daemon never ran keeps its offline commands. Device enrollment does
* not stamp the host shell; see `isIsolatedCliProcess`. */
function isolatedCliProcess(): boolean {
let osUserHomeDir: string | undefined;
try { osUserHomeDir = userInfo().homedir; } catch { osUserHomeDir = undefined; }
if (!osUserHomeDir) return isIsolatedCliProcess(process.env, '');
return isIsolatedCliProcess(process.env, osUserHomeDir);
}
const ISOLATED_CLI_OFFLINE_ERROR = '隔离会话内不能离线修改会话(daemon 不可达)';

/** Is this bot's store held by a live host (occupancy lease, or a fresh
* heartbeat while no live lease exists)? Same data dir as the store access
* above. Never throws. */
Expand All @@ -3645,14 +3683,15 @@ type OfflineAbandonResult =
* the newest durable row under the shared session-file lock. Provider-specific
* backing cleanup remains owned by the dedicated backend lifecycle changes. */
async function abandonSessionOffline(session: SessionData): Promise<OfflineAbandonResult> {
let current = mutateSessionOffline(session, () => false);
if (!current) return { ok: false, error: 'owning_daemon_became_available' };
const first = readSessionOffline(session);
if (!first.ok) return first;
let current = first.current;

const originalPid = adoptedCliPid(current);
const ownedWorkerPid = current.pid && current.pid !== originalPid ? current.pid : undefined;
if (ownedWorkerPid) {
// Narrow the unavoidable occupancy race: do not signal a worker after an
// owning daemon has claimed the row. The locked write below repeats this.
// owning daemon has claimed the row. The locked command below repeats this.
if (current.larkAppId && occupancyHeld(current.larkAppId)) {
return { ok: false, error: 'owning_daemon_became_available' };
}
Expand All @@ -3666,24 +3705,21 @@ async function abandonSessionOffline(session: SessionData): Promise<OfflineAband

// Persist worker-less state without touching FIFO authority. If a new
// daemon/generation changed the row while SIGTERM settled, fail closed.
let workerCleared = false;
const afterStop = mutateSessionOffline(current, latest => {
if (latest.pid !== ownedWorkerPid
|| isAdoptedSession(latest) !== isAdoptedSession(current!)) return false;
delete latest.pid;
workerCleared = true;
return true;
});
if (!afterStop || !workerCleared) {
const afterStop = applySessionOffline(
current,
{ type: 'worker-exited', pid: ownedWorkerPid },
{ expectAdopted: isAdoptedSession(current) },
);
if (afterStop.outcome !== 'applied') {
return { ok: false, error: 'session_changed_while_stopping_worker' };
}
current = afterStop;
current = afterStop.row as unknown as SessionData;
} else {
// Even without a Botmux worker pid, re-read after the first authority check
// so the cleanup inputs are the newest locked backend/task lineage.
const refreshed = mutateSessionOffline(current, () => false);
if (!refreshed) return { ok: false, error: 'owning_daemon_became_available' };
current = refreshed;
const refreshed = readSessionOffline(current);
if (!refreshed.ok) return refreshed;
current = refreshed.current;
}

// Provider-specific backing teardown (no daemon to run killWorker()). Adopted
Expand Down Expand Up @@ -3726,64 +3762,50 @@ async function abandonSessionOffline(session: SessionData): Promise<OfflineAband
}
}

let applied = false;
const published = mutateSessionOffline(current, latest => {
if (isAdoptedSession(latest) !== isAdoptedSession(current)) return false;
if (latest.status === 'closed') {
applied = true;
return false;
}
latest.status = 'closed';
latest.closedAt = new Date().toISOString();
delete latest.codexAppDispatchLedger;
delete latest.codexAppGenerationCommits;
delete latest.queuedActivationPending;
delete latest.queuedActivationTail;
delete latest.pendingRepoSetup;
delete latest.previewTarget;
applied = true;
return true;
});
if (!published || !applied) {
// The same close the daemon applies (one field list, one module); an
// already-closed fresh row is a success that keeps its original closedAt.
const published = applySessionOffline(
current,
{ type: 'close' },
{ expectAdopted: isAdoptedSession(current) },
);
if (published.outcome !== 'applied' && published.outcome !== 'noop') {
return { ok: false, error: 'session_changed_during_offline_cleanup' };
}

return { ok: true, current: published, ...(cleanedBacking ? { cleanedBacking } : {}) };
return {
ok: true,
current: published.row as unknown as SessionData,
...(cleanedBacking ? { cleanedBacking } : {}),
};
}

function pruneSessionOfflineIfLedgerEmpty(session: SessionData): boolean {
let pruned = false;
mutateSessionOffline(session, current => {
if (hasProtectedSessionMutationOwnership(current)) return false;
current.status = 'closed';
current.closedAt = new Date().toISOString();
delete current.codexAppDispatchLedger;
delete current.codexAppGenerationCommits;
delete current.previewTarget;
pruned = true;
return true;
});
return pruned;
const result = applySessionOffline(session, { type: 'prune' });
return result.outcome === 'applied' || result.outcome === 'noop';
}

function patchSessionWhiteboardOffline(session: SessionData, whiteboardId: string): boolean {
return !!mutateSessionOffline(session, current => {
current.whiteboardId = whiteboardId;
return true;
});
const result = applySessionOffline(session, { type: 'whiteboard', whiteboardId });
return result.outcome === 'applied' || result.outcome === 'noop';
}

/** `unavailable`: no daemon answered, and this host may take the offline
* path. `forbidden_isolated`: no daemon answered, and this process is a
* sandboxed / read-isolated CLI that may only send — never write. */
async function postOwningDaemonSessionMutation(
session: SessionData,
suffix: 'close' | 'prune' | 'whiteboard',
body?: Record<string, unknown>,
): Promise<'applied' | 'refused' | 'unavailable'> {
if (!session.larkAppId) return 'unavailable';
): Promise<'applied' | 'refused' | 'unavailable' | 'forbidden_isolated'> {
const unavailable = (): 'unavailable' | 'forbidden_isolated' =>
(isolatedCliProcess() ? 'forbidden_isolated' : 'unavailable');
if (!session.larkAppId) return unavailable();
let daemon: ReturnType<typeof findDaemon>;
try { daemon = findDaemon(session.larkAppId); } catch { return 'unavailable'; }
if (!daemon) return 'unavailable';
try { daemon = findDaemon(session.larkAppId); } catch { return unavailable(); }
if (!daemon) return unavailable();
let secret: string;
try { secret = loadDaemonIpcSecret(); } catch { return 'unavailable'; }
try { secret = loadDaemonIpcSecret(); } catch { return unavailable(); }
let res: Awaited<ReturnType<typeof fetchDaemonIpc>>;
try {
res = await fetchDaemonIpc(
Expand All @@ -3807,7 +3829,7 @@ async function postOwningDaemonSessionMutation(
if (occupancyHeld(session.larkAppId)) {
throw new Error(`连接 daemon 失败: ${err instanceof Error ? err.message : String(err)}`);
}
return 'unavailable';
return unavailable();
}
if (suffix === 'prune' && res.status === 409) return 'refused';
// A daemon that ANSWERED is alive and authoritative whatever the lease says:
Expand Down Expand Up @@ -3875,6 +3897,10 @@ async function abandonSessionAuthoritatively(
}
}
}
// A sandboxed / read-isolated CLI has no store host capability (design §1):
// with no daemon to send the command to it fails here, explicitly, instead
// of degrading into a write behind the sandbox's read-only grant.
if (isolatedCliProcess()) return { ok: false, error: ISOLATED_CLI_OFFLINE_ERROR };
const offline = await abandonSessionOffline(session);
return offline.ok
? {
Expand All @@ -3889,7 +3915,7 @@ async function abandonSessionAuthoritatively(
async function pruneSessionAuthoritatively(session: SessionData): Promise<boolean> {
const result = await postOwningDaemonSessionMutation(session, 'prune');
if (result === 'applied') return true;
if (result === 'refused') return false;
if (result === 'refused' || result === 'forbidden_isolated') return false;
return pruneSessionOfflineIfLedgerEmpty(session);
}

Expand All @@ -3899,10 +3925,25 @@ async function patchSessionWhiteboardAuthoritatively(
): Promise<boolean> {
const result = await postOwningDaemonSessionMutation(session, 'whiteboard', { whiteboardId });
if (result === 'applied') return true;
if (result === 'refused') return false;
if (result === 'refused' || result === 'forbidden_isolated') return false;
return patchSessionWhiteboardOffline(session, whiteboardId);
}

/** Persist the binding, then mirror it on the in-memory row. A failed
* authoritative patch must not pretend the session is bound. */
async function bindSessionWhiteboard(
session: SessionData,
whiteboardId: string,
): Promise<boolean> {
const bound = await persistThenRememberWhiteboard(
session,
whiteboardId,
patchSessionWhiteboardAuthoritatively,
);
if (!bound) console.error(whiteboardBindFailedMessage(session.sessionId));
return bound;
}

function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
Expand Down Expand Up @@ -6534,8 +6575,7 @@ Context flags: --session-id, --lark-app-id, --chat-id, --working-dir/--repo`);
if (!meta && argFlag(rest, '--create')) {
meta = ensureDefaultWhiteboard({ larkAppId: ctx.larkAppId, chatId: ctx.chatId, workingDir: ctx.workingDir, sessionId: ctx.sessionId });
if (ctx.session) {
await patchSessionWhiteboardAuthoritatively(ctx.session, meta.id);
ctx.session.whiteboardId = meta.id;
await bindSessionWhiteboard(ctx.session, meta.id);
}
}
if (!meta) {
Expand All @@ -6551,8 +6591,7 @@ Context flags: --session-id, --lark-app-id, --chat-id, --working-dir/--repo`);
const ctx = currentWhiteboardContext(rest);
const meta = createWhiteboard({ id: argValue(rest, '--id'), title: argValue(rest, '--title'), larkAppId: ctx.larkAppId, chatId: ctx.chatId, workingDir: ctx.workingDir, sessionId: ctx.sessionId });
if (ctx.session && !ctx.session.whiteboardId) {
await patchSessionWhiteboardAuthoritatively(ctx.session, meta.id);
ctx.session.whiteboardId = meta.id;
await bindSessionWhiteboard(ctx.session, meta.id);
}
console.log(JSON.stringify({ board: meta, path: whiteboardPath(meta.id) }, null, 2));
return;
Expand All @@ -6575,8 +6614,7 @@ Context flags: --session-id, --lark-app-id, --chat-id, --working-dir/--repo`);
const meta = ensureDefaultWhiteboard({ larkAppId: ctx.larkAppId, chatId: ctx.chatId, workingDir: ctx.workingDir, sessionId: ctx.sessionId });
id = meta.id;
if (ctx.session) {
await patchSessionWhiteboardAuthoritatively(ctx.session, id);
ctx.session.whiteboardId = id;
await bindSessionWhiteboard(ctx.session, id);
}
}
if (!id) { console.error('No whiteboard id. Pass --id or run `botmux whiteboard current --create`.'); process.exit(1); }
Expand Down
12 changes: 9 additions & 3 deletions src/core/dashboard-ipc-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
writeManagedOriginAttestationProof,
} from './managed-origin-attestation.js';
import * as sessionStore from '../services/session-store.js';
import { applySessionRowCommand } from '../services/session-commands.js';
import { cliSupportsNativeUsage } from '../services/transcript-resolver.js';
import {
cliModelSupportsReasoningEffort,
Expand Down Expand Up @@ -2455,15 +2456,20 @@ ipcRoute('POST', '/api/sessions/:sessionId/whiteboard', async (req, res, params)
return withBotTurnAdmission(larkAppId, async () => {
const current = findSessionRecord(params.sessionId);
if (!current) return jsonRes(res, 404, { ok: false, error: 'session_not_found' });
if (expect !== undefined && current.whiteboardId !== expect) {
// The same command a host applies offline (services/session-commands.ts);
// here it runs on the daemon's own live row.
const applied = applySessionRowCommand(current, {
type: 'whiteboard',
whiteboardId: unbind ? null : bindId,
...(expect !== undefined ? { expectWhiteboardId: expect } : {}),
}, { now: new Date() });
if (applied.outcome === 'refused') {
return jsonRes(res, 409, {
ok: false,
error: 'whiteboard_changed',
whiteboardId: current.whiteboardId ?? null,
});
}
if (unbind) current.whiteboardId = undefined;
else current.whiteboardId = bindId;
sessionStore.updateSession(current);
jsonRes(res, 200, { ok: true, whiteboardId: current.whiteboardId ?? null });
});
Expand Down
Loading
Loading