Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
e31a654
fix(delegation): preserve live child delegation links across extensio…
Sep 2, 2026
1168e84
test(task-persistence): cover mutation edge cases for live child live…
Sep 3, 2026
973fa6b
test(task-persistence): kill surviving static-mutant arithmetic mutan…
Sep 3, 2026
46b0834
chore(gitignore): ignore local worktrees (.wt-*) and scratch files
Sep 3, 2026
38f03dc
test(task-persistence): make liveness-boundary tests filesystem-preci…
Sep 3, 2026
72401e7
fix(delegation): guard replayDelegationRepairIntent against live cros…
Sep 4, 2026
b339549
fix(delegation): run delegation reconciliation on periodic reconcile …
Sep 5, 2026
365f97c
fix(delegation): use console.warn for live-child skip log
Sep 5, 2026
d06cc75
test(delegation): route undefined child mtime through initialize() en…
Sep 5, 2026
34a4b87
test(lifecycle): model cross-window child liveness guard in lifecycle…
Sep 5, 2026
ac85ed3
chore(gitignore): drop local worktree and scratch ignore patterns
Sep 5, 2026
5e37522
test(delegation): kill 15 surviving changed-code mutants
Sep 5, 2026
382ec05
test(delegation): replace fixed-count timer pump with condition polling
Sep 5, 2026
9355da1
fix(delegation): harden cross-window liveness guards per CodeRabbit r…
Sep 8, 2026
36f41e6
fix(delegation): roll back ownership claim on non-started resume path…
Sep 8, 2026
e90c99a
test(delegation): add markLocallyInactive to wholesale TaskHistorySto…
Sep 8, 2026
831de26
test(delegation): kill 19 changed-code mutants in claim/rollback and …
Sep 8, 2026
11a2ea2
fix(delegation): close ownership race in reconciliation; CodeRabbit r…
Sep 8, 2026
a4b3786
test(delegation): strengthen resolved-task assertion in hookless crea…
Sep 8, 2026
d7cbec7
refactor(webview): eliminate all 12 no-explicit-any violations from C…
Sep 8, 2026
82300a6
refactor(task): eliminate all 17 no-explicit-any violations from Task.ts
Sep 8, 2026
be2c084
test(task): kill 43 changed-code mutants surfaced by Task.ts type-cle…
Sep 8, 2026
446df12
test(task): pin reasoning-block id key-absence with explicit property…
Sep 8, 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
43 changes: 23 additions & 20 deletions docs/architecture/task-lifecycle-model.md

Large diffs are not rendered by default.

