Delete Design A, the harness-hook completion path, and retired deploy files

Acts on the seven review comments on PR #1.

Design A is gone (comment 4). clients/ deleted rather than tracked: with
workers carrying cross-machine work the bridge is undeployed, which supersedes
the 2026-07-27 "keep through Phase 5" decision. CLAUDE.md, AGENTS.md and
AUDIT.md updated from "retained" to "deleted".

The harness-hook completion path is gone (comment 10). Investigation of the
live OpenCode QA run showed orchestra-worker owns completion end to end: it
watches for .orchestra/done, confirms via AgentStatus that the agent is not
busy, then posts through /v1/federation/* with both lease epoch and expected
version. The hook scripts used a different, older convention
(.orchestra-report.md) and posted to /v1/harness/complete, which had already
been reduced to a 410 stub - so that path could not have completed a task.
Nothing exercised it, because the live run never used it. Deleted: the three
deploy/hooks scripts, the 410 route, the unmounted harnessCompletion handler,
and its test. That test passed against a handler no mux routed to, which is
the exact "looks wired but isn't" pattern CLAUDE.md warns about; the
constant-time token compare added to it earlier today goes with it, having
never been reachable. /v1/harness/turn is untouched and still live.

Retired deployment files (comments 8, 12, 14): deploy/orchestra.service and
deploy/redeploy.sh (which sudo-installed to /usr/local/bin and restarted that
unit), plus deploy/docker-api-entrypoint.sh. The entrypoint was safe to remove
once its premise was checked: env vars reach the container through
`env_file: .env` in compose.yaml, not by sourcing /etc/orchestra/orchestra.env
- only config.jsonc is bind-mounted there - and Dockerfile.api's line 17
already sets ORCHESTRA_DATA/ORCHESTRA_PORT. Dockerfile.api now execs
/app/orchestra directly. orchestra-worker.service is a different, current unit
and is kept.

deploy/config.example.json deleted as a duplicate (comment 6); the annotated
.jsonc is the one registry.go points at, and its header no longer tells the
reader to copy the file that just went away.

Documentation corrected beyond the deletions:
- CLAUDE.md's deployment section claimed the container bind-mounts
  /etc/orchestra:ro and its entrypoint sources the env file. Both wrong.
- AGENTS.md still described a systemd deployment on homesrv as of 2026-07-27.
- AUDIT.md's H5 row still described a "retained compatibility handler".
- deploy/DEPLOYMENT.md still named redeploy.sh as the deployment path.
- deploy/orchestra.env.example still cited EnvironmentFile=.

TOKEN_MINIMAL_WORKFLOW_PLAN.md (comment 2) is untouched: it and WEB_UI_PLAN.md
were both missed by REVIEW.md's documentation sweep, and reconciling a 534-line
forward-looking plan against AUDIT.md is its own task, not a review fixup.

Verified: go build ./..., go vet ./..., go test ./... all pass after the
deletions, and go list ./... has no node_modules entry. No live herdr or pane
was touched; nothing was deployed. The running image still predates this
commit until compose is rebuilt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GEugbHVYfAXFpTqDYbByEB
This commit is contained in:
kami
2026-07-31 00:29:42 +04:00
parent 56f5aac582
commit 97a9c65302
20 changed files with 114 additions and 665 deletions
+3 -3
View File
@@ -3,14 +3,14 @@
.orchestra-config/
# Built binaries (never commit — a stale committed binary is a deployment-
# confusion hazard). `clients/` is deliberately NOT ignored: it holds the
# still-deployed Design A herdr bridge, and deployed code must be tracked.
# confusion hazard).
/orchestra
/orchestra-worker
# Web UI build inputs/outputs. node_modules in particular ships vendored Go
# packages (e.g. flatted/golang), so leaving it merely untracked is not
# enough — `go build ./...` and `go test ./...` walk into it.
# enough — `go build ./...` and `go test ./...` walk into it. `web/go.mod` is
# the fix: it ends the parent module's package graph at that directory.
/package-lock.json
node_modules/
.node_modules/
+33 -14
View File
@@ -57,15 +57,25 @@ the live herdr instance and check.
today — don't "clean up" that code without checking this note first, or it
might start doing a real numeric-vs-string comparison and break.
## Deployment topology (as of 2026-07-27)
## Deployment topology (as of 2026-07-31)
- Runs as `orchestra.service` on **homesrv** (this machine's own systemd —
`journalctl -u orchestra.service` for logs; `sudo` isn't available in this
sandbox environment, but plain `journalctl` without sudo works here).
- Config: `.orchestra-config/orchestra.env` (env vars) +
`.orchestra-config/config.jsonc` (projects/machines/herdrs registry).
`ORCHESTRA_CONFIG=/etc/orchestra/config.jsonc` — the deployed copy, not the
repo's `deploy/config.example.jsonc`.
- **Runs under Docker Compose, not systemd.** `docker compose -f compose.yaml
-f compose.live.yaml` in `/home/kami/docker-apps/orchestra-web-ui`, building
both images from this repo: `orchestra-api` (bound `0.0.0.0:9145`, which is
intentional — ufw restricts the port to one other LAN machine) and
`orchestra-web-ui` (nginx proxy, `127.0.0.1:19145`). Logs are
`docker logs orchestra-api`. **Deploying a code change means rebuilding the
compose images** (`up -d --build`) — the running image can silently predate
recent commits, so compare its build time against `git log`.
- `orchestra.service` was the previous deployment; its unit file and
`redeploy.sh` were deleted from `deploy/` on 2026-07-31. A stale installed
copy must stay stopped — it binds the same port and data dir as the
container. `orchestra-worker.service` is a *different*, still-current unit.
- Config: env vars come from **`.env` in the compose directory** via
`env_file:`; only `config.jsonc` is bind-mounted into `/etc/orchestra/`.
There is no container entrypoint script — `Dockerfile.api` execs
`/app/orchestra` directly. Neither deployed file is the repo's
`deploy/config.example.jsonc`.
- Two machines in the registry: `homesrv` (192.168.1.104) and `workpc`
(192.168.1.105), each nominally running 3 herdrs (claude/codex/opencode).
In practice **homesrv has no local herdr running** (connection refused on
@@ -94,12 +104,21 @@ resolve an adapter for a session owned by a non-local herdr, returning
(`git rev-parse HEAD`) against the wrong machine's checkout. Rotation/cleanup
therefore no longer act on remote leases.
**Design A is retained, not dead.** `clients/herdr-bridge.go` ("drive the
remote socket": homesrv calling `worktree.create`/`agent.start` directly on
workpc's herdr over TCP as if it were local) is kept through Phase 5 per the
standing AUDIT.md decision of 2026-07-27; deletion of Design A and `clients/`
is deferred to the Phase 6 cutover. Do not delete it early, and do not add new
cross-machine call paths to it.
**Design A is gone (deleted 2026-07-31).** `clients/herdr-bridge.go` ("drive
the remote socket": homesrv calling `worktree.create`/`agent.start` directly on
workpc's herdr over TCP as if it were local) was deleted along with the whole
`clients/` directory — the operator confirmed it is undeployed now that workers
carry cross-machine work, which superseded the 2026-07-27 "keep through Phase
5" decision. There is no bridge to preserve; do not reintroduce
coordinator-side calls to a remote herdr socket.
**Completion is worker-owned.** `orchestra-worker` watches for an
`.orchestra/done` marker, confirms via `AgentStatus` that the agent is no
longer busy, then posts through `/v1/federation/*` with the lease epoch and
expected version. The old harness-hook path — `.orchestra-report.md` plus
`POST /v1/harness/complete` — is **deleted**, endpoint, handler, and
`deploy/hooks/` scripts alike. `/v1/harness/turn` remains for turn-boundary
decisions.
## Working conventions
+19 -5
View File
@@ -36,10 +36,24 @@ resolve an adapter for a session owned by a non-local herdr, so rotation and
cleanup can no longer validate a git anchor against the wrong machine's
checkout.
Design A itself is **retained through Phase 5** per that same decision.
`clients/herdr-bridge.go` is now tracked in git (deployed code must be in
version control); deleting Design A and `clients/` is deferred to the Phase 6
cutover.
Design A is **deleted as of 2026-07-31**, superseding the 2026-07-27 "retain
through Phase 5" decision: the operator confirmed the bridge is undeployed now
that workers carry cross-machine work, so `clients/` was removed rather than
tracked. The Phase 6 cutover is therefore already done on this axis.
The legacy harness-hook completion path was removed in the same pass, once it
was confirmed that nothing calls it. `orchestra-worker` owns completion — it
watches for `.orchestra/done`, confirms via `AgentStatus` that the agent is not
busy, then posts through `/v1/federation/*` with the lease epoch and expected
version. Deleted: the `/v1/harness/complete` route (a `410` stub), its unmounted
`harnessCompletion` handler, that handler's test (green against unreachable
code — the pattern this audit exists to catch), and the three `deploy/hooks/`
scripts, which still used the older `.orchestra-report.md` marker and would
have failed against the `410`. `/v1/harness/turn` is unaffected and still live.
Note for the QA matrix: the OpenCode run completed through the worker path, so
no hook script was exercised. Nothing about hook-based completion was ever
verified live, which is why deleting it costs nothing.
## Remaining release blockers
@@ -87,7 +101,7 @@ cutover.
| H2 | **Closed 2026-07-30.** `PrepareRelease` verifies immutable `TASK.md`, checkpoints all repository work except protocol markers, always pushes the per-task project's scratch anchor, verifies it with `ls-remote`, and only then seals the CAS handoff. | `TestScratchCommitCapturesAllGitStatesExceptProtocolMarkers` covers staged, deleted, renamed, untracked, and protocol-marker cases; release uses the configured project remote. |
| H3 | **Closed 2026-07-30.** Worker state persists idempotent release transactions through `prepared → anchor_pushed → event_committed → pickup_validated → predecessor_retired`. Release/pickup endpoints bind transaction, anchor, and lease version; a predecessor remains mapped and is retired only after matching pickup validation. | `TestReleaseTransactionSurvivesReLeaseUntilMatchingPickup` covers transaction propagation and pickup epoch binding; full race suite passes. |
| H4 | **Closed 2026-07-30.** Every new lease carries an opaque durable `lease_epoch`; renew/release/pickup/complete validate the exact harness owner and epoch at the store boundary and federation API. Offline heartbeats retain leases until expiry, new workers require a fresh reachable local-herdr probe, and local/worker ownership loss stops or durably quarantines the old pane before its mapping is dropped. | `TestLeaseEpochFencesStaleOwnerLifecycleWrites`, `TestAvailableRequiresFreshReachableLocalHerdrHealth`, plus the full race suite cover stale re-lease/completion and health admission. |
| H5 | **Closed 2026-07-30.** `Store.Append` validates legal state/owner/epoch transitions, fsyncs the event before applying its projection, and replays projections solely from `events.jsonl` (snapshots are disposable caches). CAS, worker/federation/coordinator state use temp-file + fsync + rename; corrupt worker state aborts startup. The live legacy `/v1/harness/complete` route is retired (410); its retained compatibility handler is fenced if invoked directly. | `TestOpenRebuildsOnlyFromLogAndIgnoresCorruptSnapshot`, `TestWorkerRefusesCorruptDurableState`, and `go test -race ./...` pass. |
| H5 | **Closed 2026-07-30.** `Store.Append` validates legal state/owner/epoch transitions, fsyncs the event before applying its projection, and replays projections solely from `events.jsonl` (snapshots are disposable caches). CAS, worker/federation/coordinator state use temp-file + fsync + rename; corrupt worker state aborts startup. The legacy `/v1/harness/complete` route and its handler were deleted outright on 2026-07-31 (previously a 410 stub plus an unmounted, separately-fenced handler). | `TestOpenRebuildsOnlyFromLogAndIgnoresCorruptSnapshot`, `TestWorkerRefusesCorruptDurableState`, and `go test -race ./...` pass. |
## P1 — autonomy and recovery
+33 -15
View File
@@ -66,15 +66,22 @@ the live herdr instance and check.
`docker logs orchestra-api`. **Deploying a code change means rebuilding the
compose images** (`up -d --build`) — the running image can silently predate
recent commits, so compare its build time against `git log`.
- `orchestra.service` is the **previous** deployment and is retired —
`compose.live.yaml` requires it stopped, since both bind the same port and
data dir. Do not start it. (`sudo` isn't available in this sandbox, so
`systemctl disable` needs the operator.)
- Config: `/etc/orchestra/orchestra.env` (env vars) + `/etc/orchestra/
config.jsonc` (projects/machines/herdrs registry) — the deployed copies, not
the repo's `.orchestra-config/` or `deploy/config.example.jsonc`. The
container bind-mounts `/etc/orchestra:ro` and its entrypoint sources the env
file, so Docker never copies the secrets.
- `orchestra.service` was the **previous** deployment; the unit file was
deleted from `deploy/` on 2026-07-31 along with `redeploy.sh` (which
`sudo install`ed to `/usr/local/bin` and restarted it). If a stale copy is
still installed on a host, it must stay stopped — it binds the same port and
data dir as the container. (`sudo` isn't available in this sandbox, so
`systemctl disable` needs the operator.) `orchestra-worker.service` is a
*different*, still-current unit — don't delete it by association.
- Config: env vars come from **`.env` in the compose directory**, loaded via
`env_file:` in `compose.yaml` — that is where the Gitea/ntfy/web tokens and
the bcrypt operator hash live. The only thing bind-mounted into
`/etc/orchestra/` is a single file, `config.jsonc`
(projects/machines/herdrs registry), via `compose.override.yaml`. There is no
container entrypoint script — `Dockerfile.api` execs `/app/orchestra`
directly, and `ORCHESTRA_DATA`/`ORCHESTRA_PORT` come from the image `ENV`
plus compose. Neither the deployed `config.jsonc` nor `.env` is the repo's
`deploy/config.example.jsonc`.
- The `0.0.0.0` bind on 9145 is **intentional**: ufw restricts the port to one
other LAN machine. Don't report it as an exposure.
- Two machines in the registry: `homesrv` (192.168.1.104) and `workpc`
@@ -107,12 +114,23 @@ resolve an adapter for a session owned by a non-local herdr, returning
(`git rev-parse HEAD`) against the wrong machine's checkout. Rotation/cleanup
therefore no longer act on remote leases.
**Design A is retained, not dead.** `clients/herdr-bridge.go` ("drive the
remote socket": homesrv calling `worktree.create`/`agent.start` directly on
workpc's herdr over TCP as if it were local) is kept through Phase 5 per the
standing AUDIT.md decision of 2026-07-27; deletion of Design A and `clients/`
is deferred to the Phase 6 cutover. Do not delete it early, and do not add new
cross-machine call paths to it.
**Design A is gone (deleted 2026-07-31).** `clients/herdr-bridge.go` ("drive
the remote socket": homesrv calling `worktree.create`/`agent.start` directly on
workpc's herdr over TCP as if it were local) was deleted along with the whole
`clients/` directory — the operator confirmed it is undeployed now that workers
carry cross-machine work, which superseded the 2026-07-27 "keep through Phase
5" decision. There is no bridge to preserve; do not reintroduce coordinator-side
calls to a remote herdr socket.
**Completion is worker-owned.** `orchestra-worker` watches for an
`.orchestra/done` marker in the worktree, confirms via `AgentStatus` that the
agent is no longer busy (a marker alone is intent, not proof), then finalizes
and posts through `/v1/federation/*` with both the lease epoch and the expected
version. The old harness-hook path — `.orchestra-report.md` plus
`POST /v1/harness/complete` — is **deleted**: the endpoint, its handler, and the
`deploy/hooks/` scripts are all gone, because an unaffiliated hook has no
durable worker identity or fencing epoch. `/v1/harness/turn` remains for
turn-boundary decisions.
## Working conventions
+1 -2
View File
@@ -12,9 +12,8 @@ FROM alpine:3.21
RUN adduser -D -u 10001 orchestra
WORKDIR /app
COPY --from=build /out/orchestra /app/orchestra
COPY deploy/docker-api-entrypoint.sh /app/docker-api-entrypoint.sh
RUN mkdir /data && chown orchestra:orchestra /data
ENV ORCHESTRA_DATA=/data ORCHESTRA_PORT=9145
VOLUME ["/data"]
EXPOSE 9145
ENTRYPOINT ["/app/docker-api-entrypoint.sh"]
ENTRYPOINT ["/app/orchestra"]
-32
View File
@@ -1,32 +0,0 @@
# workpc herdr TCP bridges
These files are intentionally local and gitignored. The workpc herdr exposes
one local Unix socket; Orchestra reaches it over the LAN through three bound
TCP bridges:
| machine | listen | herdr socket |
|---|---|---|
| workpc | 192.168.1.105:9245 | ~/.config/herdr/herdr.sock |
The bridge is a byte-preserving proxy for the newline-delimited JSON protocol.
It does not interpret or authenticate requests; restrict the ports with the
LAN firewall/WireGuard policy.
## Not a federation worker
`herdr-bridge` deliberately does **not** own a checkout. It cannot write
`TASK.md`, inspect Git `HEAD`/branch/dirty hashes, make a scratch commit, or
publish a canonical continuity handoff. Treat it as Design A compatibility
only, not the cross-machine protocol described by Orchestra's spec.
`cmd/orchestra-worker` is the worker-side client. It registers and
heartbeats as the configured herdr id, consumes only router-issued
`TaskLeased` events for that id, creates/validates the local checkout, drives
the local herdr, and seals/pushes a canonical handoff locally before calling
the federation handoff endpoint. It deliberately has no task-claim operation:
homesrv's router is the only scheduler.
Install `deploy/orchestra-worker.service` on the worker host and configure
the federation variables in `deploy/orchestra.env.example`. Set
`ORCHESTRA_MACHINE_ID` on homesrv; that prevents its coordinator from ever
opening, validating, rotating, or cleaning up a workpc checkout.
-42
View File
@@ -1,42 +0,0 @@
package main
import (
"flag"
"io"
"log"
"net"
"os"
)
func main() {
listen := flag.String("listen", "192.168.1.105:9245", "TCP address to expose")
socket := flag.String("socket", os.Getenv("HOME")+"/.config/herdr/herdr.sock", "herdr Unix socket")
flag.Parse()
ln, err := net.Listen("tcp", *listen)
if err != nil {
log.Fatal(err)
}
log.Printf("herdr bridge listening on %s -> unix:%s", *listen, *socket)
for {
c, err := ln.Accept()
if err != nil {
log.Printf("accept: %v", err)
continue
}
go proxy(c, *socket)
}
}
func proxy(c net.Conn, socket string) {
defer c.Close()
u, err := net.Dial("unix", socket)
if err != nil {
log.Printf("dial herdr: %v", err)
return
}
defer u.Close()
done := make(chan struct{}, 2)
go func() { _, _ = io.Copy(u, c); _ = u.Close(); done <- struct{}{} }()
go func() { _, _ = io.Copy(c, u); _ = c.Close(); done <- struct{}{} }()
<-done
}
-11
View File
@@ -1,11 +0,0 @@
[Unit]
Description=Herdr LAN bridge for Orchestra
After=network-online.target herdr.service
[Service]
ExecStart=/usr/local/bin/herdr-bridge -listen 192.168.1.105:9245 -socket %h/.config/herdr/herdr.sock
Restart=on-failure
NoNewPrivileges=true
[Install]
WantedBy=default.target
+10 -99
View File
@@ -126,87 +126,6 @@ func validateLocalMachine(rr registry.Registry, localMachine string) error {
return nil
}
type harnessCompletion struct {
store *store.Store
route func(domain.Event) error
token string
}
func (h harnessCompletion) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if h.token != "" && subtle.ConstantTimeCompare([]byte(r.Header.Get("Authorization")), []byte("Bearer "+h.token)) != 1 {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var p struct {
TaskID string `json:"task_id"`
WorkerID string `json:"worker_id"`
LeaseEpoch string `json:"lease_epoch"`
Harness string `json:"harness"`
TranscriptPath string `json:"transcript_path"`
Report string `json:"report"`
}
if json.NewDecoder(r.Body).Decode(&p) != nil || p.TaskID == "" || p.WorkerID == "" || p.LeaseEpoch == "" || p.Report == "" || p.TranscriptPath == "" {
http.Error(w, "task_id, worker_id, lease_epoch, transcript_path, and report are required", http.StatusBadRequest)
return
}
t, ok := h.store.Task(p.TaskID)
if !ok {
http.Error(w, "task not found", http.StatusNotFound)
return
}
if t.State != domain.StateLeased || t.Lease == nil || t.Lease.HarnessID != p.WorkerID || t.Lease.Epoch != p.LeaseEpoch {
http.Error(w, "lease not owned", http.StatusConflict)
return
}
var usage herdr.Usage
var err error
switch p.Harness {
case "codex":
usage, err = herdr.CodexUsage(p.TranscriptPath)
case "opencode":
usage, err = herdr.OpenCodeUsage(p.TranscriptPath)
case "", "claude":
usage, err = herdr.ClaudeUsage(p.TranscriptPath)
default:
http.Error(w, "unknown harness: "+p.Harness, http.StatusBadRequest)
return
}
if err != nil {
http.Error(w, "reading transcript: "+err.Error(), http.StatusBadRequest)
return
}
ref, err := h.store.PutArtifact([]byte(p.Report))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
payload, _ := json.Marshal(map[string]any{"report_ref": ref, "harness_id": p.WorkerID, "lease_epoch": p.LeaseEpoch, "expected_version": t.Version, "receipt": map[string]any{
"input_tokens": usage.Input, "cache_read_tokens": usage.CacheRead,
"cache_write_tokens": usage.CacheWrite, "output_tokens": usage.Output, "numerator": usage.Numerator(),
}})
e := domain.Event{ID: id(), Type: "TaskCompleted", TaskID: p.TaskID, Version: t.Version + 1, Payload: payload, Surface: string(authz.System)}
if err := h.store.Append(e); err != nil {
http.Error(w, err.Error(), http.StatusConflict)
return
}
if h.route != nil {
if err := h.route(e); err != nil {
log.Printf("route task: %v", err)
}
}
if t.Lease != nil && t.Lease.HarnessID != "" {
qp, _ := json.Marshal(map[string]any{"harness_id": t.Lease.HarnessID, "consumed": float64(usage.Numerator())})
if err := h.store.Append(domain.Event{ID: id(), Type: "QuotaReported", TaskID: "system", Payload: qp, Surface: string(authz.System)}); err != nil {
log.Printf("quota report: %v", err)
}
}
json.NewEncoder(w).Encode(e)
}
func main() {
dir := os.Getenv("ORCHESTRA_DATA")
if dir == "" {
@@ -553,27 +472,19 @@ func main() {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(b)
})
// /v1/harness/complete is the automatic TaskCompleted producer (AUDIT.md
// B3): a harness-side hook posts here when the agent has declared the
// task done (see deploy/hooks/orchestra-stop.sh), not on every turn
// boundary. It reads the transcript locally to build an honest receipt —
// same session-file assumption as CLIAdapter.Occupancy — rather than
// trusting a self-reported number.
harnessToken := os.Getenv("ORCHESTRA_HARNESS_TOKEN")
// The unaffiliated harness hook has no durable worker identity or fencing
// epoch, so it cannot safely mutate a leased task. Completion is accepted
// only through the authenticated federation worker endpoint below.
mux.HandleFunc("/v1/harness/complete", func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "legacy harness completion endpoint retired; use worker completion", http.StatusGone)
})
// /v1/harness/turn is the unified turn-decision endpoint (AUDIT.md Phase
// 2 items 1-2): the Face-B stop hook posts here on every ordinary turn
// boundary (task completion is not handled here — it goes through the
// authenticated worker completion endpoint, since the retired
// /v1/harness/complete had no fencing epoch) and gets back continue /
// prepare_handoff / rotate_now / refuse, per spec §5.3. This replaces
// what would otherwise be separate ad-hoc marker-file conventions per
// 2 items 1-2): a harness-side caller posts here on an ordinary turn
// boundary and gets back continue / prepare_handoff / rotate_now / refuse,
// per spec §5.3, instead of separate ad-hoc marker-file conventions per
// decision.
//
// Completion does NOT go through this endpoint, and there is no longer a
// /v1/harness/complete: an unaffiliated harness hook has no durable worker
// identity or fencing epoch, so it cannot safely mutate a leased task.
// orchestra-worker owns completion — it watches for the .orchestra/done
// marker, confirms the agent is no longer busy, and posts through the
// authenticated federation endpoints with both lease epoch and version.
mux.HandleFunc("/v1/harness/turn", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
-76
View File
@@ -1,19 +1,12 @@
package main
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"orchestra/internal/authz"
"orchestra/internal/domain"
"orchestra/internal/registry"
"orchestra/internal/store"
)
type unreachable struct{}
@@ -55,75 +48,6 @@ func TestCoordinatorOwnsOnlyLocalHerdrInFederationMode(t *testing.T) {
}
}
func TestHarnessCompletionBuildsReceiptAndQuotaFromTranscript(t *testing.T) {
s, err := store.Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
if err := s.Append(domain.Event{ID: domain.NewID(), Type: "TaskCreated", TaskID: "done", Surface: string(authz.System), Payload: []byte(`{"source":"qa","external_id":"done","project":"p"}`)}); err != nil {
t.Fatal(err)
}
if _, err := s.Lease("done", "local-claude", time.Minute); err != nil {
t.Fatal(err)
}
transcript := filepath.Join(t.TempDir(), "transcript.jsonl")
if err := os.WriteFile(transcript, []byte(`{"message":{"usage":{"input_tokens":100,"cache_read_input_tokens":20,"cache_creation_input_tokens":5,"output_tokens":7}}}`+"\n"), 0600); err != nil {
t.Fatal(err)
}
task, _ := s.Task("done")
body, _ := json.Marshal(map[string]string{"task_id": "done", "worker_id": "local-claude", "lease_epoch": task.Lease.Epoch, "transcript_path": transcript, "report": "# done"})
req := httptest.NewRequest(http.MethodPost, "/v1/harness/complete", bytes.NewReader(body))
res := httptest.NewRecorder()
harnessCompletion{store: s, token: "secret"}.ServeHTTP(res, req)
if res.Code != http.StatusUnauthorized {
t.Fatalf("missing token status = %d, want 401", res.Code)
}
req.Header.Set("Authorization", "Bearer secret")
req = httptest.NewRequest(http.MethodPost, "/v1/harness/complete", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer secret")
res = httptest.NewRecorder()
harnessCompletion{store: s, token: "secret"}.ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("completion status = %d: %s", res.Code, res.Body.String())
}
task, ok := s.Task("done")
if !ok || task.State != domain.StateCompleted {
t.Fatalf("task after completion = %#v, present=%v", task, ok)
}
var completed struct {
Receipt struct {
Input int `json:"input_tokens"`
CacheRead int `json:"cache_read_tokens"`
CacheWrite int `json:"cache_write_tokens"`
Output int `json:"output_tokens"`
} `json:"receipt"`
}
for _, e := range s.Events(0) {
if e.Type == "TaskCompleted" {
if err := json.Unmarshal(e.Payload, &completed); err != nil {
t.Fatal(err)
}
}
}
if completed.Receipt.Input != 100 || completed.Receipt.CacheRead != 20 || completed.Receipt.CacheWrite != 5 || completed.Receipt.Output != 7 {
t.Fatalf("receipt = %#v", completed.Receipt)
}
var quota struct {
Harness string `json:"harness_id"`
Consumed float64 `json:"consumed"`
}
found := false
for _, e := range s.Events(0) {
if e.Type == "QuotaReported" {
_ = json.Unmarshal(e.Payload, &quota)
found = true
}
}
if !found || quota.Harness != "local-claude" || quota.Consumed != 125 {
t.Fatalf("quota report = %#v, found=%v", quota, found)
}
}
func TestMultiMachineRegistryRequiresKnownLocalMachine(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{
+5 -3
View File
@@ -1,8 +1,10 @@
# Deployment verification
Both binaries embed their Git revision, UTC build time, and dirty flag. Build
the coordinator with `deploy/redeploy.sh`; it installs and restarts the local
`orchestra.service`.
Both binaries embed their Git revision, UTC build time, and dirty flag. The
coordinator is deployed as a Docker Compose image — see "For the Docker
coordinator deployment" below for the build that carries provenance. (The old
`deploy/redeploy.sh` + `orchestra.service` path was deleted on 2026-07-31;
`orchestra-worker.service` is a different, still-current unit.)
## Browser operator login
-66
View File
@@ -1,66 +0,0 @@
{
"projects": [
{
"id": "correx",
"machine_affinity": ["mainframe"],
"repo": "/var/lib/orchestra/repos/correx.git",
"worktree_root": "/var/lib/orchestra/worktrees/correx"
},
{
"id": "maven",
"machine_affinity": ["mainframe", "satellite"]
}
],
"machines": [
{
"id": "mainframe",
"address": "10.0.0.10:9145"
},
{
"id": "satellite",
"address": "10.0.0.11:9145"
}
],
"herdrs": [
{
"id": "mainframe-claude-1",
"machine_id": "mainframe",
"harness": "claude",
"protocol": "1",
"capabilities": ["code", "review"],
"concurrency": 2,
"quota_limit_5h": 50,
"quota_limit_weekly": 500
},
{
"id": "satellite-claude-1",
"machine_id": "satellite",
"address": "10.0.0.11:9245",
"harness": "claude",
"protocol": "1",
"capabilities": ["code"],
"concurrency": 1,
"quota_limit_5h": 20,
"quota_limit_weekly": 200
},
{
"id": "mainframe-codex-1",
"machine_id": "mainframe",
"harness": "codex",
"protocol": "1",
"capabilities": ["code"],
"concurrency": 1,
"quota_limit_weekly": 300
},
{
"id": "satellite-opencode-1",
"machine_id": "satellite",
"address": "10.0.0.11:9345",
"harness": "opencode",
"protocol": "1",
"capabilities": ["code", "review"],
"concurrency": 1,
"quota_limit_weekly": 300
}
]
}
+5 -2
View File
@@ -1,6 +1,9 @@
// Annotated reference for config.example.json (registry.Config, internal/registry/registry.go).
// Annotated reference for registry.Config (internal/registry/registry.go).
// This file is NOT valid JSON (it has comments) and is not loaded by orchestra —
// it exists purely to document fields. Copy config.example.json, not this file.
// it exists purely to document fields. Copy it, strip the comments, and install
// the result as the deployed config.jsonc (bind-mounted into /etc/orchestra/ by
// compose.override.yaml). The plain config.example.json was deleted on
// 2026-07-31 as a duplicate of this file.
{
// Static project topology. One entry per project the fleet routes tasks for.
"projects": [
-13
View File
@@ -1,13 +0,0 @@
#!/bin/sh
set -eu
# Keep the existing coordinator's secrets/config in the host-owned env file;
# Docker never needs to read or copy it. The process runs as the same numeric
# service user and can therefore read the bind-mounted file.
if [ -r /etc/orchestra/orchestra.env ]; then
set -a
. /etc/orchestra/orchestra.env
set +a
fi
export ORCHESTRA_DATA=/data
export ORCHESTRA_PORT=9145
exec /app/orchestra
-84
View File
@@ -1,84 +0,0 @@
#!/bin/sh
# Codex turn-boundary/completion poller.
#
# Unlike Claude Code, Codex has no native Stop hook — nothing calls out at a
# turn boundary. This script is meant to run as a background loop inside the
# pane alongside the codex process (AUDIT.md Phase 2 item 4, "Not done:
# Codex/opencode Stop-hook-equivalent scripts"), polling the same
# two endpoints the Claude Stop hook (orchestra-stop.sh) calls on every turn:
# - completion: if the agent has written .orchestra-report.md, POST
# /v1/harness/complete with harness=codex and the newest active rollout
# path, then remove the marker.
# - turn decision: otherwise POST /v1/harness/turn and log (never kill the
# harness process on "refuse" — there's no turn boundary to refuse at
# from outside the process the way exit-code-2 works for a real Stop
# hook; this is advisory-only for codex until app-server integration
# exists).
#
# Requires: ORCHESTRA_TASK_ID, ORCHESTRA_URL, ORCHESTRA_WORKTREE (the pane's
# cwd) set in the pane's env. Optional: ORCHESTRA_HARNESS_TOKEN,
# ORCHESTRA_POLL_INTERVAL (seconds, default 60).
# Needs: jq, find, curl.
set -u
: "${ORCHESTRA_POLL_INTERVAL:=60}"
: "${ORCHESTRA_WORKTREE:=$PWD}"
[ -z "${ORCHESTRA_TASK_ID:-}" ] && { echo "orchestra-codex-poll: ORCHESTRA_TASK_ID not set" >&2; exit 1; }
[ -z "${ORCHESTRA_URL:-}" ] && { echo "orchestra-codex-poll: ORCHESTRA_URL not set" >&2; exit 1; }
auth_header=""
if [ -n "${ORCHESTRA_HARNESS_TOKEN:-}" ]; then
auth_header="Authorization: Bearer ${ORCHESTRA_HARNESS_TOKEN}"
fi
# Newest rollout file under ~/.codex/sessions, matching CodexActiveUsage's
# own "most recently touched" heuristic (internal/herdr/occupancy.go).
latest_rollout() {
find "$HOME/.codex/sessions" -name 'rollout-*.jsonl' -type f -printf '%T@ %p\n' 2>/dev/null \
| sort -rn | head -n1 | cut -d' ' -f2-
}
report_file="${ORCHESTRA_WORKTREE%/}/.orchestra-report.md"
while true; do
sleep "$ORCHESTRA_POLL_INTERVAL"
rollout="$(latest_rollout)"
[ -z "$rollout" ] && continue
if [ -f "$report_file" ]; then
report="$(cat "$report_file")"
body="$(jq -n \
--arg task_id "$ORCHESTRA_TASK_ID" \
--arg transcript_path "$rollout" \
--arg report "$report" \
'{task_id: $task_id, harness: "codex", transcript_path: $transcript_path, report: $report}')"
if curl -fsS -X POST "${ORCHESTRA_URL%/}/v1/harness/complete" \
-H "Content-Type: application/json" \
${auth_header:+-H "$auth_header"} \
-d "$body" >/dev/null 2>&1; then
rm -f "$report_file"
exit 0
else
echo "orchestra-codex-poll: failed to report completion" >&2
fi
continue
fi
body="$(jq -n --arg task_id "$ORCHESTRA_TASK_ID" '{task_id: $task_id}')"
response="$(curl -fsS -X POST "${ORCHESTRA_URL%/}/v1/harness/turn" \
-H "Content-Type: application/json" \
${auth_header:+-H "$auth_header"} \
-d "$body" 2>/dev/null)" || {
echo "orchestra-codex-poll: failed to reach turn-decision endpoint" >&2
continue
}
decision="$(printf '%s' "$response" | jq -r '.decision // empty')"
if [ "$decision" = "refuse" ] || [ "$decision" = "rotate_now" ]; then
echo "orchestra-codex-poll: turn decision is $decision" >&2
fi
done
-79
View File
@@ -1,79 +0,0 @@
#!/bin/sh
# opencode turn-boundary/completion poller — same rationale as
# orchestra-codex-poll.sh: opencode has no native Stop hook, so this runs as
# a background loop in the pane, polling /v1/harness/turn and watching for
# the .orchestra-report.md completion marker.
#
# opencode's own SSE session-status stream (OpenCodeStatus in
# internal/herdr/occupancy.go) is a fast path this script does not use — it
# would need the pane's session id, which isn't reliably known outside the
# opencode process. Instead this falls back to the same
# most-recently-written message file under opencode's storage dir that
# OpenCodeUsage already reads as its backstop (per AUDIT.md's "Real harness
# quota sources": "the SSE stream is not rock-solid").
#
# Requires: ORCHESTRA_TASK_ID, ORCHESTRA_URL, ORCHESTRA_WORKTREE set in the
# pane's env. Optional: ORCHESTRA_HARNESS_TOKEN,
# ORCHESTRA_POLL_INTERVAL (seconds, default 60).
# Needs: jq, find, curl.
set -u
: "${ORCHESTRA_POLL_INTERVAL:=60}"
: "${ORCHESTRA_WORKTREE:=$PWD}"
[ -z "${ORCHESTRA_TASK_ID:-}" ] && { echo "orchestra-opencode-poll: ORCHESTRA_TASK_ID not set" >&2; exit 1; }
[ -z "${ORCHESTRA_URL:-}" ] && { echo "orchestra-opencode-poll: ORCHESTRA_URL not set" >&2; exit 1; }
auth_header=""
if [ -n "${ORCHESTRA_HARNESS_TOKEN:-}" ]; then
auth_header="Authorization: Bearer ${ORCHESTRA_HARNESS_TOKEN}"
fi
latest_message() {
find "$HOME/.local/share/opencode/storage/message" -type f -name '*.json' -printf '%T@ %p\n' 2>/dev/null \
| sort -rn | head -n1 | cut -d' ' -f2-
}
report_file="${ORCHESTRA_WORKTREE%/}/.orchestra-report.md"
while true; do
sleep "$ORCHESTRA_POLL_INTERVAL"
msg="$(latest_message)"
[ -z "$msg" ] && continue
if [ -f "$report_file" ]; then
report="$(cat "$report_file")"
body="$(jq -n \
--arg task_id "$ORCHESTRA_TASK_ID" \
--arg transcript_path "$msg" \
--arg report "$report" \
'{task_id: $task_id, harness: "opencode", transcript_path: $transcript_path, report: $report}')"
if curl -fsS -X POST "${ORCHESTRA_URL%/}/v1/harness/complete" \
-H "Content-Type: application/json" \
${auth_header:+-H "$auth_header"} \
-d "$body" >/dev/null 2>&1; then
rm -f "$report_file"
exit 0
else
echo "orchestra-opencode-poll: failed to report completion" >&2
fi
continue
fi
body="$(jq -n --arg task_id "$ORCHESTRA_TASK_ID" '{task_id: $task_id}')"
response="$(curl -fsS -X POST "${ORCHESTRA_URL%/}/v1/harness/turn" \
-H "Content-Type: application/json" \
${auth_header:+-H "$auth_header"} \
-d "$body" 2>/dev/null)" || {
echo "orchestra-opencode-poll: failed to reach turn-decision endpoint" >&2
continue
}
decision="$(printf '%s' "$response" | jq -r '.decision // empty')"
if [ "$decision" = "refuse" ] || [ "$decision" = "rotate_now" ]; then
echo "orchestra-opencode-poll: turn decision is $decision" >&2
fi
done
-70
View File
@@ -1,70 +0,0 @@
#!/bin/sh
# Claude Code Stop hook — fires on every turn boundary, not just completion.
# Convention: the agent signals "this task is done" by writing a report file
# named .orchestra-report.md at the worktree root before stopping. If that
# marker is present, report completion. If it is absent, this is an ordinary
# turn boundary — ask the unified turn-decision endpoint (AUDIT.md B3, Phase
# 2 items 1-2) what to do instead of no-op'ing.
#
# Requires: ORCHESTRA_TASK_ID and ORCHESTRA_URL set in the pane's env.
# Optional: ORCHESTRA_HARNESS_TOKEN if the server requires one.
#
# Reads the Stop hook's JSON payload from stdin (has "transcript_path" and
# "cwd"); needs jq.
set -eu
payload="$(cat)"
transcript_path="$(printf '%s' "$payload" | jq -r '.transcript_path // empty')"
cwd="$(printf '%s' "$payload" | jq -r '.cwd // empty')"
[ -z "$transcript_path" ] && exit 0
[ -z "${ORCHESTRA_TASK_ID:-}" ] && exit 0
[ -z "${ORCHESTRA_URL:-}" ] && exit 0
auth_header=""
if [ -n "${ORCHESTRA_HARNESS_TOKEN:-}" ]; then
auth_header="Authorization: Bearer ${ORCHESTRA_HARNESS_TOKEN}"
fi
report_file="${cwd:-.}/.orchestra-report.md"
if [ -f "$report_file" ]; then
report="$(cat "$report_file")"
body="$(jq -n \
--arg task_id "$ORCHESTRA_TASK_ID" \
--arg transcript_path "$transcript_path" \
--arg report "$report" \
'{task_id: $task_id, harness: "claude", transcript_path: $transcript_path, report: $report}')"
if curl -fsS -X POST "${ORCHESTRA_URL%/}/v1/harness/complete" \
-H "Content-Type: application/json" \
${auth_header:+-H "$auth_header"} \
-d "$body" >/dev/null 2>&1; then
rm -f "$report_file"
else
echo "orchestra-stop: failed to report completion" >&2
exit 2
fi
exit 0
fi
body="$(jq -n --arg task_id "$ORCHESTRA_TASK_ID" '{task_id: $task_id}')"
response="$(curl -fsS -X POST "${ORCHESTRA_URL%/}/v1/harness/turn" \
-H "Content-Type: application/json" \
${auth_header:+-H "$auth_header"} \
-d "$body" 2>/dev/null)" || {
echo "orchestra-stop: failed to reach turn-decision endpoint" >&2
exit 0
}
decision="$(printf '%s' "$response" | jq -r '.decision // empty')"
if [ "$decision" = "refuse" ]; then
echo "orchestra-stop: turn decision is refuse — this turn boundary is not safe to stop at" >&2
exit 2
fi
exit 0
+5 -3
View File
@@ -1,6 +1,8 @@
# Copy to /etc/orchestra/orchestra.env (chmod 600, owned by the orchestra
# user) and fill in the values you need. Referenced by orchestra.service via
# EnvironmentFile=. Every var below is read directly from os.Getenv in
# Copy to the compose directory as `.env` (chmod 600) and fill in the values
# you need; `compose.yaml` loads it via `env_file:`. The retired
# orchestra.service EnvironmentFile= path is gone as of 2026-07-31, and the
# container has no entrypoint script that sources an env file — compose passes
# these in directly. Every var below is read directly from os.Getenv in
# cmd/orchestra/main.go and the packages it wires up — grep ORCHESTRA_ in the
# repo if this list ever needs re-deriving.
-25
View File
@@ -1,25 +0,0 @@
[Unit]
Description=Orchestra task orchestrator
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=orchestra
Group=orchestra
WorkingDirectory=/var/lib/orchestra
EnvironmentFile=/etc/orchestra/orchestra.env
ExecStart=/usr/local/bin/orchestra
Restart=on-failure
RestartSec=5s
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/lib/orchestra
# ORCHESTRA_WORKTREE_ROOT / per-project worktree_root paths and
# ORCHESTRA_REPO must live under one of these, or under /var/lib/orchestra —
# add further ReadWritePaths= lines here if you keep repos elsewhere.
[Install]
WantedBy=multi-user.target
-21
View File
@@ -1,21 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
repo_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
tmp_bin="$(mktemp)"
trap 'rm -f -- "$tmp_bin"' EXIT
cd "$repo_dir"
echo "Building Orchestra..."
revision="$(git rev-parse HEAD)"
build_time="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
dirty=false
if [[ -n "$(git status --porcelain)" ]]; then dirty=true; fi
go build -ldflags "-X orchestra/internal/buildinfo.Revision=$revision -X orchestra/internal/buildinfo.Time=$build_time -X orchestra/internal/buildinfo.Dirty=$dirty" -o "$tmp_bin" ./cmd/orchestra
echo "Installing /usr/local/bin/orchestra..."
sudo install -o root -g root -m 0755 "$tmp_bin" /usr/local/bin/orchestra
echo "Restarting orchestra.service..."
sudo systemctl restart orchestra.service
sudo systemctl --no-pager --lines=8 status orchestra.service