Skip to content

Commit b753190

Browse files
committed
feat(tasks): implement task management system with CRUD operations
- Added a new SQL migration to create the `tasks` table with relevant fields and indexes. - Introduced task management tools for creating, listing, and updating tasks within the system. - Enhanced the API to support task operations, including endpoints for task creation, retrieval, updating, and deletion. - Updated documentation to include new task tools and their usage. - Integrated task management into the agent's workflow, allowing for better task tracking and execution.
1 parent 083398a commit b753190

27 files changed

Lines changed: 1691 additions & 20 deletions
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
CREATE TABLE IF NOT EXISTS tasks (
2+
id TEXT PRIMARY KEY,
3+
agent_id TEXT NOT NULL,
4+
task_number INTEGER NOT NULL,
5+
title TEXT NOT NULL,
6+
description TEXT,
7+
status TEXT NOT NULL DEFAULT 'backlog',
8+
priority TEXT NOT NULL DEFAULT 'medium',
9+
subtasks TEXT,
10+
metadata TEXT,
11+
source_memory_id TEXT,
12+
worker_id TEXT,
13+
created_by TEXT NOT NULL,
14+
approved_at TIMESTAMP,
15+
approved_by TEXT,
16+
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
17+
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
18+
completed_at TIMESTAMP,
19+
UNIQUE(agent_id, task_number)
20+
);
21+
22+
CREATE INDEX IF NOT EXISTS idx_tasks_agent ON tasks(agent_id);
23+
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
24+
CREATE INDEX IF NOT EXISTS idx_tasks_number ON tasks(agent_id, task_number);
25+
CREATE INDEX IF NOT EXISTS idx_tasks_source_memory ON tasks(source_memory_id);
26+
CREATE INDEX IF NOT EXISTS idx_tasks_worker ON tasks(worker_id);

prompts/en/branch.md.j2

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ Depending on why the channel branched, you might:
2222
- **Process complex input** — The user said something that requires analysis. Break it down, think through it, return your understanding.
2323
- **Spawn a worker** — If the user wants something done *now*, spawn a worker for it. Set a status so the channel knows what's happening. Return a summary of what you kicked off.
2424
- **Save for later** — If the user mentions something they want to do but not right now ("I need to update the tests at some point", "remind me to check the deploy tomorrow"), save it as a **todo** memory instead of spawning a worker. The difference is timing intent: immediate action = worker, future action = todo.
25+
- **Manage the task board** — For task commands (approve, list, pick up, update progress), use task tools from this branch. Channels do not manage task state directly.
2526

2627
## Tools
2728

@@ -37,6 +38,15 @@ Forget a memory by ID. Use this when the user wants something removed, or when y
3738
### spawn_worker
3839
If the user wants something done now and it needs execution tools (shell, file, exec), spawn a worker. Give it a specific task description with enough context to work independently. The worker won't have the conversation history — it only knows what you tell it. If the user is describing something for later rather than requesting immediate action, save a **todo** memory instead.
3940

41+
### task_list
42+
List tasks on the board (optionally by status/priority) before making state changes when you need to verify references.
43+
44+
### task_create
45+
Create structured tasks when the user asks to add planned work to the board.
46+
47+
### task_update
48+
Update task status, priority, subtasks, metadata, or assigned worker. For "pick up #N", move `ready -> in_progress`, spawn a worker, then bind the worker ID to the task.
49+
4050
## Rules
4151

4252
1. Be concise. The channel is going to read your conclusion and use it in a conversation. Don't write an essay. Return the signal, not the process.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
Fork a branch to think independently. The branch gets a clone of your current conversation history and has access to memory_recall, memory_save, and memory_delete tools. It runs independently and returns a conclusion.
1+
Fork a branch to think independently. The branch gets a clone of your current conversation history and can use memory tools, task tools (task_create/task_list/task_update), and spawn_worker for execution handoff. It runs independently and returns a conclusion.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Create a structured task on the board. Use this when a user asks to add work, capture explicit backlog items, or formalize something actionable. Prefer short titles and concrete subtasks.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
List tasks from the board, optionally filtered by status or priority. Use this before task updates when you need to verify a task number or current state.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Update an existing task by task number. You can change status, priority, title, description, subtasks, or metadata. For worker processes, only update the task assigned to that worker.

