Watch
0
0
Fork
You've already forked hyperhive
0

set an agent's initial agents.md at create

This commit is contained in:
atlas 2026-10-18 06:39:19 +02:00 • committed by mara
commit 456cf9a7cb
19 changed files with 708 additions and 19 deletions

View file

@ -505,6 +505,8 @@ async fn serve_main<S: Surface>(socket: &Path, poll_ms: u64) -> Result<()> {
swarm_agent_state::spawn(&bus, login_state.clone());
// Once per start: a new icon only arrives with a new harness process.
swarm_agent_icon::spawn();
// Before the mirror and before any turn reads the file.
swarm_agents_md::seed_initial().await;
// At start and on every change, so the swarm can read it while the
// agent is stopped.
swarm_agents_md::spawn();

View file

@ -29,6 +29,7 @@ use std::time::Duration;
use async_nats::jetstream::context::PublishErrorKind;
use sha1::{Digest as _, Sha1};
use swarm_queue_client::agents_md::{Apply, ApplyReply, Mirror};
use swarm_queue_client::agents_md_initial::Initial;
use tokio::sync::Notify;
use crate::claude_md_watch::agents_md_path;
@ -94,6 +95,103 @@ pub fn spawn() {
});
}
/// How many times, and for how long each, the key is read at start before the
/// agent starts without the text.
const SEED_ATTEMPTS: u32 = 3;
const SEED_READ_TIMEOUT: Duration = Duration::from_secs(5);
const SEED_RETRY_WAIT: Duration = Duration::from_secs(1);
/// Write the text the swarm stored for this agent at create to `AGENTS.md`,
/// when the file does not exist. Done before anything reads the file, so the
/// agent's first turn already has it, and before the mirror, so the swarm
/// sees it from the first publish.
///
/// An existing file is never touched, so a restart or a re-created agent
/// keeps its edits. The read of the key is bounded to about
/// [`SEED_ATTEMPTS`] × [`SEED_READ_TIMEOUT`], so a queue that refuses it delays
/// boot by seconds; the agent then starts without the text and a later start
/// tries again.
pub async fn seed_initial() {
let agent = crate::identity::label();
let dir = crate::paths::state_dir();
if agent.is_empty() || exists(&agents_md_path(&dir)) {
return;
}
let client = crate::swarm_queue::client().await;
let read = read_bounded(SEED_ATTEMPTS, SEED_READ_TIMEOUT, SEED_RETRY_WAIT, || {
swarm_queue_client::agents_md_initial::read_own(&client, &agent)
})
.await;
match read {
Ok(initial) => {
if let Err(e) = seed(&dir, initial.as_ref()) {
tracing::warn!(error = %e, "writing the initial AGENTS.md failed");
}
}
Err(e) => {
tracing::warn!(error = %e, "reading the initial AGENTS.md failed; starting without it");
}
}
}
/// `read`, tried up to `attempts` times with `wait` between, each cut off
/// after `timeout`. The first answer wins; the last failure is the error.
async fn read_bounded<T, E, F, Fut>(
attempts: u32,
timeout: Duration,
wait: Duration,
mut read: F,
) -> Result<T, String>
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<T, E>>,
E: std::fmt::Display,
{
let mut last = String::new();
for attempt in 0..attempts {
if attempt > 0 {
tokio::time::sleep(wait).await;
}
match tokio::time::timeout(timeout, read()).await {
Ok(Ok(value)) => return Ok(value),
Ok(Err(e)) => last = e.to_string(),
Err(_) => last = format!("no answer within {timeout:?}"),
}
}
Err(last)
}
/// Whether anything, a dangling link included, is at `path`.
fn exists(path: &Path) -> bool {
std::fs::symlink_metadata(path).is_ok()
}
/// Create `AGENTS.md` in `state_dir` with `initial`'s text, unless a file is
/// already there. The text goes to a temporary file in the same directory
/// first and is linked into place without replacing anything
/// (`persist_noclobber`), so a failed write leaves no file and a file written
/// meanwhile is kept.
fn seed(state_dir: &Path, initial: Option<&Initial>) -> std::io::Result<()> {
use std::os::unix::fs::PermissionsExt as _;
let Some(initial) = initial else {
return Ok(());
};
std::fs::create_dir_all(state_dir)?;
let mut temp = tempfile::NamedTempFile::new_in(state_dir)?;
temp.write_all(initial.content.as_bytes())?;
temp.as_file()
.set_permissions(std::fs::Permissions::from_mode(0o644))?;
match temp.persist_noclobber(agents_md_path(state_dir)) {
Ok(_) => {
tracing::info!(bytes = initial.content.len(), "wrote the initial AGENTS.md");
Ok(())
}
Err(e) if e.error.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
Err(e) => Err(e.error),
}
}
/// What became of one mirror write.
#[derive(Debug)]
enum Published {
@ -503,4 +601,95 @@ mod tests {
assert_eq!(second.revision, revision(AFTER.as_bytes()));
task.abort();
}
fn initial(text: &str) -> swarm_queue_client::agents_md_initial::Initial {
swarm_queue_client::agents_md_initial::Initial {
content: text.to_owned(),
}
}
/// The stored text becomes the file when there is none, and the mirror's
/// revision then covers it.
#[test]
fn the_initial_text_is_written_when_there_is_no_file() {
let dir = tempfile::tempdir().expect("tempdir");
super::seed(dir.path(), Some(&initial(BEFORE))).expect("seed");
assert_eq!(file(&dir), BEFORE);
// Presence control for the next two: nothing stored writes nothing.
let empty = tempfile::tempdir().expect("tempdir");
super::seed(empty.path(), None).expect("seed");
assert!(!empty.path().join("AGENTS.md").exists());
}
/// A file the agent already has, edited or not, is never replaced.
#[test]
fn an_existing_file_is_not_overwritten_by_the_initial_text() {
let dir = state(AFTER);
super::seed(dir.path(), Some(&initial(BEFORE))).expect("seed");
assert_eq!(file(&dir), AFTER);
let empty = state("");
super::seed(empty.path(), Some(&initial(BEFORE))).expect("seed");
assert_eq!(file(&empty), "", "an emptied file is still the agent's");
}
/// A second start, with the key still stored, keeps the agent's edits.
#[test]
fn a_second_seed_keeps_what_the_agent_did_with_the_file() {
let dir = tempfile::tempdir().expect("tempdir");
super::seed(dir.path(), Some(&initial(BEFORE))).expect("seed");
std::fs::write(dir.path().join("AGENTS.md"), AFTER).expect("edit");
super::seed(dir.path(), Some(&initial(BEFORE))).expect("seed again");
assert_eq!(file(&dir), AFTER);
}
/// A seed that cannot finish leaves no `AGENTS.md`, so the next start
/// tries again. The state dir's parent is a file, so no temp file can be
/// made; the leftover directory listing is empty either way.
#[test]
fn a_seed_that_fails_leaves_no_file_and_no_temp_file() {
let dir = tempfile::tempdir().expect("tempdir");
let blocker = dir.path().join("blocker");
std::fs::write(&blocker, "x").expect("write");
super::seed(&blocker.join("state"), Some(&initial(BEFORE))).expect_err("no dir");
assert!(!blocker.join("state").exists());
// Control: a good dir leaves exactly AGENTS.md, no temp file.
let ok = tempfile::tempdir().expect("tempdir");
super::seed(ok.path(), Some(&initial(BEFORE))).expect("seed");
let names: Vec<_> = std::fs::read_dir(ok.path())
.expect("ls")
.map(|e| e.expect("entry").file_name())
.collect();
assert_eq!(names, ["AGENTS.md"]);
}
/// A read that never answers costs `attempts` × `timeout` plus the waits,
/// not a startup retry schedule, and the last failure is reported.
#[tokio::test(start_paused = true)]
async fn a_read_that_never_answers_is_given_up_after_the_bound() {
let started = tokio::time::Instant::now();
let out = super::read_bounded(3, Duration::from_secs(5), Duration::from_secs(1), || {
std::future::pending::<Result<(), String>>()
})
.await;
assert!(out.unwrap_err().contains("no answer within"));
assert_eq!(started.elapsed(), Duration::from_secs(17));
}
/// A later attempt that answers wins over an earlier failure.
#[tokio::test(start_paused = true)]
async fn a_read_that_fails_once_and_then_answers_returns_the_answer() {
let calls = std::sync::atomic::AtomicU32::new(0);
let out = super::read_bounded(3, Duration::from_secs(5), Duration::from_secs(1), || {
let n = calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
async move {
if n == 0 {
Err("down".to_owned())
} else {
Ok(7)
}
}
})
.await;
assert_eq!(out, Ok(7));
}
}