Skip to content
This repository was archived by the owner on Apr 8, 2026. It is now read-only.

Commit 21a1e1d

Browse files
author
Jobdori
committed
Merge jobdori/task-runtime: TaskRegistry in-memory lifecycle management
2 parents a98f2b6 + 5ea138e commit 21a1e1d

File tree

2 files changed

+336
-0
lines changed

2 files changed

+336
-0
lines changed

rust/crates/runtime/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ mod remote;
1616
pub mod sandbox;
1717
mod session;
1818
mod sse;
19+
pub mod task_registry;
1920
mod usage;
2021

2122
pub use bash::{execute_bash, BashCommandInput, BashCommandOutput};
Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
//! In-memory task registry for sub-agent task lifecycle management.
2+
//!
3+
//! Provides create, get, list, stop, update, and output operations
4+
//! matching the upstream TaskCreate/TaskGet/TaskList/TaskStop/TaskUpdate/TaskOutput
5+
//! tool surface.
6+
7+
use std::collections::HashMap;
8+
use std::sync::{Arc, Mutex};
9+
use std::time::{SystemTime, UNIX_EPOCH};
10+
11+
use serde::{Deserialize, Serialize};
12+
13+
/// Current status of a managed task.
14+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15+
#[serde(rename_all = "snake_case")]
16+
pub enum TaskStatus {
17+
Created,
18+
Running,
19+
Completed,
20+
Failed,
21+
Stopped,
22+
}
23+
24+
impl std::fmt::Display for TaskStatus {
25+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26+
match self {
27+
Self::Created => write!(f, "created"),
28+
Self::Running => write!(f, "running"),
29+
Self::Completed => write!(f, "completed"),
30+
Self::Failed => write!(f, "failed"),
31+
Self::Stopped => write!(f, "stopped"),
32+
}
33+
}
34+
}
35+
36+
/// A single managed task entry.
37+
#[derive(Debug, Clone, Serialize, Deserialize)]
38+
pub struct Task {
39+
pub task_id: String,
40+
pub prompt: String,
41+
pub description: Option<String>,
42+
pub status: TaskStatus,
43+
pub created_at: u64,
44+
pub updated_at: u64,
45+
pub messages: Vec<TaskMessage>,
46+
pub output: String,
47+
pub team_id: Option<String>,
48+
}
49+
50+
/// A message exchanged with a running task.
51+
#[derive(Debug, Clone, Serialize, Deserialize)]
52+
pub struct TaskMessage {
53+
pub role: String,
54+
pub content: String,
55+
pub timestamp: u64,
56+
}
57+
58+
/// Thread-safe task registry.
59+
#[derive(Debug, Clone, Default)]
60+
pub struct TaskRegistry {
61+
inner: Arc<Mutex<RegistryInner>>,
62+
}
63+
64+
#[derive(Debug, Default)]
65+
struct RegistryInner {
66+
tasks: HashMap<String, Task>,
67+
counter: u64,
68+
}
69+
70+
fn now_secs() -> u64 {
71+
SystemTime::now()
72+
.duration_since(UNIX_EPOCH)
73+
.unwrap_or_default()
74+
.as_secs()
75+
}
76+
77+
impl TaskRegistry {
78+
/// Create a new empty registry.
79+
#[must_use]
80+
pub fn new() -> Self {
81+
Self::default()
82+
}
83+
84+
/// Create a new task and return its ID.
85+
pub fn create(&self, prompt: &str, description: Option<&str>) -> Task {
86+
let mut inner = self.inner.lock().expect("registry lock poisoned");
87+
inner.counter += 1;
88+
let ts = now_secs();
89+
let task_id = format!("task_{:08x}_{}", ts, inner.counter);
90+
let task = Task {
91+
task_id: task_id.clone(),
92+
prompt: prompt.to_owned(),
93+
description: description.map(str::to_owned),
94+
status: TaskStatus::Created,
95+
created_at: ts,
96+
updated_at: ts,
97+
messages: Vec::new(),
98+
output: String::new(),
99+
team_id: None,
100+
};
101+
inner.tasks.insert(task_id, task.clone());
102+
task
103+
}
104+
105+
/// Look up a task by ID.
106+
pub fn get(&self, task_id: &str) -> Option<Task> {
107+
let inner = self.inner.lock().expect("registry lock poisoned");
108+
inner.tasks.get(task_id).cloned()
109+
}
110+
111+
/// List all tasks, optionally filtered by status.
112+
pub fn list(&self, status_filter: Option<TaskStatus>) -> Vec<Task> {
113+
let inner = self.inner.lock().expect("registry lock poisoned");
114+
inner
115+
.tasks
116+
.values()
117+
.filter(|t| status_filter.map_or(true, |s| t.status == s))
118+
.cloned()
119+
.collect()
120+
}
121+
122+
/// Mark a task as stopped.
123+
pub fn stop(&self, task_id: &str) -> Result<Task, String> {
124+
let mut inner = self.inner.lock().expect("registry lock poisoned");
125+
let task = inner
126+
.tasks
127+
.get_mut(task_id)
128+
.ok_or_else(|| format!("task not found: {task_id}"))?;
129+
130+
match task.status {
131+
TaskStatus::Completed | TaskStatus::Failed | TaskStatus::Stopped => {
132+
return Err(format!(
133+
"task {task_id} is already in terminal state: {}",
134+
task.status
135+
));
136+
}
137+
_ => {}
138+
}
139+
140+
task.status = TaskStatus::Stopped;
141+
task.updated_at = now_secs();
142+
Ok(task.clone())
143+
}
144+
145+
/// Send a message to a task, updating its state.
146+
pub fn update(&self, task_id: &str, message: &str) -> Result<Task, String> {
147+
let mut inner = self.inner.lock().expect("registry lock poisoned");
148+
let task = inner
149+
.tasks
150+
.get_mut(task_id)
151+
.ok_or_else(|| format!("task not found: {task_id}"))?;
152+
153+
task.messages.push(TaskMessage {
154+
role: String::from("user"),
155+
content: message.to_owned(),
156+
timestamp: now_secs(),
157+
});
158+
task.updated_at = now_secs();
159+
Ok(task.clone())
160+
}
161+
162+
/// Get the accumulated output of a task.
163+
pub fn output(&self, task_id: &str) -> Result<String, String> {
164+
let inner = self.inner.lock().expect("registry lock poisoned");
165+
let task = inner
166+
.tasks
167+
.get(task_id)
168+
.ok_or_else(|| format!("task not found: {task_id}"))?;
169+
Ok(task.output.clone())
170+
}
171+
172+
/// Append output to a task (used by the task executor).
173+
pub fn append_output(&self, task_id: &str, output: &str) -> Result<(), String> {
174+
let mut inner = self.inner.lock().expect("registry lock poisoned");
175+
let task = inner
176+
.tasks
177+
.get_mut(task_id)
178+
.ok_or_else(|| format!("task not found: {task_id}"))?;
179+
task.output.push_str(output);
180+
task.updated_at = now_secs();
181+
Ok(())
182+
}
183+
184+
/// Transition a task to a new status.
185+
pub fn set_status(&self, task_id: &str, status: TaskStatus) -> Result<(), String> {
186+
let mut inner = self.inner.lock().expect("registry lock poisoned");
187+
let task = inner
188+
.tasks
189+
.get_mut(task_id)
190+
.ok_or_else(|| format!("task not found: {task_id}"))?;
191+
task.status = status;
192+
task.updated_at = now_secs();
193+
Ok(())
194+
}
195+
196+
/// Assign a task to a team.
197+
pub fn assign_team(&self, task_id: &str, team_id: &str) -> Result<(), String> {
198+
let mut inner = self.inner.lock().expect("registry lock poisoned");
199+
let task = inner
200+
.tasks
201+
.get_mut(task_id)
202+
.ok_or_else(|| format!("task not found: {task_id}"))?;
203+
task.team_id = Some(team_id.to_owned());
204+
task.updated_at = now_secs();
205+
Ok(())
206+
}
207+
208+
/// Remove a task from the registry.
209+
pub fn remove(&self, task_id: &str) -> Option<Task> {
210+
let mut inner = self.inner.lock().expect("registry lock poisoned");
211+
inner.tasks.remove(task_id)
212+
}
213+
214+
/// Number of tasks in the registry.
215+
#[must_use]
216+
pub fn len(&self) -> usize {
217+
let inner = self.inner.lock().expect("registry lock poisoned");
218+
inner.tasks.len()
219+
}
220+
221+
/// Whether the registry has no tasks.
222+
#[must_use]
223+
pub fn is_empty(&self) -> bool {
224+
self.len() == 0
225+
}
226+
}
227+
228+
#[cfg(test)]
229+
mod tests {
230+
use super::*;
231+
232+
#[test]
233+
fn creates_and_retrieves_tasks() {
234+
let registry = TaskRegistry::new();
235+
let task = registry.create("Do something", Some("A test task"));
236+
assert_eq!(task.status, TaskStatus::Created);
237+
assert_eq!(task.prompt, "Do something");
238+
assert_eq!(task.description.as_deref(), Some("A test task"));
239+
240+
let fetched = registry.get(&task.task_id).expect("task should exist");
241+
assert_eq!(fetched.task_id, task.task_id);
242+
}
243+
244+
#[test]
245+
fn lists_tasks_with_optional_filter() {
246+
let registry = TaskRegistry::new();
247+
registry.create("Task A", None);
248+
let task_b = registry.create("Task B", None);
249+
registry
250+
.set_status(&task_b.task_id, TaskStatus::Running)
251+
.expect("set status should succeed");
252+
253+
let all = registry.list(None);
254+
assert_eq!(all.len(), 2);
255+
256+
let running = registry.list(Some(TaskStatus::Running));
257+
assert_eq!(running.len(), 1);
258+
assert_eq!(running[0].task_id, task_b.task_id);
259+
260+
let created = registry.list(Some(TaskStatus::Created));
261+
assert_eq!(created.len(), 1);
262+
}
263+
264+
#[test]
265+
fn stops_running_task() {
266+
let registry = TaskRegistry::new();
267+
let task = registry.create("Stoppable", None);
268+
registry
269+
.set_status(&task.task_id, TaskStatus::Running)
270+
.unwrap();
271+
272+
let stopped = registry.stop(&task.task_id).expect("stop should succeed");
273+
assert_eq!(stopped.status, TaskStatus::Stopped);
274+
275+
// Stopping again should fail
276+
let result = registry.stop(&task.task_id);
277+
assert!(result.is_err());
278+
}
279+
280+
#[test]
281+
fn updates_task_with_messages() {
282+
let registry = TaskRegistry::new();
283+
let task = registry.create("Messageable", None);
284+
let updated = registry
285+
.update(&task.task_id, "Here's more context")
286+
.expect("update should succeed");
287+
assert_eq!(updated.messages.len(), 1);
288+
assert_eq!(updated.messages[0].content, "Here's more context");
289+
assert_eq!(updated.messages[0].role, "user");
290+
}
291+
292+
#[test]
293+
fn appends_and_retrieves_output() {
294+
let registry = TaskRegistry::new();
295+
let task = registry.create("Output task", None);
296+
registry
297+
.append_output(&task.task_id, "line 1\n")
298+
.expect("append should succeed");
299+
registry
300+
.append_output(&task.task_id, "line 2\n")
301+
.expect("append should succeed");
302+
303+
let output = registry.output(&task.task_id).expect("output should exist");
304+
assert_eq!(output, "line 1\nline 2\n");
305+
}
306+
307+
#[test]
308+
fn assigns_team_and_removes_task() {
309+
let registry = TaskRegistry::new();
310+
let task = registry.create("Team task", None);
311+
registry
312+
.assign_team(&task.task_id, "team_abc")
313+
.expect("assign should succeed");
314+
315+
let fetched = registry.get(&task.task_id).unwrap();
316+
assert_eq!(fetched.team_id.as_deref(), Some("team_abc"));
317+
318+
let removed = registry.remove(&task.task_id);
319+
assert!(removed.is_some());
320+
assert!(registry.get(&task.task_id).is_none());
321+
assert!(registry.is_empty());
322+
}
323+
324+
#[test]
325+
fn rejects_operations_on_missing_task() {
326+
let registry = TaskRegistry::new();
327+
assert!(registry.stop("nonexistent").is_err());
328+
assert!(registry.update("nonexistent", "msg").is_err());
329+
assert!(registry.output("nonexistent").is_err());
330+
assert!(registry.append_output("nonexistent", "data").is_err());
331+
assert!(registry
332+
.set_status("nonexistent", TaskStatus::Running)
333+
.is_err());
334+
}
335+
}

0 commit comments

Comments
 (0)