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
22 changes: 21 additions & 1 deletion cmd/spinloop/orchestrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"github.com/spinloop-ai/spinloop/internal/orchestrator"

"github.com/spf13/cobra"
"golang.org/x/term"
)

func orchestratorCmd() *cobra.Command {
Expand Down Expand Up @@ -61,7 +62,12 @@ that has ended is not run again on a restart.
While it works, the orchestrator serves the work list — the items, their
state, and each agent's kept output — over HTTP, the gateway's server
pattern: an address and a token, loopback the bind that needs neither
beyond the machine.`,
beyond the machine.

Once the work list API is ready, the command prints the startup banner and
then the work list itself, the way spinloop work list prints it — a table
on a terminal, plain lines otherwise — so a restart's recovered state shows
without a separate call to spinloop work list.`,
Args: cobra.NoArgs,
SilenceErrors: true,
SilenceUsage: true,
Expand Down Expand Up @@ -206,6 +212,7 @@ func runOrchestratorCommand(gatewayAddr, itemsPath, token, harnessName, logLevel
}
fmt.Printf("Working %s against %s: %d %s in the backlog\n", itemsPath, gatewayAddr, len(items), unit)
fmt.Printf("Work list on %s\n\n", ln.Addr().String())
printOrchestratorStartupWorkList(wl.List())

// The signal ends the run the way the loop's contract says it does: the
// agents it has launched are stopped, their items back in the backlog.
Expand All @@ -228,3 +235,16 @@ func runOrchestratorCommand(gatewayAddr, itemsPath, token, harnessName, logLevel
cancel()
return runErr
}

// printOrchestratorStartupWorkList prints the run's view of the items the
// way spinloop work list prints the work list API's: a table where stdout
// is a terminal, one tab-separated line per item otherwise.
func printOrchestratorStartupWorkList(items []orchestrator.ItemView) {
if term.IsTerminal(int(os.Stdout.Fd())) {
fmt.Print(workListTable(items))
return
}
for _, v := range items {
fmt.Println(workListLine(v, false))
}
}
79 changes: 79 additions & 0 deletions cmd/spinloop/orchestrator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import (
"strings"
"testing"
"time"

"github.com/spinloop-ai/spinloop/internal/orchestrator"
)

// The command's help reads per the cli-ux conventions: a lowercase
Expand Down Expand Up @@ -433,6 +435,13 @@ func TestCmdOrchestrator_LoopbackServesTheWorkListWithoutAToken(t *testing.T) {
if !strings.Contains(string(data), "Work list on 127.0.0.1:4010") {
t.Fatalf("the startup line should name the work list's address, got:\n%s", data)
}
// Stdout is redirected to a file, not a terminal, so the startup work
// list is the plain tab-separated line spinloop work list prints off a
// pipe: the item's id and its state, backlog since no node carries the
// tag it names.
if !strings.Contains(string(data), "a\tbacklog") {
t.Fatalf("the startup output should carry the work list, item %q backlog, got:\n%s", "a", data)
}

// A tokenless caller reads the work list while the run works.
resp, err := http.Get("http://127.0.0.1:4010/v1/items")
Expand Down Expand Up @@ -464,3 +473,73 @@ func TestCmdOrchestrator_LoopbackServesTheWorkListWithoutAToken(t *testing.T) {
t.Errorf("the interrupt should leave the state saved: %v", err)
}
}

// A restart's recovered state shows at startup: an item the state beside
// the file already records done shows done in the startup work list, not
// backlog.
func TestCmdOrchestrator_StartupShowsARestartsRecoveredState(t *testing.T) {
isolateConfig(t)
t.Setenv("OPENAI_API_KEY", "the-token")
t.Setenv("SPINLOOP_API_TOKEN", "")
node := newRoutableNode(t, "qwen3-27b", true, 300)
dir := t.TempDir()
fleetFileIn(t, dir, "nodes:\n"+node.entry("gpu-box"))
t.Chdir(dir)
// Both items name a tag no node carries, so neither is launched: the
// backlog one stays backlog, and the recorded one keeps its record.
mustWrite(t, "work.yaml", "- id: a\n instructions: do\n dir: .\n tags:\n - gpu=a100\n"+
"- id: b\n instructions: do\n dir: .\n tags:\n - gpu=a100\n")
if err := orchestrator.SaveStateFile("work.yaml", map[string]orchestrator.ItemState{
"a": {State: orchestrator.StateDone, Node: "gpu-box", EndedAt: "2026-01-01T00:00:00Z"},
}); err != nil {
t.Fatal(err)
}

srv, ln, err := newGatewayServer("", "127.0.0.1:0", "", "")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
go srv.Serve(ln)

stdout := filepath.Join(t.TempDir(), "stdout")
f, err := os.Create(stdout)
if err != nil {
t.Fatal(err)
}
old := os.Stdout
os.Stdout = f
t.Cleanup(func() {
os.Stdout = old
f.Close()
})

done := make(chan error, 1)
go func() { done <- cmdOrchestrator([]string{"--gateway", "http://" + ln.Addr().String(), "-l"}) }()

deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
data, _ := os.ReadFile(stdout)
if strings.Contains(string(data), "Work list on") {
break
}
time.Sleep(20 * time.Millisecond)
}
data, _ := os.ReadFile(stdout)
if !strings.Contains(string(data), "a\tdone") {
t.Errorf("the startup work list should show item %q's recorded state done, got:\n%s", "a", data)
}
if !strings.Contains(string(data), "b\tbacklog") {
t.Errorf("the startup work list should show item %q with no record as backlog, got:\n%s", "b", data)
}

interruptSelf(t)
select {
case err := <-done:
if err != nil {
t.Fatalf("the interrupt should end the run without an error, got %v", err)
}
case <-time.After(15 * time.Second):
t.Fatal("the interrupt did not end the run")
}
}
11 changes: 11 additions & 0 deletions docs/commands/orchestrator.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,19 @@ list before it returns.
```
Working work.yaml against http://gateway.internal:4000: 3 items in the backlog
Work list on 127.0.0.1:4010

a backlog - - -
b running n 2026-09-14T10:00:00Z -
c done n 2026-09-14T09:00:00Z 2026-09-14T09:30:00Z
```

