forge, effect, cli, web: the two planning paths (phase 4)
commit 3b7e536
forge, effect, cli, web: the two planning paths (phase 4)
The mobile path is a second effect: agent-plan shares agent-exec’s
meta(refs/meta/agent-sessions/*) trigger (query.no-extensions) and its
own pure dispatch predicate — fires iff Planning with a prompt and no
plan, cheap pass otherwise. plan_worker.rs (composition root, mirroring
agent_worker.rs) needs no claim: drafting mutates only plan/thread, so
receive’s CAS on the session ref serializes racing drafters and the
loser records a pass. The prompt reaches the sandbox as a sideband file;
the command writes its draft to another; the new forge draft_plan lands
plan + Ready + the drafting transcript in one commit, atomically with
the results record (draft_plan_transition, same split as finish).
The laptop path is the planning-chat page: turns append to thread/ as
opaque blobs through the new append_thread (which refuses outright on a
queued, running, or terminal session — the explicit un-queue is reopen
or revise_plan, never a chat message), and the LLM sits behind a
Planner seam in AppState, installed as "not configured" until Phase 6
brings per-member credentials. SSE stays a page-level concern: one POST
/agents/{id}/chat route content-negotiates text/event-stream — a
mutating GET would have bypassed the sign-in gate that only checks POST
— and ents.js enhances the plain-POST form into a streamed reply.
confirm now refuses an empty or all-whitespace plan leaf (Phase 4
acceptance), with red tests; a mobile end-to-end test drives prompt →
headless draft → confirm from a second request → agent-exec claim →
Done.
crates/cli/ents-web/src/lib.rs
@@ -114,6 +114,7 @@
pub mod identity;
pub(crate) mod markdown;
pub mod pages;
+pub mod planner;
pub mod render;
pub mod router;
pub mod session;
crates/cli/ents-web/src/state.rs
@@ -32,6 +32,7 @@
use crate::auth::ChallengeStore;
use crate::identity::SigningIdentity;
+use crate::planner::{Planner, UnconfiguredPlanner};
use crate::session::SessionStore;
/// Who may mutate through this deployment's web UI — injected by the
@@ -101,6 +102,11 @@
/// unless the composition root said otherwise via
/// [`AppState::with_access`].
pub access: AccessPolicy,
+ /// The planning-chat page's LLM seam
+ /// (`docs/agent-sessions-plan.adoc`'s Phase 4): [`UnconfiguredPlanner`]
+ /// unless the composition root said otherwise via
+ /// [`AppState::with_planner`].
+ pub planner: Box<dyn Planner>,
}
impl<O> AppState<O> {
@@ -124,6 +130,7 @@
path,
sessions: SessionStore::default(),
access: AccessPolicy::Trusted,
+ planner: Box::new(UnconfiguredPlanner),
}
}
@@ -139,6 +146,17 @@
self
}
+ /// Replace the default [`UnconfiguredPlanner`] — a real Planner is
+ /// wired the same consuming-builder way once one exists (per-member
+ /// credentials, `docs/agent-sessions-plan.adoc`'s Phase 6), so every
+ /// existing `new` caller stays untouched until a composition root
+ /// opts in.
+ #[must_use]
+ pub fn with_planner(mut self, planner: Box<dyn Planner>) -> Self {
+ self.planner = planner;
+ self
+ }
+
/// Lock the object store for the duration of one request.
///
/// Poisoning recovers rather than propagating (mirrors
crates/cli/ents-web/tests/router.rs
@@ -3764,3 +3764,254 @@
assert!(after.contains(&result_ref_display));
assert!(!after.contains("not yet recorded"));
}
+
+// ---------------------------------------------------------------------
+// `crate::pages::agent_chat` (`docs/agent-sessions-plan.adoc`'s Phase 4:
+// the laptop planning-chat page).
+// ---------------------------------------------------------------------
+
+/// `POST path` with a signed-in session's cookie and a form-encoded body,
+/// returning the response -- the chat-page tests' own thin wrapper around
+/// the same request shape [`agent_create_is_rejected_without_a_valid_csrf_token`]
+/// builds inline, factored out here since every test below needs at least
+/// one.
+async fn post_form(
+ router: &axum::Router,
+ path: &str,
+ cookie: &str,
+ body: String,
+) -> axum::http::Response<Body> {
+ router
+ .clone()
+ .oneshot(
+ Request::post(path)
+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+ .header(header::COOKIE, cookie)
+ .body(Body::from(body))
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call")
+}
+
+/// A freshly started (`planning`) session's detail page links to its own
+/// planning-chat page, which itself renders the composer and the seeded
+/// prompt (the one place a thread blob is deliberately rendered -- see
+/// `crate::pages::agent_chat`'s own doc: it is the very page that wrote
+/// it, for the member who wrote it).
+#[tokio::test]
+// @relation(lens.parity, scope=function, role=Verifies)
+async fn agent_detail_links_to_chat_and_chat_renders_the_composer_and_prompt() {
+ let state = build_state(FixtureIdentity {
+ name: "filer",
+ key: Keypair::from_seed(30),
+ });
+ let router = ents_web::router(state.clone());
+ let id = seed_agent_via_web(&router, &state, "reproduce the flaky test").await;
+
+ let detail = get_body(&router, &format!("/agents/{id}")).await;
+ assert!(detail.contains(&format!("/agents/{id}/chat")));
+
+ let chat = get_body(&router, &format!("/agents/{id}/chat")).await;
+ assert!(chat.contains("data-agent-chat"));
+ assert!(chat.contains("reproduce the flaky test"));
+ assert!(!chat.contains("Reopen for planning"));
+}
+
+/// `POST /agents/{id}/chat` appends the member's message and the injected
+/// `Planner`'s reply (`ents_web::planner::UnconfiguredPlanner`, the
+/// default every composition root installs) in one commit, both of which
+/// then render on a reload of the chat page.
+#[tokio::test]
+// @relation(lens.parity, roots.web-signing, roots.web-session, scope=function, role=Verifies)
+async fn agent_chat_send_appends_the_turn_pair_and_renders_the_stub_planners_reply() {
+ let state = build_state(FixtureIdentity {
+ name: "filer",
+ key: Keypair::from_seed(31),
+ });
+ let router = ents_web::router(state.clone());
+ let id = seed_agent_via_web(&router, &state, "prompt").await;
+
+ let (cookie, csrf) =
+ session_cookie_and_csrf(&router, &state, &format!("/agents/{id}/chat")).await;
+ let response = post_form(
+ &router,
+ &format!("/agents/{id}/chat"),
+ &cookie,
+ format!("message=what+should+the+plan+look+like%3F&csrf={csrf}"),
+ )
+ .await;
+ assert!(response.status().is_redirection());
+
+ let session =
+ ents_forge::agent::show(state.refs.as_ref(), &*state.objects(), &id).expect("shows");
+ assert_eq!(
+ session.thread.len(),
+ 3,
+ "prompt + user turn + assistant turn"
+ );
+
+ let chat = get_body(&router, &format!("/agents/{id}/chat")).await;
+ assert!(chat.contains("what should the plan look like?"));
+ assert!(chat.to_lowercase().contains("not configured"));
+}
+
+/// `roots.web-session`: `POST /agents/{id}/chat` is a state-changing route,
+/// refused without a valid CSRF token exactly like every other mutation in
+/// this crate.
+#[tokio::test]
+// @relation(roots.web-session, scope=function, role=Verifies)
+async fn agent_chat_send_is_rejected_without_a_valid_csrf_token() {
+ let state = build_state(FixtureIdentity {
+ name: "filer",
+ key: Keypair::from_seed(32),
+ });
+ let router = ents_web::router(state.clone());
+ let id = seed_agent_via_web(&router, &state, "prompt").await;
+
+ let (cookie, _csrf) =
+ session_cookie_and_csrf(&router, &state, &format!("/agents/{id}/chat")).await;
+ let response = post_form(
+ &router,
+ &format!("/agents/{id}/chat"),
+ &cookie,
+ "message=sneaky&csrf=wrong".to_owned(),
+ )
+ .await;
+ assert!(!response.status().is_success() && !response.status().is_redirection());
+
+ let session =
+ ents_forge::agent::show(state.refs.as_ref(), &*state.objects(), &id).expect("shows");
+ assert_eq!(
+ session.thread.len(),
+ 1,
+ "the bad-csrf message must not land"
+ );
+}
+
+/// `docs/agent-sessions-plan.adoc`'s Phase 4 acceptance: once a session is
+/// confirmed and queued, `POST /agents/{id}/chat` refuses rather than
+/// silently un-queueing it; `POST /agents/{id}/reopen` is the explicit
+/// un-queue that returns it to `planning`, after which chatting works
+/// again.
+#[tokio::test]
+// @relation(scope=function, role=Verifies)
+async fn agent_chat_refuses_a_queued_session_until_explicitly_reopened() {
+ let seed = 33u8;
+ let state = build_state(FixtureIdentity {
+ name: "filer",
+ key: Keypair::from_seed(seed),
+ });
+ let router = ents_web::router(state.clone());
+ let id = seed_agent_via_web(&router, &state, "prompt").await;
+
+ let key = Keypair::from_seed(seed);
+ let identity = Identity {
+ actor: fixture_actor("filer"),
+ author: None,
+ sign: &|payload| key.sign(payload),
+ };
+ ents_forge::agent::revise_plan(
+ state.refs.as_ref(),
+ &*state.objects(),
+ state.events.as_ref(),
+ &id,
+ "do the thing".to_owned(),
+ &identity,
+ state.mode,
+ )
+ .expect("revises");
+ ents_forge::agent::confirm(
+ state.refs.as_ref(),
+ &*state.objects(),
+ state.events.as_ref(),
+ &id,
+ None,
+ &identity,
+ state.mode,
+ )
+ .expect("confirms");
+ assert!(
+ ents_forge::agent::show(state.refs.as_ref(), &*state.objects(), &id)
+ .expect("shows")
+ .queued()
+ );
+
+ let (cookie, csrf) =
+ session_cookie_and_csrf(&router, &state, &format!("/agents/{id}/chat")).await;
+ let refused = post_form(
+ &router,
+ &format!("/agents/{id}/chat"),
+ &cookie,
+ format!("message=let%27s+change+something&csrf={csrf}"),
+ )
+ .await;
+ assert!(
+ !refused.status().is_success() && !refused.status().is_redirection(),
+ "a queued session must refuse a chat message rather than silently un-queue"
+ );
+
+ let queued_chat = get_body(&router, &format!("/agents/{id}/chat")).await;
+ assert!(queued_chat.contains("Reopen for planning"));
+
+ let reopened = post_form(
+ &router,
+ &format!("/agents/{id}/reopen"),
+ &cookie,
+ format!("csrf={csrf}"),
+ )
+ .await;
+ assert!(reopened.status().is_redirection());
+
+ let session =
+ ents_forge::agent::show(state.refs.as_ref(), &*state.objects(), &id).expect("shows");
+ assert_eq!(session.meta.status, ents_forge::agent::Status::Planning);
+ assert!(session.confirm.is_none());
+
+ let sent = post_form(
+ &router,
+ &format!("/agents/{id}/chat"),
+ &cookie,
+ format!("message=let%27s+change+something&csrf={csrf}"),
+ )
+ .await;
+ assert!(
+ sent.status().is_redirection(),
+ "chatting works again once the session is reopened"
+ );
+}
+
+/// `POST /agents/{id}/plan` commits the plan editor's text via
+/// `ents_forge::agent::revise_plan`, transitioning the session to
+/// `ready`/awaiting-confirmation -- the same commit path
+/// `docs/agent-sessions-plan.adoc`'s Phase 4 names for both this chat page
+/// and the headless `agent-plan` effect.
+#[tokio::test]
+// @relation(lens.parity, roots.web-signing, roots.web-session, scope=function, role=Verifies)
+async fn agent_plan_commit_transitions_to_ready_awaiting_confirmation() {
+ let state = build_state(FixtureIdentity {
+ name: "filer",
+ key: Keypair::from_seed(34),
+ });
+ let router = ents_web::router(state.clone());
+ let id = seed_agent_via_web(&router, &state, "prompt").await;
+
+ let (cookie, csrf) =
+ session_cookie_and_csrf(&router, &state, &format!("/agents/{id}/chat")).await;
+ let response = post_form(
+ &router,
+ &format!("/agents/{id}/plan"),
+ &cookie,
+ format!("plan=1.+read+the+test%0A2.+fix+it&csrf={csrf}"),
+ )
+ .await;
+ assert!(response.status().is_redirection());
+
+ let session =
+ ents_forge::agent::show(state.refs.as_ref(), &*state.objects(), &id).expect("shows");
+ assert_eq!(session.meta.status, ents_forge::agent::Status::Ready);
+ assert!(session.awaiting_confirmation());
+
+ let detail = get_body(&router, &format!("/agents/{id}")).await;
+ assert!(detail.contains("Confirm plan"));
+}
crates/cli/git-ents/src/hook.rs
@@ -261,6 +261,25 @@
&|payload| signer.sign(payload),
Mode::Mandatory,
)?;
+ } else if effect_name == crate::plan_worker::AGENT_PLAN_NAME {
+ // The `agent-plan` effect (`docs/agent-sessions-plan.adoc`'s
+ // Phase 4) needs the same bespoke handling `agent-exec` does,
+ // for the same reason: a plain pass/fail result on a single
+ // ref cannot express "draft a plan and land it atomically with
+ // this effect's own result."
+ crate::plan_worker::run_agent_plan(
+ &root.refs,
+ &root.objects,
+ &root.events,
+ executor,
+ scratch,
+ &toolchains,
+ &effect.run,
+ oid,
+ &author,
+ &|payload| signer.sign(payload),
+ Mode::Mandatory,
+ )?;
} else {
run_one(
&root.refs,
crates/cli/git-ents/src/lib.rs
@@ -81,6 +81,7 @@
pub mod hook;
pub mod mutate;
pub mod package;
+pub mod plan_worker;
pub mod root;
pub mod sign;
crates/cli/git-ents/tests/agent.rs
@@ -5,14 +5,27 @@
//! mirroring `tests/issue.rs`'s own shape for the same reason: every
//! operation here is `commands::agent`'s own library call
//! (`lens.parity`), not a re-implementation for the test.
+//!
+//! The mobile end-to-end test at the bottom (`docs/agent-sessions-plan.adoc`'s
+//! Phase 4 acceptance) additionally drives `git_ents::plan_worker` and
+//! `git_ents::agent_worker` directly — the same composition-root run
+//! functions `crate::hook::post_receive` calls for the `agent-plan` and
+//! `agent-exec` effects, exercised here without a real push/hook cycle.
#![allow(clippy::expect_used, reason = "integration test")]
mod common;
+use ents_effect::executor::SandboxInputs;
+use ents_effect::{Executor, RunOutput, RunStatus};
use ents_forge::agent::Status;
+use ents_model::MemberId;
+use ents_receive::Mode;
use git_ents::commands::agent;
use git_ents::root::LocalRoot;
+use git_ents::{agent_worker, plan_worker};
+use gix_object::{Commit, Kind};
+use gix_ref_store::{Expected, RefEdit, RefStore, RefStoreRead as _};
/// `git ents agent new` seeds `planning`, with no plan yet — neither
/// derived predicate holds.
@@ -192,3 +205,205 @@
assert!(ids.contains(&first));
assert!(ids.contains(&second));
}
+
+// ---------------------------------------------------------------------
+// Mobile end-to-end (`docs/agent-sessions-plan.adoc`'s Phase 4
+// acceptance): prompt in -> headless draft -> confirm from a second
+// request -> execution.
+// ---------------------------------------------------------------------
+
+/// Write an empty-tree commit and move `refname` to it directly through
+/// the ref store — a branch ref needs no signature at all
+/// (`gate.principled-split`), mirroring `tests/reconcile.rs`'s own
+/// `advance_branch` but generalized to any `RefStore`/object-store pair
+/// rather than one root type, since this file's fixture is a
+/// [`LocalRoot`], not a `HostedRoot`.
+fn advance_branch(
+ refs: &dyn RefStore,
+ objects: &impl gix_object::Write,
+ refname: &str,
+ seconds: i64,
+) -> gix_hash::ObjectId {
+ let empty_tree = objects.write(&gix_object::Tree::empty()).expect("tree");
+ let actor = gix::actor::Signature {
+ name: "test".into(),
+ email: "test@ents.test".into(),
+ time: gix::date::Time { seconds, offset: 0 },
+ };
+ let commit = Commit {
+ tree: empty_tree,
+ parents: Default::default(),
+ author: actor.clone(),
+ committer: actor,
+ encoding: None,
+ message: "advance".into(),
+ extra_headers: Vec::new(),
+ };
+ let mut raw = Vec::new();
+ gix_object::WriteTo::write_to(&commit, &mut raw).expect("serialize");
+ let oid = objects.write_buf(Kind::Commit, &raw).expect("write");
+
+ let name: gix::refs::FullName = refname.try_into().expect("valid refname");
+ refs.transaction(&[RefEdit {
+ name,
+ expected: Expected::Any,
+ new: Some(oid),
+ }])
+ .expect("moves the ref");
+ oid
+}
+
+/// A stub `agent-plan` executor: writes a drafted plan file to the
+/// checked-out workdir, exactly the contract
+/// `git_ents::plan_worker::run_agent_plan`'s own doc describes for the
+/// declared `run` command.
+fn write_draft(workdir: &std::path::Path) {
+ std::fs::write(
+ workdir.join(plan_worker::AGENT_PLAN_DRAFT_FILE),
+ "1. reproduce the flake\n2. fix the race\n3. verify it stays green",
+ )
+ .expect("write draft");
+}
+
+struct DraftExecutor;
+impl Executor for DraftExecutor {
+ fn run(&self, inputs: &SandboxInputs<'_>) -> ents_effect::Result<RunOutput> {
+ write_draft(inputs.workdir);
+ Ok(RunOutput {
+ status: RunStatus::Pass,
+ log: "drafted".to_owned(),
+ })
+ }
+}
+
+/// A stub `agent-exec` executor: no filesystem mutation, always passes.
+struct ExecExecutor;
+impl Executor for ExecExecutor {
+ fn run(&self, _inputs: &SandboxInputs<'_>) -> ents_effect::Result<RunOutput> {
+ Ok(RunOutput {
+ status: RunStatus::Pass,
+ log: "executed".to_owned(),
+ })
+ }
+}
+
+/// The full mobile path: a session created with a prompt only (no plan)
+/// drafts headlessly through the `agent-plan` effect (a stubbed executor
+/// standing in for the real headless agent SDK), reaches `ready` with a
+/// plan, is confirmed from what stands in here for "a second request" (a
+/// fresh, independent `agent::confirm` call against the same repository —
+/// the same call shape a second HTTP request to the hosted web UI would
+/// make), reaches `queued`, and the `agent-exec` effect's own claim
+/// succeeds against it and runs it to `done` — reusing
+/// `git_ents::plan_worker::run_agent_plan` and
+/// `git_ents::agent_worker::run_agent_exec` directly, the same
+/// composition-root seam `crate::hook::post_receive` drives for both
+/// effects.
+// @relation(scope=function, role=Verifies)
+#[test]
+fn mobile_end_to_end_prompt_to_headless_draft_to_confirm_to_claim() {
+ let fixture = common::Fixture::new(6);
+ let root = LocalRoot::open(fixture.path()).expect("opens");
+ advance_branch(&root.refs, &root.objects, "refs/heads/main", 100);
+
+ // 1. A session created with a prompt only -- no plan.
+ let id = agent::new(
+ &root,
+ "fix the flaky test".to_owned(),
+ "claude-sonnet-5".to_owned(),
+ vec![],
+ "refs/heads/main".to_owned(),
+ "manual".to_owned(),
+ None,
+ Some(fixture.key_path.clone()),
+ )
+ .expect("starts a session");
+ let planning = agent::show(&root, &id).expect("shows");
+ assert_eq!(planning.meta.status, Status::Planning);
+ assert!(planning.plan.is_none());
+
+ // 2. The `agent-plan` effect drafts headlessly.
+ let session_ref = ents_model::namespace::agent_session_ref(&id).expect("valid");
+ let tip = root
+ .refs
+ .get(session_ref.as_ref())
+ .expect("readable")
+ .expect("exists");
+ let worker_author = gix::actor::Signature {
+ name: "worker".into(),
+ email: "worker@ents.test".into(),
+ time: gix::date::Time {
+ seconds: 200,
+ offset: 0,
+ },
+ };
+ let signer = git_ents::sign::Signer::load(&fixture.key_path).expect("loads");
+ let scratch = tempfile::tempdir().expect("tempdir");
+ let plan_run = plan_worker::run_agent_plan(
+ &root.refs,
+ &root.objects,
+ &root.events,
+ &DraftExecutor,
+ scratch.path(),
+ &[],
+ "true",
+ tip,
+ &worker_author,
+ &|payload| signer.sign(payload),
+ root.mode(),
+ )
+ .expect("drafts");
+ assert!(matches!(
+ plan_run,
+ plan_worker::AgentPlanOutcome::Drafted { .. }
+ ));
+
+ let drafted = agent::show(&root, &id).expect("shows");
+ assert_eq!(drafted.meta.status, Status::Ready);
+ assert!(drafted.awaiting_confirmation());
+ assert!(
+ drafted
+ .plan
+ .as_deref()
+ .is_some_and(|plan| plan.contains("reproduce the flake"))
+ );
+
+ // 3. Confirm from what stands in for "a second request": an
+ // independent `agent::confirm` call, exactly what a second HTTP
+ // request to the web UI would make.
+ agent::confirm(&root, &id, None, Some(fixture.key_path.clone())).expect("confirms");
+ let queued = agent::show(&root, &id).expect("shows");
+ assert!(queued.queued());
+
+ // 4. `agent-exec`'s own claim succeeds against the now-queued session
+ // and runs it to `done`.
+ let queued_ref = ents_model::namespace::agent_session_ref(&id).expect("valid");
+ let queued_tip = root
+ .refs
+ .get(queued_ref.as_ref())
+ .expect("readable")
+ .expect("exists");
+ let exec_run = agent_worker::run_agent_exec(
+ &root.refs,
+ &root.objects,
+ &root.events,
+ &ExecExecutor,
+ scratch.path(),
+ &[],
+ "true",
+ queued_tip,
+ MemberId::new("worker"),
+ "sprite-1".to_owned(),
+ &worker_author,
+ &|payload| signer.sign(payload),
+ Mode::Advisory,
+ )
+ .expect("claims and runs");
+ assert!(matches!(
+ exec_run,
+ agent_worker::AgentRunOutcome::Finished { .. }
+ ));
+
+ let done = agent::show(&root, &id).expect("shows");
+ assert_eq!(done.meta.status, Status::Done);
+}
crates/forge/ents-forge/tests/agent_sessions.rs
@@ -298,6 +298,31 @@
assert!(matches!(error, ents_forge::Error::InvalidArgument(_)));
}
+/// `confirm` refuses a session whose plan is empty, or all-whitespace —
+/// `docs/agent-sessions-plan.adoc`'s Phase 4 acceptance: "no confirm can
+/// bind an empty or absent plan leaf."
+// @relation(scope=function, role=Verifies)
+#[rstest]
+#[case::empty("")]
+#[case::whitespace(" \n\t")]
+fn confirm_refuses_a_session_with_an_empty_plan(#[case] empty_plan: &str) {
+ let fixture = Fixture::new();
+ let id = fixture.new_session();
+ fixture.revise_plan(&id, empty_plan);
+
+ let error = agent::confirm(
+ &fixture.refs,
+ &fixture.objects,
+ &NullEventSink,
+ &id,
+ None,
+ &fixture.identity(),
+ Mode::Advisory,
+ )
+ .expect_err("refused");
+ assert!(matches!(error, ents_forge::Error::InvalidArgument(_)));
+}
+
/// `revise_plan` refuses a session once it is past the point of no return
/// (`Running`, `Done`, or `Failed`) — seeded directly onto the ref, since no
/// Phase 1 command reaches those statuses yet (Phase 2's effect worker
@@ -556,3 +581,205 @@
})
);
}
+
+// ---------------------------------------------------------------------
+// Phase 4 (`docs/agent-sessions-plan.adoc`): `reopen`, `append_thread`,
+// `draft_plan`.
+// ---------------------------------------------------------------------
+
+/// `reopen` returns a queued session to `planning`, dropping the confirm —
+/// the plan's resolved-by-default "un-queue" item, offered as its own
+/// action rather than only as `revise_plan`'s side effect.
+// @relation(scope=function, role=Verifies)
+#[rstest]
+fn reopen_returns_a_queued_session_to_planning_and_drops_the_confirm() {
+ let fixture = Fixture::new();
+ let id = fixture.queued_session();
+
+ let outcome = agent::reopen(
+ &fixture.refs,
+ &fixture.objects,
+ &NullEventSink,
+ &id,
+ &fixture.identity(),
+ Mode::Advisory,
+ )
+ .expect("reopens");
+ assert_eq!(outcome.result, TxResult::Applied);
+
+ let session = agent::show(&fixture.refs, &fixture.objects, &id).expect("shows");
+ assert_eq!(session.meta.status, Status::Planning);
+ assert!(session.confirm.is_none());
+ assert!(
+ session.plan.is_some(),
+ "reopening keeps the plan text around for the resumed conversation's context"
+ );
+}
+
+/// `reopen` refuses a session that is not `Ready`: still `planning` has
+/// nothing to reopen, and `running`/terminal are past the point of no
+/// return.
+// @relation(scope=function, role=Verifies)
+#[rstest]
+fn reopen_refuses_a_session_that_is_not_ready() {
+ let fixture = Fixture::new();
+ let planning = fixture.new_session();
+ let error = agent::reopen(
+ &fixture.refs,
+ &fixture.objects,
+ &NullEventSink,
+ &planning,
+ &fixture.identity(),
+ Mode::Advisory,
+ )
+ .expect_err("refused: still planning");
+ assert!(matches!(error, ents_forge::Error::InvalidArgument(_)));
+
+ let queued = fixture.queued_session();
+ fixture.claim(&queued).expect("claims");
+ let error = agent::reopen(
+ &fixture.refs,
+ &fixture.objects,
+ &NullEventSink,
+ &queued,
+ &fixture.identity(),
+ Mode::Advisory,
+ )
+ .expect_err("refused: running, past the point of no return");
+ assert!(matches!(error, ents_forge::Error::InvalidArgument(_)));
+}
+
+/// `append_thread` appends a chat turn while `planning`, touching nothing
+/// else.
+// @relation(scope=function, role=Verifies)
+#[rstest]
+fn append_thread_appends_a_turn_while_planning() {
+ let fixture = Fixture::new();
+ let id = fixture.new_session();
+ let before = agent::show(&fixture.refs, &fixture.objects, &id).expect("shows");
+ let turns_before = before.thread.len();
+
+ let outcome = agent::append_thread(
+ &fixture.refs,
+ &fixture.objects,
+ &NullEventSink,
+ &id,
+ vec![b"user: what should the plan look like?".to_vec()],
+ &fixture.identity(),
+ Mode::Advisory,
+ )
+ .expect("appends");
+ assert_eq!(outcome.result, TxResult::Applied);
+
+ let session = agent::show(&fixture.refs, &fixture.objects, &id).expect("shows");
+ assert_eq!(session.meta.status, Status::Planning);
+ assert_eq!(session.thread.len(), turns_before.saturating_add(1));
+}
+
+/// `append_thread` also accepts a turn while `ready`-and-awaiting
+/// confirmation, but refuses once the session is queued, running, or
+/// terminal — `docs/agent-sessions-plan.adoc`'s Phase 4 acceptance: "after
+/// confirm, no endpoint accepts messages or revisions without the explicit
+/// un-queue."
+// @relation(scope=function, role=Verifies)
+#[rstest]
+fn append_thread_refuses_a_queued_running_or_terminal_session() {
+ let fixture = Fixture::new();
+
+ let awaiting = fixture.new_session();
+ fixture.revise_plan(&awaiting, "do the thing");
+ agent::append_thread(
+ &fixture.refs,
+ &fixture.objects,
+ &NullEventSink,
+ &awaiting,
+ vec![b"one more thought".to_vec()],
+ &fixture.identity(),
+ Mode::Advisory,
+ )
+ .expect("awaiting confirmation still accepts a chat turn");
+
+ let queued = fixture.queued_session();
+ let error = agent::append_thread(
+ &fixture.refs,
+ &fixture.objects,
+ &NullEventSink,
+ &queued,
+ vec![b"a message that must not land".to_vec()],
+ &fixture.identity(),
+ Mode::Advisory,
+ )
+ .expect_err("refused: queued");
+ assert!(matches!(error, ents_forge::Error::InvalidArgument(_)));
+
+ fixture.claim(&queued).expect("claims");
+ let error = agent::append_thread(
+ &fixture.refs,
+ &fixture.objects,
+ &NullEventSink,
+ &queued,
+ vec![b"a message that must not land".to_vec()],
+ &fixture.identity(),
+ Mode::Advisory,
+ )
+ .expect_err("refused: running");
+ assert!(matches!(error, ents_forge::Error::InvalidArgument(_)));
+}
+
+/// `draft_plan` commits a plan and transcript in one commit, transitioning
+/// `planning` to `ready` — the `agent-plan` effect's own commit.
+// @relation(scope=function, role=Verifies)
+#[rstest]
+fn draft_plan_commits_the_plan_and_transcript_and_transitions_to_ready() {
+ let fixture = Fixture::new();
+ let id = fixture.new_session();
+
+ let outcome = agent::draft_plan(
+ &fixture.refs,
+ &fixture.objects,
+ &NullEventSink,
+ &id,
+ "1. read the failing test\n2. fix it\n3. re-run".to_owned(),
+ vec![b"drafting transcript".to_vec()],
+ &fixture.identity(),
+ Mode::Advisory,
+ )
+ .expect("drafts");
+ assert_eq!(outcome.result, TxResult::Applied);
+
+ let session = agent::show(&fixture.refs, &fixture.objects, &id).expect("shows");
+ assert_eq!(session.meta.status, Status::Ready);
+ assert!(session.awaiting_confirmation());
+ assert_eq!(
+ session.plan.as_deref(),
+ Some("1. read the failing test\n2. fix it\n3. re-run")
+ );
+ assert_eq!(
+ session.thread.last().map(Vec::as_slice),
+ Some(b"drafting transcript".as_slice())
+ );
+}
+
+/// `draft_plan` refuses a session that is not `planning` — a human already
+/// moved it to `ready` (or further) by hand, and this function must not
+/// clobber that race rather than silently overwrite it.
+// @relation(scope=function, role=Verifies)
+#[rstest]
+fn draft_plan_refuses_a_session_that_is_not_planning() {
+ let fixture = Fixture::new();
+ let id = fixture.new_session();
+ fixture.revise_plan(&id, "a human already drafted this");
+
+ let error = agent::draft_plan(
+ &fixture.refs,
+ &fixture.objects,
+ &NullEventSink,
+ &id,
+ "a race draft".to_owned(),
+ vec![],
+ &fixture.identity(),
+ Mode::Advisory,
+ )
+ .expect_err("refused: already ready");
+ assert!(matches!(error, ents_forge::Error::InvalidArgument(_)));
+}
crates/kernel/ents-effect/src/definition.rs
@@ -100,6 +100,50 @@
}
}
+/// The canonical `agent-plan` effect's own name
+/// (`docs/agent-sessions-plan.adoc`'s Phase 4, "headless plan drafting is a
+/// second effect (`agent-plan`)") — the final segment of
+/// `refs/meta/effects/agent-plan`.
+pub const AGENT_PLAN_NAME: &str = "agent-plan";
+
+/// `agent-plan`'s trigger: identical to [`AGENT_EXEC_TRIGGER`] — every
+/// author-written `refs/meta/agent-sessions/*` tip. Both effects share one
+/// query grammar; the plan's own words are "the runner decides by
+/// inspecting the tip" — what tells `agent-plan` and `agent-exec` apart is
+/// never the trigger, only each effect's own dispatch predicate
+/// (`ents_forge::agent::dispatch_plan` here, `ents_forge::agent::dispatch`
+/// for `agent-exec`) and its own results namespace
+/// (`refs/meta/results/agent-plan/*`, distinct from `agent-exec`'s).
+pub const AGENT_PLAN_TRIGGER: &str = AGENT_EXEC_TRIGGER;
+
+/// The canonical `agent-plan` [`Effect`] definition
+/// (`docs/agent-sessions-plan.adoc`'s Phase 4): headless plan drafting,
+/// firing on every commit entering the agent-sessions namespace exactly
+/// like `agent-exec` — the runner's own dispatch predicate is what makes it
+/// a cheap no-op except when a session is `planning`, carries a prompt,
+/// and has no plan leaf yet. `toolchains` and `run` are a deployment's own
+/// choice, exactly as [`agent_exec`]'s own doc explains for its two fixed
+/// fields.
+///
+/// # Examples
+///
+/// ```
+/// use ents_effect::definition::{agent_plan, validate};
+///
+/// let effect = agent_plan(vec!["agent-runtime".to_owned()], "git-ents agent-plan draft");
+/// assert_eq!(effect.name, "agent-plan");
+/// validate(&effect).expect("the canonical trigger validates");
+/// ```
+#[must_use]
+pub fn agent_plan(toolchains: Vec<String>, run: impl Into<String>) -> Effect {
+ Effect {
+ name: AGENT_PLAN_NAME.to_owned(),
+ trigger: AGENT_PLAN_TRIGGER.to_owned(),
+ toolchains,
+ run: run.into(),
+ }
+}
+
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used, reason = "unit test")]
@@ -181,4 +225,33 @@
refs/meta/index/*)"
);
}
+
+ // ---- The canonical `agent-plan` definition
+ // (`docs/agent-sessions-plan.adoc`'s Phase 4) ----
+
+ #[rstest]
+ // @relation(query.grammar, scope=function, role=Verifies)
+ fn agent_plan_trigger_parses_against_the_real_query_grammar() {
+ AGENT_PLAN_TRIGGER
+ .parse::<ents_query::Query>()
+ .expect("the canonical agent-plan trigger parses");
+ }
+
+ #[rstest]
+ // @relation(effect.validation, query.meta, scope=function, role=Verifies)
+ fn agent_plan_definition_validates() {
+ let effect = agent_plan(
+ vec!["agent-runtime".to_owned()],
+ "git-ents agent-plan draft",
+ );
+ assert_eq!(effect.name, AGENT_PLAN_NAME);
+ validate(&effect).expect("the canonical agent-plan definition validates");
+ }
+
+ #[rstest]
+ // @relation(scope=function, role=Verifies)
+ fn agent_plan_and_agent_exec_share_a_trigger_but_not_a_name() {
+ assert_eq!(AGENT_PLAN_TRIGGER, AGENT_EXEC_TRIGGER);
+ assert_ne!(AGENT_PLAN_NAME, AGENT_EXEC_NAME);
+ }
}
crates/cli/ents-web/src/assets/ents.js
@@ -220,3 +220,116 @@
cell.appendChild(button);
});
})();
+
+/*
+ * Progressive enhancement for the agent-sessions planning-chat page
+ * (`crate::pages::agent_chat`, `docs/agent-sessions-plan.adoc`'s Phase 4):
+ * intercept the composer's (`form[data-agent-chat]`) submit and stream the
+ * assistant's reply into the transcript instead of a full-page POST and
+ * redirect. The form's own `action`/`method` already work as a plain POST
+ * with no script at all (`crate::pages::agent_chat::send`'s own doc); this
+ * only asks that same route for a streamed, `Accept: text/event-stream`
+ * response instead, via `fetch` (unlike `EventSource`, `fetch` can read a
+ * streamed body from a POST, so no second route is needed just to stream
+ * from -- see that handler's own doc for why a mutating `GET` route was
+ * deliberately rejected).
+ */
+(function () {
+ "use strict";
+
+ var form = document.querySelector("form[data-agent-chat]");
+ var thread = document.querySelector("[data-chat-thread]");
+ if (!form || !thread || typeof window.fetch !== "function") {
+ return;
+ }
+ var input = form.querySelector('textarea[name="message"]');
+
+ function appendTurn(role, text) {
+ var row = document.createElement("div");
+ row.className = "chat-turn chat-" + role;
+ var roleEl = document.createElement("span");
+ roleEl.className = "chat-role";
+ roleEl.textContent = role;
+ var textEl = document.createElement("p");
+ textEl.textContent = text;
+ row.appendChild(roleEl);
+ row.appendChild(textEl);
+ thread.appendChild(row);
+ }
+
+ // A minimal SSE frame parser over a `fetch` response body: `\n\n`
+ // separates events, `field: value` lines within one. Only the two
+ // fields this page's own server side ever sends (`event`, `data`) are
+ // recognized; anything else is ignored rather than failing the stream.
+ function parseFrame(frame) {
+ var event = "message";
+ var data = "";
+ frame.split("\n").forEach(function (line) {
+ if (line.indexOf("event:") === 0) {
+ event = line.slice(6).trim();
+ } else if (line.indexOf("data:") === 0) {
+ data += line.slice(5).trim();
+ }
+ });
+ return { event: event, data: data };
+ }
+
+ form.addEventListener("submit", function (event) {
+ event.preventDefault();
+ var message = input ? input.value : "";
+ if (!message) {
+ return;
+ }
+ appendTurn("user", message);
+ if (input) {
+ input.value = "";
+ }
+ var body = new URLSearchParams();
+ Array.prototype.forEach.call(new FormData(form).entries(), function (entry) {
+ body.append(entry[0], entry[1]);
+ });
+
+ fetch(form.getAttribute("action"), {
+ method: "POST",
+ credentials: "same-origin",
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ Accept: "text/event-stream",
+ },
+ body: body.toString(),
+ })
+ .then(function (response) {
+ if (!response.body) {
+ return response.text().then(function () {
+ window.location.reload();
+ });
+ }
+ var reader = response.body.getReader();
+ var decoder = new TextDecoder();
+ var buffer = "";
+ function pump() {
+ return reader.read().then(function (chunk) {
+ if (chunk.done) {
+ return;
+ }
+ buffer += decoder.decode(chunk.value, { stream: true });
+ var frames = buffer.split("\n\n");
+ buffer = frames.pop();
+ frames.forEach(function (raw) {
+ var parsed = parseFrame(raw);
+ if (parsed.event === "message" && parsed.data) {
+ appendTurn("assistant", parsed.data);
+ }
+ });
+ return pump();
+ });
+ }
+ return pump();
+ })
+ .catch(function () {
+ // Best-effort enhancement: a network failure mid-stream leaves the
+ // user's own turn visible above, already appended server-side by
+ // the request that just failed to finish streaming its response.
+ });
+ });
+})();
crates/cli/ents-web/src/pages/mod.rs
@@ -24,6 +24,7 @@
//! `.wb-bar`'s own `.palette` search form rather than any rail item.
pub mod account;
+pub mod agent_chat;
pub mod agents;
pub mod comments;
pub mod commits;
crates/forge/ents-forge/src/agent/command.rs
@@ -207,6 +207,108 @@
)?)
}
+/// The web planning-chat page's explicit un-queue action
+/// (`docs/agent-sessions-plan.adoc`'s resolved-by-default item 1, and
+/// Phase 4's "Iteration" bullet: "from `ready`, reopening chat or
+/// requesting a redraft returns to `planning`"): return a `Ready` session
+/// to `Planning`, dropping any confirm it carries — the same drop
+/// [`revise_plan`] performs as a side effect of a plan-text change, offered
+/// here on its own for a member who wants to resume the planning
+/// conversation before having redrafted anything.
+///
+/// # Errors
+///
+/// [`Error::NotFound`] if `id` has no session ref; [`Error::InvalidArgument`]
+/// if the session is not `Ready` — still `Planning` has nothing to reopen,
+/// and `Running`, `Done`, or `Failed` are past the point of no return;
+/// otherwise propagates serialization or `receive` failures.
+// @relation(lens.parity, scope=function)
+pub fn reopen(
+ refs: &dyn RefStore,
+ objects: &(impl Find + Write),
+ events: &dyn ents_receive::EventSink,
+ id: &str,
+ identity: &Identity<'_>,
+ mode: Mode,
+) -> Result<Outcome> {
+ let mut session = session_at(refs, objects, id)?;
+ if session.meta.status != Status::Ready {
+ return Err(Error::InvalidArgument(format!(
+ "agent session {id} is not ready; only a ready session may be reopened for planning"
+ )));
+ }
+ session.meta.status = Status::Planning;
+ session.confirm = None;
+
+ let ref_name = ents_model::namespace::agent_session_ref(id)?;
+ Ok(propose_entity(
+ refs,
+ objects,
+ events,
+ ref_name,
+ &session,
+ identity,
+ &format!("Reopen agent session {id} for planning"),
+ mode,
+ )?)
+}
+
+/// The web planning-chat page's message endpoint: append `blobs` — one
+/// opaque chat turn each, exactly like the prompt turn [`new`] seeds — to
+/// `id`'s `thread`, touching neither `plan`, `confirm`, nor
+/// `meta.status`.
+///
+/// A session past the point where planning conversation may still mutate
+/// it refuses outright rather than silently un-queueing it
+/// (`docs/agent-sessions-plan.adoc`'s Phase 4 acceptance: "after confirm,
+/// no endpoint accepts messages or revisions without the explicit
+/// un-queue" — [`reopen`] and [`revise_plan`] are that explicit un-queue;
+/// this function is deliberately not one).
+///
+/// # Errors
+///
+/// [`Error::NotFound`] if `id` has no session ref; [`Error::InvalidArgument`]
+/// if the session is [`AgentSession::queued`], `Running`, `Done`, or
+/// `Failed` — only `Planning`, or `Ready` while still
+/// [`AgentSession::awaiting_confirmation`], accepts a new turn; otherwise
+/// propagates serialization or `receive` failures.
+// @relation(lens.parity, scope=function)
+pub fn append_thread(
+ refs: &dyn RefStore,
+ objects: &(impl Find + Write),
+ events: &dyn ents_receive::EventSink,
+ id: &str,
+ blobs: Vec<Vec<u8>>,
+ identity: &Identity<'_>,
+ mode: Mode,
+) -> Result<Outcome> {
+ let mut session = session_at(refs, objects, id)?;
+ let chattable = match session.meta.status {
+ Status::Planning => true,
+ Status::Ready => session.awaiting_confirmation(),
+ Status::Running | Status::Done | Status::Failed(_) => false,
+ };
+ if !chattable {
+ return Err(Error::InvalidArgument(format!(
+ "agent session {id} is queued, running, or terminal; a chat message may not mutate \
+ it without an explicit un-queue"
+ )));
+ }
+ session.thread.extend(blobs);
+
+ let ref_name = ents_model::namespace::agent_session_ref(id)?;
+ Ok(propose_entity(
+ refs,
+ objects,
+ events,
+ ref_name,
+ &session,
+ identity,
+ &format!("Append chat turn(s) to agent session {id}"),
+ mode,
+ )?)
+}
+
/// `git ents agent confirm`: record a [`Confirm`] binding `id`'s current
/// plan hash, resolving the review policy to `review_policy` when given, or
/// to [`SessionMeta::review_policy`] otherwise.
@@ -214,9 +316,11 @@
/// # Errors
///
/// [`Error::NotFound`] if `id` has no session ref; [`Error::InvalidArgument`]
-/// if the session is not `Ready`, or has no plan to confirm (a confirm can
-/// never bind an absent plan leaf); otherwise propagates serialization or
-/// `receive` failures.
+/// if the session is not `Ready`, or has no plan to confirm, or its plan is
+/// empty or all-whitespace (`docs/agent-sessions-plan.adoc`'s Phase 4
+/// acceptance, "no confirm can bind an empty or absent plan leaf" — a
+/// confirm can never bind an absent, or effectively absent, plan leaf);
+/// otherwise propagates serialization or `receive` failures.
// @relation(lens.parity, scope=function)
pub fn confirm(
refs: &dyn RefStore,
@@ -233,6 +337,16 @@
"agent session {id} is not ready to confirm"
)));
}
+ let plan_is_bindable = session
+ .plan
+ .as_deref()
+ .is_some_and(|text| !text.trim().is_empty());
+ if !plan_is_bindable {
+ return Err(Error::InvalidArgument(format!(
+ "agent session {id} has no plan to confirm; a confirm may not bind an empty or \
+ absent plan leaf"
+ )));
+ }
let Some(hash) = session.plan_hash() else {
return Err(Error::InvalidArgument(format!(
"agent session {id} has no plan to confirm"
@@ -445,6 +559,87 @@
)?)
}
+/// The `agent-plan` effect's own commit (`docs/agent-sessions-plan.adoc`'s
+/// Phase 4, "headless plan drafting ... commits the plan leaf and
+/// transitions to `ready`"): like [`revise_plan`], but atomically appending
+/// the drafting run's own transcript to `thread` in the same commit, and
+/// requiring the session still be exactly `Planning` — the runner's own
+/// dispatch precondition ([`super::dispatch_plan`]) — rather than
+/// [`revise_plan`]'s looser `Planning`-or-`Ready`: a session a human has
+/// already moved to `Ready` by hand raced ahead of this draft, and this
+/// function refuses rather than clobbering it.
+///
+/// # Errors
+///
+/// [`Error::NotFound`] if `id` has no session ref; [`Error::InvalidArgument`]
+/// if the session is not `Planning`; otherwise propagates serialization or
+/// `receive` failures.
+// @relation(lens.parity, scope=function)
+#[expect(
+ clippy::too_many_arguments,
+ reason = "one field per draft input plus the ordinary refs/objects/events/identity/mode \
+ quintet every mutation command in this module takes"
+)]
+pub fn draft_plan(
+ refs: &dyn RefStore,
+ objects: &(impl Find + Write),
+ events: &dyn ents_receive::EventSink,
+ id: &str,
+ plan: String,
+ transcript: Vec<Vec<u8>>,
+ identity: &Identity<'_>,
+ mode: Mode,
+) -> Result<Outcome> {
+ let (transition, tip) = draft_plan_transition(refs, objects, id, plan, transcript, identity)?;
+ let proposal = ents_receive::Proposal {
+ transitions: vec![transition],
+ objects: vec![tip],
+ auth: None,
+ };
+ Ok(ents_receive::receive(
+ refs, objects, events, &proposal, mode,
+ )?)
+}
+
+/// Build (but do not send) the [`draft_plan`] transition — the seam
+/// `git-ents`'s `agent-plan` effect handler uses to land the session's
+/// draft alongside its own results record in one atomic
+/// [`ents_receive::Proposal`], exactly as [`finish_transition`] does for
+/// `agent-exec`'s finalize.
+///
+/// # Errors
+///
+/// See [`draft_plan`] — identical.
+pub fn draft_plan_transition(
+ refs: &dyn RefStore,
+ objects: &(impl Find + Write),
+ id: &str,
+ plan: String,
+ transcript: Vec<Vec<u8>>,
+ identity: &Identity<'_>,
+) -> Result<(ents_receive::RefTransition, ObjectId)> {
+ let mut session = session_at(refs, objects, id)?;
+ if session.meta.status != Status::Planning {
+ return Err(Error::InvalidArgument(format!(
+ "agent session {id} is not planning; only a planning session may be auto-drafted"
+ )));
+ }
+ session.plan = Some(plan);
+ session.confirm = None;
+ session.meta.status = Status::Ready;
+ session.thread.extend(transcript);
+
+ let ref_name = ents_model::namespace::agent_session_ref(id)?;
+ Ok(ents_receive::entity_transition(
+ refs,
+ objects,
+ &ref_name,
+ &session,
+ identity,
+ &format!("Draft plan for agent session {id}"),
+ )?)
+}
+
/// `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/dispatch.rs
@@ -14,7 +14,7 @@
//! 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;
+use super::{AgentSession, Status};
/// What a dequeued `(agent-exec, oid)` pair resolves to once the runner
/// reads the agent session tip at `oid`.
@@ -63,6 +63,62 @@
}
}
+/// What a dequeued `(agent-plan, oid)` pair resolves to once the runner
+/// reads the agent session tip at `oid`
+/// (`docs/agent-sessions-plan.adoc`'s Phase 4): headless plan drafting
+/// fires iff the session is [`Status::Planning`], carries a prompt (a
+/// non-empty [`AgentSession::thread`], seeded by [`super::command::new`]),
+/// and has no plan leaf yet ([`AgentSession::plan`] is `None`) — everything
+/// else, including a session already `Ready` awaiting confirmation, one
+/// already running, or one that is `Planning` but has no prompt at all
+/// (unreachable through [`super::command::new`], but not through this
+/// predicate), is a cheap `pass` no-op.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum PlanDispatch {
+ /// The tip is `Planning`, has a prompt, and has no plan yet: the
+ /// runner should draft one ([`super::command::draft_plan`]).
+ Draft,
+ /// Anything else: a cheap `pass` no-op, no state change.
+ NoOp,
+}
+
+/// Decide [`PlanDispatch`] for `session`'s current tip.
+///
+/// # Examples
+///
+/// ```
+/// use ents_forge::agent::{PlanDispatch, ReviewPolicy, SessionMeta, dispatch_plan};
+/// use ents_model::MemberId;
+///
+/// let mut session = ents_forge::agent::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_plan(&session), PlanDispatch::NoOp, "no prompt yet");
+///
+/// session.thread.push(b"fix the flaky test".to_vec());
+/// assert_eq!(dispatch_plan(&session), PlanDispatch::Draft);
+///
+/// session.plan = Some("do the thing".to_owned());
+/// assert_eq!(dispatch_plan(&session), PlanDispatch::NoOp, "already has a plan");
+/// ```
+#[must_use]
+pub fn dispatch_plan(session: &AgentSession) -> PlanDispatch {
+ if session.meta.status == Status::Planning
+ && !session.thread.is_empty()
+ && session.plan.is_none()
+ {
+ PlanDispatch::Draft
+ } else {
+ PlanDispatch::NoOp
+ }
+}
+
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used, reason = "unit test")]
@@ -70,7 +126,7 @@
use rstest::rstest;
use super::*;
- use crate::agent::{Confirm, FailureReason, ReviewPolicy, SessionMeta, Status};
+ use crate::agent::{Confirm, FailureReason, ReviewPolicy, SessionMeta};
fn session(status: Status, plan: Option<&str>, confirm: Option<Confirm>) -> AgentSession {
let mut meta = SessionMeta::new(
@@ -147,4 +203,46 @@
assert!(session.awaiting_confirmation());
assert_eq!(dispatch(&session), Dispatch::NoOp);
}
+
+ // ---------------------------------------------------------------
+ // `dispatch_plan`: `docs/agent-sessions-plan.adoc`'s Phase 4.
+ // ---------------------------------------------------------------
+
+ #[rstest]
+ // @relation(scope=function, role=Verifies)
+ fn dispatch_plan_drafts_a_planning_session_with_a_prompt_and_no_plan() {
+ let mut planning_with_prompt = session(Status::Planning, None, None);
+ planning_with_prompt
+ .thread
+ .push(b"fix the flaky test".to_vec());
+ assert_eq!(dispatch_plan(&planning_with_prompt), PlanDispatch::Draft);
+ }
+
+ #[rstest]
+ // @relation(scope=function, role=Verifies)
+ fn dispatch_plan_is_no_op_with_no_prompt_at_all() {
+ let planning_no_prompt = session(Status::Planning, None, None);
+ assert!(planning_no_prompt.thread.is_empty());
+ assert_eq!(dispatch_plan(&planning_no_prompt), PlanDispatch::NoOp);
+ }
+
+ #[rstest]
+ #[case::ready(Status::Ready)]
+ #[case::running(Status::Running)]
+ #[case::done(Status::Done)]
+ #[case::failed(Status::Failed(FailureReason { detail: "oops".to_owned() }))]
+ // @relation(scope=function, role=Verifies)
+ fn dispatch_plan_is_no_op_outside_planning(#[case] status: Status) {
+ let mut with_prompt = session(status, None, None);
+ with_prompt.thread.push(b"fix the flaky test".to_vec());
+ assert_eq!(dispatch_plan(&with_prompt), PlanDispatch::NoOp);
+ }
+
+ #[rstest]
+ // @relation(scope=function, role=Verifies)
+ fn dispatch_plan_is_no_op_once_a_plan_already_exists() {
+ let mut already_planned = session(Status::Planning, Some("do the thing"), None);
+ already_planned.thread.push(b"fix the flaky test".to_vec());
+ assert_eq!(dispatch_plan(&already_planned), PlanDispatch::NoOp);
+ }
}
crates/cli/ents-web/src/pages/agent_chat.rs
@@ -1,0 +1,402 @@
+//! `GET /agents/{id}/chat`, `POST /agents/{id}/chat`, `POST
+//! /agents/{id}/plan`, `POST /agents/{id}/reopen`: the laptop
+//! planning-chat page (`docs/agent-sessions-plan.adoc`'s Phase 4) — linked
+//! from `crate::pages::agents::show` whenever a session is `planning` or
+//! `ready`.
+//!
+//! Every read is `ents_forge::agent::show`; every mutation is
+//! `ents_forge::agent::{append_thread, revise_plan, reopen}` — the same
+//! `lens.parity` contract `crate::pages::agents` follows, and the same
+//! commands enforce this page's own enforcement rules (`append_thread`
+//! refuses a queued/running/terminal session; `revise_plan` drops a stale
+//! confirm) rather than this module re-implementing them. The actual LLM
+//! call goes through `crate::planner::Planner`, injected via `AppState`;
+//! this module never talks to a model directly (per-member credentials are
+//! Phase 6's own scope).
+//!
+//! SSE is a page-level concern here alone
+//! (`docs/agent-sessions-plan.adoc`'s Phase 4 acceptance): [`send`]'s own
+//! doc explains why the streamed reply rides back over the *same* `POST
+//! /agents/{id}/chat` route (content-negotiated on `Accept`) rather than a
+//! second `GET` route — this crate's only use of SSE anywhere, feeding
+//! this page's own progressive-enhancement script (`crate::assets::SCRIPT`'s
+//! agent-chat block).
+
+use std::sync::Arc;
+
+use axum::Form;
+use axum::extract::{Path, State};
+use axum::response::sse::{Event, KeepAlive, Sse};
+use axum::response::{IntoResponse, Redirect};
+use ents_forge::agent::{self, AgentSession, Status as SessionStatus};
+use gix_object::{Find, Write};
+use maud::{Markup, html};
+use serde::Deserialize;
+
+use crate::error::Result;
+use crate::session::Session;
+use crate::state::AppState;
+
+/// One rendered turn in the chat transcript, decoded from an opaque
+/// `AgentSession::thread` blob by this page's own convention (see
+/// [`encode_turn`]/[`decode_turn`]) — never by `ents_forge::agent` itself,
+/// which keeps treating `thread` as write-only audit material everywhere
+/// else in this crate (`crate::pages::agents`'s own doc: "Thread blobs are
+/// never rendered anywhere ... only counted"). This page is the one place
+/// that convention is deliberately different: it is the very page that
+/// wrote those blobs, in a format only it needs to understand.
+struct Turn {
+ /// `"prompt"` for the session's own seeded first turn, `"user"` or
+ /// `"assistant"` for a chat exchange this page appended, or `"note"`
+ /// for any other blob (a hand-written `git ents agent finish`
+ /// transcript, say) this page did not itself write.
+ role: &'static str,
+ /// The turn's own text, with this page's own role prefix (if any)
+ /// stripped.
+ text: String,
+}
+
+/// This page's own encoding for a chat turn appended to `thread`: a
+/// `"<role>: "` prefix over otherwise-plain UTF-8 text.
+fn encode_turn(role: &str, text: &str) -> Vec<u8> {
+ format!("{role}: {text}").into_bytes()
+}
+
+/// Decode one `thread` blob at `index` into a [`Turn`] for display (see
+/// [`Turn`]'s own doc for the role rules).
+fn decode_turn(index: usize, blob: &[u8]) -> Turn {
+ let text = String::from_utf8_lossy(blob).into_owned();
+ if index == 0 {
+ return Turn {
+ role: "prompt",
+ text,
+ };
+ }
+ if let Some(rest) = text.strip_prefix("user: ") {
+ return Turn {
+ role: "user",
+ text: rest.to_owned(),
+ };
+ }
+ if let Some(rest) = text.strip_prefix("assistant: ") {
+ return Turn {
+ role: "assistant",
+ text: rest.to_owned(),
+ };
+ }
+ Turn { role: "note", text }
+}
+
+/// What this page renders below the transcript, derived from the
+/// session's own state — mirrors `ents_forge::agent::append_thread`'s own
+/// precondition exactly (this is a rendering decision, not a second
+/// enforcement point; the command itself is what actually refuses an
+/// illegal mutation).
+enum ChatMode {
+ /// `planning`, or `ready`-and-awaiting-confirmation: the composer and
+ /// the plan editor both render.
+ Compose,
+ /// `ready`-and-queued: chatting or redrafting first requires the
+ /// explicit un-queue (`POST /agents/{id}/reopen`).
+ Queued,
+ /// `running`, `done`, or `failed`: past the point of no return: the
+ /// transcript is read-only.
+ Closed,
+}
+
+fn chat_mode(session: &AgentSession) -> ChatMode {
+ match session.meta.status {
+ SessionStatus::Planning => ChatMode::Compose,
+ SessionStatus::Ready if session.awaiting_confirmation() => ChatMode::Compose,
+ SessionStatus::Ready => ChatMode::Queued,
+ SessionStatus::Running | SessionStatus::Done | SessionStatus::Failed(_) => ChatMode::Closed,
+ }
+}
+
+/// `GET /agents/{id}/chat`: the planning-chat page — the transcript so
+/// far, then [`chat_mode`]'s own composer/queued-notice/closed-notice.
+///
+/// # Errors
+///
+/// Propagates [`ents_forge::agent::show`]'s own failures (including
+/// [`ents_forge::Error::NotFound`]).
+// @relation(lens.parity, scope=function)
+pub async fn show<O>(
+ State(state): State<Arc<AppState<O>>>,
+ axum::Extension(session): axum::Extension<Session>,
+ Path(id): Path<String>,
+) -> Result<Markup>
+where
+ O: Find + Write + Send + 'static,
+{
+ let agent_session = agent::show(state.refs.as_ref(), &*state.objects(), &id)?;
+ let title = format!("Planning chat \u{2014} {}", ents_forge::abbreviate_id(&id));
+ let turns: Vec<Turn> = agent_session
+ .thread
+ .iter()
+ .enumerate()
+ .map(|(index, blob)| decode_turn(index, blob))
+ .collect();
+
+ Ok(super::layout(
+ &super::RepoHeader::from_state(&state),
+ &super::identity_label(&state),
+ super::Tab::Agents,
+ &title,
+ html! {
+ (super::child_crumbs("agents", &format!("/agents/{id}"), "chat"))
+ div.readable {
+ div.card {
+ h1.commit-subject { (title) }
+ div.chat-thread data-chat-thread {
+ @for turn in &turns {
+ div.chat-turn class={ "chat-" (turn.role) } {
+ span.chat-role { (turn.role) }
+ p { (turn.text) }
+ }
+ }
+ }
+ @match chat_mode(&agent_session) {
+ ChatMode::Compose => (composer(&session, &id, agent_session.plan.as_deref())),
+ ChatMode::Queued => (queued_notice(&session, &id)),
+ ChatMode::Closed => (closed_notice()),
+ }
+ }
+ }
+ },
+ ))
+}
+
+/// The message composer (`POST /agents/{id}/chat`, JS-enhanced by
+/// `crate::assets::SCRIPT`'s agent-chat block into a streamed `fetch` of
+/// that same route — see [`send`]'s own doc) and the plan editor (`POST
+/// /agents/{id}/plan`), rendered together while [`ChatMode::Compose`]
+/// holds.
+fn composer(session: &Session, id: &str, plan: Option<&str>) -> Markup {
+ html! {
+ form.chat-composer method="post" action={ "/agents/" (id) "/chat" }
+ data-agent-chat data-csrf=(session.csrf)
+ {
+ (super::csrf_input(session))
+ label { "message" textarea name="message" {} }
+ button type="submit" { "Send" }
+ }
+ form.plan-editor method="post" action={ "/agents/" (id) "/plan" } {
+ (super::csrf_input(session))
+ label { "plan" textarea name="plan" { (plan.unwrap_or_default()) } }
+ button type="submit" { "Commit plan" }
+ }
+ }
+}
+
+/// The un-queue notice (`POST /agents/{id}/reopen`), rendered instead of
+/// the composer while [`ChatMode::Queued`] holds — chatting or redrafting
+/// a queued session first requires this explicit action
+/// (`docs/agent-sessions-plan.adoc`'s resolved-by-default item 1).
+fn queued_notice(session: &Session, id: &str) -> Markup {
+ html! {
+ p.muted {
+ "This session is confirmed and queued for execution. Reopen it to resume planning \
+ — this drops the existing confirmation and requires a fresh one before it can run."
+ }
+ form method="post" action={ "/agents/" (id) "/reopen" } {
+ (super::csrf_input(session))
+ button type="submit" { "Reopen for planning" }
+ }
+ }
+}
+
+/// The read-only notice rendered once a session is past the point of no
+/// return ([`ChatMode::Closed`]).
+fn closed_notice() -> Markup {
+ html! {
+ p.muted { "This session's planning is closed; the transcript above is read-only." }
+ }
+}
+
+/// The form fields `POST /agents/{id}/chat` accepts.
+#[derive(Debug, Deserialize)]
+pub struct ChatForm {
+ /// The member's message.
+ message: String,
+ /// The per-session CSRF token (`roots.web-session`).
+ csrf: String,
+}
+
+/// Append `message` as a user turn and `state.planner`'s reply to it as an
+/// assistant turn, in one atomic commit
+/// (`ents_forge::agent::append_thread`) — the one mutation [`send`]
+/// performs, before it renders either of its two response shapes.
+///
+/// # Errors
+///
+/// Propagates [`ents_forge::agent::show`]'s and
+/// [`ents_forge::agent::append_thread`]'s own failures (including the
+/// queued/running/terminal refusal — `docs/agent-sessions-plan.adoc`'s
+/// Phase 4 acceptance: "after confirm, no endpoint accepts messages ...
+/// without the explicit un-queue").
+fn reply_and_append<O>(
+ state: &AppState<O>,
+ session: &Session,
+ id: &str,
+ message: &str,
+) -> Result<String>
+where
+ O: Find + Write,
+{
+ let agent_session = agent::show(state.refs.as_ref(), &*state.objects(), id)?;
+ let reply = state.planner.reply(&agent_session, message);
+ let identity = state.identity.as_ref();
+ let outcome = agent::append_thread(
+ state.refs.as_ref(),
+ &*state.objects(),
+ state.events.as_ref(),
+ id,
+ vec![
+ encode_turn("user", message),
+ encode_turn("assistant", &reply),
+ ],
+ &crate::receive_identity!(identity, super::member_author(session)),
+ state.mode,
+ )?;
+ crate::error::outcome_to_result(outcome)?;
+ Ok(reply)
+}
+
+/// `POST /agents/{id}/chat`: append the turn pair synchronously
+/// ([`reply_and_append`]), then respond one of two shapes from the exact
+/// same, single, CSRF-checked, auth-gated `POST` — deliberately not a
+/// second, unauthenticated `GET` route, since
+/// `crate::router::auth_middleware`'s own sign-in-required policy assumes
+/// "every mutation in this crate is a `POST`" and gates exactly that
+/// method; a `GET` that also mutated would silently bypass it.
+///
+/// * A plain form submission (no `Accept: text/event-stream`) gets an
+/// ordinary redirect back to the chat page — the no-JS fallback that
+/// works with no script at all.
+/// * `crate::assets::SCRIPT`'s agent-chat block instead issues this same
+/// `POST` via `fetch` with that `Accept` header, and reads back an SSE
+/// response — one `message` event carrying the assistant's reply
+/// (`docs/agent-sessions-plan.adoc`'s Phase 4: "SSE as a page-level
+/// concern"), then a `done` event closing the stream. `fetch` (unlike
+/// `EventSource`) can read a streamed body from any method, so this
+/// needs no second route to stream from.
+///
+/// # Errors
+///
+/// [`crate::Error::BadCsrf`] if `form.csrf` does not match; otherwise see
+/// [`reply_and_append`].
+// @relation(roots.web-signing, roots.web-session, lens.parity, scope=function)
+pub async fn send<O>(
+ State(state): State<Arc<AppState<O>>>,
+ axum::Extension(session): axum::Extension<Session>,
+ Path(id): Path<String>,
+ headers: axum::http::HeaderMap,
+ Form(form): Form<ChatForm>,
+) -> Result<axum::response::Response>
+where
+ O: Find + Write + Send + 'static,
+{
+ super::require_csrf(&session, &form.csrf)?;
+ let reply = reply_and_append(&state, &session, &id, &form.message)?;
+
+ let wants_stream = headers
+ .get(axum::http::header::ACCEPT)
+ .and_then(|value| value.to_str().ok())
+ .is_some_and(|accept| accept.contains("text/event-stream"));
+ if wants_stream {
+ let events: Vec<std::result::Result<Event, std::convert::Infallible>> = vec![
+ Ok(Event::default().data(reply)),
+ Ok(Event::default().event("done").data("")),
+ ];
+ return Ok(Sse::new(futures_util::stream::iter(events))
+ .keep_alive(KeepAlive::default())
+ .into_response());
+ }
+ Ok(Redirect::to(&format!("/agents/{id}/chat")).into_response())
+}
+
+/// The plan editor's own form fields (`POST /agents/{id}/plan`).
+#[derive(Debug, Deserialize)]
+pub struct PlanForm {
+ /// The plan text to commit.
+ plan: String,
+ /// The per-session CSRF token (`roots.web-session`).
+ csrf: String,
+}
+
+/// `POST /agents/{id}/plan`: commit `form.plan` as the session's plan
+/// (`ents_forge::agent::revise_plan`), transitioning it to `ready` and
+/// dropping any stale confirm — the same path
+/// `docs/agent-sessions-plan.adoc`'s Phase 4 names for both the laptop
+/// chat page and the mobile `agent-plan` effect.
+///
+/// # Errors
+///
+/// [`crate::Error::BadCsrf`] if `form.csrf` does not match; otherwise
+/// propagates [`ents_forge::agent::revise_plan`]'s own failures.
+// @relation(roots.web-signing, roots.web-session, lens.parity, scope=function)
+pub async fn commit_plan<O>(
+ State(state): State<Arc<AppState<O>>>,
+ axum::Extension(session): axum::Extension<Session>,
+ Path(id): Path<String>,
+ Form(form): Form<PlanForm>,
+) -> Result<impl IntoResponse>
+where
+ O: Find + Write + Send + 'static,
+{
+ super::require_csrf(&session, &form.csrf)?;
+ let identity = state.identity.as_ref();
+ let outcome = agent::revise_plan(
+ state.refs.as_ref(),
+ &*state.objects(),
+ state.events.as_ref(),
+ &id,
+ form.plan,
+ &crate::receive_identity!(identity, super::member_author(&session)),
+ state.mode,
+ )?;
+ crate::error::outcome_to_result(outcome)?;
+ Ok(Redirect::to(&format!("/agents/{id}")))
+}
+
+/// The reopen action's own form fields (`POST /agents/{id}/reopen`).
+#[derive(Debug, Deserialize)]
+pub struct ReopenForm {
+ /// The per-session CSRF token (`roots.web-session`).
+ csrf: String,
+}
+
+/// `POST /agents/{id}/reopen`: the explicit un-queue
+/// (`ents_forge::agent::reopen`) — return a queued session to `planning`,
+/// dropping its confirm, then back to the chat page to resume the
+/// conversation.
+///
+/// # Errors
+///
+/// [`crate::Error::BadCsrf`] if `form.csrf` does not match; otherwise
+/// propagates [`ents_forge::agent::reopen`]'s own failures (including the
+/// "not ready" precondition miss).
+// @relation(roots.web-signing, roots.web-session, lens.parity, scope=function)
+pub async fn reopen<O>(
+ State(state): State<Arc<AppState<O>>>,
+ axum::Extension(session): axum::Extension<Session>,
+ Path(id): Path<String>,
+ Form(form): Form<ReopenForm>,
+) -> Result<impl IntoResponse>
+where
+ O: Find + Write + Send + 'static,
+{
+ super::require_csrf(&session, &form.csrf)?;
+ let identity = state.identity.as_ref();
+ let outcome = agent::reopen(
+ state.refs.as_ref(),
+ &*state.objects(),
+ state.events.as_ref(),
+ &id,
+ &crate::receive_identity!(identity, super::member_author(&session)),
+ state.mode,
+ )?;
+ crate::error::outcome_to_result(outcome)?;
+ Ok(Redirect::to(&format!("/agents/{id}/chat")))
+}
crates/cli/ents-web/src/planner.rs
@@ -1,0 +1,104 @@
+//! The planning-chat page's LLM seam (`docs/agent-sessions-plan.adoc`'s
+//! Phase 4, "Plan-mode Agent SDK with the member's credential"): a small
+//! trait an assistant turn is generated through, injected via
+//! [`crate::state::AppState`] exactly like
+//! [`crate::identity::SigningIdentity`] is (`roots.web-agnostic`) — a
+//! composition root wires whichever implementation its deployment can
+//! offer; nothing in [`crate::pages`] loads a credential or calls out to a
+//! model directly.
+//!
+//! Per-member credentials (BYOK) are `docs/agent-sessions-plan.adoc`'s
+//! Phase 6, explicitly out of scope here — this crate ships only
+//! [`UnconfiguredPlanner`], the default every composition root installs
+//! until a real one exists. A future real implementation lives outside
+//! this crate (wherever a member's credential is resolved) and is injected
+//! the same way [`crate::identity::SigningIdentity`]'s real
+//! implementations are.
+
+use ents_forge::agent::AgentSession;
+
+/// Produce the assistant's next reply inside a session's ongoing planning
+/// conversation.
+///
+/// Read-only by design: a [`Planner`] never mutates a session itself — the
+/// planning-chat page's own handler is what appends the resulting turns to
+/// `thread` (`ents_forge::agent::append_thread`) and, separately, commits
+/// any drafted plan text the member asks to keep
+/// (`ents_forge::agent::revise_plan`).
+///
+/// # Examples
+///
+/// ```
+/// use ents_forge::agent::{AgentSession, ReviewPolicy, SessionMeta};
+/// use ents_model::MemberId;
+/// use ents_web::planner::{Planner, UnconfiguredPlanner};
+///
+/// let session = AgentSession {
+/// meta: SessionMeta::new(
+/// MemberId::new("jdc"), 1_000, "claude-sonnet-5", vec![],
+/// "refs/heads/main", ReviewPolicy::Manual, None,
+/// ),
+/// plan: None,
+/// confirm: None,
+/// thread: vec![b"fix the flaky test".to_vec()],
+/// };
+/// let planner = UnconfiguredPlanner;
+/// assert!(!planner.reply(&session, "what's the plan?").is_empty());
+/// ```
+pub trait Planner: Send + Sync {
+ /// Reply to `message`, given `session`'s current state (its seeded
+ /// prompt, prior thread turns, and any plan already drafted) for
+ /// context.
+ fn reply(&self, session: &AgentSession, message: &str) -> String;
+}
+
+/// The default composition root's [`Planner`]: per-member credentials are
+/// not wired in this build, so this renders one fixed, honest notice
+/// instead of ever calling out to a real model — the chat page renders its
+/// reply exactly like any other assistant turn, with no special-casing for
+/// "no backend" beyond the text itself.
+#[derive(Debug, Default, Clone, Copy)]
+pub struct UnconfiguredPlanner;
+
+impl Planner for UnconfiguredPlanner {
+ fn reply(&self, _session: &AgentSession, _message: &str) -> String {
+ "Planning backend not configured for this deployment (per-member credentials are \
+ Phase 6's own scope, not yet wired). Draft the plan text yourself below and commit it \
+ with \u{201c}Commit plan\u{201d}, or run `git ents agent plan` from the command line."
+ .to_owned()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use rstest::rstest;
+
+ use super::*;
+
+ fn session() -> AgentSession {
+ AgentSession {
+ meta: ents_forge::agent::SessionMeta::new(
+ ents_model::MemberId::new("jdc"),
+ 1_000,
+ "claude-sonnet-5",
+ vec![],
+ "refs/heads/main",
+ ents_forge::agent::ReviewPolicy::Manual,
+ None,
+ ),
+ plan: None,
+ confirm: None,
+ thread: vec![b"fix the flaky test".to_vec()],
+ }
+ }
+
+ #[rstest]
+ // @relation(scope=function, role=Verifies)
+ fn unconfigured_planner_replies_with_a_fixed_notice_regardless_of_input() {
+ let planner = UnconfiguredPlanner;
+ let first = planner.reply(&session(), "what's the plan?");
+ let second = planner.reply(&session(), "a completely different message");
+ assert_eq!(first, second);
+ assert!(first.to_lowercase().contains("not configured"));
+ }
+}
crates/cli/git-ents/src/plan_worker.rs
@@ -1,0 +1,814 @@
+//! The `agent-plan` effect's run path (`docs/agent-sessions-plan.adoc`'s
+//! Phase 4): headless plan drafting for a `planning` session that carries a
+//! prompt and no plan yet, landing the drafted plan and its own results
+//! record atomically.
+//!
+//! # Why this lives here, not in `ents-effect` or `ents-forge`
+//!
+//! Exactly [`crate::agent_worker`]'s own reasoning: this needs both
+//! [`ents_forge::agent`]'s typed session commands and
+//! [`ents_effect::Executor`]'s sandbox seam at once, which neither kernel
+//! crate may depend on the other to provide (`ents-effect`'s `Cargo.toml`
+//! links exactly `ents-model`, `ents-query`, and `ents-receive`; `ents-forge`
+//! is not among them, by design, from both sides — see
+//! `ents_forge::agent::dispatch`'s own doc). `git-ents` already depends on
+//! both, so this is the same "session handler" composition-root seam
+//! [`crate::hook::post_receive`] installs [`crate::agent_worker`] for,
+//! installed here for the one other effect name
+//! ([`AGENT_PLAN_NAME`]) that needs bespoke handling.
+//!
+//! # No claim, unlike `agent-exec`
+//!
+//! Drafting a plan is read-only context gathering against the declared base
+//! ref, never a mutation of anything but the session's own `plan`/`thread` —
+//! there is nothing here for a claim to protect (`docs/agent-sessions-plan.adoc`'s
+//! Phase 4: "claim is NOT needed here"). Two workers racing to draft the
+//! same session concurrently are serialized by `receive`'s own compare-
+//! and-swap on the session ref (`receive.refstore-seam`'s atomic write
+//! step): the loser's [`ents_forge::agent::draft_plan_transition`] proposal
+//! comes back as anything but [`TxResult::Applied`], and this module
+//! surfaces that exactly like [`crate::agent_worker::run_agent_exec`]'s own
+//! lost-claim race — a cheap `pass` recorded to discharge the obligation,
+//! no error, no retry of this same dequeued oid.
+//!
+//! # How the prompt reaches the sandbox, and the draft comes back
+//!
+//! Mirrors [`crate::agent_worker`]'s own sideband-file convention: the
+//! session's seeded prompt (`AgentSession::thread`'s first turn) is written
+//! to [`AGENT_PROMPT_FILE`] in the checked-out workdir before
+//! [`ents_effect::Executor::run`] is called; the declared command is
+//! expected to write its drafted plan text to [`AGENT_PLAN_DRAFT_FILE`] in
+//! that same workdir, read back once the command completes. Unlike
+//! `agent-exec`, no output tree is ever captured from the workdir — plan
+//! drafting is read-only context gathering, not a code change, so there is
+//! no result branch to push and nothing to clean up before an object write
+//! that never happens.
+
+use std::path::{Path, PathBuf};
+
+use ents_effect::executor::SandboxInputs;
+use ents_effect::run::short_oid;
+use ents_effect::{Executor, RunStatus};
+use ents_forge::agent::{AgentSession, PlanDispatch, dispatch_plan};
+use ents_model::{ResultRecord, Status as ResultStatus};
+use ents_receive::{EventSink, Identity, Mode, Outcome, Proposal, TxResult};
+use gix_hash::ObjectId;
+use gix_object::{CommitRef, Find, Kind, Write};
+use gix_ref_store::RefStore;
+
+use crate::commands::commit_tree;
+use crate::error::{Error, Result};
+
+/// `agent-plan`'s own effect name — re-exported so a caller deciding which
+/// bespoke handler a dequeued `(effect, oid)` obligation belongs to does
+/// not need a second import of `ents_effect::definition`.
+pub const AGENT_PLAN_NAME: &str = ents_effect::definition::AGENT_PLAN_NAME;
+
+/// The file the session's seeded prompt is written to inside the
+/// checked-out workdir before the drafting command runs (see this module's
+/// own doc).
+pub const AGENT_PROMPT_FILE: &str = ".git-ents-agent-prompt.txt";
+
+/// The file the drafting command is expected to write its drafted plan
+/// text to, read back once it completes.
+pub const AGENT_PLAN_DRAFT_FILE: &str = ".git-ents-agent-plan-draft.txt";
+
+/// What running the `agent-plan` effect against one dequeued `(effect,
+/// oid)` obligation did.
+#[derive(Debug)]
+pub enum AgentPlanOutcome {
+ /// The tip was not a `planning` session with a prompt and no plan; a
+ /// cheap `pass` was recorded to discharge the obligation, no session
+ /// was touched.
+ NoOp,
+ /// The drafting command ran and reported failure (an ordinary,
+ /// completed result, not an infrastructure failure): a `fail` was
+ /// recorded on this effect's own results ref, and the session was left
+ /// untouched, still `planning` and still dispatchable on a future
+ /// commit.
+ DraftFailed,
+ /// This worker drafted a plan, but by the time its finalize proposal
+ /// reached `receive`, another worker's own draft had already landed
+ /// (`receive`'s CAS on the session ref serialized the race) — a `pass`
+ /// was recorded for the dequeued oid instead, and this worker's own
+ /// draft was discarded.
+ DraftLost,
+ /// This worker drafted the plan and landed it, atomically with this
+ /// effect's own results record.
+ Drafted {
+ /// The session's own genesis-oid id.
+ id: String,
+ /// The atomic finalize's outcome.
+ outcome: Outcome,
+ },
+}
+
+/// Run the `agent-plan` effect against the single dequeued commit `oid` —
+/// a tip entering `refs/meta/agent-sessions/*`
+/// (`ents_effect::definition::AGENT_PLAN_TRIGGER`'s own `meta()` semantics).
+///
+/// `toolchains` and `command` are this effect's own declared toolchains
+/// (already resolved to host `bin/` directories) and its declared `run`
+/// command; `scratch` is where the base tree is checked out for one run,
+/// mirroring [`crate::agent_worker::run_agent_exec`]'s identical
+/// parameters.
+///
+/// # Errors
+///
+/// Any [`Error`] from reading or decoding the session, checking out the
+/// base tree, [`Executor::run`] itself (an infrastructure failure
+/// propagates with nothing published: the session stays `planning`
+/// untouched, and the queue's own retry policy is what revisits it,
+/// `effect.result-taxonomy`), reading back the drafted plan file, or
+/// building and sending the finalize proposal.
+// @relation(effect.execution, effect.results-writeback, effect.result-taxonomy, receive.multi-ref-atomicity, scope=function)
+#[expect(
+ clippy::too_many_arguments,
+ reason = "one input per materialization/identity step, mirrors run_agent_exec's own shape"
+)]
+pub fn run_agent_plan<O>(
+ refs: &dyn RefStore,
+ objects: &O,
+ events: &dyn EventSink,
+ executor: &dyn Executor,
+ scratch: &Path,
+ toolchains: &[(String, PathBuf)],
+ command: &str,
+ oid: ObjectId,
+ author: &gix::actor::Signature,
+ sign: &dyn Fn(&[u8]) -> String,
+ mode: Mode,
+) -> Result<AgentPlanOutcome>
+where
+ O: Find + Write,
+{
+ let tree = commit_tree(objects, oid)?;
+ let session: AgentSession = facet_git_tree::deserialize(&tree, objects)?;
+ let results_ref = ents_model::namespace::result_ref(AGENT_PLAN_NAME, &short_oid(oid))?;
+
+ if dispatch_plan(&session) == PlanDispatch::NoOp {
+ record_result(
+ refs,
+ objects,
+ events,
+ &results_ref,
+ oid,
+ ResultStatus::Pass,
+ author,
+ sign,
+ mode,
+ )?;
+ return Ok(AgentPlanOutcome::NoOp);
+ }
+
+ let id = genesis_of(objects, oid)?.to_string();
+ let identity = Identity {
+ actor: author.clone(),
+ author: None,
+ sign,
+ };
+
+ // Read-only context gathering against the declared base ref: no output
+ // tree is ever captured back from this checkout (unlike `agent-exec`'s
+ // run), since drafting a plan is never a code change.
+ let base_ref: gix::refs::FullName =
+ session
+ .meta
+ .base_ref
+ .clone()
+ .try_into()
+ .map_err(|_source| {
+ Error::InvalidArgument(format!(
+ "agent session {id}'s base ref {:?} is not a well-formed refname",
+ session.meta.base_ref
+ ))
+ })?;
+ let base_tip = refs
+ .get(base_ref.as_ref())?
+ .ok_or_else(|| Error::NotFound {
+ what: session.meta.base_ref.clone(),
+ })?;
+ let base_tree = commit_tree(objects, base_tip)?;
+
+ let workdir = scratch.join(oid.to_string());
+ reset_dir(&workdir)?;
+ ents_effect::materialize::checkout(objects, base_tree, &workdir)?;
+
+ let prompt = session
+ .thread
+ .first()
+ .map(|bytes| String::from_utf8_lossy(bytes).into_owned())
+ .unwrap_or_default();
+ let prompt_path = workdir.join(AGENT_PROMPT_FILE);
+ std::fs::write(&prompt_path, &prompt).map_err(|source| Error::Io {
+ path: prompt_path.clone(),
+ source,
+ })?;
+
+ let inputs = SandboxInputs {
+ workdir: &workdir,
+ toolchains,
+ command,
+ };
+ // An `Err` here is an infrastructure failure the sandbox itself never
+ // turned into a completed pass/fail: it propagates as-is, past every
+ // write below, so the finalize proposal is never even built — the
+ // session stays `planning` (a legal continuation), the queue's own
+ // retry bound decides what happens next.
+ let output = executor.run(&inputs)?;
+
+ if output.status == RunStatus::Fail {
+ // A completed, failed drafting attempt: record it and leave the
+ // session untouched (still `planning`, still no plan) rather than
+ // ever moving it toward a terminal `Status::Failed` — that variant
+ // names a session's own run failing, not a drafting attempt that
+ // may simply be retried on a future commit.
+ record_result(
+ refs,
+ objects,
+ events,
+ &results_ref,
+ oid,
+ ResultStatus::Fail,
+ author,
+ sign,
+ mode,
+ )?;
+ return Ok(AgentPlanOutcome::DraftFailed);
+ }
+
+ let draft_path = workdir.join(AGENT_PLAN_DRAFT_FILE);
+ let plan_text = std::fs::read_to_string(&draft_path).map_err(|source| Error::Io {
+ path: draft_path.clone(),
+ source,
+ })?;
+
+ let (draft_transition, draft_tip) = match ents_forge::agent::draft_plan_transition(
+ refs,
+ objects,
+ &id,
+ plan_text,
+ vec![output.log.into_bytes()],
+ &identity,
+ ) {
+ Ok(built) => built,
+ // The ordinary "first drafter wins, losers no-op" race: by the
+ // time this worker re-read the session fresh to build its own
+ // transition, another worker's draft had already landed, so the
+ // session is no longer `Planning`
+ // (`ents_forge::agent::draft_plan_transition`'s own precondition).
+ Err(ents_forge::Error::InvalidArgument(_)) => {
+ record_result(
+ refs,
+ objects,
+ events,
+ &results_ref,
+ oid,
+ ResultStatus::Pass,
+ author,
+ sign,
+ mode,
+ )?;
+ return Ok(AgentPlanOutcome::DraftLost);
+ }
+ Err(other) => return Err(other.into()),
+ };
+ let record = ResultRecord::new(AGENT_PLAN_NAME, oid, ResultStatus::Pass);
+ let (result_transition, result_tip) = ents_receive::entity_transition(
+ refs,
+ objects,
+ &results_ref,
+ &record,
+ &identity,
+ "Record agent-plan result",
+ )?;
+
+ // Finalize = one atomic multi-ref proposal: the session's draft and
+ // this effect's own result land together or not at all
+ // (`receive.multi-ref-atomicity`).
+ let proposal = Proposal {
+ transitions: vec![draft_transition, result_transition],
+ objects: vec![draft_tip, result_tip],
+ auth: None,
+ };
+ let outcome = ents_receive::receive(refs, objects, events, &proposal, mode)?;
+ if outcome.result != TxResult::Applied {
+ // Lost the race: another worker's draft already landed between
+ // this worker's own read and its finalize attempt — `receive`'s
+ // CAS on the session ref is what serializes concurrent drafters
+ // (`docs/agent-sessions-plan.adoc`'s Phase 4). Record a cheap pass
+ // to discharge this obligation, exactly like
+ // `agent_worker::run_agent_exec`'s own lost-claim race.
+ record_result(
+ refs,
+ objects,
+ events,
+ &results_ref,
+ oid,
+ ResultStatus::Pass,
+ author,
+ sign,
+ mode,
+ )?;
+ return Ok(AgentPlanOutcome::DraftLost);
+ }
+ Ok(AgentPlanOutcome::Drafted { id, outcome })
+}
+
+/// Record a result for `oid` on the canonical `agent-plan` results ref —
+/// the no-op, failed-draft, and lost-race paths all discharge their
+/// obligation through this one call.
+#[expect(
+ clippy::too_many_arguments,
+ reason = "one input per write_result parameter; a thin, single-call wrapper"
+)]
+fn record_result<O: Find + Write>(
+ refs: &dyn RefStore,
+ objects: &O,
+ events: &dyn EventSink,
+ results_ref: &gix::refs::FullName,
+ oid: ObjectId,
+ status: ResultStatus,
+ author: &gix::actor::Signature,
+ sign: &dyn Fn(&[u8]) -> String,
+ mode: Mode,
+) -> Result<Outcome> {
+ Ok(ents_effect::write_result(
+ refs,
+ objects,
+ events,
+ results_ref.clone(),
+ AGENT_PLAN_NAME,
+ oid,
+ status,
+ author,
+ sign,
+ mode,
+ )?)
+}
+
+/// Wipe and recreate `dir` — a fresh workdir per run; mirrors
+/// `agent_worker`'s identical helper.
+fn reset_dir(dir: &Path) -> Result<()> {
+ if dir.exists() {
+ std::fs::remove_dir_all(dir).map_err(|source| Error::Io {
+ path: dir.to_owned(),
+ source,
+ })?;
+ }
+ std::fs::create_dir_all(dir).map_err(|source| Error::Io {
+ path: dir.to_owned(),
+ source,
+ })
+}
+
+/// The oldest ancestor of `oid` reachable by following each commit's first
+/// parent — the session's own genesis oid and id; duplicated from
+/// `agent_worker`'s own private copy (that module's own doc names this
+/// codebase's accepted pattern for a small per-module copy).
+fn genesis_of(objects: &impl Find, oid: ObjectId) -> Result<ObjectId> {
+ let mut current = oid;
+ loop {
+ let mut buf = Vec::new();
+ let data = objects
+ .try_find(¤t, &mut buf)
+ .map_err(|source| Error::InvalidArgument(source.to_string()))?
+ .ok_or_else(|| Error::NotFound {
+ what: current.to_string(),
+ })?;
+ if data.kind != Kind::Commit {
+ return Err(Error::NotFound {
+ what: current.to_string(),
+ });
+ }
+ let commit = CommitRef::from_bytes(data.data, current.kind())
+ .map_err(|source| Error::InvalidArgument(source.to_string()))?;
+ match commit.parents().next() {
+ Some(parent) => current = parent,
+ None => return Ok(current),
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(
+ clippy::expect_used,
+ clippy::unwrap_used,
+ clippy::panic,
+ reason = "unit test; the panic is an assertion on a `let else` branch"
+ )]
+
+ use ents_forge::agent::ReviewPolicy;
+ use ents_gate::Config;
+ use ents_model::{MemberId, Provenance, namespace};
+ use ents_receive::NullEventSink;
+ use ents_testutil::{Keypair, MemRefStore, ObjectStore, advance_ref, enroll_member};
+ use gix_ref_store::RefStoreRead as _;
+ use rstest::rstest;
+
+ use super::*;
+
+ struct StubExecutor {
+ status: RunStatus,
+ prepare: fn(&Path),
+ }
+
+ impl Executor for StubExecutor {
+ fn run(&self, inputs: &SandboxInputs<'_>) -> ents_effect::Result<ents_effect::RunOutput> {
+ (self.prepare)(inputs.workdir);
+ Ok(ents_effect::RunOutput {
+ status: self.status,
+ log: "planning transcript".to_owned(),
+ })
+ }
+ }
+
+ fn no_draft(workdir: &Path) {
+ assert!(
+ workdir.join(AGENT_PROMPT_FILE).exists(),
+ "the prompt sideband file must exist before the drafting command runs"
+ );
+ }
+
+ fn writes_a_draft(workdir: &Path) {
+ no_draft(workdir);
+ std::fs::write(
+ workdir.join(AGENT_PLAN_DRAFT_FILE),
+ "1. read the test\n2. fix it\n3. re-run",
+ )
+ .expect("write draft");
+ }
+
+ fn writes_a_different_draft(workdir: &Path) {
+ no_draft(workdir);
+ std::fs::write(
+ workdir.join(AGENT_PLAN_DRAFT_FILE),
+ "this worker's own draft",
+ )
+ .expect("write draft");
+ }
+
+ struct FailingExecutor;
+ impl Executor for FailingExecutor {
+ fn run(&self, _inputs: &SandboxInputs<'_>) -> ents_effect::Result<ents_effect::RunOutput> {
+ Err(ents_effect::Error::Sandbox(
+ "the sandbox never started".to_owned(),
+ ))
+ }
+ }
+
+ type Signer = Box<dyn Fn(&[u8]) -> String>;
+
+ struct Fixture {
+ refs: MemRefStore,
+ objects: ObjectStore,
+ sign: Signer,
+ }
+
+ impl Fixture {
+ fn new() -> Self {
+ let refs = MemRefStore::default();
+ let objects = ObjectStore::default();
+ let key = Keypair::from_seed(1);
+ let sign = Keypair::from_seed(1);
+ enroll_member(
+ &refs,
+ &objects,
+ "worker",
+ &key,
+ Provenance::AdminRegistered,
+ 100,
+ );
+ let config_ref: gix::refs::FullName = namespace::CONFIG_REF.try_into().expect("valid");
+ ents_testutil::write_meta_entity(
+ &refs,
+ &objects,
+ config_ref,
+ &Config {
+ epoch: Some(150),
+ ..Config::default()
+ },
+ Some(&key),
+ 150,
+ );
+ advance_ref(&refs, &objects, "refs/heads/main", 1, 200);
+ Self {
+ refs,
+ objects,
+ sign: Box::new(move |payload: &[u8]| sign.sign(payload)),
+ }
+ }
+
+ fn identity(&self) -> Identity<'_> {
+ Identity {
+ actor: self.author(),
+ author: None,
+ sign: &*self.sign,
+ }
+ }
+
+ fn author(&self) -> gix::actor::Signature {
+ gix::actor::Signature {
+ name: "worker".into(),
+ email: "worker@ents.test".into(),
+ time: gix::date::Time {
+ seconds: 1_000,
+ offset: 0,
+ },
+ }
+ }
+
+ fn sign_fn(&self) -> &dyn Fn(&[u8]) -> String {
+ &*self.sign
+ }
+
+ /// A brand-new, `planning` session with a prompt and no plan — the
+ /// only precondition [`super::run_agent_plan`]'s
+ /// [`PlanDispatch::Draft`] arm runs against.
+ fn planning_session(&self) -> (ObjectId, String) {
+ let identity = self.identity();
+ let (id, outcome) = ents_forge::agent::new(
+ &self.refs,
+ &self.objects,
+ &NullEventSink,
+ ents_forge::agent::NewAgentSession {
+ member: MemberId::new("jdc"),
+ prompt: "fix the flaky test".to_owned(),
+ model: "claude-sonnet-5".to_owned(),
+ toolchains: vec![],
+ base_ref: "refs/heads/main".to_owned(),
+ review_policy: ReviewPolicy::Manual,
+ retry_of: None,
+ },
+ &identity,
+ Mode::Advisory,
+ )
+ .expect("creates");
+ assert_eq!(outcome.result, TxResult::Applied);
+
+ let ref_name = ents_model::namespace::agent_session_ref(&id).expect("valid");
+ let tip = self
+ .refs
+ .get(ref_name.as_ref())
+ .expect("readable")
+ .expect("exists");
+ (tip, id)
+ }
+ }
+
+ #[rstest]
+ // @relation(scope=function, role=Verifies)
+ fn a_ready_session_is_a_cheap_no_op() {
+ let fixture = Fixture::new();
+ let (oid, id) = fixture.planning_session();
+ // Move it to `ready` by hand, as a human redrafting would.
+ ents_forge::agent::revise_plan(
+ &fixture.refs,
+ &fixture.objects,
+ &NullEventSink,
+ &id,
+ "already drafted".to_owned(),
+ &fixture.identity(),
+ Mode::Advisory,
+ )
+ .expect("revises");
+ let ref_name = ents_model::namespace::agent_session_ref(&id).expect("valid");
+ let ready_oid = fixture
+ .refs
+ .get(ref_name.as_ref())
+ .expect("readable")
+ .expect("exists");
+
+ let author = fixture.author();
+ let run = run_agent_plan(
+ &fixture.refs,
+ &fixture.objects,
+ &NullEventSink,
+ &FailingExecutor,
+ Path::new("/does/not/matter"),
+ &[],
+ "true",
+ ready_oid,
+ &author,
+ fixture.sign_fn(),
+ Mode::Advisory,
+ )
+ .expect("dispatch never touches the sandbox for a no-op");
+ assert!(matches!(run, AgentPlanOutcome::NoOp));
+
+ let results_ref = ents_model::namespace::result_ref(AGENT_PLAN_NAME, &short_oid(ready_oid))
+ .expect("valid");
+ assert!(
+ fixture
+ .refs
+ .get(results_ref.as_ref())
+ .expect("readable")
+ .is_some(),
+ "the no-op path must still discharge the obligation with a recorded pass"
+ );
+ // Sanity: the originally dequeued `planning` oid is untouched by
+ // this assertion path — `oid` above was reassigned to the later
+ // `ready` tip deliberately, since dispatch always re-reads the
+ // *current* tip's own decoded state, never the dequeued oid's
+ // historical one.
+ let _ = oid;
+ }
+
+ #[rstest]
+ // @relation(effect.execution, effect.results-writeback, receive.multi-ref-atomicity, scope=function, role=Verifies)
+ fn a_planning_session_with_a_prompt_drafts_and_lands_ready() {
+ let fixture = Fixture::new();
+ let (oid, id) = fixture.planning_session();
+
+ let scratch = tempfile::tempdir().expect("tempdir");
+ let executor = StubExecutor {
+ status: RunStatus::Pass,
+ prepare: writes_a_draft,
+ };
+ let author = fixture.author();
+ let run = run_agent_plan(
+ &fixture.refs,
+ &fixture.objects,
+ &NullEventSink,
+ &executor,
+ scratch.path(),
+ &[],
+ "true",
+ oid,
+ &author,
+ fixture.sign_fn(),
+ Mode::Advisory,
+ )
+ .expect("drafts and finalizes");
+ let AgentPlanOutcome::Drafted {
+ id: drafted_id,
+ outcome,
+ } = run
+ else {
+ panic!("expected Drafted, got {run:?}");
+ };
+ assert_eq!(drafted_id, id);
+ assert_eq!(outcome.result, TxResult::Applied);
+
+ let session = ents_forge::agent::show(&fixture.refs, &fixture.objects, &id).expect("shows");
+ assert_eq!(session.meta.status, ents_forge::agent::Status::Ready);
+ assert!(session.awaiting_confirmation());
+ assert_eq!(
+ session.plan.as_deref(),
+ Some("1. read the test\n2. fix it\n3. re-run")
+ );
+ assert!(
+ !session.thread.is_empty(),
+ "the drafting run's transcript must land as a thread blob"
+ );
+
+ let results_ref =
+ ents_model::namespace::result_ref(AGENT_PLAN_NAME, &short_oid(oid)).expect("valid");
+ let result_tip = fixture
+ .refs
+ .get(results_ref.as_ref())
+ .expect("readable")
+ .expect("result landed");
+ let result_tree = commit_tree(&fixture.objects, result_tip).expect("tree");
+ let record: ResultRecord =
+ facet_git_tree::deserialize(&result_tree, &fixture.objects).expect("decodes");
+ assert_eq!(record.status, ResultStatus::Pass);
+ assert_eq!(record.target(), oid);
+ }
+
+ #[rstest]
+ // @relation(effect.result-taxonomy, scope=function, role=Verifies)
+ fn a_failed_draft_records_a_fail_result_and_leaves_the_session_planning() {
+ let fixture = Fixture::new();
+ let (oid, id) = fixture.planning_session();
+
+ let scratch = tempfile::tempdir().expect("tempdir");
+ let executor = StubExecutor {
+ status: RunStatus::Fail,
+ prepare: no_draft,
+ };
+ let author = fixture.author();
+ let run = run_agent_plan(
+ &fixture.refs,
+ &fixture.objects,
+ &NullEventSink,
+ &executor,
+ scratch.path(),
+ &[],
+ "true",
+ oid,
+ &author,
+ fixture.sign_fn(),
+ Mode::Advisory,
+ )
+ .expect("runs");
+ assert!(matches!(run, AgentPlanOutcome::DraftFailed));
+
+ let session = ents_forge::agent::show(&fixture.refs, &fixture.objects, &id).expect("shows");
+ assert_eq!(session.meta.status, ents_forge::agent::Status::Planning);
+ assert!(session.plan.is_none());
+
+ let results_ref =
+ ents_model::namespace::result_ref(AGENT_PLAN_NAME, &short_oid(oid)).expect("valid");
+ let result_tip = fixture
+ .refs
+ .get(results_ref.as_ref())
+ .expect("readable")
+ .expect("result landed");
+ let result_tree = commit_tree(&fixture.objects, result_tip).expect("tree");
+ let record: ResultRecord =
+ facet_git_tree::deserialize(&result_tree, &fixture.objects).expect("decodes");
+ assert_eq!(record.status, ResultStatus::Fail);
+ }
+
+ #[rstest]
+ // @relation(effect.result-taxonomy, scope=function, role=Verifies)
+ fn a_sandbox_infrastructure_failure_publishes_nothing_and_leaves_the_session_planning() {
+ let fixture = Fixture::new();
+ let (oid, id) = fixture.planning_session();
+
+ let scratch = tempfile::tempdir().expect("tempdir");
+ let author = fixture.author();
+ let error = run_agent_plan(
+ &fixture.refs,
+ &fixture.objects,
+ &NullEventSink,
+ &FailingExecutor,
+ scratch.path(),
+ &[],
+ "true",
+ oid,
+ &author,
+ fixture.sign_fn(),
+ Mode::Advisory,
+ )
+ .expect_err("an infra failure must propagate, not silently finalize");
+ assert!(matches!(error, Error::Effect(_)));
+
+ let session = ents_forge::agent::show(&fixture.refs, &fixture.objects, &id).expect("shows");
+ assert_eq!(session.meta.status, ents_forge::agent::Status::Planning);
+ assert!(session.plan.is_none());
+
+ let results_ref =
+ ents_model::namespace::result_ref(AGENT_PLAN_NAME, &short_oid(oid)).expect("valid");
+ assert!(
+ fixture
+ .refs
+ .get(results_ref.as_ref())
+ .expect("readable")
+ .is_none(),
+ "no result may be recorded when the sandbox never completed the run"
+ );
+ }
+
+ #[rstest]
+ // @relation(scope=function, role=Verifies)
+ fn a_lost_draft_race_records_a_pass_and_leaves_the_racing_draft_alone() {
+ let fixture = Fixture::new();
+ let (oid, id) = fixture.planning_session();
+
+ // Simulate a second worker having already drafted the session
+ // before this worker's own finalize attempt runs.
+ ents_forge::agent::draft_plan(
+ &fixture.refs,
+ &fixture.objects,
+ &NullEventSink,
+ &id,
+ "someone else's draft".to_owned(),
+ vec![],
+ &fixture.identity(),
+ Mode::Advisory,
+ )
+ .expect("first draft succeeds");
+
+ let scratch = tempfile::tempdir().expect("tempdir");
+ let executor = StubExecutor {
+ status: RunStatus::Pass,
+ prepare: writes_a_different_draft,
+ };
+ let author = fixture.author();
+ let run = run_agent_plan(
+ &fixture.refs,
+ &fixture.objects,
+ &NullEventSink,
+ &executor,
+ scratch.path(),
+ &[],
+ "true",
+ oid,
+ &author,
+ fixture.sign_fn(),
+ Mode::Advisory,
+ )
+ .expect("a lost race is not an error");
+ assert!(matches!(run, AgentPlanOutcome::DraftLost));
+
+ let session = ents_forge::agent::show(&fixture.refs, &fixture.objects, &id).expect("shows");
+ assert_eq!(
+ session.plan.as_deref(),
+ Some("someone else's draft"),
+ "the losing worker's own draft must never land on the session"
+ );
+ }
+}