set an agent's initial agents.md at create
This commit is contained in:
parent
a6147dc875
commit
456cf9a7cb
19 changed files with 708 additions and 19 deletions
|
|
@ -49,6 +49,7 @@ swarm-queue-client = { workspace = true, features = [
|
|||
"agent-schedules",
|
||||
"agent-inference",
|
||||
"agent-permissions",
|
||||
"agents-md-initial",
|
||||
"operator-profiles",
|
||||
] }
|
||||
# This agent's own queue secret, read from the store under its own certificate.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue