fix: 修复终端异常断开情况,resume续跑;修复用户消息排队信息被goal输出信息覆盖的问题。

This commit is contained in:
moyu 2026-06-12 16:21:43 +08:00
parent fde1370c80
commit 17eac385c8
9 changed files with 71 additions and 24 deletions

View File

@ -36,6 +36,13 @@ import { removeByFilter } from 'src/utils/messageQueueManager.js';
import { GoalReplaceConfirmDialog } from './GoalReplaceConfirmDialog.js'; import { GoalReplaceConfirmDialog } from './GoalReplaceConfirmDialog.js';
const MAX_OBJECTIVE_CHARS = 4000; const MAX_OBJECTIVE_CHARS = 4000;
const MAX_DISPLAY_CHARS = 80;
function truncateForDisplay(objective: string): string {
const firstLine = objective.split('\n')[0] ?? objective;
if (firstLine.length <= MAX_DISPLAY_CHARS) return firstLine;
return firstLine.slice(0, MAX_DISPLAY_CHARS) + '…';
}
function drainGoalContinuationQueue(): void { function drainGoalContinuationQueue(): void {
removeByFilter( removeByFilter(
@ -174,6 +181,7 @@ export async function call(
onDone(summary, { onDone(summary, {
display: 'system', display: 'system',
shouldQuery: true, shouldQuery: true,
displayArgs: truncateForDisplay(trimmed),
metaMessages: [`<goal-objective-updated>\n${trimmed}\n</goal-objective-updated>`], metaMessages: [`<goal-objective-updated>\n${trimmed}\n</goal-objective-updated>`],
}); });
return null; return null;
@ -184,10 +192,12 @@ export async function call(
currentGoal={existing} currentGoal={existing}
newObjective={trimmed} newObjective={trimmed}
onConfirm={() => { onConfirm={() => {
drainGoalContinuationQueue();
const summary = applySetGoal(trimmed); const summary = applySetGoal(trimmed);
onDone(summary, { onDone(summary, {
display: 'system', display: 'system',
shouldQuery: true, shouldQuery: true,
displayArgs: truncateForDisplay(trimmed),
metaMessages: [`<goal-objective-updated>\n${trimmed}\n</goal-objective-updated>`], metaMessages: [`<goal-objective-updated>\n${trimmed}\n</goal-objective-updated>`],
}); });
}} }}

View File

@ -9,21 +9,22 @@
* 1. GOAL feature flag enabled * 1. GOAL feature flag enabled
* 2. Goal exists and status === 'active' * 2. Goal exists and status === 'active'
* 3. Query just finished (isLoading transitioned false) * 3. Query just finished (isLoading transitioned false)
* 4. No pending user input in queue * 4. No active local-JSX UI (modal dialog)
* 5. No active local-JSX UI (modal dialog) * 5. Not in plan mode
* 6. Not in plan mode * 6. turnsExecuted < MAX_GOAL_TURNS
* 7. turnsExecuted < MAX_GOAL_TURNS * 7. No user messages in the queue (user input always takes priority)
* *
* When all conditions are met, a meta-message containing the * When user messages are queued during a goal turn, the hook always
* continuation prompt is enqueued. The queue processor picks it up * yields to let them process first. After the user messages are
* and submits it as a new turn the model sees the steering prompt * handled, the next idle will fire the hook again to continue.
* and continues working towards the goal. * This ensures commands like `/goal pause` are never starved by
* auto-continuation.
* *
* The hook is intentionally simple: a single useEffect that fires * The hook is intentionally simple: a single useEffect that fires
* when `isLoading` flips to false. No timers, no intervals the * when `isLoading` flips to false. No timers, no intervals the
* idleenqueueprocessqueryidle cycle is self-sustaining. * idleenqueueprocessqueryidle cycle is self-sustaining.
*/ */
import { useEffect, useRef } from 'react' import { useLayoutEffect, useRef } from 'react'
import { logForDebugging } from 'src/utils/debug.js' import { logForDebugging } from 'src/utils/debug.js'
import { import {
@ -37,7 +38,10 @@ import {
buildBudgetLimitPrompt, buildBudgetLimitPrompt,
buildContinuationPrompt, buildContinuationPrompt,
} from 'src/services/goal/prompts.js' } from 'src/services/goal/prompts.js'
import { enqueue, getCommandQueueSnapshot } from 'src/utils/messageQueueManager.js' import {
enqueue,
getCommandQueueSnapshot,
} from 'src/utils/messageQueueManager.js'
function hookLog(msg: string): void { function hookLog(msg: string): void {
logForDebugging(`[goal] hook: ${msg}`) logForDebugging(`[goal] hook: ${msg}`)
@ -63,10 +67,13 @@ export function useGoalContinuation(
const optsRef = useRef(opts) const optsRef = useRef(opts)
optsRef.current = opts optsRef.current = opts
// Track whether we already enqueued for the current idle window.
// Reset to false every time isLoading becomes true (new turn starts).
const enqueuedRef = useRef(false) const enqueuedRef = useRef(false)
// Fire budget_limit prompt exactly once per budget transition.
const budgetLimitFiredRef = useRef(false) const budgetLimitFiredRef = useRef(false)
useEffect(() => { useLayoutEffect(() => {
if (opts.isLoading) { if (opts.isLoading) {
enqueuedRef.current = false enqueuedRef.current = false
return return
@ -86,13 +93,15 @@ export function useGoalContinuation(
// Already enqueued for this idle window // Already enqueued for this idle window
if (enqueuedRef.current) return if (enqueuedRef.current) return
// Blocking conditions // User messages always take priority over auto-continuation.
// If the user typed something (e.g. `/goal pause`) while a turn was
// running, let their message process first. After it finishes, the
// next idle cycle will re-evaluate whether to continue.
const liveQueueLength = getCommandQueueSnapshot().length const liveQueueLength = getCommandQueueSnapshot().length
if (liveQueueLength > 0) { if (liveQueueLength > 0) {
hookLog('skip: liveQueuedCommands=' + liveQueueLength) hookLog('skip: yielding to queued user messages')
return return
} }
if (opts.queuedCommandsLength > 0) { hookLog('skip: queuedCommands=' + opts.queuedCommandsLength); return }
if (opts.hasActiveLocalJsxUI) { hookLog('skip: activeLocalJsxUI'); return } if (opts.hasActiveLocalJsxUI) { hookLog('skip: activeLocalJsxUI'); return }
if (opts.isInPlanMode) { hookLog('skip: planMode'); return } if (opts.isInPlanMode) { hookLog('skip: planMode'); return }
@ -115,7 +124,7 @@ export function useGoalContinuation(
enqueue({ enqueue({
value: prompt, value: prompt,
mode: 'prompt', mode: 'prompt',
priority: 'later', priority: 'now',
isMeta: true, isMeta: true,
origin: 'goal-budget-limit', origin: 'goal-budget-limit',
skipSlashCommands: true, skipSlashCommands: true,
@ -153,7 +162,7 @@ export function useGoalContinuation(
enqueue({ enqueue({
value: prompt, value: prompt,
mode: 'prompt', mode: 'prompt',
priority: 'later', priority: 'now',
isMeta: true, isMeta: true,
origin: 'goal-continuation', origin: 'goal-continuation',
skipSlashCommands: true, skipSlashCommands: true,

View File

@ -4031,6 +4031,7 @@ export function REPL({
doneOptions?: { doneOptions?: {
display?: CommandResultDisplay; display?: CommandResultDisplay;
metaMessages?: string[]; metaMessages?: string[];
displayArgs?: string;
}, },
): void => { ): void => {
doneWasCalled = true; doneWasCalled = true;
@ -4054,8 +4055,9 @@ export function REPL({
// doesn't change model context). Outside fullscreen the // doesn't change model context). Outside fullscreen the
// transcript entry stays so scrollback shows what ran. // transcript entry stays so scrollback shows what ran.
if (!isFullscreenEnvEnabled()) { if (!isFullscreenEnvEnabled()) {
const breadcrumbArgs = doneOptions?.displayArgs ?? commandArgs;
newMessages.push( newMessages.push(
createCommandInputMessage(formatCommandInputTags(getCommandName(matchingCommand), commandArgs)), createCommandInputMessage(formatCommandInputTags(getCommandName(matchingCommand), breadcrumbArgs)),
createCommandInputMessage( createCommandInputMessage(
`<${LOCAL_COMMAND_STDOUT_TAG}>${escapeXml(result)}</${LOCAL_COMMAND_STDOUT_TAG}>`, `<${LOCAL_COMMAND_STDOUT_TAG}>${escapeXml(result)}</${LOCAL_COMMAND_STDOUT_TAG}>`,
), ),
@ -5931,7 +5933,6 @@ export function REPL({
!hasRunningTeammates && !hasRunningTeammates &&
isBriefOnly && isBriefOnly &&
!viewedAgentTask && <BriefIdleStatus />} !viewedAgentTask && <BriefIdleStatus />}
{isFullscreenEnvEnabled() && <PromptInputQueuedCommands />}
</> </>
} }
bottom={ bottom={
@ -5944,6 +5945,7 @@ export function REPL({
<CompanionSprite /> <CompanionSprite />
) : null} ) : null}
<Box flexDirection="column" flexGrow={1}> <Box flexDirection="column" flexGrow={1}>
{isFullscreenEnvEnabled() && <PromptInputQueuedCommands />}
{permissionStickyFooter} {permissionStickyFooter}
{/* Immediate local-jsx commands (/btw, /sandbox, /assistant, {/* Immediate local-jsx commands (/btw, /sandbox, /assistant,
/issue) render here, NOT inside scrollable. They stay mounted /issue) render here, NOT inside scrollable. They stay mounted

View File

@ -297,11 +297,11 @@ export function ResumeConversation({
} }
} }
if (feature('GOAL') && log.goal && result.sessionId) { if (feature('GOAL') && result.goal && result.sessionId) {
const { hydrateGoalFromTranscript } = const { hydrateGoalFromTranscript } =
require('src/services/goal/goalStorage.js') as typeof import('src/services/goal/goalStorage.js'); require('src/services/goal/goalStorage.js') as typeof import('src/services/goal/goalStorage.js');
const goalsMap = new Map<UUID, import('src/types/logs.js').GoalState>(); const goalsMap = new Map<UUID, import('src/types/logs.js').GoalState>();
goalsMap.set(result.sessionId, log.goal); goalsMap.set(result.sessionId, result.goal);
hydrateGoalFromTranscript(goalsMap, result.sessionId); hydrateGoalFromTranscript(goalsMap, result.sessionId);
} }

View File

@ -122,6 +122,8 @@ export type LocalJSXCommandOnDone = (
metaMessages?: string[] metaMessages?: string[]
nextInput?: string nextInput?: string
submitNextInput?: boolean submitNextInput?: boolean
/** Override the args shown in the command breadcrumb (e.g. truncated). Full args still reach metaMessages. */
displayArgs?: string
}, },
) => void ) => void

View File

@ -507,6 +507,8 @@ export async function loadConversationForResume(
prRepository?: string prRepository?: string
// Full path to the session file (for cross-directory resume) // Full path to the session file (for cross-directory resume)
fullPath?: string fullPath?: string
// Goal state for hydration on resume
goal?: import('../types/logs.js').GoalState
} | null> { } | null> {
try { try {
let log: LogOption | null = null let log: LogOption | null = null
@ -618,6 +620,8 @@ export async function loadConversationForResume(
prRepository: log?.prRepository, prRepository: log?.prRepository,
// Include full path for cross-directory resume // Include full path for cross-directory resume
fullPath: log?.fullPath, fullPath: log?.fullPath,
// Goal state for hydration on resume
goal: log?.goal,
} }
} catch (error) { } catch (error) {
logError(error as Error) logError(error as Error)

View File

@ -740,6 +740,7 @@ async function getMessagesForSlashCommand(
metaMessages?: string[]; metaMessages?: string[];
nextInput?: string; nextInput?: string;
submitNextInput?: boolean; submitNextInput?: boolean;
displayArgs?: string;
}, },
) => { ) => {
doneWasCalled = true; doneWasCalled = true;
@ -773,20 +774,22 @@ async function getMessagesForSlashCommand(
const skipTranscript = const skipTranscript =
isFullscreenEnvEnabled() && typeof result === 'string' && result.endsWith(' dismissed'); isFullscreenEnvEnabled() && typeof result === 'string' && result.endsWith(' dismissed');
const breadcrumbArgs = options?.displayArgs ?? args;
void resolve({ void resolve({
messages: messages:
options?.display === 'system' options?.display === 'system'
? skipTranscript ? skipTranscript
? metaMessages ? metaMessages
: [ : [
createCommandInputMessage(formatCommandInput(command, args)), createCommandInputMessage(formatCommandInput(command, breadcrumbArgs)),
createCommandInputMessage(`<local-command-stdout>${result}</local-command-stdout>`), createCommandInputMessage(`<local-command-stdout>${result}</local-command-stdout>`),
...metaMessages, ...metaMessages,
] ]
: [ : [
createUserMessage({ createUserMessage({
content: prepareUserContent({ content: prepareUserContent({
inputString: formatCommandInput(command, args), inputString: formatCommandInput(command, breadcrumbArgs),
precedingInputBlocks, precedingInputBlocks,
}), }),
}), }),

View File

@ -312,6 +312,7 @@ type ResumeLoadResult = {
prNumber?: number prNumber?: number
prUrl?: string prUrl?: string
prRepository?: string prRepository?: string
goal?: import('../types/logs.js').GoalState
} }
/** /**
@ -471,6 +472,16 @@ export async function processResumedConversation(
opts.forkSession ? { ...result, worktreeSession: undefined } : result, opts.forkSession ? { ...result, worktreeSession: undefined } : result,
) )
if (feature('GOAL') && result.goal) {
const { hydrateGoalFromTranscript } =
// eslint-disable-next-line @typescript-eslint/no-require-imports
require('../services/goal/goalStorage.js') as typeof import('../services/goal/goalStorage.js')
const goalsMap = new Map<UUID, import('../types/logs.js').GoalState>()
const sid = (opts.sessionIdOverride ?? result.sessionId ?? getSessionId()) as UUID
goalsMap.set(sid, result.goal)
hydrateGoalFromTranscript(goalsMap, sid)
}
if (!opts.forkSession) { if (!opts.forkSession) {
// Cd back into the worktree the session was in when it last exited. // Cd back into the worktree the session was in when it last exited.
// Done after restoreSessionMetadata (which caches the worktree state // Done after restoreSessionMetadata (which caches the worktree state

View File

@ -3066,7 +3066,10 @@ export async function loadFullLog(log: LogOption): Promise<LogOption> {
} = await loadTranscriptFile(sessionFile) } = await loadTranscriptFile(sessionFile)
if (messages.size === 0) { if (messages.size === 0) {
return log const fallbackGoal = log.sessionId
? goals.get(log.sessionId as UUID)
: undefined
return fallbackGoal ? { ...log, goal: fallbackGoal } : log
} }
// Find the most recent user/assistant leaf message from the transcript // Find the most recent user/assistant leaf message from the transcript
@ -3077,7 +3080,10 @@ export async function loadFullLog(log: LogOption): Promise<LogOption> {
(msg.type === 'user' || msg.type === 'assistant'), (msg.type === 'user' || msg.type === 'assistant'),
) )
if (!mostRecentLeaf) { if (!mostRecentLeaf) {
return log const fallbackGoal = log.sessionId
? goals.get(log.sessionId as UUID)
: undefined
return fallbackGoal ? { ...log, goal: fallbackGoal } : log
} }
// Build the conversation chain from this leaf // Build the conversation chain from this leaf