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>
196 lines
6.6 KiB
Go
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)
|
|
}
|