use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use crate::agent::Taint;
use crate::session::Session;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Question {
pub id: String,
pub status: String,
pub question: String,
#[serde(default)]
pub options: Vec<String>,
pub session_id: String,
#[serde(default)]
pub task_id: Option<String>,
#[serde(default)]
pub workspace: Option<PathBuf>,
#[serde(default)]
pub taint: Taint,
pub asked_at: String,
#[serde(default)]
pub answered_at: Option<String>,
#[serde(default)]
pub answer: Option<String>,
}
impl Question {
pub fn is_open(&self) -> bool {
self.status == "open"
}
pub fn summary(&self) -> String {
let q = self.question.trim().replace('\n', " ");
let q: String = q.chars().take(72).collect();
match &self.task_id {
Some(t) => format!("{q} ({t})"),
None => q,
}
}
}
pub struct QuestionStore {
root: PathBuf,
}
pub struct QuestionLock {
_file: std::fs::File,
}
impl QuestionStore {
pub fn default_root() -> Result<PathBuf> {
if let Ok(dir) = std::env::var("MECHA_QUESTIONS_DIR") {
return Ok(PathBuf::from(dir));
}
Ok(crate::work::mecha_home()?.join("questions"))
}
pub fn open(root: impl Into<PathBuf>) -> Result<Self> {
let root = root.into();
crate::create_private_dir(&root).with_context(|| format!("creating {}", root.display()))?;
Ok(QuestionStore { root })
}
pub fn open_existing_default() -> Option<Self> {
let root = Self::default_root().ok()?;
root.is_dir().then_some(QuestionStore { root })
}
pub fn root(&self) -> &Path {
&self.root
}
fn path(&self, id: &str) -> PathBuf {
self.root.join(format!("{id}.json"))
}
pub fn lock(&self) -> Result<QuestionLock> {
use std::os::unix::io::AsRawFd;
let file = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(self.root.join(".lock"))?;
if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
return Err(std::io::Error::last_os_error()).context("locking the question store");
}
Ok(QuestionLock { _file: file })
}
pub fn items(&self) -> Result<Vec<Question>> {
let mut out = Vec::new();
let dir = match std::fs::read_dir(&self.root) {
Ok(d) => d,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
Err(e) => return Err(e).context("reading the question store"),
};
for entry in dir.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
match std::fs::read_to_string(&path)
.ok()
.and_then(|t| serde_json::from_str::<Question>(&t).ok())
{
Some(q) => out.push(q),
None => tracing::warn!(path = %path.display(), "skipping unreadable question"),
}
}
out.sort_by(|a, b| b.asked_at.cmp(&a.asked_at));
Ok(out)
}
pub fn open_items(&self) -> Result<Vec<Question>> {
Ok(self
.items()?
.into_iter()
.filter(Question::is_open)
.collect())
}
pub fn get(&self, id: &str) -> Result<Question> {
let text = std::fs::read_to_string(self.path(id))
.with_context(|| format!("no such question: {id}"))?;
serde_json::from_str(&text).with_context(|| format!("unreadable question: {id}"))
}
pub fn short(id: &str) -> &str {
id.rsplit_once('-').map(|(_, tail)| tail).unwrap_or(id)
}
pub fn find(&self, needle: &str) -> Result<Question> {
if let Ok(q) = self.get(needle) {
return Ok(q);
}
let matches: Vec<Question> = self
.items()?
.into_iter()
.filter(|q| q.id.starts_with(needle) || q.id.ends_with(needle))
.collect();
match matches.len() {
0 => anyhow::bail!("no such question: {needle}"),
1 => Ok(matches.into_iter().next().expect("just checked")),
n => anyhow::bail!("{needle} matches {n} questions — use more of the id"),
}
}
pub fn put(&self, q: &Question) -> Result<()> {
let path = self.path(&q.id);
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, serde_json::to_string_pretty(q)?)?;
std::fs::rename(&tmp, &path)?;
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub fn park(
&self,
question: &str,
options: Vec<String>,
session_id: &str,
task_id: Option<String>,
workspace: Option<PathBuf>,
taint: Taint,
) -> Result<Question> {
let q = Question {
id: Session::new_id(),
status: "open".into(),
question: question.to_string(),
options,
session_id: session_id.to_string(),
task_id,
workspace,
taint,
asked_at: chrono::Utc::now().to_rfc3339(),
answered_at: None,
answer: None,
};
let _lock = self.lock()?;
self.put(&q)?;
Ok(q)
}
pub fn answer(&self, id: &str, answer: &str) -> Result<Question> {
let _lock = self.lock()?;
let mut q = self.find(id)?;
anyhow::ensure!(
q.is_open(),
"question {} is already {} — answering it again would resume a conversation that \
already moved on",
q.id,
q.status
);
q.status = "answered".into();
q.answered_at = Some(chrono::Utc::now().to_rfc3339());
q.answer = Some(answer.to_string());
self.put(&q)?;
Ok(q)
}
pub fn abandon(&self, id: &str) -> Result<Question> {
let _lock = self.lock()?;
let mut q = self.find(id)?;
anyhow::ensure!(q.is_open(), "question {} is already {}", q.id, q.status);
q.status = "abandoned".into();
q.answered_at = Some(chrono::Utc::now().to_rfc3339());
self.put(&q)?;
Ok(q)
}
}
pub struct ParkingAsker {
store: std::sync::Arc<QuestionStore>,
session_id: String,
task_id: Option<String>,
parked: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
}
impl ParkingAsker {
pub fn new(
store: std::sync::Arc<QuestionStore>,
session_id: impl Into<String>,
task_id: Option<String>,
) -> Self {
ParkingAsker {
store,
session_id: session_id.into(),
task_id,
parked: Default::default(),
}
}
pub fn parked(&self) -> Vec<String> {
self.parked.lock().map(|p| p.clone()).unwrap_or_default()
}
pub fn stamp_taint(&self, taint: Taint) {
for id in self.parked() {
let updated = self.store.get(&id).map(|mut q| {
q.taint.merge(taint);
q
});
if let Ok(q) = updated {
let _lock = self.store.lock();
if let Err(e) = self.store.put(&q) {
tracing::warn!(error = %e, id, "could not record taint on a parked question");
}
}
}
}
fn record(
&self,
question: &str,
options: &[String],
workspace: Option<PathBuf>,
taint: Option<Taint>,
) -> String {
let taint = taint.unwrap_or(Taint {
private: true,
untrusted: true,
});
match self.store.park(
question,
options.to_vec(),
&self.session_id,
self.task_id.clone(),
workspace,
taint,
) {
Ok(q) => {
if let Ok(mut p) = self.parked.lock() {
p.push(q.id.clone());
}
format!(
"Put to the owner as question {}. This run is ending here — it resumes with \
their answer as the next turn, so there is nothing further to do now. Use \
your last words to say where you got to.",
q.id
)
}
Err(e) => format!(
"The question could not be stored ({e:#}), so nobody will see it. Do not wait \
on an answer — carry on if you can, and say what you needed if you cannot."
),
}
}
}
#[async_trait::async_trait]
impl crate::tool::ask::Asker for ParkingAsker {
async fn ask(&self, question: &str, options: &[String]) -> Option<String> {
Some(self.record(question, options, None, None))
}
async fn ask_in(
&self,
ctx: &crate::tool::ToolCtx,
question: &str,
options: &[String],
) -> Option<String> {
let before = self.parked().len();
let answer = self.record(question, options, Some(ctx.workspace.clone()), ctx.taint);
if self.parked().len() > before {
if let Some(cancel) = &ctx.cancel {
cancel.cancel();
}
}
Some(answer)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tool::ask::Asker;
use crate::tool::ToolCtx;
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"mecha-questions-test-{name}-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
dir
}
fn store(name: &str) -> QuestionStore {
QuestionStore::open(scratch(name)).unwrap()
}
#[test]
fn a_parked_question_is_open_and_carries_its_session() {
let s = store("park");
let q = s
.park(
"Which address should the letter go to?",
vec!["work".into(), "home".into()],
"sess-1",
Some("task-9".into()),
Some(PathBuf::from("/w/a")),
Taint::default(),
)
.unwrap();
assert!(q.is_open());
assert_eq!(q.session_id, "sess-1");
assert_eq!(q.task_id.as_deref(), Some("task-9"));
assert_eq!(s.open_items().unwrap().len(), 1);
assert_eq!(s.get(&q.id).unwrap().options.len(), 2);
}
#[test]
fn answering_records_the_words_and_closes_it_once() {
let s = store("answer");
let q = s
.park("Which one?", vec![], "sess-1", None, None, Taint::default())
.unwrap();
let answered = s.answer(&q.id, "the work address").unwrap();
assert_eq!(answered.status, "answered");
assert_eq!(answered.answer.as_deref(), Some("the work address"));
assert!(answered.answered_at.is_some());
assert!(s.open_items().unwrap().is_empty());
assert!(s.answer(&q.id, "no, home").is_err());
}
#[test]
fn abandoning_leaves_the_answer_empty() {
let s = store("abandon");
let q = s
.park("Which one?", vec![], "sess-1", None, None, Taint::default())
.unwrap();
let done = s.abandon(&q.id).unwrap();
assert_eq!(done.status, "abandoned");
assert!(done.answer.is_none());
assert!(s.open_items().unwrap().is_empty());
}
#[test]
fn the_short_form_is_the_tail_because_the_head_is_a_date() {
let a = "20260826T101804-476080dd";
let b = "20260826T134102-91ac33fe";
assert_eq!(&a[..8], &b[..8], "the premise: same day, same prefix");
assert_eq!(QuestionStore::short(a), "476080dd");
assert_ne!(QuestionStore::short(a), QuestionStore::short(b));
}
#[test]
fn a_question_is_found_by_its_printed_tail() {
let s = store("tail");
let q = s
.park("a?", vec![], "sess", None, None, Taint::default())
.unwrap();
let tail = QuestionStore::short(&q.id).to_string();
assert_eq!(s.find(&tail).unwrap().id, q.id, "what is printed must work");
}
#[test]
fn an_ambiguous_prefix_is_an_error_rather_than_a_guess() {
let s = store("find");
let a = s
.park("a?", vec![], "sess", None, None, Taint::default())
.unwrap();
assert!(s.find(&a.id).is_ok());
assert!(s.find(&a.id[..8]).is_ok());
assert!(s.find("nope").is_err());
let mut twin = a.clone();
twin.id = format!("{}zz", a.id);
s.put(&twin).unwrap();
assert!(s.find(&a.id[..8]).is_err(), "ambiguous prefix must refuse");
}
#[tokio::test]
async fn asking_parks_the_question_and_stops_the_run() {
let s = std::sync::Arc::new(store("asker"));
let asker = ParkingAsker::new(std::sync::Arc::clone(&s), "sess-7", Some("task-3".into()));
let cancel = tokio_util::sync::CancellationToken::new();
let ctx = ToolCtx {
workspace: PathBuf::from("/w/a"),
cancel: Some(cancel.clone()),
..Default::default()
};
assert!(!cancel.is_cancelled());
let answer = asker.ask_in(&ctx, "Which address?", &[]).await.unwrap();
assert!(cancel.is_cancelled(), "the run must end, not wait");
assert_eq!(asker.parked().len(), 1);
let q = s.get(&asker.parked()[0]).unwrap();
assert_eq!(q.question, "Which address?");
assert_eq!(q.session_id, "sess-7");
assert_eq!(q.workspace.as_deref(), Some(Path::new("/w/a")));
assert!(answer.contains(&q.id));
assert!(!answer.to_lowercase().contains("declined"));
}
#[tokio::test]
async fn a_store_that_cannot_write_leaves_the_run_alive() {
let dir = scratch("broken");
let s = std::sync::Arc::new(QuestionStore::open(&dir).unwrap());
std::fs::remove_dir_all(&dir).unwrap();
let asker = ParkingAsker::new(s, "sess-7", None);
let cancel = tokio_util::sync::CancellationToken::new();
let ctx = ToolCtx {
cancel: Some(cancel.clone()),
..Default::default()
};
let answer = asker.ask_in(&ctx, "Which address?", &[]).await.unwrap();
assert!(
!cancel.is_cancelled(),
"a lost question must not end the run"
);
assert!(asker.parked().is_empty());
assert!(answer.contains("could not be stored"));
}
#[tokio::test]
async fn taint_is_recorded_when_the_question_is_parked() {
let s = std::sync::Arc::new(store("taint"));
let asker = ParkingAsker::new(std::sync::Arc::clone(&s), "sess-7", None);
let ctx = ToolCtx {
taint: Some(Taint {
private: true,
untrusted: true,
}),
..Default::default()
};
asker.ask_in(&ctx, "Which address?", &[]).await.unwrap();
let q = s.get(&asker.parked()[0]).unwrap();
assert!(
q.taint.private && q.taint.untrusted,
"the warning must not depend on a stamp that may never run"
);
}
#[tokio::test]
async fn a_context_with_no_taint_parks_as_untrusted() {
let s = std::sync::Arc::new(store("taint-unknown"));
let asker = ParkingAsker::new(std::sync::Arc::clone(&s), "sess-7", None);
asker
.ask_in(&ToolCtx::default(), "Which address?", &[])
.await
.unwrap();
let q = s.get(&asker.parked()[0]).unwrap();
assert!(q.taint.untrusted, "unknown must not read as clean");
}
#[tokio::test]
async fn the_stamp_can_only_add_taint_never_remove_it() {
let s = std::sync::Arc::new(store("taint-merge"));
let asker = ParkingAsker::new(std::sync::Arc::clone(&s), "sess-7", None);
asker
.ask_in(&ToolCtx::default(), "Which address?", &[])
.await
.unwrap();
let id = asker.parked()[0].clone();
asker.stamp_taint(Taint::default());
assert!(
s.get(&id).unwrap().taint.untrusted,
"a clean stamp must not launder an unknown park"
);
let s2 = std::sync::Arc::new(store("taint-merge-2"));
let a2 = ParkingAsker::new(std::sync::Arc::clone(&s2), "sess-8", None);
let clean = ToolCtx {
taint: Some(Taint::default()),
..Default::default()
};
a2.ask_in(&clean, "Which?", &[]).await.unwrap();
let id2 = a2.parked()[0].clone();
assert!(!s2.get(&id2).unwrap().taint.untrusted);
a2.stamp_taint(Taint {
private: false,
untrusted: true,
});
assert!(s2.get(&id2).unwrap().taint.untrusted, "growth still lands");
}
}