forge, model: add the AgentSession entity (agent-sessions phase 1)
commit
5ec8417forge, 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>
Reviews
No reviews of this commit yet — record a verdict below.
Start a review
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 { .. }));
+}