git-ents.gitmain
⌘K
foforge
commit 68fd832
gate, 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>

Joseph D. Carpinelli · 28 days ago

Reviews

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

Start a review

verdict

crates/cli/git-ents/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); + } +}