Skip to main content

falsegreen_agent/
falsegreen.rs

1use std::fmt;
2use std::path::Path;
3use std::thread;
4use std::time::{Duration, Instant};
5
6use serde::{Deserialize, Serialize};
7use serde_json::{Value, json};
8use thiserror::Error;
9
10use crate::workspace::Workspace;
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum FalseGreenVerdict {
15    Accepted,
16    Failed,
17    Incomplete,
18    InsufficientEvidence,
19    Invalid,
20}
21
22/// Completion permission is independent from the canonical verification result.
23/// Both flags must be true before the harness may enter `Completed`.
24#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
25pub struct CompletionAuthority {
26    #[serde(default)]
27    pub authority_ready: bool,
28    #[serde(default)]
29    pub may_claim_complete: bool,
30}
31
32impl CompletionAuthority {
33    #[must_use]
34    pub const fn permits_completion(self) -> bool {
35        self.authority_ready && self.may_claim_complete
36    }
37}
38
39#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
40pub struct FalseGreenResult {
41    #[serde(alias = "verdict")]
42    pub verification: FalseGreenVerdict,
43    #[serde(alias = "canonical_status")]
44    pub verification_status: String,
45    #[serde(default)]
46    pub completion_authority: CompletionAuthority,
47    pub repairable: bool,
48    pub candidate_sha256: String,
49    pub authoritative_source_sha256: Option<String>,
50    pub run_id: Option<String>,
51    pub evidence: Value,
52}
53
54impl FalseGreenResult {
55    #[must_use]
56    pub fn accepted(candidate_sha256: impl Into<String>) -> Self {
57        Self {
58            verification: FalseGreenVerdict::Accepted,
59            verification_status: "accepted".to_owned(),
60            completion_authority: CompletionAuthority {
61                authority_ready: true,
62                may_claim_complete: true,
63            },
64            repairable: false,
65            candidate_sha256: candidate_sha256.into(),
66            authoritative_source_sha256: None,
67            run_id: None,
68            evidence: json!({"status": "accepted"}),
69        }
70    }
71
72    #[must_use]
73    pub fn accepted_awaiting_authority(candidate_sha256: impl Into<String>) -> Self {
74        Self {
75            verification: FalseGreenVerdict::Accepted,
76            verification_status: "accepted".to_owned(),
77            completion_authority: CompletionAuthority::default(),
78            repairable: false,
79            candidate_sha256: candidate_sha256.into(),
80            authoritative_source_sha256: None,
81            run_id: None,
82            evidence: json!({"status": "accepted"}),
83        }
84    }
85
86    #[must_use]
87    pub fn failed(candidate_sha256: impl Into<String>, evidence: Value) -> Self {
88        Self {
89            verification: FalseGreenVerdict::Failed,
90            verification_status: "failed".to_owned(),
91            completion_authority: CompletionAuthority::default(),
92            repairable: true,
93            candidate_sha256: candidate_sha256.into(),
94            authoritative_source_sha256: None,
95            run_id: None,
96            evidence,
97        }
98    }
99
100    #[must_use]
101    pub fn permits_completion(&self) -> bool {
102        self.verification == FalseGreenVerdict::Accepted
103            && self.completion_authority.permits_completion()
104    }
105}
106
107#[derive(Debug, Error)]
108pub enum FalseGreenError {
109    #[error(transparent)]
110    Client(#[from] falsegreen_core::ClientError),
111    #[error("canonical FalseGreen result has no string status")]
112    MissingStatus,
113    #[error("canonical FalseGreen pending result has no run identity")]
114    MissingRunIdentity,
115    #[error("canonical FalseGreen pending run identity changed")]
116    RunIdentityChanged,
117    #[error("canonical FalseGreen verification timed out")]
118    Timeout,
119    #[error("canonical FalseGreen repair evidence is unavailable")]
120    MissingRepairEvidence,
121}
122
123pub trait FalseGreenVerifier {
124    fn verify(
125        &mut self,
126        workspace: &Workspace,
127        candidate_sha256: &str,
128    ) -> Result<FalseGreenResult, FalseGreenError>;
129
130    fn prepare_repair(&mut self, _workspace: &Workspace) -> Result<Value, FalseGreenError> {
131        Ok(Value::Null)
132    }
133}
134
135trait CanonicalClient {
136    fn check_completion(
137        &self,
138        workspace_root: &Path,
139        task_id: &str,
140    ) -> Result<Value, FalseGreenError>;
141
142    fn authority_status(&self, task_id: &str) -> Result<Value, FalseGreenError>;
143}
144
145impl CanonicalClient for falsegreen_core::Client {
146    fn check_completion(
147        &self,
148        workspace_root: &Path,
149        task_id: &str,
150    ) -> Result<Value, FalseGreenError> {
151        Ok(self
152            .check_completion(workspace_root, task_id)?
153            .into_payload())
154    }
155
156    fn authority_status(&self, task_id: &str) -> Result<Value, FalseGreenError> {
157        Ok(self.authority_status(task_id)?.into_payload())
158    }
159}
160
161/// In-process adapter for the canonical FalseGreen Rust client.
162///
163/// This adapter submits source through the pinned Core library. It never runs a
164/// local executable and never converts model or local-test claims into
165/// acceptance.
166pub struct EmbeddedFalseGreenVerifier {
167    client: Box<dyn CanonicalClient>,
168    task_id: String,
169    verification_timeout: Duration,
170    poll_interval: Duration,
171    repair_evidence: Option<Value>,
172}
173
174impl EmbeddedFalseGreenVerifier {
175    /// Use the session established by the standalone `falsegreen login`
176    /// command without spawning that binary at runtime.
177    pub fn from_stored_session(
178        task_id: impl Into<String>,
179        verification_timeout: Duration,
180    ) -> Result<Self, FalseGreenError> {
181        Ok(Self {
182            client: Box::new(falsegreen_core::Client::from_stored_session()?),
183            task_id: task_id.into(),
184            verification_timeout,
185            poll_interval: Duration::from_millis(250),
186            repair_evidence: None,
187        })
188    }
189
190    #[cfg(test)]
191    fn with_client(task_id: impl Into<String>, client: impl CanonicalClient + 'static) -> Self {
192        Self {
193            client: Box::new(client),
194            task_id: task_id.into(),
195            verification_timeout: Duration::from_secs(1),
196            poll_interval: Duration::ZERO,
197            repair_evidence: None,
198        }
199    }
200
201    fn completion_decision(&self, workspace_root: &Path) -> Result<Value, FalseGreenError> {
202        let started = Instant::now();
203        let mut pending_run_id: Option<String> = None;
204        loop {
205            let decision = self
206                .client
207                .check_completion(workspace_root, &self.task_id)?;
208            let status = decision
209                .get("status")
210                .and_then(Value::as_str)
211                .ok_or(FalseGreenError::MissingStatus)?;
212            if !pending_status(status) {
213                return Ok(decision);
214            }
215
216            let run_id = decision
217                .get("run_id")
218                .and_then(Value::as_str)
219                .ok_or(FalseGreenError::MissingRunIdentity)?;
220            if pending_run_id
221                .as_deref()
222                .is_some_and(|expected| expected != run_id)
223            {
224                return Err(FalseGreenError::RunIdentityChanged);
225            }
226            pending_run_id.get_or_insert_with(|| run_id.to_owned());
227
228            let elapsed = started.elapsed();
229            if elapsed >= self.verification_timeout {
230                return Err(FalseGreenError::Timeout);
231            }
232            thread::sleep(
233                self.poll_interval
234                    .min(self.verification_timeout.saturating_sub(elapsed)),
235            );
236        }
237    }
238}
239
240impl fmt::Debug for EmbeddedFalseGreenVerifier {
241    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
242        formatter
243            .debug_struct("EmbeddedFalseGreenVerifier")
244            .field("task_id", &self.task_id)
245            .field("verification_timeout", &self.verification_timeout)
246            .field("repair_evidence", &self.repair_evidence)
247            .finish_non_exhaustive()
248    }
249}
250
251impl FalseGreenVerifier for EmbeddedFalseGreenVerifier {
252    fn verify(
253        &mut self,
254        workspace: &Workspace,
255        candidate_sha256: &str,
256    ) -> Result<FalseGreenResult, FalseGreenError> {
257        let decision = self.completion_decision(workspace.root())?;
258        let status = decision
259            .get("status")
260            .and_then(Value::as_str)
261            .ok_or(FalseGreenError::MissingStatus)?
262            .to_owned();
263        let authority = self.client.authority_status(&self.task_id)?;
264        let repairable = explicit_true(&decision, "repair_authorized");
265        self.repair_evidence = repairable.then(|| decision.clone());
266
267        let verification = match status.as_str() {
268            "accepted" => FalseGreenVerdict::Accepted,
269            "failed" | "unsafe" => FalseGreenVerdict::Failed,
270            "incomplete" => FalseGreenVerdict::Incomplete,
271            "insufficient_evidence" => FalseGreenVerdict::InsufficientEvidence,
272            _ => FalseGreenVerdict::Invalid,
273        };
274        let completion_authority = CompletionAuthority {
275            authority_ready: explicit_true(&authority, "authority_ready"),
276            may_claim_complete: explicit_true(&authority, "may_claim_complete"),
277        };
278        Ok(FalseGreenResult {
279            verification,
280            verification_status: status,
281            completion_authority,
282            repairable,
283            candidate_sha256: candidate_sha256.to_owned(),
284            authoritative_source_sha256: source_sha256(&decision),
285            run_id: decision
286                .get("run_id")
287                .and_then(Value::as_str)
288                .map(str::to_owned),
289            evidence: json!({
290                "completion_decision": decision,
291                "authority_status": authority
292            }),
293        })
294    }
295
296    fn prepare_repair(&mut self, _workspace: &Workspace) -> Result<Value, FalseGreenError> {
297        self.repair_evidence
298            .take()
299            .ok_or(FalseGreenError::MissingRepairEvidence)
300    }
301}
302
303fn explicit_true(payload: &Value, name: &str) -> bool {
304    payload.get(name).and_then(Value::as_bool) == Some(true)
305}
306
307fn pending_status(status: &str) -> bool {
308    matches!(
309        status,
310        "QUEUED" | "CLAIMED" | "EXECUTING" | "RUNNING" | "queued" | "running"
311    )
312}
313
314fn source_sha256(payload: &Value) -> Option<String> {
315    payload
316        .get("source_sha256")
317        .or_else(|| payload.get("source_artifact_sha256"))
318        .and_then(Value::as_str)
319        .map(str::to_owned)
320}
321
322#[cfg(test)]
323mod tests {
324    use std::cell::RefCell;
325    use std::collections::VecDeque;
326    use std::path::Path;
327    use std::rc::Rc;
328
329    use serde_json::{Value, json};
330
331    use crate::workspace::Workspace;
332    use crate::workspace::tests::git_fixture;
333
334    use super::{
335        CanonicalClient, CompletionAuthority, EmbeddedFalseGreenVerifier, FalseGreenError,
336        FalseGreenVerdict, FalseGreenVerifier,
337    };
338
339    struct ScriptedClient {
340        checks: RefCell<VecDeque<Value>>,
341        statuses: RefCell<VecDeque<Value>>,
342        calls: Rc<RefCell<Vec<String>>>,
343    }
344
345    impl ScriptedClient {
346        fn new(check: Value, status: Value) -> (Self, Rc<RefCell<Vec<String>>>) {
347            Self::with_checks(vec![check], status)
348        }
349
350        fn with_checks(checks: Vec<Value>, status: Value) -> (Self, Rc<RefCell<Vec<String>>>) {
351            let calls = Rc::new(RefCell::new(Vec::new()));
352            (
353                Self {
354                    checks: RefCell::new(VecDeque::from(checks)),
355                    statuses: RefCell::new(VecDeque::from([status])),
356                    calls: Rc::clone(&calls),
357                },
358                calls,
359            )
360        }
361    }
362
363    impl CanonicalClient for ScriptedClient {
364        fn check_completion(
365            &self,
366            workspace_root: &Path,
367            task_id: &str,
368        ) -> Result<Value, FalseGreenError> {
369            assert!(workspace_root.is_dir());
370            self.calls.borrow_mut().push(format!("check:{task_id}"));
371            Ok(self.checks.borrow_mut().pop_front().expect("check result"))
372        }
373
374        fn authority_status(&self, task_id: &str) -> Result<Value, FalseGreenError> {
375            self.calls.borrow_mut().push(format!("status:{task_id}"));
376            Ok(self
377                .statuses
378                .borrow_mut()
379                .pop_front()
380                .expect("status result"))
381        }
382    }
383
384    fn verify_shape(
385        decision: Value,
386        authority: Value,
387    ) -> (super::FalseGreenResult, EmbeddedFalseGreenVerifier) {
388        let directory = git_fixture();
389        let workspace = Workspace::open(directory.path()).expect("workspace");
390        let (client, _) = ScriptedClient::new(decision, authority);
391        let mut verifier = EmbeddedFalseGreenVerifier::with_client("task_1", client);
392        let result = verifier.verify(&workspace, "candidate").expect("verify");
393        (result, verifier)
394    }
395
396    #[test]
397    fn canonical_check_and_status_are_called_in_process() {
398        let directory = git_fixture();
399        let workspace = Workspace::open(directory.path()).expect("workspace");
400        let (client, calls) = ScriptedClient::new(
401            json!({"task_id": "task_1", "status": "accepted"}),
402            json!({
403                "task_id": "task_1",
404                "status": "accepted",
405                "authority_ready": true,
406                "may_claim_complete": true
407            }),
408        );
409        let mut verifier = EmbeddedFalseGreenVerifier::with_client("task_1", client);
410
411        let result = verifier.verify(&workspace, "candidate").expect("verify");
412
413        assert_eq!(&*calls.borrow(), &["check:task_1", "status:task_1"]);
414        assert!(result.permits_completion());
415    }
416
417    #[test]
418    fn hosted_pending_run_is_polled_without_changing_run_identity() {
419        let directory = git_fixture();
420        let workspace = Workspace::open(directory.path()).expect("workspace");
421        let (client, calls) = ScriptedClient::with_checks(
422            vec![
423                json!({"task_id": "task_1", "status": "QUEUED", "run_id": "run_1"}),
424                json!({"task_id": "task_1", "status": "EXECUTING", "run_id": "run_1"}),
425                json!({"task_id": "task_1", "status": "accepted", "run_id": "result_1"}),
426            ],
427            json!({
428                "task_id": "task_1",
429                "status": "accepted",
430                "authority_ready": true,
431                "may_claim_complete": true
432            }),
433        );
434        let mut verifier = EmbeddedFalseGreenVerifier::with_client("task_1", client);
435
436        let result = verifier.verify(&workspace, "candidate").expect("verify");
437
438        assert_eq!(
439            &*calls.borrow(),
440            &[
441                "check:task_1",
442                "check:task_1",
443                "check:task_1",
444                "status:task_1"
445            ]
446        );
447        assert!(result.permits_completion());
448        assert_eq!(result.run_id.as_deref(), Some("result_1"));
449    }
450
451    #[test]
452    fn hosted_pending_run_identity_cannot_change_during_polling() {
453        let directory = git_fixture();
454        let workspace = Workspace::open(directory.path()).expect("workspace");
455        let (client, _) = ScriptedClient::with_checks(
456            vec![
457                json!({"task_id": "task_1", "status": "QUEUED", "run_id": "run_1"}),
458                json!({"task_id": "task_1", "status": "EXECUTING", "run_id": "run_2"}),
459            ],
460            json!({"task_id": "task_1", "status": "accepted"}),
461        );
462        let mut verifier = EmbeddedFalseGreenVerifier::with_client("task_1", client);
463
464        assert!(matches!(
465            verifier.verify(&workspace, "candidate"),
466            Err(FalseGreenError::RunIdentityChanged)
467        ));
468    }
469
470    #[test]
471    fn accepted_verification_remains_accepted_while_awaiting_authority() {
472        let (result, _) = verify_shape(
473            json!({"task_id": "task_1", "status": "accepted"}),
474            json!({
475                "task_id": "task_1",
476                "status": "accepted",
477                "authority_ready": false,
478                "may_claim_complete": false
479            }),
480        );
481        assert_eq!(result.verification, FalseGreenVerdict::Accepted);
482        assert_eq!(result.verification_status, "accepted");
483        assert_eq!(result.completion_authority, CompletionAuthority::default());
484        assert!(!result.permits_completion());
485    }
486
487    #[test]
488    fn authority_matrix_requires_both_flags_and_accepted_verification() {
489        for authority_ready in [false, true] {
490            for may_claim_complete in [false, true] {
491                let (result, _) = verify_shape(
492                    json!({"task_id": "task_1", "status": "accepted"}),
493                    json!({
494                        "task_id": "task_1",
495                        "status": "accepted",
496                        "authority_ready": authority_ready,
497                        "may_claim_complete": may_claim_complete
498                    }),
499                );
500                assert_eq!(
501                    result.permits_completion(),
502                    authority_ready && may_claim_complete
503                );
504            }
505        }
506        let (failed, _) = verify_shape(
507            json!({"task_id": "task_1", "status": "failed"}),
508            json!({
509                "task_id": "task_1",
510                "status": "failed",
511                "authority_ready": true,
512                "may_claim_complete": true
513            }),
514        );
515        assert_eq!(failed.verification, FalseGreenVerdict::Failed);
516        assert!(!failed.permits_completion());
517    }
518
519    #[test]
520    fn only_core_repair_authorization_opens_repair_path_without_double_consumption() {
521        let decision = json!({
522            "task_id": "task_1",
523            "status": "incomplete",
524            "repair_authorized": true,
525            "repair_cycles_remaining": 1,
526            "failures": [{"criterion": "AC-1", "summary": "failed"}]
527        });
528        let (result, mut verifier) = verify_shape(
529            decision.clone(),
530            json!({"task_id": "task_1", "status": "incomplete"}),
531        );
532        assert!(result.repairable);
533        let directory = git_fixture();
534        let workspace = Workspace::open(directory.path()).expect("workspace");
535        assert_eq!(verifier.prepare_repair(&workspace).unwrap(), decision);
536        assert!(matches!(
537            verifier.prepare_repair(&workspace),
538            Err(FalseGreenError::MissingRepairEvidence)
539        ));
540    }
541
542    #[test]
543    fn local_status_and_remaining_budget_cannot_infer_repair_authority() {
544        let (result, mut verifier) = verify_shape(
545            json!({
546                "task_id": "task_1",
547                "status": "incomplete",
548                "repair_cycles_remaining": 2
549            }),
550            json!({"task_id": "task_1", "status": "incomplete"}),
551        );
552        assert!(!result.repairable);
553        let directory = git_fixture();
554        let workspace = Workspace::open(directory.path()).expect("workspace");
555        assert!(matches!(
556            verifier.prepare_repair(&workspace),
557            Err(FalseGreenError::MissingRepairEvidence)
558        ));
559    }
560
561    #[test]
562    fn unknown_status_is_invalid_even_with_authority_flags() {
563        let (result, _) = verify_shape(
564            json!({"task_id": "task_1", "status": "model_says_done"}),
565            json!({
566                "task_id": "task_1",
567                "authority_ready": true,
568                "may_claim_complete": true
569            }),
570        );
571        assert_eq!(result.verification, FalseGreenVerdict::Invalid);
572        assert!(!result.permits_completion());
573    }
574}