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