Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,31 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Pushes the Homebrew formula to spinloop-ai/homebrew-tap.
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}

agent-image:
needs: test
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v7

# The tag names the image the same way it names the release — GoReleaser
# stamps main.version from it in the binaries job above, and
# DefaultDockerImage builds --dispatch docker's default from the same
# value at runtime, so the two never drift apart.
- name: Resolve the image tag
id: tag
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"

- name: Log in to ghcr.io
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin

- name: Build and push
run: |
docker buildx build --push \
--platform linux/amd64,linux/arm64 \
-t "ghcr.io/spinloop-ai/agent:${{ steps.tag.outputs.version }}" \
-t ghcr.io/spinloop-ai/agent:latest \
images/agent
6 changes: 6 additions & 0 deletions cmd/spinloop/complete.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,12 @@ func compLogLevel(_ *cobra.Command, _ []string, _ string) ([]string, cobra.Shell
return daemon.LevelNames(), cobra.ShellCompDirectiveNoFileComp
}

// compDispatchBackends offers the dispatch backends spinloop orchestrator
// accepts.
func compDispatchBackends(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"bare", "docker"}, cobra.ShellCompDirectiveNoFileComp
}

// compFiles offers no static candidates and lets the shell fall back to
// filesystem paths.
func compFiles(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
Expand Down
8 changes: 8 additions & 0 deletions cmd/spinloop/complete_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,14 @@ func TestComplete_FlagValues(t *testing.T) {
if _, directive := complete(t, "orchestrator", "--fleet", ""); directive != directiveFile {
t.Errorf("orchestrator --fleet should complete paths, got %q", directive)
}
// --harness-config names a file too.
if _, directive := complete(t, "orchestrator", "--harness-config", ""); directive != directiveFile {
t.Errorf("orchestrator --harness-config should complete paths, got %q", directive)
}
// --dispatch offers the two backends.
if got, _ := complete(t, "orchestrator", "--dispatch", ""); !hasAll(got, "bare", "docker") {
t.Errorf("orchestrator --dispatch: bare/docker missing from %v", got)
}
if got, _ := complete(t, "completion", ""); !hasAll(got, "bash", "zsh", "powershell") {
t.Errorf("shells missing from %v", got)
}
Expand Down
54 changes: 50 additions & 4 deletions cmd/spinloop/orchestrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ func orchestratorCmd() *cobra.Command {
var createItemDirs bool
var listen, apiToken, apiTokenFile string
var loopback bool
var dispatchBackend, dispatchImage, harnessConfigPath string
c := &cobra.Command{
Use: "orchestrator",
Short: "work a backlog of items against the fleet, at the fleet's pace",
Expand Down Expand Up @@ -81,7 +82,7 @@ without a separate call to spinloop work list.`,
if err != nil {
return err
}
return runOrchestratorCommand(gateway, itemsPath, tokenVar, harnessName, logLevel, createItemDirs, listenAddr, apiToken, apiTokenFile)
return runOrchestratorCommand(gateway, itemsPath, tokenVar, harnessName, c.Flags().Changed("harness"), logLevel, createItemDirs, listenAddr, apiToken, apiTokenFile, dispatchBackend, c.Flags().Changed("dispatch"), dispatchImage, harnessConfigPath)
},
}

Expand All @@ -98,9 +99,14 @@ without a separate call to spinloop work list.`,
fs.BoolVarP(&loopback, "loopback", "l", false, "serve the work list API on loopback on the default port ("+orchestrator.LoopbackListen+"); needs no token")
fs.StringVar(&apiTokenFile, "api-token-file", "", "read the work list API's bearer token from this file")
fs.StringVar(&apiToken, "api-token", "", "the work list API's bearer token")
fs.StringVar(&dispatchBackend, "dispatch", "bare", "how an admitted item's agent runs: bare (a host process) or docker (a container)")
fs.StringVar(&dispatchImage, "dispatch-image", "", "the agent image the docker backend runs (default: ghcr.io/spinloop-ai/agent:<spinloop's version>); only meaningful with --dispatch docker")
fs.StringVar(&harnessConfigPath, "harness-config", "", "the harness.yaml to read (default: harness.yaml beside the items file, where one exists)")
compRegister(c, "items", compFiles)
compRegister(c, "harness", compHarnessNames)
compRegister(c, "log-level", compLogLevel)
compRegister(c, "dispatch", compDispatchBackends)
compRegister(c, "harness-config", compFiles)
return c
}

Expand Down Expand Up @@ -164,8 +170,23 @@ func orchestratorListenAddr(listen string, listenExplicit, loopback bool) (strin
// and then the work list API standing before the run's first pass, the way
// the gateway has its handler in before a signal can arrive, held until the
// signal ends the run and the server goes down with it.
func runOrchestratorCommand(gatewayAddr, itemsPath, token, harnessName, logLevel string, createItemDirs bool, listenAddr, apiToken, apiTokenFile string) error {
h, _, err := harness.Resolve(harnessName)
func runOrchestratorCommand(gatewayAddr, itemsPath, token, harnessName string, harnessChanged bool, logLevel string, createItemDirs bool, listenAddr, apiToken, apiTokenFile, dispatchBackend string, dispatchChanged bool, dispatchImage, harnessConfigPath string) error {
hc, err := orchestrator.LoadHarnessConfig(harnessConfigPath, itemsPath)
if err != nil {
return err
}
if err := orchestrator.CheckHarnessEnvCollision(hc.Env); err != nil {
return err
}

// --harness, given explicitly, wins outright; otherwise harness.yaml's
// own harness: is tried before harness.Resolve's own env-var/stored-
// preference/default chain.
resolveName := harnessName
if !harnessChanged && hc.Harness != "" {
resolveName = hc.Harness
}
h, _, err := harness.Resolve(resolveName)
if err != nil {
return err
}
Expand All @@ -190,7 +211,31 @@ func runOrchestratorCommand(gatewayAddr, itemsPath, token, harnessName, logLevel
}
defer ln.Close()

dispatch := orchestrator.NewDispatcher(h, gatewayAddr, token, createItemDirs)
// --dispatch, given explicitly, wins outright; otherwise harness.yaml's
// own dispatch: is the run's choice, and the flag's default ("bare")
// only where neither says anything.
backend, backendSource := dispatchBackend, "--dispatch"
if !dispatchChanged && hc.Dispatch != "" {
backend, backendSource = hc.Dispatch, "harness.yaml's dispatch"
}

var dispatch orchestrator.Launcher
switch backend {
case "", "bare":
dispatch = orchestrator.NewDispatcher(h, gatewayAddr, token, createItemDirs).WithHarnessConfig(hc).WithBaseDir(hc.BaseDir)
case "docker":
if err := orchestrator.CheckDockerReachable(); err != nil {
return err
}
image := dispatchImage
if image == "" {
image = orchestrator.DefaultDockerImage(version)
}
dispatch = orchestrator.NewDockerLauncher(h, gatewayAddr, token, image, createItemDirs).WithHarnessConfig(hc).WithBaseDir(hc.BaseDir)
default:
return fmt.Errorf("unknown %s %q: expected bare or docker", backendSource, backend)
}

store, err := orchestrator.OpenStore(itemsPath)
if err != nil {
return err
Expand All @@ -199,6 +244,7 @@ func runOrchestratorCommand(gatewayAddr, itemsPath, token, harnessName, logLevel
if err != nil {
return err
}
wl = wl.WithBaseDir(hc.BaseDir)
// The store's lock goes with the process, after the server has gone
// down and with it any request that could still touch the store.
defer wl.Close()
Expand Down
155 changes: 155 additions & 0 deletions cmd/spinloop/orchestrator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -543,3 +543,158 @@ func TestCmdOrchestrator_StartupShowsARestartsRecoveredState(t *testing.T) {
t.Fatal("the interrupt did not end the run")
}
}

// An unrecognised --dispatch stops the command before it works an item,
// naming the flag, the value, and the accepted set.
func TestCmdOrchestrator_AnUnrecognisedDispatchBackendNamesIt(t *testing.T) {
isolateConfig(t)
t.Setenv("OPENAI_API_KEY", "the-token")
dir := t.TempDir()
t.Chdir(dir)
mustWrite(t, "work.yaml", "- id: a\n instructions: do\n dir: .\n")
err := cmdOrchestrator([]string{"--gateway", "http://gw:4000", "-l", "--dispatch", "vm"})
if err == nil {
t.Fatal("an unrecognised --dispatch should stop the command")
}
if !strings.Contains(err.Error(), "--dispatch") || !strings.Contains(err.Error(), "vm") ||
!strings.Contains(err.Error(), "bare") || !strings.Contains(err.Error(), "docker") {
t.Errorf("the failure should name the flag, the value, and the accepted set, got %v", err)
}
if _, err := os.Stat("work.yaml.state.json"); !os.IsNotExist(err) {
t.Errorf("a run that worked no item records no state, got %v", err)
}
}

// harness.yaml's dispatch: is the run's choice where --dispatch is not
// given: naming a value the command does not recognise stops it, naming
// harness.yaml's own field rather than the flag.
func TestCmdOrchestrator_HarnessYamlDispatchIsUsedWhenFlagNotGiven(t *testing.T) {
isolateConfig(t)
t.Setenv("OPENAI_API_KEY", "the-token")
dir := t.TempDir()
t.Chdir(dir)
mustWrite(t, "work.yaml", "- id: a\n instructions: do\n dir: .\n")
mustWrite(t, "harness.yaml", "dispatch: vm\n")
err := cmdOrchestrator([]string{"--gateway", "http://gw:4000", "-l"})
if err == nil {
t.Fatal("an unrecognised harness.yaml dispatch should stop the command")
}
if !strings.Contains(err.Error(), "harness.yaml") || !strings.Contains(err.Error(), "vm") {
t.Errorf("the failure should name harness.yaml's own field and the value, got %v", err)
}
}

// An explicit --dispatch wins over harness.yaml's dispatch:, even where
// harness.yaml names a value the command would otherwise refuse.
func TestCmdOrchestrator_ExplicitDispatchFlagWinsOverHarnessYaml(t *testing.T) {
isolateConfig(t)
t.Setenv("OPENAI_API_KEY", "the-token")
dir := t.TempDir()
t.Chdir(dir)
mustWrite(t, "work.yaml", "- id: a\n instructions: do\n dir: .\n")
mustWrite(t, "harness.yaml", "dispatch: vm\n")
// bare, explicitly, reaches the (unreachable) gateway's own failure —
// not harness.yaml's unrecognised value, which --dispatch bare never
// consults.
err := cmdOrchestrator([]string{"--gateway", "http://127.0.0.1:1", "-l", "--dispatch", "bare"})
if err == nil || !strings.Contains(err.Error(), "http://127.0.0.1:1") {
t.Errorf("an explicit --dispatch should win over harness.yaml's, got %v", err)
}
}

// --dispatch docker checks the daemon once, at startup: an unreachable one
// stops the command before it works an item, naming the fix.
func TestCmdOrchestrator_DispatchDockerChecksTheDaemonAtStartup(t *testing.T) {
isolateConfig(t)
t.Setenv("OPENAI_API_KEY", "the-token")
// No `docker` on PATH stands in for a daemon that does not answer: both
// are the docker backend's own reachability check failing before any
// item is worked.
t.Setenv("PATH", t.TempDir())
dir := t.TempDir()
t.Chdir(dir)
mustWrite(t, "work.yaml", "- id: a\n instructions: do\n dir: .\n")
err := cmdOrchestrator([]string{"--gateway", "http://gw:4000", "-l", "--dispatch", "docker"})
if err == nil {
t.Fatal("an unreachable docker daemon should stop the command")
}
if !strings.Contains(err.Error(), "docker") {
t.Errorf("the failure should name the fix, got %v", err)
}
if _, err := os.Stat("work.yaml.state.json"); !os.IsNotExist(err) {
t.Errorf("a run that worked no item records no state, got %v", err)
}
}

// A harness.yaml whose env collides with the token's own variable stops
// the command before it works an item, whether the file is the default
// beside the items file or one --harness-config names.
func TestCmdOrchestrator_HarnessYamlEnvCollisionNamesTheVariable(t *testing.T) {
isolateConfig(t)
t.Setenv("OPENAI_API_KEY", "the-token")
dir := t.TempDir()
t.Chdir(dir)
mustWrite(t, "work.yaml", "- id: a\n instructions: do\n dir: .\n")
mustWrite(t, "harness.yaml", "env:\n OPENAI_API_KEY: nope\n")
err := cmdOrchestrator([]string{"--gateway", "http://gw:4000", "-l"})
if err == nil || !strings.Contains(err.Error(), "OPENAI_API_KEY") {
t.Errorf("the failure should name the colliding variable, got %v", err)
}
if _, err := os.Stat("work.yaml.state.json"); !os.IsNotExist(err) {
t.Errorf("a run that worked no item records no state, got %v", err)
}
}

// --harness-config names a file read in preference to harness.yaml beside
// the items file.
func TestCmdOrchestrator_HarnessConfigFlagOverridesTheDefault(t *testing.T) {
isolateConfig(t)
t.Setenv("OPENAI_API_KEY", "the-token")
dir := t.TempDir()
t.Chdir(dir)
mustWrite(t, "work.yaml", "- id: a\n instructions: do\n dir: .\n")
mustWrite(t, "harness.yaml", "env:\n OPENAI_API_KEY: nope\n")
mustWrite(t, "other.yaml", "env:\n FOO: bar\n")
err := cmdOrchestrator([]string{"--gateway", "http://127.0.0.1:1", "-l", "--harness-config", "other.yaml"})
// The default harness.yaml's collision would have stopped the command
// before it ever reached the gateway; reaching the (unreachable)
// gateway's own failure instead shows the flag's file was the one read.
if err == nil || !strings.Contains(err.Error(), "http://127.0.0.1:1") {
t.Errorf("the flag's file should be read instead of the colliding default, got %v", err)
}
}

// harness.yaml's own harness: is used where --harness is not given.
func TestCmdOrchestrator_HarnessYamlHarnessIsUsedWhenFlagNotGiven(t *testing.T) {
isolateConfig(t)
t.Setenv("OPENAI_API_KEY", "the-token")
dir := t.TempDir()
t.Chdir(dir)
mustWrite(t, "work.yaml", "- id: a\n instructions: do\n dir: .\n")
mustWrite(t, "harness.yaml", "harness: not-a-real-harness\n")
err := cmdOrchestrator([]string{"--gateway", "http://gw:4000", "-l"})
if err == nil {
t.Fatal("an unrecognised harness.yaml harness should stop the command")
}
if !strings.Contains(err.Error(), "not-a-real-harness") {
t.Errorf("the failure should name harness.yaml's own value, got %v", err)
}
}

// An explicit --harness wins over harness.yaml's harness:, even where
// harness.yaml names a value the command would otherwise refuse.
func TestCmdOrchestrator_ExplicitHarnessFlagWinsOverHarnessYaml(t *testing.T) {
isolateConfig(t)
t.Setenv("OPENAI_API_KEY", "the-token")
dir := t.TempDir()
t.Chdir(dir)
mustWrite(t, "work.yaml", "- id: a\n instructions: do\n dir: .\n")
mustWrite(t, "harness.yaml", "harness: not-a-real-harness\n")
// opencode, explicitly, reaches the (unreachable) gateway's own
// failure — not harness.yaml's unrecognised value, which an explicit
// --harness never consults.
err := cmdOrchestrator([]string{"--gateway", "http://127.0.0.1:1", "-l", "--harness", "opencode"})
if err == nil || !strings.Contains(err.Error(), "http://127.0.0.1:1") {
t.Errorf("an explicit --harness should win over harness.yaml's, got %v", err)
}
}
Loading
Loading