Skip to content
Merged
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
15 changes: 15 additions & 0 deletions src/core/command-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
ensureSessionWhiteboard,
getAvailableBots,
} from './session-manager.js';
import { markInitialUserTurnPending } from './initial-user-turn.js';
import { discoverSlashCommandsForAdapter, listMcpServerNames, supportsFilesystemCommandDiscovery } from './command-discovery.js';
import { validateWorkingDir } from './working-dir.js';
import { repinSessionWorkingDir } from './session-cwd.js';
Expand Down Expand Up @@ -1467,6 +1468,11 @@ export async function handleCommand(
pendingPrompt.trim().length > 0 ||
(ds!.pendingAttachments?.length ?? 0) > 0 ||
(ds!.pendingFollowUps?.length ?? 0) > 0;
// Nothing to submit at all (bare `/repo`: the message IS the command).
// The CLI boots idle, so the user's NEXT real message is its first
// turn and must carry the full new-topic opening — see
// markInitialUserTurnPending below.
const emptyStart = !pendingRawInput && !hasBufferedInput;
if (hasBufferedInput) ensureSessionWhiteboard(ds!);
const wrappedInput = hasBufferedInput
? buildNewTopicCliInput(
Expand Down Expand Up @@ -1526,6 +1532,11 @@ export async function handleCommand(
if (pendingRawInput || hasBufferedInput) {
rememberLastCliInput(ds!, pendingRawInput ?? pendingPrompt, pendingRawInput ?? wrappedInput);
}
// Durable, one-shot: the CLI is up but has never received a real user
// turn, so the next business message must be built as a NEW TOPIC
// (routing + built-in skill discovery + identity), not a follow-up.
// Set after the fork so a throwing fork leaves the session untouched.
if (emptyStart) markInitialUserTurnPending(ds!);
ds!.pendingPrompt = undefined;
ds!.pendingCodexAppText = undefined;
ds!.pendingCodexAppApplicationContext = undefined;
Expand Down Expand Up @@ -1647,6 +1658,10 @@ export async function handleCommand(
sessionStore.updateSession(ds!.session);
ds!.hasHistory = false;
forkWorker(ds!, '', false);
// Brand-new CLI in a brand-new session record: it has never seen the
// botmux opening context either, so the next real business message
// is its new-topic first turn (same invariant as the pending path).
markInitialUserTurnPending(ds!);
try {
await sessionReply(rootId, t('cmd.repo.switched_to', { name: displayName }, loc));
} catch (e) {
Expand Down
68 changes: 68 additions & 0 deletions src/core/initial-user-turn.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* 「CLI 已空启动,但还没吃过任何真实用户轮」的一次性会话状态。
*
* 背景:`/repo <name>` / 选仓卡 / 跳过 / mid-session 切仓这几条路径,在**没有**
* 任何 buffered 用户输入时会 `forkWorker(ds, '', false)` 把 CLI 拉起来待命。这时
* 进程活着、`ds.worker` 非空,但 CLI 从没见过 `<botmux_routing>` /
* `<botmux_builtin_skills>` / `<identity>` —— 这些只由 `buildNewTopicCliInput`
* 产出。下一条业务消息若只按「worker 活没活」判定,就会被当 follow-up,开场上下文
* 永久丢失(见 `Session.initialUserTurnPending` 的注释与 PR #477)。
*
* 状态放在**持久化的 `Session`** 上而不是内存 `DaemonSession`:空启动之后、首条
* 业务消息之前 daemon 重启,重启后那条消息仍必须是 opening。`ds.session` 在
* restore 时就是从磁盘读回来的同一个对象,所以恢复是自动的。
*
* 并发语义:`claimInitialUserTurn` 是**同步**的读-改-写,Node 单线程下天然互斥,
* 因此紧邻/并发到达的两条首消息只有一条能成为 opener,另一条按既有队列顺序退化为
* 普通 follow-up。投递失败(fork 抛错 / worker 拒收)时用 `releaseInitialUserTurn`
* 把状态放回去,下一条消息重新竞争。
*
* 边界(刻意不消费的路径):adopt/bridge 会话从不接受 botmux 包装,因此
* `isInitialUserTurnPending` 直接对它们返回 false;botmux 控制命令、CLI raw
* passthrough、卡片回调等在到达输入构造点之前就 return 了,天然不消费。
*/
import * as sessionStore from '../services/session-store.js';
import { logger } from '../utils/logger.js';
import type { DaemonSession } from './types.js';

/** 落盘失败绝不能掀翻消息路由:状态最差退化成「只在本进程生效」。 */
function persist(ds: DaemonSession): void {
try {
sessionStore.updateSession(ds.session);
} catch (e) {
logger.warn(
`[${ds.session.sessionId.substring(0, 8)}] Failed to persist initialUserTurnPending: ${e instanceof Error ? e.message : e}`,
);
}
}

/** CLI 空启动后置位。幂等。 */
export function markInitialUserTurnPending(ds: DaemonSession): void {
if (ds.session.initialUserTurnPending === true) return;
ds.session.initialUserTurnPending = true;
persist(ds);
}

/** 只读探测——用于在 await 之前判断「这轮可能要走 opening」,不消费状态。 */
export function isInitialUserTurnPending(ds: DaemonSession): boolean {
// 外部 CLI 的桥接会话从不接受 botmux 的 XML 包装,opening 对它们无意义。
if (ds.adoptedFrom) return false;
return ds.session.initialUserTurnPending === true;
}

/**
* 同步一次性认领。返回 true 表示**本轮**是 opening;返回 false 表示别人已经拿走
* 了(或本来就没有),按普通 follow-up 处理。调用点与真正的 fork/send 之间可以有
* await,失败时用 {@link releaseInitialUserTurn} 归还。
*/
export function claimInitialUserTurn(ds: DaemonSession): boolean {
if (!isInitialUserTurnPending(ds)) return false;
delete ds.session.initialUserTurnPending;
persist(ds);
return true;
}

/** 认领后投递失败(fork 抛错 / worker 拒收)时归还,下一条消息重新竞争。 */
export function releaseInitialUserTurn(ds: DaemonSession): void {
markInitialUserTurnPending(ds);
}
9 changes: 9 additions & 0 deletions src/core/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1412,6 +1412,15 @@ export async function restoreActiveSessions(activeSessions: Map<string, DaemonSe
await setActiveSessionSafe(activeSessions, activeSessionKey(ds), ds);
announceSessionRow(ds);

if (session.initialUserTurnPending) {
// `hasHistory: true` above means "there may be a CLI process/transcript to
// reattach or resume", NOT "a user has ever spoken to it". This session's
// CLI was booted idle by the repo flow and never received a real turn, so
// the next business message still routes through the new-topic opening
// builder and cold-spawns instead of `--resume`. See core/initial-user-turn.ts.
logger.info(`[${session.sessionId.substring(0, 8)}] Restored empty-started session — its first real user turn still opens as a new topic`);
}

logger.debug(`Registered session ${session.sessionId} (scope: ${scope}, anchor: ${anchor})`);
}

Expand Down
137 changes: 126 additions & 11 deletions src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ import {
ensureSessionWhiteboard,
} from './core/session-manager.js';
import { triggerSessionTurn } from './core/trigger-session.js';
import { claimInitialUserTurn, isInitialUserTurnPending, releaseInitialUserTurn } from './core/initial-user-turn.js';
import { applyQueuedCodexAppLegacyFallback, mergeQueuedCodexAppTurn } from './core/session-create.js';
import { findOnlineDaemon, listOnlineDaemons } from './utils/daemon-discovery.js';
import { beginReplyTargetTurn, fallbackTurnId, isSubstituteTurn, resolveSessionReplyTarget, syncReplyTargetState } from './core/reply-target.js';
Expand Down Expand Up @@ -16010,6 +16011,15 @@ async function handleThreadReply(data: any, ctx: RoutingContext): Promise<void>
// Mark a new turn so the CLI's response to /model, /clear, /compact, etc.
// shows up as a fresh streaming card instead of silently PATCH-ing the
// previous turn's card.
//
// Compatibility note (empty-start opening): a raw passthrough is a
// LITERAL CLI command — its contract is that the CLI sees exactly the
// bytes the user typed, never a botmux XML envelope. So it deliberately
// does NOT consume `Session.initialUserTurnPending`: wrapping `/model`
// in `<user_message>` would break the literal contract, and silently
// clearing the marker would lose the opening for the next real turn.
// `/model` on an empty-started session therefore stays literal and the
// FOLLOWING business message still opens as a new topic.
beginNewTurn(ds, commandContent);
ds.worker.send({
type: 'raw_input',
Expand Down Expand Up @@ -16499,19 +16509,53 @@ async function handleThreadReply(data: any, ctx: RoutingContext): Promise<void>
const selfBot = getBot(ds.larkAppId);
if (!isBridge) ensureSessionWhiteboard(ds);
const effectiveCliId = ds.session.cliId ?? dsBotCfgForMsg.cliId;
// Empty-started session (repo select/skip/switch booted the CLI with no
// turn): a LIVE worker is not proof the CLI ever saw botmux's opening
// context — only `buildNewTopicCliInput` emits <botmux_routing> /
// <botmux_builtin_skills> / <identity>. Probe (non-consuming) before the
// awaits below, then claim SYNCHRONOUSLY right before building so two
// near-simultaneous first messages can only produce one opener; the loser
// degrades to an ordinary follow-up in queue order.
const wantsOpening = !isBridge && isInitialUserTurnPending(ds);
const openingBots = wantsOpening ? await getAvailableBots(larkAppId, ds.chatId) : undefined;
const turnSender = await getThreadSender();
const openingTurn = wantsOpening && claimInitialUserTurn(ds);
const cliInput = isBridge
? { content: buildBridgeInputContent(promptContent, {
attachments,
mentions: parsed.mentions,
selfMention: { name: selfBot.botName, openId: selfBot.botOpenId },
}) }
: openingTurn
? buildNewTopicCliInput(
promptContent,
ds.session.sessionId,
effectiveCliId,
ds.session.cliPathOverride ?? dsBotCfgForMsg.cliPathOverride,
attachments,
parsed.mentions,
openingBots,
undefined,
{ name: selfBot.botName, openId: selfBot.botOpenId },
localeForBot(larkAppId),
turnSender,
{
larkAppId,
chatId: ds.session.chatId,
whiteboardId: ds.session.whiteboardId,
substituteTrigger,
codexAppText: parsed.content,
codexAppApplicationContext,
codexAppMessageContext,
},
)
: buildFollowUpCliInput(promptContent, ds.session.sessionId, {
attachments,
mentions: parsed.mentions,
isAdoptMode: false,
cliId: effectiveCliId,
cliPathOverride: ds.session.cliPathOverride ?? dsBotCfgForMsg.cliPathOverride,
sender: await getThreadSender(),
sender: turnSender,
larkAppId,
chatId: ds.session.chatId,
whiteboardId: ds.session.whiteboardId,
Expand All @@ -16521,9 +16565,22 @@ async function handleThreadReply(data: any, ctx: RoutingContext): Promise<void>
codexAppMessageContext,
});
beginNewTurn(ds, parsed.content);
await noteTurnReceived(ds, parsed.messageId, parsed.content, await getThreadSender(), parsed.messageId, substituteTrigger ? SUBSTITUTE_RECEIVED_REACTION_EMOJI_TYPE : undefined);
rememberLastCliInput(ds, promptContent, cliInput);
sendWorkerInput(ds, cliInput, parsed.messageId);
await noteTurnReceived(ds, parsed.messageId, parsed.content, turnSender, parsed.messageId, substituteTrigger ? SUBSTITUTE_RECEIVED_REACTION_EMOJI_TYPE : undefined);
let accepted = false;
try {
accepted = sendWorkerInput(ds, cliInput, parsed.messageId);
// Record the input as the session's last real CLI turn ONLY after the
// worker accepted it. Recording before delivery (the old order) persisted
// lastCliInput / lastUserPrompt / Codex-App sidecar for a turn that never
// reached the CLI; a rejected send then left that poison behind, and the
// next message's worker-null refork would read it as `hadPriorCliInput`
// and wrongly `--resume` a CLI that never took a real turn.
if (accepted) rememberLastCliInput(ds, promptContent, cliInput);
} finally {
// The opening is one-shot: give it back when the worker died / refused,
// so the next message re-opens instead of silently losing the context.
if (openingTurn && !accepted) releaseInitialUserTurn(ds);
}
} else {
// Worker not running — re-fork with resume. This is a NEW turn, so drop
// any restored streaming-card reference; worker_ready will POST a fresh
Expand Down Expand Up @@ -16584,19 +16641,36 @@ async function handleThreadReply(data: any, ctx: RoutingContext): Promise<void>
currentText: parsed.content,
currentMessageContext: codexAppMessageContext,
});
// Empty-started session that lost its worker (daemon restart, idle sweep,
// CLI exit) — same rule as the live branch: this is still the FIRST real
// user turn, so it must be built as a new topic. A queued(待办池) activation
// is excluded: its queuedPrompt already owns the first turn.
const wantsOpening = !ds.adoptedFrom && !queuedDashboardTurn && isInitialUserTurnPending(ds);
const openingBots = wantsOpening ? await getAvailableBots(larkAppId, ds.chatId) : undefined;
const reforkSender = await getThreadSender();
// An empty-started CLI has nothing to resume: `hasHistory` is set
// unconditionally by restoreActiveSessions (and by claude_exit /
// suspendWorker), so it cannot tell "booted idle" from "has real history".
// `session.lastCliInput` can: rememberLastCliInput writes it on EVERY real
// CLI input, and the empty-start fork deliberately writes none. Snapshot it
// BEFORE this turn's own rememberLastCliInput below, so a session some
// non-IM path (scheduler / webhook trigger / doc comment) already fed keeps
// its normal `--resume` instead of being cold-spawned over.
const hadPriorCliInput = !!(ds.lastCliInput ?? ds.session.lastCliInput);
const openingTurn = wantsOpening && claimInitialUserTurn(ds);
const builtReforkInput = buildReforkCliInput(ds, reforkContent, {
attachments,
mentions: parsed.mentions,
cliId: ds.session.cliId ?? dsBotCfgForFork.cliId,
cliPathOverride: ds.session.cliPathOverride ?? dsBotCfgForFork.cliPathOverride,
selfMention: { name: selfBot.botName, openId: selfBot.botOpenId },
sender: await getThreadSender(),
sender: reforkSender,
substituteTrigger,
codexAppText: reforkCodexApp.text,
codexAppApplicationContext,
codexAppMessageContext: reforkCodexApp.messageContext,
});
const wrappedInput = applyQueuedCodexAppLegacyFallback(builtReforkInput, {
let wrappedInput = applyQueuedCodexAppLegacyFallback(builtReforkInput, {
queued: queuedDashboardTurn,
queuedText: queuedCodexAppText,
});
Expand All @@ -16607,13 +16681,54 @@ async function handleThreadReply(data: any, ctx: RoutingContext): Promise<void>
// contain the reply and would silently discard the original task.
logger.warn(`[${tag(ds)}] Legacy queued dashboard task has no clean-input text; using the full legacy activation prompt`);
}
await noteTurnReceived(ds, parsed.messageId, parsed.content, await getThreadSender(), parsed.messageId, substituteTrigger ? SUBSTITUTE_RECEIVED_REACTION_EMOJI_TYPE : undefined);
if (openingTurn) {
// Replace the follow-up envelope built above with the real opening. The
// discarded build is pure string assembly (no side effects) — keeping the
// refork statement unconditional keeps the queued/substitute wiring, and
// its guard test, on a single code path.
wrappedInput = buildNewTopicCliInput(
reforkContent,
ds.session.sessionId,
ds.session.cliId ?? dsBotCfgForFork.cliId,
ds.session.cliPathOverride ?? dsBotCfgForFork.cliPathOverride,
attachments,
parsed.mentions,
openingBots,
undefined,
{ name: selfBot.botName, openId: selfBot.botOpenId },
localeForBot(larkAppId),
reforkSender,
{
larkAppId,
chatId: ds.session.chatId,
whiteboardId: ds.session.whiteboardId,
substituteTrigger,
codexAppText: reforkCodexApp.text,
codexAppApplicationContext,
codexAppMessageContext: reforkCodexApp.messageContext,
},
);
}
await noteTurnReceived(ds, parsed.messageId, parsed.content, reforkSender, parsed.messageId, substituteTrigger ? SUBSTITUTE_RECEIVED_REACTION_EMOJI_TYPE : undefined);
try {
forkWorker(ds, wrappedInput, {
// See `hadPriorCliInput` above — an opening on a CLI that never took any
// input cold-spawns rather than `--resume`-ing an empty session.
resume: ds.hasHistory && !(openingTurn && !hadPriorCliInput),
turnId: parsed.messageId,
});
} catch (e) {
if (openingTurn) releaseInitialUserTurn(ds);
throw e;
}
// Record the input as the session's last real CLI turn ONLY after the fork
// succeeded. Recording before the fork (the old order) persisted lastCliInput
// for a turn that never launched when forkWorker threw; the retry then read
// that poison as `hadPriorCliInput` and wrongly `--resume`d a CLI that never
// took a real turn — breaking the empty-start invariant. forkWorker itself
// persists the session (clearing queued); this records last* + reply state.
rememberLastCliInput(ds, promptContent, wrappedInput);
sessionStore.updateSession(ds.session);
forkWorker(ds, wrappedInput, {
resume: ds.hasHistory,
turnId: parsed.messageId,
});
}
}

Expand Down
Loading