Skip to content

Commit e4578ef

Browse files
committed
fix: reset auto-turn limit on explicit resume
1 parent 3000449 commit e4578ef

9 files changed

Lines changed: 275 additions & 42 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ Defaults:
178178
- `auto_continue`: `true`
179179
- `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.
180180
- `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`.
181-
- `max_auto_turns`: `25`
181+
- `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.
182182
- `min_continue_interval_seconds`: `3`
183183
- Fast V2 executions that finish inside this interval schedule a delayed continuation; they do not require another user message to wake up.
184184
- `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.

‎dist/server.js‎

Lines changed: 70 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ var MAX_LISTED_GOALS = 50;
132132
var CHECKPOINT_CHAR_LIMIT = 280;
133133
var DEFAULT_NO_PROGRESS_TOKEN_THRESHOLD = 50;
134134
var DEFAULT_MAX_NO_PROGRESS_TURNS = 2;
135+
var MAX_AUTO_CONTINUES_STOP_REASON_PREFIX = "max auto-continues reached (";
135136
var PLAN_MODE_STOP_REASON = "plan mode";
136137
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.";
137138
var NullableString = Schema.NullOr(Schema.String);
@@ -689,7 +690,7 @@ async function pauseGoalForPlanMode(sessionID) {
689690
return snapshot(goal);
690691
});
691692
}
692-
async function setGoalStatus(sessionID, status, agent) {
693+
async function setGoalStatus(sessionID, status, agent, options) {
693694
const agentValue = typeof agent === "string" && agent.trim() ? agent.trim() : null;
694695
return mutate((state) => {
695696
const goal = state.goals[sessionID];
@@ -701,10 +702,12 @@ async function setGoalStatus(sessionID, status, agent) {
701702
return snapshot(goal);
702703
if (status === "paused" && goal.status !== "active")
703704
return snapshot(goal);
705+
const resumesAutoTurnLimit = options?.resetAutoTurnLimit === true && status === "active" && goal.status === "usageLimited" && goal.stopReason?.startsWith(MAX_AUTO_CONTINUES_STOP_REASON_PREFIX) === true;
704706
accountWallClock(goal);
705707
goal.status = status;
706708
goal.updatedAt = nowSeconds();
707709
goal.lastAccountedAt = status === "active" ? goal.updatedAt : null;
710+
goal.autoTurns = resumesAutoTurnLimit ? 0 : goal.autoTurns;
708711
goal.continuationFailures = status === "active" ? 0 : goal.continuationFailures;
709712
goal.pendingAttempt = status === "active" ? null : goal.pendingAttempt;
710713
goal.noProgressTurns = status === "active" ? 0 : goal.noProgressTurns;
@@ -1053,7 +1056,7 @@ function maybeStopForUsageLimit(goal, defaultMaxAutoTurns, now = nowSeconds()) {
10531056
if (effectiveMaxAutoTurns > 0 && goal.autoTurns >= effectiveMaxAutoTurns) {
10541057
goal.status = "usageLimited";
10551058
goal.lastAccountedAt = null;
1056-
goal.stopReason = `max auto-continues reached (${effectiveMaxAutoTurns})`;
1059+
goal.stopReason = `${MAX_AUTO_CONTINUES_STOP_REASON_PREFIX}${effectiveMaxAutoTurns})`;
10571060
goal.lastStatus = `${goal.stopReason}; wrap-up required.`;
10581061
pushHistory(goal, "limited", goal.lastStatus);
10591062
return true;
@@ -1617,7 +1620,7 @@ ${budgetLines(goal, locale)}
16171620
\u72B6\u6001\uFF1A${presentGoalStatus(goal.status, locale)}
16181621
\u505C\u6B62\u539F\u56E0\uFF1A${presentGoalStopReason(goal.stopReason ?? "goal limit reached", locale)}
16191622
1620-
\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`;
1623+
\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`;
16211624
}
16221625
return `The active session goal has reached a safety limit.
16231626
@@ -1633,7 +1636,7 @@ ${budgetLines(goal, locale)}
16331636
Status: ${goal.status}
16341637
Stop reason: ${goal.stopReason ?? "goal limit reached"}
16351638
1636-
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.`;
1639+
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.`;
16371640
}
16381641
function systemReminder(locale = "en") {
16391642
if (locale === "zh-CN") {
@@ -1890,6 +1893,10 @@ Ignore any command arguments. Call get_goal first, then handle only this resume
18901893
18911894
Do not create, edit, clear, complete, or mark a goal unmet.`;
18921895
}
1896+
function isExplicitResumePrompt(text, commandName, locale, messages) {
1897+
const value = text.trim();
1898+
return value === goalStatusCommandTemplate("resume_goal", locale) || value === goalCommandTemplate(commandName, locale).replace("$ARGUMENTS", "resume") || value === messages.tui.resumePrompt;
1899+
}
18931900
function goalCommandDefinitions(commandName, locale = "en") {
18941901
const messages = messagesFor(locale);
18951902
return [
@@ -2662,10 +2669,11 @@ async function closeGoalFromTool(input, context, services) {
26622669
return JSON.stringify({ goal, unmet_report: report }, null, 2);
26632670
}
26642671
async function updateGoalStatusFromTool(input, context, services) {
2672+
const resetAutoTurnLimit = input.status === "active" && services.consumeAutoTurnReset(context.sessionID);
26652673
if (input.status === "active" && services.isPlanAgent(context.agent)) {
26662674
throw new Error(services.messages.notices.cannotResumeInPlan);
26672675
}
2668-
const goal = await setGoalStatus(context.sessionID, input.status, typeof context.agent === "string" ? context.agent : null);
2676+
const goal = await setGoalStatus(context.sessionID, input.status, typeof context.agent === "string" ? context.agent : null, { resetAutoTurnLimit });
26692677
return JSON.stringify({ goal }, null, 2);
26702678
}
26712679
function v2ObjectSchema(properties, required = []) {
@@ -2737,10 +2745,18 @@ var server = async ({ client }, options) => {
27372745
const nativeRetrySessions = new Set;
27382746
const locallyDeliveredPendingSessions = new Set;
27392747
const toolAttempts = new Map;
2748+
const explicitResumeRequests = new Set;
27402749
const watchdogRescuedSessions = new Set;
27412750
const planAgents = restrictedAgentSet(options);
27422751
const isPlanAgent = (agent) => typeof agent === "string" && planAgents.has(agent.trim().toLowerCase());
2743-
const goalServices = { options: options ?? {}, locale, messages, isPlanAgent, maxObjectiveChars: objectiveChars };
2752+
const goalServices = {
2753+
options: options ?? {},
2754+
locale,
2755+
messages,
2756+
isPlanAgent,
2757+
maxObjectiveChars: objectiveChars,
2758+
consumeAutoTurnReset: (sessionID) => explicitResumeRequests.delete(sessionID)
2759+
};
27442760
const stopStateRecoveryReporting = onStateRecovery(statePath(), async ({ stateFile, quarantineFile, outcome, error }) => {
27452761
await client.app?.log?.({
27462762
body: {
@@ -3014,6 +3030,7 @@ var server = async ({ client }, options) => {
30143030
locallyDeliveredPendingSessions.clear();
30153031
nativeRetrySessions.clear();
30163032
toolAttempts.clear();
3033+
explicitResumeRequests.clear();
30173034
},
30183035
async config(config) {
30193036
if (!registerCommand)
@@ -3116,14 +3133,19 @@ var server = async ({ client }, options) => {
31163133
},
31173134
async "command.execute.before"(input, output) {
31183135
if (input.command === commandName) {
3119-
escapeGoalCommandArguments(output, goalCommandTemplate(commandName, locale), input.arguments);
3136+
const sanitized = escapeGoalCommandArguments(output, goalCommandTemplate(commandName, locale), input.arguments);
3137+
if (sanitized && input.arguments.trim().toLowerCase() === "resume") {
3138+
explicitResumeRequests.add(input.sessionID);
3139+
}
31203140
return;
31213141
}
31223142
if (input.command !== "pause_goal" && input.command !== "resume_goal")
31233143
return;
31243144
const template = goalStatusCommandTemplate(input.command, locale);
31253145
if (!sanitizeGoalStatusCommandParts(output, template))
31263146
return;
3147+
if (input.command === "resume_goal")
3148+
explicitResumeRequests.add(input.sessionID);
31273149
if (input.command !== "pause_goal")
31283150
return;
31293151
const goal = await getGoal(input.sessionID);
@@ -3164,7 +3186,13 @@ var server = async ({ client }, options) => {
31643186
async "chat.message"(input, output) {
31653187
const sessionID = typeof input?.sessionID === "string" ? input.sessionID : output.message?.sessionID;
31663188
const agent = typeof input?.agent === "string" && input.agent.trim() ? input.agent : output.message?.agent;
3167-
if (typeof sessionID !== "string" || typeof agent !== "string" || !agent.trim())
3189+
if (typeof sessionID !== "string")
3190+
return;
3191+
explicitResumeRequests.delete(sessionID);
3192+
if (output.parts?.some((part) => isExplicitResumePrompt(textFromPart(part), commandName, locale, messages))) {
3193+
explicitResumeRequests.add(sessionID);
3194+
}
3195+
if (typeof agent !== "string" || !agent.trim())
31683196
return;
31693197
await recordPromptAgent(sessionID, agent);
31703198
},
@@ -3215,6 +3243,7 @@ var server = async ({ client }, options) => {
32153243
if (status.type === "busy")
32163244
await markPendingContinuationStarted(sessionID);
32173245
if (status.type === "idle") {
3246+
explicitResumeRequests.delete(sessionID);
32183247
busySessions.delete(sessionID);
32193248
nativeRetrySessions.delete(sessionID);
32203249
clearTurnWatchdog(sessionID);
@@ -3229,13 +3258,15 @@ var server = async ({ client }, options) => {
32293258
}
32303259
}
32313260
if (sessionID && eventType === "session.idle") {
3261+
explicitResumeRequests.delete(sessionID);
32323262
busySessions.delete(sessionID);
32333263
nativeRetrySessions.delete(sessionID);
32343264
clearTurnWatchdog(sessionID);
32353265
watchdogRescuedSessions.delete(sessionID);
32363266
taskTracker.observeSessionStatus(sessionID, "idle");
32373267
}
32383268
if (sessionID && eventType === "session.error") {
3269+
explicitResumeRequests.delete(sessionID);
32393270
const inNativeRetry = nativeRetrySessions.has(sessionID);
32403271
busySessions.delete(sessionID);
32413272
clearTurnWatchdog(sessionID);
@@ -3265,6 +3296,7 @@ var server = async ({ client }, options) => {
32653296
}
32663297
}
32673298
if (sessionID && eventType === "session.deleted") {
3299+
explicitResumeRequests.delete(sessionID);
32683300
busySessions.delete(sessionID);
32693301
clearTurnWatchdog(sessionID);
32703302
watchdogRescuedSessions.delete(sessionID);
@@ -3323,6 +3355,7 @@ async function setupV2(context) {
33233355
const locallyDeliveredPendingSessions = new Set;
33243356
const watchdogRescuedSessions = new Set;
33253357
const toolAttempts = new Map;
3358+
const explicitResumeRequests = new Set;
33263359
const planAgents = restrictedAgentSet(options);
33273360
const isPlanAgent = (agent) => typeof agent === "string" && planAgents.has(agent.trim().toLowerCase());
33283361
const activeContinuationsV2 = new Set;
@@ -3336,6 +3369,7 @@ async function setupV2(context) {
33363369
messages,
33373370
maxObjectiveChars: objectiveChars,
33383371
isPlanAgent,
3372+
consumeAutoTurnReset: (sessionID) => explicitResumeRequests.delete(sessionID),
33393373
initializeUsage: async (sessionID) => {
33403374
try {
33413375
await accountUsage(sessionID, stepTokenSums.get(sessionID) ?? 0, { cumulative: true, source: "v2.steps" });
@@ -3706,6 +3740,7 @@ async function setupV2(context) {
37063740
await markPendingContinuationStarted(sessionID);
37073741
}
37083742
if (status.type === "idle") {
3743+
explicitResumeRequests.delete(sessionID);
37093744
busySessions.delete(sessionID);
37103745
nativeRetrySessions.delete(sessionID);
37113746
clearTurnWatchdog(sessionID);
@@ -3728,6 +3763,7 @@ async function setupV2(context) {
37283763
case "session.execution.succeeded":
37293764
case "session.idle": {
37303765
if (sessionID) {
3766+
explicitResumeRequests.delete(sessionID);
37313767
busySessions.delete(sessionID);
37323768
nativeRetrySessions.delete(sessionID);
37333769
clearTurnWatchdog(sessionID);
@@ -3744,6 +3780,7 @@ async function setupV2(context) {
37443780
case "session.execution.interrupted": {
37453781
if (!sessionID)
37463782
return;
3783+
explicitResumeRequests.delete(sessionID);
37473784
stoppedExecutions.add(sessionID);
37483785
busySessions.delete(sessionID);
37493786
nativeRetrySessions.delete(sessionID);
@@ -3757,6 +3794,7 @@ async function setupV2(context) {
37573794
case "session.execution.failed": {
37583795
if (!sessionID)
37593796
return;
3797+
explicitResumeRequests.delete(sessionID);
37603798
nativeRetrySessions.delete(sessionID);
37613799
busySessions.delete(sessionID);
37623800
clearTurnWatchdog(sessionID);
@@ -3791,6 +3829,7 @@ async function setupV2(context) {
37913829
case "session.deleted": {
37923830
if (!sessionID)
37933831
return;
3832+
explicitResumeRequests.delete(sessionID);
37943833
stoppedExecutions.delete(sessionID);
37953834
sessionOwnership.delete(sessionID);
37963835
busySessions.delete(sessionID);
@@ -3956,6 +3995,9 @@ async function setupV2(context) {
39563995
cancelScheduledContinuation(input.sessionID);
39573996
clearTurnWatchdog(input.sessionID);
39583997
}
3998+
if (command.action === "resume" || command.action === "goal" && input.prompt.text.trim().toLowerCase() === "resume") {
3999+
explicitResumeRequests.add(input.sessionID);
4000+
}
39594001
let forwardedPrompt = {};
39604002
if (command.action === "goal") {
39614003
const stripMention = ({ mention: _mention, ...attachment }) => attachment;
@@ -3977,25 +4019,31 @@ async function setupV2(context) {
39774019
});
39784020
}
39794021
}));
4022+
}
4023+
if (registerCommand) {
39804024
registrations.push(await context.session.hook("prompt", async (input) => {
39814025
if (typeof input.sessionID === "string")
39824026
markSessionOwnership(input.sessionID, true);
4027+
explicitResumeRequests.delete(input.sessionID);
39834028
const pauseTemplate = goalStatusCommandTemplate("pause_goal", locale);
39844029
const resumeTemplate = goalStatusCommandTemplate("resume_goal", locale);
39854030
const template = input.prompt.text.startsWith(pauseTemplate) ? pauseTemplate : input.prompt.text.startsWith(resumeTemplate) ? resumeTemplate : null;
3986-
if (!template)
3987-
return;
3988-
input.prompt.text = template;
3989-
delete input.prompt.files;
3990-
delete input.prompt.agents;
3991-
delete input.prompt.skills;
3992-
if (template !== pauseTemplate)
3993-
return;
3994-
const goal = await getGoal(input.sessionID);
3995-
if (goal?.status === "active")
3996-
await setGoalStatus(input.sessionID, "paused");
3997-
cancelScheduledContinuation(input.sessionID);
3998-
clearTurnWatchdog(input.sessionID);
4031+
if (template) {
4032+
input.prompt.text = template;
4033+
delete input.prompt.files;
4034+
delete input.prompt.agents;
4035+
delete input.prompt.skills;
4036+
if (template === pauseTemplate) {
4037+
const goal = await getGoal(input.sessionID);
4038+
if (goal?.status === "active")
4039+
await setGoalStatus(input.sessionID, "paused");
4040+
cancelScheduledContinuation(input.sessionID);
4041+
clearTurnWatchdog(input.sessionID);
4042+
}
4043+
}
4044+
if (isExplicitResumePrompt(input.prompt.text, commandName, locale, messages)) {
4045+
explicitResumeRequests.add(input.sessionID);
4046+
}
39994047
}));
40004048
}
40014049
registrations.push(await context.tool.transform((draft) => {
@@ -4114,6 +4162,7 @@ async function setupV2(context) {
41144162
locallyDeliveredPendingSessions.clear();
41154163
watchdogRescuedSessions.clear();
41164164
toolAttempts.clear();
4165+
explicitResumeRequests.clear();
41174166
for (const registration of registrations)
41184167
await registration.dispose();
41194168
const termination = Promise.allSettled([consumer, eventIterator?.return?.()]);

‎src/prompts.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ ${budgetLines(goal, locale)}
140140
状态:${presentGoalStatus(goal.status, locale)}
141141
停止原因:${presentGoalStopReason(goal.stopReason ?? "goal limit reached", locale)}
142142
143-
不要为此目标开始新的实质性工作。尽快结束本轮:使用简体中文总结有效进展,指出剩余工作或阻塞项,并给用户一个清晰的下一步。除非目标确实已经完成,否则不要调用 update_goal。`
143+
不要为此目标开始新的实质性工作。不要调用 update_goal_status 来继续目标;只有用户明确发出继续命令后才能继续。尽快结束本轮:使用简体中文总结有效进展,指出剩余工作或阻塞项,并给用户一个清晰的下一步。除非目标确实已经完成,否则不要调用 update_goal。`
144144
}
145145
return `The active session goal has reached a safety limit.
146146
@@ -156,7 +156,7 @@ ${budgetLines(goal, locale)}
156156
Status: ${goal.status}
157157
Stop reason: ${goal.stopReason ?? "goal limit reached"}
158158
159-
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.`
159+
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.`
160160
}
161161

162162
export function systemReminder(locale: GoalLocale = "en") {

0 commit comments

Comments
 (0)