src/agent/channel.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1069,6 +1069,9 @@ async fn spawn_branch(
10691069
};
10701070

10711071
let tool_server = crate::tools::create_branch_tool_server(
1072+
Some(state.clone()),
1073+
state.deps.agent_id.clone(),
1074+
state.deps.task_store.clone(),
10721075
state.deps.memory_search.clone(),
10731076
state.conversation_logger.clone(),
10741077
state.channel_store.clone(),

src/agent/cortex.rs

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ use crate::error::Result;
1313
use crate::llm::SpacebotModel;
1414
use crate::memory::search::{SearchConfig, SearchMode, SearchSort};
1515
use crate::memory::types::{Association, MemoryType, RelationType};
16+
use crate::tasks::{TaskStatus, UpdateTaskInput};
1617
use crate::{AgentDeps, ProcessEvent, ProcessType};
18+
use crate::agent::worker::Worker;
1719
use crate::hooks::CortexHook;
1820

1921
use rig::agent::AgentBuilder;
@@ -721,6 +723,181 @@ pub fn spawn_association_loop(deps: AgentDeps, logger: CortexLogger) -> tokio::t
721723
})
722724
}
723725

726+
/// Spawn a background loop that picks up ready tasks when idle.
727+
pub fn spawn_ready_task_loop(deps: AgentDeps, logger: CortexLogger) -> tokio::task::JoinHandle<()> {
728+
tokio::spawn(async move {
729+
if let Err(error) = run_ready_task_loop(&deps, &logger).await {
730+
tracing::error!(%error, "cortex ready-task loop exited with error");
731+
}
732+
})
733+
}
734+
735+
async fn run_ready_task_loop(deps: &AgentDeps, logger: &CortexLogger) -> anyhow::Result<()> {
736+
tracing::info!("cortex ready-task loop started");
737+
738+
// Let startup settle before first pickup attempt.
739+
tokio::time::sleep(Duration::from_secs(10)).await;
740+
741+
loop {
742+
let interval = (**deps.runtime_config.cortex.load()).tick_interval_secs;
743+
tokio::time::sleep(Duration::from_secs(interval.max(5))).await;
744+
745+
if let Err(error) = pickup_one_ready_task(deps, logger).await {
746+
tracing::warn!(%error, "ready-task pickup pass failed");
747+
}
748+
}
749+
}
750+
751+
async fn pickup_one_ready_task(deps: &AgentDeps, logger: &CortexLogger) -> anyhow::Result<()> {
752+
let Some(task) = deps.task_store.claim_next_ready(&deps.agent_id).await? else {
753+
return Ok(());
754+
};
755+
756+
logger.log(
757+
"task_pickup_started",
758+
&format!("Picked up ready task #{}", task.task_number),
759+
Some(serde_json::json!({
760+
"task_number": task.task_number,
761+
"title": task.title,
762+
})),
763+
);
764+
765+
let prompt_engine = deps.runtime_config.prompts.load();
766+
let worker_system_prompt = prompt_engine
767+
.render_worker_prompt(
768+
&deps.runtime_config.instance_dir.display().to_string(),
769+
&deps.runtime_config.workspace_dir.display().to_string(),
770+
)
771+
.expect("failed to render worker prompt");
772+
773+
let mut task_prompt = format!("Execute task #{}: {}", task.task_number, task.title);
774+
if let Some(description) = &task.description {
775+
task_prompt.push_str("\n\nDescription:\n");
776+
task_prompt.push_str(description);
777+
}
778+
if !task.subtasks.is_empty() {
779+
task_prompt.push_str("\n\nSubtasks:\n");
780+
for (index, subtask) in task.subtasks.iter().enumerate() {
781+
let marker = if subtask.completed { "[x]" } else { "[ ]" };
782+
task_prompt.push_str(&format!("{}. {} {}\n", index + 1, marker, subtask.title));
783+
}
784+
}
785+
786+
let screenshot_dir = deps.runtime_config.workspace_dir.join(".spacebot").join("screenshots");
787+
let logs_dir = deps.runtime_config.workspace_dir.join(".spacebot").join("logs");
788+
let _ = std::fs::create_dir_all(&screenshot_dir);
789+
let _ = std::fs::create_dir_all(&logs_dir);
790+
791+
let browser_config = (**deps.runtime_config.browser_config.load()).clone();
792+
let brave_search_key = (**deps.runtime_config.brave_search_key.load()).clone();
793+
let worker = Worker::new(
794+
None,
795+
task_prompt,
796+
worker_system_prompt,
797+
deps.clone(),
798+
browser_config,
799+
screenshot_dir,
800+
brave_search_key,
801+
logs_dir,
802+
);
803+
804+
let worker_id = worker.id;
805+
deps.task_store
806+
.update(
807+
&deps.agent_id,
808+
task.task_number,
809+
UpdateTaskInput {
810+
worker_id: Some(worker_id.to_string()),
811+
..Default::default()
812+
},
813+
)
814+
.await?;
815+
816+
let _ = deps.event_tx.send(ProcessEvent::WorkerStarted {
817+
agent_id: deps.agent_id.clone(),
818+
worker_id,
819+
channel_id: None,
820+
task: format!("task #{}: {}", task.task_number, task.title),
821+
});
822+
823+
let task_store = deps.task_store.clone();
824+
let agent_id = deps.agent_id.to_string();
825+
let event_tx = deps.event_tx.clone();
826+
let logger = logger.clone();
827+
tokio::spawn(async move {
828+
match worker.run().await {
829+
Ok(result_text) => {
830+
if let Err(error) = task_store
831+
.update(
832+
&agent_id,
833+
task.task_number,
834+
UpdateTaskInput {
835+
status: Some(TaskStatus::Done),
836+
..Default::default()
837+
},
838+
)
839+
.await
840+
{
841+
tracing::warn!(%error, task_number = task.task_number, "failed to mark picked-up task done");
842+
}
843+
844+
logger.log(
845+
"task_pickup_completed",
846+
&format!("Completed picked-up task #{}", task.task_number),
847+
Some(serde_json::json!({
848+
"task_number": task.task_number,
849+
"worker_id": worker_id.to_string(),
850+
})),
851+
);
852+
853+
let _ = event_tx.send(ProcessEvent::WorkerComplete {
854+
agent_id: Arc::from(agent_id.as_str()),
855+
worker_id,
856+
channel_id: None,
857+
result: result_text,
858+
notify: true,
859+
});
860+
}
861+
Err(error) => {
862+
if let Err(update_error) = task_store
863+
.update(
864+
&agent_id,
865+
task.task_number,
866+
UpdateTaskInput {
867+
status: Some(TaskStatus::Ready),
868+
clear_worker_id: true,
869+
..Default::default()
870+
},
871+
)
872+
.await
873+
{
874+
tracing::warn!(%update_error, task_number = task.task_number, "failed to return task to ready after failure");
875+
}
876+
877+
logger.log(
878+
"task_pickup_failed",
879+
&format!("Picked-up task #{} failed: {error}", task.task_number),
880+
Some(serde_json::json!({
881+
"task_number": task.task_number,
882+
"worker_id": worker_id.to_string(),
883+
"error": error.to_string(),
884+
})),
885+
);
886+
887+
let _ = event_tx.send(ProcessEvent::WorkerComplete {
888+
agent_id: Arc::from(agent_id.as_str()),
889+
worker_id,
890+
channel_id: None,
891+
result: format!("Worker failed: {error}"),
892+
notify: true,
893+
});
894+
}
895+
}
896+
});
897+
898+
Ok(())
899+
}
900+
724901
async fn run_association_loop(deps: &AgentDeps, logger: &CortexLogger) -> anyhow::Result<()> {
725902
tracing::info!("cortex association loop started");
726903

src/agent/ingestion.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,9 @@ async fn process_chunk(
473473
crate::conversation::history::ConversationLogger::new(deps.sqlite_pool.clone());
474474
let channel_store = crate::conversation::ChannelStore::new(deps.sqlite_pool.clone());
475475
let tool_server: ToolServerHandle = crate::tools::create_branch_tool_server(
476+
None,
477+
deps.agent_id.clone(),
478+
deps.task_store.clone(),
476479
deps.memory_search.clone(),
477480
conversation_logger,
478481
channel_store,

src/agent/worker.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ impl Worker {
174174
self.deps.agent_id.clone(),
175175
self.id,
176176
self.channel_id.clone(),
177+
self.deps.task_store.clone(),
177178
self.deps.event_tx.clone(),
178179
self.browser_config.clone(),
179180
self.screenshot_dir.clone(),

0 commit comments

Comments
 (0)