git-ents.gitmain
⌘K
foforge
commit 8d6ceb5
effect, forge, cli: the agent-exec run path with atomic finalize (phase 2b)

The composition-root seam docs/agent-sessions-plan.adoc’s Phase 2 calls for: crates/cli/git-ents/src/agent_worker.rs drives dispatch → claim (CAS through receive; a lost race records a cheap pass) → sandboxed run via the Executor trait → ONE atomic three-ref finalize (session finish

+ result record + result branch), so worker death before finalize publishes nothing (effect.result-taxonomy) and a stale finish refuses the whole batch (receive.multi-ref-atomicity).

Supporting seams, each the smallest that keeps the crate graph clean (ents-effect never links ents-forge):

  • ents-receive: entity_transition made pub (build one typed transition without sending it, for larger atomic proposals).

  • ents-forge: finish split into finish_transition (build) + finish (build+send).

  • ents-effect: materialize::write_tree, checkout’s reverse, snapshots the post-run workdir into the result branch’s tree.

  • hook.rs::post_receive branches agent-exec to the bespoke handler; every other effect keeps run_one.

  • git ents agent {new,plan,confirm,list,show} wired into the binary.

The confirmed plan reaches the sandbox as .git-ents-agent-plan.txt in the workdir, removed again before the output tree is captured. Known gap, documented not assumed away: SpriteExecutor syncs host→sandbox only; sandbox→host sync-back is a follow-up before hosted agent runs produce genuine output trees.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Joseph D. Carpinelli · 28 days ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

