Skip to main content

agent_doc_flow/
outcome.rs

1use serde::{Deserialize, Serialize};
2use std::fmt;
3
4pub const BINARY_OUTCOME_CONTRACT_VERSION: &str = "binary-outcome-v1";
5pub const USER_FACING_OUTCOME_CONTRACT_VERSION: &str = "ui-outcome-v1";
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case")]
9pub enum BinaryOutcomeClass {
10    Ok,
11    Recoverable,
12    Blocked,
13    Operator,
14}
15
16impl BinaryOutcomeClass {
17    pub const fn as_str(self) -> &'static str {
18        match self {
19            Self::Ok => "ok",
20            Self::Recoverable => "recoverable",
21            Self::Blocked => "blocked",
22            Self::Operator => "operator",
23        }
24    }
25}
26
27impl fmt::Display for BinaryOutcomeClass {
28    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29        f.write_str(self.as_str())
30    }
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum UserFacingOutcomeKind {
36    QueuedBehindOwner,
37    RecoveredAndRetried,
38    DeferredForOperatorProof,
39    /// The in-session loop has no drainable head, but a `[focused-cycle]` head
40    /// remains that the SUPERVISOR clear-and-continue path will drain (force
41    /// `/clear` + re-dispatch to a fresh context). No operator action is required;
42    /// the in-session agent simply ends its turn so the supervisor takes over
43    /// (`#qfocsup`).
44    DeferredForSupervisorDrain,
45    /// `#turnsaferecycle` Goal 3 — the hosting supervisor is running a stale binary,
46    /// so the current turn phase (preflight / route / stream / session-check / write)
47    /// skips its doomed IPC write, schedules the recycle (forced PCP recycle +
48    /// supervisor recycle-request), and defers uniformly instead of each phase
49    /// thrashing the buffer. No operator action is required; the recycle promotes the
50    /// fresh binary at the next idle boundary and the phase re-runs cleanly.
51    DeferredForRecycle,
52    NoDrainableWork,
53    RealComponentConflict,
54    BlockedWithExactUnblocker,
55}
56
57impl UserFacingOutcomeKind {
58    pub const fn as_str(self) -> &'static str {
59        match self {
60            Self::QueuedBehindOwner => "queued_behind_owner",
61            Self::RecoveredAndRetried => "recovered_and_retried",
62            Self::DeferredForOperatorProof => "deferred_for_operator_proof",
63            Self::DeferredForSupervisorDrain => "deferred_for_supervisor_drain",
64            Self::DeferredForRecycle => "deferred_for_recycle",
65            Self::NoDrainableWork => "no_drainable_work",
66            Self::RealComponentConflict => "real_component_conflict",
67            Self::BlockedWithExactUnblocker => "blocked_with_exact_unblocker",
68        }
69    }
70
71    pub const fn class(self) -> BinaryOutcomeClass {
72        match self {
73            Self::QueuedBehindOwner
74            | Self::NoDrainableWork
75            | Self::DeferredForSupervisorDrain
76            | Self::DeferredForRecycle => BinaryOutcomeClass::Ok,
77            Self::RecoveredAndRetried => BinaryOutcomeClass::Recoverable,
78            Self::DeferredForOperatorProof => BinaryOutcomeClass::Operator,
79            Self::RealComponentConflict | Self::BlockedWithExactUnblocker => {
80                BinaryOutcomeClass::Blocked
81            }
82        }
83    }
84
85    pub const fn next_action(self) -> &'static str {
86        match self {
87            Self::QueuedBehindOwner => "wait_for_owner_turn_to_drain",
88            Self::RecoveredAndRetried => "continue_after_recovery_retry",
89            Self::DeferredForOperatorProof => "operator_proof_required",
90            Self::DeferredForSupervisorDrain => "yield_to_supervisor_clear_and_continue",
91            Self::DeferredForRecycle => "yield_for_supervisor_recycle",
92            Self::NoDrainableWork => "no_agent_action",
93            Self::RealComponentConflict => "resolve_component_conflict",
94            Self::BlockedWithExactUnblocker => "follow_unblocker",
95        }
96    }
97}
98
99impl fmt::Display for UserFacingOutcomeKind {
100    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
101        f.write_str(self.as_str())
102    }
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106pub struct UserFacingOutcome {
107    pub contract_version: String,
108    pub outcome: UserFacingOutcomeKind,
109    pub class: BinaryOutcomeClass,
110    pub next_action: String,
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub unblocker: Option<String>,
113}
114
115impl UserFacingOutcome {
116    pub fn new(outcome: UserFacingOutcomeKind) -> Result<Self, BinaryOutcomeError> {
117        if outcome == UserFacingOutcomeKind::BlockedWithExactUnblocker {
118            return Err(BinaryOutcomeError::EmptyField { field: "unblocker" });
119        }
120        Ok(Self {
121            contract_version: USER_FACING_OUTCOME_CONTRACT_VERSION.to_string(),
122            outcome,
123            class: outcome.class(),
124            next_action: outcome.next_action().to_string(),
125            unblocker: None,
126        })
127    }
128
129    pub fn with_unblocker(
130        outcome: UserFacingOutcomeKind,
131        unblocker: impl Into<String>,
132    ) -> Result<Self, BinaryOutcomeError> {
133        let unblocker = validate_token("unblocker", unblocker.into())?;
134        Ok(Self {
135            contract_version: USER_FACING_OUTCOME_CONTRACT_VERSION.to_string(),
136            outcome,
137            class: outcome.class(),
138            next_action: outcome.next_action().to_string(),
139            unblocker: Some(unblocker),
140        })
141    }
142
143    pub fn log_fields(&self) -> String {
144        let fields = format!(
145            "ui_outcome_contract={} ui_outcome={} ui_outcome_class={} next_action={}",
146            self.contract_version,
147            self.outcome.as_str(),
148            self.class.as_str(),
149            self.next_action
150        );
151        match self.unblocker.as_deref() {
152            Some(unblocker) => format!("{fields} unblocker={unblocker}"),
153            None => fields,
154        }
155    }
156}
157
158pub fn user_outcome_fields(kind: UserFacingOutcomeKind) -> String {
159    UserFacingOutcome::new(kind)
160        .expect("static user-facing outcome is valid")
161        .log_fields()
162}
163
164pub fn blocked_with_exact_unblocker_fields(unblocker: &str) -> String {
165    UserFacingOutcome::with_unblocker(UserFacingOutcomeKind::BlockedWithExactUnblocker, unblocker)
166        .expect("static user-facing unblocker is valid")
167        .log_fields()
168}
169
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171pub struct BinaryOutcome {
172    pub contract_version: String,
173    pub class: BinaryOutcomeClass,
174    pub invariant_id: String,
175    pub proof_marker: String,
176    pub next_action: String,
177}
178
179impl BinaryOutcome {
180    pub fn new(
181        class: BinaryOutcomeClass,
182        invariant_id: impl Into<String>,
183        proof_marker: impl Into<String>,
184        next_action: impl Into<String>,
185    ) -> Result<Self, BinaryOutcomeError> {
186        let invariant_id = validate_token("invariant_id", invariant_id.into())?;
187        let proof_marker = validate_token("proof_marker", proof_marker.into())?;
188        let next_action = validate_token("next_action", next_action.into())?;
189        Ok(Self {
190            contract_version: BINARY_OUTCOME_CONTRACT_VERSION.to_string(),
191            class,
192            invariant_id,
193            proof_marker,
194            next_action,
195        })
196    }
197
198    pub fn ok(
199        invariant_id: impl Into<String>,
200        proof_marker: impl Into<String>,
201        next_action: impl Into<String>,
202    ) -> Result<Self, BinaryOutcomeError> {
203        Self::new(
204            BinaryOutcomeClass::Ok,
205            invariant_id,
206            proof_marker,
207            next_action,
208        )
209    }
210
211    pub fn recoverable(
212        invariant_id: impl Into<String>,
213        proof_marker: impl Into<String>,
214        next_action: impl Into<String>,
215    ) -> Result<Self, BinaryOutcomeError> {
216        Self::new(
217            BinaryOutcomeClass::Recoverable,
218            invariant_id,
219            proof_marker,
220            next_action,
221        )
222    }
223
224    pub fn blocked(
225        invariant_id: impl Into<String>,
226        proof_marker: impl Into<String>,
227        next_action: impl Into<String>,
228    ) -> Result<Self, BinaryOutcomeError> {
229        Self::new(
230            BinaryOutcomeClass::Blocked,
231            invariant_id,
232            proof_marker,
233            next_action,
234        )
235    }
236
237    pub fn operator(
238        invariant_id: impl Into<String>,
239        proof_marker: impl Into<String>,
240        next_action: impl Into<String>,
241    ) -> Result<Self, BinaryOutcomeError> {
242        Self::new(
243            BinaryOutcomeClass::Operator,
244            invariant_id,
245            proof_marker,
246            next_action,
247        )
248    }
249
250    pub fn log_fields(&self) -> String {
251        format!(
252            "binary_outcome={} invariant={} proof_marker={} next_action={}",
253            self.class.as_str(),
254            self.invariant_id,
255            self.proof_marker,
256            self.next_action
257        )
258    }
259}
260
261/// `#turnsaferecycle` Goal 3 — the production emitter for the `supervisor_freshness`
262/// binary-outcome contract. A turn phase that short-circuits a doomed IPC write
263/// against a stale supervisor (skips the write, schedules the recycle) records this
264/// recoverable outcome so the stale→self-recycle→retry contract is attributable in
265/// the flow log, not just asserted in a unit test.
266pub fn supervisor_stale_self_recycled_outcome() -> BinaryOutcome {
267    BinaryOutcome::recoverable(
268        "supervisor_freshness",
269        "supervisor_binary_stale_self_recycled",
270        "restart_supervisor_once_and_retry",
271    )
272    .expect("static supervisor_freshness outcome tokens are contract-valid")
273}
274
275/// `#turnsaferecycle` Goal 3 — the user-facing outcome every turn phase returns when
276/// it defers uniformly for a stale-supervisor recycle instead of thrashing the write.
277pub fn deferred_for_recycle_outcome() -> UserFacingOutcome {
278    UserFacingOutcome::new(UserFacingOutcomeKind::DeferredForRecycle)
279        .expect("deferred_for_recycle requires no unblocker")
280}
281
282#[derive(Debug, Clone, PartialEq, Eq)]
283pub enum BinaryOutcomeError {
284    EmptyField { field: &'static str },
285    InvalidToken { field: &'static str, value: String },
286}
287
288impl fmt::Display for BinaryOutcomeError {
289    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
290        match self {
291            Self::EmptyField { field } => write!(f, "{field} must not be empty"),
292            Self::InvalidToken { field, value } => {
293                write!(f, "{field} must be a single field-safe token: {value}")
294            }
295        }
296    }
297}
298
299impl std::error::Error for BinaryOutcomeError {}
300
301fn validate_token(field: &'static str, value: String) -> Result<String, BinaryOutcomeError> {
302    if value.trim().is_empty() {
303        return Err(BinaryOutcomeError::EmptyField { field });
304    }
305    if value.trim() != value || !value.chars().all(is_token_char) {
306        return Err(BinaryOutcomeError::InvalidToken { field, value });
307    }
308    Ok(value)
309}
310
311fn is_token_char(ch: char) -> bool {
312    ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.' | ':' | '/')
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318
319    #[test]
320    fn binary_outcome_records_required_contract_fields() {
321        let outcome = BinaryOutcome::recoverable(
322            "supervisor_freshness",
323            "supervisor_binary_stale_self_recycled",
324            "restart_supervisor_once_and_retry",
325        )
326        .unwrap();
327
328        assert_eq!(outcome.contract_version, "binary-outcome-v1");
329        assert_eq!(outcome.class, BinaryOutcomeClass::Recoverable);
330        assert_eq!(outcome.invariant_id, "supervisor_freshness");
331        assert_eq!(
332            outcome.log_fields(),
333            "binary_outcome=recoverable invariant=supervisor_freshness proof_marker=supervisor_binary_stale_self_recycled next_action=restart_supervisor_once_and_retry"
334        );
335    }
336
337    #[test]
338    fn supervisor_stale_self_recycled_and_deferred_for_recycle_emitters_are_contract_valid() {
339        // `#turnsaferecycle` Goal 3: the real emitters (not test-only inline
340        // constructions) produce the supervisor_freshness recoverable contract and
341        // the deferred_for_recycle user-facing outcome.
342        let binary = supervisor_stale_self_recycled_outcome();
343        assert_eq!(binary.class, BinaryOutcomeClass::Recoverable);
344        assert_eq!(binary.invariant_id, "supervisor_freshness");
345        assert_eq!(binary.proof_marker, "supervisor_binary_stale_self_recycled");
346        assert_eq!(binary.next_action, "restart_supervisor_once_and_retry");
347
348        let ui = deferred_for_recycle_outcome();
349        assert_eq!(ui.outcome, UserFacingOutcomeKind::DeferredForRecycle);
350        assert_eq!(ui.class, BinaryOutcomeClass::Ok);
351        assert_eq!(ui.next_action, "yield_for_supervisor_recycle");
352    }
353
354    #[test]
355    fn binary_outcome_classes_are_stable_snake_case() {
356        assert_eq!(BinaryOutcomeClass::Ok.as_str(), "ok");
357        assert_eq!(BinaryOutcomeClass::Recoverable.as_str(), "recoverable");
358        assert_eq!(BinaryOutcomeClass::Blocked.as_str(), "blocked");
359        assert_eq!(BinaryOutcomeClass::Operator.as_str(), "operator");
360
361        let json = serde_json::to_value(
362            BinaryOutcome::operator(
363                "two_editor_convergence",
364                "missing_live_vscode_ack",
365                "request_operator_live_proof",
366            )
367            .unwrap(),
368        )
369        .unwrap();
370
371        assert_eq!(json["class"], "operator");
372        assert_eq!(json["next_action"], "request_operator_live_proof");
373    }
374
375    #[test]
376    fn user_facing_outcome_vocabulary_is_stable_and_typed() {
377        use UserFacingOutcomeKind as Kind;
378
379        let cases = [
380            (
381                Kind::QueuedBehindOwner,
382                "queued_behind_owner",
383                BinaryOutcomeClass::Ok,
384                "wait_for_owner_turn_to_drain",
385            ),
386            (
387                Kind::RecoveredAndRetried,
388                "recovered_and_retried",
389                BinaryOutcomeClass::Recoverable,
390                "continue_after_recovery_retry",
391            ),
392            (
393                Kind::DeferredForOperatorProof,
394                "deferred_for_operator_proof",
395                BinaryOutcomeClass::Operator,
396                "operator_proof_required",
397            ),
398            (
399                Kind::DeferredForSupervisorDrain,
400                "deferred_for_supervisor_drain",
401                BinaryOutcomeClass::Ok,
402                "yield_to_supervisor_clear_and_continue",
403            ),
404            (
405                Kind::DeferredForRecycle,
406                "deferred_for_recycle",
407                BinaryOutcomeClass::Ok,
408                "yield_for_supervisor_recycle",
409            ),
410            (
411                Kind::NoDrainableWork,
412                "no_drainable_work",
413                BinaryOutcomeClass::Ok,
414                "no_agent_action",
415            ),
416            (
417                Kind::RealComponentConflict,
418                "real_component_conflict",
419                BinaryOutcomeClass::Blocked,
420                "resolve_component_conflict",
421            ),
422            (
423                Kind::BlockedWithExactUnblocker,
424                "blocked_with_exact_unblocker",
425                BinaryOutcomeClass::Blocked,
426                "follow_unblocker",
427            ),
428        ];
429
430        for (kind, token, class, next_action) in cases {
431            assert_eq!(kind.as_str(), token);
432            assert_eq!(kind.class(), class);
433            assert_eq!(kind.next_action(), next_action);
434        }
435
436        let json = serde_json::to_value(
437            UserFacingOutcome::new(Kind::QueuedBehindOwner)
438                .expect("queued outcome does not require an unblocker"),
439        )
440        .unwrap();
441        assert_eq!(json["contract_version"], "ui-outcome-v1");
442        assert_eq!(json["outcome"], "queued_behind_owner");
443        assert_eq!(json["class"], "ok");
444        assert_eq!(json["next_action"], "wait_for_owner_turn_to_drain");
445    }
446
447    #[test]
448    fn blocked_user_facing_outcome_requires_exact_unblocker() {
449        use UserFacingOutcomeKind as Kind;
450
451        assert_eq!(
452            UserFacingOutcome::new(Kind::BlockedWithExactUnblocker).unwrap_err(),
453            BinaryOutcomeError::EmptyField { field: "unblocker" }
454        );
455
456        let outcome = UserFacingOutcome::with_unblocker(
457            Kind::BlockedWithExactUnblocker,
458            "restore_idle_prompt",
459        )
460        .unwrap();
461        assert_eq!(
462            outcome.log_fields(),
463            "ui_outcome_contract=ui-outcome-v1 ui_outcome=blocked_with_exact_unblocker ui_outcome_class=blocked next_action=follow_unblocker unblocker=restore_idle_prompt"
464        );
465        assert!(matches!(
466            UserFacingOutcome::with_unblocker(
467                Kind::BlockedWithExactUnblocker,
468                "restore idle prompt"
469            )
470            .unwrap_err(),
471            BinaryOutcomeError::InvalidToken {
472                field: "unblocker",
473                ..
474            }
475        ));
476    }
477
478    #[test]
479    fn user_outcome_field_helpers_emit_contract_fields() {
480        use UserFacingOutcomeKind as Kind;
481
482        assert_eq!(
483            user_outcome_fields(Kind::QueuedBehindOwner),
484            "ui_outcome_contract=ui-outcome-v1 ui_outcome=queued_behind_owner ui_outcome_class=ok next_action=wait_for_owner_turn_to_drain"
485        );
486        assert_eq!(
487            blocked_with_exact_unblocker_fields("run_recovery_command"),
488            "ui_outcome_contract=ui-outcome-v1 ui_outcome=blocked_with_exact_unblocker ui_outcome_class=blocked next_action=follow_unblocker unblocker=run_recovery_command"
489        );
490    }
491
492    #[test]
493    fn binary_outcome_rejects_ambiguous_or_multi_action_fields() {
494        assert_eq!(
495            BinaryOutcome::ok("", "proof", "continue").unwrap_err(),
496            BinaryOutcomeError::EmptyField {
497                field: "invariant_id"
498            }
499        );
500
501        assert!(matches!(
502            BinaryOutcome::blocked("queue head", "proof", "stop").unwrap_err(),
503            BinaryOutcomeError::InvalidToken {
504                field: "invariant_id",
505                ..
506            }
507        ));
508
509        assert!(matches!(
510            BinaryOutcome::recoverable("queue_head", "proof", "retry,clear").unwrap_err(),
511            BinaryOutcomeError::InvalidToken {
512                field: "next_action",
513                ..
514            }
515        ));
516    }
517}