fix: 增加断网后状态默认设置为PAUSE机制、完成暂停-恢复状态切换,且正常进行前端渲染。设置达到max turn后处理逻辑。
This commit is contained in:
parent
bffb80e110
commit
fde1370c80
|
|
@ -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',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user