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:
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user