Skip to content

Commit 09f7f46

Browse files
author
Zoo (VP)
committed
fix(task-persistence): keep live delegated children during reconcile
A delegated child streaming a long turn can go minutes without writing anything to its history file, so startup/periodic reconciliation in another window — or this window after an extension-host restart, before any local-ownership claim — misjudged the live child as a crash orphan (mtime > 5 min), repaired it to interrupted, and severed the delegation link so the child's completion was discarded (AttemptCompletionTool: 'Skipping delegation ... childStatus: interrupted, parentStatus: active'). The owning session now persists a throttled liveness heartbeat (lastActivityAt, 60s interval, child tasks only) while streaming, and reconcile's cross-instance liveness guard treats a child as live when EITHER the history-file mtime OR the heartbeat is fresh within the 5-minute threshold (shared predicate isDelegatedChildLive). A child with both signals stale — the genuine crash orphan — is still repaired. - packages/types: optional lastActivityAt on HistoryItem - TaskHistoryStore: recordTaskActivity heartbeat write; reconcile and repair-intent replay use isDelegatedChildLive; skip log names the signal that kept the child alive - Task: start/stop liveness heartbeat around streaming for child tasks; stopped on dispose so a trailing beat never claims life for an orphan - taskLifecycle: isLivenessSignalFresh / isDelegatedChildLive reducers - check-task-lifecycle model: heartbeat/expireHeartbeat actions, heartbeat-protected landmark, invariant 8 covers either signal - docs: task-lifecycle-model.md mapping/landmarks/invariant updated
1 parent 6d05781 commit 09f7f46

8 files changed

Lines changed: 433 additions & 55 deletions

File tree

‎docs/architecture/task-lifecycle-model.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,11 @@ TLA+/PlusCal or Quint with TLC becomes a better fit when the lifecycle needs tem
5050
| `abandon(child)` | `ClineProvider.abandonSubtask` |
5151
| `reconcileStartup(parent)` | startup/periodic `TaskHistoryStore.reconcileDelegationStateCore` orphan repair |
5252
| `markLiveElsewhere(child)` / `expireLiveElsewhere(child)` | child history-file mtime recent vs stale past `LIVE_CHILD_MTIME_THRESHOLD_MS` (abstracted; no wall clock in model) |
53+
| `heartbeat(child)` / `expireHeartbeat(child)` | persisted `lastActivityAt` liveness heartbeat fresh vs stale past `LIVE_CHILD_MTIME_THRESHOLD_MS` (production: the owning session's throttled heartbeat during a long streaming turn, shared predicate `isDelegatedChildLive`) |
5354
| Atomic event step | `atomicReadAndUpdate`, `atomicUpdatePair`, and per-parent delegation transition lock |
5455
| Event interleaving | Competing completion, cancellation, abandonment, and new delegation calls |
5556

56-
The model has three fixed task slots, enough to cover competing siblings and a nested parent-child-grandchild chain, plus one abstract boolean per slot recording whether an active child's session is owned by another window (recent history-file mtime). It explores every reachable interleaving through depth 12, deduplicating canonical states. Representative checks also exercise rejected operations that do not create a new state: a second concurrent delegation while the first child is active, stale completion after re-delegation, late completion after abandonment, completion after interruption, and nested completion. Named semantic landmarks require the graph to retain interrupted-child re-delegation and nested delegation even when the raw state total changes, a delegated parent whose active child is live in another window surviving startup reconciliation unchanged, and a stale-mtime (crash-orphan) active child being repaired to `interrupted` with the parent returned to `active` only through `reconcileStartup`.
57+
The model has three fixed task slots, enough to cover competing siblings and a nested parent-child-grandchild chain, plus two abstract booleans per slot recording whether an active child's session shows life: one for ownership by another window (recent history-file mtime) and one for a fresh persisted `lastActivityAt` heartbeat from the owning session. It explores every reachable interleaving through depth 12, deduplicating canonical states. Representative checks also exercise rejected operations that do not create a new state: a second concurrent delegation while the first child is active, stale completion after re-delegation, late completion after abandonment, completion after interruption, and nested completion. Named semantic landmarks require the graph to retain interrupted-child re-delegation and nested delegation even when the raw state total changes, a delegated parent whose active child is live in another window surviving startup reconciliation unchanged, a delegated parent whose active child heartbeats through a long streaming turn (stale mtime, fresh `lastActivityAt`) likewise surviving unchanged, and a stale-mtime, stale-heartbeat (crash-orphan) active child being repaired to `interrupted` with the parent returned to `active` only through `reconcileStartup`.
5758

5859
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.
5960

@@ -136,7 +137,7 @@ The task delegation checker currently enforces:
136137
5. Parent-child lineage is acyclic.
137138
6. Completed task records cannot be changed by later lifecycle events.
138139
7. Active-child re-delegation, stale completion after ownership moves to another child, duplicate/late completion, and abandonment of a live child are rejected by the shared production guards.
139-
8. No transition may clear a delegated parent's link to a child that is active and marked live-elsewhere (`ModelState.liveElsewhere` true); reconciliation repairs the link only when that flag is false. This encodes the PR #1495 cross-window misrepair bug class, which broke delegation links so subtask completion could not return to the parent.
140+
8. No transition may clear a delegated parent's link to a child that is active and live, where live means either marked live-elsewhere (`ModelState.liveElsewhere` true, recent history-file mtime) or heartbeat-alive (`ModelState.heartbeatAlive` true, fresh persisted `lastActivityAt`); reconciliation repairs the link only when both flags are false. This encodes the PR #1495 cross-window misrepair bug class — which broke delegation links so subtask completion could not return to the parent — extended by the liveness-heartbeat fix so a child streaming a long turn (minutes without any other history-file write) is likewise preserved.
140141

141142
The completion persistence checker additionally enforces:
142143

‎packages/types/src/history.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export const historyItemSchema = z.object({
4848
awaitingChildId: z.string().optional(), // Child currently awaited (set when delegated)
4949
completedByChildId: z.string().optional(), // Child that completed and resumed this parent
5050
completionResultSummary: z.string().optional(), // Summary from completed child
51+
lastActivityAt: z.number().optional(), // Liveness heartbeat: last time the owning session persisted activity
5152
pendingAction: pendingTaskActionSchema.optional(),
5253
})
5354

0 commit comments

Comments
 (0)