Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
1886938
fix(lifecycle): stop rejected subtask replay loops
edelauna Sep 20, 2026
a7f94b4
fix(lifecycle): preserve pending actions in model checks
edelauna Sep 20, 2026
72141a7
fix(lifecycle): preserve replacement pending actions
edelauna Sep 21, 2026
1047343
fix(lifecycle): honor cross-host task deletion
edelauna Sep 21, 2026
d6304f3
fix(lifecycle): preserve completed task records
edelauna Sep 22, 2026
0e04c1e
fix(lifecycle): prevent rejected subtask replay on restart and serial…
coderabbitai[bot] Sep 22, 2026
29ae8ec
fix(persistence): serialize task deletion with history writes
edelauna Sep 23, 2026
db394b7
Merge branch 'main' into issue/1714
edelauna Sep 23, 2026
3c8a82b
fix(lifecycle): scope branch to exact-action settlement and merge main
edelauna Sep 23, 2026
bbde189
fix(lifecycle): handle authoritative settlement results
edelauna Sep 24, 2026
7018228
test(lifecycle): cover settlement lock errors
edelauna Sep 27, 2026
3ff3767
fix(lifecycle): harden rejected action settlement
edelauna Sep 28, 2026
422166b
fix: narrow failed history cleanup
edelauna Sep 28, 2026
ac0e975
fix: preserve atomic rollback backup
edelauna Sep 28, 2026
c842cd2
fix: hold advisory lock during task history deletion
edelauna Sep 28, 2026
8797436
test: provide getStorageBasePath in provider storage mock
edelauna Sep 28, 2026
981b668
fix: flush write-through on partial deleteMany and reject on lock com…
edelauna Sep 28, 2026
748f8e3
test: assert partial-failure write-through call and release error det…
edelauna Sep 28, 2026
eb7e970
fix(lifecycle): narrow PR to rejected-action settlement and dedupe Ge…
edelauna Sep 28, 2026
69f761b
test: require both partial chunks in the Gemini call-ID test
edelauna Sep 28, 2026
29765dc
fix: serialize history deletion with settlement and validate the lock…
edelauna Sep 28, 2026
edcf937
fix: fail task-history deletion closed and verify the disk outcome
edelauna Sep 29, 2026
b68cb8f
Merge remote-tracking branch 'origin/main' into issue/1714
edelauna Sep 29, 2026
c326dad
test: provide getStorageBasePath in the ClineProvider.spec storage mock
edelauna Sep 29, 2026
7c2528c
fix: refresh task history before restart settlement and restore best-…
edelauna Sep 30, 2026
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
45 changes: 26 additions & 19 deletions docs/architecture/task-lifecycle-model.md

Large diffs are not rendered by default.

190 changes: 181 additions & 9 deletions scripts/check-task-lifecycle.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import assert from "node:assert/strict"

import type { HistoryItem } from "../packages/types/src/history"
import type { HistoryItem, PendingTaskAction } from "../packages/types/src/history"

import {
abandonDelegatedChild,
completeDelegatedChild,
delegateTaskToChild,
interruptDelegatedChild,
settleRejectedCreateSubtaskAction,
} from "../src/core/task-persistence/taskLifecycle"

const taskIds = ["parent", "child-a", "child-b"] as const
Expand All @@ -16,16 +17,26 @@ type ModelState = Record<TaskId, HistoryItem | undefined>
interface Transition {
name: string
next: ModelState
delegation?: { parentId: TaskId }
completion?: { childId: TaskId; pendingActionId?: string }
settlement?: { taskId: TaskId; actionId: string }
}

interface TraceStep {
action: string
state: ModelState
}

interface WitnessContext {
prev: ModelState
next: ModelState
transition: Transition
}

