- prompt 中 update 行为描述与运行时不一致(no-op → error) - src/commands/goal/ 使用相对路径导入,改为 src/* 别名 - /goal 命令标记 bridgeSafe 但含交互式对话框,改为 false - useGoalContinuation 中 origin 使用 as unknown as string 强转,改为直接传字符串 - ResumeConversation 路径缺少 goal hydration,补齐恢复逻辑 - onCancel 在非查询状态下误暂停 goal,加 queryGuard 守卫 - resumeGoal 允许从终态恢复,收紧为仅允许 paused 状态 - buildGoalContextBlock 生成畸形 XML 属性,改为合法 budget 属性
257 lines
7.1 KiB
TypeScript
257 lines
7.1 KiB
TypeScript
/**
|
|
* Per-session goal state machine. Pure in-memory management — no FS,
|
|
* no network. Persistence is handled by goalStorage.ts.
|
|
*
|
|
* Uses Map<string, GoalState> keyed by sessionId so concurrent
|
|
* sub-sessions (agents, worktrees) don't leak into each other.
|
|
*/
|
|
import type { GoalState, GoalStatus } from '../../types/logs.js'
|
|
import { getSessionId } from '../../bootstrap/state.js'
|
|
import { logForDebugging } from '../../utils/debug.js'
|
|
|
|
export const BLOCKED_CONSECUTIVE_THRESHOLD = 3
|
|
export const MAX_GOAL_TURNS = 50
|
|
|
|
const goals = new Map<string, GoalState>()
|
|
|
|
function goalLog(
|
|
tag: string,
|
|
msg: string,
|
|
extra?: Record<string, unknown>,
|
|
): void {
|
|
const suffix = extra ? ` ${JSON.stringify(extra)}` : ''
|
|
logForDebugging(`[goal] ${tag}: ${msg}${suffix}`)
|
|
}
|
|
|
|
function resolveSessionId(sessionId?: string): string {
|
|
return sessionId ?? getSessionId()
|
|
}
|
|
|
|
export function setGoal(
|
|
objective: string,
|
|
options?: { tokenBudget?: number; sessionId?: string },
|
|
): GoalState {
|
|
const id = resolveSessionId(options?.sessionId)
|
|
const budget =
|
|
options?.tokenBudget !== undefined &&
|
|
Number.isFinite(options.tokenBudget) &&
|
|
options.tokenBudget > 0
|
|
? options.tokenBudget
|
|
: null
|
|
const now = Date.now()
|
|
const state: GoalState = {
|
|
objective,
|
|
status: 'active',
|
|
tokenBudget: budget,
|
|
tokensUsed: 0,
|
|
startTime: now,
|
|
pausedAt: null,
|
|
accumulatedActiveMs: 0,
|
|
blockedAttempts: 0,
|
|
lastBlockReason: null,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
turnsExecuted: 0,
|
|
}
|
|
goals.set(id, state)
|
|
goalLog('SET', `objective="${objective.slice(0, 80)}"`, {
|
|
tokenBudget: state.tokenBudget,
|
|
})
|
|
return state
|
|
}
|
|
|
|
export function getGoal(sessionId?: string): GoalState | null {
|
|
return goals.get(resolveSessionId(sessionId)) ?? null
|
|
}
|
|
|
|
export function clearGoal(sessionId?: string): boolean {
|
|
const had = goals.has(resolveSessionId(sessionId))
|
|
const result = goals.delete(resolveSessionId(sessionId))
|
|
if (had) goalLog('CLEAR', 'goal removed')
|
|
return result
|
|
}
|
|
|
|
export function pauseGoal(sessionId?: string): GoalState | null {
|
|
const id = resolveSessionId(sessionId)
|
|
const goal = goals.get(id)
|
|
if (!goal || goal.status !== 'active') return null
|
|
const now = Date.now()
|
|
goal.accumulatedActiveMs += now - goal.startTime
|
|
goal.pausedAt = now
|
|
goal.status = 'paused'
|
|
goal.updatedAt = now
|
|
goalLog(
|
|
'PAUSE',
|
|
`paused after ${Math.round(goal.accumulatedActiveMs / 1000)}s active`,
|
|
)
|
|
return goal
|
|
}
|
|
|
|
export function resumeGoal(sessionId?: string): GoalState | null {
|
|
const id = resolveSessionId(sessionId)
|
|
const goal = goals.get(id)
|
|
if (!goal) return null
|
|
if (goal.status !== 'paused') {
|
|
return null
|
|
}
|
|
const now = Date.now()
|
|
goal.startTime = now
|
|
goal.pausedAt = null
|
|
goal.status = 'active'
|
|
goal.updatedAt = now
|
|
goalLog('RESUME', 'goal resumed, blockedAttempts reset')
|
|
goal.blockedAttempts = 0
|
|
goal.lastBlockReason = null
|
|
return goal
|
|
}
|
|
|
|
export function completeGoal(sessionId?: string): GoalState | null {
|
|
const id = resolveSessionId(sessionId)
|
|
const goal = goals.get(id)
|
|
if (!goal) return null
|
|
const now = Date.now()
|
|
if (goal.status === 'active' && goal.pausedAt === null) {
|
|
goal.accumulatedActiveMs += now - goal.startTime
|
|
}
|
|
goal.status = 'complete'
|
|
goal.updatedAt = now
|
|
goalLog('COMPLETE', `goal achieved`, {
|
|
tokensUsed: goal.tokensUsed,
|
|
turns: goal.turnsExecuted,
|
|
})
|
|
return goal
|
|
}
|
|
|
|
export function updateGoalTokens(
|
|
delta: number,
|
|
sessionId?: string,
|
|
): GoalState | null {
|
|
const id = resolveSessionId(sessionId)
|
|
const goal = goals.get(id)
|
|
if (!goal) return null
|
|
if (goal.status !== 'active') return null
|
|
if (!Number.isFinite(delta) || delta <= 0) return goal
|
|
const sanitized = delta
|
|
goal.tokensUsed += sanitized
|
|
goal.updatedAt = Date.now()
|
|
if (goal.tokenBudget !== null && goal.tokensUsed >= goal.tokenBudget) {
|
|
goal.status = 'budget_limited'
|
|
goalLog(
|
|
'BUDGET_LIMITED',
|
|
`tokens ${goal.tokensUsed} >= budget ${goal.tokenBudget}`,
|
|
)
|
|
} else if (sanitized > 0) {
|
|
goalLog(
|
|
'TOKENS',
|
|
`+${sanitized} → total ${goal.tokensUsed}${goal.tokenBudget ? `/${goal.tokenBudget}` : ''}`,
|
|
)
|
|
}
|
|
return goal
|
|
}
|
|
|
|
export function markUsageLimited(sessionId?: string): GoalState | null {
|
|
const id = resolveSessionId(sessionId)
|
|
const goal = goals.get(id)
|
|
if (!goal || goal.status !== 'active') return null
|
|
goal.status = 'usage_limited'
|
|
goal.updatedAt = Date.now()
|
|
return goal
|
|
}
|
|
|
|
export function incrementGoalTurns(sessionId?: string): number {
|
|
const id = resolveSessionId(sessionId)
|
|
const goal = goals.get(id)
|
|
if (!goal) return 0
|
|
goal.turnsExecuted += 1
|
|
goal.updatedAt = Date.now()
|
|
goalLog('TURN', `#${goal.turnsExecuted}/${MAX_GOAL_TURNS}`, {
|
|
status: goal.status,
|
|
tokensUsed: goal.tokensUsed,
|
|
})
|
|
return goal.turnsExecuted
|
|
}
|
|
|
|
export function recordBlockedAttempt(
|
|
reason: string,
|
|
sessionId?: string,
|
|
): { status: GoalStatus; attempts: number } | null {
|
|
const id = resolveSessionId(sessionId)
|
|
const goal = goals.get(id)
|
|
if (!goal || goal.status !== 'active') return null
|
|
const normalised = reason.trim().toLowerCase()
|
|
if (
|
|
goal.lastBlockReason !== null &&
|
|
goal.lastBlockReason.trim().toLowerCase() !== normalised
|
|
) {
|
|
goal.blockedAttempts = 0
|
|
}
|
|
goal.lastBlockReason = reason
|
|
goal.blockedAttempts += 1
|
|
goal.updatedAt = Date.now()
|
|
if (goal.blockedAttempts >= BLOCKED_CONSECUTIVE_THRESHOLD) {
|
|
goal.status = 'blocked'
|
|
goalLog('BLOCKED', `3-strike reached! reason="${normalised}"`)
|
|
} else {
|
|
goalLog(
|
|
'BLOCK_ATTEMPT',
|
|
`attempt ${goal.blockedAttempts}/${BLOCKED_CONSECUTIVE_THRESHOLD} reason="${normalised}"`,
|
|
)
|
|
}
|
|
return { status: goal.status, attempts: goal.blockedAttempts }
|
|
}
|
|
|
|
/**
|
|
* Wall-clock time the goal has been actively worked on (excludes
|
|
* paused intervals). Used by status displays and completion reports.
|
|
*/
|
|
export function getActiveElapsedMs(goal: GoalState): number {
|
|
const ongoing =
|
|
goal.status === 'active' && goal.pausedAt === null
|
|
? Date.now() - goal.startTime
|
|
: 0
|
|
return goal.accumulatedActiveMs + ongoing
|
|
}
|
|
|
|
/** Test-only: wipe the in-memory map without touching disk. */
|
|
export function _clearAllGoalsForTesting(): void {
|
|
goals.clear()
|
|
}
|
|
|
|
/**
|
|
* Test/internal: hydrate the in-memory map from persisted state.
|
|
* Called by goalStorage on session resume.
|
|
*/
|
|
export function _setGoalFromPersistedState(
|
|
state: GoalState,
|
|
sessionId?: string,
|
|
): void {
|
|
goals.set(resolveSessionId(sessionId), state)
|
|
}
|
|
|
|
/** Format the elapsed time as "Xm Ys" / "Ys" for UI display. */
|
|
export function formatGoalElapsed(goal: GoalState): string {
|
|
const elapsedMs = getActiveElapsedMs(goal)
|
|
const seconds = Math.floor(elapsedMs / 1000)
|
|
const minutes = Math.floor(seconds / 60)
|
|
if (minutes === 0) return `${seconds}s`
|
|
return `${minutes}m ${seconds % 60}s`
|
|
}
|
|
|
|
/** Human-readable status label for UI. */
|
|
export function formatGoalStatusLabel(status: GoalStatus): string {
|
|
switch (status) {
|
|
case 'active':
|
|
return 'Active'
|
|
case 'paused':
|
|
return 'Paused'
|
|
case 'blocked':
|
|
return 'Blocked'
|
|
case 'budget_limited':
|
|
return 'Budget Limited'
|
|
case 'usage_limited':
|
|
return 'Usage Limited'
|
|
case 'complete':
|
|
return 'Complete'
|
|
}
|
|
}
|