e0601296e0
Slice B, second half. OperatorInterventionRecorded is the one command for saying "I fixed this by hand": a manual repair happens outside Orchestra by definition, so the only honest way to have the evidence is for the person who made it to state it. Inferring "an operator probably intervened" from a gap would put guesses into the record the ledger is built from. The debt projection now consumes both new kinds. A closed incident is one observation carrying its repeat count as intensity, so recurrence stays a count of independent incidents: 301 repeats on one lease and 2 on another is a recurrence of two with an intensity of 303, not a recurrence of 303. Both kinds were previously reported as holes in the system. They are ordinary evidence now, so their absence from a history is a fact about that history, and the gap list says so. The worker also stamps a per-process incarnation on registration and every heartbeat. Nothing else on the wire distinguishes a restarted worker from a running one, and an incident cannot outlive the process that reported it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
257 lines
8.5 KiB
Go
257 lines
8.5 KiB
Go
package store
|
|
|
|
import (
|
|
"encoding/json"
|
|
"sort"
|
|
"strings"
|
|
|
|
"orchestra/internal/domain"
|
|
"orchestra/internal/review"
|
|
)
|
|
|
|
// ProjectDebt folds canonical history into a debt ledger. It is a pure
|
|
// function of the events so it can be tested against a real log, and it writes
|
|
// nothing: this projection is a read model, not a new kind of truth.
|
|
//
|
|
// It deliberately reports what it cannot see. Worker observations and operator
|
|
// interventions are not carried by any event type today, so a ledger that
|
|
// stayed silent about them would read as "no operational cost" when it means
|
|
// "operational cost is unrecorded".
|
|
func ProjectDebt(events []domain.Event) domain.DebtLedger {
|
|
return projectDebt(events, nil)
|
|
}
|
|
|
|
// ProjectDebtWithArtifacts is ProjectDebt plus the CAS reads that review
|
|
// findings need. Findings are sealed as an artifact rather than inlined in the
|
|
// event, and they are the only evidence structural debt has, so a projection
|
|
// without them can never produce a structural item at all.
|
|
func ProjectDebtWithArtifacts(events []domain.Event, read func(ref string) ([]byte, error)) domain.DebtLedger {
|
|
return projectDebt(events, read)
|
|
}
|
|
|
|
func projectDebt(events []domain.Event, readArtifact func(string) ([]byte, error)) domain.DebtLedger {
|
|
bySignature := map[string]*domain.DebtItem{}
|
|
order := []string{}
|
|
seenKinds := map[domain.ObservationKind]bool{}
|
|
|
|
add := func(class domain.DebtClass, sig string, o domain.DebtObservation, symptom string, sev review.Severity) {
|
|
o.Signature = sig
|
|
if o.Validate() != nil {
|
|
return
|
|
}
|
|
seenKinds[o.Kind] = true
|
|
item, ok := bySignature[sig]
|
|
if !ok {
|
|
item = &domain.DebtItem{
|
|
// The id is the signature, not an Fxx number. This projection
|
|
// produces candidates from history; adopting a curated id is a
|
|
// later, deliberate step, and minting one here would collide
|
|
// with the hand-written ledger.
|
|
ID: sig, Class: class, Status: domain.DebtObserved,
|
|
Symptom: symptom, Signatures: []string{sig}, Severity: sev,
|
|
}
|
|
bySignature[sig] = item
|
|
order = append(order, sig)
|
|
}
|
|
item.Observations = append(item.Observations, o)
|
|
for _, p := range o.Paths {
|
|
if !contains(item.Paths, p) {
|
|
item.Paths = append(item.Paths, p)
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, e := range events {
|
|
var p map[string]any
|
|
if len(e.Payload) > 0 && json.Unmarshal(e.Payload, &p) != nil {
|
|
continue
|
|
}
|
|
base := domain.DebtObservation{EventID: e.ID, TaskID: e.TaskID, At: e.At}
|
|
harness, _ := p["harness_id"].(string)
|
|
switch e.Type {
|
|
case "TaskBlocked":
|
|
reason, _ := p["block_reason"].(string)
|
|
if reason == "" {
|
|
reason = string(domain.InferBlockReason(str(p["blocker"])))
|
|
}
|
|
class, ok := domain.DebtClassForBlockReason(domain.BlockReason(reason))
|
|
if !ok {
|
|
continue
|
|
}
|
|
o := base
|
|
o.Kind = domain.ObservationBlockReason
|
|
o.Detail = str(p["blocker"])
|
|
add(class, domain.DebtSignature(class, reason, harness, "lease"), o,
|
|
"tasks are blocked with "+reason, review.Important)
|
|
case "TaskFailed", "TaskReleased":
|
|
// A release carries a failure class only when it is a reclaim. A
|
|
// handoff release is ordinary progress and never debt.
|
|
failure, _ := p["failure_class"].(string)
|
|
if failure == "" {
|
|
failure, _ = p["reason"].(string)
|
|
}
|
|
if e.Type == "TaskReleased" && str(p["handoff_ref"]) != "" {
|
|
continue
|
|
}
|
|
class, ok := domain.DebtClassForFailureClass(failure)
|
|
if !ok {
|
|
continue
|
|
}
|
|
o := base
|
|
o.Kind = domain.ObservationFailureClass
|
|
o.Detail = str(p["last_error"])
|
|
add(class, domain.DebtSignature(class, failure, harness, "lease"), o,
|
|
"tasks end in "+failure, review.Important)
|
|
case domain.EventObservationIncidentClosed:
|
|
// One incident, whatever it repeated. The debt class comes from
|
|
// the signature's shape rather than a failure class, because a
|
|
// worker observation is a symptom the worker described, not a
|
|
// lifecycle outcome Orchestra decided.
|
|
var inc domain.ObservationIncident
|
|
if json.Unmarshal(e.Payload, &inc) != nil || inc.Signature == "" {
|
|
continue
|
|
}
|
|
o := base
|
|
o.Kind = domain.ObservationWorkerFailure
|
|
o.TaskID = inc.TaskID
|
|
o.Detail = inc.Detail
|
|
o.Repeats = inc.RepeatCount
|
|
add(domain.DebtOperational,
|
|
domain.DebtSignature(domain.DebtOperational, inc.Signature, inc.WorkerID, "worker"),
|
|
o, "workers report "+inc.Signature, review.Important)
|
|
case domain.EventOperatorInterventionRecorded:
|
|
var in domain.OperatorIntervention
|
|
if json.Unmarshal(e.Payload, &in) != nil || !in.Kind.Valid() {
|
|
continue
|
|
}
|
|
o := base
|
|
o.Kind = domain.ObservationManualIntervention
|
|
o.TaskID = in.TaskID
|
|
o.Detail = in.Reason
|
|
o.Paths = in.Components
|
|
add(domain.DebtOperational,
|
|
domain.DebtSignature(domain.DebtOperational, string(in.Kind), in.WorkerID, "manual"),
|
|
o, "an operator repairs this by hand ("+string(in.Kind)+")", review.Important)
|
|
case domain.EventPlanMismatchRecorded:
|
|
o := base
|
|
o.Kind = domain.ObservationPlanMismatch
|
|
o.Detail = str(p["observed"])
|
|
o.Paths = strList(p["evidence"])
|
|
component := firstComponent(o.Paths)
|
|
add(domain.DebtCorrectness,
|
|
domain.DebtSignature(domain.DebtCorrectness, "plan_mismatch", "", component), o,
|
|
"the repository contradicts sealed plans", review.Important)
|
|
case domain.EventReviewRecorded:
|
|
// Repeated findings in one component are the only mechanical
|
|
// evidence that structure is costing future changes. One finding
|
|
// is a review doing its job.
|
|
if readArtifact == nil {
|
|
break
|
|
}
|
|
raw, err := readArtifact(str(p["artifact_ref"]))
|
|
if err != nil {
|
|
break
|
|
}
|
|
result, err := review.Decode(raw)
|
|
if err != nil {
|
|
break
|
|
}
|
|
for _, f := range result.Findings {
|
|
component := domain.DebtComponent(f.File)
|
|
o := base
|
|
o.Kind = domain.ObservationReviewFinding
|
|
o.Detail = f.Claim
|
|
if f.File != "" {
|
|
o.Paths = []string{f.File}
|
|
}
|
|
add(domain.DebtStructural,
|
|
domain.DebtSignature(domain.DebtStructural, string(f.Severity), "", component), o,
|
|
"review keeps finding "+string(f.Severity)+" issues in "+component, f.Severity)
|
|
}
|
|
case domain.EventDeferredFindingRecorded:
|
|
o := base
|
|
o.Kind = domain.ObservationDeferredFinding
|
|
o.Detail = str(p["summary"])
|
|
// Polish until something demonstrates a cost. A discovery nobody
|
|
// has paid for yet is not debt with a priority.
|
|
add(domain.DebtPolish,
|
|
domain.DebtSignature(domain.DebtPolish, "deferred_finding", "", ""), o,
|
|
"out-of-scope discoveries recorded and unaddressed", review.Minor)
|
|
}
|
|
}
|
|
|
|
items := make([]domain.DebtItem, 0, len(order))
|
|
for _, sig := range order {
|
|
items = append(items, *bySignature[sig])
|
|
}
|
|
sort.SliceStable(items, func(i, j int) bool {
|
|
if items[i].Recurrence() != items[j].Recurrence() {
|
|
return items[i].Recurrence() > items[j].Recurrence()
|
|
}
|
|
return items[i].ID < items[j].ID
|
|
})
|
|
return domain.DebtLedger{Items: items, Gaps: debtGaps(seenKinds), Events: len(events)}
|
|
}
|
|
|
|
// debtGaps separates two different silences. A kind no event type carries is a
|
|
// hole in the system. A kind the log could carry and does not is a fact about
|
|
// this history.
|
|
func debtGaps(seen map[domain.ObservationKind]bool) []domain.EvidenceGap {
|
|
// Both of these were once permanent holes in the system. They are ordinary
|
|
// evidence now, so their absence is a fact about this history rather than
|
|
// about Orchestra.
|
|
var gaps []domain.EvidenceGap
|
|
for _, k := range []domain.ObservationKind{
|
|
domain.ObservationBlockReason, domain.ObservationFailureClass,
|
|
domain.ObservationReviewFinding, domain.ObservationPlanMismatch,
|
|
domain.ObservationDeferredFinding, domain.ObservationWorkerFailure,
|
|
domain.ObservationManualIntervention,
|
|
} {
|
|
if !seen[k] {
|
|
gaps = append(gaps, domain.EvidenceGap{Kind: k, Durable: true,
|
|
Reason: "carried by the log, but this history contains none"})
|
|
}
|
|
}
|
|
return gaps
|
|
}
|
|
|
|
// DebtLedger projects the whole log. Read-only, like every other projection
|
|
// the coordinator serves.
|
|
func (s *Store) DebtLedger() domain.DebtLedger {
|
|
return ProjectDebtWithArtifacts(s.Events(0), s.Artifact)
|
|
}
|
|
|
|
func str(v any) string { s, _ := v.(string); return s }
|
|
|
|
func strList(v any) []string {
|
|
list, ok := v.([]any)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
out := make([]string, 0, len(list))
|
|
for _, item := range list {
|
|
if s := strings.TrimSpace(str(item)); s != "" {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func firstComponent(paths []string) string {
|
|
for _, p := range paths {
|
|
if c := domain.DebtComponent(p); c != "" {
|
|
return c
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func contains(list []string, s string) bool {
|
|
for _, item := range list {
|
|
if item == s {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|