See video in Discord
https://discord.com/channels/1497384592494297201/1551176637821882389
Summary
When a task is left interrupted while it still holds an uncompleted new_task delegation, reopening the task with subtask auto-approval enabled enters an unbounded loop. Zoo Code re-executes the pending delegation, the lifecycle guard rejects interrupted → delegated, the throwaway child is created and then deleted during rollback, and the parent is re-instantiated with the pending action still set. The state after each failure is identical to the state before it, so the cycle repeats on every load with no backoff. Observed up to 545 iterations, growing ui_messages.json to about 4 MB.
Environment
- Zoo Code: 3.82.2 (stable), also reproduced with 3.82.1-era persisted task state
- Host: VS Code remote (code-server) in a Linux container
- Provider:
apiProvider: "openai" against https://api.deepseek.com (the loop is provider-independent)
- Auto-approve: required.
autoApprovalEnabled: true and alwaysAllowSubtasks: true
Steps to reproduce
- Auto-approve settings: turn the Subtasks toggle off.
- Start a task in a mode that delegates, for example ask a manager mode:
please create a new task instance and wait for me in there.
- Approve the
new_task prompt. A child task opens. The parent becomes delegated, the child is active.
- In the child, ask it to delegate again. The
new_task approval prompt appears. Leave it unanswered.
- Reload the window (or restart VS Code). The child ends up
interrupted while still holding the pending action. This happens in practice through the startup delegation reconciliation of a persisted active child, and can also be reached by interrupting while the delegation is in flight.
- Auto-approve settings: turn the Subtasks toggle back on.
- Reload the window and open the child task.
Expected
The interrupted task resumes normally. The pending delegation is either re-presented as a prompt or discarded. No repeated child creation.
Actual
The task loops indefinitely. Extension log, verbatim:
[delegateParentAndOpenChild] Failed to persist parent metadata for 01a0be43-69d7-705e-aaf9-26dcdbc8bd09 -> 01a0be43-dfa3-71ba-8538-4a1eb4dbdf5f: Invalid task status transition: interrupted → delegated
[delegateParentAndOpenChild] Failed to persist parent metadata for 01a0be43-69d7-705e-aaf9-26dcdbc8bd09 -> 01a0be43-e09b-72c9-8859-f8907ab79637: Invalid task status transition: interrupted → delegated
[delegateParentAndOpenChild] Failed to persist parent metadata for 01a0be43-69d7-705e-aaf9-26dcdbc8bd09 -> 01a0be43-e16b-7580-ae82-1375c0c3e9dd: Invalid task status transition: interrupted → delegated
Every line names a fresh child id, because each attempt creates a child and deletes it during rollback.
Blast radius observed
| Session |
Iterations |
ui_messages.json |
Identical newTask ask: tool entries |
| 2026-09-20 |
154 |
289 KB / 162 messages |
154 |
| 2026-09-19 |
545 |
4.1 MB / 668 messages |
545 |
| earlier |
37+ |
4.7 MB / 934 messages |
662 |
Orphaned .ui_messages.json.new_*.tmp files accumulate in the task directory. No API requests are made during the loop, so no tokens are spent; the impact is disk growth plus a task that cannot be opened.
The affected task's persisted record at the time of the loop:
{ "status": "interrupted",
"pendingAction": { "kind": "create_subtask", "actionId": "call_00_NC3wrPZHcEDPAaPRnJ6d5283", "...": "..." },
"childIds": [] }
childIds stays empty because the rollback deletes each throwaway child, so the loop is invisible in the history list while ui_messages.json keeps growing.
Root cause
Five steps chain together:
-
src/core/tools/NewTaskTool.ts — newTaskTool.execute() persists the pending action before asking for approval:
const pendingActionId = toolCallId ? sanitizeToolUseId(toolCallId) : undefined
if (pendingActionId) {
const pendingAction: PendingTaskAction = { kind: "create_subtask", actionId: pendingActionId, approvalText: toolMessage, mode, message, todos: todoItems }
await provider.setPendingTaskAction(task.taskId, pendingAction)
task.setPendingTaskAction(pendingAction)
}
const didApprove = await askApproval("tool", toolMessage)
So an unanswered prompt already leaves pendingAction set, with no tool_result for actionId.
-
src/core/task/Task.ts — resumeTaskFromHistory() can only clear the pending action when a durable tool_result for pendingAction.actionId exists in apiConversationHistory. When it does not exist, the pending action is executed:
if (this.pendingAction) {
this.isInitialized = true
await this.resumePendingTaskAction(this.pendingAction)
return
}
-
src/core/task/Task.ts — resumePendingTaskAction() calls ask("tool", action.approvalText, false). With alwaysAllowSubtasks enabled, the auto-approval layer returns "approve" without user input, so it proceeds to provider.delegateParentAndOpenChild({ parentTaskId, message, initialTodos, mode, pendingActionId }).
-
src/core/webview/ClineProvider.ts — delegateParentAndOpenChild() validates the pending action id and runs the transition under the store lock, clearing the pending action only on success:
const awaitedChildStatus = historyItem.awaitingChildId ? this.taskHistoryStore.get(historyItem.awaitingChildId)?.status : undefined
const delegated = delegateTaskToChild(historyItem, child.taskId, awaitedChildStatus)
return { ...delegated, pendingAction: delegated.pendingAction?.actionId === pendingActionId ? undefined : delegated.pendingAction }
-
src/core/task-persistence/taskLifecycle.ts — delegateTaskToChild() asserts the transition map:
export const VALID_TASK_STATUS_TRANSITIONS = {
active: ["delegated", "completed", "interrupted"],
delegated: ["active"],
interrupted: ["completed"],
completed: [],
}
With the task interrupted, assertValidTransition("interrupted", "delegated") throws Invalid task status transition: interrupted → delegated.
The throw is caught by delegateParentAndOpenChild()'s rollback, which logs the line quoted above, pops the child off the task stack, deletes the child task, and restores the parent. Because the throw happens inside the atomicReadAndUpdate updater, no write occurs: pendingAction is still set and status is still interrupted. That is byte-for-byte the precondition for step 2, so the next load repeats steps 2 to 5 forever. Nothing in the path increments an attempt counter or applies a backoff.
Secondary observations
interrupted is effectively terminal. VALID_TASK_STATUS_TRANSITIONS.interrupted = ["completed"], so once a task reaches interrupted it can neither delegate again nor return to active. Even after the pending action is cleared, a user has to hand-edit status back to active to make the task usable. That contradicts the intent of resuming an interrupted task.
- The two interrupt paths disagree.
cancelTask() reads the task record from disk before writing interrupted, so pendingAction survives. markDelegatedChildInterrupted() (used when the active task is evicted, for example by navigating to the History list) prefers the in-memory store snapshot, which can be the delegation-time entry, so it can drop pendingAction. That is why the bug reproduces intermittently and sometimes appears to "fix itself".
- The
interrupted status also blocks the normal recovery: delegateTaskToChild() supports delegated → active → delegated when the awaited child is interrupted, but there is no equivalent path out of interrupted.
Suggested fixes
- Fail closed when the pending action cannot be executed. If
delegateParentAndOpenChild() rejects the transition, clear pendingAction and persist a normal tool result (for example "Task was interrupted before this tool call could be completed.") instead of leaving the action to be re-executed on every load.
- Make the retry bounded. Record an attempt count on
pendingAction, or apply a backoff, so a repeatedly failing pending action cannot loop without limit.
- Let a recovered task become usable again. Either allow
interrupted → active when the user explicitly resumes, or have delegateTaskToChild()/resumePendingTaskAction() repair interrupted to active before delegating.
- Align the interrupt paths so
markDelegatedChildInterrupted() cannot drop pendingAction (read the record from disk, or merge with the authoritative file).
Workaround
With the task closed, in <globalStorage>/zoocodeorganization.zoo-code/tasks/<task-id>/:
-
Append a durable result for the pending action to api_conversation_history.json:
{"role":"user","content":[{"type":"tool_result","tool_use_id":"<pendingAction.actionId>","content":"Task was interrupted before this tool call could be completed.","is_error":true}],"ts":<epoch_ms>}
-
Set "pendingAction": null in history_item.json, and set "status": "active" if the task needs to delegate again.
-
Dedupe the repeated ask: tool entries in ui_messages.json (keep the first of each identical payload) and delete any .ui_messages.json.new_*.tmp files.
-
Reload the window so the extension re-reads the files. An edit made while the task is open is overwritten from memory.
Related
See video in Discord
https://discord.com/channels/1497384592494297201/1551176637821882389
Summary
When a task is left
interruptedwhile it still holds an uncompletednew_taskdelegation, reopening the task with subtask auto-approval enabled enters an unbounded loop. Zoo Code re-executes the pending delegation, the lifecycle guard rejectsinterrupted → delegated, the throwaway child is created and then deleted during rollback, and the parent is re-instantiated with the pending action still set. The state after each failure is identical to the state before it, so the cycle repeats on every load with no backoff. Observed up to 545 iterations, growingui_messages.jsonto about 4 MB.Environment
apiProvider: "openai"againsthttps://api.deepseek.com(the loop is provider-independent)autoApprovalEnabled: trueandalwaysAllowSubtasks: trueSteps to reproduce
please create a new task instance and wait for me in there.new_taskprompt. A child task opens. The parent becomesdelegated, the child isactive.new_taskapproval prompt appears. Leave it unanswered.interruptedwhile still holding the pending action. This happens in practice through the startup delegation reconciliation of a persistedactivechild, and can also be reached by interrupting while the delegation is in flight.Expected
The interrupted task resumes normally. The pending delegation is either re-presented as a prompt or discarded. No repeated child creation.
Actual
The task loops indefinitely. Extension log, verbatim:
Every line names a fresh child id, because each attempt creates a child and deletes it during rollback.
Blast radius observed
ui_messages.jsonnewTaskask: toolentriesOrphaned
.ui_messages.json.new_*.tmpfiles accumulate in the task directory. No API requests are made during the loop, so no tokens are spent; the impact is disk growth plus a task that cannot be opened.The affected task's persisted record at the time of the loop:
{ "status": "interrupted", "pendingAction": { "kind": "create_subtask", "actionId": "call_00_NC3wrPZHcEDPAaPRnJ6d5283", "...": "..." }, "childIds": [] }childIdsstays empty because the rollback deletes each throwaway child, so the loop is invisible in the history list whileui_messages.jsonkeeps growing.Root cause
Five steps chain together:
src/core/tools/NewTaskTool.ts—newTaskTool.execute()persists the pending action before asking for approval:So an unanswered prompt already leaves
pendingActionset, with notool_resultforactionId.src/core/task/Task.ts—resumeTaskFromHistory()can only clear the pending action when a durabletool_resultforpendingAction.actionIdexists inapiConversationHistory. When it does not exist, the pending action is executed:src/core/task/Task.ts—resumePendingTaskAction()callsask("tool", action.approvalText, false). WithalwaysAllowSubtasksenabled, the auto-approval layer returns "approve" without user input, so it proceeds toprovider.delegateParentAndOpenChild({ parentTaskId, message, initialTodos, mode, pendingActionId }).src/core/webview/ClineProvider.ts—delegateParentAndOpenChild()validates the pending action id and runs the transition under the store lock, clearing the pending action only on success:src/core/task-persistence/taskLifecycle.ts—delegateTaskToChild()asserts the transition map:With the task
interrupted,assertValidTransition("interrupted", "delegated")throwsInvalid task status transition: interrupted → delegated.The throw is caught by
delegateParentAndOpenChild()'s rollback, which logs the line quoted above, pops the child off the task stack, deletes the child task, and restores the parent. Because the throw happens inside theatomicReadAndUpdateupdater, no write occurs:pendingActionis still set andstatusis stillinterrupted. That is byte-for-byte the precondition for step 2, so the next load repeats steps 2 to 5 forever. Nothing in the path increments an attempt counter or applies a backoff.Secondary observations
interruptedis effectively terminal.VALID_TASK_STATUS_TRANSITIONS.interrupted = ["completed"], so once a task reachesinterruptedit can neither delegate again nor return toactive. Even after the pending action is cleared, a user has to hand-editstatusback toactiveto make the task usable. That contradicts the intent of resuming an interrupted task.cancelTask()reads the task record from disk before writinginterrupted, sopendingActionsurvives.markDelegatedChildInterrupted()(used when the active task is evicted, for example by navigating to the History list) prefers the in-memory store snapshot, which can be the delegation-time entry, so it can droppendingAction. That is why the bug reproduces intermittently and sometimes appears to "fix itself".interruptedstatus also blocks the normal recovery:delegateTaskToChild()supportsdelegated → active → delegatedwhen the awaited child isinterrupted, but there is no equivalent path out ofinterrupted.Suggested fixes
delegateParentAndOpenChild()rejects the transition, clearpendingActionand persist a normal tool result (for example "Task was interrupted before this tool call could be completed.") instead of leaving the action to be re-executed on every load.pendingAction, or apply a backoff, so a repeatedly failing pending action cannot loop without limit.interrupted → activewhen the user explicitly resumes, or havedelegateTaskToChild()/resumePendingTaskAction()repairinterruptedtoactivebefore delegating.markDelegatedChildInterrupted()cannot droppendingAction(read the record from disk, or merge with the authoritative file).Workaround
With the task closed, in
<globalStorage>/zoocodeorganization.zoo-code/tasks/<task-id>/:Append a durable result for the pending action to
api_conversation_history.json:{"role":"user","content":[{"type":"tool_result","tool_use_id":"<pendingAction.actionId>","content":"Task was interrupted before this tool call could be completed.","is_error":true}],"ts":<epoch_ms>}Set
"pendingAction": nullinhistory_item.json, and set"status": "active"if the task needs to delegate again.Dedupe the repeated
ask: toolentries inui_messages.json(keep the first of each identical payload) and delete any.ui_messages.json.new_*.tmpfiles.Reload the window so the extension re-reads the files. An edit made while the task is open is overwritten from memory.
Related
0ea690508.