magi/merge_decision.rs
1//! Persisted owner decisions at the merge gate. Tokens are independent of UI prose.
2
3use serde::{Deserialize, Serialize};
4
5/// Explicit closure confirmation, also usable as a CLI choice.
6pub const WITHDRAW: &str = "withdraw: close PR and end task";
7/// Prefix followed by the exact configured agent id.
8pub const REIMPLEMENT: &str = "reimplement:";
9
10/// The target displayed by an approval card, retained across legacy upgrades.
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12pub struct Target {
13 /// PR URL at the time of the decision.
14 pub pr: String,
15 /// Exact head displayed in the decision panel.
16 pub head: String,
17 /// Operator language used by the card.
18 pub language: String,
19}
20
21/// A stop decision survives head movement and driver restarts. The question
22/// retains the owner answer; this record checkpoints its application.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct Decision {
25 /// Approval question id and operation idempotency key.
26 pub question: String,
27 /// Immutable PR and displayed head binding.
28 pub target: Target,
29 /// Exact owner choice token, including the selected implementer.
30 pub choice: String,
31 #[serde(default)]
32 /// True only after the forge confirms the PR is closed without merging.
33 pub closed: bool,
34 #[serde(default)]
35 /// New attempt id, recorded before dispatching its agents.
36 pub replacement_run: Option<String>,
37 #[serde(default)]
38 /// New PR URL, once the replacement publishes it.
39 pub replacement_pr: Option<String>,
40}
41
42/// Same task and attachments, a new attempt with an immutable implementer pin.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct Replacement {
45 /// Approval question id and operation idempotency key.
46 pub question: String,
47 /// Previous execution, which must have stopped before dispatch.
48 pub run: String,
49 /// PR URL at the time of the decision.
50 pub pr: String,
51 /// Configured implementer id; never substituted by fallback.
52 pub agent: String,
53 /// Review evidence carried alongside the original instruction.
54 pub context: String,
55}
56
57/// Extract the explicit implementer from a choice token.
58pub fn selected_agent(choice: &str) -> Option<&str> {
59 choice.strip_prefix(REIMPLEMENT).filter(|id| !id.is_empty())
60}
61
62/// Whether this choice must stop landing before any further mutations.
63pub fn stops(choice: &str) -> bool {
64 choice.eq_ignore_ascii_case(crate::land::HOLD)
65 || choice == WITHDRAW
66 || selected_agent(choice).is_some()
67}
68
69/// Only an applied hold can be reopened by an explicit run/task resume.
70/// Failed closure and replacement decisions retain their stop checkpoint.
71pub(crate) fn resumable_hold(state: &crate::run::RunState) -> bool {
72 state.status == crate::run::RunStatus::Blocked
73 && state.merge_decision.as_ref().is_some_and(|decision| {
74 !decision.closed && decision.choice.eq_ignore_ascii_case(crate::land::HOLD)
75 })
76}
77
78/// Chat consultation may request a stop/repair only with the exact latest
79/// choice. Closing a PR explicitly names the consequence in its token.
80pub fn exact_owner_choice(choice: &str, message: &str, quote: &str) -> bool {
81 if choice.eq_ignore_ascii_case(crate::land::HOLD) {
82 message.trim().eq_ignore_ascii_case(choice) && quote.trim().eq_ignore_ascii_case(choice)
83 } else {
84 (stops(choice) || choice == crate::land::REPAIR)
85 && message.trim() == choice
86 && quote.trim() == choice
87 }
88}