gate, effect, forge: worker claim vocabulary and the agent-exec effect (phase 2a)
commit
68fd832gate, effect, forge: worker claim vocabulary and the agent-exec effect (phase 2a)
Config gains a designated-worker roster; agent-session mutation admits genesis signer, admins, and workers, mirroring effect.official’s refname-keyed trust. ents-forge gains claim/finish commands with full guard tests and a pure dispatch(tip) → Claim|NoOp decision; ents-effect gains the canonical agent-exec definition whose meta() trigger is validated against the real query grammar.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reviews
No reviews of this commit yet — record a verdict below.
Start a review
crates/cli/git-ents/tests/hosted_root.rs
@@ -177,7 +177,10 @@
&admin_root.objects,
&admin_root.events,
config_ref,
- &ents_gate::Config { epoch: Some(1_000) },
+ &ents_gate::Config {
+ epoch: Some(1_000),
+ ..ents_gate::Config::default()
+ },
&identity,
"Enable the tip invariant",
admin_root.mode(),
crates/forge/ents-forge/tests/agent_sessions.rs
@@ -10,7 +10,10 @@
reason = "integration test: fixtures panic on setup failure"
)]
-use ents_forge::agent::{self, FailureReason, NewAgentSession, ReviewPolicy, Status};
+use ents_forge::agent::{
+ self, ClaimAgentSession, FailureReason, FinishAgentSession, FinishOutcome, NewAgentSession,
+ ReviewPolicy, Status,
+};
use ents_model::MemberId;
use ents_receive::{Identity, Mode, NullEventSink, TxResult};
use ents_testutil::{Keypair, MemRefStore, ObjectStore};
@@ -91,6 +94,59 @@
)
.expect("revises")
}
+
+ fn confirm(&self, id: &str) -> ents_receive::Outcome {
+ agent::confirm(
+ &self.refs,
+ &self.objects,
+ &NullEventSink,
+ id,
+ None,
+ &self.identity(),
+ Mode::Advisory,
+ )
+ .expect("confirms")
+ }
+
+ /// A session revised and confirmed against its own plan — `queued`,
+ /// the only precondition [`agent::claim`] accepts.
+ fn queued_session(&self) -> String {
+ let id = self.new_session();
+ self.revise_plan(&id, "do the thing");
+ self.confirm(&id);
+ id
+ }
+
+ fn claim(&self, id: &str) -> ents_forge::Result<ents_receive::Outcome> {
+ agent::claim(
+ &self.refs,
+ &self.objects,
+ &NullEventSink,
+ id,
+ ClaimAgentSession {
+ worker: MemberId::new("worker"),
+ sprite: "sprite-1".to_owned(),
+ },
+ &self.identity(),
+ Mode::Advisory,
+ )
+ }
+
+ fn finish(
+ &self,
+ id: &str,
+ finish: FinishAgentSession,
+ ) -> ents_forge::Result<ents_receive::Outcome> {
+ agent::finish(
+ &self.refs,
+ &self.objects,
+ &NullEventSink,
+ id,
+ finish,
+ &self.identity(),
+ Mode::Advisory,
+ )
+ }
}
// ---------------------------------------------------------------------
@@ -312,3 +368,191 @@
.expect_err("refused");
assert!(matches!(error, ents_forge::Error::NotFound { .. }));
}
+
+// ---------------------------------------------------------------------
+// `claim` and `finish` (`docs/agent-sessions-plan.adoc`'s Phase 2a): the
+// guards around advancing to `Running` and to a terminal state.
+// ---------------------------------------------------------------------
+
+/// `claim` refuses a session that is not queued: still `planning` (no
+/// plan at all), and `ready` but awaiting confirmation (a plan with no
+/// confirm bound to it).
+// @relation(scope=function, role=Verifies)
+#[rstest]
+fn claim_refuses_a_session_that_is_not_queued() {
+ let fixture = Fixture::new();
+
+ let planning = fixture.new_session();
+ let error = fixture
+ .claim(&planning)
+ .expect_err("refused: still planning");
+ assert!(matches!(error, ents_forge::Error::InvalidArgument(_)));
+
+ fixture.revise_plan(&planning, "do the thing");
+ let error = fixture
+ .claim(&planning)
+ .expect_err("refused: awaiting confirmation, not queued");
+ assert!(matches!(error, ents_forge::Error::InvalidArgument(_)));
+}
+
+/// `claim` on a queued session advances it to `Running`, recording the
+/// worker, the sprite name, and the claim's own timestamp as `started`.
+// @relation(scope=function, role=Verifies)
+#[rstest]
+fn claim_advances_a_queued_session_to_running_with_worker_sprite_and_started() {
+ let fixture = Fixture::new();
+ let id = fixture.queued_session();
+
+ let outcome = fixture.claim(&id).expect("claims");
+ assert_eq!(outcome.result, TxResult::Applied);
+
+ let session = agent::show(&fixture.refs, &fixture.objects, &id).expect("shows");
+ assert_eq!(session.meta.status, Status::Running);
+ assert_eq!(session.meta.worker, Some(MemberId::new("worker")));
+ assert_eq!(session.meta.sprite.as_deref(), Some("sprite-1"));
+ assert_eq!(session.meta.started, Some(1_000));
+ assert!(!session.queued(), "Running is past queued");
+}
+
+/// A second `claim` against an already-claimed session refuses at the
+/// command layer: the first claim already advanced the session past
+/// `queued`, so the ordinary precondition check refuses it — first worker
+/// wins, the loser gets an ordinary [`ents_forge::Error::InvalidArgument`],
+/// never a second `Running` write.
+// @relation(scope=function, role=Verifies)
+#[rstest]
+fn a_second_claim_refuses_at_the_command_layer() {
+ let fixture = Fixture::new();
+ let id = fixture.queued_session();
+ fixture.claim(&id).expect("first claim succeeds");
+
+ let error = fixture.claim(&id).expect_err("refused: no longer queued");
+ assert!(matches!(error, ents_forge::Error::InvalidArgument(_)));
+
+ // The session still carries the first claim's worker, untouched by
+ // the refused second attempt.
+ let session = agent::show(&fixture.refs, &fixture.objects, &id).expect("shows");
+ assert_eq!(session.meta.worker, Some(MemberId::new("worker")));
+}
+
+/// `finish` refuses a session that was never claimed (`planning`,
+/// `ready`/awaiting-confirmation, and `ready`/queued) — only a `Running`
+/// session may be finished.
+// @relation(scope=function, role=Verifies)
+#[rstest]
+fn finish_refuses_a_session_that_is_not_running() {
+ let fixture = Fixture::new();
+ let done = FinishAgentSession {
+ outcome: FinishOutcome::Done,
+ result_branch: None,
+ thread: vec![],
+ };
+
+ let planning = fixture.new_session();
+ let error = fixture
+ .finish(&planning, done.clone())
+ .expect_err("refused: still planning");
+ assert!(matches!(error, ents_forge::Error::InvalidArgument(_)));
+
+ let queued = fixture.queued_session();
+ let error = fixture
+ .finish(&queued, done)
+ .expect_err("refused: queued, never claimed");
+ assert!(matches!(error, ents_forge::Error::InvalidArgument(_)));
+}
+
+/// `finish` refuses a session that already reached a terminal state —
+/// `finish` may not be called twice.
+// @relation(scope=function, role=Verifies)
+#[rstest]
+fn finish_refuses_a_session_already_finished() {
+ let fixture = Fixture::new();
+ let id = fixture.queued_session();
+ fixture.claim(&id).expect("claims");
+ fixture
+ .finish(
+ &id,
+ FinishAgentSession {
+ outcome: FinishOutcome::Done,
+ result_branch: None,
+ thread: vec![],
+ },
+ )
+ .expect("finishes");
+
+ let error = fixture
+ .finish(
+ &id,
+ FinishAgentSession {
+ outcome: FinishOutcome::Done,
+ result_branch: None,
+ thread: vec![],
+ },
+ )
+ .expect_err("refused: already done");
+ assert!(matches!(error, ents_forge::Error::InvalidArgument(_)));
+}
+
+/// `finish` from `Running` with `Done` records the finished timestamp, the
+/// result branch, and appends the execution transcript to `thread/`.
+// @relation(scope=function, role=Verifies)
+#[rstest]
+fn finish_done_records_branch_timestamp_and_appends_the_transcript() {
+ let fixture = Fixture::new();
+ let id = fixture.queued_session();
+ fixture.claim(&id).expect("claims");
+
+ let before = agent::show(&fixture.refs, &fixture.objects, &id).expect("shows");
+ let turns_before = before.thread.len();
+
+ let outcome = fixture
+ .finish(
+ &id,
+ FinishAgentSession {
+ outcome: FinishOutcome::Done,
+ result_branch: Some(format!("agent/jdc/{id}")),
+ thread: vec![b"turn: ran the fix".to_vec()],
+ },
+ )
+ .expect("finishes");
+ assert_eq!(outcome.result, TxResult::Applied);
+
+ let session = agent::show(&fixture.refs, &fixture.objects, &id).expect("shows");
+ assert_eq!(session.meta.status, Status::Done);
+ assert_eq!(session.meta.finished, Some(1_000));
+ assert_eq!(session.meta.result_branch, Some(format!("agent/jdc/{id}")));
+ assert_eq!(session.thread.len(), turns_before.saturating_add(1));
+ assert_eq!(
+ session.thread.last().map(Vec::as_slice),
+ Some(b"turn: ran the fix".as_slice())
+ );
+}
+
+/// `finish` from `Running` with `Failed` records the failure reason as the
+/// session's terminal state.
+// @relation(scope=function, role=Verifies)
+#[rstest]
+fn finish_failed_records_the_failure_reason() {
+ let fixture = Fixture::new();
+ let id = fixture.queued_session();
+ fixture.claim(&id).expect("claims");
+
+ fixture
+ .finish(
+ &id,
+ FinishAgentSession {
+ outcome: FinishOutcome::Failed("sandbox died".to_owned()),
+ result_branch: None,
+ thread: vec![],
+ },
+ )
+ .expect("finishes");
+
+ let session = agent::show(&fixture.refs, &fixture.objects, &id).expect("shows");
+ assert_eq!(
+ session.meta.status,
+ Status::Failed(FailureReason {
+ detail: "sandbox died".to_owned()
+ })
+ );
+}
crates/kernel/ents-effect/src/definition.rs
@@ -56,6 +56,50 @@
Ok(())
}
+/// The canonical `agent-exec` effect's own name
+/// (`docs/agent-sessions-plan.adoc`'s Phase 2) — the final segment of
+/// `refs/meta/effects/agent-exec` (`model.effect-definition`).
+pub const AGENT_EXEC_NAME: &str = "agent-exec";
+
+/// `agent-exec`'s trigger: every author-written `refs/meta/agent-sessions/*`
+/// tip — every commit entering the agent-sessions namespace
+/// (`docs/agent-sessions-plan.adoc`'s Phase 2, "An `agent-exec` effect
+/// subscribed via `meta(...)` to the agent namespace"). `meta()`'s own
+/// grammar rule (`query.meta`) only forbids matching an effect-written
+/// namespace — `refs/meta/results/*` or `refs/meta/index/*` — and
+/// `refs/meta/agent-sessions/*` is neither, so this glob needs no
+/// grammar extension (`query.no-extensions`); this module's own tests pin
+/// that against the real parser.
+pub const AGENT_EXEC_TRIGGER: &str = "meta(refs/meta/agent-sessions/*)";
+
+/// The canonical `agent-exec` [`Effect`] definition
+/// (`docs/agent-sessions-plan.adoc`'s Phase 2): fires once per commit
+/// entering the agent-sessions namespace. `toolchains` and `run` are a
+/// deployment's own choice — this constructor only fixes the two fields
+/// that make the effect *this* effect, `name` and `trigger`
+/// (`model.effect-definition`); a real deployment still writes its own
+/// signed commit onto `refs/meta/effects/agent-exec` through the ordinary
+/// admin-only path (`effect.admin-only`), this fixture is not that write.
+///
+/// # Examples
+///
+/// ```
+/// use ents_effect::definition::{agent_exec, validate};
+///
+/// let effect = agent_exec(vec!["agent-runtime".to_owned()], "git-ents agent-exec run");
+/// assert_eq!(effect.name, "agent-exec");
+/// validate(&effect).expect("the canonical trigger validates");
+/// ```
+#[must_use]
+pub fn agent_exec(toolchains: Vec<String>, run: impl Into<String>) -> Effect {
+ Effect {
+ name: AGENT_EXEC_NAME.to_owned(),
+ trigger: AGENT_EXEC_TRIGGER.to_owned(),
+ toolchains,
+ run: run.into(),
+ }
+}
+
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used, reason = "unit test")]
@@ -102,4 +146,39 @@
fn validate_rejects_an_invalid_toolchain_name() {
assert!(validate(&effect("rev(refs/heads/main)", &["../escape"])).is_err());
}
+
+ // ---- The canonical `agent-exec` definition
+ // (`docs/agent-sessions-plan.adoc`'s Phase 2) ----
+
+ #[rstest]
+ // @relation(query.grammar, scope=function, role=Verifies)
+ fn agent_exec_trigger_parses_against_the_real_query_grammar() {
+ AGENT_EXEC_TRIGGER
+ .parse::<ents_query::Query>()
+ .expect("the canonical agent-exec trigger parses");
+ }
+
+ #[rstest]
+ // @relation(effect.validation, query.meta, scope=function, role=Verifies)
+ fn agent_exec_definition_validates() {
+ let effect = agent_exec(vec!["agent-runtime".to_owned()], "git-ents agent-exec run");
+ assert_eq!(effect.name, AGENT_EXEC_NAME);
+ validate(&effect).expect("the canonical agent-exec definition validates");
+ }
+
+ /// `query.meta` forbids `meta(glob)` from matching only
+ /// `refs/meta/results/*` and `refs/meta/index/*` — the agent-sessions
+ /// namespace is neither, so it must never be rejected as
+ /// effect-written the way `validate_rejects_a_meta_glob_naming_an_effect_written_namespace`
+ /// proves the results namespace is.
+ #[rstest]
+ // @relation(query.meta, scope=function, role=Verifies)
+ fn the_agent_sessions_namespace_is_not_rejected_as_effect_written() {
+ assert!(
+ validate(&effect(AGENT_EXEC_TRIGGER, &[])).is_ok(),
+ "refs/meta/agent-sessions/* is an author-written namespace, not one of \
+ query.meta's forbidden effect-written namespaces (refs/meta/results/*, \
+ refs/meta/index/*)"
+ );
+ }
}
crates/kernel/ents-gate/src/config.rs
@@ -1,20 +1,30 @@
-//! The verification epoch, read from `refs/meta/config` (`gate.epoch`).
+//! The verification epoch and designated-worker roster, read from
+//! `refs/meta/config` (`gate.epoch`, and this crate's own doc on
+//! "Finer-grained, config-stored refname rules").
use facet::Facet;
use gix_hash::ObjectId;
use gix_object::Find;
use gix_ref_store::RefStoreRead;
+use ents_model::MemberId;
+
use crate::error::{Error, Result};
use crate::object::expect_commit;
/// The slice of `refs/meta/config`'s typed tree the gate consults: the
-/// verification epoch (`gate.epoch`).
+/// verification epoch (`gate.epoch`) and the designated-worker roster this
+/// crate's own module doc names as "a later, additive narrowing" —
+/// `docs/agent-sessions-plan.adoc`'s Phase 2a is the first consumer,
+/// narrowing agent-session advance authorization
+/// (`ents-gate`'s `owner_mutation`); a future narrowing of canonical
+/// `refs/meta/results/<effect>/*` (`effect.official`) would read the same
+/// field.
///
/// `model.sdoc` defines no Config entity yet, so this struct is the
/// first (and currently only) definition of the config tree's shape; it
-/// lives here rather than in `ents-model` because the epoch is the only
-/// field any crate reads today. When configuration grows non-gate fields
+/// lives here rather than in `ents-model` because these are the only
+/// fields any crate reads today. When configuration grows non-gate fields
/// (description, role rules, ...), the entity moves to `ents-model` and
/// that change is a storage migration like any other struct change
/// (`meta-ref.migration`).
@@ -31,7 +41,7 @@
/// ```
/// use ents_gate::Config;
///
-/// let config = Config { epoch: Some(1_700_000_000) };
+/// let config = Config { epoch: Some(1_700_000_000), ..Config::default() };
/// let (root, store) = facet_git_tree::serialize(&config).expect("serialize");
/// let back: Config = facet_git_tree::deserialize(&root, &store).expect("deserialize");
/// assert_eq!(back, config);
@@ -42,6 +52,25 @@
/// When the tip invariant came into force, seconds since the Unix
/// epoch; `None` while verification has never been enabled.
pub epoch: Option<u64>,
+ /// Members trusted, alongside an entity's genesis signer and any
+ /// admin-registered member, to advance an owner-mutation-gated
+ /// namespace on another member's behalf — today, an agent session's
+ /// claim/finish advance (`docs/agent-sessions-plan.adoc`'s Phase 2a);
+ /// eventually the same roster narrowing `refs/meta/results/<effect>/*`
+ /// per `effect.official`'s "designated worker keys", once that
+ /// narrowing lands. Empty by default: no worker is designated until a
+ /// signed config write adds one.
+ pub workers: Vec<MemberId>,
+}
+
+/// The config recorded by the tree of the commit at `oid`, or an
+/// [`Error::Entity`] when the tree does not parse as [`Config`] — an
+/// unreadable config fails closed rather than silently disabling the
+/// gate.
+fn config_at_commit(objects: &dyn Find, oid: ObjectId) -> Result<Config> {
+ let commit = expect_commit(objects, oid)?;
+ facet_git_tree::deserialize(&commit.tree, objects)
+ .map_err(|source| Error::Entity { oid, source })
}
/// The epoch recorded by the config tree of the commit at `oid`, or an
@@ -49,16 +78,13 @@
/// unreadable config fails closed rather than silently disabling the
/// gate.
pub(crate) fn epoch_at_commit(objects: &dyn Find, oid: ObjectId) -> Result<Option<u64>> {
- let commit = expect_commit(objects, oid)?;
- let config: Config = facet_git_tree::deserialize(&commit.tree, objects)
- .map_err(|source| Error::Entity { oid, source })?;
- Ok(config.epoch)
+ Ok(config_at_commit(objects, oid)?.epoch)
}
-/// The epoch currently in force, read from `refs/meta/config`'s tip;
-/// `None` when the config ref does not exist or records no epoch.
-// @relation(gate.epoch, gate.policy-as-state, scope=function)
-pub(crate) fn current_epoch(refs: &dyn RefStoreRead, objects: &dyn Find) -> Result<Option<u64>> {
+/// `refs/meta/config`'s current tree, or [`Config::default`] when the
+/// config ref does not exist yet — the same "absent means no narrowing in
+/// force" reading [`current_epoch`] already gives absence.
+fn current_config(refs: &dyn RefStoreRead, objects: &dyn Find) -> Result<Config> {
#[expect(
clippy::expect_used,
clippy::unwrap_in_result,
@@ -69,7 +95,27 @@
.try_into()
.expect("CONFIG_REF is a valid refname");
match refs.get(name.as_ref())? {
- Some(tip) => epoch_at_commit(objects, tip),
- None => Ok(None),
+ Some(tip) => config_at_commit(objects, tip),
+ None => Ok(Config::default()),
}
}
+
+/// The epoch currently in force, read from `refs/meta/config`'s tip;
+/// `None` when the config ref does not exist or records no epoch.
+// @relation(gate.epoch, gate.policy-as-state, scope=function)
+pub(crate) fn current_epoch(refs: &dyn RefStoreRead, objects: &dyn Find) -> Result<Option<u64>> {
+ Ok(current_config(refs, objects)?.epoch)
+}
+
+/// The designated-worker roster currently in force, read from
+/// `refs/meta/config`'s tip; empty when the config ref does not exist or
+/// designates no workers — [`crate::verify::verify`]'s AgentSession advance
+/// rule ORs this into the existing genesis-signer/admin check
+/// (`docs/agent-sessions-plan.adoc`'s Phase 2a).
+// @relation(gate.policy-as-state, scope=function)
+pub(crate) fn designated_workers(
+ refs: &dyn RefStoreRead,
+ objects: &dyn Find,
+) -> Result<Vec<MemberId>> {
+ Ok(current_config(refs, objects)?.workers)
+}
crates/kernel/ents-gate/src/lib.rs
@@ -71,10 +71,15 @@
//! (`meta-ref.inbox`) — `refs/meta/effects/*` is admin-only
//! (`effect.admin-only`), and self-attested members are refused
//! canonical refs until promoted (`model.member-provenance`).
-//! Finer-grained, config-stored refname rules (for example designating
-//! worker keys for one effect's results namespace, `effect.official`)
-//! are a later, additive narrowing: they arrive with a Config entity in
-//! `ents-model`, not a new gate.
+//! Finer-grained, config-stored refname rules are a later, additive
+//! narrowing read from the same [`Config`] the epoch already lives on
+//! (`Config::workers`): `docs/agent-sessions-plan.adoc`'s Phase 2a is the
+//! first one, admitting a designated worker to advance any member's agent
+//! session (∪ genesis signer, ∪ admins) — `verify::owner_mutation`'s
+//! `Namespace::AgentSession` arm. Designating worker keys for one effect's
+//! canonical results namespace (`effect.official`) is the same roster,
+//! unbuilt until a caller needs it; `Config` itself moves to `ents-model`
+//! only once configuration grows fields no gate rule reads.
//!
//! Acceptance-time semantics: a signature is judged against the member
//! entity *currently in force* — the member ref's tip in the same
@@ -111,7 +116,7 @@
//! // 2. The epoch-setting commit is the first gated tip of refs/meta/config.
//! let config_ref: gix::refs::FullName = namespace::CONFIG_REF.try_into().expect("valid");
//! let epoch_tip = write_meta_entity(
-//! &refs, &objects, config_ref, &Config { epoch: Some(200) }, Some(&key), 200,
+//! &refs, &objects, config_ref, &Config { epoch: Some(200), ..Config::default() }, Some(&key), 200,
//! );
//!
//! // 3. From here on, every meta-ref update is judged by the tip invariant.
crates/kernel/ents-gate/src/verify.rs
@@ -244,12 +244,22 @@
}
// gate.owner-mutation: a hash-identified entity's ref advances only
- // under its genesis signer (∪ admins); a review advances only under
- // the member its refname names. Creation stays provenance-keyed,
- // already judged by `authorize` above.
+ // under its genesis signer (∪ admins, ∪ designated workers for an
+ // agent session — `docs/agent-sessions-plan.adoc`'s Phase 2a); a
+ // review advances only under the member its refname names. Creation
+ // stays provenance-keyed, already judged by `authorize` above.
// @relation(gate.owner-mutation, scope=function)
- if let Some(refusal) = owner_mutation(objects, &update.name, old, new, &members, &id, &member)?
- {
+ let workers = config::designated_workers(refs, objects)?;
+ if let Some(refusal) = owner_mutation(
+ objects,
+ &update.name,
+ old,
+ new,
+ &members,
+ &id,
+ &member,
+ &workers,
+ )? {
return Ok(Verdict::Fail(refusal));
}
@@ -782,11 +792,18 @@
}
/// Ownership keys mutation (`gate.owner-mutation`): a hash-identified
-/// entity's ref advances only under its genesis signer or an
-/// admin-registered member; a review advances only under the member its
-/// refname names. Creation stays provenance-keyed (judged by `authorize`),
-/// so this fires only on an advance.
+/// entity's ref advances only under its genesis signer, an admin-registered
+/// member, or — for an agent session only — a designated worker
+/// (`docs/agent-sessions-plan.adoc`'s Phase 2a, `workers`); a review
+/// advances only under the member its refname names. Creation stays
+/// provenance-keyed (judged by `authorize`), so this fires only on an
+/// advance.
// @relation(gate.owner-mutation, scope=function)
+#[expect(
+ clippy::too_many_arguments,
+ reason = "a private continuation of verify(); grouping these into a struct would only rename \
+ the arguments, same rationale as bootstrap()'s own expect"
+)]
fn owner_mutation(
objects: &dyn Find,
name: &FullName,
@@ -795,6 +812,7 @@
members: &[Enrolled],
signer_id: &MemberId,
signer: &Member,
+ workers: &[MemberId],
) -> Result<Option<Refusal>> {
let Some(namespace) = namespace::classify(name.as_ref()) else {
return Ok(None);
@@ -809,16 +827,9 @@
};
let is_admin = signer.provenance == Provenance::AdminRegistered;
match namespace {
- // An agent session's mutation owner is exactly its genesis signer
- // (∪ admins), the same rule a comment or issue advances under —
- // Phase 1b keeps this owner-only rather than inventing a
- // worker-may-advance-status carve-out: `ents-gate`'s vocabulary has
- // no notion of "a claim, once made, authorizes a different signer"
- // to express that safely, so a worker's status-advance commits stay
- // Phase 2's concern (`docs/agent-sessions-plan.adoc` Phase 2's
- // claim-via-CAS), landed by the session's own member or an admin
- // acting on their behalf until that machinery exists.
- Namespace::Comment | Namespace::Issue | Namespace::AgentSession => {
+ // A comment or issue's mutation owner is exactly its genesis
+ // signer (∪ admins).
+ Namespace::Comment | Namespace::Issue => {
// Creation is provenance-keyed; only an advance is owner-keyed.
if old.is_none() {
return Ok(None);
@@ -841,6 +852,46 @@
)))
}
}
+ // An agent session's mutation owner is its genesis signer (∪
+ // admins, ∪ designated workers): Phase 1b kept this owner-only
+ // because `ents-gate` had no notion of "a claim, once made,
+ // authorizes a different signer"; Phase 2a is that machinery — a
+ // signer listed in `refs/meta/config`'s `workers` roster
+ // (`Config::workers`) may advance any member's session, exactly the
+ // additive narrowing this crate's own module doc predicted for a
+ // future `effect.official` roster over `refs/meta/results/*`. A
+ // worker's advance still passes through every other check
+ // unchanged: `gate.fast-forward` still refuses a non-descendant
+ // tip, and `gate.identity-binding` still refuses a mismatched
+ // refname — a designated worker gains a new signer authorized to
+ // advance the ref, never a way around what "advance" means.
+ Namespace::AgentSession => {
+ // Creation is provenance-keyed; only an advance is owner-keyed.
+ if old.is_none() {
+ return Ok(None);
+ }
+ if is_admin {
+ return Ok(None);
+ }
+ if workers.iter().any(|worker| worker == signer_id) {
+ return Ok(None);
+ }
+ let genesis = all_roots(objects, new)?;
+ let genesis_signer = match genesis.first() {
+ Some(root) => commit_signer(objects, members, *root)?,
+ None => None,
+ };
+ if genesis_signer.as_ref() == Some(signer_id) {
+ Ok(None)
+ } else {
+ Ok(refuse(format!(
+ "{signer_id} is neither the member whose signature this entity's genesis \
+ carries, an admin-registered member, nor a designated worker, so may not \
+ advance {}",
+ name.as_bstr()
+ )))
+ }
+ }
Namespace::Review => {
let Some((_, member)) = namespace::parse_review_ref(name.as_ref()) else {
return Ok(None);
crates/kernel/ents-gate/tests/gate.rs
@@ -65,7 +65,10 @@
&refs,
&objects,
config_ref,
- &Config { epoch: Some(200) },
+ &Config {
+ epoch: Some(200),
+ ..Config::default()
+ },
Some(&admin),
200,
);
@@ -835,6 +838,113 @@
expect_fail(&run(&f, &refname, Some(advance)), Requirement::TipSigned);
}
+// ---------------------------------------------------------------------
+// Designated workers (`docs/agent-sessions-plan.adoc`'s Phase 2a): an
+// additive ∪ onto the agent-session advance rule, read from
+// `refs/meta/config`'s `workers` roster.
+// ---------------------------------------------------------------------
+
+/// Advance `refs/meta/config` past `forge()`'s own epoch-setting write,
+/// designating `worker_id` in [`Config::workers`] — a signed fast-forward
+/// exactly like any other config mutation, not a special write path.
+fn designate_worker(f: &Forge, worker_id: &str, seconds: i64) {
+ let config_ref: FullName = namespace::CONFIG_REF.try_into().expect("valid");
+ write_meta_entity(
+ &f.refs,
+ &f.objects,
+ config_ref,
+ &Config {
+ epoch: Some(200),
+ workers: vec![MemberId::new(worker_id)],
+ },
+ Some(&f.admin),
+ seconds,
+ );
+}
+
+#[rstest]
+// @relation(gate.owner-mutation, model.member-worker, scope=function, role=Verifies)
+fn a_designated_worker_may_advance_another_members_agent_session() {
+ // The new ∪ term: a member listed in `refs/meta/config`'s `workers`
+ // roster may advance a session it neither created nor administers —
+ // the machinery Phase 1b deferred ("a worker's status-advance commits
+ // stay Phase 2's concern").
+ let f = forge();
+ let worker = Keypair::from_seed(OUTSIDER_SEED);
+ enroll_member(
+ &f.refs,
+ &f.objects,
+ "worker",
+ &worker,
+ Provenance::AdminRegistered,
+ 210,
+ );
+ designate_worker(&f, "worker", 220);
+
+ let genesis = commit(&f, vec![], Some(&f.admin), 300);
+ let refname = agent_session_ref(genesis);
+ f.refs.set(refname.as_ref(), genesis);
+ let advance = commit(&f, vec![genesis], Some(&worker), 310);
+ expect_pass(
+ &run(&f, &refname, Some(advance)),
+ AdmissionKind::TipInvariant,
+ );
+}
+
+#[rstest]
+// @relation(gate.owner-mutation, model.member-provenance, model.member-worker, scope=function, role=Verifies)
+fn an_unpromoted_member_cannot_advance_an_agent_session_merely_by_being_listed_as_a_worker() {
+ // Listing a member id in `Config::workers` is not itself a promotion:
+ // a self-attested member stays refused for canonical refs
+ // (`model.member-provenance`) even when a signed config write names
+ // its id as a designated worker — the roster narrows an already
+ // *authorized* signer's owner-mutation reach, it does not grant
+ // canonical-ref authorization on its own. This is the "undesignated
+ // member may not" boundary in this codebase's actual two-tier
+ // provenance model: the only signer that ever reaches the
+ // owner-mutation check unauthorized is a self-attested one, and even a
+ // roster listing cannot carry it past `authorize`'s earlier refusal.
+ let f = forge();
+ designate_worker(&f, "guest", 220);
+
+ let genesis = commit(&f, vec![], Some(&f.admin), 300);
+ let refname = agent_session_ref(genesis);
+ f.refs.set(refname.as_ref(), genesis);
+ let advance = commit(&f, vec![genesis], Some(&f.guest), 310);
+ expect_fail(&run(&f, &refname, Some(advance)), Requirement::TipSigned);
+}
+
+#[rstest]
+// @relation(gate.owner-mutation, gate.fast-forward, model.member-worker, scope=function, role=Verifies)
+fn a_designated_worker_still_cannot_rewrite_history() {
+ // Owner-mutation admitting a new signer is not a way around what
+ // "advance" means: a designated worker's tip that does not descend
+ // from the session's current tip is still refused for fast-forward,
+ // exactly as it would be for the genesis signer or an admin.
+ let f = forge();
+ let worker = Keypair::from_seed(OUTSIDER_SEED);
+ enroll_member(
+ &f.refs,
+ &f.objects,
+ "worker",
+ &worker,
+ Provenance::AdminRegistered,
+ 210,
+ );
+ designate_worker(&f, "worker", 220);
+
+ let genesis = commit(&f, vec![], Some(&f.admin), 300);
+ let refname = agent_session_ref(genesis);
+ let first_advance = commit(&f, vec![genesis], Some(&f.admin), 305);
+ f.refs.set(refname.as_ref(), first_advance);
+ // `sibling` is rooted at the same genesis (so identity-binding's
+ // all-roots walk holds) but does not descend from `first_advance`, the
+ // ref's current tip — a worker signing a divergent sibling is still
+ // refused for fast-forward, not admitted as an "advance".
+ let sibling = commit(&f, vec![genesis], Some(&worker), 310);
+ expect_fail(&run(&f, &refname, Some(sibling)), Requirement::FastForward);
+}
+
#[rstest]
// @relation(gate.owner-mutation, model.review, scope=function, role=Verifies)
fn the_wrong_member_cannot_advance_a_review() {
@@ -1038,8 +1148,14 @@
guest: Keypair::from_seed(SELF_ATTESTED_SEED),
};
- let tree = facet_git_tree::serialize_into(&Config { epoch: Some(200) }, &f.objects)
- .expect("config serializes");
+ let tree = facet_git_tree::serialize_into(
+ &Config {
+ epoch: Some(200),
+ ..Config::default()
+ },
+ &f.objects,
+ )
+ .expect("config serializes");
let make = |key: Option<&Keypair>| {
write_commit(
&f.objects,
@@ -1079,7 +1195,10 @@
&refs,
&objects,
config_ref,
- &Config { epoch: Some(50) },
+ &Config {
+ epoch: Some(50),
+ ..Config::default()
+ },
None,
50,
);
@@ -1284,7 +1403,16 @@
#[rstest]
// @relation(gate.epoch, scope=function, role=Verifies)
fn config_round_trips_with_and_without_an_epoch() {
- for config in [Config { epoch: None }, Config { epoch: Some(42) }] {
+ for config in [
+ Config {
+ epoch: None,
+ ..Config::default()
+ },
+ Config {
+ epoch: Some(42),
+ ..Config::default()
+ },
+ ] {
let (root, store) = facet_git_tree::serialize(&config).expect("serialize");
let back: Config = facet_git_tree::deserialize(&root, &store).expect("deserialize");
assert_eq!(back, config);
crates/kernel/ents-receive/src/lib.rs
@@ -70,7 +70,7 @@
//! enroll_member(&refs, &objects, "admin", &admin, Provenance::AdminRegistered, 100);
//! let config_ref: gix::refs::FullName = namespace::CONFIG_REF.try_into().expect("valid");
//! let tip = write_meta_entity(
-//! &refs, &objects, config_ref.clone(), &Config { epoch: Some(200) }, Some(&admin), 200,
+//! &refs, &objects, config_ref.clone(), &Config { epoch: Some(200), ..Config::default() }, Some(&admin), 200,
//! );
//!
//! // The fixture already moved the ref; re-propose the same tip through
crates/kernel/ents-receive/src/receive.rs
@@ -106,7 +106,7 @@
///
/// enroll_member(&refs, &objects, "admin", &admin, Provenance::AdminRegistered, 100);
/// let config_ref: gix::refs::FullName = namespace::CONFIG_REF.try_into().expect("valid");
-/// write_meta_entity(&refs, &objects, config_ref, &Config { epoch: Some(200) }, Some(&admin), 200);
+/// write_meta_entity(&refs, &objects, config_ref, &Config { epoch: Some(200), ..Config::default() }, Some(&admin), 200);
///
/// let issue = Issue {
/// title: "t".into(), body: "b".into(), state: "open".into(),
crates/kernel/ents-receive/tests/receive.rs
@@ -76,7 +76,10 @@
&refs,
&objects,
config_ref,
- &Config { epoch: Some(200) },
+ &Config {
+ epoch: Some(200),
+ ..Config::default()
+ },
Some(&admin),
200,
);
crates/kernel/ents-sync/src/lib.rs
@@ -68,7 +68,7 @@
//! // Enroll (bootstrap) and turn verification on by setting the epoch.
//! enroll_member(&refs, &objects, "jdc", &jdc, Provenance::AdminRegistered, 100);
//! let config: gix::refs::FullName = namespace::CONFIG_REF.try_into().expect("valid");
-//! write_meta_entity(&refs, &objects, config, &Config { epoch: Some(200) }, Some(&jdc), 200);
+//! write_meta_entity(&refs, &objects, config, &Config { epoch: Some(200), ..Config::default() }, Some(&jdc), 200);
//!
//! let issue = Issue {
//! title: "t".into(), body: "b".into(), state: "open".into(),
crates/kernel/ents-sync/src/preflight.rs
@@ -92,7 +92,7 @@
/// let admin = Keypair::from_seed(1);
/// enroll_member(&refs, &objects, "admin", &admin, Provenance::AdminRegistered, 100);
/// let config: gix::refs::FullName = namespace::CONFIG_REF.try_into().expect("valid");
-/// write_meta_entity(&refs, &objects, config, &ents_gate::Config { epoch: Some(200) }, Some(&admin), 200);
+/// write_meta_entity(&refs, &objects, config, &ents_gate::Config { epoch: Some(200), ..ents_gate::Config::default() }, Some(&admin), 200);
///
/// // A self-attested contributor's canonical issue push fails pre-flight,
/// // and the inbox route is offered at once.
crates/kernel/ents-sync/tests/preflight.rs
@@ -99,7 +99,10 @@
&refs,
&objects,
config,
- &Config { epoch: Some(200) },
+ &Config {
+ epoch: Some(200),
+ ..Config::default()
+ },
Some(&admin),
200,
);
crates/kernel/ents-sync/tests/resolve.rs
@@ -96,7 +96,10 @@
refs,
objects,
config,
- &Config { epoch: Some(200) },
+ &Config {
+ epoch: Some(200),
+ ..Config::default()
+ },
Some(admin),
200,
);
crates/kernel/ents-sync/tests/transfer.rs
@@ -83,7 +83,10 @@
refs,
objects,
config,
- &Config { epoch: Some(200) },
+ &Config {
+ epoch: Some(200),
+ ..Config::default()
+ },
Some(admin),
200,
);
crates/forge/ents-forge/src/agent/command.rs
@@ -18,7 +18,9 @@
use gix_object::{CommitRef, Find, Kind, Write};
use gix_ref_store::{RefStore, RefStoreRead};
-use super::{AgentSession, Confirm, ReviewPolicy, SessionMeta, Status, ToolchainPin};
+use super::{
+ AgentSession, Confirm, FailureReason, ReviewPolicy, SessionMeta, Status, ToolchainPin,
+};
use crate::error::{Error, Result};
/// The tree of the commit at `oid` — duplicated from `crate::issue::command`'s
@@ -252,6 +254,165 @@
)?)
}
+/// What `git ents agent claim` needs: which worker is claiming the session,
+/// and the sandbox's name for this run.
+#[derive(Debug, Clone)]
+pub struct ClaimAgentSession {
+ /// The worker claiming the session — becomes [`SessionMeta::worker`].
+ pub worker: MemberId,
+ /// The sandbox's name for this run — becomes [`SessionMeta::sprite`].
+ pub sprite: String,
+}
+
+/// `git ents agent claim`: a worker claims `id`'s session, advancing it to
+/// `Running` with the worker, the sandbox name, and the claim's own
+/// timestamp as [`SessionMeta::started`] — the point of no return past
+/// which no plan revision or un-queue is legal
+/// (`docs/agent-sessions-plan.adoc`'s Phase 2, "Claim = CAS a `running`
+/// status commit through `receive`; first worker wins, losers no-op": a
+/// second `claim` call against the same session finds it no longer
+/// [`AgentSession::queued`], since the first claim already advanced its
+/// status past `Ready`, so it refuses exactly like any other
+/// precondition miss — no separate "already claimed" error variant is
+/// needed).
+///
+/// This is the command layer only: legality is judged purely from the
+/// decoded tip's own state, never from who is signing — the gate's
+/// designated-worker roster (`docs/agent-sessions-plan.adoc`'s Phase 2a)
+/// is what actually authorizes a worker's signature onto the ref; this
+/// function would happily build the same commit for any caller, exactly
+/// as [`confirm`] does not itself check that `identity` is the session's
+/// own member.
+///
+/// # Errors
+///
+/// [`Error::NotFound`] if `id` has no session ref; [`Error::InvalidArgument`]
+/// if the session is not queued — not `Ready`, or `Ready` without a confirm
+/// binding the current plan (`AgentSession::queued`) — including a session
+/// a prior claim already advanced to `Running` or past; otherwise
+/// propagates serialization or `receive` failures.
+// @relation(lens.parity, scope=function)
+pub fn claim(
+ refs: &dyn RefStore,
+ objects: &(impl Find + Write),
+ events: &dyn ents_receive::EventSink,
+ id: &str,
+ claim: ClaimAgentSession,
+ identity: &Identity<'_>,
+ mode: Mode,
+) -> Result<Outcome> {
+ let mut session = session_at(refs, objects, id)?;
+ if !session.queued() {
+ return Err(Error::InvalidArgument(format!(
+ "agent session {id} is not queued; only a queued session (ready, with a confirm \
+ binding its current plan) may be claimed"
+ )));
+ }
+ session.meta.status = Status::Running;
+ session.meta.worker = Some(claim.worker);
+ session.meta.sprite = Some(claim.sprite);
+ session.meta.started = Some(identity.actor.time.seconds);
+
+ let ref_name = ents_model::namespace::agent_session_ref(id)?;
+ Ok(propose_entity(
+ refs,
+ objects,
+ events,
+ ref_name,
+ &session,
+ identity,
+ &format!("Claim agent session {id}"),
+ mode,
+ )?)
+}
+
+/// How a run ended, for [`finish`].
+#[derive(Debug, Clone)]
+pub enum FinishOutcome {
+ /// The run completed and its result landed.
+ Done,
+ /// The run could not complete, or was refused, for the carried reason.
+ Failed(String),
+}
+
+/// What `git ents agent finish` needs: the run's outcome, the result
+/// branch the worker pushed (if any), and the execution transcript to
+/// append to `thread/`.
+#[derive(Debug, Clone)]
+pub struct FinishAgentSession {
+ /// How the run ended.
+ pub outcome: FinishOutcome,
+ /// The branch the worker pushed the run's commits to
+ /// (`agent/<member>/<abbrev-genesis>`, per the plan's resolved-by-default
+ /// item) — becomes [`SessionMeta::result_branch`] when given; `None`
+ /// leaves any existing value untouched (a `Failed` run that never
+ /// reached the point of pushing a branch has nothing to record here).
+ pub result_branch: Option<String>,
+ /// Opaque, verbatim execution transcript blobs to append to
+ /// [`AgentSession::thread`] — never typed or decoded by this crate,
+ /// exactly like the prompt turn [`new`] seeds.
+ pub thread: Vec<Vec<u8>>,
+}
+
+/// `git ents agent finish`: a worker finishes `id`'s session, advancing it
+/// to a terminal state — `Done`, or `Failed` with a reason — and recording
+/// the finish's own timestamp as [`SessionMeta::finished`], the result
+/// branch when the run produced one, and the run's execution transcript
+/// appended to `thread/` (`docs/agent-sessions-plan.adoc`'s Phase 2,
+/// "Finalize = one atomic multi-ref push: thread blobs + `done`/`failed`
+/// meta into the session tree" — the session-tree half of that multi-ref
+/// write; landing the result ref and the result branch alongside it in the
+/// same [`ents_receive::receive`] proposal is the composition root's job,
+/// this crate never holding key material or touching those other refs).
+///
+/// Like [`claim`], this is the command layer only: legality is judged from
+/// the decoded tip's own state, never from who is signing.
+///
+/// # Errors
+///
+/// [`Error::NotFound`] if `id` has no session ref; [`Error::InvalidArgument`]
+/// if the session is not `Running` — a session that was never claimed, or
+/// one already finished, may not be finished again; otherwise propagates
+/// serialization or `receive` failures.
+// @relation(lens.parity, scope=function)
+pub fn finish(
+ refs: &dyn RefStore,
+ objects: &(impl Find + Write),
+ events: &dyn ents_receive::EventSink,
+ id: &str,
+ finish: FinishAgentSession,
+ identity: &Identity<'_>,
+ mode: Mode,
+) -> Result<Outcome> {
+ let mut session = session_at(refs, objects, id)?;
+ if session.meta.status != Status::Running {
+ return Err(Error::InvalidArgument(format!(
+ "agent session {id} is not running; only a running session may be finished"
+ )));
+ }
+ session.meta.status = match finish.outcome {
+ FinishOutcome::Done => Status::Done,
+ FinishOutcome::Failed(detail) => Status::Failed(FailureReason { detail }),
+ };
+ session.meta.finished = Some(identity.actor.time.seconds);
+ if finish.result_branch.is_some() {
+ session.meta.result_branch = finish.result_branch;
+ }
+ session.thread.extend(finish.thread);
+
+ let ref_name = ents_model::namespace::agent_session_ref(id)?;
+ Ok(propose_entity(
+ refs,
+ objects,
+ events,
+ ref_name,
+ &session,
+ identity,
+ &format!("Finish 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
crates/forge/ents-forge/src/agent/entity.rs
@@ -171,11 +171,21 @@
pub member: MemberId,
/// When the session was created, in seconds since the Unix epoch.
pub created: i64,
+ /// The worker that claimed this session, if one ever has
+ /// ([`super::command::claim`] sets this) — the member whose signature
+ /// the gate's designated-worker roster admits to advance this session
+ /// on [`Self::member`]'s behalf (`docs/agent-sessions-plan.adoc`'s
+ /// Phase 2a).
+ pub worker: Option<MemberId>,
+ /// The name of the sandbox executing this run, verbatim, once claimed
+ /// ([`super::command::claim`] sets this) — the plan's own "sandbox
+ /// name verbatim while running" (Phase 3's detail page).
+ pub sprite: Option<String>,
/// When a worker claimed the session and began running it, if it ever
- /// has (Phase 2 sets this).
+ /// has ([`super::command::claim`] sets this).
pub started: Option<i64>,
- /// When the run reached a terminal state, if it ever has (Phase 2 sets
- /// this).
+ /// When the run reached a terminal state, if it ever has
+ /// ([`super::command::finish`] sets this).
pub finished: Option<i64>,
/// The model id the run executes against.
pub model: String,
@@ -214,6 +224,8 @@
Self {
member,
created,
+ worker: None,
+ sprite: None,
started: None,
finished: None,
model: model.into(),
crates/forge/ents-forge/src/agent/mod.rs
@@ -10,10 +10,15 @@
mod cli;
mod command;
+mod dispatch;
mod entity;
pub use cli::AgentAction;
-pub use command::{NewAgentSession, confirm, list, list_all, new, revise_plan, show};
+pub use command::{
+ ClaimAgentSession, FinishAgentSession, FinishOutcome, NewAgentSession, claim, confirm, finish,
+ list, list_all, new, revise_plan, show,
+};
+pub use dispatch::{Dispatch, dispatch};
pub use entity::{
AgentSession, Confirm, FailureReason, ReviewPolicy, SessionMeta, Status, ToolchainPin,
};
crates/forge/ents-forge/src/agent/dispatch.rs
@@ -1,0 +1,150 @@
+//! The runner's claim-or-no-op decision (`docs/agent-sessions-plan.adoc`'s
+//! Phase 2, "the runner inspects the tip and records a cheap `pass` no-op
+//! unless the tip is queued-and-unclaimed"): a pure function from a
+//! decoded [`AgentSession`] tip to what a dequeued `(agent-exec, oid)` pair
+//! should do next.
+//!
+//! This lives in `ents-forge`, alongside [`AgentSession`] itself, rather
+//! than in `ents-effect`: `ents-effect`'s own `Cargo.toml` depends on
+//! exactly `ents-model`, `ents-query`, and `ents-receive` (mirrored in
+//! `docs/spec/overview.adoc`'s crate-graph table), and `ents-forge` is not
+//! among them — adding it would be a new edge the spec's crate graph does
+//! not name, for a decision that needs nothing `ents-effect` carries.
+//! `ents-forge` already depends on none of `ents-effect`'s own crates in
+//! the wrong direction either, so this stays a same-crate function next to
+//! the type it decides over, with no new cross-crate dependency at all.
+
+use super::AgentSession;
+
+/// What a dequeued `(agent-exec, oid)` pair resolves to once the runner
+/// reads the agent session tip at `oid`.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Dispatch {
+ /// The tip is queued and unclaimed
+ /// ([`AgentSession::queued`]): the runner should CAS a `Running` status
+ /// commit ([`super::command::claim`]) — first worker wins, losers
+ /// no-op on the same tip once it is no longer queued.
+ Claim,
+ /// Anything else — planning, awaiting confirmation, already running,
+ /// or a terminal state: a cheap `pass` no-op, no state change.
+ NoOp,
+}
+
+/// Decide [`Dispatch`] for `session`'s current tip — queued-and-unclaimed
+/// maps to [`Dispatch::Claim`], everything else to [`Dispatch::NoOp`].
+///
+/// # Examples
+///
+/// ```
+/// use ents_forge::agent::{AgentSession, Dispatch, ReviewPolicy, SessionMeta, dispatch};
+/// use ents_model::MemberId;
+///
+/// let mut 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![],
+/// };
+/// assert_eq!(dispatch(&session), Dispatch::NoOp, "planning, no plan yet");
+///
+/// session.plan = Some("do the thing".to_owned());
+/// session.meta.status = ents_forge::agent::Status::Ready;
+/// assert_eq!(dispatch(&session), Dispatch::NoOp, "awaiting confirmation");
+/// ```
+#[must_use]
+pub fn dispatch(session: &AgentSession) -> Dispatch {
+ if session.queued() {
+ Dispatch::Claim
+ } else {
+ Dispatch::NoOp
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(clippy::expect_used, reason = "unit test")]
+
+ use rstest::rstest;
+
+ use super::*;
+ use crate::agent::{Confirm, FailureReason, ReviewPolicy, SessionMeta, Status};
+
+ fn session(status: Status, plan: Option<&str>, confirm: Option<Confirm>) -> AgentSession {
+ let mut meta = SessionMeta::new(
+ ents_model::MemberId::new("jdc"),
+ 1_000,
+ "claude-sonnet-5",
+ vec![],
+ "refs/heads/main",
+ ReviewPolicy::Manual,
+ None,
+ );
+ meta.status = status;
+ AgentSession {
+ meta,
+ plan: plan.map(str::to_owned),
+ confirm,
+ thread: vec![],
+ }
+ }
+
+ /// A [`Confirm`] binding `plan`'s own git blob hash — the same content
+ /// hash [`AgentSession::plan_hash`] computes internally, recomputed
+ /// here since that accessor is the only way this test crosses into it
+ /// (mirroring `entity`'s own `blob_hash` test helper).
+ fn confirm_for(plan: &str) -> Confirm {
+ let hash = gix_object::compute_hash(
+ gix_hash::Kind::Sha1,
+ gix_object::Kind::Blob,
+ plan.as_bytes(),
+ )
+ .expect("hashing an in-memory byte slice cannot fail");
+ Confirm::new(hash, ReviewPolicy::Manual)
+ }
+
+ #[rstest]
+ #[case::planning_no_plan(Status::Planning, None, None, Dispatch::NoOp)]
+ #[case::ready_awaiting_confirmation(Status::Ready, Some("do the thing"), None, Dispatch::NoOp)]
+ #[case::running(Status::Running, Some("do the thing"), None, Dispatch::NoOp)]
+ #[case::done(Status::Done, Some("do the thing"), None, Dispatch::NoOp)]
+ #[case::failed(
+ Status::Failed(FailureReason { detail: "sandbox died".to_owned() }),
+ Some("do the thing"),
+ None,
+ Dispatch::NoOp
+ )]
+ // @relation(scope=function, role=Verifies)
+ fn dispatch_is_no_op_outside_queued(
+ #[case] status: Status,
+ #[case] plan: Option<&str>,
+ #[case] confirm: Option<Confirm>,
+ #[case] expected: Dispatch,
+ ) {
+ assert_eq!(dispatch(&session(status, plan, confirm)), expected);
+ }
+
+ #[rstest]
+ // @relation(scope=function, role=Verifies)
+ fn dispatch_claims_a_queued_session() {
+ let plan = "do the thing";
+ let session = session(Status::Ready, Some(plan), Some(confirm_for(plan)));
+ assert!(session.queued());
+ assert_eq!(dispatch(&session), Dispatch::Claim);
+ }
+
+ #[rstest]
+ // @relation(scope=function, role=Verifies)
+ fn dispatch_is_no_op_when_a_confirm_binds_a_stale_plan_hash() {
+ let stale = confirm_for("an earlier draft");
+ let session = session(
+ Status::Ready,
+ Some("a materially different plan"),
+ Some(stale),
+ );
+ assert!(session.awaiting_confirmation());
+ assert_eq!(dispatch(&session), Dispatch::NoOp);
+ }
+}