Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions apps/vscode-e2e/src/fixtures/subtasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ const SUBTASK_FAST_PARENT_MARKER = "SUBTASK_PARENT_IMMEDIATE_COMPLETION"
const SUBTASK_FAST_CHILD_MARKER = "SUBTASK_CHILD_IMMEDIATE_COMPLETION"
const SUBTASK_APPROVAL_RESTORE_PARENT_MARKER = "SUBTASK_PARENT_APPROVAL_RESTORE"
const SUBTASK_APPROVAL_RESTORE_CHILD_MARKER = "SUBTASK_CHILD_APPROVAL_RESTORE"
export const SUBTASK_PENDING_REPLAY_ROOT = "SUBTASK_PENDING_REPLAY_ROOT: Create a child task."
const SUBTASK_PENDING_REPLAY_CHILD = "SUBTASK_PENDING_REPLAY_CHILD: Create a grandchild task."
const SUBTASK_PENDING_REPLAY_LEAF = "SUBTASK_PENDING_REPLAY_LEAF: Wait for user instructions."
const SUBTASK_XPROFILE_PARENT_MARKER = "SUBTASK_PARENT_CROSS_PROFILE"
const SUBTASK_XPROFILE_SAME_CHILD_MARKER = "SUBTASK_CHILD_SAME_PROFILE"
const SUBTASK_XPROFILE_DIFFERENT_CHILD_MARKER = "SUBTASK_CHILD_DIFFERENT_PROFILE"
Expand Down Expand Up @@ -127,6 +130,30 @@ const completionAfterAnswer = (followupId: string, completionId: string) => ({
})

