Skip to content

[BUG] Infinite subtask creation loop when a pending new_task survives an interruption (Invalid task status transition: interrupted → delegated) #1714

Description

@huankimtran

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

  1. Auto-approve settings: turn the Subtasks toggle off.
  2. 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.
  3. Approve the new_task prompt. A child task opens. The parent becomes delegated, the child is active.
  4. In the child, ask it to delegate again. The new_task approval prompt appears. Leave it unanswered.
  5. 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.
  6. Auto-approve settings: turn the Subtasks toggle back on.
  7. 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:

  1. 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.

  2. 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
    }
  3. 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 }).

  4. 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 }
  5. 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

  1. 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.
  2. Make the retry bounded. Record an attempt count on pendingAction, or apply a backoff, so a repeatedly failing pending action cannot loop without limit.
  3. 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.
  4. 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>/:

  1. 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>}
  2. Set "pendingAction": null in history_item.json, and set "status": "active" if the task needs to delegate again.

  3. 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.

  4. Reload the window so the extension re-reads the files. An edit made while the task is open is overwritten from memory.

Related

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions