Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,19 @@ jobs:

- name: Fleet integration test
run: examples/fleet-docker/run-tests.sh

# The dockerised gateway example, driven end to end: real daemons, a real
# gateway process selecting, waking and keying over the network. Same
# double duty as the fleet job — coverage and example at once.
gateway-integration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true

- name: Gateway integration test
run: examples/gateway-docker/run-tests.sh
35 changes: 18 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,9 @@ spinloop harness # launch the agent, now running it
```

opencode, Pi and lucinate are all supported, chosen when you launch rather than
written into the file. A `Spinloop` naming a `FLEET` routes the launch to a node
that already has the model — or can load it — so the machine you are sitting at
needs no addresses of its own.
written into the file. A launch routed through a [fleet file](docs/commands/fleet.md)
goes to a node that already has the model — or can load it — so the machine you
are sitting at needs no addresses of its own.

Not serving it yourself? The same commands point an agent at a hosted model:
`spinloop add -p openrouter -m deepseek/deepseek-v4-flash`, then
Expand Down Expand Up @@ -366,9 +366,8 @@ MODEL deepseek/deepseek-v4-pro # the provider-native model ref
ALIAS deepseek # optional; friendly name for the model
CONTEXT 128k # optional; context window
OUTPUT 32k # optional; max output tokens
PARALLEL 2 # optional; concurrent slots when serving
BASEURL https://gateway/v1 # optional; API base URL override
FLEET ./fleet.yaml # optional; route the launch to a node
PARALLEL 2 # optional; concurrent slots when serving
BASEURL https://gateway/v1 # optional; API base URL override
```

```sh
Expand All @@ -382,11 +381,11 @@ spinloop export > Spinloop # capture your current setup as a Spinloop

A `Spinloop` describes one provider selection and applies exactly like the
equivalent `add`. The full keyword set is `PROVIDER`, `MODEL`, `ALIAS`,
`CONTEXT`, `OUTPUT`, `PARALLEL`, `BASEURL`, `PRESET`, `REMOTE`, `FLEET` and
`ENV` — `FLEET` and `REMOTE` are mutually exclusive, being two different answers
to where the model runs. Full syntax is in [`docs/spinloop-file.md`](docs/spinloop-file.md),
and ready-to-use examples live under [`examples/`](examples/), including
[fetching one from a URL](examples/remote-spinloop/).
`CONTEXT`, `OUTPUT`, `PARALLEL`, `BASEURL`, `PRESET`, `REMOTE` and `ENV`.
Routing a launch through a fleet is a launch concern, not a Spinloop field — see
the [fleet file](docs/commands/fleet.md). Full syntax is in
[`docs/spinloop-file.md`](docs/spinloop-file.md), and ready-to-use examples live
under [`examples/`](examples/), including [fetching one from a URL](examples/remote-spinloop/).

## Aliases

Expand Down Expand Up @@ -566,14 +565,16 @@ thing for: which machine is doing nothing?

#### Launching against the fleet

A fleet is also where `spinloop harness` sends the agent. A Spinloop naming a
`FLEET` picks a node and launches against its engine, so the machine you are
sitting at needs no addresses of its own:
A fleet is also where `spinloop harness` sends the agent: a launch routed
through a fleet file picks a node and launches against its engine, so the
machine you are sitting at needs no addresses of its own. The fleet file comes
from `--fleet`, or from the `./fleet.yaml` in the working directory when the
Spinloop is not named:

```sh
spinloop harness my-spinloop # picks a node, launches the agent against it
spinloop harness --fleet f.yaml # overrides the Spinloop's FLEET
spinloop fleet route my-spinloop # which node would I get? (launches nothing)
spinloop harness -O -f f.yaml # valueless -O wears ./Spinloop; routes through f.yaml
spinloop harness --fleet f.yaml # name the fleet file explicitly
spinloop fleet route -f f.yaml # which node would I get? (launches nothing)
```

The agent comes up talking to the node it picked — its address arrives as
Expand Down
136 changes: 76 additions & 60 deletions cmd/spinloop/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ harness could be configured with, spinloop show what it has been.`,
serveCmd(),
upCmd(),
daemonCmd(),
gatewayCmd(),
exportCmd(),
hfCmd(),
initProvidersCmd(),
Expand Down Expand Up @@ -177,6 +178,12 @@ exits. Honours -H/--harness and SPINLOOP_HARNESS.`,
}
}

// A named Spinloop — the flag's value, a leading positional, or the
// alias SPINLOOP_ALIAS names — travels to its fleet only by flag, so
// a fleet.yaml in the working directory is not picked up for it. A
// valueless --spinloop wears the default Spinloop and is not named.
route.spinloopNamed = spinloopPath.path != "" || spinloopAliasInForce()

// A .env beside the applied Spinloop is where its keys live, so the
// launched agent is given the same ones. Without a Spinloop there is
// no such file and only the environment (plus any provider key
Expand All @@ -196,65 +203,7 @@ exits. Honours -H/--harness and SPINLOOP_HARNESS.`,
} else if route.fleetPath != "" {
return fmt.Errorf("--fleet needs a Spinloop: it is the Spinloop's model that decides which node can serve you")
}
// The resolver the launch uses knows the remote key too, so every
// key the agent is given comes from the same place the apply step
// reported.
resolveKey := remoteLaunchResolver(opencode.EnvResolver(envDir), remoteResp)

