tokio's File buffers; an append that returned Ok could still be in memory when the next take() read the file. Caught by multiple_appends_accumulate on a runner, never on a developer machine.
182 lines
6.4 KiB
Rust
182 lines
6.4 KiB
Rust
//! Pending heartbeat surfacings — the stash between an autonomous cycle and
|
|
//! the next time a human opens a conversation.
|
|
//!
|
|
//! When the CronSensor fires a background turn, the subconscious runs her
|
|
//! N+1 pass and may surface an observation, a reflection, or an archivist
|
|
//! synthesis. Those events are emitted onto a stream that
|
|
//! `inject_background_turn` drains silently — no UI is listening. They also
|
|
//! broadcast on the EventBus, but a TUI that wasn't running never saw them.
|
|
//!
|
|
//! This module is the bridge: the background drain appends what surfaced to a
|
|
//! JSONL file *beside* the agent's memfs — not *inside* it. A pickup queue is
|
|
//! transient runtime state, not memory; writing it into the git-tracked memfs
|
|
//! would churn her history with ephemeral files. The next TUI/CLI session
|
|
//! reads the file, shows her what happened while she was away, and clears it.
|
|
//! Read-once: no cursor, no dedup bookkeeping.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// One thing the subconscious surfaced during an autonomous cycle.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PendingSurfacing {
|
|
/// `"surfacing"`, `"reflection"`, or `"archivist"`.
|
|
pub kind: String,
|
|
/// Surfacing source (`complete`/`verify`/`persist`/`surface`); empty for
|
|
/// reflections and archivist syntheses.
|
|
#[serde(default)]
|
|
pub source: String,
|
|
/// The observation / reflection / synthesis text.
|
|
pub content: String,
|
|
/// Surfacing priority (`low`/`high`/`critical`); empty for non-surfacings.
|
|
#[serde(default)]
|
|
pub priority: String,
|
|
/// When the autonomous cycle produced it.
|
|
pub at: chrono::DateTime<chrono::Utc>,
|
|
}
|
|
|
|
/// JSONL file path: `<agent_data_dir>/pending-surfacings.jsonl`.
|
|
fn path(agent_data_dir: &Path) -> PathBuf {
|
|
agent_data_dir.join("pending-surfacings.jsonl")
|
|
}
|
|
|
|
/// Append surfacings produced by a background turn. One JSON object per line:
|
|
/// append-friendly across multiple heartbeats between sessions, and a single
|
|
/// corrupt line can't poison the rest.
|
|
pub async fn append(agent_data_dir: &Path, items: &[PendingSurfacing]) -> anyhow::Result<()> {
|
|
if items.is_empty() {
|
|
return Ok(());
|
|
}
|
|
use tokio::io::AsyncWriteExt;
|
|
|
|
let p = path(agent_data_dir);
|
|
if let Some(parent) = p.parent() {
|
|
tokio::fs::create_dir_all(parent).await.ok();
|
|
}
|
|
let mut file = tokio::fs::OpenOptions::new()
|
|
.create(true)
|
|
.append(true)
|
|
.open(&p)
|
|
.await?;
|
|
|
|
let mut buf = String::new();
|
|
for item in items {
|
|
buf.push_str(&serde_json::to_string(item)?);
|
|
buf.push('\n');
|
|
}
|
|
file.write_all(buf.as_bytes()).await?;
|
|
// tokio's File buffers. Without this the bytes can still be in memory when
|
|
// the next take() reads the file, and an append that returned Ok is simply
|
|
// absent -- which is what multiple_appends_accumulate caught on a runner
|
|
// and never caught on a developer machine.
|
|
file.flush().await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Read every surfacing stashed since the last pickup, then delete the file.
|
|
///
|
|
/// Corrupt lines are skipped, not fatal — a half-written line from a crash
|
|
/// mid-append loses that one entry, never the rest. Returns empty when no
|
|
/// autonomous cycle ran (the common case).
|
|
pub async fn take(agent_data_dir: &Path) -> Vec<PendingSurfacing> {
|
|
let p = path(agent_data_dir);
|
|
let raw = match tokio::fs::read_to_string(&p).await {
|
|
Ok(s) => s,
|
|
Err(_) => return Vec::new(),
|
|
};
|
|
let items: Vec<PendingSurfacing> = raw
|
|
.lines()
|
|
.filter(|l| !l.trim().is_empty())
|
|
.filter_map(|l| serde_json::from_str(l).ok())
|
|
.collect();
|
|
// Read-once: clear the queue so the next session starts fresh.
|
|
let _ = tokio::fs::remove_file(&p).await;
|
|
items
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn sample(kind: &str, content: &str) -> PendingSurfacing {
|
|
PendingSurfacing {
|
|
kind: kind.to_string(),
|
|
source: String::new(),
|
|
content: content.to_string(),
|
|
priority: String::new(),
|
|
at: chrono::Utc::now(),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn append_then_take_round_trips() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
append(dir.path(), &[sample("surfacing", "first")])
|
|
.await
|
|
.unwrap();
|
|
let items = take(dir.path()).await;
|
|
assert_eq!(items.len(), 1);
|
|
assert_eq!(items[0].content, "first");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn take_clears_the_file() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
append(dir.path(), &[sample("reflection", "x")])
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(take(dir.path()).await.len(), 1);
|
|
// Second take finds nothing — the queue was drained.
|
|
assert!(take(dir.path()).await.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn multiple_appends_accumulate() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
// Two heartbeats fire between sessions — each appends.
|
|
append(dir.path(), &[sample("surfacing", "a")])
|
|
.await
|
|
.unwrap();
|
|
append(dir.path(), &[sample("archivist", "b")])
|
|
.await
|
|
.unwrap();
|
|
let items = take(dir.path()).await;
|
|
assert_eq!(items.len(), 2);
|
|
assert_eq!(items[0].content, "a");
|
|
assert_eq!(items[1].content, "b");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn corrupt_line_is_skipped_not_fatal() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
append(dir.path(), &[sample("surfacing", "good")])
|
|
.await
|
|
.unwrap();
|
|
// Simulate a half-written line from a crash mid-append.
|
|
use tokio::io::AsyncWriteExt;
|
|
let mut f = tokio::fs::OpenOptions::new()
|
|
.append(true)
|
|
.open(path(dir.path()))
|
|
.await
|
|
.unwrap();
|
|
f.write_all(b"{not valid json\n").await.unwrap();
|
|
|
|
let items = take(dir.path()).await;
|
|
assert_eq!(items.len(), 1, "the valid entry survives a corrupt line");
|
|
assert_eq!(items[0].content, "good");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn take_on_missing_file_is_empty() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
assert!(take(dir.path()).await.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn append_empty_is_noop() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
append(dir.path(), &[]).await.unwrap();
|
|
assert!(!path(dir.path()).exists());
|
|
}
|
|
}
|