fix: 修复终端异常断开情况,resume续跑;修复用户消息排队信息被goal输出信息覆盖的问题。
This commit is contained in:
parent
fde1370c80
commit
17eac385c8
|
|
@ -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>`],
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
* idle→enqueue→process→query→idle cycle is self-sustaining.
|
* idle→enqueue→process→query→idle 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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user