Skip to content

Commit fc5a487

Browse files
committed
feat(task-lifecycle): explicit interrupted status + delegation state machine
1 parent 367013f commit fc5a487

14 files changed

Lines changed: 1166 additions & 422 deletions

apps/vscode-e2e/src/suite/subtasks.test.ts

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -617,4 +617,165 @@ suite("Roo Code Subtasks", function () {
617617
await waitFor(() => api.getCurrentTaskStack().length === 0).catch(() => {})
618618
}
619619
})
620+
621+
// Issue #559: explicit "Abandon subtask" action. Unlike cancellation alone (which leaves
622+
// the child "interrupted" and the parent "delegated" so the child can still resume and
623+
// report back), abandoning severs the link outright: the parent goes back to "active" and
624+
// the child's parentTaskId/rootTaskId are cleared so a later resume can never reattach it.
625+
test("abandoning an interrupted subtask severs the parent-child link", async () => {
626+
const api = globalThis.api
627+
const asks: Record<string, ClineMessage[]> = {}
628+
const says: Record<string, ClineMessage[]> = {}
629+
630+
const messageHandler = ({ taskId, message }: { taskId: string; message: ClineMessage }) => {
631+
if (message.type === "ask") {
632+
asks[taskId] = asks[taskId] || []
633+
asks[taskId].push(message)
634+
}
635+
if (message.type === "say" && message.partial === false) {
636+
says[taskId] = says[taskId] || []
637+
says[taskId].push(message)
638+
}
639+
}
640+
641+
api.on(RooCodeEventName.Message, messageHandler)
642+
643+
try {
644+
const parentTaskId = await api.startNewTask({
645+
configuration: {
646+
mode: "ask",
647+
alwaysAllowModeSwitch: true,
648+
alwaysAllowSubtasks: true,
649+
autoApprovalEnabled: true,
650+
enableCheckpoints: false,
651+
},
652+
text: SUBTASK_INTERRUPT_PARENT_PROMPT,
653+
})
654+
655+
let childTaskId: string | undefined
656+
await waitFor(() => {
657+
const stack = api.getCurrentTaskStack()
658+
const current = stack[stack.length - 1]
659+
if (current && current !== parentTaskId) {
660+
childTaskId = current
661+
return true
662+
}
663+
return false
664+
})
665+
666+
await waitFor(() => asks[childTaskId!]?.some(({ ask }) => ask === "followup") ?? false)
667+
await waitFor(async () => (await api.getTaskApiConversationHistoryLength(childTaskId!)) > 0)
668+
669+
// Cancel the child — marked "interrupted", parent stays "delegated".
670+
await api.cancelCurrentTask()
671+
672+
await waitFor(() => api.getCurrentTaskStack().at(-1) === childTaskId)
673+
await waitFor(
674+
() => asks[childTaskId!]?.some(({ type, ask }) => type === "ask" && ask === "resume_task") ?? false,
675+
)
676+
677+
const interruptedChild = await api.getTaskHistoryItem(childTaskId!)
678+
assert.strictEqual(interruptedChild?.status, "interrupted", "Child should be marked interrupted")
679+
680+
const delegatedParent = await api.getTaskHistoryItem(parentTaskId)
681+
assert.strictEqual(delegatedParent?.status, "delegated", "Parent should still be delegated before abandon")
682+
assert.strictEqual(
683+
delegatedParent?.awaitingChildId,
684+
childTaskId,
685+
"Parent should await the interrupted child",
686+
)
687+
688+
// The interrupted child is the live/open task at this point (cancelTask rehydrates
689+
// it onto the stack). Abandon must close that live instance before severing the
690+
// persisted link — otherwise a later save on the still-open child would rebuild
691+
// parentTaskId/rootTaskId from its live (readonly) fields and silently reattach it.
692+
const abandoned = await api.abandonSubtask(childTaskId!)
693+
assert.strictEqual(abandoned, true, "abandonSubtask should report the link was severed")
694+
695+
await waitFor(() => api.getCurrentTaskStack().at(-1) !== childTaskId)
696+
697+
const parentAfterAbandon = await api.getTaskHistoryItem(parentTaskId)
698+
assert.strictEqual(parentAfterAbandon?.status, "active", "Parent should return to active after abandon")
699+
assert.strictEqual(
700+
parentAfterAbandon?.awaitingChildId,
701+
undefined,
702+
"Parent awaitingChildId should be cleared",
703+
)
704+
assert.strictEqual(parentAfterAbandon?.delegatedToId, undefined, "Parent delegatedToId should be cleared")
705+
706+
const childAfterAbandon = await api.getTaskHistoryItem(childTaskId!)
707+
// The child's own status is left untouched (VALID_TRANSITIONS only allows interrupted → completed);
708+
// only its parent/root links are cleared so it can never reattach to the parent again.
709+
assert.strictEqual(childAfterAbandon?.status, "interrupted", "Child status stays interrupted")
710+
assert.strictEqual(childAfterAbandon?.parentTaskId, undefined, "Child parentTaskId should be cleared")
711+
assert.strictEqual(childAfterAbandon?.rootTaskId, undefined, "Child rootTaskId should be cleared")
712+
713+
// A second abandon call is a no-op since the parent is no longer delegated to this child.
714+
const secondAbandon = await api.abandonSubtask(childTaskId!)
715+
assert.strictEqual(secondAbandon, false, "Second abandonSubtask call should be a no-op")
716+
717+
// Resume and complete the abandoned child — it must NOT reopen or reattach to the
718+
// parent. Before the abandon fix, a subsequent save on the still-live child could
719+
// silently rewrite its persisted parentTaskId back to the parent; this proves the
720+
// link stays severed all the way through a real resume/save/complete cycle.
721+
// api.resumeTask() re-instantiates the child from history, which re-raises its own
722+
// "resume_task" ask; answering it with the follow-up answer (same pattern the sibling
723+
// "cancelled child completes and reopens parent" test above uses) both resumes the
724+
// task and supplies the answer the re-asked follow-up question is waiting for.
725+
// asks[childTaskId] already holds the earlier resume_task ask from the pre-abandon
726+
// cancellation, so the wait below must look for a NEW one, not just any occurrence.
727+
const askCountBeforeResume = asks[childTaskId!]?.length ?? 0
728+
await api.resumeTask(childTaskId!)
729+
await waitFor(() =>
730+
(asks[childTaskId!] ?? [])
731+
.slice(askCountBeforeResume)
732+
.some(({ type, ask }) => type === "ask" && ask === "resume_task"),
733+
)
734+
735+
const completedChildTaskId = await waitUntilCompleted({
736+
api,
737+
start: async () => {
738+
await api.sendMessage(SUBTASK_INTERRUPT_CHILD_FOLLOWUP_ANSWER)
739+
return childTaskId!
740+
},
741+
})
742+
743+
assert.strictEqual(
744+
completedChildTaskId,
745+
childTaskId,
746+
"The abandoned child itself should be the task that completes, not the parent",
747+
)
748+
assert.strictEqual(
749+
says[parentTaskId]?.find(({ say }) => say === "completion_result"),
750+
undefined,
751+
"Parent must never complete/reopen after its abandoned child resumes and completes",
752+
)
753+
754+
const parentAfterChildCompletes = await api.getTaskHistoryItem(parentTaskId)
755+
assert.strictEqual(
756+
parentAfterChildCompletes?.status,
757+
"active",
758+
"Parent status must remain untouched by the abandoned child's completion",
759+
)
760+
assert.strictEqual(
761+
parentAfterChildCompletes?.awaitingChildId,
762+
undefined,
763+
"Parent must not start awaiting the abandoned child again",
764+
)
765+
766+
const childAfterCompletion = await api.getTaskHistoryItem(childTaskId!)
767+
assert.strictEqual(
768+
childAfterCompletion?.parentTaskId,
769+
undefined,
770+
"Child parentTaskId must still be cleared after it completes on its own — " +
771+
"proves the live-instance save did not resurrect the old link",
772+
)
773+
} finally {
774+
api.off(RooCodeEventName.Message, messageHandler)
775+
while (api.getCurrentTaskStack().length > 0) {
776+
await api.clearCurrentTask()
777+
}
778+
await waitFor(() => api.getCurrentTaskStack().length === 0).catch(() => {})
779+
}
780+
})
620781
})

packages/types/src/api.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,14 @@ export interface RooCodeAPI extends EventEmitter<RooCodeAPIEvents> {
6464
* Cancels the current task.
6565
*/
6666
cancelCurrentTask(): Promise<void>
67+
/**
68+
* Severs the delegated parent-child link for an interrupted (cancelled, not running)
69+
* subtask, so the parent stops waiting on it and returns to "active". No-op (returns
70+
* false) unless the child is interrupted and its parent is still delegated to it.
71+
* @param childTaskId The ID of the child (subtask) to abandon.
72+
* @returns True if the link was severed, false if there was nothing to abandon.
73+
*/
74+
abandonSubtask(childTaskId: string): Promise<boolean>
6775
/**
6876
* Sends a message to the current task.
6977
* @param message Optional message to send.

packages/types/src/vscode-extension-host.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,7 @@ export interface WebviewMessage {
460460
| "shareCurrentTask"
461461
| "showTaskWithId"
462462
| "deleteTaskWithId"
463+
| "abandonSubtaskWithId"
463464
| "exportTaskWithId"
464465
| "importSettings"
465466
| "exportSettings"

0 commit comments

Comments
 (0)