7f12c7fc37
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>
221 lines
7.3 KiB
Go
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
|
|
}
|