crates/cli/git-ents/src/cli.rs @@ -9,6 +9,7 @@ use facet::Facet; use figue::{self as args, FigueBuiltins}; +pub use ents_forge::agent::AgentAction; pub use ents_forge::comment::CommentAction; pub use ents_forge::issue::IssueAction; pub use ents_forge::review::ReviewAction; @@ -99,6 +100,16 @@ #[facet(args::subcommand)] action: IssueAction, }, + /// Manage agent sessions at `refs/meta/agent-sessions/<id>`: start one, + /// draft or redraft its plan, and confirm it for execution + /// (`docs/agent-sessions-plan.adoc`). Claiming and running a queued + /// session is the effect worker's job (`git-ents hook post-receive` on + /// the hosted root), not a subcommand here. + Agent { + /// The agent action to run. + #[facet(args::subcommand)] + action: AgentAction, + }, /// Review a commit: a verdict plus a body at /// `refs/meta/reviews/<target>/<member>`, with a retention pin at /// `refs/meta/pins/reviews/<target>/<member>` keeping the reviewed
crates/cli/git-ents/src/exe.rs @@ -8,8 +8,8 @@ )] use crate::cli::{ - AccountAction, Cli, CommentAction, EffectAction, HookAction, InboxAction, IssueAction, - MembersAction, RedactAction, ReviewAction, ToolchainAction, Top, + AccountAction, AgentAction, Cli, CommentAction, EffectAction, HookAction, InboxAction, + IssueAction, MembersAction, RedactAction, ReviewAction, ToolchainAction, Top, }; use crate::commands; use crate::error::Result; @@ -50,6 +50,7 @@ Top::Toolchain { action } => run_toolchain(action, out), Top::Comment { action } => run_comment(action, out), Top::Issue { action } => run_issue(action, out), + Top::Agent { action } => run_agent(action, out), Top::Review { action } => run_review(action, out), Top::Inbox { action } => run_inbox(action, out), Top::Redact { action } => run_redact(action, out), @@ -361,6 +362,75 @@ Ok(()) } +fn run_agent(action: AgentAction, out: &mut impl std::io::Write) -> Result<()> { + let root = LocalRoot::discover(".")?; + match action { + AgentAction::New { + prompt, + model, + toolchain, + base, + review_policy, + retry_of, + key, + } => { + let id = commands::agent::new( + &root, + prompt, + model, + toolchain, + base, + review_policy, + retry_of, + key, + )?; + let _ = writeln!(out, "started {id}"); + } + AgentAction::Plan { id, text, key } => { + commands::agent::plan(&root, &id, text, key)?; + let _ = writeln!(out, "drafted plan for {id}"); + } + AgentAction::Confirm { + id, + review_policy, + key, + } => { + commands::agent::confirm(&root, &id, review_policy, key)?; + let _ = writeln!(out, "confirmed {id}"); + } + AgentAction::List => { + for (id, session) in commands::agent::list(&root)? { + let _ = writeln!( + out, + "{}\t{:?}\t{}", + ents_forge::abbreviate_id(&id), + session.meta.status, + session.meta.member + ); + } + } + AgentAction::Show { id } => { + let session = commands::agent::show(&root, &id)?; + let _ = writeln!(out, "member: {}", session.meta.member); + let _ = writeln!(out, "status: {:?}", session.meta.status); + let _ = writeln!(out, "base: {}", session.meta.base_ref); + let _ = writeln!(out, "queued: {}", session.queued()); + let _ = writeln!( + out, + "awaiting confirmation: {}", + session.awaiting_confirmation() + ); + if let Some(plan) = &session.plan { + let _ = writeln!(out, "plan: {plan}"); + } + if let Some(branch) = &session.meta.result_branch { + let _ = writeln!(out, "result branch: {branch}"); + } + } + } + Ok(()) +} + fn run_review(action: ReviewAction, out: &mut impl std::io::Write) -> Result<()> { let root = LocalRoot::discover(".")?; match action {
crates/cli/git-ents/src/hook.rs @@ -226,9 +226,10 @@ let Some(effect) = read_effect(&root.refs, &root.objects, &effect_name)? else { continue; }; - // `run_one` no longer resolves toolchain names itself: resolve and - // materialize this effect's declared toolchains here, before - // handing the run loop an already-materialized slice. + // `run_one`/`run_agent_exec` no longer resolve toolchain names + // themselves: resolve and materialize this effect's declared + // toolchains here, before handing the run loop an + // already-materialized slice. let mut toolchains = Vec::with_capacity(effect.toolchains.len()); for toolchain_name in &effect.toolchains { let (_, recipe) = @@ -236,20 +237,46 @@ let bin = ents_kiln::toolchain::materialize(&recipe, &root.objects, toolchain_cache)?; toolchains.push((toolchain_name.clone(), bin)); } - run_one( - &root.refs, - &root.objects, - &root.events, - executor, - scratch, - &toolchains, - oid, - &effect, - result_ref, - &author, - |payload| signer.sign(payload), - Mode::Mandatory, - )?; + + // The `agent-exec` effect (`docs/agent-sessions-plan.adoc`'s Phase + // 2) needs the bespoke dispatch/claim/finalize handling + // `crate::agent_worker::run_agent_exec` provides — a plain + // pass/fail result on a single ref, `run_one`'s own contract, + // cannot express "claim, run a sandbox, and land the session's + // terminal state, its result, and its result branch atomically." + // Every other effect keeps the ordinary single-ref path. + if effect_name == crate::agent_worker::AGENT_EXEC_NAME { + crate::agent_worker::run_agent_exec( + &root.refs, + &root.objects, + &root.events, + executor, + scratch, + &toolchains, + &effect.run, + oid, + ents_model::MemberId::new(crate::root::HOSTED_WORKER_NAME), + crate::root::HOSTED_WORKER_NAME.to_owned(), + &author, + &|payload| signer.sign(payload), + Mode::Mandatory, + )?; + } else { + run_one( + &root.refs, + &root.objects, + &root.events, + executor, + scratch, + &toolchains, + oid, + &effect, + result_ref, + &author, + |payload| signer.sign(payload), + Mode::Mandatory, + )?; + } ran = ran.saturating_add(1); } Ok(ran)
crates/cli/git-ents/src/lib.rs @@ -73,6 +73,7 @@ //! assert!(root.refs.get(name.as_ref()).expect("reads").is_some()); //! ``` +pub mod agent_worker; pub mod cli; pub mod commands; pub mod error;
crates/cli/git-ents/tests/cli_help.rs @@ -40,6 +40,7 @@ "toolchain", "comment", "issue", + "agent", "review", "inbox", "redact",
crates/kernel/ents-effect/src/materialize.rs @@ -23,8 +23,8 @@ use gix_hash::ObjectId; use gix_object::bstr::ByteSlice as _; -use gix_object::tree::EntryKind; -use gix_object::{Find, Kind, TreeRef}; +use gix_object::tree::{Entry, EntryKind, EntryMode}; +use gix_object::{Find, Kind, Tree, TreeRef, Write}; use crate::error::{Error, Result}; @@ -155,6 +155,139 @@ Ok(()) } +/// The reverse of [`checkout`]: recursively read `src`'s current on-disk +/// state and write it as a git tree, preserving the executable bit and +/// symlink targets `checkout` itself would restore. A directory walk can +/// never discover a gitlink (there is no way for a plain host directory to +/// carry one), so unlike `checkout` this has no submodule case to refuse. +/// +/// This is `docs/agent-sessions-plan.adoc`'s Phase 2 finalize's other +/// half from `checkout`: after [`crate::Executor::run`] completes, a +/// composition root reads the checked-out workdir's now-current state back +/// through this function to build the sandbox's output tree — the tree the +/// result branch's commit carries. Every effect backend built so far in +/// this crate (`UnsandboxedExecutor` today; a future `SpriteExecutor` that +/// syncs its sandbox's filesystem back onto `workdir` before returning) +/// leaves its command's file-level effects on `workdir` itself, so this +/// reads the same host directory [`SandboxInputs::workdir`] named, not a +/// second, backend-specific location. +/// +/// # Errors +/// +/// [`Error::NotUtf8`] for a non-UTF-8 entry name or symlink target; +/// [`Error::Io`] for a host filesystem failure reading `src` or one of its +/// entries; [`Error::ObjectWrite`] if writing a blob or tree object fails. +/// +/// # Examples +/// +/// ``` +/// use ents_effect::materialize::write_tree; +/// use ents_testutil::ObjectStore; +/// +/// let dir = tempfile::tempdir().expect("tempdir"); +/// std::fs::write(dir.path().join("README"), b"hello\n").expect("write"); +/// std::fs::create_dir(dir.path().join("sub")).expect("mkdir"); +/// std::fs::write(dir.path().join("sub/leaf.txt"), b"leaf\n").expect("write"); +/// +/// let objects = ObjectStore::default(); +/// let tree = write_tree(&objects, dir.path()).expect("writes"); +/// +/// let checked_out = tempfile::tempdir().expect("tempdir"); +/// ents_effect::materialize::checkout(&objects, tree, checked_out.path()).expect("checkout"); +/// assert_eq!( +/// std::fs::read_to_string(checked_out.path().join("README")).expect("read"), +/// "hello\n" +/// ); +/// assert_eq!( +/// std::fs::read_to_string(checked_out.path().join("sub").join("leaf.txt")).expect("read"), +/// "leaf\n" +/// ); +/// ``` +// @relation(effect.execution, scope=function) +pub fn write_tree(objects: &(impl Find + Write), src: &Path) -> Result<ObjectId> { + let read_dir = std::fs::read_dir(src).map_err(|source| Error::Io { + path: src.to_owned(), + source, + })?; + + let mut entries = Vec::new(); + for dir_entry in read_dir { + let dir_entry = dir_entry.map_err(|source| Error::Io { + path: src.to_owned(), + source, + })?; + let path = dir_entry.path(); + let name = dir_entry + .file_name() + .into_string() + .map_err(|_not_utf8| Error::NotUtf8(path.clone()))?; + let file_type = dir_entry.file_type().map_err(|source| Error::Io { + path: path.clone(), + source, + })?; + + let (mode, oid) = if file_type.is_dir() { + ( + EntryMode::from(EntryKind::Tree), + write_tree(objects, &path)?, + ) + } else if file_type.is_symlink() { + let target = std::fs::read_link(&path).map_err(|source| Error::Io { + path: path.clone(), + source, + })?; + let target = target + .to_str() + .ok_or_else(|| Error::NotUtf8(path.clone()))?; + let oid = objects.write_buf(Kind::Blob, target.as_bytes())?; + (EntryMode::from(EntryKind::Link), oid) + } else { + let bytes = std::fs::read(&path).map_err(|source| Error::Io { + path: path.clone(), + source, + })?; + let kind = if is_executable(&path)? { + EntryKind::BlobExecutable + } else { + EntryKind::Blob + }; + ( + EntryMode::from(kind), + objects.write_buf(Kind::Blob, &bytes)?, + ) + }; + entries.push(Entry { + mode, + filename: name.into(), + oid, + }); + } + // `gix_object::Tree::write_to` debug-asserts its entries are sorted by + // its own `Ord` (git's tree-entry order, a directory compared as if it + // carried a trailing `/`) — a plain directory walk has no such + // ordering, so this sorts before handing the tree to `objects.write`. + entries.sort(); + Ok(objects.write(&Tree { entries })?) +} + +#[cfg(unix)] +fn is_executable(path: &Path) -> Result<bool> { + use std::os::unix::fs::PermissionsExt as _; + let mode = std::fs::metadata(path) + .map_err(|source| Error::Io { + path: path.to_owned(), + source, + })? + .permissions() + .mode(); + Ok(mode & 0o111 != 0) +} + +#[cfg(not(unix))] +fn is_executable(_path: &Path) -> Result<bool> { + Ok(false) +} + fn make_dir(path: &Path) -> Result<()> { std::fs::create_dir_all(path).map_err(|source| Error::Io { path: path.to_owned(), @@ -438,4 +571,84 @@ "content\n" ); } + + // ----------------------------------------------------------------- + // write_tree: checkout's reverse. + // ----------------------------------------------------------------- + + #[test] + // @relation(effect.execution, scope=function, role=Verifies) + fn write_tree_round_trips_plain_files_and_subdirectories_through_checkout() { + let objects = ObjectStore::default(); + let src = tempfile::tempdir().expect("tempdir"); + std::fs::write(src.path().join("README"), b"hello\n").expect("write"); + std::fs::create_dir(src.path().join("sub")).expect("mkdir"); + std::fs::write(src.path().join("sub/leaf.txt"), b"leaf\n").expect("write"); + + let tree = write_tree(&objects, src.path()).expect("writes"); + + let dest = tempfile::tempdir().expect("tempdir"); + checkout(&objects, tree, dest.path()).expect("checkout"); + assert_eq!( + std::fs::read_to_string(dest.path().join("README")).expect("read"), + "hello\n" + ); + assert_eq!( + std::fs::read_to_string(dest.path().join("sub").join("leaf.txt")).expect("read"), + "leaf\n" + ); + } + + #[test] + // @relation(effect.execution, scope=function, role=Verifies) + fn write_tree_preserves_the_executable_bit() { + let objects = ObjectStore::default(); + let src = tempfile::tempdir().expect("tempdir"); + let script = src.path().join("run.sh"); + std::fs::write(&script, b"#!/bin/sh\necho hi\n").expect("write"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + let mut perms = std::fs::metadata(&script).expect("stat").permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&script, perms).expect("chmod"); + } + + let tree = write_tree(&objects, src.path()).expect("writes"); + let dest = tempfile::tempdir().expect("tempdir"); + checkout(&objects, tree, dest.path()).expect("checkout"); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + let mode = std::fs::metadata(dest.path().join("run.sh")) + .expect("stat") + .permissions() + .mode(); + assert_eq!( + mode & 0o111, + 0o111, + "the executable bit must survive the round trip" + ); + } + } + + #[test] + // @relation(effect.execution, scope=function, role=Verifies) + fn write_tree_entries_are_sorted_for_serialization() { + // A directory walk yields entries in whatever order the host + // filesystem happens to return them, never guaranteed to already be + // git's own tree-entry order; `write_tree` must sort them itself so + // `Tree::write_to`'s debug assertion never trips. + let objects = ObjectStore::default(); + let src = tempfile::tempdir().expect("tempdir"); + for name in ["z-file", "a-file", "m-dir"] { + if name == "m-dir" { + std::fs::create_dir(src.path().join(name)).expect("mkdir"); + } else { + std::fs::write(src.path().join(name), b"x").expect("write"); + } + } + write_tree(&objects, src.path()).expect("writes without tripping the sort assertion"); + } }
crates/kernel/ents-receive/src/lib.rs @@ -100,8 +100,8 @@ pub use outcome::{Mode, Outcome, TxResult}; pub use proposal::{Proposal, RefTransition, TransportAuth}; pub use propose::{ - Identity, propose_delete, propose_entity, propose_entity_with_pin, propose_genesis, - propose_genesis_retaining, propose_pin, + Identity, entity_transition, propose_delete, propose_entity, propose_entity_with_pin, + propose_genesis, propose_genesis_retaining, propose_pin, }; pub use receive::receive; pub use reconcile::reconcile;
crates/kernel/ents-receive/src/propose.rs @@ -138,7 +138,17 @@ /// `propose_entity_with_pin` share: serialize `entity`, wrap it in a signed /// commit whose only parent is `name`'s current tip, and return that /// transition alongside the tip oid the [`Proposal`] must carry. -fn entity_transition<T: for<'facet> facet::Facet<'facet>>( +/// +/// Public so a caller assembling a larger, bespoke atomic multi-ref +/// proposal (`receive.multi-ref-atomicity`) than [`propose_entity_with_pin`] +/// covers — for instance `ents-forge`'s agent-session `finish` alongside a +/// result record and a result branch, `docs/agent-sessions-plan.adoc`'s +/// Phase 2 finalize — can build one of its transitions with the identical +/// signing plumbing every entity mutation uses, then bundle it into its own +/// [`Proposal`] and call [`crate::receive`] exactly once. This does not +/// widen `receive`'s own contract: the transition still only becomes +/// durable through that one call. +pub fn entity_transition<T: for<'facet> facet::Facet<'facet>>( refs: &dyn RefStore, objects: &(impl Find + Write), name: &FullName,
crates/cli/git-ents/src/commands/mod.rs @@ -9,6 +9,7 @@ )] pub mod account; +pub mod agent; pub mod comment; pub mod effect; pub mod inbox;
crates/forge/ents-forge/src/agent/command.rs @@ -384,6 +384,40 @@ identity: &Identity<'_>, mode: Mode, ) -> Result<Outcome> { + let (transition, tip) = finish_transition(refs, objects, id, finish, identity)?; + let proposal = ents_receive::Proposal { + transitions: vec![transition], + objects: vec![tip], + auth: None, + }; + Ok(ents_receive::receive( + refs, objects, events, &proposal, mode, + )?) +} + +/// Build (but do not send) the [`finish`] transition: the same validation +/// and tree-building `finish` itself does, returning the +/// [`ents_receive::RefTransition`] and the new tip's oid instead of +/// proposing it alone. +/// +/// This is the seam a composition root uses to land the session's finish +/// alongside the run's result record and its result branch in one atomic +/// [`ents_receive::Proposal`] (`receive.multi-ref-atomicity`, +/// `docs/agent-sessions-plan.adoc`'s Phase 2 finalize: "one atomic +/// multi-ref receive proposal") — [`finish`] itself is this function plus +/// wrapping the single transition in its own one-transition proposal, for +/// a caller that only ever needs the session ref to move alone. +/// +/// # Errors +/// +/// See [`finish`] — identical. +pub fn finish_transition( + refs: &dyn RefStore, + objects: &(impl Find + Write), + id: &str, + finish: FinishAgentSession, + identity: &Identity<'_>, +) -> Result<(ents_receive::RefTransition, ObjectId)> { let mut session = session_at(refs, objects, id)?; if session.meta.status != Status::Running { return Err(Error::InvalidArgument(format!( @@ -401,15 +435,13 @@ session.thread.extend(finish.thread); let ref_name = ents_model::namespace::agent_session_ref(id)?; - Ok(propose_entity( + Ok(ents_receive::entity_transition( refs, objects, - events, - ref_name, + &ref_name, &session, identity, &format!("Finish agent session {id}"), - mode, )?) }
crates/forge/ents-forge/src/agent/mod.rs @@ -16,7 +16,7 @@ pub use cli::AgentAction; pub use command::{ ClaimAgentSession, FinishAgentSession, FinishOutcome, NewAgentSession, claim, confirm, finish, - list, list_all, new, revise_plan, show, + finish_transition, list, list_all, new, revise_plan, show, }; pub use dispatch::{Dispatch, dispatch}; pub use entity::{
crates/cli/git-ents/src/agent_worker.rs @@ -1,0 +1,1075 @@ +//! The `agent-exec` effect's run path (`docs/agent-sessions-plan.adoc`'s +//! Phase 2): dispatch, claim, sandbox execution, and the atomic finalize +//! that lands the session's terminal state, its result record, and its +//! result branch in one [`ents_receive::receive`] call. +//! +//! # Why this lives here, not in `ents-effect` or `ents-forge` +//! +//! `ents-effect`'s own `Cargo.toml` links exactly `ents-model`, +//! `ents-query`, and `ents-receive` (`docs/spec/overview.adoc`'s crate-graph +//! table) — `ents-forge` is not among them, and must never become one +//! (`ents-forge`'s own `agent::dispatch` module doc states the same +//! constraint from the other side). Landing the session's `finish` and its +//! result record and its result branch in a *single* atomic proposal needs +//! both [`ents_forge::agent`]'s typed session commands and +//! [`ents_effect::Executor`]'s sandbox seam at once — a function that could +//! not live in either kernel crate without creating the wrong-direction +//! edge the crate graph forbids. +//! +//! `git-ents` already depends on both (it is the composition root every +//! other command module in this crate already wires stores and executors +//! for), so this module is the "session handler" seam the plan's Phase 2b +//! calls for: a callback the worker loop ([`crate::hook::post_receive`] for +//! the hosted root, [`crate::commands::agent::run`] for a local `git ents +//! agent run`) installs for the one effect name ([`AGENT_EXEC_NAME`]) whose +//! dispatched obligations need this bespoke handling, falling through to +//! the ordinary [`ents_effect::run::run_one`] path for every other effect. +//! +//! # How the confirmed plan reaches the sandbox +//! +//! [`ents_effect::executor::SandboxInputs::command`] is a fixed string (the +//! effect's own declared `run`); there is no existing convention for +//! threading per-invocation data through it. This module's own convention: +//! the confirmed plan text is written to [`AGENT_PLAN_FILE`] inside the +//! checked-out workdir *before* [`ents_effect::Executor::run`] is called — +//! the same host directory a backend's sandbox syncs in +//! ([`ents_effect::sprite::SpriteExecutor`]'s `sync_dir`), so the declared +//! command finds it already present once it starts, without this crate +//! templating untrusted plan text into a shell string. The file is removed +//! again before the sandbox's output is captured back into a tree (see +//! below), so it never leaks into the pushed result branch. +//! +//! # How the sandbox's output becomes the result branch +//! +//! [`ents_effect::Executor::run`] reports only pass/fail and a log — no +//! backend in this crate today reports back a modified filesystem. This +//! module reads the checked-out workdir's state *after* the command +//! completes and writes it back to a tree with +//! [`ents_effect::materialize::write_tree`] (`checkout`'s reverse), which is +//! correct for any backend whose command mutates `workdir` directly (true +//! of a real local run and of this module's own tests' stub executors). +//! [`ents_effect::sprite::SpriteExecutor`] does not yet sync a sandbox's +//! filesystem back onto its host `workdir` after a run — see that module's +//! own doc for the one-way `sync_dir` it has today — so a real Sprite +//! deployment of this effect needs that follow-up before the result +//! branch's tree reflects genuine sandbox output; this is called out again +//! in the module-level doc of `ents-effect`'s `sprite` module as a known +//! gap, not silently assumed away here. + +use std::path::{Path, PathBuf}; + +use ents_effect::executor::SandboxInputs; +use ents_effect::run::short_oid; +use ents_effect::{Executor, RunStatus}; +use ents_forge::agent::{ + AgentSession, ClaimAgentSession, Dispatch, FinishAgentSession, FinishOutcome, dispatch, +}; +use ents_model::{MemberId, ResultRecord, Status as ResultStatus}; +use ents_receive::{EventSink, Identity, Mode, Outcome, Proposal, RefTransition, TxResult}; +use gix_hash::ObjectId; +use gix_object::{CommitRef, Find, Kind, Write, WriteTo as _}; +use gix_ref_store::RefStore; + +use crate::commands::commit_tree; +use crate::error::{Error, Result}; + +/// `agent-exec`'s own effect name — re-exported so a caller deciding +/// whether a dequeued `(effect, oid)` obligation belongs to this module's +/// bespoke handling, or the ordinary [`ents_effect::run::run_one`] path, +/// does not need a second import of `ents_effect::definition`. +pub const AGENT_EXEC_NAME: &str = ents_effect::definition::AGENT_EXEC_NAME; + +/// The file the confirmed plan text is written to inside the checked-out +/// workdir before the sandboxed command runs (see this module's own doc). +/// Dot-prefixed to keep collisions with a real repository's own top-level +/// entries unlikely; removed again before the workdir's post-run state is +/// captured into the result branch's tree. +pub const AGENT_PLAN_FILE: &str = ".git-ents-agent-plan.txt"; + +/// What running the `agent-exec` effect against one dequeued `(effect, +/// oid)` obligation did. +#[derive(Debug)] +pub enum AgentRunOutcome { + /// The tip was not queued-and-unclaimed; a cheap `pass` was recorded to + /// discharge the obligation, no session was touched + /// (`docs/agent-sessions-plan.adoc`'s Phase 2: "records a cheap `pass` + /// no-op unless the tip is queued-and-unclaimed"). + NoOp, + /// The tip was queued, but by the time this worker attempted to claim + /// it, it no longer was (`Claim = CAS ...; first worker wins, losers + /// no-op`) — a `pass` was recorded for the dequeued oid exactly as the + /// no-op case above, and no session was touched. + ClaimLost, + /// This worker claimed the session, ran it, and finalized the run — + /// `outcome` is the atomic multi-ref [`Outcome`] landing the session's + /// terminal state, its result record, and its result branch together. + Finished { + /// The session's own genesis-oid id. + id: String, + /// The atomic finalize's outcome. + outcome: Outcome, + }, +} + +/// Run the `agent-exec` effect against the single dequeued commit `oid` — a +/// tip entering `refs/meta/agent-sessions/*` (`AGENT_EXEC_TRIGGER`'s own +/// `meta()` semantics: one obligation per ref tip, so `oid` is always a +/// session ref's own tip at match time). +/// +/// Dispatch is pure and decided directly from `oid`'s own decoded session +/// tree (`ents_forge::agent::dispatch`), never from a second, possibly +/// racy re-read of the ref's current tip — the claim attempt below is what +/// re-reads fresh and is the sole place a race is judged. +/// +/// `worker` and `sprite` become [`ents_forge::agent::SessionMeta::worker`] +/// and [`ents_forge::agent::SessionMeta::sprite`] on a successful claim. +/// `toolchains` and `command` are the `agent-exec` effect's own declared +/// toolchains (already resolved to host `bin/` directories, in declared +/// order — mirrors [`ents_effect::run::run_one`]'s own `toolchains` +/// parameter) and its declared `run` command; `scratch` is where the base +/// tree is checked out for one run, wiped and re-created per call exactly +/// like [`ents_effect::run::run_one`]'s own workdir. +/// +/// # Errors +/// +/// Any [`Error`] from reading or decoding the session, the claim attempt +/// (propagated unless it is the ordinary "no longer queued" precondition +/// miss, which this function turns into [`AgentRunOutcome::ClaimLost`] +/// instead), checking out the base tree, [`Executor::run`] itself — an +/// infrastructure failure here propagates with *nothing* published: the +/// session stays `Running`, and the queue's own retry policy is what +/// revisits it (`effect.result-taxonomy`) — or building and sending the +/// finalize proposal. +// @relation(effect.execution, effect.results-writeback, effect.result-taxonomy, receive.multi-ref-atomicity, scope=function) +#[expect( + clippy::too_many_arguments, + reason = "one input per materialization/identity step, mirrors ents_effect::run::run_one's \ + and run_effect's own shape" +)] +pub fn run_agent_exec<O>( + refs: &dyn RefStore, + objects: &O, + events: &dyn EventSink, + executor: &dyn Executor, + scratch: &Path, + toolchains: &[(String, PathBuf)], + command: &str, + oid: ObjectId, + worker: MemberId, + sprite: String, + author: &gix::actor::Signature, + sign: &dyn Fn(&[u8]) -> String, + mode: Mode, +) -> Result<AgentRunOutcome> +where + O: Find + Write, +{ + let tree = commit_tree(objects, oid)?; + let session: AgentSession = facet_git_tree::deserialize(&tree, objects)?; + let results_ref = ents_model::namespace::result_ref(AGENT_EXEC_NAME, &short_oid(oid))?; + + if dispatch(&session) == Dispatch::NoOp { + record_pass(refs, objects, events, &results_ref, oid, author, sign, mode)?; + return Ok(AgentRunOutcome::NoOp); + } + + let id = genesis_of(objects, oid)?.to_string(); + let identity = Identity { + actor: author.clone(), + author: None, + sign, + }; + + let claim_outcome = match ents_forge::agent::claim( + refs, + objects, + events, + &id, + ClaimAgentSession { worker, sprite }, + &identity, + mode, + ) { + Ok(outcome) => outcome, + // The ordinary "first worker wins, losers no-op" race: by the time + // this worker's claim re-read the session fresh, it was no longer + // queued (`ents_forge::agent::claim`'s own precondition). + Err(ents_forge::Error::InvalidArgument(_)) => { + record_pass(refs, objects, events, &results_ref, oid, author, sign, mode)?; + return Ok(AgentRunOutcome::ClaimLost); + } + Err(other) => return Err(other.into()), + }; + if claim_outcome.result != TxResult::Applied { + record_pass(refs, objects, events, &results_ref, oid, author, sign, mode)?; + return Ok(AgentRunOutcome::ClaimLost); + } + + // 3. Run the sandbox against the declared base ref's tip. + let base_ref: gix::refs::FullName = + session + .meta + .base_ref + .clone() + .try_into() + .map_err(|_source| { + Error::InvalidArgument(format!( + "agent session {id}'s base ref {:?} is not a well-formed refname", + session.meta.base_ref + )) + })?; + let base_tip = refs + .get(base_ref.as_ref())? + .ok_or_else(|| Error::NotFound { + what: session.meta.base_ref.clone(), + })?; + let base_tree = commit_tree(objects, base_tip)?; + + let workdir = scratch.join(oid.to_string()); + reset_dir(&workdir)?; + ents_effect::materialize::checkout(objects, base_tree, &workdir)?; + + let plan_path = workdir.join(AGENT_PLAN_FILE); + std::fs::write(&plan_path, session.plan.as_deref().unwrap_or_default()).map_err(|source| { + Error::Io { + path: plan_path.clone(), + source, + } + })?; + + let inputs = SandboxInputs { + workdir: &workdir, + toolchains, + command, + }; + // An `Err` here is an infrastructure failure the sandbox itself never + // turned into a completed pass/fail (`effect.result-taxonomy`): it + // propagates as-is, past every write below, so the finalize proposal + // is never even built — the session stays `Running` (a legal + // continuation, `docs/agent-sessions-plan.adoc`'s Phase 1b), the + // queue's own retry bound decides what happens next. + let output = executor.run(&inputs)?; + + // Never let the sideband plan file leak into the pushed result branch. + // Best-effort: the command may already have deleted it itself, and + // either way `write_tree` below must not see it. + #[expect( + clippy::let_underscore_must_use, + reason = "best-effort cleanup; a failure here (already removed, or a transient host \ + filesystem error) is not actionable and must not fail an otherwise-successful run" + )] + let _ = std::fs::remove_file(&plan_path); + let output_tree = ents_effect::materialize::write_tree(objects, &workdir)?; + + let branch_name = result_branch_name(&session.meta.member, &id); + let branch_ref: gix::refs::FullName = + format!("refs/heads/{branch_name}") + .try_into() + .map_err(|_source| { + Error::InvalidArgument(format!( + "computed result branch name {branch_name:?} is not a well-formed refname" + )) + })?; + let output_commit = seal_commit( + objects, + output_tree, + vec![base_tip], + author, + &format!("agent-exec output for session {id}"), + sign, + )?; + + let (status, finish_outcome) = match output.status { + RunStatus::Pass => (ResultStatus::Pass, FinishOutcome::Done), + RunStatus::Fail => ( + ResultStatus::Fail, + FinishOutcome::Failed("the agent-exec command exited nonzero".to_owned()), + ), + }; + + let finish = FinishAgentSession { + outcome: finish_outcome, + result_branch: Some(branch_name), + thread: vec![output.log.into_bytes()], + }; + let (finish_transition, finish_tip) = + ents_forge::agent::finish_transition(refs, objects, &id, finish, &identity)?; + + let record = ResultRecord::new(AGENT_EXEC_NAME, oid, status); + let (result_transition, result_tip) = ents_receive::entity_transition( + refs, + objects, + &results_ref, + &record, + &identity, + "Record agent-exec result", + )?; + + let branch_old = refs.get(branch_ref.as_ref())?; + let branch_transition = RefTransition { + name: branch_ref, + old: branch_old, + new: Some(output_commit), + }; + + // Finalize = one atomic multi-ref proposal: the session's terminal + // state, the result record, and the result branch land together or not + // at all (`receive.multi-ref-atomicity`). + let proposal = Proposal { + transitions: vec![finish_transition, result_transition, branch_transition], + objects: vec![finish_tip, result_tip, output_commit], + auth: None, + }; + let outcome = ents_receive::receive(refs, objects, events, &proposal, mode)?; + Ok(AgentRunOutcome::Finished { id, outcome }) +} + +/// Record a cheap `pass` for `oid` on the canonical `agent-exec` results +/// ref — the no-op path both [`Dispatch::NoOp`] and a lost claim race take. +#[expect( + clippy::too_many_arguments, + reason = "one input per write_result parameter; a thin, single-call wrapper" +)] +fn record_pass<O: Find + Write>( + refs: &dyn RefStore, + objects: &O, + events: &dyn EventSink, + results_ref: &gix::refs::FullName, + oid: ObjectId, + author: &gix::actor::Signature, + sign: &dyn Fn(&[u8]) -> String, + mode: Mode, +) -> Result<Outcome> { + Ok(ents_effect::write_result( + refs, + objects, + events, + results_ref.clone(), + AGENT_EXEC_NAME, + oid, + ResultStatus::Pass, + author, + sign, + mode, + )?) +} + +/// The result branch name `docs/agent-sessions-plan.adoc`'s +/// resolved-by-default item fixes: `agent/<member>/<abbrev-genesis>`. +#[must_use] +fn result_branch_name(member: &MemberId, id: &str) -> String { + format!("agent/{member}/{}", ents_forge::abbreviate_id(id)) +} + +/// Wipe and recreate `dir` — a fresh workdir per run, mirroring +/// [`ents_effect::run::run_one`]'s own "never inherit a previous run's +/// artifacts" rationale. +fn reset_dir(dir: &Path) -> Result<()> { + if dir.exists() { + std::fs::remove_dir_all(dir).map_err(|source| Error::Io { + path: dir.to_owned(), + source, + })?; + } + std::fs::create_dir_all(dir).map_err(|source| Error::Io { + path: dir.to_owned(), + source, + }) +} + +/// The oldest ancestor of `oid` reachable by following each commit's first +/// parent — the session's own genesis oid and id +/// (`meta-ref.identity-binding`), since every commit `ents_forge::agent` +/// writes onto a session ref is a straight-line, single-parent advance from +/// [`ents_forge::agent::new`]'s own parentless genesis. +fn genesis_of(objects: &impl Find, oid: ObjectId) -> Result<ObjectId> { + let mut current = oid; + loop { + let mut buf = Vec::new(); + let data = objects + .try_find(&current, &mut buf) + .map_err(|source| Error::InvalidArgument(source.to_string()))? + .ok_or_else(|| Error::NotFound { + what: current.to_string(), + })?; + if data.kind != Kind::Commit { + return Err(Error::NotFound { + what: current.to_string(), + }); + } + let commit = CommitRef::from_bytes(data.data, current.kind()) + .map_err(|source| Error::InvalidArgument(source.to_string()))?; + match commit.parents().next() { + Some(parent) => current = parent, + None => return Ok(current), + } + } +} + +/// Build, sign, and write a commit carrying `tree` and `parents` — the same +/// signing shape [`ents_receive`]'s own `signed_commit` and +/// `ents_effect::write_result`'s inline copy use, duplicated here rather +/// than shared cross-crate per this codebase's own convention (see +/// `ents_effect::results`'s module doc: "seals it ... exactly the way +/// `ents_sync::resolve::merge_heads` seals a merge tip"). This builds the +/// result branch's output commit specifically — not an entity, so +/// `ents_receive::entity_transition`'s typed-tree path does not apply to +/// it. +fn seal_commit( + objects: &impl Write, + tree: ObjectId, + parents: Vec<ObjectId>, + author: &gix::actor::Signature, + subject: &str, + sign: &dyn Fn(&[u8]) -> String, +) -> Result<ObjectId> { + let mut commit = gix_object::Commit { + tree, + parents: parents.into(), + author: author.clone(), + committer: author.clone(), + encoding: None, + message: subject.to_owned().into(), + extra_headers: Vec::new(), + }; + let mut payload = Vec::new(); + commit.write_to(&mut payload).map_err(|source| { + Error::InvalidArgument(format!( + "serializing the result branch commit failed: {source}" + )) + })?; + let pem = sign(&payload); + commit + .extra_headers + .push(("gpgsig".into(), pem.trim_end().into())); + + let mut raw = Vec::new(); + commit.write_to(&mut raw).map_err(|source| { + Error::InvalidArgument(format!( + "serializing the signed result branch commit failed: {source}" + )) + })?; + Ok(objects.write_buf(Kind::Commit, &raw)?) +} + +#[cfg(test)] +mod tests { + #![allow( + clippy::expect_used, + clippy::unwrap_used, + clippy::panic, + reason = "unit test; the panic is an assertion on a `let else` branch" + )] + + use ents_forge::agent::ReviewPolicy; + use ents_gate::Config; + use ents_model::{Provenance, namespace}; + use ents_receive::NullEventSink; + use ents_testutil::{ + Keypair, MemRefStore, ObjectStore, advance_ref, enroll_member, write_meta_entity, + }; + use gix_ref_store::RefStoreRead as _; + use rstest::rstest; + + use super::*; + + struct StubExecutor { + status: RunStatus, + mutate: fn(&Path), + } + + impl Executor for StubExecutor { + fn run(&self, inputs: &SandboxInputs<'_>) -> ents_effect::Result<ents_effect::RunOutput> { + (self.mutate)(inputs.workdir); + Ok(ents_effect::RunOutput { + status: self.status, + log: "agent transcript".to_owned(), + }) + } + } + + struct FailingExecutor; + impl Executor for FailingExecutor { + fn run(&self, _inputs: &SandboxInputs<'_>) -> ents_effect::Result<ents_effect::RunOutput> { + Err(ents_effect::Error::Sandbox( + "the sandbox never started".to_owned(), + )) + } + } + + fn no_mutation(_workdir: &Path) {} + + fn writes_a_file(workdir: &Path) { + std::fs::write(workdir.join("agent-output.txt"), b"the agent's work").expect("write"); + } + + /// Fixture: a member, an admin-registered worker, and `refs/heads/main` + /// with one commit — everything a queued session needs to claim and + /// run against. + /// A detached signer over some bytes, returning an armored signature — + /// mirrors `ents-forge`'s own `agent_sessions.rs` integration test + /// fixture's identical type alias. + type Signer = Box<dyn Fn(&[u8]) -> String>; + + struct Fixture { + refs: MemRefStore, + objects: ObjectStore, + key: Keypair, + sign: Signer, + base: ObjectId, + } + + impl Fixture { + fn new() -> Self { + let refs = MemRefStore::default(); + let objects = ObjectStore::default(); + let key = Keypair::from_seed(1); + // `Keypair` deliberately carries no `Clone` (a real signing key + // never should); `from_seed` is a pure function of the seed, so + // a second, independent instance signs identically to `key`. + let sign = Keypair::from_seed(1); + enroll_member( + &refs, + &objects, + "worker", + &key, + Provenance::AdminRegistered, + 100, + ); + // The tip invariant only applies once an epoch is in force + // (`gate.epoch`) — set one so the finalize's own gate checks + // (`gate.fast-forward` in particular, the stale-CAS test below) + // are genuinely exercised rather than short-circuited as + // pre-epoch. + let config_ref: gix::refs::FullName = namespace::CONFIG_REF.try_into().expect("valid"); + write_meta_entity( + &refs, + &objects, + config_ref, + &Config { + epoch: Some(150), + ..Config::default() + }, + Some(&key), + 150, + ); + let commits = advance_ref(&refs, &objects, "refs/heads/main", 1, 200); + let base = *commits.first().expect("advance_ref produced a commit"); + Self { + refs, + objects, + key, + sign: Box::new(move |payload: &[u8]| sign.sign(payload)), + base, + } + } + + fn identity(&self) -> Identity<'_> { + Identity { + actor: self.author(), + author: None, + sign: &*self.sign, + } + } + + fn author(&self) -> gix::actor::Signature { + gix::actor::Signature { + name: "worker".into(), + email: "worker@ents.test".into(), + time: gix::date::Time { + seconds: 1_000, + offset: 0, + }, + } + } + + fn sign_fn(&self) -> &dyn Fn(&[u8]) -> String { + &*self.sign + } + + /// A brand-new, queued session (ready, plan confirmed) — the only + /// precondition [`super::run_agent_exec`]'s `Dispatch::Claim` arm + /// runs against. + fn queued_session(&self) -> (ObjectId, String) { + let identity = self.identity(); + let (id, outcome) = ents_forge::agent::new( + &self.refs, + &self.objects, + &NullEventSink, + ents_forge::agent::NewAgentSession { + member: MemberId::new("jdc"), + prompt: "fix the flaky test".to_owned(), + model: "claude-sonnet-5".to_owned(), + toolchains: vec![], + base_ref: "refs/heads/main".to_owned(), + review_policy: ReviewPolicy::Manual, + retry_of: None, + }, + &identity, + Mode::Advisory, + ) + .expect("creates"); + assert_eq!(outcome.result, TxResult::Applied); + + ents_forge::agent::revise_plan( + &self.refs, + &self.objects, + &NullEventSink, + &id, + "do the thing".to_owned(), + &identity, + Mode::Advisory, + ) + .expect("revises"); + ents_forge::agent::confirm( + &self.refs, + &self.objects, + &NullEventSink, + &id, + None, + &identity, + Mode::Advisory, + ) + .expect("confirms"); + + let ref_name = ents_model::namespace::agent_session_ref(&id).expect("valid"); + let tip = self + .refs + .get(ref_name.as_ref()) + .expect("readable") + .expect("exists"); + (tip, id) + } + } + + #[rstest] + // @relation(scope=function, role=Verifies) + fn a_planning_session_is_a_cheap_no_op() { + let fixture = Fixture::new(); + let identity = fixture.identity(); + let (id, outcome) = ents_forge::agent::new( + &fixture.refs, + &fixture.objects, + &NullEventSink, + ents_forge::agent::NewAgentSession { + member: MemberId::new("jdc"), + prompt: "prompt".to_owned(), + model: "claude-sonnet-5".to_owned(), + toolchains: vec![], + base_ref: "refs/heads/main".to_owned(), + review_policy: ReviewPolicy::Manual, + retry_of: None, + }, + &identity, + Mode::Advisory, + ) + .expect("creates"); + assert_eq!(outcome.result, TxResult::Applied); + let ref_name = ents_model::namespace::agent_session_ref(&id).expect("valid"); + let oid = fixture + .refs + .get(ref_name.as_ref()) + .expect("readable") + .expect("exists"); + + let author = fixture.author(); + let run = run_agent_exec( + &fixture.refs, + &fixture.objects, + &NullEventSink, + &FailingExecutor, + Path::new("/does/not/matter"), + &[], + "true", + oid, + MemberId::new("worker"), + "sprite-1".to_owned(), + &author, + fixture.sign_fn(), + Mode::Advisory, + ) + .expect("dispatch never touches the sandbox for a no-op"); + assert!(matches!(run, AgentRunOutcome::NoOp)); + + let results_ref = + ents_model::namespace::result_ref(AGENT_EXEC_NAME, &short_oid(oid)).expect("valid"); + assert!( + fixture + .refs + .get(results_ref.as_ref()) + .expect("readable") + .is_some(), + "the no-op path must still discharge the obligation with a recorded pass" + ); + } + + #[rstest] + // @relation(scope=function, role=Verifies) + fn a_lost_claim_race_records_a_pass_and_touches_no_session_state() { + let fixture = Fixture::new(); + let (oid, id) = fixture.queued_session(); + + // Simulate a second worker having already claimed the session + // before this worker's own claim attempt runs. + ents_forge::agent::claim( + &fixture.refs, + &fixture.objects, + &NullEventSink, + &id, + ClaimAgentSession { + worker: MemberId::new("someone-else"), + sprite: "sprite-0".to_owned(), + }, + &fixture.identity(), + Mode::Advisory, + ) + .expect("first claim succeeds"); + + let author = fixture.author(); + let run = run_agent_exec( + &fixture.refs, + &fixture.objects, + &NullEventSink, + &FailingExecutor, + Path::new("/does/not/matter"), + &[], + "true", + oid, + MemberId::new("worker"), + "sprite-1".to_owned(), + &author, + fixture.sign_fn(), + Mode::Advisory, + ) + .expect("a lost race is not an error"); + assert!(matches!(run, AgentRunOutcome::ClaimLost)); + + let session = ents_forge::agent::show(&fixture.refs, &fixture.objects, &id).expect("shows"); + assert_eq!( + session.meta.worker, + Some(MemberId::new("someone-else")), + "the losing worker's identity must never land on the session" + ); + } + + #[rstest] + // @relation(effect.execution, effect.results-writeback, receive.multi-ref-atomicity, scope=function, role=Verifies) + fn a_queued_session_lands_done_atomically_with_its_result_and_branch() { + let fixture = Fixture::new(); + let (oid, id) = fixture.queued_session(); + + let scratch = tempfile::tempdir().expect("tempdir"); + let executor = StubExecutor { + status: RunStatus::Pass, + mutate: writes_a_file, + }; + let author = fixture.author(); + let run = run_agent_exec( + &fixture.refs, + &fixture.objects, + &NullEventSink, + &executor, + scratch.path(), + &[], + "true", + oid, + MemberId::new("worker"), + "sprite-1".to_owned(), + &author, + fixture.sign_fn(), + Mode::Advisory, + ) + .expect("runs and finalizes"); + let AgentRunOutcome::Finished { + id: finished_id, + outcome, + } = run + else { + panic!("expected Finished, got {run:?}"); + }; + assert_eq!(finished_id, id); + assert_eq!(outcome.result, TxResult::Applied); + + // (a) the session ref: Done, with the transcript appended. + let session = ents_forge::agent::show(&fixture.refs, &fixture.objects, &id).expect("shows"); + assert_eq!(session.meta.status, ents_forge::agent::Status::Done); + assert!(session.meta.finished.is_some()); + assert!( + !session.thread.is_empty(), + "the run's transcript must land as a thread blob" + ); + let branch_name = session + .meta + .result_branch + .clone() + .expect("a result branch name was recorded"); + + // (b) the result record, on the results namespace. + let results_ref = + ents_model::namespace::result_ref(AGENT_EXEC_NAME, &short_oid(oid)).expect("valid"); + let result_tip = fixture + .refs + .get(results_ref.as_ref()) + .expect("readable") + .expect("result landed"); + let result_tree = commit_tree(&fixture.objects, result_tip).expect("tree"); + let record: ResultRecord = + facet_git_tree::deserialize(&result_tree, &fixture.objects).expect("decodes"); + assert_eq!(record.status, ResultStatus::Pass); + assert_eq!(record.target(), oid); + + // (c) the result branch, pointing at a commit whose tree carries + // the sandbox's actual output. + let branch_ref: gix::refs::FullName = format!("refs/heads/{branch_name}") + .try_into() + .expect("valid"); + let branch_tip = fixture + .refs + .get(branch_ref.as_ref()) + .expect("readable") + .expect("branch landed"); + let branch_tree = commit_tree(&fixture.objects, branch_tip).expect("tree"); + let checkout_dir = tempfile::tempdir().expect("tempdir"); + ents_effect::materialize::checkout(&fixture.objects, branch_tree, checkout_dir.path()) + .expect("checkout"); + assert_eq!( + std::fs::read_to_string(checkout_dir.path().join("agent-output.txt")) + .expect("read the agent's own output"), + "the agent's work" + ); + assert!( + !checkout_dir.path().join(AGENT_PLAN_FILE).exists(), + "the sideband plan file must never leak into the pushed branch" + ); + } + + #[rstest] + // @relation(effect.result-taxonomy, scope=function, role=Verifies) + fn a_failed_run_lands_the_session_as_failed_with_a_fail_result() { + let fixture = Fixture::new(); + let (oid, id) = fixture.queued_session(); + + let scratch = tempfile::tempdir().expect("tempdir"); + let executor = StubExecutor { + status: RunStatus::Fail, + mutate: no_mutation, + }; + let author = fixture.author(); + run_agent_exec( + &fixture.refs, + &fixture.objects, + &NullEventSink, + &executor, + scratch.path(), + &[], + "true", + oid, + MemberId::new("worker"), + "sprite-1".to_owned(), + &author, + fixture.sign_fn(), + Mode::Advisory, + ) + .expect("runs and finalizes"); + + let session = ents_forge::agent::show(&fixture.refs, &fixture.objects, &id).expect("shows"); + assert!(matches!( + session.meta.status, + ents_forge::agent::Status::Failed(_) + )); + + let results_ref = + ents_model::namespace::result_ref(AGENT_EXEC_NAME, &short_oid(oid)).expect("valid"); + let result_tip = fixture + .refs + .get(results_ref.as_ref()) + .expect("readable") + .expect("result landed"); + let result_tree = commit_tree(&fixture.objects, result_tip).expect("tree"); + let record: ResultRecord = + facet_git_tree::deserialize(&result_tree, &fixture.objects).expect("decodes"); + assert_eq!(record.status, ResultStatus::Fail); + } + + #[rstest] + // @relation(effect.result-taxonomy, scope=function, role=Verifies) + fn a_sandbox_infrastructure_failure_publishes_nothing_and_leaves_the_session_running() { + let fixture = Fixture::new(); + let (oid, id) = fixture.queued_session(); + + let scratch = tempfile::tempdir().expect("tempdir"); + let author = fixture.author(); + let error = run_agent_exec( + &fixture.refs, + &fixture.objects, + &NullEventSink, + &FailingExecutor, + scratch.path(), + &[], + "true", + oid, + MemberId::new("worker"), + "sprite-1".to_owned(), + &author, + fixture.sign_fn(), + Mode::Advisory, + ) + .expect_err("an infra failure must propagate, not silently finalize"); + assert!(matches!(error, Error::Effect(_))); + + // The claim already landed (the session is Running, the point of + // no return) but nothing past it did: a legal continuation per + // Phase 1b's model, never a partially-published finalize. + let session = ents_forge::agent::show(&fixture.refs, &fixture.objects, &id).expect("shows"); + assert_eq!(session.meta.status, ents_forge::agent::Status::Running); + assert!(session.meta.result_branch.is_none()); + + let results_ref = + ents_model::namespace::result_ref(AGENT_EXEC_NAME, &short_oid(oid)).expect("valid"); + assert!( + fixture + .refs + .get(results_ref.as_ref()) + .expect("readable") + .is_none(), + "no result may be recorded when the sandbox never completed the run" + ); + } + + /// `receive.multi-ref-atomicity`'s own guarantee, demonstrated directly + /// at the mechanism [`super::run_agent_exec`]'s finalize relies on: a + /// [`ents_forge::agent::finish_transition`] built against a since-moved + /// session tip fails the gate's `gate.fast-forward` check under a + /// mandatory-mode `receive`, refusing the *entire* batch before any of + /// the three transitions — session, result, branch — writes anything. + #[rstest] + // @relation(receive.multi-ref-atomicity, gate.fast-forward, scope=function, role=Verifies) + fn a_stale_finish_transition_refuses_the_whole_finalize_batch() { + let fixture = Fixture::new(); + let (oid, id) = fixture.queued_session(); + let identity = fixture.identity(); + + ents_forge::agent::claim( + &fixture.refs, + &fixture.objects, + &NullEventSink, + &id, + ClaimAgentSession { + worker: MemberId::new("worker"), + sprite: "sprite-1".to_owned(), + }, + &identity, + Mode::Advisory, + ) + .expect("claims"); + + // Build the finish transition against the tip as claimed above... + let (finish_transition, finish_tip) = ents_forge::agent::finish_transition( + &fixture.refs, + &fixture.objects, + &id, + FinishAgentSession { + outcome: FinishOutcome::Done, + result_branch: Some("agent/jdc/deadbee".to_owned()), + thread: vec![b"transcript".to_vec()], + }, + &identity, + ) + .expect("builds"); + + // ...then race: another signed commit lands on the session ref + // (an admin editing the session, say) before this finalize is + // actually sent. + let session_ref = ents_model::namespace::agent_session_ref(&id).expect("valid"); + let raced_tip = ents_testutil::write_commit( + &fixture.objects, + &ents_testutil::CommitSpec { + tree: commit_tree(&fixture.objects, finish_tip).expect("tree"), + parents: vec![ + fixture + .refs + .get(session_ref.as_ref()) + .expect("readable") + .expect("exists"), + ], + message: "a racing admin edit".to_owned(), + seconds: 1_100, + }, + Some(&fixture.key), + ); + fixture.refs.set(session_ref.as_ref(), raced_tip); + + let results_ref = + ents_model::namespace::result_ref(AGENT_EXEC_NAME, &short_oid(oid)).expect("valid"); + let record = ResultRecord::new(AGENT_EXEC_NAME, oid, ResultStatus::Pass); + let (result_transition, result_tip) = ents_receive::entity_transition( + &fixture.refs, + &fixture.objects, + &results_ref, + &record, + &identity, + "Record agent-exec result", + ) + .expect("builds"); + + let branch_ref: gix::refs::FullName = + "refs/heads/agent/jdc/deadbee".try_into().expect("valid"); + let output_tree = ents_testutil::empty_tree(&fixture.objects); + let output_commit = ents_testutil::write_commit( + &fixture.objects, + &ents_testutil::CommitSpec { + tree: output_tree, + parents: vec![fixture.base], + message: "agent-exec output".to_owned(), + seconds: 1_100, + }, + Some(&fixture.key), + ); + + let proposal = Proposal { + transitions: vec![ + finish_transition, + result_transition, + RefTransition { + name: branch_ref.clone(), + old: None, + new: Some(output_commit), + }, + ], + objects: vec![finish_tip, result_tip, output_commit], + auth: None, + }; + // Mandatory mode is what gives a failed verdict teeth: a stale + // transition refuses the whole batch before any write, rather than + // merely being annotated (`gate.mandatory-hosted`). + let outcome = ents_receive::receive( + &fixture.refs, + &fixture.objects, + &NullEventSink, + &proposal, + Mode::Mandatory, + ) + .expect("evaluates"); + assert_eq!(outcome.result, TxResult::Refused); + + // Nothing published: the session ref still holds the race's own + // tip, never our finish commit; no result, no branch. + assert_eq!( + fixture.refs.get(session_ref.as_ref()).expect("readable"), + Some(raced_tip) + ); + assert!( + fixture + .refs + .get(results_ref.as_ref()) + .expect("readable") + .is_none() + ); + assert!( + fixture + .refs + .get(branch_ref.as_ref()) + .expect("readable") + .is_none() + ); + } +}
crates/cli/git-ents/src/commands/agent.rs @@ -1,0 +1,189 @@ +//! `git ents agent`: a thin wrapper around `ents_forge::agent`'s business +//! logic — this module only resolves the signer/actor identity (and, for +//! `new`, the session's own owning member, via the same key-to-member +//! lookup [`review::reviewer_member_id`](super::review) uses for a +//! review's reviewer) and translates a reached `Outcome` into a CLI-facing +//! [`Result`] (`crate::mutate::outcome_to_result`), exactly as every other +//! mutation command does. Every operation is the library call itself +//! (`lens.parity`); nothing here re-implements one. +//! +//! Only the plan-and-confirm ceremony (`new`, `plan`, `confirm`, `list`, +//! `show` — [`crate::cli::AgentAction`]'s own Phase 1 grammar) is wired +//! here. `claim` and `finish` are worker primitives +//! [`crate::agent_worker`] drives from the effect worker loop +//! (`crate::hook::post_receive`), not a porcelain a member runs by hand — +//! authorizing them to an interactive signer would only invite a +//! non-worker to forge a claim the gate's designated-worker roster exists +//! specifically to restrict (`docs/agent-sessions-plan.adoc`'s Phase 2a). +//! There is also no `git ents agent run` local-execution counterpart to +//! `git ents effect run`: the plan fixes Sprites as the sole execution +//! target for this feature and explicitly defers `effect.local-run`'s +//! parity requirement ("the tension ... is accepted for now and revisited +//! if local agent runs are ever wanted"). + +use std::path::PathBuf; + +use ents_forge::agent::{self, AgentSession, NewAgentSession, ReviewPolicy}; +use ents_model::MemberId; +use ents_receive::Identity; + +use super::{actor, signer}; +use crate::error::Result; +use crate::mutate::outcome_to_result; +use crate::root::LocalRoot; + +/// `git ents agent list`: every agent session recorded in this repository. +/// +/// # Errors +/// +/// Propagates a ref-store or object read failure. +pub fn list(root: &LocalRoot) -> Result<Vec<(String, AgentSession)>> { + Ok(agent::list(&root.refs, &root.objects)?) +} + +/// `git ents agent show`: `id`'s agent session. +/// +/// # Errors +/// +/// [`crate::error::Error::Forge`] (wrapping [`ents_forge::Error::NotFound`]) +/// if `id` has no session ref. +pub fn show(root: &LocalRoot, id: &str) -> Result<AgentSession> { + Ok(agent::show(&root.refs, &root.objects, id)?) +} + +/// `git ents agent new`: start an agent session owned by the signer's own +/// resolved member. +/// +/// # Errors +/// +/// [`crate::error::Error::Forge`] if a named toolchain has no +/// `refs/meta/toolchains/*` ref, or `retry_of` is given and is not a +/// well-formed oid; [`crate::error::Error::InvalidArgument`] if +/// `review_policy` is not `auto` or `manual`; otherwise see +/// [`crate::mutate::outcome_to_result`]. +#[expect( + clippy::too_many_arguments, + reason = "one field per NewAgentSession-shaping CLI flag, mirroring git ents issue new's \ + identically-justified shape" +)] +pub fn new( + root: &LocalRoot, + prompt: String, + model: String, + toolchains: Vec<String>, + base_ref: String, + review_policy: String, + retry_of: Option<String>, + key: Option<PathBuf>, +) -> Result<String> { + let signer = signer(root, key)?; + let member = session_owner(root, &signer)?; + let identity = Identity { + actor: actor(&signer), + author: None, + sign: &|payload| signer.sign(payload), + }; + let new = NewAgentSession { + member, + prompt, + model, + toolchains, + base_ref, + review_policy: review_policy.parse::<ReviewPolicy>()?, + retry_of, + }; + let (id, outcome) = agent::new( + &root.refs, + &root.objects, + &root.events, + new, + &identity, + root.mode(), + )?; + outcome_to_result(outcome, None)?; + Ok(id) +} + +/// `git ents agent plan`: draft or redraft `id`'s plan text. +/// +/// # Errors +/// +/// See [`crate::mutate::outcome_to_result`]; propagates +/// [`ents_forge::Error::NotFound`] or [`ents_forge::Error::InvalidArgument`] +/// (the session is past the point of no return) via [`crate::error::Error::Forge`]. +pub fn plan(root: &LocalRoot, id: &str, text: String, key: Option<PathBuf>) -> Result<()> { + let signer = signer(root, key)?; + let identity = Identity { + actor: actor(&signer), + author: None, + sign: &|payload| signer.sign(payload), + }; + let outcome = agent::revise_plan( + &root.refs, + &root.objects, + &root.events, + id, + text, + &identity, + root.mode(), + )?; + outcome_to_result(outcome, None)?; + Ok(()) +} + +/// `git ents agent confirm`: confirm `id`'s current plan, queueing it for +/// execution. +/// +/// # Errors +/// +/// [`crate::error::Error::InvalidArgument`] if `review_policy` is given and +/// is not `auto` or `manual`; otherwise see +/// [`crate::mutate::outcome_to_result`]. +pub fn confirm( + root: &LocalRoot, + id: &str, + review_policy: Option<String>, + key: Option<PathBuf>, +) -> Result<()> { + let signer = signer(root, key)?; + let identity = Identity { + actor: actor(&signer), + author: None, + sign: &|payload| signer.sign(payload), + }; + let policy = review_policy + .map(|policy| policy.parse::<ReviewPolicy>()) + .transpose()?; + let outcome = agent::confirm( + &root.refs, + &root.objects, + &root.events, + id, + policy, + &identity, + root.mode(), + )?; + outcome_to_result(outcome, None)?; + Ok(()) +} + +/// The member id owning the signer's key — the same key-to-member scan +/// `git ents review new`'s own `reviewer_member_id` performs, duplicated +/// rather than shared across the two command modules for the same reason +/// this codebase accepts other small per-module copies (see +/// `crate::commands::commit_tree`'s own doc): a member session's owner and +/// a review's reviewer are conceptually distinct fields that only happen +/// to resolve identically today. +/// +/// # Errors +/// +/// Propagates a ref-store or object read failure. +fn session_owner(root: &LocalRoot, signer: &crate::sign::Signer) -> Result<MemberId> { + let pubkey = signer.public_openssh(); + if let Some((username, _state)) = + super::members::find_by_key(&root.refs, &root.objects, &pubkey)? + { + return Ok(MemberId::new(username)); + } + Ok(MemberId::new(super::short_fingerprint(signer))) +}
crates/cli/git-ents/tests/agent.rs @@ -1,0 +1,194 @@ +//! Integration coverage for `git ents agent` against a real local +//! composition root (`roots.local`): the plan-and-confirm ceremony +//! (`new`, `plan`, `confirm`) end to end, `list`/`show` reading it back, +//! and the guard that refuses confirming a session with no plan yet — +//! mirroring `tests/issue.rs`'s own shape for the same reason: every +//! operation here is `commands::agent`'s own library call +//! (`lens.parity`), not a re-implementation for the test. + +#![allow(clippy::expect_used, reason = "integration test")] + +mod common; + +use ents_forge::agent::Status; +use git_ents::commands::agent; +use git_ents::root::LocalRoot; + +/// `git ents agent new` seeds `planning`, with no plan yet — neither +/// derived predicate holds. +// @relation(model.extensibility, scope=function, role=Verifies) +#[test] +fn agent_new_starts_in_planning_with_no_plan() { + let fixture = common::Fixture::new(1); + let root = LocalRoot::open(fixture.path()).expect("opens"); + + let id = agent::new( + &root, + "fix the flaky test".to_owned(), + "claude-sonnet-5".to_owned(), + vec![], + "refs/heads/main".to_owned(), + "manual".to_owned(), + None, + Some(fixture.key_path.clone()), + ) + .expect("starts a session"); + + let session = agent::show(&root, &id).expect("shows"); + assert_eq!(session.meta.status, Status::Planning); + assert!(session.plan.is_none()); + assert!(!session.queued()); + assert!(!session.awaiting_confirmation()); +} + +/// The full plan-and-confirm ceremony: `plan` drafts the text and moves the +/// session to `ready`/awaiting-confirmation; `confirm` binds the plan hash, +/// moving it to `ready`/queued — the only state +/// `docs/agent-sessions-plan.adoc`'s Phase 2 worker ever claims out of. +// @relation(model.extensibility, scope=function, role=Verifies) +#[test] +fn agent_plan_then_confirm_reaches_queued() { + let fixture = common::Fixture::new(2); + let root = LocalRoot::open(fixture.path()).expect("opens"); + + let id = agent::new( + &root, + "fix the flaky test".to_owned(), + "claude-sonnet-5".to_owned(), + vec![], + "refs/heads/main".to_owned(), + "manual".to_owned(), + None, + Some(fixture.key_path.clone()), + ) + .expect("starts a session"); + + agent::plan( + &root, + &id, + "read the flaky test, find the race, fix it".to_owned(), + Some(fixture.key_path.clone()), + ) + .expect("drafts a plan"); + + let drafted = agent::show(&root, &id).expect("shows"); + assert_eq!(drafted.meta.status, Status::Ready); + assert!(drafted.awaiting_confirmation()); + assert!(!drafted.queued()); + + agent::confirm(&root, &id, None, Some(fixture.key_path.clone())).expect("confirms"); + + let queued = agent::show(&root, &id).expect("shows"); + assert!(queued.queued()); + assert!(!queued.awaiting_confirmation()); +} + +/// Revising the plan after a confirm drops the stale confirm, returning the +/// session to awaiting confirmation — the CLI surface for the same +/// guarantee `ents-forge`'s own `agent_sessions.rs` integration test proves +/// at the command layer directly. +// @relation(scope=function, role=Verifies) +#[test] +fn agent_revising_the_plan_drops_a_stale_confirm() { + let fixture = common::Fixture::new(3); + let root = LocalRoot::open(fixture.path()).expect("opens"); + + let id = agent::new( + &root, + "prompt".to_owned(), + "claude-sonnet-5".to_owned(), + vec![], + "refs/heads/main".to_owned(), + "manual".to_owned(), + None, + Some(fixture.key_path.clone()), + ) + .expect("starts a session"); + agent::plan( + &root, + &id, + "first draft".to_owned(), + Some(fixture.key_path.clone()), + ) + .expect("drafts"); + agent::confirm(&root, &id, None, Some(fixture.key_path.clone())).expect("confirms"); + assert!(agent::show(&root, &id).expect("shows").queued()); + + agent::plan( + &root, + &id, + "a materially different plan".to_owned(), + Some(fixture.key_path.clone()), + ) + .expect("redrafts"); + + let revised = agent::show(&root, &id).expect("shows"); + assert!(revised.confirm.is_none()); + assert!(revised.awaiting_confirmation()); + assert!(!revised.queued()); +} + +/// `confirm` refuses a session with no plan yet — the CLI surfaces the +/// command layer's own guard as an ordinary error, not a panic. +// @relation(scope=function, role=Verifies) +#[test] +fn agent_confirm_refuses_a_session_with_no_plan() { + let fixture = common::Fixture::new(4); + let root = LocalRoot::open(fixture.path()).expect("opens"); + + let id = agent::new( + &root, + "prompt".to_owned(), + "claude-sonnet-5".to_owned(), + vec![], + "refs/heads/main".to_owned(), + "manual".to_owned(), + None, + Some(fixture.key_path.clone()), + ) + .expect("starts a session"); + + let error = agent::confirm(&root, &id, None, Some(fixture.key_path.clone())) + .expect_err("refused: no plan to confirm"); + assert!(matches!(error, git_ents::Error::Forge(_))); +} + +/// `git ents agent list` reports every session recorded, including one +/// still in `planning`. +// @relation(scope=function, role=Verifies) +#[test] +fn agent_list_reports_every_session() { + let fixture = common::Fixture::new(5); + let root = LocalRoot::open(fixture.path()).expect("opens"); + + let first = agent::new( + &root, + "first prompt".to_owned(), + "claude-sonnet-5".to_owned(), + vec![], + "refs/heads/main".to_owned(), + "manual".to_owned(), + None, + Some(fixture.key_path.clone()), + ) + .expect("starts a session"); + let second = agent::new( + &root, + "second prompt".to_owned(), + "claude-sonnet-5".to_owned(), + vec![], + "refs/heads/main".to_owned(), + "auto".to_owned(), + None, + Some(fixture.key_path.clone()), + ) + .expect("starts a session"); + + let ids: Vec<String> = agent::list(&root) + .expect("lists") + .into_iter() + .map(|(id, _)| id) + .collect(); + assert!(ids.contains(&first)); + assert!(ids.contains(&second)); +}