From 5fb88724bd0b90757246d881f2c171bdc5cdf9d7 Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 27 Jul 2026 23:25:01 +0400 Subject: [PATCH] fix(operations): per-project git sync + completion receipts in brief (S2, S3) Brief.Git was a single GitSync read from ORCHESTRA_DATA (never a git checkout), and completions were counted but discarded their report_ref/ receipt. Brief.Git is now keyed by project ID and built from each project's real repo; GitSync gained Ahead/Behind vs upstream; Brief now carries Receipts pulled from each TaskCompleted payload. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01W1rkJ2hBMybnJctPbcy4tT --- AUDIT.md | 27 ++++++++++++++++ cmd/orchestra/main.go | 14 +++++++- internal/operations/operations.go | 45 ++++++++++++++++++++------ internal/operations/operations_test.go | 11 +++++-- progress.md | 13 +++++++- 5 files changed, 97 insertions(+), 13 deletions(-) diff --git a/AUDIT.md b/AUDIT.md index 815145a..8cf6711 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -840,6 +840,33 @@ by `TestFanoutContinuesAfterSendError` (`internal/delivery/delivery_test.go` sender both receive the event, the cursor still advances, and `Run` only exits on context cancellation, never on the send error. +### S2, S3 — closed, 2026-07-27 + +`Brief.Git` was a single `GitSync` read from `ORCHESTRA_DATA` (the event-log +directory, never a git checkout, so it always reported `"git unavailable"`) — +S2 named this and asked for per-project state from the actual worktrees. +`Brief.Git` is now `map[string]GitSync` keyed by project ID; `/v1/brief` +(`cmd/orchestra/main.go`) builds it from `registry.Project.Repo` for every +project that sets one, falling back to a single `"default"` entry keyed off +`ORCHESTRA_REPO` for single-repo deployments that predate per-project repos. +`operations.GitState` also gained `Ahead`/`Behind` (via +`git rev-list --left-range --count @{u}...HEAD`) so "what pushed" and "what +workpc still needs to pull" (§7.4's own phrasing) are both answerable, not +just branch/HEAD/dirty. + +S3: `BuildBrief` counted `TaskCompleted` but discarded the payload, so the +brief never carried the `report_ref`/receipt proofs §7.4 names as the reason +completions are surfaced at all ("the receipts for every completion"). Added +`operations.CompletionReceipt{TaskID, ReportRef, Receipt}` and +`Brief.Receipts []CompletionReceipt`, populated straight from each +`TaskCompleted` event's existing payload (no new event fields needed — +`domain.ValidatePayload` already requires both on every `TaskCompleted`). +Covered by an updated `TestBuildBrief` (`internal/operations/operations_test.go`) +asserting a completion's `report_ref`/`receipt` and a project's `GitSync` +both come through in the brief. + +`go build ./...`, `go vet ./...`, `go test ./...` all pass. + ### Design consequences (not yet implemented) 1. **Percentages are a level, not a delta.** diff --git a/cmd/orchestra/main.go b/cmd/orchestra/main.go index e666917..a8bdbe9 100644 --- a/cmd/orchestra/main.go +++ b/cmd/orchestra/main.go @@ -363,7 +363,19 @@ func main() { if v, parseErr := time.Parse(time.RFC3339, r.URL.Query().Get("to")); parseErr == nil { to = v } - json.NewEncoder(w).Encode(operations.BuildBrief(s.Events(0), from, to, operations.GitState(dir))) + git := map[string]operations.GitSync{} + for _, p := range rr.Projects() { + if p.Repo == "" { + continue + } + git[p.ID] = operations.GitState(p.Repo) + } + if len(git) == 0 { + if repo := os.Getenv("ORCHESTRA_REPO"); repo != "" { + git["default"] = operations.GitState(repo) + } + } + json.NewEncoder(w).Encode(operations.BuildBrief(s.Events(0), from, to, git)) }) standup := func() (domain.Event, error) { return operations.GenerateStandupAdvisory(s, time.Now().UTC()) diff --git a/internal/operations/operations.go b/internal/operations/operations.go index 7a7e2cd..a74e985 100644 --- a/internal/operations/operations.go +++ b/internal/operations/operations.go @@ -13,19 +13,37 @@ import ( ) type Brief struct { - From time.Time `json:"from"` - To time.Time `json:"to"` - Completed int `json:"completed"` - Failed int `json:"failed"` - Blocked int `json:"blocked"` - NeedsAttention []domain.Event `json:"needs_attention"` - Quota map[string]float64 `json:"quota_consumed"` - Git GitSync `json:"git_sync"` + From time.Time `json:"from"` + To time.Time `json:"to"` + Completed int `json:"completed"` + Failed int `json:"failed"` + Blocked int `json:"blocked"` + NeedsAttention []domain.Event `json:"needs_attention"` + Quota map[string]float64 `json:"quota_consumed"` + Receipts []CompletionReceipt `json:"receipts"` + // Git is keyed by project ID (spec §7.4: "what pushed, what's on which + // branch, what workpc still needs to pull" is a per-project question, + // not a single global answer read off the event-log directory). + Git map[string]GitSync `json:"git_sync"` } type GitSync struct { Branch string `json:"branch"` Head string `json:"head"` Status string `json:"status"` + // Ahead/Behind are counts vs the branch's upstream, when one is + // configured — "what pushed" and "what workpc still needs to pull" + // (§7.4). Zero when there is no upstream (e.g. detached HEAD). + Ahead int `json:"ahead,omitempty"` + Behind int `json:"behind,omitempty"` +} + +// CompletionReceipt names the proof a completion carries (§7.4: "what +// pushed... and the receipts for every completion"), pulled out of +// TaskCompleted's payload rather than just counted. +type CompletionReceipt struct { + TaskID string `json:"task_id"` + ReportRef string `json:"report_ref"` + Receipt map[string]any `json:"receipt"` } type StandupItem struct { @@ -70,7 +88,7 @@ func StandupItems(tasks []domain.Task) []StandupItem { } // BuildBrief folds only events in [from,to]. It is deliberately read-only. -func BuildBrief(events []domain.Event, from, to time.Time, git GitSync) Brief { +func BuildBrief(events []domain.Event, from, to time.Time, git map[string]GitSync) Brief { b := Brief{From: from, To: to, Quota: map[string]float64{}, Git: git} for _, e := range events { if e.At.Before(from) || e.At.After(to) { @@ -81,6 +99,9 @@ func BuildBrief(events []domain.Event, from, to time.Time, git GitSync) Brief { switch e.Type { case "TaskCompleted": b.Completed++ + receipt, _ := p["receipt"].(map[string]any) + reportRef, _ := p["report_ref"].(string) + b.Receipts = append(b.Receipts, CompletionReceipt{TaskID: e.TaskID, ReportRef: reportRef, Receipt: receipt}) case "TaskFailed": b.Failed++ b.NeedsAttention = append(b.NeedsAttention, e) @@ -178,5 +199,11 @@ func GitState(dir string) GitSync { if g.Head == "" { g.Status = fmt.Sprintf("git unavailable (%s)", dir) } + if counts := run("rev-list", "--left-right", "--count", "@{u}...HEAD"); counts != "" { + var behind, ahead int + if n, _ := fmt.Sscanf(counts, "%d\t%d", &behind, &ahead); n == 2 { + g.Behind, g.Ahead = behind, ahead + } + } return g } diff --git a/internal/operations/operations_test.go b/internal/operations/operations_test.go index 1e48cdd..5a9c664 100644 --- a/internal/operations/operations_test.go +++ b/internal/operations/operations_test.go @@ -12,11 +12,18 @@ import ( func TestBuildBrief(t *testing.T) { now := time.Now() p, _ := json.Marshal(map[string]any{"harness_id": "cc", "consumed": 12}) - es := []domain.Event{{Type: "TaskCompleted", At: now}, {Type: "TaskFailed", At: now}, {Type: "ApprovalRequested", At: now}, {Type: "QuotaReported", At: now, Payload: p}} - b := BuildBrief(es, now.Add(-time.Minute), now.Add(time.Minute), GitSync{Status: "clean"}) + completed, _ := json.Marshal(map[string]any{"report_ref": "sha256:abc", "receipt": map[string]any{"numerator": 42.0}}) + es := []domain.Event{{TaskID: "t1", Type: "TaskCompleted", At: now, Payload: completed}, {Type: "TaskFailed", At: now}, {Type: "ApprovalRequested", At: now}, {Type: "QuotaReported", At: now, Payload: p}} + b := BuildBrief(es, now.Add(-time.Minute), now.Add(time.Minute), map[string]GitSync{"proj": {Status: "clean"}}) if b.Completed != 1 || b.Failed != 1 || len(b.NeedsAttention) != 2 || b.Quota["cc"] != 12 { t.Fatalf("unexpected brief: %+v", b) } + if len(b.Receipts) != 1 || b.Receipts[0].TaskID != "t1" || b.Receipts[0].ReportRef != "sha256:abc" || b.Receipts[0].Receipt["numerator"] != 42.0 { + t.Fatalf("unexpected receipts: %+v", b.Receipts) + } + if b.Git["proj"].Status != "clean" { + t.Fatalf("unexpected git: %+v", b.Git) + } } func TestAggregateQuotaSumsRotationsAndWindows(t *testing.T) { diff --git a/progress.md b/progress.md index 7c28970..4b2b0d4 100644 --- a/progress.md +++ b/progress.md @@ -208,7 +208,18 @@ Fixed so far: entire log from seq 0. `internal/delivery` previously had zero tests; added `TestFanoutContinuesAfterSendError`. -Not yet started: Codex/opencode completion producers, S2–S3, S7–S11. See +- **S2 + S3** — `/v1/brief`'s git state used to come from `ORCHESTRA_DATA` + (the event-log directory, not a git checkout — always reported + `"git unavailable"`), and completions were only counted, never surfaced + with proof. `operations.Brief.Git` is now `map[string]GitSync` keyed by + project ID, built in `main.go` from each `registry.Project.Repo` (falling + back to a single `"default"` entry off `ORCHESTRA_REPO` for deployments + without per-project repos); `GitSync` gained `Ahead`/`Behind` vs upstream. + New `Brief.Receipts []operations.CompletionReceipt` pulls `report_ref`/ + `receipt` straight out of each `TaskCompleted` event's existing payload. + Covered by an updated `TestBuildBrief`. + +Not yet started: Codex/opencode completion producers, S7–S11. See `AUDIT.md` for the full plan. **Phase 0 done (2026-07-27):** this box has live TCP reachability to the real