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