822f086451
The brief at agentctx.go:167 advertised findings[].id and findings[].confidence to every research session. The struct carried neither, so encoding/json dropped both on every seal, silently, for as long as the schema has existed. A plan phase had nothing stable to cite and no way to tell an observation from an assumption. Finding gains ID and Confidence. Ids are unique within an artifact and shaped so "research:<id>" is unambiguous in plan prose. Confidence is fact, inference, or assumption, matching the labels the output style already uses. DecodeStoredResearch reads what is already in the CAS and backfills both. Refusing an artifact sealed before this change would block every task whose research predates it, including at rotation, where the agent that could fix it is already gone. A backfilled finding is labelled inference rather than fact: the old schema required evidence and made no verification claim, so upgrading it on the way in would be the same class of lie this commit removes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
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.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 {
|
|
{
|
|
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
|
|
}
|