fix: 增加断网后状态默认设置为PAUSE机制、完成暂停-恢复状态切换,且正常进行前端渲染。设置达到max turn后处理逻辑。

This commit is contained in:
moyu 2026-06-12 14:31:20 +08:00
parent bffb80e110
commit fde1370c80
8 changed files with 225 additions and 32 deletions

View File

@ -8,7 +8,8 @@
* `/goal status` -> alias of bare `/goal`
* `/goal clear` -> remove the active goal (persists tombstone)
* `/goal pause` -> pause auto-continuation
* `/goal resume` -> resume from paused/blocked/limited
* `/goal resume` -> resume from paused state
* `/goal continue` -> reset turn counter after max-turns and continue
* `/goal complete` -> mark complete (manual override; tools usually do this)
* `/goal <objective>` -> set a new goal; if one is already active and not
* complete, a confirmation dialog appears first.
@ -16,43 +17,62 @@
import * as React from 'react';
import type { LocalJSXCommandContext } from 'src/commands.js';
import type { LocalJSXCommandOnDone } from 'src/types/command.js';
import {
clearGoal,
completeGoal,
continueGoalFromMaxTurns,
formatGoalElapsed,
formatGoalStatusLabel,
getGoal,
incrementGoalTurns,
MAX_GOAL_TURNS,
pauseGoal,
resumeGoal,
setGoal,
} from 'src/services/goal/goalState.js';
import { persistCurrentGoal, persistGoalClear } from 'src/services/goal/goalStorage.js';
import type { LocalJSXCommandOnDone } from 'src/types/command.js';
import { removeByFilter } from 'src/utils/messageQueueManager.js';
import { GoalReplaceConfirmDialog } from './GoalReplaceConfirmDialog.js';
const MAX_OBJECTIVE_CHARS = 4000;
function drainGoalContinuationQueue(): void {
removeByFilter(
cmd =>
cmd.origin === 'goal-continuation' ||
cmd.origin === 'goal-budget-limit',
);
}
function formatGoalStatus(): string {
const goal = getGoal();
if (!goal) {
return 'No active goal. Set one with `/goal <objective>`.';
}
const tokens = goal.tokenBudget !== null ? `${goal.tokensUsed} / ${goal.tokenBudget}` : `${goal.tokensUsed}`;
return [
const lines = [
`Goal: ${goal.objective}`,
`Status: ${formatGoalStatusLabel(goal.status)}`,
`Time: ${formatGoalElapsed(goal)}`,
`Tokens: ${tokens}`,
`Continuation turns: ${goal.turnsExecuted}`,
].join('\n');
];
if (goal.status === 'max_turns') {
lines.push(
`Hint: Max continuation turns reached (${MAX_GOAL_TURNS}). Run \`/goal continue\` to reset and continue.`,
);
}
return lines.join('\n');
}
function applySetGoal(objective: string): string {
setGoal(objective);
incrementGoalTurns();
persistCurrentGoal();
return `Goal set: ${objective}\n\n${formatGoalStatus()}`;
return 'Goal set.';
}
export async function call(
@ -71,7 +91,10 @@ export async function call(
if (lower === 'clear') {
const cleared = clearGoal();
if (cleared) persistGoalClear();
if (cleared) {
persistGoalClear();
drainGoalContinuationQueue();
}
onDone(cleared ? 'Goal cleared.' : 'No active goal to clear.', {
display: 'system',
});
@ -80,7 +103,10 @@ export async function call(
if (lower === 'pause') {
const g = pauseGoal();
if (g) persistCurrentGoal();
if (g) {
persistCurrentGoal();
drainGoalContinuationQueue();
}
onDone(g ? 'Goal paused.' : 'No active goal to pause.', {
display: 'system',
});
@ -88,17 +114,44 @@ export async function call(
}
if (lower === 'resume') {
const current = getGoal();
if (current?.status === 'max_turns') {
onDone(
`Goal reached max continuation turns (${MAX_GOAL_TURNS}). Run \`/goal continue\` to reset turn counter and continue.`,
{ display: 'system' },
);
return null;
}
const g = resumeGoal();
if (g) persistCurrentGoal();
onDone(g ? 'Goal resumed.' : 'No paused goal to resume.', {
display: 'system',
shouldQuery: Boolean(g),
});
return null;
}
if (lower === 'continue') {
const g = continueGoalFromMaxTurns();
if (g) persistCurrentGoal();
onDone(
g
? `Goal continuation counter reset (0/${MAX_GOAL_TURNS}). Continuing...`
: 'Current goal is not in max-turns state.',
{
display: 'system',
shouldQuery: Boolean(g),
},
);
return null;
}
if (lower === 'complete') {
const g = completeGoal();
if (g) persistCurrentGoal();
if (g) {
persistCurrentGoal();
drainGoalContinuationQueue();
}
onDone(g ? 'Goal marked complete.' : 'No active goal to complete.', {
display: 'system',
});

View File

@ -284,14 +284,15 @@ export function addToTotalSessionCost(
const modelUsage = addToTotalModelUsage(cost, usage, model)
addToTotalCostState(cost, modelUsage, model)
if (feature('GOAL')) {
const { updateGoalTokens } =
const { getGoal, updateGoalTokens } =
require('./services/goal/goalState.js') as typeof import('./services/goal/goalState.js')
const totalDelta =
(usage.input_tokens ?? 0) +
(usage.output_tokens ?? 0) +
(usage.cache_read_input_tokens ?? 0) +
(usage.cache_creation_input_tokens ?? 0)
if (totalDelta > 0) {
const currentGoal = getGoal()
if (totalDelta > 0 && currentGoal?.status === 'active') {
const { logForDebugging: goalDbg } =
require('./utils/debug.js') as typeof import('./utils/debug.js')
goalDbg(

View File

@ -18,11 +18,16 @@
* 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.
*
* 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 { logForDebugging } from 'src/utils/debug.js'
import {
markGoalMaxTurnsReached,
getGoal,
incrementGoalTurns,
MAX_GOAL_TURNS,
@ -32,7 +37,7 @@ import {
buildBudgetLimitPrompt,
buildContinuationPrompt,
} from 'src/services/goal/prompts.js'
import { enqueue } from 'src/utils/messageQueueManager.js'
import { enqueue, getCommandQueueSnapshot } from 'src/utils/messageQueueManager.js'
function hookLog(msg: string): void {
logForDebugging(`[goal] hook: ${msg}`)
@ -44,9 +49,17 @@ export type UseGoalContinuationOpts = {
queuedCommandsLength: number
hasActiveLocalJsxUI: boolean
isInPlanMode: boolean
isQueryActiveNow?: () => boolean
onMaxTurnsReached?: () => void
onContinuationEnqueued?: (payload: {
turn: number
objective: string
}) => void
}
export function useGoalContinuation(opts: UseGoalContinuationOpts): void {
export function useGoalContinuation(
opts: UseGoalContinuationOpts,
): void {
const optsRef = useRef(opts)
optsRef.current = opts
@ -59,25 +72,29 @@ export function useGoalContinuation(opts: UseGoalContinuationOpts): void {
return
}
if (opts.wasAborted) {
hookLog('skip: wasAborted=true')
// Avoid stale-render races: queue processing can reserve QueryGuard in an
// earlier effect during the same commit. Read live state before deciding.
if (opts.isQueryActiveNow?.()) {
hookLog('skip: queryActiveNow=true')
return
}
// Codex parity: continuation only after normal completion.
// Aborted turns (Ctrl+C / Escape) must not trigger a new turn.
if (opts.wasAborted) { hookLog('skip: wasAborted=true'); return }
// Already enqueued for this idle window
if (enqueuedRef.current) return
if (opts.queuedCommandsLength > 0) {
hookLog('skip: queuedCommands=' + opts.queuedCommandsLength)
return
}
if (opts.hasActiveLocalJsxUI) {
hookLog('skip: activeLocalJsxUI')
return
}
if (opts.isInPlanMode) {
hookLog('skip: planMode')
// Blocking conditions
const liveQueueLength = getCommandQueueSnapshot().length
if (liveQueueLength > 0) {
hookLog('skip: liveQueuedCommands=' + liveQueueLength)
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 }
const goal = getGoal()
if (!goal) {
@ -88,13 +105,13 @@ export function useGoalContinuation(opts: UseGoalContinuationOpts): void {
budgetLimitFiredRef.current = false
}
// Budget-limited: inject one final steering prompt so the model
// knows to stop substantive work and summarise progress.
if (goal.status === 'budget_limited' && !budgetLimitFiredRef.current) {
budgetLimitFiredRef.current = true
enqueuedRef.current = true
const prompt = buildBudgetLimitPrompt(goal)
logForDebugging(
'[goal] hook: budget limit reached, injecting wrap-up prompt',
)
logForDebugging('[goal] hook: budget limit reached, injecting wrap-up prompt')
enqueue({
value: prompt,
mode: 'prompt',
@ -106,27 +123,32 @@ export function useGoalContinuation(opts: UseGoalContinuationOpts): void {
return
}
// Only continue for active goals
if (goal.status !== 'active') {
hookLog(`skip: status="${goal.status}" (not active)`)
return
}
if (goal.turnsExecuted >= MAX_GOAL_TURNS) {
const marked = markGoalMaxTurnsReached()
if (marked) {
persistCurrentGoal()
opts.onMaxTurnsReached?.()
}
logForDebugging(
`[goal] hook: MAX_GOAL_TURNS (${MAX_GOAL_TURNS}) reached, stopping`,
)
return
}
// All conditions met — enqueue a continuation turn
enqueuedRef.current = true
const turns = incrementGoalTurns()
persistCurrentGoal()
const prompt = buildContinuationPrompt(goal)
logForDebugging(
`[goal] hook: enqueuing turn ${turns} for "${goal.objective.slice(0, 60)}"`,
)
logForDebugging(`[goal] hook: enqueuing turn ${turns} for "${goal.objective.slice(0, 60)}"`)
enqueue({
value: prompt,
@ -136,6 +158,10 @@ export function useGoalContinuation(opts: UseGoalContinuationOpts): void {
origin: 'goal-continuation',
skipSlashCommands: true,
})
opts.onContinuationEnqueued?.({
turn: turns,
objective: goal.objective,
})
}, [
opts.isLoading,
opts.wasAborted,

View File

@ -3199,6 +3199,38 @@ export function REPL({
proactiveModule?.setContextBlocked(false);
}
}
// Auto-pause active /goal when the turn failed due to connectivity.
// Continuing immediately after network failures usually burns turns
// without progress and can rapidly hit max-turn guards.
if (feature('GOAL') && newMessage.type === 'assistant' && 'isApiErrorMessage' in newMessage && newMessage.isApiErrorMessage) {
const assistantText =
getContentText((newMessage.message?.content ?? '') as string | ContentBlockParam[]) ?? '';
const lowerText = assistantText.toLowerCase();
const isConnectivityFailure =
lowerText.includes('connection error') ||
lowerText.includes('fetch failed') ||
lowerText.includes('network error') ||
lowerText.includes('enotfound') ||
lowerText.includes('econnreset') ||
lowerText.includes('etimedout');
if (isConnectivityFailure) {
const { getGoal, pauseGoal } =
require('../services/goal/goalState.js') as typeof import('../services/goal/goalState.js');
const { persistCurrentGoal } =
require('../services/goal/goalStorage.js') as typeof import('../services/goal/goalStorage.js');
const currentGoal = getGoal();
if (currentGoal?.status === 'active') {
pauseGoal();
persistCurrentGoal();
addNotification({
key: 'goal-auto-paused-connectivity-error',
text: 'Detected connection error. Active goal was auto-paused. Run /goal resume after network recovers.',
priority: 'immediate',
});
}
}
}
// Relay assistant response to master when in slave mode.
if (feature('UDS_INBOX') && newMessage.type === 'assistant') {
// Extract text from content blocks (API format)
@ -5062,6 +5094,24 @@ export function REPL({
queuedCommandsLength: queuedCommands.length,
hasActiveLocalJsxUI: isShowingLocalJSXCommand,
isInPlanMode: toolPermissionContext.mode === 'plan',
isQueryActiveNow: queryGuard.getSnapshot,
onContinuationEnqueued: ({ turn, objective }) => {
const visibleGoalTurnInput = `Goal auto-continue (${turn}/1): continue advancing "${objective}".`;
setMessages(oldMessages => [
...oldMessages,
createUserMessage({
content: visibleGoalTurnInput,
isVisibleInTranscriptOnly: true,
}),
]);
},
onMaxTurnsReached: () => {
addNotification({
key: 'goal-max-turns-reached',
text: 'Goal reached max continuation turns (1). Run /goal continue to reset turn counter and continue.',
priority: 'immediate',
});
},
});
useEffect(() => {

View File

@ -13,6 +13,7 @@ mock.module('src/utils/log.ts', logMock)
import {
_clearAllGoalsForTesting,
BLOCKED_CONSECUTIVE_THRESHOLD,
continueGoalFromMaxTurns,
clearGoal,
completeGoal,
formatGoalElapsed,
@ -21,6 +22,8 @@ import {
getGoal,
incrementGoalTurns,
markUsageLimited,
markGoalMaxTurnsReached,
MAX_GOAL_TURNS,
pauseGoal,
recordBlockedAttempt,
resumeGoal,
@ -229,6 +232,26 @@ describe('incrementGoalTurns', () => {
})
})
describe('max_turns lifecycle', () => {
test('markGoalMaxTurnsReached flips active goal once cap is reached', () => {
setGoal('x', { sessionId: SESSION })
const goal = getGoal(SESSION)!
goal.turnsExecuted = MAX_GOAL_TURNS
const marked = markGoalMaxTurnsReached(SESSION)
expect(marked?.status).toBe('max_turns')
})
test('continueGoalFromMaxTurns resets turns and re-activates goal', () => {
setGoal('x', { sessionId: SESSION })
const goal = getGoal(SESSION)!
goal.turnsExecuted = MAX_GOAL_TURNS
markGoalMaxTurnsReached(SESSION)
const resumed = continueGoalFromMaxTurns(SESSION)
expect(resumed?.status).toBe('active')
expect(resumed?.turnsExecuted).toBe(0)
})
})
describe('formatGoalStatusLabel', () => {
test('returns human-readable labels', () => {
expect(formatGoalStatusLabel('active')).toBe('Active')
@ -236,6 +259,7 @@ describe('formatGoalStatusLabel', () => {
expect(formatGoalStatusLabel('blocked')).toBe('Blocked')
expect(formatGoalStatusLabel('budget_limited')).toBe('Budget Limited')
expect(formatGoalStatusLabel('usage_limited')).toBe('Usage Limited')
expect(formatGoalStatusLabel('max_turns')).toBe('Max Turns Reached')
expect(formatGoalStatusLabel('complete')).toBe('Complete')
})
})

View File

@ -27,6 +27,7 @@ export function isGoalTerminal(status: GoalStatus): boolean {
status === 'complete' ||
status === 'blocked' ||
status === 'budget_limited' ||
status === 'usage_limited'
status === 'usage_limited' ||
status === 'max_turns'
)
}

View File

@ -10,7 +10,7 @@ import { getSessionId } from '../../bootstrap/state.js'
import { logForDebugging } from '../../utils/debug.js'
export const BLOCKED_CONSECUTIVE_THRESHOLD = 3
export const MAX_GOAL_TURNS = 50
export const MAX_GOAL_TURNS = 150
const goals = new Map<string, GoalState>()
@ -105,6 +105,40 @@ export function resumeGoal(sessionId?: string): GoalState | null {
return goal
}
/**
* Transition an active goal into max_turns once continuation cap is hit.
* Idempotent: repeated calls while already max_turns are no-ops.
*/
export function markGoalMaxTurnsReached(sessionId?: string): GoalState | null {
const goal = getGoal(sessionId)
if (!goal || goal.status !== 'active') return null
if (goal.turnsExecuted < MAX_GOAL_TURNS) return null
goal.status = 'max_turns'
goal.updatedAt = Date.now()
goalLog('MAX_TURNS', `reached ${MAX_GOAL_TURNS} turns`)
return goal
}
/**
* Reset continuation turn counter after a max_turns stop and resume work.
* This is a deliberate user action (`/goal continue`) to prevent silent
* runaway loops.
*/
export function continueGoalFromMaxTurns(sessionId?: string): GoalState | null {
const goal = getGoal(sessionId)
if (!goal || goal.status !== 'max_turns') return null
const now = Date.now()
goal.turnsExecuted = 0
goal.status = 'active'
goal.startTime = now
goal.pausedAt = null
goal.blockedAttempts = 0
goal.lastBlockReason = null
goal.updatedAt = now
goalLog('CONTINUE', `turn counter reset, status active (max=${MAX_GOAL_TURNS})`)
return goal
}
export function completeGoal(sessionId?: string): GoalState | null {
const id = resolveSessionId(sessionId)
const goal = goals.get(id)
@ -250,6 +284,8 @@ export function formatGoalStatusLabel(status: GoalStatus): string {
return 'Budget Limited'
case 'usage_limited':
return 'Usage Limited'
case 'max_turns':
return 'Max Turns Reached'
case 'complete':
return 'Complete'
}

View File

@ -148,6 +148,7 @@ export type ModeEntry = {
* - blocked: model reported the same blocker for >=3 consecutive turns
* - budget_limited: tokensUsed >= tokenBudget (auto-transition)
* - usage_limited: provider rate/usage limit triggered (auto-transition)
* - max_turns: auto-continuation reached MAX_GOAL_TURNS safety cap
* - complete: model audit confirmed objective achieved
*/
export type GoalStatus =
@ -156,6 +157,7 @@ export type GoalStatus =
| 'blocked'
| 'budget_limited'
| 'usage_limited'
| 'max_turns'
| 'complete'
/**