Skip to content

feat: af install --path selector + Go SDK harness fixes#750

Open
AbirAbbas wants to merge 13 commits into
mainfrom
feat/install-path-selector
Open

feat: af install --path selector + Go SDK harness fixes#750
AbirAbbas wants to merge 13 commits into
mainfrom
feat/install-path-selector

Conversation

@AbirAbbas

@AbirAbbas AbirAbbas commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

This PR consolidates two related changes into a single reviewable unit. It
supersedes and replaces #746 — the three Go SDK commits from that PR
(7c6f11e1, 18c3f3ea, 5b763d87) are cherry-picked here (-x), and #746
has been closed in favor of this one. The two changes are independent (one
touches control-plane/, the other only sdk/go/) but ship together so a
single node repo can both be installed via af install --path and drive a Go
SDK harness that talks to the control plane correctly.


Part 1 — af install --path selector for one-repo multi-package installs

Problem

The SWE-AF repository ships two installable nodes from one repo: a Python node
(manifest at the repo root) and a Go port under go/ (which will get its own
go/agentfield-package.yaml). Today af install has no way to select a
subdirectory — GitInstaller.findPackageRoot walks the clone and takes the
first agentfield-package.yaml (root wins). Registry entries are keyed by
manifest name, so two packages from one repo already coexist cleanly — only
the selector was missing.

The root manifest must stay exactly what a bare af install <repo> gets. This PR
adds an opt-in --path <subdir> selector without changing any default behavior.

Design

af install <source> --path <subdir>:

  • <subdir> is relative to the source root (e.g. go). The manifest MUST exist
    at <root>/<subdir>/agentfield-package.yaml, and that subdirectory becomes the
    package root — its subtree is what gets copied to
    ~/.agentfield/packages/<name>. A missing manifest fails with an error naming
    the full expected path.
  • Validation: absolute paths and paths that escape the source root via ..
    (after filepath.Clean, plus a post-join containment check) are rejected. For
    the git path this syntactic check runs before any clone work.
  • No flag → byte-identical to today (root-first walk).
  • Composes with the existing @ref pin on git URLs (ref is parsed from the URL,
    subdir is resolved after clone — orthogonal).
  • Applies to both the git install path and the local-directory install path
    with identical semantics (<local-dir>/<subdir>), resolved before any copy or
    registry mutation.
  • The selector targets the top-level source only; it is never propagated to
    recursively-installed node dependencies.

Key pieces: domain.InstallOptions.Path; shared
packages.ResolvePackageSubdir / ValidateSubdirSelector;
GitInstaller.Subdir + resolvePackageRoot; --path flag + help text in the
CLI; local path threaded through installLocalPackage.

Validation contract

  • --path go on a repo with manifests at both root and go/ installs the
    go/ package (registry entry named per the go/ manifest; package dir holds
    the go/ subtree at its root).
  • Bare af install <repo> on the same repo installs the ROOT package —
    unchanged behavior.
  • --path <dir> with no manifest there errors with the expected manifest
    path; nothing installed, no registry mutation.
  • --path /abs and --path ../escape are rejected before any install work.
  • --path go combined with an @ref on the URL — both honored.
  • A Go-language subdir package (go.mod + entrypoint.build at the subdir
    root) resolves its build relative to the subdir-as-package-root.

Test plan / gates run

New behavior-driven tests (derived from the contract, not the implementation):

  • control-plane/internal/packages/install_path_test.go — resolver + git
    post-clone resolution (faked clone, no network) + @ref composition + Go
    subdir build via the existing stubbed toolchain.
  • control-plane/internal/core/services/install_path_test.go — local-directory
    --path selection, root default, and rejection paths leaving the registry
    unmutated.

