32 Commits

Author SHA1 Message Date
kami 982741fe97 Say which operator actions are debt and which are just operations
An intervention is an action required to recover, repair, unblock or correct
behaviour that should have proceeded on its own. A deploy restart, a planned
upgrade and burn-in setup are none of those, and recording them would inflate
operator cost with work Orchestra is expected to need.

Policy rather than schema. worker_restart is the one kind that spans both
sides, and it stays one kind until someone actually misuses it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
2026-08-30 14:29:17 +04:00
kami 42c5f07844 Write down the rule both slice B defects broke
A projection must never manufacture provenance to make evidence easier to
classify. task=None is better than a confident lie.

Also records that signatures are frozen at write time, so an old event never
changes meaning because normalization code changed. Repairing historical
signatures is an explicit migration, not a different projection result from
the same log.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
2026-08-30 14:23:41 +04:00
kami 79d20534b5 Stop the ring's history from manufacturing recurrence
Both defects came from the first real run of slice B against live data, and
neither was visible in a unit test written from the design.

The ring is a bounded history, not a set of live conditions. A quiet timeout
closed an incident, its entry stayed in the ring because nothing evicts it,
and every later heartbeat opened the same incident again: three signatures,
four incidents each, from failures that never happened twice. An incident
now opens only when the entry actually advances past what was already
accounted, and the high-water mark survives the close.

The ring also outlives the work it describes, so attributing its entries to
whatever the worker is running now invented an association. The task is read
out of the message, and only a failure that names no task belongs to the
current lease. An incident that names an older task has no live lease to
bound it, so it closes on quiet timeout rather than on the next epoch change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
2026-08-30 14:20:47 +04:00
kami 74cad5d374 Collapse a pane name's task id in an observation signature
The ledger's first live run showed it: a pane name carries the task id in
lower case, so "phase rotation ...: pane orchestra-<task>-<sha>:1.0 still
holds input" signed differently on every task and could never accumulate
recurrence across them. The id pattern is case-insensitive now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
2026-08-30 06:55:51 +04:00
kami e0601296e0 Record what an operator repaired, and let debt count incidents
Slice B, second half. OperatorInterventionRecorded is the one command for
saying "I fixed this by hand": a manual repair happens outside Orchestra by
definition, so the only honest way to have the evidence is for the person
who made it to state it. Inferring "an operator probably intervened" from a
gap would put guesses into the record the ledger is built from.

The debt projection now consumes both new kinds. A closed incident is one
observation carrying its repeat count as intensity, so recurrence stays a
count of independent incidents: 301 repeats on one lease and 2 on another
is a recurrence of two with an intensity of 303, not a recurrence of 303.

Both kinds were previously reported as holes in the system. They are
ordinary evidence now, so their absence from a history is a fact about that
history, and the gap list says so.

The worker also stamps a per-process incarnation on registration and every
heartbeat. Nothing else on the wire distinguishes a restarted worker from a
running one, and an incident cannot outlive the process that reported it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
2026-08-30 06:53:26 +04:00
kami 438c1d6df3 Make worker observations durable as incidents, not as symptoms
Slice B, first half. The F18 ring is bounded, lossy and local, so the debt
ledger reported it as a gap about itself. Two events make it durable:
ObservationIncidentOpened at first sight, appended immediately so a
coordinator that dies mid-incident still leaves the fact that it existed,
and ObservationIncidentClosed carrying the aggregate.

The rules are what matter. Repeats update the aggregate and append nothing,
because run 11's 409 loop was one incident with an intensity of 301 rather
than 301 pieces of evidence. Absence from the ring closes nothing, since a
bounded history evicts as easily as it recovers. An incident is scoped to
its lease and closes on lease end, epoch change, worker restart, or, for
observations with no lease to bound them, on last_seen going stale. A ring
entry that is evicted and recreated accumulates: 34 then 3 is 37.

Also collapses three drifted copies of the list of events that carry no
task into one predicate. Adding a type to two of them left it rejected by
the third, which is how the first version of this failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
2026-08-30 06:35:17 +04:00
kami 76da8c40b7 End the implementer's trajectory when Orchestra reopens the phase
Run 20 showed the leak precisely: the reopen rotated the session, and 31
seconds later the planning session that replaced it recorded phase-2 of the
plan being replaced. The implementer's .orchestra/plan-progress.json had
outlived it in the worktree, and the successor executed it.

Two changes. Phase verification now belongs to the implement phase and is
refused anywhere else, which covers every worker and every path rather than
the one that produced it. The rotation also drops the ended session's
request files, so a successor never inherits work from a trajectory
Orchestra has already invalidated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
2026-08-30 05:46:11 +04:00
kami 575e3ef87e Record run 21: F66 proven, and why round one did not count
The first fix wrote the projection and lost it at the rotation the reopen
causes, so the planner still got nothing. Both rounds passed their unit
tests; what separated them was where the assertion sat in the sequence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
2026-08-29 22:03:51 +04:00
kami 75396963ef Keep the contradiction alive across the rotation the reopen causes
The clearing rule sat below the reducer switch, so it ran for every event
rather than for the correction it was written for. TaskReleased found the
task unblocked and erased the contradiction, which is the rotation the
reopen itself causes: the planning session launched one lease later and was
told nothing again.

Scoped to TaskCorrected, and the store test walks the real sequence
(mismatch, reopen, release, lease) rather than reading the projection at
the moment it is written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
2026-08-29 21:05:01 +04:00
kami edf00761fd Tell the planner what it was convened to fix
F66, found live on run 20. A replan reopens the plan phase and the session
it convenes was given the ordinary plan brief and an empty template: no
mention that a plan already exists, what contradicted it, which phase, what
evidence, or that its output supersedes an accepted plan. All of it was
already durable on PlanMismatchRecorded and none of it reached the agent,
so nothing stopped the replacement from sealing with the same
contradiction in it.

The contradiction is now projected onto the task and rendered above the
sealed artifacts, because it changes how they should be read. Its lifetime
is bounded at both ends: a sealed replacement answers it, and so does a
human reply to the stop it caused. Only the phases a mismatch can reopen
render it, since an implementer looks at the code rather than at an account
of the last contradiction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
2026-08-29 20:47:44 +04:00
kami 1330ad9943 Stop reporting a blocker the task is no longer blocked on
F67. TaskCorrected cleared the standing question when a task resumed and
left Blocker and BlockReason in place, so task 29 ran through implement,
review and submission still reporting block_reason plan_mismatch. Every
surface that reads the projection rather than the event log showed a stop
that had already been answered.

The blocker has the same lifetime as the question beside it, and is now
cleared with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
2026-08-29 20:41:36 +04:00
kami cb80dd1952 Record run 20: the mismatch and replan ladder holds, and F66
Both halves proven live. A human_decision mismatch stops the task, the
blocker packet states the contradiction, and a Gitea reply resumes the same
plan in 55 seconds with no reseal. A replan reopens into plan while the old
plan stays accepted with its progress, and sealing the replacement moves
plan_ref, retains the old one in history, stops the old progress counting,
and hands the successor the replacement whole.

F66 recorded: the planning session a replan convenes is told nothing about
the contradiction it exists to fix, though all of it is durable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
2026-08-29 20:39:04 +04:00
kami de18f372d3 Fence the two coordinator-side stops nobody had leased
F65, found live on run 20. A plan mismatch asking for a human decision
recorded its observation, then failed to block the task: Store.Append fences
every lifecycle event on a leased task against the current owner and epoch,
and this TaskBlocked carried neither. The task kept implementing while the
contradiction sat durable in the log, and the agent was told its report was
refused. The trajectory gate had the same omission.

The human-decision path already did this correctly and explained why in a
comment. That comment is now a helper all three call.

The tests could not have caught it. planWith never leased its task, so
every plan test ran in a state no agent can be in, which is exactly what
the lease helper's own comment warns against. It leases now, and the
mismatch block test fails without the fence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
2026-08-29 20:07:23 +04:00
kami bdc0d4d5be Say that a coordinator deploy leaves the console behind
The ethos console sat undeployed for hours while two coordinator deploys
went out, because both used --no-deps orchestra-api and the web image is
built separately. Checking the commit does not catch it; checking the
served bundle does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
2026-08-29 20:02:29 +04:00
kami 6cb2f932d8 Go and read the reply to a question Orchestra asked
F64. A plan mismatch that asks for a human decision blocks the task, and
nothing came back. Two independent gaps, either one enough to strand it:
the reconciler ran only before a lease and at a turn boundary, so a blocked
task's reply was never even read, and ResumeAnsweredBlockers listed two
block reasons, not this one. PlanMismatchAnswered had no caller anywhere.

Blocked tasks awaiting a reply are now reconciled on their own loop, the
same reasoning the submitted-work loop above it already uses: a task that
cannot be leased cannot be reconciled behind a pre-lease hook. One
predicate, BlockReasonAwaitsReply, now names the set for both loops so they
cannot drift apart again.

The existing test asserted the predicate and never the resume, which is how
this survived. It asserts the resume now, and fails without the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
2026-08-29 19:57:11 +04:00
kami b5c37f693b Bind a manual sign-off to the tree it was given against
F63, found live on run 19. A manual check on these projects is a human
reading what the code prints. RecordPlanPhaseVerification asked only
whether a sign-off for that plan and phase existed, and one exists forever,
so rerunning a phase's automated checks at a new commit carried the human
half along with it. The rig proved it twice: two operator commits and two
re-verification requests, each coming back verified without anyone looking.

The reducer now records which tree the human confirmed, the record carries
it forward as provenance, and a run whose commit does not match it waits
for the human again. A sign-off given before any run has no confirmed tree
and still counts, so the ordinary ordering is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
2026-08-29 19:41:50 +04:00
kami bbf3335857 Record run 19: the manual verification seam, and F63
The keyed seam holds. Automated checks alone leave a phase awaiting, a
generic approving comment satisfies nothing, and the keyed form verifies
exactly the phase it names under the plan ref Orchestra supplies. A
re-verification request reruns the plan's own commands at the new tree and
writes a record bound to it.

F63 is what the rig found. A manual sign-off is consulted by existence
alone, so it survives every later tree the human never saw, while the
automated half is re-established at each new SHA. The proposed fix stamps
the satisfied record's sha on the decision and requires it to match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
2026-08-29 19:25:21 +04:00
kami 99b209ba10 Record run 18: an implement successor inherits verified phase progress
The rung the last two runs missed. A five-phase task with ten named checks
kept the implement phase open long enough to rotate inside it. The successor
picked up in implement and its launch context carried the whole sealed plan,
phase-1 and phase-2 as verified, phase-3 as the first unfinished phase, and
the current human authority.

Unasked-for bonus: the progress block renders SHA staleness itself, naming
the tree each phase was verified against and the tree it is now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
2026-08-29 17:48:17 +04:00
kami ab3258833d Record run 17: F62 proven live, from a production trigger
The rig was not needed. An implement to review phase change asked for the
handoff through the ordinary path, and the agent ignored it, which is the
exact shape F62 was written for. Every assertion held: a causal release
rather than an idle expiry, four renewals during the bounded wait, one
resend carrying the original reason, the timeout class at 10m5s, no stale
transaction, and a successor that leased normally a minute later.

Also recorded: a five-phase plan does not lengthen the implement phase, and
the state-file lever cannot be driven with systemctl restart alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
2026-08-29 17:38:15 +04:00
kami c587f2cc8d Bound the wait for a handoff nobody answers
F62. The rotation is agent-driven: the worker asks, and the agent must write
its handoff. When the agent never does, renewals stopped on the ordinary
progress gate, the lease expired, and the task lost an attempt with nothing on
record saying a handoff had ever been requested. Run 16 showed only "agent
status idle and pane unchanged", 34 times.

The request is now stamped, and the wait around it is bounded. While Orchestra
is explicitly waiting the lease renews, because a quiet pane is the answer the
agent was told to give. The request is re-sent once after four minutes, with
the reason it was first asked with. At ten minutes the worker nacks with
failure class handoff_unanswered, and the coordinator releases the task naming
that cause instead of letting the lease die as generic idleness.

The class is known to DebtClassForFailureClass, so a harness that ignores
handoff requests accumulates as its own debt item rather than hiding inside
lease_expired.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
2026-08-29 16:21:35 +04:00
kami 41658aea5b Carry the resume order and the tag's meaning in the handoff
The next session should not rebuild either from the commits. A defect
found in experimental work is not a reason to reopen settled
architecture.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
2026-08-29 15:51:42 +04:00
kami 765bf2afc6 Hand off with the release path settled and the UI as a truth detector
F57 to F60 close the release-transaction family, F18 closes the single
last_error slot, the debt ledger exists as a read-only projection, and
the operator console is rebuilt on ethos.

The part worth acting on is what the UI could not do honestly: no
web-facing human-decision write path, no keystroke forwarding, context
occupancy trapped in herdr, project configuration unserved, and no
federated handoff request. F61 and F62 are recorded and unbuilt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
2026-08-29 14:59:05 +04:00
kami cbd6b11c49 Record run 16: the rotation rung, half proven
A successor inherits the whole sealed plan. Phase progress is withheld
from a review successor on purpose, so the rung still needs an
implement-phase successor. F62: a requested handoff nobody answers is
invisible, and its only consequence is an expiry indistinguishable from
an idle one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
2026-08-29 14:14:37 +04:00
kami 34f3c2888f Rebuild the operator console on the ethos shell
Nine screens against orchestra-ui-spec.md and the accepted mockups, on
the ethos design system: signal violet #8F7AE5, the routing fork motif,
the 64px rail and 56px top bar, mono for every machine value and sans
for every human one.

Each screen was built by its own agent against a fixed foundation, so
the shell, tokens and primitives have one author and the screens cannot
drift into nine dialects.

Real data only. Where no endpoint exists the screen says which one it
needs instead of inventing a value. That is most of what was learned
here: Steer / Correct is disabled because nothing records a human
decision from the web, take-control is disabled because nothing
forwards keystrokes to a pane, context occupancy is missing from three
screens, and projects can show no repo, remote, quality gate or
verification policy because those live only in config.jsonc.

Three bugs the render caught that no computed value would have:

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 applies only to
the login route, which 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 at the API boundary every
screen reads through, with a test.

Long machine ids overflowed their cards and painted under the next one.

Verified by rendering: chromium screenshots of the dashboard, tasks,
task detail, terminal, workers and review at 1440px, and the dashboard
at 390px. Geist is still not on disk, so both stacks fall back to the
system faces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
2026-08-29 02:44:43 +04:00
kami 118ac9fbcb Commit the concurrent session's pending web and docs work
Not my work. These nine files sat uncommitted in the shared checkout
while another session worked on them, and the UI redesign that follows
rewrites web/src/main.tsx and web/src/style.css. Committing first means
that work is recoverable rather than overwritten.

Contents, by inspection rather than by authorship: whitespace
normalisation and edits across main.tsx, 568 added lines of style.css,
client and client test changes, the orchestra-user line in build.sh, and
docs updates to AGENTS.md, AUDIT.md, DEPLOYMENT.md and the env example.

Committed at the operator's explicit instruction.
2026-08-29 02:17:32 +04:00
kami 2d28f7b462 Record slice one's first run against 881 real events
Seven eligible items, three gaps, and four defects in the model that a
read-only projection surfaced before any schema was committed to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
2026-08-29 02:02:34 +04:00
kami a757cffc78 Project a debt ledger from canonical history, read-only
Slice one of DEBT-DESIGN.md, with the four amendments applied. It writes
nothing: no new event types, no scheduling, no clustering, no
maintenance tasks. The point is to find out whether the model can
represent debt this project already knows about, before committing to a
durable schema.

The projected type lives in domain and the fold lives in store, so the
first implementation does not bake a read model into the command layer.
operations owns the one action that exists, CheckDebtEligibility, which
is a pure function returning explicit reasons like CheckSubmission.

Signatures carry their version in the string. Normalization rules will
change, and without a version that silently regroups history and moves
the recurrence counts eligibility was already decided on.

Observations require exactly one of event_id and legacy_ref. Imported
Fxx history predates the events that would justify it, and a fabricated
event id would break the provenance rule the ledger exists to enforce.

Incompleteness is reported, not hidden. Manual interventions and worker
observations are carried by no event type, so the ledger names both as
non-durable gaps rather than reading as "no operational cost". The
operational refusal reason says the intervention count is structurally
zero on every current log.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
2026-08-29 02:00:58 +04:00
kami 2f61a99986 Design the debt ledger from what the event log already carries
Nine questions answered against the tree at 3c7cf95, no implementation.
The ledger is two event types, one projection beside Task, one pure
eligibility function and one read-only endpoint. Nothing in the task
lifecycle changes.

The first slice writes nothing: a projection over the existing log that
must reproduce the release loop, the opencode adapter gap and the
retry-idleness dynamic from runs 10 to 14. A model that cannot represent
debt already known is wrong before any schema is committed to.

Also corrects run 14: the OpenCode Zen free tier is not blocked, the
selected model is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
2026-08-29 01:51:53 +04:00
kami 595a1d3533 Record run 14: plan-phase execution proven, opencode diagnosed
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
2026-08-29 01:11:57 +04:00
kami 0797432d6f Record run 13: F60 settled, and F18 populated under a real failure
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
2026-08-28 23:55:20 +04:00
kami 3c7cf95d8c Settle a release transaction deterministically in every case
F60, and the general rule F58 and F59 were reaching for one case at a
time: a transaction must settle or be abandoned deterministically, and
must never spin on an answer that cannot change.

Terminal now means failed or completed. Both drop the transaction and
free the session; nothing will ever lease either task again.

Blocked keeps the transaction, because a reopen returns the task to the
queue and that exact owner can still commit. TaskBlocked therefore
retains the ending epoch the way TaskReleased already did, or the
late-handoff path would have nothing to fence against after the reopen.

A refusal parks the commit instead of retrying every five seconds. It
is the coordinator's answer about who owns the task, so it stays true
until an event about that task arrives, and any such event un-parks it.
A reopen arrives as TaskCorrected, so the rule cannot be a list of
event types. Backoff runs 30s to a 5 minute cap.

A transport failure is not an answer and keeps retrying at once. That
distinction is the whole reason the park keys on a 4xx StatusError
rather than on any error at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
2026-08-28 23:52:18 +04:00
kami 0f83559ecc Record F18, the merge, and what run 12 leaves open
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
2026-08-28 23:45:03 +04:00
107 changed files with 12690 additions and 753 deletions
+1
View File
@@ -16,3 +16,4 @@ node_modules/
.node_modules/
web/dist/
web/tsconfig.tsbuildinfo
build/
+5
View File
@@ -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
+34
View File
@@ -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`.
+742
View File
@@ -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.
+8
View File
@@ -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
+481
View File
@@ -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.
+349
View File
@@ -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.
+17
View File
@@ -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.
+110
View File
@@ -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)
}
}
+139 -11
View File
@@ -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)
}
+117
View File
@@ -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)
}
}
+29
View File
@@ -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)
}
}
+105
View File
@@ -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 {
+31 -8
View File
@@ -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.
+3 -1
View File
@@ -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"
+5 -6
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

+32
View File
@@ -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
+58
View File
@@ -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")
}
}
+266
View File
@@ -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
}
+54
View File
@@ -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")
}
}
+58 -9
View File
@@ -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
+81
View File
@@ -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()
}
+160
View File
@@ -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
}
+7
View File
@@ -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
+11 -4
View File
@@ -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
+8
View File
@@ -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.
+81
View File
@@ -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"`
}
+68
View File
@@ -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)
}
}
+19 -12
View File
@@ -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) {
+275
View File
@@ -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
}
+295
View File
@@ -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)
}
}
+4 -2
View File
@@ -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
}
+28
View File
@@ -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")
}
+28 -5
View File
@@ -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
+93 -1
View File
@@ -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())
}
}
+4 -2
View File
@@ -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
}
+256
View File
@@ -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
}
+155
View File
@@ -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")
}
}
}
+91 -3
View File
@@ -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()
+99
View File
@@ -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)
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+107
View File
@@ -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>
+13 -3
View File
@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

+635
View File
@@ -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: 130170 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.
+12 -1
View File
@@ -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>
+107
View File
@@ -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>
+60
View File
@@ -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>
)
}
+33 -13
View File
@@ -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')
})
})
+39 -14
View File
@@ -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',
+68
View File
@@ -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[] } }[]
}
+19
View File
@@ -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>
)
}
+133
View File
@@ -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`
}
+193 -165
View File
@@ -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 Orchestras 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 Orchestras 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>Youll 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>Youll 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 Orchestras 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(
+126
View File
@@ -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;
}
}
+361
View File
@@ -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>
)
}
+206
View File
@@ -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;
}
}
+458
View File
@@ -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>
)
}
+16
View File
@@ -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);
}
}
+296
View File
@@ -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 coordinators 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>
)
}
+195
View File
@@ -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;
}
}
+426
View File
@@ -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>
)
}
+164
View File
@@ -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);
}
}
+256
View File
@@ -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>
)
}
+293
View File
@@ -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;
}
}
+454
View File
@@ -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>
)
}
+202
View File
@@ -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;
}
}
+329
View File
@@ -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>
)
}
+192
View File
@@ -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;
}
}
+320
View File
@@ -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>
)
}

Some files were not shown because too many files have changed in this diff Show More