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';
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 {
removeByFilter(
@ -174,6 +181,7 @@ export async function call(
onDone(summary, {
display: 'system',
shouldQuery: true,
displayArgs: truncateForDisplay(trimmed),
metaMessages: [`<goal-objective-updated>\n${trimmed}\n</goal-objective-updated>`],
});
return null;
@ -184,10 +192,12 @@ export async function call(
currentGoal={existing}
newObjective={trimmed}
onConfirm={() => {
drainGoalContinuationQueue();
const summary = applySetGoal(trimmed);
onDone(summary, {
display: 'system',
shouldQuery: true,
displayArgs: truncateForDisplay(trimmed),
metaMessages: [`<goal-objective-updated>\n${trimmed}\n</goal-objective-updated>`],
});
}}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -312,6 +312,7 @@ type ResumeLoadResult = {
prNumber?: number
prUrl?: string
prRepository?: string
goal?: import('../types/logs.js').GoalState
}
/**
@ -471,6 +472,16 @@ export async function processResumedConversation(
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) {
// Cd back into the worktree the session was in when it last exited.
// 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)
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
@ -3077,7 +3080,10 @@ export async function loadFullLog(log: LogOption): Promise<LogOption> {
(msg.type === 'user' || msg.type === 'assistant'),
)
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