Part 2 — Go SDK harness fixes (consolidates #746)

Problem

The Go SDK's claudecode harness and Agent.Call had three defects that broke
long-running child reasoners and the claude runtime path:

  1. Prompt swallowed by variadic flags. The claude prompt was passed as a
    positional argument, but the variadic --allowedTools flag consumed it —
    claude never received the prompt.
  2. Wrong note endpoint. The note endpoint path was missing its /api/v1
    prefix.
  3. 15s hard cap on Agent.Call. Agent.Call was a synchronous
    POST /execute/{target} bounded by the 15s default http.Client timeout, so
    any child reasoner that ran longer than 15s was killed.
  4. Idle watchdog starved. The claude provider's output format didn't stream
    incremental events, so the idle watchdog could fire mid-run.

Fixes

  • Prompt via stdin. The claude prompt is now delivered on stdin rather
    than as a positional arg, so the variadic --allowedTools flag can no longer
    swallow it.
  • Note endpoint path corrected to /api/v1.
  • Agent.Call rewritten to async submit + poll. It now mirrors the Python
    SDK: submit via /execute/async/{target}, then poll the execution record with
    Python-parity pacing (0.25s doubling to 4s, jittered), the call timeout
    bounding each individual request rather than the overall wait, fully
    ctx-cancellable, with lineage headers on submit and polls so DAG parentage is
    preserved. This removes the sync endpoint's 15s cap.
  • claude output switched to --output-format stream-json --verbose, so
    incremental events keep the idle watchdog fed for the full duration of a run.

Tests

  • sdk/go/agent/agent_call_coverage_test.go — covers the async Call
    submit/poll paths: poll-interval clamping, submit-side request/transport/
    read/decode/4xx errors, and await-side ctx-cancel, transport, read, HTTP-error,
    and decode branches.
  • sdk/go/harness/claudecode_defaultrunner_test.go — covers the
    claudecode default-runner fallback (the runCLI == nil path in
    ClaudeCodeProvider.Execute).
  • Plus stdin/stream-json coverage in claudecode_test.go,
    claudecode_integration_test.go, and agent_test.go / note_test.go.

These lift sdk-go patch coverage above the 80% min_patch gate (98% on the
touched lines).


Gates run (both parts)

Run locally against the CI-literal steps (Go 1.25.4, GOWORK=off):

  • control-plane: go build ./..., go vet ./..., go test ./... — pass
    (one pre-existing, environment-dependent failure, TestDevServiceRunDev,
    which spawns a real agent subprocess and polls its port; unrelated to and
    untouched by this PR — it passes in CI).
  • sdk/go: go build ./..., go vet ./..., go test ./... — pass.
  • gofmt -l clean on all changed files.
  • golangci-lint run --new-from-rev=origin/main on control-plane → 0 new issues.
  • sdk-go patch-coverage gate (diff-cover vs origin/main) → 98%, above the
    80% min_patch floor.

Part 3 — provider + failure-path parity fixes

Four verified Go SDK fixes bringing the harness providers and the async
failure path to parity with the Python reference implementations. All changes
are under sdk/go/ (plus one new sdk/go/go.mod dependency).

Fix 1 — codex native structured output (harness/codex.go)

The codex provider ignored options.Model and built a deprecated
codex exec --json [--full-auto] invocation. Ported from the codex harness
patch:

  • Pass -m <model> (SWE-AF resolves gpt-5.5 vs gpt-5.3-codex by auth mode and
    the value must reach the CLI).
  • Add --skip-git-repo-check.
  • Replace deprecated --full-auto with the permission->sandbox mapping: auto
    -> --dangerously-bypass-approvals-and-sandbox; read-only /
    workspace-write / danger-full-access -> --sandbox <mode>; else
    --sandbox workspace-write.
  • Deliver the prompt via stdin (mirroring the claude provider), not argv.
  • Native structured output: when a schema is set, pass --output-schema
    (pointing at the STRICT rewrite of the schema — defaults stripped, all
    properties required, additionalProperties:false, recursively) and
    --output-last-message, reading the last-message file when stdout yields no
    parseable final text. The runner owns the strict-schema computation + file
    write and hands codex the paths via a schemaAware interface, then swaps in a
    codex-native prompt suffix while claude/opencode keep the Write-tool suffix.
    Options gains no schema field.

Fix 2 — opencode JSON stream + exit-0 error surfacing (harness/opencode.go)

  • Add --format json and parse the JSONL event stream: recover the final
    message, sum per-step cost from step_finish events (nil when none report a
    cost), and count turns per step_start (falling back to tool_use).
  • Surface hard failures on which opencode exits 0 (Model not found,
    AuthenticationError, Unauthorized, APIError) plus in-band JSON error
    events, instead of returning empty output that reads downstream as "no
    result". Non-JSON stdout still falls back to trimmed raw text.

Fix 3 — native ReasonerFailed carrier (agent/)

New exported ReasonerFailed{Message, Result, ErrorDetails} error type. In
executeReasonerAsync's error branch it is detected via errors.As and its
result / error_details are attached to the single 5x-retried status post
(only when non-nil), so a reasoner that ran but failed records status=failed
without discarding its structured outcome. The two sync HTTP paths
(handleExecute / handleReasoner) mirror this on their error response; a
plain error synthesizes neither key.

Fix 4 — real schema validation in the harness retry loop (harness/)

ParseAndValidate was unmarshal-only, so output missing required fields,
carrying invalid enum values, or (with additionalProperties:false) extra
fields passed silently and the schema-retry loop never fired. Add
validateAgainstSchema (github.com/santhosh-tekuri/jsonschema/v5) and run it
at every parse-success point in handleSchemaWithRetry, so a validation failure
drives the existing retry branch (schemaMaxRetries default 2 preserved).
Validation applies only when both a destination and a schema are provided;
uncompilable schemas skip validation (no regression).

Note: this makes map-schema harness calls stricter than before (previously
unmarshal-only) — callers control strictness through the schema they pass.

Gates run (Part 3)

  • sdk/go: go build ./..., go vet ./..., go test ./... — pass; gofmt -l
    clean on all changed files.
  • golangci-lint run --new-from-rev=origin/main on sdk/go -> 0 new issues
    (the 7 pre-existing findings on earlier commits are untouched).
  • sdk-go patch-coverage gate (diff-cover vs origin/main) -> 94% on 621
    touched lines, above the 80% min_patch floor.

Part 4 — Agent.Call resilience to transient control-plane outages (agent/)

Real-LLM testing exposed a robustness bug in the async submit+poll Agent.Call
path this branch introduced: a single failed HTTP request to the control plane
killed the whole call. Two ~hour-long SWE-AF builds died at ~55 minutes when the
CP briefly went slow under system load (load avg ~37 on a 16-core box; the CP
itself was healthy and recovered) — one status poll that timed out "awaiting
headers" aborted a call that had been running 30+ minutes, and a slow submit
POST likewise killed a call before it could be confirmed accepted.

Poll resilience. A transient poll failure (transport error/timeout, 408/429,
or 5xx) no longer fails the call. Consecutive failures retry with exponential
backoff (capped 15s) for a configurable window of unbroken failure — default
5 min — after which the call fails with a clear control plane unreachable for Xs
error rather than the raw first error. A single successful poll resets the
window. A 404 on a just-submitted execution (the CP can lag before the row is
queryable) retries within a shorter bounded window, then fails with a distinct
not-found message. Permanent 4xx (auth, bad request) still abort immediately.

Submit safety. Re-POSTing execute/async is not idempotent (a duplicate
would double-run a coder), so the submit retries only on errors that prove the
request never reached the server — dial / DNS / connection-refused, detected by
inspecting the error chain (syscall.ECONNREFUSED, net.DNSError, dial-phase
net.OpError). Ambiguous failures (a timeout awaiting headers — the request may
already have been accepted) are never blind-retried; instead the submit client
timeout is raised substantially (default 120s) so a slow-but-healthy CP doesn't
abort an accepted request, failing with a clear message only if it still times
out.

Config. Three env knobs (AGENTFIELD_* integer-seconds convention, read
once and cached): AGENTFIELD_CALL_POLL_TIMEOUT_SECONDS (60),
AGENTFIELD_CALL_RETRY_WINDOW_SECONDS (300),
AGENTFIELD_CALL_SUBMIT_TIMEOUT_SECONDS (120), with dedicated submit/poll HTTP
clients so these timeouts don't affect other client traffic. Each retried
failure logs at warn via the existing structured-log seam
(call.outbound.submit_retry / call.outbound.poll_retry) with attempt count
and elapsed window, so operators see degradation without the call dying.

Python parity. The Python SDK's async Call path (_submit_execution_sync /
_await_execution_sync in client.py) uses bare requests with
raise_for_status() and no transient retry — its shared-session urllib3
Retry(total=3) adapter is not applied to those calls — so it shares this
fragility (its only comparable retry is wait_for_approval's catch-all
back-off loop). This Go change improves on Python: a bounded failure window,
404-specific handling, and idempotency-safe submit retries.

