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 }