| 1 | package recovery |
| 2 | |
| 3 | // Route is the pure decision outcome for a proposed action. |
| 4 | type Route int |
| 5 | |
| 6 | const ( |
| 7 | // RouteBypass leaves the call to the ordinary Ask/YOLO approval path. |
| 8 | RouteBypass Route = iota |
| 9 | // RouteAllow lets Auto execute without a human card or reviewer call. |
| 10 | RouteAllow |
| 11 | // RouteReview hands an ambiguous recovery mutation to the isolated reviewer. |
| 12 | RouteReview |
| 13 | // RouteStop blocks one exact operation after repeated technical failure. |
| 14 | // Other operations in the same Episode may still proceed. |
| 15 | RouteStop |
| 16 | // RouteStopTurn blocks further execution after an Episode-level hard limit. |
| 17 | // Host-proven read-only diagnosis remains available. |
| 18 | RouteStopTurn |
| 19 | ) |
| 20 | |
| 21 | // String returns a stable route name for tests and diagnostics. |
| 22 | func (r Route) String() string { |
| 23 | switch r { |
| 24 | case RouteBypass: |
| 25 | return "bypass" |
| 26 | case RouteAllow: |
| 27 | return "allow" |
| 28 | case RouteReview: |
| 29 | return "review" |
| 30 | case RouteStop: |
| 31 | return "stop" |
| 32 | case RouteStopTurn: |
| 33 | return "stop_turn" |
| 34 | default: |
| 35 | return "unknown" |
| 36 | } |
| 37 | } |
| 38 | |
| 39 | // Facts are the host-observed inputs for the pure decision engine. |
| 40 | // The engine never locks, calls a model, shows UI, or mutates state. |
| 41 | type Facts struct { |
| 42 | // AutoMode is true only when tool-approval mode is Auto. |
| 43 | AutoMode bool |
| 44 | |
| 45 | // Proposal classification. |
| 46 | ReadOnly bool |
| 47 | Mutates bool |
| 48 | Verification bool |
| 49 | HighRisk bool |
| 50 | // PlanTransition is a host-observed structural rewrite of an active plan. |
| 51 | PlanTransition bool |
| 52 | |
| 53 | // Active failure context (zero values when none). |
| 54 | HasActiveFailure bool |
| 55 | SameFailedOperation bool |
| 56 | ExpandedScope bool |
| 57 | StrategyChanged bool |
| 58 | SafeRetryAvailable bool |
| 59 | // FailureCount is the exact-operation failure count (1 = first failure). |
| 60 | FailureCount uint8 |
| 61 | // EpisodeFailureCount is the Task's total qualifying failures since last |
| 62 | // real progress inside the current Episode. |
| 63 | EpisodeFailureCount uint8 |
| 64 | // ReviewRejects is the cumulative reviewer rejection count for the Episode. |
| 65 | ReviewRejects uint8 |
| 66 | // OperationAlreadyStopped is true when this exact operation already hit its |
| 67 | // per-operation limit earlier in the Episode. |
| 68 | OperationAlreadyStopped bool |
| 69 | // EpisodeStopped is true when a previous decision already exhausted the |
| 70 | // Episode for this Task. |
| 71 | EpisodeStopped bool |
| 72 | // StopReason is the reason the Episode was stopped (when EpisodeStopped). |
| 73 | StopReason StopReason |
| 74 | } |
| 75 | |
| 76 | // DecisionResult is the pure routing result. |
| 77 | type DecisionResult struct { |
| 78 | Route Route |
| 79 | // ConsumeSafeRetry is set when RouteAllow was chosen because this is the |
| 80 | // first safe verification retry; the coordinator must spend the budget. |
| 81 | ConsumeSafeRetry bool |
| 82 | // StopReason is set for RouteStop / RouteStopTurn. |
| 83 | StopReason StopReason |
| 84 | } |
| 85 | |
| 86 | // Decide is the pure Auto Guard decision engine. |
| 87 | // |
| 88 | // Order is fixed by product policy: |
| 89 | // 1. non-Auto → bypass ordinary approval |
| 90 | // 2. Episode already stopped → allow read-only diagnosis, stop execution |
| 91 | // 3. structured plan transition → reviewer |
| 92 | // 4. read-only diagnosis → allow |
| 93 | // 5. no active failure → allow ordinary mutations |
| 94 | // 6. operations other than the exact failed operation → allow |
| 95 | // 7. first safe verification retry → allow (+ consume budget) |
| 96 | // 8. already-stopped operation re-proposal is handled by the gate (retries) |
| 97 | // 9. three consecutive failures of the same operation → stop that operation |
| 98 | // 10. remaining exact-operation retries → reviewer |
| 99 | // |
| 100 | // Episode-level totals (6 failures / 3 review rejects / 3 stopped-op retries) |
| 101 | // are enforced by the gate before or after Decide; Decide focuses on pure |
| 102 | // routing of one proposal given Facts. |
| 103 | func Decide(f Facts) DecisionResult { |
| 104 | if !f.AutoMode { |
| 105 | return DecisionResult{Route: RouteBypass} |
| 106 | } |
| 107 | if f.EpisodeStopped { |
| 108 | if f.ReadOnly && !f.Mutates && !f.Verification && !f.PlanTransition { |
| 109 | return DecisionResult{Route: RouteAllow} |
| 110 | } |
| 111 | reason := f.StopReason |
| 112 | if reason == StopReasonNone { |
| 113 | reason = StopReasonEpisodeFailures |
| 114 | } |
| 115 | return DecisionResult{Route: RouteStopTurn, StopReason: reason} |
| 116 | } |
| 117 | if f.PlanTransition { |
| 118 | return DecisionResult{Route: RouteReview} |
| 119 | } |
| 120 | // Non-mutating, non-verification calls (and host-proven read-only tools) |
| 121 | // always continue so diagnosis can proceed without cards. |
| 122 | if f.ReadOnly && !f.Mutates { |
| 123 | return DecisionResult{Route: RouteAllow} |
| 124 | } |
| 125 | if !f.Mutates && !f.Verification { |
| 126 | return DecisionResult{Route: RouteAllow} |
| 127 | } |
| 128 | if !f.HasActiveFailure { |
| 129 | return DecisionResult{Route: RouteAllow} |
| 130 | } |
| 131 | if f.SafeRetryAvailable { |
| 132 | return DecisionResult{Route: RouteAllow, ConsumeSafeRetry: true} |
| 133 | } |
| 134 | // A failure is an execution-reliability signal, not a task-wide safety |
| 135 | // boundary. Keep unrelated work on the zero-confirmation path; permission, |
| 136 | // sandbox, and the Episode ceiling still apply independently. |
| 137 | if !f.SameFailedOperation { |
| 138 | return DecisionResult{Route: RouteAllow} |
| 139 | } |
| 140 | // Already-stopped exact operation: gate escalates retries; Decide stops the |
| 141 | // operation so the agent cannot re-run it. |
| 142 | if f.OperationAlreadyStopped && f.SameFailedOperation { |
| 143 | return DecisionResult{Route: RouteStop, StopReason: StopReasonOperationFailures} |
| 144 | } |
| 145 | // Repeated technical failure of the same exact operation is not a |
| 146 | // user-owned product decision. Stop only that operation. |
| 147 | if f.FailureCount >= MaxOperationFailures && f.SameFailedOperation { |
| 148 | return DecisionResult{Route: RouteStop, StopReason: StopReasonOperationFailures} |
| 149 | } |
| 150 | return DecisionResult{Route: RouteReview} |
| 151 | } |
| 152 |