Gates run (Part 4)

  • sdk/go: go build ./..., go vet ./..., go test ./... — pass; gofmt -l
    clean on all changed files.
  • golangci-lint run --new-from-rev=origin/main on sdk/go -> 0 issues
    (also cleaned the pre-existing branch-delta findings this gate surfaced).
  • sdk-go patch-coverage gate (diff-cover vs origin/main) -> 89% on 779
    touched lines, above the 80% min_patch floor; agent package 92.3%.

🤖 Generated with Claude Code

AbirAbbas and others added 3 commits July 10, 2026 09:44
Add a --path flag to `af install` so a single repository can ship more than
one installable agent node (e.g. a Python node at the root and a Go port under
go/). By default `af install` finds the first agentfield-package.yaml root-first;
with --path <subdir> it installs the node whose manifest lives at
<root>/<subdir>/agentfield-package.yaml, and that subtree becomes the package
root that is copied to ~/.agentfield/packages/<name>.

- domain.InstallOptions gains Path (top-level source only; never propagated to
  recursively-installed node dependencies).
- packages.ResolvePackageSubdir / ValidateSubdirSelector: shared, reusable
  resolution+validation. Reject absolute paths and paths that escape the source
  root via ".." (validated before any clone/copy work); a missing manifest is
  reported with the full expected path.