// Launch the harness, forwarding stdio and any trailing args.
bin := h.Command()
cmd := exec.Command(bin, rest...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = harnessEnv(providers, resolveKey, remoteResp)
// A routed launch points the agent at the node that was chosen. As
// on the remote path, an explicit setting in the environment already
// won: routing fills what is unset rather than overriding a
// deliberate choice.
if choice != nil {
cmd.Env = setEnvIfBlank(cmd.Env, "OPENAI_BASE_URL", choice.BaseURL)
if choice.APIKey != "" {
cmd.Env = setEnvIfBlank(cmd.Env, "OPENAI_API_KEY", choice.APIKey)
}
}
// A worn Spinloop brings its whole local environment to the launched
// agent: its adjacent .env fills any gaps left above, and its ENV
// instructions override everything. These shape only the child's
// environment — spinloop never mutates its own — and follow the same
// precedence the remote commands use: ENV > process environment >
// .env.
if spinloopPath.set {
cmd.Env = overlayLocalEnv(cmd.Env, sel, envDir)
}
// lucinate reads an OpenAI-compatible key from
// LUCINATE_OPENAI_API_KEY when its stored secret is empty — which is
// exactly how spinloop configures it, with no secret on disk. Supply
// the active provider's key here so the launched agent can
// authenticate the model it boots into, without ever writing it to
// lucinate's config. An explicit setting already in the child's env
// wins.
if h.Name() == "lucinate" {
if choice != nil && choice.APIKey != "" {
cmd.Env = setEnvIfBlank(cmd.Env, "LUCINATE_OPENAI_API_KEY", choice.APIKey)
}
if key, ok := lucinateLaunchKey(providers, resolveKey, sel, spinloopPath.set); ok {
cmd.Env = setEnvIfAbsent(cmd.Env, "LUCINATE_OPENAI_API_KEY", key)
}
}
if err := cmd.Run(); err != nil {
if errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("%s not found — install the %s harness or add it to your PATH", bin, h.Name())
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
// The harness ran and chose its own exit code; surface it
// verbatim.
os.Exit(exitErr.ExitCode())
}
return err
}
return nil
return launchAgent(h, rest, providers, envDir, remoteResp, sel, spinloopPath.set, choice)
},
}
fs := c.Flags()
Expand All @@ -267,14 +216,80 @@ exits. Honours -H/--harness and SPINLOOP_HARNESS.`,
// path readSpinloop resolves as SPINLOOP_ALIAS > ./Spinloop.
fs.Lookup("spinloop").NoOptDefVal = "true"
fs.StringVar(&providers, "providers", "", "path to a providers.yaml override")
fs.StringVarP(&route.fleetPath, "fleet", "f", "", "route through this fleet file (overrides the Spinloop's FLEET)")
fs.StringVarP(&route.fleetPath, "fleet", "f", "", "route through this fleet file (default: ./fleet.yaml, when the Spinloop is not named)")
fs.StringVar(&route.node, "node", "", "pin the launch to this fleet node")
fs.StringVar(&route.prefer, "prefer", "", "rank fleet nodes by `idle` or `active` (overrides the fleet file)")
fs.BoolVar(&route.noWake, "no-wake", false, "fail rather than starting an engine on an idle fleet node")
fs.DurationVar(&route.wakeTimeout, "wake-timeout", 0, "how long to wait for a woken node's engine")
return c
}

// launchAgent runs the harness as the launch's child: stdio and any trailing
// args forwarded, and the environment the apply step reported as the source of
// every key the agent is given. Both launch commands end here, so the agent a
// launch is given can only differ the way the apply that preceded it did.
func launchAgent(h harness.Harness, rest []string, providers, envDir string, remoteResp *remote.Response, sel spinloop.Selection, worn bool, choice *fleet.Choice) error {
// The resolver the launch uses knows the remote key too, so every
// key the agent is given comes from the same place the apply step
// reported.
resolveKey := remoteLaunchResolver(opencode.EnvResolver(envDir), remoteResp)

// Launch the harness, forwarding stdio and any trailing args.
bin := h.Command()
cmd := exec.Command(bin, rest...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = harnessEnv(providers, resolveKey, remoteResp)
// A routed launch points the agent at the node that was chosen. As
// on the remote path, an explicit setting in the environment already
// won: routing fills what is unset rather than overriding a
// deliberate choice.
if choice != nil {
cmd.Env = setEnvIfBlank(cmd.Env, "OPENAI_BASE_URL", choice.BaseURL)
if choice.APIKey != "" {
cmd.Env = setEnvIfBlank(cmd.Env, "OPENAI_API_KEY", choice.APIKey)
}
}
// A worn Spinloop brings its whole local environment to the launched
// agent: its adjacent .env fills any gaps left above, and its ENV
// instructions override everything. These shape only the child's
// environment — spinloop never mutates its own — and follow the same
// precedence the remote commands use: ENV > process environment >
// .env.
if worn {
cmd.Env = overlayLocalEnv(cmd.Env, sel, envDir)
}
// lucinate reads an OpenAI-compatible key from
// LUCINATE_OPENAI_API_KEY when its stored secret is empty — which is
// exactly how spinloop configures it, with no secret on disk. Supply
// the active provider's key here so the launched agent can
// authenticate the model it boots into, without ever writing it to
// lucinate's config. An explicit setting already in the child's env
// wins.
if h.Name() == "lucinate" {
if choice != nil && choice.APIKey != "" {
cmd.Env = setEnvIfBlank(cmd.Env, "LUCINATE_OPENAI_API_KEY", choice.APIKey)
}
if key, ok := lucinateLaunchKey(providers, resolveKey, sel, worn); ok {
cmd.Env = setEnvIfAbsent(cmd.Env, "LUCINATE_OPENAI_API_KEY", key)
}
}
if err := cmd.Run(); err != nil {
if errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("%s not found — install the %s harness or add it to your PATH", bin, h.Name())
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
// The harness ran and chose its own exit code; surface it
// verbatim.
os.Exit(exitErr.ExitCode())
}
return err
}
return nil
}

// versionCmd prints the version, the same spelling the old dispatch gave
// `spinloop version`.
func versionCmd() *cobra.Command {
Expand Down Expand Up @@ -330,6 +345,7 @@ error — only a problem with the fleet file itself fails a command.`,
fleetLogsCmd(),
fleetDashboardCmd(),
fleetRouteCmd(),
fleetHarnessCmd(),
fleetStartCmd(),
fleetStopCmd(),
fleetDeployCmd(),
Expand Down
8 changes: 6 additions & 2 deletions cmd/spinloop/dashboard_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,12 @@ import (
)

// dashboardRefreshInterval is how often the board re-reads the local daemon
// machines. It is a variable so a test never waits for a slow node.
var dashboardRefreshInterval = 2 * time.Second
// machines. It is one call per machine per tick, so the cost is the fleet's
// size times this rate; the figures it draws — utilization, token counters,
// idle time — are sampled by each daemon every 15 seconds anyway, so polling
// faster than this re-fetches readings that have not changed. It is a
// variable so a test never waits for a slow node.
var dashboardRefreshInterval = 5 * time.Second

// dashboardRemoteRefreshInterval is the cadence for kind: remote
// environments instead. Each of their statuses is a signed call through the
Expand Down
Loading