Skip to content

feat(control-plane): instance identity on execution reads and an orphan-reap kill switch for multi-replica agents - #1031

Merged
AbirAbbas merged 5 commits into
mainfrom
fix/ext-cp-execution-read-surface
Aug 31, 2026
Merged

feat(control-plane): instance identity on execution reads and an orphan-reap kill switch for multi-replica agents#1031
AbirAbbas merged 5 commits into
mainfrom
fix/ext-cp-execution-read-surface

Conversation

@AbirAbbas

Copy link
Copy Markdown
Contributor

Summary

Two small, additive pieces for operating agents on Kubernetes. Execution read surfaces now expose agent_node_id and instance_id (the instance the execution was created against), so a reaped or orphaned execution can be attributed to the pod that owned it. And AGENTFIELD_AGENT_ORPHAN_REAP_ENABLED=false gives deployments that run more than one replica of a node id an escape hatch: with several replicas, a sibling registering is indistinguishable from a replacement today, and the deferred reap would fail the still-alive sibling's in-flight work — exactly the #987 symptom. The stale-execution sweep stays on as the backstop. Default is true; behaviour is byte-identical to v0.1.137 unless the flag is set to false.

Why

Refs #987.

Changes

  • feat(control-plane): expose agent_node_id/instance_id on execution reads
  • feat(control-plane): add an orphan-reap kill switch for multi-replica agents
  • docs: document instance_id attribution and the orphan-reap kill switch

Validation contract

    1. GET /executions/{id} returns instance_id and agent_node_id matching the stored execution → TestGetExecutionStatusHandler_ReturnsAgentAndInstanceIdentifiers
    1. Execution with no reported instance id omits instance_id entirely (not an empty string) → TestGetExecutionStatusHandler_OmitsEmptyInstanceID
    1. batch-status returns both fields for FOUND executions; synthetic not_found/error entries omit both → TestBatchExecutionStatusHandler_IdentifierFieldsOnlyForFoundExecutions
    1. POST /executions/{id}/status returns both fields on EVERY call (renders via renderStatusWithApproval) → TestUpdateExecutionStatusHandler_Success
    1. GET /api/ui/v1/executions/{id}/details gains instance_id → TestGetExecutionDetailsGlobalHandlerReturnsInstanceID
    1. No existing field of any of these responses changes name, type or presence → full control-plane suite passes with every pre-existing assertion unmodified; both additions are omitempty and the three inline batch-status literals were left untouched
    1. Docs describe instance_id as 'the instance the execution was created against' and state it is NOT re-stamped on replay across a restart → docs/api/EXECUTE.md
    1. EXECUTE.md's exact field-list enumeration for the polling response is updated → docs/api/EXECUTE.md
    1. k8s doc does not promise pod attribution; names the re-registration log as the only instance_id->pod path → docs/deploying-on-kubernetes.md (tightened to name Python as the only reporting SDK, verified against sdk/go and sdk/typescript)
    1. EXECUTION_RESTART.md notes the reap also sweeps empty-instance_id rows, so a legacy row can name an instance that never owned it → docs/api/EXECUTION_RESTART.md
    1. With the flag unset or true, re-registration behaviour is byte-identical to v0.1.137 → TestRegisterNodeHandler_ReapsOrphansOnInstanceChange, TestRegisterNodeHandler_NoReapOnSameInstance, TestRegisterNodeHandler_PersistsInstanceID
    1. With the flag false, the previous instance's in-flight executions survive the drain grace and a later completion callback lands as succeeded → TestReRegistrationSkipsOrphanReapWhenDisabled
    1. Invalid values fall back to true with a warning; env var documented in ENVIRONMENT_VARIABLES.md and .env.example → TestOrphanReapEnabledParsing, TestOrphanReapEnabledYAMLFalseIsPreserved, TestOrphanReapEnabledViperFalseIsPreserved

How it was tested

CI-literal gates in the worktree: go build, gofmt -l on touched files, go vet, full control-plane suite (-tags sqlite_fts5, minus internal/packages), ./scripts/coverage-surface.sh control-plane, ./scripts/patch-coverage-gate.sh — ALL-PASS, patch coverage 100% on 44 touched lines. Re-based onto current main and the control-plane gate re-run before push.

Both loaders needed presence tracking so an explicit false survives (yaml.v3 UnmarshalYAML hook + viper IsSet — viper decodes via mapstructure and never calls the yaml hook); all three config load paths call ApplyDefaults before ApplyEnvOverrides, so the env override always wins. Both paths are tested.

Notes / follow-ups

Non-blocking review findings kept as follow-ups:

  • (should-fix) control-plane/internal/config/config.go — The new AgentOrphanReapEnabled bool breaks the struct-level contract documented four lines above it at config.go:202 — "NodeHealthConfig ... Zero values are treated as 'use default' — set explicitly to override." Every other field in this struct ho
  • (nit) control-plane/internal/config/config.goMarkExecutionCleanupEnabledIfSet now also records presence for agentfield.node_health.agent_orphan_reap_enabled. The function name says it handles execution cleanup only, so a future loader author who reads the name and skips the call will silent
  • (nit) control-plane/internal/handlers/execute_status_update_test.goTestBatchExecutionStatusHandler_IdentifierFieldsOnlyForFoundExecutions wraps the store in batchStatusFallbackErrorStore, whose GetExecutionRecordsBatch always errors. That forces the handler down the per-ID fallback branch, so the test only exe