- GitInstaller gains a Subdir field and resolvePackageRoot: no selector keeps the
  historical root-first walk; a selector resolves+validates the subdir post-clone.
  Composes with the existing @ref URL pin (parsed independently).
- Local-directory installs honor --path with identical semantics
  (<local-dir>/<subdir>), resolved before any copy/registry mutation.
- CLI help/examples document --path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Behavior-driven tests derived from the --path validation contract:

- packages: ResolvePackageSubdir / ValidateSubdirSelector (root vs subdir
  selection, in-tree traversal allowed, absolute + escaping paths rejected,
  missing manifest names the expected path); GitInstaller.resolvePackageRoot
  against a faked clone (root install unchanged, --path go selects the go
  subtree, missing/absolute/escaping paths rejected — the last also proving
  InstallFromGit rejects a bad selector before any clone); @ref composes with
  --path; a Go subdir package builds relative to the subdir-as-root.
- services: local-directory `af install --path <subdir>` selects the subdir node,
  bare install installs the root node, a missing/absolute/escaping --path errors
  and leaves the registry unmutated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add a "One repo, many nodes: --path" section to the agent-node install guide
covering git and local sources, @ref composition, the relative-path/escape
rules, and that a bare install is unchanged. Add a --path row to the lifecycle
reference table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AbirAbbas AbirAbbas requested a review from a team as a code owner July 10, 2026 13:46
@github-actions

github-actions Bot commented Jul 10, 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 86.90% 87.40% ↓ -0.50 pp 🟡
sdk-go 92.50% 92.00% ↑ +0.50 pp 🟢
sdk-python 93.76% 93.73% ↑ +0.03 pp 🟢
sdk-typescript 90.42% 90.42% → +0.00 pp 🟢
web-ui 84.76% 84.79% ↓ -0.03 pp 🟡
aggregate 85.55% 85.75% ↓ -0.20 pp 🟡

✅ Gate passed

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

@github-actions

github-actions Bot commented Jul 10, 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 82 92.00%
sdk-go 849 95.00%
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 and others added 3 commits July 10, 2026 09:56
The claude provider appended the prompt as a trailing positional after
repeated --allowedTools flags; claude CLI 2.1.x treats --allowedTools
as variadic and swallows the positional, so every tool-passing call
exited 1 with 'Input must be provided'. The prompt now flows through
stdin (RunCLIWithStdin), which --print reads natively; codex/opencode/
gemini were inspected and are unaffected. Verified against the real
CLI via a gated integration test.

Note() posted to {base}/executions/note without the /api/v1 prefix,
404ing on the control plane (route registered under the /api/v1 group)
and silently dropping every progress note; existing tests masked it by
baking /api/v1 into the base URL, contrary to the SDK-wide bare-base
contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 7c6f11e)
Agent.Call was a synchronous POST /execute/{target} bounded by the
15s CallTimeout http.Client default, killing any caller whose child
reasoner worked longer. It now mirrors the Python SDK: submit via
/execute/async/{target}, then poll the execution record with
Python-parity pacing (0.25s doubling to 4s, jittered), CallTimeout
bounding each request rather than the overall wait, ctx-cancellable,
lineage headers on submit and polls so DAG parentage is unchanged.

The claude provider now uses --output-format stream-json --verbose:
per-event output keeps the CLI idle watchdog fed during long silent
turns (plain json emitted nothing until completion, so >120s turns
were SIGKILLed), and the terminal result event carries the same
fields including total_cost_usd. Real 2.1.191 capture committed as a
fixture; ctx cancellation now kills the whole CLI process group.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 18c3f3e)
…lt-runner fallback

