Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 982741fe97 | |||
| 42c5f07844 | |||
| 79d20534b5 | |||
| 74cad5d374 | |||
| e0601296e0 | |||
| 438c1d6df3 | |||
| 76da8c40b7 | |||
| 575e3ef87e | |||
| 75396963ef | |||
| edf00761fd | |||
| 1330ad9943 | |||
| cb80dd1952 | |||
| de18f372d3 | |||
| bdc0d4d5be | |||
| 6cb2f932d8 | |||
| b5c37f693b | |||
| bbf3335857 | |||
| 99b209ba10 | |||
| ab3258833d | |||
| c587f2cc8d | |||
| 41658aea5b | |||
| 765bf2afc6 | |||
| cbd6b11c49 | |||
| 34f3c2888f | |||
| 118ac9fbcb | |||
| 2d28f7b462 | |||
| a757cffc78 | |||
| 2f61a99986 | |||
| 595a1d3533 | |||
| 0797432d6f | |||
| 3c7cf95d8c | |||
| 0f83559ecc |
@@ -16,3 +16,4 @@ node_modules/
|
||||
.node_modules/
|
||||
web/dist/
|
||||
web/tsconfig.tsbuildinfo
|
||||
build/
|
||||
|
||||
@@ -76,6 +76,11 @@ the live herdr instance and check.
|
||||
There is no container entrypoint script — `Dockerfile.api` execs
|
||||
`/app/orchestra` directly. Neither deployed file is the repo's
|
||||
`deploy/config.example.jsonc`.
|
||||
- Browser operator accounts live in `$ORCHESTRA_DATA/auth.db`. Create or reset
|
||||
one with `orchestra-user set -data /data -username NAME` while the API is
|
||||
stopped, or use the authenticated Settings screen. The old
|
||||
`ORCHESTRA_WEB_USERNAME`/`ORCHESTRA_WEB_PASSWORD_HASH` pair is accepted only
|
||||
for a one-time import into an empty database and should then be removed.
|
||||
- Two machines in the registry: `homesrv` (192.168.1.104) and `workpc`
|
||||
(192.168.1.105), each nominally running 3 herdrs (claude/codex/opencode).
|
||||
In practice **homesrv has no local herdr running** (connection refused on
|
||||
|
||||
@@ -391,3 +391,37 @@ work.
|
||||
|
||||
Sudo is not available in this sandbox, so the worker install, the restart, and
|
||||
the `worker.env` scrub remain operator steps.
|
||||
|
||||
## Browser operator database and UI refresh (2026-08-26)
|
||||
|
||||
The browser login no longer depends on an operator copying a bcrypt hash into
|
||||
deployment configuration. The live startup path in `cmd/orchestra/main.go`
|
||||
opens `$ORCHESTRA_DATA/auth.db` through `internal/authn`, refuses to serve with
|
||||
an empty operator database, and registers the database-backed session and
|
||||
account handlers before wrapping the mux with `authz.HTTPWithSessions`.
|
||||
|
||||
- `auth.db` is an embedded bbolt database created mode 0600. Passwords are
|
||||
bcrypt-hashed before the record is written; login also performs bcrypt for an
|
||||
unknown username to avoid an account-existence timing shortcut.
|
||||
- `orchestra-user set -data DIR -username NAME` reads and confirms a password
|
||||
from the terminal, creates the first operator, and resets an existing one.
|
||||
The Docker API image includes this helper. The authenticated Settings screen
|
||||
changes the current username/password and revokes every session for that
|
||||
identity.
|
||||
- An existing `ORCHESTRA_WEB_USERNAME`/`ORCHESTRA_WEB_PASSWORD_HASH` pair is
|
||||
imported once if and only if the database has no users. Once a user exists,
|
||||
those variables are ignored with an explicit startup log, so an old `.env`
|
||||
cannot overwrite a database credential.
|
||||
- The browser now gets its actual username from `GET /v1/ui/session`, renders
|
||||
it in the shell, and has a dedicated account page. The login view was rebuilt
|
||||
as a responsive desktop/mobile entry experience.
|
||||
- Frontend state drift was fixed at the same time: `needs_attention` and
|
||||
`in_review`, plus the three newer block reasons, are in the TypeScript model,
|
||||
board lanes, status colors, diagnosis copy, and filtering. The seven-state
|
||||
"All" board now has an explicit layout instead of falling back to one column.
|
||||
|
||||
Verified from the working tree after rebuilding the embedded assets:
|
||||
`go build ./...`, `go vet ./...`, and `go test ./...` all pass (21 test
|
||||
packages). The frontend TypeScript build passes, all five API-client tests
|
||||
pass, and Vite's production build emits the assets embedded by
|
||||
`internal/webui`.
|
||||
|
||||
@@ -2274,3 +2274,745 @@ tmux -L orchestra kill-session -t <session>
|
||||
|
||||
Task `06G4JX6MSQEP7N0D5JWW9EP5X4` is the other run 12 task and is `in_review` on
|
||||
a pull request. It is real work and should be reviewed or failed, not cleaned.
|
||||
|
||||
### F18 closed: a bounded observation ring
|
||||
|
||||
`d6ee10f`. `WorkerHealth` now carries up to sixteen distinct observations, each
|
||||
with a repeat count and first/last times. Collapsing is by message, not by
|
||||
position, because a loop interleaved with other failures would otherwise still
|
||||
flush the ring. Eviction drops the least recently seen, so a loop keeps its
|
||||
slot but carries the count that says what it is. `last_error` and `error_at`
|
||||
keep their wire names and still report only the newest failure.
|
||||
|
||||
The ring lives in memory beside `last_error` and is not persisted, which is
|
||||
what `last_error` already did across a restart.
|
||||
|
||||
Deployed and verified on both halves. It reports nothing yet, because
|
||||
`omitempty` hides an empty ring and nothing has failed since the deploy. No UI
|
||||
renders it: `internal/ui` never showed `last_error` either, so
|
||||
`GET /v1/federation/workers` is the only reader today.
|
||||
|
||||
### Run 12 closed out
|
||||
|
||||
PR 17 merged, task `06G4JX6MSQEP7N0D5JWW9EP5X4` completed at v32, pane closed
|
||||
by the normal completion path. The quality gate was re-run independently
|
||||
against the submitted commit `419e17fc` before the merge, rather than trusted
|
||||
from the worker's own report:
|
||||
|
||||
```text
|
||||
bash -n scripts/*.sh syntax ok
|
||||
bash scripts/orchestra_e2e_healthcheck.sh OK - all healthchecks passed, exit 0
|
||||
bash scripts/test_healthcheck.sh all checks passed, exit 0
|
||||
```
|
||||
|
||||
Worker state is empty: no sessions, no leases, no release transactions, and
|
||||
`tmux -L orchestra ls` lists nothing. The two panes the previous section left
|
||||
for an operator were already gone by then.
|
||||
|
||||
| Half | Revision |
|
||||
|---|---|
|
||||
| Coordinator, homesrv container | `d6ee10f` |
|
||||
| Worker, workpc systemd | `d6ee10f` |
|
||||
|
||||
```text
|
||||
commit d6ee10f02843e2267c859b03d17db00995941e64
|
||||
coordinator sha256 f8a847477ae0ac03fe78338775f52ed1f167f0e67eab557feffe4d67da3ad99e
|
||||
worker sha256 fe7d2188b12369fb7682d3424fa11e2db47abed9c55313291a4b467b5972d02d
|
||||
```
|
||||
|
||||
### Still open after run 12
|
||||
|
||||
- **29 blocked `test-e2e` tasks**, mostly burn-in debris. Operator hygiene, not
|
||||
lifecycle code.
|
||||
- **Three queued `correx` tasks** that cannot be scheduled, because `correx` has
|
||||
no entry in the coordinator's `config.jsonc`. Either give it one or block them
|
||||
explicitly.
|
||||
- **Gitea returns 422 for a review on your own pull request**, so
|
||||
`REQUEST_CHANGES` needs a separate bot account for `ORCHESTRA_GITEA_TOKEN`.
|
||||
- **A blocked task that is never reopened can still loop** on a dead release
|
||||
transaction. F59 covers failed, not blocked.
|
||||
|
||||
## Run 13, 2026-08-28: F60, and the ring proven under a real failure
|
||||
|
||||
### F60: a transaction must settle deterministically, in every case
|
||||
|
||||
`3c7cf95`. F58 and F59 each fixed one case of the same rule. F60 states the
|
||||
rule and covers the rest:
|
||||
|
||||
> a transaction that can no longer be legitimately committed must be abandoned
|
||||
> deterministically, and must never spin on an answer that cannot change.
|
||||
|
||||
- **Terminal is failed or completed.** Both drop the transaction and free the
|
||||
session. Completed was the gap F59 left.
|
||||
- **Blocked keeps the transaction**, because a reopen returns the task to the
|
||||
queue and that exact owner can still commit.
|
||||
- **`TaskBlocked` now retains the ending epoch**, as `TaskReleased` already did.
|
||||
Without it a reopened task has no epoch for the late-handoff path to fence
|
||||
against, so keeping the transaction would be a lie.
|
||||
- **A refusal parks the commit**, 30s backing off to a 5 minute cap. Any event
|
||||
about the task un-parks it. A reopen arrives as `TaskCorrected`, so the rule
|
||||
cannot be a list of event types.
|
||||
- **A transport failure is not an answer** and retries at once. The park keys on
|
||||
a 4xx `StatusError`, never on any error.
|
||||
|
||||
### Proven live, and the contrast with run 10
|
||||
|
||||
Task `06G4KVSHAK9B8M9K8HENAF23CG`, same race-guard rig: force the expiry inside
|
||||
the push, then hand the task to `race-guard-probe`.
|
||||
|
||||
```text
|
||||
19:54:10.888 transaction 06G4KVYT91GQH5ZYY6CNG6FWZ8 opens at prepared
|
||||
19:54:10.907 forced expiry accepted
|
||||
19:54:10.916 successor lease to race-guard-probe accepted
|
||||
19:54:11.776 commit refused once, 409 lease not owned, parked
|
||||
19:54:15.741 superseded by lease 06G4KVYSXJH053K3DAG6FKHD20, transaction abandoned
|
||||
```
|
||||
|
||||
Run 10's equivalent ran roughly 5,000 retries over seven hours and needed an
|
||||
operator to clear the state file by hand. This one asked once and settled
|
||||
itself in four seconds. Worker state afterwards: no sessions, no leases, no
|
||||
transactions, and `tmux -L orchestra ls` empty.
|
||||
|
||||
### F18 populated under a real failure
|
||||
|
||||
The gap the previous section recorded is closed. The same run produced this
|
||||
health payload, two distinct observations rather than one overwritten slot:
|
||||
|
||||
```json
|
||||
"observations": [
|
||||
{"message": "release 06G4KVSHAK9B8M9K8HENAF23CG commit: federation: 409 Conflict: lease not owned",
|
||||
"count": 1, "first": "2026-08-28T19:54:11.776815524Z", "last": "2026-08-28T19:54:11.776815524Z"},
|
||||
{"message": "release 06G4KVSHAK9B8M9K8HENAF23CG superseded by lease 06G4KVYSXJH053K3DAG6FKHD20: abandoning transaction 06G4KVYT91GQH5ZYY6CNG6FWZ8",
|
||||
"count": 1, "first": "2026-08-28T19:54:15.74116014Z", "last": "2026-08-28T19:54:15.74116014Z"}
|
||||
]
|
||||
```
|
||||
|
||||
Under the old single slot the abandonment message would have erased the 409
|
||||
that caused it, and the causal chain would have been unreadable. Both counts
|
||||
are 1, which is itself the F60 evidence: nothing looped.
|
||||
|
||||
### Deployed state
|
||||
|
||||
| Half | Revision |
|
||||
|---|---|
|
||||
| Coordinator, homesrv container | `3c7cf95` |
|
||||
| Worker, workpc systemd | `3c7cf95` |
|
||||
|
||||
```text
|
||||
commit 3c7cf95d8cdaaff5dcec460c5d7874f7f4f0dbea
|
||||
coordinator sha256 3fec1e99a638da851f5c970abaf80a006dc87425936f71fe33f10830f4bac573
|
||||
worker sha256 a373445f167f0a321dfc3866a1d2ed703b670b0fb3c188a8d00ad8099586dfdf
|
||||
```
|
||||
|
||||
`orchestra-f18-baseline` tags `d6ee10f`, the post-F18 deployed baseline, and is
|
||||
pushed. The branch is pushed too: 91 commits, because nothing had gone up since
|
||||
`97a9c65` on 2026-07-31.
|
||||
|
||||
### Still open
|
||||
|
||||
Unchanged and all operational rather than runtime: 29 blocked `test-e2e` tasks,
|
||||
three unschedulable `correx` tasks, and the Gitea 422 on reviewing your own
|
||||
pull request. The rig task above will requeue when the probe lease expires and
|
||||
start fresh, which is correct: its anchor was abandoned, not committed.
|
||||
|
||||
## Run 14, 2026-08-28: plan-phase execution proven on `3c7cf95`
|
||||
|
||||
Four tasks. Two carried a full lifecycle to a pull request, one failed for a
|
||||
cause outside Orchestra, and the F60 rig task failed on its retry budget.
|
||||
|
||||
### The rung: the worker runs the sealed plan's commands
|
||||
|
||||
Task `06G4M8WHGQ4P3GQMPEEH0RJRHM`, plan `789ed6a8477b`, both phases verified
|
||||
against the plan document rather than the request.
|
||||
|
||||
```text
|
||||
plan phase-1 - run: ["bash", "-n", "scripts/orchestra_e2e_healthcheck.sh"]
|
||||
- run: ["bash", "scripts/orchestra_e2e_healthcheck.sh"]
|
||||
event v19 commands identical, exit_codes [0,0], at_sha 3b66b2b6a669,
|
||||
evidence_ref a5dbc4ecc9ce, status awaiting_manual_verification
|
||||
|
||||
plan phase-2 - run: ["bash", "-n", "scripts/orchestra_e2e_healthcheck.sh"]
|
||||
- run: ["bash", "-n", "scripts/test_healthcheck.sh"]
|
||||
- run: ["bash", "scripts/orchestra_e2e_healthcheck.sh"]
|
||||
event v20 commands identical, exit_codes [0,0,0], same at_sha and plan_ref
|
||||
```
|
||||
|
||||
Every record binds `plan_ref`, `phase_id`, `at_sha`, `evidence_ref`,
|
||||
`lease_epoch` and `harness_id`. Rotation ran between every work phase with
|
||||
`TaskPickupValidated` each time.
|
||||
|
||||
**Not yet proven:** a successor picking up mid-implement. Both phases verified
|
||||
under one lease epoch, so no rotation separated phase 1 from phase 2.
|
||||
|
||||
**The planner found the legal route to a forbidden command.**
|
||||
`scripts/test_healthcheck.sh` may not be executed, but `["bash", "-n", ...]`
|
||||
covers any path, so it syntax-checked the file it could not run.
|
||||
|
||||
### The refusal loop is actionable, not merely observable
|
||||
|
||||
Task `06G4M6HF1Z3EREX1X3NEKSHP24` sealed a plan naming
|
||||
`bash scripts/test_healthcheck.sh`, which the policy forbids.
|
||||
|
||||
```text
|
||||
20:45:02 phase request refused: "bash scripts/test_healthcheck.sh" is not in the policy
|
||||
20:45:33 implement <- the planner rewrote and resealed, 31 seconds later
|
||||
20:48:03 in_review
|
||||
```
|
||||
|
||||
F52 refused on the planner, F55's answer path delivered the reason, and the
|
||||
agent corrected. Count stayed at 1 for both tasks that hit it. A brief that
|
||||
states the policy avoids the round trip entirely: the `--list-checks` task was
|
||||
told the allowed argv and sealed on the first attempt.
|
||||
|
||||
### F61, recorded and deliberately not built
|
||||
|
||||
The verification allow-list is enforced and never stated to the planner. Every
|
||||
plan that guesses pays one refused round trip. It is not a one-liner:
|
||||
`agentctx.Input.Policy` is filled from the worker's `SafeOperations`, while the
|
||||
verification policy is coordinator-side by design, so this needs a new channel.
|
||||
|
||||
The reason to promote it later is local models. A strong planner consumed the
|
||||
refusal and repaired the plan in 31 seconds. A weaker one may propose forbidden
|
||||
commands repeatedly, because it cannot infer the allowed substitute. Then F61
|
||||
stops being latency and becomes model compatibility.
|
||||
|
||||
### opencode is not running, and the reason was only in the pane
|
||||
|
||||
Task `06G4M6HH5BAM235AC4PWYXF1HM` failed at attempt 3 having never left
|
||||
`frame`. Its worktree held only `launch.md`. The pane said why. The blocked
|
||||
thing is the selected model, not the OpenCode Zen free tier, which works:
|
||||
|
||||
```text
|
||||
This model is not available in your country.
|
||||
▣ Build · Muse Spark 1.2 Free OpenCode Zen
|
||||
```
|
||||
|
||||
Orchestra behaved correctly throughout: lease, no progress, expire, requeue,
|
||||
fail. Two things are worth keeping.
|
||||
|
||||
- **The adapter cannot resolve a session file for opencode**, so activity is
|
||||
`unknown` and the worker falls back to agent status. The failure therefore
|
||||
reads as "done and pane unchanged" rather than "dead". Orchestra cannot tell
|
||||
finished from never-started on this harness.
|
||||
- **No observation carried the cause.** Four of them named the lease, the
|
||||
rotation and the activity. The fatal line existed only in the pane capture,
|
||||
which Orchestra publishes but nothing summarises. Parsing harness chrome is
|
||||
not Orchestra's job; surfacing the capture is.
|
||||
|
||||
**Do not route to `workpc-opencode` until its model is reachable.** Every task
|
||||
sent there burns three attempts and then fails. `~/.config/opencode/opencode.jsonc`
|
||||
sets no top-level `model`, so OpenCode picks its own default. The file already
|
||||
declares reachable `nvidia-nim` and `llama-cpp` providers.
|
||||
|
||||
### The retry budget counts idleness, not just failure
|
||||
|
||||
The F60 rig task `06G4KVSHAK9B8M9K8HENAF23CG` failed at attempt 3 mid-review.
|
||||
Two attempts were rig-induced expiries and the third was a natural idle
|
||||
expiry. Every lease expiry increments `attempt`, and an agent going quiet at a
|
||||
phase boundary is a routine expiry here. A task making real progress can spend
|
||||
its retry budget on idleness. The two clean tasks never expired at all, so this
|
||||
is a dynamic to watch rather than a defect to fix.
|
||||
|
||||
F59 was confirmed live twice more: both failed tasks left no release
|
||||
transaction and no session behind, on either worker.
|
||||
|
||||
### Result
|
||||
|
||||
| Task | Harness | Outcome |
|
||||
|---|---|---|
|
||||
| `06G4M6HF1Z3EREX1X3NEKSHP24` | workpc-claude | in_review, recovered from a policy refusal |
|
||||
| `06G4M8WHGQ4P3GQMPEEH0RJRHM` | workpc-claude | in_review, plan-phase execution proven |
|
||||
| `06G4M6HH5BAM235AC4PWYXF1HM` | workpc-opencode | failed, model unavailable |
|
||||
| `06G4KVSHAK9B8M9K8HENAF23CG` | workpc-claude | failed, retry budget |
|
||||
|
||||
Both workers hold no sessions and no release transactions. Deployed pair is
|
||||
still `3c7cf95` on both halves.
|
||||
|
||||
## Run 16, 2026-08-29: the rotation rung, half proven
|
||||
|
||||
Task `06G4SWEVP71FYYKAV5FV0ZK5ZG` on `34f3c28`, a three-phase plan
|
||||
(`78d25730140c`) with automated checks inside policy.
|
||||
|
||||
### Proven: a successor inherits the whole sealed plan
|
||||
|
||||
The launch context of the session that picked the task up after its rotation
|
||||
carries the complete plan: overview, current state, desired end state,
|
||||
non-goals, approach, all three phases with their files, changes and
|
||||
verification, testing strategy, risks, migration, and ten research citations.
|
||||
|
||||
### Not a defect: that successor was not given phase progress
|
||||
|
||||
`renderPlanProgress` runs only for `WorkPhaseImplement`
|
||||
(`internal/agentctx/agentctx.go:321`). This successor picked up in review, and
|
||||
the code says why: an independent review must reconstruct the change from the
|
||||
diff rather than inherit the implementer's account. Progress is withheld there
|
||||
deliberately.
|
||||
|
||||
So the rung still needs an **implement-phase** successor. The records exist and
|
||||
are durable, projected by `Task.PlanPhases()`.
|
||||
|
||||
### Why the mid-implement rotation missed
|
||||
|
||||
A trivial task spends about four minutes in implement, and all three phases
|
||||
verified inside two and a half of them, every one against the same tree
|
||||
`3da86dbf8ad5`. The implementer does the whole change first, then verifies each
|
||||
phase. The suspend, edit and restart that sets `handoff_requested` takes about
|
||||
a minute, and it landed after the implementer had already asked for review.
|
||||
|
||||
Two mechanics worth keeping:
|
||||
|
||||
- **The web handoff action cannot drive this.** `RequestHandoff` needs a local
|
||||
coordinator and every session here is worker-owned, so it answers 503. That
|
||||
is the Design A guardrail working, and a fourth UI-exposed gap.
|
||||
- **A state-file edit does not survive a running worker.** It holds sessions in
|
||||
memory and writes them back. Suspend, edit, then restart; resuming lets the
|
||||
old copy win. This cost one attempt to learn.
|
||||
|
||||
### F62: a requested handoff that is never answered is invisible
|
||||
|
||||
The rotation is agent-driven: the worker asks, and the agent must write
|
||||
`HANDOFF.md` at a turn boundary. The review agent never did. Renewals stopped,
|
||||
the lease expired at 10:12:05, and the task requeued having lost an attempt.
|
||||
|
||||
Worker health recorded exactly one thing, 34 times:
|
||||
|
||||
```text
|
||||
x34 lease 06G4SWEVP71FYYKAV5FV0ZK5ZG not renewed: agent status idle and pane unchanged
|
||||
```
|
||||
|
||||
Nothing says a handoff was requested and left unanswered. There is no timeout,
|
||||
no retry, and no observation. The only visible consequence is an expiry that
|
||||
looks identical to an ordinary idle one. Diagnosing it needed the worker state
|
||||
file.
|
||||
|
||||
The ring earned its keep again: one distinct message with a count of 34, rather
|
||||
than 34 overwrites of one slot.
|
||||
|
||||
**Fixed, not yet proven live.** The request is now stamped
|
||||
(`herdr.Session.HandoffRequestedAt`) and the wait is bounded by
|
||||
`watchHandoff` in the worker:
|
||||
|
||||
- the lease renews while Orchestra is explicitly waiting, because a quiet pane
|
||||
is the answer the agent was asked for — the renewal gate's new case, bounded
|
||||
by `handoffAnswerTimeout`;
|
||||
- the request is re-sent once at `handoffRetryAfter` (4 minutes), with the same
|
||||
reason it was first asked with;
|
||||
- at 10 minutes the worker nacks with failure class `handoff_unanswered`, and
|
||||
the coordinator emits `TaskReleased reason=handoff_unanswered` rather than
|
||||
letting the lease die as generic idleness.
|
||||
|
||||
`DebtClassForFailureClass` knows the class, so a harness that repeatedly
|
||||
ignores handoff requests now accumulates in the debt ledger instead of hiding
|
||||
inside `lease_expired`.
|
||||
|
||||
Runtime proof still owed: force a request the agent will not answer, and read
|
||||
the release event rather than the worker state file.
|
||||
|
||||
## Run 17, 2026-08-29: F62 proven live on `c587f2c`
|
||||
|
||||
Task `06G4V20T528ZTER7KZBVGNAYXC`, a five-phase plan
|
||||
(`3b16b77054b9`) on the deployed pair, coordinator and worker both at
|
||||
`c587f2c`.
|
||||
|
||||
### The trigger was production, not a rig
|
||||
|
||||
The rig planned for this run was a state-file lever. It was never needed. The
|
||||
`implement -> review` phase change asked for a handoff through the ordinary
|
||||
path (`rotateForPhase`, reason `phase_changed`), and the agent ignored it
|
||||
because this task's brief instructed it to from phase 4 onward. Nothing touched
|
||||
tmux, no process was stopped, and the pane stayed open for the whole wait.
|
||||
|
||||
```text
|
||||
12:48:09 implement -> review, handoff requested, reason=phase_changed
|
||||
12:50:09 TaskLeaseRenewed agent idle, pane capture unchanged
|
||||
12:52:15 pane input confirmed the single resend, 4m06s after the request
|
||||
12:52:39 TaskLeaseRenewed
|
||||
12:55:14 TaskLeaseRenewed
|
||||
12:57:49 TaskLeaseRenewed
|
||||
12:58:14 TaskReleased reason=handoff_unanswered failure_class=handoff_unanswered
|
||||
last_error="handoff requested (phase_changed) and unanswered for 10m5s"
|
||||
12:59:14 TaskLeased successor leases normally
|
||||
13:02:20 TaskSubmitted
|
||||
```
|
||||
|
||||
Every assertion the operator set for this rung held:
|
||||
|
||||
- the release does not look like an ordinary `lease_expired`: it carries
|
||||
`reason=handoff_unanswered` and `lifecycle_phase=handoff_unanswered`;
|
||||
- the idle-renewal rule did not kill the wait: four renewals on an idle pane
|
||||
with no capture movement, each one the gate would have refused before this
|
||||
commit;
|
||||
- the resend preserved the reason the request was first made with;
|
||||
- exactly one resend, not one per tick;
|
||||
- the timeout produced the class at `10m5s`, one tick past the bound;
|
||||
- nothing stale was left: `releases: {}`, no session, no lease, and zero tmux
|
||||
panes matching the id;
|
||||
- the next attempt leased 60s later and reached `TaskSubmitted`.
|
||||
|
||||
The debt projection classified it with no new code beyond the class mapping:
|
||||
|
||||
```text
|
||||
v1:operational:handoff_unanswered:workpc-claude:lease operational important
|
||||
"handoff requested (phase_changed) and unanswered for 10m5s"
|
||||
```
|
||||
|
||||
**Inference, not observation.** The resent prompt's text was not captured. That
|
||||
it carried the `phase_changed` wording rests on the code path and the timing,
|
||||
not on a pane capture. A future rig should publish a capture across the resend.
|
||||
|
||||
### The implement-successor rung missed again, and why
|
||||
|
||||
A five-phase plan does not lengthen the implement phase. This one ran 12:45:18
|
||||
to 12:48:09, about 2m50s, with verifications 20 to 40 seconds apart:
|
||||
|
||||
```text
|
||||
12:45:47 phase-1 12:46:07 phase-2 12:46:32 phase-3
|
||||
12:47:12 phase-4 12:47:52 phase-5 12:48:09 implement -> review
|
||||
```
|
||||
|
||||
The brief's explicit one-phase-at-a-time requirement was followed, and it
|
||||
still took under three minutes. Phase count is not the lever; volume of
|
||||
mechanical work is.
|
||||
|
||||
`awaiting_manual_verification` does not hold the phase open either. All five
|
||||
phases carried that status and the agent moved on regardless.
|
||||
|
||||
### The state-file lever cannot be driven with `restart` alone
|
||||
|
||||
The documented procedure is stop, edit, start. The unattended sudo rule grants
|
||||
`install` and `systemctl restart orchestra-worker`, with no `stop` or `start`.
|
||||
With `restart` only, the running worker's five-second tick save landed between
|
||||
the edit and the restart, and the edit was gone. Widening the sudoers line is
|
||||
the fix.
|
||||
|
||||
### One cosmetic note
|
||||
|
||||
`Session.HandoffRequestedAt` serializes as `0001-01-01T00:00:00Z` when unset,
|
||||
because `omitempty` does not omit a zero `time.Time`. The worker parses it back
|
||||
to a zero value and `watchHandoff` starts the clock, so behaviour is correct.
|
||||
It is the same marshalling trap the operator console hit.
|
||||
|
||||
## Run 18, 2026-08-29: the implement-successor rung, proven
|
||||
|
||||
Task `06G4VF5HZW7Q4JBM3TTY7W1Y64` on `c587f2c`, a five-phase plan whose phases
|
||||
are ten named checks rather than one refactor. Volume of mechanical work is
|
||||
what keeps the implement phase open; phase count does not.
|
||||
|
||||
### The rotation
|
||||
|
||||
```text
|
||||
13:43:10 implement session launched
|
||||
13:43:44 phase-1 verified
|
||||
13:44:04 phase-2 verified
|
||||
13:44:35 handoff requested, reason=milestone, delivered by watchHandoff's resend
|
||||
13:45:25 TaskLeaseRenewed
|
||||
13:46:51 TaskReleased handoff_ref 0b4fae6f705a anchor 8ea3c7d1ae8f
|
||||
13:46:51 TaskLeased successor, same harness
|
||||
13:46:55 TaskPickupValidated
|
||||
13:47:06 TaskLaunchAcknowledged, still in implement
|
||||
```
|
||||
|
||||
The lever was the worker state file, backdated past `handoffRetryAfter` so the
|
||||
request went out on the next tick. Under `c587f2c` that lever now produces a
|
||||
real prompt: the flag alone used to sit there unasked, which is how run 16
|
||||
ended in a silent expiry.
|
||||
|
||||
### What the successor was given
|
||||
|
||||
From `.orchestra/launch.md`, written at 13:47:05:
|
||||
|
||||
```text
|
||||
## Current phase
|
||||
implement: Implement the accepted plan below. Verify as you go.
|
||||
|
||||
## Verified git state
|
||||
- head: 8ea3c7d1ae8fd960897344694645117adbd8ee82
|
||||
- uncommitted changes: false
|
||||
|
||||
## Current human decisions
|
||||
None recorded. Work from the goal and acceptance above.
|
||||
|
||||
## Plan progress
|
||||
Orchestra established this by running the plan's own verification. You cannot
|
||||
write it.
|
||||
- phase-1 (...): automated checks passed at d3acd4989a20, stale because the
|
||||
tree is now at 8ea3c7d1ae8f, waiting for the human to confirm the manual steps
|
||||
- phase-2 (...): automated checks passed at d3acd4989a20, stale because the
|
||||
tree is now at 8ea3c7d1ae8f, waiting for the human to confirm the manual steps
|
||||
- phase-3 (Checks 7 to 8): not started
|
||||
- phase-4 (...): not started
|
||||
- phase-5 (...): not started
|
||||
```
|
||||
|
||||
Everything the rung asked for is there: the complete accepted plan with all
|
||||
five phases, their files, changes and verification; the phases already
|
||||
verified; the first unfinished phase; and the current human authority. The
|
||||
launch context also carries the accepted research and its dead ends.
|
||||
|
||||
**SHA staleness renders itself.** Neither phase is reported as simply passed.
|
||||
Each says the checks passed at `d3acd4989a20` and are stale because the tree
|
||||
has moved to `8ea3c7d1ae8f`. That is half of the manual-verification rung
|
||||
observed without being asked for.
|
||||
|
||||
### The lever still loses a race, sometimes
|
||||
|
||||
`systemctl restart` alone leaves a five-second window in which the running
|
||||
worker's tick save can clobber the edit. It clobbered run 17's attempt and
|
||||
survived run 18's. Stop, edit, start is the reliable sequence, and it needs
|
||||
`systemctl stop` and `start` in the unattended sudo rule.
|
||||
|
||||
## Run 19, 2026-08-29: the manual verification seam, and F63
|
||||
|
||||
Two tasks, both ingested from Gitea so that comments reconcile:
|
||||
`06G4W2TMZ9MW9SEBM5MGTM5288` (issue 24, plan `500464b9`) and
|
||||
`06G4W63545T3RV8SSKGKN11B3G` (issue 26, plan `53f7918b`).
|
||||
|
||||
A burn-in task cannot test this rung. Comments reconcile only for the source
|
||||
the task came from, so a task created with `source: "burnin"` has no comment
|
||||
stream at all.
|
||||
|
||||
### Proven: the keyed seam
|
||||
|
||||
```text
|
||||
15:09:15 PlanPhaseVerified phase-1 awaiting_manual_verification at 80728967
|
||||
comment: "Looks good to me. Nice work on this one, ship it."
|
||||
17:10:03 HumanDecisionRecorded subject=operator_instruction
|
||||
phase-1 unchanged, still awaiting_manual_verification
|
||||
comment: "orchestra verify phase-1"
|
||||
17:10:25 HumanDecisionRecorded subject=plan_phase_verification:500464b9…:phase-1
|
||||
phase-1 verified
|
||||
```
|
||||
|
||||
All five phases reached `awaiting_manual_verification` on their automated
|
||||
checks alone. A generic approval satisfied nothing. The keyed form flipped
|
||||
exactly the phase it named, under the plan ref Orchestra supplied rather than
|
||||
one the commenter chose.
|
||||
|
||||
### Proven: re-verification at a new SHA
|
||||
|
||||
On issue 26, inside one live implement lease, with the worker stopped for the
|
||||
whole edit:
|
||||
|
||||
```text
|
||||
15:23:00 PlanPhaseVerified phase-1 awaiting_manual_verification at f5d8f90b
|
||||
operator commit moves the tree f5d8f90b -> 1f07b89c
|
||||
.orchestra/plan-progress.json requests phase-1 again
|
||||
15:23:17 PlanPhaseVerified phase-1 awaiting_manual_verification at 1f07b89c
|
||||
17:23:16 HumanDecisionRecorded subject=plan_phase_verification:53f7918b…:phase-1
|
||||
phase-1 verified at 1f07b89c
|
||||
```
|
||||
|
||||
The rerun ran the plan's own commands at the new tree and wrote a fresh record
|
||||
bound to it. The worker never reads a command out of the request: the request
|
||||
names a phase and nothing else about it is trusted.
|
||||
|
||||
### F63: a manual sign-off outlives the tree it was given against
|
||||
|
||||
The second half of the same rig, with no new comment:
|
||||
|
||||
```text
|
||||
operator commit moves the tree 1f07b89c -> 28af84ff
|
||||
.orchestra/plan-progress.json requests phase-1 again
|
||||
15:24:02 PlanPhaseVerified phase-1 verified at 28af84ff
|
||||
```
|
||||
|
||||
`RecordPlanPhaseVerification` consults `manuallySignedOff`
|
||||
(`internal/operations/planprogress.go:127,152`), which asks only whether a
|
||||
decision with that subject exists. It does, forever, for that plan ref and
|
||||
phase id. So the automated half of a phase is re-established at every new SHA
|
||||
while the manual half is asserted from a tree the human never saw.
|
||||
|
||||
This is the same class of bug `AtSHA` exists to prevent, applied to the half
|
||||
that `AtSHA` does not cover. A manual check on this project is a human reading
|
||||
printed output; an edit between the sign-off and the rerun can change exactly
|
||||
that output.
|
||||
|
||||
Only a replan clears it, because `PlanPhases()` drops records whose plan ref is
|
||||
not the accepted one.
|
||||
|
||||
Proposed shape, not built: when the reducer flips a record on a keyed decision
|
||||
(`internal/store/store.go:273`), it knows the record it satisfied and that
|
||||
record's `AtSHA`. Stamp that sha on the decision, and have `manuallySignedOff`
|
||||
require it to match the run being recorded. A sign-off that arrives before any
|
||||
run has no sha and keeps today's behaviour, so the ordinary ordering is
|
||||
unchanged.
|
||||
|
||||
### Also established
|
||||
|
||||
- `review -> implement` is legal in `legalPhaseTransitions`, but
|
||||
`AdvanceWorkPhase` only walks the project's path forward. A task that has
|
||||
left implement can return only through a review that requests changes, not
|
||||
through the operator phase endpoint.
|
||||
- The agent's re-verification request is a file an operator can write:
|
||||
`.orchestra/plan-progress.json`, `{"phase": "...", "status":
|
||||
"ready_for_verification"}`. It is the whole lever for this rung.
|
||||
- Ingest turns every issue label into a required capability, so a rig issue
|
||||
must carry no labels or it will not schedule.
|
||||
|
||||
## Run 20, 2026-08-29: the mismatch and replan ladder, and three defects
|
||||
|
||||
Task `06G4WJ9T4F35NZC4Z8QQXM9Z6G` (issue 29) on `de18f37`, plus a first attempt
|
||||
on `06G4WDJ06G7FK00QF73Z9DDBT4` (issue 27) on `c587f2c` that found F65.
|
||||
|
||||
The lever throughout is the agent's own file, written by the operator with the
|
||||
worker stopped: `.orchestra/plan-mismatch.json`, carrying `phase_id`,
|
||||
`observed`, `contradicts`, `evidence` and `requested_action`. The worker fills
|
||||
`plan_ref` and `at_sha` from what it can verify, never from the file.
|
||||
|
||||
### F65: a task that stops for a human never stopped
|
||||
|
||||
Found on the first attempt, live:
|
||||
|
||||
```text
|
||||
16:04:25 PlanMismatchRecorded phase-2 human_decision
|
||||
16:04:25 worker: plan mismatch refused: task version conflict
|
||||
16:04:48 PlanPhaseVerified phase-1 work continues as if nothing happened
|
||||
```
|
||||
|
||||
`Store.Append` fences every lifecycle event on a leased task against the
|
||||
current owner and epoch. `blockForPlanMismatch` carried neither `harness_id`
|
||||
nor `lease_epoch`, so the block was refused on every task that was actually
|
||||
running, which is every task that can produce a mismatch. The observation was
|
||||
durable and the transition silently did not happen: the repo's own documented
|
||||
failure shape. `raiseTrajectoryGate` had the identical omission, so the
|
||||
trajectory gate could never have stopped a live task either.
|
||||
|
||||
`RecordPlanMismatch` also returns that error after the record is already
|
||||
appended, so the worker told the agent its report was refused for a report
|
||||
Orchestra had accepted.
|
||||
|
||||
Fixed in `de18f37` with one `fenceToLease` helper, called by all three stops.
|
||||
The human-decision path already did it correctly and explained why in a
|
||||
comment; that comment is now the helper.
|
||||
|
||||
**Why no test caught it.** `planWith` never leased its task, so every
|
||||
plan-progress and plan-mismatch test ran in a state no agent can be in. The
|
||||
`lease` helper in the same package carries a comment warning about exactly
|
||||
that. It leases now, and the mismatch block test fails without the fence with
|
||||
the same `task version conflict`.
|
||||
|
||||
### Proven: human_decision stops, and the reply resumes the same plan
|
||||
|
||||
```text
|
||||
16:24:21 PlanMismatchRecorded phase-2 human_decision
|
||||
16:24:21 TaskBlocked plan_mismatch, lifecycle awaiting_human
|
||||
18:24:46 HumanDecisionRecorded operator_instruction (Gitea's timestamp)
|
||||
16:25:16 TaskCorrected resumed
|
||||
16:25:16 TaskLeased plan_ref unchanged at 0bc8ef84
|
||||
16:25:21 TaskPickupValidated
|
||||
```
|
||||
|
||||
55 seconds from block to resumed, on the same plan, with no reseal. The
|
||||
blocker packet states the observation, the plan's claim and the evidence.
|
||||
|
||||
This is also the live proof of F64: before `6cb2f93` nothing read a blocked
|
||||
task's comments, because the reconciler ran only before a lease and at a turn
|
||||
boundary, and a blocked task is never leased.
|
||||
|
||||
### Proven: replan keeps the old plan until a replacement seals
|
||||
|
||||
```text
|
||||
16:26:07 PlanMismatchRecorded phase-3 replan
|
||||
16:26:07 WorkPhaseChanged plan reopen=PlanMismatchRecorded
|
||||
during planning: plan_ref 0bc8ef84 accepted, phase-1 and phase-2 counted
|
||||
after the seal:
|
||||
plan_ref 922f293b
|
||||
plan_history [0bc8ef84]
|
||||
progress empty
|
||||
successor in implement: four phases, all "not started"
|
||||
```
|
||||
|
||||
The successor's launch context carries the replacement whole, with files,
|
||||
changes and verification per phase. The old progress stops counting because
|
||||
`PlanPhases()` drops records whose plan ref is not the accepted one. An
|
||||
abandoned replan would have cost nothing.
|
||||
|
||||
### F66: the planner replans blind
|
||||
|
||||
The planning session convened by a replan is given the ordinary plan brief and
|
||||
an empty template. Its launch context says nothing about the plan that already
|
||||
exists, the contradiction that reopened the phase, the phase id, the evidence,
|
||||
or the fact that its output supersedes an accepted plan. The only human input
|
||||
it carried was an unrelated earlier correction.
|
||||
|
||||
Everything it needs is already durable. `PlanMismatchRecorded` holds the phase,
|
||||
the observation, the contradiction and the evidence, and `reopenPhase` writes
|
||||
`reopen_phase_id` onto the `WorkPhaseChanged` payload. None of it reaches the
|
||||
agent, so nothing stops the replacement from sealing with the same
|
||||
contradiction in it.
|
||||
|
||||
### Smaller observation
|
||||
|
||||
The implement session kept working after the reopen. It verified phase-2
|
||||
against the old plan 40 seconds after the task had moved to `plan`. Harmless,
|
||||
because supersession discards it, but the reopen does not end the session that
|
||||
reported the contradiction. That session rotates at its own next boundary.
|
||||
|
||||
## Run 21, 2026-08-29: F66 proven, in two rounds
|
||||
|
||||
Tasks `06G4WW6TND26M16CZA6WE5T458` (issue 32, on `edf0076`) and
|
||||
`06G4XAFH1MBPC35VSJN7V3NS14` (issue 34, on `7539696`).
|
||||
|
||||
### Round one failed, and the failure was in the fix
|
||||
|
||||
`edf0076` projected the contradiction onto the task and rendered it into the
|
||||
plan-phase context. The projection appeared at the reopen and was gone before
|
||||
the planning session launched:
|
||||
|
||||
```text
|
||||
16:58:01 PlanMismatchRecorded phase-2 replan, plan reopened
|
||||
projection carries the contradiction
|
||||
16:58:20 TaskReleased the rotation the reopen causes
|
||||
projection empty
|
||||
16:59:29 replacement sealed, planner never told anything
|
||||
```
|
||||
|
||||
The clearing rule added with F67 sits **below** the reducer switch, so it runs
|
||||
for every event rather than for the correction it was written for. A release
|
||||
found the task unblocked and erased the contradiction. Two mistakes made it:
|
||||
the rule was written as if it were inside `case "TaskCorrected"`, which is
|
||||
merely the nearest case above it, and the unit test read the projection at the
|
||||
moment it was written rather than at the moment the planner reads it.
|
||||
|
||||
`7539696` scopes the clear to `TaskCorrected`, and the store test now walks the
|
||||
real sequence: mismatch, reopen, release, lease, then assert.
|
||||
|
||||
### Round two: the planner is told what it was convened to fix
|
||||
|
||||
```text
|
||||
18:00:17 PlanMismatchRecorded phase-2 replan, plan reopened
|
||||
projection survives the rotation
|
||||
18:01:02 planning session launches
|
||||
```
|
||||
|
||||
From that session's `.orchestra/launch.md`:
|
||||
|
||||
```text
|
||||
## Why this phase reopened
|
||||
|
||||
A plan was already accepted and the code contradicted it. Orchestra reopened
|
||||
this phase to settle that, and the session that found it is gone.
|
||||
|
||||
- phase: phase-2
|
||||
- observed: The summary lines are printed from the recorded results after
|
||||
every check has run, ...
|
||||
- the plan says: The plan states that phase 2 prints the header from a helper
|
||||
that returns the number of checks about to run.
|
||||
- evidence: scripts/orchestra_e2e_healthcheck.sh:1
|
||||
|
||||
The accepted plan stays accepted until you seal a replacement, and sealing one
|
||||
supersedes it along with every phase it had verified. Address the
|
||||
contradiction above: a replacement that repeats it will be contradicted again.
|
||||
```
|
||||
|
||||
It renders above the sealed research and plan, because it changes how they
|
||||
should be read. The first planning session of a task carries no such section,
|
||||
and neither does implement. Sealing the replacement (`2edcbe9e`) cleared the
|
||||
projection, which is the other end of its lifetime.
|
||||
|
||||
### The lesson worth keeping
|
||||
|
||||
Both rounds of this fix passed their unit tests. What separated them was where
|
||||
the assertion sat in the sequence. A projection written correctly and read one
|
||||
rotation later is not the same claim, and only the live run put the read where
|
||||
the agent does.
|
||||
|
||||
@@ -66,6 +66,14 @@ the live herdr instance and check.
|
||||
`docker logs orchestra-api`. **Deploying a code change means rebuilding the
|
||||
compose images** (`up -d --build`) — the running image can silently predate
|
||||
recent commits, so compare its build time against `git log`.
|
||||
- **A coordinator deploy does not deploy the console.** They are two images
|
||||
built from the same repo, and the usual `up -d --no-deps orchestra-api`
|
||||
leaves `orchestra-web-ui` on whatever it was. This has bitten twice: the
|
||||
ethos console landed in the repo on 2026-08-29 02:44 and was still serving a
|
||||
2026-07-30 image hours later, through two coordinator deploys. Rebuild it
|
||||
explicitly from the same clean worktree (`docker build` in `web/`, then
|
||||
`up -d --no-deps orchestra-web-ui`), and check the served bundle rather than
|
||||
the commit: `curl -s http://127.0.0.1:19145/assets/<css> | grep 8F7AE5`.
|
||||
- `orchestra.service` was the **previous** deployment; the unit file was
|
||||
deleted from `deploy/` on 2026-07-31 along with `redeploy.sh` (which
|
||||
`sudo install`ed to `/usr/local/bin` and restarted it). If a stale copy is
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
# Debt ledger: design from what the tree already supports
|
||||
|
||||
Written 2026-08-29 against `3c7cf95`. Read with `BURNIN.md` (the run ledger),
|
||||
`PLAN-SPEC-DESIGN.md` and `AUDIT.md`.
|
||||
|
||||
This is a design, not an implementation. Nothing here has been built.
|
||||
|
||||
## The problem being solved
|
||||
|
||||
AI raises implementation throughput faster than it raises integration, cleanup,
|
||||
observability, consolidation and architectural understanding. The residue is
|
||||
fixes, compatibility paths, operational workarounds, duplicate config, adapter
|
||||
gaps and one-off patches. Some are justified. Some become permanent because
|
||||
nobody remembers why they were added or what they have cost since.
|
||||
|
||||
The target is a first-class debt ledger driven by evidence from real tasks, not
|
||||
a second TODO list.
|
||||
|
||||
## Classes
|
||||
|
||||
Four, and no more. A fifth class invented at runtime makes the priority model
|
||||
meaningless.
|
||||
|
||||
| Class | Meaning |
|
||||
|---|---|
|
||||
| correctness | Known behaviour is wrong or violates an invariant. |
|
||||
| operational | The system works, but diagnosis, recovery, deployment, observability or operation repeatedly costs time. |
|
||||
| structural | Duplication or architecture is demonstrably raising the cost of future changes. |
|
||||
| polish | Cleanup or consistency work with no demonstrated cost yet. |
|
||||
|
||||
Correctness and operational debt gain priority quickly. Structural debt needs
|
||||
evidence that it is causing repeated work. Polish never becomes work on its own.
|
||||
|
||||
## 1. What Orchestra already has
|
||||
|
||||
The event log is the durable spine, and it already carries most of what a debt
|
||||
ledger needs. Every row below is mechanically countable today, with no new
|
||||
instrumentation.
|
||||
|
||||
| Evidence | Source | Carries |
|
||||
|---|---|---|
|
||||
| Failure shape | `TaskFailed`, `TaskBlocked` | 12 typed `block_reason` values, 6 `failure_class` values |
|
||||
| Retry cost | `Task.Attempt`, `NextRetryAt`, `FailureClass` | how many leases a task burned, durable in the projection |
|
||||
| Review findings | `ReviewRecorded` | `{ID, Severity, File, Line, Claim, Evidence}`, bound to a `ResultSHA` |
|
||||
| Out-of-scope discoveries | `DeferredFindingRecorded` | `{Summary, Why}`, already recorded outside agent context |
|
||||
| Plan contradicted by code | `PlanMismatchRecorded` | `{PlanRef, PhaseID, AtSHA, Observed, Contradicts, Evidence[]}` |
|
||||
| Verification history | `PlanPhaseVerified` | `{Commands, ExitCodes, AtSHA, EvidenceRef}` per phase |
|
||||
| Rework rounds | `TaskChangesRequested`, `TaskSubmitted` | how many times a change went back |
|
||||
| Provenance | `TaskCreated` | `Source`, `ExternalID`, `Parent` |
|
||||
|
||||
Three existing patterns matter more than the data.
|
||||
|
||||
**Global-subject events already work.** `QuotaReported` and `StandupAdvisory`
|
||||
use `TaskID: "system"` and are whitelisted in `internal/domain/domain.go:279`.
|
||||
A debt item needs no new subject mechanism.
|
||||
|
||||
**`GenerateStandupAdvisory` is the precedent for the shape.** It is a
|
||||
persisted, read-only recommendation, and applying it is a separate
|
||||
approval-gated operation. A debt ledger is that pattern with better inputs.
|
||||
|
||||
**`CheckSubmission` is the precedent for eligibility.** It is a pure function
|
||||
of task, commit and gate run, returning `{Eligible bool, Reasons []string}`.
|
||||
Debt eligibility should have the same shape.
|
||||
|
||||
## 2. What evidence is missing
|
||||
|
||||
**Worker observations are not durable.** This is the largest gap. The F18 ring
|
||||
lives in worker memory and reaches the coordinator inside `WorkerHealth` on
|
||||
heartbeat. `Registry.persistedState` holds captures, commands and workers only,
|
||||
with no health. A coordinator restart erases every observation.
|
||||
|
||||
Operational debt is exactly what that ring holds. Repeated 409s, unrenewed
|
||||
leases, adapter gaps.
|
||||
|
||||
**No operator-intervention record.** Every manual repair in run 12 and run 13
|
||||
left no trace in Orchestra. State-file edits, worker restarts, two manual
|
||||
transaction cleanups. "Required manual recovery" is the strongest priority
|
||||
signal available, and it is currently unrecorded.
|
||||
|
||||
**No diagnosis-cost signal.** Time spent diagnosing is not measured. The
|
||||
closest proxy is wall time in `blocked` or `needs_attention`.
|
||||
|
||||
**No component dimension.** Findings carry file paths. Tasks carry none.
|
||||
Breadth across components has to be derived from paths.
|
||||
|
||||
**No link from a repair commit back to what it repaired.** The Fxx-to-commit
|
||||
mapping exists only in `BURNIN.md` prose.
|
||||
|
||||
**Deferred findings are too thin.** `{Summary, Why}` has no class, no severity,
|
||||
no paths and no evidence refs.
|
||||
|
||||
## 3. Minimal durable data model
|
||||
|
||||
No new subsystem. Two event types on the existing spine, one projection beside
|
||||
`Task`.
|
||||
|
||||
```go
|
||||
// DebtObservation is one piece of counted evidence, always pointing at the
|
||||
// event that produced it. Provenance is an event id, never prose.
|
||||
type DebtObservation struct {
|
||||
// Exactly one of EventID and LegacyRef. See section 8.
|
||||
EventID string `json:"event_id,omitempty"`
|
||||
LegacyRef string `json:"legacy_ref,omitempty"`
|
||||
TaskID string `json:"task_id,omitempty"`
|
||||
Kind string `json:"kind"` // block_reason, failure_class, review_finding,
|
||||
// plan_mismatch, manual_intervention, deferred_finding
|
||||
Signature string `json:"signature"` // the mechanical dedup key, see section 4
|
||||
Detail string `json:"detail"`
|
||||
Paths []string `json:"paths,omitempty"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
type DebtItem struct {
|
||||
ID string `json:"id"` // continues the Fxx namespace
|
||||
Class DebtClass `json:"class"` // correctness, operational, structural, polish
|
||||
Status DebtStatus `json:"status"` // observed, eligible, scheduled, repaired, withdrawn
|
||||
Symptom string `json:"symptom"`
|
||||
Consequence string `json:"consequence"`
|
||||
Severity Severity `json:"severity"` // reuse review.Severity
|
||||
Paths []string `json:"paths,omitempty"`
|
||||
Signatures []string `json:"signatures"` // every key that attaches here
|
||||
Observations []DebtObservation `json:"observations"`
|
||||
IntroducedIn string `json:"introduced_in,omitempty"` // task id or commit
|
||||
RepairBoundary string `json:"repair_boundary,omitempty"`
|
||||
RepairTask string `json:"repair_task,omitempty"`
|
||||
RepairCommit string `json:"repair_commit,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
Every counted field the priority model needs is derived, never stored:
|
||||
|
||||
```text
|
||||
recurrence len(Observations)
|
||||
blocked_tasks distinct TaskID where Kind is block_reason
|
||||
manual_interventions count of Kind == manual_intervention
|
||||
breadth distinct components derived from Paths
|
||||
```
|
||||
|
||||
A stored count invites drift from the log. A derived one cannot drift.
|
||||
|
||||
Two events carry it. `DebtObserved` appends one observation. `DebtItemUpdated`
|
||||
records class, status, severity or a merge. Both need adding to the `allowed`
|
||||
map and a validator, which is the work every existing event type already did.
|
||||
|
||||
## 4. Deduplication without corrupting provenance
|
||||
|
||||
**A signature is computed from typed facts, never from prose.**
|
||||
|
||||
```text
|
||||
operational class + block_reason + harness + normalized component
|
||||
correctness class + failure_class + normalized component
|
||||
structural class + finding severity + normalized component
|
||||
```
|
||||
|
||||
Exact signature match attaches automatically. That is the only automatic path.
|
||||
|
||||
**Signatures are versioned, in the string itself.**
|
||||
|
||||
```text
|
||||
v1:operational:lease_not_renewed:workpc-opencode:federation
|
||||
```
|
||||
|
||||
Normalization rules will change. Without a version, changing them silently
|
||||
regroups every historical observation, and the recurrence counts that drive
|
||||
eligibility move underneath the items that already used them. A `v2` signature
|
||||
never matches a `v1` one, so old evidence keeps the grouping it was counted
|
||||
under.
|
||||
|
||||
**A model may propose, never merge.** Clustering emits `DebtMergeSuggested`
|
||||
carrying both item ids and its reasoning. The merge is an operator action or a
|
||||
policy threshold, recorded as `DebtItemUpdated`.
|
||||
|
||||
**Merges are additive.** The surviving item gains the other's signatures and
|
||||
observations. The absorbed item becomes `withdrawn` with a pointer, and its
|
||||
observations keep their original event ids. Nothing is rewritten, so a bad
|
||||
merge is reversible by reading the log.
|
||||
|
||||
**Every observation names its event.** A debt item's evidence is always
|
||||
checkable against the log that produced it. That rule is what stops a fuzzy
|
||||
proposal from becoming an unverifiable claim.
|
||||
|
||||
## 5. Eligibility policy
|
||||
|
||||
A pure function, mirroring `CheckSubmission`.
|
||||
|
||||
```go
|
||||
func CheckDebtEligibility(item DebtItem) DebtCheck // {Eligible bool, Reasons []string}
|
||||
```
|
||||
|
||||
| Class | Becomes eligible when |
|
||||
|---|---|
|
||||
| correctness | first confirmed observation, always |
|
||||
| operational | recurrence >= 3 across >= 2 distinct tasks, or >= 1 manual intervention |
|
||||
| structural | >= 3 review findings, or >= 2 tasks blocked or reworked in the same component |
|
||||
| polish | never automatically, operator promotion only |
|
||||
|
||||
`Reasons` lists what fired and what did not. "Not eligible" alone sends an
|
||||
operator reading code, which is the mistake `SubmissionCheck` already documents.
|
||||
|
||||
Eligible means a task may be created. It does not create one.
|
||||
|
||||
## 6. How maintenance and consolidation fit the existing lifecycle
|
||||
|
||||
A maintenance task is an ordinary task. Source `debt`, external id the debt item
|
||||
id, phases as usual.
|
||||
|
||||
```text
|
||||
research → plan → implement → verification → review → submission
|
||||
```
|
||||
|
||||
Nothing in the lifecycle changes. One extension is needed: the task's brief must
|
||||
carry the debt item's evidence. That is the mechanism research and plan
|
||||
artifacts already use, a CAS ref on the task.
|
||||
|
||||
A consolidation task is the same thing with a research brief that asks:
|
||||
|
||||
- which temporary paths are still necessary?
|
||||
- which fixes now duplicate each other?
|
||||
- which compatibility branches are obsolete?
|
||||
- which config or state is represented twice?
|
||||
- which abstractions exist only because of defects that have since disappeared?
|
||||
- what can now be deleted safely?
|
||||
|
||||
Its first output is a deletion plan, never an automatic refactor. Verification
|
||||
uses the project's existing policy. Review is the normal independent review.
|
||||
|
||||
**The repair gets no discount.** Classification as debt changes what gets
|
||||
scheduled, never what gets checked.
|
||||
|
||||
### Success metrics for maintenance work
|
||||
|
||||
Lines of code are not a metric. Prefer evidence that can be checked:
|
||||
|
||||
- behaviour preserved
|
||||
- tests preserved or strengthened
|
||||
- branches removed
|
||||
- obsolete types removed
|
||||
- compatibility code removed
|
||||
- duplicate config removed
|
||||
- manual recovery paths eliminated
|
||||
|
||||
## 7. What must not be automated
|
||||
|
||||
- No autonomous cleanup agent, and no repo-wide sweep.
|
||||
- No LLM score treated as authoritative. Counted facts decide, models propose.
|
||||
- No automatic merge of debt items.
|
||||
- No maintenance task from polish without an operator.
|
||||
- No deletion without an independent review.
|
||||
- No auto-closing an item because a commit touched the file. Closure needs a
|
||||
repair task or a stated run of clean evidence.
|
||||
- No new classes invented at runtime.
|
||||
|
||||
## 8. Migration, so there is one track and not two
|
||||
|
||||
`BURNIN.md` holds 58 Fxx entries in the form `| F52 | commit | prose |`.
|
||||
`AUDIT.md` holds the older narrative.
|
||||
|
||||
One-time import, by hand, with a class assigned per row. Each becomes a
|
||||
`DebtItem` with `Status: repaired`, its commit as `RepairCommit`, and one
|
||||
observation citing the ledger.
|
||||
|
||||
**Imported observations carry legacy provenance, never a fabricated event id.**
|
||||
The Fxx history predates the capability that would have produced an event, and
|
||||
inventing one would break the provenance rule the ledger exists to enforce.
|
||||
|
||||
```go
|
||||
EventID string `json:"event_id,omitempty"`
|
||||
LegacyRef string `json:"legacy_ref,omitempty"` // "BURNIN.md:F18"
|
||||
```
|
||||
|
||||
Exactly one is required. A reader can then tell a counted fact from an imported
|
||||
claim at a glance, and the counts that drive eligibility can exclude imported
|
||||
evidence if that turns out to matter.
|
||||
|
||||
**Keep the namespace.** New items continue at F62. Two numbering schemes would
|
||||
be the first structural debt the ledger itself creates.
|
||||
|
||||
After import, `BURNIN.md` stops being the item ledger and remains the run
|
||||
narrative. The defect table becomes generated output from the projection.
|
||||
|
||||
### The history that tests the model
|
||||
|
||||
These are not special cases. They are the check on whether the model can
|
||||
represent the history this project already has.
|
||||
|
||||
| History | Expected class | Why |
|
||||
|---|---|---|
|
||||
| F18, the single `last_error` slot | operational | Repeatedly destroyed causal evidence before it was fixed. |
|
||||
| F43 to F46, the submission path | correctness | Apparently implemented, actually unreachable. |
|
||||
| F61, planner learns policy by refusal | operational | Costs a round trip per plan, and becomes model compatibility on weaker planners. |
|
||||
| Duplicated `quality_gate` config | structural | Only if it keeps causing drift or operator mistakes. |
|
||||
| Old blocked burn-in tasks | neither | Hygiene, unless one exposes a runtime defect. |
|
||||
| The blocked release loop, F57 to F60 | correctness and operational | Wrong behaviour, and it required manual cleanup twice. |
|
||||
|
||||
## 9. Smallest slice to live-prove first
|
||||
|
||||
**A read-only projection over the existing log. No new events, no writes, no
|
||||
schema commitment.**
|
||||
|
||||
```text
|
||||
internal/domain/debt.go DebtItem, DebtObservation, signature, classification
|
||||
internal/store/debt_projection.go the fold over s.Events(0), and the gap report
|
||||
internal/operations/debt.go CheckDebtEligibility, and later the actions
|
||||
GET /v1/debt read-only, tui surface
|
||||
```
|
||||
|
||||
The projected type stays out of `operations`. Baking a read model into the
|
||||
command layer in the first slice is the mistake that would be hardest to undo
|
||||
later. `operations` owns actions: `CheckDebtEligibility` now, and
|
||||
`SuggestDebtMerge`, `PromoteDebtItem` and `ScheduleDebtRepair` when they exist.
|
||||
|
||||
The proof is what the projection can and cannot recover from canonical history.
|
||||
Section 2 already says worker observations are not durable and operator
|
||||
interventions are unrecorded, so demanding their reconstruction would be asking
|
||||
the projection to invent evidence.
|
||||
|
||||
The first burn-in is therefore three requirements, not one:
|
||||
|
||||
1. Recover every debt signal that canonical history actually encodes.
|
||||
2. Report, explicitly and per kind, where known debt cannot be reconstructed.
|
||||
3. Never infer a missing observation from `BURNIN.md`.
|
||||
|
||||
Incompleteness is part of the result, not a failure. The 409 loop lived in the
|
||||
F18 worker ring rather than in an event, so the ledger should say it has no
|
||||
durable evidence for that shape. That statement is what makes slice two
|
||||
necessary, measurably rather than by assertion.
|
||||
|
||||
What the projection should recover from the log alone:
|
||||
|
||||
- repeated `lease_expired` and `retry_limit` on one harness, which is the
|
||||
opencode failure shape across four tasks
|
||||
- the retry-idleness dynamic, attached to the tasks that expired
|
||||
- review findings grouped by component, if any repeat
|
||||
|
||||
A model that cannot represent debt already known is wrong, and no schema has
|
||||
been committed to yet. That is the cheapest place to find out.
|
||||
|
||||
**Slice two** makes worker observations durable, because that is the input the
|
||||
projection will most visibly lack.
|
||||
|
||||
**Slice three** adds `DebtObserved` and manual-intervention recording.
|
||||
|
||||
## Scope boundary
|
||||
|
||||
This design changes no part of the task lifecycle, phases, leases, review,
|
||||
submission, federation or the plan machinery. The only extensions it needs are
|
||||
two event types, one projection, one pure eligibility function and one
|
||||
read-only endpoint.
|
||||
|
||||
---
|
||||
|
||||
# Slice one, run against real history
|
||||
|
||||
Built and deployed as `a757cff`. `GET /v1/debt` folded 881 events from the
|
||||
live log, wrote nothing, and produced 15 candidate items and 3 gaps.
|
||||
|
||||
## What it recovered
|
||||
|
||||
```text
|
||||
v1:operational:lease_expired:workpc-opencode:lease r=41 tasks=4
|
||||
v1:operational:lease_expired:workpc-claude:lease r=29 tasks=13
|
||||
v1:operational:lease_failure:-:lease r=20 tasks=16
|
||||
v1:operational:system_error:-:lease r=11 tasks=7
|
||||
v1:correctness:handoff_validation:-:lease r=5 tasks=5
|
||||
v1:correctness:plan_mismatch:-:... r=1 tasks=1
|
||||
v1:structural:minor:-:scripts/orchestra_e2e_healthcheck.sh r=1 tasks=1
|
||||
```
|
||||
|
||||
The opencode failure shape is the top item, found mechanically. Run 14
|
||||
diagnosed the same thing by hand from a pane capture. The retry-idleness
|
||||
dynamic is the second item, and it is now a number: 29 expiries across 13
|
||||
tasks on one harness.
|
||||
|
||||
Seven items are eligible under the stated policy. Polish and the single
|
||||
structural finding correctly are not.
|
||||
|
||||
## What it reported that it could not see
|
||||
|
||||
```text
|
||||
durable=false manual_intervention no event type records an operator repair
|
||||
durable=false worker_observation worker health is not persisted
|
||||
durable=true deferred_finding carried by the log, this history has none
|
||||
```
|
||||
|
||||
The 409 release loop does not appear, and it should not. That evidence lived in
|
||||
the F18 worker ring, which no event carries. The ledger says so rather than
|
||||
inferring it, which is the result slice two exists to change.
|
||||
|
||||
## Four defects the first run exposed
|
||||
|
||||
**The component part is too coarse for lease evidence.** Everything
|
||||
lease-related normalizes to `lease`, so `lease_expired:workpc-claude` is one
|
||||
bucket holding idle agents, rig interference and real failures. Recurrence 29
|
||||
is true and the item is not a defect.
|
||||
|
||||
**`harness` is often empty on block reasons.** `TaskBlocked` payloads do not
|
||||
always carry `harness_id`, so `lease_failure:-` mixes harnesses that should be
|
||||
separate items.
|
||||
|
||||
**Path normalization mangled a mismatch reference.**
|
||||
`scripts/orchestra_e2e_healthcheck.sh:12` became
|
||||
`scripts/orchestra_e2e_healthcheck.sh_12`, because the signature sanitizer
|
||||
replaces `:` and the mismatch evidence field holds prose, not clean paths.
|
||||
|
||||
**Recurrence alone is the wrong sort.** 41 occurrences on 4 tasks currently
|
||||
outranks 29 on 13 tasks. Breadth is in the design and not yet in the ordering,
|
||||
which is the priority function slice one deliberately omitted.
|
||||
|
||||
None of these required a schema commitment to discover. That was the point of
|
||||
making the first slice read-only.
|
||||
|
||||
## The invariant slice B was written against
|
||||
|
||||
**A projection must never manufacture provenance to make evidence easier to
|
||||
classify.** `task=None` is better than a confident lie.
|
||||
|
||||
Both defects the first live run of durable observations exposed were failures
|
||||
of exactly this rule, and both looked perfectly reasonable in code:
|
||||
|
||||
- **False attribution.** The worker's ring outlives the work it describes, so
|
||||
binding its entries to whatever task the worker is running now produced a
|
||||
clean, well-formed, wrong association between an old failure and an unrelated
|
||||
task. The task is read from the observation itself, and only an observation
|
||||
that names no task belongs to the current lease.
|
||||
- **Manufactured recurrence.** Treating "still present in the ring" as "it
|
||||
happened again" turned one failure into four incidents. Presence is not
|
||||
occurrence: an incident opens only when the entry advances past what has
|
||||
already been accounted for, and that high-water mark survives the close.
|
||||
|
||||
Either one would have corrupted the ledger while every number in it stayed
|
||||
plausible, which is the specific way this repo's bugs have always presented.
|
||||
|
||||
## Signatures are frozen at write time
|
||||
|
||||
The signature is computed when an incident is recorded and stored in the event.
|
||||
Re-projecting an old log with a newer normalizer therefore changes nothing:
|
||||
running one history through the build before and after the pane-name fix
|
||||
produced identical output.
|
||||
|
||||
That is the intended behavior. An event must not silently change meaning
|
||||
because normalization code changed. Repairing historical signatures is an
|
||||
explicit migration or reclassification event, never a different projection
|
||||
result from the same log.
|
||||
|
||||
## What counts as a manual intervention
|
||||
|
||||
An intervention is an operator action required to recover, repair, unblock or
|
||||
correct behaviour that should otherwise have proceeded autonomously. The
|
||||
ledger measures what the system costs to keep running, so routine operation
|
||||
does not belong in it.
|
||||
|
||||
Counts:
|
||||
|
||||
```text
|
||||
transaction_cleanup
|
||||
forced_release
|
||||
state_repair
|
||||
manual_requeue when recovery failed and a human had to requeue
|
||||
manual_phase_recovery
|
||||
worker_restart only when restarting is itself the repair
|
||||
```
|
||||
|
||||
Does not count:
|
||||
|
||||
```text
|
||||
deploy restart
|
||||
planned upgrade
|
||||
configuration rollout
|
||||
normal shutdown and start
|
||||
deliberate burn-in setup
|
||||
```
|
||||
|
||||
The distinction is policy rather than schema. `worker_restart` is the one kind
|
||||
that spans both sides, and it stays a single kind until someone actually
|
||||
misuses it; a field added before the confusion exists is a guess about how it
|
||||
will be misread.
|
||||
|
||||
The first live consequence: the deploy restart of `79d2053` was not recorded,
|
||||
and `manual_intervention` stayed in the gap list afterwards. That is the
|
||||
correct result. Orchestra can record the evidence and this history contains
|
||||
none, which is an honest gap rather than synthetic evidence.
|
||||
@@ -0,0 +1,349 @@
|
||||
# Handoff: the release path is settled, and the UI became a truth detector
|
||||
|
||||
Written 2026-08-29, 14:20 local (10:20 UTC). Read with `BURNIN.md` (the run
|
||||
ledger, current through run 16), `DEBT-DESIGN.md`, `PLAN-SPEC-DESIGN.md`,
|
||||
`AUDIT.md` and `CLAUDE.md`. The previous handoff is
|
||||
`HANDOFF-2026-08-28-plan-v1.md`.
|
||||
|
||||
Everything below was observed live unless it says otherwise.
|
||||
|
||||
## The headline
|
||||
|
||||
**The expired-release defect is fixed and proven both ways.** The whole family
|
||||
around it is closed. F57 through F60 settle what happens to a release
|
||||
transaction in every case. That includes the ones that used to need an operator
|
||||
with a text editor.
|
||||
|
||||
**Three new things exist that did not before.** A bounded observation ring on
|
||||
worker health, closing F18. A read-only debt ledger projected from the event
|
||||
log. An operator console rebuilt on the ethos design system.
|
||||
|
||||
**The UI turned out to be a truth detector.** Nine screens were built against
|
||||
real endpoints. They found four places where Orchestra has no capability to
|
||||
support the intended interface. That list is the most valuable output of the
|
||||
session.
|
||||
|
||||
```text
|
||||
orchestra-plan-v1 plan machinery proven
|
||||
↓
|
||||
orchestra-f18-baseline d6ee10f, the bounded observation ring
|
||||
↓ 6 commits
|
||||
34f3c28 deployed now: release path settled, debt ledger, new UI
|
||||
```
|
||||
|
||||
## Deployed state
|
||||
|
||||
| Half | Revision |
|
||||
|---|---|
|
||||
| Coordinator, homesrv container | `34f3c28` |
|
||||
| Worker, workpc systemd | `34f3c28` |
|
||||
|
||||
```text
|
||||
commit 34f3c2888fc7d45d190d93e1ea42501b6cd3e474
|
||||
coordinator sha256 1d32d83ec859b36e12473fab01bbfc3c769b97b4f6ae4db2841db5161e0eda19
|
||||
worker sha256 0e3877321dea8a1eeda51ccc6f3ead1a14aa5f5cae4d95b704f61c364248ec65
|
||||
```
|
||||
|
||||
`cbd6b11` is one commit above and is documentation only. Three commits are
|
||||
unpushed.
|
||||
|
||||
**Worker installs no longer need a human.** The operator installed the
|
||||
`/etc/sudoers.d` line, so `sudo -n install …` and
|
||||
`sudo -n systemctl restart orchestra-worker` both work unattended. Verify the
|
||||
running revision from the journal, never the installed file.
|
||||
|
||||
## The defects fixed, and how each was found
|
||||
|
||||
Not one came from reading code. Every one came from a live run failing.
|
||||
|
||||
| Id | Commit | What |
|
||||
|---|---|---|
|
||||
| F57 | `6565b9f` | An expired lease could never commit the anchor it had already pushed. The worker sent an epoch the expiry replay had deleted, and the coordinator refused any handoff without a live lease. The epoch now belongs to the transaction, `TaskReleased` retains the ending epoch, and `lateHandoffAccepted` lets exactly that owner commit while the task is queued and unleased. |
|
||||
| F58 | `03663f4` | A superseded transaction retried a permanent 409 forever, holding the pane and pinning `ActiveTask`. Run 10's task did it for seven hours. `TaskLeased` now abandons a transaction whose id the lease does not carry. |
|
||||
| F59 | `8e37989` | F58 fires on `TaskLeased`, and a failed task is never leased again. `TaskFailed` now drops the transaction too. |
|
||||
| F60 | `3c7cf95` | The general rule the other two were reaching for. Terminal is failed or completed. Blocked keeps the transaction, because a reopen can still commit it, so `TaskBlocked` now retains the ending epoch as well. A refusal parks the commit for 30s backing off to 5 minutes, and any event about the task un-parks it. A transport failure is not an answer and retries at once. |
|
||||
| F18 | `d6ee10f` | The single `last_error` slot. Worker health now carries up to sixteen distinct observations with repeat counts and first/last times, collapsing by message rather than by position. |
|
||||
|
||||
### The rig that proved F57, and the guard
|
||||
|
||||
```text
|
||||
19:00:10.742 transaction opens at prepared, anchor pushing
|
||||
19:00:10.727 TaskReleased v15 reason=lease_expired surface=tui
|
||||
19:00:11.662 TaskReleased v16 the late commit, accepted 935ms after the lease died
|
||||
19:00:11.665 TaskLeased v17 successor picks up the handoff
|
||||
19:00:15.524 TaskPickupValidated v18
|
||||
```
|
||||
|
||||
Race guard, next boundary: force the expiry, then lease the task to a probe
|
||||
harness before the push finishes. The late commit is refused, no handoff is
|
||||
written, and the successor's lease stands.
|
||||
|
||||
**The rig technique matters more than the rig.** Suspending the worker cannot
|
||||
produce this ordering. The event replay runs at the top of every tick and
|
||||
discards the transaction. The ordering exists only inside one call:
|
||||
transaction opened, anchor pushing, commit not yet sent. So poll the worker
|
||||
state file at 2ms and fire `POST /v1/tasks/<id>/release` the instant a
|
||||
transaction appears at `prepared`.
|
||||
|
||||
**A named probe harness owns a lease without starting an agent.**
|
||||
`race-guard-probe` never picks anything up and expires on the normal TTL.
|
||||
|
||||
## Corrections to the previous handoff
|
||||
|
||||
**The operator lifecycle actions do not lose a version race.** On a leased
|
||||
task, `block`, `release` and `attention` are refused by
|
||||
`internal/store/store.go:885-901` when the payload omits `harness_id` and
|
||||
`lease_epoch`. Ten attempts in 550ms all failed that way. Send both fencing
|
||||
fields and they succeed on the first try.
|
||||
|
||||
**The OpenCode Zen free tier is not blocked.** The selected model was.
|
||||
|
||||
## The debt ledger
|
||||
|
||||
`DEBT-DESIGN.md` answers nine design questions and carries four amendments the
|
||||
operator made. Slice one is built, deployed and run.
|
||||
|
||||
**Slice one writes nothing.** A read-only projection over the existing log,
|
||||
plus a pure eligibility function and `GET /v1/debt`. It folded 881 events and
|
||||
produced 15 candidates and 3 gaps.
|
||||
|
||||
```text
|
||||
v1:operational:lease_expired:workpc-opencode:lease r=41 tasks=4
|
||||
v1:operational:lease_expired:workpc-claude:lease r=29 tasks=13
|
||||
v1:operational:lease_failure:-:lease r=20 tasks=16
|
||||
v1:correctness:handoff_validation:-:lease r=5 tasks=5
|
||||
```
|
||||
|
||||
The opencode failure shape is the top item, found mechanically. Run 14 reached
|
||||
the same conclusion by hand from a pane capture.
|
||||
|
||||
**It reported what it cannot see, which was the point.** The 409 release loop
|
||||
does not appear. That evidence lived in the F18 worker ring, and no event
|
||||
carries it. Manual interventions are a non-durable gap for the same reason.
|
||||
|
||||
**The first run exposed four defects in the model**, all recorded at the end of
|
||||
`DEBT-DESIGN.md`:
|
||||
|
||||
- the component part is too coarse for lease evidence
|
||||
- `harness` is often empty on block reasons
|
||||
- path normalization mangled a mismatch reference
|
||||
- recurrence alone is the wrong sort order
|
||||
|
||||
Layout, so the read model does not end up in the command layer:
|
||||
|
||||
```text
|
||||
internal/domain/debt.go types, signature, classification
|
||||
internal/store/debt_projection.go the fold, and the gap report
|
||||
internal/operations/debt.go CheckDebtEligibility
|
||||
```
|
||||
|
||||
## The operator console
|
||||
|
||||
Nine screens on the ethos system, signal violet `#8F7AE5`, routing fork motif.
|
||||
Each screen was built by its own agent against a foundation with one author.
|
||||
The shell, tokens and primitives could not drift into nine dialects.
|
||||
|
||||
**Render before signing off.** Three bugs existed that no computed value would
|
||||
have caught. All three came from looking at a screenshot:
|
||||
|
||||
- The previous stylesheet fought every shared class name and leaked properties
|
||||
the new rules never mention, which is how `position: fixed` survived on
|
||||
`.topbar`. It is now scoped under `.legacy` and reaches only the login route.
|
||||
That also stops its green accent and its `backdrop-filter` from reaching the
|
||||
console.
|
||||
- Go marshals a zero `time.Time` as `0001-01-01T00:00:00Z` and `omitempty` does
|
||||
not omit a struct, so absent timestamps arrived populated-looking and
|
||||
rendered as `739855d ago`. Stripped once in `client.ts`, with a test.
|
||||
- Long machine ids overflowed their cards and painted under the next one.
|
||||
|
||||
Chromium is installed at `/usr/bin/chromium`. To see a screen without a live
|
||||
session, write a throwaway harness that stubs `window.fetch` and renders
|
||||
`<Console>` inside a `MemoryRouter`, served by vite on a spare port. Note that
|
||||
`npx` and `./node_modules/.bin/*` do not work on this filesystem: call
|
||||
`node ./node_modules/vite/bin/vite.js` directly.
|
||||
|
||||
## What the UI proved Orchestra cannot do
|
||||
|
||||
This is the part worth acting on. Each screen refused to fake something, and
|
||||
the refusals name real capability gaps.
|
||||
|
||||
| Gap | Evidence |
|
||||
|---|---|
|
||||
| **No web-facing human-decision write path** | `Steer / Correct` is disabled. `internal/ui/ui.go`'s action switch has grant/deny approval, resubmit, handoff, release, block and complete, and nothing writes `HumanDecisionRecorded`. The spec makes steering the primary action of the task detail screen. |
|
||||
| **No keystroke forwarding** | `Take control` is disabled. Only resubmit and approval grant/deny reach a live pane. |
|
||||
| **Context occupancy is trapped in herdr** | Three screens independently hit it. No projection carries it. |
|
||||
| **Project configuration is not served** | Repo, remote, quality gate and verification policy live only in `config.jsonc`. The projects screen can show none of it. |
|
||||
| **The web cannot request a handoff for a federated task** | `RequestHandoff` needs a local coordinator and answers 503. That is the Design A guardrail working. |
|
||||
|
||||
The operator's direction on these. Treat first-class direct human input as the
|
||||
highest-value backend feature. Build it as `POST /v1/tasks/<id>/decisions`,
|
||||
using the same durable decision semantics as Gitea comments, so Gitea, CLI and
|
||||
web converge on one `HumanDecisionRecorded`. Keep take-control disabled,
|
||||
because arbitrary pane input bypasses the durable authority model. Expose
|
||||
occupancy through a session health projection rather than teaching the web
|
||||
server about herdr. Add a read-only effective project configuration endpoint,
|
||||
which F61 will also need.
|
||||
|
||||
## Where the plan-machinery ladder stands
|
||||
|
||||
Proven in run 14: the worker executes the **sealed plan's** commands rather
|
||||
than the request's, and every `PlanPhaseVerified` binds `plan_ref`, `phase_id`,
|
||||
`at_sha`, `evidence_ref`, `lease_epoch` and `harness_id`.
|
||||
|
||||
Proven in run 16: a successor inherits the **whole sealed plan**, all phases
|
||||
with their files, changes, verification, and the research citations.
|
||||
|
||||
**Still unproven, and the next runtime item:**
|
||||
|
||||
```text
|
||||
mid-implement rotation
|
||||
→ successor picks up in implement
|
||||
→ launch context states which phases are already verified
|
||||
|
||||
manual verification
|
||||
→ SHA goes stale
|
||||
→ re-verification
|
||||
|
||||
plan mismatch
|
||||
→ human decision
|
||||
→ real replan, old plan retained, replacement launched
|
||||
```
|
||||
|
||||
Two things make the first one hard, and both are now known:
|
||||
|
||||
- **A trivial task spends about four minutes in implement**, and verifies every
|
||||
phase against one tree near the end. The implementer writes the whole change
|
||||
first, then verifies each phase in turn. Use a task whose implement phase
|
||||
genuinely runs long.
|
||||
- **A state-file edit does not survive a running worker.** It holds sessions in
|
||||
memory and writes them back. Suspend, edit, then `sudo systemctl restart`.
|
||||
Resuming lets the old copy win. Setting `handoff_requested` on the session is
|
||||
the production rotation lever.
|
||||
|
||||
Progress is withheld from a **review** successor on purpose
|
||||
(`internal/agentctx/agentctx.go:321`), because an independent review must
|
||||
reconstruct the change from the diff. That absence is not the defect.
|
||||
|
||||
## F61 and F62, recorded and not built
|
||||
|
||||
**F61: the planner learns the verification policy by refusal.** The brief says
|
||||
a policy exists, not what is in it. Every plan therefore pays one refused round
|
||||
trip. The recovery loop works: run 14's planner consumed the refusal and
|
||||
resealed 31 seconds later. It is not a one-liner, because `agentctx.Input.Policy` is filled
|
||||
from the worker's `SafeOperations` while the verification policy is
|
||||
coordinator-side. The reason to promote it later is local models, which may
|
||||
propose forbidden commands repeatedly because they cannot infer the allowed
|
||||
substitute.
|
||||
|
||||
**F62: a requested handoff nobody answers is invisible.** The rotation is
|
||||
agent-driven. In run 16 the agent never wrote `HANDOFF.md`, renewals stopped,
|
||||
the lease expired, and the task lost an attempt. Worker health recorded only
|
||||
`agent status idle and pane unchanged`, 34 times. There is no timeout, no
|
||||
retry, and no observation saying a handoff was requested and left unanswered.
|
||||
The expiry is indistinguishable from an ordinary idle one.
|
||||
|
||||
## Live state
|
||||
|
||||
Three pull requests are open and unreviewed:
|
||||
|
||||
```text
|
||||
06G4M6HF1Z3EREX1X3NEKSHP24 pulls/18
|
||||
06G4M8WHGQ4P3GQMPEEH0RJRHM pulls/19
|
||||
06G4SWEVP71FYYKAV5FV0ZK5ZG pulls/20
|
||||
```
|
||||
|
||||
Both workers are online on `34f3c28` with no release transactions and no
|
||||
sessions. 29 blocked `test-e2e` tasks are burn-in debris. Three `correx` tasks
|
||||
are queued and unschedulable, because `correx` has no entry in the
|
||||
coordinator's `config.jsonc`.
|
||||
|
||||
**opencode now runs.** The model was the problem, and `hy3-free` works. It then
|
||||
stops on a permission prompt, because `~/.config/opencode/opencode.jsonc` sets
|
||||
`"bash": "ask"`. Orchestra can answer that exact dialog, but only when an
|
||||
operator queues `grant_approval`, so unattended work stalls on the first
|
||||
command. Set `"bash": "allow"` for unattended runs. That file also has no
|
||||
top-level `model` key, so OpenCode picks whatever sits at the top of
|
||||
`~/.local/state/opencode/model.json`, which any manual pick silently changes.
|
||||
|
||||
**The opencode adapter is now a debt item, not a curiosity.** It cannot resolve
|
||||
a session file. Activity therefore reads `unknown`, and the worker cannot tell
|
||||
finished from never-started. The debt projection surfaced it independently as
|
||||
the top recurring operational item.
|
||||
|
||||
## Things that will bite
|
||||
|
||||
- **Background python tasks get killed here.** Three watchers died before doing
|
||||
anything. Foreground polling and the `Monitor` tool both work.
|
||||
- **`/v1/events` is one line of JSON.** A `grep` for two substrings matches
|
||||
across unrelated tasks. Parse it.
|
||||
- **`npx` and `./node_modules/.bin/*` fail on this filesystem.** Call node
|
||||
directly.
|
||||
- **`rm` and `cp` are interactive.** Use `/bin/rm -f` and `install`.
|
||||
- **Secrets are guarded.** Expand a token inside the container in one remote
|
||||
command:
|
||||
`T=$(docker exec orchestra-api printenv ORCHESTRA_TUI_TOKEN); curl -s -H "X-Orchestra-Surface: tui" -H "Authorization: Bearer $T" ...`
|
||||
- **Rebuild the coordinator from a detached worktree**, and split the worktree
|
||||
add, the docker build and the compose up into separate commands.
|
||||
- **`deploy/build.sh` builds both halves from one commit with one stamp.** Use
|
||||
it.
|
||||
- **Geist is not on disk.** Both stacks fall back to system faces, and the
|
||||
ethos threat model rules out the font CDN.
|
||||
|
||||
## Resume in this order
|
||||
|
||||
Set by the operator at the session boundary. Do not rebuild it from the commits.
|
||||
|
||||
1. **F62 first.** Make a requested-but-unanswered handoff visible and bounded.
|
||||
Preserve the lease while Orchestra is explicitly waiting, retry the confirmed
|
||||
request, then emit a causal timeout. Today it ends as generic idleness.
|
||||
2. **Repeat the implement-successor rung** with a deliberately longer task. Get
|
||||
at least one `PlanPhaseVerified`, force the handoff while still in
|
||||
`implement`, and prove the successor sees the complete accepted plan, the
|
||||
verified previous phase, the first unfinished phase, and the current human
|
||||
authority.
|
||||
3. **Finish the remaining ladder.** Manual verification, SHA staleness and
|
||||
reverification, human-decision mismatch, then a real replan with old-plan
|
||||
provenance.
|
||||
4. **Use the capability table above as backend work discovery.** Do not add fake
|
||||
controls. Each disabled action is concrete evidence of a missing capability.
|
||||
5. **Continue the debt slices independently.** Durable worker observations and
|
||||
manual-intervention events are the next evidence gaps. Not automatic
|
||||
maintenance yet.
|
||||
|
||||
## What the tag means
|
||||
|
||||
```text
|
||||
orchestra-release-v1 -> 34f3c28
|
||||
deployed, settled lifecycle plus the truth-detector UI baseline
|
||||
|
||||
later HEADs
|
||||
experimental plan, debt and runtime work that must earn their own
|
||||
release proof
|
||||
```
|
||||
|
||||
Use that distinction. A defect found in experimental work is not a reason to
|
||||
reopen settled architecture.
|
||||
|
||||
## The roadmap the operator set
|
||||
|
||||
```text
|
||||
A. runtime correctness finish the plan-machinery live proof
|
||||
B. evidence and debt durable worker observations, manual-intervention
|
||||
events, then rerun the projection
|
||||
C. operator surface first-class HumanDecision write API,
|
||||
effective project-config read API,
|
||||
session and context health projection
|
||||
D. adapter fix opencode activity and session resolution
|
||||
E. UI wire capabilities as backend support becomes real
|
||||
```
|
||||
|
||||
The framing that ties them together is the thing to keep. The UI says what
|
||||
Orchestra cannot expose or control. The debt ledger says which of those
|
||||
shortcomings repeatedly costs something. The burn-in says which runtime
|
||||
semantics are reliable. Those three decide what gets built next.
|
||||
|
||||
One design question to settle before B's slice two writes any code. The worker
|
||||
ring is bounded and lossy by construction. Ingesting it durably means deciding
|
||||
whether the coordinator stores every observation as an event, or only
|
||||
transitions. Storing every heartbeat's ring would write the same 41-count
|
||||
observation hundreds of times.
|
||||
@@ -0,0 +1,17 @@
|
||||
# orchestra ui mockups
|
||||
|
||||
`final/` contains the nine accepted/current screen directions:
|
||||
|
||||
1. dashboard
|
||||
2. task detail
|
||||
3. terminal live pane
|
||||
4. tasks
|
||||
5. decisions
|
||||
6. workers
|
||||
7. projects
|
||||
8. review
|
||||
9. settings
|
||||
|
||||
`iterations/` contains every generated mockup from the design session, including superseded variants.
|
||||
|
||||
`orchestra-ui-spec.md` is the accompanying implementation/design specification.
|
||||
@@ -0,0 +1,110 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"orchestra/internal/domain"
|
||||
"orchestra/internal/federation"
|
||||
"orchestra/internal/herdr"
|
||||
)
|
||||
|
||||
// F62. Run 16: the agent was asked to hand off, never wrote HANDOFF.md,
|
||||
// renewals stopped, and the lease died as ordinary idleness. Waiting is now
|
||||
// bounded: re-ask once, then give the task up with a class that says why.
|
||||
func TestUnansweredHandoffIsRetriedThenGivenUp(t *testing.T) {
|
||||
var nack map[string]any
|
||||
w, backend, _, done := phaseWorker(t, func(rw http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "/nack") {
|
||||
_ = json.NewDecoder(r.Body).Decode(&nack)
|
||||
}
|
||||
rw.Write([]byte(`{}`))
|
||||
})
|
||||
defer done()
|
||||
ctx := context.Background()
|
||||
requested := func(ago time.Duration) herdr.Session {
|
||||
s := w.sessions["task"]
|
||||
s.HandoffRequested, s.HandoffReason = true, "phase_changed"
|
||||
s.HandoffRequestedAt = time.Now().UTC().Add(-ago)
|
||||
w.sessions["task"] = s
|
||||
return s
|
||||
}
|
||||
|
||||
// Still inside the answering window: nothing said, nothing given up.
|
||||
if s, gaveUp := w.watchHandoff(ctx, "task", requested(time.Minute)); gaveUp || s.HandoffRetried {
|
||||
t.Fatalf("gave up while still waiting: gaveUp=%v session=%+v", gaveUp, s)
|
||||
}
|
||||
if len(backend.prompts) != 0 {
|
||||
t.Fatalf("re-asked too early: %q", backend.prompts)
|
||||
}
|
||||
|
||||
// Past the retry point: asked again, exactly once.
|
||||
s, gaveUp := w.watchHandoff(ctx, "task", requested(handoffRetryAfter+time.Minute))
|
||||
if gaveUp || !s.HandoffRetried || len(backend.prompts) != 1 {
|
||||
t.Fatalf("retry: gaveUp=%v retried=%v prompts=%q", gaveUp, s.HandoffRetried, backend.prompts)
|
||||
}
|
||||
if _, gaveUp = w.watchHandoff(ctx, "task", s); gaveUp || len(backend.prompts) != 1 {
|
||||
t.Fatalf("re-asked every tick: %q", backend.prompts)
|
||||
}
|
||||
|
||||
// Past the bound: a causal reclaim, and no lease left to renew.
|
||||
s = requested(handoffAnswerTimeout + time.Second)
|
||||
s.HandoffRetried = true
|
||||
w.sessions["task"] = s
|
||||
if _, gaveUp = w.watchHandoff(ctx, "task", s); !gaveUp {
|
||||
t.Fatal("an unanswered handoff waited forever")
|
||||
}
|
||||
if nack["failure_class"] != "handoff_unanswered" {
|
||||
t.Fatalf("nack = %+v", nack)
|
||||
}
|
||||
if detail, _ := nack["last_error"].(string); !strings.Contains(detail, "phase_changed") {
|
||||
t.Fatalf("the reclaim does not name the request: %q", detail)
|
||||
}
|
||||
if _, held := w.leases["task"]; held {
|
||||
t.Fatal("the given-up task kept its lease")
|
||||
}
|
||||
}
|
||||
|
||||
// The lease must survive the wait it was asked to make: an idle pane is the
|
||||
// answer Orchestra requested, not evidence of an agent that stopped working.
|
||||
func TestWaitingForAHandoffKeepsTheLease(t *testing.T) {
|
||||
renewals := 0
|
||||
api := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
renewals++
|
||||
rw.Write([]byte(`{}`))
|
||||
}))
|
||||
defer api.Close()
|
||||
backend := &recordingBackend{status: "idle", progress: "same screen"}
|
||||
w := &worker{
|
||||
api: federation.Client{BaseURL: api.URL, WorkerID: "h", Token: "t"},
|
||||
backend: backend,
|
||||
harness: "claude",
|
||||
sessions: map[string]herdr.Session{"task": {PaneID: "pane", HandoffRequested: true, HandoffRequestedAt: time.Now().UTC()}},
|
||||
leases: map[string]lease{"task": {Epoch: "e", Version: 1, Until: time.Now(), ProgressSHA: domain.Hash([]byte("same screen"))}},
|
||||
quarantined: map[string]bool{},
|
||||
statePath: filepath.Join(t.TempDir(), "state.json"),
|
||||
}
|
||||
w.renewLeases(context.Background())
|
||||
if renewals != 1 {
|
||||
t.Fatalf("a lease waiting on a requested handoff renewed %d times, want 1", renewals)
|
||||
}
|
||||
|
||||
// Past the bound the exemption stops: watchHandoff has given the task up
|
||||
// by then, and nothing keeps an unanswered request alive.
|
||||
s := w.sessions["task"]
|
||||
s.HandoffRequestedAt = time.Now().UTC().Add(-handoffAnswerTimeout - time.Second)
|
||||
w.sessions["task"] = s
|
||||
l := w.leases["task"]
|
||||
l.Until = time.Now()
|
||||
w.leases["task"] = l
|
||||
w.renewLeases(context.Background())
|
||||
if renewals != 1 {
|
||||
t.Fatalf("the exemption outlived its bound: renewals=%d", renewals)
|
||||
}
|
||||
}
|
||||
@@ -103,8 +103,13 @@ func (w *worker) recordError(err error) {
|
||||
w.observations = append(w.observations, federation.Observation{Message: msg, Count: 1, First: now, Last: now})
|
||||
}
|
||||
|
||||
// workerIncarnation identifies this process. A restarted worker cannot
|
||||
// continue the previous process's failures, and nothing else on the wire says
|
||||
// a restart happened: build revision and worker id both survive it.
|
||||
var workerIncarnation = domain.NewID()
|
||||
|
||||
func (w *worker) health(ctx context.Context) federation.WorkerHealth {
|
||||
h := federation.WorkerHealth{HerdrStatus: "unknown"}
|
||||
h := federation.WorkerHealth{HerdrStatus: "unknown", Incarnation: workerIncarnation}
|
||||
if backend := w.executionBackend(); backend != nil {
|
||||
h.Backend = backend.Kind()
|
||||
}
|
||||
@@ -157,7 +162,24 @@ type releaseTransaction struct {
|
||||
AgentReleased bool `json:"agent_released,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
// NextAttemptAt parks a commit the coordinator has refused. A refusal is
|
||||
// an answer about the task, not a transport failure, so it stays true
|
||||
// until something about the task changes.
|
||||
NextAttemptAt time.Time `json:"next_attempt_at,omitempty"`
|
||||
Attempts int `json:"attempts,omitempty"`
|
||||
}
|
||||
|
||||
// releaseBackoff spaces out refused commits. The first wait is long enough
|
||||
// that a parked transaction stops filling the observation ring, and the cap
|
||||
// keeps a reopen from waiting more than five minutes to be noticed.
|
||||
func releaseBackoff(attempts int) time.Duration {
|
||||
d := 30 * time.Second << (attempts - 1)
|
||||
if attempts < 1 || d > 5*time.Minute {
|
||||
return 5 * time.Minute
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
type projectConfig struct {
|
||||
Repo string `json:"repo"`
|
||||
Root string `json:"worktree_root"`
|
||||
@@ -657,6 +679,13 @@ func (w *worker) releaseReady(ctx context.Context) {
|
||||
w.advanceRelease(ctx, id, s)
|
||||
continue
|
||||
}
|
||||
if s.HandoffRequested {
|
||||
next, gaveUp := w.watchHandoff(ctx, id, s)
|
||||
if gaveUp {
|
||||
continue
|
||||
}
|
||||
s = next
|
||||
}
|
||||
w.rotationTick(ctx, id, s)
|
||||
}
|
||||
}
|
||||
@@ -756,7 +785,7 @@ func (w *worker) rotationTick(ctx context.Context, id string, s herdr.Session) {
|
||||
w.recordError(fmt.Errorf("rotation %s threshold prompt: %w", id, err))
|
||||
return
|
||||
}
|
||||
s.HandoffRequested, s.HandoffReason = true, d.Reason
|
||||
s.HandoffRequested, s.HandoffReason, s.HandoffRequestedAt = true, d.Reason, time.Now().UTC()
|
||||
w.sessions[id] = s
|
||||
_ = w.save()
|
||||
}
|
||||
@@ -894,18 +923,31 @@ func (w *worker) advanceRelease(ctx context.Context, id string, s herdr.Session)
|
||||
w.releases[id] = tx
|
||||
_ = w.save()
|
||||
}
|
||||
if tx.Phase == "anchor_pushed" && time.Now().Before(tx.NextAttemptAt) {
|
||||
return
|
||||
}
|
||||
if tx.Phase == "anchor_pushed" {
|
||||
// The epoch comes from the transaction, not from w.leases: an expiry
|
||||
// replay deletes the lease, and the coordinator needs the epoch of the
|
||||
// lease this anchor was pushed under to accept the late commit.
|
||||
if err := w.api.Release(ctx, id, tx.Ref, tx.AnchorSHA, tx.ID, tx.LeaseEpoch, tx.LeaseVersion, w.sessionEvidence(ctx, id, s)); err != nil {
|
||||
tx.LastError, tx.UpdatedAt = err.Error(), time.Now().UTC()
|
||||
// A refusal is the coordinator's answer about who owns the task.
|
||||
// It cannot change until an event about that task does, so asking
|
||||
// again every five seconds only burns the observation ring. A
|
||||
// transport failure is the opposite and must retry at once.
|
||||
var refused *federation.StatusError
|
||||
if errors.As(err, &refused) && refused.Code >= 400 && refused.Code < 500 {
|
||||
tx.Attempts++
|
||||
tx.NextAttemptAt = time.Now().UTC().Add(releaseBackoff(tx.Attempts))
|
||||
}
|
||||
w.releases[id] = tx
|
||||
_ = w.save()
|
||||
w.recordError(fmt.Errorf("release %s commit: %w", id, err))
|
||||
return
|
||||
}
|
||||
tx.Phase, tx.LastError, tx.UpdatedAt = "event_committed", "", time.Now().UTC()
|
||||
tx.NextAttemptAt, tx.Attempts = time.Time{}, 0
|
||||
w.releases[id] = tx
|
||||
_ = w.save()
|
||||
}
|
||||
@@ -1137,6 +1179,70 @@ func (w *worker) submit(ctx context.Context, id string, s herdr.Session, e compl
|
||||
// the directory also holds for a worktree that has no inner .gitignore.
|
||||
const stageExclude = ":!.orchestra"
|
||||
|
||||
// F62: a requested handoff nobody answers was invisible. Renewals stopped,
|
||||
// the lease expired, and the task lost an attempt with nothing on record
|
||||
// saying a handoff had ever been asked for — worker health showed only "agent
|
||||
// status idle and pane unchanged", 34 times in run 16.
|
||||
const (
|
||||
handoffRetryAfter = 4 * time.Minute
|
||||
handoffAnswerTimeout = 10 * time.Minute
|
||||
)
|
||||
|
||||
// watchHandoff bounds the wait for an agent's handoff answer: re-send the
|
||||
// request once, then give the task up with a class that names the cause. It
|
||||
// returns the session to keep using and whether the task was given up.
|
||||
func (w *worker) watchHandoff(ctx context.Context, id string, s herdr.Session) (herdr.Session, bool) {
|
||||
if s.HandoffRequestedAt.IsZero() {
|
||||
// A session persisted before the stamp existed, or requested by a path
|
||||
// that does not set it. Start the clock now rather than time out a
|
||||
// request retroactively.
|
||||
s.HandoffRequestedAt = time.Now().UTC()
|
||||
w.sessions[id] = s
|
||||
_ = w.save()
|
||||
return s, false
|
||||
}
|
||||
waited := time.Since(s.HandoffRequestedAt)
|
||||
if waited < handoffRetryAfter {
|
||||
return s, false
|
||||
}
|
||||
if waited < handoffAnswerTimeout {
|
||||
if s.HandoffRetried || w.executionBackend() == nil {
|
||||
return s, false
|
||||
}
|
||||
a := herdr.CLIAdapter{Backend: w.executionBackend(), Harness: w.harness}
|
||||
var err error
|
||||
if s.HandoffReason != "" {
|
||||
err = a.RequestHandoffReason(ctx, s, s.HandoffReason, nil)
|
||||
} else {
|
||||
err = a.RequestHandoff(ctx, s)
|
||||
}
|
||||
if err != nil {
|
||||
w.recordError(fmt.Errorf("handoff %s re-request: %w", id, err))
|
||||
return s, false
|
||||
}
|
||||
s.HandoffRetried = true
|
||||
w.sessions[id] = s
|
||||
_ = w.save()
|
||||
w.recordError(fmt.Errorf("handoff %s (%s) unanswered for %s: request re-sent", id, s.HandoffReason, waited.Round(time.Second)))
|
||||
return s, false
|
||||
}
|
||||
l, ok := w.leases[id]
|
||||
if !ok {
|
||||
return s, false
|
||||
}
|
||||
detail := fmt.Sprintf("handoff requested (%s) and unanswered for %s", s.HandoffReason, waited.Round(time.Second))
|
||||
if err := w.api.NackStart(ctx, id, l.Epoch, l.Version, "handoff_unanswered", detail, w.sessionEvidence(ctx, id, s)); err != nil {
|
||||
w.recordError(fmt.Errorf("handoff timeout %s: %w", id, err))
|
||||
return s, false
|
||||
}
|
||||
// The coordinator answers with TaskReleased; its replay quarantines the
|
||||
// pane. Drop the lease here so nothing renews it in the meantime.
|
||||
delete(w.leases, id)
|
||||
_ = w.save()
|
||||
w.recordError(errors.New(detail))
|
||||
return s, true
|
||||
}
|
||||
|
||||
func (w *worker) renewLeases(ctx context.Context) {
|
||||
if w.executionBackend() == nil {
|
||||
return
|
||||
@@ -1170,6 +1276,11 @@ func (w *worker) renewLeases(ctx context.Context) {
|
||||
case l.ProgressSHA == "":
|
||||
// First renewal has no baseline to compare against. Record one and
|
||||
// allow this renewal; the next one must show real movement.
|
||||
case s.HandoffRequested && !s.HandoffRequestedAt.IsZero() && time.Since(s.HandoffRequestedAt) < handoffAnswerTimeout:
|
||||
// Orchestra told this agent to stop and write its handoff. A quiet
|
||||
// pane is the answer it was asked for, so the lease is held while
|
||||
// the wait is explicitly bounded (F62). Past the bound the case
|
||||
// stops matching and watchHandoff has already given the task up.
|
||||
default:
|
||||
w.recordError(fmt.Errorf("lease %s not renewed: agent status %s and pane unchanged since the last renewal", taskID, status))
|
||||
continue
|
||||
@@ -1313,6 +1424,13 @@ func (w *worker) once(ctx context.Context) error {
|
||||
if t, ok := created(e); ok {
|
||||
w.tasks[t.ID] = t
|
||||
}
|
||||
// Any event about this task is the change a parked commit was waiting
|
||||
// for. A reopen arrives as TaskCorrected, so this cannot be a list of
|
||||
// specific types without going stale.
|
||||
if tx, parked := w.releases[e.TaskID]; parked && !tx.NextAttemptAt.IsZero() {
|
||||
tx.NextAttemptAt, tx.Attempts = time.Time{}, 0
|
||||
w.releases[e.TaskID] = tx
|
||||
}
|
||||
if e.Type == "TaskLeased" {
|
||||
var p struct {
|
||||
HarnessID string `json:"harness_id"`
|
||||
@@ -1403,7 +1521,7 @@ func (w *worker) once(ctx context.Context) error {
|
||||
w.leases[e.TaskID] = l
|
||||
}
|
||||
}
|
||||
if e.Type == "TaskReleased" || e.Type == "TaskFailed" || e.Type == "TaskBlocked" {
|
||||
if e.Type == "TaskReleased" || e.Type == "TaskFailed" || e.Type == "TaskBlocked" || e.Type == "TaskCompleted" {
|
||||
if e.Type == "TaskReleased" {
|
||||
var p struct {
|
||||
TransactionID string `json:"transaction_id"`
|
||||
@@ -1425,12 +1543,13 @@ func (w *worker) once(ctx context.Context) error {
|
||||
// mapping protects nothing. F30: a transaction stuck at "prepared"
|
||||
// held the session forever once its pane was gone, health() kept
|
||||
// reporting ActiveTask, and the harness never leased again.
|
||||
// A failed task is terminal: no successor will ever lease it, so
|
||||
// its anchor protects nothing and its transaction can only retry
|
||||
// a refusal forever. Blocked is different, because a reopen still
|
||||
// produces a successor.
|
||||
// Failed and completed are terminal: no successor will ever lease
|
||||
// the task, so the anchor protects nothing and the transaction can
|
||||
// only retry a refusal forever. Blocked is different, because a
|
||||
// reopen returns the task to the queue and the epoch that ended is
|
||||
// still on record, so that exact commit can still be accepted.
|
||||
tx, releasing := w.releases[e.TaskID]
|
||||
if !releasing || tx.Ref == "" || e.Type == "TaskFailed" {
|
||||
if !releasing || tx.Ref == "" || e.Type == "TaskFailed" || e.Type == "TaskCompleted" {
|
||||
if releasing {
|
||||
delete(w.releases, e.TaskID)
|
||||
}
|
||||
@@ -1770,7 +1889,7 @@ func main() {
|
||||
window: window,
|
||||
// Capacity stays one per identity because a herdr's declared
|
||||
// concurrency is one. Serving N harnesses gives the process N slots.
|
||||
registration: federation.Worker{ID: spec.ID, Address: spec.Address, Capacity: 1, SupportedProjects: supported, Build: buildinfo.Current()},
|
||||
registration: federation.Worker{ID: spec.ID, Address: spec.Address, Capacity: 1, SupportedProjects: supported, Build: buildinfo.Current(), Incarnation: workerIncarnation},
|
||||
}
|
||||
if w.registration.Address == "" {
|
||||
w.registration.Address = os.Getenv("ORCHESTRA_WORKER_ADDRESS")
|
||||
@@ -1895,7 +2014,7 @@ func (w *worker) federatedTurn(ctx context.Context, id string, a herdr.Adapter,
|
||||
w.recordError(fmt.Errorf("reconcile failure handoff %s: %w", id, err))
|
||||
return
|
||||
}
|
||||
s.HandoffRequested, s.HandoffReason = true, "reconcile_failure"
|
||||
s.HandoffRequested, s.HandoffReason, s.HandoffRequestedAt = true, "reconcile_failure", time.Now().UTC()
|
||||
w.sessions[id] = s
|
||||
_ = w.save()
|
||||
return
|
||||
@@ -1972,9 +2091,18 @@ func (w *worker) rotateForPhase(ctx context.Context, id string, a herdr.Adapter,
|
||||
w.recordError(fmt.Errorf("phase rotation %s: %w", id, err))
|
||||
return
|
||||
}
|
||||
s.HandoffRequested, s.HandoffReason = true, "phase_changed"
|
||||
s.HandoffRequested, s.HandoffReason, s.HandoffRequestedAt = true, "phase_changed", time.Now().UTC()
|
||||
w.sessions[id] = s
|
||||
_ = w.save()
|
||||
// A request belongs to the session that wrote it. Both files sit in the
|
||||
// worktree, which outlives the session, so a successor in a different
|
||||
// phase would find and execute them: that is how a reopened planning
|
||||
// session verified a phase of the plan it was replacing.
|
||||
for _, name := range []string{planProgressFile, phaseRequestFile} {
|
||||
if err := os.Remove(filepath.Join(s.Worktree, ".orchestra", name)); err != nil && !os.IsNotExist(err) {
|
||||
w.recordError(fmt.Errorf("phase rotation %s: drop %s: %w", id, name, err))
|
||||
}
|
||||
}
|
||||
log.Printf("phase changed for %s: session rotating", id)
|
||||
}
|
||||
|
||||
|
||||
@@ -1327,3 +1327,120 @@ func TestObservationRingEvictsLeastRecentlySeen(t *testing.T) {
|
||||
t.Fatal("least recently seen entry survived")
|
||||
}
|
||||
}
|
||||
|
||||
// F60. A refusal is an answer about the task, not a transport failure, and it
|
||||
// stays true until something about that task changes. Run 10's blocked task
|
||||
// asked 5,000 times over seven hours and got the same 409 every time.
|
||||
func TestRefusedCommitParksUntilSomethingChanges(t *testing.T) {
|
||||
var commits int
|
||||
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/handoff"):
|
||||
commits++
|
||||
http.Error(w, "lease not owned", http.StatusConflict)
|
||||
case strings.HasSuffix(r.URL.Path, "/events"):
|
||||
_, _ = w.Write([]byte(`{"cursor":0,"events":[{"seq":1,"id":"c","type":"TaskCorrected","task_id":"t","version":9,"payload":{"state":"queued"},"surface":"web"}]}`))
|
||||
default:
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}))
|
||||
defer s.Close()
|
||||
tx := releaseTransaction{ID: "tx", Phase: "anchor_pushed", Ref: "sha256:abc", AnchorSHA: "abc", LeaseEpoch: "e1"}
|
||||
w := &worker{
|
||||
api: federation.Client{BaseURL: s.URL, WorkerID: "h", Token: "t"},
|
||||
harnessID: "h",
|
||||
tasks: map[string]domain.Task{"t": {ID: "t"}},
|
||||
sessions: map[string]herdr.Session{"t": {PaneID: "pane"}},
|
||||
leases: map[string]lease{},
|
||||
releases: map[string]releaseTransaction{"t": tx},
|
||||
quarantined: map[string]bool{},
|
||||
statePath: t.TempDir() + "/state.json",
|
||||
hard: .75,
|
||||
}
|
||||
w.advanceRelease(context.Background(), "t", w.sessions["t"])
|
||||
w.advanceRelease(context.Background(), "t", w.sessions["t"])
|
||||
if commits != 1 {
|
||||
t.Fatalf("refused commit retried %d times without waiting", commits)
|
||||
}
|
||||
if w.releases["t"].NextAttemptAt.IsZero() {
|
||||
t.Fatal("refused commit was not parked")
|
||||
}
|
||||
// A reopen arrives as TaskCorrected. Any event about the task is the
|
||||
// change the parked commit was waiting for, so the same tick retries it
|
||||
// and, still refused, parks it again.
|
||||
if err := w.once(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if commits != 2 {
|
||||
t.Fatalf("an event about the task did not un-park its commit, commits=%d", commits)
|
||||
}
|
||||
if w.releases["t"].NextAttemptAt.IsZero() {
|
||||
t.Fatal("the second refusal did not park it again")
|
||||
}
|
||||
}
|
||||
|
||||
// The opposite case, and the one a backoff must not break: the coordinator is
|
||||
// unreachable or broken rather than answering. That says nothing about who
|
||||
// owns the task, so it has to retry at once.
|
||||
func TestTransientCommitFailureKeepsRetryingAtOnce(t *testing.T) {
|
||||
var commits int
|
||||
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "/handoff") {
|
||||
commits++
|
||||
http.Error(w, "upstream unavailable", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer s.Close()
|
||||
w := &worker{
|
||||
api: federation.Client{BaseURL: s.URL, WorkerID: "h", Token: "t"},
|
||||
harnessID: "h",
|
||||
tasks: map[string]domain.Task{"t": {ID: "t"}},
|
||||
sessions: map[string]herdr.Session{"t": {PaneID: "pane"}},
|
||||
leases: map[string]lease{},
|
||||
releases: map[string]releaseTransaction{"t": {ID: "tx", Phase: "anchor_pushed", Ref: "sha256:abc", AnchorSHA: "abc", LeaseEpoch: "e1"}},
|
||||
quarantined: map[string]bool{},
|
||||
statePath: t.TempDir() + "/state.json",
|
||||
hard: .75,
|
||||
}
|
||||
w.advanceRelease(context.Background(), "t", w.sessions["t"])
|
||||
w.advanceRelease(context.Background(), "t", w.sessions["t"])
|
||||
if commits != 2 {
|
||||
t.Fatalf("transport failure was parked like a refusal, commits=%d", commits)
|
||||
}
|
||||
if !w.releases["t"].NextAttemptAt.IsZero() {
|
||||
t.Fatal("transport failure must not park the transaction")
|
||||
}
|
||||
}
|
||||
|
||||
// Completion is terminal for a release transaction just as failure is. The
|
||||
// task is done; nothing will ever lease it again to pick the anchor up.
|
||||
func TestCompletedTaskDropsItsReleaseTransaction(t *testing.T) {
|
||||
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "/events") {
|
||||
_, _ = w.Write([]byte(`{"cursor":0,"events":[{"seq":1,"id":"c","type":"TaskCompleted","task_id":"t","version":9,"payload":{"report_ref":"sha256:r"},"surface":"system"}]}`))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer s.Close()
|
||||
w := &worker{
|
||||
api: federation.Client{BaseURL: s.URL, WorkerID: "h", Token: "t"},
|
||||
harnessID: "h",
|
||||
backend: deadTmuxBackend(t),
|
||||
tasks: map[string]domain.Task{"t": {ID: "t"}},
|
||||
sessions: map[string]herdr.Session{"t": {PaneID: "pane"}},
|
||||
leases: map[string]lease{},
|
||||
releases: map[string]releaseTransaction{"t": {ID: "tx", Phase: "anchor_pushed", Ref: "sha256:abc", AnchorSHA: "abc", LeaseEpoch: "e1"}},
|
||||
quarantined: map[string]bool{},
|
||||
statePath: t.TempDir() + "/state.json",
|
||||
hard: .75,
|
||||
}
|
||||
if err := w.once(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(w.releases) != 0 || len(w.sessions) != 0 {
|
||||
t.Fatalf("completed task kept its release: releases=%v sessions=%v", w.releases, w.sessions)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -457,3 +457,32 @@ func TestPlanVerificationRunsThePlansCommandsAndReportsExitCodes(t *testing.T) {
|
||||
t.Fatalf("the outcome was not delivered: %v", backend.prompts)
|
||||
}
|
||||
}
|
||||
|
||||
// A request belongs to the session that wrote it. The worktree outlives the
|
||||
// session, so a rotation that leaves these files behind hands them to a
|
||||
// successor running in a different phase (run 20).
|
||||
func TestRotationDropsTheEndedSessionsRequests(t *testing.T) {
|
||||
w, backend, wt, done := phaseWorker(t, func(rw http.ResponseWriter, r *http.Request) {
|
||||
rw.Write([]byte(`{}`))
|
||||
})
|
||||
defer done()
|
||||
progress := filepath.Join(wt, ".orchestra", planProgressFile)
|
||||
request := filepath.Join(wt, ".orchestra", phaseRequestFile)
|
||||
for _, p := range []string{progress, request} {
|
||||
if err := os.WriteFile(p, []byte(`{"phase":"phase-1","status":"ready_for_verification"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
a := herdr.CLIAdapter{Backend: backend, Harness: "claude"}
|
||||
w.rotateForPhase(context.Background(), "task", a, w.sessions["task"])
|
||||
|
||||
for _, p := range []string{progress, request} {
|
||||
if _, err := os.Stat(p); !os.IsNotExist(err) {
|
||||
t.Fatalf("%s survived the rotation that ended the session that wrote it", filepath.Base(p))
|
||||
}
|
||||
}
|
||||
if s := w.sessions["task"]; !s.HandoffRequested || s.HandoffReason != "phase_changed" {
|
||||
t.Fatalf("the session was not rotated: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,6 +228,9 @@ func main() {
|
||||
// Pull-request readers by source name, for reflecting submitted work.
|
||||
pullRequests := map[string]human.PullRequestSource{}
|
||||
localMachine := os.Getenv("ORCHESTRA_MACHINE_ID")
|
||||
// One tracker for the process: it holds the per-incident accumulation that
|
||||
// makes an evicted and recreated ring entry add up instead of restarting.
|
||||
observations := &operations.ObservationTracker{Store: s}
|
||||
workers := &federation.Registry{AdmitToken: os.Getenv("ORCHESTRA_FEDERATION_ADMIT_TOKEN"), StatePath: filepath.Join(dir, "federation-state.json")}
|
||||
if err := workers.Load(); err != nil {
|
||||
log.Fatalf("load federation state: %v", err)
|
||||
@@ -445,6 +448,60 @@ func main() {
|
||||
}
|
||||
json.NewEncoder(w).Encode(s.Events(n))
|
||||
})
|
||||
mux.HandleFunc("/v1/interventions", func(w http.ResponseWriter, r *http.Request) {
|
||||
// The one command an operator has for saying "I fixed this by hand".
|
||||
// Everything Orchestra does to itself is already an event; a manual
|
||||
// repair is the only kind of recovery that leaves no trace unless the
|
||||
// person who made it says so.
|
||||
if r.Method == http.MethodGet {
|
||||
out := make([]domain.Event, 0)
|
||||
for _, e := range s.Events(0) {
|
||||
if e.Type == domain.EventOperatorInterventionRecorded {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
json.NewEncoder(w).Encode(out)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if err := authz.AuthorizeEvent(surface(r), domain.EventOperatorInterventionRecorded); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
var in domain.OperatorIntervention
|
||||
if json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&in) != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
e, err := operations.RecordIntervention(s, surface(r), in)
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrNotFound):
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
case errors.Is(err, domain.ErrInvalid):
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
case err != nil:
|
||||
http.Error(w, err.Error(), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(e)
|
||||
})
|
||||
mux.HandleFunc("/v1/debt", func(w http.ResponseWriter, r *http.Request) {
|
||||
// Read-only, and deliberately so. This projection is evidence about
|
||||
// history, not a new kind of truth: nothing here writes an event,
|
||||
// schedules work, or decides that something is worth repairing.
|
||||
ledger := s.DebtLedger()
|
||||
out := struct {
|
||||
domain.DebtLedger
|
||||
Eligible []operations.DebtCandidate `json:"eligible"`
|
||||
}{ledger, operations.EligibleDebt(ledger)}
|
||||
json.NewEncoder(w).Encode(out)
|
||||
})
|
||||
mux.HandleFunc("/v1/handoffs", func(w http.ResponseWriter, r *http.Request) {
|
||||
out := make([]domain.Event, 0)
|
||||
for _, e := range s.Events(0) {
|
||||
@@ -1380,6 +1437,29 @@ func main() {
|
||||
http.Error(w, err.Error(), 404)
|
||||
return
|
||||
}
|
||||
// The ring carries messages and nothing else, so the coordinator
|
||||
// attributes them: the worker's active task, and that task's
|
||||
// current lease epoch, are what bind an incident to the work it
|
||||
// happened during. A failed fold is logged rather than failing the
|
||||
// heartbeat, because losing evidence about a worker is not a reason
|
||||
// to also stop believing the worker is alive.
|
||||
report := operations.WorkerReport{
|
||||
WorkerID: parts[3], Incarnation: health.Incarnation,
|
||||
TaskID: health.ActiveTask, At: time.Now().UTC(),
|
||||
}
|
||||
if report.TaskID != "" {
|
||||
if t, ok := s.Task(report.TaskID); ok && t.Lease != nil {
|
||||
report.LeaseEpoch = t.Lease.Epoch
|
||||
}
|
||||
}
|
||||
for _, o := range health.Observations {
|
||||
report.Observations = append(report.Observations, domain.WorkerObservation{
|
||||
Message: o.Message, Count: o.Count, First: o.First, Last: o.Last,
|
||||
})
|
||||
}
|
||||
if _, err := observations.Ingest(report); err != nil {
|
||||
log.Printf("observation incidents for %s: %v", parts[3], err)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
@@ -1578,6 +1658,12 @@ func main() {
|
||||
case "invalid_handoff":
|
||||
typ = "TaskBlocked"
|
||||
p, _ = json.Marshal(map[string]any{"blocker": b.LastError, "block_reason": string(domain.BlockReasonHandoffValidation), "harness_id": parts[3], "lease_epoch": b.LeaseEpoch, "expected_version": t.Version, "lifecycle_phase": "launch_nacked", "last_error": b.LastError, "session_evidence": b.SessionEvidence})
|
||||
case "handoff_unanswered":
|
||||
// F62: not a launch failure. The agent was asked to hand off
|
||||
// and never did, so the reclaim says exactly that instead of
|
||||
// arriving as an ordinary idle expiry.
|
||||
typ = "TaskReleased"
|
||||
p, _ = json.Marshal(map[string]any{"reason": "handoff_unanswered", "failure_class": b.FailureClass, "harness_id": parts[3], "lease_epoch": b.LeaseEpoch, "expected_version": t.Version, "lifecycle_phase": "handoff_unanswered", "last_error": b.LastError, "session_evidence": b.SessionEvidence})
|
||||
case "launch_uncertain":
|
||||
typ = "TaskNeedsAttention"
|
||||
p, _ = json.Marshal(map[string]any{"blocker": b.LastError, "block_reason": string(domain.BlockReasonLeaseFailure), "harness_id": parts[3], "lease_epoch": b.LeaseEpoch, "expected_version": t.Version, "lifecycle_phase": "launch_uncertain", "last_error": b.LastError, "session_evidence": b.SessionEvidence})
|
||||
@@ -1814,6 +1900,25 @@ func main() {
|
||||
coordinator.ReconcileFailureHandoff = v
|
||||
}
|
||||
}
|
||||
// The third reconciliation point, and the same reasoning the submitted
|
||||
// work loop above already uses: a blocked task cannot be leased, so a
|
||||
// pre-lease hook can never see the reply that should unblock it (F64).
|
||||
// Orchestra stopped and asked the human a question; nothing else here
|
||||
// will go and read the answer.
|
||||
go func() {
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
for _, t := range s.Tasks() {
|
||||
if t.State != domain.StateBlocked || !domain.BlockReasonAwaitsReply(t.BlockReason) {
|
||||
continue
|
||||
}
|
||||
if err := reconciler.Reconcile(context.Background(), t.ID); err != nil {
|
||||
log.Printf("reconcile blocked task %s: %v", t.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
if path := os.Getenv("ORCHESTRA_JSONL"); path != "" {
|
||||
sup := &provider.Supervisor{Name: "jsonl", Run: func(ctx context.Context) error {
|
||||
|
||||
@@ -8,17 +8,40 @@ coordinator deployment" below for the build that carries provenance. (The old
|
||||
|
||||
## Browser operator login
|
||||
|
||||
The browser UI requires `ORCHESTRA_WEB_USERNAME` and
|
||||
`ORCHESTRA_WEB_PASSWORD_HASH`. Generate a bcrypt hash without putting the
|
||||
password in shell history:
|
||||
Browser operators now live in the embedded `${ORCHESTRA_DATA}/auth.db`
|
||||
database. Passwords are bcrypt-hashed inside that database; no password hash
|
||||
belongs in `.env`.
|
||||
|
||||
For a new local data directory, create the first account while Orchestra is
|
||||
stopped. The command reads and confirms the password from the terminal:
|
||||
|
||||
```sh
|
||||
go run ./cmd/orchestra-password
|
||||
go run ./cmd/orchestra-user set -data ./data -username kami
|
||||
```
|
||||
|
||||
Set the emitted hash in the service environment along with the chosen
|
||||
username, then restart the coordinator. `ORCHESTRA_WEB_TOKEN` is not used by
|
||||
the browser UI anymore.
|
||||
For the Docker Compose deployment, the API image includes the same helper.
|
||||
Keep the API stopped while it opens the database, then use the existing data
|
||||
volume through Compose:
|
||||
|
||||
```sh
|
||||
docker compose stop orchestra-api
|
||||
docker compose run --rm --entrypoint /app/orchestra-user \
|
||||
orchestra-api set -data /data -username kami
|
||||
docker compose up -d orchestra-api
|
||||
```
|
||||
|
||||
After signing in, the Settings screen can change the username or password.
|
||||
Every browser session for that account is revoked after a credential change.
|
||||
To recover a forgotten password, stop the API and run `orchestra-user set`
|
||||
again for the same username. `orchestra-user list -data /data` lists usernames
|
||||
without exposing password hashes.
|
||||
|
||||
On the first start after upgrading, an empty auth database automatically
|
||||
imports the existing `ORCHESTRA_WEB_USERNAME` and
|
||||
`ORCHESTRA_WEB_PASSWORD_HASH` pair. Once the startup log confirms the import,
|
||||
remove both legacy values from `.env`; they are ignored whenever the database
|
||||
already contains an account. `ORCHESTRA_WEB_TOKEN` remains unused by the
|
||||
browser UI.
|
||||
|
||||
Build a worker for staging on workpc with:
|
||||
|
||||
@@ -44,7 +67,7 @@ credential: its `build` object is the coordinator provenance. `GET
|
||||
/v1/federation/workers` shows every worker's `build`, supported projects, and
|
||||
worker-local health without SSH.
|
||||
|
||||
Build both binaries with `deploy/build.sh`, which stamps them from one commit
|
||||
Build the coordinator and worker with `deploy/build.sh`, which stamps them from one commit
|
||||
and refuses a dirty tree. A burn-in run must never pair a new coordinator with
|
||||
an old worker, and matching revisions are how that is checked rather than
|
||||
assumed.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/bin/sh
|
||||
# Build the coordinator and the worker from one commit, with one stamp, so a
|
||||
# Build the coordinator, worker, and operator-account helper from one commit,
|
||||
# with one stamp, so a
|
||||
# burn-in run can never pair a new coordinator with an old worker. Both
|
||||
# binaries then report the same revision at /v1/admin/diagnostics and in the
|
||||
# worker's registration, which is what makes deployed identity evidence rather
|
||||
@@ -23,4 +24,5 @@ flags="-s -w -X orchestra/internal/buildinfo.Revision=$rev -X orchestra/internal
|
||||
mkdir -p "$out"
|
||||
(cd "$tree" && go build -trimpath -ldflags="$flags" -o "$out/orchestra" ./cmd/orchestra)
|
||||
(cd "$tree" && go build -trimpath -ldflags="$flags" -o "$out/orchestra-worker" ./cmd/orchestra-worker)
|
||||
(cd "$tree" && go build -trimpath -ldflags="-s -w" -o "$out/orchestra-user" ./cmd/orchestra-user)
|
||||
echo "$rev"
|
||||
|
||||
@@ -139,12 +139,11 @@ ORCHESTRA_CONTEXT_WINDOW=200000
|
||||
# --- Bus authorization tokens (bearer auth per surface; a surface with no
|
||||
# token set has no auth requirement — set these once you have real clients) ---
|
||||
#ORCHESTRA_TUI_TOKEN=
|
||||
# Required: the service refuses to start without both. The browser UI's
|
||||
# task, lifecycle and approval controls are session-gated; it no longer
|
||||
# accepts a shared Web bearer token. Generate the bcrypt hash with:
|
||||
# go run ./cmd/orchestra-password
|
||||
ORCHESTRA_WEB_USERNAME=operator
|
||||
ORCHESTRA_WEB_PASSWORD_HASH=
|
||||
# Browser operators are stored in $ORCHESTRA_DATA/auth.db, not in this file.
|
||||
# With Orchestra stopped, create or reset one interactively with:
|
||||
# orchestra-user set -data /data -username kami
|
||||
# Existing ORCHESTRA_WEB_USERNAME + ORCHESTRA_WEB_PASSWORD_HASH values are
|
||||
# imported once only when auth.db contains no users, then should be removed.
|
||||
# Set when the UI is served over plain HTTP, so the session cookie can be
|
||||
# sent without Secure. Leave unset behind TLS.
|
||||
#ORCHESTRA_UI_INSECURE_COOKIE=1
|
||||
|
||||
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
@@ -314,6 +314,9 @@ func renderTask(in Input) string {
|
||||
fmt.Fprintf(&b, "- head: %s\n", fallback(in.Git.HeadSHA))
|
||||
fmt.Fprintf(&b, "- uncommitted changes: %t\n", in.Git.Dirty)
|
||||
|
||||
// Above the sealed artifacts, because it is the reason this session exists
|
||||
// and it changes how the artifacts below should be read.
|
||||
b.WriteString(renderReopen(in))
|
||||
b.WriteString(renderSealed(in))
|
||||
// Below the plan, above continuity. Progress is a fact about the plan, so
|
||||
// it follows the plan; continuity is one predecessor's account, so it
|
||||
@@ -499,6 +502,35 @@ func renderPlanProgress(in Input) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// renderReopen states the contradiction that reopened this phase. Without it
|
||||
// the planning session a replan convenes gets the ordinary plan brief and an
|
||||
// empty template, with nothing saying a plan already exists, what contradicted
|
||||
// it, or that its output supersedes an accepted plan (F66). Everything here is
|
||||
// durable on PlanMismatchRecorded; it simply never reached the agent.
|
||||
//
|
||||
// Only the phases a mismatch can reopen render it. An implementer that finds
|
||||
// the next contradiction is looking at the code, not at this account of the
|
||||
// last one.
|
||||
func renderReopen(in Input) string {
|
||||
m := in.Task.PlanMismatch
|
||||
if m == nil || (in.Phase != domain.WorkPhasePlan && in.Phase != domain.WorkPhaseResearch) {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("\n## Why this phase reopened\n\n")
|
||||
fmt.Fprintf(&b, "A plan was already accepted and the code contradicted it. Orchestra reopened this phase to settle that, and the session that found it is gone.\n\n")
|
||||
fmt.Fprintf(&b, "- phase: %s\n", collapse(m.PhaseID))
|
||||
fmt.Fprintf(&b, "- observed: %s\n", collapse(m.Observed))
|
||||
fmt.Fprintf(&b, "- the plan says: %s\n", collapse(m.Contradicts))
|
||||
for _, e := range m.Evidence {
|
||||
fmt.Fprintf(&b, "- evidence: %s\n", collapse(e))
|
||||
}
|
||||
if in.Phase == domain.WorkPhasePlan {
|
||||
b.WriteString("\nThe accepted plan stays accepted until you seal a replacement, and sealing one supersedes it along with every phase it had verified. Address the contradiction above: a replacement that repeats it will be contradicted again.\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func renderSealed(in Input) string {
|
||||
var b strings.Builder
|
||||
research := in.Research
|
||||
|
||||
@@ -734,3 +734,61 @@ func TestPendingManualGateRendersStale(t *testing.T) {
|
||||
t.Errorf("a pending manual gate at HEAD rendered stale:\n%s", fresh)
|
||||
}
|
||||
}
|
||||
|
||||
// F66, found live on run 20. A replan convenes a planning session to settle a
|
||||
// contradiction, and that session was told nothing about it: the ordinary plan
|
||||
// brief, an empty template, and no mention that a plan already exists.
|
||||
func TestAReopenedPlanPhaseIsToldWhatContradictedThePlan(t *testing.T) {
|
||||
in := input()
|
||||
in.Phase = domain.WorkPhasePlan
|
||||
in.Task.PlanRef = "plan-a"
|
||||
in.Task.PlanMismatch = &domain.PlanMismatch{
|
||||
PlanRef: "plan-a", PhaseID: "phase-3", AtSHA: "18ccaf00000000000000000000000000000000aa",
|
||||
Observed: "the aggregation runs per figure, not per person",
|
||||
Contradicts: "the plan states the pipeline already aggregates per person",
|
||||
Evidence: []string{"internal/figures/aggregate.go:88"},
|
||||
RequestedAction: domain.PlanMismatchReplan,
|
||||
}
|
||||
out, err := Build(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"## Why this phase reopened",
|
||||
"phase: phase-3",
|
||||
"the aggregation runs per figure",
|
||||
"the plan states the pipeline already aggregates per person",
|
||||
"internal/figures/aggregate.go:88",
|
||||
"stays accepted until you seal a replacement",
|
||||
} {
|
||||
if !strings.Contains(out.Task, want) {
|
||||
t.Fatalf("the planner was not told %q:\n%s", want, out.Task)
|
||||
}
|
||||
}
|
||||
// It must come before the artifacts it changes the reading of.
|
||||
in.Research = &workphase.Research{Findings: []workphase.Finding{{
|
||||
ID: "r1", Confidence: "fact", Claim: "aggregation is per figure",
|
||||
Evidence: "internal/figures/aggregate.go:88",
|
||||
}}}
|
||||
out, err = Build(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out.Task, "## Accepted research") {
|
||||
t.Fatal("the research this planner works from is missing")
|
||||
}
|
||||
if strings.Index(out.Task, "## Why this phase reopened") > strings.Index(out.Task, "## Accepted research") {
|
||||
t.Fatal("the reason this session exists is rendered below the material it qualifies")
|
||||
}
|
||||
|
||||
// An implementer is looking at the code, not at an account of the last
|
||||
// contradiction, so it renders nowhere else.
|
||||
in.Phase = domain.WorkPhaseImplement
|
||||
out, err = Build(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(out.Task, "## Why this phase reopened") {
|
||||
t.Fatal("a settled contradiction reached the implementation phase")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"orchestra/internal/review"
|
||||
)
|
||||
|
||||
// Debt is the projected read model for accumulated cost: the defects,
|
||||
// workarounds and operational burdens that real tasks keep paying for. It is
|
||||
// derived from the event log rather than written by hand, so a claim in the
|
||||
// ledger can always be checked against the events that produced it.
|
||||
//
|
||||
// Four classes, and no more. A fifth invented at runtime makes the priority
|
||||
// model meaningless, because the thresholds in operations.CheckDebtEligibility
|
||||
// are stated per class.
|
||||
type DebtClass string
|
||||
|
||||
const (
|
||||
// DebtCorrectness is behaviour that is wrong or violates an invariant.
|
||||
DebtCorrectness DebtClass = "correctness"
|
||||
// DebtOperational works, but repeatedly costs time to diagnose, recover,
|
||||
// deploy, observe or operate.
|
||||
DebtOperational DebtClass = "operational"
|
||||
// DebtStructural is duplication or architecture demonstrably raising the
|
||||
// cost of future changes.
|
||||
DebtStructural DebtClass = "structural"
|
||||
// DebtPolish has no demonstrated cost yet, and never promotes itself.
|
||||
DebtPolish DebtClass = "polish"
|
||||
)
|
||||
|
||||
func (c DebtClass) Valid() bool {
|
||||
switch c {
|
||||
case DebtCorrectness, DebtOperational, DebtStructural, DebtPolish:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type DebtStatus string
|
||||
|
||||
const (
|
||||
DebtObserved DebtStatus = "observed"
|
||||
DebtEligible DebtStatus = "eligible"
|
||||
DebtScheduled DebtStatus = "scheduled"
|
||||
DebtRepaired DebtStatus = "repaired"
|
||||
DebtWithdrawn DebtStatus = "withdrawn"
|
||||
)
|
||||
|
||||
// ObservationKind names where one piece of evidence came from. It is the kind
|
||||
// of the source fact, never an interpretation of it.
|
||||
type ObservationKind string
|
||||
|
||||
const (
|
||||
ObservationBlockReason ObservationKind = "block_reason"
|
||||
ObservationFailureClass ObservationKind = "failure_class"
|
||||
ObservationReviewFinding ObservationKind = "review_finding"
|
||||
ObservationPlanMismatch ObservationKind = "plan_mismatch"
|
||||
ObservationDeferredFinding ObservationKind = "deferred_finding"
|
||||
ObservationManualIntervention ObservationKind = "manual_intervention"
|
||||
ObservationWorkerFailure ObservationKind = "worker_observation"
|
||||
)
|
||||
|
||||
// SignatureVersion prefixes every signature this build produces. Normalization
|
||||
// rules will change, and without a version a change silently regroups every
|
||||
// historical observation, moving the recurrence counts that eligibility was
|
||||
// already decided on. A v2 signature never matches a v1 one, so old evidence
|
||||
// keeps the grouping it was counted under.
|
||||
const SignatureVersion = "v1"
|
||||
|
||||
// DebtSignature is the mechanical dedup key. Every part is a typed fact, never
|
||||
// prose, because exact signature match is the only path that attaches evidence
|
||||
// without a human. An empty part becomes "-" so the arity never varies.
|
||||
func DebtSignature(class DebtClass, reason, scope, component string) string {
|
||||
part := func(s string) string {
|
||||
s = strings.TrimSpace(strings.ToLower(s))
|
||||
s = strings.ReplaceAll(s, ":", "_")
|
||||
if s == "" {
|
||||
return "-"
|
||||
}
|
||||
return s
|
||||
}
|
||||
return strings.Join([]string{SignatureVersion, part(string(class)), part(reason), part(scope), part(component)}, ":")
|
||||
}
|
||||
|
||||
// DebtComponent reduces a repository path to the unit that owns it. Two
|
||||
// segments is the whole rule: it keeps internal/store distinct from
|
||||
// internal/herdr without splitting one package across files.
|
||||
func DebtComponent(path string) string {
|
||||
path = strings.TrimSpace(strings.Trim(path, "/"))
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) == 1 {
|
||||
return parts[0]
|
||||
}
|
||||
return parts[0] + "/" + parts[1]
|
||||
}
|
||||
|
||||
// DebtObservation is one piece of counted evidence. Provenance is required and
|
||||
// is either an event id or, for history imported from a markdown ledger that
|
||||
// predates this projection, an explicit legacy reference. Exactly one, because
|
||||
// a fabricated event id would break the rule the ledger exists to enforce.
|
||||
type DebtObservation struct {
|
||||
EventID string `json:"event_id,omitempty"`
|
||||
LegacyRef string `json:"legacy_ref,omitempty"`
|
||||
TaskID string `json:"task_id,omitempty"`
|
||||
Kind ObservationKind `json:"kind"`
|
||||
Signature string `json:"signature"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Paths []string `json:"paths,omitempty"`
|
||||
At time.Time `json:"at"`
|
||||
// Repeats is how many times this one incident recurred. It is intensity,
|
||||
// never recurrence: one worker stuck in a five-second retry loop produced
|
||||
// 301 repeats of a single failure, and counting those as 301 pieces of
|
||||
// evidence would make one broken worker look like chronic, system-wide
|
||||
// debt. Recurrence is the number of independent observations.
|
||||
Repeats int `json:"repeats,omitempty"`
|
||||
}
|
||||
|
||||
func (o DebtObservation) Validate() error {
|
||||
if (o.EventID == "") == (o.LegacyRef == "") {
|
||||
return fmt.Errorf("observation needs exactly one of event_id and legacy_ref")
|
||||
}
|
||||
if o.Kind == "" {
|
||||
return fmt.Errorf("observation kind required")
|
||||
}
|
||||
if !strings.HasPrefix(o.Signature, SignatureVersion+":") {
|
||||
return fmt.Errorf("observation signature %q is not %s", o.Signature, SignatureVersion)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DebtItem groups observations that share a signature. Every count the
|
||||
// priority model needs is derived from Observations rather than stored, so a
|
||||
// count can never drift from the log that justifies it.
|
||||
type DebtItem struct {
|
||||
ID string `json:"id"`
|
||||
Class DebtClass `json:"class"`
|
||||
Status DebtStatus `json:"status"`
|
||||
Symptom string `json:"symptom"`
|
||||
Consequence string `json:"consequence,omitempty"`
|
||||
Severity review.Severity `json:"severity,omitempty"`
|
||||
Signatures []string `json:"signatures"`
|
||||
Paths []string `json:"paths,omitempty"`
|
||||
Observations []DebtObservation `json:"observations"`
|
||||
IntroducedIn string `json:"introduced_in,omitempty"`
|
||||
RepairBoundary string `json:"repair_boundary,omitempty"`
|
||||
RepairTask string `json:"repair_task,omitempty"`
|
||||
RepairCommit string `json:"repair_commit,omitempty"`
|
||||
}
|
||||
|
||||
// Recurrence is how many times this shape has been seen.
|
||||
func (d DebtItem) Recurrence() int { return len(d.Observations) }
|
||||
|
||||
// AffectedTasks is how broadly the shape has spread. Distinct tasks, because
|
||||
// one task failing ten times is weaker evidence than ten tasks failing once.
|
||||
func (d DebtItem) AffectedTasks() int {
|
||||
seen := map[string]bool{}
|
||||
for _, o := range d.Observations {
|
||||
if o.TaskID != "" {
|
||||
seen[o.TaskID] = true
|
||||
}
|
||||
}
|
||||
return len(seen)
|
||||
}
|
||||
|
||||
// BlockedTasks counts distinct tasks this shape actually stopped.
|
||||
func (d DebtItem) BlockedTasks() int {
|
||||
seen := map[string]bool{}
|
||||
for _, o := range d.Observations {
|
||||
if o.Kind == ObservationBlockReason && o.TaskID != "" {
|
||||
seen[o.TaskID] = true
|
||||
}
|
||||
}
|
||||
return len(seen)
|
||||
}
|
||||
|
||||
// ManualInterventions counts recorded operator repairs. It reads zero on any
|
||||
// log written before that recording exists, which is why the ledger reports it
|
||||
// as an evidence gap rather than as an absence of operator cost.
|
||||
func (d DebtItem) ManualInterventions() int {
|
||||
n := 0
|
||||
for _, o := range d.Observations {
|
||||
if o.Kind == ObservationManualIntervention {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// Components is the breadth of the shape across the tree.
|
||||
func (d DebtItem) Components() []string {
|
||||
seen := map[string]bool{}
|
||||
out := []string{}
|
||||
for _, o := range d.Observations {
|
||||
for _, p := range o.Paths {
|
||||
if c := DebtComponent(p); c != "" && !seen[c] {
|
||||
seen[c] = true
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Imported reports whether any evidence here came from a markdown ledger
|
||||
// rather than from an event. A reader can then tell a counted fact from an
|
||||
// imported claim without opening the observations.
|
||||
func (d DebtItem) Imported() bool {
|
||||
for _, o := range d.Observations {
|
||||
if o.LegacyRef != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// EvidenceGap is a debt signal the ledger knows it cannot see. Reporting the
|
||||
// gap is the point: a ledger that silently omits what it cannot record reads
|
||||
// as "no operator cost" when it means "operator cost is not recorded".
|
||||
type EvidenceGap struct {
|
||||
Kind ObservationKind `json:"kind"`
|
||||
Reason string `json:"reason"`
|
||||
// Durable is false when no event type carries this evidence at all. It is
|
||||
// true when the log could carry it and this particular log does not.
|
||||
Durable bool `json:"durable"`
|
||||
}
|
||||
|
||||
// DebtLedger is the whole read model: what history establishes, and what it
|
||||
// cannot.
|
||||
type DebtLedger struct {
|
||||
Items []DebtItem `json:"items"`
|
||||
Gaps []EvidenceGap `json:"gaps"`
|
||||
// Events is how many log entries the projection folded, so a caller can
|
||||
// tell an empty ledger from an unread log.
|
||||
Events int `json:"events"`
|
||||
}
|
||||
|
||||
// DebtClassForBlockReason maps a typed block reason to a class. A reason that
|
||||
// is a normal lifecycle stop rather than a cost returns false: waiting for a
|
||||
// human decision is the system working, not debt.
|
||||
func DebtClassForBlockReason(r BlockReason) (DebtClass, bool) {
|
||||
switch r {
|
||||
case BlockReasonLeaseFailure, BlockReasonWorkerOffline, BlockReasonLeaseExpired, BlockReasonSystem:
|
||||
return DebtOperational, true
|
||||
case BlockReasonHandoffValidation, BlockReasonPlanMismatch:
|
||||
return DebtCorrectness, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// DebtClassForFailureClass maps a worker failure class to a class. Only the
|
||||
// classes a worker actually emits are listed; an unknown one is not guessed at.
|
||||
func DebtClassForFailureClass(f string) (DebtClass, bool) {
|
||||
switch f {
|
||||
case "retry_limit", "launch_failed", "launch_transient", "launch_uncertain", "prompt_not_submitted", "lease_expired", "handoff_unanswered":
|
||||
return DebtOperational, true
|
||||
case "invalid_handoff":
|
||||
return DebtCorrectness, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package domain
|
||||
|
||||
import "testing"
|
||||
|
||||
// Normalization rules will change. A v2 signature must never match a v1 one,
|
||||
// or changing them silently regroups history and moves the recurrence counts
|
||||
// eligibility was already decided on.
|
||||
func TestSignatureCarriesItsVersion(t *testing.T) {
|
||||
sig := DebtSignature(DebtOperational, "lease_expired", "workpc-opencode", "internal/herdr")
|
||||
if sig != "v1:operational:lease_expired:workpc-opencode:internal/herdr" {
|
||||
t.Fatalf("signature %q", sig)
|
||||
}
|
||||
// Arity never varies, so a missing part cannot shift the fields left.
|
||||
if got := DebtSignature(DebtPolish, "deferred_finding", "", ""); got != "v1:polish:deferred_finding:-:-" {
|
||||
t.Fatalf("empty parts not padded: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComponentIsTwoSegments(t *testing.T) {
|
||||
for path, want := range map[string]string{
|
||||
"internal/store/store.go": "internal/store",
|
||||
"internal/herdr/adapter.go": "internal/herdr",
|
||||
"cmd/orchestra-worker/main.go": "cmd/orchestra-worker",
|
||||
"BURNIN.md": "BURNIN.md",
|
||||
"": "",
|
||||
} {
|
||||
if got := DebtComponent(path); got != want {
|
||||
t.Fatalf("component(%q) = %q, want %q", path, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Imported history predates the events that would have justified it. A
|
||||
// fabricated event id would break the provenance rule the ledger enforces.
|
||||
func TestObservationNeedsExactlyOneProvenance(t *testing.T) {
|
||||
sig := DebtSignature(DebtOperational, "x", "", "")
|
||||
both := DebtObservation{EventID: "e1", LegacyRef: "BURNIN.md:F18", Kind: ObservationBlockReason, Signature: sig}
|
||||
neither := DebtObservation{Kind: ObservationBlockReason, Signature: sig}
|
||||
if both.Validate() == nil || neither.Validate() == nil {
|
||||
t.Fatal("exactly one of event_id and legacy_ref must be required")
|
||||
}
|
||||
for _, ok := range []DebtObservation{
|
||||
{EventID: "e1", Kind: ObservationBlockReason, Signature: sig},
|
||||
{LegacyRef: "BURNIN.md:F18", Kind: ObservationBlockReason, Signature: sig},
|
||||
} {
|
||||
if err := ok.Validate(); err != nil {
|
||||
t.Fatalf("valid observation refused: %v", err)
|
||||
}
|
||||
}
|
||||
stale := DebtObservation{EventID: "e1", Kind: ObservationBlockReason, Signature: "v0:operational:x:-:-"}
|
||||
if stale.Validate() == nil {
|
||||
t.Fatal("a signature from another version must be refused")
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,23 @@ const (
|
||||
BlockReasonUnknown BlockReason = "unknown"
|
||||
)
|
||||
|
||||
// BlockReasonAwaitsReply reports whether this stop is a question waiting on a
|
||||
// human, rather than a fault or a budget. Every reason listed here resumes on
|
||||
// a reply, so both the loop that goes and reads replies and the loop that
|
||||
// returns answered tasks to the queue must agree on the set. They did not:
|
||||
// plan_mismatch blocked for an answer that nothing ever went to fetch, and
|
||||
// nothing would have resumed it if it had (F64).
|
||||
//
|
||||
// operator_required is deliberately absent. A task that has spent its question
|
||||
// budget continues when an operator says so, not when someone replies.
|
||||
func BlockReasonAwaitsReply(r BlockReason) bool {
|
||||
switch r {
|
||||
case BlockReasonHumanDecision, BlockReasonTrajectoryGate, BlockReasonPlanMismatch:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (r BlockReason) Valid() bool {
|
||||
switch r {
|
||||
case BlockReasonLeaseFailure, BlockReasonWorkerOffline, BlockReasonLeaseExpired,
|
||||
@@ -168,19 +185,19 @@ type Task struct {
|
||||
QualityGate string `json:"quality_gate,omitempty"`
|
||||
// Block evidence is projected from TaskBlocked so terminal records remain
|
||||
// diagnosable after the live coordinator mapping is gone.
|
||||
Blocker string `json:"blocker,omitempty"`
|
||||
BlockReason BlockReason `json:"block_reason,omitempty"`
|
||||
BlockedAt time.Time `json:"blocked_at,omitempty"`
|
||||
LastPaneID string `json:"last_pane_id,omitempty"`
|
||||
LastHarness string `json:"last_harness_id,omitempty"`
|
||||
Blocker string `json:"blocker,omitempty"`
|
||||
BlockReason BlockReason `json:"block_reason,omitempty"`
|
||||
BlockedAt time.Time `json:"blocked_at,omitempty"`
|
||||
LastPaneID string `json:"last_pane_id,omitempty"`
|
||||
LastHarness string `json:"last_harness_id,omitempty"`
|
||||
// LastLeaseEpoch is the fencing token of the lease that most recently
|
||||
// ended. A worker can push its release anchor and only then discover the
|
||||
// lease expired; the finished work is durable in git but the commit can
|
||||
// never land. Retaining the epoch lets exactly that owner still commit
|
||||
// while the task sits unleased.
|
||||
LastLeaseEpoch string `json:"last_lease_epoch,omitempty"`
|
||||
PaneState string `json:"pane_state,omitempty"` // open, closed, unreachable, unknown
|
||||
LastSession SessionEvidence `json:"last_session,omitempty"`
|
||||
LastLeaseEpoch string `json:"last_lease_epoch,omitempty"`
|
||||
PaneState string `json:"pane_state,omitempty"` // open, closed, unreachable, unknown
|
||||
LastSession SessionEvidence `json:"last_session,omitempty"`
|
||||
// Recovery state is part of the durable projection, never process-local
|
||||
// router memory. This makes retry and operator diagnostics survive a
|
||||
// coordinator restart.
|
||||
@@ -217,6 +234,13 @@ type Task struct {
|
||||
// current one, oldest first. A superseded plan stays queryable: the
|
||||
// verification recorded against it is provenance, not garbage.
|
||||
PlanHistory []string `json:"plan_history,omitempty"`
|
||||
// PlanMismatch is the contradiction that reopened this task's phase. The
|
||||
// planning session convened by a replan has to be told what it is there to
|
||||
// fix, and the implementer that found it is gone by then (F66). It is
|
||||
// cleared when a replacement plan is accepted, or when the stop it caused
|
||||
// is answered, because after either it is history rather than a live
|
||||
// instruction.
|
||||
PlanMismatch *PlanMismatch `json:"plan_mismatch,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
}
|
||||
|
||||
@@ -269,6 +293,25 @@ func NewID() string {
|
||||
_, _ = rand.Read(b[6:])
|
||||
return ulidEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// EventWithoutTask reports whether an event records something about the
|
||||
// system rather than about one task's lifecycle. These carry the "system"
|
||||
// aggregate id, so no task projection has to exist for them.
|
||||
//
|
||||
// One list, because there were three: the replay guard, the append guard and
|
||||
// the transition check each kept their own copy, and adding an event type to
|
||||
// two of them left it rejected by the third.
|
||||
func EventWithoutTask(typ string) bool {
|
||||
switch typ {
|
||||
case "QuotaReported", "StandupAdvisory", "ApprovalGranted", "ApprovalDenied",
|
||||
EventObservationIncidentOpened, EventObservationIncidentClosed:
|
||||
// An intervention is deliberately absent: it names a task when it
|
||||
// repaired one, and that task must exist.
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ValidateEvent(e Event) error {
|
||||
if e.SchemaVersion > CurrentEventSchema || e.Type == "" || e.TaskID == "" || len(e.Payload) == 0 || len(e.Payload) > 64*1024 {
|
||||
return ErrInvalid
|
||||
@@ -276,7 +319,7 @@ func ValidateEvent(e Event) error {
|
||||
if e.SchemaVersion >= 2 && strings.TrimSpace(e.Surface) == "" {
|
||||
return fmt.Errorf("%w: surface required", ErrInvalid)
|
||||
}
|
||||
allowed := map[string]bool{"TaskCreated": true, "TaskLeased": true, "TaskLeaseRenewed": true, "TaskReleased": true, "TaskLaunchAcknowledged": true, "TaskPickupValidated": true, "TaskCompleted": true, "TaskFailed": true, "TaskBlocked": true, "TaskNeedsAttention": true, "ApprovalRequested": true, "ApprovalGranted": true, "ApprovalDenied": true, "TaskAmended": true, "TaskCorrected": true, "QuotaReported": true, "StandupAdvisory": true, EventHumanDecisionRecorded: true, EventHumanDecisionSuperseded: true, EventWorkPhaseChanged: true, EventDeferredFindingRecorded: true, EventReviewRecorded: true, EventTaskSubmitted: true, EventTaskChangesRequested: true, EventPlanPhaseVerified: true, EventPlanMismatchRecorded: true}
|
||||
allowed := map[string]bool{"TaskCreated": true, "TaskLeased": true, "TaskLeaseRenewed": true, "TaskReleased": true, "TaskLaunchAcknowledged": true, "TaskPickupValidated": true, "TaskCompleted": true, "TaskFailed": true, "TaskBlocked": true, "TaskNeedsAttention": true, "ApprovalRequested": true, "ApprovalGranted": true, "ApprovalDenied": true, "TaskAmended": true, "TaskCorrected": true, "QuotaReported": true, "StandupAdvisory": true, EventHumanDecisionRecorded: true, EventHumanDecisionSuperseded: true, EventWorkPhaseChanged: true, EventDeferredFindingRecorded: true, EventReviewRecorded: true, EventTaskSubmitted: true, EventTaskChangesRequested: true, EventPlanPhaseVerified: true, EventPlanMismatchRecorded: true, EventObservationIncidentOpened: true, EventObservationIncidentClosed: true, EventOperatorInterventionRecorded: true}
|
||||
if !allowed[e.Type] {
|
||||
return fmt.Errorf("%w: unknown type %q", ErrInvalid, e.Type)
|
||||
}
|
||||
@@ -558,6 +601,12 @@ func ValidatePayload(typ string, p map[string]any) error {
|
||||
return ValidatePlanPhaseVerified(p)
|
||||
case EventPlanMismatchRecorded:
|
||||
return ValidatePlanMismatchRecorded(p)
|
||||
case EventObservationIncidentOpened:
|
||||
return ValidateObservationIncidentOpened(p)
|
||||
case EventObservationIncidentClosed:
|
||||
return ValidateObservationIncidentClosed(p)
|
||||
case EventOperatorInterventionRecorded:
|
||||
return ValidateOperatorInterventionRecorded(p)
|
||||
case EventReviewRecorded:
|
||||
if err := requiredHash(p, "artifact_ref"); err != nil {
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// EventOperatorInterventionRecorded is a human saying what they repaired by
|
||||
// hand. Nothing infers it: a manual fix happens outside Orchestra by
|
||||
// definition, so the only honest way to have the evidence is for the operator
|
||||
// to state it. Without this the debt ledger reported manual recovery as a
|
||||
// permanent gap, and every repair that kept the system running was invisible
|
||||
// to the record of how much the system costs to run.
|
||||
const EventOperatorInterventionRecorded = "OperatorInterventionRecorded"
|
||||
|
||||
// InterventionKind is what the operator did. The list is closed so the ledger
|
||||
// can group repairs; an unrecognised kind is refused rather than guessed at.
|
||||
type InterventionKind string
|
||||
|
||||
const (
|
||||
InterventionWorkerRestart InterventionKind = "worker_restart"
|
||||
InterventionTransactionClean InterventionKind = "transaction_cleanup"
|
||||
InterventionForcedRelease InterventionKind = "forced_release"
|
||||
InterventionStateRepair InterventionKind = "state_repair"
|
||||
InterventionManualRequeue InterventionKind = "manual_requeue"
|
||||
InterventionPhaseRecovery InterventionKind = "manual_phase_recovery"
|
||||
)
|
||||
|
||||
func (k InterventionKind) Valid() bool {
|
||||
switch k {
|
||||
case InterventionWorkerRestart, InterventionTransactionClean, InterventionForcedRelease,
|
||||
InterventionStateRepair, InterventionManualRequeue, InterventionPhaseRecovery:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// OperatorIntervention is one recorded manual repair.
|
||||
type OperatorIntervention struct {
|
||||
TaskID string `json:"task_id,omitempty"`
|
||||
WorkerID string `json:"worker_id,omitempty"`
|
||||
Kind InterventionKind `json:"kind"`
|
||||
// Reason is the operator's own account of why it was needed. It is the
|
||||
// part a later reader cannot reconstruct from anything else.
|
||||
Reason string `json:"reason"`
|
||||
// RelatedEventID and RelatedTransactionID point at what was repaired, so a
|
||||
// reader can find the failure this answered rather than infer it.
|
||||
RelatedEventID string `json:"related_event_id,omitempty"`
|
||||
RelatedTransactionID string `json:"related_transaction_id,omitempty"`
|
||||
Components []string `json:"components,omitempty"`
|
||||
At time.Time `json:"at,omitempty"`
|
||||
}
|
||||
|
||||
const maxInterventionReason = 1000
|
||||
|
||||
func (i OperatorIntervention) Validate() error {
|
||||
if !i.Kind.Valid() {
|
||||
return fmt.Errorf("%w: %q is not an intervention kind", ErrInvalid, i.Kind)
|
||||
}
|
||||
if strings.TrimSpace(i.Reason) == "" {
|
||||
return fmt.Errorf("%w: an intervention states why it was needed", ErrInvalid)
|
||||
}
|
||||
if len(i.Reason) > maxInterventionReason {
|
||||
return fmt.Errorf("%w: reason exceeds %d characters", ErrInvalid, maxInterventionReason)
|
||||
}
|
||||
if i.TaskID == "" && i.WorkerID == "" {
|
||||
return fmt.Errorf("%w: an intervention names the task or the worker it repaired", ErrInvalid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateOperatorInterventionRecorded(p map[string]any) error {
|
||||
kind, _ := p["kind"].(string)
|
||||
reason, _ := p["reason"].(string)
|
||||
task, _ := p["task_id"].(string)
|
||||
worker, _ := p["worker_id"].(string)
|
||||
return OperatorIntervention{
|
||||
Kind: InterventionKind(kind), Reason: reason, TaskID: task, WorkerID: worker,
|
||||
}.Validate()
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A worker's observation ring is bounded, lossy, and local: it holds distinct
|
||||
// failure messages with repeat counts and nothing else, and it disappears when
|
||||
// the process does. The debt ledger reported that gap about itself, because no
|
||||
// event carried any of it.
|
||||
//
|
||||
// These two events make it durable as incidents rather than as symptoms. Run
|
||||
// 11 saw the same 409 refusal 301 times; that is one incident with an
|
||||
// intensity of 301, not 301 pieces of evidence. Recurrence has to mean "this
|
||||
// happened on four independent leases", or one stuck loop makes everything
|
||||
// look chronic.
|
||||
const (
|
||||
EventObservationIncidentOpened = "ObservationIncidentOpened"
|
||||
EventObservationIncidentClosed = "ObservationIncidentClosed"
|
||||
)
|
||||
|
||||
// ObservationCloseReason is why Orchestra finalized an incident. None of them
|
||||
// is "the message stopped appearing in the ring": the ring is a bounded
|
||||
// history, so absence proves eviction as easily as recovery.
|
||||
type ObservationCloseReason string
|
||||
|
||||
const (
|
||||
// ObservationCloseLeaseEnd and ObservationCloseEpochChange are the natural
|
||||
// boundaries of a lease-scoped incident. The work it was about is over.
|
||||
ObservationCloseLeaseEnd ObservationCloseReason = "lease_end"
|
||||
ObservationCloseEpochChange ObservationCloseReason = "epoch_change"
|
||||
// ObservationCloseWorkerRestart ends every incident of an incarnation. A
|
||||
// new process cannot continue the old one's symptom.
|
||||
ObservationCloseWorkerRestart ObservationCloseReason = "worker_restart"
|
||||
// ObservationCloseQuietTimeout is the only closer for an observation with
|
||||
// no lease to bound it, and it fires on last_seen going stale rather than
|
||||
// on the entry vanishing.
|
||||
ObservationCloseQuietTimeout ObservationCloseReason = "quiet_timeout"
|
||||
)
|
||||
|
||||
func (r ObservationCloseReason) Valid() bool {
|
||||
switch r {
|
||||
case ObservationCloseLeaseEnd, ObservationCloseEpochChange, ObservationCloseWorkerRestart, ObservationCloseQuietTimeout:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// WorkerObservation is one entry of a worker's ring as reported on a
|
||||
// heartbeat. It is the input to the incident projection, never a stored event.
|
||||
type WorkerObservation struct {
|
||||
Message string `json:"message"`
|
||||
Count int `json:"count"`
|
||||
First time.Time `json:"first"`
|
||||
Last time.Time `json:"last"`
|
||||
}
|
||||
|
||||
// ObservationIncident is one durable incident: a signature seen by one worker,
|
||||
// on one lease when there is one, from its first occurrence to the boundary
|
||||
// that ended it.
|
||||
type ObservationIncident struct {
|
||||
ID string `json:"observation_id"`
|
||||
WorkerID string `json:"worker_id"`
|
||||
Incarnation string `json:"incarnation,omitempty"`
|
||||
TaskID string `json:"task_id,omitempty"`
|
||||
LeaseEpoch string `json:"lease_epoch,omitempty"`
|
||||
// Signature is the message with its task ids, commit shas, paths and
|
||||
// durations replaced, so the same failure on two tasks shares it. Grouping
|
||||
// on the raw message would make every task its own kind of problem.
|
||||
Signature string `json:"signature"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
FirstSeen time.Time `json:"first_seen"`
|
||||
// LastSeen is the last actual occurrence. ClosedAt is when Orchestra
|
||||
// finalized the incident, which is later and often much later: an incident
|
||||
// stays open until its lease ends, and open means "not yet final evidence"
|
||||
// rather than "happening right now".
|
||||
LastSeen time.Time `json:"last_seen,omitempty"`
|
||||
ClosedAt time.Time `json:"closed_at,omitempty"`
|
||||
RepeatCount int `json:"repeat_count,omitempty"`
|
||||
CloseReason ObservationCloseReason `json:"close_reason,omitempty"`
|
||||
}
|
||||
|
||||
// Key identifies an incident. Two workers reporting the same failure are two
|
||||
// incidents, and so are two leases of one task.
|
||||
func (i ObservationIncident) Key() string {
|
||||
return strings.Join([]string{i.WorkerID, i.TaskID, i.LeaseEpoch, i.Signature}, "\x00")
|
||||
}
|
||||
|
||||
var (
|
||||
// Case-insensitive: a task id appears upper-case in a message and
|
||||
// lower-case inside a pane name, and the live ledger's first run showed
|
||||
// pane names keeping their task, which would give the same failure a
|
||||
// different signature on every task.
|
||||
observationID = regexp.MustCompile(`(?i)\b[0-9A-HJKMNP-TV-Z]{26}\b`)
|
||||
observationSHA = regexp.MustCompile(`\b[0-9a-f]{7,64}\b`)
|
||||
observationDuration = regexp.MustCompile(`\b\d+(\.\d+)?(ns|us|µs|ms|s|m|h)(\d+(\.\d+)?(ns|us|µs|ms|s|m|h))*\b`)
|
||||
observationNumber = regexp.MustCompile(`\b\d+\b`)
|
||||
observationPath = regexp.MustCompile(`(/[\w.-]+){2,}`)
|
||||
)
|
||||
|
||||
// ObservationSignature collapses one message to the kind of failure it is.
|
||||
// "lease A not renewed" and "lease B not renewed" are the same problem seen
|
||||
// twice, which is the whole basis of counting recurrence across tasks.
|
||||
func ObservationSignature(message string) string {
|
||||
s := strings.TrimSpace(message)
|
||||
s = observationID.ReplaceAllString(s, "<id>")
|
||||
s = observationPath.ReplaceAllString(s, "<path>")
|
||||
s = observationDuration.ReplaceAllString(s, "<dur>")
|
||||
s = observationSHA.ReplaceAllString(s, "<sha>")
|
||||
s = observationNumber.ReplaceAllString(s, "<n>")
|
||||
s = strings.Join(strings.Fields(s), " ")
|
||||
if len(s) > 200 {
|
||||
s = s[:200]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ObservationTaskID reads the task a failure was about out of the message
|
||||
// itself. The ring is a history: it holds entries from tasks that ended long
|
||||
// ago, so the worker's currently active task is the wrong answer for most of
|
||||
// them, and attributing an old failure to whatever is running now would be a
|
||||
// fabricated association.
|
||||
func ObservationTaskID(message string) string {
|
||||
if m := observationTaskID.FindString(message); m != "" {
|
||||
return strings.ToUpper(m)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var observationTaskID = regexp.MustCompile(`(?i)\b[0-9A-HJKMNP-TV-Z]{26}\b`)
|
||||
|
||||
func ValidateObservationIncidentOpened(p map[string]any) error {
|
||||
if id, _ := p["observation_id"].(string); strings.TrimSpace(id) == "" {
|
||||
return fmt.Errorf("%w: observation_id required", ErrInvalid)
|
||||
}
|
||||
if w, _ := p["worker_id"].(string); strings.TrimSpace(w) == "" {
|
||||
return fmt.Errorf("%w: worker_id required", ErrInvalid)
|
||||
}
|
||||
if sig, _ := p["signature"].(string); strings.TrimSpace(sig) == "" {
|
||||
return fmt.Errorf("%w: signature required", ErrInvalid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateObservationIncidentClosed(p map[string]any) error {
|
||||
if id, _ := p["observation_id"].(string); strings.TrimSpace(id) == "" {
|
||||
return fmt.Errorf("%w: observation_id required", ErrInvalid)
|
||||
}
|
||||
reason, _ := p["close_reason"].(string)
|
||||
if !ObservationCloseReason(reason).Valid() {
|
||||
return fmt.Errorf("%w: close_reason %q is not a close reason", ErrInvalid, reason)
|
||||
}
|
||||
if c, ok := p["repeat_count"].(float64); ok && c < 0 {
|
||||
return fmt.Errorf("%w: repeat_count cannot be negative", ErrInvalid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -55,6 +55,13 @@ type PlanPhaseRecord struct {
|
||||
// EvidenceRef is the CAS ref of the captured command output.
|
||||
EvidenceRef string `json:"evidence_ref,omitempty"`
|
||||
At time.Time `json:"at"`
|
||||
// ManualAtSHA is the tree a human was actually looking at when they signed
|
||||
// this phase off. A manual check on most projects is a human reading
|
||||
// output, so a sign-off establishes something about one tree and nothing
|
||||
// about the next one (F63). Rerunning the automated half re-establishes it
|
||||
// at the new commit; the manual half has to be given again, and this is
|
||||
// what makes the difference visible instead of assumed.
|
||||
ManualAtSHA string `json:"manual_at_sha,omitempty"`
|
||||
}
|
||||
|
||||
// Stale reports whether the tree has moved since this phase was verified. A
|
||||
|
||||
@@ -21,10 +21,14 @@ type Worker struct {
|
||||
Capacity int `json:"capacity"`
|
||||
SupportedProjects []string `json:"supported_projects"`
|
||||
Build buildinfo.Info `json:"build"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
Online bool `json:"online"`
|
||||
Health WorkerHealth `json:"health"`
|
||||
Token string `json:"-"`
|
||||
// Incarnation identifies one worker process. Nothing else on the wire
|
||||
// distinguishes a restarted worker from a running one, and an observation
|
||||
// incident cannot outlive the process that reported it.
|
||||
Incarnation string `json:"incarnation,omitempty"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
Online bool `json:"online"`
|
||||
Health WorkerHealth `json:"health"`
|
||||
Token string `json:"-"`
|
||||
}
|
||||
|
||||
// WorkerHealth is reported by the worker that owns the local execution backend.
|
||||
@@ -42,6 +46,9 @@ type WorkerHealth struct {
|
||||
// Observations is the bounded set of distinct failures behind LastError,
|
||||
// which keeps its wire name and still reports only the newest.
|
||||
Observations []Observation `json:"observations,omitempty"`
|
||||
// Incarnation repeats the worker's process identity on every heartbeat, so
|
||||
// the coordinator sees a restart even if it missed the registration.
|
||||
Incarnation string `json:"incarnation,omitempty"`
|
||||
}
|
||||
|
||||
// Observation is one distinct worker failure with its repeat count. A single
|
||||
|
||||
@@ -174,6 +174,14 @@ type Session struct {
|
||||
// its §6.1 handoff (HandoffFile) — avoids re-sending the same prompt
|
||||
// every tick while Release keeps waiting for the file to appear.
|
||||
HandoffRequested bool `json:"handoff_requested,omitempty"`
|
||||
// HandoffRequestedAt stamps that prompt. A request nobody answers used to
|
||||
// end as an ordinary idle expiry, indistinguishable from an agent that
|
||||
// never started (F62); the stamp is what makes the wait bounded and the
|
||||
// giving-up causal.
|
||||
HandoffRequestedAt time.Time `json:"handoff_requested_at,omitempty"`
|
||||
// HandoffRetried records that the request was re-sent once, so a session
|
||||
// waiting on an answer is not re-prompted every tick.
|
||||
HandoffRetried bool `json:"handoff_retried,omitempty"`
|
||||
// HandoffReason is selected by the coordinator when it asks for the
|
||||
// semantic report. The checkout worker, rather than the harness, copies
|
||||
// it into the canonical handoff it seals at release time.
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package operations
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"orchestra/internal/domain"
|
||||
)
|
||||
|
||||
// DebtCheck is why a debt item may or may not become work. Reasons are listed
|
||||
// rather than summarised, for the same reason SubmissionCheck lists them: "not
|
||||
// eligible" alone sends an operator reading code.
|
||||
type DebtCheck struct {
|
||||
Eligible bool `json:"eligible"`
|
||||
Reasons []string `json:"reasons,omitempty"`
|
||||
}
|
||||
|
||||
// CheckDebtEligibility is the whole promotion rule, as one pure function of a
|
||||
// projected item. It decides only whether a maintenance task may be created.
|
||||
// It never creates one, and it never changes what that task must then pass:
|
||||
// classification as debt changes what gets scheduled, never what gets checked.
|
||||
//
|
||||
// The thresholds differ by class on purpose. Correctness and operational debt
|
||||
// have already cost something measurable. Structural debt needs evidence that
|
||||
// it is causing repeated work rather than merely offending taste. Polish never
|
||||
// promotes itself, or the ledger becomes a permanent cleanup generator.
|
||||
func CheckDebtEligibility(item domain.DebtItem) DebtCheck {
|
||||
recurrence, tasks := item.Recurrence(), item.AffectedTasks()
|
||||
blocked, manual := item.BlockedTasks(), item.ManualInterventions()
|
||||
switch item.Class {
|
||||
case domain.DebtCorrectness:
|
||||
if recurrence >= 1 {
|
||||
return DebtCheck{true, []string{fmt.Sprintf("correctness debt is eligible on first confirmed observation, and has %d", recurrence)}}
|
||||
}
|
||||
return DebtCheck{false, []string{"no confirmed observation"}}
|
||||
case domain.DebtOperational:
|
||||
var why []string
|
||||
if manual >= 1 {
|
||||
why = append(why, fmt.Sprintf("%d manual intervention(s) recorded", manual))
|
||||
}
|
||||
if recurrence >= 3 && tasks >= 2 {
|
||||
why = append(why, fmt.Sprintf("recurred %d times across %d tasks", recurrence, tasks))
|
||||
}
|
||||
if len(why) > 0 {
|
||||
return DebtCheck{true, why}
|
||||
}
|
||||
return DebtCheck{false, []string{
|
||||
fmt.Sprintf("needs 3 occurrences across 2 tasks, or 1 manual intervention; has %d across %d tasks with %d interventions", recurrence, tasks, manual),
|
||||
"manual interventions are not recorded by any event type, so that count reads 0 on every current log",
|
||||
}}
|
||||
case domain.DebtStructural:
|
||||
if recurrence >= 3 {
|
||||
return DebtCheck{true, []string{fmt.Sprintf("%d review findings in this component", recurrence)}}
|
||||
}
|
||||
if blocked >= 2 {
|
||||
return DebtCheck{true, []string{fmt.Sprintf("blocked %d distinct tasks in this component", blocked)}}
|
||||
}
|
||||
return DebtCheck{false, []string{
|
||||
fmt.Sprintf("needs 3 review findings or 2 blocked tasks in one component; has %d findings and %d blocked", recurrence, blocked),
|
||||
}}
|
||||
case domain.DebtPolish:
|
||||
return DebtCheck{false, []string{"polish never promotes itself, an operator promotes it explicitly"}}
|
||||
}
|
||||
return DebtCheck{false, []string{"unknown debt class " + string(item.Class)}}
|
||||
}
|
||||
|
||||
// EligibleDebt filters a projected ledger to what policy would allow to become
|
||||
// work. It returns the check alongside each item so the reasons stay visible.
|
||||
func EligibleDebt(ledger domain.DebtLedger) []DebtCandidate {
|
||||
out := []DebtCandidate{}
|
||||
for _, item := range ledger.Items {
|
||||
if check := CheckDebtEligibility(item); check.Eligible {
|
||||
out = append(out, DebtCandidate{Item: item, Check: check})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type DebtCandidate struct {
|
||||
Item domain.DebtItem `json:"item"`
|
||||
Check DebtCheck `json:"check"`
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package operations
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"orchestra/internal/domain"
|
||||
)
|
||||
|
||||
func debtItem(class domain.DebtClass, kinds ...struct {
|
||||
kind domain.ObservationKind
|
||||
task string
|
||||
}) domain.DebtItem {
|
||||
item := domain.DebtItem{Class: class}
|
||||
for _, k := range kinds {
|
||||
item.Observations = append(item.Observations, domain.DebtObservation{Kind: k.kind, TaskID: k.task})
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
type obs = struct {
|
||||
kind domain.ObservationKind
|
||||
task string
|
||||
}
|
||||
|
||||
// The thresholds are the whole policy, so each class gets its own case. Polish
|
||||
// is the one that must never pass, or the ledger becomes a cleanup generator.
|
||||
func TestDebtEligibilityPerClass(t *testing.T) {
|
||||
fail := obs{domain.ObservationFailureClass, "t1"}
|
||||
if !CheckDebtEligibility(debtItem(domain.DebtCorrectness, fail)).Eligible {
|
||||
t.Fatal("correctness debt is eligible on first observation")
|
||||
}
|
||||
twice := debtItem(domain.DebtOperational, fail, obs{domain.ObservationFailureClass, "t1"})
|
||||
if CheckDebtEligibility(twice).Eligible {
|
||||
t.Fatal("two occurrences on one task must not qualify as operational debt")
|
||||
}
|
||||
spread := debtItem(domain.DebtOperational, fail,
|
||||
obs{domain.ObservationFailureClass, "t2"}, obs{domain.ObservationFailureClass, "t3"})
|
||||
if !CheckDebtEligibility(spread).Eligible {
|
||||
t.Fatal("three occurrences across three tasks must qualify")
|
||||
}
|
||||
manual := debtItem(domain.DebtOperational, obs{domain.ObservationManualIntervention, "t1"})
|
||||
if !CheckDebtEligibility(manual).Eligible {
|
||||
t.Fatal("one manual intervention must qualify on its own")
|
||||
}
|
||||
blocked := debtItem(domain.DebtStructural,
|
||||
obs{domain.ObservationBlockReason, "t1"}, obs{domain.ObservationBlockReason, "t2"})
|
||||
if !CheckDebtEligibility(blocked).Eligible {
|
||||
t.Fatal("two blocked tasks in one component must qualify as structural")
|
||||
}
|
||||
polish := debtItem(domain.DebtPolish, fail, fail, fail, fail, fail)
|
||||
if CheckDebtEligibility(polish).Eligible {
|
||||
t.Fatal("polish must never promote itself, at any recurrence")
|
||||
}
|
||||
}
|
||||
|
||||
// A refusal has to say what is missing. "Not eligible" alone sends an operator
|
||||
// reading code, which is the mistake SubmissionCheck already documents.
|
||||
func TestDebtRefusalNamesTheMissingEvidence(t *testing.T) {
|
||||
check := CheckDebtEligibility(debtItem(domain.DebtOperational, obs{domain.ObservationFailureClass, "t1"}))
|
||||
if check.Eligible || len(check.Reasons) == 0 {
|
||||
t.Fatalf("want a refusal with reasons, got %+v", check)
|
||||
}
|
||||
joined := strings.Join(check.Reasons, " ")
|
||||
if !strings.Contains(joined, "manual interventions are not recorded") {
|
||||
t.Fatalf("the refusal must say the intervention count is structurally zero: %v", check.Reasons)
|
||||
}
|
||||
}
|
||||
@@ -58,18 +58,27 @@ func RequestHumanDecision(s *store.Store, project registry.Project, taskID strin
|
||||
return blockTask(s, t, domain.BlockReasonHumanDecision, req.Render(), &req)
|
||||
}
|
||||
|
||||
// fenceToLease binds a lifecycle event to the lease that is producing it.
|
||||
// Store.Append fences every lifecycle event on a leased task against the
|
||||
// current owner and epoch, so an event that omits them is a conflict rather
|
||||
// than a block. Every coordinator-side stop goes through here: two of them did
|
||||
// not, and both failed silently against a live lease (F65). A task stops for a
|
||||
// human only while some session is running, so the leased case is the only one
|
||||
// that ever mattered.
|
||||
func fenceToLease(payload map[string]any, t domain.Task) {
|
||||
if t.Lease == nil {
|
||||
return
|
||||
}
|
||||
payload["harness_id"] = t.Lease.HarnessID
|
||||
payload["lease_epoch"] = t.Lease.Epoch
|
||||
}
|
||||
|
||||
func blockTask(s *store.Store, t domain.Task, reason domain.BlockReason, blocker string, req *domain.DecisionRequest) (domain.Event, error) {
|
||||
payload := map[string]any{
|
||||
"blocker": blocker, "block_reason": string(reason),
|
||||
"lifecycle_phase": "awaiting_human",
|
||||
}
|
||||
if t.Lease != nil {
|
||||
// Store.Append fences every lifecycle event on a leased task against
|
||||
// the current owner and epoch. A question from a session that no
|
||||
// longer owns the task is a conflict, not a block.
|
||||
payload["harness_id"] = t.Lease.HarnessID
|
||||
payload["lease_epoch"] = t.Lease.Epoch
|
||||
}
|
||||
fenceToLease(payload, t)
|
||||
if req != nil {
|
||||
payload["decision_request"] = req
|
||||
}
|
||||
@@ -107,11 +116,9 @@ func ResumeAnsweredBlockers(s *store.Store) ([]domain.Event, error) {
|
||||
if t.State != domain.StateBlocked {
|
||||
continue
|
||||
}
|
||||
switch t.BlockReason {
|
||||
case domain.BlockReasonHumanDecision, domain.BlockReasonTrajectoryGate:
|
||||
default:
|
||||
// operator_required is deliberately not resumed by a reply. An
|
||||
// operator decides when a task that spent its budget continues.
|
||||
// operator_required is deliberately not resumed by a reply. An
|
||||
// operator decides when a task that spent its budget continues.
|
||||
if !domain.BlockReasonAwaitsReply(t.BlockReason) {
|
||||
continue
|
||||
}
|
||||
if !blockerAnswered(s, t.ID, t.BlockReason) {
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
package operations
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"orchestra/internal/authz"
|
||||
"orchestra/internal/domain"
|
||||
"orchestra/internal/store"
|
||||
)
|
||||
|
||||
// QuietTimeout bounds an incident that has no lease to bound it. A worker-level
|
||||
// observation has no terminal boundary of its own, so staleness of its last
|
||||
// actual occurrence is the only honest closer.
|
||||
const QuietTimeout = 10 * time.Minute
|
||||
|
||||
// ObservationTracker turns a worker's bounded, lossy ring into durable
|
||||
// incidents. It is deliberately not a copy of the ring.
|
||||
//
|
||||
// The rules that matter, and why:
|
||||
//
|
||||
// - An incident is opened at first sight and appended immediately, so a
|
||||
// coordinator that dies mid-incident still leaves the fact that it existed.
|
||||
// - A repeat updates the aggregate and appends nothing. Run 11's 409 loop
|
||||
// repeated 301 times; appending each would have been 301 pieces of evidence
|
||||
// for one problem, and would have made every debt item eligible at once.
|
||||
// - Absence from the ring closes nothing. The ring is a bounded history, so a
|
||||
// message can vanish because it was evicted rather than because it stopped.
|
||||
// - The lease is the scope. The same signature going quiet and returning
|
||||
// inside one epoch is one incident, not two recurrences.
|
||||
type ObservationTracker struct {
|
||||
Store *store.Store
|
||||
// counts is the last count this tracker saw for an open incident, so a
|
||||
// ring entry that is evicted and recreated accumulates rather than
|
||||
// restarting. Reported 34, evicted, reported 3 again means 37 occurrences,
|
||||
// not 3. In-memory: a restart loses the accumulation, never the incident.
|
||||
counts map[string]int
|
||||
// seen is the high-water mark per worker and signature, kept after an
|
||||
// incident closes. The ring is a history, so a closed incident's entry
|
||||
// keeps being reported for as long as it survives eviction; without this,
|
||||
// every heartbeat after a quiet timeout opened the same incident again and
|
||||
// manufactured recurrence out of one old failure. Live on the first real
|
||||
// run: three signatures, four incidents each, none of them a new event.
|
||||
seen map[string]watermark
|
||||
// incarnations is the last incarnation seen per worker, which is what makes
|
||||
// a restart detectable at all.
|
||||
incarnations map[string]string
|
||||
}
|
||||
|
||||
// watermark is the last occurrence this tracker accounted for one worker's
|
||||
// signature, whether or not its incident is still open.
|
||||
type watermark struct {
|
||||
last time.Time
|
||||
count int
|
||||
}
|
||||
|
||||
// WorkerReport is one heartbeat's worth of attributed observations. The
|
||||
// coordinator attributes them, because the ring carries only messages: the
|
||||
// worker's active task and that task's current lease epoch are what bind an
|
||||
// incident to the work it happened during.
|
||||
type WorkerReport struct {
|
||||
WorkerID string
|
||||
Incarnation string
|
||||
TaskID string
|
||||
LeaseEpoch string
|
||||
Observations []domain.WorkerObservation
|
||||
At time.Time
|
||||
}
|
||||
|
||||
// Ingest folds one heartbeat into the durable incidents and returns the events
|
||||
// it appended. Every close it decides is one of the four boundaries; none of
|
||||
// them is "the message is no longer in the ring".
|
||||
func (t *ObservationTracker) Ingest(r WorkerReport) ([]domain.Event, error) {
|
||||
if t == nil || t.Store == nil || r.WorkerID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if t.counts == nil {
|
||||
t.counts, t.incarnations = map[string]int{}, map[string]string{}
|
||||
t.seen = map[string]watermark{}
|
||||
}
|
||||
at := r.At
|
||||
if at.IsZero() {
|
||||
at = time.Now().UTC()
|
||||
}
|
||||
var appended []domain.Event
|
||||
|
||||
// A new process cannot continue the previous one's symptom, so its
|
||||
// incidents are finalized before anything this heartbeat says is folded in.
|
||||
if previous, seen := t.incarnations[r.WorkerID]; r.Incarnation != "" && seen && previous != r.Incarnation {
|
||||
closed, err := t.closeWhere(at, domain.ObservationCloseWorkerRestart, func(inc domain.ObservationIncident) bool {
|
||||
return inc.WorkerID == r.WorkerID
|
||||
})
|
||||
appended = append(appended, closed...)
|
||||
if err != nil {
|
||||
return appended, err
|
||||
}
|
||||
}
|
||||
if r.Incarnation != "" {
|
||||
t.incarnations[r.WorkerID] = r.Incarnation
|
||||
}
|
||||
|
||||
open := map[string]domain.ObservationIncident{}
|
||||
for _, inc := range t.Store.OpenObservations() {
|
||||
if inc.WorkerID == r.WorkerID {
|
||||
open[inc.Key()] = inc
|
||||
}
|
||||
}
|
||||
for _, o := range r.Observations {
|
||||
signature := domain.ObservationSignature(o.Message)
|
||||
if signature == "" {
|
||||
continue
|
||||
}
|
||||
// The task comes from the message, because the ring outlives the work
|
||||
// it describes. Only a failure that names no task is attributed to the
|
||||
// lease running now.
|
||||
taskID, epoch := domain.ObservationTaskID(o.Message), ""
|
||||
if taskID == "" {
|
||||
taskID, epoch = r.TaskID, r.LeaseEpoch
|
||||
} else if taskID == r.TaskID {
|
||||
epoch = r.LeaseEpoch
|
||||
}
|
||||
candidate := domain.ObservationIncident{
|
||||
WorkerID: r.WorkerID, Incarnation: r.Incarnation, Signature: signature,
|
||||
TaskID: taskID, LeaseEpoch: epoch,
|
||||
}
|
||||
mark := t.seen[r.WorkerID+"\x00"+signature]
|
||||
existing, isOpen := open[candidate.Key()]
|
||||
if !isOpen {
|
||||
// Nothing new: this is a closed incident's entry still sitting in
|
||||
// the ring. Presence is not occurrence.
|
||||
if !firstOr(o.Last, at).After(mark.last) && o.Count <= mark.count {
|
||||
continue
|
||||
}
|
||||
candidate.ID = domain.NewID()
|
||||
candidate.Detail = o.Message
|
||||
candidate.FirstSeen = firstOr(o.First, at)
|
||||
candidate.LastSeen = firstOr(o.Last, at)
|
||||
e, err := t.append(domain.EventObservationIncidentOpened, candidate)
|
||||
if err != nil {
|
||||
return appended, err
|
||||
}
|
||||
appended = append(appended, e)
|
||||
t.counts[candidate.ID] = o.Count
|
||||
t.seen[r.WorkerID+"\x00"+signature] = watermark{last: candidate.LastSeen, count: o.Count}
|
||||
continue
|
||||
}
|
||||
// Open already: accumulate, append nothing. A count lower than the last
|
||||
// one means the ring evicted the entry and started it again.
|
||||
delta := o.Count - t.counts[existing.ID]
|
||||
if delta < 0 {
|
||||
delta = o.Count
|
||||
}
|
||||
t.counts[existing.ID] += delta
|
||||
if last := firstOr(o.Last, at); last.After(existing.LastSeen) {
|
||||
existing.LastSeen = last
|
||||
t.Store.NoteObservation(existing) // last_seen is durable at close
|
||||
}
|
||||
t.seen[r.WorkerID+"\x00"+signature] = watermark{last: existing.LastSeen, count: o.Count}
|
||||
}
|
||||
|
||||
// The boundaries. A lease that ended, an epoch that changed, and a
|
||||
// worker-level incident whose last occurrence has gone stale.
|
||||
closed, err := t.closeWhere(at, "", func(inc domain.ObservationIncident) bool {
|
||||
if inc.WorkerID != r.WorkerID {
|
||||
return false
|
||||
}
|
||||
// Only an incident bound to a live lease has a lease boundary to close
|
||||
// it. One read out of the ring about a task that already finished has
|
||||
// no such boundary, so it ends the way a worker-level incident does.
|
||||
if inc.LeaseEpoch == "" {
|
||||
return at.Sub(inc.LastSeen) > QuietTimeout
|
||||
}
|
||||
return inc.TaskID != r.TaskID || inc.LeaseEpoch != r.LeaseEpoch
|
||||
})
|
||||
appended = append(appended, closed...)
|
||||
return appended, err
|
||||
}
|
||||
|
||||
// closeWhere finalizes every open incident the predicate selects. A reason of
|
||||
// "" is resolved per incident, which is what lets one sweep close a lease that
|
||||
// ended and a worker-level incident that went quiet.
|
||||
func (t *ObservationTracker) closeWhere(at time.Time, reason domain.ObservationCloseReason, match func(domain.ObservationIncident) bool) ([]domain.Event, error) {
|
||||
var out []domain.Event
|
||||
for _, inc := range t.Store.OpenObservations() {
|
||||
if !match(inc) {
|
||||
continue
|
||||
}
|
||||
inc.ClosedAt = at
|
||||
inc.RepeatCount = t.counts[inc.ID]
|
||||
inc.CloseReason = reason
|
||||
if inc.CloseReason == "" {
|
||||
switch {
|
||||
case inc.LeaseEpoch == "":
|
||||
inc.CloseReason = domain.ObservationCloseQuietTimeout
|
||||
case inc.TaskID != "":
|
||||
inc.CloseReason = domain.ObservationCloseEpochChange
|
||||
default:
|
||||
inc.CloseReason = domain.ObservationCloseLeaseEnd
|
||||
}
|
||||
}
|
||||
e, err := t.append(domain.EventObservationIncidentClosed, inc)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
delete(t.counts, inc.ID)
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// append writes the incident as a worker-scoped event. The task it happened
|
||||
// during is carried in the payload rather than in Event.TaskID on purpose: an
|
||||
// incident is evidence about a worker, and binding it to the task aggregate
|
||||
// would bump that task's version from a path the lease knows nothing about.
|
||||
func (t *ObservationTracker) append(typ string, inc domain.ObservationIncident) (domain.Event, error) {
|
||||
b, err := json.Marshal(inc)
|
||||
if err != nil {
|
||||
return domain.Event{}, err
|
||||
}
|
||||
// "system" is the same aggregate QuotaReported uses for worker-scoped
|
||||
// facts: every event needs a task id, and this evidence belongs to a
|
||||
// worker rather than to any one task.
|
||||
e := domain.Event{ID: domain.NewID(), Type: typ, TaskID: "system", Payload: b, Surface: string(authz.System)}
|
||||
if err := t.Store.Append(e); err != nil {
|
||||
return domain.Event{}, fmt.Errorf("record observation incident: %w", err)
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func firstOr(t, fallback time.Time) time.Time {
|
||||
if t.IsZero() {
|
||||
return fallback
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// RecordIntervention writes down a repair the operator made by hand. It is
|
||||
// deliberately an explicit act: Orchestra cannot see a worker someone
|
||||
// restarted or a transaction someone deleted, and inferring "an operator
|
||||
// probably intervened" from a gap in the log would put guesses into the
|
||||
// evidence the ledger is built from.
|
||||
func RecordIntervention(s *store.Store, surface authz.Surface, in domain.OperatorIntervention) (domain.Event, error) {
|
||||
if err := in.Validate(); err != nil {
|
||||
return domain.Event{}, err
|
||||
}
|
||||
if in.At.IsZero() {
|
||||
in.At = time.Now().UTC()
|
||||
}
|
||||
taskID := in.TaskID
|
||||
version := 0
|
||||
if taskID != "" {
|
||||
t, ok := s.Task(taskID)
|
||||
if !ok {
|
||||
return domain.Event{}, domain.ErrNotFound
|
||||
}
|
||||
version = t.Version + 1
|
||||
} else {
|
||||
// A repair with no task is still about this deployment, so it lands on
|
||||
// the same aggregate the other worker-scoped facts use.
|
||||
taskID = "system"
|
||||
}
|
||||
b, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
return domain.Event{}, err
|
||||
}
|
||||
e := domain.Event{
|
||||
ID: domain.NewID(), Type: domain.EventOperatorInterventionRecorded,
|
||||
TaskID: taskID, Version: version, At: in.At, Payload: b, Surface: string(surface),
|
||||
}
|
||||
if err := s.Append(e); err != nil {
|
||||
return domain.Event{}, err
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
package operations
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"orchestra/internal/domain"
|
||||
"orchestra/internal/store"
|
||||
)
|
||||
|
||||
func tracker(t *testing.T) (*ObservationTracker, *store.Store) {
|
||||
t.Helper()
|
||||
s, err := store.Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &ObservationTracker{Store: s}, s
|
||||
}
|
||||
|
||||
func ring(message string, count int, last time.Time) []domain.WorkerObservation {
|
||||
return []domain.WorkerObservation{{Message: message, Count: count, First: last.Add(-time.Minute), Last: last}}
|
||||
}
|
||||
|
||||
func closedIncident(t *testing.T, s *store.Store) domain.ObservationIncident {
|
||||
t.Helper()
|
||||
var out domain.ObservationIncident
|
||||
found := 0
|
||||
for _, e := range s.Events(0) {
|
||||
if e.Type != domain.EventObservationIncidentClosed {
|
||||
continue
|
||||
}
|
||||
found++
|
||||
if err := json.Unmarshal(e.Payload, &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if found != 1 {
|
||||
t.Fatalf("closed incidents = %d, want 1", found)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 301 repeats of one refusal are one incident with an intensity of 301, not
|
||||
// 301 pieces of evidence. Appending each would spam the log and make one stuck
|
||||
// loop look like chronic, recurring debt.
|
||||
func TestRepeatsAreOneIncident(t *testing.T) {
|
||||
tr, s := tracker(t)
|
||||
at := time.Unix(1700000000, 0).UTC()
|
||||
report := func(count int, when time.Time) {
|
||||
if _, err := tr.Ingest(WorkerReport{
|
||||
WorkerID: "workpc-claude", Incarnation: "boot-1", TaskID: "task-a", LeaseEpoch: "epoch-1",
|
||||
Observations: ring("release task-a commit: 409 superseded", count, when), At: when,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
report(1, at)
|
||||
report(40, at.Add(time.Minute))
|
||||
report(301, at.Add(2*time.Minute))
|
||||
|
||||
opened := 0
|
||||
for _, e := range s.Events(0) {
|
||||
if e.Type == domain.EventObservationIncidentOpened {
|
||||
opened++
|
||||
}
|
||||
if e.Type == domain.EventObservationIncidentClosed {
|
||||
t.Fatal("an incident was closed while its lease was still running")
|
||||
}
|
||||
}
|
||||
if opened != 1 {
|
||||
t.Fatalf("opened %d incidents for one repeating failure", opened)
|
||||
}
|
||||
if open := s.OpenObservations(); len(open) != 1 || open[0].TaskID != "task-a" {
|
||||
t.Fatalf("open incidents = %+v", open)
|
||||
}
|
||||
}
|
||||
|
||||
// The ring is a bounded history, so an entry that disappears may have been
|
||||
// evicted rather than resolved. Absence must not close anything, and a
|
||||
// recreated entry must accumulate rather than restart its count.
|
||||
func TestEvictionNeitherClosesNorRestartsTheCount(t *testing.T) {
|
||||
tr, s := tracker(t)
|
||||
at := time.Unix(1700000000, 0).UTC()
|
||||
send := func(obs []domain.WorkerObservation, when time.Time) {
|
||||
if _, err := tr.Ingest(WorkerReport{
|
||||
WorkerID: "w", Incarnation: "boot-1", TaskID: "task-a", LeaseEpoch: "epoch-1",
|
||||
Observations: obs, At: when,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
send(ring("lease task-a not renewed: agent idle", 34, at), at)
|
||||
// Evicted: the message is simply gone from this heartbeat.
|
||||
send(nil, at.Add(time.Minute))
|
||||
if len(s.OpenObservations()) != 1 {
|
||||
t.Fatal("an incident was closed because its message left a bounded ring")
|
||||
}
|
||||
// Recreated, counting from scratch on the worker side.
|
||||
send(ring("lease task-a not renewed: agent idle", 3, at.Add(2*time.Minute)), at.Add(2*time.Minute))
|
||||
|
||||
// The lease ends, which is a real boundary.
|
||||
if _, err := tr.Ingest(WorkerReport{WorkerID: "w", Incarnation: "boot-1", At: at.Add(3 * time.Minute)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inc := closedIncident(t, s)
|
||||
if inc.RepeatCount != 37 {
|
||||
t.Fatalf("repeat_count = %d, want 37 (34 before eviction plus 3 after)", inc.RepeatCount)
|
||||
}
|
||||
if inc.CloseReason != domain.ObservationCloseEpochChange {
|
||||
t.Fatalf("close_reason = %q", inc.CloseReason)
|
||||
}
|
||||
if !inc.LastSeen.Equal(at.Add(2 * time.Minute)) {
|
||||
t.Fatalf("last_seen = %s, want the last actual occurrence", inc.LastSeen)
|
||||
}
|
||||
if !inc.ClosedAt.After(inc.LastSeen) {
|
||||
t.Fatal("closed_at must be when Orchestra finalized it, not when the failure last happened")
|
||||
}
|
||||
}
|
||||
|
||||
// Recurrence is independent incidents. The same signature on two tasks is two,
|
||||
// which is the evidence that means something; repeats inside one are intensity.
|
||||
func TestTheSameSignatureOnAnotherTaskIsASecondIncident(t *testing.T) {
|
||||
tr, s := tracker(t)
|
||||
at := time.Unix(1700000000, 0).UTC()
|
||||
if _, err := tr.Ingest(WorkerReport{
|
||||
WorkerID: "w", Incarnation: "boot-1", TaskID: "task-a", LeaseEpoch: "e1",
|
||||
Observations: ring("lease task-a not renewed: agent idle", 5, at), At: at,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tr.Ingest(WorkerReport{
|
||||
WorkerID: "w", Incarnation: "boot-1", TaskID: "task-b", LeaseEpoch: "e2",
|
||||
Observations: ring("lease task-b not renewed: agent idle", 2, at.Add(time.Minute)), At: at.Add(time.Minute),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opened, closed := 0, 0
|
||||
for _, e := range s.Events(0) {
|
||||
switch e.Type {
|
||||
case domain.EventObservationIncidentOpened:
|
||||
opened++
|
||||
case domain.EventObservationIncidentClosed:
|
||||
closed++
|
||||
}
|
||||
}
|
||||
if opened != 2 {
|
||||
t.Fatalf("opened = %d, want one incident per lease", opened)
|
||||
}
|
||||
if closed != 1 {
|
||||
t.Fatalf("closed = %d, want the first lease finalized when the second began", closed)
|
||||
}
|
||||
}
|
||||
|
||||
// A restart cannot continue the previous process's symptom.
|
||||
func TestAWorkerRestartClosesItsIncidents(t *testing.T) {
|
||||
tr, s := tracker(t)
|
||||
at := time.Unix(1700000000, 0).UTC()
|
||||
if _, err := tr.Ingest(WorkerReport{
|
||||
WorkerID: "w", Incarnation: "boot-1", TaskID: "task-a", LeaseEpoch: "e1",
|
||||
Observations: ring("herdr unreachable", 9, at), At: at,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tr.Ingest(WorkerReport{WorkerID: "w", Incarnation: "boot-2", At: at.Add(time.Minute)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inc := closedIncident(t, s)
|
||||
if inc.CloseReason != domain.ObservationCloseWorkerRestart || inc.RepeatCount != 9 {
|
||||
t.Fatalf("incident = %+v", inc)
|
||||
}
|
||||
}
|
||||
|
||||
// An observation with no lease has no terminal boundary, so staleness of its
|
||||
// last actual occurrence is what ends it.
|
||||
func TestAWorkerLevelIncidentClosesOnQuietTimeout(t *testing.T) {
|
||||
tr, s := tracker(t)
|
||||
at := time.Unix(1700000000, 0).UTC()
|
||||
if _, err := tr.Ingest(WorkerReport{
|
||||
WorkerID: "w", Incarnation: "boot-1",
|
||||
Observations: ring("heartbeat: connection refused", 4, at), At: at,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tr.Ingest(WorkerReport{WorkerID: "w", Incarnation: "boot-1", At: at.Add(time.Minute)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(s.OpenObservations()) != 1 {
|
||||
t.Fatal("a worker-level incident closed before its quiet timeout")
|
||||
}
|
||||
if _, err := tr.Ingest(WorkerReport{WorkerID: "w", Incarnation: "boot-1", At: at.Add(QuietTimeout + time.Minute)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if inc := closedIncident(t, s); inc.CloseReason != domain.ObservationCloseQuietTimeout {
|
||||
t.Fatalf("close_reason = %q", inc.CloseReason)
|
||||
}
|
||||
}
|
||||
|
||||
// The signature is what makes recurrence countable across tasks.
|
||||
func TestSignatureCollapsesIdsAndCounts(t *testing.T) {
|
||||
a := domain.ObservationSignature("lease 06G4WJ9T4F35NZC4Z8QQXM9Z6G not renewed: agent status idle and pane unchanged")
|
||||
b := domain.ObservationSignature("lease 06G4VF5HZW7Q4JBM3TTY7W1Y64 not renewed: agent status idle and pane unchanged")
|
||||
if a != b {
|
||||
t.Fatalf("the same failure on two tasks has two signatures:\n%s\n%s", a, b)
|
||||
}
|
||||
if c := domain.ObservationSignature("release 06G4WJ9T4F35NZC4Z8QQXM9Z6G commit: 409 superseded"); c == a {
|
||||
t.Fatal("two different failures collapsed to one signature")
|
||||
}
|
||||
}
|
||||
|
||||
// The first live run of the ledger caught this: a pane name carries the task
|
||||
// id in lower case, so the same failure signed differently on every task and
|
||||
// could never accumulate recurrence.
|
||||
func TestSignatureCollapsesAPaneName(t *testing.T) {
|
||||
a := domain.ObservationSignature(`phase rotation 06G4XAFH1MBPC35VSJN7V3NS14: pane orchestra-06g4xafh1mbpc35vsjn7v3ns14-be13b045:1.0 still holds input`)
|
||||
b := domain.ObservationSignature(`phase rotation 06G4WW6TND26M16CZA6WE5T458: pane orchestra-06g4ww6tnd26m16cza6we5t458-4d839c05:1.0 still holds input`)
|
||||
if a != b {
|
||||
t.Fatalf("one failure has two signatures:\n%s\n%s", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
// The first live run manufactured recurrence out of one old failure: a quiet
|
||||
// timeout closed the incident, the entry stayed in the ring because the ring
|
||||
// is a bounded history rather than a set of live conditions, and every later
|
||||
// heartbeat opened it again. Four incidents, one failure, no new occurrence.
|
||||
func TestAClosedIncidentDoesNotReopenFromAStaleRingEntry(t *testing.T) {
|
||||
tr, s := tracker(t)
|
||||
at := time.Unix(1700000000, 0).UTC()
|
||||
entry := ring("heartbeat: connection refused", 4, at)
|
||||
if _, err := tr.Ingest(WorkerReport{WorkerID: "w", Incarnation: "b1", Observations: entry, At: at}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Long enough to close on quiet timeout, with the entry still reported.
|
||||
quiet := at.Add(QuietTimeout + time.Minute)
|
||||
for i := 0; i < 4; i++ {
|
||||
if _, err := tr.Ingest(WorkerReport{
|
||||
WorkerID: "w", Incarnation: "b1", Observations: entry,
|
||||
At: quiet.Add(time.Duration(i) * time.Minute),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
opened, closed := 0, 0
|
||||
for _, e := range s.Events(0) {
|
||||
switch e.Type {
|
||||
case domain.EventObservationIncidentOpened:
|
||||
opened++
|
||||
case domain.EventObservationIncidentClosed:
|
||||
closed++
|
||||
}
|
||||
}
|
||||
if opened != 1 || closed != 1 {
|
||||
t.Fatalf("opened=%d closed=%d for one failure that never happened again", opened, closed)
|
||||
}
|
||||
|
||||
// A real new occurrence, which the ring shows by advancing the entry.
|
||||
later := quiet.Add(time.Hour)
|
||||
if _, err := tr.Ingest(WorkerReport{
|
||||
WorkerID: "w", Incarnation: "b1", Observations: ring("heartbeat: connection refused", 5, later), At: later,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reopened := 0
|
||||
for _, e := range s.Events(0) {
|
||||
if e.Type == domain.EventObservationIncidentOpened {
|
||||
reopened++
|
||||
}
|
||||
}
|
||||
if reopened != 2 {
|
||||
t.Fatalf("a genuine new occurrence did not open an incident: opened=%d", reopened)
|
||||
}
|
||||
}
|
||||
|
||||
// The ring outlives the work it describes, so the task comes from the message
|
||||
// rather than from whatever the worker happens to be running now.
|
||||
func TestTheTaskComesFromTheMessageNotTheCurrentLease(t *testing.T) {
|
||||
tr, s := tracker(t)
|
||||
at := time.Unix(1700000000, 0).UTC()
|
||||
if _, err := tr.Ingest(WorkerReport{
|
||||
WorkerID: "w", Incarnation: "b1", TaskID: "06G4XAFH1MBPC35VSJN7V3NS14", LeaseEpoch: "now",
|
||||
Observations: ring("renew lease 06G4WW6TND26M16CZA6WE5T458: 409 conflict", 3, at), At: at,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
open := s.OpenObservations()
|
||||
if len(open) != 1 {
|
||||
t.Fatalf("open = %+v", open)
|
||||
}
|
||||
if open[0].TaskID != "06G4WW6TND26M16CZA6WE5T458" {
|
||||
t.Fatalf("the failure was attributed to the wrong task: %q", open[0].TaskID)
|
||||
}
|
||||
if open[0].LeaseEpoch != "" {
|
||||
t.Fatalf("an old failure inherited the current lease's epoch: %q", open[0].LeaseEpoch)
|
||||
}
|
||||
}
|
||||
@@ -154,11 +154,13 @@ func blockForPlanMismatch(s *store.Store, taskID string, m domain.PlanMismatch)
|
||||
packet += "- evidence: " + oneLine(e) + "\n"
|
||||
}
|
||||
packet += "\nReply to say how to proceed. Your reply becomes a recorded decision and outranks the plan. If it resolves the contradiction, the task resumes on the same plan; say so explicitly if you want the plan rewritten instead.\n"
|
||||
b, err := json.Marshal(map[string]any{
|
||||
payload := map[string]any{
|
||||
"blocker": packet,
|
||||
"block_reason": string(domain.BlockReasonPlanMismatch),
|
||||
"lifecycle_phase": "awaiting_human",
|
||||
})
|
||||
}
|
||||
fenceToLease(payload, t)
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -108,6 +108,11 @@ func TestReplanKeepsTheOldPlanUntilAReplacementIsSealed(t *testing.T) {
|
||||
if len(during.PlanHistory) != 0 {
|
||||
t.Fatalf("the plan was moved to history early: %v", during.PlanHistory)
|
||||
}
|
||||
// F66: the planning session this reopen convenes has to be told what it is
|
||||
// there to fix, and the implementer that found it is gone by then.
|
||||
if during.PlanMismatch == nil || during.PlanMismatch.PhaseID != "phase-1" {
|
||||
t.Fatalf("the contradiction did not survive to the phase it reopened: %+v", during.PlanMismatch)
|
||||
}
|
||||
}
|
||||
|
||||
// Sealing the replacement is the moment the old plan is superseded. Progress
|
||||
@@ -151,6 +156,11 @@ func TestSealingTheReplacementSupersedesThePlanAndItsProgress(t *testing.T) {
|
||||
if _, err := s.Artifact(oldRef); err != nil {
|
||||
t.Fatalf("the superseded plan is unreadable: %v", err)
|
||||
}
|
||||
// The replacement answers the contradiction, so it stops being a live
|
||||
// instruction and stays in the log as history.
|
||||
if after, _ := s.Task(id); after.PlanMismatch != nil {
|
||||
t.Fatalf("a settled contradiction is still live: %+v", after.PlanMismatch)
|
||||
}
|
||||
assertPhase(t, s, id, domain.WorkPhaseImplement)
|
||||
}
|
||||
|
||||
@@ -215,7 +225,25 @@ func TestHumanAnswerResumesTheSamePlanWithoutResealing(t *testing.T) {
|
||||
if !PlanMismatchAnswered(s, id) {
|
||||
t.Fatal("the human answered and the task is still waiting")
|
||||
}
|
||||
// F64: asserting the predicate is not asserting the resume. This test
|
||||
// passed for as long as the predicate had no caller, while a task blocked
|
||||
// on a mismatch stayed blocked forever however the human replied.
|
||||
if events, err := ResumeAnsweredBlockers(s); err != nil || len(events) != 1 {
|
||||
t.Fatalf("an answered mismatch did not return to the queue: events=%v err=%v", events, err)
|
||||
}
|
||||
after, _ := s.Task(id)
|
||||
if after.State != domain.StateQueued {
|
||||
t.Fatalf("state = %s after the human answered, want queued", after.State)
|
||||
}
|
||||
// F67: the stop is over, so the projection must not keep reporting it.
|
||||
if after.BlockReason != "" || after.Blocker != "" {
|
||||
t.Fatalf("a resumed task still reports its blocker: %q %q", after.BlockReason, after.Blocker)
|
||||
}
|
||||
// The answer outranks the plan and stands as an ordinary decision, so the
|
||||
// contradiction it settled is not carried into later sessions either.
|
||||
if after.PlanMismatch != nil {
|
||||
t.Fatalf("an answered contradiction is still live: %+v", after.PlanMismatch)
|
||||
}
|
||||
if after.PlanRef != planRef {
|
||||
t.Fatal("answering the question replaced the plan")
|
||||
}
|
||||
|
||||
@@ -43,6 +43,13 @@ func PlanPhaseCommands(s *store.Store, project registry.Project, taskID, phaseID
|
||||
if t.PlanRef == "" {
|
||||
return workphase.PlanPhase{}, fmt.Errorf("%w: this task has no accepted plan", ErrPlanPhase)
|
||||
}
|
||||
// Verification is implementation work. A request that arrives in another
|
||||
// phase belongs to a trajectory Orchestra has already ended: run 20's
|
||||
// reopened planning session executed the implementer's leftover request
|
||||
// and recorded a phase of a plan that was being replaced.
|
||||
if current(t) != domain.WorkPhaseImplement {
|
||||
return workphase.PlanPhase{}, fmt.Errorf("%w: phase verification belongs to the implement phase, and this task is in %s", ErrPlanPhase, current(t))
|
||||
}
|
||||
raw, err := s.Artifact(t.PlanRef)
|
||||
if err != nil {
|
||||
return workphase.PlanPhase{}, fmt.Errorf("read accepted plan: %w", err)
|
||||
@@ -124,7 +131,12 @@ func RecordPlanPhaseVerification(s *store.Store, project registry.Project, taskI
|
||||
// was established.
|
||||
record.Status = domain.PlanPhaseAwaitingManual
|
||||
}
|
||||
if record.Status != domain.PlanPhaseInProgress && manuallySignedOff(s, t, phaseID) {
|
||||
// Carry the confirmed tree forward as provenance. Without it a second
|
||||
// rerun would compare against nothing and re-inherit the sign-off.
|
||||
if prior, ok := t.PlanPhase(phaseID); ok {
|
||||
record.ManualAtSHA = prior.ManualAtSHA
|
||||
}
|
||||
if record.Status != domain.PlanPhaseInProgress && manuallySignedOff(s, t, phaseID, record) {
|
||||
record.Status = domain.PlanPhaseVerified
|
||||
}
|
||||
if ref, err := s.PutArtifact(verificationEvidence(runs)); err == nil {
|
||||
@@ -135,7 +147,7 @@ func RecordPlanPhaseVerification(s *store.Store, project registry.Project, taskI
|
||||
payload := map[string]any{
|
||||
"plan_ref": record.PlanRef, "phase_id": record.PhaseID, "status": string(record.Status),
|
||||
"commands": record.Commands, "exit_codes": record.ExitCodes, "at_sha": record.AtSHA,
|
||||
"evidence_ref": record.EvidenceRef, "at": record.At,
|
||||
"evidence_ref": record.EvidenceRef, "at": record.At, "manual_at_sha": record.ManualAtSHA,
|
||||
}
|
||||
if t.Lease != nil {
|
||||
payload["harness_id"], payload["lease_epoch"] = t.Lease.HarnessID, t.Lease.Epoch
|
||||
@@ -149,9 +161,20 @@ func RecordPlanPhaseVerification(s *store.Store, project registry.Project, taskI
|
||||
}
|
||||
|
||||
// manuallySignedOff reports whether a human has already approved this exact
|
||||
// phase of this exact plan. The subject carries both, so a later "looks good"
|
||||
// on an unrelated thread cannot satisfy a gate nobody was discussing.
|
||||
func manuallySignedOff(s *store.Store, t domain.Task, phaseID string) bool {
|
||||
// phase of this exact plan, against the tree this run examined. The subject
|
||||
// carries plan and phase, so a later "looks good" on an unrelated thread
|
||||
// cannot satisfy a gate nobody was discussing.
|
||||
//
|
||||
// The tree matters as much as the subject (F63). A sign-off is a human saying
|
||||
// they read what this code prints; an edit afterwards can change exactly that.
|
||||
// A record whose ManualAtSHA names a different commit is therefore not signed
|
||||
// off, and waits for the human again. A sign-off given before any run has no
|
||||
// confirmed tree to compare against and still counts, which keeps the ordinary
|
||||
// ordering unchanged.
|
||||
func manuallySignedOff(s *store.Store, t domain.Task, phaseID string, record domain.PlanPhaseRecord) bool {
|
||||
if record.ManualAtSHA != "" && record.ManualAtSHA != record.AtSHA {
|
||||
return false
|
||||
}
|
||||
intent, err := s.EffectiveIntent(t.ID)
|
||||
if err != nil {
|
||||
return false
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
const shaOne = "1111111111111111111111111111111111111111"
|
||||
const shaTwo = "2222222222222222222222222222222222222222"
|
||||
const shaThree = "3333333333333333333333333333333333333333"
|
||||
|
||||
func planProject() registry.Project {
|
||||
p := registry.Project{
|
||||
@@ -27,6 +28,11 @@ func planProject() registry.Project {
|
||||
func planWith(t *testing.T, markdown string) (*store.Store, registry.Project, string) {
|
||||
t.Helper()
|
||||
s, id := phaseStore(t)
|
||||
// Leased, because everything these tests drive comes from a live implement
|
||||
// session. Skipping it hid F65: two coordinator-side stops omitted the
|
||||
// fencing fields Store.Append requires on a leased task, and every test
|
||||
// passed because no test ever leased one.
|
||||
lease(t, s, id)
|
||||
project := planProject()
|
||||
if _, err := AdvanceWorkPhase(s, project, id, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -251,6 +257,14 @@ func TestLegacyPlanIsExplicitlyNonProgressable(t *testing.T) {
|
||||
// signOff records a human decision bound to one phase of one plan, which is
|
||||
// the only thing that satisfies a manual verification gate.
|
||||
func signOff(t *testing.T, s *store.Store, taskID, subject string) {
|
||||
t.Helper()
|
||||
signOffFrom(t, s, taskID, subject, "signoff-"+subject)
|
||||
}
|
||||
|
||||
// signOffFrom names the comment the sign-off came from. Two sign-offs on one
|
||||
// subject are a real sequence once a rerun sends a phase back to the human,
|
||||
// and provenance is unique per comment.
|
||||
func signOffFrom(t *testing.T, s *store.Store, taskID, subject, externalID string) {
|
||||
t.Helper()
|
||||
task, _ := s.Task(taskID)
|
||||
if err := s.Append(domain.Event{
|
||||
@@ -259,9 +273,87 @@ func signOff(t *testing.T, s *store.Store, taskID, subject string) {
|
||||
Payload: mustJSONBytes(t, map[string]any{
|
||||
"decision_id": domain.NewID(), "kind": "answer", "subject": subject,
|
||||
"value": "manual steps confirmed",
|
||||
"source": map[string]any{"provider": "gitea", "external_id": "signoff-" + subject},
|
||||
"source": map[string]any{"provider": "gitea", "external_id": externalID},
|
||||
}),
|
||||
}); err != nil {
|
||||
t.Fatalf("sign off: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// F63, found live on run 19. A manual sign-off says a human read what this
|
||||
// code prints. An edit afterwards can change exactly that, so rerunning the
|
||||
// automated half at a new commit must not carry the human half with it.
|
||||
func TestASignOffDoesNotSurviveTheTreeItWasGivenAgainst(t *testing.T) {
|
||||
s, project, id := planWith(t, twoPhasePlan)
|
||||
run := []VerificationRun{{Command: []string{"go", "test", "./internal/..."}, ExitCode: 0}}
|
||||
if _, err := RecordPlanPhaseVerification(s, project, id, "phase-2", shaOne, run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task, _ := s.Task(id)
|
||||
signOff(t, s, id, domain.PlanPhaseSubject(task.PlanRef, "phase-2"))
|
||||
task, _ = s.Task(id)
|
||||
rec, _ := task.PlanPhase("phase-2")
|
||||
if rec.Status != domain.PlanPhaseVerified || rec.ManualAtSHA != shaOne {
|
||||
t.Fatalf("sign-off did not bind to the tree it read: %+v", rec)
|
||||
}
|
||||
|
||||
// The tree moves and the phase is re-verified. The commands pass again;
|
||||
// the human has not seen the new output.
|
||||
if _, err := RecordPlanPhaseVerification(s, project, id, "phase-2", shaTwo, run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task, _ = s.Task(id)
|
||||
rec, _ = task.PlanPhase("phase-2")
|
||||
if rec.Status != domain.PlanPhaseAwaitingManual {
|
||||
t.Fatalf("status = %q at a tree the human never saw, want awaiting_manual_verification", rec.Status)
|
||||
}
|
||||
if rec.ManualAtSHA != shaOne {
|
||||
t.Fatalf("the confirmed tree was lost: %+v", rec)
|
||||
}
|
||||
|
||||
// A second rerun must not re-inherit it either, which is what carrying
|
||||
// ManualAtSHA forward is for.
|
||||
if _, err := RecordPlanPhaseVerification(s, project, id, "phase-2", shaThree, run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task, _ = s.Task(id)
|
||||
rec, _ = task.PlanPhase("phase-2")
|
||||
if rec.Status != domain.PlanPhaseAwaitingManual {
|
||||
t.Fatalf("a second rerun re-inherited the sign-off: %q", rec.Status)
|
||||
}
|
||||
|
||||
// Signing off again, on the tree that is now current, verifies it.
|
||||
signOffFrom(t, s, id, domain.PlanPhaseSubject(task.PlanRef, "phase-2"), "signoff-second")
|
||||
task, _ = s.Task(id)
|
||||
rec, _ = task.PlanPhase("phase-2")
|
||||
if rec.Status != domain.PlanPhaseVerified || rec.ManualAtSHA != shaThree {
|
||||
t.Fatalf("a fresh sign-off did not verify the current tree: %+v", rec)
|
||||
}
|
||||
}
|
||||
|
||||
// Run 20: a replan reopened the plan phase, the implementer's leftover
|
||||
// verification request outlived its session, and the planning session that
|
||||
// replaced it executed the request. Orchestra recorded a verified phase of the
|
||||
// plan it was in the middle of replacing.
|
||||
func TestVerificationIsRefusedOutsideImplement(t *testing.T) {
|
||||
s, project, id := planWith(t, twoPhasePlan)
|
||||
task, _ := s.Task(id)
|
||||
m := mismatch(task.PlanRef)
|
||||
m.RequestedAction = domain.PlanMismatchReplan
|
||||
if _, err := RecordPlanMismatch(s, project, id, m, shaOne); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertPhase(t, s, id, domain.WorkPhasePlan)
|
||||
|
||||
_, err := RecordPlanPhaseVerification(s, project, id, "phase-1", shaOne,
|
||||
[]VerificationRun{{Command: []string{"go", "build", "./..."}, ExitCode: 0}})
|
||||
if !errors.Is(err, ErrPlanPhase) {
|
||||
t.Fatalf("a reopened task verified a phase of the plan being replaced: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "implement") {
|
||||
t.Fatalf("the refusal does not say which phase owns verification: %v", err)
|
||||
}
|
||||
if after, _ := s.Task(id); len(after.PlanPhases()) != 0 {
|
||||
t.Fatalf("progress was recorded anyway: %+v", after.PlanPhases())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,11 +67,13 @@ func raiseTrajectoryGate(s *store.Store, t domain.Task, from, to domain.WorkPhas
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b, err := json.Marshal(map[string]any{
|
||||
payload := map[string]any{
|
||||
"blocker": packet,
|
||||
"block_reason": string(domain.BlockReasonTrajectoryGate),
|
||||
"lifecycle_phase": "awaiting_human",
|
||||
})
|
||||
}
|
||||
fenceToLease(payload, t)
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"orchestra/internal/domain"
|
||||
"orchestra/internal/review"
|
||||
)
|
||||
|
||||
// ProjectDebt folds canonical history into a debt ledger. It is a pure
|
||||
// function of the events so it can be tested against a real log, and it writes
|
||||
// nothing: this projection is a read model, not a new kind of truth.
|
||||
//
|
||||
// It deliberately reports what it cannot see. Worker observations and operator
|
||||
// interventions are not carried by any event type today, so a ledger that
|
||||
// stayed silent about them would read as "no operational cost" when it means
|
||||
// "operational cost is unrecorded".
|
||||
func ProjectDebt(events []domain.Event) domain.DebtLedger {
|
||||
return projectDebt(events, nil)
|
||||
}
|
||||
|
||||
// ProjectDebtWithArtifacts is ProjectDebt plus the CAS reads that review
|
||||
// findings need. Findings are sealed as an artifact rather than inlined in the
|
||||
// event, and they are the only evidence structural debt has, so a projection
|
||||
// without them can never produce a structural item at all.
|
||||
func ProjectDebtWithArtifacts(events []domain.Event, read func(ref string) ([]byte, error)) domain.DebtLedger {
|
||||
return projectDebt(events, read)
|
||||
}
|
||||
|
||||
func projectDebt(events []domain.Event, readArtifact func(string) ([]byte, error)) domain.DebtLedger {
|
||||
bySignature := map[string]*domain.DebtItem{}
|
||||
order := []string{}
|
||||
seenKinds := map[domain.ObservationKind]bool{}
|
||||
|
||||
add := func(class domain.DebtClass, sig string, o domain.DebtObservation, symptom string, sev review.Severity) {
|
||||
o.Signature = sig
|
||||
if o.Validate() != nil {
|
||||
return
|
||||
}
|
||||
seenKinds[o.Kind] = true
|
||||
item, ok := bySignature[sig]
|
||||
if !ok {
|
||||
item = &domain.DebtItem{
|
||||
// The id is the signature, not an Fxx number. This projection
|
||||
// produces candidates from history; adopting a curated id is a
|
||||
// later, deliberate step, and minting one here would collide
|
||||
// with the hand-written ledger.
|
||||
ID: sig, Class: class, Status: domain.DebtObserved,
|
||||
Symptom: symptom, Signatures: []string{sig}, Severity: sev,
|
||||
}
|
||||
bySignature[sig] = item
|
||||
order = append(order, sig)
|
||||
}
|
||||
item.Observations = append(item.Observations, o)
|
||||
for _, p := range o.Paths {
|
||||
if !contains(item.Paths, p) {
|
||||
item.Paths = append(item.Paths, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, e := range events {
|
||||
var p map[string]any
|
||||
if len(e.Payload) > 0 && json.Unmarshal(e.Payload, &p) != nil {
|
||||
continue
|
||||
}
|
||||
base := domain.DebtObservation{EventID: e.ID, TaskID: e.TaskID, At: e.At}
|
||||
harness, _ := p["harness_id"].(string)
|
||||
switch e.Type {
|
||||
case "TaskBlocked":
|
||||
reason, _ := p["block_reason"].(string)
|
||||
if reason == "" {
|
||||
reason = string(domain.InferBlockReason(str(p["blocker"])))
|
||||
}
|
||||
class, ok := domain.DebtClassForBlockReason(domain.BlockReason(reason))
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
o := base
|
||||
o.Kind = domain.ObservationBlockReason
|
||||
o.Detail = str(p["blocker"])
|
||||
add(class, domain.DebtSignature(class, reason, harness, "lease"), o,
|
||||
"tasks are blocked with "+reason, review.Important)
|
||||
case "TaskFailed", "TaskReleased":
|
||||
// A release carries a failure class only when it is a reclaim. A
|
||||
// handoff release is ordinary progress and never debt.
|
||||
failure, _ := p["failure_class"].(string)
|
||||
if failure == "" {
|
||||
failure, _ = p["reason"].(string)
|
||||
}
|
||||
if e.Type == "TaskReleased" && str(p["handoff_ref"]) != "" {
|
||||
continue
|
||||
}
|
||||
class, ok := domain.DebtClassForFailureClass(failure)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
o := base
|
||||
o.Kind = domain.ObservationFailureClass
|
||||
o.Detail = str(p["last_error"])
|
||||
add(class, domain.DebtSignature(class, failure, harness, "lease"), o,
|
||||
"tasks end in "+failure, review.Important)
|
||||
case domain.EventObservationIncidentClosed:
|
||||
// One incident, whatever it repeated. The debt class comes from
|
||||
// the signature's shape rather than a failure class, because a
|
||||
// worker observation is a symptom the worker described, not a
|
||||
// lifecycle outcome Orchestra decided.
|
||||
var inc domain.ObservationIncident
|
||||
if json.Unmarshal(e.Payload, &inc) != nil || inc.Signature == "" {
|
||||
continue
|
||||
}
|
||||
o := base
|
||||
o.Kind = domain.ObservationWorkerFailure
|
||||
o.TaskID = inc.TaskID
|
||||
o.Detail = inc.Detail
|
||||
o.Repeats = inc.RepeatCount
|
||||
add(domain.DebtOperational,
|
||||
domain.DebtSignature(domain.DebtOperational, inc.Signature, inc.WorkerID, "worker"),
|
||||
o, "workers report "+inc.Signature, review.Important)
|
||||
case domain.EventOperatorInterventionRecorded:
|
||||
var in domain.OperatorIntervention
|
||||
if json.Unmarshal(e.Payload, &in) != nil || !in.Kind.Valid() {
|
||||
continue
|
||||
}
|
||||
o := base
|
||||
o.Kind = domain.ObservationManualIntervention
|
||||
o.TaskID = in.TaskID
|
||||
o.Detail = in.Reason
|
||||
o.Paths = in.Components
|
||||
add(domain.DebtOperational,
|
||||
domain.DebtSignature(domain.DebtOperational, string(in.Kind), in.WorkerID, "manual"),
|
||||
o, "an operator repairs this by hand ("+string(in.Kind)+")", review.Important)
|
||||
case domain.EventPlanMismatchRecorded:
|
||||
o := base
|
||||
o.Kind = domain.ObservationPlanMismatch
|
||||
o.Detail = str(p["observed"])
|
||||
o.Paths = strList(p["evidence"])
|
||||
component := firstComponent(o.Paths)
|
||||
add(domain.DebtCorrectness,
|
||||
domain.DebtSignature(domain.DebtCorrectness, "plan_mismatch", "", component), o,
|
||||
"the repository contradicts sealed plans", review.Important)
|
||||
case domain.EventReviewRecorded:
|
||||
// Repeated findings in one component are the only mechanical
|
||||
// evidence that structure is costing future changes. One finding
|
||||
// is a review doing its job.
|
||||
if readArtifact == nil {
|
||||
break
|
||||
}
|
||||
raw, err := readArtifact(str(p["artifact_ref"]))
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
result, err := review.Decode(raw)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
for _, f := range result.Findings {
|
||||
component := domain.DebtComponent(f.File)
|
||||
o := base
|
||||
o.Kind = domain.ObservationReviewFinding
|
||||
o.Detail = f.Claim
|
||||
if f.File != "" {
|
||||
o.Paths = []string{f.File}
|
||||
}
|
||||
add(domain.DebtStructural,
|
||||
domain.DebtSignature(domain.DebtStructural, string(f.Severity), "", component), o,
|
||||
"review keeps finding "+string(f.Severity)+" issues in "+component, f.Severity)
|
||||
}
|
||||
case domain.EventDeferredFindingRecorded:
|
||||
o := base
|
||||
o.Kind = domain.ObservationDeferredFinding
|
||||
o.Detail = str(p["summary"])
|
||||
// Polish until something demonstrates a cost. A discovery nobody
|
||||
// has paid for yet is not debt with a priority.
|
||||
add(domain.DebtPolish,
|
||||
domain.DebtSignature(domain.DebtPolish, "deferred_finding", "", ""), o,
|
||||
"out-of-scope discoveries recorded and unaddressed", review.Minor)
|
||||
}
|
||||
}
|
||||
|
||||
items := make([]domain.DebtItem, 0, len(order))
|
||||
for _, sig := range order {
|
||||
items = append(items, *bySignature[sig])
|
||||
}
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
if items[i].Recurrence() != items[j].Recurrence() {
|
||||
return items[i].Recurrence() > items[j].Recurrence()
|
||||
}
|
||||
return items[i].ID < items[j].ID
|
||||
})
|
||||
return domain.DebtLedger{Items: items, Gaps: debtGaps(seenKinds), Events: len(events)}
|
||||
}
|
||||
|
||||
// debtGaps separates two different silences. A kind no event type carries is a
|
||||
// hole in the system. A kind the log could carry and does not is a fact about
|
||||
// this history.
|
||||
func debtGaps(seen map[domain.ObservationKind]bool) []domain.EvidenceGap {
|
||||
// Both of these were once permanent holes in the system. They are ordinary
|
||||
// evidence now, so their absence is a fact about this history rather than
|
||||
// about Orchestra.
|
||||
var gaps []domain.EvidenceGap
|
||||
for _, k := range []domain.ObservationKind{
|
||||
domain.ObservationBlockReason, domain.ObservationFailureClass,
|
||||
domain.ObservationReviewFinding, domain.ObservationPlanMismatch,
|
||||
domain.ObservationDeferredFinding, domain.ObservationWorkerFailure,
|
||||
domain.ObservationManualIntervention,
|
||||
} {
|
||||
if !seen[k] {
|
||||
gaps = append(gaps, domain.EvidenceGap{Kind: k, Durable: true,
|
||||
Reason: "carried by the log, but this history contains none"})
|
||||
}
|
||||
}
|
||||
return gaps
|
||||
}
|
||||
|
||||
// DebtLedger projects the whole log. Read-only, like every other projection
|
||||
// the coordinator serves.
|
||||
func (s *Store) DebtLedger() domain.DebtLedger {
|
||||
return ProjectDebtWithArtifacts(s.Events(0), s.Artifact)
|
||||
}
|
||||
|
||||
func str(v any) string { s, _ := v.(string); return s }
|
||||
|
||||
func strList(v any) []string {
|
||||
list, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(list))
|
||||
for _, item := range list {
|
||||
if s := strings.TrimSpace(str(item)); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstComponent(paths []string) string {
|
||||
for _, p := range paths {
|
||||
if c := domain.DebtComponent(p); c != "" {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func contains(list []string, s string) bool {
|
||||
for _, item := range list {
|
||||
if item == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"orchestra/internal/domain"
|
||||
)
|
||||
|
||||
func debtEvent(id, typ, task string, payload map[string]any) domain.Event {
|
||||
b, _ := json.Marshal(payload)
|
||||
return domain.Event{ID: id, Type: typ, TaskID: task, Payload: b, At: time.Now().UTC()}
|
||||
}
|
||||
|
||||
// The opencode shape from run 14: one harness failing the same way across
|
||||
// several tasks. That is the recurrence the ledger exists to notice.
|
||||
func TestProjectDebtGroupsOneFailureShapeAcrossTasks(t *testing.T) {
|
||||
events := []domain.Event{
|
||||
debtEvent("e1", "TaskFailed", "t1", map[string]any{"failure_class": "retry_limit", "harness_id": "workpc-opencode"}),
|
||||
debtEvent("e2", "TaskFailed", "t2", map[string]any{"failure_class": "retry_limit", "harness_id": "workpc-opencode"}),
|
||||
debtEvent("e3", "TaskFailed", "t3", map[string]any{"failure_class": "retry_limit", "harness_id": "workpc-opencode"}),
|
||||
debtEvent("e4", "TaskFailed", "t4", map[string]any{"failure_class": "retry_limit", "harness_id": "workpc-claude"}),
|
||||
// A handoff release is ordinary progress and must not become debt.
|
||||
debtEvent("e5", "TaskReleased", "t5", map[string]any{"handoff_ref": "sha256:abc", "harness_id": "workpc-claude"}),
|
||||
}
|
||||
ledger := ProjectDebt(events)
|
||||
if len(ledger.Items) != 2 {
|
||||
t.Fatalf("want one item per harness, got %d: %+v", len(ledger.Items), ledger.Items)
|
||||
}
|
||||
top := ledger.Items[0]
|
||||
if top.Recurrence() != 3 || top.AffectedTasks() != 3 {
|
||||
t.Fatalf("recurrence %d across %d tasks, want 3 and 3", top.Recurrence(), top.AffectedTasks())
|
||||
}
|
||||
if top.Class != domain.DebtOperational {
|
||||
t.Fatalf("class %q, want operational", top.Class)
|
||||
}
|
||||
if got := top.ID; got != "v1:operational:retry_limit:workpc-opencode:lease" {
|
||||
t.Fatalf("signature %q", got)
|
||||
}
|
||||
for _, o := range top.Observations {
|
||||
if o.EventID == "" || o.LegacyRef != "" {
|
||||
t.Fatalf("observation lost its event provenance: %+v", o)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A lifecycle stop is the system working. Waiting for a human is not debt, and
|
||||
// counting it would drown the real signal.
|
||||
func TestProjectDebtIgnoresOrdinaryLifecycleStops(t *testing.T) {
|
||||
events := []domain.Event{
|
||||
debtEvent("e1", "TaskBlocked", "t1", map[string]any{"block_reason": "human_decision", "blocker": "which route"}),
|
||||
debtEvent("e2", "TaskBlocked", "t2", map[string]any{"block_reason": "trajectory_gate", "blocker": "confirm plan"}),
|
||||
debtEvent("e3", "TaskBlocked", "t3", map[string]any{"block_reason": "lease_expired", "blocker": "lease expired"}),
|
||||
}
|
||||
ledger := ProjectDebt(events)
|
||||
if len(ledger.Items) != 1 || ledger.Items[0].Class != domain.DebtOperational {
|
||||
t.Fatalf("want only the lease_expired item, got %+v", ledger.Items)
|
||||
}
|
||||
}
|
||||
|
||||
// Incompleteness is part of the result. A ledger that stays silent about what
|
||||
// it cannot record reads as "no operator cost" when it means "operator cost is
|
||||
// not recorded anywhere".
|
||||
func TestProjectDebtReportsWhatItCannotSee(t *testing.T) {
|
||||
ledger := ProjectDebt(nil)
|
||||
for _, g := range ledger.Gaps {
|
||||
if g.Reason == "" {
|
||||
t.Fatalf("gap %q has no reason", g.Kind)
|
||||
}
|
||||
// Slice B closed the two holes this ledger used to report about
|
||||
// itself. Every silence is now a fact about one history, never a kind
|
||||
// of evidence the system cannot record at all.
|
||||
if !g.Durable {
|
||||
t.Fatalf("gap %q is reported as unrecordable: %+v", g.Kind, g)
|
||||
}
|
||||
}
|
||||
var worker, manual bool
|
||||
for _, g := range ledger.Gaps {
|
||||
switch g.Kind {
|
||||
case domain.ObservationWorkerFailure:
|
||||
worker = true
|
||||
case domain.ObservationManualIntervention:
|
||||
manual = true
|
||||
}
|
||||
}
|
||||
if !worker || !manual {
|
||||
t.Fatalf("an empty history should still name both kinds as absent: %+v", ledger.Gaps)
|
||||
}
|
||||
}
|
||||
|
||||
// The whole point of incidents. One worker stuck in a retry loop must not
|
||||
// manufacture recurrence, while its intensity is still on the record.
|
||||
func TestRecurrenceCountsIncidentsAndKeepsIntensitySeparate(t *testing.T) {
|
||||
closed := func(id, worker, task, epoch string, repeats int) domain.Event {
|
||||
b, _ := json.Marshal(domain.ObservationIncident{
|
||||
ID: id, WorkerID: worker, TaskID: task, LeaseEpoch: epoch,
|
||||
Signature: "lease <id> not renewed: agent status idle and pane unchanged",
|
||||
Detail: "lease " + task + " not renewed: agent status idle and pane unchanged",
|
||||
RepeatCount: repeats, CloseReason: domain.ObservationCloseEpochChange,
|
||||
})
|
||||
return domain.Event{ID: id, Type: domain.EventObservationIncidentClosed, TaskID: "system", Payload: b}
|
||||
}
|
||||
ledger := ProjectDebt([]domain.Event{
|
||||
closed("i1", "workpc-claude", "task-a", "e1", 301),
|
||||
closed("i2", "workpc-claude", "task-b", "e2", 2),
|
||||
})
|
||||
if len(ledger.Items) != 1 {
|
||||
t.Fatalf("one kind of failure produced %d items", len(ledger.Items))
|
||||
}
|
||||
item := ledger.Items[0]
|
||||
if len(item.Observations) != 2 {
|
||||
t.Fatalf("recurrence = %d, want one per incident", len(item.Observations))
|
||||
}
|
||||
intensity := 0
|
||||
for _, o := range item.Observations {
|
||||
if o.Kind != domain.ObservationWorkerFailure {
|
||||
t.Fatalf("observation kind = %q", o.Kind)
|
||||
}
|
||||
intensity += o.Repeats
|
||||
}
|
||||
if intensity != 303 {
|
||||
t.Fatalf("intensity = %d, want 303 carried alongside a recurrence of 2", intensity)
|
||||
}
|
||||
tasks := map[string]bool{}
|
||||
for _, o := range item.Observations {
|
||||
tasks[o.TaskID] = true
|
||||
}
|
||||
if len(tasks) != 2 {
|
||||
t.Fatalf("the two incidents are not attributed to their tasks: %+v", item.Observations)
|
||||
}
|
||||
}
|
||||
|
||||
// A repair the operator made by hand is evidence like any other, once they say
|
||||
// it happened.
|
||||
func TestAnOperatorRepairBecomesDebtEvidence(t *testing.T) {
|
||||
b, _ := json.Marshal(domain.OperatorIntervention{
|
||||
WorkerID: "workpc-opencode", Kind: domain.InterventionTransactionClean,
|
||||
Reason: "deleted a release transaction stuck at prepared so the pane could be reused",
|
||||
})
|
||||
ledger := ProjectDebt([]domain.Event{{
|
||||
ID: "i1", Type: domain.EventOperatorInterventionRecorded, TaskID: "system", Payload: b,
|
||||
}})
|
||||
if len(ledger.Items) != 1 || len(ledger.Items[0].Observations) != 1 {
|
||||
t.Fatalf("the repair produced no debt evidence: %+v", ledger.Items)
|
||||
}
|
||||
if got := ledger.Items[0].Observations[0].Kind; got != domain.ObservationManualIntervention {
|
||||
t.Fatalf("kind = %q", got)
|
||||
}
|
||||
for _, g := range ledger.Gaps {
|
||||
if g.Kind == domain.ObservationManualIntervention {
|
||||
t.Fatal("manual intervention is still reported as missing from a history that contains one")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,9 @@ type Store struct {
|
||||
cursors map[string]string
|
||||
cursorPath string
|
||||
decisionSource map[string]string
|
||||
// openObservations are the incidents opened and not yet closed, by id.
|
||||
// Derived from the log, so a restart finds them again.
|
||||
openObservations map[string]domain.ObservationIncident
|
||||
// PreLease runs immediately before a lease is minted, which is the single
|
||||
// point where ownership of a task begins. Reconciliation of newer human
|
||||
// input belongs here rather than in any individual launch path, because a
|
||||
@@ -96,7 +99,7 @@ func Open(dir string) (*Store, error) {
|
||||
if t, ok := s.tasks[e.TaskID]; ok && e.Version != t.Version+1 {
|
||||
return nil, domain.ErrConflict
|
||||
}
|
||||
global := e.Type == "QuotaReported" || e.Type == "StandupAdvisory" || e.Type == "ApprovalGranted" || e.Type == "ApprovalDenied"
|
||||
global := domain.EventWithoutTask(e.Type)
|
||||
if _, ok := s.tasks[e.TaskID]; !ok && e.Type != "TaskCreated" && !global {
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
@@ -272,6 +275,9 @@ func (s *Store) apply(e domain.Event) error {
|
||||
}
|
||||
if p.Subject == domain.PlanPhaseSubject(rec.PlanRef, rec.PhaseID) {
|
||||
t.PlanProgress.Phases[i].Status = domain.PlanPhaseVerified
|
||||
// Record which tree the sign-off was about, so a later run
|
||||
// at a different commit cannot inherit it (F63).
|
||||
t.PlanProgress.Phases[i].ManualAtSHA = rec.AtSHA
|
||||
t.Version = e.Version
|
||||
s.replaceTask(e.TaskID, t)
|
||||
}
|
||||
@@ -309,6 +315,9 @@ func (s *Store) apply(e domain.Event) error {
|
||||
}
|
||||
t.PlanRef = p.ArtifactRef
|
||||
t.PlanProgress = nil
|
||||
// The replacement answers the contradiction. Carrying it
|
||||
// further would put a solved problem in every later context.
|
||||
t.PlanMismatch = nil
|
||||
}
|
||||
}
|
||||
t.WorkPhase = p.Phase
|
||||
@@ -424,6 +433,13 @@ func (s *Store) apply(e domain.Event) error {
|
||||
t.Lease = nil
|
||||
case "TaskBlocked", "TaskNeedsAttention":
|
||||
if e.Type == "TaskBlocked" {
|
||||
if t.Lease != nil {
|
||||
// Same reason as TaskReleased: a worker may hold a pushed
|
||||
// anchor whose commit was refused. A reopen returns the task
|
||||
// to the queue, and the late-handoff path can only accept it
|
||||
// if the epoch that ended is still on record.
|
||||
t.LastLeaseEpoch = t.Lease.Epoch
|
||||
}
|
||||
t.State = domain.StateBlocked
|
||||
t.Lease = nil
|
||||
} else {
|
||||
@@ -477,6 +493,39 @@ func (s *Store) apply(e domain.Event) error {
|
||||
t.Version = e.Version
|
||||
s.replaceTask(e.TaskID, t)
|
||||
return nil
|
||||
case domain.EventObservationIncidentOpened:
|
||||
// Open incidents are projected so a coordinator restart resumes them
|
||||
// instead of orphaning them half-recorded. The event is what makes an
|
||||
// incident durable at first sight; this is how it is found again.
|
||||
var inc domain.ObservationIncident
|
||||
if err := json.Unmarshal(e.Payload, &inc); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.openObservations == nil {
|
||||
s.openObservations = map[string]domain.ObservationIncident{}
|
||||
}
|
||||
s.openObservations[inc.ID] = inc
|
||||
return nil
|
||||
case domain.EventObservationIncidentClosed:
|
||||
var inc domain.ObservationIncident
|
||||
if err := json.Unmarshal(e.Payload, &inc); err != nil {
|
||||
return err
|
||||
}
|
||||
delete(s.openObservations, inc.ID)
|
||||
return nil
|
||||
case domain.EventPlanMismatchRecorded:
|
||||
// Projected so the phase this reopens can be told what reopened it.
|
||||
// The event is the record; this is the live instruction derived from
|
||||
// it, and it stops being live as soon as a replacement seals or the
|
||||
// stop is answered.
|
||||
var m domain.PlanMismatch
|
||||
if err := json.Unmarshal(e.Payload, &m); err != nil {
|
||||
return err
|
||||
}
|
||||
t.PlanMismatch = &m
|
||||
t.Version = e.Version
|
||||
s.replaceTask(e.TaskID, t)
|
||||
return nil
|
||||
case domain.EventPlanPhaseVerified:
|
||||
var pp domain.PlanPhaseRecord
|
||||
if err := json.Unmarshal(e.Payload, &pp); err != nil {
|
||||
@@ -578,8 +627,22 @@ func (s *Store) apply(e domain.Event) error {
|
||||
// A question only stands while the task is blocked on it. Afterwards the
|
||||
// answer is an ordinary standing decision and the log still holds the
|
||||
// question, so keeping it on the task would put it in every later context.
|
||||
//
|
||||
// The blocker itself has the same lifetime and was outliving it (F67). A
|
||||
// task resumed from a plan mismatch kept reporting block_reason
|
||||
// plan_mismatch while it ran, so every surface reading the projection
|
||||
// showed a stop that had already been answered.
|
||||
if t.State != domain.StateBlocked {
|
||||
t.DecisionRequest = nil
|
||||
t.Blocker, t.BlockReason = "", ""
|
||||
// The contradiction is cleared only by the correction that answers the
|
||||
// stop, never by any other event that happens to find the task
|
||||
// unblocked. This block runs for every event, and clearing it here
|
||||
// unconditionally erased the contradiction at the very rotation the
|
||||
// reopen causes, which is exactly when the planner needs it.
|
||||
if e.Type == "TaskCorrected" {
|
||||
t.PlanMismatch = nil
|
||||
}
|
||||
}
|
||||
if phase, ok := p["lifecycle_phase"].(string); ok && phase != "" {
|
||||
t.LifecyclePhase = phase
|
||||
@@ -807,7 +870,7 @@ func (s *Store) Append(e domain.Event) error {
|
||||
return fmt.Errorf("%w: corrects references unknown event %q for this task", domain.ErrInvalid, corrects)
|
||||
}
|
||||
}
|
||||
global := e.Type == "QuotaReported" || e.Type == "StandupAdvisory" || e.Type == "ApprovalGranted" || e.Type == "ApprovalDenied"
|
||||
global := domain.EventWithoutTask(e.Type)
|
||||
if !taskExists && e.Type != "TaskCreated" && !global {
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
@@ -869,7 +932,7 @@ func (s *Store) Append(e domain.Event) error {
|
||||
// for every lifecycle mutation.
|
||||
func (s *Store) validateTransition(e domain.Event, t domain.Task, exists bool, p map[string]any) error {
|
||||
if !exists {
|
||||
if e.Type != "TaskCreated" && e.Type != "QuotaReported" && e.Type != "StandupAdvisory" && e.Type != "ApprovalGranted" && e.Type != "ApprovalDenied" {
|
||||
if e.Type != "TaskCreated" && !domain.EventWithoutTask(e.Type) {
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
@@ -1073,6 +1136,31 @@ func (s *Store) Artifact(ref string) ([]byte, error) {
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// NoteObservation updates an open incident's last actual occurrence. It writes
|
||||
// no event: the aggregate is durable when the incident is finalized, and
|
||||
// appending one per heartbeat is exactly the spam this design exists to avoid.
|
||||
func (s *Store) NoteObservation(inc domain.ObservationIncident) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.openObservations[inc.ID]; ok {
|
||||
s.openObservations[inc.ID] = inc
|
||||
}
|
||||
}
|
||||
|
||||
// OpenObservations returns the incidents that are open, newest first by first
|
||||
// sight. Open means not yet finalized as durable evidence, never "the failure
|
||||
// is happening right now".
|
||||
func (s *Store) OpenObservations() []domain.ObservationIncident {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]domain.ObservationIncident, 0, len(s.openObservations))
|
||||
for _, inc := range s.openObservations {
|
||||
out = append(out, inc)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].FirstSeen.After(out[j].FirstSeen) })
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Store) Task(id string) (domain.Task, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
@@ -693,3 +693,102 @@ func TestExpiryRetainsLeaseEpoch(t *testing.T) {
|
||||
t.Fatalf("last lease epoch %q, want %q", after.LastLeaseEpoch, epoch)
|
||||
}
|
||||
}
|
||||
|
||||
// A worker can hold a pushed anchor whose commit was refused when an operator
|
||||
// blocks the task. A reopen returns it to the queue, and the late-handoff path
|
||||
// can only accept that exact owner if the epoch that ended is still recorded.
|
||||
func TestBlockRetainsLeaseEpochForALaterReopen(t *testing.T) {
|
||||
s, err := Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Append(created("e1")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := s.Tasks()[0].ID
|
||||
if _, err := s.Lease(id, "h1", time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
leased, _ := s.Task(id)
|
||||
epoch := leased.Lease.Epoch
|
||||
p, _ := json.Marshal(map[string]any{"blocker": "parked by the operator", "harness_id": "h1", "lease_epoch": epoch})
|
||||
if err := s.Append(domain.Event{ID: domain.NewID(), Type: "TaskBlocked", TaskID: id, Version: leased.Version + 1, Payload: p, Surface: string(authz.System)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, _ := s.Task(id)
|
||||
if after.State != domain.StateBlocked || after.Lease != nil {
|
||||
t.Fatalf("expected a blocked unleased task, got %s lease=%v", after.State, after.Lease)
|
||||
}
|
||||
if after.LastLeaseEpoch != epoch || epoch == "" {
|
||||
t.Fatalf("last lease epoch %q, want %q", after.LastLeaseEpoch, epoch)
|
||||
}
|
||||
}
|
||||
|
||||
// F66's projection has to survive the rotation the reopen causes. The session
|
||||
// that reported the contradiction hands off, a successor leases, and only then
|
||||
// is the planning context rendered. Live on run 21, the field was gone by
|
||||
// then: the reopen recorded it and the rotation lost it.
|
||||
func TestTheContradictionSurvivesTheRotationItCauses(t *testing.T) {
|
||||
s, err := Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := []byte(`{"source":"s","external_id":"x","project":"p"}`)
|
||||
if err := s.Append(domain.Event{ID: "create", Type: "TaskCreated", TaskID: "t", Version: 1, Payload: b, Surface: string(authz.System)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Lease("t", "h", time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Frame to implement, the shortest legal route to the phase a mismatch
|
||||
// can be reported from.
|
||||
task, _ := s.Task("t")
|
||||
toImplement, _ := json.Marshal(map[string]any{"phase": "implement", "from": "frame"})
|
||||
if err := s.Append(domain.Event{ID: "impl", Type: domain.EventWorkPhaseChanged, TaskID: "t", Version: task.Version + 1, Payload: toImplement, Surface: string(authz.System)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task, _ = s.Task("t")
|
||||
m, _ := json.Marshal(map[string]any{
|
||||
"plan_ref": "plan-a", "phase_id": "phase-2",
|
||||
"at_sha": "0123456789012345678901234567890123456789",
|
||||
"observed": "the body is assembled inline",
|
||||
"contradicts": "the plan says one helper returns it",
|
||||
"harness_id": task.Lease.HarnessID,
|
||||
"lease_epoch": task.Lease.Epoch,
|
||||
"requested_action": "replan",
|
||||
})
|
||||
if err := s.Append(domain.Event{ID: "mismatch", Type: domain.EventPlanMismatchRecorded, TaskID: "t", Version: task.Version + 1, Payload: m, Surface: string(authz.System)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := s.Task("t"); got.PlanMismatch == nil {
|
||||
t.Fatal("the contradiction was not projected at all")
|
||||
}
|
||||
|
||||
// The reopen, then the rotation it causes.
|
||||
task, _ = s.Task("t")
|
||||
ph, _ := json.Marshal(map[string]any{"phase": "plan", "from": "implement", "reopen": domain.EventPlanMismatchRecorded, "reopen_phase_id": "phase-2"})
|
||||
if err := s.Append(domain.Event{ID: "reopen", Type: domain.EventWorkPhaseChanged, TaskID: "t", Version: task.Version + 1, Payload: ph, Surface: string(authz.System)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ref, err := s.PutArtifact([]byte("handoff"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task, _ = s.Task("t")
|
||||
rel, _ := json.Marshal(map[string]any{"handoff_ref": ref, "anchor_sha": "0123456789012345678901234567890123456789", "harness_id": task.Lease.HarnessID, "lease_epoch": task.Lease.Epoch, "expected_version": task.Version})
|
||||
if err := s.Append(domain.Event{ID: "release", Type: "TaskReleased", TaskID: "t", Version: task.Version + 1, Payload: rel, Surface: string(authz.System)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Lease("t", "h", time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// This is the moment the planning context is rendered.
|
||||
got, _ := s.Task("t")
|
||||
if got.PlanMismatch == nil {
|
||||
t.Fatal("the planner is convened to settle a contradiction it is no longer told about")
|
||||
}
|
||||
if got.PlanMismatch.PhaseID != "phase-2" {
|
||||
t.Fatalf("the contradiction changed across the rotation: %+v", got.PlanMismatch)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
ETHOS — icon set
|
||||
One hand, one grid. Every icon: viewBox 0 0 24 24, stroke="currentColor",
|
||||
stroke-width 1.7, round caps/joins, fill="none". Transport glyphs (play,
|
||||
pause, prev, next, more) are the only filled exceptions.
|
||||
|
||||
USE: <svg class="icon" width="22" height="22"><use href="/ethos-icons.svg#i-search"/> <symbol id="i-fork" viewBox="0 0 24 24"><path d="M4 12h4M8 12l4-5h8M8 12l4 5h8"/><circle cx="4" cy="12" r="1.6"/><circle cx="20" cy="7" r="1.6"/><circle cx="20" cy="17" r="1.6"/></symbol>
|
||||
<symbol id="i-task" viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="16" rx="3"/><path d="M8.5 12l2.5 2.5 4.5-5"/></symbol>
|
||||
<symbol id="i-decision" viewBox="0 0 24 24"><path d="M12 3l9 9-9 9-9-9z"/><path d="M12 8v4M12 15.5v.5"/></symbol>
|
||||
<symbol id="i-worker" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01M7 16.5h.01"/></symbol>
|
||||
<symbol id="i-project" viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></symbol>
|
||||
<symbol id="i-review" viewBox="0 0 24 24"><path d="M6 3h9l4 4v14a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><path d="M14 3v5h5M9 13h6M9 17h4"/></symbol>
|
||||
<symbol id="i-terminal" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7.5 9.5l3 2.5-3 2.5M13 15h4"/></symbol>
|
||||
<symbol id="i-alert" viewBox="0 0 24 24"><path d="M12 4l9 15H3z"/><path d="M12 10v4M12 16.5v.5"/></symbol>
|
||||
<symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/></symbol>
|
||||
<symbol id="i-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.2 5.2l1.4 1.4M17.4 17.4l1.4 1.4M18.8 5.2l-1.4 1.4M6.6 17.4l-1.4 1.4"/></symbol>
|
||||
</svg>
|
||||
color + width/height come from the consumer; the icon inherits them.
|
||||
|
||||
EXTEND: add a new <symbol id="i-NAME"> on the same 24px grid, same stroke,
|
||||
same corner feel. Match the existing hand — do not import lucide or
|
||||
any other pack. Keep ids prefixed i- and kebab-cased.
|
||||
-->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style="display:none" aria-hidden="true">
|
||||
|
||||
<!-- ── app mark / motif seeds ── -->
|
||||
<symbol id="i-wave" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round">
|
||||
<path d="M2 12h1M6 8v8M10 4v16M14 7v10M18 5v14M21 11v2"/>
|
||||
</symbol>
|
||||
<symbol id="i-grid" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
|
||||
<rect x="4" y="4" width="7" height="7" rx="1.5"/><rect x="13" y="4" width="7" height="7" rx="1.5"/>
|
||||
<rect x="4" y="13" width="7" height="7" rx="1.5"/><rect x="13" y="13" width="7" height="7" rx="1.5"/>
|
||||
</symbol>
|
||||
|
||||
<!-- ── navigation ── -->
|
||||
<symbol id="i-home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 11l8-6 8 6M6 10v9h12v-9"/>
|
||||
</symbol>
|
||||
<symbol id="i-listen" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 18V9l14-3v9"/><circle cx="6" cy="18" r="2.4"/><circle cx="18" cy="15" r="2.4"/>
|
||||
</symbol>
|
||||
<symbol id="i-library" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
|
||||
<rect x="4" y="4" width="6" height="16" rx="1.5"/><rect x="14" y="4" width="6" height="16" rx="1.5"/>
|
||||
</symbol>
|
||||
<symbol id="i-album" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7">
|
||||
<circle cx="12" cy="12" r="8.5"/><circle cx="12" cy="12" r="2"/>
|
||||
</symbol>
|
||||
<symbol id="i-artist" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="8" r="4"/><path d="M5 20c0-3.5 3.1-6 7-6s7 2.5 7 6"/>
|
||||
</symbol>
|
||||
<symbol id="i-queue" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 7h11M4 12h11M4 17h7M18 9v8"/><circle cx="18" cy="18.5" r="1.6"/>
|
||||
</symbol>
|
||||
<symbol id="i-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
|
||||
<path d="M4 7a1 1 0 011-1h4.5l2 2H19a1 1 0 011 1v8a1 1 0 01-1 1H5a1 1 0 01-1-1z"/>
|
||||
</symbol>
|
||||
<symbol id="i-file" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
|
||||
<path d="M14 3H7a1 1 0 00-1 1v16a1 1 0 001 1h10a1 1 0 001-1V8z"/><path d="M14 3v5h5"/>
|
||||
</symbol>
|
||||
<symbol id="i-settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3.2"/><path d="M12 2v3M12 19v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2 12h3M19 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1"/>
|
||||
</symbol>
|
||||
|
||||
<!-- ── transport (filled) ── -->
|
||||
<symbol id="i-play" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></symbol>
|
||||
<symbol id="i-pause" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5h3v14H8zM13 5h3v14h-3z"/></symbol>
|
||||
<symbol id="i-prev" viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6z"/><path d="M20 6v12l-9-6z"/></symbol>
|
||||
<symbol id="i-next" viewBox="0 0 24 24" fill="currentColor"><path d="M16 6h2v12h-2z"/><path d="M6 6v12l9-6z"/></symbol>
|
||||
<symbol id="i-shuffle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M16 4h4v4M20 4l-6 6M4 20l16-16M16 20h4v-4M14 14l6 6M4 4l4 4"/>
|
||||
</symbol>
|
||||
<symbol id="i-repeat" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M17 2l3 3-3 3M20 5H8a4 4 0 00-4 4v1M7 22l-3-3 3-3M4 19h12a4 4 0 004-4v-1"/>
|
||||
</symbol>
|
||||
|
||||
<!-- ── actions / status ── -->
|
||||
<symbol id="i-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
|
||||
<circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/>
|
||||
</symbol>
|
||||
<symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></symbol>
|
||||
<symbol id="i-x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></symbol>
|
||||
<symbol id="i-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l4.5 4.5L20 7"/></symbol>
|
||||
<symbol id="i-chevron-left" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M15 6l-6 6 6 6"/></symbol>
|
||||
<symbol id="i-chevron-right" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 6l6 6-6 6"/></symbol>
|
||||
<symbol id="i-more" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/></symbol>
|
||||
<symbol id="i-bell" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M6 9a6 6 0 0112 0c0 5 2 6 2 6H4s2-1 2-6M10 21h4"/>
|
||||
</symbol>
|
||||
<symbol id="i-download" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 4v11M8 11l4 4 4-4M5 20h14"/>
|
||||
</symbol>
|
||||
<symbol id="i-upload" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 15V4M8 8l4-4 4 4M5 20h14"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="i-fork" viewBox="0 0 24 24"><path d="M4 12h4M8 12l4-5h8M8 12l4 5h8"/><circle cx="4" cy="12" r="1.6"/><circle cx="20" cy="7" r="1.6"/><circle cx="20" cy="17" r="1.6"/></symbol>
|
||||
<symbol id="i-task" viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="16" rx="3"/><path d="M8.5 12l2.5 2.5 4.5-5"/></symbol>
|
||||
<symbol id="i-decision" viewBox="0 0 24 24"><path d="M12 3l9 9-9 9-9-9z"/><path d="M12 8v4M12 15.5v.5"/></symbol>
|
||||
<symbol id="i-worker" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01M7 16.5h.01"/></symbol>
|
||||
<symbol id="i-project" viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></symbol>
|
||||
<symbol id="i-review" viewBox="0 0 24 24"><path d="M6 3h9l4 4v14a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><path d="M14 3v5h5M9 13h6M9 17h4"/></symbol>
|
||||
<symbol id="i-terminal" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7.5 9.5l3 2.5-3 2.5M13 15h4"/></symbol>
|
||||
<symbol id="i-alert" viewBox="0 0 24 24"><path d="M12 4l9 15H3z"/><path d="M12 10v4M12 16.5v.5"/></symbol>
|
||||
<symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/></symbol>
|
||||
<symbol id="i-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.2 5.2l1.4 1.4M17.4 17.4l1.4 1.4M18.8 5.2l-1.4 1.4M6.6 17.4l-1.4 1.4"/></symbol>
|
||||
</svg>
|
||||
@@ -1,3 +1,13 @@
|
||||
<script type="module" crossorigin src="/assets/index-C0H-zYgt.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B0BkAbwp.css">
|
||||
<div id="root"></div>
|
||||
<!doctype html>
|
||||
<html lang="en" data-app="orchestra">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>orchestra</title>
|
||||
<script type="module" crossorigin src="/assets/index-BAiY1a7C.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BUuDvtvX.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
@@ -0,0 +1,635 @@
|
||||
# orchestra web control ui
|
||||
|
||||
status: mockup specification
|
||||
app: orchestra
|
||||
shared design system: ethos
|
||||
accent: signal violet `#8F7AE5`
|
||||
motif: routing fork
|
||||
primary operator: single infrastructure operator
|
||||
|
||||
## 1. purpose
|
||||
|
||||
orchestra's web ui is an operator console for understanding and steering agent work without reconstructing state from logs, tmux sessions, gitea, or the event stream.
|
||||
|
||||
it has three jobs:
|
||||
|
||||
1. show what needs the operator now.
|
||||
2. show what orchestra believes and what an agent is actually doing.
|
||||
3. expose intervention and forensic detail without making the default screens noisy.
|
||||
|
||||
it is not a generic project-management product, an analytics dashboard, or a chat-first interface.
|
||||
|
||||
## 2. ethos application
|
||||
|
||||
orchestra inherits the ethos shell and neutral system unchanged.
|
||||
|
||||
### fingerprint
|
||||
|
||||
- accent: signal violet `#8F7AE5`
|
||||
- motif: routing fork
|
||||
- accent use: active navigation, focus, selected phase, primary operator action, routing/transition marks
|
||||
- motif use: app mark, phase transitions, routing state, empty states, one hero/detail moment
|
||||
|
||||
suggested token block:
|
||||
|
||||
```css
|
||||
[data-app="orchestra"] {
|
||||
--accent: #8F7AE5;
|
||||
--accent-hi: #A291EC;
|
||||
--accent-dim: rgba(143, 122, 229, 0.14);
|
||||
--accent-line: rgba(143, 122, 229, 0.32);
|
||||
--accent-glow: rgba(143, 122, 229, 0.22);
|
||||
}
|
||||
```
|
||||
|
||||
### type
|
||||
|
||||
- geist sans: labels, task names, explanations, buttons, headings, operator-authored prose
|
||||
- geist mono: task ids, epochs, worker ids, pane ids, timestamps, percentages, context counts, revisions, paths, refs, sha values, lease durations
|
||||
- machine values must never silently use sans for visual convenience
|
||||
|
||||
### surfaces
|
||||
|
||||
- page room: `--bg-0`
|
||||
- normal panel: `--bg-1`
|
||||
- selected/raised content: `--bg-2`
|
||||
- hover: `--bg-3`
|
||||
- borders: 1px `--line`, `--line-hi` only where selection or risk needs stronger separation
|
||||
- no blur or glass
|
||||
- accent never washes a whole content panel
|
||||
|
||||
### motion
|
||||
|
||||
all ui transitions use ethos mechanical motion: 130–170 ms, `cubic-bezier(0.2, 0, 0, 1)`, no spring or bounce.
|
||||
|
||||
## 3. shared shell
|
||||
|
||||
### desktop
|
||||
|
||||
- 64px vertical icon rail
|
||||
- 56px top bar
|
||||
- main content fills remaining viewport
|
||||
- global `cmd+k` command/search surface in the top bar
|
||||
- right side of top bar shows only high-value machine state: orchestra revision, live state, current time
|
||||
|
||||
primary navigation:
|
||||
|
||||
1. dashboard
|
||||
2. tasks
|
||||
3. decisions
|
||||
4. workers
|
||||
5. projects
|
||||
6. review
|
||||
7. settings
|
||||
|
||||
terminal is entered from a task or worker and does not need permanent primary navigation.
|
||||
|
||||
### mobile
|
||||
|
||||
- rail becomes bottom navigation with no more than five primary items
|
||||
- secondary pages move behind task/project drill-down or header menus
|
||||
- machine readouts relocate into detail screens rather than disappearing
|
||||
- terminal remains a dedicated full-screen view
|
||||
|
||||
## 4. information hierarchy
|
||||
|
||||
orchestra should visually distinguish five classes of information.
|
||||
|
||||
| class | meaning | treatment |
|
||||
|---|---|---|
|
||||
| operator authority | human decision, correction, constraint | high prominence, accent signal |
|
||||
| verified orchestra state | lifecycle, lease, phase, review, refs | neutral surfaces, machine values in mono |
|
||||
| agent claims | proposed plan, handoff text, self-reported risk | visibly labeled as agent-supplied |
|
||||
| historical/superseded | stale plan, previous handoff, old decision | muted, never visually dominant |
|
||||
| fault/attention | blocked, refused, stale, mismatch | status color + explicit reason |
|
||||
|
||||
raw events and implementation details are drill-down evidence, not default-page content.
|
||||
|
||||
## 5. dashboard
|
||||
|
||||
mockup: `final/01-dashboard.png`
|
||||
|
||||
purpose: answer "what needs me, what is running, and is the system healthy?"
|
||||
|
||||
### layout
|
||||
|
||||
three primary regions only:
|
||||
|
||||
1. **needs your attention**
|
||||
- trajectory gate
|
||||
- blocking human decision
|
||||
- pr awaiting review
|
||||
- closed-without-merge/operator-required state
|
||||
- one line explaining why each item needs the operator
|
||||
|
||||
2. **running tasks**
|
||||
- task name/id
|
||||
- current phase
|
||||
- worker/harness
|
||||
- last real progress
|
||||
- context occupancy
|
||||
- lease timing
|
||||
- compact routing-fork phase indicator
|
||||
|
||||
3. **system state and capacity**
|
||||
- workers online/available
|
||||
- quota status
|
||||
- active leases
|
||||
- router queue/rejection state
|
||||
- source reconciliation
|
||||
- herdr health
|
||||
|
||||
### rules
|
||||
|
||||
- no disk, cpu, ram, or generic host-monitor metrics unless they directly block orchestration
|
||||
- no vanity charts
|
||||
- health should show actual counts/times, not vague "running" indicators
|
||||
- dashboard actions should navigate to the relevant task/review/worker rather than becoming an operator-control surface itself
|
||||
|
||||
## 6. tasks list
|
||||
|
||||
mockup: `final/04-tasks.png`
|
||||
|
||||
purpose: browse all work under orchestra and locate a task quickly.
|
||||
|
||||
### groups
|
||||
|
||||
- needs attention
|
||||
- running
|
||||
- waiting/queued
|
||||
- completed
|
||||
- failed
|
||||
|
||||
### row fields
|
||||
|
||||
- task title/id
|
||||
- project
|
||||
- current phase
|
||||
- compact routing-fork phase path
|
||||
- worker/harness when assigned
|
||||
- last update/progress
|
||||
- context occupancy for active sessions
|
||||
- status
|
||||
|
||||
### filters
|
||||
|
||||
- project
|
||||
- phase
|
||||
- worker/harness
|
||||
- status
|
||||
- attention state
|
||||
|
||||
avoid turning this into a kanban board. orchestra's lifecycle is stateful and event-backed; the list should expose state, not encourage arbitrary drag-and-drop mutation.
|
||||
|
||||
## 7. task detail
|
||||
|
||||
mockup: `final/02-task-detail.png`
|
||||
|
||||
purpose: provide one calm page answering "what are we doing?", "what is happening?", and "where are we in the workflow?"
|
||||
|
||||
### top area
|
||||
|
||||
- title
|
||||
- task id
|
||||
- project
|
||||
- current phase
|
||||
- steer/correct primary action
|
||||
- secondary actions menu
|
||||
|
||||
### what we're doing
|
||||
|
||||
show only:
|
||||
|
||||
- goal
|
||||
- acceptance summary
|
||||
- latest effective human decisions when present
|
||||
- active constraints when present
|
||||
|
||||
full contract, all decisions, and historical versions open in drill-down views.
|
||||
|
||||
### live execution
|
||||
|
||||
show only:
|
||||
|
||||
- worker/harness
|
||||
- pane
|
||||
- lease remaining
|
||||
- context occupancy
|
||||
- last progress
|
||||
- one-line current activity
|
||||
|
||||
links:
|
||||
|
||||
- open live pane
|
||||
- worker logs
|
||||
- launch context
|
||||
- git diff
|
||||
|
||||
### workflow
|
||||
|
||||
render the ace-fca path visibly:
|
||||
|
||||
`frame → research → plan → implement → review → pr → merged`
|
||||
|
||||
completed phases are quiet green, current phase uses the orchestra accent, future phases remain neutral.
|
||||
|
||||
phase detail, artifacts, refusal history, and raw events belong in drill-downs rather than the default task screen.
|
||||
|
||||
## 8. live pane / terminal
|
||||
|
||||
mockup: `final/03-terminal-live-pane.png`
|
||||
|
||||
purpose: show the actual tmux/herdr pane, not a reconstructed log view.
|
||||
|
||||
### behavior
|
||||
|
||||
- read-only by default
|
||||
- exact pane dimensions and terminal rendering preserved when possible
|
||||
- fit mode may scale visually but must not alter the underlying pane
|
||||
- scrollback inspection must not disturb the live cursor
|
||||
- `take control` explicitly enables keyboard forwarding
|
||||
- strong visual state when control is captured
|
||||
- escape or a fixed shortcut releases control
|
||||
|
||||
### why this screen exists
|
||||
|
||||
it must reveal real tui state such as:
|
||||
|
||||
- input still sitting in the editor
|
||||
- queued input
|
||||
- paste placeholders
|
||||
- permission/approval dialogs
|
||||
- `/clear`
|
||||
- `@HANDOFF.md`
|
||||
- agent busy/idle presentation
|
||||
- tmux status line and pane identity
|
||||
|
||||
### surrounding chrome
|
||||
|
||||
keep it minimal:
|
||||
|
||||
- worker/pane identity
|
||||
- read-only/control state
|
||||
- fit/copy/scrollback controls
|
||||
- lease epoch/timing
|
||||
- context occupancy
|
||||
- last verified progress
|
||||
- pane/worker health
|
||||
- small live event strip for orchestra-originated interactions and confirmations
|
||||
|
||||
when a pane is lost, preserve the last captured frame and state the exact loss time and lease consequence.
|
||||
|
||||
## 9. decisions list
|
||||
|
||||
mockup: `final/05-decisions.png`
|
||||
|
||||
purpose: inspect durable human authority and approvals across tasks.
|
||||
|
||||
this is not the human attention queue; that is review.
|
||||
|
||||
### list fields
|
||||
|
||||
- decision id
|
||||
- short decision text
|
||||
- task
|
||||
- phase
|
||||
- source/provenance
|
||||
- requested/recorded time
|
||||
- status: active, superseded, waiting, approved, rejected, auto
|
||||
- operator/actor
|
||||
|
||||
### filters
|
||||
|
||||
- active/superseded
|
||||
- kind: decision, correction, constraint, answer, scope change
|
||||
- project
|
||||
- task
|
||||
- actor/source
|
||||
|
||||
### detail
|
||||
|
||||
opening a decision should show:
|
||||
|
||||
- exact value
|
||||
- subject
|
||||
- provenance
|
||||
- supersedes/superseded-by links
|
||||
- event sequence
|
||||
- effective authority impact
|
||||
|
||||
machine provenance is mono; the human decision text remains sans.
|
||||
|
||||
## 10. workers
|
||||
|
||||
mockup: `final/06-workers.png`
|
||||
|
||||
purpose: answer "where can work run and is that execution machinery usable?"
|
||||
|
||||
### worker list fields
|
||||
|
||||
- worker/harness identity
|
||||
- online/idle/busy/offline/degraded
|
||||
- harness type
|
||||
- current task when leased
|
||||
- phase
|
||||
- lease or heartbeat age
|
||||
|
||||
intentionally omit generic infrastructure metrics such as disk, memory, cpu, and uptime unless orchestra directly uses them for eligibility.
|
||||
|
||||
### worker detail
|
||||
|
||||
- current session/task
|
||||
- pane id
|
||||
- lease epoch/remaining
|
||||
- last verified progress
|
||||
- worker revision
|
||||
- herdr status
|
||||
- last heartbeat
|
||||
- projects declared by the worker
|
||||
- declared capabilities if orchestra actually consumes them
|
||||
|
||||
### capabilities
|
||||
|
||||
capabilities must come from worker registration/project configuration, never be guessed from host inspection.
|
||||
|
||||
the ui labels them **declared capabilities** and can expose registration provenance.
|
||||
|
||||
### registration
|
||||
|
||||
registration is worker-driven:
|
||||
|
||||
1. worker starts with coordinator credentials/configuration
|
||||
2. worker announces identity, revision, harnesses, projects, capacity and declared capabilities
|
||||
3. coordinator records it and heartbeat updates liveness
|
||||
|
||||
`add worker` should therefore be a registration guide/bootstrap flow, not a magical browser-side creation of a remote process.
|
||||
|
||||
## 11. projects
|
||||
|
||||
mockup: `final/07-projects.png`
|
||||
|
||||
purpose: group long-running repository/workflow configuration and its active work.
|
||||
|
||||
### project list
|
||||
|
||||
- project name
|
||||
- short description
|
||||
- state
|
||||
- active/total task count
|
||||
- attention/decision count
|
||||
- last meaningful activity
|
||||
|
||||
avoid aggregate artifact counts or infrastructure metrics unless they help choose an action.
|
||||
|
||||
### project detail
|
||||
|
||||
- description
|
||||
- source/repository identity
|
||||
- configured worker/harness affinity
|
||||
- workflow phases
|
||||
- trajectory-gate policy
|
||||
- quality gate
|
||||
- human reconcile source
|
||||
- safe-operation policy
|
||||
- active tasks
|
||||
- recent meaningful events
|
||||
|
||||
configuration edits belong in the project detail, while global defaults remain in settings.
|
||||
|
||||
## 12. review
|
||||
|
||||
mockup: `final/08-review.png`
|
||||
|
||||
purpose: the operator inbox: **what needs me to look at something and judge?**
|
||||
|
||||
workers show machinery. review shows human attention.
|
||||
|
||||
### review item types
|
||||
|
||||
- trajectory decision
|
||||
- human blocker / decision request
|
||||
- code/pr review
|
||||
- ai review requiring operator override
|
||||
- operator-required lifecycle state
|
||||
- closed pr without merge
|
||||
- information/fyi, optionally hidden by default
|
||||
|
||||
### list fields
|
||||
|
||||
- type
|
||||
- task/item
|
||||
- why it is here
|
||||
- project
|
||||
- priority
|
||||
- age
|
||||
|
||||
worker/pane is secondary metadata, not a primary column.
|
||||
|
||||
### detail drawer/page
|
||||
|
||||
show the minimum evidence needed to act:
|
||||
|
||||
- why this needs the operator
|
||||
- effective task goal/constraints relevant to the decision
|
||||
- proposed options when applicable
|
||||
- relevant plan/diff/review evidence
|
||||
- provenance and age
|
||||
- comments/context drill-down
|
||||
|
||||
primary action names must match the lifecycle action: make decision, answer blocker, review pr, request changes, retry, etc.
|
||||
|
||||
snooze/defer may exist for attention management but must not silently mutate underlying task truth.
|
||||
|
||||
## 13. settings
|
||||
|
||||
mockup: `final/09-settings.png`
|
||||
|
||||
purpose: configure orchestra itself, project-independent defaults, integrations, policies, and operator/security settings.
|
||||
|
||||
### tabs
|
||||
|
||||
1. general
|
||||
2. appearance
|
||||
3. notifications
|
||||
4. access & security
|
||||
5. integrations
|
||||
6. agents & tools
|
||||
7. policies
|
||||
8. advanced
|
||||
|
||||
### general
|
||||
|
||||
- instance name/environment
|
||||
- locale/time display
|
||||
- default task preferences
|
||||
- default workflow policy
|
||||
- event/artifact retention
|
||||
- storage/backup state only if orchestra owns it
|
||||
|
||||
### appearance
|
||||
|
||||
- theme: system/dark/light
|
||||
- ethos remains fixed; no arbitrary shell redesign controls
|
||||
- accessibility and reduced motion
|
||||
|
||||
### access & security
|
||||
|
||||
- surface tokens/status
|
||||
- trusted review actors
|
||||
- ignored actors
|
||||
- operator sessions
|
||||
- agent-surface auth
|
||||
- secret/config source status without exposing secret values
|
||||
|
||||
### integrations
|
||||
|
||||
- gitea
|
||||
- vikunja
|
||||
- notification sinks
|
||||
- source reconciliation state
|
||||
|
||||
show endpoint/identity and last successful interaction in mono.
|
||||
|
||||
### agents & tools
|
||||
|
||||
- harness definitions
|
||||
- adapter/runtime configuration
|
||||
- tmux/herdr ownership
|
||||
- default launch behavior
|
||||
- context occupancy thresholds
|
||||
|
||||
### policies
|
||||
|
||||
- work phase defaults
|
||||
- trajectory gate
|
||||
- bounded decision request budget
|
||||
- retry policy
|
||||
- reconcile failure handoff threshold
|
||||
- quality/review/submission policy
|
||||
|
||||
### advanced
|
||||
|
||||
- event-store diagnostics
|
||||
- snapshots/replay
|
||||
- raw configuration
|
||||
- migration/status information
|
||||
- destructive operator actions in a visually separated danger zone
|
||||
|
||||
## 14. global command surface
|
||||
|
||||
`cmd+k` is the shared ethos command surface and should handle navigation plus safe operator actions.
|
||||
|
||||
examples:
|
||||
|
||||
- open task by id/title
|
||||
- open worker/pane
|
||||
- show decisions for task
|
||||
- steer/correct active task
|
||||
- answer current blocker
|
||||
- retry failed task
|
||||
- open review item
|
||||
|
||||
high-risk lifecycle actions require explicit confirmation and should not be the first fuzzy-search result for ordinary text.
|
||||
|
||||
## 15. status grammar
|
||||
|
||||
status is compact and consistent across screens.
|
||||
|
||||
recommended semantic colors:
|
||||
|
||||
- green: verified healthy/completed/accepted
|
||||
- violet: current/selected/orchestra-controlled active state
|
||||
- amber: waiting/degraded/operator attention
|
||||
- red: failed/refused/danger
|
||||
- gray: idle/unknown/historical
|
||||
|
||||
color never carries the entire meaning; every state also has text/iconography.
|
||||
|
||||
## 16. evidence drill-down
|
||||
|
||||
any important derived claim should be traceable without ssh.
|
||||
|
||||
from relevant detail screens the operator should be able to reach:
|
||||
|
||||
- event(s) that established the state
|
||||
- exact generated launch context
|
||||
- sealed research/plan/review/submission artifact refs
|
||||
- git sha/ref
|
||||
- worker journal observations
|
||||
- input confirmation receipt
|
||||
- pane capture/terminal
|
||||
- source comment/review provenance
|
||||
|
||||
raw evidence is accessible but not visible by default.
|
||||
|
||||
## 17. copy rules
|
||||
|
||||
- labels describe what the operator controls, not implementation class names
|
||||
- machine values remain exact and honest
|
||||
- errors state what failed and what consequence follows
|
||||
- no "something went wrong"
|
||||
- no spinner where a queue depth, heartbeat age, poll timestamp, retry time, or progress value exists
|
||||
|
||||
examples:
|
||||
|
||||
- `source reconcile failed · retry at 14:06:00`
|
||||
- `worker heartbeat 42s old · ineligible after 60s`
|
||||
- `phase request refused · next phase is research`
|
||||
- `pane lost at 13:42:18 · lease expires in 09:14`
|
||||
|
||||
## 18. responsive behavior
|
||||
|
||||
### tablet/mobile priorities
|
||||
|
||||
keep visible:
|
||||
|
||||
1. attention state
|
||||
2. task title/phase
|
||||
3. latest human authority
|
||||
4. live execution state
|
||||
5. primary action
|
||||
|
||||
collapse or drill down:
|
||||
|
||||
- long acceptance lists
|
||||
- complete workflow history
|
||||
- detailed lease metadata
|
||||
- worker journal
|
||||
- raw events
|
||||
- project configuration
|
||||
|
||||
terminal becomes its own full-screen route and should not be squeezed into a card.
|
||||
|
||||
## 19. implementation order
|
||||
|
||||
recommended web-ui build order:
|
||||
|
||||
1. ethos shell + orchestra tokens/motif
|
||||
2. dashboard
|
||||
3. tasks list
|
||||
4. task detail
|
||||
5. live pane
|
||||
6. review inbox
|
||||
7. decisions list/detail
|
||||
8. workers
|
||||
9. projects
|
||||
10. settings
|
||||
11. global command surface
|
||||
12. evidence/raw-event drill-down
|
||||
|
||||
build each screen against real orchestra api data rather than static ui-only state as early as practical.
|
||||
|
||||
## 20. verification
|
||||
|
||||
for every screen:
|
||||
|
||||
- render and visually inspect pixels
|
||||
- check desktop and <=640px mobile layout
|
||||
- assert no horizontal overflow
|
||||
- verify mono/sans split visibly
|
||||
- verify accent is signal-only
|
||||
- verify no blur/backdrop-filter exists
|
||||
- verify empty/error/loading states show real machine state
|
||||
- compare implemented screen against the corresponding mockup as direction, not pixel-perfect contract
|
||||
|
||||
mockups communicate hierarchy, density, and interaction intent. orchestra state and the ethos design laws are the actual specification.
|
||||
@@ -1 +1,12 @@
|
||||
<div id="root"></div><script type="module" src="/src/main.tsx"></script>
|
||||
<!doctype html>
|
||||
<html lang="en" data-app="orchestra">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>orchestra</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
ETHOS — icon set
|
||||
One hand, one grid. Every icon: viewBox 0 0 24 24, stroke="currentColor",
|
||||
stroke-width 1.7, round caps/joins, fill="none". Transport glyphs (play,
|
||||
pause, prev, next, more) are the only filled exceptions.
|
||||
|
||||
USE: <svg class="icon" width="22" height="22"><use href="/ethos-icons.svg#i-search"/> <symbol id="i-fork" viewBox="0 0 24 24"><path d="M4 12h4M8 12l4-5h8M8 12l4 5h8"/><circle cx="4" cy="12" r="1.6"/><circle cx="20" cy="7" r="1.6"/><circle cx="20" cy="17" r="1.6"/></symbol>
|
||||
<symbol id="i-task" viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="16" rx="3"/><path d="M8.5 12l2.5 2.5 4.5-5"/></symbol>
|
||||
<symbol id="i-decision" viewBox="0 0 24 24"><path d="M12 3l9 9-9 9-9-9z"/><path d="M12 8v4M12 15.5v.5"/></symbol>
|
||||
<symbol id="i-worker" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01M7 16.5h.01"/></symbol>
|
||||
<symbol id="i-project" viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></symbol>
|
||||
<symbol id="i-review" viewBox="0 0 24 24"><path d="M6 3h9l4 4v14a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><path d="M14 3v5h5M9 13h6M9 17h4"/></symbol>
|
||||
<symbol id="i-terminal" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7.5 9.5l3 2.5-3 2.5M13 15h4"/></symbol>
|
||||
<symbol id="i-alert" viewBox="0 0 24 24"><path d="M12 4l9 15H3z"/><path d="M12 10v4M12 16.5v.5"/></symbol>
|
||||
<symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/></symbol>
|
||||
<symbol id="i-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.2 5.2l1.4 1.4M17.4 17.4l1.4 1.4M18.8 5.2l-1.4 1.4M6.6 17.4l-1.4 1.4"/></symbol>
|
||||
</svg>
|
||||
color + width/height come from the consumer; the icon inherits them.
|
||||
|
||||
EXTEND: add a new <symbol id="i-NAME"> on the same 24px grid, same stroke,
|
||||
same corner feel. Match the existing hand — do not import lucide or
|
||||
any other pack. Keep ids prefixed i- and kebab-cased.
|
||||
-->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style="display:none" aria-hidden="true">
|
||||
|
||||
<!-- ── app mark / motif seeds ── -->
|
||||
<symbol id="i-wave" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round">
|
||||
<path d="M2 12h1M6 8v8M10 4v16M14 7v10M18 5v14M21 11v2"/>
|
||||
</symbol>
|
||||
<symbol id="i-grid" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
|
||||
<rect x="4" y="4" width="7" height="7" rx="1.5"/><rect x="13" y="4" width="7" height="7" rx="1.5"/>
|
||||
<rect x="4" y="13" width="7" height="7" rx="1.5"/><rect x="13" y="13" width="7" height="7" rx="1.5"/>
|
||||
</symbol>
|
||||
|
||||
<!-- ── navigation ── -->
|
||||
<symbol id="i-home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 11l8-6 8 6M6 10v9h12v-9"/>
|
||||
</symbol>
|
||||
<symbol id="i-listen" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 18V9l14-3v9"/><circle cx="6" cy="18" r="2.4"/><circle cx="18" cy="15" r="2.4"/>
|
||||
</symbol>
|
||||
<symbol id="i-library" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
|
||||
<rect x="4" y="4" width="6" height="16" rx="1.5"/><rect x="14" y="4" width="6" height="16" rx="1.5"/>
|
||||
</symbol>
|
||||
<symbol id="i-album" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7">
|
||||
<circle cx="12" cy="12" r="8.5"/><circle cx="12" cy="12" r="2"/>
|
||||
</symbol>
|
||||
<symbol id="i-artist" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="8" r="4"/><path d="M5 20c0-3.5 3.1-6 7-6s7 2.5 7 6"/>
|
||||
</symbol>
|
||||
<symbol id="i-queue" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 7h11M4 12h11M4 17h7M18 9v8"/><circle cx="18" cy="18.5" r="1.6"/>
|
||||
</symbol>
|
||||
<symbol id="i-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
|
||||
<path d="M4 7a1 1 0 011-1h4.5l2 2H19a1 1 0 011 1v8a1 1 0 01-1 1H5a1 1 0 01-1-1z"/>
|
||||
</symbol>
|
||||
<symbol id="i-file" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
|
||||
<path d="M14 3H7a1 1 0 00-1 1v16a1 1 0 001 1h10a1 1 0 001-1V8z"/><path d="M14 3v5h5"/>
|
||||
</symbol>
|
||||
<symbol id="i-settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3.2"/><path d="M12 2v3M12 19v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2 12h3M19 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1"/>
|
||||
</symbol>
|
||||
|
||||
<!-- ── transport (filled) ── -->
|
||||
<symbol id="i-play" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></symbol>
|
||||
<symbol id="i-pause" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5h3v14H8zM13 5h3v14h-3z"/></symbol>
|
||||
<symbol id="i-prev" viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6z"/><path d="M20 6v12l-9-6z"/></symbol>
|
||||
<symbol id="i-next" viewBox="0 0 24 24" fill="currentColor"><path d="M16 6h2v12h-2z"/><path d="M6 6v12l9-6z"/></symbol>
|
||||
<symbol id="i-shuffle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M16 4h4v4M20 4l-6 6M4 20l16-16M16 20h4v-4M14 14l6 6M4 4l4 4"/>
|
||||
</symbol>
|
||||
<symbol id="i-repeat" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M17 2l3 3-3 3M20 5H8a4 4 0 00-4 4v1M7 22l-3-3 3-3M4 19h12a4 4 0 004-4v-1"/>
|
||||
</symbol>
|
||||
|
||||
<!-- ── actions / status ── -->
|
||||
<symbol id="i-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
|
||||
<circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/>
|
||||
</symbol>
|
||||
<symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></symbol>
|
||||
<symbol id="i-x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></symbol>
|
||||
<symbol id="i-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l4.5 4.5L20 7"/></symbol>
|
||||
<symbol id="i-chevron-left" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M15 6l-6 6 6 6"/></symbol>
|
||||
<symbol id="i-chevron-right" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 6l6 6-6 6"/></symbol>
|
||||
<symbol id="i-more" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/></symbol>
|
||||
<symbol id="i-bell" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M6 9a6 6 0 0112 0c0 5 2 6 2 6H4s2-1 2-6M10 21h4"/>
|
||||
</symbol>
|
||||
<symbol id="i-download" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 4v11M8 11l4 4 4-4M5 20h14"/>
|
||||
</symbol>
|
||||
<symbol id="i-upload" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 15V4M8 8l4-4 4 4M5 20h14"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="i-fork" viewBox="0 0 24 24"><path d="M4 12h4M8 12l4-5h8M8 12l4 5h8"/><circle cx="4" cy="12" r="1.6"/><circle cx="20" cy="7" r="1.6"/><circle cx="20" cy="17" r="1.6"/></symbol>
|
||||
<symbol id="i-task" viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="16" rx="3"/><path d="M8.5 12l2.5 2.5 4.5-5"/></symbol>
|
||||
<symbol id="i-decision" viewBox="0 0 24 24"><path d="M12 3l9 9-9 9-9-9z"/><path d="M12 8v4M12 15.5v.5"/></symbol>
|
||||
<symbol id="i-worker" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01M7 16.5h.01"/></symbol>
|
||||
<symbol id="i-project" viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></symbol>
|
||||
<symbol id="i-review" viewBox="0 0 24 24"><path d="M6 3h9l4 4v14a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><path d="M14 3v5h5M9 13h6M9 17h4"/></symbol>
|
||||
<symbol id="i-terminal" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7.5 9.5l3 2.5-3 2.5M13 15h4"/></symbol>
|
||||
<symbol id="i-alert" viewBox="0 0 24 24"><path d="M12 4l9 15H3z"/><path d="M12 10v4M12 16.5v.5"/></symbol>
|
||||
<symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/></symbol>
|
||||
<symbol id="i-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.2 5.2l1.4 1.4M17.4 17.4l1.4 1.4M18.8 5.2l-1.4 1.4M6.6 17.4l-1.4 1.4"/></symbol>
|
||||
</svg>
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Route, Routes } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Shell } from './shell/Shell'
|
||||
import { Dashboard } from './screens/Dashboard'
|
||||
import { Tasks } from './screens/Tasks'
|
||||
import { TaskDetail } from './screens/TaskDetail'
|
||||
import { Terminal } from './screens/Terminal'
|
||||
import { Decisions } from './screens/Decisions'
|
||||
import { Workers } from './screens/Workers'
|
||||
import { Projects } from './screens/Projects'
|
||||
import { Review } from './screens/Review'
|
||||
import { Settings } from './screens/Settings'
|
||||
import { api } from './api/client'
|
||||
import type { Account } from './api/types'
|
||||
|
||||
/** The operator console. Auth stays in main.tsx; this is only what an
|
||||
* authenticated operator sees. */
|
||||
export function Console({
|
||||
account,
|
||||
onLogout,
|
||||
onCredentialsChanged,
|
||||
}: {
|
||||
account: Account
|
||||
onLogout: () => void
|
||||
onCredentialsChanged: (username: string) => void
|
||||
}) {
|
||||
const overview = useQuery({
|
||||
queryKey: ['overview'],
|
||||
queryFn: api.overview,
|
||||
refetchInterval: 5000,
|
||||
})
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
{/* No revision in the top bar yet. The overview carries worker builds,
|
||||
not the coordinator's, and labelling a worker build as the
|
||||
coordinator's would be a lie in the one place an operator trusts. */}
|
||||
<Route element={<Shell live={!overview.isError} />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="tasks" element={<Tasks />} />
|
||||
<Route path="tasks/:id" element={<TaskDetail />} />
|
||||
<Route path="tasks/:id/terminal" element={<Terminal />} />
|
||||
<Route path="decisions" element={<Decisions />} />
|
||||
<Route path="workers" element={<Workers />} />
|
||||
<Route path="projects" element={<Projects />} />
|
||||
<Route path="review" element={<Review />} />
|
||||
<Route
|
||||
path="settings"
|
||||
element={
|
||||
<Settings
|
||||
account={account}
|
||||
onLogout={onLogout}
|
||||
onCredentialsChanged={onCredentialsChanged}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
@@ -17,20 +17,40 @@ describe('UI API client',()=>{
|
||||
expect(fetch).toHaveBeenCalledWith('/v1/artifacts',expect.objectContaining({method:'POST',body:'report'}))
|
||||
})
|
||||
it('submits username and password to the browser login endpoint',async()=>{
|
||||
const fetch=vi.fn().mockResolvedValue(new Response(JSON.stringify({username:'operator'}),{status:200}))
|
||||
const fetch=vi.fn().mockResolvedValue(new Response(JSON.stringify({username:'operator'}),{status:200}))
|
||||
vi.stubGlobal('fetch',fetch)
|
||||
await expect(api.login('operator','not stored in the browser')).resolves.toEqual({username:'operator'})
|
||||
await expect(api.login('operator','not stored in the browser')).resolves.toEqual({username:'operator'})
|
||||
expect(fetch).toHaveBeenCalledWith('/v1/ui/session',expect.objectContaining({method:'POST',body:JSON.stringify({username:'operator',password:'not stored in the browser'})}))
|
||||
})
|
||||
it('treats a missing browser session as a normal signed-out state',async()=>{
|
||||
const fetch=vi.fn().mockResolvedValue(new Response('unauthorized',{status:401}))
|
||||
vi.stubGlobal('fetch',fetch)
|
||||
await expect(api.session()).resolves.toBeUndefined()
|
||||
})
|
||||
it('updates account credentials through the session-gated account endpoint',async()=>{
|
||||
const fetch=vi.fn().mockResolvedValue(new Response(JSON.stringify({username:'kami'}),{status:200}))
|
||||
vi.stubGlobal('fetch',fetch)
|
||||
await api.updateAccount({current_password:'old password',username:'kami',new_password:'new password'})
|
||||
expect(fetch).toHaveBeenCalledWith('/v1/ui/account',expect.objectContaining({method:'PUT'}))
|
||||
})
|
||||
it('treats a missing browser session as a normal signed-out state',async()=>{
|
||||
const fetch=vi.fn().mockResolvedValue(new Response('unauthorized',{status:401}))
|
||||
vi.stubGlobal('fetch',fetch)
|
||||
await expect(api.session()).resolves.toBeUndefined()
|
||||
})
|
||||
it('updates account credentials through the session-gated account endpoint',async()=>{
|
||||
const fetch=vi.fn().mockResolvedValue(new Response(JSON.stringify({username:'kami'}),{status:200}))
|
||||
vi.stubGlobal('fetch',fetch)
|
||||
await api.updateAccount({current_password:'old password',username:'kami',new_password:'new password'})
|
||||
expect(fetch).toHaveBeenCalledWith('/v1/ui/account',expect.objectContaining({method:'PUT'}))
|
||||
})
|
||||
|
||||
it('a zero Go timestamp is stripped rather than rendered as a date', async () => {
|
||||
const fetchMock = vi.fn(async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
tasks: [{ id: 't', blocked_at: '0001-01-01T00:00:00Z', at: '2026-08-29T00:00:00Z' }],
|
||||
workers: [],
|
||||
sessions: [],
|
||||
updated_at: '2026-08-29T00:00:00Z',
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
),
|
||||
)
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const overview = await api.overview()
|
||||
const task = overview.tasks[0] as unknown as Record<string, unknown>
|
||||
expect(task.blocked_at).toBeUndefined()
|
||||
expect(task.at).toBe('2026-08-29T00:00:00Z')
|
||||
expect(overview.updated_at).toBe('2026-08-29T00:00:00Z')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Account, CreatedEvent, Detail, Overview } from './types'
|
||||
import type { Account, CreatedEvent, DebtLedger, Detail, Event, Overview, Worker } from './types'
|
||||
|
||||
function sessionExpired(response: Response) {
|
||||
if (response.status === 401) {
|
||||
@@ -11,6 +11,27 @@ async function responseError(response: Response) {
|
||||
return new Error(message || `${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
/** Go marshals a zero time.Time as "0001-01-01T00:00:00Z", and omitempty does
|
||||
* not omit a struct, so an absent timestamp arrives populated-looking. Every
|
||||
* screen that formatted one rendered "739855d ago", which reads as data.
|
||||
* Stripping it here is one guard on the boundary every screen reads through,
|
||||
* rather than one guard per screen per field. */
|
||||
function stripZeroTimes(value: unknown): unknown {
|
||||
if (typeof value === 'string') {
|
||||
return value.startsWith('0001-01-01') ? undefined : value
|
||||
}
|
||||
if (Array.isArray(value)) return value.map(stripZeroTimes)
|
||||
if (value && typeof value === 'object') {
|
||||
const out: Record<string, unknown> = {}
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
const cleaned = stripZeroTimes(v)
|
||||
if (cleaned !== undefined) out[k] = cleaned
|
||||
}
|
||||
return out
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
credentials: 'same-origin',
|
||||
@@ -23,7 +44,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
sessionExpired(response)
|
||||
throw await responseError(response)
|
||||
}
|
||||
return response.json() as Promise<T>
|
||||
return stripZeroTimes(await response.json()) as T
|
||||
}
|
||||
|
||||
async function text(path: string) {
|
||||
@@ -50,10 +71,10 @@ async function upload(body: string) {
|
||||
}
|
||||
|
||||
async function session(): Promise<Account | undefined> {
|
||||
const response = await fetch('/v1/ui/session', { credentials: 'same-origin' })
|
||||
if (response.status === 401) return undefined
|
||||
if (!response.ok) throw await responseError(response)
|
||||
return response.json() as Promise<Account>
|
||||
const response = await fetch('/v1/ui/session', { credentials: 'same-origin' })
|
||||
if (response.status === 401) return undefined
|
||||
if (!response.ok) throw await responseError(response)
|
||||
return response.json() as Promise<Account>
|
||||
}
|
||||
|
||||
async function login(username: string, password: string) {
|
||||
@@ -64,7 +85,7 @@ async function login(username: string, password: string) {
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
if (!response.ok) throw await responseError(response)
|
||||
return response.json() as Promise<Account>
|
||||
return response.json() as Promise<Account>
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
@@ -76,15 +97,14 @@ async function logout() {
|
||||
}
|
||||
|
||||
export const api = {
|
||||
session,
|
||||
session,
|
||||
login,
|
||||
logout,
|
||||
account: () => request<Account>('/v1/ui/account'),
|
||||
updateAccount: (body: { current_password: string; username: string; new_password?: string }) =>
|
||||
request<Account>('/v1/ui/account', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
updateAccount: (body: { current_password: string; username: string; new_password?: string }) =>
|
||||
request<Account>('/v1/ui/account', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
overview: () => request<Overview>('/v1/ui/overview'),
|
||||
detail: (id: string) => request<Detail>(`/v1/ui/tasks/${id}`),
|
||||
artifact: (ref: string) => text(`/v1/ui/artifacts/${ref}`),
|
||||
@@ -94,6 +114,11 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
// Endpoints outside /v1/ui that the console reads directly. Adding a UI
|
||||
// wrapper for each would be a second copy of the same projection.
|
||||
workers: () => request<Worker[]>('/v1/federation/workers'),
|
||||
events: (since = 0) => request<Event[]>(`/v1/events?since=${since}`),
|
||||
debt: () => request<DebtLedger>('/v1/debt'),
|
||||
action: (id: string, action: string, body: object = {}) =>
|
||||
request<Detail>(`/v1/ui/tasks/${id}/actions/${action}`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -35,7 +35,27 @@ export interface SessionEvidence {
|
||||
checked_at?: string
|
||||
}
|
||||
|
||||
/** Mirrors domain.DecisionRequest. Fields are optional here because a
|
||||
* projection may omit them, and a screen must never assume a question. */
|
||||
export interface DecisionOption {
|
||||
id?: string
|
||||
description?: string
|
||||
tradeoff?: string
|
||||
}
|
||||
|
||||
export interface DecisionRequest {
|
||||
question?: string
|
||||
why?: string
|
||||
options?: DecisionOption[]
|
||||
evidence?: string[]
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
/** On the wire from domain.Task. Three screens widened these locally
|
||||
* before they lived here. */
|
||||
work_phase?: string
|
||||
last_lease_epoch?: string
|
||||
decision_request?: DecisionRequest
|
||||
id: string
|
||||
source: string
|
||||
external_id: string
|
||||
@@ -122,7 +142,17 @@ export interface Detail {
|
||||
actions: Action[]
|
||||
}
|
||||
|
||||
/** One distinct worker failure with its repeat count. The count is the
|
||||
* signal: a repeat means something looped rather than merely broke. */
|
||||
export interface Observation {
|
||||
message: string
|
||||
count: number
|
||||
first: string
|
||||
last: string
|
||||
}
|
||||
|
||||
export interface WorkerHealth {
|
||||
observations?: Observation[]
|
||||
backend?: 'herdr' | 'tmux'
|
||||
herdr_status: 'reachable' | 'unreachable' | 'unknown'
|
||||
checked_at?: string
|
||||
@@ -154,3 +184,41 @@ export interface CreatedEvent {
|
||||
task_id: string
|
||||
id: string
|
||||
}
|
||||
|
||||
/** The read-only debt projection. Items are candidates derived from history,
|
||||
* and gaps name the evidence the log cannot carry at all. */
|
||||
export interface DebtObservation {
|
||||
event_id?: string
|
||||
legacy_ref?: string
|
||||
task_id?: string
|
||||
kind: string
|
||||
signature: string
|
||||
detail?: string
|
||||
paths?: string[]
|
||||
at: string
|
||||
}
|
||||
|
||||
export interface DebtItem {
|
||||
id: string
|
||||
class: 'correctness' | 'operational' | 'structural' | 'polish'
|
||||
status: string
|
||||
symptom: string
|
||||
consequence?: string
|
||||
severity?: string
|
||||
signatures: string[]
|
||||
paths?: string[]
|
||||
observations: DebtObservation[]
|
||||
}
|
||||
|
||||
export interface EvidenceGap {
|
||||
kind: string
|
||||
reason: string
|
||||
durable: boolean
|
||||
}
|
||||
|
||||
export interface DebtLedger {
|
||||
items: DebtItem[]
|
||||
gaps: EvidenceGap[]
|
||||
events: number
|
||||
eligible?: { item: DebtItem; check: { eligible: boolean; reasons?: string[] } }[]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/** One sprite, one hand. Never import a generic icon pack: that breaks the
|
||||
* single-hand rule the ethos set exists to hold. */
|
||||
export function Icon({ name, size = 20 }: { name: string; size?: number }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.7}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<use href={`/ethos-icons.svg#i-${name}`} />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Icon } from './Icon'
|
||||
|
||||
/** Machine values are mono, always. A label is human, its value is not. */
|
||||
export function M({ children }: { children: ReactNode }) {
|
||||
return <span className="mono">{children}</span>
|
||||
}
|
||||
|
||||
export function Panel({
|
||||
title,
|
||||
count,
|
||||
trailing,
|
||||
children,
|
||||
bodied = false,
|
||||
}: {
|
||||
title?: string
|
||||
count?: number
|
||||
trailing?: ReactNode
|
||||
children: ReactNode
|
||||
bodied?: boolean
|
||||
}) {
|
||||
return (
|
||||
<section className="panel">
|
||||
{title && (
|
||||
<header className="panel-head">
|
||||
<h2>{title}</h2>
|
||||
{count !== undefined && <span className="count">{count}</span>}
|
||||
{trailing && <span className="trailing">{trailing}</span>}
|
||||
</header>
|
||||
)}
|
||||
{bodied ? <div className="panel-body">{children}</div> : children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export type Health = 'healthy' | 'degraded' | 'error' | 'unknown'
|
||||
|
||||
export function Dot({ health }: { health: Health }) {
|
||||
return <span className="dot" data-health={health} />
|
||||
}
|
||||
|
||||
export function Chip({
|
||||
tone,
|
||||
children,
|
||||
}: {
|
||||
tone?: 'accent' | 'warn' | 'fault' | 'done'
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<span className="chip" data-tone={tone}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function Meter({ value }: { value: number }) {
|
||||
const pct = Math.max(0, Math.min(100, Math.round(value)))
|
||||
return (
|
||||
<span className="meter" role="img" aria-label={`${pct} percent`}>
|
||||
<span style={{ width: `${pct}%` }} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** The ace-fca path, drawn as the routing fork. Completed phases read quiet
|
||||
* green, the current phase carries the accent, the future stays neutral. */
|
||||
export const PHASES = ['frame', 'research', 'plan', 'implement', 'review', 'pr', 'merged'] as const
|
||||
export type Phase = (typeof PHASES)[number]
|
||||
|
||||
export function PhasePath({ current, labels = true }: { current?: string; labels?: boolean }) {
|
||||
const at = PHASES.indexOf((current || 'frame') as Phase)
|
||||
return (
|
||||
<div className="phases">
|
||||
{PHASES.map((phase, i) => {
|
||||
const state = i < at ? 'done' : i === at ? 'current' : 'todo'
|
||||
return (
|
||||
<span key={phase} style={{ display: 'flex', alignItems: 'center' }}>
|
||||
{i > 0 && <span className="phase-link" data-state={i <= at ? 'done' : 'todo'} />}
|
||||
<span className="phase-step" data-state={state}>
|
||||
<span className="phase-node" />
|
||||
{labels && <span className="phase-name">{phase}</span>}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Empty({ title, children }: { title: string; children?: ReactNode }) {
|
||||
return (
|
||||
<div className="empty">
|
||||
<span className="motif">
|
||||
<Icon name="fork" size={28} />
|
||||
</span>
|
||||
<h3>{title}</h3>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** A screen with no endpoint says which one it needs. The interface never
|
||||
* implies data it does not have. */
|
||||
export function EndpointGap({ path, what }: { path: string; what: string }) {
|
||||
return (
|
||||
<div className="panel-body">
|
||||
<p className="gap-note">
|
||||
no endpoint yet: {path}
|
||||
</p>
|
||||
<p style={{ color: 'var(--text-mid)', marginBottom: 0 }}>{what}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Go marshals a zero time.Time as "0001-01-01T00:00:00Z", and omitempty does
|
||||
* not omit a struct. Every timestamp off the wire can therefore arrive
|
||||
* populated-looking and mean absent. Without this guard a screen prints
|
||||
* "739000d ago" and reads as data. */
|
||||
export function absent(at?: string) {
|
||||
return !at || at.startsWith('0001-01-01')
|
||||
}
|
||||
|
||||
/** A relative age, or null when the timestamp is absent. Never a fabricated
|
||||
* duration. */
|
||||
export function ago(at?: string): string | null {
|
||||
if (absent(at)) return null
|
||||
const seconds = Math.round((Date.now() - new Date(at as string).getTime()) / 1000)
|
||||
if (!Number.isFinite(seconds)) return null
|
||||
if (seconds < 60) return `${Math.max(0, seconds)}s ago`
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`
|
||||
return `${Math.floor(seconds / 86400)}d ago`
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
} from '@tanstack/react-query'
|
||||
import { api } from './api/client'
|
||||
import type {
|
||||
Account,
|
||||
Account,
|
||||
Action,
|
||||
BlockReason,
|
||||
Capture,
|
||||
@@ -32,6 +32,8 @@ import type {
|
||||
Worker,
|
||||
} from './api/types'
|
||||
import './style.css'
|
||||
import './styles/orchestra.css'
|
||||
import { Console } from './Console'
|
||||
|
||||
const client = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -46,9 +48,9 @@ const historyStates: TaskState[] = ['completed', 'failed']
|
||||
const stateLabel: Record<TaskState, string> = {
|
||||
queued: 'Queued',
|
||||
leased: 'In session',
|
||||
needs_attention: 'Needs attention',
|
||||
needs_attention: 'Needs attention',
|
||||
blocked: 'Blocked',
|
||||
in_review: 'In review',
|
||||
in_review: 'In review',
|
||||
completed: 'Complete',
|
||||
failed: 'Failed',
|
||||
}
|
||||
@@ -61,9 +63,9 @@ const blockLabel: Record<BlockReason, string> = {
|
||||
handoff_validation: 'Handoff validation',
|
||||
operator_block: 'Operator block',
|
||||
system_error: 'System error',
|
||||
trajectory_gate: 'Plan approval',
|
||||
human_decision: 'Decision needed',
|
||||
operator_required: 'Operator required',
|
||||
trajectory_gate: 'Plan approval',
|
||||
human_decision: 'Decision needed',
|
||||
operator_required: 'Operator required',
|
||||
unknown: 'Unknown',
|
||||
}
|
||||
|
||||
@@ -100,8 +102,8 @@ type IconName =
|
||||
| 'refresh'
|
||||
| 'search'
|
||||
| 'server'
|
||||
| 'settings'
|
||||
| 'shield'
|
||||
| 'settings'
|
||||
| 'shield'
|
||||
| 'terminal'
|
||||
| 'users'
|
||||
| 'x'
|
||||
@@ -169,13 +171,13 @@ function Icon({ name, size = 18 }: { name: IconName; size?: number }) {
|
||||
<path d="M7 7h.01M7 17h.01" />
|
||||
</>
|
||||
),
|
||||
settings: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6v.2h-4V21a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H2.8v-4H3a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1A1.7 1.7 0 0 0 9 4.6 1.7 1.7 0 0 0 10 3v-.2h4V3a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.2v4H21a1.7 1.7 0 0 0-1.6 1Z" />
|
||||
</>
|
||||
),
|
||||
shield: <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Zm-3-10 2 2 4-5" />,
|
||||
settings: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6v.2h-4V21a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H2.8v-4H3a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1A1.7 1.7 0 0 0 9 4.6 1.7 1.7 0 0 0 10 3v-.2h4V3a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.2v4H21a1.7 1.7 0 0 0-1.6 1Z" />
|
||||
</>
|
||||
),
|
||||
shield: <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Zm-3-10 2 2 4-5" />,
|
||||
terminal: (
|
||||
<>
|
||||
<path d="m4 6 5 5-5 5M11 18h9" />
|
||||
@@ -256,8 +258,8 @@ function humanize(value: string) {
|
||||
}
|
||||
|
||||
function initials(username: string) {
|
||||
const parts = username.trim().split(/[\s._-]+/).filter(Boolean)
|
||||
return (parts.length > 1 ? `${parts[0][0]}${parts[1][0]}` : username.slice(0, 2)).toUpperCase()
|
||||
const parts = username.trim().split(/[\s._-]+/).filter(Boolean)
|
||||
return (parts.length > 1 ? `${parts[0][0]}${parts[1][0]}` : username.slice(0, 2)).toUpperCase()
|
||||
}
|
||||
|
||||
function sessionFor(task: Task, overview: Overview) {
|
||||
@@ -284,13 +286,13 @@ function taskExplanation(task: Task, overview: Overview) {
|
||||
if (session?.blocker) return session.blocker
|
||||
return session?.capture ? 'Harness is publishing live output' : 'Leased · capture unavailable'
|
||||
}
|
||||
if (task.state === 'needs_attention') {
|
||||
return task.blocker || 'The current lease is retained while an operator investigates'
|
||||
}
|
||||
if (task.state === 'needs_attention') {
|
||||
return task.blocker || 'The current lease is retained while an operator investigates'
|
||||
}
|
||||
if (task.state === 'blocked') {
|
||||
return task.blocker || 'No blocker detail was retained for this task'
|
||||
}
|
||||
if (task.state === 'in_review') return 'Submitted change is waiting for human review'
|
||||
if (task.state === 'in_review') return 'Submitted change is waiting for human review'
|
||||
if (task.state === 'failed') return task.last_error || 'Review the failure before retrying'
|
||||
return 'Work and completion evidence retained'
|
||||
}
|
||||
@@ -344,7 +346,7 @@ function CommandPalette({ close }: { close: () => void }) {
|
||||
['board', 'Open dispatch board', '', 'grid'],
|
||||
['new', 'Create a new task', 'N', 'plus'],
|
||||
['workers', 'Open worker pool', '', 'server'],
|
||||
['settings', 'Open account settings', '', 'settings'],
|
||||
['settings', 'Open account settings', '', 'settings'],
|
||||
['refresh', 'Refresh live data', 'R', 'refresh'],
|
||||
] as const,
|
||||
[],
|
||||
@@ -364,7 +366,7 @@ function CommandPalette({ close }: { close: () => void }) {
|
||||
const choose = (id: string) => {
|
||||
if (id === 'board') navigate('/')
|
||||
if (id === 'workers') navigate('/workers')
|
||||
if (id === 'settings') navigate('/settings')
|
||||
if (id === 'settings') navigate('/settings')
|
||||
if (id === 'new') {
|
||||
navigate('/')
|
||||
window.setTimeout(() => window.dispatchEvent(new Event('orchestra:new-task')), 0)
|
||||
@@ -451,8 +453,8 @@ function Shell({ children, account, onLogout }: { children: React.ReactNode; acc
|
||||
? 'Dispatch board'
|
||||
: location.pathname === '/workers'
|
||||
? 'Worker pool'
|
||||
: location.pathname === '/settings'
|
||||
? 'Account settings'
|
||||
: location.pathname === '/settings'
|
||||
? 'Account settings'
|
||||
: location.pathname.startsWith('/artifacts/')
|
||||
? 'Evidence artifact'
|
||||
: 'Task record'
|
||||
@@ -501,10 +503,10 @@ function Shell({ children, account, onLogout }: { children: React.ReactNode; acc
|
||||
<span>Workers</span>
|
||||
<b className="nav-count">{onlineWorkers}/{workers.length}</b>
|
||||
</NavLink>
|
||||
<NavLink to="/settings">
|
||||
<Icon name="settings" />
|
||||
<span>Settings</span>
|
||||
</NavLink>
|
||||
<NavLink to="/settings">
|
||||
<Icon name="settings" />
|
||||
<span>Settings</span>
|
||||
</NavLink>
|
||||
</nav>
|
||||
<div className="sidebar-status">
|
||||
<span className={onlineWorkers ? 'signal online' : 'signal'} />
|
||||
@@ -538,17 +540,17 @@ function Shell({ children, account, onLogout }: { children: React.ReactNode; acc
|
||||
aria-expanded={accountOpen}
|
||||
onClick={() => setAccountOpen((open) => !open)}
|
||||
>
|
||||
<span className="avatar">{initials(account.username)}</span>
|
||||
<span className="account-label">{account.username}</span>
|
||||
<span className="avatar">{initials(account.username)}</span>
|
||||
<span className="account-label">{account.username}</span>
|
||||
</button>
|
||||
{accountOpen && (
|
||||
<div className="account-menu">
|
||||
<div className="account-menu-user">
|
||||
<span className="avatar">{initials(account.username)}</span>
|
||||
<span><b>{account.username}</b><small>Operator account</small></span>
|
||||
</div>
|
||||
<Link to="/settings" onClick={() => setAccountOpen(false)}><Icon name="settings" size={15} /> Account settings</Link>
|
||||
<button type="button" onClick={onLogout}><Icon name="arrow-left" size={15} /> Sign out</button>
|
||||
<div className="account-menu-user">
|
||||
<span className="avatar">{initials(account.username)}</span>
|
||||
<span><b>{account.username}</b><small>Operator account</small></span>
|
||||
</div>
|
||||
<Link to="/settings" onClick={() => setAccountOpen(false)}><Icon name="settings" size={15} /> Account settings</Link>
|
||||
<button type="button" onClick={onLogout}><Icon name="arrow-left" size={15} /> Sign out</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -941,9 +943,9 @@ function OverviewPage() {
|
||||
const projects = [...new Set(data.tasks.map((task) => task.project).filter(Boolean))].sort()
|
||||
const pendingApprovals = sessions.filter((session) => session.pending_approval)
|
||||
const approvalTask = pendingApprovals.find((session) => session.capture?.task_id)?.capture?.task_id
|
||||
const inSession = data.tasks.filter((task) => task.state === 'leased' || task.state === 'needs_attention').length
|
||||
const inSession = data.tasks.filter((task) => task.state === 'leased' || task.state === 'needs_attention').length
|
||||
const queued = data.tasks.filter((task) => task.state === 'queued').length
|
||||
const attention = data.tasks.filter((task) => task.state === 'needs_attention' || task.state === 'blocked' || task.state === 'failed').length
|
||||
const attention = data.tasks.filter((task) => task.state === 'needs_attention' || task.state === 'blocked' || task.state === 'failed').length
|
||||
const history = data.tasks.filter((task) => historyStates.includes(task.state)).length
|
||||
const onlineWorkers = data.workers.filter((worker) => worker.online).length
|
||||
const term = search.trim().toLowerCase()
|
||||
@@ -1267,14 +1269,14 @@ function TaskDiagnosis({ detail }: { detail: Detail }) {
|
||||
const observed = evidence?.captured_at || evidence?.checked_at || detail.session?.capture?.at
|
||||
const title = approval
|
||||
? 'Waiting for operator approval'
|
||||
: detail.task.state === 'blocked' || detail.task.state === 'needs_attention'
|
||||
: detail.task.state === 'blocked' || detail.task.state === 'needs_attention'
|
||||
? blockLabel[detail.task.block_reason || 'unknown']
|
||||
: detail.task.state === 'leased'
|
||||
? detail.session?.capture ? 'Agent session is active' : 'Session capture is unavailable'
|
||||
: stateLabel[detail.task.state]
|
||||
const explanation = approval
|
||||
? 'The harness is paused at a permission boundary. Review the exact request below.'
|
||||
: detail.task.state === 'blocked' || detail.task.state === 'needs_attention'
|
||||
: detail.task.state === 'blocked' || detail.task.state === 'needs_attention'
|
||||
? detail.task.blocker || 'No blocker detail was retained.'
|
||||
: detail.task.state === 'leased'
|
||||
? detail.session?.capture
|
||||
@@ -1282,8 +1284,8 @@ function TaskDiagnosis({ detail }: { detail: Detail }) {
|
||||
: detail.session?.blocker || 'The lease exists, but Orchestra cannot read current pane output.'
|
||||
: detail.task.state === 'queued'
|
||||
? 'This task is eligible for routing when a compatible worker has capacity.'
|
||||
: detail.task.state === 'in_review'
|
||||
? 'The implementation was submitted and is waiting for the bound human review.'
|
||||
: detail.task.state === 'in_review'
|
||||
? 'The implementation was submitted and is waiting for the bound human review.'
|
||||
: 'This is a terminal task record with retained evidence.'
|
||||
|
||||
return (
|
||||
@@ -1581,114 +1583,116 @@ function Workers() {
|
||||
}
|
||||
|
||||
function Settings({ account, onCredentialsChanged }: { account: Account; onCredentialsChanged: (username: string) => void }) {
|
||||
const [username, setUsername] = useState(account.username)
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmation, setConfirmation] = useState('')
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [formError, setFormError] = useState('')
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => api.updateAccount({
|
||||
current_password: currentPassword,
|
||||
username: username.trim(),
|
||||
...(newPassword ? { new_password: newPassword } : {}),
|
||||
}),
|
||||
onSuccess: (updated) => onCredentialsChanged(updated.username),
|
||||
})
|
||||
const usernameChanged = username.trim() !== account.username
|
||||
const changed = usernameChanged || !!newPassword
|
||||
const passwordLongEnough = newPassword.length >= 10
|
||||
const passwordWithinLimit = new TextEncoder().encode(newPassword).length <= 72
|
||||
const passwordsMatch = newPassword === confirmation
|
||||
const [username, setUsername] = useState(account.username)
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmation, setConfirmation] = useState('')
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [formError, setFormError] = useState('')
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => api.updateAccount({
|
||||
current_password: currentPassword,
|
||||
username: username.trim(),
|
||||
...(newPassword ? { new_password: newPassword } : {}),
|
||||
}),
|
||||
onSuccess: (updated) => onCredentialsChanged(updated.username),
|
||||
})
|
||||
const usernameChanged = username.trim() !== account.username
|
||||
const changed = usernameChanged || !!newPassword
|
||||
const passwordLongEnough = Array.from(newPassword).length >= 10
|
||||
const passwordWithinLimit = new TextEncoder().encode(newPassword).length <= 72
|
||||
const passwordsMatch = newPassword === confirmation
|
||||
|
||||
const submit = (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
setFormError('')
|
||||
if (!username.trim()) {
|
||||
setFormError('Username cannot be empty.')
|
||||
return
|
||||
}
|
||||
if (!changed) {
|
||||
setFormError('Change the username or enter a new password first.')
|
||||
return
|
||||
}
|
||||
if (!currentPassword) {
|
||||
setFormError('Enter your current password to authorize this change.')
|
||||
return
|
||||
}
|
||||
if (newPassword && (!passwordLongEnough || !passwordWithinLimit || !passwordsMatch)) {
|
||||
setFormError(!passwordsMatch ? 'The new passwords do not match.' : 'Use a password between 10 and 72 bytes.')
|
||||
return
|
||||
}
|
||||
mutation.mutate()
|
||||
}
|
||||
const submit = (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
setFormError('')
|
||||
if (!username.trim()) {
|
||||
setFormError('Username cannot be empty.')
|
||||
return
|
||||
}
|
||||
if (!changed) {
|
||||
setFormError('Change the username or enter a new password first.')
|
||||
return
|
||||
}
|
||||
if (!currentPassword) {
|
||||
setFormError('Enter your current password to authorize this change.')
|
||||
return
|
||||
}
|
||||
if (newPassword && (!passwordLongEnough || !passwordWithinLimit || !passwordsMatch)) {
|
||||
setFormError(!passwordsMatch ? 'The new passwords do not match.' : 'Use a password between 10 and 72 bytes.')
|
||||
return
|
||||
}
|
||||
mutation.mutate()
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="page settings-page">
|
||||
<header className="page-header">
|
||||
<div>
|
||||
<span className="eyebrow">Operator identity</span>
|
||||
<h1>Your account.</h1>
|
||||
<p>Change the credentials you use for this control plane. No environment hash is involved.</p>
|
||||
</div>
|
||||
</header>
|
||||
return (
|
||||
<main className="page settings-page">
|
||||
<header className="page-header">
|
||||
<div>
|
||||
<span className="eyebrow">Operator identity</span>
|
||||
<h1>Your account.</h1>
|
||||
<p>Change the credentials you use for this control plane. No environment hash is involved.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="settings-layout">
|
||||
<aside className="profile-card">
|
||||
<span className="profile-avatar">{initials(account.username)}</span>
|
||||
<h2>{account.username}</h2>
|
||||
<p>Full-control operator</p>
|
||||
<dl>
|
||||
<div><dt>Account created</dt><dd>{date(account.created_at)}</dd></div>
|
||||
<div><dt>Credentials updated</dt><dd>{date(account.updated_at)}</dd></div>
|
||||
</dl>
|
||||
<div className="database-badge"><Icon name="shield" size={17} /><span><b>Local credential database</b><small>Password hashes stay inside Orchestra’s data volume.</small></span></div>
|
||||
</aside>
|
||||
<div className="settings-layout">
|
||||
<aside className="profile-card">
|
||||
<span className="profile-avatar">{initials(account.username)}</span>
|
||||
<h2>{account.username}</h2>
|
||||
<p>Full-control operator</p>
|
||||
<dl>
|
||||
<div><dt>Account created</dt><dd>{date(account.created_at)}</dd></div>
|
||||
<div><dt>Credentials updated</dt><dd>{date(account.updated_at)}</dd></div>
|
||||
</dl>
|
||||
<div className="database-badge"><Icon name="shield" size={17} /><span><b>Local credential database</b><small>Password hashes stay inside Orchestra’s data volume.</small></span></div>
|
||||
</aside>
|
||||
|
||||
<section className="settings-card">
|
||||
<header>
|
||||
<span className="settings-icon"><Icon name="settings" /></span>
|
||||
<div><h2>Sign-in credentials</h2><p>Changing either field signs out every browser using this account.</p></div>
|
||||
</header>
|
||||
<form onSubmit={submit} noValidate>
|
||||
<label htmlFor="account-username">Username</label>
|
||||
<input id="account-username" autoComplete="username" value={username} onChange={(event) => { setUsername(event.target.value); setFormError('') }} />
|
||||
<section className="settings-card">
|
||||
<header>
|
||||
<span className="settings-icon"><Icon name="settings" /></span>
|
||||
<div><h2>Sign-in credentials</h2><p>Changing either field signs out every browser using this account.</p></div>
|
||||
</header>
|
||||
<form onSubmit={submit} noValidate>
|
||||
<label htmlFor="account-username">Username</label>
|
||||
<input id="account-username" autoComplete="username" value={username} onChange={(event) => { setUsername(event.target.value); setFormError('') }} />
|
||||
|
||||
<div className="settings-divider"><span>Optional password change</span></div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="account-new-password">New password
|
||||
<div className="password-field">
|
||||
<input id="account-new-password" type={visible ? 'text' : 'password'} autoComplete="new-password" value={newPassword} onChange={(event) => { setNewPassword(event.target.value); setFormError('') }} placeholder="Leave blank to keep it" />
|
||||
<button type="button" onClick={() => setVisible((value) => !value)}>{visible ? 'Hide' : 'Show'}</button>
|
||||
</div>
|
||||
</label>
|
||||
<label htmlFor="account-confirm-password">Confirm new password
|
||||
<input id="account-confirm-password" type={visible ? 'text' : 'password'} autoComplete="new-password" value={confirmation} onChange={(event) => { setConfirmation(event.target.value); setFormError('') }} placeholder="Repeat new password" />
|
||||
</label>
|
||||
</div>
|
||||
{newPassword && (
|
||||
<div className="password-rules" aria-live="polite">
|
||||
<span className={passwordLongEnough ? 'met' : ''}><Icon name="check" size={13} /> 10+ characters</span>
|
||||
<span className={passwordWithinLimit ? 'met' : ''}><Icon name="check" size={13} /> 72 bytes or fewer</span>
|
||||
<span className={passwordsMatch && !!confirmation ? 'met' : ''}><Icon name="check" size={13} /> Passwords match</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="settings-divider"><span>Optional password change</span></div>
|
||||
<div className="form-row">
|
||||
<div className="settings-field">
|
||||
<label htmlFor="account-new-password">New password</label>
|
||||
<div className="password-field">
|
||||
<input id="account-new-password" type={visible ? 'text' : 'password'} autoComplete="new-password" value={newPassword} onChange={(event) => { setNewPassword(event.target.value); setFormError('') }} placeholder="Leave blank to keep it" />
|
||||
<button type="button" onClick={() => setVisible((value) => !value)}>{visible ? 'Hide' : 'Show'}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-field">
|
||||
<label htmlFor="account-confirm-password">Confirm new password</label>
|
||||
<input id="account-confirm-password" type={visible ? 'text' : 'password'} autoComplete="new-password" value={confirmation} onChange={(event) => { setConfirmation(event.target.value); setFormError('') }} placeholder="Repeat new password" />
|
||||
</div>
|
||||
</div>
|
||||
{newPassword && (
|
||||
<div className="password-rules" aria-live="polite">
|
||||
<span className={passwordLongEnough ? 'met' : ''}><Icon name="check" size={13} /> 10+ characters</span>
|
||||
<span className={passwordWithinLimit ? 'met' : ''}><Icon name="check" size={13} /> 72 bytes or fewer</span>
|
||||
<span className={passwordsMatch && !!confirmation ? 'met' : ''}><Icon name="check" size={13} /> Passwords match</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="current-password-block">
|
||||
<label htmlFor="account-current-password">Current password</label>
|
||||
<p>Required to save account changes.</p>
|
||||
<input id="account-current-password" type="password" autoComplete="current-password" value={currentPassword} onChange={(event) => { setCurrentPassword(event.target.value); setFormError('') }} />
|
||||
</div>
|
||||
{(formError || mutation.error) && <p className="form-error" role="alert"><Icon name="alert" size={15} /> {formError || errorMessage(mutation.error)}</p>}
|
||||
<footer className="settings-actions">
|
||||
<span>You’ll sign in again after saving.</span>
|
||||
<button type="submit" disabled={mutation.isPending || !changed}>{mutation.isPending ? 'Saving…' : 'Save credentials'}</button>
|
||||
</footer>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
<div className="current-password-block">
|
||||
<label htmlFor="account-current-password">Current password</label>
|
||||
<p>Required to save account changes.</p>
|
||||
<input id="account-current-password" type="password" autoComplete="current-password" value={currentPassword} onChange={(event) => { setCurrentPassword(event.target.value); setFormError('') }} />
|
||||
</div>
|
||||
{(formError || mutation.error) && <p className="form-error" role="alert"><Icon name="alert" size={15} /> {formError || errorMessage(mutation.error)}</p>}
|
||||
<footer className="settings-actions">
|
||||
<span>You’ll sign in again after saving.</span>
|
||||
<button type="submit" disabled={mutation.isPending || !changed}>{mutation.isPending ? 'Saving…' : 'Save credentials'}</button>
|
||||
</footer>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function Artifact() {
|
||||
@@ -1721,7 +1725,7 @@ function Artifact() {
|
||||
)
|
||||
}
|
||||
|
||||
function Login({ onAuthenticated, message }: { onAuthenticated: () => void; message?: string }) {
|
||||
function Login({ onAuthenticated, message }: { onAuthenticated: (account: Account) => void; message?: string }) {
|
||||
const usernameInput = useRef<HTMLInputElement>(null)
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
@@ -1742,9 +1746,9 @@ function Login({ onAuthenticated, message }: { onAuthenticated: () => void; mess
|
||||
setError('')
|
||||
setPending(true)
|
||||
try {
|
||||
await api.login(username, password)
|
||||
const account = await api.login(username, password)
|
||||
setPassword('')
|
||||
onAuthenticated()
|
||||
onAuthenticated(account)
|
||||
} catch {
|
||||
setError('That username or password was not accepted. Check both and try again.')
|
||||
} finally {
|
||||
@@ -1753,9 +1757,26 @@ function Login({ onAuthenticated, message }: { onAuthenticated: () => void; mess
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="login-page">
|
||||
<main className="legacy login-page">
|
||||
<div className="login-grid" aria-hidden="true" />
|
||||
<section className="login-card" aria-labelledby="login-title">
|
||||
<div className="login-layout">
|
||||
<section className="login-showcase" aria-label="Orchestra overview">
|
||||
<div className="showcase-brand"><Logo /><span><b>Orchestra</b><small>Unattended work, under control</small></span></div>
|
||||
<div className="showcase-copy">
|
||||
<span className="eyebrow">Operator console</span>
|
||||
<h2>Keep every agent<br />on the same score.</h2>
|
||||
<p>Dispatch work, inspect live sessions, resolve decisions, and retain the evidence that brought each task home.</p>
|
||||
</div>
|
||||
<div className="showcase-flow" aria-hidden="true">
|
||||
<span><i className="flow-dot queued" /> Queue</span><b />
|
||||
<span><i className="flow-dot active" /> Agent</span><b />
|
||||
<span><i className="flow-dot review" /> Review</span><b />
|
||||
<span><i className="flow-dot done" /> Done</span>
|
||||
</div>
|
||||
<footer><span className="signal online" /> Control plane ready</footer>
|
||||
</section>
|
||||
|
||||
<section className="login-card" aria-labelledby="login-title">
|
||||
<header className="login-brand"><Logo /><span><b>Orchestra</b><small>Control plane</small></span></header>
|
||||
<div className="login-heading">
|
||||
<span className="eyebrow">Operator access</span>
|
||||
@@ -1799,10 +1820,11 @@ function Login({ onAuthenticated, message }: { onAuthenticated: () => void; mess
|
||||
</button>
|
||||
</form>
|
||||
<footer className="login-security">
|
||||
<span><Icon name="check" size={15} /></span>
|
||||
<p>Credentials are verified server-side. This browser receives an HttpOnly session cookie that expires after 12 hours.</p>
|
||||
<span><Icon name="shield" size={15} /></span>
|
||||
<p>Your password is verified against Orchestra’s local operator database. The browser receives only a 12-hour HttpOnly session cookie.</p>
|
||||
</footer>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1820,13 +1842,14 @@ function NotFound() {
|
||||
)
|
||||
}
|
||||
|
||||
function RoutesApp({ onLogout }: { onLogout: () => void }) {
|
||||
function RoutesApp({ account, onLogout, onCredentialsChanged }: { account: Account; onLogout: () => void; onCredentialsChanged: (username: string) => void }) {
|
||||
return (
|
||||
<Shell onLogout={onLogout}>
|
||||
<Shell account={account} onLogout={onLogout}>
|
||||
<Routes>
|
||||
<Route path="/" element={<OverviewPage />} />
|
||||
<Route path="/tasks/:taskID" element={<TaskDetail />} />
|
||||
<Route path="/workers" element={<Workers />} />
|
||||
<Route path="/settings" element={<Settings account={account} onCredentialsChanged={onCredentialsChanged} />} />
|
||||
<Route path="/artifacts/:ref" element={<Artifact />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
@@ -1835,7 +1858,7 @@ function RoutesApp({ onLogout }: { onLogout: () => void }) {
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [ready, setReady] = useState(false)
|
||||
const [account, setAccount] = useState<Account>()
|
||||
const [checking, setChecking] = useState(true)
|
||||
const [message, setMessage] = useState('')
|
||||
|
||||
@@ -1843,13 +1866,13 @@ function App() {
|
||||
const unauthorized = () => {
|
||||
client.clear()
|
||||
setMessage('Your browser session expired. Sign in again to continue.')
|
||||
setReady(false)
|
||||
setAccount(undefined)
|
||||
setChecking(false)
|
||||
}
|
||||
window.addEventListener('orchestra:unauthorized', unauthorized)
|
||||
api.overview()
|
||||
.then(() => setReady(true))
|
||||
.catch(() => setReady(false))
|
||||
api.session()
|
||||
.then((session) => setAccount(session))
|
||||
.catch(() => setAccount(undefined))
|
||||
.finally(() => setChecking(false))
|
||||
return () => window.removeEventListener('orchestra:unauthorized', unauthorized)
|
||||
}, [])
|
||||
@@ -1858,12 +1881,17 @@ function App() {
|
||||
await api.logout()
|
||||
client.clear()
|
||||
setMessage('You have signed out.')
|
||||
setReady(false)
|
||||
setAccount(undefined)
|
||||
}
|
||||
const credentialsChanged = (username: string) => {
|
||||
client.clear()
|
||||
setMessage(`Credentials for ${username} were saved. Sign in again to continue.`)
|
||||
setAccount(undefined)
|
||||
}
|
||||
|
||||
if (checking) {
|
||||
return (
|
||||
<main className="login-page">
|
||||
<main className="legacy login-page">
|
||||
<section className="session-check" aria-live="polite">
|
||||
<Logo /><span className="spinner" /><span>Checking your operator session…</span>
|
||||
</section>
|
||||
@@ -1871,9 +1899,9 @@ function App() {
|
||||
)
|
||||
}
|
||||
|
||||
return ready
|
||||
? <RoutesApp onLogout={logout} />
|
||||
: <Login message={message} onAuthenticated={() => { client.clear(); setMessage(''); setReady(true) }} />
|
||||
return account
|
||||
? <Console account={account} onLogout={logout} onCredentialsChanged={credentialsChanged} />
|
||||
: <Login message={message} onAuthenticated={(authenticated) => { client.clear(); setMessage(''); setAccount(authenticated) }} />
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/* Dashboard-only geometry. Everything structural (panel, row, table, stat,
|
||||
chip, meter) comes from orchestra.css and is not restated here. */
|
||||
|
||||
.head-stamp {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: var(--text-lo);
|
||||
}
|
||||
|
||||
.stamp-fault {
|
||||
color: #e06c60;
|
||||
}
|
||||
|
||||
/* The attention mark is a square of the row's own tone — a signal, never a
|
||||
wash across the row. */
|
||||
.att-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex: none;
|
||||
border-radius: var(--r-sm);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-2);
|
||||
color: var(--text-mid);
|
||||
}
|
||||
|
||||
.att-mark[data-kind='fault'] {
|
||||
border-color: rgba(216, 166, 87, 0.3);
|
||||
background: rgba(216, 166, 87, 0.1);
|
||||
color: #d8a657;
|
||||
}
|
||||
|
||||
.att-mark[data-kind='review'] {
|
||||
border-color: var(--accent-line);
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent-hi);
|
||||
}
|
||||
|
||||
.att-task {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.att-when {
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row-main,
|
||||
.att-task,
|
||||
.cell-link {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.row-title,
|
||||
.row-sub {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cell-link {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.phase-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.table td .mono[data-expired] {
|
||||
color: #e06c60;
|
||||
}
|
||||
|
||||
.worker-lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
|
||||
.worker-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 9px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: 12px;
|
||||
transition: background var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.worker-line:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.worker-line:hover {
|
||||
background: var(--bg-2);
|
||||
}
|
||||
|
||||
.worker-seen {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.worker-errors {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.worker-errors p {
|
||||
margin: 0 0 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.att-task,
|
||||
.att-when {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.worker-line {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { api } from '../api/client'
|
||||
import type { BlockReason, Overview, Session, Task, Worker } from '../api/types'
|
||||
import { Chip, Dot, Empty, EndpointGap, M, Panel, PhasePath } from '../components/Primitives'
|
||||
import { Icon } from '../components/Icon'
|
||||
import './Dashboard.css'
|
||||
|
||||
/* The ace-fca phase lives on the task as work_phase; the shared Task type has
|
||||
not caught up with the Go domain yet, so it is read through a narrow cast
|
||||
rather than invented or renamed. */
|
||||
const phaseOf = (t: Task) => (t as Task & { work_phase?: string }).work_phase
|
||||
|
||||
const now = () => Date.now()
|
||||
|
||||
/** "18s ago" / "2m ago" / "1h ago" — a machine value, so it renders mono. */
|
||||
function ago(at?: string) {
|
||||
// Go marshals a zero time.Time as "0001-01-01T00:00:00Z" and omitempty does
|
||||
// not omit a struct, so an absent timestamp arrives populated-looking. Left
|
||||
// unguarded this rendered "739855d ago", which reads as data.
|
||||
if (!at || at.startsWith('0001-01-01')) return undefined
|
||||
const s = Math.max(0, Math.round((now() - Date.parse(at)) / 1000))
|
||||
if (!Number.isFinite(s)) return undefined
|
||||
if (s < 60) return `${s}s ago`
|
||||
if (s < 3600) return `${Math.floor(s / 60)}m ago`
|
||||
if (s < 86400) return `${Math.floor(s / 3600)}h ago`
|
||||
return `${Math.floor(s / 86400)}d ago`
|
||||
}
|
||||
|
||||
/** Time remaining on a lease as mm:ss, or "expired" when it already ran out. */
|
||||
function until(at?: string) {
|
||||
if (!at) return undefined
|
||||
const s = Math.round((Date.parse(at) - now()) / 1000)
|
||||
if (!Number.isFinite(s)) return undefined
|
||||
if (s <= 0) return 'expired'
|
||||
const m = Math.floor(s / 60)
|
||||
return `${String(m).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const shortId = (id: string) => (id.length > 14 ? `${id.slice(0, 6)}…${id.slice(-4)}` : id)
|
||||
|
||||
/* Why an item is on the operator's desk. Every line is derived from a real
|
||||
block_reason or state — none of it is decorative copy. */
|
||||
const attentionReason: Record<BlockReason, string> = {
|
||||
trajectory_gate: 'A trajectory gate is waiting for your steering.',
|
||||
human_decision: 'The agent asked for a decision it may not make itself.',
|
||||
operator_required: 'Orchestra cannot proceed without an operator action.',
|
||||
approval: 'A tool or edit approval is pending.',
|
||||
handoff_validation: 'The handoff failed validation and was not accepted.',
|
||||
lease_failure: 'The lease could not be established.',
|
||||
lease_expired: 'The lease expired before the agent finished.',
|
||||
worker_offline: 'The worker holding this task went offline.',
|
||||
system_error: 'Orchestra hit an error it could not retry past.',
|
||||
operator_block: 'You blocked this task.',
|
||||
unknown: 'Blocked for a reason orchestra could not classify.',
|
||||
}
|
||||
|
||||
const stateReason: Partial<Record<Task['state'], string>> = {
|
||||
in_review: 'A pull request is waiting on review.',
|
||||
failed: 'The task failed and will not retry on its own.',
|
||||
needs_attention: 'Orchestra flagged this task for you.',
|
||||
blocked: 'The task is blocked.',
|
||||
}
|
||||
|
||||
function reasonFor(t: Task) {
|
||||
if (t.block_reason && attentionReason[t.block_reason]) return attentionReason[t.block_reason]
|
||||
return stateReason[t.state] ?? 'Waiting on you.'
|
||||
}
|
||||
|
||||
const needsOperator = (t: Task) =>
|
||||
t.state === 'needs_attention' || t.state === 'blocked' || t.state === 'failed' || t.state === 'in_review'
|
||||
|
||||
/** Sessions carry no task id of their own unless a capture is attached, so
|
||||
* they are matched on the pane the task last held. */
|
||||
function sessionFor(t: Task, sessions: Session[]) {
|
||||
return sessions.find(
|
||||
(s) =>
|
||||
s.capture?.task_id === t.id ||
|
||||
(t.last_pane_id !== undefined && s.pane_id === t.last_pane_id) ||
|
||||
(t.lease !== undefined && s.harness_id === t.lease.harness_id),
|
||||
)
|
||||
}
|
||||
|
||||
function attentionIcon(t: Task) {
|
||||
if (t.state === 'in_review') return 'review'
|
||||
if (t.block_reason === 'trajectory_gate') return 'fork'
|
||||
if (t.block_reason === 'human_decision' || t.block_reason === 'approval') return 'decision'
|
||||
return 'alert'
|
||||
}
|
||||
|
||||
function Attention({ tasks }: { tasks: Task[] }) {
|
||||
return (
|
||||
<Panel
|
||||
title="Needs your attention"
|
||||
count={tasks.length}
|
||||
trailing={<Link to="/tasks">View all</Link>}
|
||||
>
|
||||
{tasks.length === 0 ? (
|
||||
<Empty title="Nothing is waiting on you">
|
||||
<p>Blocked tasks, trajectory gates and open reviews land here.</p>
|
||||
</Empty>
|
||||
) : (
|
||||
<div className="rows">
|
||||
{tasks.map((t) => (
|
||||
<Link className="row" key={t.id} to={`/tasks/${t.id}`}>
|
||||
<span className="att-mark" data-kind={t.state === 'in_review' ? 'review' : 'fault'}>
|
||||
<Icon name={attentionIcon(t)} size={18} />
|
||||
</span>
|
||||
<span className="row-main">
|
||||
<span className="row-title">{t.title || 'Untitled task'}</span>
|
||||
<span className="row-sub">{reasonFor(t)}</span>
|
||||
</span>
|
||||
<span className="att-task">
|
||||
<span className="row-title">{t.title ? t.project : t.id}</span>
|
||||
<span className="row-sub">
|
||||
<M>{shortId(t.id)}</M>
|
||||
{phaseOf(t) && (
|
||||
<>
|
||||
{' · '}
|
||||
<M>{phaseOf(t)}</M>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
{t.blocked_at && (
|
||||
<span className="mono att-when">{ago(t.blocked_at)}</span>
|
||||
)}
|
||||
<Chip tone={t.state === 'in_review' ? 'accent' : 'warn'}>
|
||||
{t.block_reason ?? t.state}
|
||||
</Chip>
|
||||
<Icon name="chevron-right" size={16} />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
function Running({ tasks, sessions }: { tasks: Task[]; sessions: Session[] }) {
|
||||
return (
|
||||
<Panel
|
||||
title="Running tasks"
|
||||
count={tasks.length}
|
||||
trailing={<Link to="/tasks">View all tasks</Link>}
|
||||
>
|
||||
{tasks.length === 0 ? (
|
||||
<Empty title="No task holds a lease">
|
||||
<p>Leased sessions appear here with their phase, worker and lease clock.</p>
|
||||
</Empty>
|
||||
) : (
|
||||
<div className="table-scroll">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Task</th>
|
||||
<th>Phase</th>
|
||||
<th>Worker / harness</th>
|
||||
<th>Last progress</th>
|
||||
<th>Lease ends in</th>
|
||||
<th>Attempt</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tasks.map((t) => {
|
||||
const s = sessionFor(t, sessions)
|
||||
const left = until(t.lease?.until)
|
||||
return (
|
||||
<tr key={t.id}>
|
||||
<td>
|
||||
<Link className="cell-link" to={`/tasks/${t.id}`}>
|
||||
<span className="row-title">{t.title || 'Untitled task'}</span>
|
||||
<span className="row-sub">
|
||||
<M>{shortId(t.id)}</M>
|
||||
</span>
|
||||
</Link>
|
||||
</td>
|
||||
<td>
|
||||
<div className="phase-cell">
|
||||
<Chip tone="accent">{phaseOf(t) ?? 'unknown'}</Chip>
|
||||
<PhasePath current={phaseOf(t)} />
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className="row-title mono">{t.lease?.harness_id ?? '—'}</span>
|
||||
<span className="row-sub">
|
||||
<M>{s?.pane_id ?? t.last_pane_id ?? 'pane unknown'}</M>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{s?.capture?.at ? (
|
||||
<>
|
||||
<span className="mono">{ago(s.capture.at)}</span>
|
||||
<span className="row-sub">
|
||||
capture <M>r{s.capture.revision}</M>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="row-sub">no capture yet</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<span className="mono" data-expired={left === 'expired' ? '' : undefined}>
|
||||
{left ?? '—'}
|
||||
</span>
|
||||
{s?.agent_status && <span className="row-sub">{s.agent_status}</span>}
|
||||
</td>
|
||||
<td>
|
||||
<M>{t.attempt ?? 1}</M>
|
||||
</td>
|
||||
<td>
|
||||
<Link className="cell-link" to={`/tasks/${t.id}`} aria-label="Open task">
|
||||
<Icon name="chevron-right" size={16} />
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<EndpointGap
|
||||
path="GET /v1/ui/overview → session.context"
|
||||
what="Context occupancy per session is measured by the herdr adapter but is not carried on the overview projection, so this table shows lease time instead of a context meter."
|
||||
/>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
function Capacity({ workers, tasks }: { workers: Worker[]; tasks: Task[] }) {
|
||||
const online = workers.filter((w) => w.online).length
|
||||
const reachable = workers.filter((w) => w.health.herdr_status === 'reachable').length
|
||||
const unreachable = workers.filter((w) => w.health.herdr_status === 'unreachable').length
|
||||
const leased = tasks.filter((t) => t.lease)
|
||||
const expired = leased.filter((t) => until(t.lease?.until) === 'expired').length
|
||||
const erroring = workers.filter((w) => w.health.last_error)
|
||||
|
||||
return (
|
||||
<Panel title="System state and capacity" trailing={<Link to="/workers">View workers</Link>}>
|
||||
<div className="panel-body">
|
||||
<div className="grid stats">
|
||||
<div className="stat">
|
||||
<span className="label">Workers online</span>
|
||||
<span className="stat-value">
|
||||
{online} / {workers.length}
|
||||
</span>
|
||||
<span className="row-sub">
|
||||
<M>{workers.reduce((n, w) => n + w.capacity, 0)}</M> total capacity
|
||||
</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="label">Active leases</span>
|
||||
<span className="stat-value">{leased.length}</span>
|
||||
<span className="row-sub">
|
||||
<M>{expired}</M> past their lease end
|
||||
</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="label">Herdr backends</span>
|
||||
<span className="stat-value">
|
||||
{reachable} / {workers.length}
|
||||
</span>
|
||||
<span className="row-sub">
|
||||
<M>{unreachable}</M> unreachable, from worker health
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="worker-lines">
|
||||
{workers.length === 0 ? (
|
||||
<p className="row-sub">No worker has ever registered.</p>
|
||||
) : (
|
||||
workers.map((w) => (
|
||||
<Link className="worker-line" key={w.id} to="/workers">
|
||||
<Dot
|
||||
health={
|
||||
!w.online
|
||||
? 'error'
|
||||
: w.health.herdr_status === 'reachable'
|
||||
? 'healthy'
|
||||
: w.health.herdr_status === 'unreachable'
|
||||
? 'degraded'
|
||||
: 'unknown'
|
||||
}
|
||||
/>
|
||||
<span className="mono">{w.id}</span>
|
||||
<span className="row-sub">{w.health.backend ?? 'backend unknown'}</span>
|
||||
<span className="mono worker-seen">seen {ago(w.last_seen) ?? '—'}</span>
|
||||
{w.health.active_task_id && (
|
||||
<span className="mono">{shortId(w.health.active_task_id)}</span>
|
||||
)}
|
||||
{w.build?.revision && <span className="mono">{w.build.revision.slice(0, 7)}</span>}
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{erroring.length > 0 && (
|
||||
<div className="worker-errors">
|
||||
{erroring.map((w) => (
|
||||
<p key={w.id}>
|
||||
<span className="mono">{w.id}</span>{' '}
|
||||
<span className="row-sub">{w.health.last_error}</span>{' '}
|
||||
<span className="mono">{ago(w.health.error_at) ?? ''}</span>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<EndpointGap
|
||||
path="GET /v1/quota, GET /v1/router, GET /v1/sources"
|
||||
what="Quota headroom, router queue depth and rejections, and source reconciliation state have no endpoint. Nothing here stands in for them."
|
||||
/>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
export function Dashboard() {
|
||||
const overview = useQuery<Overview>({
|
||||
queryKey: ['overview'],
|
||||
queryFn: api.overview,
|
||||
refetchInterval: 5000,
|
||||
})
|
||||
const workers = useQuery<Worker[]>({
|
||||
queryKey: ['workers'],
|
||||
queryFn: api.workers,
|
||||
refetchInterval: 5000,
|
||||
})
|
||||
|
||||
const tasks = overview.data?.tasks ?? []
|
||||
const sessions = overview.data?.sessions ?? []
|
||||
// Workers come from the federation registry; the overview snapshot is the
|
||||
// fallback when that call has not landed or is failing.
|
||||
const workerList = workers.data ?? overview.data?.workers ?? []
|
||||
|
||||
return (
|
||||
<main className="page">
|
||||
<div className="page-head">
|
||||
<h1>Dashboard</h1>
|
||||
<p>What needs you, what is running, and whether the system is healthy.</p>
|
||||
<span className="head-stamp">
|
||||
{overview.data ? (
|
||||
<>
|
||||
read <M>{new Date(overview.data.updated_at).toISOString().slice(11, 19)} UTC</M>
|
||||
</>
|
||||
) : overview.isError ? (
|
||||
<span className="stamp-fault">overview unreachable — {String(overview.error)}</span>
|
||||
) : (
|
||||
'reading the first snapshot'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Attention tasks={tasks.filter(needsOperator)} />
|
||||
<Running tasks={tasks.filter((t) => t.state === 'leased')} sessions={sessions} />
|
||||
<Capacity workers={workerList} tasks={tasks} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
/* Decisions — durable human authority.
|
||||
Only what the shared sheet does not already carry: the filter bar, the
|
||||
standing/superseded split, and the expanded detail row. */
|
||||
|
||||
.dec-filters {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
padding: 12px 18px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.dec-search {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
height: 32px;
|
||||
padding: 0 11px;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--text-hi);
|
||||
font: inherit;
|
||||
outline: none;
|
||||
transition: border-color var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.dec-search:hover,
|
||||
.dec-search:focus {
|
||||
border-color: var(--line-hi);
|
||||
}
|
||||
|
||||
.dec-filter {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.dec-filter select {
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--text-hi);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Operator authority is the prominent class; history is muted and never
|
||||
dominant. The accent marks the standing decision, it does not wash the row. */
|
||||
.dec-table tbody tr[data-status] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dec-table tbody tr[data-status='active'] td:first-child {
|
||||
box-shadow: inset 2px 0 0 var(--accent);
|
||||
}
|
||||
|
||||
.dec-table tbody tr[data-status='waiting'] td:first-child {
|
||||
box-shadow: inset 2px 0 0 #d8a657;
|
||||
}
|
||||
|
||||
.dec-table tbody tr[data-status='superseded'] .row-title,
|
||||
.dec-table tbody tr[data-status='superseded'] .row-sub,
|
||||
.dec-table tbody tr[data-status='superseded'] .mono {
|
||||
color: var(--text-lo);
|
||||
}
|
||||
|
||||
.dec-table tbody tr[data-status='superseded'] .row-title {
|
||||
text-decoration: line-through;
|
||||
text-decoration-color: var(--line-hi);
|
||||
}
|
||||
|
||||
.dec-table tbody tr[data-open='true'] {
|
||||
background: var(--bg-2);
|
||||
}
|
||||
|
||||
.dec-table .row-title {
|
||||
max-width: 42ch;
|
||||
}
|
||||
|
||||
.dec-task {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dec-task:hover .mono {
|
||||
color: var(--accent-hi);
|
||||
}
|
||||
|
||||
.dec-task .row-sub {
|
||||
max-width: 22ch;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── detail ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.dec-detail-row td {
|
||||
background: var(--bg-2);
|
||||
border-top: 1px solid var(--line-hi);
|
||||
}
|
||||
|
||||
.dec-detail {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr) minmax(0, 1.1fr);
|
||||
gap: 24px;
|
||||
padding: 6px 4px 10px;
|
||||
}
|
||||
|
||||
.dec-detail .label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.dec-detail p {
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
/* The human decision text stays sans, and reads as the loudest thing here. */
|
||||
.dec-value {
|
||||
font-size: 15px;
|
||||
color: var(--text-hi);
|
||||
}
|
||||
|
||||
.dec-subject {
|
||||
color: var(--text-mid);
|
||||
}
|
||||
|
||||
.dec-impact {
|
||||
color: var(--accent-hi);
|
||||
}
|
||||
|
||||
.dec-impact[data-retired='true'] {
|
||||
color: var(--text-lo);
|
||||
}
|
||||
|
||||
.dec-facts {
|
||||
display: grid;
|
||||
grid-template-columns: max-content minmax(0, 1fr);
|
||||
align-items: baseline;
|
||||
gap: 4px 14px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dec-facts .label {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dec-facts > div {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.dec-link {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
color: var(--accent-hi);
|
||||
font-size: 12px;
|
||||
display: block;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.dec-trail {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dec-event {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: baseline;
|
||||
font-size: 11px;
|
||||
padding: 3px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dec-event:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.dec-event-type {
|
||||
color: var(--text-mid);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dec-event .mono:last-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stat-value[data-accent='true'] {
|
||||
color: var(--accent-hi);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.dec-detail {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
import { Fragment, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { api } from '../api/client'
|
||||
import type { Event, Task } from '../api/types'
|
||||
import { Chip, Empty, EndpointGap, M, Panel } from '../components/Primitives'
|
||||
import './Decisions.css'
|
||||
|
||||
/** The payload of a HumanDecisionRecorded event. There is no decisions
|
||||
* endpoint, so this screen reduces the log the way domain.ReduceIntent does:
|
||||
* a decision stands until something names it. */
|
||||
interface DecisionPayload {
|
||||
decision_id?: string
|
||||
kind?: string
|
||||
subject?: string
|
||||
value?: string
|
||||
supersedes?: string[]
|
||||
source?: { provider?: string; external_id?: string }
|
||||
}
|
||||
|
||||
/** The coordinator serves these on the task; api/types.ts does not model them
|
||||
* yet, so the shapes this screen needs are narrowed here rather than assumed. */
|
||||
interface BlockedTask extends Task {
|
||||
work_phase?: string
|
||||
decision_request?: { question?: string; why?: string }
|
||||
}
|
||||
|
||||
interface Decision {
|
||||
id: string
|
||||
taskId: string
|
||||
kind: string
|
||||
subject: string
|
||||
value: string
|
||||
supersedes: string[]
|
||||
provider: string
|
||||
externalId: string
|
||||
at: string
|
||||
eventId: string
|
||||
seq?: number
|
||||
}
|
||||
|
||||
/** A question the operator has not answered yet. It is not a decision — it
|
||||
* has no id and no value — but it is the same authority, pending. */
|
||||
interface Waiting {
|
||||
taskId: string
|
||||
question: string
|
||||
why: string
|
||||
at: string
|
||||
}
|
||||
|
||||
function payload(event: Event): DecisionPayload {
|
||||
return (event.payload ?? {}) as DecisionPayload
|
||||
}
|
||||
|
||||
function stamp(at: string) {
|
||||
return at.replace('T', ' ').slice(0, 19) + ' UTC'
|
||||
}
|
||||
|
||||
function shortId(id: string) {
|
||||
return id.length > 8 ? id.slice(-6) : id
|
||||
}
|
||||
|
||||
interface Reduced {
|
||||
decisions: Decision[]
|
||||
/** decision id -> the decision that retired it, or '' for a standalone
|
||||
* HumanDecisionSuperseded event with no successor. */
|
||||
retiredBy: Map<string, string>
|
||||
events: Map<string, Event[]>
|
||||
}
|
||||
|
||||
function reduce(events: Event[]): Reduced {
|
||||
const decisions: Decision[] = []
|
||||
const retiredBy = new Map<string, string>()
|
||||
const byDecision = new Map<string, Event[]>()
|
||||
|
||||
const touch = (id: string, event: Event) => {
|
||||
const list = byDecision.get(id)
|
||||
if (list) list.push(event)
|
||||
else byDecision.set(id, [event])
|
||||
}
|
||||
|
||||
for (const event of events) {
|
||||
const p = payload(event)
|
||||
if (!p.decision_id) continue
|
||||
if (event.type === 'HumanDecisionRecorded') {
|
||||
const supersedes = p.supersedes ?? []
|
||||
decisions.push({
|
||||
id: p.decision_id,
|
||||
taskId: event.task_id ?? '',
|
||||
kind: p.kind ?? '',
|
||||
subject: p.subject ?? '',
|
||||
value: p.value ?? '',
|
||||
supersedes,
|
||||
provider: p.source?.provider ?? '',
|
||||
externalId: p.source?.external_id ?? '',
|
||||
at: event.at,
|
||||
eventId: event.id,
|
||||
seq: event.seq,
|
||||
})
|
||||
touch(p.decision_id, event)
|
||||
for (const target of supersedes) {
|
||||
retiredBy.set(target, p.decision_id)
|
||||
touch(target, event)
|
||||
}
|
||||
} else if (event.type === 'HumanDecisionSuperseded') {
|
||||
if (!retiredBy.has(p.decision_id)) retiredBy.set(p.decision_id, '')
|
||||
touch(p.decision_id, event)
|
||||
}
|
||||
}
|
||||
|
||||
decisions.sort((a, b) => b.at.localeCompare(a.at) || b.id.localeCompare(a.id))
|
||||
return { decisions, retiredBy, events: byDecision }
|
||||
}
|
||||
|
||||
function Select({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
options: string[]
|
||||
onChange: (next: string) => void
|
||||
}) {
|
||||
return (
|
||||
<label className="dec-filter">
|
||||
<span className="label">{label}</span>
|
||||
<select value={value} onChange={(e) => onChange(e.target.value)}>
|
||||
<option value="">all</option>
|
||||
{options.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export function Decisions() {
|
||||
const events = useQuery({ queryKey: ['events'], queryFn: () => api.events(), refetchInterval: 5000 })
|
||||
const overview = useQuery({ queryKey: ['overview'], queryFn: api.overview, refetchInterval: 5000 })
|
||||
|
||||
const [status, setStatus] = useState('')
|
||||
const [kind, setKind] = useState('')
|
||||
const [project, setProject] = useState('')
|
||||
const [source, setSource] = useState('')
|
||||
const [query, setQuery] = useState('')
|
||||
const [open, setOpen] = useState<string>()
|
||||
|
||||
const tasks = useMemo(() => {
|
||||
const map = new Map<string, BlockedTask>()
|
||||
for (const task of (overview.data?.tasks ?? []) as BlockedTask[]) map.set(task.id, task)
|
||||
return map
|
||||
}, [overview.data])
|
||||
|
||||
const { decisions, retiredBy, events: trail } = useMemo(
|
||||
() => reduce(events.data ?? []),
|
||||
[events.data],
|
||||
)
|
||||
|
||||
const waiting = useMemo<Waiting[]>(
|
||||
() =>
|
||||
[...tasks.values()]
|
||||
.filter((t) => t.block_reason === 'human_decision' && t.decision_request)
|
||||
.map((t) => ({
|
||||
taskId: t.id,
|
||||
question: t.decision_request?.question ?? '',
|
||||
why: t.decision_request?.why ?? '',
|
||||
at: t.blocked_at ?? '',
|
||||
})),
|
||||
[tasks],
|
||||
)
|
||||
|
||||
const kinds = useMemo(
|
||||
() => [...new Set(decisions.map((d) => d.kind).filter(Boolean))].sort(),
|
||||
[decisions],
|
||||
)
|
||||
const sources = useMemo(
|
||||
() => [...new Set(decisions.map((d) => d.provider).filter(Boolean))].sort(),
|
||||
[decisions],
|
||||
)
|
||||
const projects = useMemo(
|
||||
() => [...new Set([...tasks.values()].map((t) => t.project).filter(Boolean))].sort(),
|
||||
[tasks],
|
||||
)
|
||||
|
||||
const needle = query.trim().toLowerCase()
|
||||
const matches = (d: Decision) => {
|
||||
const task = tasks.get(d.taskId)
|
||||
const retired = retiredBy.has(d.id)
|
||||
if (status === 'active' && retired) return false
|
||||
if (status === 'superseded' && !retired) return false
|
||||
if (status === 'waiting') return false
|
||||
if (kind && d.kind !== kind) return false
|
||||
if (source && d.provider !== source) return false
|
||||
if (project && task?.project !== project) return false
|
||||
if (needle) {
|
||||
const hay = [d.id, d.taskId, d.subject, d.value, task?.title ?? ''].join(' ').toLowerCase()
|
||||
if (!hay.includes(needle)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const visible = decisions.filter(matches)
|
||||
const visibleWaiting = waiting.filter((w) => {
|
||||
if (status && status !== 'waiting') return false
|
||||
if (kind || source) return false
|
||||
if (project && tasks.get(w.taskId)?.project !== project) return false
|
||||
if (needle) return [w.taskId, w.question, w.why].join(' ').toLowerCase().includes(needle)
|
||||
return true
|
||||
})
|
||||
|
||||
const active = decisions.length - retiredBy.size
|
||||
const loading = events.isLoading || overview.isLoading
|
||||
|
||||
return (
|
||||
<main className="page">
|
||||
<div className="page-head">
|
||||
<h1>Decisions</h1>
|
||||
<p>Durable human authority: what the operator decided, and what still stands.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid stats">
|
||||
<div className="stat">
|
||||
<span className="label">Recorded</span>
|
||||
<span className="stat-value">{decisions.length}</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="label">Standing</span>
|
||||
<span className="stat-value" data-accent="true">
|
||||
{active}
|
||||
</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="label">Superseded</span>
|
||||
<span className="stat-value">{retiredBy.size}</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="label">Waiting on you</span>
|
||||
<span className="stat-value">{waiting.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Panel
|
||||
title="Decision log"
|
||||
count={visible.length + visibleWaiting.length}
|
||||
trailing={
|
||||
events.isError || overview.isError ? 'read failed' : `reduced from ${(events.data ?? []).length} events`
|
||||
}
|
||||
>
|
||||
<div className="dec-filters">
|
||||
<input
|
||||
className="dec-search"
|
||||
placeholder="Search decisions…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
label="Status"
|
||||
value={status}
|
||||
options={['active', 'superseded', 'waiting']}
|
||||
onChange={setStatus}
|
||||
/>
|
||||
<Select label="Kind" value={kind} options={kinds} onChange={setKind} />
|
||||
<Select label="Project" value={project} options={projects} onChange={setProject} />
|
||||
<Select label="Source" value={source} options={sources} onChange={setSource} />
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="panel-body">
|
||||
<p className="gap-note">reading /v1/events…</p>
|
||||
</div>
|
||||
) : visible.length + visibleWaiting.length === 0 ? (
|
||||
<Empty title="No decisions match">
|
||||
<p>
|
||||
The log holds <M>{decisions.length}</M> recorded decisions. Clear the filters to see
|
||||
them.
|
||||
</p>
|
||||
</Empty>
|
||||
) : (
|
||||
<div className="table-scroll">
|
||||
<table className="table dec-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Decision</th>
|
||||
<th>Task</th>
|
||||
<th>Phase</th>
|
||||
<th>Provenance</th>
|
||||
<th>Recorded</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleWaiting.map((w) => (
|
||||
<tr key={`w-${w.taskId}`} data-status="waiting">
|
||||
<td className="mono">—</td>
|
||||
<td>
|
||||
<div className="row-title">{w.question}</div>
|
||||
<div className="row-sub">{w.why}</div>
|
||||
</td>
|
||||
<td>
|
||||
<Link className="dec-task" to={`/tasks/${w.taskId}`}>
|
||||
<M>{shortId(w.taskId)}</M>
|
||||
<span className="row-sub">{tasks.get(w.taskId)?.title ?? ''}</span>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="mono">{tasks.get(w.taskId)?.work_phase ?? '—'}</td>
|
||||
<td className="mono">—</td>
|
||||
<td className="mono">{w.at ? stamp(w.at) : '—'}</td>
|
||||
<td>
|
||||
<Chip tone="warn">waiting</Chip>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{visible.map((d) => {
|
||||
const retired = retiredBy.has(d.id)
|
||||
const successor = retiredBy.get(d.id)
|
||||
const task = tasks.get(d.taskId)
|
||||
const isOpen = open === d.id
|
||||
return (
|
||||
<Fragment key={d.id}>
|
||||
<tr
|
||||
data-status={retired ? 'superseded' : 'active'}
|
||||
data-open={isOpen ? 'true' : undefined}
|
||||
onClick={() => setOpen(isOpen ? undefined : d.id)}
|
||||
>
|
||||
<td className="mono" title={d.id}>
|
||||
{shortId(d.id)}
|
||||
</td>
|
||||
<td>
|
||||
<div className="row-title">{d.value}</div>
|
||||
<div className="row-sub">{d.subject}</div>
|
||||
</td>
|
||||
<td>
|
||||
<Link
|
||||
className="dec-task"
|
||||
to={`/tasks/${d.taskId}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<M>{shortId(d.taskId)}</M>
|
||||
<span className="row-sub">{task?.title ?? ''}</span>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="mono">{task?.work_phase ?? '—'}</td>
|
||||
<td className="mono" title={d.externalId}>
|
||||
{d.provider || '—'}
|
||||
</td>
|
||||
<td className="mono">{stamp(d.at)}</td>
|
||||
<td>
|
||||
{retired ? (
|
||||
<Chip>superseded</Chip>
|
||||
) : (
|
||||
<Chip tone="accent">{d.kind || 'active'}</Chip>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{isOpen && (
|
||||
<tr className="dec-detail-row">
|
||||
<td colSpan={7}>
|
||||
<div className="dec-detail">
|
||||
<div>
|
||||
<span className="label">Value</span>
|
||||
<p className="dec-value">{d.value}</p>
|
||||
<span className="label">Subject</span>
|
||||
<p className="dec-subject">{d.subject || '—'}</p>
|
||||
<span className="label">Effective authority</span>
|
||||
<p className="dec-impact" data-retired={retired ? 'true' : undefined}>
|
||||
{retired
|
||||
? successor
|
||||
? 'Retired. It is out of the effective intent for this task.'
|
||||
: 'Retired by an explicit supersede. It no longer binds the agent.'
|
||||
: 'Standing. It is in the effective intent handed to every session of this task.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="dec-facts">
|
||||
<span className="label">Decision id</span>
|
||||
<div>
|
||||
<M>{d.id}</M>
|
||||
</div>
|
||||
<span className="label">Kind</span>
|
||||
<div>
|
||||
<M>{d.kind}</M>
|
||||
</div>
|
||||
<span className="label">Task</span>
|
||||
<div>
|
||||
<Link to={`/tasks/${d.taskId}`}>
|
||||
<M>{d.taskId}</M>
|
||||
</Link>
|
||||
</div>
|
||||
<span className="label">Provenance</span>
|
||||
<div>
|
||||
<M>{d.provider || 'unknown'}</M>{' '}
|
||||
{d.externalId && <M>· {d.externalId}</M>}
|
||||
</div>
|
||||
<span className="label">Supersedes</span>
|
||||
<div>
|
||||
{d.supersedes.length === 0 ? (
|
||||
<M>none</M>
|
||||
) : (
|
||||
d.supersedes.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
className="dec-link mono"
|
||||
onClick={() => setOpen(s)}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<span className="label">Superseded by</span>
|
||||
<div>
|
||||
{!retired ? (
|
||||
<M>none</M>
|
||||
) : successor ? (
|
||||
<button className="dec-link mono" onClick={() => setOpen(successor)}>
|
||||
{successor}
|
||||
</button>
|
||||
) : (
|
||||
<M>explicit supersede event</M>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="dec-trail">
|
||||
<span className="label">Event sequence</span>
|
||||
{(trail.get(d.id) ?? []).map((e) => (
|
||||
<div key={e.id} className="dec-event">
|
||||
<M>{e.seq !== undefined ? `#${e.seq}` : '—'}</M>
|
||||
<span className="dec-event-type">{e.type}</span>
|
||||
<M>{stamp(e.at)}</M>
|
||||
<M>{e.id}</M>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Law 5: the mockup's approval columns have no source in the log. */}
|
||||
<EndpointGap
|
||||
path="/v1/ui/decisions"
|
||||
what="Recorded, superseded and waiting are reduced from the event log. Approved, rejected and auto are not: the domain has no approval state on a decision, so those statuses are omitted rather than guessed. The acting human is not recorded either — source.provider is the channel the decision arrived on, not the person. Phase is the task's current work phase, not the phase the decision was made in."
|
||||
/>
|
||||
</Panel>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/* Projects: two columns on desktop, stacked on mobile. Everything else comes
|
||||
from orchestra.css — this file exists only for the reflow. */
|
||||
.projects-split {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 340px);
|
||||
}
|
||||
|
||||
/* .row is a flex container; its title/subtitle stack. */
|
||||
.projects-split .row-main > span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.projects-split {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { api } from '../api/client'
|
||||
import type { Task, TaskState, Worker } from '../api/types'
|
||||
import { Chip, Dot, Empty, EndpointGap, M, Panel } from '../components/Primitives'
|
||||
import './Projects.css'
|
||||
|
||||
/** Orchestra has no projects endpoint. The registry that defines a project —
|
||||
* repo, remote, quality gate, verification policy, machine affinity — lives in
|
||||
* the coordinator's config.jsonc and is never served over HTTP. What this
|
||||
* screen shows is derived from work that actually exists: the tasks in
|
||||
* /v1/ui/overview carry a project, and the workers in the federation registry
|
||||
* declare which projects they will accept. Everything else is a stated gap,
|
||||
* not a plausible-looking guess. */
|
||||
|
||||
const ATTENTION: TaskState[] = ['needs_attention', 'blocked', 'failed']
|
||||
|
||||
interface ProjectRow {
|
||||
id: string
|
||||
tasks: Task[]
|
||||
counts: Record<TaskState, number>
|
||||
attention: number
|
||||
active: number
|
||||
/** Newest capture/check the coordinator has for this project's sessions.
|
||||
* A task carries no updated_at, so this is the only honest recency signal
|
||||
* the overview projection actually contains. */
|
||||
lastSeen?: string
|
||||
workers: Worker[]
|
||||
}
|
||||
|
||||
function fmt(at?: string) {
|
||||
if (!at) return undefined
|
||||
const d = new Date(at)
|
||||
return Number.isNaN(d.getTime()) ? at : `${d.toISOString().slice(0, 16).replace('T', ' ')} UTC`
|
||||
}
|
||||
|
||||
function newest(a?: string, b?: string) {
|
||||
if (!a) return b
|
||||
if (!b) return a
|
||||
return a > b ? a : b
|
||||
}
|
||||
|
||||
function derive(tasks: Task[], workers: Worker[]): ProjectRow[] {
|
||||
const rows = new Map<string, ProjectRow>()
|
||||
for (const task of tasks) {
|
||||
const id = task.project || '(unset)'
|
||||
let row = rows.get(id)
|
||||
if (!row) {
|
||||
row = {
|
||||
id,
|
||||
tasks: [],
|
||||
counts: {} as Record<TaskState, number>,
|
||||
attention: 0,
|
||||
active: 0,
|
||||
workers: workers.filter((w) => (w.supported_projects || []).includes(id)),
|
||||
}
|
||||
rows.set(id, row)
|
||||
}
|
||||
row.tasks.push(task)
|
||||
row.counts[task.state] = (row.counts[task.state] || 0) + 1
|
||||
if (ATTENTION.includes(task.state)) row.attention++
|
||||
if (task.state === 'leased') row.active++
|
||||
row.lastSeen = newest(
|
||||
row.lastSeen,
|
||||
newest(task.last_session?.captured_at, task.last_session?.checked_at),
|
||||
)
|
||||
}
|
||||
return [...rows.values()].sort(
|
||||
(a, b) => b.attention - a.attention || b.active - a.active || a.id.localeCompare(b.id),
|
||||
)
|
||||
}
|
||||
|
||||
function stateChips(counts: Record<TaskState, number>) {
|
||||
const order: TaskState[] = [
|
||||
'leased',
|
||||
'queued',
|
||||
'in_review',
|
||||
'needs_attention',
|
||||
'blocked',
|
||||
'failed',
|
||||
'completed',
|
||||
]
|
||||
return order
|
||||
.filter((state) => counts[state])
|
||||
.map((state) => (
|
||||
<Chip
|
||||
key={state}
|
||||
tone={
|
||||
state === 'leased'
|
||||
? 'accent'
|
||||
: state === 'completed'
|
||||
? 'done'
|
||||
: state === 'needs_attention'
|
||||
? 'warn'
|
||||
: ATTENTION.includes(state)
|
||||
? 'fault'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{state.replace('_', ' ')} <M>{counts[state]}</M>
|
||||
</Chip>
|
||||
))
|
||||
}
|
||||
|
||||
export function Projects() {
|
||||
const overview = useQuery({ queryKey: ['overview'], queryFn: api.overview, refetchInterval: 5000 })
|
||||
const [selected, setSelected] = useState<string>()
|
||||
|
||||
const rows = useMemo(
|
||||
() => derive(overview.data?.tasks || [], overview.data?.workers || []),
|
||||
[overview.data],
|
||||
)
|
||||
const current = rows.find((r) => r.id === selected) || rows[0]
|
||||
|
||||
return (
|
||||
<main className="page">
|
||||
<div className="page-head">
|
||||
<h1>Projects</h1>
|
||||
<p>
|
||||
Projects orchestra is holding work for, derived from the tasks it knows about. The
|
||||
coordinator does not serve its project registry.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{overview.isError && (
|
||||
<Panel>
|
||||
<div className="panel-body">
|
||||
<p className="gap-note">/v1/ui/overview failed: {String(overview.error)}</p>
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{!overview.data && !overview.isError && (
|
||||
<Panel>
|
||||
<div className="panel-body">
|
||||
<p className="gap-note">reading /v1/ui/overview</p>
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{overview.data && rows.length === 0 && (
|
||||
<Panel>
|
||||
<Empty title="No project has work">
|
||||
<p>
|
||||
Every project here comes from a task. Orchestra holds{' '}
|
||||
<M>{overview.data.tasks.length}</M> tasks, so nothing groups yet.
|
||||
</p>
|
||||
</Empty>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{current && (
|
||||
<div className="grid projects-split">
|
||||
<Panel title="With work" count={rows.length}>
|
||||
<div className="table-scroll">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Project</th>
|
||||
<th>Tasks</th>
|
||||
<th>Leased</th>
|
||||
<th>Attention</th>
|
||||
<th>Last session evidence</th>
|
||||
<th>Workers</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
onClick={() => setSelected(row.id)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
background: row.id === current.id ? 'var(--bg-2)' : undefined,
|
||||
boxShadow:
|
||||
row.id === current.id ? 'inset 3px 0 0 0 var(--accent)' : undefined,
|
||||
}}
|
||||
>
|
||||
<td>
|
||||
<M>{row.id}</M>
|
||||
</td>
|
||||
<td>
|
||||
<M>{row.tasks.length}</M>
|
||||
</td>
|
||||
<td>
|
||||
<M>{row.active}</M>
|
||||
</td>
|
||||
<td>
|
||||
{row.attention ? (
|
||||
<Chip tone="fault">
|
||||
<M>{row.attention}</M>
|
||||
</Chip>
|
||||
) : (
|
||||
<M>0</M>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<M>{fmt(row.lastSeen) || '—'}</M>
|
||||
</td>
|
||||
<td>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||
{row.workers.length === 0 ? (
|
||||
<M>none</M>
|
||||
) : (
|
||||
row.workers.map((w) => (
|
||||
<Chip key={w.id}>
|
||||
<Dot health={w.online ? 'healthy' : 'error'} />
|
||||
<M>{w.id}</M>
|
||||
</Chip>
|
||||
))
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<div className="grid" style={{ alignContent: 'start' }}>
|
||||
<Panel
|
||||
title={current.id}
|
||||
trailing={<Link to={`/tasks?project=${encodeURIComponent(current.id)}`}>Tasks →</Link>}
|
||||
>
|
||||
<div className="panel-body" style={{ display: 'grid', gap: 12 }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{stateChips(current.counts)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="label">Workers declaring this project</div>
|
||||
<p style={{ margin: '4px 0 0', color: 'var(--text-mid)' }}>
|
||||
{current.workers.length === 0
|
||||
? 'No registered worker accepts it. Orchestra does not treat an omitted declaration as a wildcard, so nothing can lease this work.'
|
||||
: 'Worker-declared, from the federation registry — not the coordinator’s project affinity.'}
|
||||
</p>
|
||||
{current.workers.map((w) => (
|
||||
<div key={w.id} style={{ marginTop: 6 }}>
|
||||
<Dot health={w.online ? 'healthy' : 'error'} /> <M>{w.id}</M>{' '}
|
||||
<M>{w.address || ''}</M>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Tasks" count={current.tasks.length}>
|
||||
<div className="rows">
|
||||
{current.tasks.slice(0, 8).map((task) => (
|
||||
<Link key={task.id} className="row" to={`/tasks/${task.id}`}>
|
||||
<span className="row-main">
|
||||
<span className="row-title">{task.title || task.external_id || 'untitled'}</span>
|
||||
<span className="row-sub">
|
||||
<M>{task.id}</M>
|
||||
</span>
|
||||
</span>
|
||||
<Chip
|
||||
tone={
|
||||
task.state === 'leased'
|
||||
? 'accent'
|
||||
: task.state === 'completed'
|
||||
? 'done'
|
||||
: task.state === 'needs_attention'
|
||||
? 'warn'
|
||||
: ATTENTION.includes(task.state)
|
||||
? 'fault'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{task.state.replace('_', ' ')}
|
||||
</Chip>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{current.tasks.length > 8 && (
|
||||
<div className="panel-body">
|
||||
<Link to={`/tasks?project=${encodeURIComponent(current.id)}`}>
|
||||
All <M>{current.tasks.length}</M> tasks →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel title="Project configuration">
|
||||
<EndpointGap
|
||||
path="/v1/ui/projects"
|
||||
what="repo, remote, quality gate and verification policy live in the coordinator's config.jsonc and are not served over HTTP"
|
||||
/>
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/* Review-only geometry. Panel, row, table, stat, chip and btn come from
|
||||
orchestra.css and are not restated here. */
|
||||
|
||||
.rev-stamp {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: var(--text-lo);
|
||||
}
|
||||
|
||||
.rev-fault {
|
||||
color: #e06c60;
|
||||
}
|
||||
|
||||
.rev-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.rev-tabs .btn[data-active='true'] {
|
||||
border-color: var(--accent-line);
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent-hi);
|
||||
}
|
||||
|
||||
/* One desk item: the row itself, plus whatever evidence it carries under it. */
|
||||
.rev-item {
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.rev-item:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.rev-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.rev-line .row.rev-link {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.rev-out {
|
||||
flex: none;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* The kind mark is a square of the item's own tone — a signal, never a wash
|
||||
across the row. */
|
||||
.rev-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex: none;
|
||||
border-radius: var(--r-sm);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-2);
|
||||
color: var(--text-mid);
|
||||
}
|
||||
|
||||
.rev-mark[data-kind='trajectory'],
|
||||
.rev-mark[data-kind='pr'] {
|
||||
border-color: var(--accent-line);
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent-hi);
|
||||
}
|
||||
|
||||
.rev-mark[data-kind='decision'],
|
||||
.rev-mark[data-kind='operator'] {
|
||||
border-color: rgba(216, 166, 87, 0.3);
|
||||
background: rgba(216, 166, 87, 0.1);
|
||||
color: #d8a657;
|
||||
}
|
||||
|
||||
.rev-mark[data-kind='findings'] {
|
||||
border-color: rgba(224, 108, 96, 0.3);
|
||||
background: rgba(224, 108, 96, 0.1);
|
||||
color: #e06c60;
|
||||
}
|
||||
|
||||
.rev-kind {
|
||||
flex: none;
|
||||
width: 88px;
|
||||
font-size: 13px;
|
||||
color: var(--text-mid);
|
||||
}
|
||||
|
||||
.rev-task {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
flex: 0 0 168px;
|
||||
}
|
||||
|
||||
.rev-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: none;
|
||||
min-width: 72px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row-main,
|
||||
.rev-task {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.rev-link .row-title,
|
||||
.rev-link .row-sub,
|
||||
.rev-task .row-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Refs sit under the row as verified orchestra state: neutral, mono values. */
|
||||
.rev-refs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 20px;
|
||||
padding: 0 16px 12px 60px;
|
||||
font-size: 12px;
|
||||
color: var(--text-machine);
|
||||
}
|
||||
|
||||
/* An agent claim is labelled as one. It is never given orchestra's voice. */
|
||||
.claim-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0 0 8px;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
color: #d8a657;
|
||||
}
|
||||
|
||||
.rev-findings,
|
||||
.rev-options {
|
||||
margin: 0 16px 14px 60px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--line-hi);
|
||||
border-left: 2px solid rgba(216, 166, 87, 0.45);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--bg-2);
|
||||
}
|
||||
|
||||
.rev-options p {
|
||||
margin: 0 0 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text-hi);
|
||||
}
|
||||
|
||||
.rev-findings pre {
|
||||
margin: 0;
|
||||
max-height: 340px;
|
||||
overflow: auto;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: var(--text-mid);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.rev-line {
|
||||
flex-wrap: wrap;
|
||||
padding: 0 16px 12px;
|
||||
}
|
||||
|
||||
.rev-kind,
|
||||
.rev-task {
|
||||
width: auto;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.rev-refs,
|
||||
.rev-findings,
|
||||
.rev-options {
|
||||
margin-left: 16px;
|
||||
padding-left: 14px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { api } from '../api/client'
|
||||
import type { Overview, Task } from '../api/types'
|
||||
import { Chip, Empty, EndpointGap, M, Panel } from '../components/Primitives'
|
||||
import { Icon } from '../components/Icon'
|
||||
import './Review.css'
|
||||
|
||||
/* The Go domain carries review, submission, decision_request and work_phase on
|
||||
the task; the shared TS Task type has not caught up. They are read through a
|
||||
narrow cast rather than invented, renamed, or added to a file this screen
|
||||
does not own. Shapes are internal/domain/domain.go and submission.go. */
|
||||
type ReviewRef = { artifact_ref: string; result_sha: string; blocking: number }
|
||||
type ExternalRef = { provider: string; id: string; url?: string }
|
||||
type SubmissionRef = {
|
||||
result_sha: string
|
||||
remote_ref?: string
|
||||
pr: ExternalRef
|
||||
gate_ref?: string
|
||||
review_ref?: string
|
||||
}
|
||||
type DecisionOption = { id: string; description: string; tradeoff?: string }
|
||||
type DecisionRequest = {
|
||||
question: string
|
||||
why: string
|
||||
options?: DecisionOption[]
|
||||
evidence?: string[]
|
||||
}
|
||||
type FullTask = Task & {
|
||||
review?: ReviewRef
|
||||
submission?: SubmissionRef
|
||||
decision_request?: DecisionRequest
|
||||
review_target_sha?: string
|
||||
work_phase?: string
|
||||
}
|
||||
|
||||
const full = (t: Task) => t as FullTask
|
||||
|
||||
/** "2m ago" — a machine value, so it renders mono at the call site. */
|
||||
function ago(at?: string) {
|
||||
if (!at) return undefined
|
||||
const s = Math.round((Date.now() - Date.parse(at)) / 1000)
|
||||
if (!Number.isFinite(s) || s < 0) return undefined
|
||||
if (s < 60) return `${s}s ago`
|
||||
if (s < 3600) return `${Math.floor(s / 60)}m ago`
|
||||
if (s < 86400) return `${Math.floor(s / 3600)}h ago`
|
||||
return `${Math.floor(s / 86400)}d ago`
|
||||
}
|
||||
|
||||
const shortId = (id: string) => (id.length > 14 ? `${id.slice(0, 6)}…${id.slice(-4)}` : id)
|
||||
const shortSha = (sha?: string) => (sha ? sha.slice(0, 7) : undefined)
|
||||
|
||||
type Kind = 'trajectory' | 'decision' | 'operator' | 'findings' | 'pr'
|
||||
|
||||
const KINDS: { kind: Kind; label: string; icon: string }[] = [
|
||||
{ kind: 'trajectory', label: 'Trajectory', icon: 'fork' },
|
||||
{ kind: 'decision', label: 'Blocker', icon: 'decision' },
|
||||
{ kind: 'operator', label: 'Operator', icon: 'alert' },
|
||||
{ kind: 'findings', label: 'AI review', icon: 'review' },
|
||||
{ kind: 'pr', label: 'Code review', icon: 'task' },
|
||||
]
|
||||
const meta = (k: Kind) => KINDS.find((x) => x.kind === k)!
|
||||
|
||||
type Item = {
|
||||
key: string
|
||||
kind: Kind
|
||||
task: FullTask
|
||||
/** Why this is on the desk, in the operator's language. Always derived. */
|
||||
why: string
|
||||
headline: string
|
||||
at?: string
|
||||
}
|
||||
|
||||
/* Reasons an operator-required block can carry. Each line names the real
|
||||
block_reason it came from — none of it is decorative copy. */
|
||||
const operatorReason: Record<string, string> = {
|
||||
operator_required: 'Orchestra cannot proceed without an operator action.',
|
||||
approval: 'A tool or edit approval is pending in the pane.',
|
||||
handoff_validation: 'The handoff failed validation and was not accepted.',
|
||||
lease_failure: 'The lease could not be established.',
|
||||
lease_expired: 'The lease expired before the agent finished.',
|
||||
worker_offline: 'The worker holding this task went offline.',
|
||||
system_error: 'Orchestra hit an error it could not retry past.',
|
||||
operator_block: 'You blocked this task.',
|
||||
unknown: 'Blocked for a reason orchestra could not classify.',
|
||||
}
|
||||
|
||||
/** One task can be on the desk for more than one reason, and each reason is
|
||||
* its own item: answering the blocker does not clear the review findings. */
|
||||
function itemsFor(task: Task): Item[] {
|
||||
const t = full(task)
|
||||
const out: Item[] = []
|
||||
const at = t.blocked_at
|
||||
|
||||
if (t.block_reason === 'trajectory_gate') {
|
||||
out.push({
|
||||
key: `${t.id}:trajectory`,
|
||||
kind: 'trajectory',
|
||||
task: t,
|
||||
headline: t.decision_request?.question ?? 'Trajectory gate is held',
|
||||
why:
|
||||
t.decision_request?.why ??
|
||||
t.blocker ??
|
||||
'A trajectory gate is waiting for your steering before work continues.',
|
||||
at,
|
||||
})
|
||||
}
|
||||
|
||||
if (t.block_reason === 'human_decision' || (t.decision_request && !out.length)) {
|
||||
out.push({
|
||||
key: `${t.id}:decision`,
|
||||
kind: 'decision',
|
||||
task: t,
|
||||
headline: t.decision_request?.question ?? t.blocker ?? 'A decision is required',
|
||||
why:
|
||||
t.decision_request?.why ??
|
||||
t.blocker ??
|
||||
'The agent asked for a decision it may not make on its own.',
|
||||
at,
|
||||
})
|
||||
}
|
||||
|
||||
if (t.state === 'blocked' && !out.length) {
|
||||
const reason = t.block_reason ?? 'unknown'
|
||||
out.push({
|
||||
key: `${t.id}:operator`,
|
||||
kind: 'operator',
|
||||
task: t,
|
||||
headline: t.blocker || `Blocked: ${reason}`,
|
||||
why: operatorReason[reason] ?? operatorReason.unknown,
|
||||
at,
|
||||
})
|
||||
}
|
||||
|
||||
if (t.state === 'needs_attention' && !out.length) {
|
||||
out.push({
|
||||
key: `${t.id}:operator`,
|
||||
kind: 'operator',
|
||||
task: t,
|
||||
headline: t.blocker || t.last_error || 'Flagged for you',
|
||||
why: 'Orchestra flagged this task and will not move it without you.',
|
||||
at,
|
||||
})
|
||||
}
|
||||
|
||||
if (t.review && t.review.blocking > 0) {
|
||||
const stale = t.review.result_sha !== (t.submission?.result_sha ?? t.review.result_sha)
|
||||
out.push({
|
||||
key: `${t.id}:findings`,
|
||||
kind: 'findings',
|
||||
task: t,
|
||||
headline: `${t.review.blocking} blocking finding${t.review.blocking === 1 ? '' : 's'}`,
|
||||
why: stale
|
||||
? 'A sealed review claims blocking findings against a commit the submission has moved past. Accept or override them.'
|
||||
: 'A sealed review claims blocking findings. They stand until you accept or override them.',
|
||||
at,
|
||||
})
|
||||
}
|
||||
|
||||
if (t.submission?.pr) {
|
||||
out.push({
|
||||
key: `${t.id}:pr`,
|
||||
kind: 'pr',
|
||||
task: t,
|
||||
headline: t.title || 'Change submitted for review',
|
||||
why: 'A pull request is open and waiting on human review.',
|
||||
at,
|
||||
})
|
||||
} else if (t.state === 'in_review' && !out.some((i) => i.kind === 'findings')) {
|
||||
out.push({
|
||||
key: `${t.id}:pr`,
|
||||
kind: 'pr',
|
||||
task: t,
|
||||
headline: t.title || 'Awaiting review',
|
||||
why: 'The task is in review with no submission recorded yet.',
|
||||
at,
|
||||
})
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/** The sealed review artifact. It is an agent claim until a human accepts it,
|
||||
* so it is labelled as one and never rendered as orchestra's own verdict. */
|
||||
function Findings({ artifactRef }: { artifactRef: string }) {
|
||||
const q = useQuery({
|
||||
queryKey: ['artifact', artifactRef],
|
||||
queryFn: () => api.artifact(artifactRef),
|
||||
staleTime: Infinity, // a sealed artifact never changes
|
||||
})
|
||||
return (
|
||||
<div className="rev-findings">
|
||||
<p className="claim-label">
|
||||
<Icon name="alert" size={14} />
|
||||
agent-supplied · sealed review artifact <M>{artifactRef}</M>
|
||||
</p>
|
||||
{q.isError ? (
|
||||
<p className="rev-fault">could not read artifact: {String(q.error)}</p>
|
||||
) : q.data === undefined ? (
|
||||
<p className="row-sub">
|
||||
reading <M>{artifactRef}</M>
|
||||
</p>
|
||||
) : (
|
||||
<pre>{q.data}</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemRow({ item }: { item: Item }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const t = item.task
|
||||
const m = meta(item.kind)
|
||||
const age = ago(item.at)
|
||||
|
||||
return (
|
||||
<div className="rev-item">
|
||||
<div className="rev-line">
|
||||
<Link className="row rev-link" to={`/tasks/${t.id}`}>
|
||||
<span className="rev-mark" data-kind={item.kind}>
|
||||
<Icon name={m.icon} size={18} />
|
||||
</span>
|
||||
<span className="rev-kind">{m.label}</span>
|
||||
<span className="row-main">
|
||||
<span className="row-title">{item.headline}</span>
|
||||
<span className="row-sub">{item.why}</span>
|
||||
</span>
|
||||
<span className="rev-task">
|
||||
<span className="row-title">{t.project}</span>
|
||||
<span className="row-sub">
|
||||
<M>{shortId(t.id)}</M>
|
||||
{t.work_phase && (
|
||||
<>
|
||||
{' · '}
|
||||
<M>{t.work_phase}</M>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="rev-meta">
|
||||
<span className="label">priority</span>
|
||||
<M>{t.inherent_priority ?? 0}</M>
|
||||
</span>
|
||||
<span className="rev-meta">
|
||||
<span className="label">age</span>
|
||||
{age ? <M>{age}</M> : <span className="row-sub">not timestamped</span>}
|
||||
</span>
|
||||
{item.kind === 'findings' && t.review && (
|
||||
<Chip tone="fault">
|
||||
<M>{t.review.blocking}</M> blocking
|
||||
</Chip>
|
||||
)}
|
||||
{item.kind === 'pr' && t.submission?.pr?.id && (
|
||||
<Chip tone="accent">
|
||||
<M>{t.submission.pr.id}</M>
|
||||
</Chip>
|
||||
)}
|
||||
<Icon name="chevron-right" size={16} />
|
||||
</Link>
|
||||
|
||||
{item.kind === 'pr' && t.submission?.pr?.url && (
|
||||
<a
|
||||
className="btn rev-out"
|
||||
href={t.submission.pr.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Icon name="upload" size={14} />
|
||||
{t.submission.pr.provider}
|
||||
</a>
|
||||
)}
|
||||
{item.kind === 'findings' && t.review && (
|
||||
<button className="btn rev-out" onClick={() => setOpen(!open)}>
|
||||
{open ? 'Hide findings' : 'Read findings'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(item.kind === 'pr' || item.kind === 'findings') && (
|
||||
<div className="rev-refs">
|
||||
{t.review && (
|
||||
<span>
|
||||
<span className="label">reviewed sha</span> <M>{shortSha(t.review.result_sha)}</M>
|
||||
</span>
|
||||
)}
|
||||
{t.submission && (
|
||||
<span>
|
||||
<span className="label">submitted sha</span>{' '}
|
||||
<M>{shortSha(t.submission.result_sha)}</M>
|
||||
</span>
|
||||
)}
|
||||
{t.submission?.gate_ref && (
|
||||
<span>
|
||||
<span className="label">gate</span> <M>{t.submission.gate_ref}</M>
|
||||
</span>
|
||||
)}
|
||||
{t.submission?.review_ref && (
|
||||
<span>
|
||||
<span className="label">review</span> <M>{t.submission.review_ref}</M>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.kind === 'trajectory' && t.decision_request?.options?.length ? (
|
||||
<div className="rev-options">
|
||||
<p className="claim-label">
|
||||
<Icon name="alert" size={14} />
|
||||
agent-supplied · proposed options
|
||||
</p>
|
||||
{t.decision_request.options.map((o) => (
|
||||
<p key={o.id}>
|
||||
<M>{o.id}</M> {o.description}
|
||||
{o.tradeoff && <span className="row-sub"> — {o.tradeoff}</span>}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{open && item.kind === 'findings' && t.review && (
|
||||
<Findings artifactRef={t.review.artifact_ref} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Review() {
|
||||
const overview = useQuery<Overview>({
|
||||
queryKey: ['overview'],
|
||||
queryFn: api.overview,
|
||||
refetchInterval: 5000,
|
||||
})
|
||||
const [kind, setKind] = useState<Kind | 'all'>('all')
|
||||
|
||||
const items = (overview.data?.tasks ?? []).flatMap(itemsFor)
|
||||
// Most urgent first: high inherent priority, then oldest wait.
|
||||
const order = KINDS.map((k) => k.kind)
|
||||
items.sort(
|
||||
(a, b) =>
|
||||
(b.task.inherent_priority ?? 0) - (a.task.inherent_priority ?? 0) ||
|
||||
Date.parse(a.at ?? '') - Date.parse(b.at ?? '') ||
|
||||
order.indexOf(a.kind) - order.indexOf(b.kind),
|
||||
)
|
||||
const shown = kind === 'all' ? items : items.filter((i) => i.kind === kind)
|
||||
const count = (k: Kind) => items.filter((i) => i.kind === k).length
|
||||
const blocking = items.reduce((n, i) => n + (i.kind === 'findings' ? (i.task.review?.blocking ?? 0) : 0), 0)
|
||||
|
||||
return (
|
||||
<main className="page">
|
||||
<div className="page-head">
|
||||
<h1>Review</h1>
|
||||
<p>Items that need your attention and judgement.</p>
|
||||
<span className="rev-stamp">
|
||||
{overview.data ? (
|
||||
<>
|
||||
read <M>{new Date(overview.data.updated_at).toISOString().slice(11, 19)} UTC</M>
|
||||
</>
|
||||
) : overview.isError ? (
|
||||
<span className="rev-fault">overview unreachable — {String(overview.error)}</span>
|
||||
) : (
|
||||
'reading the first snapshot'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid stats">
|
||||
<div className="stat">
|
||||
<span className="label">Needs you</span>
|
||||
<span className="stat-value">{items.length}</span>
|
||||
<span className="row-sub">
|
||||
across <M>{new Set(items.map((i) => i.task.id)).size}</M> tasks
|
||||
</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="label">Blocking findings</span>
|
||||
<span className="stat-value">{blocking}</span>
|
||||
<span className="row-sub">claimed by sealed reviews, not yet accepted</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="label">Pull requests open</span>
|
||||
<span className="stat-value">{count('pr')}</span>
|
||||
<span className="row-sub">recorded on a submission</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Panel title="Attention queue" count={shown.length}>
|
||||
<div className="rev-tabs">
|
||||
<button className="btn" data-active={kind === 'all'} onClick={() => setKind('all')}>
|
||||
All <M>{items.length}</M>
|
||||
</button>
|
||||
{KINDS.map((k) => (
|
||||
<button
|
||||
key={k.kind}
|
||||
className="btn"
|
||||
data-active={kind === k.kind}
|
||||
onClick={() => setKind(k.kind)}
|
||||
>
|
||||
{k.label} <M>{count(k.kind)}</M>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{shown.length === 0 ? (
|
||||
<Empty title={items.length === 0 ? 'Nothing is waiting on you' : 'Nothing of this type'}>
|
||||
<p>
|
||||
Trajectory gates, human decisions, operator-required blocks, blocking review findings
|
||||
and open pull requests land here.
|
||||
</p>
|
||||
</Empty>
|
||||
) : (
|
||||
<div className="rows">
|
||||
{shown.map((i) => (
|
||||
<ItemRow key={i.key} item={i} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<EndpointGap
|
||||
path="GET /v1/ui/submissions → pr.state, GET /v1/ui/review/snoozes"
|
||||
what="Two item types the spec lists have no source. A closed-but-unmerged pull request cannot be derived: submission.pr carries provider, id and url but no forge state, and orchestra never polls the forge. Informational/FYI items and snooze or defer state have no endpoint and no event either, so no tab stands in for them."
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/* Settings. Only what the shared shell does not carry: the tab strip, the
|
||||
credential form geometry, and the fact list. */
|
||||
|
||||
.set-tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex-wrap: wrap;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.set-tab {
|
||||
background: none;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
padding: 8px 12px;
|
||||
color: var(--text-mid);
|
||||
cursor: pointer;
|
||||
transition: color var(--fast) var(--ease), border-color var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.set-tab:hover {
|
||||
color: var(--text-hi);
|
||||
}
|
||||
|
||||
/* Accent marks the section you are in, and the save button. Nothing else. */
|
||||
.set-tab[aria-selected='true'] {
|
||||
color: var(--accent-hi);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.set-account {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.set-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.set-note {
|
||||
margin: 0;
|
||||
color: var(--text-mid);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.set-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.set-split {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.set-input {
|
||||
height: 34px;
|
||||
padding: 0 11px;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--text-hi);
|
||||
font: inherit;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
transition: border-color var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.set-input::placeholder {
|
||||
color: var(--text-lo);
|
||||
}
|
||||
|
||||
.set-input:focus {
|
||||
border-color: var(--accent-line);
|
||||
}
|
||||
|
||||
.set-reveal {
|
||||
position: relative;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.set-reveal-btn {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 4px;
|
||||
height: 26px;
|
||||
padding: 0 9px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-3);
|
||||
color: var(--text-mid);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.set-rules {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
font-size: 12px;
|
||||
color: var(--text-lo);
|
||||
}
|
||||
|
||||
.set-rules span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.set-rules span[data-met='true'] {
|
||||
color: #5fbf82;
|
||||
}
|
||||
|
||||
.set-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #e06c60;
|
||||
}
|
||||
|
||||
.set-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.set-facts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.set-facts > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.set-signout {
|
||||
align-self: flex-start;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.set-account {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { api } from '../api/client'
|
||||
import type { Account } from '../api/types'
|
||||
import { Chip, EndpointGap, M, Panel } from '../components/Primitives'
|
||||
import { Icon } from '../components/Icon'
|
||||
import './Settings.css'
|
||||
|
||||
/** The spec asks for eight tabs. The coordinator has exactly one settings
|
||||
* mutation — PUT /v1/ui/account — so exactly one tab holds controls. Every
|
||||
* other tab names the endpoint that would supply it rather than showing a
|
||||
* form that cannot save. Law 5. */
|
||||
type TabKey =
|
||||
| 'account'
|
||||
| 'general'
|
||||
| 'appearance'
|
||||
| 'notifications'
|
||||
| 'access'
|
||||
| 'integrations'
|
||||
| 'agents'
|
||||
| 'policies'
|
||||
| 'advanced'
|
||||
|
||||
const TABS: { key: TabKey; label: string }[] = [
|
||||
{ key: 'account', label: 'Account' },
|
||||
{ key: 'general', label: 'General' },
|
||||
{ key: 'appearance', label: 'Appearance' },
|
||||
{ key: 'notifications', label: 'Notifications' },
|
||||
{ key: 'access', label: 'Access & security' },
|
||||
{ key: 'integrations', label: 'Integrations' },
|
||||
{ key: 'agents', label: 'Agents & tools' },
|
||||
{ key: 'policies', label: 'Policies' },
|
||||
{ key: 'advanced', label: 'Advanced' },
|
||||
]
|
||||
|
||||
const GAPS: Record<Exclude<TabKey, 'account'>, { path: string; what: string }> = {
|
||||
general: {
|
||||
path: 'GET/PUT /v1/ui/settings/general',
|
||||
what: 'Instance name and environment, time display, default task preferences, default workflow policy and event/artifact retention. The coordinator keeps these in config.jsonc and the container environment; nothing reads or writes them over the API.',
|
||||
},
|
||||
appearance: {
|
||||
path: 'GET/PUT /v1/ui/settings/appearance',
|
||||
what: 'Theme and reduced motion are the only appearance choices ethos permits, and neither is stored server-side today. The console follows the operating system.',
|
||||
},
|
||||
notifications: {
|
||||
path: 'GET/PUT /v1/ui/settings/notifications',
|
||||
what: 'Notification sinks and per-event routing. ntfy credentials live in the compose .env file and are never returned by the API.',
|
||||
},
|
||||
access: {
|
||||
path: 'GET /v1/ui/settings/access',
|
||||
what: 'Web tokens and their status, trusted and ignored review actors, live operator sessions, agent-surface auth and the source each secret is loaded from. Only the operator credential below is exposed today.',
|
||||
},
|
||||
integrations: {
|
||||
path: 'GET /v1/ui/settings/integrations',
|
||||
what: 'Gitea and Vikunja endpoints, notification sinks and source reconciliation state, each with its identity and last successful interaction. No endpoint reports integration health.',
|
||||
},
|
||||
agents: {
|
||||
path: 'GET /v1/ui/settings/harnesses',
|
||||
what: 'Harness definitions, adapter runtime configuration, herdr and tmux ownership, default launch behaviour and context occupancy thresholds. The registry is read from config.jsonc at start-up and is not served.',
|
||||
},
|
||||
policies: {
|
||||
path: 'GET/PUT /v1/ui/settings/policies',
|
||||
what: 'Work phase defaults, the trajectory gate, the bounded decision request budget, retry policy, reconcile failure handoff threshold and review/submission policy. These are compiled-in defaults, not stored settings.',
|
||||
},
|
||||
advanced: {
|
||||
path: 'GET /v1/ui/settings/diagnostics',
|
||||
what: 'Event-store diagnostics, snapshots and replay, raw configuration, migration status and destructive operator actions. Destructive actions must not be built before the coordinator can report what they would touch.',
|
||||
},
|
||||
}
|
||||
|
||||
function stamp(at?: string) {
|
||||
if (!at) return '—'
|
||||
const ms = Date.parse(at)
|
||||
return Number.isFinite(ms) ? new Date(ms).toISOString().replace('T', ' ').slice(0, 19) + ' UTC' : at
|
||||
}
|
||||
|
||||
function message(error: unknown) {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
export function Settings({ account, onLogout, onCredentialsChanged }: {
|
||||
account: Account
|
||||
onLogout: () => void
|
||||
onCredentialsChanged: (username: string) => void
|
||||
}) {
|
||||
const [tab, setTab] = useState<TabKey>('account')
|
||||
return (
|
||||
<main className="page">
|
||||
<div className="page-head">
|
||||
<h1>Settings</h1>
|
||||
<p>One operator credential is stored here. Everything else Orchestra runs on comes from config and environment.</p>
|
||||
</div>
|
||||
|
||||
<div className="set-tabs" role="tablist" aria-label="Settings sections">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
role="tab"
|
||||
type="button"
|
||||
className="set-tab"
|
||||
aria-selected={tab === t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'account' ? (
|
||||
<AccountTab account={account} onLogout={onLogout} onCredentialsChanged={onCredentialsChanged} />
|
||||
) : (
|
||||
<Panel title={TABS.find((t) => t.key === tab)?.label}>
|
||||
<EndpointGap path={GAPS[tab].path} what={GAPS[tab].what} />
|
||||
</Panel>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function AccountTab({ account, onLogout, onCredentialsChanged }: {
|
||||
account: Account
|
||||
onLogout: () => void
|
||||
onCredentialsChanged: (username: string) => void
|
||||
}) {
|
||||
const [username, setUsername] = useState(account.username)
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmation, setConfirmation] = useState('')
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [formError, setFormError] = useState('')
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
api.updateAccount({
|
||||
current_password: currentPassword,
|
||||
username: username.trim(),
|
||||
...(newPassword ? { new_password: newPassword } : {}),
|
||||
}),
|
||||
onSuccess: (updated) => onCredentialsChanged(updated.username),
|
||||
})
|
||||
|
||||
const changed = username.trim() !== account.username || !!newPassword
|
||||
const longEnough = newPassword.length >= 10
|
||||
const withinLimit = new TextEncoder().encode(newPassword).length <= 72
|
||||
const matches = newPassword === confirmation
|
||||
|
||||
const submit = (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
setFormError('')
|
||||
if (!username.trim()) return setFormError('Username cannot be empty.')
|
||||
if (!changed) return setFormError('Change the username or enter a new password first.')
|
||||
if (!currentPassword) return setFormError('Enter your current password to authorize this change.')
|
||||
if (newPassword && (!longEnough || !withinLimit || !matches)) {
|
||||
return setFormError(matches ? 'Use a password between 10 and 72 bytes.' : 'The new passwords do not match.')
|
||||
}
|
||||
mutation.mutate()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="set-account">
|
||||
<Panel title="Sign-in credentials">
|
||||
<form className="panel-body set-form" onSubmit={submit} noValidate>
|
||||
<p className="set-note">
|
||||
Changing either field signs out every browser using this account. Your current password authorizes the change and is never stored by the console.
|
||||
</p>
|
||||
|
||||
<div className="set-field">
|
||||
<label className="label" htmlFor="set-username">Username</label>
|
||||
<input
|
||||
id="set-username"
|
||||
className="set-input"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => { setUsername(e.target.value); setFormError('') }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="set-split">
|
||||
<div className="set-field">
|
||||
<label className="label" htmlFor="set-new">New password</label>
|
||||
<div className="set-reveal">
|
||||
<input
|
||||
id="set-new"
|
||||
className="set-input"
|
||||
type={visible ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
placeholder="Leave blank to keep it"
|
||||
value={newPassword}
|
||||
onChange={(e) => { setNewPassword(e.target.value); setFormError('') }}
|
||||
/>
|
||||
<button type="button" className="set-reveal-btn" onClick={() => setVisible((v) => !v)}>
|
||||
{visible ? 'Hide' : 'Show'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="set-field">
|
||||
<label className="label" htmlFor="set-confirm">Confirm new password</label>
|
||||
<input
|
||||
id="set-confirm"
|
||||
className="set-input"
|
||||
type={visible ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
placeholder="Repeat new password"
|
||||
value={confirmation}
|
||||
onChange={(e) => { setConfirmation(e.target.value); setFormError('') }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{newPassword && (
|
||||
<div className="set-rules" aria-live="polite">
|
||||
<span data-met={longEnough}><Icon name="check" size={13} /> 10 characters or more</span>
|
||||
<span data-met={withinLimit}><Icon name="check" size={13} /> 72 bytes or fewer</span>
|
||||
<span data-met={matches && !!confirmation}><Icon name="check" size={13} /> Passwords match</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="set-field">
|
||||
<label className="label" htmlFor="set-current">Current password</label>
|
||||
<input
|
||||
id="set-current"
|
||||
className="set-input"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => { setCurrentPassword(e.target.value); setFormError('') }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(formError || mutation.error) && (
|
||||
<p className="set-error" role="alert">
|
||||
<Icon name="alert" size={15} /> {formError || message(mutation.error)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="set-actions">
|
||||
<span className="set-note">You sign in again after saving.</span>
|
||||
<button className="btn" data-variant="primary" type="submit" disabled={mutation.isPending || !changed}>
|
||||
{mutation.isPending ? 'Saving' : 'Save credentials'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Panel>
|
||||
|
||||
<Panel title="This account" trailing={<Chip tone="accent">operator</Chip>}>
|
||||
<div className="panel-body set-facts">
|
||||
<div><span className="label">Username</span><span>{account.username}</span></div>
|
||||
<div><span className="label">Created</span><M>{stamp(account.created_at)}</M></div>
|
||||
<div><span className="label">Credentials updated</span><M>{stamp(account.updated_at)}</M></div>
|
||||
<div><span className="label">Credential store</span><M>bcrypt · orchestra data volume</M></div>
|
||||
<button className="btn set-signout" type="button" onClick={onLogout}>Sign out</button>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
/* Task detail. Only what orchestra.css does not already carry: the two-column
|
||||
split, the agent-claim treatment, and the muted superseded register. */
|
||||
|
||||
.td-top {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.td-top .page-head {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.td-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.td-ids {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px 18px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: -12px;
|
||||
}
|
||||
|
||||
.td-columns {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 5fr) minmax(320px, 7fr);
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.td-doing > .label {
|
||||
display: block;
|
||||
margin: 18px 0 6px;
|
||||
}
|
||||
|
||||
.td-doing > .label:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.td-prose {
|
||||
margin: 0;
|
||||
color: var(--text-hi);
|
||||
}
|
||||
|
||||
.td-muted {
|
||||
color: var(--text-lo);
|
||||
font-size: 13px;
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
|
||||
.td-list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
color: var(--text-mid);
|
||||
}
|
||||
|
||||
.td-more > summary {
|
||||
cursor: pointer;
|
||||
color: var(--text-lo);
|
||||
font-size: 13px;
|
||||
margin-top: 8px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.td-more > summary::before {
|
||||
content: '▸ ';
|
||||
color: var(--text-lo);
|
||||
}
|
||||
|
||||
.td-more[open] > summary::before {
|
||||
content: '▾ ';
|
||||
}
|
||||
|
||||
.td-more[open] > summary {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Operator authority: the one class of content that carries the accent. */
|
||||
.td-decisions {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.td-decisions li {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--accent-dim);
|
||||
border-left: 2px solid var(--accent);
|
||||
}
|
||||
|
||||
.td-kind {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--accent-hi);
|
||||
}
|
||||
|
||||
.td-subject {
|
||||
color: var(--text-mid);
|
||||
}
|
||||
|
||||
.td-value {
|
||||
color: var(--text-hi);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Superseded: still readable, never dominant, and stripped of the accent. */
|
||||
.td-superseded .td-decisions li {
|
||||
background: var(--bg-2);
|
||||
border-left-color: var(--line-hi);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.td-superseded .td-kind {
|
||||
color: var(--text-lo);
|
||||
}
|
||||
|
||||
.td-superseded .td-value {
|
||||
color: var(--text-mid);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.td-exec {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.td-exec .stat {
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.td-exec .stat-value {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
/* Agent claims read as quotation, not as state: dashed rule, machine text,
|
||||
and a tag that names the source. */
|
||||
.td-claim-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
margin: 18px 0 6px;
|
||||
}
|
||||
|
||||
.td-claim-tag {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
color: var(--text-lo);
|
||||
}
|
||||
|
||||
.td-claim {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-2);
|
||||
border: 1px dashed var(--line-hi);
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
|
||||
.td-claim > .mono:first-child {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.td-claim-at {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.td-approval {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--accent-line);
|
||||
background: var(--accent-dim);
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
|
||||
.td-approval .td-prose {
|
||||
margin: 4px 0 8px;
|
||||
}
|
||||
|
||||
.td-approval-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.td-approval-row .mono {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.td-links {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin: 16px 0 4px;
|
||||
}
|
||||
|
||||
.td-flow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.td-flow .phases {
|
||||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.td-entries {
|
||||
display: flex;
|
||||
gap: 8px 20px;
|
||||
flex-wrap: wrap;
|
||||
color: var(--text-lo);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.td-entry-phase {
|
||||
color: var(--text-mid);
|
||||
}
|
||||
|
||||
/* Actions menu: a native disclosure, not a popover library. */
|
||||
.td-menu {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.td-menu > summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.td-menu > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.td-menu-body {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 6px);
|
||||
z-index: 10;
|
||||
min-width: 250px;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line-hi);
|
||||
border-radius: var(--r-md);
|
||||
box-shadow: var(--shadow-soft);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.td-menu-body .row {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.td-menu-body .row:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.td-menu-body .row:disabled:hover {
|
||||
background: none;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.td-columns {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { api } from '../api/client'
|
||||
import type { Action, Detail, Event, Task } from '../api/types'
|
||||
import { Chip, EndpointGap, M, Panel, PhasePath } from '../components/Primitives'
|
||||
import { Icon } from '../components/Icon'
|
||||
import './TaskDetail.css'
|
||||
|
||||
/** work_phase is on the wire (domain.Task) but not yet in api/types.ts, which
|
||||
* another screen owns. Read it through a local widening rather than editing a
|
||||
* shared file out from under someone. */
|
||||
type Phased = Task & { work_phase?: string; last_lease_epoch?: string }
|
||||
|
||||
const time = (iso?: string) => (iso ? new Date(iso).toISOString().slice(11, 19) : '—')
|
||||
|
||||
function ago(iso?: string) {
|
||||
if (!iso) return '—'
|
||||
const s = Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 1000))
|
||||
if (s < 60) return `${s}s ago`
|
||||
if (s < 3600) return `${Math.floor(s / 60)}m ago`
|
||||
return `${Math.floor(s / 3600)}h ago`
|
||||
}
|
||||
|
||||
/** ponytail: recomputed on each 5s poll rather than ticking per second. The
|
||||
* operator reads a lease in minutes; a second-accurate countdown would need a
|
||||
* timer for no decision it changes. */
|
||||
function remaining(iso?: string) {
|
||||
if (!iso) return undefined
|
||||
const s = Math.round((Date.parse(iso) - Date.now()) / 1000)
|
||||
if (s <= 0) return 'expired'
|
||||
return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
type Decision = {
|
||||
id: string
|
||||
kind: string
|
||||
subject: string
|
||||
value: string
|
||||
at: string
|
||||
standing: boolean
|
||||
}
|
||||
|
||||
/** The standing authority for this task, reduced from its own log. Mirrors
|
||||
* domain.ReduceIntent: a decision is retired only by being named, never by a
|
||||
* newer decision on the same subject. Superseded records are kept so the
|
||||
* screen can show them muted instead of pretending they never existed. */
|
||||
function decisionsOf(events: Event[]): Decision[] {
|
||||
const out: Decision[] = []
|
||||
const retired = new Set<string>()
|
||||
for (const e of events) {
|
||||
const p = (e.payload ?? {}) as {
|
||||
decision_id?: string
|
||||
kind?: string
|
||||
subject?: string
|
||||
value?: string
|
||||
supersedes?: string[]
|
||||
}
|
||||
if (!p.decision_id) continue
|
||||
if (e.type === 'HumanDecisionRecorded') {
|
||||
out.push({
|
||||
id: p.decision_id,
|
||||
kind: p.kind ?? 'decision',
|
||||
subject: p.subject ?? '',
|
||||
value: p.value ?? '',
|
||||
at: e.at,
|
||||
standing: true,
|
||||
})
|
||||
p.supersedes?.forEach((id) => retired.add(id))
|
||||
} else if (e.type === 'HumanDecisionSuperseded') {
|
||||
retired.add(p.decision_id)
|
||||
}
|
||||
}
|
||||
return out.map((d) => ({ ...d, standing: !retired.has(d.id) }))
|
||||
}
|
||||
|
||||
/** When each phase was entered, from the events Orchestra itself appended.
|
||||
* Verified state, not a claim. */
|
||||
function phaseEntries(events: Event[]) {
|
||||
return events
|
||||
.filter((e) => e.type === 'WorkPhaseChanged')
|
||||
.map((e) => ({ phase: (e.payload as { phase?: string })?.phase ?? '', at: e.at }))
|
||||
.filter((p) => p.phase)
|
||||
}
|
||||
|
||||
/** The last thing the pane actually showed. Agent-authored text: it is
|
||||
* rendered as a claim, never as orchestra state. */
|
||||
function lastLine(text?: string) {
|
||||
const lines = (text ?? '').split('\n').map((l) => l.trimEnd()).filter((l) => l.trim())
|
||||
return lines[lines.length - 1]
|
||||
}
|
||||
|
||||
function stateTone(task: Task) {
|
||||
if (task.state === 'blocked' || task.state === 'failed' || task.state === 'needs_attention') {
|
||||
return 'fault' as const
|
||||
}
|
||||
if (task.state === 'completed') return 'done' as const
|
||||
if (task.state === 'leased') return 'accent' as const
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function TaskDetail() {
|
||||
const { id = '' } = useParams()
|
||||
const client = useQueryClient()
|
||||
const query = useQuery({
|
||||
queryKey: ['task', id],
|
||||
queryFn: () => api.detail(id),
|
||||
refetchInterval: 5000,
|
||||
})
|
||||
const act = useMutation({
|
||||
mutationFn: (action: string) => api.action(id, action),
|
||||
onSuccess: () => client.invalidateQueries({ queryKey: ['task', id] }),
|
||||
})
|
||||
|
||||
if (query.isError) {
|
||||
return (
|
||||
<main className="page">
|
||||
<div className="page-head">
|
||||
<h1>Task</h1>
|
||||
</div>
|
||||
<Panel>
|
||||
<div className="panel-body">
|
||||
<p className="gap-note">{String((query.error as Error).message)}</p>
|
||||
</div>
|
||||
</Panel>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
if (!query.data) {
|
||||
return (
|
||||
<main className="page">
|
||||
<p className="mono">reading /v1/ui/tasks/{id}…</p>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
const detail: Detail = query.data
|
||||
const task = detail.task as Phased
|
||||
const session = detail.session
|
||||
const capture = session?.capture
|
||||
const approval = session?.pending_approval
|
||||
const decisions = decisionsOf(detail.events)
|
||||
const standing = decisions.filter((d) => d.standing)
|
||||
const constraints = standing.filter((d) => d.kind === 'constraint')
|
||||
const effective = standing.filter((d) => d.kind !== 'constraint')
|
||||
const superseded = decisions.filter((d) => !d.standing)
|
||||
const acceptance = task.acceptance ?? []
|
||||
const shown = acceptance.slice(0, 4)
|
||||
const lease = session?.lease_until ?? task.lease?.until
|
||||
const activity = lastLine(capture?.text)
|
||||
const entries = phaseEntries(detail.events)
|
||||
|
||||
return (
|
||||
<main className="page">
|
||||
{/* ── top area ──────────────────────────────────────────────────── */}
|
||||
<header className="td-top">
|
||||
<div className="page-head">
|
||||
<h1>{task.title || task.external_id || 'untitled task'}</h1>
|
||||
<Chip tone="accent">{task.work_phase || 'frame'} phase</Chip>
|
||||
<Chip tone={stateTone(task)}>{task.state}</Chip>
|
||||
</div>
|
||||
<div className="td-actions">
|
||||
<ActionsMenu actions={detail.actions} pending={act.isPending} run={act.mutate} />
|
||||
<button className="btn" data-variant="primary" disabled title="no endpoint yet">
|
||||
<Icon name="decision" size={16} />
|
||||
Steer / Correct
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="td-ids">
|
||||
<span className="label">task id</span>
|
||||
<M>{task.id}</M>
|
||||
<span className="label">epoch</span>
|
||||
<M>{task.lease?.epoch || task.last_lease_epoch || '—'}</M>
|
||||
<span className="label">project</span>
|
||||
<M>{task.project}</M>
|
||||
<span className="label">version</span>
|
||||
<M>{task.version}</M>
|
||||
</div>
|
||||
|
||||
{act.isError && <p className="gap-note">{String((act.error as Error).message)}</p>}
|
||||
|
||||
<div className="td-columns">
|
||||
{/* ── what we're doing ────────────────────────────────────────── */}
|
||||
<Panel title="What we're doing">
|
||||
<div className="panel-body td-doing">
|
||||
<h3 className="label">goal</h3>
|
||||
<p className="td-prose">{task.description || task.title || 'No goal recorded.'}</p>
|
||||
|
||||
<h3 className="label">
|
||||
acceptance <M>{acceptance.length}</M>
|
||||
</h3>
|
||||
{acceptance.length === 0 ? (
|
||||
<p className="td-muted">No acceptance criteria on the contract.</p>
|
||||
) : (
|
||||
<>
|
||||
<ul className="td-list">
|
||||
{shown.map((c) => (
|
||||
<li key={c}>{c}</li>
|
||||
))}
|
||||
</ul>
|
||||
{acceptance.length > shown.length && (
|
||||
<details className="td-more">
|
||||
<summary>
|
||||
<M>+{acceptance.length - shown.length}</M> more criteria
|
||||
</summary>
|
||||
<ul className="td-list">
|
||||
{acceptance.slice(shown.length).map((c) => (
|
||||
<li key={c}>{c}</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{task.quality_gate && (
|
||||
<p className="td-muted">
|
||||
<span className="label">quality gate</span> <M>{task.quality_gate}</M>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{effective.length > 0 && (
|
||||
<>
|
||||
<h3 className="label">effective human decisions</h3>
|
||||
<ul className="td-decisions">
|
||||
{effective.map((d) => (
|
||||
<li key={d.id}>
|
||||
<span className="td-kind">{d.kind}</span>
|
||||
<span className="td-subject">{d.subject}</span>
|
||||
<span className="td-value">{d.value}</span>
|
||||
<M>{time(d.at)}</M>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
{constraints.length > 0 && (
|
||||
<>
|
||||
<h3 className="label">active constraints</h3>
|
||||
<ul className="td-decisions">
|
||||
{constraints.map((d) => (
|
||||
<li key={d.id}>
|
||||
<span className="td-kind">constraint</span>
|
||||
<span className="td-subject">{d.subject}</span>
|
||||
<span className="td-value">{d.value}</span>
|
||||
<M>{time(d.at)}</M>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
{superseded.length > 0 && (
|
||||
<details className="td-more td-superseded">
|
||||
<summary>
|
||||
<M>{superseded.length}</M> superseded {superseded.length === 1 ? 'decision' : 'decisions'}
|
||||
</summary>
|
||||
<ul className="td-decisions">
|
||||
{superseded.map((d) => (
|
||||
<li key={d.id}>
|
||||
<span className="td-kind">{d.kind}</span>
|
||||
<span className="td-subject">{d.subject}</span>
|
||||
<span className="td-value">{d.value}</span>
|
||||
<M>{time(d.at)}</M>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{/* ── live execution ──────────────────────────────────────────── */}
|
||||
<Panel
|
||||
title="Live execution"
|
||||
trailing={
|
||||
session?.agent_status ? (
|
||||
<Chip tone={session.agent_status === 'blocked' ? 'fault' : 'accent'}>
|
||||
{session.agent_status}
|
||||
</Chip>
|
||||
) : (
|
||||
<span className="mono">no session</span>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="panel-body">
|
||||
<div className="td-exec">
|
||||
<div className="stat">
|
||||
<span className="label">worker / harness</span>
|
||||
<M>{session?.harness_id || task.lease?.harness_id || task.last_harness_id || '—'}</M>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="label">pane</span>
|
||||
<M>{session?.pane_id || task.last_pane_id || '—'}</M>
|
||||
<span className="td-muted">
|
||||
<M>{task.pane_state || 'unknown'}</M>
|
||||
</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="label">lease remaining</span>
|
||||
<span className="stat-value">{remaining(lease) ?? '—'}</span>
|
||||
<span className="td-muted">
|
||||
until <M>{time(lease)}</M>
|
||||
</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="label">last progress</span>
|
||||
<span className="stat-value">{ago(capture?.at)}</span>
|
||||
<span className="td-muted">
|
||||
capture rev <M>{capture?.revision ?? '—'}</M>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Context occupancy is the one number this panel is supposed to
|
||||
carry and the read model does not produce it. Say so rather
|
||||
than compute a plausible-looking figure. */}
|
||||
<EndpointGap
|
||||
path="/v1/ui/tasks/:id (no context accounting on session)"
|
||||
what="Context occupancy — tokens used against the harness window — has no field in the read model, so no meter is drawn."
|
||||
/>
|
||||
|
||||
<h3 className="label td-claim-head">
|
||||
current activity
|
||||
<span className="td-claim-tag">
|
||||
agent-supplied · pane capture{capture?.source ? ` · ${capture.source}` : ''}
|
||||
</span>
|
||||
</h3>
|
||||
{activity ? (
|
||||
<p className="td-claim">
|
||||
<M>{activity}</M>
|
||||
<span className="td-claim-at">
|
||||
<M>{time(capture?.at)}</M>
|
||||
</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="td-muted">No capture from the owning worker.</p>
|
||||
)}
|
||||
|
||||
{approval && (
|
||||
<div className="td-approval">
|
||||
<span className="label">pending approval</span>
|
||||
<p className="td-prose">{approval.summary}</p>
|
||||
{approval.command && <M>{approval.command}</M>}
|
||||
<div className="td-approval-row">
|
||||
<span className="mono">
|
||||
{approval.pane_id} · rev {approval.capture_revision}
|
||||
</span>
|
||||
<button
|
||||
className="btn"
|
||||
data-variant="primary"
|
||||
disabled={act.isPending}
|
||||
onClick={() => act.mutate('grant_approval')}
|
||||
>
|
||||
Grant
|
||||
</button>
|
||||
<button className="btn" disabled={act.isPending} onClick={() => act.mutate('deny_approval')}>
|
||||
Deny
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="td-links">
|
||||
<Link className="btn" to={`/tasks/${task.id}/terminal`}>
|
||||
<Icon name="terminal" size={16} />
|
||||
Open live pane
|
||||
</Link>
|
||||
{task.handoff_ref && (
|
||||
<a className="btn" href={`/v1/ui/artifacts/${task.handoff_ref}`}>
|
||||
<Icon name="file" size={16} />
|
||||
Handoff
|
||||
</a>
|
||||
)}
|
||||
{detail.report_ref && (
|
||||
<a className="btn" href={`/v1/ui/artifacts/${detail.report_ref}`}>
|
||||
<Icon name="file" size={16} />
|
||||
Report
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<EndpointGap
|
||||
path="/v1/ui/tasks/:id/{logs,launch_context,diff}"
|
||||
what="Worker logs, launch context and git diff have no endpoint, so those links are not offered."
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
{/* ── workflow ──────────────────────────────────────────────────── */}
|
||||
<Panel title="Workflow" trailing={<span className="mono">ace-fca</span>}>
|
||||
<div className="panel-body td-flow">
|
||||
<PhasePath current={task.work_phase} />
|
||||
{entries.length > 0 ? (
|
||||
<div className="td-entries">
|
||||
{entries.map((e) => (
|
||||
<span key={`${e.phase}-${e.at}`}>
|
||||
<span className="td-entry-phase">{e.phase}</span> entered <M>{time(e.at)}</M>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="td-muted">
|
||||
No WorkPhaseChanged event in this task's log — the path shows the current phase only.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
/** Operator actions, exactly as the server declares them. An action that
|
||||
* needs input the console cannot collect stays disabled and says what it
|
||||
* wants — offering a button that will 409 is the dishonest option. */
|
||||
function ActionsMenu({
|
||||
actions,
|
||||
pending,
|
||||
run,
|
||||
}: {
|
||||
actions: Action[]
|
||||
pending: boolean
|
||||
run: (action: string) => void
|
||||
}) {
|
||||
return (
|
||||
<details className="td-menu">
|
||||
<summary className="btn">
|
||||
Actions <Icon name="chevron-right" size={16} />
|
||||
</summary>
|
||||
<div className="td-menu-body">
|
||||
{actions.map((a) => {
|
||||
const blocked = !a.enabled || (a.needs?.length ?? 0) > 0
|
||||
return (
|
||||
<button
|
||||
key={a.id}
|
||||
className="row"
|
||||
disabled={blocked || pending}
|
||||
onClick={() => run(a.id)}
|
||||
title={a.needs?.length ? `needs ${a.needs.join(', ')}` : a.reason}
|
||||
>
|
||||
<span className="row-main">
|
||||
<span className="row-title">{a.id}</span>
|
||||
<span className="row-sub">
|
||||
{a.needs?.length ? `needs ${a.needs.join(', ')}` : a.enabled ? a.reason || '' : a.reason || 'unavailable'}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
/* Tasks list. Only what the shared shell does not already carry: the row
|
||||
column geometry, the group disclosure, and the filter bar. */
|
||||
|
||||
.tasks-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tasks-tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tasks-tab {
|
||||
background: none;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
padding: 7px 11px;
|
||||
color: var(--text-mid);
|
||||
cursor: pointer;
|
||||
transition: color var(--fast) var(--ease), border-color var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.tasks-tab span {
|
||||
font-size: 12px;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.tasks-tab:hover {
|
||||
color: var(--text-hi);
|
||||
}
|
||||
|
||||
.tasks-tab[aria-selected='true'] {
|
||||
color: var(--text-hi);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.tasks-filters {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-left: auto;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tasks-filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.tasks-filter select {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-machine);
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 5px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tasks-filter select:hover {
|
||||
border-color: var(--line-hi);
|
||||
}
|
||||
|
||||
.page-head .count {
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
/* ── group disclosure ────────────────────────────────────────────────── */
|
||||
|
||||
.tasks-group > summary {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.tasks-group > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tasks-group:not([open]) > summary {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.tasks-group > summary > svg:first-child {
|
||||
color: var(--text-lo);
|
||||
}
|
||||
|
||||
.tasks-group > summary > svg:last-child {
|
||||
margin-left: auto;
|
||||
color: var(--text-lo);
|
||||
transform: rotate(90deg);
|
||||
transition: transform var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.tasks-group[open] > summary > svg:last-child {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
/* ── row geometry ────────────────────────────────────────────────────── */
|
||||
|
||||
.task-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.task-row > svg:last-child {
|
||||
color: var(--text-lo);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.task-row-main {
|
||||
flex: 1 1 220px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.task-row-main .row-title,
|
||||
.task-row-main .row-sub {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-row-phase {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.task-row-phase-name {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* The compact path carries no labels, so the connector no longer needs to
|
||||
sit above a name. */
|
||||
.tasks-phases .phase-step {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tasks-phases .phase-link {
|
||||
width: 14px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.task-row-worker {
|
||||
flex: none;
|
||||
width: 150px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.task-row-when {
|
||||
flex: none;
|
||||
width: 118px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.task-row-status {
|
||||
flex: none;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
min-width: 130px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.task-row-phase,
|
||||
.task-row-when {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.task-row-worker {
|
||||
width: 110px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.task-row-worker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tasks-filters {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { api } from '../api/client'
|
||||
import type { Overview, Task } from '../api/types'
|
||||
import { Chip, Empty, EndpointGap, M, PHASES, PhasePath } from '../components/Primitives'
|
||||
import { Icon } from '../components/Icon'
|
||||
import './Tasks.css'
|
||||
|
||||
type GroupKey = 'attention' | 'running' | 'waiting' | 'completed' | 'failed'
|
||||
|
||||
const GROUPS: { key: GroupKey; title: string; icon: string; open: boolean }[] = [
|
||||
{ key: 'attention', title: 'Needs attention', icon: 'alert', open: true },
|
||||
{ key: 'running', title: 'Running', icon: 'play', open: true },
|
||||
{ key: 'waiting', title: 'Waiting / queued', icon: 'queue', open: true },
|
||||
{ key: 'completed', title: 'Completed', icon: 'check', open: false },
|
||||
{ key: 'failed', title: 'Failed', icon: 'x', open: false },
|
||||
]
|
||||
|
||||
function groupOf(task: Task): GroupKey {
|
||||
switch (task.state) {
|
||||
case 'needs_attention':
|
||||
case 'blocked':
|
||||
return 'attention'
|
||||
case 'leased':
|
||||
return 'running'
|
||||
case 'completed':
|
||||
return 'completed'
|
||||
case 'failed':
|
||||
return 'failed'
|
||||
default:
|
||||
return 'waiting'
|
||||
}
|
||||
}
|
||||
|
||||
/** The harness holding the lease, or the last one that did. Nothing is
|
||||
* invented: a task that never ran reports no worker. */
|
||||
function harnessOf(task: Task) {
|
||||
return task.lease?.harness_id || task.last_harness_id || ''
|
||||
}
|
||||
|
||||
function stateTone(task: Task) {
|
||||
if (task.state === 'failed' || task.state === 'blocked') return 'fault' as const
|
||||
if (task.state === 'needs_attention') return 'warn' as const
|
||||
if (task.state === 'completed') return 'done' as const
|
||||
if (task.state === 'leased') return 'accent' as const
|
||||
return undefined
|
||||
}
|
||||
|
||||
function ago(at?: string) {
|
||||
if (!at) return ''
|
||||
const ms = Date.now() - Date.parse(at)
|
||||
if (!Number.isFinite(ms)) return ''
|
||||
return `${span(ms)} ago`
|
||||
}
|
||||
|
||||
function span(ms: number) {
|
||||
const s = Math.max(0, Math.round(ms / 1000))
|
||||
if (s < 60) return `${s}s`
|
||||
if (s < 3600) return `${Math.round(s / 60)}m`
|
||||
if (s < 86400) return `${Math.round(s / 3600)}h`
|
||||
return `${Math.round(s / 86400)}d`
|
||||
}
|
||||
|
||||
/** The one time value a task actually carries per state. There is no
|
||||
* per-task updated_at on the overview, so this never claims one. */
|
||||
function timing(task: Task): { label: string; value: string } | undefined {
|
||||
if (task.lease?.until) {
|
||||
const left = Date.parse(task.lease.until) - Date.now()
|
||||
return left > 0
|
||||
? { label: 'lease', value: `${span(left)} left` }
|
||||
: { label: 'lease', value: `expired ${span(-left)} ago` }
|
||||
}
|
||||
if (task.blocked_at) return { label: 'blocked', value: ago(task.blocked_at) }
|
||||
if (task.next_retry_at) {
|
||||
const left = Date.parse(task.next_retry_at) - Date.now()
|
||||
return { label: 'retry', value: left > 0 ? `in ${span(left)}` : 'due' }
|
||||
}
|
||||
if (task.due) return { label: 'due', value: task.due.slice(0, 10) }
|
||||
return undefined
|
||||
}
|
||||
|
||||
function unique(values: (string | undefined)[]) {
|
||||
return [...new Set(values.filter((value): value is string => !!value))].sort()
|
||||
}
|
||||
|
||||
function Select({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
options: string[]
|
||||
onChange: (next: string) => void
|
||||
}) {
|
||||
return (
|
||||
<label className="tasks-filter">
|
||||
<span className="label">{label}</span>
|
||||
<select value={value} onChange={(event) => onChange(event.target.value)}>
|
||||
<option value="">any</option>
|
||||
{options.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ task, agentStatus }: { task: Task; agentStatus?: string }) {
|
||||
const harness = harnessOf(task)
|
||||
const when = timing(task)
|
||||
return (
|
||||
<Link className="row task-row" to={`/tasks/${task.id}`}>
|
||||
<span className="task-row-main">
|
||||
<span className="row-title">{task.title || task.external_id || 'untitled task'}</span>
|
||||
<span className="row-sub">
|
||||
{task.project} · <M>{task.external_id || task.id}</M>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className="task-row-phase tasks-phases">
|
||||
<PhasePath current={task.lifecycle_phase} labels={false} />
|
||||
<span className="task-row-phase-name mono">{task.lifecycle_phase || '—'}</span>
|
||||
</span>
|
||||
|
||||
<span className="task-row-worker">
|
||||
{harness ? (
|
||||
<>
|
||||
<M>{harness}</M>
|
||||
{task.last_pane_id && <span className="row-sub mono">{task.last_pane_id}</span>}
|
||||
</>
|
||||
) : (
|
||||
<span className="row-sub">unassigned</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<span className="task-row-when">
|
||||
{when && (
|
||||
<>
|
||||
<span className="label">{when.label}</span>
|
||||
<M>{when.value}</M>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<span className="task-row-status">
|
||||
{agentStatus && <Chip>{agentStatus}</Chip>}
|
||||
<Chip tone={stateTone(task)}>{task.block_reason || task.state}</Chip>
|
||||
</span>
|
||||
|
||||
<Icon name="chevron-right" size={16} />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function Group({
|
||||
title,
|
||||
icon,
|
||||
open,
|
||||
tasks,
|
||||
statusFor,
|
||||
}: {
|
||||
title: string
|
||||
icon: string
|
||||
open: boolean
|
||||
tasks: Task[]
|
||||
statusFor: (task: Task) => string | undefined
|
||||
}) {
|
||||
return (
|
||||
<details className="panel tasks-group" open={open && tasks.length > 0}>
|
||||
<summary className="panel-head">
|
||||
<Icon name={icon} size={17} />
|
||||
<h2>{title}</h2>
|
||||
<span className="count">{tasks.length}</span>
|
||||
<Icon name="chevron-right" size={16} />
|
||||
</summary>
|
||||
{tasks.length ? (
|
||||
<div className="rows">
|
||||
{tasks.map((task) => (
|
||||
<Row key={task.id} task={task} agentStatus={statusFor(task)} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Empty title="Nothing here" />
|
||||
)}
|
||||
</details>
|
||||
)
|
||||
}
|
||||
|
||||
export function Tasks() {
|
||||
const { data, isLoading, error } = useQuery<Overview>({
|
||||
queryKey: ['overview'],
|
||||
queryFn: api.overview,
|
||||
refetchInterval: 5000,
|
||||
})
|
||||
|
||||
const [project, setProject] = useState('')
|
||||
const [phase, setPhase] = useState('')
|
||||
const [worker, setWorker] = useState('')
|
||||
const [attention, setAttention] = useState('')
|
||||
const [group, setGroup] = useState<GroupKey | ''>('')
|
||||
|
||||
const tasks = useMemo(() => data?.tasks ?? [], [data])
|
||||
|
||||
/** Agent status comes from the live session, matched by pane. It is an
|
||||
* agent claim, so it stays a separate chip from orchestra's own state. */
|
||||
const statusFor = useMemo(() => {
|
||||
const byPane = new Map((data?.sessions ?? []).map((s) => [s.pane_id, s]))
|
||||
return (task: Task) =>
|
||||
task.state === 'leased' ? byPane.get(task.last_pane_id)?.agent_status : undefined
|
||||
}, [data])
|
||||
|
||||
const filtered = tasks.filter((task) => {
|
||||
if (project && task.project !== project) return false
|
||||
if (phase && task.lifecycle_phase !== phase) return false
|
||||
if (worker && harnessOf(task) !== worker) return false
|
||||
if (attention === 'needs' && groupOf(task) !== 'attention') return false
|
||||
if (attention === 'clear' && groupOf(task) === 'attention') return false
|
||||
if (attention && attention !== 'needs' && attention !== 'clear' && task.block_reason !== attention)
|
||||
return false
|
||||
return true
|
||||
})
|
||||
|
||||
const counts = (key: GroupKey) => filtered.filter((task) => groupOf(task) === key).length
|
||||
const shown = GROUPS.filter((g) => !group || g.key === group)
|
||||
|
||||
return (
|
||||
<main className="page">
|
||||
<div className="page-head">
|
||||
<h1>Tasks</h1>
|
||||
<p>All work under orchestration.</p>
|
||||
<span className="count mono">{tasks.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="tasks-controls">
|
||||
<div className="tasks-tabs" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={group === ''}
|
||||
className="tasks-tab"
|
||||
onClick={() => setGroup('')}
|
||||
>
|
||||
All <span className="mono">{filtered.length}</span>
|
||||
</button>
|
||||
{GROUPS.map((g) => (
|
||||
<button
|
||||
key={g.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={group === g.key}
|
||||
className="tasks-tab"
|
||||
onClick={() => setGroup(group === g.key ? '' : g.key)}
|
||||
>
|
||||
{g.title} <span className="mono">{counts(g.key)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="tasks-filters">
|
||||
<Select
|
||||
label="Project"
|
||||
value={project}
|
||||
options={unique(tasks.map((task) => task.project))}
|
||||
onChange={setProject}
|
||||
/>
|
||||
<Select label="Phase" value={phase} options={[...PHASES]} onChange={setPhase} />
|
||||
<Select
|
||||
label="Worker"
|
||||
value={worker}
|
||||
options={unique(tasks.map(harnessOf))}
|
||||
onChange={setWorker}
|
||||
/>
|
||||
<Select
|
||||
label="Attention"
|
||||
value={attention}
|
||||
options={['needs', 'clear', ...unique(tasks.map((task) => task.block_reason))]}
|
||||
onChange={setAttention}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="panel panel-body">
|
||||
<p className="gap-note">/v1/ui/overview failed: {(error as Error).message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && !data && (
|
||||
<div className="panel panel-body">
|
||||
<p className="gap-note">loading /v1/ui/overview…</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && !tasks.length && (
|
||||
<div className="panel">
|
||||
<Empty title="No tasks yet">
|
||||
<p>Orchestra has no work on record. Ingest a task to start.</p>
|
||||
</Empty>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data &&
|
||||
tasks.length > 0 &&
|
||||
shown.map((g) => (
|
||||
<Group
|
||||
key={g.key}
|
||||
title={g.title}
|
||||
icon={g.icon}
|
||||
open={g.open || !!group}
|
||||
tasks={filtered.filter((task) => groupOf(task) === g.key)}
|
||||
statusFor={statusFor}
|
||||
/>
|
||||
))}
|
||||
|
||||
{data && tasks.length > 0 && (
|
||||
<div className="panel">
|
||||
<EndpointGap
|
||||
path="/v1/ui/overview carries no per-task progress, ETA or context occupancy"
|
||||
what="Rows show lease, block and retry times because those are the only per-task clocks orchestra records. Progress bars and context meters are left out rather than estimated."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
/* The live pane. Tokens only — nothing here redefines the ethos ramp. */
|
||||
|
||||
.term {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.term-head {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.term-identity {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.term-sep {
|
||||
color: var(--text-lo);
|
||||
}
|
||||
|
||||
.term-controls {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.term-mode {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.term-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* The frame. Depth from a hairline and a surface step, never from glass. */
|
||||
.term-frame {
|
||||
background: var(--bg-0);
|
||||
border: 1px solid var(--line-hi);
|
||||
border-radius: var(--r-md);
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.term-frame[data-lost='true'] {
|
||||
border-color: rgba(224, 108, 96, 0.42);
|
||||
}
|
||||
|
||||
.term-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-2);
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.term-bar-right {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* Exact pane characters: no wrapping, no reflow, no prettifying. */
|
||||
.term-text {
|
||||
margin: 0;
|
||||
padding: 14px 16px;
|
||||
height: min(62vh, 720px);
|
||||
overflow: auto;
|
||||
white-space: pre;
|
||||
tab-size: 8;
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
color: #cfc6b4;
|
||||
background: var(--bg-0);
|
||||
}
|
||||
|
||||
.term-text:focus-visible {
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.term-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.term-foot:not(:empty) {
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--bg-1);
|
||||
}
|
||||
|
||||
.term-jump {
|
||||
height: 28px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.term-warn {
|
||||
font-size: 12px;
|
||||
color: #e06c60;
|
||||
}
|
||||
|
||||
.term-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.term-fields,
|
||||
.term-lost {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.term-lost p {
|
||||
margin: 0 0 2px;
|
||||
color: var(--text-mid);
|
||||
}
|
||||
|
||||
.term-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.term-field-value {
|
||||
font-size: 12px;
|
||||
color: var(--text-mid);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.term-health {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.term-link:hover {
|
||||
color: var(--accent-hi);
|
||||
}
|
||||
|
||||
.term-resubmit p {
|
||||
margin: 8px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-mid);
|
||||
}
|
||||
|
||||
.term-events {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.term-event {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-mid);
|
||||
}
|
||||
|
||||
.term-muted {
|
||||
margin: 0;
|
||||
color: var(--text-lo);
|
||||
}
|
||||
|
||||
@media (max-width: 1040px) {
|
||||
.term-layout {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.term-controls {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.term-text {
|
||||
height: 54vh;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { api } from '../api/client'
|
||||
import type { Capture, Detail } from '../api/types'
|
||||
import { Chip, Dot, Empty, EndpointGap, M, Panel } from '../components/Primitives'
|
||||
import { Icon } from '../components/Icon'
|
||||
import './Terminal.css'
|
||||
|
||||
/** This is the screen that must not lie. Everything inside the frame is the
|
||||
* pane's own bytes; everything outside it is state Orchestra can prove. */
|
||||
|
||||
function ago(at?: string) {
|
||||
if (!at) return '—'
|
||||
const seconds = Math.max(0, Math.round((Date.now() - new Date(at).getTime()) / 1000))
|
||||
if (seconds < 60) return `${seconds}s ago`
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`
|
||||
return `${Math.floor(seconds / 3600)}h ago`
|
||||
}
|
||||
|
||||
function clock(at?: string) {
|
||||
return at ? new Date(at).toISOString().slice(11, 19) : '—'
|
||||
}
|
||||
|
||||
function until(at?: string) {
|
||||
if (!at) return '—'
|
||||
const seconds = Math.round((new Date(at).getTime() - Date.now()) / 1000)
|
||||
if (seconds <= 0) return 'expired'
|
||||
const m = Math.floor(seconds / 60)
|
||||
return `${String(m).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function Field({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="term-field">
|
||||
<span className="label">{label}</span>
|
||||
<span className="term-field-value">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Terminal() {
|
||||
const { id = '' } = useParams()
|
||||
const queries = useQueryClient()
|
||||
const detail = useQuery<Detail>({
|
||||
queryKey: ['detail', id],
|
||||
queryFn: () => api.detail(id),
|
||||
refetchInterval: 3000,
|
||||
})
|
||||
|
||||
// The last frame we actually received, kept across refreshes so a lost pane
|
||||
// still shows what was true when it was lost instead of an empty box.
|
||||
const [frame, setFrame] = useState<Capture | undefined>()
|
||||
const [lostAt, setLostAt] = useState<string | undefined>()
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [pinned, setPinned] = useState(true)
|
||||
const view = useRef<HTMLPreElement>(null)
|
||||
|
||||
const task = detail.data?.task
|
||||
const session = detail.data?.session
|
||||
const capture = session?.capture
|
||||
|
||||
useEffect(() => {
|
||||
if (capture) {
|
||||
setFrame(capture)
|
||||
setLostAt(undefined)
|
||||
return
|
||||
}
|
||||
// A leased task with no capture is a lost pane, not an empty one.
|
||||
if (detail.isSuccess && task?.state === 'leased') {
|
||||
setLostAt((was) => was ?? new Date().toISOString())
|
||||
}
|
||||
}, [capture, detail.isSuccess, task?.state])
|
||||
|
||||
// Scrollback inspection must not disturb the live view: we only follow the
|
||||
// tail while the operator is already sitting at the tail.
|
||||
useEffect(() => {
|
||||
const el = view.current
|
||||
if (el && pinned) el.scrollTop = el.scrollHeight
|
||||
}, [frame?.revision, pinned])
|
||||
|
||||
const resubmit = useMutation({
|
||||
mutationFn: () => api.action(id, 'resubmit'),
|
||||
onSuccess: (next) => queries.setQueryData(['detail', id], next),
|
||||
})
|
||||
|
||||
const canResubmit = detail.data?.actions.find((a) => a.id === 'resubmit')?.enabled ?? false
|
||||
const lost = Boolean(lostAt) && Boolean(frame)
|
||||
const status = session?.agent_status || task?.pane_state || 'unknown'
|
||||
const health = lost ? 'error' : status === 'blocked' ? 'degraded' : status ? 'healthy' : 'unknown'
|
||||
|
||||
if (detail.isLoading) {
|
||||
return (
|
||||
<main className="page">
|
||||
<div className="page-head">
|
||||
<h1>Terminal</h1>
|
||||
</div>
|
||||
<Panel>
|
||||
<div className="panel-body">Reading the pane…</div>
|
||||
</Panel>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (detail.isError || !task) {
|
||||
return (
|
||||
<main className="page">
|
||||
<div className="page-head">
|
||||
<h1>Terminal</h1>
|
||||
</div>
|
||||
<Panel>
|
||||
<Empty title="No such task">
|
||||
<p>
|
||||
<M>{id}</M> is not in the store, so there is no pane to show.
|
||||
</p>
|
||||
</Empty>
|
||||
</Panel>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="page term">
|
||||
<div className="page-head term-head">
|
||||
<h1>Terminal — live pane</h1>
|
||||
{lost ? <Chip tone="fault">Pane lost</Chip> : capture ? <Chip tone="accent">Live</Chip> : <Chip>No session</Chip>}
|
||||
<span className="term-identity">
|
||||
<M>{session?.harness_id || task.last_harness_id || 'no worker'}</M>
|
||||
<span className="term-sep">/</span>
|
||||
<M>{session?.pane_id || task.last_pane_id || 'no pane'}</M>
|
||||
</span>
|
||||
<div className="term-controls">
|
||||
<span className="chip term-mode" data-mode="read-only">
|
||||
<Icon name="terminal" size={14} /> Read-only
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled
|
||||
title="Orchestra has no keystroke-forwarding endpoint"
|
||||
>
|
||||
Take control
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={!frame}
|
||||
onClick={() => {
|
||||
if (!frame) return
|
||||
void navigator.clipboard.writeText(frame.text).then(() => {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
})
|
||||
}}
|
||||
>
|
||||
{copied ? 'Copied' : 'Copy frame'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="term-layout">
|
||||
<div className="term-frame" data-lost={lost}>
|
||||
<div className="term-bar">
|
||||
<M>{session?.pane_id || task.last_pane_id || '—'}</M>
|
||||
<span className="term-bar-right">
|
||||
<M>rev {frame ? frame.revision : '—'}</M>
|
||||
<M>{frame?.source || '—'}</M>
|
||||
<M>{clock(frame?.at)}</M>
|
||||
</span>
|
||||
</div>
|
||||
{frame ? (
|
||||
<pre
|
||||
ref={view}
|
||||
className="term-text"
|
||||
tabIndex={0}
|
||||
aria-label="Pane capture, read-only"
|
||||
onScroll={(event) => {
|
||||
const el = event.currentTarget
|
||||
setPinned(el.scrollHeight - el.scrollTop - el.clientHeight < 24)
|
||||
}}
|
||||
>
|
||||
{frame.text}
|
||||
</pre>
|
||||
) : (
|
||||
<Empty title="No capture">
|
||||
<p>
|
||||
This task holds no leased session, so no worker is publishing pane text for{' '}
|
||||
<M>{task.id}</M>.
|
||||
</p>
|
||||
</Empty>
|
||||
)}
|
||||
<div className="term-foot">
|
||||
{frame?.truncated && <span className="term-warn">frame truncated by the worker</span>}
|
||||
{!pinned && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn term-jump"
|
||||
onClick={() => {
|
||||
setPinned(true)
|
||||
const el = view.current
|
||||
if (el) el.scrollTop = el.scrollHeight
|
||||
}}
|
||||
>
|
||||
<Icon name="chevron-right" size={14} /> Scrollback held — jump to live
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="term-side">
|
||||
{lost && (
|
||||
<Panel title="Pane lost">
|
||||
<div className="panel-body term-lost">
|
||||
<p>
|
||||
The frame above is the last one Orchestra received. It is history, not the
|
||||
present.
|
||||
</p>
|
||||
<Field label="Frame captured" value={<M>{clock(frame?.at)} UTC</M>} />
|
||||
<Field label="Capture stopped" value={<M>{clock(lostAt)} UTC</M>} />
|
||||
<Field
|
||||
label="Lease consequence"
|
||||
value={
|
||||
task.lease?.until ? (
|
||||
<span>
|
||||
the lease still runs until <M>{clock(task.lease.until)} UTC</M>; nothing is
|
||||
rerouted before it expires
|
||||
</span>
|
||||
) : (
|
||||
<span>no lease is held, so this task can be routed again immediately</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
<Panel title="Session">
|
||||
<div className="panel-body term-fields">
|
||||
<Field label="Task" value={<Link to={`/tasks/${task.id}`} className="term-link"><M>{task.id}</M></Link>} />
|
||||
<Field label="Worker" value={<M>{session?.harness_id || task.last_harness_id || '—'}</M>} />
|
||||
<Field label="Pane" value={<M>{session?.pane_id || task.last_pane_id || '—'}</M>} />
|
||||
<Field label="Lease epoch" value={<M>{task.lease?.epoch || '—'}</M>} />
|
||||
<Field
|
||||
label="Lease"
|
||||
value={
|
||||
task.lease?.until ? (
|
||||
<span>
|
||||
<M>{until(task.lease.until)}</M> left · until <M>{clock(task.lease.until)} UTC</M>
|
||||
</span>
|
||||
) : (
|
||||
<M>none</M>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Field label="Capture source" value={<M>{frame?.source || '—'}</M>} />
|
||||
<Field label="Capture revision" value={<M>{frame ? frame.revision : '—'}</M>} />
|
||||
<Field label="Last verified progress" value={<M>{ago(frame?.at)}</M>} />
|
||||
<Field label="Mode" value={<M>read-only</M>} />
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Health">
|
||||
<div className="panel-body term-fields">
|
||||
<Field
|
||||
label="Agent"
|
||||
value={
|
||||
<span className="term-health">
|
||||
<Dot health={health} />
|
||||
<M>{status}</M>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<Field label="Checked" value={<M>{ago(task.last_session?.checked_at || frame?.at)}</M>} />
|
||||
{(session?.blocker || task.blocker) && (
|
||||
<Field label="Blocker" value={<span>{session?.blocker || task.blocker}</span>} />
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Keyboard forwarding">
|
||||
<EndpointGap
|
||||
path="POST /v1/ui/tasks/:id/actions/send_keys"
|
||||
what="Orchestra exposes no endpoint that forwards arbitrary keystrokes to a pane. The only operator input that reaches a live pane today is resubmit (a single Enter on text Orchestra itself submitted) and approval grant/deny. Take control stays disabled rather than opening a text box that goes nowhere."
|
||||
/>
|
||||
<div className="panel-body term-resubmit">
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-variant="primary"
|
||||
disabled={!canResubmit || resubmit.isPending}
|
||||
onClick={() => resubmit.mutate()}
|
||||
>
|
||||
{resubmit.isPending ? 'Sending…' : 'Resubmit (send Enter)'}
|
||||
</button>
|
||||
<p>
|
||||
Presses Enter once on input Orchestra placed in this pane. It sends no other key and
|
||||
changes no lifecycle state.
|
||||
</p>
|
||||
{resubmit.isError && (
|
||||
<p className="term-warn">{(resubmit.error as Error).message}</p>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Recent events" count={detail.data?.events.length}>
|
||||
<div className="panel-body term-events">
|
||||
{(detail.data?.events ?? []).slice(-5).reverse().map((event) => (
|
||||
<div key={event.id} className="term-event">
|
||||
<M>{clock(event.at)}</M>
|
||||
<span>{event.type}</span>
|
||||
</div>
|
||||
))}
|
||||
{!detail.data?.events.length && <p className="term-muted">No events recorded yet.</p>}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||