204 changes: 179 additions & 25 deletions scripts/check-task-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,24 @@ import {

const taskIds = ["parent", "child-a", "child-b"] as const
type TaskId = (typeof taskIds)[number]
type ModelState = Record<TaskId, HistoryItem | undefined>
type TaskMap = Record<TaskId, HistoryItem | undefined>

/**
* Abstract cross-window liveness flag. Production decides whether an active
* child awaited by a delegated parent belongs to another live window by
* comparing the child's history-file mtime against a 5-minute threshold
* (`TaskHistoryStore.LIVE_CHILD_MTIME_THRESHOLD_MS`). The model never reads
* wall-clock time: `liveElsewhere[child]` is true exactly when the modeled
* mtime is "recent" (the child is owned by another window) and false when it
* is "stale" or unreadable (the child is a crash orphan, repaired
* conservatively).
*/
type LivenessMap = Record<TaskId, boolean>

interface ModelState {
tasks: TaskMap
liveElsewhere: LivenessMap
}

interface Transition {
name: string
Expand All @@ -25,17 +42,55 @@ interface TraceStep {

const MAX_DEPTH = 12
const MAX_STATES = 10_000
const expectedActions = ["delegate", "interrupt", "complete", "abandon"] as const
const expectedActions = [
"delegate",
"interrupt",
"complete",
"abandon",
"markLiveElsewhere",
"expireLiveElsewhere",
"reconcileStartup",
] as const
const semanticLandmarks = {
"interrupted-child-redelegation": (state: ModelState) =>
state.parent?.status === "delegated" &&
state.parent.awaitingChildId === "child-b" &&
state["child-a"]?.status === "interrupted",
state.tasks.parent?.status === "delegated" &&
state.tasks.parent.awaitingChildId === "child-b" &&
state.tasks["child-a"]?.status === "interrupted",
"nested-delegation": (state: ModelState) =>
state.parent?.status === "delegated" &&
state.parent.awaitingChildId === "child-a" &&
state["child-a"]?.status === "delegated" &&
state["child-a"].awaitingChildId === "child-b",
state.tasks.parent?.status === "delegated" &&
state.tasks.parent.awaitingChildId === "child-a" &&
state.tasks["child-a"]?.status === "delegated" &&
state.tasks["child-a"].awaitingChildId === "child-b",
// Proves the fix for the cross-window misrepair bug (PR #1495): startup
// reconciliation must leave a delegated parent awaiting an active child
// owned by another window untouched. The reconciliation skip is an identity
// transition, so this landmark plus the universal transition invariant in
// `checkTransitionInvariants` (no reachable action may clear the link while
// the child is active and live-elsewhere) formalizes "not repaired".
"live-child-preserved-across-reconciliation": (state: ModelState) => {
const parent = state.tasks.parent
if (parent?.status !== "delegated" || !parent.awaitingChildId) {
return false
}
const childId = parent.awaitingChildId as TaskId
return state.tasks[childId]?.status === "active" && state.liveElsewhere[childId]
},
// Proves the repair half of the same reconciliation outcome still works: a
// non-live (crash-orphan) active child is repaired to interrupted while the
// parent resumes as active with both delegation pointers cleared. This
// state class is only reachable through `reconcileStartup`, never through
// `interrupt`/`abandon`/`complete`.
"crash-orphan-repaired-by-startup": (state: ModelState) => {
const parent = state.tasks.parent
const child = state.tasks["child-a"]
return (
parent?.status === "active" &&
!parent.awaitingChildId &&
child?.status === "interrupted" &&
child.parentTaskId === "parent" &&
!state.liveElsewhere["child-a"]
)
},
} satisfies Record<string, (state: ModelState) => boolean>

function task(id: TaskId, parentTaskId?: TaskId): HistoryItem {
Expand All @@ -55,24 +110,33 @@ function task(id: TaskId, parentTaskId?: TaskId): HistoryItem {
}

function initialState(): ModelState {
return { parent: task("parent"), "child-a": undefined, "child-b": undefined }
return {
tasks: { parent: task("parent"), "child-a": undefined, "child-b": undefined },
liveElsewhere: { parent: false, "child-a": false, "child-b": false },
}
}

function replace(state: ModelState, ...updates: HistoryItem[]): ModelState {
const next = { ...state }
for (const update of updates) next[update.id as TaskId] = update
return next
const tasks = { ...state.tasks }
for (const update of updates) tasks[update.id as TaskId] = update
return { tasks, liveElsewhere: state.liveElsewhere }
}

function transitions(state: ModelState): Transition[] {
const result: Transition[] = []
for (const parentId of taskIds) {
const parent = state[parentId]
const parent = state.tasks[parentId]
if (!parent) continue

// A parent marked live-elsewhere is owned by another window; window-local
// delegation from it would race that window's own lifecycle operations.
if (state.liveElsewhere[parentId]) continue

for (const childId of taskIds) {
if (childId === parentId || state[childId]) continue
const awaitedStatus = parent.awaitingChildId ? state[parent.awaitingChildId as TaskId]?.status : undefined
if (childId === parentId || state.tasks[childId]) continue
const awaitedStatus = parent.awaitingChildId
? state.tasks[parent.awaitingChildId as TaskId]?.status
: undefined
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (parent.status !== "active" && !(parent.status === "delegated" && awaitedStatus === "interrupted")) {
continue
}
Expand All @@ -85,11 +149,18 @@ function transitions(state: ModelState): Transition[] {
}

for (const childId of taskIds) {
const child = state[childId]
const child = state.tasks[childId]
if (!child?.parentTaskId) continue
const parent = state[child.parentTaskId as TaskId]
const parent = state.tasks[child.parentTaskId as TaskId]
if (!parent) continue

// A child marked live-elsewhere is owned by another window's session, so
// window-local lifecycle operations cannot target it until the flag
// expires. `checkTransitionInvariants` re-proves universally that no
// reachable action clears the parent's link while the child is active
// and live-elsewhere.
if (state.liveElsewhere[childId]) continue

if (parent.status === "delegated" && parent.awaitingChildId === child.id && child.status === "active") {
const interrupted = interruptDelegatedChild(parent, child)
result.push({ name: `interrupt(${childId})`, next: replace(state, interrupted) })
Expand All @@ -115,21 +186,85 @@ function transitions(state: ModelState): Transition[] {
})
}
}

// Cross-window startup reconciliation (`TaskHistoryStore.reconcileDelegationStateCore`,
// run at initialize() and on every periodic tick). For every delegated parent
// whose awaited child is active, the outcome is decided solely by the
// abstract liveness flag:
// - stale/unreadable mtime (not live-elsewhere) → repair: child → interrupted
// via the shared production reducer, parent → active with both delegation
// pointers cleared. The parent-side rewrite is modeled directly here
// because production performs it as administrative recovery through
// `upsertCore(..., { skipTransitionCheck: true })`, outside the shared
// `taskLifecycle.ts` reducers; the child side matches `interruptDelegatedChild`.
// - recent mtime (live-elsewhere) → skip: the pre-fix bug repaired exactly
// this child, breaking the delegation link so the subtask's completion
// could no longer return to the parent. The fix `continue`s, so the
// action stays observable (it still marks `reconcileStartup` as executed)
// while intentionally not producing a new state.
for (const parentId of taskIds) {
const parent = state.tasks[parentId]
if (parent?.status !== "delegated" || !parent.awaitingChildId) continue
const childId = parent.awaitingChildId as TaskId
const child = state.tasks[childId]
if (child?.status !== "active") continue
if (state.liveElsewhere[childId]) {
result.push({ name: `reconcileStartup(${parentId})`, next: state })
continue
}
const repairedParent: HistoryItem = {
...parent,
status: "active",
awaitingChildId: undefined,
delegatedToId: undefined,
}
const repairedChild = interruptDelegatedChild(parent, child)
result.push({
name: `reconcileStartup(${parentId})`,
next: replace(state, repairedParent, repairedChild),
})
}

// Model actions for the abstract mtime liveness flag: `markLiveElsewhere`
// represents another window actively persisting the child (recent mtime),
// and `expireLiveElsewhere` represents the owning window going quiet past
// the threshold (e.g. it crashed after startup skipped its repair), after
// which the next `reconcileStartup` repairs it as a crash orphan. Only
// active tasks that are themselves children can toggle the flag; the root
// slot has no owning window in this bug class, and restricting the flag to
// child sessions keeps the liveness dimension from multiplying the state
// space beyond the explicit budget.
for (const id of taskIds) {
const current = state.tasks[id]
if (current?.status !== "active" || !current.parentTaskId) continue
const id2 = id as TaskId
if (!state.liveElsewhere[id2]) {
result.push({
name: `markLiveElsewhere(${id2})`,
next: { tasks: state.tasks, liveElsewhere: { ...state.liveElsewhere, [id2]: true } },
})
} else {
result.push({
name: `expireLiveElsewhere(${id2})`,
next: { tasks: state.tasks, liveElsewhere: { ...state.liveElsewhere, [id2]: false } },
})
}
}
return result
}

function invariantViolations(state: ModelState): string[] {
const violations: string[] = []
for (const id of taskIds) {
const current = state[id]
const current = state.tasks[id]
if (!current) continue

if (current.status === "delegated") {
if (!current.awaitingChildId || current.delegatedToId !== current.awaitingChildId) {
violations.push(`${id}: delegated task must point to exactly one awaited child`)
continue
}
const child = state[current.awaitingChildId as TaskId]
const child = state.tasks[current.awaitingChildId as TaskId]
if (!child || child.parentTaskId !== id || child.status === "completed") {
violations.push(`${id}: awaited child must exist, link back, and not be completed`)
}
Expand All @@ -141,7 +276,7 @@ function invariantViolations(state: ModelState): string[] {
}

if (current.parentTaskId && current.status !== "interrupted") {
const parent = state[current.parentTaskId as TaskId]
const parent = state.tasks[current.parentTaskId as TaskId]
if (current.status !== "completed" && parent?.awaitingChildId !== id) {
violations.push(`${id}: active or delegated linked child must be the child its parent awaits`)
}
Expand All @@ -155,14 +290,14 @@ function invariantViolations(state: ModelState): string[] {
break
}
ancestors.add(cursor)
cursor = state[cursor as TaskId]?.parentTaskId
cursor = state.tasks[cursor as TaskId]?.parentTaskId
}
}
return violations
}

function canonical(state: ModelState): string {
return JSON.stringify(taskIds.map((id) => state[id] ?? null))
return JSON.stringify([taskIds.map((id) => state.tasks[id] ?? null), taskIds.map((id) => state.liveElsewhere[id])])
}

function formatCounterexample(message: string, trace: TraceStep[]): string {
Expand All @@ -183,10 +318,29 @@ function formatCounterexample(message: string, trace: TraceStep[]): string {
function checkTransitionInvariants(previous: ModelState, transition: Transition): string[] {
const violations: string[] = []
for (const id of taskIds) {
const before = previous[id]
const after = transition.next[id]
const before = previous.tasks[id]
const after = transition.next.tasks[id]
if (before?.status === "completed" && canonicalTask(before) !== canonicalTask(after)) {
violations.push(`${id}: completed task changed after ${transition.name}`)
continue
}
// Cross-window ownership guard (PR #1495 bug class): no transition may
// clear a delegated parent's link to a child that is active AND marked
// live-elsewhere. Pre-fix, startup reconciliation repaired exactly these
// children; the mtime guard skips them, so the only enabled successor for
// such a state is the identity reconciliation. Any future model edit
// that reintroduces a link-clearing transition on a live-elsewhere child
// fails here with the shortest causal trace.
if (before?.status === "delegated" && before.awaitingChildId) {
const childId = before.awaitingChildId as TaskId
const childBefore = previous.tasks[childId]
if (childBefore?.status === "active" && previous.liveElsewhere[childId]) {
if (after?.status !== "delegated" || after.awaitingChildId !== childId) {
violations.push(
`${id}: ${transition.name} cleared delegation to active live-elsewhere child ${childId}`,
)
}
}
}
}
return violations
Expand Down
8 changes: 7 additions & 1 deletion src/__tests__/helpers/provider-stub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ type ProviderStubFields = {
delegationTransitionLocks?: Map<string, Promise<void>>
cancelledDelegationChildIds?: Set<string>
log?: ReturnType<typeof vi.fn>
taskHistoryStore?: { get: (id: string) => unknown }
taskHistoryStore?: {
get: (id: string) => unknown
markLocallyActive?: (taskId: string) => void
markLocallyInactive?: (taskId: string) => void
}
taskRegistry?: TaskRegistry
clineStack?: Task[]
tasks?: Task[]
Expand Down Expand Up @@ -38,6 +42,8 @@ export function makeProviderStub<T extends object>(stub: T): ClineProvider {
s.cancelledDelegationChildIds ??= new Set()
s.log ??= vi.fn()
s.taskHistoryStore ??= { get: () => undefined }
s.taskHistoryStore.markLocallyActive ??= () => {}
s.taskHistoryStore.markLocallyInactive ??= () => {}

// Convert legacy clineStack array into a TaskRegistry
if (!s.taskRegistry) {
Expand Down
30 changes: 25 additions & 5 deletions src/__tests__/single-open-invariant.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,11 @@ describe("Single-open-task invariant", () => {
taskRegistry: registry,
taskScheduler: { schedule: schedulespy },
getCurrentTask: vi.fn(() => existingTask),
taskHistoryStore: { get: vi.fn(() => undefined) },
taskHistoryStore: {
get: vi.fn(() => undefined),
markLocallyActive: vi.fn(),
markLocallyInactive: vi.fn(),
},
markDelegatedChildInterrupted: vi.fn().mockResolvedValue(undefined),
get evictCurrentTask() {
return privateClineProvider.evictCurrentTask.bind(this)
Expand Down Expand Up @@ -168,7 +172,11 @@ describe("Single-open-task invariant", () => {

const provider = {
getCurrentTask: vi.fn(() => undefined), // ensure not rehydrating
taskHistoryStore: { get: vi.fn(() => undefined) },
taskHistoryStore: {
get: vi.fn(() => undefined),
markLocallyActive: vi.fn(),
markLocallyInactive: vi.fn(),
},
markDelegatedChildInterrupted: vi.fn().mockResolvedValue(undefined),
get evictCurrentTask() {
return privateClineProvider.evictCurrentTask.bind(this)
Expand Down Expand Up @@ -243,7 +251,11 @@ describe("Single-open-task invariant", () => {
const provider = {
getCurrentTask: vi.fn(() => existingTask),
taskRegistry: registry,
taskHistoryStore: { get: vi.fn(() => undefined) },
taskHistoryStore: {
get: vi.fn(() => undefined),
markLocallyActive: vi.fn(),
markLocallyInactive: vi.fn(),
},
markDelegatedChildInterrupted: vi.fn().mockResolvedValue(undefined),
get evictCurrentTask() {
return privateClineProvider.evictCurrentTask.bind(this)
Expand Down Expand Up @@ -319,7 +331,11 @@ describe("Single-open-task invariant", () => {
historyTaskCreationQueue: Promise.resolve(),
getCurrentTask: vi.fn(() => registry.current),
taskRegistry: registry,
taskHistoryStore: { get: vi.fn(() => undefined) },
taskHistoryStore: {
get: vi.fn(() => undefined),
markLocallyActive: vi.fn(),
markLocallyInactive: vi.fn(),
},
evictCurrentTask,
addClineToStack: vi.fn().mockImplementation(async (task: Task) => registry.push(task)),
log: vi.fn(),
Expand Down Expand Up @@ -386,7 +402,11 @@ describe("Single-open-task invariant", () => {
const provider = {
context: {} as unknown,
getCurrentTask: vi.fn(() => undefined),
taskHistoryStore: { get: vi.fn(() => undefined) },
taskHistoryStore: {
get: vi.fn(() => undefined),
markLocallyActive: vi.fn(),
markLocallyInactive: vi.fn(),
},
markDelegatedChildInterrupted: vi.fn().mockResolvedValue(undefined),
get evictCurrentTask() {
return privateClineProvider.evictCurrentTask.bind(this)
Expand Down
Loading
Loading