After the banner, the startup output is the work list itself, the run's own
view of the items at that moment — the same list
[`spinloop work list`](work.md#listing-the-work) reads from the API, in the
same form: a table on a terminal, plain tab-separated lines otherwise. An
item a prior run recorded `done`, `failed` or `running` before this restart
shows in that state, not `backlog`.

| Path | Meaning |
| ---- | ------- |
| `GET /health` | That the orchestrator is up. It touches no file and no work on purpose — it is how you tell the orchestrator down from the fleet down. |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-17
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
## Why

`spinloop orchestrator` starts with a one-line banner naming the items file,
the gateway, and the backlog count, but nothing shows which items are in the
backlog, what state they carry, or what a restart recovered. An operator has
to run `spinloop work list` against the freshly-started API to see that —
one more step every time the command starts.

## What Changes

- On startup, after the existing banner, the orchestrator prints the work
list itself — the same table a terminal gets from `spinloop work list`,
or the same plain tab-separated lines a pipe gets, drawn from the run's
view of the items before the first admission pass runs.

## Capabilities

### New Capabilities

(none)

### Modified Capabilities

- `fleet-orchestrator`: the orchestrator command's startup requirement gains
a scenario — the startup output includes the work list, not just the
banner naming the file, the gateway, and the count.

## Impact

- `cmd/spinloop/orchestrator.go`: `runOrchestratorCommand` prints the list
after building the `WorkList`, reusing the rendering `cmd/spinloop/work.go`
already has for `spinloop work list` (`workListTable` / `workListLine`,
chosen by whether stdout is a terminal).
- No API, storage, or wire-format change — the output is additional text on
the process's stdout at startup.
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
## MODIFIED Requirements

### Requirement: The orchestrator command

`spinloop orchestrator` SHALL run as a long-running foreground process, the
way `spinloop gateway` and `spinloop serve` do: its lifecycle is the
process's, and it is a top-level command beside them.

It SHALL take the address of a fleet's gateway and the path of its work items
file. The gateway's address comes from an explicit flag, or — where no flag
is given — from the `gateway` section of a fleet file, the one an explicit
fleet flag names or the file in the working directory; the flag, where
given, wins. The items file is a flag with a default of `./work.yaml` in the
working directory. It SHALL authenticate to the gateway with a bearer token
resolved the way the fleet file's gateway section resolves one: a flag
naming the environment variable, defaulting to `OPENAI_API_KEY` — the
section's own variable where the gateway comes from the file and the flag
was not given — and, where the gateway comes from the file, the value read
from the process environment first, then the `.env` beside the fleet file.

The fleet file, where the command reads it, is read for the gateway's
address and the token's variable and nothing else: no node fact in it
reaches the run, and the gateway is the run's only view of the fleet. Where
neither a flag nor a readable fleet file's section names a gateway, the
command SHALL fail before it works an item, naming the flag and the file. A
gateway it cannot reach, or will not authenticate it to, SHALL stop the
command with a message naming the gateway.

The command SHALL take a `--create-item-dirs` flag, defaulting to false:
where it is set, the orchestrator creates an item's missing directory before
launching its agent; where it is not, a missing directory fails the item.

The command SHALL take a `--listen` flag, defaulting to loopback port 4010:
where it is set, the orchestrator serves the work list API on that address
for the life of the run. The command SHALL take a `--loopback` flag that
selects loopback port 4010: it SHALL refuse to run the API on an address
that is not loopback unless an API token is set, and the API token is a flag
or a flag naming the file that holds the token — it SHALL resolve the flag
first, then the file, and SHALL NOT write it anywhere. The API token is the
orchestrator's own: it is distinct from the gateway's token and is not
inherited from any environment variable the command itself uses.

Once the work list API is ready and before the run's first admission pass,
the command SHALL print the startup banner — the items file, the gateway,
the backlog count, and the work list API's address — followed by the work
list itself, in the form `spinloop work list` prints it: a table where
standard output is a terminal, plain tab-separated lines otherwise. The
list SHALL be the run's own view of the items, the one the work list API
would answer at that moment, so a restart's recovered state — an item
already recorded done, failed, or running — shows the way it would show to
a caller of the API.

#### Scenario: A gateway and an items file drive the command

- **WHEN** the operator runs the command naming a reachable gateway and an
items file, with the gateway's token in the environment
- **THEN** it reads the fleet's topology from the gateway and works the
items file's backlog

#### Scenario: A fleet file names the gateway

- **WHEN** the operator runs the command naming no gateway, and the fleet
file — the one the fleet flag names, or the file in the working
directory — names a gateway in its section
- **THEN** the command works against that gateway, authenticating under the
section's token variable where the token flag was not given, the token's
value read from the environment first, then the `.env` beside the file

#### Scenario: No gateway anywhere stops the command

- **WHEN** the operator names no gateway, and no readable fleet file — or
none whose section names a gateway — is to hand
- **THEN** the command fails before it works an item, naming the flag and
the file

#### Scenario: An unreachable gateway stops the command

- **WHEN** the operator runs the command naming a gateway that does not
answer, or that refuses its token
- **THEN** the command fails, naming the gateway and what went wrong, and
works no item

#### Scenario: A loopback API takes no token

- **WHEN** the operator runs the command with a loopback listen address, and
no API token set
- **THEN** the work list API serves on that address, and its requests take
no token

#### Scenario: A non-loopback API without a token refuses

- **WHEN** the operator runs the command with a non-loopback listen address,
and no API token set
- **THEN** the command fails before it works an item, naming the token

#### Scenario: The API token is its own

- **WHEN** the operator runs the command against a gateway that has a
token, with the API token set
- **THEN** the API authenticates on its own token, and the gateway's token
is not what its requests present

#### Scenario: A listen address that conflicts is refused

- **WHEN** the operator gives both a loopback flag and a listen address, or
an address that is not loopback with no token
- **THEN** the command fails before it works an item, naming the conflict

#### Scenario: Startup prints the work list on a terminal

- **WHEN** the command starts with standard output a terminal, and the
items file carries items
- **THEN** after the startup banner, the command prints the work list as a
table — a heading row and one aligned row per item, id, state, node,
started and ended — the same table `spinloop work list` prints

#### Scenario: Startup prints the work list on a pipe

- **WHEN** the command's standard output is piped into another program
- **THEN** after the startup banner, the command prints one plain
tab-separated line per item, in the items file's order, with no
decoration

#### Scenario: A restart's recovered state shows at startup

- **WHEN** the command starts against an items file whose state beside it
already records an item done, failed, or running from a prior run
- **THEN** the startup work list shows that item in its recorded state, not
backlog
32 changes: 32 additions & 0 deletions openspec/changes/archive/2026-09-18-orch-status-on-start/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
## 1. Startup output

- [x] 1.1 In `runOrchestratorCommand` (`cmd/spinloop/orchestrator.go`), after
`wl` (the `*orchestrator.WorkList`) is built and before the startup
banner's `fmt.Printf` calls, keep printing the banner as it stands
today.
- [x] 1.2 After the banner, print the work list from `wl.List()` using the
same rendering `spinloop work list` uses (`workListTable` on a
terminal, one `workListLine` per item otherwise), gated on
`term.IsTerminal(int(os.Stdout.Fd()))` the way `workListCmd` gates it.
Verify by running `go build ./...`.
- [x] 1.3 Update the command's `Long` help text to mention the startup work
list. Verify with `spinloop orchestrator --help` showing the new
sentence.

## 2. Tests

- [x] 2.1 Extend `TestCmdOrchestrator_LoopbackServesTheWorkListWithoutAToken`
(or add a sibling test) in `cmd/spinloop/orchestrator_test.go` to
assert the captured stdout contains the item's id and its state
(`backlog`) after the banner. Verify with
`go test ./cmd/spinloop/... -run TestCmdOrchestrator -v`.
- [x] 2.2 Add a test that seeds the state file beside the items file with a
`done` record before starting the command, and asserts the startup
output shows that item `done`, not `backlog` — covering the restart
scenario. Verify with the same `go test` command.
- [x] 2.3 Run `go test ./... -cover` and confirm coverage has not dropped
below the project's 80% floor.

## 3. Wrap-up

- [x] 3.1 Run `gofmt -l .` and confirm it reports no files.
Loading
Loading