const MAX_DEPTH = 12
const MAX_STATES = 10_000
const expectedActions = ["delegate", "interrupt", "complete", "abandon"] as const
const actionIds = ["action-1", "action-2"] as const
const expectedActions = ["delegate", "interrupt", "complete", "abandon", "stage", "settle-rejected"] as const
const semanticLandmarks = {
"interrupted-child-redelegation": (state: ModelState) =>
state.parent?.status === "delegated" &&
Expand All @@ -37,6 +48,58 @@ const semanticLandmarks = {
state["child-a"]?.status === "delegated" &&
state["child-a"].awaitingChildId === "child-b",
} satisfies Record<string, (state: ModelState) => boolean>
const semanticWitnesses = {
"interrupted-pending-delegation-settled": ({ prev, next, transition }: WitnessContext) =>
transition.settlement !== undefined &&
prev[transition.settlement.taskId]?.status === "interrupted" &&
prev[transition.settlement.taskId]?.pendingAction?.actionId === transition.settlement.actionId &&
next[transition.settlement.taskId]?.pendingAction === undefined,
"successful-active-delegation-settlement": ({ prev, next, transition }: WitnessContext) =>
transition.delegation !== undefined &&
prev[transition.delegation.parentId]?.status === "active" &&
prev[transition.delegation.parentId]?.pendingAction?.kind === "create_subtask" &&
next[transition.delegation.parentId]?.pendingAction === undefined,
"completion-preserves-unrelated-pending-action": ({ prev, next, transition }: WitnessContext) => {
if (transition.completion === undefined) return false
const beforeAction = prev[transition.completion.childId]?.pendingAction
return (
beforeAction !== undefined &&
next[transition.completion.childId]?.status === "completed" &&
canonicalTask(next[transition.completion.childId]?.pendingAction) === canonicalTask(beforeAction)
)
},
"stale-settlement-rejected": ({ prev, next, transition }: WitnessContext) => {
if (transition.settlement === undefined) return false
const beforeAction = prev[transition.settlement.taskId]?.pendingAction
return (
beforeAction?.kind === "create_subtask" &&
beforeAction.actionId !== transition.settlement.actionId &&
next[transition.settlement.taskId]?.pendingAction?.actionId === beforeAction.actionId
)
},
"matching-completion-clears-pending-action": ({ prev, next, transition }: WitnessContext) => {
if (transition.completion?.pendingActionId === undefined) return false
const beforeAction = prev[transition.completion.childId]?.pendingAction
const after = next[transition.completion.childId]
return (
beforeAction?.kind === "create_subtask" &&
beforeAction.actionId === transition.completion.pendingActionId &&
after?.status === "completed" &&
after.pendingAction === undefined
)
},
"replacement-completion-preserves-pending-action": ({ prev, next, transition }: WitnessContext) => {
if (transition.completion?.pendingActionId === undefined) return false
const beforeAction = prev[transition.completion.childId]?.pendingAction
const after = next[transition.completion.childId]
return (
beforeAction?.kind === "create_subtask" &&
beforeAction.actionId !== transition.completion.pendingActionId &&
after?.status === "completed" &&
canonicalTask(after.pendingAction) === canonicalTask(beforeAction)
)
},
} satisfies Record<string, (context: WitnessContext) => boolean>

function task(id: TaskId, parentTaskId?: TaskId): HistoryItem {
return {
Expand All @@ -54,6 +117,17 @@ function task(id: TaskId, parentTaskId?: TaskId): HistoryItem {
}
}

function createSubtaskAction(actionId: (typeof actionIds)[number]): PendingTaskAction {
return {
kind: "create_subtask",
actionId,
approvalText: "{}",
mode: "code",
message: `message for ${actionId}`,
todos: [],
}
}

function initialState(): ModelState {
return { parent: task("parent"), "child-a": undefined, "child-b": undefined }
}
Expand All @@ -70,18 +144,38 @@ function transitions(state: ModelState): Transition[] {
const parent = state[parentId]
if (!parent) continue

const awaitedStatus = parent.awaitingChildId ? state[parent.awaitingChildId as TaskId]?.status : undefined
const delegationValid =
parent.status === "active" || (parent.status === "delegated" && awaitedStatus === "interrupted")

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")) {
continue
}
const delegated = delegateTaskToChild(parent, childId, awaitedStatus)
if (!delegationValid) continue
const delegated = { ...delegateTaskToChild(parent, childId, awaitedStatus), pendingAction: undefined }
result.push({
name: `delegate(${parentId}, ${childId})`,
next: replace(state, delegated, task(childId, parentId)),
delegation: { parentId },
})
}

for (const actionId of actionIds) {
if (parent.status === "active" || parent.status === "interrupted") {
result.push({
name: `stage(${parentId}, ${actionId})`,
next: replace(state, { ...parent, pendingAction: createSubtaskAction(actionId) }),
})
}

const pending = parent.pendingAction
if (!delegationValid && parent.status !== "completed" && pending?.kind === "create_subtask") {
result.push({
name: `settle-rejected(${parentId}, ${actionId})`,
next: replace(state, settleRejectedCreateSubtaskAction(parent, actionId)),
settlement: { taskId: parentId, actionId },
})
}
}
}

for (const childId of taskIds) {
Expand All @@ -104,7 +198,19 @@ function transitions(state: ModelState): Transition[] {
result.push({
name: `complete(${childId})`,
next: replace(state, completed.parent, completed.child),
completion: { childId },
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})
for (const actionId of actionIds) {
const completedChild: HistoryItem =
child.pendingAction?.actionId === actionId
? { ...completed.child, pendingAction: undefined }
: completed.child
result.push({
name: `complete(${childId}, ${actionId})`,
next: replace(state, completed.parent, completedChild),
completion: { childId, pendingActionId: actionId },
})
}
}

if (parent.status === "delegated" && parent.awaitingChildId === child.id && child.status === "interrupted") {
Expand Down Expand Up @@ -189,10 +295,49 @@ function checkTransitionInvariants(previous: ModelState, transition: Transition)
violations.push(`${id}: completed task changed after ${transition.name}`)
}
}

const settlement = transition.settlement
if (settlement) {
const before = previous[settlement.taskId]
const after = transition.next[settlement.taskId]
const beforeAction = before?.pendingAction
const afterAction = after?.pendingAction

if (afterAction && canonicalTask(afterAction) !== canonicalTask(beforeAction)) {
violations.push(`${settlement.taskId}: settlement after ${transition.name} modified a replacement action`)
}
if (
!afterAction &&
beforeAction &&
!(beforeAction.kind === "create_subtask" && beforeAction.actionId === settlement.actionId)
) {
violations.push(`${settlement.taskId}: settlement after ${transition.name} cleared a non-matching action`)
}
const beforeRest = { ...before, pendingAction: undefined }
const afterRest = { ...after, pendingAction: undefined }
if (canonicalTask(beforeRest) !== canonicalTask(afterRest)) {
violations.push(
`${settlement.taskId}: settlement after ${transition.name} changed status, lineage, or accounting`,
)
}
}

const completion = transition.completion
if (completion) {
const beforeAction = previous[completion.childId]?.pendingAction
const afterAction = transition.next[completion.childId]?.pendingAction

if (afterAction && canonicalTask(afterAction) !== canonicalTask(beforeAction)) {
violations.push(`${completion.childId}: completion after ${transition.name} replaced a pending action`)
}
if (!afterAction && beforeAction && beforeAction.actionId !== completion.pendingActionId) {
violations.push(`${completion.childId}: completion after ${transition.name} cleared a non-matching action`)
}
}
return violations
}

function canonicalTask(value: HistoryItem | undefined): string {
function canonicalTask(value: unknown): string {
return JSON.stringify(value ?? null)
}

Expand All @@ -204,6 +349,7 @@ function runModelCheck(): number {
const visited = new Set([canonical(start)])
const reachedActions = new Set<string>()
const reachedLandmarks = new Set<string>()
const reachedWitnesses = new Set<string>()
const frontier: ModelState[] = []

for (let index = 0; index < queue.length; index++) {
Expand All @@ -220,6 +366,9 @@ function runModelCheck(): number {

for (const transition of transitions(node.state)) {
reachedActions.add(transition.name.slice(0, transition.name.indexOf("(")))
for (const [name, matches] of Object.entries(semanticWitnesses)) {
if (matches({ prev: node.state, next: transition.next, transition })) reachedWitnesses.add(name)
}
const transitionViolations = checkTransitionInvariants(node.state, transition)
const trace = [...node.trace, { action: transition.name, state: transition.next }]
if (transitionViolations.length) {
Expand All @@ -244,6 +393,10 @@ function runModelCheck(): number {
if (missingLandmarks.length) {
throw new Error(`Task lifecycle model has unreachable semantic landmarks: ${missingLandmarks.join(", ")}`)
}
const missingWitnesses = Object.keys(semanticWitnesses).filter((name) => !reachedWitnesses.has(name))
if (missingWitnesses.length) {
throw new Error(`Task lifecycle model has unreachable semantic witnesses: ${missingWitnesses.join(", ")}`)
}
const unexploredSuccessor = frontier
.flatMap((state) => transitions(state))
.find((transition) => !visited.has(canonical(transition.next)))
Expand Down Expand Up @@ -278,10 +431,29 @@ function runRepresentativeScenarios(): void {
const interruptedCompletion = completeDelegatedChild(delegated, interruptedA, "resumed result")
assert.equal(interruptedCompletion.child.status, "completed")
assert.equal(interruptedCompletion.parent.status, "active")

const rejectedParent: HistoryItem = {
...parent,
status: "interrupted",
pendingAction: createSubtaskAction("action-1"),
}
assert.throws(() => delegateTaskToChild(rejectedParent, childA.id), /Invalid task status transition/)
const settled = settleRejectedCreateSubtaskAction(rejectedParent, "action-1")
assert.equal(settled.status, "interrupted")
assert.equal(settled.pendingAction, undefined)
assert.equal(settled.childIds, rejectedParent.childIds)

const replacement = { ...rejectedParent, pendingAction: createSubtaskAction("action-2") }
assert.equal(settleRejectedCreateSubtaskAction(replacement, "action-1"), replacement)
assert.equal(
settleRejectedCreateSubtaskAction({ ...rejectedParent, status: "completed" }, "action-1").pendingAction
?.actionId,
"action-1",
)
}

runRepresentativeScenarios()
const checkedStates = runModelCheck()
console.log(
`Task lifecycle model check passed: ${checkedStates} reachable states, ${expectedActions.length}/${expectedActions.length} actions reachable, ${Object.keys(semanticLandmarks).length}/${Object.keys(semanticLandmarks).length} landmarks reached, depth <= ${MAX_DEPTH}, ${taskIds.length} task slots`,
`Task lifecycle model check passed: ${checkedStates} reachable states, ${expectedActions.length}/${expectedActions.length} actions reachable, ${Object.keys(semanticLandmarks).length}/${Object.keys(semanticLandmarks).length} landmarks reached, ${Object.keys(semanticWitnesses).length}/${Object.keys(semanticWitnesses).length} semantic witnesses reached, depth <= ${MAX_DEPTH}, ${taskIds.length} task slots`,
)
Loading
Loading