fix: make worker handoff rotation durable
This commit is contained in:
+121
-10
@@ -38,11 +38,69 @@ const (
|
||||
StateBlocked TaskState = "blocked"
|
||||
)
|
||||
|
||||
// BlockReason is the machine-readable diagnosis for a TaskBlocked event.
|
||||
// Blocker remains the operator-facing detail; this field lets projections
|
||||
// group attention without repeatedly parsing prose at read time.
|
||||
type BlockReason string
|
||||
|
||||
const (
|
||||
BlockReasonLeaseFailure BlockReason = "lease_failure"
|
||||
BlockReasonWorkerOffline BlockReason = "worker_offline"
|
||||
BlockReasonLeaseExpired BlockReason = "lease_expired"
|
||||
BlockReasonApproval BlockReason = "approval"
|
||||
BlockReasonHandoffValidation BlockReason = "handoff_validation"
|
||||
BlockReasonOperator BlockReason = "operator_block"
|
||||
BlockReasonSystem BlockReason = "system_error"
|
||||
BlockReasonUnknown BlockReason = "unknown"
|
||||
)
|
||||
|
||||
func (r BlockReason) Valid() bool {
|
||||
switch r {
|
||||
case BlockReasonLeaseFailure, BlockReasonWorkerOffline, BlockReasonLeaseExpired,
|
||||
BlockReasonApproval, BlockReasonHandoffValidation, BlockReasonOperator,
|
||||
BlockReasonSystem, BlockReasonUnknown:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// InferBlockReason supplies a stable category for older events which only
|
||||
// recorded a prose blocker. New producers should send block_reason directly.
|
||||
func InferBlockReason(blocker string) BlockReason {
|
||||
v := strings.ToLower(blocker)
|
||||
switch {
|
||||
case strings.Contains(v, "handoff"):
|
||||
return BlockReasonHandoffValidation
|
||||
case strings.Contains(v, "approval") || strings.Contains(v, "permission"):
|
||||
return BlockReasonApproval
|
||||
case strings.Contains(v, "expired") && strings.Contains(v, "lease"):
|
||||
return BlockReasonLeaseExpired
|
||||
case strings.Contains(v, "worker") && (strings.Contains(v, "offline") || strings.Contains(v, "unreachable")):
|
||||
return BlockReasonWorkerOffline
|
||||
case strings.Contains(v, "lease") || strings.Contains(v, "agent.start") || strings.Contains(v, "pane"):
|
||||
return BlockReasonLeaseFailure
|
||||
default:
|
||||
return BlockReasonSystem
|
||||
}
|
||||
}
|
||||
|
||||
type Estimate struct {
|
||||
Value float64 `json:"value"`
|
||||
Who string `json:"who"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
}
|
||||
|
||||
// SessionEvidence is captured by the machine that owns a pane immediately
|
||||
// before it drops its mapping. It is deliberately observation-only: it never
|
||||
// claims that a pane is still live after the worker has closed it.
|
||||
type SessionEvidence struct {
|
||||
PaneID string `json:"pane_id,omitempty"`
|
||||
HarnessID string `json:"harness_id,omitempty"`
|
||||
PaneState string `json:"pane_state,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
CapturedAt time.Time `json:"captured_at,omitempty"`
|
||||
CheckedAt time.Time `json:"checked_at,omitempty"`
|
||||
}
|
||||
type Lease struct {
|
||||
HarnessID string `json:"harness_id"`
|
||||
Until time.Time `json:"until"`
|
||||
@@ -62,17 +120,27 @@ type Task struct {
|
||||
// HandoffRef survives the queued interval between TaskReleased and the
|
||||
// next router-owned TaskLeased event; it is the only artifact the worker
|
||||
// may use for local pickup validation.
|
||||
HandoffRef string `json:"handoff_ref,omitempty"`
|
||||
Version int `json:"version"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
HandoffRef string `json:"handoff_ref,omitempty"`
|
||||
// ReleaseTransaction and ReleaseAnchor bind successor pickup to the exact
|
||||
// durable predecessor checkpoint. They survive queueing and re-lease.
|
||||
ReleaseTransaction string `json:"release_transaction,omitempty"`
|
||||
ReleaseAnchor string `json:"release_anchor,omitempty"`
|
||||
PickupTransaction string `json:"pickup_transaction,omitempty"`
|
||||
PickupLeaseVersion int `json:"pickup_lease_version,omitempty"`
|
||||
Version int `json:"version"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Acceptance []string `json:"acceptance,omitempty"`
|
||||
QualityGate string `json:"quality_gate,omitempty"`
|
||||
// Block evidence is projected from TaskBlocked so terminal records remain
|
||||
// diagnosable after the live coordinator mapping is gone.
|
||||
Blocker string `json:"blocker,omitempty"`
|
||||
BlockedAt time.Time `json:"blocked_at,omitempty"`
|
||||
LastPaneID string `json:"last_pane_id,omitempty"`
|
||||
LastHarness string `json:"last_harness_id,omitempty"`
|
||||
PaneState string `json:"pane_state,omitempty"` // open, closed, unreachable, unknown
|
||||
Blocker string `json:"blocker,omitempty"`
|
||||
BlockReason BlockReason `json:"block_reason,omitempty"`
|
||||
BlockedAt time.Time `json:"blocked_at,omitempty"`
|
||||
LastPaneID string `json:"last_pane_id,omitempty"`
|
||||
LastHarness string `json:"last_harness_id,omitempty"`
|
||||
PaneState string `json:"pane_state,omitempty"` // open, closed, unreachable, unknown
|
||||
LastSession SessionEvidence `json:"last_session,omitempty"`
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
@@ -112,7 +180,7 @@ func ValidateEvent(e Event) error {
|
||||
if e.SchemaVersion >= 2 && strings.TrimSpace(e.Surface) == "" {
|
||||
return fmt.Errorf("%w: surface required", ErrInvalid)
|
||||
}
|
||||
allowed := map[string]bool{"TaskCreated": true, "TaskLeased": true, "TaskReleased": true, "TaskCompleted": true, "TaskFailed": true, "TaskBlocked": true, "ApprovalRequested": true, "ApprovalGranted": true, "ApprovalDenied": true, "TaskAmended": true, "TaskCorrected": true, "QuotaReported": true, "StandupAdvisory": true}
|
||||
allowed := map[string]bool{"TaskCreated": true, "TaskLeased": true, "TaskLeaseRenewed": true, "TaskReleased": true, "TaskPickupValidated": true, "TaskCompleted": true, "TaskFailed": true, "TaskBlocked": true, "ApprovalRequested": true, "ApprovalGranted": true, "ApprovalDenied": true, "TaskAmended": true, "TaskCorrected": true, "QuotaReported": true, "StandupAdvisory": true}
|
||||
if !allowed[e.Type] {
|
||||
return fmt.Errorf("%w: unknown type %q", ErrInvalid, e.Type)
|
||||
}
|
||||
@@ -160,10 +228,36 @@ func ValidatePayload(typ string, p map[string]any) error {
|
||||
if v, ok := p["expected_version"].(float64); !ok || v < 0 || v != float64(int(v)) {
|
||||
return fmt.Errorf("%w: expected_version invalid", ErrInvalid)
|
||||
}
|
||||
case "TaskLeaseRenewed":
|
||||
if err := requiredString("harness_id"); err != nil {
|
||||
return err
|
||||
}
|
||||
until, ok := p["until_ns"].(float64)
|
||||
if !ok || until <= float64(time.Now().UnixNano()) {
|
||||
return fmt.Errorf("%w: until_ns required", ErrInvalid)
|
||||
}
|
||||
if v, ok := p["expected_version"].(float64); !ok || v < 0 || v != float64(int(v)) {
|
||||
return fmt.Errorf("%w: expected_version invalid", ErrInvalid)
|
||||
}
|
||||
case "TaskReleased":
|
||||
if err := requiredString("handoff_ref"); err != nil && p["reason"] == nil {
|
||||
return err
|
||||
}
|
||||
case "TaskPickupValidated":
|
||||
for _, key := range []string{"transaction_id", "handoff_ref", "anchor_sha", "harness_id"} {
|
||||
if err := requiredString(key); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := requiredHash(p, "handoff_ref"); err != nil {
|
||||
return err
|
||||
}
|
||||
if v, ok := p["anchor_sha"].(string); !ok || len(v) != 40 {
|
||||
return fmt.Errorf("%w: anchor_sha invalid", ErrInvalid)
|
||||
}
|
||||
if v, ok := p["lease_version"].(float64); !ok || v < 1 || v != float64(int(v)) {
|
||||
return fmt.Errorf("%w: lease_version invalid", ErrInvalid)
|
||||
}
|
||||
if _, ok := p["handoff_ref"]; ok {
|
||||
if err := requiredHash(p, "handoff_ref"); err != nil {
|
||||
return err
|
||||
@@ -183,6 +277,17 @@ func ValidatePayload(typ string, p map[string]any) error {
|
||||
if receipt, ok := p["receipt"].(map[string]any); !ok || len(receipt) == 0 {
|
||||
return fmt.Errorf("%w: receipt required", ErrInvalid)
|
||||
}
|
||||
if v, ok := p["result_sha"]; ok {
|
||||
if s, ok := v.(string); !ok || len(s) != 40 {
|
||||
return fmt.Errorf("%w: result_sha invalid", ErrInvalid)
|
||||
}
|
||||
if err := requiredString("branch"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := requiredString("remote"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case "TaskFailed":
|
||||
if err := requiredString("reason"); err != nil {
|
||||
return err
|
||||
@@ -191,6 +296,12 @@ func ValidatePayload(typ string, p map[string]any) error {
|
||||
if err := requiredString("blocker"); err != nil {
|
||||
return err
|
||||
}
|
||||
if v, ok := p["block_reason"]; ok {
|
||||
s, ok := v.(string)
|
||||
if !ok || !BlockReason(s).Valid() {
|
||||
return fmt.Errorf("%w: block_reason invalid", ErrInvalid)
|
||||
}
|
||||
}
|
||||
if _, ok := p["handoff_ref"]; ok {
|
||||
if err := requiredHash(p, "handoff_ref"); err != nil {
|
||||
return err
|
||||
|
||||
Reference in New Issue
Block a user