From 37a8de9dd7a6663db82f9655219edcef68fe6a96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20A=C3=9Fmus?= Date: Wed, 15 Jul 2026 23:12:35 +0200 Subject: [PATCH 1/6] docs(roadmap): fold in review findings from the PAL side - wait types migrate together with the goal state machine (the controller's Wait decision folds out of it); stores/probes/sweeps stay host concerns - explicit migration order: turn handle + evidence first, then goal/wait types with the store traits (so PAL's planned transactional repository is written against them once), then run convergence - the turn handle is not the send-or-queue channel dispatch; a merged operation would need an explicit Queued outcome - RunSpec budgets are owner policy (PAL children run to completion by design); the run/attempt split is what ChildRun still lacks - durable approvals: PAL builds action intents P0 in pal_core first, the upstream generalization is a deliberate second migration --- ROADMAP.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 39d9c519..6cbc8a34 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -183,6 +183,10 @@ reasoning. The following pieces belong upstream: - `GoalEvaluator`, `GoalStore`, `Clock`, and `GoalRunner` traits; - deterministic controller decisions (`Continue`, `Wait`, `AwaitInput`, `Blocked`, `Done`, and `Failed`); +- typed wait barriers (`WaitKind`, wait requests, the armed → satisfied / + timed-out / cancelled state machine, and clock-only predicates): the + controller's `Wait` decision folds out of the same state machine, so leaving + the wait types behind would split that machine across repositories; - a default bounded LLM evaluator that requires concrete verification evidence; - generic create/show/pause/resume/cancel/update operations and tool contracts. @@ -192,8 +196,22 @@ The following remain PAL responsibilities: - choosing or rotating the concrete session incarnation that pursues it; - startup sweeps, orphan adoption, and proactive continuation after restart; - durable timer/job/child/event/human-input resolvers; +- wait stores, runtime probes, and the turn-free sweep passes; - cross-channel notifications and final delivery. +### Migration order + +1. **Turn handle and structured evidence first.** Both are purely additive + upstream seams: PAL benefits immediately (goal turns and supervised child + runs stop inferring outcomes from the event stream) and no PAL code moves + yet. +2. **Goal and wait types, the controller, and the store/evaluator traits** + into the orchestration crate. Defining the store traits in this same slice + matters: PAL plans to replace its per-file JSON stores with one + transactional repository, and that repository should be written against + the shared traits rather than migrating persistence twice. +3. **Run/delegation convergence** (see Next) once goals are shared. + ### Replace event inference with an exact turn handle `SessionService::try_send_user_message_if_idle` is the correct atomic dispatch @@ -221,6 +239,12 @@ cancellation, and resolve once with bounded output: This removes a race from PAL and is independently useful for background agents, ACP, tests, CLI automation, and future work-graph workers. +Channel-style dispatch (PAL's send-or-queue path) is deliberately not this +operation: an autonomous controller needs exactly `Busy | Started`, while +queueing a user message for later is host delivery policy. If the two paths +ever merge, the operation needs an explicit `Queued` outcome rather than +overloading `Busy`. + ### Evidence must be structured at the source Goal completion must not rely only on the assistant's final narrative. Extend @@ -277,6 +301,14 @@ Provide multiple policies over the same primitives: Do not make every code-assistant sub-agent durable. Durability has storage, recovery, UX, and cost consequences and should be selected by the owner. +Budgets in `RunSpec` are owner policy, not a platform mandate: PAL's +supervised children deliberately run to completion, limited only at launch +time (depth, concurrency, workdir ownership, an optional wall-clock deadline), +and the shared types must keep that legal. The run/attempt split, conversely, +is exactly what PAL's `ChildRun` still lacks — converging on it supplies the +planned per-child retry policy and attempt history instead of a parallel +implementation. + ## Next: first-class projects The current project model is essentially one filesystem path plus formatter @@ -369,6 +401,11 @@ expiry, preconditions, and idempotency key. Approval revalidates current policy and preconditions before executing. The host supplies presentation and delivery: GUI/ACP in code-assistant, channels and outbox in PAL. +Sequencing note: PAL ranks durable action intents P0 — they gate unattended +outward actions — and will build them in `pal_core` first. This section then +becomes a deliberate second migration under the extract-when-a-consumer-exists +rule; it is not a reason for PAL to wait for the upstream generalization. + Define execution targets independently of goals and workers: - local sandbox; From 61fb9d9599ea0f1befa2d84bb5360e4e4e34d22a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20A=C3=9Fmus?= Date: Wed, 15 Jul 2026 23:38:05 +0200 Subject: [PATCH 2/6] feat(session): exact turn handle for autonomous controllers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit start_turn_if_idle is the typed sibling of try_send_user_message_if_idle (ROADMAP 'Replace event inference with an exact turn handle'): it starts a turn only on an idle session, atomically inside the service actor, and returns a TurnHandle identifying exactly that turn. The handle resolves once with a bounded TurnOutcome — final narration, tool lifecycle records, resource writes, token/tool/wall-time usage, and whether a queued user message was absorbed mid-run — collected by a TurnRecorder teed synchronously into the session's event publisher, so outcomes never depend on a broadcast subscriber keeping up. An aborted agent task resolves the outcome as failed via the recorder's Drop instead of losing it; the token delta is read from the state the run persisted, not from fire-and-forget metadata events. Also: - AgentRuntimeOptions.llm_client_factory: injectable LLM construction per run (scripted providers for tests and fault-injection harnesses) - compaction: a model unknown to models.json (playback, injected factory) disables automatic compaction for the run instead of failing the turn --- crates/code_assistant/src/app/gpui.rs | 1 + .../src/plugins/compaction.rs | 22 +- .../src/session/instance.rs | 20 +- .../src/session/manager.rs | 30 +- crates/code_assistant_core/src/session/mod.rs | 5 + .../src/session/service.rs | 281 ++++++++- .../code_assistant_core/src/session/turn.rs | 585 ++++++++++++++++++ crates/ui_terminal/src/app.rs | 1 + 8 files changed, 924 insertions(+), 21 deletions(-) create mode 100644 crates/code_assistant_core/src/session/turn.rs diff --git a/crates/code_assistant/src/app/gpui.rs b/crates/code_assistant/src/app/gpui.rs index e1d9f41d..a03de80d 100644 --- a/crates/code_assistant/src/app/gpui.rs +++ b/crates/code_assistant/src/app/gpui.rs @@ -62,6 +62,7 @@ pub fn run(config: AgentRunConfig) -> Result<()> { command_executor_factory: super::session_command_executor_factory(), project_manager_factory: code_assistant_core::session::service::default_project_manager_factory(), + llm_client_factory: None, }), events, ); diff --git a/crates/code_assistant_core/src/plugins/compaction.rs b/crates/code_assistant_core/src/plugins/compaction.rs index 736f3a4d..7d766900 100644 --- a/crates/code_assistant_core/src/plugins/compaction.rs +++ b/crates/code_assistant_core/src/plugins/compaction.rs @@ -5,7 +5,7 @@ use crate::plugins::AgentAppState; use agent_core::hooks::{CompactionPolicy, ContextSnapshot}; use anyhow::Result; use std::any::Any; -use tracing::debug; +use tracing::{debug, warn}; pub struct TokenRatioCompaction { threshold: f32, @@ -33,11 +33,21 @@ impl CompactionPolicy for TokenRatioCompaction { let limit = if let Some(limit) = state.context_limit_override { limit } else { - let config_system = llm::provider_config::ConfigurationSystem::load()?; - config_system - .get_model(&model_name) - .map(|model| model.context_token_limit) - .ok_or_else(|| anyhow::anyhow!("Model not found in models.json: {model_name}"))? + // An agent can legitimately run with a model the configuration + // does not know: playback mode and injected LLM factories (tests, + // fault-injection harnesses) bypass client construction from + // models.json. An unknown limit disables automatic compaction for + // the run; it must not fail the turn. + let known_limit = llm::provider_config::ConfigurationSystem::load() + .ok() + .and_then(|config| config.get_model(&model_name).map(|m| m.context_token_limit)); + match known_limit { + Some(limit) => limit, + None => { + warn!("No context limit known for model '{model_name}'; compaction disabled for this run"); + return Ok(None); + } + } }; Ok(if limit == 0 { None } else { Some(limit) }) diff --git a/crates/code_assistant_core/src/session/instance.rs b/crates/code_assistant_core/src/session/instance.rs index fd35a881..8cb94af0 100644 --- a/crates/code_assistant_core/src/session/instance.rs +++ b/crates/code_assistant_core/src/session/instance.rs @@ -416,9 +416,15 @@ impl SessionInstance { /// Create the publisher this session's agent talks to: it records /// in-flight state for snapshots and publishes everything session-tagged /// to the core→UI broadcast stream. + /// + /// `turn_recorder` is the synchronous tee for a controller-started turn + /// (see [`crate::session::turn`]): it sees the run's events *before* the + /// lossy broadcast, so a turn outcome never depends on a subscriber + /// keeping up. pub fn create_publisher( &self, events: crate::session::event_stream::EventStream, + turn_recorder: Option>, ) -> Arc { Arc::new(SessionEventPublisher { events, @@ -428,6 +434,7 @@ impl SessionInstance { activity: self.activity.clone(), stop_requested: self.stop_requested.clone(), session_id: self.session.id.clone(), + turn_recorder, }) } @@ -885,6 +892,9 @@ struct SessionEventPublisher { activity: SessionActivity, stop_requested: Arc, session_id: String, + /// Synchronous tee for a controller-started turn (see + /// [`crate::session::turn`]); `None` for ordinary user turns. + turn_recorder: Option>, } impl SessionEventPublisher { @@ -906,6 +916,9 @@ impl SessionEventPublisher { #[async_trait] impl UserInterface for SessionEventPublisher { async fn send_event(&self, event: UiEvent) -> Result<(), UIError> { + if let Some(recorder) = &self.turn_recorder { + recorder.observe(&event); + } // Handle special events that need buffer management and activity state updates match &event { UiEvent::StreamingStarted { node_id, .. } => { @@ -992,6 +1005,9 @@ impl UserInterface for SessionEventPublisher { } fn display_fragment(&self, fragment: &DisplayFragment) -> Result<(), UIError> { + if let Some(recorder) = &self.turn_recorder { + recorder.observe_fragment(fragment); + } // Record the in-flight fragment for snapshots. Cleared on streaming // start/stop/rollback, so the buffer is bounded by one response. if let Ok(mut buffer) = self.fragment_buffer.lock() { @@ -1205,6 +1221,7 @@ mod tests { activity, stop_requested: Arc::new(std::sync::atomic::AtomicBool::new(false)), session_id: session_id.to_string(), + turn_recorder: None, } } @@ -1217,7 +1234,8 @@ mod tests { None, ); let instance = SessionInstance::new(session, crate::tools::test_registry()); - let publisher = instance.create_publisher(crate::session::event_stream::EventStream::new()); + let publisher = + instance.create_publisher(crate::session::event_stream::EventStream::new(), None); // The agent announces the request with the node id the message will // be persisted under, then streams fragments. diff --git a/crates/code_assistant_core/src/session/manager.rs b/crates/code_assistant_core/src/session/manager.rs index dfff7915..00468eaf 100644 --- a/crates/code_assistant_core/src/session/manager.rs +++ b/crates/code_assistant_core/src/session/manager.rs @@ -716,6 +716,7 @@ impl SessionManager { command_executor, permission_handler, None, + None, ) .await } @@ -728,6 +729,11 @@ impl SessionManager { /// instead of the scope derived from the session config. Per-run only: /// nothing is persisted, the next run derives its scope normally. Used /// for system-initiated turns such as a memory-only session wrap-up. + /// + /// `turn_recorder` is the synchronous outcome tee of a controller-started + /// turn (see [`crate::session::turn`]): it is wired into the run's + /// publisher and finished when the agent task ends. `None` for ordinary + /// user turns. #[allow(clippy::too_many_arguments)] pub async fn start_agent_for_session( &mut self, @@ -737,6 +743,7 @@ impl SessionManager { command_executor: Box, permission_handler: Option>, tool_scope_override: Option, + turn_recorder: Option>, ) -> Result<()> { // A new run is the point where configuration changes take effect: // pull the current registry before anything below binds to it. @@ -780,7 +787,8 @@ impl SessionManager { // previous run's live tool statuses. session_instance.begin_agent_run(); - let publisher = session_instance.create_publisher(self.events.clone()); + let publisher = + session_instance.create_publisher(self.events.clone(), turn_recorder.clone()); let activity = session_instance.activity.clone(); let pending_message_ref = session_instance.pending_message.clone(); @@ -852,6 +860,10 @@ impl SessionManager { self.tool_registry.clone(), self.events.clone(), ))); + // For the turn outcome's token delta: the agent saves synchronously + // through this manager, so its persisted state is complete when the + // run ends. + let manager_for_outcome = session_manager_ref.clone(); let state_storage = Box::new(crate::agent::persistence::SessionStatePersistence::new( session_manager_ref, @@ -1066,6 +1078,22 @@ impl SessionManager { } } + // Resolve the turn outcome for a controller-started turn. All of + // the run's events already passed through the recorder tee (the + // publisher is synchronous), so the record is complete here; the + // token delta comes from the state the run persisted. + if let Some(recorder) = turn_recorder { + let final_usage = { + let mut manager = manager_for_outcome.lock().await; + manager + .ensure_session_loaded(&session_id_clone) + .ok() + .and_then(|_| manager.get_session(&session_id_clone)) + .map(|instance| instance.calculate_total_usage()) + }; + recorder.finish(result.as_ref().err().map(|e| format!("{e:#}")), final_usage); + } + // Signal that this agent is no longer running so the system sleep // inhibition can be released once all agents have finished. sleep_inhibitor.agent_stopped(); diff --git a/crates/code_assistant_core/src/session/mod.rs b/crates/code_assistant_core/src/session/mod.rs index aba66af9..73c9051c 100644 --- a/crates/code_assistant_core/src/session/mod.rs +++ b/crates/code_assistant_core/src/session/mod.rs @@ -15,6 +15,7 @@ pub mod manager; pub mod permissions; pub mod service; pub mod sleep_inhibitor; +pub mod turn; pub mod wakeup; pub mod watcher; @@ -23,6 +24,10 @@ pub mod watcher; pub use event_stream::{EventPayload, EventStream, SessionEvent, StreamError, Subscription}; pub use manager::SessionManager; pub use service::SessionService; +pub use turn::{ + ResourceRef, ToolRecord, TurnDispatch, TurnHandle, TurnOutcome, TurnRequest, TurnStatus, + TurnUsage, +}; pub use wakeup::{spawn_wakeup_scheduler, SessionWakeups, WakeupHandle}; /// Owned snapshot of everything a frontend needs to render a session. diff --git a/crates/code_assistant_core/src/session/service.rs b/crates/code_assistant_core/src/session/service.rs index 1f5c204d..820d51f3 100644 --- a/crates/code_assistant_core/src/session/service.rs +++ b/crates/code_assistant_core/src/session/service.rs @@ -55,6 +55,11 @@ pub fn default_project_manager_factory() -> ProjectManagerFactory { Arc::new(|| Box::new(DefaultProjectManager::new())) } +/// Builds the LLM client for a model name when an agent run starts. `None` +/// uses the configured providers (`create_llm_client_from_model`); tests and +/// fault-injection harnesses supply scripted providers here. +pub type LlmClientFactory = Arc Result> + Send + Sync>; + /// Options for running agents: LLM recording/playback plus the command /// executor and project-manager factories. #[derive(Clone)] @@ -67,6 +72,8 @@ pub struct AgentRuntimeOptions { /// Builds the project manager an agent run resolves `project` arguments /// against. See [`default_project_manager_factory`]. pub project_manager_factory: ProjectManagerFactory, + /// Overrides LLM client construction per run. See [`LlmClientFactory`]. + pub llm_client_factory: Option, } /// A single entry in the input-area skill picker. @@ -494,6 +501,7 @@ impl SessionService { &attachments, branch_parent_id, None, + None, ) .await }) @@ -519,6 +527,7 @@ impl SessionService { &attachments, None, Some(tool_scope), + None, ) .await }) @@ -560,6 +569,30 @@ impl SessionService { .await } + /// The typed sibling of [`Self::try_send_user_message_if_idle`]: start a + /// turn only when the session is idle (atomically, inside the actor) and + /// return a [`crate::session::TurnHandle`] identifying exactly the turn + /// that was started. The handle resolves once with a bounded + /// [`crate::session::TurnOutcome`] — final narration, tool and resource + /// evidence, usage, and whether user input was absorbed — collected + /// synchronously at the publisher, so the caller never infers "its" turn + /// from the lossy broadcast stream. + /// + /// This is the dispatch seam for autonomous controllers (goal passes, + /// delegated child runs, work-graph workers) and for tests/automation + /// that need an exact turn result. + pub async fn start_turn_if_idle( + &self, + session_id: String, + request: crate::session::TurnRequest, + ) -> Result { + let service = self.clone(); + self.call(move |ctx| async move { + start_turn_if_idle_impl(&ctx, service, session_id, request).await + }) + .await + } + /// Whether the session is currently running a turn, decided from the live /// in-memory activity state inside the service actor (authoritative). This /// is the same ground truth [`Self::try_send_user_message_if_idle`] gates @@ -699,7 +732,7 @@ impl SessionService { } } - send_user_message_impl(&ctx, &session_id, &message, &[], None, None).await + send_user_message_impl(&ctx, &session_id, &message, &[], None, None, None).await }) .await } @@ -1160,6 +1193,7 @@ async fn send_user_message_impl( attachments: &[DraftAttachment], branch_parent_id: Option, tool_scope_override: Option, + turn_recorder: Option>, ) -> Result<()> { debug!( "User message for session {}: {} (with {} attachments, branch_parent: {:?})", @@ -1211,7 +1245,7 @@ async fn send_user_message_impl( ); } - start_agent_impl(ctx, session_id, tool_scope_override).await + start_agent_impl(ctx, session_id, tool_scope_override, turn_recorder).await } /// Shared by [`SessionService::send_or_queue_user_message`] and the wakeup @@ -1248,7 +1282,7 @@ async fn send_or_queue_user_message_impl( return Ok(()); } - send_user_message_impl(ctx, session_id, message, attachments, None, None).await + send_user_message_impl(ctx, session_id, message, attachments, None, None, None).await } async fn try_send_user_message_if_idle_impl( @@ -1269,10 +1303,53 @@ async fn try_send_user_message_if_idle_impl( return Ok(false); } - send_user_message_impl(ctx, session_id, message, attachments, None, None).await?; + send_user_message_impl(ctx, session_id, message, attachments, None, None, None).await?; Ok(true) } +async fn start_turn_if_idle_impl( + ctx: &ServiceCtx, + service: SessionService, + session_id: String, + request: crate::session::TurnRequest, +) -> Result { + use crate::session::turn::TurnRecorder; + use crate::session::{TurnDispatch, TurnHandle}; + + // The idle check and the usage baseline for the outcome's token delta + // come from the same lock scope; the actor serializes commands, so no + // other dispatch can slip in before the send below. + let baseline_usage = { + let mut manager = ctx.manager.lock().await; + manager.ensure_session_loaded(&session_id)?; + let instance = manager + .get_session(&session_id) + .ok_or_else(|| anyhow::anyhow!("Session not found: {session_id}"))?; + if !instance.get_activity_state().is_terminal() { + return Ok(TurnDispatch::Busy); + } + instance.calculate_total_usage() + }; + + let (recorder, parts) = TurnRecorder::arm(baseline_usage); + send_user_message_impl( + ctx, + &session_id, + &request.message, + &request.attachments, + None, + request.tool_scope, + Some(recorder), + ) + .await?; + Ok(TurnDispatch::Started(TurnHandle::new( + session_id, + parts.turn_id, + service, + parts.outcome, + ))) +} + async fn inject_wakeup_impl(ctx: &ServiceCtx, session_id: &str, message: &str) -> Result<()> { // Session deleted since the wakeup was armed — drop silently. if ctx.manager.lock().await.get_session(session_id).is_none() { @@ -1308,7 +1385,7 @@ async fn resume_session_impl(ctx: &ServiceCtx, session_id: &str) -> Result<()> { } } - start_agent_impl(ctx, session_id, None).await + start_agent_impl(ctx, session_id, None, None).await } /// Start the agent loop for a session against its current message history. @@ -1337,6 +1414,7 @@ async fn start_agent_impl( ctx: &ServiceCtx, session_id: &str, tool_scope_override: Option, + turn_recorder: Option>, ) -> Result<()> { let (session_config, default_model_name) = { let manager = ctx.manager.lock().await; @@ -1359,14 +1437,18 @@ async fn start_agent_impl( }); } - let llm_client = create_llm_client_from_model( - &session_config.model_name, - ctx.runtime.playback_path.clone(), - ctx.runtime.fast_playback, - ctx.runtime.record_path.clone(), - ) - .await - .context("Failed to create LLM client")?; + let llm_client = match &ctx.runtime.llm_client_factory { + Some(factory) => factory(&session_config.model_name) + .context("Failed to create LLM client from injected factory")?, + None => create_llm_client_from_model( + &session_config.model_name, + ctx.runtime.playback_path.clone(), + ctx.runtime.fast_playback, + ctx.runtime.record_path.clone(), + ) + .await + .context("Failed to create LLM client")?, + }; let project_manager = (ctx.runtime.project_manager_factory)(); let command_executor = (ctx.runtime.command_executor_factory)(session_id); @@ -1386,6 +1468,7 @@ async fn start_agent_impl( command_executor, permission_handler, tool_scope_override, + turn_recorder, ) .await .context("Failed to start agent")?; @@ -1419,6 +1502,7 @@ mod tests { Box::new(crate::mocks::create_command_executor_mock()) }), project_manager_factory: default_project_manager_factory(), + llm_client_factory: None, }); let (service, worker) = SessionService::new(manager.clone(), runtime, events); tokio::spawn(worker); @@ -1429,6 +1513,176 @@ mod tests { test_service_with_manager(root).0 } + /// A provider that streams its scripted text through the callback (like + /// a real provider) and returns it as the response — enough to drive a + /// complete agent turn without any network. + struct StreamingScriptedProvider { + text: String, + } + + #[async_trait::async_trait] + impl llm::LLMProvider for StreamingScriptedProvider { + async fn send_message( + &mut self, + _request: llm::LLMRequest, + streaming_callback: Option<&llm::StreamingCallback>, + ) -> Result { + if let Some(callback) = streaming_callback { + callback(&llm::StreamingChunk::Text(self.text.clone()))?; + callback(&llm::StreamingChunk::StreamingComplete)?; + } + Ok(llm::LLMResponse { + content: vec![llm::ContentBlock::new_text(&self.text)], + usage: llm::Usage { + input_tokens: 10, + output_tokens: 5, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + rate_limit_info: None, + }) + } + } + + /// Service whose agent runs use the injected LLM factory instead of the + /// configured providers. + fn test_service_with_llm( + root: &std::path::Path, + factory: LlmClientFactory, + ) -> (SessionService, Arc>) { + let events = EventStream::new(); + let persistence = FileSessionPersistence::new_with_root_dir(root.to_path_buf()); + let manager = Arc::new(Mutex::new(SessionManager::new( + persistence, + SessionConfig::default(), + "test-model".to_string(), + crate::tools::test_registry(), + events.clone(), + ))); + let runtime = Arc::new(AgentRuntimeOptions { + record_path: None, + playback_path: None, + fast_playback: false, + command_executor_factory: Arc::new(|_| { + Box::new(crate::mocks::create_command_executor_mock()) + }), + project_manager_factory: default_project_manager_factory(), + llm_client_factory: Some(factory), + }); + let (service, worker) = SessionService::new(manager.clone(), runtime, events); + tokio::spawn(worker); + (service, manager) + } + + #[tokio::test(flavor = "multi_thread")] + async fn start_turn_if_idle_resolves_the_exact_outcome() { + let tmp = tempfile::tempdir().unwrap(); + let (service, _) = test_service_with_llm( + tmp.path(), + Arc::new(|_model| { + Ok(Box::new(StreamingScriptedProvider { + text: "Considered it carefully; done.".to_string(), + })) + }), + ); + let id = service.create_session(None, None).await.unwrap(); + + let dispatch = service + .start_turn_if_idle( + id.clone(), + crate::session::TurnRequest::text("please do the thing"), + ) + .await + .unwrap(); + let handle = match dispatch { + crate::session::TurnDispatch::Started(handle) => handle, + crate::session::TurnDispatch::Busy => panic!("fresh session reported busy"), + }; + assert_eq!(handle.session_id(), id); + + let outcome = handle.wait().await.unwrap(); + assert_eq!(outcome.status, crate::session::TurnStatus::Completed); + assert_eq!(outcome.final_response, "Considered it carefully; done."); + assert_eq!(outcome.usage.llm_requests, 1); + assert!(!outcome.user_preempted); + // The token delta comes from the persisted-state notifications. + let tokens = outcome.usage.tokens.expect("usage recorded"); + assert_eq!(tokens.output_tokens, 5); + + // The turn is over: the session is idle again and a second turn gets + // a distinct turn id. + match service + .start_turn_if_idle(id.clone(), crate::session::TurnRequest::text("again")) + .await + .unwrap() + { + crate::session::TurnDispatch::Started(second) => { + assert_ne!(second.turn_id(), outcome.turn_id); + let _ = second.wait().await.unwrap(); + } + crate::session::TurnDispatch::Busy => panic!("session still busy after outcome"), + } + } + + #[tokio::test] + async fn start_turn_if_idle_refuses_a_busy_session_without_queueing() { + let tmp = tempfile::tempdir().unwrap(); + let (service, manager) = test_service_with_manager(tmp.path()); + let id = service.create_session(None, None).await.unwrap(); + { + let mut manager = manager.lock().await; + manager + .get_session_mut(&id) + .unwrap() + .set_activity_state(crate::session::instance::SessionActivityState::AgentRunning); + } + + match service + .start_turn_if_idle(id.clone(), crate::session::TurnRequest::text("nope")) + .await + .unwrap() + { + crate::session::TurnDispatch::Busy => {} + crate::session::TurnDispatch::Started(_) => panic!("dispatched into a busy session"), + } + // Nothing was appended or queued. + let snapshot = service.load_session(id.clone(), None).await.unwrap(); + assert!(snapshot.messages.is_empty()); + assert_eq!(service.take_pending_message(id).await.unwrap(), None); + } + + #[tokio::test(flavor = "multi_thread")] + async fn a_failing_turn_still_resolves_with_a_failed_outcome() { + let tmp = tempfile::tempdir().unwrap(); + let (service, _) = test_service_with_llm( + tmp.path(), + Arc::new(|_model| { + Ok(Box::new(crate::mocks::MockLLMProvider::new(vec![Err( + anyhow::anyhow!("model exploded"), + )]))) + }), + ); + let id = service.create_session(None, None).await.unwrap(); + + let handle = match service + .start_turn_if_idle(id, crate::session::TurnRequest::text("try")) + .await + .unwrap() + { + crate::session::TurnDispatch::Started(handle) => handle, + crate::session::TurnDispatch::Busy => panic!("fresh session reported busy"), + }; + match handle.wait().await.unwrap().status { + crate::session::TurnStatus::Failed { error } => { + assert!( + error.contains("model exploded"), + "unexpected error: {error}" + ) + } + status => panic!("expected Failed, got {status:?}"), + } + } + #[tokio::test] async fn create_list_delete_session_roundtrip() { let tmp = tempfile::tempdir().unwrap(); @@ -1794,6 +2048,7 @@ mod tests { Box::new(crate::mocks::create_command_executor_mock()) }), project_manager_factory: default_project_manager_factory(), + llm_client_factory: None, }); let (service, worker) = SessionService::new(manager, runtime, events); drop(worker); // never spawned diff --git a/crates/code_assistant_core/src/session/turn.rs b/crates/code_assistant_core/src/session/turn.rs new file mode 100644 index 00000000..fc671873 --- /dev/null +++ b/crates/code_assistant_core/src/session/turn.rs @@ -0,0 +1,585 @@ +//! Typed, correlation-safe turn tracking (the ROADMAP's "exact turn handle"). +//! +//! [`SessionService::start_turn_if_idle`] is the atomic idle-only dispatch for +//! autonomous controllers. Unlike `try_send_user_message_if_idle`'s boolean it +//! returns a [`TurnHandle`] identifying the exact session and turn, which +//! resolves exactly once with a bounded [`TurnOutcome`] — the caller never +//! infers "its" turn from the broadcast event stream (a lagging subscriber +//! can drop transitions and mis-attribute completions). +//! +//! The [`TurnRecorder`] is the collection half: it is teed *synchronously* +//! into the session's event publisher, so it sees exactly what the agent +//! emitted for this run — narration fragments, tool lifecycle, resource +//! writes — without a lossy broadcast channel in between. The agent task +//! finishes the recorder when the run ends; aborting the task without a +//! finish resolves the outcome as failed via the recorder's `Drop`. +//! +//! [`SessionService::start_turn_if_idle`]: crate::session::SessionService::start_turn_if_idle + +use crate::persistence::DraftAttachment; +use crate::tools::core::ToolScope; +use crate::ui::UiEvent; +use agent_core::ui::{DisplayFragment, ToolStatus}; +use anyhow::Result; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; +use tokio::sync::oneshot; + +/// Everything the turn's visible narration may accumulate. Beyond this the +/// text is truncated — an outcome is a bounded summary, not a transcript. +const MAX_RESPONSE_LEN: usize = 64 * 1024; +/// Per-tool bound for the recorded output excerpt. +const MAX_TOOL_OUTPUT_LEN: usize = 8 * 1024; +/// Per-parameter bound for recorded tool parameter values. +const MAX_PARAMETER_LEN: usize = 2 * 1024; +/// How many tool invocations are recorded in detail. Further calls still +/// count in [`TurnUsage::tool_calls`]. +const MAX_TOOL_RECORDS: usize = 256; +/// How many resource writes are recorded. +const MAX_RESOURCE_RECORDS: usize = 1024; + +static NEXT_TURN_ID: AtomicU64 = AtomicU64::new(1); + +/// What to run for a controller-started turn. +#[derive(Debug, Clone, Default)] +pub struct TurnRequest { + pub message: String, + pub attachments: Vec, + /// Per-run tool scope override, like `send_user_message_scoped`. `None` + /// derives the scope from the session config as usual. + pub tool_scope: Option, +} + +impl TurnRequest { + pub fn text(message: impl Into) -> Self { + Self { + message: message.into(), + ..Default::default() + } + } + + pub fn with_tool_scope(mut self, scope: ToolScope) -> Self { + self.tool_scope = Some(scope); + self + } +} + +/// Result of the atomic idle-only dispatch. +pub enum TurnDispatch { + /// The session is running another turn; nothing was appended or queued. + Busy, + /// The turn was started; the handle resolves when it ends. + Started(TurnHandle), +} + +/// How the turn ended. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TurnStatus { + /// The agent loop finished normally. + Completed, + /// The run was stopped on request (`TurnHandle::cancel` / `request_stop`). + Cancelled, + /// The agent loop ended with an error, or the agent task died without + /// reporting an outcome. + Failed { error: String }, +} + +/// One recorded tool invocation of the turn (bounded). +#[derive(Debug, Clone)] +pub struct ToolRecord { + pub tool_id: String, + pub name: String, + pub status: ToolStatus, + /// The tool's short status message, when it reported one. + pub message: Option, + /// Bounded excerpt of the tool output. + pub output: Option, + /// Bounded parameter values in emission order (`replace` semantics of + /// streamed parameters applied). + pub parameters: Vec<(String, String)>, +} + +/// A resource the turn wrote, as reported by the runtime's resource events. +#[derive(Debug, Clone)] +pub struct ResourceRef { + pub project: String, + pub path: PathBuf, +} + +/// Bounded usage of the turn. +#[derive(Debug, Clone, Default)] +pub struct TurnUsage { + /// LLM requests the turn made (streaming starts, including retries). + pub llm_requests: u32, + /// Tool invocations the turn made (including ones beyond the detailed + /// record bound). + pub tool_calls: u32, + /// Wall time from dispatch to the terminal state. + pub wall_time: Duration, + /// Token usage of this turn: the session's total usage delta between + /// turn start and the last persisted state. `None` when no state was + /// persisted during the run (e.g. immediate failure). + pub tokens: Option, +} + +/// The bounded, typed result of one turn. Resolves exactly once per handle. +#[derive(Debug, Clone)] +pub struct TurnOutcome { + pub turn_id: u64, + pub status: TurnStatus, + /// The assistant's visible narration of the turn (plain-text fragments; + /// thinking is deliberately excluded), trimmed and bounded. + pub final_response: String, + /// Tool lifecycle records, bounded (see [`TurnUsage::tool_calls`] for + /// the full count). + pub tools: Vec, + /// Resources written during the turn. + pub resources_written: Vec, + /// A queued user message was absorbed into this turn while it ran — the + /// narration past that point answers the user, not only the controller. + pub user_preempted: bool, + pub usage: TurnUsage, +} + +/// Identifies one started turn and resolves once with its outcome. +pub struct TurnHandle { + session_id: String, + turn_id: u64, + service: crate::session::SessionService, + outcome: oneshot::Receiver, +} + +impl TurnHandle { + pub(crate) fn new( + session_id: String, + turn_id: u64, + service: crate::session::SessionService, + outcome: oneshot::Receiver, + ) -> Self { + Self { + session_id, + turn_id, + service, + outcome, + } + } + + pub fn session_id(&self) -> &str { + &self.session_id + } + + pub fn turn_id(&self) -> u64 { + self.turn_id + } + + /// Resolve the turn's outcome. A turn that errored still resolves (with + /// [`TurnStatus::Failed`]); `Err` means the outcome itself was lost, + /// which does not happen in an intact process. + pub async fn wait(self) -> Result { + self.outcome + .await + .map_err(|_| anyhow::anyhow!("turn outcome lost (agent task dropped its recorder)")) + } + + /// Ask the running agent to stop at its next checkpoint. The outcome + /// still resolves (normally as [`TurnStatus::Cancelled`]). + pub async fn cancel(&self) -> Result<()> { + self.service.request_stop(self.session_id.clone()).await + } +} + +/// Collection half of a turn: teed synchronously into the session's event +/// publisher for the duration of one agent run. Constructed only by +/// [`SessionService::start_turn_if_idle`]; public because it appears in the +/// manager's and instance's (semi-internal) signatures. +/// +/// [`SessionService::start_turn_if_idle`]: crate::session::SessionService::start_turn_if_idle +pub struct TurnRecorder { + turn_id: u64, + started: Instant, + inner: Mutex, +} + +struct RecorderInner { + sender: Option>, + response: String, + tools: Vec, + resources: Vec, + user_preempted: bool, + llm_requests: u32, + tool_calls: u32, + cancelled: bool, + /// The session's total usage when the turn was armed. + baseline_usage: llm::Usage, + /// The session's total usage after the run (supplied at finish). + latest_usage: Option, +} + +impl TurnRecorder { + /// Arm a recorder for a new turn. `baseline_usage` is the session's + /// total usage before the turn (for the token delta). + pub(crate) fn arm(baseline_usage: llm::Usage) -> (std::sync::Arc, TurnParts) { + let (tx, rx) = oneshot::channel(); + let turn_id = NEXT_TURN_ID.fetch_add(1, Ordering::Relaxed); + let recorder = std::sync::Arc::new(Self { + turn_id, + started: Instant::now(), + inner: Mutex::new(RecorderInner { + sender: Some(tx), + response: String::new(), + tools: Vec::new(), + resources: Vec::new(), + user_preempted: false, + llm_requests: 0, + tool_calls: 0, + cancelled: false, + baseline_usage, + latest_usage: None, + }), + }); + ( + recorder, + TurnParts { + turn_id, + outcome: rx, + }, + ) + } + + /// Tee of the publisher's `send_event`. + pub(crate) fn observe(&self, event: &UiEvent) { + let mut inner = self.inner.lock().expect("turn recorder lock poisoned"); + match event { + UiEvent::StreamingStarted { .. } => inner.llm_requests += 1, + UiEvent::StreamingStopped { + cancelled: true, .. + } => inner.cancelled = true, + // Published by the agent when it absorbs a queued user message + // mid-run (the turn's own user message is announced by the + // service before the run, not through the run's publisher). + UiEvent::DisplayUserInput { .. } => inner.user_preempted = true, + UiEvent::StartTool { name, id } => { + inner.tool_calls += 1; + if inner.tools.len() < MAX_TOOL_RECORDS { + inner.tools.push(ToolRecord { + tool_id: id.clone(), + name: name.clone(), + status: ToolStatus::Pending, + message: None, + output: None, + parameters: Vec::new(), + }); + } + } + UiEvent::UpdateToolParameter { + tool_id, + name, + value, + replace, + } => { + if let Some(tool) = inner.tools.iter_mut().find(|t| &t.tool_id == tool_id) { + match tool.parameters.iter_mut().find(|(n, _)| n == name) { + Some((_, existing)) => { + if *replace { + existing.clear(); + } + push_bounded(existing, value, MAX_PARAMETER_LEN); + } + None => { + let mut recorded = String::new(); + push_bounded(&mut recorded, value, MAX_PARAMETER_LEN); + tool.parameters.push((name.clone(), recorded)); + } + } + } + } + UiEvent::UpdateToolStatus { + tool_id, + status, + message, + output, + .. + } => { + if let Some(tool) = inner.tools.iter_mut().find(|t| &t.tool_id == tool_id) { + tool.status = *status; + if message.is_some() { + tool.message = message.clone(); + } + if let Some(output) = output { + let recorded = tool.output.get_or_insert_with(String::new); + recorded.clear(); + push_bounded(recorded, output, MAX_TOOL_OUTPUT_LEN); + } + } + } + UiEvent::ResourceWritten { project, path } => { + if inner.resources.len() < MAX_RESOURCE_RECORDS { + inner.resources.push(ResourceRef { + project: project.clone(), + path: path.clone(), + }); + } + } + _ => {} + } + } + + /// Tee of the publisher's `display_fragment`. Only visible narration is + /// recorded — thinking is never part of an outcome. + pub(crate) fn observe_fragment(&self, fragment: &DisplayFragment) { + if let DisplayFragment::PlainText(text) = fragment { + let mut inner = self.inner.lock().expect("turn recorder lock poisoned"); + let response = &mut inner.response; + push_bounded(response, text, MAX_RESPONSE_LEN); + } + } + + /// Resolve the outcome. `error` is the agent task's terminal error, if + /// any; a stop request recorded during the run wins over `Completed`. + /// `final_total_usage` is the session's total usage after the run (read + /// from the persisted state, which the agent saves synchronously) — the + /// outcome's token usage is its delta against the armed baseline. + pub(crate) fn finish(&self, error: Option, final_total_usage: Option) { + let mut inner = self.inner.lock().expect("turn recorder lock poisoned"); + inner.latest_usage = final_total_usage.or(inner.latest_usage.take()); + let Some(sender) = inner.sender.take() else { + return; + }; + let status = match error { + Some(error) => TurnStatus::Failed { error }, + None if inner.cancelled => TurnStatus::Cancelled, + None => TurnStatus::Completed, + }; + let tokens = inner.latest_usage.as_ref().map(|latest| llm::Usage { + input_tokens: latest + .input_tokens + .saturating_sub(inner.baseline_usage.input_tokens), + output_tokens: latest + .output_tokens + .saturating_sub(inner.baseline_usage.output_tokens), + cache_creation_input_tokens: latest + .cache_creation_input_tokens + .saturating_sub(inner.baseline_usage.cache_creation_input_tokens), + cache_read_input_tokens: latest + .cache_read_input_tokens + .saturating_sub(inner.baseline_usage.cache_read_input_tokens), + }); + let outcome = TurnOutcome { + turn_id: self.turn_id, + status, + final_response: inner.response.trim().to_string(), + tools: std::mem::take(&mut inner.tools), + resources_written: std::mem::take(&mut inner.resources), + user_preempted: inner.user_preempted, + usage: TurnUsage { + llm_requests: inner.llm_requests, + tool_calls: inner.tool_calls, + wall_time: self.started.elapsed(), + tokens, + }, + }; + // The handle may already be gone (caller dropped it) — fine. + let _ = sender.send(outcome); + } +} + +impl Drop for TurnRecorder { + /// An aborted agent task (e.g. `terminate_agent`) never reaches its + /// finish; resolve the outcome as failed instead of losing it. + fn drop(&mut self) { + if self + .inner + .lock() + .map(|inner| inner.sender.is_some()) + .unwrap_or(false) + { + self.finish( + Some("agent task ended without reporting an outcome".to_string()), + None, + ); + } + } +} + +/// The handle-side parts produced by [`TurnRecorder::arm`]. +pub(crate) struct TurnParts { + pub(crate) turn_id: u64, + pub(crate) outcome: oneshot::Receiver, +} + +fn push_bounded(target: &mut String, addition: &str, bound: usize) { + let remaining = bound.saturating_sub(target.len()); + if remaining == 0 { + return; + } + if addition.len() <= remaining { + target.push_str(addition); + } else { + let mut cut = remaining; + while !addition.is_char_boundary(cut) { + cut -= 1; + } + target.push_str(&addition[..cut]); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn armed() -> (std::sync::Arc, oneshot::Receiver) { + let (recorder, parts) = TurnRecorder::arm(llm::Usage::zero()); + (recorder, parts.outcome) + } + + fn usage(input: u32, output: u32) -> llm::Usage { + llm::Usage { + input_tokens: input, + output_tokens: output, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + } + } + + #[test] + fn records_narration_tools_and_resources() { + let (recorder, outcome) = armed(); + + recorder.observe(&UiEvent::StreamingStarted { + request_id: 1, + node_id: 1, + }); + recorder.observe_fragment(&DisplayFragment::PlainText("Working on it. ".into())); + recorder.observe_fragment(&DisplayFragment::ThinkingText { + text: "secret reasoning".into(), + duration_seconds: None, + }); + recorder.observe(&UiEvent::StartTool { + name: "write_file".into(), + id: "t1".into(), + }); + recorder.observe(&UiEvent::UpdateToolParameter { + tool_id: "t1".into(), + name: "path".into(), + value: "notes.md".into(), + replace: false, + }); + recorder.observe(&UiEvent::UpdateToolStatus { + tool_id: "t1".into(), + status: ToolStatus::Success, + message: Some("wrote notes.md".into()), + output: Some("ok".into()), + styled_output: None, + duration_seconds: None, + images: Vec::new(), + }); + recorder.observe(&UiEvent::ResourceWritten { + project: "workspace".into(), + path: PathBuf::from("notes.md"), + }); + recorder.observe_fragment(&DisplayFragment::PlainText("Done.".into())); + recorder.finish(None, None); + + let outcome = outcome.blocking_recv().unwrap(); + assert_eq!(outcome.status, TurnStatus::Completed); + assert_eq!(outcome.final_response, "Working on it. Done."); + assert!(!outcome.final_response.contains("secret")); + assert_eq!(outcome.tools.len(), 1); + assert_eq!(outcome.tools[0].name, "write_file"); + assert_eq!(outcome.tools[0].status, ToolStatus::Success); + assert_eq!( + outcome.tools[0].parameters, + vec![("path".to_string(), "notes.md".to_string())] + ); + assert_eq!(outcome.resources_written.len(), 1); + assert_eq!(outcome.usage.llm_requests, 1); + assert_eq!(outcome.usage.tool_calls, 1); + assert!(!outcome.user_preempted); + } + + #[test] + fn tokens_are_the_delta_against_the_baseline() { + let (recorder, parts) = TurnRecorder::arm(usage(100, 50)); + recorder.finish(None, Some(usage(160, 80))); + let outcome = parts.outcome.blocking_recv().unwrap(); + let tokens = outcome.usage.tokens.expect("delta recorded"); + assert_eq!(tokens.input_tokens, 60); + assert_eq!(tokens.output_tokens, 30); + } + + #[test] + fn a_stop_request_resolves_as_cancelled_and_an_error_as_failed() { + let (recorder, outcome) = armed(); + recorder.observe(&UiEvent::StreamingStopped { + id: 1, + cancelled: true, + error: None, + }); + recorder.finish(None, None); + assert_eq!( + outcome.blocking_recv().unwrap().status, + TurnStatus::Cancelled + ); + + let (recorder, outcome) = armed(); + recorder.finish(Some("model exploded".into()), None); + assert_eq!( + outcome.blocking_recv().unwrap().status, + TurnStatus::Failed { + error: "model exploded".into() + } + ); + } + + #[test] + fn an_absorbed_user_message_marks_preemption() { + let (recorder, outcome) = armed(); + recorder.observe(&UiEvent::DisplayUserInput { + content: "actually, stop".into(), + attachments: Vec::new(), + node_id: None, + }); + recorder.finish(None, None); + assert!(outcome.blocking_recv().unwrap().user_preempted); + } + + #[test] + fn dropping_an_unfinished_recorder_fails_the_outcome_instead_of_losing_it() { + let (recorder, outcome) = armed(); + drop(recorder); + match outcome.blocking_recv().unwrap().status { + TurnStatus::Failed { error } => assert!(error.contains("without reporting")), + status => panic!("expected Failed, got {status:?}"), + } + } + + #[test] + fn narration_and_tool_output_are_bounded() { + let (recorder, outcome) = armed(); + let chunk = "x".repeat(50 * 1024); + recorder.observe_fragment(&DisplayFragment::PlainText(chunk.clone())); + recorder.observe_fragment(&DisplayFragment::PlainText(chunk.clone())); + recorder.observe(&UiEvent::StartTool { + name: "execute_command".into(), + id: "t1".into(), + }); + recorder.observe(&UiEvent::UpdateToolStatus { + tool_id: "t1".into(), + status: ToolStatus::Success, + message: None, + output: Some(chunk), + styled_output: None, + duration_seconds: None, + images: Vec::new(), + }); + recorder.finish(None, None); + let outcome = outcome.blocking_recv().unwrap(); + assert_eq!(outcome.final_response.len(), MAX_RESPONSE_LEN); + assert_eq!( + outcome.tools[0].output.as_ref().unwrap().len(), + MAX_TOOL_OUTPUT_LEN + ); + } +} diff --git a/crates/ui_terminal/src/app.rs b/crates/ui_terminal/src/app.rs index 88d33182..7f530096 100644 --- a/crates/ui_terminal/src/app.rs +++ b/crates/ui_terminal/src/app.rs @@ -1195,6 +1195,7 @@ impl TerminalTuiApp { command_executor_factory, project_manager_factory: code_assistant_core::session::service::default_project_manager_factory(), + llm_client_factory: None, }), events, ); From a8bc0a19a123d21900aca25a5afcc96111c89125 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20A=C3=9Fmus?= Date: Wed, 15 Jul 2026 23:53:24 +0200 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20agent=5Forchestration=20=E2=80=94?= =?UTF-8?q?=20shared=20goal/wait=20domain=20(from=20PAL)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generic half of PAL's durable-goal feature moves upstream, per the ROADMAP's 'Now: converge goals' slice: Goal/GoalState/CompletionContract/ Budget/Subgoal, the attempt/evidence ledger with claim+revision atomicity, the deterministic controller fold (apply_evaluation → Continue/Wait/ AwaitInput/Blocked/Done/Failed), typed wait barriers (WaitKind, the armed → satisfied/timed-out/cancelled machine, clock-only predicates), the GoalEvaluator/WaitProbe seams, the bounded LLM evaluator over an injected provider, and goal-turn framing. All 78 deterministic tests moved with it and run without an LLM or frontend. Host-neutral owner binding: SessionKey generalizes to OwnerKey (a plain hierarchical string key; PAL re-exports it as its lane key), the goals/waits owner field deserializes legacy 'lane'-keyed JSON via a serde alias. GoalRepository/WaitRepository traits capture the store surface: the bundled JSON stores (explicit path, no global config) implement them today; PAL's planned transactional repository implements the same traits instead of migrating persistence twice. PAL keeps everything deployment-shaped: lane binding, gateway passes (goal_pass/wait_pass), startup sweeps, durable probes, delivery. --- Cargo.lock | 15 + Cargo.toml | 2 +- crates/agent_orchestration/Cargo.toml | 18 + crates/agent_orchestration/src/goal_eval.rs | 543 +++++ crates/agent_orchestration/src/goals.rs | 2136 +++++++++++++++++++ crates/agent_orchestration/src/lib.rs | 48 + crates/agent_orchestration/src/waits.rs | 1056 +++++++++ 7 files changed, 3817 insertions(+), 1 deletion(-) create mode 100644 crates/agent_orchestration/Cargo.toml create mode 100644 crates/agent_orchestration/src/goal_eval.rs create mode 100644 crates/agent_orchestration/src/goals.rs create mode 100644 crates/agent_orchestration/src/lib.rs create mode 100644 crates/agent_orchestration/src/waits.rs diff --git a/Cargo.lock b/Cargo.lock index b11864ee..d62134e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -119,6 +119,21 @@ dependencies = [ "tracing", ] +[[package]] +name = "agent_orchestration" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "llm", + "serde", + "serde_json", + "tempfile", + "tokio", + "tracing", +] + [[package]] name = "ahash" version = "0.8.12" diff --git a/Cargo.toml b/Cargo.toml index 29ed773a..0dca0855 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/agent_core", "crates/code_assistant", "crates/code_assistant_core", "crates/ui_acp", "crates/ui_gpui", "crates/ui_terminal", "crates/command_executor", "crates/fs_explorer", "crates/git", "crates/llm", "crates/mcp_client", "crates/mcp_server", "crates/pty_session", "crates/sandbox", "crates/terminal", "crates/terminal_output", "crates/terminal_test_app", "crates/terminal_view", "crates/tools_core", "crates/web"] +members = ["crates/agent_core", "crates/agent_orchestration", "crates/code_assistant", "crates/code_assistant_core", "crates/ui_acp", "crates/ui_gpui", "crates/ui_terminal", "crates/command_executor", "crates/fs_explorer", "crates/git", "crates/llm", "crates/mcp_client", "crates/mcp_server", "crates/pty_session", "crates/sandbox", "crates/terminal", "crates/terminal_output", "crates/terminal_test_app", "crates/terminal_view", "crates/tools_core", "crates/web"] resolver = "2" diff --git a/crates/agent_orchestration/Cargo.toml b/crates/agent_orchestration/Cargo.toml new file mode 100644 index 00000000..2fb908b4 --- /dev/null +++ b/crates/agent_orchestration/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "agent_orchestration" +version = "0.1.0" +edition = "2021" + +[dependencies] +anyhow = "1.0" +async-trait = "0.1" +chrono = { version = "0.4", features = ["serde"] } +llm = { path = "../llm" } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +tokio = { version = "1", features = ["sync"] } +tracing = "0.1" + +[dev-dependencies] +tempfile = "3" +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } diff --git a/crates/agent_orchestration/src/goal_eval.rs b/crates/agent_orchestration/src/goal_eval.rs new file mode 100644 index 00000000..74441328 --- /dev/null +++ b/crates/agent_orchestration/src/goal_eval.rs @@ -0,0 +1,543 @@ +//! The production [`GoalEvaluator`]: judge one autonomous goal turn against +//! the goal's completion contract with a bounded model call, mapping a +//! [`TurnOutcome`] to an [`Evaluation`]. +//! +//! This is the LLM-shaped seam the bounded controller trusts (see +//! [`crate::goals`]). It is a *judge*, not the working agent: given the +//! contract and the agent's own account of the turn, it decides one verdict — +//! and is deliberately strict about `Satisfied`, which is the only path a goal +//! has to `Done`. Kept parallel to `pal_observations`' `LlmExtractor`: a thin +//! wrapper over the `llm` crate, so any provider configured in the shared +//! `models.json` works. Unlike the extractor it runs in an async context (the +//! controller pass), so it calls the provider directly rather than through a +//! blocking bridge. + +use crate::goals::{AttemptVerdict, Evaluation, Goal, GoalEvaluator, TurnOutcome}; +use crate::waits::{WaitKind, WaitRequest}; +use chrono::NaiveDateTime; +use serde::Deserialize; +use std::sync::atomic::{AtomicU64, Ordering}; +use tokio::sync::Mutex; + +const DEFAULT_EVALUATION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); + +/// The judge's discipline: strict about success, honest about obstacles. +const SYSTEM_PROMPT: &str = "\ +You are the evaluator of an autonomous assistant that is pursuing a durable \ +goal on its user's behalf. You are given the goal's completion contract and \ +the assistant's own report of what it did this turn. Judge the turn against \ +the contract and return exactly one verdict. + +Respond with a JSON object and nothing else: +{\"verdict\": \"progressed\" | \"satisfied\" | \"blocked\" | \"needs_input\" | \"stopped\" | \"waiting\", \ +\"summary\": \"...\", \"artifacts\": [\"...\"], \"evidence\": [\"...\"], \ +\"completed_subgoals\": [\"exact checklist text\"]} + +- verdict: + - \"satisfied\": the contract's verification is DEMONSTRABLY met by the \ +evidence in the report. Be strict — never satisfied merely because the \ +assistant says it is done, or because the work looks plausible. If the \ +verification was not actually run and shown to pass, it is not satisfied. A \ +constraint or boundary violation also rules out satisfaction. + - \"progressed\": real, concrete progress was made, but the contract is not \ +yet fully met. + - \"blocked\": a genuine external obstacle stopped the work — something the \ +assistant cannot clear by itself (a missing document, a failing external \ +service, a required credential). Not for ordinary difficulty. + - \"needs_input\": the work cannot continue without a decision or answer \ +from the user. + - \"stopped\": the completion contract's explicit Stop-if condition has been \ +met. Continuing would violate the user's envelope; cite the observed fact in \ +the summary. + - \"waiting\": the turn set up a durable dependency and there is genuinely \ +nothing more to do until it resolves — a background build or process must \ +finish, a scheduled job or child agent must complete, an external event or a \ +reply from the user must arrive, or work should simply resume at a later time. \ +Prefer this over \"progressed\" when the next step is only to wait: it parks \ +the goal so it burns no turns polling. Do NOT use it to avoid hard work. When \ +you use it, include a \"wait\" object naming the barrier. +- wait: only with the \"waiting\" verdict. A JSON object naming exactly one \ +barrier (add an optional ISO-8601 \"timeout\" after which the goal should wake \ +even if the barrier has not fired): + - {\"barrier\": \"until\", \"at\": \"2026-07-14T18:00:00\"} — resume at a time. + - {\"barrier\": \"process_exit\", \"handle\": \"\"} — a background \ +process exits. + - {\"barrier\": \"output_pattern\", \"handle\": \"\", \"pattern\": \ +\"BUILD SUCCESSFUL\"} — a background process prints a pattern. + - {\"barrier\": \"job_completion\", \"job_id\": \"\"} — a scheduled job \ +finishes. + - {\"barrier\": \"sub_agent_completion\", \"agent_id\": \"\"} — a child \ +agent finishes. + - {\"barrier\": \"event\", \"key\": \"\"} — an external event arrives. + - {\"barrier\": \"human_input\"} — the user replies on this channel. +- summary: a short, factual account of what the turn did (one or two \ +sentences). No speculation, no chain-of-thought. +- artifacts: file paths or references the turn produced or changed, as stated \ +in the report. [] if none. +- evidence: the verification output or checks that support your verdict \ +(command results, file checks). [] if none. +- completed_subgoals: exact text of checklist entries demonstrably completed \ +by this turn's evidence. [] if none."; + +/// Frame the contract and the turn's report for the judge. Kept compact and +/// factual — the judge sees the same evidence the ledger will record. +fn user_prompt(goal: &Goal, turn: &TurnOutcome) -> String { + let c = &goal.contract; + let mut lines = vec![ + format!("Goal: {}", goal.objective), + String::new(), + "Completion contract:".to_string(), + format!("- Done when: {}", c.outcome), + format!("- Verify by: {}", c.verification), + format!("- Stop if: {}", c.stop_condition), + ]; + for constraint in &c.constraints { + lines.push(format!("- Constraint: {constraint}")); + } + for boundary in &c.boundaries { + lines.push(format!("- Boundary: {boundary}")); + } + if !goal.subgoals.is_empty() { + lines.push(String::new()); + lines.push("Checklist:".to_string()); + for subgoal in &goal.subgoals { + lines.push(format!( + "- [{}] {}", + if subgoal.done { "x" } else { " " }, + subgoal.description + )); + } + } + + lines.push(String::new()); + lines.push("The assistant's report of this turn:".to_string()); + lines.push(if turn.assistant_summary.trim().is_empty() { + "(the assistant reported nothing)".to_string() + } else { + turn.assistant_summary.trim().to_string() + }); + if !turn.artifacts.is_empty() { + lines.push(format!("Artifacts named: {}", turn.artifacts.join(", "))); + } + if !turn.verification.is_empty() { + lines.push(format!( + "Verification output: {}", + turn.verification.join("\n") + )); + } + lines.join("\n") +} + +#[derive(Deserialize)] +struct EvalResponse { + verdict: AttemptVerdict, + summary: String, + #[serde(default)] + artifacts: Vec, + #[serde(default)] + evidence: Vec, + #[serde(default)] + completed_subgoals: Vec, + #[serde(default)] + wait: Option, +} + +/// The evaluator's `wait` object: a flat, model-friendly shape (the barrier's +/// fields inline with an optional `timeout`) that lifts into a [`WaitRequest`]. +/// [`WaitKind`]'s own `barrier` tag drives which fields are required. +#[derive(Deserialize)] +struct WaitRequestDto { + #[serde(flatten)] + kind: WaitKind, + #[serde(default)] + timeout: Option, +} + +impl WaitRequestDto { + fn into_request(self) -> WaitRequest { + WaitRequest { + kind: self.kind, + timeout: self.timeout, + } + } +} + +/// Pull the JSON object out of a model response that may be wrapped in code +/// fences or chatter — the same tolerance as the observation extractor. +/// Anything between the first `{` and the last `}` is given to the parser, +/// which stays the arbiter of validity. +fn parse_evaluation(text: &str) -> anyhow::Result { + let (Some(start), Some(end)) = (text.find('{'), text.rfind('}')) else { + anyhow::bail!("evaluator response contains no JSON object: {text:?}"); + }; + anyhow::ensure!( + start < end, + "evaluator response contains no JSON object: {text:?}" + ); + let parsed: EvalResponse = serde_json::from_str(&text[start..=end]) + .map_err(|e| anyhow::anyhow!("evaluator response is not a valid verdict object: {e}"))?; + anyhow::ensure!( + parsed.verdict != AttemptVerdict::Error, + "evaluator may not emit the controller-only error verdict" + ); + let summary = parsed.summary.trim().to_string(); + anyhow::ensure!(!summary.is_empty(), "evaluator returned an empty summary"); + Ok(Evaluation { + verdict: parsed.verdict, + summary, + artifacts: clean(parsed.artifacts), + evidence: clean(parsed.evidence), + completed_subgoals: clean(parsed.completed_subgoals), + wait: parsed.wait.map(WaitRequestDto::into_request), + }) +} + +fn clean(items: Vec) -> Vec { + items + .into_iter() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() +} + +/// Production evaluator: one `send_message` per turn against a model from the +/// shared configuration (built via `llm::factory` in `pal::runtime`). +pub struct LlmGoalEvaluator { + provider: Mutex>, + request_counter: AtomicU64, + timeout: std::time::Duration, +} + +impl LlmGoalEvaluator { + pub fn new(provider: Box) -> Self { + Self { + provider: Mutex::new(provider), + request_counter: AtomicU64::new(1), + timeout: DEFAULT_EVALUATION_TIMEOUT, + } + } + + #[cfg(test)] + fn with_timeout(mut self, timeout: std::time::Duration) -> Self { + self.timeout = timeout; + self + } +} + +#[async_trait::async_trait] +impl GoalEvaluator for LlmGoalEvaluator { + async fn evaluate(&self, goal: &Goal, turn: &TurnOutcome) -> anyhow::Result { + let request = llm::LLMRequest { + messages: vec![llm::Message::new_user(user_prompt(goal, turn))], + system_prompt: SYSTEM_PROMPT.to_string(), + request_id: self.request_counter.fetch_add(1, Ordering::Relaxed), + session_id: format!("pal-goal-eval-{}", goal.id), + ..Default::default() + }; + let mut provider = self.provider.lock().await; + let response = tokio::time::timeout(self.timeout, provider.send_message(request, None)) + .await + .map_err(|_| anyhow::anyhow!("goal evaluation timed out after {:?}", self.timeout))? + .map_err(|e| anyhow::anyhow!("goal evaluation model call failed: {e:#}"))?; + let text: String = response + .content + .iter() + .filter_map(|block| match block { + llm::ContentBlock::Text { text, .. } => Some(text.as_str()), + _ => None, + }) + .collect(); + parse_evaluation(&text) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::goals::{Budget, CompletionContract}; + use crate::OwnerKey; + use chrono::NaiveDate; + use std::sync::{Arc, Mutex as StdMutex}; + + fn goal() -> Goal { + Goal::new( + "goal-1", + OwnerKey::from_parts(&["telegram", "private", "42"]), + "prepare the 2025 tax return", + CompletionContract::new( + "a filled ELSTER draft", + "the draft file validates", + "give up if a required document is missing", + ), + Budget::turns(5), + NaiveDate::from_ymd_opt(2026, 7, 14) + .unwrap() + .and_hms_opt(9, 0, 0) + .unwrap(), + ) + } + + fn outcome(summary: &str) -> TurnOutcome { + TurnOutcome { + assistant_summary: summary.to_string(), + artifacts: Vec::new(), + verification: Vec::new(), + } + } + + /// Replies with a fixed text; records the requests it saw. + struct ScriptedProvider { + reply: &'static str, + fail: bool, + seen: Arc>>, + } + + impl ScriptedProvider { + fn new(reply: &'static str) -> (Self, Arc>>) { + let seen = Arc::new(StdMutex::new(Vec::new())); + ( + Self { + reply, + fail: false, + seen: seen.clone(), + }, + seen, + ) + } + } + + #[async_trait::async_trait] + impl llm::LLMProvider for ScriptedProvider { + async fn send_message( + &mut self, + request: llm::LLMRequest, + _callback: Option<&llm::StreamingCallback>, + ) -> anyhow::Result { + self.seen.lock().unwrap().push(request); + if self.fail { + anyhow::bail!("model unreachable"); + } + Ok(llm::LLMResponse { + content: vec![llm::ContentBlock::Text { + text: self.reply.to_string(), + start_time: None, + end_time: None, + }], + usage: llm::Usage::zero(), + rate_limit_info: None, + }) + } + } + + fn evaluator(reply: &'static str) -> (LlmGoalEvaluator, Arc>>) { + let (provider, seen) = ScriptedProvider::new(reply); + (LlmGoalEvaluator::new(Box::new(provider)), seen) + } + + #[tokio::test] + async fn sends_the_contract_and_the_turn_report() { + let (evaluator, seen) = evaluator(r#"{"verdict": "progressed", "summary": "did a step"}"#); + evaluator + .evaluate(&goal(), &outcome("downloaded the receipts")) + .await + .unwrap(); + + let requests = seen.lock().unwrap(); + assert_eq!(requests.len(), 1); + let req = &requests[0]; + assert!( + req.system_prompt.contains("JSON object"), + "{}", + req.system_prompt + ); + let llm::MessageContent::Text(prompt) = &req.messages[0].content else { + panic!("expected a plain text message"); + }; + assert!(prompt.contains("prepare the 2025 tax return"), "{prompt}"); + assert!(prompt.contains("the draft file validates"), "{prompt}"); + assert!(prompt.contains("downloaded the receipts"), "{prompt}"); + } + + #[tokio::test] + async fn parses_a_clean_verdict_object() { + let (evaluator, _) = evaluator( + r#"{"verdict": "satisfied", "summary": "draft validated", "artifacts": ["elster/draft.xml"], "evidence": ["validation passed"]}"#, + ); + let eval = evaluator + .evaluate(&goal(), &outcome("ran the check")) + .await + .unwrap(); + assert_eq!(eval.verdict, AttemptVerdict::Satisfied); + assert_eq!(eval.summary, "draft validated"); + assert_eq!(eval.artifacts, ["elster/draft.xml"]); + assert_eq!(eval.evidence, ["validation passed"]); + } + + #[tokio::test] + async fn subgoals_are_judged_and_completed_from_evidence() { + let (evaluator, seen) = evaluator( + r#"{"verdict":"progressed","summary":"receipts collected","completed_subgoals":["collect receipts"]}"#, + ); + let mut goal = goal(); + goal.subgoals = vec![crate::goals::Subgoal::new("collect receipts")]; + + let evaluation = evaluator + .evaluate(&goal, &outcome("downloaded every receipt")) + .await + .unwrap(); + + assert_eq!(evaluation.completed_subgoals, ["collect receipts"]); + let requests = seen.lock().unwrap(); + let llm::MessageContent::Text(prompt) = &requests[0].messages[0].content else { + panic!("expected text prompt"); + }; + assert!(prompt.contains("[ ] collect receipts"), "{prompt}"); + } + + #[tokio::test] + async fn tolerates_code_fences_and_chatter() { + let (evaluator, _) = evaluator( + "Here is my judgement:\n```json\n{\n \"verdict\": \"blocked\",\n \"summary\": \"the 2024 statement is missing\"\n}\n```\n", + ); + let eval = evaluator + .evaluate(&goal(), &outcome("looked for the statement")) + .await + .unwrap(); + assert_eq!(eval.verdict, AttemptVerdict::Blocked); + assert_eq!(eval.summary, "the 2024 statement is missing"); + assert!(eval.artifacts.is_empty()); + } + + #[tokio::test] + async fn parses_a_waiting_verdict_with_a_flat_barrier() { + let (evaluator, _) = evaluator( + r#"{"verdict":"waiting","summary":"started the build","wait":{"barrier":"output_pattern","handle":"pty-3","pattern":"BUILD SUCCESSFUL","timeout":"2026-07-14T18:00:00"}}"#, + ); + let eval = evaluator + .evaluate(&goal(), &outcome("kicked off the build in the background")) + .await + .unwrap(); + assert_eq!(eval.verdict, AttemptVerdict::Waiting); + let request = eval.wait.expect("a waiting verdict carries a barrier"); + assert_eq!( + request.kind, + WaitKind::OutputPattern { + handle: "pty-3".into(), + pattern: "BUILD SUCCESSFUL".into(), + } + ); + assert_eq!( + request.timeout, + Some( + NaiveDateTime::parse_from_str("2026-07-14 18:00:00", "%Y-%m-%d %H:%M:%S").unwrap() + ) + ); + } + + #[tokio::test] + async fn a_human_input_barrier_needs_no_extra_fields() { + let (evaluator, _) = evaluator( + r#"{"verdict":"waiting","summary":"asked the user to confirm the account","wait":{"barrier":"human_input"}}"#, + ); + let eval = evaluator + .evaluate(&goal(), &outcome("posed the question")) + .await + .unwrap(); + assert_eq!(eval.verdict, AttemptVerdict::Waiting); + let request = eval.wait.expect("a waiting verdict carries a barrier"); + assert_eq!(request.kind, WaitKind::HumanInput); + assert!(request.timeout.is_none()); + } + + #[tokio::test] + async fn needs_input_verdict_maps_through() { + let (evaluator, _) = + evaluator(r#"{"verdict": "needs_input", "summary": "which bank account?"}"#); + let eval = evaluator + .evaluate(&goal(), &outcome("hit a fork")) + .await + .unwrap(); + assert_eq!(eval.verdict, AttemptVerdict::NeedsInput); + } + + #[tokio::test] + async fn stop_condition_verdict_maps_through() { + let (evaluator, _) = evaluator( + r#"{"verdict": "stopped", "summary": "the required document is unavailable"}"#, + ); + let eval = evaluator + .evaluate(&goal(), &outcome("confirmed the document is unavailable")) + .await + .unwrap(); + assert_eq!(eval.verdict, AttemptVerdict::Stopped); + } + + #[tokio::test] + async fn a_response_without_a_json_object_is_an_error() { + let (evaluator, _) = evaluator("I think it made good progress this turn."); + let err = evaluator + .evaluate(&goal(), &outcome("x")) + .await + .unwrap_err(); + assert!(err.to_string().contains("no JSON object"), "{err}"); + } + + #[tokio::test] + async fn an_unknown_verdict_is_an_error() { + let (evaluator, _) = evaluator(r#"{"verdict": "maybe", "summary": "unsure"}"#); + let err = evaluator + .evaluate(&goal(), &outcome("x")) + .await + .unwrap_err(); + assert!( + err.to_string().contains("not a valid verdict object"), + "{err}" + ); + } + + #[tokio::test] + async fn an_empty_summary_is_an_error() { + let (evaluator, _) = evaluator(r#"{"verdict": "progressed", "summary": " "}"#); + let err = evaluator + .evaluate(&goal(), &outcome("x")) + .await + .unwrap_err(); + assert!(err.to_string().contains("empty summary"), "{err}"); + } + + #[tokio::test] + async fn a_failing_model_call_surfaces_as_an_error() { + let (mut provider, _) = ScriptedProvider::new("{}"); + provider.fail = true; + let evaluator = LlmGoalEvaluator::new(Box::new(provider)); + let err = evaluator + .evaluate(&goal(), &outcome("x")) + .await + .unwrap_err(); + assert!(err.to_string().contains("model call failed"), "{err}"); + } + + #[tokio::test] + async fn a_wedged_model_call_is_time_bounded() { + struct WedgedProvider; + + #[async_trait::async_trait] + impl llm::LLMProvider for WedgedProvider { + async fn send_message( + &mut self, + _request: llm::LLMRequest, + _callback: Option<&llm::StreamingCallback>, + ) -> anyhow::Result { + std::future::pending().await + } + } + + let evaluator = LlmGoalEvaluator::new(Box::new(WedgedProvider)) + .with_timeout(std::time::Duration::from_millis(10)); + let err = evaluator + .evaluate(&goal(), &outcome("x")) + .await + .unwrap_err(); + assert!(err.to_string().contains("timed out"), "{err}"); + } +} diff --git a/crates/agent_orchestration/src/goals.rs b/crates/agent_orchestration/src/goals.rs new file mode 100644 index 00000000..af27b4b8 --- /dev/null +++ b/crates/agent_orchestration/src/goals.rs @@ -0,0 +1,2136 @@ +//! Durable goals and a work ledger: an outcome that must stay active across +//! session incarnations. An `update_plan` plan belongs to one code-assistant +//! session, and pal rotates that session daily — so a goal cannot live there. +//! A goal is the pal_core entity that survives rotation, restart and the +//! expiry watcher, carrying its own completion contract, budget, state machine +//! and an evidence ledger. +//! +//! This module is the *domain* layer only: the state machine, the budget +//! accounting and the bounded-controller policy that folds an evaluation into +//! the next decision (see [`Goal::apply_evaluation`]). Whether a turn actually +//! satisfied the contract is decided by an injected [`GoalEvaluator`] — the +//! LLM-shaped seam — so the whole policy stays testable without a model. The +//! ledger records *attempts, artifacts and evidence*, never model +//! chain-of-thought. +//! +//! All timestamps are naive local time, like `pal_core::session` and +//! `pal_core::jobs`. + +use crate::OwnerKey; +use chrono::NaiveDateTime; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::{Arc, Mutex, OnceLock}; + +/// Where a goal is in its lifecycle. `Done` and `Failed` are terminal. +/// +/// The controller only ever *continues* an autonomous run out of `Running`. +/// Every other non-terminal state is a deliberate stop: `Waiting` on a durable +/// barrier (a later roadmap item arms these), `Blocked` on a real obstacle the +/// agent surfaced instead of looping, `Paused` by the user. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GoalState { + Running, + Waiting, + Blocked, + Paused, + Done, + Failed, +} + +impl GoalState { + /// Terminal states admit no further transitions. + pub fn is_terminal(&self) -> bool { + matches!(self, GoalState::Done | GoalState::Failed) + } + + /// A stable lowercase label for prompts and listings. + pub fn label(&self) -> &'static str { + match self { + GoalState::Running => "running", + GoalState::Waiting => "waiting", + GoalState::Blocked => "blocked", + GoalState::Paused => "paused", + GoalState::Done => "done", + GoalState::Failed => "failed", + } + } + + /// Whether `self -> next` is a legal edge. Re-entering the same state is + /// always allowed (idempotent). Only `Running` may reach `Done`: a + /// `Waiting`/`Blocked` goal must first be woken back into `Running` and + /// take a turn before it can claim success. + pub fn can_transition_to(&self, next: GoalState) -> bool { + use GoalState::*; + if *self == next { + return !self.is_terminal(); + } + match (self, next) { + (Running, Waiting | Blocked | Paused | Done | Failed) => true, + (Waiting, Running | Blocked | Paused | Failed) => true, + (Blocked, Running | Paused | Failed) => true, + (Paused, Running | Failed) => true, + (Done | Failed, _) => false, + _ => false, + } + } +} + +/// A checklist step under the objective. Cheap structure — the real proof of +/// progress lives in the ledger, not here. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Subgoal { + pub description: String, + #[serde(default)] + pub done: bool, +} + +impl Subgoal { + pub fn new(description: impl Into) -> Self { + Self { + description: description.into(), + done: false, + } + } +} + +/// What "done" means for this goal, in terms the evaluator can check against. +/// The agent may declare success only against this contract — never because a +/// turn *felt* finished. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CompletionContract { + /// The outcome in the user's terms ("a filled-in tax form at …"). + pub outcome: String, + /// How success is verified (a command, a check, an artifact's existence). + pub verification: String, + /// Things that must hold throughout ("keep the original file intact"). + #[serde(default)] + pub constraints: Vec, + /// Hard limits on what the agent may do ("never submit, only prepare"). + #[serde(default)] + pub boundaries: Vec, + /// When to give up rather than keep trying. + pub stop_condition: String, +} + +impl CompletionContract { + pub fn new( + outcome: impl Into, + verification: impl Into, + stop_condition: impl Into, + ) -> Self { + Self { + outcome: outcome.into(), + verification: verification.into(), + constraints: Vec::new(), + boundaries: Vec::new(), + stop_condition: stop_condition.into(), + } + } +} + +/// The resource envelope for autonomous continuation. A goal that exhausts its +/// budget fails rather than looping forever. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Budget { + /// Maximum number of attempts (turns) the controller will drive. Reaching + /// it without success is a failure, not a silent stop. + pub max_turns: u32, + /// Optional wall-clock deadline; past it the goal fails. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub deadline: Option, +} + +impl Budget { + pub fn turns(max_turns: u32) -> Self { + Self { + max_turns, + deadline: None, + } + } + + pub fn with_deadline(mut self, deadline: NaiveDateTime) -> Self { + self.deadline = Some(deadline); + self + } + + /// Whether the deadline (if any) has been reached at `now`. + pub fn deadline_passed(&self, now: NaiveDateTime) -> bool { + self.deadline.is_some_and(|dl| now >= dl) + } +} + +/// The evaluator's judgement of one attempt against the contract. This is the +/// only channel through which a goal reaches `Done`: the injected evaluator is +/// what checks the completion contract, the controller merely trusts its +/// verdict. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AttemptVerdict { + /// Real progress; keep going if budget allows. + Progressed, + /// The completion contract is met — the goal is done. + Satisfied, + /// A genuine obstacle the agent cannot clear itself (surface, don't loop). + Blocked, + /// The goal needs an answer from the user before it can continue. + NeedsInput, + /// The turn set up a durable dependency and there is nothing more to do + /// until it resolves — the goal parks on a wait barrier instead of burning + /// turns polling. The barrier travels in [`Evaluation::wait`]; a `Waiting` + /// verdict with no barrier is malformed and surfaces as `Blocked`. + Waiting, + /// The completion contract's explicit stop condition was met; continuing + /// would violate the user's envelope. + Stopped, + /// The controller could not obtain a trustworthy evaluation for the turn + /// (runtime failure, timeout, malformed judge response). Never emitted by + /// a [`GoalEvaluator`]; recorded so failures cannot refund budget. + Error, +} + +/// One recorded attempt: what was tried and the evidence for it, never the +/// model's reasoning. Appended to the goal's ledger by [`Goal::apply_evaluation`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Attempt { + pub at: NaiveDateTime, + pub verdict: AttemptVerdict, + /// A short, factual account of what the turn did. + pub summary: String, + /// Artifacts (paths/refs) the turn produced or changed. + #[serde(default)] + pub artifacts: Vec, + /// Verification output supporting the verdict (command results, checks). + #[serde(default)] + pub evidence: Vec, +} + +/// A controller turn that has claimed budget durably but has not yet been +/// folded into the evidence ledger. Persisting this marker before dispatch is +/// what makes the turn budget survive evaluator failures and process crashes. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct InFlightAttempt { + pub started_at: NaiveDateTime, + /// Revision assigned to this claim. Unlike the timestamp, this changes + /// when a busy claim is abandoned and the goal is reclaimed immediately. + #[serde(default)] + pub claim_revision: u64, +} + +/// A durable goal owned by one owner. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Goal { + pub id: String, + /// Optimistic-concurrency revision. Store updates only succeed against the + /// revision that was loaded, so a controller snapshot cannot overwrite a + /// newer user transition. + #[serde(default)] + pub revision: u64, + /// The owner whose incarnations pursue this goal (and whose channel hears + /// about it). + #[serde(alias = "owner")] + pub owner: OwnerKey, + /// The user's objective, verbatim intent. + pub objective: String, + #[serde(default)] + pub subgoals: Vec, + pub contract: CompletionContract, + pub budget: Budget, + pub state: GoalState, + #[serde(default)] + pub attempts: Vec, + /// Present from immediately before an autonomous turn is dispatched until + /// its evaluation (or controller error) is recorded. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub in_flight: Option, + /// Human-readable reason for the current `Blocked`/`Failed`/`Paused` + /// state; cleared when the goal returns to `Running`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub note: Option, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, +} + +impl Goal { + pub fn new( + id: impl Into, + owner: OwnerKey, + objective: impl Into, + contract: CompletionContract, + budget: Budget, + now: NaiveDateTime, + ) -> Self { + Self { + id: id.into(), + revision: 0, + owner, + objective: objective.into(), + subgoals: Vec::new(), + contract, + budget, + state: GoalState::Running, + attempts: Vec::new(), + in_flight: None, + note: None, + created_at: now, + updated_at: now, + } + } + + /// Attempts recorded so far — the turn budget is spent per attempt. + pub fn turns_used(&self) -> u32 { + self.attempts.len() as u32 + u32::from(self.in_flight.is_some()) + } + + /// Turns still available before the budget is exhausted. + pub fn turns_remaining(&self) -> u32 { + self.budget.max_turns.saturating_sub(self.turns_used()) + } + + /// Claim one autonomous turn from the budget. The caller persists this + /// state before dispatching work; only then is a crash unable to erase the + /// fact that the turn was attempted. + pub fn begin_attempt(&mut self, now: NaiveDateTime) -> anyhow::Result<()> { + anyhow::ensure!( + self.state == GoalState::Running, + "cannot begin an attempt for a goal in state {:?}", + self.state + ); + anyhow::ensure!( + self.in_flight.is_none(), + "goal already has an in-flight attempt" + ); + anyhow::ensure!( + !self.budget.deadline_passed(now), + "cannot begin an attempt after the goal deadline" + ); + anyhow::ensure!(self.turns_remaining() > 0, "goal turn budget is exhausted"); + self.in_flight = Some(InFlightAttempt { + started_at: now, + claim_revision: self.revision.saturating_add(1), + }); + self.updated_at = now; + Ok(()) + } + + /// Close the in-flight attempt after a runtime/evaluator failure. The + /// failed turn remains in the evidence ledger and consumes budget; retry + /// is therefore bounded by the same envelope as successful evaluations. + pub fn record_attempt_error( + &mut self, + reason: impl Into, + now: NaiveDateTime, + ) -> anyhow::Result { + anyhow::ensure!( + self.in_flight.take().is_some(), + "cannot record an attempt error without an in-flight attempt" + ); + let reason = reason.into(); + self.attempts.push(Attempt { + at: now, + verdict: AttemptVerdict::Error, + summary: reason.clone(), + artifacts: Vec::new(), + evidence: Vec::new(), + }); + self.updated_at = now; + + if self.budget.deadline_passed(now) { + if !self.state.is_terminal() { + self.transition(GoalState::Failed, now)?; + } + self.note = Some("deadline passed".into()); + Ok(ControllerDecision::Failed(FailureReason::DeadlinePassed)) + } else if self.turns_remaining() == 0 { + if !self.state.is_terminal() { + self.transition(GoalState::Failed, now)?; + } + self.note = Some(format!( + "turn budget exhausted after controller error: {reason}" + )); + Ok(ControllerDecision::Failed( + FailureReason::TurnBudgetExhausted, + )) + } else { + Ok(ControllerDecision::Continue) + } + } + + /// Move to `next`, validating the edge and stamping `updated_at`. Errors on + /// an illegal transition rather than silently corrupting the state machine. + pub fn transition(&mut self, next: GoalState, now: NaiveDateTime) -> anyhow::Result<()> { + if !self.state.can_transition_to(next) { + anyhow::bail!("illegal goal transition {:?} -> {:?}", self.state, next); + } + self.state = next; + self.updated_at = now; + Ok(()) + } + + /// Fold one turn's [`Evaluation`] into the goal: append it to the ledger + /// (spending a turn) and let the bounded-controller policy pick what + /// happens next. Only a `Running` goal is evaluable; the controller reaches + /// `Done` solely through a `Satisfied` verdict, surfaces a `Blocked` / + /// `NeedsInput` verdict instead of looping, and fails a `Progressed` goal + /// that has run out of budget or passed its deadline rather than continuing + /// forever. See [`ControllerDecision`]. + pub fn apply_evaluation( + &mut self, + eval: Evaluation, + now: NaiveDateTime, + ) -> anyhow::Result { + if self.state != GoalState::Running { + anyhow::bail!( + "cannot evaluate a goal in state {:?}; only Running is evaluable", + self.state + ); + } + anyhow::ensure!( + self.in_flight.is_some(), + "cannot evaluate a goal without an in-flight attempt" + ); + self.in_flight = None; + for subgoal in &mut self.subgoals { + if eval + .completed_subgoals + .iter() + .any(|completed| completed == &subgoal.description) + { + subgoal.done = true; + } + } + // Record the attempt (spends a turn) before deciding — the reason for a + // block/needs-input is the attempt's own summary. + let wait_request = eval.wait; + let reason = eval.summary.clone(); + self.attempts.push(Attempt { + at: now, + verdict: eval.verdict, + summary: eval.summary, + artifacts: eval.artifacts, + evidence: eval.evidence, + }); + + // A wall-clock deadline is a hard envelope, including for a turn that + // started before it but only finished afterwards. The evidence stays + // on the ledger, but late success cannot turn the goal into Done. + if self.budget.deadline_passed(now) { + self.transition(GoalState::Failed, now)?; + self.note = Some("deadline passed".into()); + return Ok(ControllerDecision::Failed(FailureReason::DeadlinePassed)); + } + + let decision = match eval.verdict { + // The evaluator is the only path to Done — it checked the contract. + AttemptVerdict::Satisfied => { + self.transition(GoalState::Done, now)?; + self.note = None; + ControllerDecision::Done + } + AttemptVerdict::Blocked => { + self.transition(GoalState::Blocked, now)?; + self.note = Some(reason); + ControllerDecision::Blocked + } + AttemptVerdict::NeedsInput => { + self.transition(GoalState::Blocked, now)?; + self.note = Some(reason); + ControllerDecision::AwaitInput + } + AttemptVerdict::Stopped => { + self.transition(GoalState::Failed, now)?; + self.note = Some(reason); + ControllerDecision::Failed(FailureReason::StopConditionMet) + } + // The turn armed a durable dependency: park until it resolves. A + // Waiting verdict is only honoured with a barrier to wait on; + // without one it is a malformed judgement, surfaced as a block + // rather than an indefinite park on nothing. + AttemptVerdict::Waiting => match wait_request { + Some(request) => { + self.transition(GoalState::Waiting, now)?; + self.note = Some(reason); + ControllerDecision::Wait(request) + } + None => { + self.transition(GoalState::Blocked, now)?; + self.note = Some(format!("asked to wait but named no barrier: {reason}")); + ControllerDecision::Blocked + } + }, + AttemptVerdict::Error => { + anyhow::bail!("controller-error verdicts cannot come from a goal evaluator") + } + // Progress is only allowed to continue while the envelope holds; + // the hard deadline was already checked above. + AttemptVerdict::Progressed => { + if self.turns_remaining() == 0 { + self.transition(GoalState::Failed, now)?; + self.note = Some("turn budget exhausted".into()); + ControllerDecision::Failed(FailureReason::TurnBudgetExhausted) + } else { + ControllerDecision::Continue + } + } + }; + Ok(decision) + } + + /// Fail a non-terminal goal whose deadline has passed (the sweep the + /// controller runs over `Waiting`/`Blocked`/`Paused` goals that never took + /// another turn). Returns whether it fired. + pub fn enforce_deadline(&mut self, now: NaiveDateTime) -> bool { + if self.state.is_terminal() || !self.budget.deadline_passed(now) { + return false; + } + // `Failed` is reachable from every non-terminal state. + let _ = self.transition(GoalState::Failed, now); + self.note = Some("deadline passed".into()); + true + } + + /// User preemption / lifecycle edges. Each is a thin, note-managing wrapper + /// over [`Goal::transition`]; an illegal edge surfaces as an error. + pub fn pause(&mut self, now: NaiveDateTime) -> anyhow::Result<()> { + self.transition(GoalState::Paused, now) + } + + /// Return a paused or blocked goal to `Running`, clearing the stop reason. + pub fn resume(&mut self, now: NaiveDateTime) -> anyhow::Result<()> { + self.transition(GoalState::Running, now)?; + self.note = None; + Ok(()) + } + + /// Arm a durable wait barrier (`Running` -> `Waiting`). + pub fn wait(&mut self, now: NaiveDateTime) -> anyhow::Result<()> { + self.transition(GoalState::Waiting, now) + } + + /// A wait barrier fired (`Waiting` -> `Running`), clearing any note. + pub fn wake(&mut self, now: NaiveDateTime) -> anyhow::Result<()> { + self.transition(GoalState::Running, now)?; + self.note = None; + Ok(()) + } + + /// Surface a real obstacle (`Running`/`Waiting` -> `Blocked`) with a reason. + pub fn block(&mut self, reason: impl Into, now: NaiveDateTime) -> anyhow::Result<()> { + self.transition(GoalState::Blocked, now)?; + self.note = Some(reason.into()); + Ok(()) + } + + /// Give up on the goal with a reason (`* ` -> `Failed`). + pub fn fail(&mut self, reason: impl Into, now: NaiveDateTime) -> anyhow::Result<()> { + self.transition(GoalState::Failed, now)?; + self.note = Some(reason.into()); + Ok(()) + } +} + +/// One turn's judgement against the contract, produced by a [`GoalEvaluator`] +/// and folded into the goal by [`Goal::apply_evaluation`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Evaluation { + pub verdict: AttemptVerdict, + /// A short, factual account of what the turn did (becomes the attempt + /// summary, and the block/needs-input note where relevant). + pub summary: String, + pub artifacts: Vec, + pub evidence: Vec, + /// Checklist entries for which this turn supplied concrete evidence. + pub completed_subgoals: Vec, + /// The durable barrier to park on, set only with an + /// [`AttemptVerdict::Waiting`] verdict. The controller arms it and moves the + /// goal to `Waiting`; a `Waiting` verdict without one is treated as a block. + pub wait: Option, +} + +impl Evaluation { + /// Convenience for a verdict with just a summary and no artifacts/evidence. + pub fn new(verdict: AttemptVerdict, summary: impl Into) -> Self { + Self { + verdict, + summary: summary.into(), + artifacts: Vec::new(), + evidence: Vec::new(), + completed_subgoals: Vec::new(), + wait: None, + } + } + + /// A `Waiting` evaluation that parks the goal on `request` until it fires. + pub fn waiting(summary: impl Into, request: crate::waits::WaitRequest) -> Self { + Self { + wait: Some(request), + ..Self::new(AttemptVerdict::Waiting, summary) + } + } +} + +/// Why a goal failed. Distinct from a `Blocked` obstacle: a failure is the +/// controller giving up because the envelope is spent, not a solvable block. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FailureReason { + /// `max_turns` attempts were spent without satisfying the contract. + TurnBudgetExhausted, + /// The wall-clock deadline passed. + DeadlinePassed, + /// The completion contract's explicit stop condition was met. + StopConditionMet, +} + +/// What the controller decided after folding an evaluation. The runtime acts on +/// this: run another turn, stop, arm a wait, or tell the user. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ControllerDecision { + /// Budget remains and the contract is not yet met — take another turn. + Continue, + /// The contract is satisfied; the goal is `Done`. + Done, + /// A real obstacle was surfaced; the goal is `Blocked` awaiting resolution. + Blocked, + /// The goal needs the user to answer before it can continue. + AwaitInput, + /// The goal parked on a durable barrier; the runtime arms it (see + /// [`crate::waits`]) and the goal spends no turns until it resolves. + Wait(crate::waits::WaitRequest), + /// The goal failed; the envelope gave out. + Failed(FailureReason), + /// A user or another controller changed the goal while the claimed turn + /// was running. Its ledger entry was recorded, but the newer stopped state + /// remains authoritative. + Preempted, +} + +/// How a claimed controller turn ended. Folded atomically by +/// [`GoalStore::finish_attempt`] so a concurrent user transition cannot be +/// overwritten while the turn's ledger entry is still retained. +pub enum AttemptCompletion { + Evaluated(Evaluation), + ControllerError(String), +} + +/// What the runtime observed at the end of a goal turn, handed to the +/// evaluator. Deliberately minimal and agent-stack-agnostic so pal_core stays +/// decoupled from code-assistant internals. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TurnOutcome { + /// The assistant's own account of what it did this turn. + pub assistant_summary: String, + /// Artifacts (paths/refs) the turn produced or changed. + pub artifacts: Vec, + /// Verification output the turn gathered (command results, checks). + pub verification: Vec, +} + +/// The LLM-shaped seam: judge a completed turn against the goal's contract. +/// Implementations range from a deterministic check (an artifact exists, a +/// command exit code) to a bounded model call — the controller policy is +/// oblivious to which, which is what keeps it testable without a model. +#[async_trait::async_trait] +pub trait GoalEvaluator: Send + Sync { + async fn evaluate(&self, goal: &Goal, turn: &TurnOutcome) -> anyhow::Result; +} + +/// JSON-file persistence for goals (`goals.json`). Every operation reloads the +/// current file and writes through atomic tmp+rename. Instances for the same +/// path share a process-local lock, while revisions reject stale snapshots and +/// attempt tokens let completion merge with concurrent lifecycle changes. +pub struct GoalStore { + path: PathBuf, + lock: Arc>, +} + +impl GoalStore { + pub fn new(path: impl Into) -> Self { + let path = path.into(); + let lock = goal_store_lock(&path); + Self { path, lock } + } + + /// All goals, terminal ones included; a missing file yields an empty list. + pub fn list(&self) -> anyhow::Result> { + let _guard = self.lock.lock().expect("goal store lock poisoned"); + self.load_unlocked() + } + + fn load_unlocked(&self) -> anyhow::Result> { + match std::fs::read_to_string(&self.path) { + Ok(content) => Ok(serde_json::from_str(&content)?), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()), + Err(e) => Err(e.into()), + } + } + + fn save_unlocked(&self, goals: &[Goal]) -> anyhow::Result<()> { + if let Some(dir) = self.path.parent() { + std::fs::create_dir_all(dir)?; + } + let tmp = self.path.with_extension("json.tmp"); + std::fs::write(&tmp, serde_json::to_string_pretty(goals)?)?; + std::fs::rename(&tmp, &self.path)?; + Ok(()) + } + + /// Add a goal with a caller-supplied id; the id must be unique. + pub fn add(&self, goal: Goal) -> anyhow::Result<()> { + let _guard = self.lock.lock().expect("goal store lock poisoned"); + let mut goals = self.load_unlocked()?; + if goals.iter().any(|g| g.id == goal.id) { + anyhow::bail!("goal id {} already exists", goal.id); + } + goals.push(goal); + self.save_unlocked(&goals) + } + + /// Create and persist a goal with a store-assigned id, returned for display + /// and control. + pub fn add_new( + &self, + owner: OwnerKey, + objective: impl Into, + contract: CompletionContract, + budget: Budget, + now: NaiveDateTime, + ) -> anyhow::Result { + let _guard = self.lock.lock().expect("goal store lock poisoned"); + let mut goals = self.load_unlocked()?; + let base = format!("goal-{}", now.format("%Y%m%d-%H%M%S")); + let mut id = base.clone(); + let mut n = 1; + while goals.iter().any(|g| g.id == id) { + n += 1; + id = format!("{base}-{n}"); + } + let goal = Goal::new(id, owner, objective, contract, budget, now); + goals.push(goal.clone()); + self.save_unlocked(&goals)?; + Ok(goal) + } + + /// A single goal by id. + pub fn get(&self, id: &str) -> anyhow::Result> { + Ok(self.list()?.into_iter().find(|g| g.id == id)) + } + + /// Persist a mutated goal (after `apply_evaluation`, a lifecycle edge, …). + /// Errors if the id is unknown — an update never silently creates. + pub fn update(&self, goal: &Goal) -> anyhow::Result { + let _guard = self.lock.lock().expect("goal store lock poisoned"); + let mut goals = self.load_unlocked()?; + let Some(slot) = goals.iter_mut().find(|g| g.id == goal.id) else { + anyhow::bail!("unknown goal id {}", goal.id); + }; + anyhow::ensure!( + slot.revision == goal.revision, + "goal {} revision conflict: expected {}, found {}", + goal.id, + goal.revision, + slot.revision + ); + let mut persisted = goal.clone(); + persisted.revision = persisted.revision.saturating_add(1); + *slot = persisted.clone(); + self.save_unlocked(&goals)?; + Ok(persisted) + } + + /// Atomically claim one controller turn against a previously loaded + /// snapshot. `None` means the snapshot became stale or the goal is no + /// longer runnable; callers must not dispatch work in that case. + pub fn claim_attempt( + &self, + snapshot: &Goal, + now: NaiveDateTime, + ) -> anyhow::Result> { + let _guard = self.lock.lock().expect("goal store lock poisoned"); + let mut goals = self.load_unlocked()?; + let Some(goal) = goals.iter_mut().find(|goal| goal.id == snapshot.id) else { + return Ok(None); + }; + if goal.revision != snapshot.revision + || goal.state != GoalState::Running + || goal.in_flight.is_some() + { + return Ok(None); + } + if goal.enforce_deadline(now) { + goal.revision = goal.revision.saturating_add(1); + self.save_unlocked(&goals)?; + return Ok(None); + } + if goal.turns_remaining() == 0 { + goal.fail("turn budget exhausted", now)?; + goal.revision = goal.revision.saturating_add(1); + self.save_unlocked(&goals)?; + return Ok(None); + } + + goal.begin_attempt(now)?; + goal.revision = goal.revision.saturating_add(1); + let claimed = goal.clone(); + self.save_unlocked(&goals)?; + Ok(Some(claimed)) + } + + /// Atomically close a previously claimed attempt. The in-flight marker is + /// the claim token: newer user transitions may advance the goal revision, + /// but as long as they preserve that marker the completed turn is merged + /// into the ledger without overwriting their state. + pub fn finish_attempt( + &self, + claim: &Goal, + completion: AttemptCompletion, + now: NaiveDateTime, + ) -> anyhow::Result> { + let Some(token) = claim.in_flight.as_ref() else { + anyhow::bail!("cannot finish a goal snapshot without an in-flight attempt"); + }; + let _guard = self.lock.lock().expect("goal store lock poisoned"); + let mut goals = self.load_unlocked()?; + let Some(goal) = goals.iter_mut().find(|goal| goal.id == claim.id) else { + return Ok(None); + }; + if goal.in_flight.as_ref() != Some(token) { + return Ok(None); + } + + let stopped_state = + (goal.state != GoalState::Running).then_some((goal.state, goal.note.clone())); + let was_terminal = goal.state.is_terminal(); + let decision = match completion { + AttemptCompletion::Evaluated(evaluation) => { + // The attempt itself was claimed while Running. Temporarily + // restore that state to fold its verdict, then reinstate a + // concurrent user stop unless the attempt reached a terminal + // outcome. Thus progress never resumes a preempted goal, while + // verified completion and hard envelope failures remain final. + if stopped_state.is_some() { + goal.state = GoalState::Running; + } + let decision = goal.apply_evaluation(evaluation, now)?; + if let Some((state, note)) = stopped_state { + if was_terminal || !goal.state.is_terminal() { + goal.state = state; + goal.note = note; + goal.updated_at = now; + ControllerDecision::Preempted + } else { + decision + } + } else { + decision + } + } + AttemptCompletion::ControllerError(reason) => { + let decision = goal.record_attempt_error(reason, now)?; + if let Some((state, note)) = stopped_state { + if was_terminal { + goal.state = state; + goal.note = note; + goal.updated_at = now; + ControllerDecision::Preempted + } else { + decision + } + } else { + decision + } + } + }; + goal.revision = goal.revision.saturating_add(1); + let finished = goal.clone(); + self.save_unlocked(&goals)?; + Ok(Some((finished, decision))) + } + + /// Release a claim when the backend atomically reports that another turn + /// already owns the session. No goal work was dispatched, so this is the + /// sole path that clears an in-flight marker without spending budget. + pub fn abandon_attempt(&self, claim: &Goal) -> anyhow::Result> { + let Some(token) = claim.in_flight.as_ref() else { + anyhow::bail!("cannot abandon a goal snapshot without an in-flight attempt"); + }; + let _guard = self.lock.lock().expect("goal store lock poisoned"); + let mut goals = self.load_unlocked()?; + let Some(goal) = goals.iter_mut().find(|goal| goal.id == claim.id) else { + return Ok(None); + }; + if goal.in_flight.as_ref() != Some(token) { + return Ok(None); + } + goal.in_flight = None; + goal.revision = goal.revision.saturating_add(1); + let abandoned = goal.clone(); + self.save_unlocked(&goals)?; + Ok(Some(abandoned)) + } + + /// Remove a goal; `false` when the id is unknown. + pub fn remove(&self, id: &str) -> anyhow::Result { + let _guard = self.lock.lock().expect("goal store lock poisoned"); + let mut goals = self.load_unlocked()?; + let before = goals.len(); + goals.retain(|g| g.id != id); + let removed = goals.len() != before; + if removed { + self.save_unlocked(&goals)?; + } + Ok(removed) + } + + /// Non-terminal goals — the ones a controller sweep must still drive. + pub fn active(&self) -> anyhow::Result> { + Ok(self + .list()? + .into_iter() + .filter(|g| !g.state.is_terminal()) + .collect()) + } + + /// Non-terminal goals owned by one owner (e.g. to preempt on a user message). + pub fn active_for_owner(&self, owner: &OwnerKey) -> anyhow::Result> { + Ok(self + .active()? + .into_iter() + .filter(|g| &g.owner == owner) + .collect()) + } + + /// Pause every goal the controller is actively driving on `owner` because + /// a user message just arrived — the human is taking the wheel, and the + /// autonomous loop must not race their turn on the same incarnation. Only + /// `Running` goals are affected: a `Waiting`/`Blocked` goal is already + /// stopped, and a `Paused` one stays paused. Returns the ids paused (empty + /// when there was nothing to preempt). A paused goal is resumed + /// deliberately, through the `goal` tool. + pub fn preempt_owner( + &self, + owner: &OwnerKey, + now: NaiveDateTime, + ) -> anyhow::Result> { + let _guard = self.lock.lock().expect("goal store lock poisoned"); + let mut goals = self.load_unlocked()?; + let mut paused = Vec::new(); + for goal in goals + .iter_mut() + .filter(|goal| &goal.owner == owner && !goal.state.is_terminal()) + { + if goal.state == GoalState::Running && goal.pause(now).is_ok() { + goal.revision = goal.revision.saturating_add(1); + paused.push(goal.id.clone()); + } + } + if !paused.is_empty() { + self.save_unlocked(&goals)?; + } + Ok(paused) + } + + /// Wake a goal a durable wait barrier just resolved: `Waiting -> Running`, + /// stamping `note` as the wake reason (why it is running again). Atomic and + /// idempotent-safe: `false` when the goal is gone or no longer `Waiting` (a + /// user resumed, paused or cancelled it meanwhile, or it already woke) — the + /// caller then simply drops the now-stale wait. This is the *only* edge from + /// `Waiting` back to `Running` the runtime takes, so waking is unambiguous. + pub fn wake_waiting( + &self, + goal_id: &str, + note: Option, + now: NaiveDateTime, + ) -> anyhow::Result { + let _guard = self.lock.lock().expect("goal store lock poisoned"); + let mut goals = self.load_unlocked()?; + let Some(goal) = goals.iter_mut().find(|g| g.id == goal_id) else { + return Ok(false); + }; + if goal.state != GoalState::Waiting { + return Ok(false); + } + goal.wake(now)?; + goal.note = note; + goal.revision = goal.revision.saturating_add(1); + self.save_unlocked(&goals)?; + Ok(true) + } +} + +fn goal_store_lock(path: &std::path::Path) -> Arc> { + static LOCKS: OnceLock>>>> = OnceLock::new(); + let locks = LOCKS.get_or_init(|| Mutex::new(HashMap::new())); + let mut locks = locks.lock().expect("goal store lock registry poisoned"); + locks + .entry(path.to_path_buf()) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone() +} + +/// Storage seam for goals. The bundled JSON [`GoalStore`] implements it; a +/// host's transactional repository (see PAL's "one orchestration store" plan) +/// implements the same trait so controllers and tools never bind to a +/// concrete store. Object-safe; every method mirrors the store's documented +/// atomicity semantics (claims and finishes are revision/token-guarded). +pub trait GoalRepository: Send + Sync { + fn list(&self) -> anyhow::Result>; + fn add(&self, goal: Goal) -> anyhow::Result<()>; + fn get(&self, id: &str) -> anyhow::Result>; + fn update(&self, goal: &Goal) -> anyhow::Result; + fn claim_attempt(&self, snapshot: &Goal, now: NaiveDateTime) -> anyhow::Result>; + fn finish_attempt( + &self, + claim: &Goal, + completion: AttemptCompletion, + now: NaiveDateTime, + ) -> anyhow::Result>; + fn abandon_attempt(&self, claim: &Goal) -> anyhow::Result>; + fn remove(&self, id: &str) -> anyhow::Result; + fn active(&self) -> anyhow::Result>; + fn active_for_owner(&self, owner: &OwnerKey) -> anyhow::Result>; + fn preempt_owner(&self, owner: &OwnerKey, now: NaiveDateTime) -> anyhow::Result>; + fn wake_waiting( + &self, + goal_id: &str, + note: Option, + now: NaiveDateTime, + ) -> anyhow::Result; +} + +impl GoalRepository for GoalStore { + fn list(&self) -> anyhow::Result> { + GoalStore::list(self) + } + fn add(&self, goal: Goal) -> anyhow::Result<()> { + GoalStore::add(self, goal) + } + fn get(&self, id: &str) -> anyhow::Result> { + GoalStore::get(self, id) + } + fn update(&self, goal: &Goal) -> anyhow::Result { + GoalStore::update(self, goal) + } + fn claim_attempt(&self, snapshot: &Goal, now: NaiveDateTime) -> anyhow::Result> { + GoalStore::claim_attempt(self, snapshot, now) + } + fn finish_attempt( + &self, + claim: &Goal, + completion: AttemptCompletion, + now: NaiveDateTime, + ) -> anyhow::Result> { + GoalStore::finish_attempt(self, claim, completion, now) + } + fn abandon_attempt(&self, claim: &Goal) -> anyhow::Result> { + GoalStore::abandon_attempt(self, claim) + } + fn remove(&self, id: &str) -> anyhow::Result { + GoalStore::remove(self, id) + } + fn active(&self) -> anyhow::Result> { + GoalStore::active(self) + } + fn active_for_owner(&self, owner: &OwnerKey) -> anyhow::Result> { + GoalStore::active_for_owner(self, owner) + } + fn preempt_owner(&self, owner: &OwnerKey, now: NaiveDateTime) -> anyhow::Result> { + GoalStore::preempt_owner(self, owner, now) + } + fn wake_waiting( + &self, + goal_id: &str, + note: Option, + now: NaiveDateTime, + ) -> anyhow::Result { + GoalStore::wake_waiting(self, goal_id, note, now) + } +} + +/// Prefix framing a goal turn's injected message (mirroring the host's +/// scheduled-job framing, e.g. PAL's `[scheduled]`). A goal turn is +/// autonomous, not a user message; the framing tells the session so. +pub const GOAL_PREFIX: &str = "[goal]"; + +/// The message a goal turn injects into the owner's incarnation. Frames the +/// autonomous turn against the completion contract so the agent works toward +/// the *contracted* outcome and reports what an evaluator can check — never +/// declaring success on a hunch. The evaluator judges the reply, so the turn +/// is asked for a factual progress report (no silence token, unlike a +/// scheduled job). +pub fn goal_turn_text(goal: &Goal) -> String { + let contract = &goal.contract; + let mut sections = vec![format!("{GOAL_PREFIX} {}", goal.objective)]; + + let mut contract_lines = vec![ + format!("- Done when: {}", contract.outcome), + format!("- Verify by: {}", contract.verification), + format!("- Stop if: {}", contract.stop_condition), + ]; + for c in &contract.constraints { + contract_lines.push(format!("- Constraint (hold throughout): {c}")); + } + for b in &contract.boundaries { + contract_lines.push(format!("- Boundary (never cross): {b}")); + } + sections.push(format!( + "Completion contract:\n{}", + contract_lines.join("\n") + )); + + if !goal.subgoals.is_empty() { + let list = goal + .subgoals + .iter() + .map(|s| format!("- [{}] {}", if s.done { "x" } else { " " }, s.description)) + .collect::>() + .join("\n"); + sections.push(format!("Checklist:\n{list}")); + } + + let progress = match goal.attempts.last() { + Some(last) => format!( + "Progress: {} of {} attempts used. Last attempt: {}", + goal.turns_used(), + goal.budget.max_turns, + last.summary + ), + None => format!( + "Progress: this is the first of {} attempts allowed.", + goal.budget.max_turns + ), + }; + sections.push(progress); + + // A Running goal carries a note only just after a durable wait woke it — + // the barrier that resolved (a build finished, the time arrived, the user + // replied) or a timeout. Surface it so the resumed turn knows what changed. + if let Some(note) = &goal.note { + sections.push(format!("Update since you last worked on this: {note}")); + } + + sections.push( + "This turn was started autonomously to advance the goal above, not by \ +the user. Make concrete progress now, using your tools. Then report, in your \ +reply: what you did this turn, any artifacts you produced or changed (their \ +paths), and the verification output that shows where the goal stands. Only \ +treat the goal as done when the contract's verification actually passes — \ +never on a hunch. If you hit a real obstacle you cannot clear yourself, or you \ +need a decision from the user, say so plainly instead of guessing." + .to_string(), + ); + + sections.join("\n\n") +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::NaiveDate; + + fn at(y: i32, mo: u32, d: u32, h: u32, mi: u32) -> NaiveDateTime { + NaiveDate::from_ymd_opt(y, mo, d) + .unwrap() + .and_hms_opt(h, mi, 0) + .unwrap() + } + + fn owner() -> OwnerKey { + OwnerKey::from_parts(&["telegram", "private", "42"]) + } + + fn goal() -> Goal { + Goal::new( + "goal-1", + owner(), + "prepare the 2025 tax return", + CompletionContract::new( + "a filled ELSTER draft ready to review", + "the draft file exists and validates", + "give up if a required document is missing", + ), + Budget::turns(5), + at(2026, 7, 14, 9, 0), + ) + } + + fn apply_at( + goal: &mut Goal, + evaluation: Evaluation, + now: NaiveDateTime, + ) -> anyhow::Result { + goal.begin_attempt(now)?; + goal.apply_evaluation(evaluation, now) + } + + #[test] + fn new_goal_starts_running_with_a_clean_ledger() { + let g = goal(); + assert_eq!(g.state, GoalState::Running); + assert!(g.attempts.is_empty()); + assert_eq!(g.turns_used(), 0); + assert_eq!(g.turns_remaining(), 5); + assert_eq!(g.created_at, g.updated_at); + } + + #[test] + fn terminal_states_are_terminal() { + assert!(GoalState::Done.is_terminal()); + assert!(GoalState::Failed.is_terminal()); + assert!(!GoalState::Running.is_terminal()); + assert!(!GoalState::Waiting.is_terminal()); + assert!(!GoalState::Blocked.is_terminal()); + assert!(!GoalState::Paused.is_terminal()); + } + + #[test] + fn only_running_can_reach_done() { + assert!(GoalState::Running.can_transition_to(GoalState::Done)); + assert!(!GoalState::Waiting.can_transition_to(GoalState::Done)); + assert!(!GoalState::Blocked.can_transition_to(GoalState::Done)); + assert!(!GoalState::Paused.can_transition_to(GoalState::Done)); + } + + #[test] + fn legal_and_illegal_edges() { + use GoalState::*; + // Running fans out to every stop and both terminals. + for next in [Waiting, Blocked, Paused, Done, Failed] { + assert!(Running.can_transition_to(next), "Running -> {next:?}"); + } + // Waiting/Blocked wake back into Running, may fail, may pause. + assert!(Waiting.can_transition_to(Running)); + assert!(Blocked.can_transition_to(Running)); + assert!(Paused.can_transition_to(Running)); + // Terminals are dead ends, even to themselves. + for next in [Running, Waiting, Blocked, Paused, Done, Failed] { + assert!(!Done.can_transition_to(next), "Done -> {next:?}"); + assert!(!Failed.can_transition_to(next), "Failed -> {next:?}"); + } + // A non-terminal state re-entering itself is a no-op edge. + assert!(Running.can_transition_to(Running)); + // Blocked cannot jump straight to Waiting. + assert!(!Blocked.can_transition_to(Waiting)); + } + + #[test] + fn transition_validates_and_stamps_updated_at() { + let mut g = goal(); + let t1 = at(2026, 7, 14, 10, 0); + g.transition(GoalState::Waiting, t1).unwrap(); + assert_eq!(g.state, GoalState::Waiting); + assert_eq!(g.updated_at, t1); + assert_eq!(g.created_at, at(2026, 7, 14, 9, 0)); + + // Illegal edge is rejected and leaves the state untouched. + let err = g.transition(GoalState::Done, at(2026, 7, 14, 11, 0)); + assert!(err.is_err()); + assert_eq!(g.state, GoalState::Waiting); + assert_eq!(g.updated_at, t1); + } + + #[test] + fn budget_deadline_accounting() { + let b = Budget::turns(3).with_deadline(at(2026, 7, 14, 18, 0)); + assert!(!b.deadline_passed(at(2026, 7, 14, 17, 59))); + assert!(b.deadline_passed(at(2026, 7, 14, 18, 0))); + assert!(b.deadline_passed(at(2026, 7, 14, 18, 1))); + // No deadline never passes. + assert!(!Budget::turns(3).deadline_passed(at(2999, 1, 1, 0, 0))); + } + + #[test] + fn contract_defaults_have_no_constraints_or_boundaries() { + let c = CompletionContract::new("out", "verify", "stop"); + assert!(c.constraints.is_empty()); + assert!(c.boundaries.is_empty()); + } + + // ---- bounded controller policy ------------------------------------ + + #[test] + fn progressed_within_budget_continues_and_records_the_attempt() { + let mut g = goal(); // budget 5 turns + let now = at(2026, 7, 14, 10, 0); + let mut eval = Evaluation::new(AttemptVerdict::Progressed, "downloaded the receipts"); + eval.artifacts = vec!["inbox/receipts.pdf".into()]; + eval.evidence = vec!["3 files fetched".into()]; + + g.begin_attempt(now).unwrap(); + let decision = g.apply_evaluation(eval, now).unwrap(); + assert_eq!(decision, ControllerDecision::Continue); + assert_eq!(g.state, GoalState::Running); + assert_eq!(g.turns_used(), 1); + assert_eq!(g.turns_remaining(), 4); + let a = g.attempts.last().unwrap(); + assert_eq!(a.at, now); + assert_eq!(a.verdict, AttemptVerdict::Progressed); + assert_eq!(a.artifacts, vec!["inbox/receipts.pdf".to_string()]); + assert_eq!(a.evidence, vec!["3 files fetched".to_string()]); + assert!(g.in_flight.is_none()); + assert_eq!(g.note, None); + } + + #[test] + fn evaluated_subgoal_progress_updates_the_durable_checklist() { + let mut g = goal(); + g.subgoals = vec![ + Subgoal::new("collect receipts"), + Subgoal::new("validate draft"), + ]; + let mut evaluation = Evaluation::new(AttemptVerdict::Progressed, "receipts collected"); + evaluation.completed_subgoals = vec!["collect receipts".into(), "unknown".into()]; + + apply_at(&mut g, evaluation, at(2026, 7, 14, 10, 0)).unwrap(); + + assert!(g.subgoals[0].done); + assert!(!g.subgoals[1].done); + } + + #[test] + fn an_attempt_spends_budget_before_the_turn_is_evaluated() { + let mut g = goal(); + let started_at = at(2026, 7, 14, 9, 30); + + g.begin_attempt(started_at).unwrap(); + + assert_eq!(g.turns_used(), 1); + assert_eq!(g.turns_remaining(), 4); + assert_eq!(g.in_flight.as_ref().unwrap().started_at, started_at); + assert!(g.attempts.is_empty()); + } + + #[test] + fn a_controller_error_is_ledgered_and_cannot_refund_the_turn() { + let mut g = goal(); + g.begin_attempt(at(2026, 7, 14, 9, 30)).unwrap(); + + let decision = g + .record_attempt_error("goal evaluation timed out", at(2026, 7, 14, 9, 31)) + .unwrap(); + + assert_eq!(decision, ControllerDecision::Continue); + assert_eq!(g.turns_used(), 1); + assert!(g.in_flight.is_none()); + assert_eq!(g.attempts[0].verdict, AttemptVerdict::Error); + assert_eq!(g.attempts[0].summary, "goal evaluation timed out"); + } + + #[test] + fn a_controller_error_on_the_last_turn_exhausts_the_goal() { + let mut g = Goal::new( + "g", + owner(), + "obj", + CompletionContract::new("out", "verify", "stop"), + Budget::turns(1), + at(2026, 7, 14, 9, 0), + ); + g.begin_attempt(at(2026, 7, 14, 9, 1)).unwrap(); + + let decision = g + .record_attempt_error("judge unavailable", at(2026, 7, 14, 9, 2)) + .unwrap(); + + assert_eq!( + decision, + ControllerDecision::Failed(FailureReason::TurnBudgetExhausted) + ); + assert_eq!(g.state, GoalState::Failed); + assert_eq!(g.turns_used(), 1); + } + + #[test] + fn satisfied_marks_done_and_clears_any_note() { + let mut g = goal(); + g.note = Some("stale".into()); + let decision = apply_at( + &mut g, + Evaluation::new(AttemptVerdict::Satisfied, "draft ready and validated"), + at(2026, 7, 14, 11, 0), + ) + .unwrap(); + assert_eq!(decision, ControllerDecision::Done); + assert_eq!(g.state, GoalState::Done); + assert_eq!(g.note, None); + assert_eq!(g.turns_used(), 1); + } + + #[test] + fn satisfied_on_the_last_turn_beats_budget_exhaustion() { + let mut g = Goal::new( + "g", + owner(), + "obj", + CompletionContract::new("out", "verify", "stop"), + Budget::turns(1), + at(2026, 7, 14, 9, 0), + ); + let decision = apply_at( + &mut g, + Evaluation::new(AttemptVerdict::Satisfied, "done"), + at(2026, 7, 14, 9, 5), + ) + .unwrap(); + assert_eq!(decision, ControllerDecision::Done); + assert_eq!(g.state, GoalState::Done); + } + + #[test] + fn blocked_verdict_surfaces_the_block_with_its_reason() { + let mut g = goal(); + let decision = apply_at( + &mut g, + Evaluation::new(AttemptVerdict::Blocked, "the 2024 statement is missing"), + at(2026, 7, 14, 12, 0), + ) + .unwrap(); + assert_eq!(decision, ControllerDecision::Blocked); + assert_eq!(g.state, GoalState::Blocked); + assert_eq!(g.note.as_deref(), Some("the 2024 statement is missing")); + } + + #[test] + fn needs_input_awaits_input() { + let mut g = goal(); + let decision = apply_at( + &mut g, + Evaluation::new( + AttemptVerdict::NeedsInput, + "which bank account should I use?", + ), + at(2026, 7, 14, 12, 0), + ) + .unwrap(); + assert_eq!(decision, ControllerDecision::AwaitInput); + assert_eq!(g.state, GoalState::Blocked); + assert_eq!(g.note.as_deref(), Some("which bank account should I use?")); + } + + #[test] + fn waiting_verdict_parks_the_goal_on_its_barrier() { + use crate::waits::{WaitKind, WaitRequest}; + let mut g = goal(); + let request = WaitRequest::new(WaitKind::OutputPattern { + handle: "pty-3".into(), + pattern: "BUILD SUCCESSFUL".into(), + }) + .with_timeout(at(2026, 7, 14, 18, 0)); + let decision = apply_at( + &mut g, + Evaluation::waiting("started the build in the background", request.clone()), + at(2026, 7, 14, 12, 0), + ) + .unwrap(); + + assert_eq!(decision, ControllerDecision::Wait(request)); + assert_eq!(g.state, GoalState::Waiting); + // The parking reason is the attempt summary, visible in listings. + assert_eq!( + g.note.as_deref(), + Some("started the build in the background") + ); + // The turn that set the barrier up still spent budget. + assert_eq!(g.turns_used(), 1); + assert_eq!(g.attempts.last().unwrap().verdict, AttemptVerdict::Waiting); + // A parked goal cannot be evaluated again until it is woken. + assert!(!GoalState::Waiting.can_transition_to(GoalState::Done)); + } + + #[test] + fn waiting_without_a_barrier_surfaces_as_a_block() { + let mut g = goal(); + // A Waiting verdict with no wait object is malformed — park on nothing + // is never right, so it is surfaced as a block instead. + let decision = apply_at( + &mut g, + Evaluation::new(AttemptVerdict::Waiting, "I'll just wait"), + at(2026, 7, 14, 12, 0), + ) + .unwrap(); + assert_eq!(decision, ControllerDecision::Blocked); + assert_eq!(g.state, GoalState::Blocked); + assert!( + g.note.as_deref().unwrap().contains("named no barrier"), + "{:?}", + g.note + ); + } + + #[test] + fn waiting_past_the_deadline_fails_rather_than_parking() { + use crate::waits::{WaitKind, WaitRequest}; + let mut g = Goal::new( + "g", + owner(), + "obj", + CompletionContract::new("out", "verify", "stop"), + Budget::turns(5).with_deadline(at(2026, 7, 14, 18, 0)), + at(2026, 7, 14, 9, 0), + ); + g.begin_attempt(at(2026, 7, 14, 17, 59)).unwrap(); + let decision = g + .apply_evaluation( + Evaluation::waiting( + "waiting for CI", + WaitRequest::new(WaitKind::Event { key: "ci".into() }), + ), + at(2026, 7, 14, 18, 1), + ) + .unwrap(); + // The hard envelope wins: a goal past its deadline does not park. + assert_eq!( + decision, + ControllerDecision::Failed(FailureReason::DeadlinePassed) + ); + assert_eq!(g.state, GoalState::Failed); + } + + #[test] + fn stop_condition_verdict_fails_the_goal_without_another_turn() { + let mut g = goal(); + let decision = apply_at( + &mut g, + Evaluation::new( + AttemptVerdict::Stopped, + "the required document is unavailable", + ), + at(2026, 7, 14, 12, 0), + ) + .unwrap(); + + assert_eq!( + decision, + ControllerDecision::Failed(FailureReason::StopConditionMet) + ); + assert_eq!(g.state, GoalState::Failed); + assert_eq!( + g.note.as_deref(), + Some("the required document is unavailable") + ); + } + + #[test] + fn progressed_at_budget_exhaustion_fails() { + let mut g = Goal::new( + "g", + owner(), + "obj", + CompletionContract::new("out", "verify", "stop"), + Budget::turns(2), + at(2026, 7, 14, 9, 0), + ); + // First progressing turn: continue. + assert_eq!( + apply_at( + &mut g, + Evaluation::new(AttemptVerdict::Progressed, "step 1"), + at(2026, 7, 14, 9, 5) + ) + .unwrap(), + ControllerDecision::Continue + ); + // Second progressing turn spends the last of the budget: fail. + let decision = apply_at( + &mut g, + Evaluation::new(AttemptVerdict::Progressed, "step 2"), + at(2026, 7, 14, 9, 10), + ) + .unwrap(); + assert_eq!( + decision, + ControllerDecision::Failed(FailureReason::TurnBudgetExhausted) + ); + assert_eq!(g.state, GoalState::Failed); + assert_eq!(g.turns_used(), 2); + assert!(g.note.is_some()); + } + + #[test] + fn progressed_past_deadline_fails_before_budget() { + let mut g = Goal::new( + "g", + owner(), + "obj", + CompletionContract::new("out", "verify", "stop"), + Budget::turns(10).with_deadline(at(2026, 7, 14, 18, 0)), + at(2026, 7, 14, 9, 0), + ); + g.begin_attempt(at(2026, 7, 14, 17, 59)).unwrap(); + let decision = g + .apply_evaluation( + Evaluation::new(AttemptVerdict::Progressed, "still working"), + at(2026, 7, 14, 18, 1), + ) + .unwrap(); + assert_eq!( + decision, + ControllerDecision::Failed(FailureReason::DeadlinePassed) + ); + assert_eq!(g.state, GoalState::Failed); + } + + #[test] + fn cannot_evaluate_a_non_running_goal() { + let mut g = goal(); + g.pause(at(2026, 7, 14, 10, 0)).unwrap(); + let err = g.apply_evaluation( + Evaluation::new(AttemptVerdict::Progressed, "x"), + at(2026, 7, 14, 10, 1), + ); + assert!(err.is_err()); + // The rejected evaluation left the ledger untouched. + assert_eq!(g.turns_used(), 0); + assert_eq!(g.state, GoalState::Paused); + } + + #[test] + fn enforce_deadline_fails_a_waiting_goal_past_its_deadline() { + let mut g = Goal::new( + "g", + owner(), + "obj", + CompletionContract::new("out", "verify", "stop"), + Budget::turns(5).with_deadline(at(2026, 7, 14, 18, 0)), + at(2026, 7, 14, 9, 0), + ); + g.wait(at(2026, 7, 14, 10, 0)).unwrap(); + // Before the deadline: nothing fires. + assert!(!g.enforce_deadline(at(2026, 7, 14, 17, 0))); + assert_eq!(g.state, GoalState::Waiting); + // Past it: the goal fails. + assert!(g.enforce_deadline(at(2026, 7, 14, 18, 30))); + assert_eq!(g.state, GoalState::Failed); + assert!(g.note.is_some()); + // Idempotent on a terminal goal. + assert!(!g.enforce_deadline(at(2026, 7, 14, 19, 0))); + } + + #[test] + fn lifecycle_edges_manage_the_note() { + let mut g = goal(); + let t = at(2026, 7, 14, 10, 0); + // Running -> Waiting -> Running (wake clears nothing, no note set). + g.wait(t).unwrap(); + assert_eq!(g.state, GoalState::Waiting); + g.wake(t).unwrap(); + assert_eq!(g.state, GoalState::Running); + // Running -> Blocked (note set) -> resume clears note. + g.block("waiting on a document", t).unwrap(); + assert_eq!(g.state, GoalState::Blocked); + assert_eq!(g.note.as_deref(), Some("waiting on a document")); + g.resume(t).unwrap(); + assert_eq!(g.state, GoalState::Running); + assert_eq!(g.note, None); + // Running -> Paused -> resume. + g.pause(t).unwrap(); + assert_eq!(g.state, GoalState::Paused); + g.resume(t).unwrap(); + assert_eq!(g.state, GoalState::Running); + // fail from Running sets the note and is terminal. + g.fail("user cancelled", t).unwrap(); + assert_eq!(g.state, GoalState::Failed); + assert_eq!(g.note.as_deref(), Some("user cancelled")); + // No lifecycle edge escapes a terminal state. + assert!(g.resume(t).is_err()); + assert!(g.wake(t).is_err()); + } + + // ---- goal turn text ---------------------------------------------- + + #[test] + fn goal_turn_text_frames_the_contract_and_asks_for_evidence() { + let mut g = Goal::new( + "g", + owner(), + "prepare the 2025 tax return", + CompletionContract { + outcome: "a filled ELSTER draft".into(), + verification: "the draft file validates".into(), + constraints: vec!["keep the original intact".into()], + boundaries: vec!["never submit, only prepare".into()], + stop_condition: "give up if a required document is missing".into(), + }, + Budget::turns(5), + at(2026, 7, 14, 9, 0), + ); + g.subgoals = vec![Subgoal::new("collect receipts")]; + + let text = goal_turn_text(&g); + assert!(text.starts_with(GOAL_PREFIX), "{text}"); + assert!(text.contains("prepare the 2025 tax return"), "{text}"); + assert!(text.contains("a filled ELSTER draft"), "{text}"); + assert!(text.contains("the draft file validates"), "{text}"); + assert!( + text.contains("give up if a required document is missing"), + "{text}" + ); + assert!(text.contains("keep the original intact"), "{text}"); + assert!(text.contains("never submit, only prepare"), "{text}"); + assert!(text.contains("- [ ] collect receipts"), "{text}"); + assert!(text.contains("first of 5 attempts"), "{text}"); + assert!(text.contains("autonomously"), "{text}"); + // No silence token: the evaluator needs a factual summary. + assert!(!text.contains("[SILENT]"), "{text}"); // the host's silence token + } + + #[test] + fn goal_turn_text_carries_the_last_attempt_as_continuity() { + let mut g = goal(); // budget 5 + apply_at( + &mut g, + Evaluation::new(AttemptVerdict::Progressed, "downloaded three receipts"), + at(2026, 7, 14, 10, 0), + ) + .unwrap(); + let text = goal_turn_text(&g); + assert!(text.contains("1 of 5 attempts used"), "{text}"); + assert!(text.contains("downloaded three receipts"), "{text}"); + } + + #[test] + fn goal_turn_text_surfaces_a_wake_note() { + let mut g = goal(); + // Park, then wake (as a resolved wait barrier would). + g.wait(at(2026, 7, 14, 10, 0)).unwrap(); + g.wake(at(2026, 7, 14, 12, 0)).unwrap(); + g.note = Some("the build finished: exit status 0".into()); + let text = goal_turn_text(&g); + assert!( + text.contains("Update since you last worked on this: the build finished"), + "{text}" + ); + // A fresh Running goal (no note) shows no update line. + assert!(!goal_turn_text(&goal()).contains("Update since")); + } + + // ---- evaluator seam ---------------------------------------------- + + struct ScriptedEvaluator(Evaluation); + + #[async_trait::async_trait] + impl GoalEvaluator for ScriptedEvaluator { + async fn evaluate(&self, _goal: &Goal, _turn: &TurnOutcome) -> anyhow::Result { + Ok(self.0.clone()) + } + } + + #[tokio::test] + async fn evaluator_seam_feeds_the_controller() { + let mut g = goal(); + let evaluator = ScriptedEvaluator(Evaluation::new( + AttemptVerdict::Satisfied, + "verified via the check command", + )); + let outcome = TurnOutcome { + assistant_summary: "ran the verification".into(), + artifacts: vec![], + verification: vec!["exit 0".into()], + }; + let eval = evaluator.evaluate(&g, &outcome).await.unwrap(); + let decision = apply_at(&mut g, eval, at(2026, 7, 14, 13, 0)).unwrap(); + assert_eq!(decision, ControllerDecision::Done); + assert_eq!(g.state, GoalState::Done); + } + + // ---- GoalStore persistence --------------------------------------- + + fn store() -> (GoalStore, tempfile::TempDir) { + let dir = tempfile::tempdir().unwrap(); + (GoalStore::new(dir.path().join("goals.json")), dir) + } + + fn contract() -> CompletionContract { + CompletionContract::new("out", "verify", "stop") + } + + #[test] + fn store_roundtrip_add_new_list_get_remove() { + let (store, _dir) = store(); + assert!(store.list().unwrap().is_empty()); + + let now = at(2026, 7, 14, 9, 0); + let g = store + .add_new(owner(), "prepare taxes", contract(), Budget::turns(5), now) + .unwrap(); + assert_eq!(store.list().unwrap(), vec![g.clone()]); + assert_eq!(store.get(&g.id).unwrap().as_ref(), Some(&g)); + assert_eq!(store.get("nope").unwrap(), None); + + assert!(store.remove(&g.id).unwrap()); + assert!(!store.remove(&g.id).unwrap()); + assert!(store.list().unwrap().is_empty()); + } + + #[test] + fn add_new_assigns_unique_ids_within_a_second() { + let (store, _dir) = store(); + let now = at(2026, 7, 14, 9, 0); + let a = store + .add_new(owner(), "a", contract(), Budget::turns(1), now) + .unwrap(); + let b = store + .add_new(owner(), "b", contract(), Budget::turns(1), now) + .unwrap(); + assert_ne!(a.id, b.id); + } + + #[test] + fn add_rejects_a_duplicate_id() { + let (store, _dir) = store(); + let now = at(2026, 7, 14, 9, 0); + let g = Goal::new("dup", owner(), "obj", contract(), Budget::turns(1), now); + store.add(g.clone()).unwrap(); + assert!(store.add(g).is_err()); + } + + #[test] + fn parallel_store_mutations_do_not_lose_goals() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("goals.json"); + let barrier = std::sync::Arc::new(std::sync::Barrier::new(8)); + let mut threads = Vec::new(); + for n in 0..8 { + let path = path.clone(); + let barrier = barrier.clone(); + threads.push(std::thread::spawn(move || { + let store = GoalStore::new(path); + let now = at(2026, 7, 14, 9, 0); + let goal = Goal::new( + format!("parallel-{n}"), + owner(), + format!("goal {n}"), + contract(), + Budget::turns(1), + now, + ); + barrier.wait(); + store.add(goal) + })); + } + + for thread in threads { + thread.join().unwrap().unwrap(); + } + assert_eq!(GoalStore::new(path).list().unwrap().len(), 8); + } + + #[test] + fn update_persists_a_mutated_goal_and_rejects_unknown_ids() { + let (store, _dir) = store(); + let now = at(2026, 7, 14, 9, 0); + let mut g = store + .add_new(owner(), "obj", contract(), Budget::turns(5), now) + .unwrap(); + + // Drive it through a turn, then persist the mutation. + apply_at( + &mut g, + Evaluation::new(AttemptVerdict::Blocked, "need a document"), + at(2026, 7, 14, 10, 0), + ) + .unwrap(); + store.update(&g).unwrap(); + + let reloaded = store.get(&g.id).unwrap().unwrap(); + assert_eq!(reloaded.state, GoalState::Blocked); + assert_eq!(reloaded.note.as_deref(), Some("need a document")); + assert_eq!(reloaded.attempts.len(), 1); + + // Updating a goal the store never saw is an error, not a silent create. + let ghost = Goal::new("ghost", owner(), "x", contract(), Budget::turns(1), now); + assert!(store.update(&ghost).is_err()); + assert_eq!(store.list().unwrap().len(), 1); + } + + #[test] + fn a_stale_goal_snapshot_cannot_overwrite_a_newer_user_transition() { + let (store, _dir) = store(); + let now = at(2026, 7, 14, 9, 0); + let original = store + .add_new(owner(), "obj", contract(), Budget::turns(5), now) + .unwrap(); + let mut controller_snapshot = original.clone(); + let mut user_snapshot = original; + + user_snapshot.pause(at(2026, 7, 14, 9, 1)).unwrap(); + store.update(&user_snapshot).unwrap(); + + controller_snapshot + .block("stale controller result", at(2026, 7, 14, 9, 2)) + .unwrap(); + let error = store.update(&controller_snapshot).unwrap_err(); + + assert!(error.to_string().contains("revision conflict"), "{error}"); + assert_eq!( + store.get(&controller_snapshot.id).unwrap().unwrap().state, + GoalState::Paused + ); + } + + #[test] + fn claiming_an_attempt_is_revision_checked_and_persisted_atomically() { + let (store, _dir) = store(); + let now = at(2026, 7, 14, 9, 0); + let snapshot = store + .add_new(owner(), "obj", contract(), Budget::turns(2), now) + .unwrap(); + + let claimed = store + .claim_attempt(&snapshot, at(2026, 7, 14, 9, 1)) + .unwrap() + .expect("fresh running snapshot should claim"); + + assert_eq!(claimed.turns_used(), 1); + assert!(claimed.in_flight.is_some()); + assert!(claimed.revision > snapshot.revision); + assert_eq!(store.get(&snapshot.id).unwrap().unwrap(), claimed); + assert!(store + .claim_attempt(&snapshot, at(2026, 7, 14, 9, 2)) + .unwrap() + .is_none()); + } + + #[test] + fn an_undispatched_busy_claim_is_abandoned_without_spending_budget() { + let (store, _dir) = store(); + let now = at(2026, 7, 14, 9, 0); + let snapshot = store + .add_new(owner(), "obj", contract(), Budget::turns(2), now) + .unwrap(); + let claim = store + .claim_attempt(&snapshot, at(2026, 7, 14, 9, 1)) + .unwrap() + .unwrap(); + + let abandoned = store.abandon_attempt(&claim).unwrap().unwrap(); + + assert_eq!(abandoned.turns_used(), 0); + assert!(abandoned.in_flight.is_none()); + assert_eq!(abandoned.state, GoalState::Running); + } + + #[test] + fn an_abandoned_claim_cannot_finish_a_replacement_with_the_same_timestamp() { + let (store, _dir) = store(); + let now = at(2026, 7, 14, 9, 0); + let snapshot = store + .add_new(owner(), "obj", contract(), Budget::turns(2), now) + .unwrap(); + let stale_claim = store.claim_attempt(&snapshot, now).unwrap().unwrap(); + let abandoned = store.abandon_attempt(&stale_claim).unwrap().unwrap(); + let replacement = store.claim_attempt(&abandoned, now).unwrap().unwrap(); + + assert_ne!(stale_claim.in_flight, replacement.in_flight); + assert!(store + .finish_attempt( + &stale_claim, + AttemptCompletion::Evaluated(Evaluation::new( + AttemptVerdict::Satisfied, + "stale result", + )), + now, + ) + .unwrap() + .is_none()); + assert_eq!( + store.get(&snapshot.id).unwrap().unwrap().in_flight, + replacement.in_flight + ); + } + + #[test] + fn cancelling_during_a_turn_remains_terminal_when_the_result_arrives() { + let (store, _dir) = store(); + let now = at(2026, 7, 14, 9, 0); + let snapshot = store + .add_new(owner(), "obj", contract(), Budget::turns(2), now) + .unwrap(); + let claim = store.claim_attempt(&snapshot, now).unwrap().unwrap(); + let mut cancelled = store.get(&snapshot.id).unwrap().unwrap(); + cancelled.fail("cancelled by user", now).unwrap(); + store.update(&cancelled).unwrap(); + + let (finished, decision) = store + .finish_attempt( + &claim, + AttemptCompletion::Evaluated(Evaluation::new( + AttemptVerdict::Satisfied, + "late success", + )), + now, + ) + .unwrap() + .unwrap(); + + assert_eq!(decision, ControllerDecision::Preempted); + assert_eq!(finished.state, GoalState::Failed); + assert_eq!(finished.note.as_deref(), Some("cancelled by user")); + assert_eq!(finished.attempts.len(), 1); + assert!(finished.in_flight.is_none()); + } + + #[test] + fn active_and_active_for_owner_filter_out_terminal_goals() { + let (store, _dir) = store(); + let now = at(2026, 7, 14, 9, 0); + let other_owner = OwnerKey::from_parts(&["tui", "default"]); + + let running = store + .add_new(owner(), "running", contract(), Budget::turns(5), now) + .unwrap(); + let mut done = store + .add_new(owner(), "done", contract(), Budget::turns(1), now) + .unwrap(); + apply_at( + &mut done, + Evaluation::new(AttemptVerdict::Satisfied, "ok"), + now, + ) + .unwrap(); + store.update(&done).unwrap(); + let elsewhere = store + .add_new( + other_owner.clone(), + "elsewhere", + contract(), + Budget::turns(5), + now, + ) + .unwrap(); + + // active() drops the Done goal, keeps both Running ones. + let mut active_ids: Vec<_> = store.active().unwrap().into_iter().map(|g| g.id).collect(); + active_ids.sort(); + let mut expected = vec![running.id.clone(), elsewhere.id.clone()]; + expected.sort(); + assert_eq!(active_ids, expected); + + // Scoped to a owner. + let for_lane = store.active_for_owner(&owner()).unwrap(); + assert_eq!(for_lane.len(), 1); + assert_eq!(for_lane[0].id, running.id); + } + + #[test] + fn preempt_owner_pauses_only_running_goals_of_that_lane() { + let (store, _dir) = store(); + let now = at(2026, 7, 14, 9, 0); + let other_owner = OwnerKey::from_parts(&["tui", "default"]); + + let running = store + .add_new(owner(), "running", contract(), Budget::turns(5), now) + .unwrap(); + // A blocked goal on the same owner stays blocked (already stopped). + let mut blocked = store + .add_new(owner(), "blocked", contract(), Budget::turns(5), now) + .unwrap(); + blocked.block("waiting on a doc", now).unwrap(); + store.update(&blocked).unwrap(); + // A running goal on another owner is untouched. + let elsewhere = store + .add_new( + other_owner.clone(), + "elsewhere", + contract(), + Budget::turns(5), + now, + ) + .unwrap(); + + let paused = store + .preempt_owner(&owner(), at(2026, 7, 14, 10, 0)) + .unwrap(); + assert_eq!(paused, vec![running.id.clone()]); + assert_eq!( + store.get(&running.id).unwrap().unwrap().state, + GoalState::Paused + ); + assert_eq!( + store.get(&blocked.id).unwrap().unwrap().state, + GoalState::Blocked + ); + assert_eq!( + store.get(&elsewhere.id).unwrap().unwrap().state, + GoalState::Running + ); + + // Idempotent: a second message preempts nothing new. + assert!(store + .preempt_owner(&owner(), at(2026, 7, 14, 10, 1)) + .unwrap() + .is_empty()); + } + + #[test] + fn wake_waiting_returns_a_parked_goal_to_running_with_a_note() { + let (store, _dir) = store(); + let now = at(2026, 7, 14, 9, 0); + let mut g = store + .add_new(owner(), "obj", contract(), Budget::turns(5), now) + .unwrap(); + // Park it (as a Waiting-verdict turn would). + g.wait(at(2026, 7, 14, 9, 30)).unwrap(); + g = store.update(&g).unwrap(); + + let woke = store + .wake_waiting( + &g.id, + Some("the build finished".into()), + at(2026, 7, 14, 10, 0), + ) + .unwrap(); + assert!(woke); + let reloaded = store.get(&g.id).unwrap().unwrap(); + assert_eq!(reloaded.state, GoalState::Running); + assert_eq!(reloaded.note.as_deref(), Some("the build finished")); + } + + #[test] + fn wake_waiting_is_a_no_op_for_a_goal_that_moved_on() { + let (store, _dir) = store(); + let now = at(2026, 7, 14, 9, 0); + let g = store + .add_new(owner(), "obj", contract(), Budget::turns(5), now) + .unwrap(); + // Still Running (never parked): waking is a no-op. + assert!(!store + .wake_waiting(&g.id, None, at(2026, 7, 14, 10, 0)) + .unwrap()); + assert_eq!(store.get(&g.id).unwrap().unwrap().state, GoalState::Running); + // An unknown goal is a no-op too. + assert!(!store + .wake_waiting("ghost", None, at(2026, 7, 14, 10, 0)) + .unwrap()); + } + + #[test] + fn goal_survives_a_json_roundtrip_with_full_ledger() { + let (store, _dir) = store(); + let now = at(2026, 7, 14, 9, 0); + let mut g = store + .add_new( + owner(), + "obj", + CompletionContract { + outcome: "out".into(), + verification: "verify".into(), + constraints: vec!["keep original".into()], + boundaries: vec!["never submit".into()], + stop_condition: "stop".into(), + }, + Budget::turns(3).with_deadline(at(2026, 7, 20, 0, 0)), + now, + ) + .unwrap(); + g.subgoals = vec![Subgoal::new("collect receipts")]; + let mut eval = Evaluation::new(AttemptVerdict::Progressed, "did a thing"); + eval.artifacts = vec!["inbox/a.pdf".into()]; + eval.evidence = vec!["ok".into()]; + apply_at(&mut g, eval, at(2026, 7, 14, 10, 0)).unwrap(); + g = store.update(&g).unwrap(); + + assert_eq!(store.get(&g.id).unwrap().unwrap(), g); + } +} diff --git a/crates/agent_orchestration/src/lib.rs b/crates/agent_orchestration/src/lib.rs new file mode 100644 index 00000000..d9f79903 --- /dev/null +++ b/crates/agent_orchestration/src/lib.rs @@ -0,0 +1,48 @@ +//! Storage-agnostic orchestration domain shared by code-assistant hosts and +//! downstream consumers (PAL is the first): durable goals with a +//! deterministic controller policy, and the typed wait barriers a goal can +//! park on. +//! +//! See ROADMAP.md ("Crate direction" / "Now: converge goals and exact turn +//! ownership"): this crate owns the *generic semantics* — entities, state +//! machines, the attempt/evidence ledger, claim/revision atomicity, and the +//! evaluator seam. Hosts own everything whose meaning comes from deployment: +//! which session incarnation pursues a goal, startup sweeps and orphan +//! adoption, durable timers/jobs/children/event probes, channel delivery. +//! +//! It deliberately depends on no frontend, no channels, and no global config +//! paths. The bundled [`goals::GoalStore`]/[`waits::WaitStore`] are plain +//! JSON-file stores rooted at an explicit path; the [`goals::GoalRepository`] +//! and [`waits::WaitRepository`] traits are the seam a transactional +//! repository implements instead. + +pub mod goal_eval; +pub mod goals; +pub mod waits; + +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// Stable identity of the owner a goal or wait is bound to. Hosts define the +/// shape: PAL uses conversation-lane keys such as `telegram:private:42`, +/// an interactive code-assistant host can use a session or project id. The +/// orchestration domain only compares and stores it. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct OwnerKey(String); + +impl OwnerKey { + /// Build a key from its hierarchy of parts (channel, chat, …). + pub fn from_parts(parts: &[&str]) -> Self { + Self(parts.join(":")) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for OwnerKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} diff --git a/crates/agent_orchestration/src/waits.rs b/crates/agent_orchestration/src/waits.rs new file mode 100644 index 00000000..5f439fb4 --- /dev/null +++ b/crates/agent_orchestration/src/waits.rs @@ -0,0 +1,1056 @@ +//! Durable wait conditions: a goal parks on an external barrier instead of +//! burning model turns polling for it. `schedule_wakeup` and a PTY session id +//! are useful *inside* a live incarnation, but neither survives rotation or a +//! restart — they are not durable dependency edges. A [`Wait`] is the pal_core +//! entity that turns "poll again in 20 minutes" into "finish this when CI / +//! the deployment / the requested document is ready": it is persisted, it wakes +//! exactly the one goal that owns it, and while it is armed the goal spends no +//! model turns at all (a `Waiting` goal is never driven by the controller — +//! see [`crate::goals`]). +//! +//! This module is the *domain* layer only: the barrier taxonomy +//! ([`WaitKind`]), the wait state machine and the clock-only predicates +//! (`timed_out`, [`WaitKind::due`]) that resolve a wait without any I/O. +//! Whether a *runtime-observable* barrier (a process exit, a job completion, an +//! external event) has fired is decided by an injected probe at the gateway +//! layer, so this policy stays testable without real processes. Persistence is +//! [`WaitStore`], the sweep that folds a probe outcome into a resolution and +//! wakes the owning goal lives in the host (PAL's gateway `wait_pass`). +//! +//! All timestamps are naive local time, like [`crate::goals`] and +//! the host's job scheduler. + +use crate::OwnerKey; +use chrono::NaiveDateTime; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::{Arc, Mutex, OnceLock}; + +/// What a wait is blocked on. The domain layer treats each barrier's +/// satisfaction opaquely: `Until` resolves against the clock alone, +/// `HumanInput` against a user message on the owner, and every other kind +/// against an injected runtime probe. The variant carries only the *identity* +/// of the thing waited on — never a live handle — so a wait means the same +/// after a restart. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "barrier", rename_all = "snake_case")] +pub enum WaitKind { + /// A pure wall-clock barrier: fires the moment `now >= at`. A durable, + /// turn-free sleep — distinct from a *timeout*, which is a wait's failure + /// edge rather than its success edge. + Until { at: NaiveDateTime }, + /// A background process (identified by a durable PTY/session handle) has + /// exited, whatever its exit status. The status rides along in the + /// resolution note so the woken goal can react to it. + ProcessExit { handle: String }, + /// A background process' accumulated output matched `pattern` (e.g. a build + /// printed `BUILD SUCCESSFUL`). Fires on first match. + OutputPattern { handle: String, pattern: String }, + /// A durable job's run reached a terminal outcome. + JobCompletion { job_id: String }, + /// A spawned child agent finished its run. + SubAgentCompletion { agent_id: String }, + /// An external event carrying this dedupe key was signalled (the seam the + /// later event-source work feeds). + Event { key: String }, + /// The user answered on the goal's own owner — the human-input barrier that + /// the gateway satisfies from the same path that preempts running goals. + HumanInput, +} + +impl WaitKind { + /// Whether a `Until` barrier has been reached at `now`. Always `false` for + /// every other kind — their satisfaction is not a function of the clock. + pub fn due(&self, now: NaiveDateTime) -> bool { + matches!(self, WaitKind::Until { at } if now >= *at) + } + + /// Whether this barrier resolves against the clock alone, needing no + /// runtime probe and no external signal. Only `Until` does. + pub fn is_clock_only(&self) -> bool { + matches!(self, WaitKind::Until { .. }) + } + + /// Whether this barrier is satisfied by a user message on the owner rather + /// than by a probe. Only `HumanInput` is. + pub fn is_human_input(&self) -> bool { + matches!(self, WaitKind::HumanInput) + } + + /// A stable lowercase label for prompts and listings. + pub fn label(&self) -> &'static str { + match self { + WaitKind::Until { .. } => "until", + WaitKind::ProcessExit { .. } => "process_exit", + WaitKind::OutputPattern { .. } => "output_pattern", + WaitKind::JobCompletion { .. } => "job_completion", + WaitKind::SubAgentCompletion { .. } => "sub_agent_completion", + WaitKind::Event { .. } => "event", + WaitKind::HumanInput => "human_input", + } + } +} + +/// A request to arm a durable wait, produced by a goal evaluation (the +/// [`crate::goals::AttemptVerdict::Waiting`] verdict) and turned into a +/// persisted [`Wait`] by the gateway through [`WaitStore::arm`]. Carries only +/// the barrier and its optional timeout — the id, owning goal and owner are +/// supplied at arming time, so the same request can be judged, logged and +/// stored without knowing those yet. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WaitRequest { + pub kind: WaitKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout: Option, +} + +impl WaitRequest { + pub fn new(kind: WaitKind) -> Self { + Self { + kind, + timeout: None, + } + } + + pub fn with_timeout(mut self, deadline: NaiveDateTime) -> Self { + self.timeout = Some(deadline); + self + } +} + +/// Where a wait is in its lifecycle. `Armed` is the only live state; the three +/// resolutions are terminal and mutually exclusive. +/// +/// - `Satisfied`: the barrier fired — the goal wakes to make use of it. +/// - `TimedOut`: the optional wall-clock timeout elapsed first — the goal wakes +/// to react to the *absence* of what it waited for. +/// - `Cancelled`: the wait was retired without firing (its goal was paused, +/// cancelled or itself reached a terminal state). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WaitState { + Armed, + Satisfied, + TimedOut, + Cancelled, +} + +impl WaitState { + /// A resolution admits no further transitions. + pub fn is_terminal(&self) -> bool { + !matches!(self, WaitState::Armed) + } + + /// Whether the owning goal should be woken when the wait reaches this + /// state. Both a fired barrier and an elapsed timeout wake the goal (it + /// must react either way); a cancelled wait does not (its goal was already + /// moved on by whoever cancelled it). + pub fn wakes_goal(&self) -> bool { + matches!(self, WaitState::Satisfied | WaitState::TimedOut) + } + + pub fn label(&self) -> &'static str { + match self { + WaitState::Armed => "armed", + WaitState::Satisfied => "satisfied", + WaitState::TimedOut => "timed_out", + WaitState::Cancelled => "cancelled", + } + } +} + +/// A durable barrier owned by exactly one goal. While it is `Armed` the goal is +/// `Waiting` and consumes no turns; a resolution is the signal to wake that one +/// goal (or, for `Cancelled`, that the goal was already moved on). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Wait { + pub id: String, + /// Optimistic-concurrency revision, mirroring [`crate::goals::Goal`]. A + /// stale sweep snapshot cannot overwrite a newer cancellation. + #[serde(default)] + pub revision: u64, + /// The goal this wait wakes. Exactly one — a wait is never shared. + pub goal_id: String, + /// The goal's owner, denormalised so the human-input path can find the wait + /// by owner without loading the goal store. + #[serde(alias = "owner")] + pub owner: OwnerKey, + pub kind: WaitKind, + pub state: WaitState, + /// Optional wall-clock timeout. Reaching it while `Armed` resolves the wait + /// to `TimedOut`. Independent of `WaitKind::Until`, whose `at` is the + /// *success* barrier — an `Until` wait may also carry a (later) timeout, + /// though that is rarely useful. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout: Option, + /// Why the wait resolved the way it did (a process exit status, the matched + /// line, a timeout notice, a cancellation reason). Carried into the goal's + /// wake note. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub note: Option, + pub armed_at: NaiveDateTime, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resolved_at: Option, + pub updated_at: NaiveDateTime, +} + +impl Wait { + pub fn new( + id: impl Into, + goal_id: impl Into, + owner: OwnerKey, + kind: WaitKind, + timeout: Option, + now: NaiveDateTime, + ) -> Self { + Self { + id: id.into(), + revision: 0, + goal_id: goal_id.into(), + owner, + kind, + state: WaitState::Armed, + timeout, + note: None, + armed_at: now, + resolved_at: None, + updated_at: now, + } + } + + /// Whether the wait is still live (waiting for its barrier). + pub fn is_active(&self) -> bool { + self.state == WaitState::Armed + } + + /// Whether the optional timeout has elapsed at `now`. `false` when there is + /// no timeout — a wait with no timeout waits forever (until cancelled). + pub fn timed_out(&self, now: NaiveDateTime) -> bool { + self.timeout.is_some_and(|deadline| now >= deadline) + } + + /// Whether the wait's *own* clock-only barrier is due — i.e. it is an + /// `Until` wait whose target time has been reached. Timeout is separate + /// (see [`Wait::timed_out`]); this is the success edge. + pub fn due(&self, now: NaiveDateTime) -> bool { + self.kind.due(now) + } + + /// The barrier fired. `Armed -> Satisfied`, carrying an optional note (a + /// process exit status, the matched output line, …). Errors if already + /// resolved so a double-fire cannot rewrite an outcome. + pub fn satisfy(&mut self, note: Option, now: NaiveDateTime) -> anyhow::Result<()> { + self.resolve(WaitState::Satisfied, note, now) + } + + /// The timeout elapsed before the barrier fired. `Armed -> TimedOut`. + pub fn time_out(&mut self, now: NaiveDateTime) -> anyhow::Result<()> { + self.resolve( + WaitState::TimedOut, + Some(format!("wait for {} timed out", self.kind.label())), + now, + ) + } + + /// The wait is retired without firing (its goal was paused/cancelled/failed + /// or superseded). `Armed -> Cancelled`. Idempotent-friendly: cancelling an + /// already-resolved wait is a no-op that returns `false`. + pub fn cancel(&mut self, reason: impl Into, now: NaiveDateTime) -> bool { + if self.state.is_terminal() { + return false; + } + let _ = self.resolve(WaitState::Cancelled, Some(reason.into()), now); + true + } + + fn resolve( + &mut self, + state: WaitState, + note: Option, + now: NaiveDateTime, + ) -> anyhow::Result<()> { + anyhow::ensure!( + self.state == WaitState::Armed, + "cannot resolve a {} wait to {}", + self.state.label(), + state.label() + ); + self.state = state; + self.note = note; + self.resolved_at = Some(now); + self.updated_at = now; + Ok(()) + } + + /// A short human/prompt line describing what this wait is blocked on. + pub fn describe(&self) -> String { + let barrier = match &self.kind { + WaitKind::Until { at } => format!("until {}", at.format("%Y-%m-%d %H:%M")), + WaitKind::ProcessExit { handle } => format!("process {handle} to exit"), + WaitKind::OutputPattern { handle, pattern } => { + format!("process {handle} to print /{pattern}/") + } + WaitKind::JobCompletion { job_id } => format!("job {job_id} to finish"), + WaitKind::SubAgentCompletion { agent_id } => format!("agent {agent_id} to finish"), + WaitKind::Event { key } => format!("event {key}"), + WaitKind::HumanInput => "a reply from you".to_string(), + }; + match self.timeout { + Some(deadline) => { + format!( + "waiting for {barrier} (timeout {})", + deadline.format("%Y-%m-%d %H:%M") + ) + } + None => format!("waiting for {barrier}"), + } + } +} + +/// The result of polling a runtime-observable barrier. `Pending` leaves the +/// wait armed for the next sweep; `Fired` resolves it (and wakes the owning +/// goal), carrying an optional note that becomes the goal's wake reason. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WaitProbeOutcome { + Pending, + Fired { note: Option }, +} + +impl WaitProbeOutcome { + /// A fired barrier with no extra detail. + pub fn fired() -> Self { + WaitProbeOutcome::Fired { note: None } + } + + /// A fired barrier carrying a note (an exit status, the matched line, …). + pub fn fired_with(note: impl Into) -> Self { + WaitProbeOutcome::Fired { + note: Some(note.into()), + } + } +} + +/// The runtime-facing seam that decides whether a *runtime-observable* barrier +/// (a process exit, an output match, a job or child-agent completion, an +/// external event) has fired. The clock-only `Until` barrier and the +/// `HumanInput` barrier are resolved by the gateway without ever reaching a +/// probe, so an implementation only needs to handle the observable kinds; it +/// may return `Pending` for anything it does not recognise. Injected exactly +/// like [`crate::goals::GoalEvaluator`], which keeps the sweep policy testable +/// without real processes. +#[async_trait::async_trait] +pub trait WaitProbe: Send + Sync { + async fn poll(&self, wait: &Wait) -> anyhow::Result; +} + +/// JSON-file persistence for durable waits (`waits.json`). Mirrors +/// [`crate::goals::GoalStore`]: every operation reloads the current file and +/// writes through atomic tmp+rename, instances for the same path share a +/// process-local lock, and [`WaitStore::update`] rejects a stale revision so a +/// sweep snapshot cannot clobber a concurrent cancellation. +/// +/// The store holds only *live* (`Armed`) waits. Resolving a wait is the job of +/// the gateway sweep, which wakes the owning goal and then [`WaitStore::remove`]s +/// the wait — so a resolution is never persisted here. The `Satisfied` / +/// `TimedOut` / `Cancelled` states exist for the in-memory transition and for +/// the crash-window reconciliation the sweep performs (a still-`Armed` wait +/// whose goal is no longer `Waiting` is stale and dropped). At most one wait is +/// armed per goal: [`WaitStore::arm`] supersedes any earlier barrier, which +/// keeps waking unambiguous. +pub struct WaitStore { + path: PathBuf, + lock: Arc>, +} + +impl WaitStore { + pub fn new(path: impl Into) -> Self { + let path = path.into(); + let lock = wait_store_lock(&path); + Self { path, lock } + } + + /// All persisted waits (normally all `Armed`); a missing file is empty. + pub fn list(&self) -> anyhow::Result> { + let _guard = self.lock.lock().expect("wait store lock poisoned"); + self.load_unlocked() + } + + fn load_unlocked(&self) -> anyhow::Result> { + match std::fs::read_to_string(&self.path) { + Ok(content) => Ok(serde_json::from_str(&content)?), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()), + Err(e) => Err(e.into()), + } + } + + fn save_unlocked(&self, waits: &[Wait]) -> anyhow::Result<()> { + if let Some(dir) = self.path.parent() { + std::fs::create_dir_all(dir)?; + } + let tmp = self.path.with_extension("json.tmp"); + std::fs::write(&tmp, serde_json::to_string_pretty(waits)?)?; + std::fs::rename(&tmp, &self.path)?; + Ok(()) + } + + /// Add a wait with a caller-supplied id; the id must be unique. Prefer + /// [`WaitStore::arm`], which also supersedes a goal's earlier barrier. + pub fn add(&self, wait: Wait) -> anyhow::Result<()> { + let _guard = self.lock.lock().expect("wait store lock poisoned"); + let mut waits = self.load_unlocked()?; + if waits.iter().any(|w| w.id == wait.id) { + anyhow::bail!("wait id {} already exists", wait.id); + } + waits.push(wait); + self.save_unlocked(&waits) + } + + /// Arm a durable barrier for a goal, superseding any wait it already owns + /// (a goal parks on exactly one barrier at a time). The store assigns the + /// id, returned for display and cancellation. + pub fn arm( + &self, + goal_id: impl Into, + owner: OwnerKey, + kind: WaitKind, + timeout: Option, + now: NaiveDateTime, + ) -> anyhow::Result { + let goal_id = goal_id.into(); + let _guard = self.lock.lock().expect("wait store lock poisoned"); + let mut waits = self.load_unlocked()?; + // Supersede: a goal never holds two armed barriers. + waits.retain(|w| w.goal_id != goal_id); + let base = format!("wait-{}", now.format("%Y%m%d-%H%M%S")); + let mut id = base.clone(); + let mut n = 1; + while waits.iter().any(|w| w.id == id) { + n += 1; + id = format!("{base}-{n}"); + } + let wait = Wait::new(id, goal_id, owner, kind, timeout, now); + waits.push(wait.clone()); + self.save_unlocked(&waits)?; + Ok(wait) + } + + /// A single wait by id. + pub fn get(&self, id: &str) -> anyhow::Result> { + Ok(self.list()?.into_iter().find(|w| w.id == id)) + } + + /// Persist a mutated wait against the revision it was loaded at. Errors on + /// an unknown id (an update never silently creates) or a revision conflict. + pub fn update(&self, wait: &Wait) -> anyhow::Result { + let _guard = self.lock.lock().expect("wait store lock poisoned"); + let mut waits = self.load_unlocked()?; + let Some(slot) = waits.iter_mut().find(|w| w.id == wait.id) else { + anyhow::bail!("unknown wait id {}", wait.id); + }; + anyhow::ensure!( + slot.revision == wait.revision, + "wait {} revision conflict: expected {}, found {}", + wait.id, + wait.revision, + slot.revision + ); + let mut persisted = wait.clone(); + persisted.revision = persisted.revision.saturating_add(1); + *slot = persisted.clone(); + self.save_unlocked(&waits)?; + Ok(persisted) + } + + /// Remove a wait; `false` when the id is unknown. A resolved wait is removed + /// (not persisted terminal) — the goal's wake note carries the outcome. + pub fn remove(&self, id: &str) -> anyhow::Result { + let _guard = self.lock.lock().expect("wait store lock poisoned"); + let mut waits = self.load_unlocked()?; + let before = waits.len(); + waits.retain(|w| w.id != id); + let removed = waits.len() != before; + if removed { + self.save_unlocked(&waits)?; + } + Ok(removed) + } + + /// Every live wait — the set the sweep must probe. + pub fn armed(&self) -> anyhow::Result> { + Ok(self.list()?.into_iter().filter(Wait::is_active).collect()) + } + + /// Live waits owned by one goal (normally at most one — see + /// [`WaitStore::arm`]). + pub fn armed_for_goal(&self, goal_id: &str) -> anyhow::Result> { + Ok(self + .armed()? + .into_iter() + .filter(|w| w.goal_id == goal_id) + .collect()) + } + + /// Live waits armed on one owner. + pub fn armed_for_owner(&self, owner: &OwnerKey) -> anyhow::Result> { + Ok(self + .armed()? + .into_iter() + .filter(|w| &w.owner == owner) + .collect()) + } + + /// Retire every wait a goal owns because the goal itself moved on (paused, + /// cancelled, blocked or reached a terminal state). Returns the removed + /// ids. Since a resolved wait is removed anyway, cancellation is a removal — + /// the reason is not persisted, only logged by the caller. + pub fn cancel_for_goal(&self, goal_id: &str) -> anyhow::Result> { + let _guard = self.lock.lock().expect("wait store lock poisoned"); + let mut waits = self.load_unlocked()?; + let mut cancelled = Vec::new(); + waits.retain(|w| { + if w.goal_id == goal_id { + cancelled.push(w.id.clone()); + false + } else { + true + } + }); + if !cancelled.is_empty() { + self.save_unlocked(&waits)?; + } + Ok(cancelled) + } + + /// A user message arrived on `owner`: satisfy and remove every armed + /// `HumanInput` wait there, returning them so the caller can wake their + /// goals. The human-input barrier is resolved from the same path that + /// preempts running goals, never by the runtime probe. + pub fn take_human_input_for_owner( + &self, + owner: &OwnerKey, + now: NaiveDateTime, + ) -> anyhow::Result> { + let _guard = self.lock.lock().expect("wait store lock poisoned"); + let mut waits = self.load_unlocked()?; + let mut taken = Vec::new(); + waits.retain(|w| { + if &w.owner == owner && w.is_active() && w.kind.is_human_input() { + let mut w = w.clone(); + let _ = w.satisfy(Some("you replied".into()), now); + taken.push(w); + false + } else { + true + } + }); + if !taken.is_empty() { + self.save_unlocked(&waits)?; + } + Ok(taken) + } +} + +/// Storage seam for waits, mirroring [`crate::goals::GoalRepository`]: the +/// bundled JSON [`WaitStore`] implements it, a transactional repository can +/// replace it without touching sweep or controller code. +pub trait WaitRepository: Send + Sync { + fn list(&self) -> anyhow::Result>; + fn add(&self, wait: Wait) -> anyhow::Result<()>; + fn arm( + &self, + goal_id: String, + owner: OwnerKey, + kind: WaitKind, + timeout: Option, + now: NaiveDateTime, + ) -> anyhow::Result; + fn get(&self, id: &str) -> anyhow::Result>; + fn update(&self, wait: &Wait) -> anyhow::Result; + fn remove(&self, id: &str) -> anyhow::Result; + fn armed(&self) -> anyhow::Result>; + fn armed_for_goal(&self, goal_id: &str) -> anyhow::Result>; + fn armed_for_owner(&self, owner: &OwnerKey) -> anyhow::Result>; + fn cancel_for_goal(&self, goal_id: &str) -> anyhow::Result>; + fn take_human_input_for_owner( + &self, + owner: &OwnerKey, + now: NaiveDateTime, + ) -> anyhow::Result>; +} + +impl WaitRepository for WaitStore { + fn list(&self) -> anyhow::Result> { + WaitStore::list(self) + } + fn add(&self, wait: Wait) -> anyhow::Result<()> { + WaitStore::add(self, wait) + } + fn arm( + &self, + goal_id: String, + owner: OwnerKey, + kind: WaitKind, + timeout: Option, + now: NaiveDateTime, + ) -> anyhow::Result { + WaitStore::arm(self, goal_id, owner, kind, timeout, now) + } + fn get(&self, id: &str) -> anyhow::Result> { + WaitStore::get(self, id) + } + fn update(&self, wait: &Wait) -> anyhow::Result { + WaitStore::update(self, wait) + } + fn remove(&self, id: &str) -> anyhow::Result { + WaitStore::remove(self, id) + } + fn armed(&self) -> anyhow::Result> { + WaitStore::armed(self) + } + fn armed_for_goal(&self, goal_id: &str) -> anyhow::Result> { + WaitStore::armed_for_goal(self, goal_id) + } + fn armed_for_owner(&self, owner: &OwnerKey) -> anyhow::Result> { + WaitStore::armed_for_owner(self, owner) + } + fn cancel_for_goal(&self, goal_id: &str) -> anyhow::Result> { + WaitStore::cancel_for_goal(self, goal_id) + } + fn take_human_input_for_owner( + &self, + owner: &OwnerKey, + now: NaiveDateTime, + ) -> anyhow::Result> { + WaitStore::take_human_input_for_owner(self, owner, now) + } +} + +fn wait_store_lock(path: &std::path::Path) -> Arc> { + static LOCKS: OnceLock>>>> = OnceLock::new(); + let locks = LOCKS.get_or_init(|| Mutex::new(HashMap::new())); + let mut locks = locks.lock().expect("wait store lock registry poisoned"); + locks + .entry(path.to_path_buf()) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone() +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::NaiveDate; + + fn at(y: i32, mo: u32, d: u32, h: u32, mi: u32) -> NaiveDateTime { + NaiveDate::from_ymd_opt(y, mo, d) + .unwrap() + .and_hms_opt(h, mi, 0) + .unwrap() + } + + fn owner() -> OwnerKey { + OwnerKey::from_parts(&["telegram", "private", "42"]) + } + + fn wait(kind: WaitKind, timeout: Option) -> Wait { + Wait::new( + "wait-1", + "goal-1", + owner(), + kind, + timeout, + at(2026, 7, 14, 9, 0), + ) + } + + #[test] + fn new_wait_is_armed_and_unresolved() { + let w = wait( + WaitKind::ProcessExit { + handle: "pty-7".into(), + }, + None, + ); + assert_eq!(w.state, WaitState::Armed); + assert!(w.is_active()); + assert_eq!(w.goal_id, "goal-1"); + assert!(w.note.is_none()); + assert!(w.resolved_at.is_none()); + assert_eq!(w.armed_at, w.updated_at); + } + + #[test] + fn armed_is_the_only_non_terminal_state() { + assert!(!WaitState::Armed.is_terminal()); + assert!(WaitState::Satisfied.is_terminal()); + assert!(WaitState::TimedOut.is_terminal()); + assert!(WaitState::Cancelled.is_terminal()); + } + + #[test] + fn only_a_fired_or_timed_out_wait_wakes_the_goal() { + assert!(WaitState::Satisfied.wakes_goal()); + assert!(WaitState::TimedOut.wakes_goal()); + assert!(!WaitState::Cancelled.wakes_goal()); + assert!(!WaitState::Armed.wakes_goal()); + } + + #[test] + fn until_is_the_only_clock_only_barrier() { + let until = WaitKind::Until { + at: at(2026, 7, 14, 12, 0), + }; + assert!(until.is_clock_only()); + assert!(!until.is_human_input()); + assert!(WaitKind::HumanInput.is_human_input()); + assert!(!WaitKind::HumanInput.is_clock_only()); + assert!(!WaitKind::ProcessExit { handle: "x".into() }.is_clock_only()); + } + + #[test] + fn until_is_due_only_once_its_time_is_reached() { + let target = at(2026, 7, 14, 12, 0); + let w = wait(WaitKind::Until { at: target }, None); + assert!(!w.due(at(2026, 7, 14, 11, 59))); + assert!(w.due(target)); + assert!(w.due(at(2026, 7, 14, 12, 1))); + // A non-Until wait is never "due" on the clock. + let p = wait(WaitKind::ProcessExit { handle: "x".into() }, None); + assert!(!p.due(at(2999, 1, 1, 0, 0))); + } + + #[test] + fn timeout_is_independent_of_the_until_barrier() { + let w = wait( + WaitKind::JobCompletion { + job_id: "job-9".into(), + }, + Some(at(2026, 7, 14, 18, 0)), + ); + assert!(!w.timed_out(at(2026, 7, 14, 17, 59))); + assert!(w.timed_out(at(2026, 7, 14, 18, 0))); + assert!(w.timed_out(at(2026, 7, 14, 18, 1))); + // No timeout means it never times out. + let forever = wait(WaitKind::HumanInput, None); + assert!(!forever.timed_out(at(2999, 1, 1, 0, 0))); + } + + #[test] + fn satisfy_records_the_note_and_resolution_time() { + let mut w = wait( + WaitKind::ProcessExit { + handle: "pty-7".into(), + }, + None, + ); + let t = at(2026, 7, 14, 10, 0); + w.satisfy(Some("exit status 0".into()), t).unwrap(); + assert_eq!(w.state, WaitState::Satisfied); + assert!(!w.is_active()); + assert_eq!(w.note.as_deref(), Some("exit status 0")); + assert_eq!(w.resolved_at, Some(t)); + assert_eq!(w.updated_at, t); + } + + #[test] + fn time_out_labels_the_barrier() { + let mut w = wait( + WaitKind::JobCompletion { + job_id: "job-9".into(), + }, + Some(at(2026, 7, 14, 18, 0)), + ); + w.time_out(at(2026, 7, 14, 18, 0)).unwrap(); + assert_eq!(w.state, WaitState::TimedOut); + assert_eq!(w.note.as_deref(), Some("wait for job_completion timed out")); + assert_eq!(w.resolved_at, Some(at(2026, 7, 14, 18, 0))); + } + + #[test] + fn a_resolved_wait_rejects_a_second_resolution() { + let mut w = wait(WaitKind::HumanInput, None); + w.satisfy(None, at(2026, 7, 14, 10, 0)).unwrap(); + // A late barrier fire cannot rewrite the outcome. + assert!(w + .satisfy(Some("late".into()), at(2026, 7, 14, 10, 1)) + .is_err()); + assert!(w.time_out(at(2026, 7, 14, 10, 2)).is_err()); + assert_eq!(w.state, WaitState::Satisfied); + assert!(w.note.is_none()); + } + + #[test] + fn cancel_is_idempotent_and_only_fires_once() { + let mut w = wait(WaitKind::Event { key: "ci".into() }, None); + let t = at(2026, 7, 14, 10, 0); + assert!(w.cancel("goal paused", t)); + assert_eq!(w.state, WaitState::Cancelled); + assert_eq!(w.note.as_deref(), Some("goal paused")); + // Second cancel is a no-op and does not overwrite the reason. + assert!(!w.cancel("something else", at(2026, 7, 14, 10, 5))); + assert_eq!(w.note.as_deref(), Some("goal paused")); + } + + #[test] + fn cancel_cannot_override_a_real_resolution() { + let mut w = wait(WaitKind::HumanInput, None); + w.satisfy(None, at(2026, 7, 14, 10, 0)).unwrap(); + assert!(!w.cancel("too late", at(2026, 7, 14, 10, 1))); + assert_eq!(w.state, WaitState::Satisfied); + } + + #[test] + fn describe_names_the_barrier_and_timeout() { + let w = wait( + WaitKind::OutputPattern { + handle: "pty-3".into(), + pattern: "BUILD SUCCESSFUL".into(), + }, + Some(at(2026, 7, 14, 18, 0)), + ); + let d = w.describe(); + assert!(d.contains("pty-3"), "{d}"); + assert!(d.contains("BUILD SUCCESSFUL"), "{d}"); + assert!(d.contains("timeout 2026-07-14 18:00"), "{d}"); + + let human = wait(WaitKind::HumanInput, None); + assert_eq!(human.describe(), "waiting for a reply from you"); + } + + #[test] + fn kind_round_trips_through_json_with_a_stable_tag() { + let w = wait( + WaitKind::Until { + at: at(2026, 7, 14, 12, 0), + }, + None, + ); + let json = serde_json::to_string(&w).unwrap(); + assert!(json.contains("\"barrier\":\"until\""), "{json}"); + let back: Wait = serde_json::from_str(&json).unwrap(); + assert_eq!(back, w); + } + + // ---- WaitStore ---------------------------------------------------- + + fn store() -> (tempfile::TempDir, WaitStore) { + let dir = tempfile::tempdir().unwrap(); + let store = WaitStore::new(dir.path().join("waits.json")); + (dir, store) + } + + #[test] + fn a_missing_file_lists_and_arms_cleanly() { + let (_dir, store) = store(); + assert!(store.list().unwrap().is_empty()); + assert!(store.armed().unwrap().is_empty()); + let w = store + .arm( + "goal-1", + owner(), + WaitKind::HumanInput, + None, + at(2026, 7, 14, 9, 0), + ) + .unwrap(); + assert_eq!(w.state, WaitState::Armed); + assert_eq!(store.list().unwrap().len(), 1); + } + + #[test] + fn arm_assigns_unique_ids_across_goals() { + let (_dir, store) = store(); + let a = store + .arm( + "goal-1", + owner(), + WaitKind::HumanInput, + None, + at(2026, 7, 14, 9, 0), + ) + .unwrap(); + let b = store + .arm( + "goal-2", + owner(), + WaitKind::Event { key: "ci".into() }, + None, + at(2026, 7, 14, 9, 0), + ) + .unwrap(); + assert_ne!(a.id, b.id); + assert_eq!(store.armed().unwrap().len(), 2); + } + + #[test] + fn arm_supersedes_a_goals_earlier_barrier() { + let (_dir, store) = store(); + let first = store + .arm( + "goal-1", + owner(), + WaitKind::Until { + at: at(2026, 7, 14, 12, 0), + }, + None, + at(2026, 7, 14, 9, 0), + ) + .unwrap(); + let second = store + .arm( + "goal-1", + owner(), + WaitKind::JobCompletion { + job_id: "job-9".into(), + }, + None, + at(2026, 7, 14, 9, 30), + ) + .unwrap(); + let armed = store.armed_for_goal("goal-1").unwrap(); + assert_eq!(armed.len(), 1, "a goal parks on exactly one barrier"); + assert_eq!(armed[0].id, second.id); + assert!(store.get(&first.id).unwrap().is_none()); + } + + #[test] + fn update_is_revisioned_and_rejects_stale_snapshots() { + let (_dir, store) = store(); + let w = store + .arm( + "goal-1", + owner(), + WaitKind::Event { key: "ci".into() }, + None, + at(2026, 7, 14, 9, 0), + ) + .unwrap(); + // A first update from the loaded snapshot succeeds and bumps revision. + let mut edit = w.clone(); + edit.timeout = Some(at(2026, 7, 14, 18, 0)); + let persisted = store.update(&edit).unwrap(); + assert_eq!(persisted.revision, w.revision + 1); + // The now-stale original snapshot is rejected. + assert!(store.update(&w).is_err()); + // Updating an unknown id never silently creates. + let ghost = wait(WaitKind::HumanInput, None); + assert!(store.update(&ghost).is_err()); + } + + #[test] + fn remove_reports_whether_it_deleted() { + let (_dir, store) = store(); + let w = store + .arm( + "goal-1", + owner(), + WaitKind::HumanInput, + None, + at(2026, 7, 14, 9, 0), + ) + .unwrap(); + assert!(store.remove(&w.id).unwrap()); + assert!(!store.remove(&w.id).unwrap()); + assert!(store.armed().unwrap().is_empty()); + } + + #[test] + fn cancel_for_goal_removes_only_that_goals_waits() { + let (_dir, store) = store(); + store + .arm( + "goal-1", + owner(), + WaitKind::HumanInput, + None, + at(2026, 7, 14, 9, 0), + ) + .unwrap(); + let other = store + .arm( + "goal-2", + owner(), + WaitKind::Event { key: "ci".into() }, + None, + at(2026, 7, 14, 9, 0), + ) + .unwrap(); + let cancelled = store.cancel_for_goal("goal-1").unwrap(); + assert_eq!(cancelled.len(), 1); + let remaining = store.armed().unwrap(); + assert_eq!(remaining.len(), 1); + assert_eq!(remaining[0].id, other.id); + // Cancelling a goal with no waits is a clean empty result. + assert!(store.cancel_for_goal("goal-1").unwrap().is_empty()); + } + + #[test] + fn take_human_input_satisfies_only_human_waits_on_the_lane() { + let (_dir, store) = store(); + let human = store + .arm( + "goal-1", + owner(), + WaitKind::HumanInput, + None, + at(2026, 7, 14, 9, 0), + ) + .unwrap(); + // A non-human barrier on the same owner is untouched. + store + .arm( + "goal-2", + owner(), + WaitKind::Event { key: "ci".into() }, + None, + at(2026, 7, 14, 9, 0), + ) + .unwrap(); + // A human barrier on a different owner is untouched. + let other_owner = OwnerKey::from_parts(&["telegram", "private", "99"]); + store + .arm( + "goal-3", + other_owner, + WaitKind::HumanInput, + None, + at(2026, 7, 14, 9, 0), + ) + .unwrap(); + + let taken = store + .take_human_input_for_owner(&owner(), at(2026, 7, 14, 10, 0)) + .unwrap(); + assert_eq!(taken.len(), 1); + assert_eq!(taken[0].id, human.id); + assert_eq!(taken[0].state, WaitState::Satisfied); + assert_eq!(taken[0].goal_id, "goal-1"); + // Only the one human wait on the owner was consumed. + assert_eq!(store.armed().unwrap().len(), 2); + // A second call finds nothing left to take. + assert!(store + .take_human_input_for_owner(&owner(), at(2026, 7, 14, 10, 1)) + .unwrap() + .is_empty()); + } + + #[test] + fn arm_persists_across_store_instances() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("waits.json"); + let w = WaitStore::new(&path) + .arm( + "goal-1", + owner(), + WaitKind::ProcessExit { + handle: "pty-7".into(), + }, + Some(at(2026, 7, 14, 18, 0)), + at(2026, 7, 14, 9, 0), + ) + .unwrap(); + // A fresh instance over the same path sees the persisted wait. + let reloaded = WaitStore::new(&path).get(&w.id).unwrap().unwrap(); + assert_eq!(reloaded, w); + } +} From 938bed26561f507539cae4742b9fed3dcee048e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20A=C3=9Fmus?= Date: Wed, 15 Jul 2026 23:59:34 +0200 Subject: [PATCH 4/6] test(orchestration): pin the legacy lane-keyed JSON alias PAL's existing goals.json/waits.json key the owner as 'lane'; the serde alias keeps them readable after the OwnerKey generalization (the initial mechanical rename had rewritten the alias string itself). --- crates/agent_orchestration/src/goals.rs | 22 +++++++++++++++++++++- crates/agent_orchestration/src/waits.rs | 22 +++++++++++++++++++++- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/crates/agent_orchestration/src/goals.rs b/crates/agent_orchestration/src/goals.rs index af27b4b8..b96973ec 100644 --- a/crates/agent_orchestration/src/goals.rs +++ b/crates/agent_orchestration/src/goals.rs @@ -230,7 +230,7 @@ pub struct Goal { pub revision: u64, /// The owner whose incarnations pursue this goal (and whose channel hears /// about it). - #[serde(alias = "owner")] + #[serde(alias = "lane")] pub owner: OwnerKey, /// The user's objective, verbatim intent. pub objective: String, @@ -1136,6 +1136,26 @@ mod tests { .unwrap() } + /// PAL's existing goals.json predates the OwnerKey generalization and + /// keys the owner as "lane" — the serde alias must keep those files + /// readable. + #[test] + fn legacy_lane_keyed_json_still_deserializes() { + let goal = Goal::new( + "g-legacy", + owner(), + "objective", + CompletionContract::new("outcome", "verify", "stop"), + Budget::turns(3), + at(2026, 7, 15, 8, 0), + ); + let json = serde_json::to_string(&goal) + .unwrap() + .replace("\"owner\"", "\"lane\""); + let restored: Goal = serde_json::from_str(&json).unwrap(); + assert_eq!(restored.owner, goal.owner); + } + fn owner() -> OwnerKey { OwnerKey::from_parts(&["telegram", "private", "42"]) } diff --git a/crates/agent_orchestration/src/waits.rs b/crates/agent_orchestration/src/waits.rs index 5f439fb4..78685492 100644 --- a/crates/agent_orchestration/src/waits.rs +++ b/crates/agent_orchestration/src/waits.rs @@ -174,7 +174,7 @@ pub struct Wait { pub goal_id: String, /// The goal's owner, denormalised so the human-input path can find the wait /// by owner without loading the goal store. - #[serde(alias = "owner")] + #[serde(alias = "lane")] pub owner: OwnerKey, pub kind: WaitKind, pub state: WaitState, @@ -648,6 +648,26 @@ mod tests { .unwrap() } + /// PAL's existing waits.json predates the OwnerKey generalization and + /// keys the owner as "lane" — the serde alias must keep those files + /// readable. + #[test] + fn legacy_lane_keyed_json_still_deserializes() { + let wait = Wait::new( + "w-legacy", + "g1", + owner(), + WaitKind::HumanInput, + None, + at(2026, 7, 15, 8, 0), + ); + let json = serde_json::to_string(&wait) + .unwrap() + .replace("\"owner\"", "\"lane\""); + let restored: Wait = serde_json::from_str(&json).unwrap(); + assert_eq!(restored.owner, wait.owner); + } + fn owner() -> OwnerKey { OwnerKey::from_parts(&["telegram", "private", "42"]) } From 065153bfb4bb3f810841c28faa33457bd15e1d19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20A=C3=9Fmus?= Date: Thu, 16 Jul 2026 00:26:19 +0200 Subject: [PATCH 5/6] fix(goal_eval): disambiguate needs_input from waiting(human_input) The evaluator prompt listed 'a reply from the user must arrive' under the generic waiting verdict while also offering needs_input, without explaining the mechanically different consequences: waiting+human_input parks the goal Waiting and the user's NEXT message on the channel wakes it automatically; needs_input parks it Blocked until someone deliberately resumes it. The prompt now states the consequence on both bullets and gives the choice rule: human_input only for a just-asked concrete question whose next reply is expected to be the answer; needs_input for decisions that must not auto-continue on an arbitrary message. --- crates/agent_orchestration/src/goal_eval.rs | 23 ++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/crates/agent_orchestration/src/goal_eval.rs b/crates/agent_orchestration/src/goal_eval.rs index 74441328..def4d2de 100644 --- a/crates/agent_orchestration/src/goal_eval.rs +++ b/crates/agent_orchestration/src/goal_eval.rs @@ -45,17 +45,23 @@ yet fully met. assistant cannot clear by itself (a missing document, a failing external \ service, a required credential). Not for ordinary difficulty. - \"needs_input\": the work cannot continue without a decision or answer \ -from the user. +from the user, and the goal must stay stopped until it is deliberately \ +resumed. It parks with the open question as its status; an arbitrary next \ +message on the channel does NOT resume it. (If the assistant just asked the \ +user a concrete question and the next reply is expected to be the answer, \ +use \"waiting\" with the \"human_input\" barrier instead — that resumes \ +automatically on the reply.) - \"stopped\": the completion contract's explicit Stop-if condition has been \ met. Continuing would violate the user's envelope; cite the observed fact in \ the summary. - \"waiting\": the turn set up a durable dependency and there is genuinely \ nothing more to do until it resolves — a background build or process must \ -finish, a scheduled job or child agent must complete, an external event or a \ -reply from the user must arrive, or work should simply resume at a later time. \ -Prefer this over \"progressed\" when the next step is only to wait: it parks \ -the goal so it burns no turns polling. Do NOT use it to avoid hard work. When \ -you use it, include a \"wait\" object naming the barrier. +finish, a scheduled job or child agent must complete, an external event must \ +arrive, the user was just asked a question whose next reply will answer it \ +(\"human_input\"), or work should simply resume at a later time. Prefer this \ +over \"progressed\" when the next step is only to wait: it parks the goal so \ +it burns no turns polling. Do NOT use it to avoid hard work. When you use \ +it, include a \"wait\" object naming the barrier. - wait: only with the \"waiting\" verdict. A JSON object naming exactly one \ barrier (add an optional ISO-8601 \"timeout\" after which the goal should wake \ even if the barrier has not fired): @@ -69,7 +75,10 @@ finishes. - {\"barrier\": \"sub_agent_completion\", \"agent_id\": \"\"} — a child \ agent finishes. - {\"barrier\": \"event\", \"key\": \"\"} — an external event arrives. - - {\"barrier\": \"human_input\"} — the user replies on this channel. + - {\"barrier\": \"human_input\"} — the user's next reply on this channel \ +resumes the goal automatically. Only for a just-asked, concrete question; \ +for a decision the user must make in their own time, use \"needs_input\", \ +which stays stopped until deliberately resumed. - summary: a short, factual account of what the turn did (one or two \ sentences). No speculation, no chain-of-thought. - artifacts: file paths or references the turn produced or changed, as stated \ From 87a83c24e0e703a8bdcf7a9feb6e3e388a476042 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20A=C3=9Fmus?= Date: Thu, 16 Jul 2026 05:06:27 +0200 Subject: [PATCH 6/6] style: collapse the resource-record bound into the match guard (clippy) --- crates/code_assistant_core/src/session/turn.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/code_assistant_core/src/session/turn.rs b/crates/code_assistant_core/src/session/turn.rs index fc671873..7dc029b6 100644 --- a/crates/code_assistant_core/src/session/turn.rs +++ b/crates/code_assistant_core/src/session/turn.rs @@ -314,13 +314,13 @@ impl TurnRecorder { } } } - UiEvent::ResourceWritten { project, path } => { - if inner.resources.len() < MAX_RESOURCE_RECORDS { - inner.resources.push(ResourceRef { - project: project.clone(), - path: path.clone(), - }); - } + UiEvent::ResourceWritten { project, path } + if inner.resources.len() < MAX_RESOURCE_RECORDS => + { + inner.resources.push(ResourceRef { + project: project.clone(), + path: path.clone(), + }); } _ => {} }