Adds tests closing the patch-coverage gap on PR #746's async-submit
Agent.Call rewrite and the claudecode stdin/stream-json changes.

agent_call_coverage_test.go (package agent) exercises the error/edge
branches of Call's submit + poll loop that the happy-path tests miss:
  - nextCallPollInterval clamps (below floor / above ceiling)
  - submitAsyncExecution: build-request, transport, read-body,
    structured 4xx, and JSON-decode errors
  - awaitExecutionResult: ctx-already-cancelled, GET build error,
    ctx-cancel-during-poll, transport error, read-body error,
    poll HTTP-error status, poll decode error, succeeded-result
    decode error, and succeeded-null-result

claudecode_defaultrunner_test.go covers the runCLI == nil fallback in
ClaudeCodeProvider.Execute via a struct-literal provider (bypassing
NewClaudeCodeProvider) driving the real default runner.

Lifts sdk-go patch coverage from 61% to 98% (agent.go 98.29%,
claudecode.go 100%), clearing the 80% min_patch gate. Tests only; no
production code changed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 5b763d8)
@AbirAbbas AbirAbbas changed the title feat(control-plane): af install --path selector for one-repo multi-package installs feat: af install --path selector + Go SDK harness fixes Jul 10, 2026
@github-actions

github-actions Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Performance

SDK Memory Δ Latency Δ Tests Status
Go 226 B -19% 0.63 µs -37%

✓ No regressions detected

AbirAbbas and others added 7 commits July 10, 2026 11:05
Port the codex harness patch behaviors into the Go codex provider so it
matches the Python reference (runtime/codex_harness_patch.py):

- Pass `-m <model>` (was ignored entirely; SWE-AF resolves gpt-5.5 vs
  gpt-5.3-codex by auth mode and the value must reach the CLI).
- Add `--skip-git-repo-check` so the harness runs outside a git repo.
- Replace deprecated `--full-auto` with the permission->sandbox mapping:
  "auto" -> --dangerously-bypass-approvals-and-sandbox; read-only /
  workspace-write / danger-full-access -> --sandbox <mode>; else
  --sandbox workspace-write.
- Deliver the prompt via stdin (not argv), mirroring the claude provider.
- Native structured output: when a schema is set, pass --output-schema
  (pointing at the STRICT rewrite) + --output-last-message, and read the
  last-message file when stdout yields no parseable final text.

Schema plumbing uses a schemaAware interface the runner detects: the
runner owns the strict-schema rewrite (codexStrictJSONSchema, ported from
_codex_strict_json_schema), writes it, hands the provider the
deterministic paths, and swaps in a codex-native prompt suffix while
claude/opencode keep the Write-tool suffix. Options gains no schema field.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ParseAndValidate was unmarshal-only, so output missing required fields,
carrying invalid enum values, or (with additionalProperties:false)
extra fields passed silently and the schema-retry loop never fired.

Add validateAgainstSchema (github.com/santhosh-tekuri/jsonschema/v5) and
run it at every parse-success point in handleSchemaWithRetry — after the
initial ParseAndValidate/TryParseFromText and inside each retry iteration
— so a validation failure sets err and the EXISTING retry branch fires
(schemaMaxRetries default 2 preserved). Validation applies only when both
a destination struct and a schema are provided; uncompilable schemas skip
validation (return nil) so there is no regression versus unmarshal-only.

This makes map-schema harness calls stricter than before; callers control
strictness through the schema they pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Port the Python opencode provider behaviors (providers/opencode.py):

- Add `--format json` and parse the JSONL event stream: recover the final
  assistant text (extract_final_text parity), sum per-step cost from
  step_finish events (nil when none report a cost, distinguishing
  "unknown" from "$0.00"), and count turns as one per step_start, falling
  back to tool_use events.
- Surface in-band JSON "error" events and — critically — hard failures on
  which opencode exits 0 (Model not found, AuthenticationError,
  Unauthorized, APIError): when stderr matches one of those markers and no
  result was produced, mark IsError with the extracted error window
  instead of returning empty output that reads downstream as "no result".

Non-JSON stdout still falls back to trimmed raw text, so older opencode
versions keep working.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add a native ReasonerFailed error type (Message / Result / ErrorDetails)
so a reasoner that ran but failed can report status=failed WITHOUT
discarding its structured outcome — mirroring the Python SDK's
ReasonerFailed exception.

