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>
This commit is contained in:
2026-08-26 18:31:20 +04:00
parent 97a9c65302
commit 7f12c7fc37
78 changed files with 16417 additions and 352 deletions
+91
View File
@@ -0,0 +1,91 @@
package provider
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"time"
"orchestra/internal/domain"
"orchestra/internal/human"
"orchestra/internal/store"
)
// GiteaComments reads issue comments as human input. It is a separate type
// from Gitea so the ingestion path and the authority path cannot be confused
// for each other: this one never creates or closes a task.
type GiteaComments struct{ Gitea }
type giteaComment struct {
ID int64 `json:"id"`
Body string `json:"body"`
User struct {
Login string `json:"login"`
} `json:"user"`
CreatedAt time.Time `json:"created_at"`
}
// FetchAfter returns the comments on the task's issue with an id above the
// cursor, oldest first. The cursor is the highest comment id already
// reconciled; comment ids are monotonic per repo, which makes them a usable
// resume point even when a comment is edited later.
func (g GiteaComments) FetchAfter(ctx context.Context, task domain.Task, cursor store.SourceCursor) ([]human.Input, store.SourceCursor, error) {
next := store.SourceCursor{TaskID: task.ID, Provider: g.SourceName(), Cursor: cursor.Cursor}
if task.ExternalID == "" || task.Source != g.SourceName() {
return nil, next, nil
}
var after int64
if cursor.Cursor != "" {
v, err := strconv.ParseInt(cursor.Cursor, 10, 64)
if err != nil {
return nil, next, fmt.Errorf("gitea comments: bad cursor %q: %w", cursor.Cursor, err)
}
after = v
}
u := strings.TrimRight(g.BaseURL, "/") + "/api/v1/repos/" + g.Owner + "/" + g.Repo + "/issues/" + task.ExternalID + "/comments"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, next, err
}
if g.Token != "" {
req.Header.Set("Authorization", "token "+g.Token)
}
resp, err := g.client().Do(req)
if err != nil {
return nil, next, err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return nil, next, fmt.Errorf("gitea comments: %s", resp.Status)
}
var comments []giteaComment
if err := json.NewDecoder(resp.Body).Decode(&comments); err != nil {
return nil, next, err
}
sort.Slice(comments, func(a, b int) bool { return comments[a].ID < comments[b].ID })
var out []human.Input
highest := after
for _, c := range comments {
if c.ID <= after {
continue
}
out = append(out, human.Input{
Provider: g.SourceName(),
ExternalID: strconv.FormatInt(c.ID, 10),
Author: c.User.Login,
At: c.CreatedAt,
Body: c.Body,
})
if c.ID > highest {
highest = c.ID
}
}
if highest > after {
next.Cursor = strconv.FormatInt(highest, 10)
}
return out, next, nil
}
+86
View File
@@ -0,0 +1,86 @@
package provider
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"orchestra/internal/domain"
"orchestra/internal/store"
)
func TestGiteaCommentsFetchAfterCursor(t *testing.T) {
var path string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path = r.URL.Path
// Deliberately out of order, to prove the source sorts by id.
w.Write([]byte(`[
{"id":920,"body":"and keep the flag","user":{"login":"kami"},"created_at":"2026-08-26T12:02:00Z"},
{"id":917,"body":"older","user":{"login":"kami"},"created_at":"2026-08-26T11:00:00Z"},
{"id":918,"body":"no, use b","user":{"login":"kami"},"created_at":"2026-08-26T12:00:00Z"}
]`))
}))
defer srv.Close()
g := GiteaComments{Gitea{BaseURL: srv.URL, Owner: "kami", Repo: "orchestra", Project: "p"}}
task := domain.Task{ID: "t1", Source: g.SourceName(), ExternalID: "381"}
got, next, err := g.FetchAfter(context.Background(), task, store.SourceCursor{Cursor: "917"})
if err != nil {
t.Fatal(err)
}
if path != "/api/v1/repos/kami/orchestra/issues/381/comments" {
t.Fatalf("path = %s", path)
}
if len(got) != 2 || got[0].ExternalID != "918" || got[1].ExternalID != "920" {
t.Fatalf("inputs = %+v", got)
}
if got[0].Body != "no, use b" || got[0].Author != "kami" || got[0].Provider != "gitea:p" {
t.Fatalf("first input = %+v", got[0])
}
if next.Cursor != "920" || next.TaskID != "t1" {
t.Fatalf("next = %+v", next)
}
// Nothing new: the cursor must stay put rather than regress.
got, next, err = g.FetchAfter(context.Background(), task, store.SourceCursor{Cursor: "920"})
if err != nil || len(got) != 0 {
t.Fatalf("inputs=%+v err=%v", got, err)
}
if next.Cursor != "920" {
t.Fatalf("next = %+v", next)
}
}
func TestGiteaCommentsIgnoresForeignAndUnkeyedTasks(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Errorf("unexpected request to %s", r.URL.Path)
}))
defer srv.Close()
g := GiteaComments{Gitea{BaseURL: srv.URL, Owner: "kami", Repo: "orchestra", Project: "p"}}
for name, task := range map[string]domain.Task{
"other source": {ID: "t1", Source: "vikunja", ExternalID: "381"},
"no issue": {ID: "t1", Source: g.SourceName()},
} {
got, _, err := g.FetchAfter(context.Background(), task, store.SourceCursor{})
if err != nil || len(got) != 0 {
t.Fatalf("%s: inputs=%+v err=%v", name, got, err)
}
}
}
func TestGiteaCommentsRejectsUnparsableCursorAndHTTPError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "boom", 500)
}))
defer srv.Close()
g := GiteaComments{Gitea{BaseURL: srv.URL, Owner: "kami", Repo: "orchestra", Project: "p"}}
task := domain.Task{ID: "t1", Source: g.SourceName(), ExternalID: "381"}
if _, _, err := g.FetchAfter(context.Background(), task, store.SourceCursor{Cursor: "abc"}); err == nil {
t.Fatal("bad cursor must not be treated as zero")
}
if _, _, err := g.FetchAfter(context.Background(), task, store.SourceCursor{}); err == nil {
t.Fatal("http failure must be reported")
}
}
+244
View File
@@ -0,0 +1,244 @@
package provider
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os/exec"
"strconv"
"strings"
"time"
"orchestra/internal/domain"
"orchestra/internal/human"
"orchestra/internal/operations"
)
// GiteaPublisher performs the two side effects of a submission: publish the
// exact commit, then create or update one pull request for its branch.
//
// It never creates a second pull request for a branch that already has an open
// one. A repeated `task pr` has to refresh the same review, not open a new one.
type GiteaPublisher struct {
Gitea
// Base is the branch the pull request targets. Empty means the repo default.
Base string
// Root is the local checkout to push from. The caller constructs one
// publisher per submission, because the checkout is per task.
Root string
}
func (g GiteaPublisher) Push(ctx context.Context, remote, branch, sha string) (string, error) {
root := g.Root
if strings.TrimSpace(root) == "" {
return "", fmt.Errorf("gitea publisher: worktree root is required")
}
if out, err := exec.CommandContext(ctx, "git", "-C", root, "push", remote, sha+":refs/heads/"+branch).CombinedOutput(); err != nil {
return "", fmt.Errorf("%s: %w", strings.TrimSpace(string(out)), err)
}
// Read back what the remote actually holds. A push that reported success
// is not proof the ref points where it should.
out, err := exec.CommandContext(ctx, "git", "-C", root, "ls-remote", remote, "refs/heads/"+branch).Output()
if err != nil {
return "", fmt.Errorf("verify pushed ref: %w", err)
}
fields := strings.Fields(string(out))
if len(fields) == 0 {
return "", fmt.Errorf("remote has no %s", branch)
}
return fields[0], nil
}
type giteaPR struct {
Number int `json:"number"`
State string `json:"state"`
URL string `json:"html_url"`
}
func (g GiteaPublisher) EnsurePR(ctx context.Context, plan operations.SubmissionPlan) (domain.ExternalRef, error) {
existing, err := g.findPR(ctx, plan.Branch)
if err != nil {
return domain.ExternalRef{}, err
}
body, _ := json.Marshal(map[string]any{
"title": plan.PRTitle, "body": plan.PRBody,
"head": plan.Branch, "base": g.base(),
})
method, path := http.MethodPost, "/pulls"
if existing != nil {
method, path = http.MethodPatch, fmt.Sprintf("/pulls/%d", existing.Number)
body, _ = json.Marshal(map[string]any{"title": plan.PRTitle, "body": plan.PRBody})
}
pr, err := g.call(ctx, method, path, body)
if err != nil {
return domain.ExternalRef{}, err
}
return domain.ExternalRef{Provider: g.SourceName(), ID: fmt.Sprint(pr.Number), URL: pr.URL}, nil
}
func (g GiteaPublisher) base() string {
if strings.TrimSpace(g.Base) != "" {
return g.Base
}
return "master"
}
func (g GiteaPublisher) findPR(ctx context.Context, branch string) (*giteaPR, error) {
u := g.repoURL() + "/pulls?state=open&limit=50"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, err
}
g.authorize(req)
resp, err := g.client().Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return nil, fmt.Errorf("gitea list pulls: %s", resp.Status)
}
var open []struct {
giteaPR
Head struct {
Ref string `json:"ref"`
} `json:"head"`
}
if err := json.NewDecoder(resp.Body).Decode(&open); err != nil {
return nil, err
}
for _, pr := range open {
if pr.Head.Ref == branch {
found := pr.giteaPR
return &found, nil
}
}
return nil, nil
}
func (g GiteaPublisher) call(ctx context.Context, method, path string, body []byte) (giteaPR, error) {
req, err := http.NewRequestWithContext(ctx, method, g.repoURL()+path, bytes.NewReader(body))
if err != nil {
return giteaPR{}, err
}
req.Header.Set("Content-Type", "application/json")
g.authorize(req)
resp, err := g.client().Do(req)
if err != nil {
return giteaPR{}, err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return giteaPR{}, fmt.Errorf("gitea %s %s: %s", method, path, resp.Status)
}
var pr giteaPR
if err := json.NewDecoder(resp.Body).Decode(&pr); err != nil {
return giteaPR{}, err
}
return pr, nil
}
func (g GiteaPublisher) repoURL() string {
return strings.TrimRight(g.BaseURL, "/") + "/api/v1/repos/" + url.PathEscape(g.Owner) + "/" + url.PathEscape(g.Repo)
}
func (g GiteaPublisher) authorize(req *http.Request) {
if g.Token != "" {
req.Header.Set("Authorization", "token "+g.Token)
}
}
type giteaPRDetail struct {
Number int `json:"number"`
State string `json:"state"`
Merged bool `json:"merged"`
MergeSHA string `json:"merge_commit_sha"`
MergedAt *time.Time `json:"merged_at"`
Head struct {
SHA string `json:"sha"`
} `json:"head"`
}
type giteaPRReview struct {
State string `json:"state"`
Body string `json:"body"`
User struct {
Login string `json:"login"`
} `json:"user"`
Submitted time.Time `json:"submitted_at"`
}
// PullRequest reads the submitted pull request's current state, its comments,
// and its reviews. It reports what the forge says rather than deciding what it
// means: the trust boundary and the lifecycle rules live in operations.
func (g GiteaPublisher) PullRequest(ctx context.Context, task domain.Task) (human.PullRequestState, error) {
if task.Submission == nil || task.Submission.PR.ID == "" {
return human.PullRequestState{}, fmt.Errorf("task %s has no submitted pull request", task.ID)
}
number := task.Submission.PR.ID
var detail giteaPRDetail
if err := g.get(ctx, "/pulls/"+url.PathEscape(number), &detail); err != nil {
return human.PullRequestState{}, err
}
out := human.PullRequestState{ID: number, HeadSHA: detail.Head.SHA, MergeSHA: detail.MergeSHA}
switch {
case detail.Merged:
out.State = "merged"
case detail.State == "closed":
out.State = "closed"
default:
out.State = "open"
}
if detail.MergedAt != nil {
out.MergedAt = *detail.MergedAt
}
// Pull request comments live on the issue endpoint in Gitea.
var comments []giteaComment
if err := g.get(ctx, "/issues/"+url.PathEscape(number)+"/comments", &comments); err != nil {
return human.PullRequestState{}, err
}
for _, c := range comments {
out.Comments = append(out.Comments, human.Input{
Provider: g.SourceName(), ExternalID: strconv.FormatInt(c.ID, 10),
Author: c.User.Login, At: c.CreatedAt, Body: c.Body,
})
}
var reviews []giteaPRReview
if err := g.get(ctx, "/pulls/"+url.PathEscape(number)+"/reviews", &reviews); err != nil {
return human.PullRequestState{}, err
}
for _, r := range reviews {
state := "commented"
switch strings.ToUpper(r.State) {
case "APPROVED":
state = "approved"
case "REQUEST_CHANGES", "CHANGES_REQUESTED":
state = "changes_requested"
}
out.Reviews = append(out.Reviews, human.ReviewObservation{
Actor: r.User.Login, State: state, At: r.Submitted, Body: r.Body,
})
}
return out, nil
}
func (g GiteaPublisher) get(ctx context.Context, path string, into any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, g.repoURL()+path, nil)
if err != nil {
return err
}
g.authorize(req)
resp, err := g.client().Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return fmt.Errorf("gitea GET %s: %s", path, resp.Status)
}
return json.NewDecoder(resp.Body).Decode(into)
}