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:
+3
-3
@@ -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/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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"]
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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, "a)
|
|
||||||
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(`{
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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": [
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
Reference in New Issue
Block a user