| /* | |
| * Licensed to the Apache Software Foundation (ASF) under one | |
| * or more contributor license agreements. See the NOTICE file | |
| * distributed with this work for additional information | |
| * regarding copyright ownership. The ASF licenses this file | |
| * to you under the Apache License, Version 2.0 (the | |
| * "License"); you may not use this file except in compliance | |
| * with the License. You may obtain a copy of the License at | |
| * | |
| * http://www.apache.org/licenses/LICENSE-2.0 | |
| * | |
| * Unless required by applicable law or agreed to in writing, | |
| * software distributed under the License is distributed on an | |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
| * KIND, either express or implied. See the License for the | |
| * specific language governing permissions and limitations | |
| * under the License. | |
| */ | |
| use maka_agent_facts::language::Language; | |
| use maka_agent_ports::execution::Submit; | |
| use maka_capabilities::authorization::Id; | |
| use maka_capabilities::storage::Data; | |
| use maka_capabilities::storage::Mutation; | |
| use maka_capabilities::storage::Record; | |
| use maka_capabilities::storage::Store; | |
| use maka_capabilities::storage::StoreError; | |
| use serde::{Deserialize, Serialize}; | |
| use std::sync::Arc; | |
| use uuid::Uuid; | |
| #[derive(Debug, thiserror::Error)] | |
| pub enum Error { | |
| #[error("{0}")] | |
| Invalid(String), | |
| #[error(transparent)] | |
| Storage(#[from] StoreError), | |
| #[error(transparent)] | |
| Authority(#[from] maka_capabilities::authorization::CommandError), | |
| #[error(transparent)] | |
| Resource(#[from] maka_agent_facts::tools::ToolError), | |
| } | |
| pub fn invalid(e: impl std::fmt::Display) -> Error { | |
| Error::Invalid(e.to_string()) | |
| } | |
| #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] | |
| #[serde(rename_all = "camelCase", deny_unknown_fields)] | |
| pub struct Arm<A = Id> { | |
| #[serde(default, skip_serializing_if = "Language::is_english")] | |
| pub language: Language, | |
| pub operation_id: Uuid, | |
| pub objective: String, | |
| pub grant: A, | |
| pub max_iterations: u32, | |
| pub token_budget: Option<u64>, | |
| #[serde(default)] | |
| pub start: bool, | |
| } | |
| impl<A> Arm<A> { | |
| pub fn validate(&self) -> Result<(), Error> { | |
| if self.objective.trim().is_empty() | |
| || self.objective.len() > 8192 | |
| || !(1..=100).contains(&self.max_iterations) | |
| || self | |
| .token_budget | |
| .is_some_and(|n| n == 0 || n > 1_000_000_000) | |
| { | |
| return Err(invalid( | |
| "Use an objective of 1–8192 bytes, 1–100 iterations and a positive token threshold up to 1 billion", | |
| )); | |
| } | |
| Ok(()) | |
| } | |
| } | |
| #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, schemars::JsonSchema)] | |
| #[serde(rename_all = "snake_case")] | |
| #[schemars(rename = "GoalStatus")] | |
| pub enum Status { | |
| Armed, | |
| Active, | |
| Paused, | |
| Waiting, | |
| Achieved, | |
| Impossible, | |
| Cancelled, | |
| CancellationUnknown, | |
| MaxIterations, | |
| BudgetLimited, | |
| BudgetUnknown, | |
| Blocked, | |
| } | |
| impl Status { | |
| pub fn terminal(self) -> bool { | |
| matches!( | |
| self, | |
| Self::Achieved | |
| | Self::Impossible | |
| | Self::Cancelled | |
| | Self::CancellationUnknown | |
| | Self::MaxIterations | |
| | Self::BudgetLimited | |
| | Self::BudgetUnknown | |
| ) | |
| } | |
| } | |
| #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, schemars::JsonSchema)] | |
| #[serde(rename_all = "snake_case")] | |
| #[schemars(rename = "GoalControl")] | |
| pub enum Control { | |
| Pause, | |
| Resume, | |
| Cancel, | |
| Complete, | |
| } | |
| #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Default, schemars::JsonSchema)] | |
| #[serde(rename_all = "camelCase")] | |
| #[schemars(rename = "GoalMeter")] | |
| pub struct Meter { | |
| pub known: u64, | |
| pub missing: u64, | |
| } | |
| #[derive(Clone, Debug, Deserialize, Serialize, schemars::JsonSchema)] | |
| #[serde(rename_all = "snake_case")] | |
| #[schemars(rename = "GoalReportKind")] | |
| pub enum ReportKind { | |
| Progress, | |
| Achieved, | |
| Waiting, | |
| Impossible, | |
| } | |
| #[derive(Clone, Debug, Deserialize, Serialize, schemars::JsonSchema)] | |
| #[serde(deny_unknown_fields)] | |
| #[schemars(rename = "GoalReport")] | |
| pub struct Report { | |
| pub status: ReportKind, | |
| pub note: String, | |
| } | |
| #[derive(Clone, Debug, Deserialize, Serialize)] | |
| pub struct Pending { | |
| pub request: Submit, | |
| pub dispatched: bool, | |
| } | |
| #[derive(Clone, Debug, Deserialize, Serialize)] | |
| #[serde(rename_all = "camelCase")] | |
| pub struct Goal<A = Id> { | |
| pub id: Uuid, | |
| pub session_id: String, | |
| pub arm: Arm<A>, | |
| pub status: Status, | |
| pub iterations: u32, | |
| pub baseline: Meter, | |
| pub consumed: Meter, | |
| pub pending: Option<Pending>, | |
| pub last_operation_id: Option<String>, | |
| pub report: Option<Report>, | |
| pub note: String, | |
| pub authority_blocked: bool, | |
| } | |
| impl<A> Goal<A> { | |
| pub fn keeps_host_awake(&self) -> bool { | |
| !self.authority_blocked | |
| && (self.status == Status::Active | |
| || (self.status == Status::Cancelled && self.pending.is_some())) | |
| } | |
| pub fn control(&mut self, action: Control) -> Result<(), Error> { | |
| match action { | |
| Control::Pause if !self.status.terminal() => { | |
| self.status = Status::Paused; | |
| self.note = (match self.arm.language { | |
| Language::En => "Paused by user; accepted execution may finish", | |
| Language::ZhCn => "用户已暂停;已接纳的执行可能继续完成", | |
| Language::ZhTw => "使用者已暫停;已接納的執行可能繼續完成", | |
| }) | |
| .into(); | |
| } | |
| Control::Resume | |
| if matches!( | |
| self.status, | |
| Status::Armed | Status::Paused | Status::Waiting | Status::Blocked | |
| ) => | |
| { | |
| self.status = Status::Active; | |
| self.report = None; | |
| self.note = (match self.arm.language { | |
| Language::En => "Resumed by user", | |
| Language::ZhCn => "用户已恢复", | |
| Language::ZhTw => "使用者已恢復", | |
| }) | |
| .into(); | |
| } | |
| Control::Cancel | |
| if !self.status.terminal() | |
| || matches!(self.status, Status::Cancelled | Status::CancellationUnknown) => | |
| { | |
| self.status = Status::Cancelled; | |
| self.report = None; | |
| self.note = (match self.arm.language { Language::En => "Cancellation requested; a committed dispatch may still be admitted before exact cancellation settles", Language::ZhCn => "已请求取消;精确取消确认前,已提交的派发仍可能被接纳", Language::ZhTw => "已請求取消;精確取消確認前,已提交的派發仍可能被接納" }) | |
| .into(); | |
| } | |
| Control::Complete if !self.status.terminal() && self.pending.is_none() => { | |
| self.status = Status::Achieved; | |
| self.note = (match self.arm.language { | |
| Language::En => "Marked complete by user", | |
| Language::ZhCn => "用户标记为完成", | |
| Language::ZhTw => "使用者標記為完成", | |
| }) | |
| .into(); | |
| } | |
| _ => return Err(invalid("Control is not valid in the current Goal state")), | |
| } | |
| Ok(()) | |
| } | |
| pub fn settled( | |
| &mut self, | |
| outcome: maka_agent_facts::event::InvocationOutcome, | |
| usage: Result<Meter, String>, | |
| ) { | |
| self.last_operation_id = self | |
| .pending | |
| .take() | |
| .map(|pending| pending.request.operation_id); | |
| if matches!(self.status, Status::Cancelled | Status::CancellationUnknown) { | |
| self.status = Status::Cancelled; | |
| self.note = (match self.arm.language { | |
| Language::En => "Cancellation settled; the accepted execution is no longer running", | |
| Language::ZhCn => "取消已确认;已接纳的执行不再运行", | |
| Language::ZhTw => "取消已確認;已接納的執行不再執行", | |
| }) | |
| .into(); | |
| } | |
| if self.status == Status::Active { | |
| match outcome { | |
| maka_agent_facts::event::InvocationOutcome::Completed => { | |
| if let Some(report) = self.report.take() { | |
| self.note = report.note; | |
| self.status = match report.status { | |
| ReportKind::Progress => Status::Active, | |
| ReportKind::Achieved => Status::Achieved, | |
| ReportKind::Waiting => Status::Waiting, | |
| ReportKind::Impossible => Status::Impossible, | |
| }; | |
| } | |
| } | |
| _ => { | |
| self.status = Status::Blocked; | |
| self.note = (match self.arm.language { Language::En => "Goal execution did not complete normally; inspect the Session before resuming", Language::ZhCn => "目标执行未正常完成;恢复前检查会话", Language::ZhTw => "目標執行未正常完成;恢復前檢查工作階段" }) | |
| .into(); | |
| } | |
| } | |
| } | |
| match usage { | |
| Ok(total) => self.meter(total), | |
| Err(_) => { | |
| if self.status == Status::Active { | |
| self.status = if self.arm.token_budget.is_some() { | |
| Status::BudgetUnknown | |
| } else { | |
| Status::Blocked | |
| }; | |
| } | |
| self.note.push_str(match self.arm.language { | |
| Language::En => { | |
| " Usage could not be refreshed; no further iteration was scheduled." | |
| } | |
| Language::ZhCn => " 无法刷新用量;未安排后续迭代。", | |
| Language::ZhTw => " 無法重新整理用量;未安排後續迭代。", | |
| }); | |
| } | |
| } | |
| } | |
| pub fn handoff_paused(&mut self) { | |
| self.last_operation_id = self | |
| .pending | |
| .take() | |
| .map(|pending| pending.request.operation_id); | |
| self.report = None; | |
| if matches!(self.status, Status::Cancelled | Status::CancellationUnknown) { | |
| self.status = Status::Cancelled; | |
| self.note = (match self.arm.language { Language::En => "Goal cancellation recorded; Host handoff is sealed and no continuation was scheduled", Language::ZhCn => "已记录目标取消;Host 交接已封存,未安排继续执行", Language::ZhTw => "已記錄目標取消;Host 交接已封存,未安排繼續執行" }).into(); | |
| } else { | |
| self.status = Status::Blocked; | |
| self.note = (match self.arm.language { Language::En => "Host handoff is paused. Resume it through Session controls, then explicitly resume the Goal; no automatic continuation was submitted.", Language::ZhCn => "Host 交接已暂停。先通过会话控件恢复,再明确恢复目标;未提交自动继续请求。", Language::ZhTw => "Host 交接已暫停。先透過工作階段控制項恢復,再明確恢復目標;未提交自動繼續請求。" }) | |
| .into(); | |
| } | |
| } | |
| pub fn reserve(&mut self) -> Result<(), Error> { | |
| if self.status != Status::Active || self.pending.is_some() { | |
| return Err(invalid("Goal is not ready to continue")); | |
| } | |
| if self.iterations >= self.arm.max_iterations { | |
| self.status = Status::MaxIterations; | |
| self.note = (match self.arm.language { | |
| Language::En => "Iteration limit reached", | |
| Language::ZhCn => "已达迭代上限", | |
| Language::ZhTw => "已達迭代上限", | |
| }) | |
| .into(); | |
| return Ok(()); | |
| } | |
| self.iterations += 1; | |
| self.report = None; | |
| let content = match self.arm.language { | |
| Language::En => format!( | |
| "Continue this explicitly authorized goal (iteration {} of {}):\n{}\n\nGoalStatus records iteration progress or changes goal status; an answer alone does not mark achievement. A configured token budget is a retrospective scheduling threshold, not a hard request limit. Previous checkpoint: {}", | |
| self.iterations, self.arm.max_iterations, self.arm.objective, self.note | |
| ), | |
| Language::ZhCn => format!( | |
| "继续此已明确授权的目标(第 {} 次,共 {} 次迭代):\n{}\n\nGoalStatus 记录迭代进度或改变目标状态;普通回答不会将目标标记为完成。配置的 token 预算是事后调度阈值,不是单次请求的硬限制。上次检查点:{}", | |
| self.iterations, self.arm.max_iterations, self.arm.objective, self.note | |
| ), | |
| Language::ZhTw => format!( | |
| "繼續此已明確授權的目標(第 {} 次,共 {} 次迭代):\n{}\n\nGoalStatus 記錄迭代進度或改變目標狀態;一般回答不會將目標標記為完成。設定的 token 預算是事後排程門檻,不是單次請求的硬限制。上次檢查點:{}", | |
| self.iterations, self.arm.max_iterations, self.arm.objective, self.note | |
| ), | |
| }; | |
| self.pending = Some(Pending { | |
| dispatched: false, | |
| request: Submit { | |
| operation_id: format!("goal-{}-{}", self.id, self.iterations), | |
| session_id: self.session_id.clone(), | |
| orchestration_mode: None, | |
| content: content.into(), | |
| }, | |
| }); | |
| Ok(()) | |
| } | |
| pub fn meter(&mut self, total: Meter) { | |
| self.consumed = Meter { | |
| known: total.known.saturating_sub(self.baseline.known), | |
| missing: total.missing.saturating_sub(self.baseline.missing), | |
| }; | |
| if let Some(limit) = self.arm.token_budget | |
| && self.status == Status::Active | |
| { | |
| if total.known < self.baseline.known | |
| || total.missing < self.baseline.missing | |
| || self.consumed.missing > 0 | |
| { | |
| self.status = Status::BudgetUnknown; | |
| self.note = (match self.arm.language { | |
| Language::En => "Token usage is incomplete; continuation stopped", | |
| Language::ZhCn => "Token 用量不完整;已停止继续执行", | |
| Language::ZhTw => "Token 用量不完整;已停止繼續執行", | |
| }) | |
| .into(); | |
| } else if self.consumed.known >= limit { | |
| self.status = Status::BudgetLimited; | |
| self.note = (match self.arm.language { | |
| Language::En => "Observed token threshold reached", | |
| Language::ZhCn => "已达观测到的 token 阈值", | |
| Language::ZhTw => "已達觀測到的 token 門檻", | |
| }) | |
| .into(); | |
| } | |
| } | |
| } | |
| } | |
| #[derive(Clone)] | |
| pub struct Saved<A = Id> { | |
| pub revision: u64, | |
| pub goal: Goal<A>, | |
| } | |
| #[derive(Clone)] | |
| pub struct Repository<A = Id> { | |
| store: Arc<dyn Store>, | |
| authority: std::marker::PhantomData<A>, | |
| } | |
| fn key(session: &str) -> String { | |
| use sha2::{Digest, Sha256}; | |
| format!( | |
| "session/{}", | |
| hex::encode(Sha256::digest(session.as_bytes())) | |
| ) | |
| } | |
| fn decode<A: serde::de::DeserializeOwned>(record: Record) -> Result<Saved<A>, Error> { | |
| let goal = serde_json::from_value( | |
| record | |
| .data | |
| .value() | |
| .ok_or_else(|| invalid("Goal was removed"))? | |
| .clone(), | |
| ) | |
| .map_err(invalid)?; | |
| Ok(Saved { | |
| revision: record.revision, | |
| goal, | |
| }) | |
| } | |
| impl<A: Clone + Serialize + serde::de::DeserializeOwned> Repository<A> { | |
| pub fn new(store: Arc<dyn Store>) -> Self { | |
| Self { | |
| store, | |
| authority: std::marker::PhantomData, | |
| } | |
| } | |
| pub async fn read(&self, session: &str) -> Result<Option<Saved<A>>, Error> { | |
| self.store.read(key(session)).await?.map(decode).transpose() | |
| } | |
| pub async fn arm(&self, session: &str, arm: Arm<A>, baseline: Meter) -> Result<Goal<A>, Error> { | |
| self.arm_with(session, arm, baseline, vec![]).await | |
| } | |
| /// Commit caller receipts in the same transaction as the original arming. | |
| pub async fn arm_with( | |
| &self, | |
| session: &str, | |
| arm: Arm<A>, | |
| baseline: Meter, | |
| mut receipts: Vec<Mutation>, | |
| ) -> Result<Goal<A>, Error> { | |
| arm.validate()?; | |
| let receipt_key = format!("arm/{}", arm.operation_id); | |
| if let Some(receipt) = self.store.read(receipt_key.clone()).await? { | |
| let expected = serde_json::json!({"session":session,"arm":arm}); | |
| if receipt.data.value() != Some(&expected) { | |
| return Err(invalid("Operation ID belongs to another Goal request")); | |
| } | |
| let saved = self | |
| .read(session) | |
| .await? | |
| .ok_or_else(|| invalid("Goal is unavailable"))?; | |
| if saved.goal.id != arm.operation_id { | |
| return Err(invalid("This Goal was superseded; read the current Goal")); | |
| } | |
| return Ok(saved.goal); | |
| } | |
| let old = self.read(session).await?; | |
| if old | |
| .as_ref() | |
| .is_some_and(|s| !s.goal.status.terminal() || s.goal.pending.is_some()) | |
| { | |
| return Err(invalid( | |
| "Finish or cancel the current Goal and await its execution before replacing it", | |
| )); | |
| } | |
| let goal = Goal { | |
| id: arm.operation_id, | |
| session_id: session.into(), | |
| status: if arm.start { | |
| Status::Active | |
| } else { | |
| Status::Armed | |
| }, | |
| arm: arm.clone(), | |
| iterations: 0, | |
| baseline, | |
| consumed: Meter::default(), | |
| pending: None, | |
| last_operation_id: None, | |
| report: None, | |
| note: String::new(), | |
| authority_blocked: false, | |
| }; | |
| receipts.extend([ | |
| Mutation { | |
| key: key(session), | |
| expected_revision: old.map(|v| v.revision), | |
| data: Data::Present(serde_json::to_value(&goal).map_err(invalid)?), | |
| }, | |
| Mutation { | |
| key: receipt_key, | |
| expected_revision: None, | |
| data: Data::Present(serde_json::json!({"session":session,"arm":arm})), | |
| }, | |
| ]); | |
| self.store.batch(receipts).await?; | |
| Ok(goal) | |
| } | |
| pub async fn save(&self, saved: Saved<A>) -> Result<(), Error> { | |
| self.save_with(saved, vec![]).await | |
| } | |
| /// State transitions and their caller receipts share the original CAS. | |
| pub async fn save_with( | |
| &self, | |
| saved: Saved<A>, | |
| mut receipts: Vec<Mutation>, | |
| ) -> Result<(), Error> { | |
| receipts.push(Mutation { | |
| key: key(&saved.goal.session_id), | |
| expected_revision: Some(saved.revision), | |
| data: Data::Present(serde_json::to_value(saved.goal).map_err(invalid)?), | |
| }); | |
| self.store.batch(receipts).await?; | |
| Ok(()) | |
| } | |
| pub async fn sessions(&self) -> Result<Vec<Saved<A>>, Error> { | |
| let mut result = vec![]; | |
| let mut after = None; | |
| loop { | |
| let page = self | |
| .store | |
| .scan(maka_capabilities::storage::Scan { | |
| prefix: "session/".into(), | |
| after, | |
| }) | |
| .await?; | |
| for entry in page.entries { | |
| let saved = decode(entry.record)?; | |
| if !saved.goal.authority_blocked | |
| && (saved.goal.status == Status::Active || saved.goal.pending.is_some()) | |
| { | |
| result.push(saved); | |
| } | |
| } | |
| after = page.next_after; | |
| if after.is_none() { | |
| return Ok(result); | |
| } | |
| } | |
| } | |
| } | |
| #[cfg(test)] | |
| mod tests { | |
| use super::*; | |
| use futures_util::future::BoxFuture; | |
| use maka_capabilities::storage; | |
| use std::{ | |
| collections::BTreeMap, | |
| sync::{ | |
| Arc, Mutex, | |
| atomic::{AtomicBool, Ordering}, | |
| }, | |
| }; | |
| use uuid::Uuid; | |
| #[derive(Default)] | |
| struct Store(Mutex<BTreeMap<String, storage::Record>>, AtomicBool); | |
| impl storage::Store for Store { | |
| fn read( | |
| &self, | |
| key: String, | |
| ) -> BoxFuture<'_, Result<Option<storage::Record>, storage::StoreError>> { | |
| Box::pin(async move { Ok(self.0.lock().unwrap().get(&key).cloned()) }) | |
| } | |
| fn scan( | |
| &self, | |
| _: storage::Scan, | |
| ) -> BoxFuture<'_, Result<storage::Page, storage::StoreError>> { | |
| Box::pin(async { | |
| Ok(storage::Page { | |
| entries: self | |
| .0 | |
| .lock() | |
| .unwrap() | |
| .iter() | |
| .filter(|(key, _)| key.starts_with("session/")) | |
| .map(|(key, record)| storage::Entry { | |
| key: key.clone(), | |
| record: record.clone(), | |
| }) | |
| .collect(), | |
| next_after: None, | |
| }) | |
| }) | |
| } | |
| fn batch( | |
| &self, | |
| mutations: Vec<storage::Mutation>, | |
| ) -> BoxFuture<'_, Result<Vec<storage::Record>, storage::StoreError>> { | |
| Box::pin(async move { | |
| let mut records = self.0.lock().unwrap(); | |
| for m in &mutations { | |
| let actual = records.get(&m.key).map(|r| r.revision); | |
| if actual != m.expected_revision { | |
| return Err(storage::StoreError::Conflict { | |
| expected: format!("{:?}", m.expected_revision), | |
| actual: format!("{actual:?}"), | |
| }); | |
| } | |
| } | |
| let committed: Vec<_> = mutations | |
| .into_iter() | |
| .map(|m| { | |
| let r = storage::Record { | |
| revision: m.expected_revision.unwrap_or(0) + 1, | |
| data: m.data, | |
| }; | |
| records.insert(m.key, r.clone()); | |
| r | |
| }) | |
| .collect(); | |
| if self.1.swap(false, Ordering::SeqCst) { | |
| return Err(storage::StoreError::OutcomeUnknown( | |
| "reply lost after commit".into(), | |
| )); | |
| } | |
| Ok(committed) | |
| }) | |
| } | |
| } | |
| fn arm() -> Arm { | |
| Arm { | |
| language: Language::En, | |
| operation_id: Uuid::new_v4(), | |
| objective: "Verify the feature".into(), | |
| grant: maka_capabilities::authorization::Id(Uuid::new_v4()), | |
| max_iterations: 2, | |
| token_budget: Some(100), | |
| start: true, | |
| } | |
| } | |
| #[tokio::test] | |
| async fn business_receipt_commits_atomically_with_goal_and_conflicts_leave_neither_effect() { | |
| let store = Arc::new(Store::default()); | |
| let repo = Repository::new(store.clone()); | |
| let receipt = |key: &str| storage::Mutation { | |
| key: key.into(), | |
| expected_revision: None, | |
| data: storage::Data::Present(serde_json::json!({"committed":true})), | |
| }; | |
| store.1.store(true, Ordering::SeqCst); | |
| assert!(matches!( | |
| repo.arm_with( | |
| "session", | |
| arm(), | |
| Meter::default(), | |
| vec![receipt("create-receipt")] | |
| ) | |
| .await, | |
| Err(Error::Storage(storage::StoreError::OutcomeUnknown(_))) | |
| )); | |
| assert!(store.0.lock().unwrap().contains_key("create-receipt")); | |
| let mut saved = repo.read("session").await.unwrap().unwrap(); | |
| saved.goal.control(Control::Pause).unwrap(); | |
| let stale = saved.clone(); | |
| store.1.store(true, Ordering::SeqCst); | |
| assert!(matches!( | |
| repo.save_with(saved, vec![receipt("pause-receipt")]).await, | |
| Err(Error::Storage(storage::StoreError::OutcomeUnknown(_))) | |
| )); | |
| assert!(store.0.lock().unwrap().contains_key("pause-receipt")); | |
| assert_eq!( | |
| repo.read("session").await.unwrap().unwrap().goal.status, | |
| Status::Paused | |
| ); | |
| assert!(matches!( | |
| repo.save_with(stale, vec![receipt("stale-receipt")]).await, | |
| Err(Error::Storage(storage::StoreError::Conflict { .. })) | |
| )); | |
| assert!(!store.0.lock().unwrap().contains_key("stale-receipt")); | |
| } | |
| #[tokio::test] | |
| async fn arm_retry_preserves_baseline_and_cannot_replace_or_resurrect_a_goal() { | |
| let store = Arc::new(Store::default()); | |
| let repo = Repository::new(store.clone()); | |
| let request = arm(); | |
| let goal = repo | |
| .arm( | |
| "session", | |
| request.clone(), | |
| Meter { | |
| known: 10, | |
| missing: 0, | |
| }, | |
| ) | |
| .await | |
| .unwrap(); | |
| let retry = repo | |
| .arm( | |
| "session", | |
| request.clone(), | |
| Meter { | |
| known: 99, | |
| missing: 0, | |
| }, | |
| ) | |
| .await | |
| .unwrap(); | |
| assert_eq!(retry.baseline.known, 10); | |
| assert_eq!(retry.id, goal.id); | |
| let mut different = request.clone(); | |
| different.objective = "different".into(); | |
| assert!( | |
| repo.arm("session", different, Meter::default()) | |
| .await | |
| .is_err() | |
| ); | |
| assert!(repo.arm("session", arm(), Meter::default()).await.is_err()); | |
| let mut saved = repo.read("session").await.unwrap().unwrap(); | |
| saved.goal.status = Status::Achieved; | |
| repo.save(saved).await.unwrap(); | |
| let new = repo.arm("session", arm(), Meter::default()).await.unwrap(); | |
| assert!( | |
| repo.arm("session", request, Meter::default()) | |
| .await | |
| .is_err() | |
| ); | |
| assert_eq!(repo.read("session").await.unwrap().unwrap().goal.id, new.id); | |
| } | |
| #[tokio::test] | |
| async fn durable_outbox_survives_restart_and_stale_writers_cannot_undo_pause() { | |
| let store = Arc::new(Store::default()); | |
| let repo = Repository::new(store.clone()); | |
| repo.arm("session", arm(), Meter::default()).await.unwrap(); | |
| let mut saved = repo.read("session").await.unwrap().unwrap(); | |
| saved.goal.reserve().unwrap(); | |
| let request = saved.goal.pending.as_ref().unwrap().request.clone(); | |
| repo.save(saved).await.unwrap(); | |
| let reopened = Repository::new(store); | |
| let mut stale = reopened.read("session").await.unwrap().unwrap(); | |
| let mut control = reopened.read("session").await.unwrap().unwrap(); | |
| control.goal.status = Status::Paused; | |
| reopened.save(control).await.unwrap(); | |
| stale.goal.pending = None; | |
| assert!(matches!( | |
| reopened.save(stale).await, | |
| Err(Error::Storage(storage::StoreError::Conflict { .. })) | |
| )); | |
| let recovered = reopened.read("session").await.unwrap().unwrap(); | |
| assert_eq!(recovered.goal.status, Status::Paused); | |
| assert_eq!(recovered.goal.pending.unwrap().request, request); | |
| let mut failed = reopened.read("session").await.unwrap().unwrap(); | |
| let mut renewed = failed.clone(); | |
| renewed.goal.arm.grant = maka_capabilities::authorization::Id(Uuid::new_v4()); | |
| renewed.goal.control(Control::Resume).unwrap(); | |
| let grant = renewed.goal.arm.grant; | |
| reopened.save(renewed).await.unwrap(); | |
| failed.goal.authority_blocked = true; | |
| failed.goal.status = Status::Blocked; | |
| assert!(matches!( | |
| reopened.save(failed).await, | |
| Err(Error::Storage(storage::StoreError::Conflict { .. })) | |
| )); | |
| let current = reopened.read("session").await.unwrap().unwrap(); | |
| assert_eq!(current.goal.arm.grant, grant); | |
| assert_eq!(current.goal.status, Status::Active); | |
| assert!(!current.goal.authority_blocked); | |
| } | |
| #[tokio::test] | |
| async fn iteration_reservations_are_bounded_and_budget_missing_fails_closed() { | |
| let repo = Repository::new(Arc::new(Store::default())); | |
| let mut goal = repo | |
| .arm( | |
| "session", | |
| arm(), | |
| Meter { | |
| known: 10, | |
| missing: 1, | |
| }, | |
| ) | |
| .await | |
| .unwrap(); | |
| goal.reserve().unwrap(); | |
| let first = goal.pending.take().unwrap(); | |
| goal.reserve().unwrap(); | |
| let second = goal.pending.take().unwrap(); | |
| assert_ne!(first.request.operation_id, second.request.operation_id); | |
| goal.reserve().unwrap(); | |
| assert_eq!(goal.status, Status::MaxIterations); | |
| assert!(goal.pending.is_none()); | |
| assert_eq!(goal.iterations, 2); | |
| goal.status = Status::Active; | |
| goal.meter(Meter { | |
| known: 109, | |
| missing: 1, | |
| }); | |
| assert_eq!(goal.status, Status::Active); | |
| goal.meter(Meter { | |
| known: 110, | |
| missing: 1, | |
| }); | |
| assert_eq!(goal.status, Status::BudgetLimited); | |
| goal.status = Status::Active; | |
| goal.meter(Meter { | |
| known: 11, | |
| missing: 2, | |
| }); | |
| assert_eq!(goal.status, Status::BudgetUnknown); | |
| } | |
| #[tokio::test] | |
| async fn lost_storage_reply_recovers_original_arm_and_outbox_without_new_identity() { | |
| let store = Arc::new(Store::default()); | |
| let repo = Repository::new(store.clone()); | |
| let request = arm(); | |
| store.1.store(true, Ordering::SeqCst); | |
| assert!(matches!( | |
| repo.arm("session", request.clone(), Meter::default()).await, | |
| Err(Error::Storage(storage::StoreError::OutcomeUnknown(_))) | |
| )); | |
| let recovered = repo | |
| .arm("session", request.clone(), Meter::default()) | |
| .await | |
| .unwrap(); | |
| assert_eq!(recovered.id, request.operation_id); | |
| let mut saved = repo.read("session").await.unwrap().unwrap(); | |
| saved.goal.reserve().unwrap(); | |
| let operation = saved | |
| .goal | |
| .pending | |
| .as_ref() | |
| .unwrap() | |
| .request | |
| .operation_id | |
| .clone(); | |
| store.1.store(true, Ordering::SeqCst); | |
| assert!(matches!( | |
| repo.save(saved).await, | |
| Err(Error::Storage(storage::StoreError::OutcomeUnknown(_))) | |
| )); | |
| let mut recovered = repo.read("session").await.unwrap().unwrap().goal; | |
| assert!(recovered.reserve().is_err()); | |
| assert_eq!(recovered.iterations, 1); | |
| assert_eq!(recovered.pending.unwrap().request.operation_id, operation); | |
| } | |
| #[tokio::test] | |
| async fn terminal_controls_and_usage_failure_cannot_rewrite_execution_facts() { | |
| let repo = Repository::new(Arc::new(Store::default())); | |
| let mut goal = repo.arm("session", arm(), Meter::default()).await.unwrap(); | |
| goal.reserve().unwrap(); | |
| goal.report = Some(Report { | |
| status: ReportKind::Achieved, | |
| note: "Evidence verified".into(), | |
| }); | |
| goal.settled( | |
| maka_agent_facts::event::InvocationOutcome::Completed, | |
| Err("usage unavailable".into()), | |
| ); | |
| assert_eq!(goal.status, Status::Achieved); | |
| assert!(goal.pending.is_none()); | |
| for action in [ | |
| Control::Cancel, | |
| Control::Complete, | |
| Control::Pause, | |
| Control::Resume, | |
| ] { | |
| assert!(goal.control(action).is_err()); | |
| assert_eq!(goal.status, Status::Achieved); | |
| } | |
| goal.status = Status::Active; | |
| goal.reserve().unwrap(); | |
| goal.settled( | |
| maka_agent_facts::event::InvocationOutcome::Completed, | |
| Err("usage unavailable".into()), | |
| ); | |
| assert_eq!(goal.status, Status::BudgetUnknown); | |
| assert!(goal.pending.is_none()); | |
| } | |
| #[tokio::test] | |
| async fn sealed_handoff_and_unknown_cancellation_do_not_pin_background_work() { | |
| let repo = Repository::new(Arc::new(Store::default())); | |
| repo.arm("session", arm(), Meter::default()).await.unwrap(); | |
| let mut saved = repo.read("session").await.unwrap().unwrap(); | |
| saved.goal.reserve().unwrap(); | |
| let operation = saved | |
| .goal | |
| .pending | |
| .as_ref() | |
| .unwrap() | |
| .request | |
| .operation_id | |
| .clone(); | |
| saved.goal.handoff_paused(); | |
| assert_eq!( | |
| saved.goal.last_operation_id.as_deref(), | |
| Some(operation.as_str()) | |
| ); | |
| repo.save(saved).await.unwrap(); | |
| assert!(repo.sessions().await.unwrap().is_empty()); | |
| let mut saved = repo.read("session").await.unwrap().unwrap(); | |
| saved.goal.status = Status::Active; | |
| saved.goal.reserve().unwrap(); | |
| saved.goal.status = Status::CancellationUnknown; | |
| saved.goal.pending.as_mut().unwrap().dispatched = true; | |
| repo.save(saved).await.unwrap(); | |
| let sessions = repo.sessions().await.unwrap(); | |
| assert_eq!(sessions.len(), 1); | |
| assert_eq!(sessions[0].goal.session_id, "session"); | |
| assert!(!sessions[0].goal.keeps_host_awake()); | |
| let mut saved = repo.read("session").await.unwrap().unwrap(); | |
| saved.goal.status = Status::Paused; | |
| for dispatched in [false, true] { | |
| saved.goal.pending.as_mut().unwrap().dispatched = dispatched; | |
| assert!(!saved.goal.keeps_host_awake()); | |
| } | |
| saved.goal.control(Control::Resume).unwrap(); | |
| assert!(saved.goal.keeps_host_awake()); | |
| } | |
| #[tokio::test] | |
| async fn goal_language_and_pending_input_survive_repository_reopen() { | |
| let store = Arc::new(Store::default()); | |
| let repo = Repository::new(store.clone()); | |
| let mut input = arm(); | |
| input.language = Language::ZhTw; | |
| input.objective = "Preserve /tmp/原始 file.rs and `exact-id`".into(); | |
| repo.arm("session", input.clone(), Meter::default()) | |
| .await | |
| .unwrap(); | |
| let mut saved = repo.read("session").await.unwrap().unwrap(); | |
| assert_eq!(saved.goal.arm.language, Language::ZhTw); | |
| saved.goal.reserve().unwrap(); | |
| let pending = saved.goal.pending.as_ref().unwrap().request.clone(); | |
| assert!( | |
| pending | |
| .content | |
| .text | |
| .contains("Preserve /tmp/原始 file.rs and `exact-id`") | |
| ); | |
| repo.save(saved).await.unwrap(); | |
| let restarted = Repository::new(store); | |
| let restored = restarted.read("session").await.unwrap().unwrap(); | |
| assert_eq!(restored.goal.arm.language, Language::ZhTw); | |
| assert_eq!( | |
| serde_json::to_value(&restored.goal.arm).unwrap()["language"], | |
| "zh-TW" | |
| ); | |
| assert_eq!(restored.goal.pending.unwrap().request, pending); | |
| assert_eq!( | |
| restarted | |
| .arm("session", input, Meter::default()) | |
| .await | |
| .unwrap() | |
| .arm | |
| .language, | |
| Language::ZhTw | |
| ); | |
| } | |
| } |