From fde1370c80e64f0567503b04837022a95c0d5fc8 Mon Sep 17 00:00:00 2001 From: moyu Date: Fri, 12 Jun 2026 14:31:20 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=A2=9E=E5=8A=A0=E6=96=AD=E7=BD=91?= =?UTF-8?q?=E5=90=8E=E7=8A=B6=E6=80=81=E9=BB=98=E8=AE=A4=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E4=B8=BAPAUSE=E6=9C=BA=E5=88=B6=E3=80=81=E5=AE=8C=E6=88=90?= =?UTF-8?q?=E6=9A=82=E5=81=9C-=E6=81=A2=E5=A4=8D=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E5=88=87=E6=8D=A2=EF=BC=8C=E4=B8=94=E6=AD=A3=E5=B8=B8=E8=BF=9B?= =?UTF-8?q?=E8=A1=8C=E5=89=8D=E7=AB=AF=E6=B8=B2=E6=9F=93=E3=80=82=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E8=BE=BE=E5=88=B0max=20turn=E5=90=8E=E5=A4=84?= =?UTF-8?q?=E7=90=86=E9=80=BB=E8=BE=91=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/commands/goal/goal.tsx | 69 ++++++++++++++++--- src/cost-tracker.ts | 5 +- src/hooks/useGoalContinuation.ts | 66 ++++++++++++------ src/screens/REPL.tsx | 50 ++++++++++++++ src/services/goal/__tests__/goalState.test.ts | 24 +++++++ src/services/goal/goalAudit.ts | 3 +- src/services/goal/goalState.ts | 38 +++++++++- src/types/logs.ts | 2 + 8 files changed, 225 insertions(+), 32 deletions(-) diff --git a/src/commands/goal/goal.tsx b/src/commands/goal/goal.tsx index 34d1db573..bc3fdb819 100644 --- a/src/commands/goal/goal.tsx +++ b/src/commands/goal/goal.tsx @@ -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 ` -> 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 `.'; } 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', }); diff --git a/src/cost-tracker.ts b/src/cost-tracker.ts index 24d5b5451..2eb98dc3a 100644 --- a/src/cost-tracker.ts +++ b/src/cost-tracker.ts @@ -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( diff --git a/src/hooks/useGoalContinuation.ts b/src/hooks/useGoalContinuation.ts index 621e187bb..10a5b03b4 100644 --- a/src/hooks/useGoalContinuation.ts +++ b/src/hooks/useGoalContinuation.ts @@ -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 + * idle→enqueue→process→query→idle 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, diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index 7592e3d3f..607a58aab 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -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(() => { diff --git a/src/services/goal/__tests__/goalState.test.ts b/src/services/goal/__tests__/goalState.test.ts index 282706fe8..8f7eca302 100644 --- a/src/services/goal/__tests__/goalState.test.ts +++ b/src/services/goal/__tests__/goalState.test.ts @@ -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') }) }) diff --git a/src/services/goal/goalAudit.ts b/src/services/goal/goalAudit.ts index 2f6690c95..4181163f3 100644 --- a/src/services/goal/goalAudit.ts +++ b/src/services/goal/goalAudit.ts @@ -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' ) } diff --git a/src/services/goal/goalState.ts b/src/services/goal/goalState.ts index fe17b7ffa..1bc21401d 100644 --- a/src/services/goal/goalState.ts +++ b/src/services/goal/goalState.ts @@ -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() @@ -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' } diff --git a/src/types/logs.ts b/src/types/logs.ts index 04bfc3461..eaf665cc5 100644 --- a/src/types/logs.ts +++ b/src/types/logs.ts @@ -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' /**