Files
orchestra/internal/operations/trajectory.go
T
kami 7f12c7fc37 v3 workflow: intent, phases, review, submission, enforcement, burn-in
The v3 stack, previously an uncommitted working tree, plus this session's two
units and the burn-in instrument. This commit is the burn-in build identity:
coordinator and worker must both report this revision before a task is created.

Workflow (earlier sessions, uncommitted until now): human decision events and
reduction, source cursors and reconcile-before-launch, turn-boundary
reconciliation, internal/agentctx as the single renderer, ace-fca phases with
sealed artifacts, the trajectory gate, bounded grilling, independent review,
task pr enforcement, and human review reflection.

Capability restrictions at the agent boundary: an authz.Agent surface at
GatedWrite may ask and may not act. It also fixes two bugs the unit exposed --
gated surfaces could not reach the two endpoints written for them, and
RequestHumanDecision would block an unowned task while rejecting a question
from the session that did own it.

Turn-boundary reconcile-failure escalation: a streak of consecutive failures
asks the session to hand off, fenced on the lease epoch, with reconcile_failure
as a real handoff reason. The worker was dropping the coordinator's verdict on
the floor; it now acts on it.

Burn-in: herdr.WriteLaunchContext dumps the exact agentctx.Build result to
<worktree>/.orchestra/launch.md at every launch, local and federated. BURNIN.md
is the runbook. deploy/build.sh stamps both binaries from one commit.

go build, go vet and go test ./... pass, 20 packages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 18:31:20 +04:00

221 lines
7.3 KiB
Go

