Skip to content

Commit 23d9086

Browse files
AbirAbbasclaude
andauthored
fix(af run): eliminate port race that killed healthy nodes on startup (#737)
`af run <node>` intermittently failed with 'agent node did not become ready within 10s' — worst for import-heavy nodes like pr-af. Root cause is a check-then-exec race between the runner and the SDK: 1. The runner probes a free port, releases the probe, exports PORT=N, then polls ONLY port N for readiness. 2. If N is momentarily unavailable when the child binds, the SDK SILENTLY moves to the next free port (N+1) — a port the runner never learns about. The runner then polls the dead port N until timeout and kills a node that actually came up fine on N+1. 3. The 10s readiness window is also too short for nodes with large import graphs, independent of the race. Fix (regression-safe, gated): - Runner exports AGENTFIELD_STRICT_PORT=1 alongside PORT. - SDK, only when that signal is set, binds the injected PORT authoritatively: if it is unavailable it exits with a clear error instead of silently bumping, so the runner and node can never disagree about the port. Absent the signal (standalone `python -m <node>.app`, manual PORT=...), the old lenient auto-bump behavior is unchanged — no regression. - Readiness timeout is now 30s and configurable via AGENTFIELD_NODE_READY_TIMEOUT (whole seconds). - Guard PortManager's reserved-ports map with a mutex (it was read/written without synchronization). Verified: with the patched control plane + SDK, all four reference nodes (swe-planner, cloudsecurity-af, sec-af, pr-af) start on their assigned ports and pass health; pr-af — previously failing almost every run — now binds its assigned port every time. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e816e3f commit 23d9086

3 files changed

Lines changed: 50 additions & 5 deletions

File tree

control-plane/internal/core/services/agent_service.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"os/exec"
1212
"path/filepath"
1313
"runtime"
14+
"strconv"
1415
"strings"
1516
"syscall"
1617
"time"
@@ -120,7 +121,7 @@ func (as *DefaultAgentService) runAgentGuarded(name string, options domain.RunOp
120121
if metadata, err := packages.ParsePackageMetadata(agentNode.Path); err == nil {
121122
healthPath = metadata.HealthcheckPath()
122123
}
123-
if err := as.waitForAgentNode(port, healthPath, 10*time.Second); err != nil {
124+
if err := as.waitForAgentNode(port, healthPath, nodeReadyTimeout()); err != nil {
124125
// Kill the process if it failed to start properly
125126
if stopErr := as.processManager.Stop(pid); stopErr != nil {
126127
return nil, fmt.Errorf("agent node failed to start: %w (additionally failed to stop process: %v)", err, stopErr)
@@ -513,6 +514,11 @@ func (as *DefaultAgentService) buildProcessConfig(agentNode packages.InstalledPa
513514
serverURL := resolveServerURL()
514515
env := os.Environ()
515516
env = append(env, fmt.Sprintf("PORT=%d", port))
517+
// Tell the SDK to bind exactly this port and fail fast if it is unavailable,
518+
// rather than silently moving to another port that the runner is not polling
519+
// (the readiness check below targets this exact port). Gated by this signal so
520+
// standalone `python -m <node>.app` keeps its lenient auto-port fallback.
521+
env = append(env, "AGENTFIELD_STRICT_PORT=1")
516522
env = append(env, fmt.Sprintf("AGENTFIELD_SERVER=%s", serverURL))
517523
env = append(env, fmt.Sprintf("AGENTFIELD_SERVER_URL=%s", serverURL))
518524

@@ -616,6 +622,20 @@ func (as *DefaultAgentService) resolveNodeEnvironment(nodeName string, metadata
616622
return resolver.Resolve(env)
617623
}
618624

625+
// nodeReadyTimeout is how long to wait for a freshly started node to answer its
626+
// health check. Import-heavy nodes (large dependency graphs) routinely take more
627+
// than the old hardcoded 10s to boot, which produced spurious "did not become
628+
// ready" failures on nodes that were actually starting fine. Default 30s,
629+
// overridable via AGENTFIELD_NODE_READY_TIMEOUT (whole seconds).
630+
func nodeReadyTimeout() time.Duration {
631+
if v := os.Getenv("AGENTFIELD_NODE_READY_TIMEOUT"); v != "" {
632+
if secs, err := strconv.Atoi(v); err == nil && secs > 0 {
633+
return time.Duration(secs) * time.Second
634+
}
635+
}
636+
return 30 * time.Second
637+
}
638+
619639
// waitForAgentNode waits for the agent node to become ready
620640
func (as *DefaultAgentService) waitForAgentNode(port int, healthPath string, timeout time.Duration) error {
621641
if healthPath == "" {

control-plane/internal/infrastructure/process/port_manager.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@ package process
44
import (
55
"fmt"
66
"net"
7+
"sync"
78

89
"github.com/Agent-Field/agentfield/control-plane/internal/core/interfaces"
910
)
1011

1112
// DefaultPortManager provides a default implementation for managing network ports.
1213
// It keeps track of reserved ports in memory.
1314
type DefaultPortManager struct {
15+
mu sync.Mutex
1416
reservedPorts map[int]bool
1517
}
1618

@@ -26,10 +28,10 @@ func NewPortManager() interfaces.PortManager {
2628
// It checks both system availability and internal reservations.
2729
// Returns the first free port found, or an error if no port is available in the range.
2830
func (pm *DefaultPortManager) FindFreePort(startPort int) (int, error) {
29-
// The search range is typically small, e.g., 100 ports.
30-
// This can be made configurable if needed.
31+
pm.mu.Lock()
32+
defer pm.mu.Unlock()
3133
for port := startPort; port <= startPort+100; port++ {
32-
if pm.IsPortAvailable(port) && !pm.reservedPorts[port] {
34+
if !pm.reservedPorts[port] && pm.IsPortAvailable(port) {
3335
return port, nil
3436
}
3537
}
@@ -53,6 +55,8 @@ func (pm *DefaultPortManager) IsPortAvailable(port int) bool {
5355
// It first checks if the port is system-available before reserving.
5456
// Returns an error if the port is not available or cannot be reserved.
5557
func (pm *DefaultPortManager) ReservePort(port int) error {
58+
pm.mu.Lock()
59+
defer pm.mu.Unlock()
5660
if !pm.IsPortAvailable(port) {
5761
return fmt.Errorf("port %d is not available at the system level", port)
5862
}
@@ -67,6 +71,8 @@ func (pm *DefaultPortManager) ReservePort(port int) error {
6771
// This makes the port available for future reservations by this manager.
6872
// It does not affect the system-level availability of the port.
6973
func (pm *DefaultPortManager) ReleasePort(port int) error {
74+
pm.mu.Lock()
75+
defer pm.mu.Unlock()
7076
if _, ok := pm.reservedPorts[port]; !ok {
7177
return fmt.Errorf("port %d was not reserved by this manager", port)
7278
}

sdk/python/agentfield/agent_server.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -677,7 +677,26 @@ def serve(
677677
env_port = os.getenv("PORT")
678678
if env_port and env_port.isdigit():
679679
suggested_port = int(env_port)
680-
if AgentUtils.is_port_available(suggested_port):
680+
if os.getenv("AGENTFIELD_STRICT_PORT") == "1":
681+
# The AgentField runner assigned this exact port and polls it
682+
# for readiness. Bind it authoritatively — never silently move
683+
# to another port, or the runner would poll a port nothing is
684+
# listening on and kill us for "not becoming ready". If it is
685+
# genuinely unavailable, exit fast with a clear error so the
686+
# runner surfaces a real failure instead of a phantom timeout.
687+
if not AgentUtils.is_port_available(suggested_port):
688+
log_error(
689+
f"AGENTFIELD_STRICT_PORT set but the assigned port "
690+
f"{suggested_port} is unavailable; exiting so the "
691+
f"control plane can reallocate and retry"
692+
)
693+
raise RuntimeError(
694+
f"assigned port {suggested_port} is unavailable"
695+
)
696+
port = suggested_port
697+
if self.agent.dev_mode:
698+
log_debug(f"Using assigned port from AgentField CLI: {port}")
699+
elif AgentUtils.is_port_available(suggested_port):
681700
port = suggested_port
682701
if self.agent.dev_mode:
683702
log_debug(f"Using port from AgentField CLI: {port}")

0 commit comments

Comments
 (0)