Skip to main content

everruns_core/
session_task.rs

1// Session tasks — unified registry of background work owned by a session.
2//
3// See knowledge/runtime-resources/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 knowledge/foundations/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    /// Append one artifact under the registry's update lock, after any replacement.
332    /// Avoids losing concurrent sink reports through read/modify/write snapshots.
333    #[serde(default, skip_serializing_if = "Option::is_none")]
334    pub append_artifact: Option<TaskArtifact>,
335    pub error: Option<TaskError>,
336    /// Merged field-by-field into existing links.
337    pub links: Option<TaskLinks>,
338    pub worker_id: Option<String>,
339    /// Liveness heartbeat timestamp.
340    pub heartbeat_at: Option<DateTime<Utc>>,
341    /// Stale-attempt fence: when set, the update is silently ignored if
342    /// `task.attempt != expected_attempt`. Executors and sinks set this to
343    /// the attempt they captured at start; the reaper bumps `attempt` (via
344    /// `increment_attempt`) when it fails an orphan, so a zombie executor's
345    /// later writes are rejected. Writers that do not track attempts
346    /// (e.g. `cancel_task` from the API) leave this None.
347    #[serde(default, skip_serializing_if = "Option::is_none")]
348    pub expected_attempt: Option<i32>,
349    /// Supersede the current attempt: bumps `task.attempt` so writes fenced
350    /// on the previous attempt are rejected from now on. Set by the reaper
351    /// when it fails an orphaned task. Ignored if the update itself is
352    /// dropped by the fence or the terminal-state invariant.
353    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
354    pub increment_attempt: bool,
355}
356
357/// Optional filter for listing tasks.
358#[derive(Debug, Clone, Default)]
359pub struct SessionTaskFilter {
360    pub kind: Option<String>,
361    pub state: Option<SessionTaskState>,
362}
363
364/// Apply a partial update to a task, enforcing lifecycle invariants.
365///
366/// All registry backends route updates through this function so semantics
367/// stay identical across PostgreSQL, in-memory, and gRPC modes:
368/// - terminal states are final: state changes on a terminal task are ignored
369///   (content fields like summary/result still apply);
370/// - first transition out of `queued` stamps `started_at`;
371/// - transition into a terminal state stamps `finished_at`;
372/// - setting `input_request` forces `awaiting_input`; leaving
373///   `awaiting_input` clears it.
374pub fn apply_task_update(task: &mut SessionTask, update: SessionTaskUpdate, now: DateTime<Utc>) {
375    // Stale-attempt fence: if the update carries an attempt expectation and it
376    // does not match the current attempt, this write came from a superseded
377    // executor — ignore it entirely (heartbeats, state changes, everything).
378    if let Some(expected) = update.expected_attempt
379        && expected != task.attempt
380    {
381        return;
382    }
383
384    let was_terminal = task.state.is_terminal();
385
386    // Terminal states are final. An update that asks for a *different* state
387    // on an already-terminal task lost a race (e.g. the reaper marking a task
388    // orphaned after it succeeded) — ignore it entirely so its content fields
389    // (error, summary) cannot corrupt the terminal record. Updates that carry
390    // the same terminal state (idempotent re-mirrors) or no state at all
391    // (content enrichment) still apply below.
392    if was_terminal
393        && let Some(state) = update.state
394        && state != task.state
395    {
396        return;
397    }
398
399    // Supersede the current attempt (reaper failing an orphan): writes fenced
400    // on the old attempt are rejected from here on.
401    if update.increment_attempt {
402        task.attempt += 1;
403    }
404
405    let mut next_state = update.state;
406    if update.input_request.is_some() && !was_terminal {
407        next_state = Some(SessionTaskState::AwaitingInput);
408    }
409
410    if let Some(input_request) = update.input_request
411        && !was_terminal
412    {
413        task.input_request = Some(input_request);
414    }
415
416    if let Some(state) = next_state
417        && !was_terminal
418        && task.state != state
419    {
420        if task.state == SessionTaskState::Queued && state != SessionTaskState::Queued {
421            task.started_at.get_or_insert(now);
422        }
423        if state.is_terminal() {
424            task.finished_at.get_or_insert(now);
425        }
426        if state != SessionTaskState::AwaitingInput {
427            task.input_request = None;
428        }
429        task.state = state;
430    }
431
432    if let Some(detail) = update.state_detail {
433        task.state_detail = Some(detail);
434    }
435    if let Some(progress) = update.progress {
436        task.progress = Some(progress);
437    }
438    if let Some(summary) = update.summary {
439        task.summary = Some(summary);
440    }
441    if let Some(result_path) = update.result_path {
442        task.result_path = Some(result_path);
443    }
444    if let Some(artifacts) = update.artifacts {
445        task.artifacts = artifacts;
446    }
447    if let Some(artifact) = update.append_artifact {
448        task.artifacts.push(artifact);
449    }
450    if let Some(error) = update.error {
451        task.error = Some(error);
452    }
453    if let Some(links) = update.links {
454        if links.child_session_id.is_some() {
455            task.links.child_session_id = links.child_session_id;
456        }
457        if links.remote_task_id.is_some() {
458            task.links.remote_task_id = links.remote_task_id;
459        }
460        for id in links.resource_ids {
461            if !task.links.resource_ids.contains(&id) {
462                task.links.resource_ids.push(id);
463            }
464        }
465    }
466    if let Some(worker_id) = update.worker_id {
467        task.worker_id = Some(worker_id);
468    }
469    if let Some(heartbeat_at) = update.heartbeat_at {
470        task.heartbeat_at = Some(heartbeat_at);
471    }
472
473    task.updated_at = now;
474}
475
476/// Build a new task from creation input.
477pub fn new_session_task(input: CreateSessionTask, now: DateTime<Utc>) -> SessionTask {
478    let state = input.state;
479    SessionTask {
480        id: input.id.unwrap_or_else(generate_task_id),
481        session_id: input.session_id,
482        // Denormalized at storage insert from the owning session's root; a
483        // freshly-built task carries no root until read back.
484        root_session_id: None,
485        kind: input.kind,
486        display_name: input.display_name,
487        spec: input.spec,
488        state,
489        state_detail: None,
490        progress: None,
491        input_request: None,
492        cancel_requested_at: None,
493        summary: None,
494        result_path: None,
495        artifacts: Vec::new(),
496        error: None,
497        attempt: 1,
498        worker_id: None,
499        heartbeat_at: None,
500        links: input.links,
501        wake_policy: input.wake_policy,
502        created_at: now,
503        started_at: if state == SessionTaskState::Queued {
504            None
505        } else {
506            Some(now)
507        },
508        finished_at: if state.is_terminal() { Some(now) } else { None },
509        updated_at: now,
510    }
511}
512
513// ============================================================================
514// Messages — bidirectional, persisted channel between session and task
515// ============================================================================
516
517/// Direction of a task message. Inbound = session → task.
518#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
519#[cfg_attr(feature = "openapi", derive(ToSchema))]
520#[serde(rename_all = "snake_case")]
521pub enum TaskMessageDirection {
522    Inbound,
523    Outbound,
524}
525
526impl std::fmt::Display for TaskMessageDirection {
527    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
528        match self {
529            Self::Inbound => write!(f, "inbound"),
530            Self::Outbound => write!(f, "outbound"),
531        }
532    }
533}
534
535impl From<&str> for TaskMessageDirection {
536    fn from(s: &str) -> Self {
537        match s {
538            "outbound" => Self::Outbound,
539            _ => Self::Inbound,
540        }
541    }
542}
543
544/// One content part of a task message.
545#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
546#[cfg_attr(feature = "openapi", derive(ToSchema))]
547#[serde(tag = "type", rename_all = "snake_case")]
548pub enum TaskMessagePart {
549    Text {
550        text: String,
551    },
552    Data {
553        #[cfg_attr(feature = "openapi", schema(value_type = Object))]
554        data: Value,
555    },
556}
557
558impl TaskMessagePart {
559    pub fn text(text: impl Into<String>) -> Self {
560        Self::Text { text: text.into() }
561    }
562}
563
564/// A message exchanged between a session and one of its tasks.
565#[derive(Debug, Clone, Serialize, Deserialize)]
566#[cfg_attr(feature = "openapi", derive(ToSchema))]
567pub struct TaskMessage {
568    /// `tmsg_*` public ID.
569    pub id: String,
570    pub task_id: String,
571    pub direction: TaskMessageDirection,
572    pub content: Vec<TaskMessagePart>,
573    /// Set when this message answers a `TaskInputRequest`.
574    #[serde(default, skip_serializing_if = "Option::is_none")]
575    pub in_reply_to: Option<String>,
576    pub created_at: DateTime<Utc>,
577}
578
579/// Input for recording a task message.
580#[derive(Debug, Clone, Serialize, Deserialize)]
581pub struct NewTaskMessage {
582    pub direction: TaskMessageDirection,
583    pub content: Vec<TaskMessagePart>,
584    #[serde(default)]
585    pub in_reply_to: Option<String>,
586    /// Stale-attempt fence for message writes: when set, registries reject
587    /// the message if `task.attempt` no longer matches, so a superseded
588    /// executor cannot append to the thread or trigger wake-ups. Not stored
589    /// with the message.
590    #[serde(default, skip_serializing_if = "Option::is_none")]
591    pub expected_attempt: Option<i32>,
592}
593
594impl NewTaskMessage {
595    pub fn inbound_text(text: impl Into<String>) -> Self {
596        Self {
597            direction: TaskMessageDirection::Inbound,
598            content: vec![TaskMessagePart::text(text)],
599            in_reply_to: None,
600            expected_attempt: None,
601        }
602    }
603
604    pub fn outbound_text(text: impl Into<String>) -> Self {
605        Self {
606            direction: TaskMessageDirection::Outbound,
607            content: vec![TaskMessagePart::text(text)],
608            in_reply_to: None,
609            expected_attempt: None,
610        }
611    }
612
613    /// Fence this message write on the given attempt (see `expected_attempt`).
614    pub fn with_expected_attempt(mut self, attempt: i32) -> Self {
615        self.expected_attempt = Some(attempt);
616        self
617    }
618}
619
620/// Plain-text rendering of message content (for steering/wake messages).
621pub fn task_message_text(content: &[TaskMessagePart]) -> String {
622    content
623        .iter()
624        .filter_map(|part| match part {
625            TaskMessagePart::Text { text } => Some(text.as_str()),
626            TaskMessagePart::Data { .. } => None,
627        })
628        .collect::<Vec<_>>()
629        .join("\n")
630}
631
632// ============================================================================
633// Registry — owns the record, invariants, events, and durability
634// ============================================================================
635
636/// Session task registry. Implementations emit `task.created` /
637/// `task.updated` (full snapshots) and `task.message.*` events on the owning
638/// session's event stream.
639#[async_trait]
640pub trait SessionTaskRegistry: Send + Sync {
641    /// Create a task (idempotent on caller-supplied ID: re-creating an
642    /// existing ID returns the stored task unchanged).
643    async fn create(&self, input: CreateSessionTask) -> Result<SessionTask>;
644
645    /// Apply a partial update through `apply_task_update` invariants.
646    async fn update(
647        &self,
648        session_id: SessionId,
649        task_id: &str,
650        update: SessionTaskUpdate,
651    ) -> Result<Option<SessionTask>>;
652
653    async fn get(&self, session_id: SessionId, task_id: &str) -> Result<Option<SessionTask>>;
654
655    async fn list(
656        &self,
657        session_id: SessionId,
658        filter: Option<&SessionTaskFilter>,
659    ) -> Result<Vec<SessionTask>>;
660
661    /// Record cooperative cancel intent (idempotent). Does not change state;
662    /// the executor winds down and reports the terminal state.
663    async fn request_cancel(
664        &self,
665        session_id: SessionId,
666        task_id: &str,
667    ) -> Result<Option<SessionTask>>;
668
669    /// Persist a message on the task's channel. Answering messages
670    /// (`in_reply_to` set) clear a matching pending input request and return
671    /// the task to `running`.
672    async fn record_message(
673        &self,
674        session_id: SessionId,
675        task_id: &str,
676        message: NewTaskMessage,
677    ) -> Result<TaskMessage>;
678
679    /// List messages on the task's channel, oldest first.
680    ///
681    /// When `after_id` is `Some`, only messages newer than that message ID are
682    /// returned (exclusive cursor, since_id-style). Both postgres and in-memory
683    /// backends implement the cursor; other backends ignore it and return all
684    /// messages up to `limit`.
685    async fn list_messages(
686        &self,
687        session_id: SessionId,
688        task_id: &str,
689        limit: Option<u32>,
690        after_id: Option<&str>,
691    ) -> Result<Vec<TaskMessage>>;
692}
693
694// ============================================================================
695// Executor — control plane, implemented per kind by capabilities
696// ============================================================================
697
698/// Control plane for a task kind. The registry/tools call into the executor;
699/// the running work pushes into a `TaskSink`.
700///
701/// Default method bodies return `unsupported` so kinds implement only what
702/// applies (e.g. a background tool rarely accepts inbound messages).
703#[async_trait]
704pub trait TaskExecutor: Send + Sync {
705    fn kind(&self) -> &str;
706
707    /// Whether this executor can re-attach to a running task after worker loss.
708    ///
709    /// Kinds returning `true` must implement `start` such that calling it with
710    /// a re-attached task snapshot (attempt already bumped by the reaper)
711    /// resumes the work idempotently and heartbeats with the new attempt.
712    /// Kinds returning `false` (the default) are failed as orphaned immediately
713    /// by the reaper.
714    fn can_reattach(&self) -> bool {
715        false
716    }
717
718    /// Whether this executor can re-attach to a *specific* task instance.
719    ///
720    /// Defaults to `self.can_reattach()`. Override to inspect per-task spec
721    /// fields (e.g. whether the spawned tool declared itself idempotent).
722    /// The reaper calls this instead of `can_reattach()` when a task snapshot
723    /// is available.
724    fn can_reattach_task(&self, task: &SessionTask) -> bool {
725        let _ = task;
726        self.can_reattach()
727    }
728
729    /// Begin execution, or re-attach after worker loss.
730    ///
731    /// Called by the reaper when re-attaching a task (attempt already bumped).
732    /// Implementations must heartbeat using `task.attempt` so stale writes from
733    /// the previous executor are rejected by the fence.
734    async fn start(
735        &self,
736        task: &SessionTask,
737        context: &crate::tool_context::ToolContext,
738    ) -> Result<()> {
739        let _ = (task, context);
740        Err(crate::error::AgentLoopError::tool(format!(
741            "task kind '{}' does not support start via the registry",
742            self.kind()
743        )))
744    }
745
746    /// Deliver an inbound message (steering or input answer) to the work.
747    async fn deliver(
748        &self,
749        task: &SessionTask,
750        message: &TaskMessage,
751        context: &crate::tool_context::ToolContext,
752    ) -> Result<()> {
753        let _ = (task, message, context);
754        Err(crate::error::AgentLoopError::tool(format!(
755            "task kind '{}' does not accept inbound messages",
756            self.kind()
757        )))
758    }
759
760    /// Cooperatively wind down. The task may still end succeeded or failed.
761    async fn cancel(
762        &self,
763        task: &SessionTask,
764        context: &crate::tool_context::ToolContext,
765    ) -> Result<()>;
766
767    /// Refresh state for polled kinds (e.g. A2A remote tasks). Reports via
768    /// the registry; no-op by default.
769    async fn reconcile(
770        &self,
771        task: &SessionTask,
772        context: &crate::tool_context::ToolContext,
773    ) -> Result<()> {
774        let _ = (task, context);
775        Ok(())
776    }
777}
778
779/// Inventory plugin so capabilities register executors without core knowing
780/// about them (same pattern as `everruns-platform`'s
781/// `SessionSandboxProviderPlugin`).
782pub struct TaskExecutorPlugin {
783    pub executor: fn() -> Arc<dyn TaskExecutor>,
784}
785
786inventory::collect!(TaskExecutorPlugin);
787
788/// Find the registered executor for a task kind.
789pub fn find_task_executor(kind: &str) -> Option<Arc<dyn TaskExecutor>> {
790    inventory::iter::<TaskExecutorPlugin>
791        .into_iter()
792        .map(|plugin| (plugin.executor)())
793        .find(|executor| executor.kind() == kind)
794}
795
796// ============================================================================
797// Sink — report plane for running work
798// ============================================================================
799
800/// Report plane handed to running work. `state`/`progress`/`request_input`
801/// mutate the task record (snapshot events fire); `post` appends to the
802/// message channel; `output` is high-frequency and ephemeral.
803#[async_trait]
804pub trait TaskSink: Send + Sync {
805    async fn state(&self, state: SessionTaskState, detail: Option<String>) -> Result<()>;
806
807    async fn progress(&self, progress: TaskProgress) -> Result<()>;
808
809    /// High-frequency output delta. Not persisted on the task record.
810    async fn output(&self, stream: &str, delta: &str) -> Result<()>;
811
812    /// Outbound message to the session; may wake the parent per wake policy.
813    async fn post(&self, message: NewTaskMessage) -> Result<()>;
814
815    /// Ask the session for input; transitions the task to `awaiting_input`.
816    async fn request_input(&self, request: TaskInputRequest) -> Result<()>;
817
818    async fn artifact(&self, artifact: TaskArtifact) -> Result<()>;
819}
820
821/// `TaskSink` backed by a `SessionTaskRegistry`. Output deltas are dropped
822/// here; kinds with live output keep their existing streaming path.
823///
824/// Carries `attempt` for stale-attempt fencing: every update includes
825/// `expected_attempt` so writes from a superseded executor are rejected once
826/// the reaper increments the attempt counter on the task record.
827pub struct RegistryTaskSink {
828    registry: Arc<dyn SessionTaskRegistry>,
829    session_id: SessionId,
830    task_id: String,
831    /// The attempt number this sink was created for (captured at task start).
832    attempt: i32,
833}
834
835impl RegistryTaskSink {
836    pub fn new(
837        registry: Arc<dyn SessionTaskRegistry>,
838        session_id: SessionId,
839        task_id: String,
840    ) -> Self {
841        Self {
842            registry,
843            session_id,
844            task_id,
845            attempt: 1,
846        }
847    }
848
849    /// Set the attempt number for fencing. Call this after reading the task
850    /// record at start so the sink rejects writes once the attempt is bumped.
851    pub fn with_attempt(mut self, attempt: i32) -> Self {
852        self.attempt = attempt;
853        self
854    }
855}
856
857#[async_trait]
858impl TaskSink for RegistryTaskSink {
859    async fn state(&self, state: SessionTaskState, detail: Option<String>) -> Result<()> {
860        self.registry
861            .update(
862                self.session_id,
863                &self.task_id,
864                SessionTaskUpdate {
865                    state: Some(state),
866                    state_detail: detail,
867                    expected_attempt: Some(self.attempt),
868                    ..Default::default()
869                },
870            )
871            .await?;
872        Ok(())
873    }
874
875    async fn progress(&self, progress: TaskProgress) -> Result<()> {
876        self.registry
877            .update(
878                self.session_id,
879                &self.task_id,
880                SessionTaskUpdate {
881                    progress: Some(progress),
882                    expected_attempt: Some(self.attempt),
883                    ..Default::default()
884                },
885            )
886            .await?;
887        Ok(())
888    }
889
890    async fn output(&self, _stream: &str, _delta: &str) -> Result<()> {
891        Ok(())
892    }
893
894    async fn post(&self, message: NewTaskMessage) -> Result<()> {
895        // Fence message writes too: record_message emits events and can wake
896        // the parent session, so a superseded executor must not post.
897        self.registry
898            .record_message(
899                self.session_id,
900                &self.task_id,
901                message.with_expected_attempt(self.attempt),
902            )
903            .await?;
904        Ok(())
905    }
906
907    async fn request_input(&self, request: TaskInputRequest) -> Result<()> {
908        self.registry
909            .update(
910                self.session_id,
911                &self.task_id,
912                SessionTaskUpdate {
913                    input_request: Some(request),
914                    expected_attempt: Some(self.attempt),
915                    ..Default::default()
916                },
917            )
918            .await?;
919        Ok(())
920    }
921
922    async fn artifact(&self, artifact: TaskArtifact) -> Result<()> {
923        self.registry
924            .update(
925                self.session_id,
926                &self.task_id,
927                SessionTaskUpdate {
928                    append_artifact: Some(artifact),
929                    expected_attempt: Some(self.attempt),
930                    ..Default::default()
931                },
932            )
933            .await?;
934        Ok(())
935    }
936}
937
938/// VFS directory for a task's result and logs.
939pub fn task_vfs_dir(task_id: &str) -> String {
940    format!("/.tasks/{task_id}")
941}
942
943/// VFS path for a task's machine result.
944pub fn task_result_path(task_id: &str) -> String {
945    format!("/.tasks/{task_id}/result.json")
946}
947
948#[cfg(test)]
949mod tests {
950    use super::*;
951
952    fn instant(seconds: i64) -> DateTime<Utc> {
953        DateTime::from_timestamp(seconds, 0).unwrap()
954    }
955
956    fn snapshot(task: &SessionTask) -> Value {
957        serde_json::to_value(task).unwrap()
958    }
959
960    fn task() -> SessionTask {
961        new_session_task(
962            CreateSessionTask {
963                session_id: SessionId::from_uuid(uuid::Uuid::from_u128(1)),
964                id: Some("task_fixed".into()),
965                kind: TASK_KIND_BACKGROUND_TOOL.to_string(),
966                display_name: "Test".to_string(),
967                spec: serde_json::json!({}),
968                state: SessionTaskState::Queued,
969                links: TaskLinks::default(),
970                wake_policy: TaskWakePolicy::Silent,
971            },
972            instant(10),
973        )
974    }
975
976    #[test]
977    fn creation_preserves_inputs_and_initial_lifecycle_timestamps() {
978        for (state, started, finished) in [
979            (SessionTaskState::Queued, None, None),
980            (SessionTaskState::Running, Some(instant(10)), None),
981            (SessionTaskState::AwaitingInput, Some(instant(10)), None),
982            (
983                SessionTaskState::Succeeded,
984                Some(instant(10)),
985                Some(instant(10)),
986            ),
987            (
988                SessionTaskState::Failed,
989                Some(instant(10)),
990                Some(instant(10)),
991            ),
992            (
993                SessionTaskState::Canceled,
994                Some(instant(10)),
995                Some(instant(10)),
996            ),
997        ] {
998            let input = CreateSessionTask {
999                session_id: task().session_id,
1000                id: Some("task_external".into()),
1001                kind: "custom_kind".into(),
1002                display_name: "Work".into(),
1003                spec: serde_json::json!({"input": [1, 2]}),
1004                state,
1005                links: TaskLinks {
1006                    remote_task_id: Some("remote".into()),
1007                    ..Default::default()
1008                },
1009                wake_policy: TaskWakePolicy::OnActivity,
1010            };
1011            let actual = new_session_task(input.clone(), instant(10));
1012            let mut expected = task();
1013            expected.id = "task_external".into();
1014            expected.kind = "custom_kind".into();
1015            expected.display_name = "Work".into();
1016            expected.spec = serde_json::json!({"input": [1, 2]});
1017            expected.state = state;
1018            expected.links.remote_task_id = Some("remote".into());
1019            expected.wake_policy = TaskWakePolicy::OnActivity;
1020            expected.started_at = started;
1021            expected.finished_at = finished;
1022            assert_eq!(snapshot(&actual), snapshot(&expected));
1023            let generated = new_session_task(CreateSessionTask { id: None, ..input }, instant(10));
1024            let suffix = generated.id.strip_prefix("task_").unwrap();
1025            assert_eq!(suffix.len(), 32);
1026            assert_eq!(uuid::Uuid::parse_str(suffix).unwrap().get_version_num(), 7);
1027        }
1028    }
1029
1030    #[test]
1031    fn serialization_redacts_spec_push_config_secrets() {
1032        let mut t = task();
1033        t.spec = serde_json::json!({
1034            "instructions": "notify",
1035            "push_configs": [
1036                {
1037                    "url": "https://hooks.example.com/everruns",
1038                    "secret": "LEAKME-HMAC-KEY",
1039                    "event_filter": ["terminal"]
1040                },
1041                {
1042                    "url": "https://hooks.example.com/no-secret",
1043                    "event_filter": ["message"]
1044                }
1045            ]
1046        });
1047
1048        let stored = t.spec.clone();
1049        assert_eq!(
1050            snapshot(&t)["spec"],
1051            serde_json::json!({
1052                "instructions": "notify",
1053                "push_configs": [
1054                    {"url": "https://hooks.example.com/everruns", "has_secret": true, "event_filter": ["terminal"]},
1055                    {"url": "https://hooks.example.com/no-secret", "event_filter": ["message"]}
1056                ]
1057            })
1058        );
1059        assert_eq!(
1060            t.spec, stored,
1061            "presentation must not mutate delivery secrets"
1062        );
1063    }
1064
1065    #[test]
1066    fn first_transition_out_of_queued_stamps_started_at() {
1067        let mut t = task();
1068        let now = instant(20);
1069        apply_task_update(
1070            &mut t,
1071            SessionTaskUpdate {
1072                state: Some(SessionTaskState::Running),
1073                ..Default::default()
1074            },
1075            now,
1076        );
1077        assert_eq!(t.state, SessionTaskState::Running);
1078        assert_eq!(t.started_at, Some(now));
1079        assert!(t.finished_at.is_none());
1080        assert_eq!(t.updated_at, instant(20));
1081        for (state, at) in [
1082            (SessionTaskState::Queued, 30),
1083            (SessionTaskState::Running, 40),
1084        ] {
1085            apply_task_update(
1086                &mut t,
1087                SessionTaskUpdate {
1088                    state: Some(state),
1089                    ..Default::default()
1090                },
1091                instant(at),
1092            );
1093            assert_eq!(
1094                t.started_at,
1095                Some(instant(20)),
1096                "first start must survive requeue"
1097            );
1098            assert_eq!(t.updated_at, instant(at));
1099        }
1100    }
1101
1102    #[test]
1103    fn terminal_transitions_reject_conflicting_updates_but_allow_enrichment() {
1104        use SessionTaskState::*;
1105        for terminal in [Succeeded, Failed, Canceled] {
1106            let mut t = task();
1107            apply_task_update(
1108                &mut t,
1109                SessionTaskUpdate {
1110                    state: Some(terminal),
1111                    summary: Some("done".into()),
1112                    ..Default::default()
1113                },
1114                instant(20),
1115            );
1116            assert_eq!(t.state, terminal);
1117            assert_eq!(t.started_at, Some(instant(20)));
1118            assert_eq!(t.finished_at, Some(instant(20)));
1119            assert_eq!(t.updated_at, instant(20));
1120            let before = snapshot(&t);
1121            for other in [Queued, Running, AwaitingInput, Succeeded, Failed, Canceled] {
1122                if other == terminal {
1123                    continue;
1124                }
1125                apply_task_update(
1126                    &mut t,
1127                    SessionTaskUpdate {
1128                        state: Some(other),
1129                        summary: Some("stale".into()),
1130                        error: Some(TaskError {
1131                            kind: "orphaned".into(),
1132                            message: "stale".into(),
1133                        }),
1134                        append_artifact: Some(artifact("stale")),
1135                        increment_attempt: true,
1136                        ..Default::default()
1137                    },
1138                    instant(30),
1139                );
1140                assert_eq!(snapshot(&t), before, "{terminal:?} -> {other:?}");
1141            }
1142            let mut expected = t.clone();
1143            for state in [Some(terminal), None] {
1144                apply_task_update(
1145                    &mut t,
1146                    SessionTaskUpdate {
1147                        state,
1148                        result_path: Some("/result".into()),
1149                        summary: Some("enriched".into()),
1150                        input_request: Some(TaskInputRequest {
1151                            id: "late".into(),
1152                            prompt: "too late".into(),
1153                            expected: None,
1154                        }),
1155                        ..Default::default()
1156                    },
1157                    instant(40),
1158                );
1159                expected.result_path = Some("/result".into());
1160                expected.summary = Some("enriched".into());
1161                expected.updated_at = instant(40);
1162                assert_eq!(
1163                    snapshot(&t),
1164                    snapshot(&expected),
1165                    "enrichment must preserve lifecycle and ignore late input"
1166                );
1167            }
1168        }
1169    }
1170
1171    #[test]
1172    fn input_request_forces_awaiting_input_and_clears_on_resume() {
1173        let mut t = task();
1174        apply_task_update(
1175            &mut t,
1176            SessionTaskUpdate {
1177                input_request: Some(TaskInputRequest {
1178                    id: "req_1".to_string(),
1179                    prompt: "Approve?".to_string(),
1180                    expected: None,
1181                }),
1182                ..Default::default()
1183            },
1184            instant(10),
1185        );
1186        assert_eq!(t.state, SessionTaskState::AwaitingInput);
1187        assert_eq!(
1188            t.input_request,
1189            Some(TaskInputRequest {
1190                id: "req_1".into(),
1191                prompt: "Approve?".into(),
1192                expected: None
1193            })
1194        );
1195        assert_eq!(t.started_at, Some(instant(10)));
1196
1197        apply_task_update(
1198            &mut t,
1199            SessionTaskUpdate {
1200                state: Some(SessionTaskState::Running),
1201                ..Default::default()
1202            },
1203            instant(10),
1204        );
1205        assert_eq!(t.state, SessionTaskState::Running);
1206        assert!(t.input_request.is_none());
1207    }
1208
1209    #[test]
1210    fn links_merge_without_duplicates() {
1211        let mut t = task();
1212        let child = SessionId::from_uuid(uuid::Uuid::from_u128(1));
1213        apply_task_update(
1214            &mut t,
1215            SessionTaskUpdate {
1216                links: Some(TaskLinks {
1217                    child_session_id: Some(child),
1218                    remote_task_id: None,
1219                    resource_ids: vec!["res_1".to_string()],
1220                }),
1221                ..Default::default()
1222            },
1223            instant(10),
1224        );
1225        apply_task_update(
1226            &mut t,
1227            SessionTaskUpdate {
1228                links: Some(TaskLinks {
1229                    child_session_id: None,
1230                    remote_task_id: Some("rt_1".to_string()),
1231                    resource_ids: vec!["res_1".to_string(), "res_2".to_string()],
1232                }),
1233                ..Default::default()
1234            },
1235            instant(10),
1236        );
1237        assert_eq!(t.links.child_session_id, Some(child));
1238        assert_eq!(t.links.remote_task_id.as_deref(), Some("rt_1"));
1239        assert_eq!(t.links.resource_ids, vec!["res_1", "res_2"]);
1240        let replacement = SessionId::from_uuid(uuid::Uuid::from_u128(2));
1241        apply_task_update(
1242            &mut t,
1243            SessionTaskUpdate {
1244                links: Some(TaskLinks {
1245                    child_session_id: Some(replacement),
1246                    remote_task_id: Some("rt_2".into()),
1247                    resource_ids: vec!["res_2".into(), "res_3".into(), "res_3".into()],
1248                }),
1249                ..Default::default()
1250            },
1251            instant(30),
1252        );
1253        assert_eq!(
1254            t.links,
1255            TaskLinks {
1256                child_session_id: Some(replacement),
1257                remote_task_id: Some("rt_2".into()),
1258                resource_ids: vec!["res_1".into(), "res_2".into(), "res_3".into()]
1259            }
1260        );
1261    }
1262
1263    #[test]
1264    fn message_text_rendering() {
1265        let content = vec![
1266            TaskMessagePart::Data {
1267                data: serde_json::json!({"text": "hidden"}),
1268            },
1269            TaskMessagePart::text("first\nline"),
1270            TaskMessagePart::text(""),
1271            TaskMessagePart::Data {
1272                data: serde_json::json!([1, 2]),
1273            },
1274            TaskMessagePart::text("last 🦀"),
1275        ];
1276        assert_eq!(task_message_text(&content), "first\nline\n\nlast 🦀");
1277        assert_eq!(task_message_text(&[]), "");
1278        assert_eq!(task_message_text(&content[..1]), "");
1279    }
1280
1281    // -------------------------------------------------------------------------
1282    // Stale-attempt fencing tests
1283    // -------------------------------------------------------------------------
1284
1285    #[test]
1286    fn attempt_fence_rejects_entire_update_and_allows_current_or_unfenced_writes() {
1287        for expected_attempt in [Some(1), Some(2), Some(3), None] {
1288            let mut actual = task();
1289            actual.attempt = 2;
1290            actual.artifacts = vec![artifact("old")];
1291            let before = snapshot(&actual);
1292            let update = SessionTaskUpdate {
1293                state: Some(SessionTaskState::Running),
1294                state_detail: Some("working".into()),
1295                summary: Some("summary".into()),
1296                result_path: Some("/result".into()),
1297                artifacts: Some(vec![artifact("replacement")]),
1298                append_artifact: Some(artifact("append")),
1299                error: Some(TaskError {
1300                    kind: "diagnostic".into(),
1301                    message: "detail".into(),
1302                }),
1303                links: Some(TaskLinks {
1304                    remote_task_id: Some("remote".into()),
1305                    ..Default::default()
1306                }),
1307                worker_id: Some("worker".into()),
1308                heartbeat_at: Some(instant(19)),
1309                expected_attempt,
1310                increment_attempt: true,
1311                ..Default::default()
1312            };
1313            let mut expected = actual.clone();
1314            apply_task_update(&mut actual, update, instant(20));
1315            if matches!(expected_attempt, Some(1 | 3)) {
1316                assert_eq!(
1317                    snapshot(&actual),
1318                    before,
1319                    "stale/future attempt {expected_attempt:?}"
1320                );
1321            } else {
1322                expected.state = SessionTaskState::Running;
1323                expected.state_detail = Some("working".into());
1324                expected.summary = Some("summary".into());
1325                expected.result_path = Some("/result".into());
1326                expected.artifacts = vec![artifact("replacement"), artifact("append")];
1327                expected.error = Some(TaskError {
1328                    kind: "diagnostic".into(),
1329                    message: "detail".into(),
1330                });
1331                expected.links.remote_task_id = Some("remote".into());
1332                expected.worker_id = Some("worker".into());
1333                expected.heartbeat_at = Some(instant(19));
1334                expected.started_at = Some(instant(20));
1335                expected.updated_at = instant(20);
1336                expected.attempt = 3;
1337                assert_eq!(snapshot(&actual), snapshot(&expected));
1338                apply_task_update(
1339                    &mut actual,
1340                    SessionTaskUpdate {
1341                        artifacts: Some(vec![]),
1342                        ..Default::default()
1343                    },
1344                    instant(30),
1345                );
1346                assert!(
1347                    actual.artifacts.is_empty(),
1348                    "explicit empty replacement clears artifacts"
1349                );
1350            }
1351        }
1352    }
1353
1354    #[test]
1355    fn reaper_update_increments_attempt_and_fences_old_executor() {
1356        let mut t = task();
1357        t.state = SessionTaskState::Running;
1358        assert_eq!(t.attempt, 1);
1359        let now = instant(20);
1360
1361        // Reaper-style update: fail as orphaned and supersede the attempt.
1362        apply_task_update(
1363            &mut t,
1364            SessionTaskUpdate {
1365                state: Some(SessionTaskState::Failed),
1366                error: Some(TaskError {
1367                    kind: "orphaned".to_string(),
1368                    message: "worker heartbeat stopped".to_string(),
1369                }),
1370                increment_attempt: true,
1371                ..Default::default()
1372            },
1373            now,
1374        );
1375        assert_eq!(t.state, SessionTaskState::Failed);
1376        assert_eq!(t.attempt, 2, "orphan reap must supersede the attempt");
1377
1378        assert_eq!(
1379            t.error,
1380            Some(TaskError {
1381                kind: "orphaned".into(),
1382                message: "worker heartbeat stopped".into()
1383            })
1384        );
1385        assert_eq!(t.finished_at, Some(instant(20)));
1386        let before = snapshot(&t);
1387        apply_task_update(
1388            &mut t,
1389            SessionTaskUpdate {
1390                heartbeat_at: Some(instant(30)),
1391                append_artifact: Some(artifact("zombie")),
1392                expected_attempt: Some(1),
1393                ..Default::default()
1394            },
1395            instant(30),
1396        );
1397        assert_eq!(snapshot(&t), before);
1398    }
1399
1400    struct ArtifactRegistry {
1401        task: tokio::sync::Mutex<SessionTask>,
1402    }
1403
1404    #[async_trait]
1405    impl SessionTaskRegistry for ArtifactRegistry {
1406        async fn create(&self, _input: CreateSessionTask) -> Result<SessionTask> {
1407            panic!("unexpected create")
1408        }
1409        async fn update(
1410            &self,
1411            session_id: SessionId,
1412            task_id: &str,
1413            update: SessionTaskUpdate,
1414        ) -> Result<Option<SessionTask>> {
1415            let mut task = self.task.lock().await;
1416            assert_eq!(task.session_id, session_id);
1417            assert_eq!(task.id, task_id);
1418            apply_task_update(&mut task, update, instant(10));
1419            Ok(Some(task.clone()))
1420        }
1421        async fn get(&self, session_id: SessionId, task_id: &str) -> Result<Option<SessionTask>> {
1422            let task = self.task.lock().await.clone();
1423            assert_eq!(task.session_id, session_id);
1424            assert_eq!(task.id, task_id);
1425            // Force concurrent read/modify/write callers to observe the same snapshot.
1426            tokio::task::yield_now().await;
1427            Ok(Some(task))
1428        }
1429        async fn list(
1430            &self,
1431            _session_id: SessionId,
1432            _filter: Option<&SessionTaskFilter>,
1433        ) -> Result<Vec<SessionTask>> {
1434            panic!("unexpected list")
1435        }
1436        async fn request_cancel(
1437            &self,
1438            _session_id: SessionId,
1439            _task_id: &str,
1440        ) -> Result<Option<SessionTask>> {
1441            panic!("unexpected cancel")
1442        }
1443        async fn record_message(
1444            &self,
1445            _session_id: SessionId,
1446            _task_id: &str,
1447            _message: NewTaskMessage,
1448        ) -> Result<TaskMessage> {
1449            panic!("unexpected message")
1450        }
1451        async fn list_messages(
1452            &self,
1453            _session_id: SessionId,
1454            _task_id: &str,
1455            _limit: Option<u32>,
1456            _after_id: Option<&str>,
1457        ) -> Result<Vec<TaskMessage>> {
1458            panic!("unexpected messages")
1459        }
1460    }
1461
1462    fn artifact(name: &str) -> TaskArtifact {
1463        TaskArtifact {
1464            name: name.into(),
1465            artifact_type: "file".into(),
1466            path: Some(format!("/results/{name}")),
1467            url: None,
1468        }
1469    }
1470
1471    #[tokio::test]
1472    async fn concurrent_sinks_append_artifacts_without_losing_siblings() {
1473        let mut task = task();
1474        task.artifacts.push(artifact("initial"));
1475        let session_id = task.session_id;
1476        let task_id = task.id.clone();
1477        let registry = Arc::new(ArtifactRegistry {
1478            task: tokio::sync::Mutex::new(task),
1479        });
1480        let first = RegistryTaskSink::new(registry.clone(), session_id, task_id.clone());
1481        let second = RegistryTaskSink::new(registry.clone(), session_id, task_id);
1482        let (a, b) = tokio::join!(
1483            first.artifact(artifact("a")),
1484            second.artifact(artifact("b"))
1485        );
1486        a.unwrap();
1487        b.unwrap();
1488        let mut artifacts = registry.task.lock().await.artifacts.clone();
1489        artifacts.sort_by(|a, b| a.name.cmp(&b.name));
1490        assert_eq!(
1491            artifacts,
1492            [artifact("a"), artifact("b"), artifact("initial")]
1493        );
1494        registry.task.lock().await.attempt = 2;
1495        let before = snapshot(&*registry.task.lock().await);
1496        first.artifact(artifact("stale")).await.unwrap();
1497        assert_eq!(snapshot(&*registry.task.lock().await), before);
1498    }
1499}