Skip to main content

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/// Retire obsolete local work without changing the PR on the forge.
8pub const DISMISS: &str = "dismiss: end obsolete approval without changing PR";
9/// Prefix followed by the exact configured agent id.
10pub const REIMPLEMENT: &str = "reimplement:";
11
12/// The target displayed by an approval card, retained across legacy upgrades.
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct Target {
15    /// PR URL at the time of the decision.
16    pub pr: String,
17    /// Exact head displayed in the decision panel.
18    pub head: String,
19    /// Operator language used by the card.
20    pub language: String,
21}
22
23/// A stop decision survives head movement and driver restarts. The question
24/// retains the owner answer; this record checkpoints its application.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct Decision {
27    /// Approval question id and operation idempotency key.
28    pub question: String,
29    /// Immutable PR and displayed head binding.
30    pub target: Target,
31    /// Exact owner choice token, including the selected implementer.
32    pub choice: String,
33    #[serde(default)]
34    /// True only after the forge confirms the PR is closed without merging.
35    pub closed: bool,
36    #[serde(default)]
37    /// New attempt id, recorded before dispatching its agents.
38    pub replacement_run: Option<String>,
39    #[serde(default)]
40    /// New PR URL, once the replacement publishes it.
41    pub replacement_pr: Option<String>,
42}
43
44/// Same task and attachments, a new attempt with an immutable implementer pin.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct Replacement {
47    /// Approval question id and operation idempotency key.
48    pub question: String,
49    /// Previous execution, which must have stopped before dispatch.
50    pub run: String,
51    /// PR URL at the time of the decision.
52    pub pr: String,
53    /// Configured implementer id; never substituted by fallback.
54    pub agent: String,
55    /// Review evidence carried alongside the original instruction.
56    pub context: String,
57}
58
59/// Extract the explicit implementer from a choice token.
60pub fn selected_agent(choice: &str) -> Option<&str> {
61    choice.strip_prefix(REIMPLEMENT).filter(|id| !id.is_empty())
62}
63
64/// Whether this choice must stop landing before any further mutations.
65pub fn stops(choice: &str) -> bool {
66    choice.eq_ignore_ascii_case(crate::land::HOLD)
67        || choice == WITHDRAW
68        || choice == DISMISS
69        || selected_agent(choice).is_some()
70}
71
72/// Apply a dismissal while the question writer holds its lock. Unlike withdrawal,
73/// this needs no forge access or queue owner. Claims serialize with a restarting
74/// daemon; the publication lock serializes with standalone driver takeover.
75/// Persist the stop before settling the answer so a partial write fails closed
76/// and retrying the same question completes the operation.
77pub(crate) fn dismiss(home: &std::path::Path, q: &crate::ask::Question) -> anyhow::Result<()> {
78    use crate::run::{Liveness, RunState, RunStatus};
79    use anyhow::ensure;
80
81    ensure!(
82        q.node == crate::land::APPROVAL_NODE,
83        "only merge approvals can be dismissed"
84    );
85    let queue = crate::queue::Queue::at(home.join("queue"));
86    let owners: Vec<_> = queue
87        .list()
88        .into_iter()
89        .filter(|t| t.runs.contains(&q.run))
90        .collect();
91    let mut claims = Vec::new();
92    for task in &owners {
93        claims.push(queue.claim(&task.id)?);
94    }
95    let _lock = crate::pr_head::owner_lock(home, &q.run)?;
96    let mut state = RunState::load_under(&q.run, home)?;
97    ensure!(
98        state.liveness(false) == Liveness::Dead,
99        "stop the current driver before dismissing this obsolete approval"
100    );
101    ensure!(
102        state.land_armed_head.is_none(),
103        "disable existing PR auto-merge before dismissing this approval"
104    );
105    let binding = state
106        .land_approval
107        .as_ref()
108        .filter(|b| b.question == q.id)
109        .ok_or_else(|| anyhow::anyhow!("this is no longer the run's approval question"))?;
110    let pr = state
111        .pr
112        .as_ref()
113        .ok_or_else(|| anyhow::anyhow!("approval has no recorded PR"))?;
114    let target = q.merge_target.clone().unwrap_or_else(|| Target {
115        pr: pr.url.clone(),
116        head: binding.head.clone(),
117        language: state.config.graph.language.clone(),
118    });
119    ensure!(
120        target.pr == pr.url && target.head == binding.head,
121        "approval target does not match the recorded run"
122    );
123    ensure!(
124        state
125            .merge_decision
126            .as_ref()
127            .is_none_or(|d| d.question == q.id && d.choice == DISMISS),
128        "another owner decision already controls this run"
129    );
130    if state.merge_decision.is_none() {
131        state.merge_decision = Some(Decision {
132            question: q.id.clone(),
133            target,
134            choice: DISMISS.into(),
135            closed: false,
136            replacement_run: None,
137            replacement_pr: None,
138        });
139        if !state.status.done() {
140            state.status = RunStatus::Superseded;
141        }
142        state.parked = false;
143        state.event(
144            "land",
145            "Owner dismissed obsolete approval; execution retired, PR unchanged",
146        );
147        state.save_under(home)?;
148    }
149    for owner in owners {
150        let mut task = queue.get(&owner.id)?;
151        // A later attempt or an already completed task owns its own outcome.
152        if task.runs.last() == Some(&q.run) && task.status != crate::queue::TaskStatus::Done {
153            task.succeed();
154            task.mark_action_applied(&q.id);
155            queue.put(&mut task)?;
156        }
157    }
158    Ok(())
159}
160
161/// Only an applied hold can be reopened by an explicit run/task resume.
162/// Failed closure and replacement decisions retain their stop checkpoint.
163pub(crate) fn resumable_hold(state: &crate::run::RunState) -> bool {
164    state.status == crate::run::RunStatus::Blocked
165        && state.merge_decision.as_ref().is_some_and(|decision| {
166            !decision.closed && decision.choice.eq_ignore_ascii_case(crate::land::HOLD)
167        })
168}
169
170/// Chat consultation may request a stop/repair only with the exact latest
171/// choice. Closing a PR explicitly names the consequence in its token.
172pub fn exact_owner_choice(choice: &str, message: &str, quote: &str) -> bool {
173    if choice.eq_ignore_ascii_case(crate::land::HOLD) {
174        message.trim().eq_ignore_ascii_case(choice) && quote.trim().eq_ignore_ascii_case(choice)
175    } else {
176        (stops(choice) || choice == crate::land::REPAIR)
177            && message.trim() == choice
178            && quote.trim() == choice
179    }
180}