fix(control-plane): instance-scoped, deferred orphan reap on agent re-registration; hold dispatch while a node drains - #1004
Merged
Merged
Conversation
Contributor
📊 Coverage gateThresholds from
✅ Gate passedNo surface regressed past the allowed threshold and the aggregate stayed above the floor. |
Contributor
📐 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
force-pushed
the
fix/cp-orphan-reap
branch
from
August 28, 2026 14:13
be6c6df to
c3feb41
Compare
Every node-announced offline transition (POST /nodes/{id}/shutdown, the
lifecycle/status route, a status PATCH) records health as inactive, and so
does the health monitor's own demotion, so gating the hold on health kept
the one case it exists for — a pod that just announced its shutdown — on
the fail-fast path and held only monitor-demoted nodes.
Gate on recency instead: a node whose last heartbeat is within
AGENTFIELD_AGENT_DRAIN_GRACE is treated as draining and held for the
restart grace; one silent for longer is dead and fails fast with 503 and no
execution row. The same window already defers the orphan reap, so both
sides of the drain agree on what "recently" means.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Keeps this PR's example variables next to the section they belong to instead of appending at end-of-file, so sibling PRs that also extend .env.example merge in any order. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The workflow-execution SELECT now reads COALESCE(instance_id, ''), so the scripted driver row and the shared lifecycle column list must carry the column too; CI's coverage run caught the 43-vs-44 Scan mismatch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AbirAbbas
force-pushed
the
fix/cp-orphan-reap
branch
from
August 28, 2026 15:03
8ea1477 to
2b80acf
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Control-plane half of #987 (Kubernetes rollout kills every in-flight run with
in-flight reasoner cannot be revived) and the hold-vs-503 half of #989. Unchanged from 0.1.127 tomainbefore this PR —nodes_register.gohad no commits in that range; #941's restart-absorb covers only a failed dial, deliberately not already-dispatched work.What was wrong. When a node re-registered with a new
instance_id,RegisterNodeHandlersynchronously failed every non-terminal execution whoseagent_node_idmatched (MarkAgentExecutionsOrphaned) — no grace, and no way to tell which pod owned a row becauseinstance_idexisted only onagent_nodes. Under a default rolling update (maxSurge 1) the new pod registers before the old one is signalled, so the CP declared work dead that was still running and about to succeed; withreplicas > 1behind one node id, one replica's restart killed every other replica's work.What changes.
035_execution_instance_id: nullableinstance_idonexecutionsandworkflow_executions(indexed withagent_node_id), stamped at dispatch from the serving agent record; SQLite auto-migrate gets the same columns. Legacy rows stay empty.instance_id = <departing> OR instance_id IS empty (legacy)— work served by another live instance of the same node id survives.AGENTFIELD_AGENT_DRAIN_GRACE(default 60 s; negative disables deferral). A completion arriving inside the window makes the row terminal, so the reap skips it. The in-memory timer can be lost on a control-plane restart; the stale-execution sweep remains the backstop (documented).status_reasonkeeps theagent_restart_orphanedtoken operators grep for, but no longer claims "cannot be revived".POST /nodes/{id}/shutdown, or an offline heartbeat) is held for up to the restart-grace window and dispatched to the replacement when it registers — reusing fix(control-plane): stop counting agent restarts as failed executions #941'sagentMayRestart/agentCameBackmachinery — instead of an immediate 503. No execution row exists while holding; if nothing registers in time, today's 503node_unavailableis returned. The hold applies only to a node that went quiet recently — lifecycleofflinewith a last heartbeat insideAGENTFIELD_AGENT_DRAIN_GRACE(the same window that defers the reap). Health is deliberately not consulted: every node-announced offline transition (/shutdown,lifecycle/status, statusPATCH) and the health monitor's own demotion all record healthinactive, so it cannot tell a draining pod from a dead one — recency can. A node silent for longer than the drain grace, or explicitly deregistered, fails fast with503 node_unavailableexactly as before and is never held.terminationGracePeriodSecondsabove the SDK drain; keep the agentversionstable across rollouts — a version bump creates a separate registration and is recovered only by the stale sweep).The
(id, version)registration lookup is intentionally untouched: a version-bumping rollout is currently the one style that survives, and reaping on version bumps would extend the bug to it.Validation contract → tests
TestExecutionRecordsPersistServingInstanceTestMarkAgentInstanceExecutionsOrphaned_ReapsDepartingAndLegacyOnly(instance-asurvivesinstance-b's restart)TestRegisterNodeHandler_ReapsOrphansOnInstanceChange(asserts empty, thenEventually),TestRegisterNodeHandler_InstanceChangeWithNothingToReapexec-legacy)TestRegisterNodeHandler_ReapsOrphansOnInstanceChangeTestWaitForDrainingAgentDispatchesToReplacement,TestWaitForDrainingAgentTimesOutWithoutExecution; gating predicate covered by existingTestAgentMayRestart/TestC17InactiveNodeWithoutUpdateIsRejectedAsUnavailable; a pod that just announced its shutdown (offline, health inactive, fresh heartbeat) is held for the restart grace and creates no row —TestExecuteHoldsRecentlyOfflineNodeRegardlessOfHealth; a node silent for longer than the drain grace is rejected in < 250 ms with a 2 s grace configured and no execution row —TestExecuteRejectsKnownDownNodeWithoutRecordingAnExecution; predicate table —TestAgentIsDrainingUsesRecencyNotHealthTestAgentDrainGraceFromEnvironmentTestWorkflowExecutionInsertQueriesCoverLifecycleColumnsTest plan
cd control-plane && go build ./... && go test ./... -count=1(full suite green on the branch)gofmt -lon touched files: cleanTestDispatchAgentRequestStopsWaitingOnceTheNodeIsDemoted(internal/handlers) fails on a main-equivalent tree on this loaded machine as well — pre-existing timing flake, not from this diff.Expected to conflict trivially with #1001 in
execute_prepare.go(adjacent hunks); whichever lands second gets rebased.Refs #987 #989 #941
🤖 Generated with Claude Code
Live smoke (real
agentfield-serverbinary from this head, fake long-running node,AGENTFIELD_AGENT_RESTART_GRACE=6s,AGENTFIELD_AGENT_DRAIN_GRACE=3s)POST /nodes/{id}/shutdown→ sync execute immediately → replacement instance registers 2 s laterinstance_id/shutdown→ execute, no replacementnode_unavailableafter 6.00 s (the restart grace), no execution row/shutdown→ wait 4 s (> drain grace) → executeKnown limitation (pre-existing, surfaced by manual testing of this branch): if the departed instance eventually answers after the drain grace,
UpdateExecutionRecord(no state-transition guard) flips theexecutionsrow back tosucceededwhileworkflow_executionscorrectly refusesfailed → succeeded; the two stores then disagree and the execution API servessucceededwith the staleagent_restart_orphanedreason attached. Same behaviour onmainwith the synchronous reap. Follow-up: either reject late writes to a reaped row or clear the reason and reconcile the workflow row.