Only the Python SDK reports an instance_id today; Go and TypeScript nodes leave it empty — the docs say so explicitly. No web-client change on purpose (the TS interfaces tolerate the additive fields); a UI surfacing pass can follow.

🤖 Generated with Claude Code

AbirAbbas and others added 3 commits August 31, 2026 13:16
When the re-registration reap fails an in-flight execution, the row's
status_reason names the departing instance but nothing in any read API
exposed which instance the execution was actually created against. An
operator had no way to tell a genuinely orphaned execution from a legacy
row that the reap swept because its instance_id was empty.

Add agent_node_id and instance_id to ExecutionStatusResponse, populated in
renderStatus -- the single builder GET /executions/:id, batch-status and
the status callback all funnel through. Both are omitempty: instance_id
vanishes for nodes that never report one (only the Python SDK does today),
and agent_node_id stays absent on the synthetic not_found/error entries
that handleBatchStatus builds inline, so those keep their current shape.

The UI details DTO gains instance_id alongside the agent_node_id it
already carried, so the DAG step drawer can surface it.

No storage or schema change: migrations 033/035 added the columns and
every execution SELECT already reads COALESCE(instance_id, '').

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… agents

The deferred reap assumes a re-registration with a new instance_id means
the previous OS process is gone. With replicas > 1 behind one node id that
assumption is wrong: a sibling replica registering is indistinguishable
from a replacement, so the reap fails the still-alive sibling's in-flight
executions once the drain grace elapses (#987).

Add AGENTFIELD_AGENT_ORPHAN_REAP_ENABLED (default true = today's
behaviour). When false, the new conjunct on shouldReapOrphans means the
deferred goroutine is never armed at all, and the stale-execution sweep
stays as the backstop. Startup logs one greppable warning when disabled.

Defaulting a bool to true needs presence tracking, since a zero value and
an explicit `false` are otherwise identical. This follows the existing
ExecutionCleanup.Enabled precedent and covers both loaders: yaml.v3 via an
UnmarshalYAML hook on NodeHealthConfig, and viper -- which decodes through
mapstructure and never calls that hook -- via IsSet in
MarkExecutionCleanupEnabledIfSet. ApplyDefaults runs before
ApplyEnvOverrides in all three load paths, so applyBoolEnv gets the last
word and its existing warn-and-keep behaviour makes a garbage value fall
back to true for free.

The grace wiring moves into configureAgentRestartSettings so the startup
behaviour is testable without booting a server.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
EXECUTE.md enumerated the polling response's exact field list, so it went
stale the moment the two new fields landed. Extend it, and state the
semantics that are easy to get wrong: instance_id names the instance the
execution was *created against* and is not re-stamped when a dispatch is
replayed across an agent restart, so a restart-absorbed execution names
the departed process even though the replacement ran the work.
Re-stamping stays out of scope because that column is the reap scope key.

EXECUTION_RESTART.md notes that the reap also sweeps rows whose
instance_id is empty, which is exactly why the new explicit field is what
lets an operator tell that legacy case apart from a real orphan.

The k8s guide gains the replicas > 1 rationale for the new env var, and a
warning not to treat instance_id as guaranteed pod attribution: only the
Python SDK reports one, it is a bare uuid4().hex the SDK never logs, and
the sole path from that value to a pod is the control plane's own
re-registration reap log (old_instance_id / new_instance_id).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AbirAbbas
AbirAbbas requested a review from a team as a code owner August 31, 2026 18:49
@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

📊 Coverage gate

Thresholds from .coverage-gate.toml: per-surface ≥ 84%, aggregate ≥ 85%, max per-surface regression ≤ 1.0 pp, max aggregate regression ≤ 0.50 pp.

Surface Current Baseline Δ
control-plane 87.70% 87.40% ↑ +0.30 pp 🟡
sdk-go 93.10% 92.00% ↑ +1.10 pp 🟢
sdk-python 94.33% 93.73% ↑ +0.60 pp 🟢
sdk-typescript 91.72% 90.42% ↑ +1.30 pp 🟢
web-ui 84.76% 84.79% ↓ -0.03 pp 🟡
aggregate 85.84% 85.75% ↑ +0.09 pp 🟡

✅ Gate passed

No surface regressed past the allowed threshold and the aggregate stayed above the floor.

@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

📐 Patch coverage gate

Threshold: 80% on lines this PR touches vs origin/main (from .coverage-gate.toml:thresholds.min_patch).

Surface Touched lines Patch coverage Status
control-plane 81 100.00%
sdk-go 0 ➖ no changes
sdk-python 0 ➖ no changes
sdk-typescript 0 ➖ no changes
web-ui 0 ➖ no changes

✅ Patch gate passed

Every surface whose lines were touched by this PR has patch coverage at or above the threshold.

@AbirAbbas
AbirAbbas merged commit 2d7fc72 into main Aug 31, 2026
26 checks passed
@AbirAbbas
AbirAbbas deleted the fix/ext-cp-execution-read-surface branch August 31, 2026 23:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant