Conversation
Closes Agent-Field#1059. The stale reaper could time out a running parent in the brief window (~50-600ms) between its child reaching a terminal state and the parent posting its own result. The parent's own updated_at does not move while it waits on the child, and the existing guard only skipped a parent while it had a non-terminal child. The moment the child posted succeeded, the guard vanished and nothing refreshed the parent's clock, so the reaper could mark the parent (and its workflow) timeout, and the parent's real success callback was then rejected with HTTP 409. Fix (issue's Option 1, query-contained): in MarkStaleExecutions and MarkStaleWorkflowExecutions, also skip a parent when a child reached a terminal state after the cutoff (COALESCE(c.completed_at, c.updated_at) > cutoff). A child that finished long before the cutoff no longer shields the parent, so genuinely stuck parents are still reaped and orphan cleanup keeps working. Timeout children are excluded from the shield (c.status != 'timeout'): the reaper's own kills set a recent completed_at/updated_at, and letting them shield would stall the bottom-up chain unwind. Applied to both the candidate SELECT and the re-evaluating UPDATE in each function. Tests: recently-finished child shields the parent; long-finished child does not; reaper-timeout child does not; bottom-up unwind preserved. Same cases for MarkStaleWorkflowExecutions. PR Agent-Field#1046 protected a workflow whose own clock advances; this covers the distinct case where the parent's clock never advances while it waits. Note: RetryStaleWorkflowExecutions has the same guard shape and race; I kept this PR to the two functions the issue names and can extend to the retry path in a follow-up if desired.
📊 Coverage gateThresholds from
✅ Gate passedNo surface regressed past the allowed threshold and the aggregate stayed above the floor. |
📐 Patch coverage gateThreshold: 80% on lines this PR touches vs
✅ Patch gate passedEvery surface whose lines were touched by this PR has patch coverage at or above the threshold. |
AbirAbbas
left a comment
There was a problem hiding this comment.
Pulled this down and ran it against a live control plane rather than just the unit tests, and the plain case is fixed: an agent whose parent reasoner makes sequential app.calls to a 6s child (3s of local work between calls, stale timeout 20s, cleanup interval 2s). On main the parent is reaped 1.9s after the third child succeeds — timeout / "execution timed out (no activity)" with four succeeded children under it. On this branch the same run walks all eight sub-calls and ends succeeded. go build ./... && go vet ./... and go test ./internal/storage/ ./internal/handlers/ are green locally too.
One case still gets through, and it's about which column the shield reads — left that inline.
Two smaller things:
RetryStaleWorkflowExecutions, which you asked about: I'd do it in this PR. It runs before both reapers when max_retries > 0 (internal/handlers/execution_cleanup.go:164) and carries the identical guard, so on that configuration the same race resets a live parent to pending mid-flight instead of timing it out, which is a worse outcome than the one you're fixing. TestRetryStaleWorkflowExecutions_TerminalChildDoesNotShieldParent pins the current behaviour and would need the same "long-finished" backdating your other two tests got.
The doc comment above MarkStaleExecutions still says "Deliberately no recency test on the child: the chain unwinds bottom-up instead ... it just takes one sweep per level". Both halves are now wrong — there is a recency test, and unwinding can take a stale window per level rather than a sweep. That comment is the thing the next person will read to decide whether they're allowed to touch this query.
…, retry path, docs Review by @AbirAbbas on Agent-Field#1063 surfaced three things: 1. completed_at is the agent's clock (UpdateExecutionStatusHandler stores req.CompletedAt verbatim), while the reaper cutoff is the control plane's time.Now(). A skewed agent clock or a late-landing callback could make completed_at older than the cutoff at the instant the CP wrote the terminal row, so the shield expired before it was installed and the parent was reaped anyway (the original 409). The shield now reads the LATER of completed_at and updated_at via a new childTerminalRecencyExpr (GREATEST on postgres, MAX(julianday(...)) on sqlite): updated_at is the CP's own write clock, so either clock being after the cutoff protects the parent. On the executions table a terminal row's updated_at is frozen (terminal->terminal is rejected), so this cannot shield indefinitely. Adds Abbas's deterministic TestMarkStaleExecutions_LateChildCallbackStillShieldsParent. 2. RetryStaleWorkflowExecutions runs before both reapers when max_retries > 0 and carried the identical guard, so the same race reset a live parent to pending mid-flight. Applied the same recent-terminal-child shield to its SELECT and re-evaluating UPDATE, backdated TestRetryStaleWorkflowExecutions_TerminalChildDoesNotShieldParent to the long-finished case, and added TestRetryStaleWorkflowExecutions_RecentlyFinishedChildShieldsParent. 3. Rewrote the stale MarkStaleExecutions doc comment: it claimed there was no child recency test and the chain unwound one sweep per level; both are now wrong. It documents the recency shield, the timeout-child exclusion, and that unwinding can take a stale window per level.
|
Thanks for the live-run review, all three addressed in 68a63fd. 1. The clock question (the important one). You're right that On the indefinite-shield worry with 2. 3. The doc comment. Rewritten, it now documents the recency shield, the
|
|
Re-verified on 68a63fd.
Live re-runs on your head (20s stale timeout, 2s sweep, parent reasoner making 8 sequential child calls with local work in between): the parent walks all 8 and ends Two small things. The comment on |
Summary
Fixes the stale-execution reaper race where a running parent is timed out in the brief window (~50-600 ms) between its child reaching a terminal state and the parent posting its own result. The parent's real
succeededcallback was then rejected with HTTP 409. Closes #1059.Type of change
Root cause
A parent's own
updated_atdoes not move while it waits on a child. The reaper's guard only skipped a parent while it had a child inrunning/pending/queued(pluswaitingfor workflows). The moment the child postedsucceeded, that guard vanished and nothing refreshed the parent's clock, so a cleanup tick in that window set the parent (and its workflow) totimeout.Fix (issue's Option 1, query-contained)
In
MarkStaleExecutionsandMarkStaleWorkflowExecutions(control-plane/internal/storage/execution_records.go), the child guard now also skips a parent when a child reached a terminal state after the cutoff:timeoutchildren are excluded from the shield: the reaper's own kills set a recentcompleted_at/updated_at, and letting them shield would stall the bottom-up chain unwind (one level per sweep). This keeps the existingUnwindsChainBottomUpbehavior intact.SELECTand the re-evaluatingUPDATEin each function.#1046protected a workflow whose own activity clock advances; this covers the distinct case where the parent's clock never advances while it waits on a child.Acceptance criteria (#1059)
TestMarkStaleExecutions_RecentlyFinishedChildShieldsParent,TestMarkStaleWorkflowExecutions_RecentlyFinishedChildShieldsParent.TestMarkStaleExecutions_LongFinishedChildDoesNotShieldParent,TestMarkStaleWorkflowExecutions_LongFinishedChildDoesNotShieldParent.MarkStaleWorkflowExecutions.Test plan
cd control-plane && go test ./internal/storage/ -run 'Stale|Reaper|Retry' -count=1cd control-plane && go test ./internal/storage/ -count=1(full package)cd control-plane && go build ./... && go vet ./internal/storage/staleTimestampExpr/cutoffExprdialect helpers, so the functional-tests-postgres CI job exercises the Postgres path.Test coverage
Checklist
Related issues / PRs
Closes #1059. Related to #1046 / #1047 (distinct cause).
Notes for reviewers
TerminalChildDoesNotShieldParentexecutions tests toLongFinishedChildDoesNotShieldParentand backdated their child's completion to before the cutoff. They previously relied (by accident) onCreateExecutionRecordstampingupdated_atat "now", so a recent terminal child slipped through the old guard; that recency is exactly what this fix now protects, so the tests were updated to assert the intended "long-finished child" semantics.RetryStaleWorkflowExecutionshas the same guard shape and the same race. I scoped this PR to the two functions the issue names; happy to extend the fix to the retry path in this PR or a follow-up — your call.@JaredAungexpressed interest in this issue on 2026-09-17; there was no assignment or PR, so I picked it up. Happy to coordinate.