Files
orchestra/internal/operations/reflect.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

196 lines
6.6 KiB
Go

package operations
import (
"encoding/json"
"errors"
"fmt"
"time"
"orchestra/internal/authz"
"orchestra/internal/domain"
"orchestra/internal/human"
"orchestra/internal/registry"
"orchestra/internal/store"
)
// ErrForeignPullRequest rejects an observation that does not belong to the
// task's own submission. Only the pull request bound in TaskSubmitted may move
// that task, or one task's forge traffic could complete another.
var ErrForeignPullRequest = errors.New("observation is for a different pull request")
// ReflectSubmission reconciles one submitted pull request.
//
// It runs on its own, not behind Store.PreLease. An in-review task cannot be
// leased, so a pre-lease hook could never observe the feedback that should make
// it leasable again. That is the same shape of bug as reconciling only at
// launch, one lifecycle stage later.
func ReflectSubmission(s *store.Store, project registry.Project, taskID string, state human.PullRequestState, trust human.Trust) ([]domain.Event, error) {
t, ok := s.Task(taskID)
if !ok {
return nil, domain.ErrNotFound
}
if t.Submission == nil {
return nil, nil
}
if state.ID != t.Submission.PR.ID {
return nil, fmt.Errorf("%w: %s is not %s", ErrForeignPullRequest, state.ID, t.Submission.PR.ID)
}
submittedAt, submissionEvent, ok := submissionRecord(s, t)
if !ok {
return nil, fmt.Errorf("%w: no submission event for task %s", domain.ErrInvalid, taskID)
}
switch state.State {
case "merged":
// Merge strategy decides what MergeSHA is, so completion rests on the
// bound pull request having merged while carrying the submitted commit.
if state.HeadSHA != t.Submission.ResultSHA {
return nil, fmt.Errorf("%w: pull request %s carries %s, but %s was submitted", ErrForeignPullRequest, state.ID, state.HeadSHA, t.Submission.ResultSHA)
}
if t.State == domain.StateCompleted {
return nil, nil
}
e, err := complete(s, t, submissionEvent, state)
if err != nil {
return nil, err
}
return []domain.Event{e}, nil
case "closed":
// Closed without a merge could mean abandoned, rejected, superseded,
// or a misclick. Guessing would be worse than surfacing it.
if t.State == domain.StateNeedsAttention || t.State != domain.StateInReview {
return nil, nil
}
e, err := blockTask(s, t, domain.BlockReasonOperator,
fmt.Sprintf("Pull request %s was closed without merging the submitted commit %s. Decide whether this task is abandoned, superseded, or should be resubmitted.", state.ID, t.Submission.ResultSHA), nil)
if err != nil {
return nil, err
}
return []domain.Event{e}, nil
}
if t.State != domain.StateInReview {
// Already reopened, or never submitted into review. Nothing to do.
return nil, nil
}
feedback := state.FeedbackAfter(t.Submission.PR.Provider, submittedAt, trust)
if len(feedback) == 0 {
return nil, nil
}
var recorded []domain.Event
var decisionIDs []string
for _, in := range feedback {
if id, exists := s.DecisionForSource(in.Provider, in.ExternalID); exists {
// Already imported. A repeated poll must not reopen the task twice
// for the same comment.
decisionIDs = append(decisionIDs, id)
continue
}
e, id, err := recordDecision(s, t.ID, in)
if err != nil {
return recorded, err
}
recorded = append(recorded, e)
decisionIDs = append(decisionIDs, id)
t, _ = s.Task(t.ID)
}
if len(recorded) == 0 {
// Every comment was already imported, so this poll changed nothing.
return nil, nil
}
e, err := requestChanges(s, t, submissionEvent, decisionIDs)
if err != nil {
return recorded, err
}
recorded = append(recorded, e)
// The work goes back to implementation, where a fresh gate and a fresh
// review will be required because the commit will change.
if _, err := AdvanceWorkPhase(s, project, t.ID, nil); err != nil {
return recorded, err
}
return recorded, nil
}
// submissionRecord finds when the current submission happened, which is the
// cutoff for "feedback on this submission".
func submissionRecord(s *store.Store, t domain.Task) (time.Time, string, bool) {
for i := len(s.Events(0)) - 1; i >= 0; i-- {
e := s.Events(0)[i]
if e.TaskID != t.ID || e.Type != domain.EventTaskSubmitted {
continue
}
var p struct {
ResultSHA string `json:"result_sha"`
}
if json.Unmarshal(e.Payload, &p) == nil && p.ResultSHA == t.Submission.ResultSHA {
return e.At, e.ID, true
}
}
return time.Time{}, "", false
}
func recordDecision(s *store.Store, taskID string, in human.Input) (domain.Event, string, error) {
current, ok := s.Task(taskID)
if !ok {
return domain.Event{}, "", domain.ErrNotFound
}
id := domain.NewID()
b, err := json.Marshal(map[string]any{
"decision_id": id, "kind": string(domain.HumanDecisionCorrection),
"subject": "operator_instruction", "value": in.Body, "author": in.Author,
"source": map[string]any{"provider": in.Provider, "external_id": in.ExternalID},
})
if err != nil {
return domain.Event{}, "", err
}
at := in.At
if at.IsZero() {
at = time.Now().UTC()
}
e := domain.Event{ID: domain.NewID(), Type: domain.EventHumanDecisionRecorded, TaskID: taskID, Version: current.Version + 1, At: at, Payload: b, Surface: string(authz.System)}
return e, id, s.Append(e)
}
func requestChanges(s *store.Store, t domain.Task, submissionEvent string, decisionIDs []string) (domain.Event, error) {
current, _ := s.Task(t.ID)
b, err := json.Marshal(map[string]any{
"submission_event": submissionEvent,
"submitted_sha": t.Submission.ResultSHA,
"decision_ids": decisionIDs,
})
if err != nil {
return domain.Event{}, err
}
e := domain.Event{ID: domain.NewID(), Type: domain.EventTaskChangesRequested, TaskID: t.ID, Version: current.Version + 1, Payload: b, Surface: string(authz.System)}
return e, s.Append(e)
}
func complete(s *store.Store, t domain.Task, submissionEvent string, state human.PullRequestState) (domain.Event, error) {
receipt := domain.CompletionReceipt{
SubmissionRef: submissionEvent, PR: t.Submission.PR,
SubmittedSHA: t.Submission.ResultSHA, MergeSHA: state.MergeSHA, MergedAt: state.MergedAt,
}
if receipt.MergedAt.IsZero() {
receipt.MergedAt = time.Now().UTC()
}
sealed, err := json.Marshal(receipt)
if err != nil {
return domain.Event{}, err
}
ref, err := s.PutArtifact(sealed)
if err != nil {
return domain.Event{}, err
}
var asMap map[string]any
if err := json.Unmarshal(sealed, &asMap); err != nil {
return domain.Event{}, err
}
current, _ := s.Task(t.ID)
b, err := json.Marshal(map[string]any{"report_ref": ref, "receipt": asMap})
if err != nil {
return domain.Event{}, err
}
e := domain.Event{ID: domain.NewID(), Type: "TaskCompleted", TaskID: t.ID, Version: current.Version + 1, Payload: b, Surface: string(authz.System)}
return e, s.Append(e)
}