export function addSubtaskFixtures(mock: InstanceType<typeof LLMock>) {
for (const [prompt, childPrompt, id] of [
[SUBTASK_PENDING_REPLAY_ROOT, SUBTASK_PENDING_REPLAY_CHILD, "call_pending_replay_root"],
[SUBTASK_PENDING_REPLAY_CHILD, SUBTASK_PENDING_REPLAY_LEAF, "call_pending_replay_child"],
]) {
mock.addFixture({
match: { userMessage: prompt, sequenceIndex: 0 },
response: {
toolCalls: [{ name: "new_task", arguments: JSON.stringify({ mode: "ask", message: childPrompt }), id }],
},
})
}
mock.addFixture({
match: { userMessage: SUBTASK_PENDING_REPLAY_LEAF },
response: {
toolCalls: [
{
name: "ask_followup_question",
arguments: JSON.stringify({ question: "What should I do next?", follow_up: [] }),
id: "call_pending_replay_wait",
},
],
},
})

mock.addFixture({
match: {
userMessage: new RegExp(SUBTASK_APPROVAL_RESTORE_PARENT_MARKER),
Expand Down
59 changes: 59 additions & 0 deletions apps/vscode-e2e/src/suite/subtasks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
SUBTASK_INTERRUPT_PARENT_PROMPT,
SUBTASK_INTERRUPT_PARENT_RESULT,
SUBTASK_PARENT_PROMPT,
SUBTASK_PENDING_REPLAY_ROOT,
SUBTASK_XPROFILE_DIFFERENT_CHILD_RESULT,
SUBTASK_XPROFILE_PARENT_PROMPT,
SUBTASK_XPROFILE_PARENT_RESULT,
Expand Down Expand Up @@ -260,6 +261,64 @@ suite("Roo Code Subtasks", function () {
}
})

test("interrupted child replays pending new_task once with subtask auto-approval", async () => {
const api = globalThis.api
const asks: Record<string, ClineMessage[]> = {}
const delegations: Array<[string, string]> = []
const onMessage = ({ taskId, message }: { taskId: string; message: ClineMessage }) => {
if (isCompletedAsk(message)) (asks[taskId] ??= []).push(message)
}
const onDelegated = (parentId: string, childId: string) => {
delegations.push([parentId, childId])
}
const hasNewTaskAsk = (taskId: string) =>
asks[taskId]?.some(
(message) => message.ask === "tool" && JSON.parse(message.text ?? "{}").tool === "newTask",
) ?? false
api.on(RooCodeEventName.Message, onMessage)
api.on(RooCodeEventName.TaskDelegated, onDelegated)
try {
const rootId = await api.startNewTask({
configuration: {
mode: "ask",
autoApprovalEnabled: true,
alwaysAllowSubtasks: false,
enableCheckpoints: false,
},
text: SUBTASK_PENDING_REPLAY_ROOT,
})
await waitFor(() => hasNewTaskAsk(rootId))
await api.approveCurrentAsk()
await waitFor(() => delegations.length === 1)
assert.ok(delegations[0])
const childId = delegations[0][1]
await waitFor(() => hasNewTaskAsk(childId))
await api.clearCurrentTask()
const interrupted = await api.getTaskHistoryItem(childId)
assert.strictEqual(interrupted?.status, "interrupted")
assert.strictEqual(interrupted?.pendingAction?.kind, "create_subtask")

await api.setConfiguration({ autoApprovalEnabled: true, alwaysAllowSubtasks: true })
await api.resumeTask(childId)
await waitFor(() => delegations.length === 2)
assert.ok(delegations[1])
const [delegatingId, grandchildId] = delegations[1]
assert.strictEqual(delegatingId, childId)
await waitFor(() => asks[grandchildId]?.some(({ ask }) => ask === "followup") ?? false)
const resumed = await api.getTaskHistoryItem(childId)
assert.strictEqual(resumed?.status, "delegated")
assert.strictEqual(resumed?.pendingAction, undefined)
assert.strictEqual(resumed?.parentTaskId, rootId)
assert.deepStrictEqual(resumed?.childIds, [grandchildId])
assert.strictEqual(api.getCurrentTaskStack().at(-1), grandchildId)
assert.strictEqual(delegations.length, 2)
} finally {
api.off(RooCodeEventName.Message, onMessage)
api.off(RooCodeEventName.TaskDelegated, onDelegated)
while (api.getCurrentTaskStack().length > 0) await api.clearCurrentTask()
}
})

// Smoke: child completing normally must resume the parent task.
test("child task returns to parent after normal completion", async () => {
const api = globalThis.api
Expand Down
2 changes: 2 additions & 0 deletions docs/architecture/task-lifecycle-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ The model has three fixed task slots, enough to cover competing siblings and a n

Production completion also accepts a recovery-compatible `active` parent that still awaits the returning child, then clears the stale pointers. Normal model transitions never create that intermediate state, so it is covered by a focused reducer test rather than admitted as a generally valid reachable state.

An approved delegation can also resume an `interrupted` task directly into `delegated`. The `resume-delegate` action and detached-task-delegation landmark cover this path without allowing arbitrary message saves to reactivate interrupted tasks. The reducer retains the task's own parent link only when that parent still awaits it; otherwise it clears stale lineage instead of taking ownership back from a newer sibling. Provider rollback persists an error tool result before rehydrating a failed pending delegation, so history resume reconciles the action rather than auto-approving it again. Failed result persistence stops restoration. Provider and history-resume tests cover this persistence boundary; the subtask extension-host smoke test covers interrupted pending approval replay with auto-approval enabled (#1714).

## Shared-store concurrency model

The same `pnpm lifecycle:model-check` command also runs a second bounded explorer over two `TaskHistoryStore` hosts. It imports the production `computeHistoryDelta` and `mergeHistoryDelta` functions, so its semantics match the store rather than assuming coherent caches or transactional pair writes:
Expand Down
26 changes: 22 additions & 4 deletions scripts/check-task-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,12 @@ interface TraceStep {

const MAX_DEPTH = 12
const MAX_STATES = 10_000
const expectedActions = ["delegate", "interrupt", "complete", "abandon"] as const
const expectedActions = ["delegate", "resume-delegate", "interrupt", "complete", "abandon"] as const
const semanticLandmarks = {
"detached-task-delegation": (state: ModelState) =>
state["child-a"]?.status === "delegated" &&
state["child-a"].parentTaskId === undefined &&
state["child-a"].awaitingChildId === "child-b",
"interrupted-child-redelegation": (state: ModelState) =>
state.parent?.status === "delegated" &&
state.parent.awaitingChildId === "child-b" &&
Expand Down Expand Up @@ -73,12 +77,17 @@ function transitions(state: ModelState): Transition[] {
for (const childId of taskIds) {
if (childId === parentId || state[childId]) continue
const awaitedStatus = parent.awaitingChildId ? state[parent.awaitingChildId as TaskId]?.status : undefined
if (parent.status !== "active" && !(parent.status === "delegated" && awaitedStatus === "interrupted")) {
if (
parent.status !== "active" &&
parent.status !== "interrupted" &&
!(parent.status === "delegated" && awaitedStatus === "interrupted")
) {
continue
}
const delegated = delegateTaskToChild(parent, childId, awaitedStatus)
const owningParent = parent.parentTaskId ? state[parent.parentTaskId as TaskId] : undefined
const delegated = delegateTaskToChild(parent, childId, awaitedStatus, owningParent)
result.push({
name: `delegate(${parentId}, ${childId})`,
name: `${parent.status === "interrupted" ? "resume-delegate" : "delegate"}(${parentId}, ${childId})`,
next: replace(state, delegated, task(childId, parentId)),
})
}
Expand Down Expand Up @@ -263,8 +272,17 @@ function runRepresentativeScenarios(): void {
assert.throws(() => delegateTaskToChild(delegated, "child-b", "active"), /not interrupted/)

const interruptedA = interruptDelegatedChild(delegated, childA)
const resumedA = delegateTaskToChild(interruptedA, "child-b", undefined, delegated)
assert.equal(resumedA.status, "delegated")
assert.equal(resumedA.parentTaskId, "parent")
const nestedReturn = completeDelegatedChild(resumedA, task("child-b", "child-a"), "nested result")
assert.equal(completeDelegatedChild(delegated, nestedReturn.parent, "resumed result").child.status, "completed")

const redelegated = delegateTaskToChild(delegated, "child-b", interruptedA.status)
assert.throws(() => completeDelegatedChild(redelegated, interruptedA, "stale"), /not delegated to child/)
const detached = delegateTaskToChild(interruptedA, "new-grandchild", undefined, redelegated)
assert.equal(detached.parentTaskId, undefined)
assert.equal(detached.rootTaskId, undefined)

const abandoned = abandonDelegatedChild(delegated, interruptedA)
assert.throws(() => completeDelegatedChild(abandoned.parent, abandoned.child, "late"), /not delegated to child/)
Expand Down
Loading
Loading