In executeReasonerAsync's error branch, detect it via errors.As and
attach payload["result"] / payload["error_details"] (only when non-nil)
so the single 5x-retried status post carries the result atomically; the
control plane stores the result regardless of terminal status. The two
sync HTTP paths (handleExecute / handleReasoner) mirror this by carrying
the result/details onto their error response. A plain error synthesizes
neither key.

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

The async submit+poll Agent.Call path (introduced earlier on this branch)
failed a call the instant a single HTTP request to the control plane failed.
Real-world testing exposed the impact: two ~hour-long SWE-AF builds died at
~55 minutes when the CP briefly went slow under system load — one timed-out
status poll ("context deadline exceeded while awaiting headers") aborted calls
that had been running for 30+ minutes, and a slow submit POST likewise killed
a call before the request could be confirmed accepted.

Poll resilience: a transient poll failure (transport error/timeout, 408/429,
or 5xx) no longer fails the call. Consecutive failures are retried with
exponential backoff (capped at 15s) for a configurable window of UNBROKEN
failure — default 5 minutes — after which the call fails with a clear
"control plane unreachable for Xs" error instead of the raw first error. A
single successful poll resets the window. A 404 on a just-submitted execution
(the CP can lag before the row is queryable) is retried within a shorter
bounded window, then fails with a distinct not-found message. Permanent 4xx
(auth, bad request) still abort immediately.

Submit safety: re-POSTing execute/async is NOT idempotent (a duplicate would
double-run a coder), so the submit is retried ONLY on errors that prove the
request never reached the server — dial/DNS/connection-refused, detected by
inspecting the error chain (syscall.ECONNREFUSED, net.DNSError, dial-phase
net.OpError). Ambiguous failures (a timeout awaiting headers — the request may
already have been accepted) are never blind-retried; instead the submit client
timeout is raised substantially (default 120s) so a slow-but-healthy CP does
not abort an accepted request, and the call fails with a clear message if it
still times out.

Config: three env knobs following the AGENTFIELD_* integer-seconds convention,
read once and cached on the agent — AGENTFIELD_CALL_POLL_TIMEOUT_SECONDS (60),
AGENTFIELD_CALL_RETRY_WINDOW_SECONDS (300), AGENTFIELD_CALL_SUBMIT_TIMEOUT_SECONDS
(120) — with dedicated submit/poll HTTP clients so these timeouts don't affect
other client traffic. Each retried failure is logged at warn via the existing
structured-log seam (call.outbound.submit_retry / call.outbound.poll_retry)
with attempt count and elapsed window so operators see degradation without the
call dying.

The Python SDK's async Call path (_submit_execution_sync / _await_execution_sync)
uses bare requests with raise_for_status() and no transient retry, so it shares
this fragility; this change improves on it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extends the async-Call test suite for the submit/poll resilience change:

- Poll retries: 2 healthy polls, then 3 transient 5xx, then recovery — the
  call succeeds and each transient failure emits a warn call.outbound.poll_retry
  log with an attempt count.
- Poll unreachable: a CP down past the (env-shortened) window fails with the
  "control plane unreachable" error, having retried rather than dying on the
  first blip.
- 404 after submit: retried within the bounded window, then a distinct 404
  not-found error.
- Submit connection-refused: refused twice then accepted creates EXACTLY ONE
  execution (asserted server-side); refused beyond the window fails unreachable.
- Submit ambiguous timeout: a hung-but-listening CP is NOT retried (exactly one
  request reaches the server) and fails with a clear message.
- Env overrides read once and cached; invalid values fall back to defaults.
- Direct unit tests for requestNeverReachedServer (dial/DNS/econnrefused vs
  ambiguous/opaque), isTransientPollStatus, nextRetryBackoff, and sleepCtx.

Existing branch tests updated for the new semantics: submit/poll now use the
dedicated call clients; a persistent poll transport error is retried to the
unreachable-window error; a permanent poll status uses 403 (5xx is now
retried). Also checks previously-unchecked json.Encode returns and switches to
tagged switches so golangci-lint is clean on the branch delta.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Call() and Config.CallTimeout doc comments still claimed CallTimeout bounds
the async submit and each status poll. That is no longer true: the Call path now
uses dedicated submit/poll HTTP clients bounded by
AGENTFIELD_CALL_SUBMIT_TIMEOUT_SECONDS / AGENTFIELD_CALL_POLL_TIMEOUT_SECONDS,
with transient failures retried within AGENTFIELD_CALL_RETRY_WINDOW_SECONDS.
Update both comments so the documented timeout semantics match the code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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