git-ents.gitmain
⌘K
foforge
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.

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

Cargo.lock @@ -1461,6 +1461,7 @@ "facet", "facet-git-tree", "facet-reflect", + "futures-util", "getrandom 0.4.3", "gix", "gix-hash",
crates/cli/ents-web/Cargo.toml @@ -21,6 +21,7 @@ facet = { workspace = true } facet-git-tree = { workspace = true } facet-reflect = "0.50.0-rc.5" +futures-util = { version = "0.3", default-features = false, features = ["alloc"] } getrandom = { workspace = true } gix = { workspace = true } gix-hash = { workspace = true }
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/router.rs @@ -110,6 +110,15 @@ ) .route("/agents/{id}", get(pages::agents::show::<O>)) .route("/agents/{id}/confirm", post(pages::agents::confirm::<O>)) + .route( + "/agents/{id}/chat", + get(pages::agent_chat::show::<O>).post(pages::agent_chat::send::<O>), + ) + .route( + "/agents/{id}/plan", + post(pages::agent_chat::commit_plan::<O>), + ) + .route("/agents/{id}/reopen", post(pages::agent_chat::reopen::<O>)) .route("/inbox", get(pages::inbox::list::<O>)) .route("/style.css", get(style)) .route("/ents.js", get(script))
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/agents.rs @@ -224,6 +224,13 @@ Some(body) => div.doc-body { (body) }, None => p.muted { "No plan has been drafted yet." }, } + @if matches!(agent_session.meta.status, SessionStatus::Planning | SessionStatus::Ready) { + p { + a.btn.btn-sm href={ "/agents/" (id) "/chat" } { + "Open planning chat" + } + } + } @if agent_session.awaiting_confirmation() { (confirm_form(&session, &id)) }
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/forge/ents-forge/src/agent/mod.rs @@ -15,10 +15,11 @@ pub use cli::AgentAction; pub use command::{ - ClaimAgentSession, FinishAgentSession, FinishOutcome, NewAgentSession, claim, confirm, finish, - finish_transition, list, list_all, new, revise_plan, show, + ClaimAgentSession, FinishAgentSession, FinishOutcome, NewAgentSession, append_thread, claim, + confirm, draft_plan, draft_plan_transition, finish, finish_transition, list, list_all, new, + reopen, revise_plan, show, }; -pub use dispatch::{Dispatch, dispatch}; +pub use dispatch::{Dispatch, PlanDispatch, dispatch, dispatch_plan}; pub use entity::{ AgentSession, Confirm, FailureReason, ReviewPolicy, SessionMeta, Status, ToolchainPin, };
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(&current, &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" + ); + } +}