Files
orchestra/internal/operations/trajectory.go
T
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

208 lines
7.1 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
}
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
}
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.DecodeStoredResearch(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 {
// The plan reaches the human as the document that was sealed. A
// trajectory gate asks whether this direction is right, and a
// flattened summary is not the thing being approved. maxPacketBytes
// below bounds what a notification surface actually carries.
if p, err := workphase.DecodeStoredPlan(planned); err == nil {
b.WriteString("\nProposed plan:\n\n")
b.WriteString(p.Markdown)
if !strings.HasSuffix(p.Markdown, "\n") {
b.WriteString("\n")
}
}
}
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
}