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/ .orchestra-config/
# Built binaries (never commit — a stale committed binary is a deployment- # Built binaries (never commit — a stale committed binary is a deployment-
# confusion hazard). `clients/` is deliberately NOT ignored: it holds the # confusion hazard).
# still-deployed Design A herdr bridge, and deployed code must be tracked.
/orchestra /orchestra
/orchestra-worker /orchestra-worker
# Web UI build inputs/outputs. node_modules in particular ships vendored Go # Web UI build inputs/outputs. node_modules in particular ships vendored Go
# packages (e.g. flatted/golang), so leaving it merely untracked is not # 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 /package-lock.json
node_modules/ node_modules/
.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 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. 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 — - **Runs under Docker Compose, not systemd.** `docker compose -f compose.yaml
`journalctl -u orchestra.service` for logs; `sudo` isn't available in this -f compose.live.yaml` in `/home/kami/docker-apps/orchestra-web-ui`, building
sandbox environment, but plain `journalctl` without sudo works here). both images from this repo: `orchestra-api` (bound `0.0.0.0:9145`, which is
- Config: `.orchestra-config/orchestra.env` (env vars) + intentional — ufw restricts the port to one other LAN machine) and
`.orchestra-config/config.jsonc` (projects/machines/herdrs registry). `orchestra-web-ui` (nginx proxy, `127.0.0.1:19145`). Logs are
`ORCHESTRA_CONFIG=/etc/orchestra/config.jsonc` — the deployed copy, not the `docker logs orchestra-api`. **Deploying a code change means rebuilding the
repo's `deploy/config.example.jsonc`. 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` - Two machines in the registry: `homesrv` (192.168.1.104) and `workpc`
(192.168.1.105), each nominally running 3 herdrs (claude/codex/opencode). (192.168.1.105), each nominally running 3 herdrs (claude/codex/opencode).
In practice **homesrv has no local herdr running** (connection refused on 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 (`git rev-parse HEAD`) against the wrong machine's checkout. Rotation/cleanup
therefore no longer act on remote leases. therefore no longer act on remote leases.
**Design A is retained, not dead.** `clients/herdr-bridge.go` ("drive the **Design A is gone (deleted 2026-07-31).** `clients/herdr-bridge.go` ("drive
remote socket": homesrv calling `worktree.create`/`agent.start` directly on 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 workpc's herdr over TCP as if it were local) was deleted along with the whole
standing AUDIT.md decision of 2026-07-27; deletion of Design A and `clients/` `clients/` directory — the operator confirmed it is undeployed now that workers
is deferred to the Phase 6 cutover. Do not delete it early, and do not add new carry cross-machine work, which superseded the 2026-07-27 "keep through Phase
cross-machine call paths to it. 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 ## 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 cleanup can no longer validate a git anchor against the wrong machine's
checkout. checkout.
Design A itself is **retained through Phase 5** per that same decision. Design A is **deleted as of 2026-07-31**, superseding the 2026-07-27 "retain
`clients/herdr-bridge.go` is now tracked in git (deployed code must be in through Phase 5" decision: the operator confirmed the bridge is undeployed now
version control); deleting Design A and `clients/` is deferred to the Phase 6 that workers carry cross-machine work, so `clients/` was removed rather than
cutover. 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 ## 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. | | 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. | | 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. | | 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 ## 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 `docker logs orchestra-api`. **Deploying a code change means rebuilding the
compose images** (`up -d --build`) — the running image can silently predate compose images** (`up -d --build`) — the running image can silently predate
recent commits, so compare its build time against `git log`. recent commits, so compare its build time against `git log`.
- `orchestra.service` is the **previous** deployment and is retired — - `orchestra.service` was the **previous** deployment; the unit file was
`compose.live.yaml` requires it stopped, since both bind the same port and deleted from `deploy/` on 2026-07-31 along with `redeploy.sh` (which
data dir. Do not start it. (`sudo` isn't available in this sandbox, so `sudo install`ed to `/usr/local/bin` and restarted it). If a stale copy is
`systemctl disable` needs the operator.) still installed on a host, it must stay stopped — it binds the same port and
- Config: `/etc/orchestra/orchestra.env` (env vars) + `/etc/orchestra/ data dir as the container. (`sudo` isn't available in this sandbox, so
config.jsonc` (projects/machines/herdrs registry) — the deployed copies, not `systemctl disable` needs the operator.) `orchestra-worker.service` is a
the repo's `.orchestra-config/` or `deploy/config.example.jsonc`. The *different*, still-current unit — don't delete it by association.
container bind-mounts `/etc/orchestra:ro` and its entrypoint sources the env - Config: env vars come from **`.env` in the compose directory**, loaded via
file, so Docker never copies the secrets. `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 - 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. other LAN machine. Don't report it as an exposure.
- Two machines in the registry: `homesrv` (192.168.1.104) and `workpc` - 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 (`git rev-parse HEAD`) against the wrong machine's checkout. Rotation/cleanup
therefore no longer act on remote leases. therefore no longer act on remote leases.
**Design A is retained, not dead.** `clients/herdr-bridge.go` ("drive the **Design A is gone (deleted 2026-07-31).** `clients/herdr-bridge.go` ("drive
remote socket": homesrv calling `worktree.create`/`agent.start` directly on 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 workpc's herdr over TCP as if it were local) was deleted along with the whole
standing AUDIT.md decision of 2026-07-27; deletion of Design A and `clients/` `clients/` directory — the operator confirmed it is undeployed now that workers
is deferred to the Phase 6 cutover. Do not delete it early, and do not add new carry cross-machine work, which superseded the 2026-07-27 "keep through Phase
cross-machine call paths to it. 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 ## Working conventions
+1 -2
View File
@@ -12,9 +12,8 @@ FROM alpine:3.21
RUN adduser -D -u 10001 orchestra RUN adduser -D -u 10001 orchestra
WORKDIR /app WORKDIR /app
COPY --from=build /out/orchestra /app/orchestra 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 RUN mkdir /data && chown orchestra:orchestra /data
ENV ORCHESTRA_DATA=/data ORCHESTRA_PORT=9145 ENV ORCHESTRA_DATA=/data ORCHESTRA_PORT=9145
VOLUME ["/data"] VOLUME ["/data"]
EXPOSE 9145 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 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() { func main() {
dir := os.Getenv("ORCHESTRA_DATA") dir := os.Getenv("ORCHESTRA_DATA")
if dir == "" { if dir == "" {
@@ -553,27 +472,19 @@ func main() {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(b) _, _ = 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") 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 // /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 // 2 items 1-2): a harness-side caller posts here on an ordinary turn
// boundary (task completion is not handled here — it goes through the // boundary and gets back continue / prepare_handoff / rotate_now / refuse,
// authenticated worker completion endpoint, since the retired // per spec §5.3, instead of separate ad-hoc marker-file conventions per
// /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
// decision. // 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) { mux.HandleFunc("/v1/harness/turn", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed) http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
-76
View File
@@ -1,19 +1,12 @@
package main package main
import ( import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"time" "time"
"orchestra/internal/authz"
"orchestra/internal/domain"
"orchestra/internal/registry" "orchestra/internal/registry"
"orchestra/internal/store"
) )
type unreachable struct{} 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) { func TestMultiMachineRegistryRequiresKnownLocalMachine(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json") path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{ if err := os.WriteFile(path, []byte(`{
+5 -3
View File
@@ -1,8 +1,10 @@
# Deployment verification # Deployment verification
Both binaries embed their Git revision, UTC build time, and dirty flag. Build Both binaries embed their Git revision, UTC build time, and dirty flag. The
the coordinator with `deploy/redeploy.sh`; it installs and restarts the local coordinator is deployed as a Docker Compose image — see "For the Docker
`orchestra.service`. 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 ## 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 — // 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. // Static project topology. One entry per project the fleet routes tasks for.
"projects": [ "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 # Copy to the compose directory as `.env` (chmod 600) and fill in the values
# user) and fill in the values you need. Referenced by orchestra.service via # you need; `compose.yaml` loads it via `env_file:`. The retired
# EnvironmentFile=. Every var below is read directly from os.Getenv in # 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 # cmd/orchestra/main.go and the packages it wires up — grep ORCHESTRA_ in the
# repo if this list ever needs re-deriving. # 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