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_under(
99            home,
100            crate::daemon::is_working_on(home, &state.id, jiff::Timestamp::now())
101        ) == Liveness::Dead,
102        "stop the current driver before dismissing this obsolete approval"
103    );
104    ensure!(
105        state.land_armed_head.is_none(),
106        "disable existing PR auto-merge before dismissing this approval"
107    );
108    let binding = state
109        .land_approval
110        .as_ref()
111        .filter(|b| b.question == q.id)
112        .ok_or_else(|| anyhow::anyhow!("this is no longer the run's approval question"))?;
113    let pr = state
114        .pr
115        .as_ref()
116        .ok_or_else(|| anyhow::anyhow!("approval has no recorded PR"))?;
117    let target = q.merge_target.clone().unwrap_or_else(|| Target {
118        pr: pr.url.clone(),
119        head: binding.head.clone(),
120        language: state.config.graph.language.clone(),
121    });
122    ensure!(
123        target.pr == pr.url && target.head == binding.head,
124        "approval target does not match the recorded run"
125    );
126    ensure!(
127        state
128            .merge_decision
129            .as_ref()
130            .is_none_or(|d| d.question == q.id && d.choice == DISMISS),
131        "another owner decision already controls this run"
132    );
133    if state.merge_decision.is_none() {
134        state.merge_decision = Some(Decision {
135            question: q.id.clone(),
136            target,
137            choice: DISMISS.into(),
138            closed: false,
139            replacement_run: None,
140            replacement_pr: None,
141        });
142        if !state.status.done() {
143            state.status = RunStatus::Superseded;
144        }
145        state.parked = false;
146        state.event(
147            "land",
148            "Owner dismissed obsolete approval; execution retired, PR unchanged",
149        );
150        state.save_under(home)?;
151    }
152    for owner in owners {
153        let mut task = queue.get(&owner.id)?;
154        // A later attempt or an already completed task owns its own outcome.
155        if task.runs.last() == Some(&q.run) && task.status != crate::queue::TaskStatus::Done {
156            task.succeed();
157            task.mark_action_applied(&q.id);
158            queue.put(&mut task)?;
159        }
160    }
161    Ok(())
162}
163
164/// Only an applied hold can be reopened by an explicit run/task resume.
165/// Failed closure and replacement decisions retain their stop checkpoint.
166pub(crate) fn resumable_hold(state: &crate::run::RunState) -> bool {
167    state.status == crate::run::RunStatus::Blocked
168        && state.merge_decision.as_ref().is_some_and(|decision| {
169            !decision.closed && decision.choice.eq_ignore_ascii_case(crate::land::HOLD)
170        })
171}
172
173/// Chat consultation may request a stop/repair only with the exact latest
174/// choice. Closing a PR explicitly names the consequence in its token.
175pub fn exact_owner_choice(choice: &str, message: &str, quote: &str) -> bool {
176    if choice.eq_ignore_ascii_case(crate::land::HOLD) {
177        message.trim().eq_ignore_ascii_case(choice) && quote.trim().eq_ignore_ascii_case(choice)
178    } else {
179        (stops(choice) || choice == crate::land::REPAIR)
180            && message.trim() == choice
181            && quote.trim() == choice
182    }
183}