Make worker observations durable as incidents, not as symptoms
Slice B, first half. The F18 ring is bounded, lossy and local, so the debt ledger reported it as a gap about itself. Two events make it durable: ObservationIncidentOpened at first sight, appended immediately so a coordinator that dies mid-incident still leaves the fact that it existed, and ObservationIncidentClosed carrying the aggregate. The rules are what matter. Repeats update the aggregate and append nothing, because run 11's 409 loop was one incident with an intensity of 301 rather than 301 pieces of evidence. Absence from the ring closes nothing, since a bounded history evicts as easily as it recovers. An incident is scoped to its lease and closes on lease end, epoch change, worker restart, or, for observations with no lease to bound them, on last_seen going stale. A ring entry that is evicted and recreated accumulates: 34 then 3 is 37. Also collapses three drifted copies of the list of events that carry no task into one predicate. Adding a type to two of them left it rejected by the third, which is how the first version of this failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
This commit is contained in:
+51
-3
@@ -49,6 +49,9 @@ type Store struct {
|
||||
cursors map[string]string
|
||||
cursorPath string
|
||||
decisionSource map[string]string
|
||||
// openObservations are the incidents opened and not yet closed, by id.
|
||||
// Derived from the log, so a restart finds them again.
|
||||
openObservations map[string]domain.ObservationIncident
|
||||
// PreLease runs immediately before a lease is minted, which is the single
|
||||
// point where ownership of a task begins. Reconciliation of newer human
|
||||
// input belongs here rather than in any individual launch path, because a
|
||||
@@ -96,7 +99,7 @@ func Open(dir string) (*Store, error) {
|
||||
if t, ok := s.tasks[e.TaskID]; ok && e.Version != t.Version+1 {
|
||||
return nil, domain.ErrConflict
|
||||
}
|
||||
global := e.Type == "QuotaReported" || e.Type == "StandupAdvisory" || e.Type == "ApprovalGranted" || e.Type == "ApprovalDenied"
|
||||
global := domain.EventWithoutTask(e.Type)
|
||||
if _, ok := s.tasks[e.TaskID]; !ok && e.Type != "TaskCreated" && !global {
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
@@ -490,6 +493,26 @@ func (s *Store) apply(e domain.Event) error {
|
||||
t.Version = e.Version
|
||||
s.replaceTask(e.TaskID, t)
|
||||
return nil
|
||||
case domain.EventObservationIncidentOpened:
|
||||
// Open incidents are projected so a coordinator restart resumes them
|
||||
// instead of orphaning them half-recorded. The event is what makes an
|
||||
// incident durable at first sight; this is how it is found again.
|
||||
var inc domain.ObservationIncident
|
||||
if err := json.Unmarshal(e.Payload, &inc); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.openObservations == nil {
|
||||
s.openObservations = map[string]domain.ObservationIncident{}
|
||||
}
|
||||
s.openObservations[inc.ID] = inc
|
||||
return nil
|
||||
case domain.EventObservationIncidentClosed:
|
||||
var inc domain.ObservationIncident
|
||||
if err := json.Unmarshal(e.Payload, &inc); err != nil {
|
||||
return err
|
||||
}
|
||||
delete(s.openObservations, inc.ID)
|
||||
return nil
|
||||
case domain.EventPlanMismatchRecorded:
|
||||
// Projected so the phase this reopens can be told what reopened it.
|
||||
// The event is the record; this is the live instruction derived from
|
||||
@@ -847,7 +870,7 @@ func (s *Store) Append(e domain.Event) error {
|
||||
return fmt.Errorf("%w: corrects references unknown event %q for this task", domain.ErrInvalid, corrects)
|
||||
}
|
||||
}
|
||||
global := e.Type == "QuotaReported" || e.Type == "StandupAdvisory" || e.Type == "ApprovalGranted" || e.Type == "ApprovalDenied"
|
||||
global := domain.EventWithoutTask(e.Type)
|
||||
if !taskExists && e.Type != "TaskCreated" && !global {
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
@@ -909,7 +932,7 @@ func (s *Store) Append(e domain.Event) error {
|
||||
// for every lifecycle mutation.
|
||||
func (s *Store) validateTransition(e domain.Event, t domain.Task, exists bool, p map[string]any) error {
|
||||
if !exists {
|
||||
if e.Type != "TaskCreated" && e.Type != "QuotaReported" && e.Type != "StandupAdvisory" && e.Type != "ApprovalGranted" && e.Type != "ApprovalDenied" {
|
||||
if e.Type != "TaskCreated" && !domain.EventWithoutTask(e.Type) {
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
@@ -1113,6 +1136,31 @@ func (s *Store) Artifact(ref string) ([]byte, error) {
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// NoteObservation updates an open incident's last actual occurrence. It writes
|
||||
// no event: the aggregate is durable when the incident is finalized, and
|
||||
// appending one per heartbeat is exactly the spam this design exists to avoid.
|
||||
func (s *Store) NoteObservation(inc domain.ObservationIncident) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.openObservations[inc.ID]; ok {
|
||||
s.openObservations[inc.ID] = inc
|
||||
}
|
||||
}
|
||||
|
||||
// OpenObservations returns the incidents that are open, newest first by first
|
||||
// sight. Open means not yet finalized as durable evidence, never "the failure
|
||||
// is happening right now".
|
||||
func (s *Store) OpenObservations() []domain.ObservationIncident {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]domain.ObservationIncident, 0, len(s.openObservations))
|
||||
for _, inc := range s.openObservations {
|
||||
out = append(out, inc)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].FirstSeen.After(out[j].FirstSeen) })
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Store) Task(id string) (domain.Task, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
Reference in New Issue
Block a user