From e4578ef7cfaaca55a66f91f4b41a1dd219ca05e5 Mon Sep 17 00:00:00 2001 From: Daniel Saldarriaga Date: Tue, 22 Sep 2026 08:33:22 +0200 Subject: [PATCH] fix: reset auto-turn limit on explicit resume --- README.md | 2 +- dist/server.js | 91 ++++++++++++++++++++++++++++--------- src/prompts.ts | 4 +- src/server.ts | 94 ++++++++++++++++++++++++++++++++------- src/state.ts | 16 ++++++- test/prompts-i18n.test.ts | 1 + test/server-v2.test.ts | 28 ++++++++++++ test/server.test.ts | 52 ++++++++++++++++++++++ test/state.test.ts | 29 ++++++++++++ 9 files changed, 275 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 0f3fd29..c38476c 100644 --- a/README.md +++ b/README.md @@ -178,7 +178,7 @@ Defaults: - `auto_continue`: `true` - `defer_while_tasks_active`: `true`; when enabled, goal auto-continuation waits for active OpenCode Task child sessions and their orchestrator reconciliation before sending the next goal prompt. A deferral re-checks child sessions on a short timer, so a goal deferred by a task never depends on a further idle event to resume. - `max_task_block_seconds`: `900`; wall-clock ceiling on how long a single Task child session may defer goal continuation. A child that stays listed but never reports a terminal state, or a terminal child whose result is never reconciled, stops blocking once the ceiling passes. Set a smaller value for shorter subagents, `0` to remove the ceiling, or disable deferral entirely with `defer_while_tasks_active: false`. -- `max_auto_turns`: `25` +- `max_auto_turns`: `25`; explicitly resuming a goal with `/goal resume` or `/resume_goal` after it reaches this limit starts a fresh auto-turn window. Token usage and elapsed-time usage are preserved. - `min_continue_interval_seconds`: `3` - Fast V2 executions that finish inside this interval schedule a delayed continuation; they do not require another user message to wake up. - `max_turn_time`: unset by default; set a positive number of seconds to retry one active-goal continuation prompt when a model turn remains busy for that long. Each new busy event resets the watchdog. Idle, built-in retry, session deletion, active Task children, and restricted agents suppress the retry. Watchdog retries are independent of `min_continue_interval_seconds` and never consume auto-turn or no-progress budgets, but recognized transport failures still count toward the `max_prompt_failures` ceiling. diff --git a/dist/server.js b/dist/server.js index c3f1ecd..dcc8110 100644 --- a/dist/server.js +++ b/dist/server.js @@ -132,6 +132,7 @@ var MAX_LISTED_GOALS = 50; var CHECKPOINT_CHAR_LIMIT = 280; var DEFAULT_NO_PROGRESS_TOKEN_THRESHOLD = 50; var DEFAULT_MAX_NO_PROGRESS_TURNS = 2; +var MAX_AUTO_CONTINUES_STOP_REASON_PREFIX = "max auto-continues reached ("; var PLAN_MODE_STOP_REASON = "plan mode"; var PLAN_MODE_BLOCKER = "Goal execution is paused while the session is in Plan mode. Switch to Build mode and resume the goal to continue."; var NullableString = Schema.NullOr(Schema.String); @@ -689,7 +690,7 @@ async function pauseGoalForPlanMode(sessionID) { return snapshot(goal); }); } -async function setGoalStatus(sessionID, status, agent) { +async function setGoalStatus(sessionID, status, agent, options) { const agentValue = typeof agent === "string" && agent.trim() ? agent.trim() : null; return mutate((state) => { const goal = state.goals[sessionID]; @@ -701,10 +702,12 @@ async function setGoalStatus(sessionID, status, agent) { return snapshot(goal); if (status === "paused" && goal.status !== "active") return snapshot(goal); + const resumesAutoTurnLimit = options?.resetAutoTurnLimit === true && status === "active" && goal.status === "usageLimited" && goal.stopReason?.startsWith(MAX_AUTO_CONTINUES_STOP_REASON_PREFIX) === true; accountWallClock(goal); goal.status = status; goal.updatedAt = nowSeconds(); goal.lastAccountedAt = status === "active" ? goal.updatedAt : null; + goal.autoTurns = resumesAutoTurnLimit ? 0 : goal.autoTurns; goal.continuationFailures = status === "active" ? 0 : goal.continuationFailures; goal.pendingAttempt = status === "active" ? null : goal.pendingAttempt; goal.noProgressTurns = status === "active" ? 0 : goal.noProgressTurns; @@ -1053,7 +1056,7 @@ function maybeStopForUsageLimit(goal, defaultMaxAutoTurns, now = nowSeconds()) { if (effectiveMaxAutoTurns > 0 && goal.autoTurns >= effectiveMaxAutoTurns) { goal.status = "usageLimited"; goal.lastAccountedAt = null; - goal.stopReason = `max auto-continues reached (${effectiveMaxAutoTurns})`; + goal.stopReason = `${MAX_AUTO_CONTINUES_STOP_REASON_PREFIX}${effectiveMaxAutoTurns})`; goal.lastStatus = `${goal.stopReason}; wrap-up required.`; pushHistory(goal, "limited", goal.lastStatus); return true; @@ -1617,7 +1620,7 @@ ${budgetLines(goal, locale)} \u72B6\u6001\uFF1A${presentGoalStatus(goal.status, locale)} \u505C\u6B62\u539F\u56E0\uFF1A${presentGoalStopReason(goal.stopReason ?? "goal limit reached", locale)} -\u4E0D\u8981\u4E3A\u6B64\u76EE\u6807\u5F00\u59CB\u65B0\u7684\u5B9E\u8D28\u6027\u5DE5\u4F5C\u3002\u5C3D\u5FEB\u7ED3\u675F\u672C\u8F6E\uFF1A\u4F7F\u7528\u7B80\u4F53\u4E2D\u6587\u603B\u7ED3\u6709\u6548\u8FDB\u5C55\uFF0C\u6307\u51FA\u5269\u4F59\u5DE5\u4F5C\u6216\u963B\u585E\u9879\uFF0C\u5E76\u7ED9\u7528\u6237\u4E00\u4E2A\u6E05\u6670\u7684\u4E0B\u4E00\u6B65\u3002\u9664\u975E\u76EE\u6807\u786E\u5B9E\u5DF2\u7ECF\u5B8C\u6210\uFF0C\u5426\u5219\u4E0D\u8981\u8C03\u7528 update_goal\u3002`; +\u4E0D\u8981\u4E3A\u6B64\u76EE\u6807\u5F00\u59CB\u65B0\u7684\u5B9E\u8D28\u6027\u5DE5\u4F5C\u3002\u4E0D\u8981\u8C03\u7528 update_goal_status \u6765\u7EE7\u7EED\u76EE\u6807\uFF1B\u53EA\u6709\u7528\u6237\u660E\u786E\u53D1\u51FA\u7EE7\u7EED\u547D\u4EE4\u540E\u624D\u80FD\u7EE7\u7EED\u3002\u5C3D\u5FEB\u7ED3\u675F\u672C\u8F6E\uFF1A\u4F7F\u7528\u7B80\u4F53\u4E2D\u6587\u603B\u7ED3\u6709\u6548\u8FDB\u5C55\uFF0C\u6307\u51FA\u5269\u4F59\u5DE5\u4F5C\u6216\u963B\u585E\u9879\uFF0C\u5E76\u7ED9\u7528\u6237\u4E00\u4E2A\u6E05\u6670\u7684\u4E0B\u4E00\u6B65\u3002\u9664\u975E\u76EE\u6807\u786E\u5B9E\u5DF2\u7ECF\u5B8C\u6210\uFF0C\u5426\u5219\u4E0D\u8981\u8C03\u7528 update_goal\u3002`; } return `The active session goal has reached a safety limit. @@ -1633,7 +1636,7 @@ ${budgetLines(goal, locale)} Status: ${goal.status} Stop reason: ${goal.stopReason ?? "goal limit reached"} -Do not start new substantive work for this goal. Wrap up this turn soon: summarize useful progress, identify remaining work or blockers, and leave the user with a clear next step. Do not call update_goal unless the goal is actually complete.`; +Do not start new substantive work for this goal. Do not call update_goal_status to resume it; only an explicit user resume command may continue the goal. Wrap up this turn soon: summarize useful progress, identify remaining work or blockers, and leave the user with a clear next step. Do not call update_goal unless the goal is actually complete.`; } function systemReminder(locale = "en") { if (locale === "zh-CN") { @@ -1890,6 +1893,10 @@ Ignore any command arguments. Call get_goal first, then handle only this resume Do not create, edit, clear, complete, or mark a goal unmet.`; } +function isExplicitResumePrompt(text, commandName, locale, messages) { + const value = text.trim(); + return value === goalStatusCommandTemplate("resume_goal", locale) || value === goalCommandTemplate(commandName, locale).replace("$ARGUMENTS", "resume") || value === messages.tui.resumePrompt; +} function goalCommandDefinitions(commandName, locale = "en") { const messages = messagesFor(locale); return [ @@ -2662,10 +2669,11 @@ async function closeGoalFromTool(input, context, services) { return JSON.stringify({ goal, unmet_report: report }, null, 2); } async function updateGoalStatusFromTool(input, context, services) { + const resetAutoTurnLimit = input.status === "active" && services.consumeAutoTurnReset(context.sessionID); if (input.status === "active" && services.isPlanAgent(context.agent)) { throw new Error(services.messages.notices.cannotResumeInPlan); } - const goal = await setGoalStatus(context.sessionID, input.status, typeof context.agent === "string" ? context.agent : null); + const goal = await setGoalStatus(context.sessionID, input.status, typeof context.agent === "string" ? context.agent : null, { resetAutoTurnLimit }); return JSON.stringify({ goal }, null, 2); } function v2ObjectSchema(properties, required = []) { @@ -2737,10 +2745,18 @@ var server = async ({ client }, options) => { const nativeRetrySessions = new Set; const locallyDeliveredPendingSessions = new Set; const toolAttempts = new Map; + const explicitResumeRequests = new Set; const watchdogRescuedSessions = new Set; const planAgents = restrictedAgentSet(options); const isPlanAgent = (agent) => typeof agent === "string" && planAgents.has(agent.trim().toLowerCase()); - const goalServices = { options: options ?? {}, locale, messages, isPlanAgent, maxObjectiveChars: objectiveChars }; + const goalServices = { + options: options ?? {}, + locale, + messages, + isPlanAgent, + maxObjectiveChars: objectiveChars, + consumeAutoTurnReset: (sessionID) => explicitResumeRequests.delete(sessionID) + }; const stopStateRecoveryReporting = onStateRecovery(statePath(), async ({ stateFile, quarantineFile, outcome, error }) => { await client.app?.log?.({ body: { @@ -3014,6 +3030,7 @@ var server = async ({ client }, options) => { locallyDeliveredPendingSessions.clear(); nativeRetrySessions.clear(); toolAttempts.clear(); + explicitResumeRequests.clear(); }, async config(config) { if (!registerCommand) @@ -3116,7 +3133,10 @@ var server = async ({ client }, options) => { }, async "command.execute.before"(input, output) { if (input.command === commandName) { - escapeGoalCommandArguments(output, goalCommandTemplate(commandName, locale), input.arguments); + const sanitized = escapeGoalCommandArguments(output, goalCommandTemplate(commandName, locale), input.arguments); + if (sanitized && input.arguments.trim().toLowerCase() === "resume") { + explicitResumeRequests.add(input.sessionID); + } return; } if (input.command !== "pause_goal" && input.command !== "resume_goal") @@ -3124,6 +3144,8 @@ var server = async ({ client }, options) => { const template = goalStatusCommandTemplate(input.command, locale); if (!sanitizeGoalStatusCommandParts(output, template)) return; + if (input.command === "resume_goal") + explicitResumeRequests.add(input.sessionID); if (input.command !== "pause_goal") return; const goal = await getGoal(input.sessionID); @@ -3164,7 +3186,13 @@ var server = async ({ client }, options) => { async "chat.message"(input, output) { const sessionID = typeof input?.sessionID === "string" ? input.sessionID : output.message?.sessionID; const agent = typeof input?.agent === "string" && input.agent.trim() ? input.agent : output.message?.agent; - if (typeof sessionID !== "string" || typeof agent !== "string" || !agent.trim()) + if (typeof sessionID !== "string") + return; + explicitResumeRequests.delete(sessionID); + if (output.parts?.some((part) => isExplicitResumePrompt(textFromPart(part), commandName, locale, messages))) { + explicitResumeRequests.add(sessionID); + } + if (typeof agent !== "string" || !agent.trim()) return; await recordPromptAgent(sessionID, agent); }, @@ -3215,6 +3243,7 @@ var server = async ({ client }, options) => { if (status.type === "busy") await markPendingContinuationStarted(sessionID); if (status.type === "idle") { + explicitResumeRequests.delete(sessionID); busySessions.delete(sessionID); nativeRetrySessions.delete(sessionID); clearTurnWatchdog(sessionID); @@ -3229,6 +3258,7 @@ var server = async ({ client }, options) => { } } if (sessionID && eventType === "session.idle") { + explicitResumeRequests.delete(sessionID); busySessions.delete(sessionID); nativeRetrySessions.delete(sessionID); clearTurnWatchdog(sessionID); @@ -3236,6 +3266,7 @@ var server = async ({ client }, options) => { taskTracker.observeSessionStatus(sessionID, "idle"); } if (sessionID && eventType === "session.error") { + explicitResumeRequests.delete(sessionID); const inNativeRetry = nativeRetrySessions.has(sessionID); busySessions.delete(sessionID); clearTurnWatchdog(sessionID); @@ -3265,6 +3296,7 @@ var server = async ({ client }, options) => { } } if (sessionID && eventType === "session.deleted") { + explicitResumeRequests.delete(sessionID); busySessions.delete(sessionID); clearTurnWatchdog(sessionID); watchdogRescuedSessions.delete(sessionID); @@ -3323,6 +3355,7 @@ async function setupV2(context) { const locallyDeliveredPendingSessions = new Set; const watchdogRescuedSessions = new Set; const toolAttempts = new Map; + const explicitResumeRequests = new Set; const planAgents = restrictedAgentSet(options); const isPlanAgent = (agent) => typeof agent === "string" && planAgents.has(agent.trim().toLowerCase()); const activeContinuationsV2 = new Set; @@ -3336,6 +3369,7 @@ async function setupV2(context) { messages, maxObjectiveChars: objectiveChars, isPlanAgent, + consumeAutoTurnReset: (sessionID) => explicitResumeRequests.delete(sessionID), initializeUsage: async (sessionID) => { try { await accountUsage(sessionID, stepTokenSums.get(sessionID) ?? 0, { cumulative: true, source: "v2.steps" }); @@ -3706,6 +3740,7 @@ async function setupV2(context) { await markPendingContinuationStarted(sessionID); } if (status.type === "idle") { + explicitResumeRequests.delete(sessionID); busySessions.delete(sessionID); nativeRetrySessions.delete(sessionID); clearTurnWatchdog(sessionID); @@ -3728,6 +3763,7 @@ async function setupV2(context) { case "session.execution.succeeded": case "session.idle": { if (sessionID) { + explicitResumeRequests.delete(sessionID); busySessions.delete(sessionID); nativeRetrySessions.delete(sessionID); clearTurnWatchdog(sessionID); @@ -3744,6 +3780,7 @@ async function setupV2(context) { case "session.execution.interrupted": { if (!sessionID) return; + explicitResumeRequests.delete(sessionID); stoppedExecutions.add(sessionID); busySessions.delete(sessionID); nativeRetrySessions.delete(sessionID); @@ -3757,6 +3794,7 @@ async function setupV2(context) { case "session.execution.failed": { if (!sessionID) return; + explicitResumeRequests.delete(sessionID); nativeRetrySessions.delete(sessionID); busySessions.delete(sessionID); clearTurnWatchdog(sessionID); @@ -3791,6 +3829,7 @@ async function setupV2(context) { case "session.deleted": { if (!sessionID) return; + explicitResumeRequests.delete(sessionID); stoppedExecutions.delete(sessionID); sessionOwnership.delete(sessionID); busySessions.delete(sessionID); @@ -3956,6 +3995,9 @@ async function setupV2(context) { cancelScheduledContinuation(input.sessionID); clearTurnWatchdog(input.sessionID); } + if (command.action === "resume" || command.action === "goal" && input.prompt.text.trim().toLowerCase() === "resume") { + explicitResumeRequests.add(input.sessionID); + } let forwardedPrompt = {}; if (command.action === "goal") { const stripMention = ({ mention: _mention, ...attachment }) => attachment; @@ -3977,25 +4019,31 @@ async function setupV2(context) { }); } })); + } + if (registerCommand) { registrations.push(await context.session.hook("prompt", async (input) => { if (typeof input.sessionID === "string") markSessionOwnership(input.sessionID, true); + explicitResumeRequests.delete(input.sessionID); const pauseTemplate = goalStatusCommandTemplate("pause_goal", locale); const resumeTemplate = goalStatusCommandTemplate("resume_goal", locale); const template = input.prompt.text.startsWith(pauseTemplate) ? pauseTemplate : input.prompt.text.startsWith(resumeTemplate) ? resumeTemplate : null; - if (!template) - return; - input.prompt.text = template; - delete input.prompt.files; - delete input.prompt.agents; - delete input.prompt.skills; - if (template !== pauseTemplate) - return; - const goal = await getGoal(input.sessionID); - if (goal?.status === "active") - await setGoalStatus(input.sessionID, "paused"); - cancelScheduledContinuation(input.sessionID); - clearTurnWatchdog(input.sessionID); + if (template) { + input.prompt.text = template; + delete input.prompt.files; + delete input.prompt.agents; + delete input.prompt.skills; + if (template === pauseTemplate) { + const goal = await getGoal(input.sessionID); + if (goal?.status === "active") + await setGoalStatus(input.sessionID, "paused"); + cancelScheduledContinuation(input.sessionID); + clearTurnWatchdog(input.sessionID); + } + } + if (isExplicitResumePrompt(input.prompt.text, commandName, locale, messages)) { + explicitResumeRequests.add(input.sessionID); + } })); } registrations.push(await context.tool.transform((draft) => { @@ -4114,6 +4162,7 @@ async function setupV2(context) { locallyDeliveredPendingSessions.clear(); watchdogRescuedSessions.clear(); toolAttempts.clear(); + explicitResumeRequests.clear(); for (const registration of registrations) await registration.dispose(); const termination = Promise.allSettled([consumer, eventIterator?.return?.()]); diff --git a/src/prompts.ts b/src/prompts.ts index b4dc9d6..a766a98 100644 --- a/src/prompts.ts +++ b/src/prompts.ts @@ -140,7 +140,7 @@ ${budgetLines(goal, locale)} 状态:${presentGoalStatus(goal.status, locale)} 停止原因:${presentGoalStopReason(goal.stopReason ?? "goal limit reached", locale)} -不要为此目标开始新的实质性工作。尽快结束本轮:使用简体中文总结有效进展,指出剩余工作或阻塞项,并给用户一个清晰的下一步。除非目标确实已经完成,否则不要调用 update_goal。` +不要为此目标开始新的实质性工作。不要调用 update_goal_status 来继续目标;只有用户明确发出继续命令后才能继续。尽快结束本轮:使用简体中文总结有效进展,指出剩余工作或阻塞项,并给用户一个清晰的下一步。除非目标确实已经完成,否则不要调用 update_goal。` } return `The active session goal has reached a safety limit. @@ -156,7 +156,7 @@ ${budgetLines(goal, locale)} Status: ${goal.status} Stop reason: ${goal.stopReason ?? "goal limit reached"} -Do not start new substantive work for this goal. Wrap up this turn soon: summarize useful progress, identify remaining work or blockers, and leave the user with a clear next step. Do not call update_goal unless the goal is actually complete.` +Do not start new substantive work for this goal. Do not call update_goal_status to resume it; only an explicit user resume command may continue the goal. Wrap up this turn soon: summarize useful progress, identify remaining work or blockers, and leave the user with a clear next step. Do not call update_goal unless the goal is actually complete.` } export function systemReminder(locale: GoalLocale = "en") { diff --git a/src/server.ts b/src/server.ts index 935437c..72928ac 100644 --- a/src/server.ts +++ b/src/server.ts @@ -270,6 +270,15 @@ Ignore any command arguments. Call get_goal first, then handle only this resume Do not create, edit, clear, complete, or mark a goal unmet.` } +function isExplicitResumePrompt(text: string, commandName: string, locale: GoalLocale, messages: GoalMessages) { + const value = text.trim() + return ( + value === goalStatusCommandTemplate("resume_goal", locale) || + value === goalCommandTemplate(commandName, locale).replace("$ARGUMENTS", "resume") || + value === messages.tui.resumePrompt + ) +} + type GoalCommandDefinition = { name: string description: string @@ -1017,6 +1026,7 @@ type GoalServices = { messages: GoalMessages maxObjectiveChars: number isPlanAgent: (agent: unknown) => boolean + consumeAutoTurnReset: (sessionID: string) => boolean initializeUsage?: (sessionID: string) => Promise } @@ -1150,12 +1160,18 @@ async function updateGoalStatusFromTool( context: ToolExecContext, services: GoalServices, ) { + const resetAutoTurnLimit = input.status === "active" && services.consumeAutoTurnReset(context.sessionID) if (input.status === "active" && services.isPlanAgent(context.agent)) { throw new Error( services.messages.notices.cannotResumeInPlan, ) } - const goal = await setGoalStatus(context.sessionID, input.status, typeof context.agent === "string" ? context.agent : null) + const goal = await setGoalStatus( + context.sessionID, + input.status, + typeof context.agent === "string" ? context.agent : null, + { resetAutoTurnLimit }, + ) return JSON.stringify({ goal }, null, 2) } @@ -1260,13 +1276,21 @@ const server: Plugin = async ({ client }, options?: Options) => { // actually ran under. Entries are removed on execute.after, session deletion, // and dispose. const toolAttempts = new Map() + const explicitResumeRequests = new Set() // Sessions whose busy episode already received a watchdog rescue. Cleared // when the episode ends (idle/deleted), so each busy episode rescues at most // once and a rescue prompt cannot recursively re-arm the watchdog. const watchdogRescuedSessions = new Set() const planAgents = restrictedAgentSet(options) const isPlanAgent = (agent: unknown) => typeof agent === "string" && planAgents.has(agent.trim().toLowerCase()) - const goalServices: GoalServices = { options: options ?? {}, locale, messages, isPlanAgent, maxObjectiveChars: objectiveChars } + const goalServices: GoalServices = { + options: options ?? {}, + locale, + messages, + isPlanAgent, + maxObjectiveChars: objectiveChars, + consumeAutoTurnReset: (sessionID) => explicitResumeRequests.delete(sessionID), + } const stopStateRecoveryReporting = onStateRecovery(statePath(), async ({ stateFile, quarantineFile, outcome, error }) => { await client.app?.log?.({ body: { @@ -1606,6 +1630,7 @@ const server: Plugin = async ({ client }, options?: Options) => { locallyDeliveredPendingSessions.clear() nativeRetrySessions.clear() toolAttempts.clear() + explicitResumeRequests.clear() }, async config(config) { if (!registerCommand) return @@ -1729,12 +1754,16 @@ const server: Plugin = async ({ client }, options?: Options) => { }, async "command.execute.before"(input, output) { if (input.command === commandName) { - escapeGoalCommandArguments(output, goalCommandTemplate(commandName, locale), input.arguments) + const sanitized = escapeGoalCommandArguments(output, goalCommandTemplate(commandName, locale), input.arguments) + if (sanitized && input.arguments.trim().toLowerCase() === "resume") { + explicitResumeRequests.add(input.sessionID) + } return } if (input.command !== "pause_goal" && input.command !== "resume_goal") return const template = goalStatusCommandTemplate(input.command, locale) if (!sanitizeGoalStatusCommandParts(output, template)) return + if (input.command === "resume_goal") explicitResumeRequests.add(input.sessionID) if (input.command !== "pause_goal") return const goal = await getGoal(input.sessionID) if (goal?.status === "active") await setGoalStatus(input.sessionID, "paused") @@ -1775,7 +1804,12 @@ const server: Plugin = async ({ client }, options?: Options) => { async "chat.message"(input, output) { const sessionID = typeof input?.sessionID === "string" ? input.sessionID : output.message?.sessionID const agent = typeof input?.agent === "string" && input.agent.trim() ? input.agent : output.message?.agent - if (typeof sessionID !== "string" || typeof agent !== "string" || !agent.trim()) return + if (typeof sessionID !== "string") return + explicitResumeRequests.delete(sessionID) + if (output.parts?.some((part) => isExplicitResumePrompt(textFromPart(part), commandName, locale, messages))) { + explicitResumeRequests.add(sessionID) + } + if (typeof agent !== "string" || !agent.trim()) return await recordPromptAgent(sessionID, agent) }, async "experimental.chat.messages.transform"(input, output) { @@ -1821,6 +1855,7 @@ const server: Plugin = async ({ client }, options?: Options) => { if (status.type === "busy") armTurnWatchdog(sessionID) if (status.type === "busy") await markPendingContinuationStarted(sessionID) if (status.type === "idle") { + explicitResumeRequests.delete(sessionID) busySessions.delete(sessionID) nativeRetrySessions.delete(sessionID) clearTurnWatchdog(sessionID) @@ -1835,6 +1870,7 @@ const server: Plugin = async ({ client }, options?: Options) => { } } if (sessionID && eventType === "session.idle") { + explicitResumeRequests.delete(sessionID) busySessions.delete(sessionID) nativeRetrySessions.delete(sessionID) clearTurnWatchdog(sessionID) @@ -1842,6 +1878,7 @@ const server: Plugin = async ({ client }, options?: Options) => { taskTracker.observeSessionStatus(sessionID, "idle") } if (sessionID && eventType === "session.error") { + explicitResumeRequests.delete(sessionID) const inNativeRetry = nativeRetrySessions.has(sessionID) busySessions.delete(sessionID) clearTurnWatchdog(sessionID) @@ -1888,6 +1925,7 @@ const server: Plugin = async ({ client }, options?: Options) => { } } if (sessionID && eventType === "session.deleted") { + explicitResumeRequests.delete(sessionID) busySessions.delete(sessionID) clearTurnWatchdog(sessionID) watchdogRescuedSessions.delete(sessionID) @@ -1953,6 +1991,7 @@ async function setupV2(context: PluginV2.Plugin.Context): Promise() + const explicitResumeRequests = new Set() const planAgents = restrictedAgentSet(options) const isPlanAgent = (agent: unknown) => typeof agent === "string" && planAgents.has(agent.trim().toLowerCase()) const activeContinuationsV2 = new Set() @@ -1969,6 +2008,7 @@ async function setupV2(context: PluginV2.Plugin.Context): Promise explicitResumeRequests.delete(sessionID), initializeUsage: async (sessionID) => { try { await accountUsage(sessionID, stepTokenSums.get(sessionID) ?? 0, { cumulative: true, source: "v2.steps" }) @@ -2412,6 +2452,7 @@ async function setupV2(context: PluginV2.Plugin.Context): Promise = {} if (command.action === "goal") { const stripMention = ({ mention: _mention, ...attachment }: T) => attachment @@ -2693,12 +2744,17 @@ async function setupV2(context: PluginV2.Plugin.Context): Promise { // Prompt hooks only fire in the session's owning location. if (typeof input.sessionID === "string") markSessionOwnership(input.sessionID, true) + explicitResumeRequests.delete(input.sessionID) const pauseTemplate = goalStatusCommandTemplate("pause_goal", locale) const resumeTemplate = goalStatusCommandTemplate("resume_goal", locale) const template = input.prompt.text.startsWith(pauseTemplate) @@ -2706,16 +2762,21 @@ async function setupV2(context: PluginV2.Plugin.Context): Promise { const goal = state.goals[sessionID] @@ -891,10 +897,16 @@ export async function setGoalStatus(sessionID: string, status: MutableGoalStatus if (isClosed(goal.status)) throw new Error("cannot update goal status because this goal is closed") if (goal.status === status) return snapshot(goal) if (status === "paused" && goal.status !== "active") return snapshot(goal) + const resumesAutoTurnLimit = + options?.resetAutoTurnLimit === true && + status === "active" && + goal.status === "usageLimited" && + goal.stopReason?.startsWith(MAX_AUTO_CONTINUES_STOP_REASON_PREFIX) === true accountWallClock(goal) goal.status = status goal.updatedAt = nowSeconds() goal.lastAccountedAt = status === "active" ? goal.updatedAt : null + goal.autoTurns = resumesAutoTurnLimit ? 0 : goal.autoTurns goal.continuationFailures = status === "active" ? 0 : goal.continuationFailures goal.pendingAttempt = status === "active" ? null : goal.pendingAttempt goal.noProgressTurns = status === "active" ? 0 : goal.noProgressTurns @@ -1328,7 +1340,7 @@ function maybeStopForUsageLimit(goal: Goal, defaultMaxAutoTurns: number, now = n if (effectiveMaxAutoTurns > 0 && goal.autoTurns >= effectiveMaxAutoTurns) { goal.status = "usageLimited" goal.lastAccountedAt = null - goal.stopReason = `max auto-continues reached (${effectiveMaxAutoTurns})` + goal.stopReason = `${MAX_AUTO_CONTINUES_STOP_REASON_PREFIX}${effectiveMaxAutoTurns})` goal.lastStatus = `${goal.stopReason}; wrap-up required.` pushHistory(goal, "limited", goal.lastStatus) return true diff --git a/test/prompts-i18n.test.ts b/test/prompts-i18n.test.ts index 7473465..578cb70 100644 --- a/test/prompts-i18n.test.ts +++ b/test/prompts-i18n.test.ts @@ -35,6 +35,7 @@ test("zh-CN wrap-up and system prompts are localized", () => { expect(limited).not.toContain("状态:budgetLimited") expect(limited).not.toContain("停止原因:token budget reached") expect(limited).toContain("不要为此目标开始新的实质性工作") + expect(limited).toContain("不要调用 update_goal_status 来继续目标") expect(limited).toContain("update_goal") const reminder = systemReminder("zh-CN") diff --git a/test/server-v2.test.ts b/test/server-v2.test.ts index fcc3704..7906c20 100644 --- a/test/server-v2.test.ts +++ b/test/server-v2.test.ts @@ -446,6 +446,34 @@ test("V2 setup registers /goal, /pause_goal, and /resume_goal via command transf await cleanup() }) +test("V2 only renews the auto-turn window after an explicit resume command", async () => { + const mock = makeMockContext({ auto_continue: false }) + const cleanup = await setupPlugin(mock as never) + await goalTool(mock, "create_goal").execute( + { objective: "continue through another window", max_auto_turns: 1 }, + toolContext("ses_resume_limit"), + ) + await reserveContinuation("ses_resume_limit", 25, 0) + await reserveContinuation("ses_resume_limit", 25, 0) + + const statusTool = goalTool(mock, "update_goal_status") + await statusTool.execute({ status: "active" }, toolContext("ses_resume_limit")) + expect((await getGoal("ses_resume_limit"))?.autoTurns).toBe(1) + await reserveContinuation("ses_resume_limit", 25, 0) + expect((await getGoal("ses_resume_limit"))?.status).toBe("usageLimited") + + const resume = mock.commands.find((candidate) => candidate.name === "resume_goal") + await resume?.execute({ sessionID: "ses_resume_limit", prompt: { text: "" }, delivery: "steer" }) + const resumePrompt = mock.promptCalls.at(-1) + if (!resumePrompt) throw new Error("expected resume command prompt") + await mock.hooks.prompt?.({ sessionID: "ses_resume_limit", prompt: resumePrompt }) + await statusTool.execute({ status: "active" }, toolContext("ses_resume_limit")) + expect(await getGoal("ses_resume_limit")).toMatchObject({ status: "active", autoTurns: 0 }) + + mock.stream.end() + await cleanup() +}) + test("V2 goal command XML-escapes delimiter breakouts while preserving objective text", async () => { const mock = makeMockContext({ auto_continue: false }) const cleanup = await setupPlugin(mock as never) diff --git a/test/server.test.ts b/test/server.test.ts index ad2b5c3..4d132ee 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -739,6 +739,58 @@ test("goal status tool pauses and resumes a goal", async () => { expect(String(resumed)).toContain('"lastStatus": "Goal resumed."') }) +test("only an explicit resume command resets the auto-turn counter", async () => { + const hooks = await setupServer( + { + client: { + session: { + promptAsync: async () => {}, + }, + }, + } as never, + { auto_continue: false }, + ) + const tools = hooks.tool + if (!tools) throw new Error("expected goal tools to be registered") + + const context = { sessionID: "ses_resume_limit", agent: "build" } as never + await requireTool(tools.create_goal, "create_goal").execute( + { objective: "finish after another continuation window", max_auto_turns: 1 }, + context, + ) + await reserveContinuation("ses_resume_limit", 25, 0) + expect((await reserveContinuation("ses_resume_limit", 25, 0))?.status).toBe("usageLimited") + + const genericResume = await requireTool(tools.update_goal_status, "update_goal_status").execute( + { status: "active" }, + context, + ) + expect(String(genericResume)).toContain('"autoTurns": 1') + expect((await reserveContinuation("ses_resume_limit", 25, 0))?.status).toBe("usageLimited") + + const config = {} as { command?: Record } + await hooks.config?.(config as never) + const goalTemplate = config.command?.goal?.template + if (!goalTemplate) throw new Error("expected goal command") + const resumeOutput = { parts: [{ type: "text", text: goalTemplate.replace("$ARGUMENTS", "resume") }] } + await hooks["command.execute.before"]?.( + { command: "goal", sessionID: "ses_resume_limit", arguments: "resume" }, + resumeOutput as never, + ) + await hooks["chat.message"]?.( + { sessionID: "ses_resume_limit", agent: "build" } as never, + { message: { sessionID: "ses_resume_limit", agent: "build" }, parts: resumeOutput.parts } as never, + ) + + const resumed = await requireTool(tools.update_goal_status, "update_goal_status").execute({ status: "active" }, context) + expect(String(resumed)).toContain('"status": "active"') + expect(String(resumed)).toContain('"autoTurns": 0') + expect((await reserveContinuation("ses_resume_limit", 25, 0))?.autoTurns).toBe(1) + await reserveContinuation("ses_resume_limit", 25, 0) + const repeated = await requireTool(tools.update_goal_status, "update_goal_status").execute({ status: "active" }, context) + expect(String(repeated)).toContain('"autoTurns": 1') +}) + test("server plugin does not overwrite existing goal commands", async () => { const hooks = await setupServer( { diff --git a/test/state.test.ts b/test/state.test.ts index 4be0f83..20db726 100644 --- a/test/state.test.ts +++ b/test/state.test.ts @@ -357,6 +357,35 @@ test("reserves continuation until max auto turns is reached", async () => { expect((await getGoal("ses_1"))?.status).toBe("usageLimited") }) +test("resuming after the auto-turn limit starts a fresh continuation window", async () => { + await createGoal("ses_1", "continue", { maxAutoTurns: 2 }) + expect((await reserveContinuation("ses_1", 25, 0))?.autoTurns).toBe(1) + expect((await reserveContinuation("ses_1", 25, 0))?.autoTurns).toBe(2) + expect((await reserveContinuation("ses_1", 25, 0))?.status).toBe("usageLimited") + + const resumed = await setGoalStatus("ses_1", "active", null, { resetAutoTurnLimit: true }) + expect(resumed).toMatchObject({ + status: "active", + autoTurns: 0, + budgetWrapupSent: false, + stopReason: null, + }) + + expect((await reserveContinuation("ses_1", 25, 0))?.autoTurns).toBe(1) + expect((await reserveContinuation("ses_1", 25, 0))?.autoTurns).toBe(2) + expect((await reserveContinuation("ses_1", 25, 0))?.status).toBe("usageLimited") +}) + +test("a generic status update cannot renew the auto-turn limit", async () => { + await createGoal("ses_1", "continue", { maxAutoTurns: 1 }) + await reserveContinuation("ses_1", 25, 0) + await reserveContinuation("ses_1", 25, 0) + + const resumed = await setGoalStatus("ses_1", "active") + expect(resumed.autoTurns).toBe(1) + expect((await reserveContinuation("ses_1", 25, 0))?.status).toBe("usageLimited") +}) + test("generic assistant observations record checkpoints but never pause the goal", async () => { await createGoal("ses_1", "continue", { noProgressTokenThreshold: 50, maxNoProgressTurns: 2 }) const first = await recordAssistantProgress("ses_1", { messageID: "m1", text: "Inspected the repo", outputTokens: 10 })