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';
|
||||
|
||||
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>`],
|
||||
});
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
* 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 {
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user