package operations
import (
"encoding/json"
"errors"
"fmt"
"strings"
"orchestra/internal/authz"
"orchestra/internal/domain"
"orchestra/internal/store"
"orchestra/internal/workphase"
)
// ErrTrajectoryGate reports that a phase change stopped for human
// confirmation. It is not a fault: the work so far is sealed and valid, and
// the human now decides whether the direction is right.
var ErrTrajectoryGate = errors.New("trajectory gate: waiting for the human to confirm the direction")
// maxPacketBytes bounds the gate packet. It travels in the TaskBlocked
// blocker field, which is what notification surfaces already deliver, so the
// human reads the packet where they already read blockers.
const maxPacketBytes = 4000
// trajectoryGateOpen reports whether the human has answered the most recent
// gate for this task.
//
// The rule is positional rather than a flag: a decision recorded after the
// gate was raised is the answer to it. That needs no new task field and
// cannot drift out of sync with the log, and it accepts any wording, which
// matters because an imported comment carries no gate-specific subject.
func trajectoryGateOpen(s *store.Store, taskID string) bool {
return blockerAnswered(s, taskID, domain.BlockReasonTrajectoryGate)
}
// blockerAnswered reports whether the human has replied since the most recent
// block of this reason.
//
// The rule is positional on purpose. Deciding whether a reply semantically
// answers the question would mean parsing intent, and a wrong parse either
// strands a task the human already answered or resumes one they did not. The
// agent receives both the question and the reply and can see for itself.
func blockerAnswered(s *store.Store, taskID string, reason domain.BlockReason) bool {
var blockSeq, decisionSeq uint64
for _, e := range s.Events(0) {
if e.TaskID != taskID {
continue
}
switch e.Type {
case "TaskBlocked":
var p struct {
BlockReason string `json:"block_reason"`
}
if json.Unmarshal(e.Payload, &p) == nil && p.BlockReason == string(reason) {
blockSeq = e.Seq
}
case domain.EventHumanDecisionRecorded:
decisionSeq = e.Seq
}
}
return blockSeq > 0 && decisionSeq > blockSeq
}
// raiseTrajectoryGate blocks the task and hands the human the packet.
func raiseTrajectoryGate(s *store.Store, t domain.Task, from, to domain.WorkPhase, proposal []byte) error {
packet, err := TrajectoryGatePacket(s, t, from, to, proposal)
if err != nil {
return err
}
b, err := json.Marshal(map[string]any{
"blocker": packet,
"block_reason": string(domain.BlockReasonTrajectoryGate),
"lifecycle_phase": "awaiting_human",
})
if err != nil {
return err
}
e := domain.Event{ID: domain.NewID(), Type: "TaskBlocked", TaskID: t.ID, Version: t.Version + 1, Payload: b, Surface: string(authz.System)}
if err := s.Append(e); err != nil {
return err
}
return fmt.Errorf("%w (task %s, %s to %s)", ErrTrajectoryGate, t.ID, from, to)
}
// TrajectoryGatePacket renders the human's decision packet from state that
// already exists. It is human-facing, unlike agentctx, and deliberately
// carries no transcript: the human is confirming a direction, not auditing a
// session.
// proposal, when set, is the artifact the finishing phase produced but has
// not sealed yet, which is exactly what the human is being asked about.
func TrajectoryGatePacket(s *store.Store, t domain.Task, from, to domain.WorkPhase, proposal []byte) (string, error) {
intent, err := s.EffectiveIntent(t.ID)
if err != nil {
return "", err
}
var b strings.Builder
fmt.Fprintf(&b, "Trajectory gate: %s to %s needs your confirmation.\n", from, to)
fmt.Fprintf(&b, "\nGoal: %s\n", oneLine(firstNonEmpty(t.Title, t.Description, "not stated")))
if len(t.Acceptance) > 0 {
b.WriteString("\nAcceptance:\n")
for _, a := range t.Acceptance {
fmt.Fprintf(&b, "- %s\n", oneLine(a))
}
}
if t.ResearchRef != "" {
if raw, err := s.Artifact(t.ResearchRef); err == nil {
if r, err := workphase.DecodeResearch(raw); err == nil {
b.WriteString("\nWhat research established:\n")
for _, f := range r.Findings {
fmt.Fprintf(&b, "- %s (%s)\n", oneLine(f.Claim), oneLine(f.Evidence))
}
for _, u := range r.Unknowns {
fmt.Fprintf(&b, "- still unknown: %s\n", oneLine(u))
}
}
}
}
planned := proposal
if len(planned) == 0 && t.PlanRef != "" {
if raw, err := s.Artifact(t.PlanRef); err == nil {
planned = raw
}
}
if len(planned) > 0 {
{
if p, err := workphase.DecodePlan(planned); err == nil {
b.WriteString("\nProposed changes:\n")
for _, c := range p.Changes {
fmt.Fprintf(&b, "- %s: %s\n", oneLine(c.Target), oneLine(c.Intent))
}
if len(p.Verification) > 0 {
b.WriteString("\nVerification:\n")
for _, v := range p.Verification {
fmt.Fprintf(&b, "- %s\n", oneLine(v))
}
}
if len(p.Risks) > 0 {
b.WriteString("\nRisks:\n")
for _, r := range p.Risks {
fmt.Fprintf(&b, "- %s\n", oneLine(r))
}
}
if len(p.DecisionsNeeded) > 0 {
b.WriteString("\nOpen decisions for you:\n")
for _, d := range p.DecisionsNeeded {
fmt.Fprintf(&b, "- %s\n", oneLine(d))
}
}
}
}
}
if len(intent.Decisions) > 0 {
b.WriteString("\nYour decisions so far:\n")
for _, d := range intent.Decisions {
fmt.Fprintf(&b, "- %s (%s): %s\n", d.Kind, d.Subject, oneLine(d.Value))
}
}
b.WriteString("\nReply to confirm or correct the direction. Your reply becomes a recorded decision and outranks the plan above.\n")
out := b.String()
if len(out) > maxPacketBytes {
out = out[:maxPacketBytes] + "\n(truncated)\n"
}
return out, nil
}
func oneLine(s string) string {
return strings.Join(strings.Fields(strings.ReplaceAll(s, "\n", " ")), " ")
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
// clearTrajectoryGate returns a gated task to the queue once the human has
// answered. The compensating TaskCorrected names the block it reverses, which
// is the §3.1 rule: a wrong or superseded event is never edited.
func clearTrajectoryGate(s *store.Store, t domain.Task) (domain.Task, error) {
return clearBlocker(s, t, domain.BlockReasonTrajectoryGate, "gate_cleared")
}
// clearBlocker returns an answered task to the queue. The compensating
// TaskCorrected names the block it reverses, per §3.1: a superseded event is
// never edited.
func clearBlocker(s *store.Store, t domain.Task, reason domain.BlockReason, phase string) (domain.Task, error) {
var gate domain.Event
for _, e := range s.Events(0) {
if e.TaskID != t.ID || e.Type != "TaskBlocked" {
continue
}
var p struct {
BlockReason string `json:"block_reason"`
}
if json.Unmarshal(e.Payload, &p) == nil && p.BlockReason == string(reason) {
gate = e
}
}
if gate.ID == "" {
return t, fmt.Errorf("%w: no %s blocker to clear on task %s", domain.ErrInvalid, reason, t.ID)
}
b, err := json.Marshal(map[string]any{
"corrects": gate.ID, "state": string(domain.StateQueued),
"lifecycle_phase": phase,
})
if err != nil {
return t, err
}
if err := s.Append(domain.Event{ID: domain.NewID(), Type: "TaskCorrected", TaskID: t.ID, Version: t.Version + 1, Payload: b, Surface: string(authz.System)}); err != nil {
return t, err
}
updated, ok := s.Task(t.ID)
if !ok {
return t, domain.ErrNotFound
}
return updated, nil
}