diff --git a/go/README.md b/go/README.md index 139fe0ab..5343f568 100644 --- a/go/README.md +++ b/go/README.md @@ -165,7 +165,7 @@ set; the load-bearing ones: | `AGENT_CALLBACK_URL` | Public URL the control plane calls the node back on. **Required for any containerized/remote deploy that isn't this compose file** (compose sets it per service) — without it the CP gets `504 agent_unreachable` | | `NODE_ID` | Node ID (`swe-planner` / `swe-fast`) | | `PORT` | Listen port (`8005` / `8006`) | -| `SWE_PRO_ENGINE` | Route per-issue coding through the bundled high-performance coding engine (beta). **Set to `1` for you by `af install` / Desktop; unset (off) everywhere else** — clone, fork, compose, bare binary. `1`/`true`/`yes`/`on` enables, `0`/`false` disables | +| `SWE_PRO_ENGINE` | Route per-issue coding through the bundled high-performance coding engine (beta), vendored for darwin-arm64, darwin-amd64, linux-amd64, and linux-arm64. Windows is not yet supported by the engine; the node logs a warning and falls back to the classic loop there. **Set to `1` for you by `af install` / Desktop; unset (off) everywhere else** — clone, fork, compose, bare binary. `1`/`true`/`yes`/`on` enables, `0`/`false` disables | | `SWE_PRO_VARIANT` | Engine reasoning-effort variant (e.g. `low` for fastest turnaround, `high` for depth). Unset keeps the engine's own default | | `SWE_PRO_MAX_COST` | Per-run cost ceiling in USD forwarded to the engine on every dispatch. Unset: no SWE-AF-side ceiling | | `SWE_PRO_PUBLIC_URL` | Callback base URL for the engine, mirroring `AGENT_CALLBACK_URL` on the nodes. **In Docker this must be set to a container-reachable URL**, otherwise the control plane can't call the engine back | diff --git a/go/bin/swe-pro-darwin-amd64 b/go/bin/swe-pro-darwin-amd64 new file mode 100755 index 00000000..11a916de Binary files /dev/null and b/go/bin/swe-pro-darwin-amd64 differ diff --git a/go/bin/swe-pro-linux-arm64 b/go/bin/swe-pro-linux-arm64 new file mode 100755 index 00000000..4b41113f Binary files /dev/null and b/go/bin/swe-pro-linux-arm64 differ diff --git a/go/docs/pro-engine.md b/go/docs/pro-engine.md index fbd2ce81..0df30ba7 100644 --- a/go/docs/pro-engine.md +++ b/go/docs/pro-engine.md @@ -2,9 +2,11 @@ The Go node runs a high-performance coding engine, shipped as prebuilt binaries — one per supported platform, vendored at `go/bin` as -`swe-pro-darwin-arm64` and `swe-pro-linux-amd64`, because one checkout is -installed on macOS and Linux alike and the node picks the matching build at -startup. +`swe-pro-darwin-arm64`, `swe-pro-darwin-amd64`, `swe-pro-linux-amd64`, and +`swe-pro-linux-arm64`, because one checkout is installed on macOS and Linux +alike and the node picks the matching build at startup. The engine does not yet +support Windows; there the node logs a warning and falls back to the classic +coding loop. It is **on by default for nodes installed with `af install` or AgentField Desktop**: `agentfield-package.yaml` declares `SWE_PRO_ENGINE` with @@ -59,8 +61,8 @@ to switch back. Two things differ from the classic loop, both additive: The engine never pushes or opens PRs — branch, push and PR creation stay with the standard pipeline, so the deliverables are unchanged. -If the flag is set but no *runnable* engine binary is found — missing, or -present without its execute bit — the node logs a warning naming the path and +If the flag is set but no *runnable* engine binary is found — missing, or on +Unix present without its execute bit — the node logs a warning naming the path and comes up on the classic coding loop: `pro_execute` is not registered and nothing is routed to an engine node that never joined. The binary is searched for at `SWE_PRO_BIN` when set (authoritative — no fallback), else @@ -75,7 +77,7 @@ what keeps a macOS install from exec'ing the Linux build. | Variable | Default | Purpose | |---|---|---| | `SWE_PRO_ENGINE` | `1` via the manifest on `af install` / Desktop; unset (off) for a clone, fork, compose stack or bare binary | Truthy (`1`/`true`/`yes`/`on`) enables; `0`/`false` opts out | -| `SWE_PRO_BIN` | `/usr/local/bin/swe-pro`, else a `swe-pro--` / `swe-pro` sibling | Engine binary path (authoritative when set) | +| `SWE_PRO_BIN` | `/usr/local/bin/swe-pro`, else a `swe-pro--` / `swe-pro` sibling (`.exe` names first on Windows) | Engine binary path (authoritative when set); must have an execute bit on Unix | | `SWE_PRO_NODE_ID` | `swe-pro` | Engine's control-plane node id | | `SWE_PRO_PORT` | `8801` | Engine's listen port | | `SWE_PRO_PUBLIC_URL` | `http://localhost:8801` (engine default) | Callback base URL — **must** be set to a container-reachable address in Docker, otherwise the control plane cannot reach the engine | diff --git a/go/internal/pro/pro.go b/go/internal/pro/pro.go index 10f69a33..4da337ec 100644 --- a/go/internal/pro/pro.go +++ b/go/internal/pro/pro.go @@ -101,16 +101,23 @@ func Enabled() bool { func BinPath() string { return envOr(EnvBin, DefaultBin) } // runnable reports whether path is an existing regular file we could actually -// spawn. Mere existence is not enough: a copy that lost its execute bit (some -// installers create destination files with a fresh 0644 mode) would otherwise -// look available and then fail at exec time, which is exactly the state the -// availability gate exists to avoid. +// spawn. On Unix, mere existence is not enough: a copy that lost its execute +// bit (some installers create destination files with a fresh 0644 mode) would +// otherwise look available and then fail at exec time. Windows does not expose +// execute bits through os.Stat, so a regular file is sufficient there. func runnable(path string) bool { info, err := os.Stat(path) - if err != nil || info.IsDir() { + if err != nil { + return false + } + return runnableMode(runtime.GOOS, info.Mode()) +} + +func runnableMode(goos string, mode os.FileMode) bool { + if !mode.IsRegular() { return false } - return info.Mode().Perm()&0o111 != 0 + return goos == "windows" || mode.Perm()&0o111 != 0 } // osExecutable is os.Executable, indirected so tests can point the sibling @@ -126,7 +133,15 @@ var osExecutable = os.Executable // plain name stays as a fallback for layouts that place one hand-built engine // beside the node (an unpacked image, a local engine build). func siblingNames() []string { - return []string{"swe-pro-" + runtime.GOOS + "-" + runtime.GOARCH, "swe-pro"} + return siblingNamesFor(runtime.GOOS, runtime.GOARCH) +} + +func siblingNamesFor(goos, goarch string) []string { + platformName := "swe-pro-" + goos + "-" + goarch + if goos == "windows" { + return []string{platformName + ".exe", "swe-pro.exe", platformName, "swe-pro"} + } + return []string{platformName, "swe-pro"} } // ResolveBin returns the first runnable engine binary on disk. An explicit diff --git a/go/internal/pro/pro_test.go b/go/internal/pro/pro_test.go index 3bcbf0eb..005dcc90 100644 --- a/go/internal/pro/pro_test.go +++ b/go/internal/pro/pro_test.go @@ -80,6 +80,49 @@ func TestDefaults(t *testing.T) { } } +func TestSiblingNamesFor(t *testing.T) { + tests := []struct { + goos, goarch string + want []string + }{ + {"linux", "amd64", []string{"swe-pro-linux-amd64", "swe-pro"}}, + {"darwin", "arm64", []string{"swe-pro-darwin-arm64", "swe-pro"}}, + {"windows", "amd64", []string{"swe-pro-windows-amd64.exe", "swe-pro.exe", "swe-pro-windows-amd64", "swe-pro"}}, + } + for _, tt := range tests { + t.Run(tt.goos+"/"+tt.goarch, func(t *testing.T) { + got := siblingNamesFor(tt.goos, tt.goarch) + if strings.Join(got, "\x00") != strings.Join(tt.want, "\x00") { + t.Errorf("siblingNamesFor(%q, %q) = %q, want %q", tt.goos, tt.goarch, got, tt.want) + } + }) + } +} + +func TestRunnableMode(t *testing.T) { + tests := []struct { + name string + goos string + mode os.FileMode + want bool + }{ + {"linux regular 0644", "linux", 0o644, false}, + {"linux regular 0755", "linux", 0o755, true}, + {"darwin regular 0644", "darwin", 0o644, false}, + {"darwin regular 0755", "darwin", 0o755, true}, + {"windows regular 0666", "windows", 0o666, true}, + {"linux directory", "linux", os.ModeDir | 0o755, false}, + {"windows directory", "windows", os.ModeDir | 0o777, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := runnableMode(tt.goos, tt.mode); got != tt.want { + t.Errorf("runnableMode(%q, %v) = %v, want %v", tt.goos, tt.mode, got, tt.want) + } + }) + } +} + // TestResolveBin covers the three-step search: explicit SWE_PRO_BIN is // authoritative (found or not — no fall-through), and Available() is the // flag AND binary-presence conjunction. @@ -110,12 +153,18 @@ func TestResolveBin(t *testing.T) { t.Fatal(err) } t.Setenv(EnvBin, nonExec) - if _, ok := ResolveBin(); ok { - t.Error("ResolveBin() reported a non-executable file as usable") - } t.Setenv(EnvEnabled, "1") - if Available() { - t.Error("Available() = true for a non-executable binary — must degrade to the classic loop") + if runtime.GOOS == "windows" { + if _, ok := ResolveBin(); !ok || !Available() { + t.Error("regular Windows binary reported unusable because os.Stat exposes no execute bits") + } + } else { + if _, ok := ResolveBin(); ok { + t.Error("ResolveBin() reported a non-executable file as usable") + } + if Available() { + t.Error("Available() = true for a non-executable binary — must degrade to the classic loop") + } } // A directory at the binary path is likewise not runnable (os.Stat alone @@ -152,24 +201,28 @@ func TestResolveBinSiblings(t *testing.T) { if runnable(DefaultBin) { t.Skipf("%s exists on this host and short-circuits the sibling search", DefaultBin) } - suffixed := "swe-pro-" + runtime.GOOS + "-" + runtime.GOARCH + names := siblingNames() + suffixed, plain := names[0], names[1] - cases := []struct { + type testCase struct { name string // present maps sibling file name to its mode; 0o644 is the // present-but-unusable case the availability gate must reject. present map[string]os.FileMode want string // sibling name, or "" for "no usable engine" wantOK bool - }{ - {"suffixed preferred over plain", map[string]os.FileMode{suffixed: 0o755, "swe-pro": 0o755}, suffixed, true}, + } + cases := []testCase{ + {"suffixed preferred over plain", map[string]os.FileMode{suffixed: 0o755, plain: 0o755}, suffixed, true}, {"suffixed alone", map[string]os.FileMode{suffixed: 0o755}, suffixed, true}, - {"plain alone is the fallback", map[string]os.FileMode{"swe-pro": 0o755}, "swe-pro", true}, + {"plain alone is the fallback", map[string]os.FileMode{plain: 0o755}, plain, true}, {"neither present", nil, "", false}, - {"plain usable, suffixed not", map[string]os.FileMode{suffixed: 0o644, "swe-pro": 0o755}, "swe-pro", true}, - // Both unusable: the warning must name the suffixed candidate, the one - // this platform was meant to run. - {"both unusable names the best candidate", map[string]os.FileMode{suffixed: 0o644, "swe-pro": 0o644}, suffixed, false}, + } + if runtime.GOOS != "windows" { + cases = append(cases, + testCase{"plain usable, suffixed not", map[string]os.FileMode{suffixed: 0o644, plain: 0o755}, plain, true}, + testCase{"both unusable names the best candidate", map[string]os.FileMode{suffixed: 0o644, plain: 0o644}, suffixed, false}, + ) } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) {