git-ents.gitmain
⌘K
foforge
commit 5ec8417
forge, model: add the AgentSession entity (agent-sessions phase 1)

New agent/ module mirroring issue/review: typed meta/plan/confirm tree with an opaque thread/ subtree, durable-phase Status enum, and derived queued/awaiting-confirmation predicates off the tip snapshot. Confirm binds the plan text’s content hash, so revision invalidates it structurally. refs/meta/agent-sessions/<genesis-oid> in ents-model, mirroring issue_ref/comment_ref; no Namespace variant yet — gate wiring is phase 1b.

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

Joseph D. Carpinelli · 29 days ago

Reviews

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

Start a review

verdict

crates/forge/ents-forge/src/lib.rs @@ -1,6 +1,7 @@ -//! The forge domain: the [`Issue`], [`comment::Comment`], and -//! [`review::Review`] entities, and the command business logic driving -//! each — kernel-independent, unlike `ents-model`'s remaining entities, +//! The forge domain: the [`Issue`], [`comment::Comment`], +//! [`review::Review`], and [`agent::AgentSession`] entities, and the +//! command business logic driving each — kernel-independent, unlike +//! `ents-model`'s remaining entities, //! because a comment or review command needs `ents-anchor` (to capture and //! project a code anchor) and `ents-receive` (to propose the mutation), //! neither of which a purely declarative vocabulary crate like @@ -46,6 +47,13 @@ //! lens offers over these entities is one of this crate's library //! functions; frontends only wire stores and render. //! +//! [`agent::AgentSession`] (`refs/meta/agent-sessions/<id>`, +//! `namespace::agent_session_ref`) is Phase 1 of +//! `docs/agent-sessions-plan.adoc`: no `model.agent-session` spec section +//! exists yet (an owner item the plan itself names), so its own module docs +//! cite only the `meta-ref.*` and `model.extensibility` ids above that +//! already apply to any hash-identified, additively-evolving typed tree. +//! //! # Examples //! //! Build an [`Issue`], and a [`comment::Comment`] anchored to a stand-in @@ -88,6 +96,7 @@ mod error; +pub mod agent; pub mod comment; pub mod issue; pub mod review; @@ -193,6 +202,13 @@ #[case::comment(comment::Comment::SHAPE.type_identifier, "Comment")] #[case::issue(Issue::SHAPE.type_identifier, "Issue")] #[case::review(review::Review::SHAPE.type_identifier, "Review")] + #[case::agent_session(agent::AgentSession::SHAPE.type_identifier, "AgentSession")] + #[case::agent_session_meta(agent::SessionMeta::SHAPE.type_identifier, "SessionMeta")] + #[case::agent_toolchain_pin(agent::ToolchainPin::SHAPE.type_identifier, "ToolchainPin")] + #[case::agent_confirm(agent::Confirm::SHAPE.type_identifier, "Confirm")] + #[case::agent_status(agent::Status::SHAPE.type_identifier, "Status")] + #[case::agent_failure_reason(agent::FailureReason::SHAPE.type_identifier, "FailureReason")] + #[case::agent_review_policy(agent::ReviewPolicy::SHAPE.type_identifier, "ReviewPolicy")] // @relation(model.extensibility, scope=function, role=Verifies) fn every_entity_shape_name_tracks_its_struct_declaration( #[case] reflected: &str,
crates/forge/ents-forge/tests/round_trip.rs @@ -37,6 +37,53 @@ } } +proptest! { + #![proptest_config(ProptestConfig::with_cases(64))] + + // @relation(meta-ref.typed-tree, scope=function, role=Verifies) + #[test] + fn agent_session_round_trips_for_any_fields_and_collection_lengths( + member in any::<String>(), + created in any::<i64>(), + model in any::<String>(), + toolchain_names in prop::collection::vec(any::<String>(), 0..4), + base_ref in any::<String>(), + plan in proptest::option::of(any::<String>()), + thread in prop::collection::vec(prop::collection::vec(any::<u8>(), 0..16), 0..4), + ) { + use ents_forge::agent::{AgentSession, Confirm, ReviewPolicy, SessionMeta, ToolchainPin}; + + let toolchains: Vec<ToolchainPin> = toolchain_names + .into_iter() + .map(|name| ToolchainPin::new(name, gix_hash::ObjectId::null(gix_hash::Kind::Sha1))) + .collect(); + let meta = SessionMeta::new( + MemberId::new(member), + created, + model, + toolchains, + base_ref, + ReviewPolicy::Manual, + None, + ); + // A confirm can only exist alongside a plan in practice (the command + // layer refuses otherwise), but the typed tree itself places no such + // constraint on what round-trips — exercised here as `plan`'s own + // hash, present only when `plan` is. + let confirm = plan + .as_deref() + .map(|text| Confirm::new( + gix_object::compute_hash(gix_hash::Kind::Sha1, gix_object::Kind::Blob, text.as_bytes()) + .expect("hashing cannot fail"), + ReviewPolicy::Auto, + )); + let session = AgentSession { meta, plan, confirm, thread }; + let (id, store) = serialize(&session).expect("serialize"); + let back: AgentSession = deserialize(&id, &store).expect("deserialize"); + prop_assert_eq!(back, session); + } +} + proptest! { #![proptest_config(ProptestConfig::with_cases(64))]
crates/kernel/ents-model/src/namespace.rs @@ -61,6 +61,25 @@ build(format!("refs/meta/comments/{id}")) } +/// The ref holding the agent session named `id` — `refs/meta/agent-sessions/<id>` +/// (`meta-ref.granularity`), where `<id>` is the oid of the session's own +/// genesis commit — the same sign-then-name shape [`issue_ref`] and +/// [`comment_ref`] bind by (`meta-ref.identity-binding`); `ents_forge::agent` +/// (`docs/agent-sessions-plan.adoc`'s Phase 1) carries the entity and +/// extracts `id` from the proposed ref the same way `ents-forge`'s own +/// `genesis_id` does for a comment or issue. +/// +/// [`classify`] deliberately has no `agent-sessions` arm yet: an unrecognized +/// segment falls through to [`Namespace::Unknown`], which is exactly what +/// `model.extensibility` asks of a namespace no gate-level vocabulary +/// interprets. Phase 1 of `docs/agent-sessions-plan.adoc` stops at this +/// builder; classifying the namespace for `ents-gate`'s identity-binding and +/// owner-mutation checks is Phase 1b's job, alongside the still-unwritten +/// `meta-ref.adoc` namespace entry and `model.agent-session` section. +pub fn agent_session_ref(id: &str) -> Result<FullName> { + build(format!("refs/meta/agent-sessions/{id}")) +} + /// The ref holding one reviewer's review of one commit — /// `refs/meta/reviews/<target>/<member>` (`meta-ref.granularity`, /// `model.review`), where `<target>` is the oid of the first commit the @@ -517,6 +536,13 @@ #[case::outside_meta("refs/heads/main", None)] #[case::unrecognized("refs/meta/index/abc", Some(Namespace::Unknown))] #[case::novel_namespace("refs/meta/widgets/7", Some(Namespace::Unknown))] + // Phase 1b, not Phase 1, teaches `classify` this segment + // (`agent_session_ref`'s own doc); until then it is forge state this + // vocabulary does not yet interpret, per `model.extensibility`. + #[case::agent_session_namespace_not_yet_classified( + "refs/meta/agent-sessions/deadbeef", + Some(Namespace::Unknown) + )] // @relation(meta-ref.namespace, meta-ref.granularity, scope=function, role=Verifies) fn classify_matches_the_namespace_table( #[case] refname: &str, @@ -578,6 +604,7 @@ member_ref(&id).expect("valid"), issue_ref("42").expect("valid"), comment_ref("abc").expect("valid"), + agent_session_ref("abc").expect("valid"), review_ref("deadbeef", &id).expect("valid"), review_pin_ref("deadbeef", &id).expect("valid"), effect_ref("unit").expect("valid"),
crates/forge/ents-forge/src/agent/cli.rs @@ -1,0 +1,80 @@ +//! `git ents agent`'s argument grammar — `figue` derive definitions only. +//! +//! Per this project's engineering conventions, this module carries no +//! logic: every doc comment here becomes `--help` text, and `git-ents`'s +//! own `exe` module would be the only place an [`AgentAction`] variant is +//! interpreted — Phase 1 stops at defining this grammar; wiring it into the +//! `git-ents` binary is CLI wiring outside this phase's scope. + +use std::path::PathBuf; + +use facet::Facet; +use figue as args; + +/// `git ents agent` actions. +#[derive(Facet)] +#[repr(u8)] +pub enum AgentAction { + /// Start a new agent session: a task prompt, seeded verbatim as the + /// thread's first turn, plus the genesis-time choices that freeze into + /// the session's metadata. The session starts in `planning`, with no + /// plan yet. + New { + /// The initial task prompt. + #[facet(args::named)] + prompt: String, + /// The model id the run executes against. + #[facet(args::named)] + model: String, + /// Toolchains this run depends on (repeatable); each is hash-pinned + /// to its ref's current tip at creation. + #[facet(args::named, args::label = "NAME", default)] + toolchain: Vec<String>, + /// The ref the run executes against as its starting point. + #[facet(args::named, default = "HEAD")] + base: String, + /// The session's initially resolved review policy: auto or manual. + #[facet(args::named, default = "manual")] + review_policy: String, + /// The genesis oid of a prior session this one retries. + #[facet(args::named)] + retry_of: Option<String>, + /// Key to sign with; defaults to `user.signingkey`. + #[facet(args::named)] + key: Option<PathBuf>, + }, + /// Draft or redraft a session's plan text, committing the plan leaf and + /// transitioning it to `ready`. Drops any existing confirm. + Plan { + /// The session to draft a plan for. + #[facet(args::positional)] + id: String, + /// The plan text. + #[facet(args::named)] + text: String, + /// Key to sign with; defaults to `user.signingkey`. + #[facet(args::named)] + key: Option<PathBuf>, + }, + /// Confirm a session's current plan: binds its hash, queueing the + /// session for execution (Phase 2). + Confirm { + /// The session to confirm. + #[facet(args::positional)] + id: String, + /// Override the session's resolved review policy at confirm time. + #[facet(args::named)] + review_policy: Option<String>, + /// Key to sign with; defaults to `user.signingkey`. + #[facet(args::named)] + key: Option<PathBuf>, + }, + /// List the agent sessions recorded in this repository. + List, + /// Show one agent session. + Show { + /// The session's id. + #[facet(args::positional)] + id: String, + }, +}
crates/forge/ents-forge/src/agent/command.rs @@ -1,0 +1,310 @@ +//! The `agent` command's business logic: start a session (`new`), draft or +//! redraft its plan (`revise_plan`, which drops any confirm bound to the +//! plan text it replaces), record a confirmation (`confirm`), list, and read +//! one back. Phase 1 of `docs/agent-sessions-plan.adoc` stops here: claiming +//! a session, running it, and landing a result are Phase 2's `ents-effect` +//! job, and this module writes no ref but the session's own. +//! +//! Generalized over the same trait-object/generic seam +//! `crate::issue::command` and `crate::review::command` use (`&dyn +//! RefStore`/`RefStoreRead`, `impl Find`/`Find + Write`, `&dyn +//! ents_receive::EventSink`), so a composition root wires the concrete +//! types and calls these functions, never the other way around +//! (`lens.parity`). + +use ents_model::MemberId; +use ents_receive::{Identity, Mode, Outcome, propose_entity, propose_genesis}; +use gix_hash::ObjectId; +use gix_object::{CommitRef, Find, Kind, Write}; +use gix_ref_store::{RefStore, RefStoreRead}; + +use super::{AgentSession, Confirm, ReviewPolicy, SessionMeta, Status, ToolchainPin}; +use crate::error::{Error, Result}; + +/// The tree of the commit at `oid` — duplicated from `crate::issue::command`'s +/// own copy; see that copy's doc for why this codebase accepts one small +/// copy per module rather than a shared helper. +fn commit_tree(objects: &impl Find, oid: ObjectId) -> Result<ObjectId> { + let mut buf = Vec::new(); + let data = objects + .try_find(&oid, &mut buf) + .map_err(|source| Error::InvalidArgument(source.to_string()))? + .ok_or_else(|| Error::NotFound { + what: oid.to_string(), + })?; + if data.kind != Kind::Commit { + return Err(Error::NotFound { + what: oid.to_string(), + }); + } + let commit = CommitRef::from_bytes(data.data, oid.kind()) + .map_err(|source| Error::InvalidArgument(source.to_string()))?; + Ok(commit.tree()) +} + +/// Read the [`AgentSession`] at `id`'s ref tip, or [`Error::NotFound`] when +/// no such ref exists. +fn session_at(refs: &dyn RefStoreRead, objects: &impl Find, id: &str) -> Result<AgentSession> { + let ref_name = ents_model::namespace::agent_session_ref(id)?; + let Some(tip) = refs.get(ref_name.as_ref())? else { + return Err(Error::NotFound { + what: format!("agent session {id}"), + }); + }; + let tree = commit_tree(objects, tip)?; + Ok(facet_git_tree::deserialize(&tree, objects)?) +} + +/// What `git ents agent new` writes: the member starting the session, the +/// initial task prompt (seeded as the thread's first opaque turn — Phase 1 +/// carries no separate `prompt` field on the entity itself), and the +/// genesis-time choices that freeze into [`SessionMeta`]. +#[derive(Debug, Clone)] +pub struct NewAgentSession { + /// The member starting the session. + pub member: MemberId, + /// The initial task prompt, seeded verbatim as `thread`'s first turn. + pub prompt: String, + /// The model id the run executes against. + pub model: String, + /// The names of the toolchains this run depends on + /// (`refs/meta/toolchains/<name>`); each is resolved to its ref's + /// current tip and hash-pinned into the session + /// ([`ToolchainPin`]) at creation. + pub toolchains: Vec<String>, + /// The ref the run executes against as its starting point. + pub base_ref: String, + /// The session's initially resolved review policy (Phase 5 lets a + /// member override it up to confirm time; Phase 1 only carries the + /// field). + pub review_policy: ReviewPolicy, + /// The genesis oid of a prior session this one retries, if any. + pub retry_of: Option<String>, +} + +/// `git ents agent new`: start an agent session at +/// `refs/meta/agent-sessions/<id>`, where `<id>` is the oid of the session's +/// own genesis commit — sign-then-name, never a locally minted id +/// (`meta-ref.identity-binding`), the same shape [`crate::issue::new`] uses. +/// +/// `meta.created` is `identity`'s own commit timestamp, never a +/// separately-supplied value: two calls built from identical `new` fields +/// and an identical `identity` (same actor, same timestamp, same signature) +/// serialize to byte-identical genesis commits and therefore the same oid — +/// the same-second double-submit lands as one session, no nonce required. +/// +/// # Errors +/// +/// [`Error::NotFound`] if a named toolchain has no `refs/meta/toolchains/*` +/// ref; [`Error::InvalidArgument`] if `new.retry_of` is given and is not a +/// well-formed oid; otherwise propagates serialization or `receive` +/// failures. +// @relation(meta-ref.identity-binding, meta-ref.typed-tree, lens.parity, scope=function) +pub fn new( + refs: &dyn RefStore, + objects: &(impl Find + Write), + events: &dyn ents_receive::EventSink, + new: NewAgentSession, + identity: &Identity<'_>, + mode: Mode, +) -> Result<(String, Outcome)> { + let mut toolchains = Vec::with_capacity(new.toolchains.len()); + for name in &new.toolchains { + let ref_name = ents_model::namespace::toolchain_ref(name)?; + let tip = refs + .get(ref_name.as_ref())? + .ok_or_else(|| Error::NotFound { + what: format!("toolchain {name}"), + })?; + toolchains.push(ToolchainPin::new(name.clone(), tip)); + } + let retry_of = new + .retry_of + .as_deref() + .map(|hex| { + ObjectId::from_hex(hex.as_bytes()) + .map_err(|_source| Error::InvalidArgument(format!("not a genesis oid: {hex}"))) + }) + .transpose()?; + + let meta = SessionMeta::new( + new.member, + identity.actor.time.seconds, + new.model, + toolchains, + new.base_ref, + new.review_policy, + retry_of, + ); + let session = AgentSession { + meta, + plan: None, + confirm: None, + thread: vec![new.prompt.into_bytes()], + }; + + let (ref_name, outcome) = propose_genesis( + refs, + objects, + events, + &session, + |oid| ents_model::namespace::agent_session_ref(&oid.to_string()), + identity, + "Start agent session", + mode, + )?; + Ok((crate::genesis_id(&ref_name), outcome)) +} + +/// `git ents agent plan`: draft or redraft `id`'s plan text, committing the +/// plan leaf and transitioning the session to `Ready`. +/// +/// Any confirm the session carried is dropped unconditionally — a plan +/// revision that happens to land on byte-identical text is a degenerate +/// case not worth special-casing, so this never compares the new text's +/// hash against the old confirm's before dropping it. That is what keeps +/// [`AgentSession::queued`] sound: a confirm surviving in the tree could +/// never outlive the plan hash it bound. +/// +/// # Errors +/// +/// [`Error::NotFound`] if `id` has no session ref; [`Error::InvalidArgument`] +/// if the session is past the point of no return (`Running`, `Done`, or +/// `Failed` — the plan may no longer move once a worker has claimed it); +/// otherwise propagates serialization or `receive` failures. +// @relation(lens.parity, scope=function) +pub fn revise_plan( + refs: &dyn RefStore, + objects: &(impl Find + Write), + events: &dyn ents_receive::EventSink, + id: &str, + plan: String, + identity: &Identity<'_>, + mode: Mode, +) -> Result<Outcome> { + let mut session = session_at(refs, objects, id)?; + if !matches!(session.meta.status, Status::Planning | Status::Ready) { + return Err(Error::InvalidArgument(format!( + "agent session {id} is past the point of no return; its plan can no longer be revised" + ))); + } + session.plan = Some(plan); + session.confirm = None; + session.meta.status = Status::Ready; + + let ref_name = ents_model::namespace::agent_session_ref(id)?; + Ok(propose_entity( + refs, + objects, + events, + ref_name, + &session, + identity, + &format!("Revise plan for agent session {id}"), + mode, + )?) +} + +/// `git ents agent confirm`: record a [`Confirm`] binding `id`'s current +/// plan hash, resolving the review policy to `review_policy` when given, or +/// to [`SessionMeta::review_policy`] otherwise. +/// +/// # Errors +/// +/// [`Error::NotFound`] if `id` has no session ref; [`Error::InvalidArgument`] +/// if the session is not `Ready`, or has no plan to confirm (a confirm can +/// never bind an absent plan leaf); otherwise propagates serialization or +/// `receive` failures. +// @relation(lens.parity, scope=function) +pub fn confirm( + refs: &dyn RefStore, + objects: &(impl Find + Write), + events: &dyn ents_receive::EventSink, + id: &str, + review_policy: Option<ReviewPolicy>, + identity: &Identity<'_>, + mode: Mode, +) -> Result<Outcome> { + let mut session = session_at(refs, objects, id)?; + if session.meta.status != Status::Ready { + return Err(Error::InvalidArgument(format!( + "agent session {id} is not ready to confirm" + ))); + } + let Some(hash) = session.plan_hash() else { + return Err(Error::InvalidArgument(format!( + "agent session {id} has no plan to confirm" + ))); + }; + let policy = review_policy.unwrap_or(session.meta.review_policy); + session.confirm = Some(Confirm::new(hash, policy)); + + let ref_name = ents_model::namespace::agent_session_ref(id)?; + Ok(propose_entity( + refs, + objects, + events, + ref_name, + &session, + identity, + &format!("Confirm agent session {id}"), + mode, + )?) +} + +/// `git ents agent list`: every agent session recorded in this repository. +/// +/// A ref whose tip this build cannot read back as an [`AgentSession`] is +/// silently absent here; [`list_all`] is the caller-facing counterpart that +/// surfaces those refs instead of dropping them. +/// +/// # Errors +/// +/// Propagates a ref-store or object read failure. +pub fn list(refs: &dyn RefStoreRead, objects: &impl Find) -> Result<Vec<(String, AgentSession)>> { + Ok(list_all(refs, objects)?.0) +} + +/// [`list`] plus the refs it could not read: every readable agent session, +/// and one [`crate::Unreadable`] per `refs/meta/agent-sessions/*` ref whose +/// tip this build's [`AgentSession`] shape could not read back — mirroring +/// [`crate::issue::list_all`]'s never-silently-dropped contract (see +/// [`crate::Unreadable`]'s own doc). +/// +/// # Errors +/// +/// Propagates a ref-store read failure — a per-ref *entity* read failure is +/// a row in the second vec, never an error. +pub fn list_all( + refs: &dyn RefStoreRead, + objects: &impl Find, +) -> Result<crate::Listing<AgentSession>> { + let mut out = Vec::new(); + let mut unreadable = Vec::new(); + for entry in refs.iter_prefix("refs/meta/agent-sessions/")? { + let (name, tip) = entry?; + let path = name.as_bstr().to_string(); + let Some(id) = path.strip_prefix("refs/meta/agent-sessions/") else { + continue; + }; + match commit_tree(objects, tip) + .and_then(|tree| Ok(facet_git_tree::deserialize::<AgentSession>(&tree, objects)?)) + { + Ok(session) => out.push((id.to_owned(), session)), + Err(error) => unreadable.push(crate::Unreadable { + refname: path.clone(), + error: error.to_string(), + }), + } + } + Ok((out, unreadable)) +} + +/// `git ents agent show`: `id`'s agent session. +/// +/// # Errors +/// +/// [`Error::NotFound`] if `id` has no session ref. +pub fn show(refs: &dyn RefStoreRead, objects: &impl Find, id: &str) -> Result<AgentSession> { + session_at(refs, objects, id) +}
crates/forge/ents-forge/src/agent/entity.rs @@ -1,0 +1,518 @@ +//! The AgentSession entity: a mandatory plan-and-confirm ceremony around a +//! headless agent run, living at its own `refs/meta/agent-sessions/<id>` ref +//! (`namespace::agent_session_ref`) — Phase 1 of +//! `docs/agent-sessions-plan.adoc` ("Session entity"). +//! +//! No `model.agent-session` spec section exists yet (an owner item the plan +//! itself names); this module cites only requirement ids that already exist +//! in `docs/spec/*.adoc` and leans on the same shapes those ids already +//! establish for [`crate::Issue`] and [`crate::review::Review`]. + +use facet::Facet; +use gix_hash::ObjectId; + +use ents_model::MemberId; + +/// Copy `oid` into the raw 20-byte form every oid-carrying field here +/// stores — `gix_hash::ObjectId` itself has no `Facet` impl, the same +/// reason [`ents_model::Redaction`] and [`ents_model::ResultRecord`] store +/// raw bytes behind an accessor rather than the type directly. +fn oid_bytes(oid: ObjectId) -> [u8; 20] { + let mut bytes = [0u8; 20]; + bytes.copy_from_slice(oid.as_slice()); + bytes +} + +/// The git blob hash of `bytes` — what `git hash-object` would report for +/// the identical content — computed without writing anything to a store. +/// [`AgentSession::plan_hash`] uses this so a confirm's binding is a content +/// hash of the plan text itself, independent of `facet-git-tree`'s own +/// `Option<String>` tree encoding for the `plan` field. +#[expect( + clippy::expect_used, + reason = "gix_object::compute_hash's Err variants concern streaming/writer failures that \ + cannot occur hashing a fixed, already-in-memory byte slice with a fixed hash kind" +)] +fn blob_hash(bytes: &[u8]) -> ObjectId { + gix_object::compute_hash(gix_hash::Kind::Sha1, gix_object::Kind::Blob, bytes) + .expect("hashing an in-memory byte slice cannot fail") +} + +/// A session's resolved review policy: whether confirming it should +/// auto-open a review of the result (Phase 5) or leave that to the member. +/// A hard enum — like [`crate::review::Verdict`], not like +/// [`crate::Issue::state`] — because it gates a follow-on effect's +/// behavior rather than describing free-form domain data. +/// +/// # Examples +/// +/// ``` +/// use ents_forge::agent::ReviewPolicy; +/// +/// let policy: ReviewPolicy = "auto".parse().expect("known policy"); +/// assert_eq!(policy, ReviewPolicy::Auto); +/// assert_eq!(policy.to_string(), "auto"); +/// assert!("sometimes".parse::<ReviewPolicy>().is_err()); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq, Facet)] +#[repr(u8)] +pub enum ReviewPolicy { + /// Confirming the session's plan also opens a review automatically once + /// a result lands (Phase 5). + Auto, + /// No review opens automatically; the member opens one manually. + Manual, +} + +impl std::str::FromStr for ReviewPolicy { + type Err = crate::Error; + + fn from_str(text: &str) -> Result<Self, Self::Err> { + match text { + "auto" => Ok(Self::Auto), + "manual" => Ok(Self::Manual), + other => Err(crate::Error::InvalidArgument(format!( + "unknown review policy {other:?}: expected auto or manual" + ))), + } + } +} + +impl std::fmt::Display for ReviewPolicy { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Auto => "auto", + Self::Manual => "manual", + }) + } +} + +/// Why a session ended in [`Status::Failed`] — a struct rather than a bare +/// `String` so later phases can extend it additively (`model.extensibility`) +/// once Phase 2 teaches the effect runner to fill it in from a run's own +/// result taxonomy (`model.result-taxonomy`, owned by `ents-model`'s +/// `Status`, unrelated to this module's own [`Status`] despite the shared +/// name). +#[derive(Debug, Clone, PartialEq, Eq, Facet)] +pub struct FailureReason { + /// A human-readable account of what went wrong. + pub detail: String, +} + +/// A session's durable lifecycle phase. Only phases that persist between +/// commits are named here — `queued`, `awaiting confirmation`, and +/// `completing` are read off the tip snapshot instead +/// ([`AgentSession::queued`], [`AgentSession::awaiting_confirmation`]), per +/// the plan's own constraint: "Ephemeral boundaries ... are derived from the +/// session's commit chain and artifacts, never enumerated in the status +/// enum." +// @relation(model.extensibility, meta-ref.typed-tree, scope=file) +#[derive(Debug, Clone, PartialEq, Eq, Facet)] +#[repr(u8)] +pub enum Status { + /// No confirmed plan exists yet — either none has been drafted, or the + /// member is redrafting one. + Planning, + /// A plan leaf exists. [`AgentSession::queued`] and + /// [`AgentSession::awaiting_confirmation`] further distinguish whether + /// it is bound by a current confirm. + Ready, + /// A worker has claimed the session and is executing it — the point of + /// no return past which no plan revision or un-queue is legal. + Running, + /// The run completed and its result landed. + Done, + /// The run could not complete, or was refused, for the carried reason. + Failed(FailureReason), +} + +/// One toolchain the session's run depends on, hash-pinned to the +/// `refs/meta/toolchains/<name>` ref's tip at the moment the session was +/// created (`model.toolchain`) — so a later change to that toolchain never +/// retroactively alters what this session declared it needed. +#[derive(Debug, Clone, PartialEq, Eq, Facet)] +pub struct ToolchainPin { + /// The toolchain's own name (`refs/meta/toolchains/<name>`). + pub name: String, + oid: [u8; 20], +} + +impl ToolchainPin { + /// Pin `name` at its ref's current tip `oid`. + #[must_use] + pub fn new(name: impl Into<String>, oid: ObjectId) -> Self { + Self { + name: name.into(), + oid: oid_bytes(oid), + } + } + + /// The pinned tip commit oid. + #[must_use] + pub fn oid(&self) -> ObjectId { + ObjectId::from_bytes_or_panic(&self.oid) + } +} + +/// A session's typed metadata: everything about it that is not the plan +/// text, its confirmation, or its thread. +/// +/// `member`, `created`, `started`, and `finished` duplicate what a full walk +/// of the session ref's own commit chain could in principle recover (the +/// genesis signer and commit times) — a deliberate departure from +/// [`crate::comment::Comment`]'s and [`ents_model::Member`]'s own +/// commit-chain-not-tree-field idiom (`meta-ref.identity-binding`), made +/// because a listing view (`docs/agent-sessions-plan.adoc`'s Phase 3) reads +/// many sessions at once and must not walk each one's full history just to +/// show who owns it and when it moved. +#[derive(Debug, Clone, PartialEq, Eq, Facet)] +pub struct SessionMeta { + /// The member who owns this session. + pub member: MemberId, + /// When the session was created, in seconds since the Unix epoch. + pub created: i64, + /// When a worker claimed the session and began running it, if it ever + /// has (Phase 2 sets this). + pub started: Option<i64>, + /// When the run reached a terminal state, if it ever has (Phase 2 sets + /// this). + pub finished: Option<i64>, + /// The model id the run executes against. + pub model: String, + /// The toolchains this run depends on, hash-pinned at creation. + pub toolchains: Vec<ToolchainPin>, + /// The session's durable lifecycle phase. + pub status: Status, + /// The ref the run executes against as its starting point. + pub base_ref: String, + /// The branch the worker pushes the run's commits to + /// (`agent/<member>/<abbrev-genesis>`, per the plan's resolved-by-default + /// item), unset until Phase 2's worker computes it — the session's own + /// genesis oid does not exist yet at creation time to derive it from. + pub result_branch: Option<String>, + /// The review policy resolved for this session, overridable up to + /// confirm time (Phase 5); [`super::Confirm::review_policy`] freezes + /// whatever value was in force at confirm. + pub review_policy: ReviewPolicy, + retry_of: Option<[u8; 20]>, +} + +impl SessionMeta { + /// A new session's metadata at genesis: `status` is always + /// [`Status::Planning`], and `started`/`finished`/`result_branch` are + /// unset — Phase 2's effect worker fills them in as the run progresses. + #[must_use] + pub fn new( + member: MemberId, + created: i64, + model: impl Into<String>, + toolchains: Vec<ToolchainPin>, + base_ref: impl Into<String>, + review_policy: ReviewPolicy, + retry_of: Option<ObjectId>, + ) -> Self { + Self { + member, + created, + started: None, + finished: None, + model: model.into(), + toolchains, + status: Status::Planning, + base_ref: base_ref.into(), + result_branch: None, + review_policy, + retry_of: retry_of.map(oid_bytes), + } + } + + /// The prior session this one retries, if any — the genesis oid of that + /// session's own ref. + #[must_use] + pub fn retry_of(&self) -> Option<ObjectId> { + self.retry_of + .map(|bytes| ObjectId::from_bytes_or_panic(&bytes)) + } +} + +/// A signed binding of a specific plan-leaf hash to a resolved review +/// policy — absent until a member approves the plan +/// [`AgentSession::plan`] currently carries. Confirm is a leaf, not a +/// status: whether the binding it carries still names the current plan is +/// read off the tip by [`AgentSession::queued`] and +/// [`AgentSession::awaiting_confirmation`], never stored as a boolean. +#[derive(Debug, Clone, PartialEq, Eq, Facet)] +pub struct Confirm { + plan_hash: [u8; 20], + /// The review policy resolved at confirm time, frozen even if + /// [`SessionMeta::review_policy`] changes afterward. + pub review_policy: ReviewPolicy, +} + +impl Confirm { + /// Approve the plan whose content hash is `plan_hash`, under + /// `review_policy`. + #[must_use] + pub fn new(plan_hash: ObjectId, review_policy: ReviewPolicy) -> Self { + Self { + plan_hash: oid_bytes(plan_hash), + review_policy, + } + } + + /// The plan-leaf hash this confirm binds. + #[must_use] + pub fn plan_hash(&self) -> ObjectId { + ObjectId::from_bytes_or_panic(&self.plan_hash) + } +} + +/// One agent session: a plan-and-confirm ceremony around a headless agent +/// run, its typed [`meta`](AgentSession::meta), its +/// [`plan`](AgentSession::plan) text, an optional +/// [`confirm`](AgentSession::confirm) binding that plan's hash, and a +/// `thread` of opaque, verbatim per-turn message blobs — never typed +/// internally, never rendered, redactable blob-by-blob +/// (`model.redaction`), exactly write-only audit material. +/// +/// Identity is the oid of the session's own genesis commit +/// (`meta-ref.identity-binding`), the same sign-then-name shape +/// [`crate::Issue`] and [`crate::comment::Comment`] use — see +/// [`super::command::new`]. +/// +/// # Examples +/// +/// ``` +/// use ents_forge::agent::{AgentSession, ReviewPolicy, SessionMeta}; +/// use ents_model::MemberId; +/// +/// let session = AgentSession { +/// meta: SessionMeta::new( +/// MemberId::new("jdc"), +/// 1_000, +/// "claude-sonnet-5", +/// vec![], +/// "refs/heads/main", +/// ReviewPolicy::Manual, +/// None, +/// ), +/// plan: None, +/// confirm: None, +/// thread: vec![b"start the task".to_vec()], +/// }; +/// let (id, store) = facet_git_tree::serialize(&session).expect("serialize"); +/// let back: AgentSession = facet_git_tree::deserialize(&id, &store).expect("deserialize"); +/// assert_eq!(back, session); +/// assert!(!session.queued()); +/// assert!(!session.awaiting_confirmation()); +/// ``` +// @relation(meta-ref.identity-binding, meta-ref.typed-tree, model.extensibility, model.redaction, scope=file) +#[derive(Debug, Clone, PartialEq, Eq, Facet)] +pub struct AgentSession { + /// The session's typed metadata. + pub meta: SessionMeta, + /// The plan text, or `None` before one has been drafted. + pub plan: Option<String>, + /// The current confirmation, or `None` before one has been recorded — + /// also `None` again immediately after any plan revision + /// ([`super::command::revise_plan`] drops it unconditionally). + pub confirm: Option<Confirm>, + /// Opaque, verbatim per-turn message blobs — write-only audit material, + /// never decoded by this crate. + pub thread: Vec<Vec<u8>>, +} + +impl AgentSession { + /// The current plan text's content hash (what `git hash-object` would + /// report for it), or `None` when no plan has been drafted yet. + #[must_use] + pub fn plan_hash(&self) -> Option<ObjectId> { + self.plan.as_deref().map(|text| blob_hash(text.as_bytes())) + } + + /// _Queued_: `Ready` and the current confirm binds the current plan's + /// hash — the plan is approved exactly as it now reads. + #[must_use] + pub fn queued(&self) -> bool { + self.meta.status == Status::Ready + && match (&self.confirm, self.plan_hash()) { + (Some(confirm), Some(hash)) => confirm.plan_hash() == hash, + _ => false, + } + } + + /// _Awaiting confirmation_: `Ready`, and not [`queued`](Self::queued) — + /// the confirm leaf is absent, or it binds a plan hash the current plan + /// text has since moved past. + #[must_use] + pub fn awaiting_confirmation(&self) -> bool { + self.meta.status == Status::Ready && !self.queued() + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used, reason = "unit test")] + + use facet_git_tree::{deserialize, serialize}; + use rstest::rstest; + + use super::*; + + fn meta(status: Status) -> SessionMeta { + let mut meta = SessionMeta::new( + MemberId::new("jdc"), + 1_000, + "claude-sonnet-5", + vec![ToolchainPin::new( + "rust-stable", + ObjectId::from_bytes_or_panic(&[3u8; 20]), + )], + "refs/heads/main", + ReviewPolicy::Manual, + Some(ObjectId::from_bytes_or_panic(&[9u8; 20])), + ); + meta.status = status; + meta + } + + fn session(status: Status, plan: Option<&str>, confirm: Option<Confirm>) -> AgentSession { + AgentSession { + meta: meta(status), + plan: plan.map(str::to_owned), + confirm, + thread: vec![b"turn one".to_vec()], + } + } + + #[rstest] + #[case::planning_no_plan(Status::Planning, None, None)] + #[case::planning_with_plan(Status::Planning, Some("do the thing"), None)] + #[case::ready_no_plan(Status::Ready, None, None)] + #[case::ready_with_plan_no_confirm(Status::Ready, Some("do the thing"), None)] + #[case::running(Status::Running, Some("do the thing"), None)] + #[case::done(Status::Done, Some("do the thing"), None)] + #[case::failed( + Status::Failed(FailureReason { detail: "sandbox died".to_owned() }), + Some("do the thing"), + None + )] + // @relation(meta-ref.typed-tree, scope=function, role=Verifies) + fn agent_session_round_trips_through_a_tree( + #[case] status: Status, + #[case] plan: Option<&str>, + #[case] confirm: Option<Confirm>, + ) { + let session = session(status, plan, confirm); + let (root, store) = serialize(&session).expect("serialize"); + let back: AgentSession = deserialize(&root, &store).expect("deserialize"); + assert_eq!(back, session); + } + + // --------------------------------------------------------------- + // Derived predicates: pure functions on the decoded tip. + // --------------------------------------------------------------- + + /// Neither predicate holds outside `Ready`, confirm or no. + #[rstest] + #[case::planning(Status::Planning)] + #[case::running(Status::Running)] + #[case::done(Status::Done)] + #[case::failed(Status::Failed(FailureReason { detail: "oops".to_owned() }))] + // @relation(scope=function, role=Verifies) + fn predicates_are_false_outside_ready(#[case] status: Status) { + let plan = "do the thing"; + let confirming = Confirm::new(blob_hash(plan.as_bytes()), ReviewPolicy::Manual); + let session = session(status, Some(plan), Some(confirming)); + assert!(!session.queued()); + assert!(!session.awaiting_confirmation()); + } + + /// `Ready` with no confirm at all is awaiting confirmation, never + /// queued. + #[rstest] + // @relation(scope=function, role=Verifies) + fn ready_with_no_confirm_is_awaiting_confirmation() { + let session = session(Status::Ready, Some("do the thing"), None); + assert!(session.awaiting_confirmation()); + assert!(!session.queued()); + } + + /// `Ready` with a confirm binding the exact current plan hash is + /// queued, never awaiting confirmation. + #[rstest] + // @relation(scope=function, role=Verifies) + fn ready_with_a_current_confirm_is_queued() { + let plan = "do the thing"; + let confirm = Confirm::new(blob_hash(plan.as_bytes()), ReviewPolicy::Auto); + let session = session(Status::Ready, Some(plan), Some(confirm)); + assert!(session.queued()); + assert!(!session.awaiting_confirmation()); + } + + /// Revising the plan text after a confirm was recorded makes the old + /// confirm's binding stale: the session reverts to awaiting + /// confirmation, never queued, purely as a function of the decoded tip + /// — no separate "stale" flag exists anywhere. + #[rstest] + // @relation(scope=function, role=Verifies) + fn a_plan_revision_makes_an_existing_confirm_stale() { + let original = "do the thing"; + let confirm = Confirm::new(blob_hash(original.as_bytes()), ReviewPolicy::Manual); + let mut session = session(Status::Ready, Some(original), Some(confirm)); + assert!(session.queued()); + + session.plan = Some("do the other thing instead".to_owned()); + assert!( + !session.queued(), + "a confirm bound to the old plan hash must not read as queued against new text" + ); + assert!(session.awaiting_confirmation()); + } + + /// A confirm binding an absent plan (impossible through the command + /// layer, but not through this predicate) never reads as queued. + #[rstest] + // @relation(scope=function, role=Verifies) + fn a_confirm_cannot_queue_an_absent_plan() { + let confirm = Confirm::new(blob_hash(b"some prior plan"), ReviewPolicy::Manual); + let session = session(Status::Ready, None, Some(confirm)); + assert!(!session.queued()); + assert!(session.awaiting_confirmation()); + } + + #[rstest] + #[case::auto(ReviewPolicy::Auto)] + #[case::manual(ReviewPolicy::Manual)] + // @relation(meta-ref.typed-tree, scope=function, role=Verifies) + fn review_policy_round_trips(#[case] policy: ReviewPolicy) { + let (id, store) = serialize(&policy).expect("serialize"); + let back: ReviewPolicy = deserialize(&id, &store).expect("deserialize"); + assert_eq!(back, policy); + } + + #[rstest] + // @relation(scope=function, role=Verifies) + fn review_policy_parses_its_own_display_form() { + for policy in [ReviewPolicy::Auto, ReviewPolicy::Manual] { + let parsed: ReviewPolicy = policy.to_string().parse().expect("round trips"); + assert_eq!(parsed, policy); + } + } + + #[rstest] + // @relation(scope=function, role=Verifies) + fn retry_of_round_trips_through_the_accessor() { + let target = ObjectId::from_bytes_or_panic(&[5u8; 20]); + let meta = SessionMeta::new( + MemberId::new("jdc"), + 1_000, + "claude-sonnet-5", + vec![], + "refs/heads/main", + ReviewPolicy::Auto, + Some(target), + ); + assert_eq!(meta.retry_of(), Some(target)); + } +}
crates/forge/ents-forge/src/agent/mod.rs @@ -1,0 +1,19 @@ +//! The agent sub-domain: the [`AgentSession`] entity (`entity`), the +//! `agent` command's business logic (`command`), and the `agent` subcommand's +//! argument grammar (`cli`) — the same three-file split [`crate::issue`] and +//! [`crate::review`] use, for the same reason: the data shape, the command +//! mechanism, and the CLI grammar stay easy to read independently. +//! +//! Phase 1 of `docs/agent-sessions-plan.adoc` ("Session entity"); Phase 1b +//! (lifecycle invariants in `ents-gate-rules`) and everything after it are +//! out of scope here. + +mod cli; +mod command; +mod entity; + +pub use cli::AgentAction; +pub use command::{NewAgentSession, confirm, list, list_all, new, revise_plan, show}; +pub use entity::{ + AgentSession, Confirm, FailureReason, ReviewPolicy, SessionMeta, Status, ToolchainPin, +};
crates/forge/ents-forge/tests/agent_sessions.rs @@ -1,0 +1,314 @@ +//! Integration coverage for the `agent` command layer +//! (`docs/agent-sessions-plan.adoc`'s Phase 1): genesis dedup under a +//! same-second double submit, plan revision dropping a stale confirm, and +//! the guards around `confirm`/`revise_plan`. + +#![allow( + clippy::expect_used, + clippy::unwrap_used, + clippy::panic, + reason = "integration test: fixtures panic on setup failure" +)] + +use ents_forge::agent::{self, FailureReason, NewAgentSession, ReviewPolicy, Status}; +use ents_model::MemberId; +use ents_receive::{Identity, Mode, NullEventSink, TxResult}; +use ents_testutil::{Keypair, MemRefStore, ObjectStore}; +use rstest::rstest; + +/// A detached signer over some bytes, returning an armored signature. +type Signer = Box<dyn Fn(&[u8]) -> String>; + +struct Fixture { + refs: MemRefStore, + objects: ObjectStore, + sign: Signer, +} + +impl Fixture { + fn new() -> Self { + let key = Keypair::from_seed(1); + Self { + refs: MemRefStore::default(), + objects: ObjectStore::default(), + sign: Box::new(move |payload| key.sign(payload)), + } + } + + /// The same identity every call in a test uses — same actor, same + /// timestamp, same (deterministic) signer — the precondition the + /// same-second double-submit test relies on. + fn identity(&self) -> Identity<'_> { + Identity { + actor: gix::actor::Signature { + name: "test".into(), + email: "test@ents.test".into(), + time: gix::date::Time { + seconds: 1_000, + offset: 0, + }, + }, + author: None, + sign: &*self.sign, + } + } + + fn draft(&self) -> NewAgentSession { + 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, + } + } + + fn new_session(&self) -> String { + let (id, outcome) = agent::new( + &self.refs, + &self.objects, + &NullEventSink, + self.draft(), + &self.identity(), + Mode::Advisory, + ) + .expect("creates"); + assert_eq!(outcome.result, TxResult::Applied); + id + } + + fn revise_plan(&self, id: &str, text: &str) -> ents_receive::Outcome { + agent::revise_plan( + &self.refs, + &self.objects, + &NullEventSink, + id, + text.to_owned(), + &self.identity(), + Mode::Advisory, + ) + .expect("revises") + } +} + +// --------------------------------------------------------------------- +// meta-ref.identity-binding: genesis dedup, no nonce. +// --------------------------------------------------------------------- + +/// Two identical submissions built from the same fields and the same +/// (same-second) identity serialize to byte-identical genesis commits, so +/// they dedupe to exactly one session ref rather than minting a second — +/// idempotent creation with no nonce anywhere. +// @relation(meta-ref.identity-binding, scope=function, role=Verifies) +#[rstest] +fn a_same_second_double_submit_produces_one_session() { + let fixture = Fixture::new(); + + let (first_id, first_outcome) = agent::new( + &fixture.refs, + &fixture.objects, + &NullEventSink, + fixture.draft(), + &fixture.identity(), + Mode::Advisory, + ) + .expect("creates"); + assert_eq!(first_outcome.result, TxResult::Applied); + + let (second_id, second_outcome) = agent::new( + &fixture.refs, + &fixture.objects, + &NullEventSink, + fixture.draft(), + &fixture.identity(), + Mode::Advisory, + ) + .expect("creates"); + assert_eq!(second_outcome.result, TxResult::Applied); + + assert_eq!( + first_id, second_id, + "identical same-second submissions must derive the same genesis oid" + ); + let (sessions, unreadable) = agent::list_all(&fixture.refs, &fixture.objects).expect("lists"); + assert!(unreadable.is_empty()); + assert_eq!( + sessions.len(), + 1, + "the duplicate submit must not mint a second session ref" + ); +} + +/// A submission with a different field (a different prompt, here) is a +/// different genesis entirely — dedup only ever collapses byte-identical +/// content, never merely-similar submissions. +// @relation(meta-ref.identity-binding, scope=function, role=Verifies) +#[rstest] +fn a_different_submission_is_a_different_session() { + let fixture = Fixture::new(); + let (first_id, _) = agent::new( + &fixture.refs, + &fixture.objects, + &NullEventSink, + fixture.draft(), + &fixture.identity(), + Mode::Advisory, + ) + .expect("creates"); + + let mut second_draft = fixture.draft(); + second_draft.prompt = "a completely different task".to_owned(); + let (second_id, _) = agent::new( + &fixture.refs, + &fixture.objects, + &NullEventSink, + second_draft, + &fixture.identity(), + Mode::Advisory, + ) + .expect("creates"); + + assert_ne!(first_id, second_id); + let sessions = agent::list(&fixture.refs, &fixture.objects).expect("lists"); + assert_eq!(sessions.len(), 2); +} + +// --------------------------------------------------------------------- +// Plan revision drops a stale confirm. +// --------------------------------------------------------------------- + +/// Confirming binds the plan hash and transitions the session to `queued`; +/// revising the plan afterward drops the confirm unconditionally, returning +/// the session to `awaiting confirmation` — never leaving a confirm bound +/// to text that no longer exists. +// @relation(scope=function, role=Verifies) +#[rstest] +fn revising_the_plan_drops_the_confirm_bound_to_the_old_text() { + let fixture = Fixture::new(); + let id = fixture.new_session(); + + fixture.revise_plan(&id, "first draft of the plan"); + let confirm_outcome = agent::confirm( + &fixture.refs, + &fixture.objects, + &NullEventSink, + &id, + None, + &fixture.identity(), + Mode::Advisory, + ) + .expect("confirms"); + assert_eq!(confirm_outcome.result, TxResult::Applied); + + let queued = agent::show(&fixture.refs, &fixture.objects, &id).expect("shows"); + assert!(queued.queued()); + assert!(!queued.awaiting_confirmation()); + + let revise_outcome = fixture.revise_plan(&id, "a materially different plan"); + assert_eq!(revise_outcome.result, TxResult::Applied); + + let revised = agent::show(&fixture.refs, &fixture.objects, &id).expect("shows"); + assert_eq!(revised.plan.as_deref(), Some("a materially different plan")); + assert!( + revised.confirm.is_none(), + "a plan revision must drop the stale confirm leaf, not merely let it read as stale" + ); + assert!(revised.awaiting_confirmation()); + assert!(!revised.queued()); +} + +// --------------------------------------------------------------------- +// Guards: confirm and revise_plan refuse outside their preconditions. +// --------------------------------------------------------------------- + +/// `confirm` refuses a session with no plan yet. +// @relation(scope=function, role=Verifies) +#[rstest] +fn confirm_refuses_a_session_with_no_plan() { + let fixture = Fixture::new(); + let id = fixture.new_session(); + let error = agent::confirm( + &fixture.refs, + &fixture.objects, + &NullEventSink, + &id, + None, + &fixture.identity(), + Mode::Advisory, + ) + .expect_err("refused"); + assert!(matches!(error, ents_forge::Error::InvalidArgument(_))); +} + +/// `revise_plan` refuses a session once it is past the point of no return +/// (`Running`, `Done`, or `Failed`) — seeded directly onto the ref, since no +/// Phase 1 command reaches those statuses yet (Phase 2's effect worker +/// does). +// @relation(scope=function, role=Verifies) +#[rstest] +#[case::running(Status::Running)] +#[case::done(Status::Done)] +#[case::failed(Status::Failed(FailureReason { detail: "sandbox died".to_owned() }))] +fn revise_plan_refuses_a_session_past_the_point_of_no_return(#[case] status: Status) { + use ents_forge::agent::{AgentSession, SessionMeta}; + + let fixture = Fixture::new(); + let mut meta = SessionMeta::new( + MemberId::new("jdc"), + 1_000, + "claude-sonnet-5", + vec![], + "refs/heads/main", + ReviewPolicy::Manual, + None, + ); + meta.status = status; + let session = AgentSession { + meta, + plan: Some("an existing plan".to_owned()), + confirm: None, + thread: vec![b"turn one".to_vec()], + }; + let refname = ents_model::namespace::agent_session_ref("deadbeef").expect("valid"); + ents_testutil::write_meta_entity( + &fixture.refs, + &fixture.objects, + refname, + &session, + None, + 900, + ); + + let error = agent::revise_plan( + &fixture.refs, + &fixture.objects, + &NullEventSink, + "deadbeef", + "a redraft".to_owned(), + &fixture.identity(), + Mode::Advisory, + ) + .expect_err("refused"); + assert!(matches!(error, ents_forge::Error::InvalidArgument(_))); +} + +/// `new` refuses a toolchain name with no `refs/meta/toolchains/*` ref. +// @relation(scope=function, role=Verifies) +#[rstest] +fn new_refuses_an_unknown_toolchain() { + let fixture = Fixture::new(); + let mut draft = fixture.draft(); + draft.toolchains = vec!["no-such-toolchain".to_owned()]; + let error = agent::new( + &fixture.refs, + &fixture.objects, + &NullEventSink, + draft, + &fixture.identity(), + Mode::Advisory, + ) + .expect_err("refused"); + assert!(matches!(error, ents_forge::Error::NotFound { .. })); +}