Skip to main content

everruns_core/
session_task.rs

1// Session tasks — unified registry of background work owned by a session.
2//
3// See specs/session-tasks.md. A task is any asynchronous work a session owns
4// (subagent, external A2A agent, background tool, monitor). The registry owns
5// the record, lifecycle invariants, and task.* events; capabilities plug in
6// `TaskExecutor`s (control plane) and report through `TaskSink` (report plane).
7//
8// Decision: lifecycle invariants live in `apply_task_update` so every backend
9// (PostgreSQL, in-memory, gRPC) applies identical semantics.
10// Decision: kind is a free-form string for extensibility — no enum.
11// Decision: cancellation is cooperative — `request_cancel` records intent via
12// `cancel_requested_at`; executors wind down and report the terminal state.
13
14use async_trait::async_trait;
15use chrono::{DateTime, Utc};
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18use std::sync::Arc;
19
20use crate::error::Result;
21use crate::typed_id::SessionId;
22
23#[cfg(feature = "openapi")]
24use utoipa::ToSchema;
25
26/// Progress shape shared with background tool execution.
27pub type TaskProgress = crate::background::BackgroundProgress;
28
29/// Well-known task kinds. Kind stays a free-form string; these constants
30/// cover the built-in executors.
31pub const TASK_KIND_SUBAGENT: &str = "subagent";
32/// Cross-agent handoff to a different configured Agent in the same harness.
33/// Distinct from `subagent` so `list_tasks(kind="subagent")` returns only
34/// same-agent subagents and not handoffs (they share the spawn shape but are a
35/// different target). Matches the historical `session_resources.kind`.
36pub const TASK_KIND_AGENT_HANDOFF: &str = "agent_handoff";
37pub const TASK_KIND_EXTERNAL_AGENT: &str = "external_agent";
38pub const TASK_KIND_BACKGROUND_TOOL: &str = "background_tool";
39/// Long-lived monitor task linked to a session schedule. Stays `running`
40/// until the linked schedule is exhausted (one-shot) or `cancel_task` is called.
41pub const TASK_KIND_MONITOR: &str = "monitor";
42
43/// Generate a new task ID (`task_` prefix per specs/id-schema.md).
44pub fn generate_task_id() -> String {
45    format!("task_{}", uuid::Uuid::now_v7().simple())
46}
47
48/// Generate a new task message ID.
49pub fn generate_task_message_id() -> String {
50    format!("tmsg_{}", uuid::Uuid::now_v7().simple())
51}
52
53/// Lifecycle state of a session task.
54///
55/// Three classes: active (`queued`, `running`), interrupted (`awaiting_input`,
56/// resumable), terminal (`succeeded`, `failed`, `canceled`). Timeout and
57/// rejection are `error.kind` values on `failed`, not states.
58#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
59#[cfg_attr(feature = "openapi", derive(ToSchema))]
60#[serde(rename_all = "snake_case")]
61pub enum SessionTaskState {
62    Queued,
63    Running,
64    AwaitingInput,
65    Succeeded,
66    Failed,
67    Canceled,
68}
69
70impl SessionTaskState {
71    pub fn is_terminal(&self) -> bool {
72        matches!(self, Self::Succeeded | Self::Failed | Self::Canceled)
73    }
74
75    /// Strict parser for caller-supplied state strings (API filters, tool
76    /// arguments). Unlike `From<&str>` — which exists for trusted,
77    /// CHECK-constrained storage values and defaults to `Queued` — this
78    /// returns None for unknown input so callers can reject it.
79    pub fn parse(s: &str) -> Option<Self> {
80        match s {
81            "queued" => Some(Self::Queued),
82            "running" => Some(Self::Running),
83            "awaiting_input" => Some(Self::AwaitingInput),
84            "succeeded" => Some(Self::Succeeded),
85            "failed" => Some(Self::Failed),
86            "canceled" => Some(Self::Canceled),
87            _ => None,
88        }
89    }
90}
91
92impl std::fmt::Display for SessionTaskState {
93    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        let s = match self {
95            Self::Queued => "queued",
96            Self::Running => "running",
97            Self::AwaitingInput => "awaiting_input",
98            Self::Succeeded => "succeeded",
99            Self::Failed => "failed",
100            Self::Canceled => "canceled",
101        };
102        write!(f, "{s}")
103    }
104}
105
106impl From<&str> for SessionTaskState {
107    fn from(s: &str) -> Self {
108        match s {
109            "running" => Self::Running,
110            "awaiting_input" => Self::AwaitingInput,
111            "succeeded" => Self::Succeeded,
112            "failed" => Self::Failed,
113            "canceled" => Self::Canceled,
114            _ => Self::Queued,
115        }
116    }
117}
118
119/// When outbound task activity wakes the owning session's agent.
120#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
121#[cfg_attr(feature = "openapi", derive(ToSchema))]
122#[serde(rename_all = "snake_case")]
123pub enum TaskWakePolicy {
124    /// Never wake; the agent polls via `get_task`/`list_tasks`.
125    #[default]
126    Silent,
127    /// Wake on transition to a terminal state.
128    OnTerminal,
129    /// Wake on any outbound message or input request, and on terminal states.
130    OnActivity,
131}
132
133/// Structured ask posted by a task that needs input to continue.
134#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
135#[cfg_attr(feature = "openapi", derive(ToSchema))]
136pub struct TaskInputRequest {
137    /// Stable ID referenced by the answering message's `in_reply_to`.
138    pub id: String,
139    /// Human/agent-readable prompt.
140    pub prompt: String,
141    /// Optional machine-readable description of the expected answer.
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    #[cfg_attr(feature = "openapi", schema(value_type = Object))]
144    pub expected: Option<Value>,
145}
146
147/// Terminal error detail. Timeout/rejection/orphaned are kinds, not states.
148#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
149#[cfg_attr(feature = "openapi", derive(ToSchema))]
150pub struct TaskError {
151    pub kind: String,
152    pub message: String,
153}
154
155/// Typed link to something the task produced.
156#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
157#[cfg_attr(feature = "openapi", derive(ToSchema))]
158pub struct TaskArtifact {
159    pub name: String,
160    /// Artifact type: "file", "url", "session", "pr", etc.
161    #[serde(rename = "type")]
162    pub artifact_type: String,
163    /// Session VFS path, when the artifact lives in the session filesystem.
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub path: Option<String>,
166    /// External URL, when the artifact lives elsewhere.
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub url: Option<String>,
169}
170
171/// Cross-references owned by a task.
172#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
173#[cfg_attr(feature = "openapi", derive(ToSchema))]
174pub struct TaskLinks {
175    /// Child session, for subagent-shaped tasks. Full transcript lives there.
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    #[cfg_attr(feature = "openapi", schema(value_type = Option<String>))]
178    pub child_session_id: Option<SessionId>,
179    /// Remote task ID, for tasks wrapping an external protocol task (A2A).
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub remote_task_id: Option<String>,
182    /// Session resources (sandboxes, browser sessions) this task holds.
183    #[serde(default, skip_serializing_if = "Vec::is_empty")]
184    pub resource_ids: Vec<String>,
185}
186
187impl TaskLinks {
188    pub fn is_empty(&self) -> bool {
189        self.child_session_id.is_none()
190            && self.remote_task_id.is_none()
191            && self.resource_ids.is_empty()
192    }
193}
194
195/// A unit of background work owned by a session.
196#[derive(Debug, Clone, Serialize, Deserialize)]
197#[cfg_attr(feature = "openapi", derive(ToSchema))]
198pub struct SessionTask {
199    /// `task_*` public ID.
200    pub id: String,
201    /// Owning session.
202    #[cfg_attr(feature = "openapi", schema(value_type = String))]
203    pub session_id: SessionId,
204    /// Task kind: "subagent", "external_agent", "background_tool", "monitor", …
205    pub kind: String,
206    /// Human-readable label.
207    pub display_name: String,
208    /// Kind-specific input (instructions, tool args, external agent id).
209    #[serde(default)]
210    #[cfg_attr(feature = "openapi", schema(value_type = Object))]
211    pub spec: Value,
212    pub state: SessionTaskState,
213    /// Short live status line ("polling remote task", "iteration 4/10").
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub state_detail: Option<String>,
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    pub progress: Option<TaskProgress>,
218    /// Pending ask while `awaiting_input`; cleared when answered.
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub input_request: Option<TaskInputRequest>,
221    /// Cooperative cancel intent. A flag, not a state.
222    #[serde(default, skip_serializing_if = "Option::is_none")]
223    pub cancel_requested_at: Option<DateTime<Utc>>,
224    /// Human-readable outcome.
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub summary: Option<String>,
227    /// Machine result in the session VFS: `/.tasks/{task_id}/result.json`.
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub result_path: Option<String>,
230    #[serde(default, skip_serializing_if = "Vec::is_empty")]
231    pub artifacts: Vec<TaskArtifact>,
232    #[serde(default, skip_serializing_if = "Option::is_none")]
233    pub error: Option<TaskError>,
234    /// Execution attempt, starting at 1. Incremented on re-attach.
235    #[serde(default = "default_attempt")]
236    pub attempt: i32,
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub worker_id: Option<String>,
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub heartbeat_at: Option<DateTime<Utc>>,
241    #[serde(default, skip_serializing_if = "TaskLinks::is_empty")]
242    pub links: TaskLinks,
243    #[serde(default)]
244    pub wake_policy: TaskWakePolicy,
245    pub created_at: DateTime<Utc>,
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub started_at: Option<DateTime<Utc>>,
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub finished_at: Option<DateTime<Utc>>,
250    pub updated_at: DateTime<Utc>,
251}
252
253fn default_attempt() -> i32 {
254    1
255}
256
257/// Input for creating a task.
258#[derive(Debug, Clone, Serialize, Deserialize)]
259pub struct CreateSessionTask {
260    pub session_id: SessionId,
261    /// Caller-supplied ID for idempotent creation; generated when None.
262    #[serde(default)]
263    pub id: Option<String>,
264    pub kind: String,
265    pub display_name: String,
266    #[serde(default)]
267    pub spec: Value,
268    /// Initial state; defaults to Queued.
269    #[serde(default = "default_queued")]
270    pub state: SessionTaskState,
271    #[serde(default)]
272    pub links: TaskLinks,
273    #[serde(default)]
274    pub wake_policy: TaskWakePolicy,
275}
276
277fn default_queued() -> SessionTaskState {
278    SessionTaskState::Queued
279}
280
281/// Partial update applied through `apply_task_update`. None = unchanged.
282#[derive(Debug, Clone, Default, Serialize, Deserialize)]
283pub struct SessionTaskUpdate {
284    pub state: Option<SessionTaskState>,
285    pub state_detail: Option<String>,
286    pub progress: Option<TaskProgress>,
287    /// Setting an input request implies `awaiting_input`.
288    pub input_request: Option<TaskInputRequest>,
289    pub summary: Option<String>,
290    pub result_path: Option<String>,
291    /// Replaces the artifact list when set.
292    pub artifacts: Option<Vec<TaskArtifact>>,
293    pub error: Option<TaskError>,
294    /// Merged field-by-field into existing links.
295    pub links: Option<TaskLinks>,
296    pub worker_id: Option<String>,
297    /// Liveness heartbeat timestamp.
298    pub heartbeat_at: Option<DateTime<Utc>>,
299    /// Stale-attempt fence: when set, the update is silently ignored if
300    /// `task.attempt != expected_attempt`. Executors and sinks set this to
301    /// the attempt they captured at start; the reaper bumps `attempt` (via
302    /// `increment_attempt`) when it fails an orphan, so a zombie executor's
303    /// later writes are rejected. Writers that do not track attempts
304    /// (e.g. `cancel_task` from the API) leave this None.
305    #[serde(default, skip_serializing_if = "Option::is_none")]
306    pub expected_attempt: Option<i32>,
307    /// Supersede the current attempt: bumps `task.attempt` so writes fenced
308    /// on the previous attempt are rejected from now on. Set by the reaper
309    /// when it fails an orphaned task. Ignored if the update itself is
310    /// dropped by the fence or the terminal-state invariant.
311    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
312    pub increment_attempt: bool,
313}
314
315/// Optional filter for listing tasks.
316#[derive(Debug, Clone, Default)]
317pub struct SessionTaskFilter {
318    pub kind: Option<String>,
319    pub state: Option<SessionTaskState>,
320}
321
322/// Apply a partial update to a task, enforcing lifecycle invariants.
323///
324/// All registry backends route updates through this function so semantics
325/// stay identical across PostgreSQL, in-memory, and gRPC modes:
326/// - terminal states are final: state changes on a terminal task are ignored
327///   (content fields like summary/result still apply);
328/// - first transition out of `queued` stamps `started_at`;
329/// - transition into a terminal state stamps `finished_at`;
330/// - setting `input_request` forces `awaiting_input`; leaving
331///   `awaiting_input` clears it.
332pub fn apply_task_update(task: &mut SessionTask, update: SessionTaskUpdate, now: DateTime<Utc>) {
333    // Stale-attempt fence: if the update carries an attempt expectation and it
334    // does not match the current attempt, this write came from a superseded
335    // executor — ignore it entirely (heartbeats, state changes, everything).
336    if let Some(expected) = update.expected_attempt
337        && expected != task.attempt
338    {
339        return;
340    }
341
342    let was_terminal = task.state.is_terminal();
343
344    // Terminal states are final. An update that asks for a *different* state
345    // on an already-terminal task lost a race (e.g. the reaper marking a task
346    // orphaned after it succeeded) — ignore it entirely so its content fields
347    // (error, summary) cannot corrupt the terminal record. Updates that carry
348    // the same terminal state (idempotent re-mirrors) or no state at all
349    // (content enrichment) still apply below.
350    if was_terminal
351        && let Some(state) = update.state
352        && state != task.state
353    {
354        return;
355    }
356
357    // Supersede the current attempt (reaper failing an orphan): writes fenced
358    // on the old attempt are rejected from here on.
359    if update.increment_attempt {
360        task.attempt += 1;
361    }
362
363    let mut next_state = update.state;
364    if update.input_request.is_some() && !was_terminal {
365        next_state = Some(SessionTaskState::AwaitingInput);
366    }
367
368    if let Some(input_request) = update.input_request
369        && !was_terminal
370    {
371        task.input_request = Some(input_request);
372    }
373
374    if let Some(state) = next_state
375        && !was_terminal
376        && task.state != state
377    {
378        if task.state == SessionTaskState::Queued && state != SessionTaskState::Queued {
379            task.started_at.get_or_insert(now);
380        }
381        if state.is_terminal() {
382            task.finished_at.get_or_insert(now);
383        }
384        if state != SessionTaskState::AwaitingInput {
385            task.input_request = None;
386        }
387        task.state = state;
388    }
389
390    if let Some(detail) = update.state_detail {
391        task.state_detail = Some(detail);
392    }
393    if let Some(progress) = update.progress {
394        task.progress = Some(progress);
395    }
396    if let Some(summary) = update.summary {
397        task.summary = Some(summary);
398    }
399    if let Some(result_path) = update.result_path {
400        task.result_path = Some(result_path);
401    }
402    if let Some(artifacts) = update.artifacts {
403        task.artifacts = artifacts;
404    }
405    if let Some(error) = update.error {
406        task.error = Some(error);
407    }
408    if let Some(links) = update.links {
409        if links.child_session_id.is_some() {
410            task.links.child_session_id = links.child_session_id;
411        }
412        if links.remote_task_id.is_some() {
413            task.links.remote_task_id = links.remote_task_id;
414        }
415        for id in links.resource_ids {
416            if !task.links.resource_ids.contains(&id) {
417                task.links.resource_ids.push(id);
418            }
419        }
420    }
421    if let Some(worker_id) = update.worker_id {
422        task.worker_id = Some(worker_id);
423    }
424    if let Some(heartbeat_at) = update.heartbeat_at {
425        task.heartbeat_at = Some(heartbeat_at);
426    }
427
428    task.updated_at = now;
429}
430
431/// Build a new task from creation input.
432pub fn new_session_task(input: CreateSessionTask, now: DateTime<Utc>) -> SessionTask {
433    let state = input.state;
434    SessionTask {
435        id: input.id.unwrap_or_else(generate_task_id),
436        session_id: input.session_id,
437        kind: input.kind,
438        display_name: input.display_name,
439        spec: input.spec,
440        state,
441        state_detail: None,
442        progress: None,
443        input_request: None,
444        cancel_requested_at: None,
445        summary: None,
446        result_path: None,
447        artifacts: Vec::new(),
448        error: None,
449        attempt: 1,
450        worker_id: None,
451        heartbeat_at: None,
452        links: input.links,
453        wake_policy: input.wake_policy,
454        created_at: now,
455        started_at: if state == SessionTaskState::Queued {
456            None
457        } else {
458            Some(now)
459        },
460        finished_at: if state.is_terminal() { Some(now) } else { None },
461        updated_at: now,
462    }
463}
464
465// ============================================================================
466// Messages — bidirectional, persisted channel between session and task
467// ============================================================================
468
469/// Direction of a task message. Inbound = session → task.
470#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
471#[cfg_attr(feature = "openapi", derive(ToSchema))]
472#[serde(rename_all = "snake_case")]
473pub enum TaskMessageDirection {
474    Inbound,
475    Outbound,
476}
477
478impl std::fmt::Display for TaskMessageDirection {
479    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
480        match self {
481            Self::Inbound => write!(f, "inbound"),
482            Self::Outbound => write!(f, "outbound"),
483        }
484    }
485}
486
487impl From<&str> for TaskMessageDirection {
488    fn from(s: &str) -> Self {
489        match s {
490            "outbound" => Self::Outbound,
491            _ => Self::Inbound,
492        }
493    }
494}
495
496/// One content part of a task message.
497#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
498#[cfg_attr(feature = "openapi", derive(ToSchema))]
499#[serde(tag = "type", rename_all = "snake_case")]
500pub enum TaskMessagePart {
501    Text {
502        text: String,
503    },
504    Data {
505        #[cfg_attr(feature = "openapi", schema(value_type = Object))]
506        data: Value,
507    },
508}
509
510impl TaskMessagePart {
511    pub fn text(text: impl Into<String>) -> Self {
512        Self::Text { text: text.into() }
513    }
514}
515
516/// A message exchanged between a session and one of its tasks.
517#[derive(Debug, Clone, Serialize, Deserialize)]
518#[cfg_attr(feature = "openapi", derive(ToSchema))]
519pub struct TaskMessage {
520    /// `tmsg_*` public ID.
521    pub id: String,
522    pub task_id: String,
523    pub direction: TaskMessageDirection,
524    pub content: Vec<TaskMessagePart>,
525    /// Set when this message answers a `TaskInputRequest`.
526    #[serde(default, skip_serializing_if = "Option::is_none")]
527    pub in_reply_to: Option<String>,
528    pub created_at: DateTime<Utc>,
529}
530
531/// Input for recording a task message.
532#[derive(Debug, Clone, Serialize, Deserialize)]
533pub struct NewTaskMessage {
534    pub direction: TaskMessageDirection,
535    pub content: Vec<TaskMessagePart>,
536    #[serde(default)]
537    pub in_reply_to: Option<String>,
538    /// Stale-attempt fence for message writes: when set, registries reject
539    /// the message if `task.attempt` no longer matches, so a superseded
540    /// executor cannot append to the thread or trigger wake-ups. Not stored
541    /// with the message.
542    #[serde(default, skip_serializing_if = "Option::is_none")]
543    pub expected_attempt: Option<i32>,
544}
545
546impl NewTaskMessage {
547    pub fn inbound_text(text: impl Into<String>) -> Self {
548        Self {
549            direction: TaskMessageDirection::Inbound,
550            content: vec![TaskMessagePart::text(text)],
551            in_reply_to: None,
552            expected_attempt: None,
553        }
554    }
555
556    pub fn outbound_text(text: impl Into<String>) -> Self {
557        Self {
558            direction: TaskMessageDirection::Outbound,
559            content: vec![TaskMessagePart::text(text)],
560            in_reply_to: None,
561            expected_attempt: None,
562        }
563    }
564
565    /// Fence this message write on the given attempt (see `expected_attempt`).
566    pub fn with_expected_attempt(mut self, attempt: i32) -> Self {
567        self.expected_attempt = Some(attempt);
568        self
569    }
570}
571
572/// Plain-text rendering of message content (for steering/wake messages).
573pub fn task_message_text(content: &[TaskMessagePart]) -> String {
574    content
575        .iter()
576        .filter_map(|part| match part {
577            TaskMessagePart::Text { text } => Some(text.as_str()),
578            TaskMessagePart::Data { .. } => None,
579        })
580        .collect::<Vec<_>>()
581        .join("\n")
582}
583
584// ============================================================================
585// Registry — owns the record, invariants, events, and durability
586// ============================================================================
587
588/// Session task registry. Implementations emit `task.created` /
589/// `task.updated` (full snapshots) and `task.message.*` events on the owning
590/// session's event stream.
591#[async_trait]
592pub trait SessionTaskRegistry: Send + Sync {
593    /// Create a task (idempotent on caller-supplied ID: re-creating an
594    /// existing ID returns the stored task unchanged).
595    async fn create(&self, input: CreateSessionTask) -> Result<SessionTask>;
596
597    /// Apply a partial update through `apply_task_update` invariants.
598    async fn update(
599        &self,
600        session_id: SessionId,
601        task_id: &str,
602        update: SessionTaskUpdate,
603    ) -> Result<Option<SessionTask>>;
604
605    async fn get(&self, session_id: SessionId, task_id: &str) -> Result<Option<SessionTask>>;
606
607    async fn list(
608        &self,
609        session_id: SessionId,
610        filter: Option<&SessionTaskFilter>,
611    ) -> Result<Vec<SessionTask>>;
612
613    /// Record cooperative cancel intent (idempotent). Does not change state;
614    /// the executor winds down and reports the terminal state.
615    async fn request_cancel(
616        &self,
617        session_id: SessionId,
618        task_id: &str,
619    ) -> Result<Option<SessionTask>>;
620
621    /// Persist a message on the task's channel. Answering messages
622    /// (`in_reply_to` set) clear a matching pending input request and return
623    /// the task to `running`.
624    async fn record_message(
625        &self,
626        session_id: SessionId,
627        task_id: &str,
628        message: NewTaskMessage,
629    ) -> Result<TaskMessage>;
630
631    /// List messages on the task's channel, oldest first.
632    ///
633    /// When `after_id` is `Some`, only messages newer than that message ID are
634    /// returned (exclusive cursor, since_id-style). Both postgres and in-memory
635    /// backends implement the cursor; other backends ignore it and return all
636    /// messages up to `limit`.
637    async fn list_messages(
638        &self,
639        session_id: SessionId,
640        task_id: &str,
641        limit: Option<u32>,
642        after_id: Option<&str>,
643    ) -> Result<Vec<TaskMessage>>;
644}
645
646// ============================================================================
647// Executor — control plane, implemented per kind by capabilities
648// ============================================================================
649
650/// Control plane for a task kind. The registry/tools call into the executor;
651/// the running work pushes into a `TaskSink`.
652///
653/// Default method bodies return `unsupported` so kinds implement only what
654/// applies (e.g. a background tool rarely accepts inbound messages).
655#[async_trait]
656pub trait TaskExecutor: Send + Sync {
657    fn kind(&self) -> &str;
658
659    /// Whether this executor can re-attach to a running task after worker loss.
660    ///
661    /// Kinds returning `true` must implement `start` such that calling it with
662    /// a re-attached task snapshot (attempt already bumped by the reaper)
663    /// resumes the work idempotently and heartbeats with the new attempt.
664    /// Kinds returning `false` (the default) are failed as orphaned immediately
665    /// by the reaper.
666    fn can_reattach(&self) -> bool {
667        false
668    }
669
670    /// Whether this executor can re-attach to a *specific* task instance.
671    ///
672    /// Defaults to `self.can_reattach()`. Override to inspect per-task spec
673    /// fields (e.g. whether the spawned tool declared itself idempotent).
674    /// The reaper calls this instead of `can_reattach()` when a task snapshot
675    /// is available.
676    fn can_reattach_task(&self, task: &SessionTask) -> bool {
677        let _ = task;
678        self.can_reattach()
679    }
680
681    /// Begin execution, or re-attach after worker loss.
682    ///
683    /// Called by the reaper when re-attaching a task (attempt already bumped).
684    /// Implementations must heartbeat using `task.attempt` so stale writes from
685    /// the previous executor are rejected by the fence.
686    async fn start(&self, task: &SessionTask, context: &crate::traits::ToolContext) -> Result<()> {
687        let _ = (task, context);
688        Err(crate::error::AgentLoopError::tool(format!(
689            "task kind '{}' does not support start via the registry",
690            self.kind()
691        )))
692    }
693
694    /// Deliver an inbound message (steering or input answer) to the work.
695    async fn deliver(
696        &self,
697        task: &SessionTask,
698        message: &TaskMessage,
699        context: &crate::traits::ToolContext,
700    ) -> Result<()> {
701        let _ = (task, message, context);
702        Err(crate::error::AgentLoopError::tool(format!(
703            "task kind '{}' does not accept inbound messages",
704            self.kind()
705        )))
706    }
707
708    /// Cooperatively wind down. The task may still end succeeded or failed.
709    async fn cancel(&self, task: &SessionTask, context: &crate::traits::ToolContext) -> Result<()>;
710
711    /// Refresh state for polled kinds (e.g. A2A remote tasks). Reports via
712    /// the registry; no-op by default.
713    async fn reconcile(
714        &self,
715        task: &SessionTask,
716        context: &crate::traits::ToolContext,
717    ) -> Result<()> {
718        let _ = (task, context);
719        Ok(())
720    }
721}
722
723/// Inventory plugin so capabilities register executors without core knowing
724/// about them (same pattern as `SessionSandboxProviderPlugin`).
725pub struct TaskExecutorPlugin {
726    pub executor: fn() -> Arc<dyn TaskExecutor>,
727}
728
729inventory::collect!(TaskExecutorPlugin);
730
731/// Find the registered executor for a task kind.
732pub fn find_task_executor(kind: &str) -> Option<Arc<dyn TaskExecutor>> {
733    inventory::iter::<TaskExecutorPlugin>
734        .into_iter()
735        .map(|plugin| (plugin.executor)())
736        .find(|executor| executor.kind() == kind)
737}
738
739// ============================================================================
740// Sink — report plane for running work
741// ============================================================================
742
743/// Report plane handed to running work. `state`/`progress`/`request_input`
744/// mutate the task record (snapshot events fire); `post` appends to the
745/// message channel; `output` is high-frequency and ephemeral.
746#[async_trait]
747pub trait TaskSink: Send + Sync {
748    async fn state(&self, state: SessionTaskState, detail: Option<String>) -> Result<()>;
749
750    async fn progress(&self, progress: TaskProgress) -> Result<()>;
751
752    /// High-frequency output delta. Not persisted on the task record.
753    async fn output(&self, stream: &str, delta: &str) -> Result<()>;
754
755    /// Outbound message to the session; may wake the parent per wake policy.
756    async fn post(&self, message: NewTaskMessage) -> Result<()>;
757
758    /// Ask the session for input; transitions the task to `awaiting_input`.
759    async fn request_input(&self, request: TaskInputRequest) -> Result<()>;
760
761    async fn artifact(&self, artifact: TaskArtifact) -> Result<()>;
762}
763
764/// `TaskSink` backed by a `SessionTaskRegistry`. Output deltas are dropped
765/// here; kinds with live output keep their existing streaming path.
766///
767/// Carries `attempt` for stale-attempt fencing: every update includes
768/// `expected_attempt` so writes from a superseded executor are rejected once
769/// the reaper increments the attempt counter on the task record.
770pub struct RegistryTaskSink {
771    registry: Arc<dyn SessionTaskRegistry>,
772    session_id: SessionId,
773    task_id: String,
774    /// The attempt number this sink was created for (captured at task start).
775    attempt: i32,
776}
777
778impl RegistryTaskSink {
779    pub fn new(
780        registry: Arc<dyn SessionTaskRegistry>,
781        session_id: SessionId,
782        task_id: String,
783    ) -> Self {
784        Self {
785            registry,
786            session_id,
787            task_id,
788            attempt: 1,
789        }
790    }
791
792    /// Set the attempt number for fencing. Call this after reading the task
793    /// record at start so the sink rejects writes once the attempt is bumped.
794    pub fn with_attempt(mut self, attempt: i32) -> Self {
795        self.attempt = attempt;
796        self
797    }
798}
799
800#[async_trait]
801impl TaskSink for RegistryTaskSink {
802    async fn state(&self, state: SessionTaskState, detail: Option<String>) -> Result<()> {
803        self.registry
804            .update(
805                self.session_id,
806                &self.task_id,
807                SessionTaskUpdate {
808                    state: Some(state),
809                    state_detail: detail,
810                    expected_attempt: Some(self.attempt),
811                    ..Default::default()
812                },
813            )
814            .await?;
815        Ok(())
816    }
817
818    async fn progress(&self, progress: TaskProgress) -> Result<()> {
819        self.registry
820            .update(
821                self.session_id,
822                &self.task_id,
823                SessionTaskUpdate {
824                    progress: Some(progress),
825                    expected_attempt: Some(self.attempt),
826                    ..Default::default()
827                },
828            )
829            .await?;
830        Ok(())
831    }
832
833    async fn output(&self, _stream: &str, _delta: &str) -> Result<()> {
834        Ok(())
835    }
836
837    async fn post(&self, message: NewTaskMessage) -> Result<()> {
838        // Fence message writes too: record_message emits events and can wake
839        // the parent session, so a superseded executor must not post.
840        self.registry
841            .record_message(
842                self.session_id,
843                &self.task_id,
844                message.with_expected_attempt(self.attempt),
845            )
846            .await?;
847        Ok(())
848    }
849
850    async fn request_input(&self, request: TaskInputRequest) -> Result<()> {
851        self.registry
852            .update(
853                self.session_id,
854                &self.task_id,
855                SessionTaskUpdate {
856                    input_request: Some(request),
857                    expected_attempt: Some(self.attempt),
858                    ..Default::default()
859                },
860            )
861            .await?;
862        Ok(())
863    }
864
865    async fn artifact(&self, artifact: TaskArtifact) -> Result<()> {
866        let Some(task) = self.registry.get(self.session_id, &self.task_id).await? else {
867            return Ok(());
868        };
869        // Check attempt before fetching artifacts to avoid a stale write.
870        if task.attempt != self.attempt {
871            return Ok(());
872        }
873        let mut artifacts = task.artifacts;
874        artifacts.push(artifact);
875        self.registry
876            .update(
877                self.session_id,
878                &self.task_id,
879                SessionTaskUpdate {
880                    artifacts: Some(artifacts),
881                    expected_attempt: Some(self.attempt),
882                    ..Default::default()
883                },
884            )
885            .await?;
886        Ok(())
887    }
888}
889
890/// VFS directory for a task's result and logs.
891pub fn task_vfs_dir(task_id: &str) -> String {
892    format!("/.tasks/{task_id}")
893}
894
895/// VFS path for a task's machine result.
896pub fn task_result_path(task_id: &str) -> String {
897    format!("/.tasks/{task_id}/result.json")
898}
899
900#[cfg(test)]
901mod tests {
902    use super::*;
903
904    fn task() -> SessionTask {
905        new_session_task(
906            CreateSessionTask {
907                session_id: SessionId::new(),
908                id: None,
909                kind: TASK_KIND_BACKGROUND_TOOL.to_string(),
910                display_name: "Test".to_string(),
911                spec: serde_json::json!({}),
912                state: SessionTaskState::Queued,
913                links: TaskLinks::default(),
914                wake_policy: TaskWakePolicy::Silent,
915            },
916            Utc::now(),
917        )
918    }
919
920    #[test]
921    fn create_generates_prefixed_id() {
922        let t = task();
923        assert!(t.id.starts_with("task_"));
924        assert_eq!(t.state, SessionTaskState::Queued);
925        assert!(t.started_at.is_none());
926    }
927
928    #[test]
929    fn first_transition_out_of_queued_stamps_started_at() {
930        let mut t = task();
931        let now = Utc::now();
932        apply_task_update(
933            &mut t,
934            SessionTaskUpdate {
935                state: Some(SessionTaskState::Running),
936                ..Default::default()
937            },
938            now,
939        );
940        assert_eq!(t.state, SessionTaskState::Running);
941        assert_eq!(t.started_at, Some(now));
942        assert!(t.finished_at.is_none());
943    }
944
945    #[test]
946    fn terminal_transition_stamps_finished_at_and_is_final() {
947        let mut t = task();
948        let now = Utc::now();
949        apply_task_update(
950            &mut t,
951            SessionTaskUpdate {
952                state: Some(SessionTaskState::Succeeded),
953                summary: Some("done".to_string()),
954                ..Default::default()
955            },
956            now,
957        );
958        assert_eq!(t.state, SessionTaskState::Succeeded);
959        assert_eq!(t.finished_at, Some(now));
960
961        // An update carrying a *different* state lost a race against the
962        // terminal transition — it is ignored entirely, content included
963        // (e.g. the reaper must not stamp an orphaned error on a task that
964        // succeeded meanwhile).
965        apply_task_update(
966            &mut t,
967            SessionTaskUpdate {
968                state: Some(SessionTaskState::Failed),
969                error: Some(TaskError {
970                    kind: "orphaned".to_string(),
971                    message: "stale".to_string(),
972                }),
973                ..Default::default()
974            },
975            Utc::now(),
976        );
977        assert_eq!(t.state, SessionTaskState::Succeeded);
978        assert!(t.error.is_none());
979
980        // Idempotent re-mirrors with the SAME terminal state still enrich.
981        apply_task_update(
982            &mut t,
983            SessionTaskUpdate {
984                state: Some(SessionTaskState::Succeeded),
985                result_path: Some("/.tasks/x/result.json".to_string()),
986                ..Default::default()
987            },
988            Utc::now(),
989        );
990        assert_eq!(t.result_path.as_deref(), Some("/.tasks/x/result.json"));
991
992        // Content-only updates (no state) also still apply.
993        apply_task_update(
994            &mut t,
995            SessionTaskUpdate {
996                summary: Some("enriched".to_string()),
997                ..Default::default()
998            },
999            Utc::now(),
1000        );
1001        assert_eq!(t.summary.as_deref(), Some("enriched"));
1002    }
1003
1004    #[test]
1005    fn input_request_forces_awaiting_input_and_clears_on_resume() {
1006        let mut t = task();
1007        apply_task_update(
1008            &mut t,
1009            SessionTaskUpdate {
1010                input_request: Some(TaskInputRequest {
1011                    id: "req_1".to_string(),
1012                    prompt: "Approve?".to_string(),
1013                    expected: None,
1014                }),
1015                ..Default::default()
1016            },
1017            Utc::now(),
1018        );
1019        assert_eq!(t.state, SessionTaskState::AwaitingInput);
1020        assert!(t.input_request.is_some());
1021
1022        apply_task_update(
1023            &mut t,
1024            SessionTaskUpdate {
1025                state: Some(SessionTaskState::Running),
1026                ..Default::default()
1027            },
1028            Utc::now(),
1029        );
1030        assert_eq!(t.state, SessionTaskState::Running);
1031        assert!(t.input_request.is_none());
1032    }
1033
1034    #[test]
1035    fn links_merge_without_duplicates() {
1036        let mut t = task();
1037        let child = SessionId::new();
1038        apply_task_update(
1039            &mut t,
1040            SessionTaskUpdate {
1041                links: Some(TaskLinks {
1042                    child_session_id: Some(child),
1043                    remote_task_id: None,
1044                    resource_ids: vec!["res_1".to_string()],
1045                }),
1046                ..Default::default()
1047            },
1048            Utc::now(),
1049        );
1050        apply_task_update(
1051            &mut t,
1052            SessionTaskUpdate {
1053                links: Some(TaskLinks {
1054                    child_session_id: None,
1055                    remote_task_id: Some("rt_1".to_string()),
1056                    resource_ids: vec!["res_1".to_string(), "res_2".to_string()],
1057                }),
1058                ..Default::default()
1059            },
1060            Utc::now(),
1061        );
1062        assert_eq!(t.links.child_session_id, Some(child));
1063        assert_eq!(t.links.remote_task_id.as_deref(), Some("rt_1"));
1064        assert_eq!(t.links.resource_ids, vec!["res_1", "res_2"]);
1065    }
1066
1067    #[test]
1068    fn message_text_rendering() {
1069        let msg = NewTaskMessage::outbound_text("hello");
1070        assert_eq!(task_message_text(&msg.content), "hello");
1071    }
1072
1073    // -------------------------------------------------------------------------
1074    // Stale-attempt fencing tests
1075    // -------------------------------------------------------------------------
1076
1077    #[test]
1078    fn update_with_matching_attempt_applies() {
1079        let mut t = task();
1080        // Task starts at attempt 1.
1081        assert_eq!(t.attempt, 1);
1082        let now = Utc::now();
1083
1084        // An update that carries expected_attempt == task.attempt applies normally.
1085        apply_task_update(
1086            &mut t,
1087            SessionTaskUpdate {
1088                state: Some(SessionTaskState::Running),
1089                state_detail: Some("step 1".to_string()),
1090                heartbeat_at: Some(now),
1091                expected_attempt: Some(1),
1092                ..Default::default()
1093            },
1094            now,
1095        );
1096        assert_eq!(t.state, SessionTaskState::Running);
1097        assert_eq!(t.state_detail.as_deref(), Some("step 1"));
1098        assert_eq!(t.heartbeat_at, Some(now));
1099    }
1100
1101    #[test]
1102    fn update_with_stale_attempt_is_fully_ignored() {
1103        let mut t = task();
1104        // Simulate the reaper bumping the attempt by directly setting it.
1105        t.attempt = 2;
1106
1107        let now = Utc::now();
1108        let before_updated = t.updated_at;
1109
1110        // A write from the old executor (attempt = 1) must be fully ignored —
1111        // state, heartbeat, and updated_at must not change.
1112        apply_task_update(
1113            &mut t,
1114            SessionTaskUpdate {
1115                state: Some(SessionTaskState::Running),
1116                state_detail: Some("superseded".to_string()),
1117                heartbeat_at: Some(now),
1118                expected_attempt: Some(1),
1119                ..Default::default()
1120            },
1121            now,
1122        );
1123        assert_eq!(t.state, SessionTaskState::Queued, "state must be unchanged");
1124        assert!(t.state_detail.is_none(), "state_detail must be unchanged");
1125        assert!(t.heartbeat_at.is_none(), "heartbeat must be unchanged");
1126        assert_eq!(t.updated_at, before_updated, "updated_at must be unchanged");
1127    }
1128
1129    #[test]
1130    fn update_with_none_expected_attempt_applies_regardless() {
1131        let mut t = task();
1132        // Even with attempt = 99 and no expected_attempt, the update applies.
1133        t.attempt = 99;
1134        let now = Utc::now();
1135
1136        apply_task_update(
1137            &mut t,
1138            SessionTaskUpdate {
1139                summary: Some("cancel from API".to_string()),
1140                expected_attempt: None,
1141                ..Default::default()
1142            },
1143            now,
1144        );
1145        // Writers that don't track attempts (e.g. cancel_task from the API) still apply.
1146        assert_eq!(t.summary.as_deref(), Some("cancel from API"));
1147    }
1148
1149    #[test]
1150    fn reaper_update_increments_attempt_and_fences_old_executor() {
1151        let mut t = task();
1152        t.state = SessionTaskState::Running;
1153        assert_eq!(t.attempt, 1);
1154        let now = Utc::now();
1155
1156        // Reaper-style update: fail as orphaned and supersede the attempt.
1157        apply_task_update(
1158            &mut t,
1159            SessionTaskUpdate {
1160                state: Some(SessionTaskState::Failed),
1161                error: Some(TaskError {
1162                    kind: "orphaned".to_string(),
1163                    message: "worker heartbeat stopped".to_string(),
1164                }),
1165                increment_attempt: true,
1166                ..Default::default()
1167            },
1168            now,
1169        );
1170        assert_eq!(t.state, SessionTaskState::Failed);
1171        assert_eq!(t.attempt, 2, "orphan reap must supersede the attempt");
1172
1173        // The zombie executor's content-only heartbeat (no state change, so the
1174        // terminal invariant alone would let it through) is now fenced out.
1175        let later = now + chrono::Duration::seconds(5);
1176        apply_task_update(
1177            &mut t,
1178            SessionTaskUpdate {
1179                heartbeat_at: Some(later),
1180                expected_attempt: Some(1),
1181                ..Default::default()
1182            },
1183            later,
1184        );
1185        assert_ne!(
1186            t.heartbeat_at,
1187            Some(later),
1188            "stale heartbeat must be rejected"
1189        );
1190    }
1191
1192    #[test]
1193    fn increment_attempt_is_inert_when_update_is_dropped() {
1194        let mut t = task();
1195        t.state = SessionTaskState::Succeeded;
1196        assert_eq!(t.attempt, 1);
1197        let now = Utc::now();
1198
1199        // Reaper losing the race against a clean finish: the terminal-state
1200        // invariant drops the whole update, including the attempt bump.
1201        apply_task_update(
1202            &mut t,
1203            SessionTaskUpdate {
1204                state: Some(SessionTaskState::Failed),
1205                error: Some(TaskError {
1206                    kind: "orphaned".to_string(),
1207                    message: "worker heartbeat stopped".to_string(),
1208                }),
1209                increment_attempt: true,
1210                ..Default::default()
1211            },
1212            now,
1213        );
1214        assert_eq!(t.state, SessionTaskState::Succeeded);
1215        assert_eq!(t.attempt, 1, "dropped update must not bump the attempt");
1216    }
1217}