Auto set task as subtask based on Git branch (vibe-kanban) (#1468)
* All implementation steps are complete. Here's a summary of what was implemented: Implemented auto-linking of subtasks when creating tasks with 6 changes: 1. **`crates/db/src/models/task_attempt.rs`** - Added `find_by_branch()` query to look up task attempts by branch name within a project 2. **`crates/db/src/models/task.rs`** - Added: - `Clone` derive to `CreateTask` struct - `with_parent_from_branch()` helper method on `CreateTask` for fluent branch-based parent resolution - `update_parent_task_attempt()` method on `Task` to update parent linkage for existing tasks 3. **`crates/server/src/routes/tasks.rs`** - Updated `create_task_and_start()` to use `with_parent_from_branch()` helper before creating the task 4. **`crates/server/src/routes/task_attempts.rs`** - Updated `create_task_attempt()` to link existing tasks to parent attempts when `base_branch` matches an existing attempt's branch 5. **`crates/server/src/mcp/task_server.rs`** - Updated MCP `create_task` to automatically set `parent_task_attempt` from the current context's `attempt_id` 6. **`crates/db/.sqlx/`** - Updated SQLx query cache with the new queries * Cleanup script changes for task attempt 3bcb8f0d-6655-4b34-9cd2-c3c731795b78 * All implementation steps are complete. Here's a summary of what was implemented: ## Summary Auto-linking of subtasks when creating tasks is now implemented for all three scenarios: 1. **MCP create_task** (`task_server.rs:404-432`): When an AI agent creates a task via MCP while working in a worktree, the new task automatically becomes a subtask of the current attempt (uses `McpContext.attempt_id`) 2. **Branch-based targeting**: - `create_task_and_start` (`tasks.rs`): Uses `with_parent_from_branch` helper to auto-link when `base_branch` matches an existing attempt - `create_task_attempt` (`task_attempts.rs`): Updates existing task's parent when creating an attempt with a matching `base_branch` 3. **Rebase** (`task_attempts.rs:1249-1257`): When rebasing a task attempt onto another attempt's branch, the task's `parent_task_attempt` is updated to point to that attempt ### Files modified: - `crates/db/src/models/task_attempt.rs` - Added `find_by_branch` query - `crates/db/src/models/task.rs` - Added `Clone` derive, `with_parent_from_branch` helper, `update_parent_task_attempt` method - `crates/server/src/routes/tasks.rs` - Updated `create_task_and_start` - `crates/server/src/routes/task_attempts.rs` - Updated `create_task_attempt` and `rebase_task_attempt` - `crates/server/src/mcp/task_server.rs` - Updated MCP `create_task` * Cleanup script changes for task attempt 3bcb8f0d-6655-4b34-9cd2-c3c731795b78 * All 8 steps are now complete. Here's a summary of the implementation: ## Summary Added auto-linking of subtasks in the `change_target_branch` endpoint (`task_attempts.rs:1047-1055`). When a user changes a task attempt's target branch to match another attempt's branch, the task automatically becomes a subtask of that attempt. The full implementation now covers all 4 scenarios: 1. **MCP create_task** - Links via `McpContext.attempt_id` 2. **Branch-based targeting** - Links in `create_task_and_start` and `create_task_attempt` via `with_parent_from_branch` 3. **Rebase** - Links in `rebase_task_attempt` after successful git rebase 4. **Change target branch** - Links in `change_target_branch` after updating target_branch (newly added) * Done. Removed `from_title_description` helper from `task.rs` and updated `task_server.rs` to construct `CreateTask` directly with all fields set correctly. All checks pass.
This commit is contained in:
committed by
GitHub
parent
aee6ac35b8
commit
d8eeab628c
74
crates/db/.sqlx/query-3893e8aa7f30ac45d817eb19bbea6c4afc9ab8ad001039593350b658ea970f3f.json
generated
Normal file
74
crates/db/.sqlx/query-3893e8aa7f30ac45d817eb19bbea6c4afc9ab8ad001039593350b658ea970f3f.json
generated
Normal file
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT ta.id as \"id!: Uuid\", ta.task_id as \"task_id!: Uuid\",\n ta.container_ref, ta.branch, ta.target_branch,\n ta.executor as \"executor!\", ta.worktree_deleted as \"worktree_deleted!: bool\",\n ta.setup_completed_at as \"setup_completed_at: DateTime<Utc>\",\n ta.created_at as \"created_at!: DateTime<Utc>\",\n ta.updated_at as \"updated_at!: DateTime<Utc>\"\n FROM task_attempts ta\n JOIN tasks t ON ta.task_id = t.id\n WHERE t.project_id = ? AND ta.branch = ?\n ORDER BY ta.created_at DESC\n LIMIT 1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id!: Uuid",
|
||||
"ordinal": 0,
|
||||
"type_info": "Blob"
|
||||
},
|
||||
{
|
||||
"name": "task_id!: Uuid",
|
||||
"ordinal": 1,
|
||||
"type_info": "Blob"
|
||||
},
|
||||
{
|
||||
"name": "container_ref",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "branch",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "target_branch",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "executor!",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "worktree_deleted!: bool",
|
||||
"ordinal": 6,
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"name": "setup_completed_at: DateTime<Utc>",
|
||||
"ordinal": 7,
|
||||
"type_info": "Datetime"
|
||||
},
|
||||
{
|
||||
"name": "created_at!: DateTime<Utc>",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "updated_at!: DateTime<Utc>",
|
||||
"ordinal": 9,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "3893e8aa7f30ac45d817eb19bbea6c4afc9ab8ad001039593350b658ea970f3f"
|
||||
}
|
||||
12
crates/db/.sqlx/query-8156a3515451357855aa31bdc6c076cfa8c00e33b6e70defd94aab04c5a42758.json
generated
Normal file
12
crates/db/.sqlx/query-8156a3515451357855aa31bdc6c076cfa8c00e33b6e70defd94aab04c5a42758.json
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE tasks SET parent_task_attempt = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "8156a3515451357855aa31bdc6c076cfa8c00e33b6e70defd94aab04c5a42758"
|
||||
}
|
||||
@@ -66,7 +66,7 @@ pub struct TaskRelationships {
|
||||
pub children: Vec<Task>, // Tasks created by this attempt
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, TS)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
pub struct CreateTask {
|
||||
pub project_id: Uuid,
|
||||
pub title: String,
|
||||
@@ -78,22 +78,6 @@ pub struct CreateTask {
|
||||
}
|
||||
|
||||
impl CreateTask {
|
||||
pub fn from_title_description(
|
||||
project_id: Uuid,
|
||||
title: String,
|
||||
description: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
project_id,
|
||||
title,
|
||||
description,
|
||||
status: Some(TaskStatus::Todo),
|
||||
parent_task_attempt: None,
|
||||
image_ids: None,
|
||||
shared_task_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_shared_task(
|
||||
project_id: Uuid,
|
||||
title: String,
|
||||
@@ -111,6 +95,23 @@ impl CreateTask {
|
||||
shared_task_id: Some(shared_task_id),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves parent_task_attempt from base_branch if not already set.
|
||||
/// If base_branch matches an existing task attempt's branch, sets that as parent.
|
||||
pub async fn with_parent_from_branch(
|
||||
mut self,
|
||||
pool: &SqlitePool,
|
||||
base_branch: &str,
|
||||
) -> Result<Self, sqlx::Error> {
|
||||
// Only resolve if parent not already set (explicit parent takes precedence)
|
||||
if self.parent_task_attempt.is_none()
|
||||
&& let Some(parent) =
|
||||
TaskAttempt::find_by_branch(pool, self.project_id, base_branch).await?
|
||||
{
|
||||
self.parent_task_attempt = Some(parent.id);
|
||||
}
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, TS)]
|
||||
@@ -348,6 +349,22 @@ ORDER BY t.created_at DESC"#,
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update the parent_task_attempt field for a task
|
||||
pub async fn update_parent_task_attempt(
|
||||
pool: &SqlitePool,
|
||||
task_id: Uuid,
|
||||
parent_task_attempt: Option<Uuid>,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query!(
|
||||
"UPDATE tasks SET parent_task_attempt = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1",
|
||||
task_id,
|
||||
parent_task_attempt
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Nullify parent_task_attempt for all tasks that reference the given attempt ID
|
||||
/// This breaks parent-child relationships before deleting a parent task
|
||||
pub async fn nullify_children_by_attempt_id<'e, E>(
|
||||
|
||||
@@ -444,6 +444,33 @@ impl TaskAttempt {
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
/// Find a task attempt by its branch name within a project.
|
||||
/// Returns the most recently created attempt if multiple exist with the same branch.
|
||||
pub async fn find_by_branch(
|
||||
pool: &SqlitePool,
|
||||
project_id: Uuid,
|
||||
branch: &str,
|
||||
) -> Result<Option<TaskAttempt>, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
TaskAttempt,
|
||||
r#"SELECT ta.id as "id!: Uuid", ta.task_id as "task_id!: Uuid",
|
||||
ta.container_ref, ta.branch, ta.target_branch,
|
||||
ta.executor as "executor!", ta.worktree_deleted as "worktree_deleted!: bool",
|
||||
ta.setup_completed_at as "setup_completed_at: DateTime<Utc>",
|
||||
ta.created_at as "created_at!: DateTime<Utc>",
|
||||
ta.updated_at as "updated_at!: DateTime<Utc>"
|
||||
FROM task_attempts ta
|
||||
JOIN tasks t ON ta.task_id = t.id
|
||||
WHERE t.project_id = ? AND ta.branch = ?
|
||||
ORDER BY ta.created_at DESC
|
||||
LIMIT 1"#,
|
||||
project_id,
|
||||
branch
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn resolve_container_ref(
|
||||
pool: &SqlitePool,
|
||||
container_ref: &str,
|
||||
|
||||
@@ -470,16 +470,22 @@ impl TaskServer {
|
||||
};
|
||||
|
||||
let url = self.url("/api/tasks");
|
||||
|
||||
// Get parent_task_attempt from context if available (auto-link subtasks)
|
||||
let parent_task_attempt = self.context.as_ref().map(|ctx| ctx.attempt_id);
|
||||
|
||||
let create_payload = CreateTask {
|
||||
project_id,
|
||||
title,
|
||||
description: expanded_description,
|
||||
status: Some(TaskStatus::Todo),
|
||||
parent_task_attempt,
|
||||
image_ids: None,
|
||||
shared_task_id: None,
|
||||
};
|
||||
|
||||
let task: Task = match self
|
||||
.send_json(
|
||||
self.client
|
||||
.post(&url)
|
||||
.json(&CreateTask::from_title_description(
|
||||
project_id,
|
||||
title,
|
||||
expanded_description,
|
||||
)),
|
||||
)
|
||||
.send_json(self.client.post(&url).json(&create_payload))
|
||||
.await
|
||||
{
|
||||
Ok(t) => t,
|
||||
|
||||
@@ -130,11 +130,20 @@ pub async fn create_task_attempt(
|
||||
State(deployment): State<DeploymentImpl>,
|
||||
Json(payload): Json<CreateTaskAttemptBody>,
|
||||
) -> Result<ResponseJson<ApiResponse<TaskAttempt>>, ApiError> {
|
||||
let pool = &deployment.db().pool;
|
||||
let executor_profile_id = payload.get_executor_profile_id();
|
||||
let task = Task::find_by_id(&deployment.db().pool, payload.task_id)
|
||||
let task = Task::find_by_id(pool, payload.task_id)
|
||||
.await?
|
||||
.ok_or(SqlxError::RowNotFound)?;
|
||||
|
||||
// Link task to parent attempt if base_branch matches an existing attempt's branch
|
||||
if task.parent_task_attempt.is_none()
|
||||
&& let Some(parent) =
|
||||
TaskAttempt::find_by_branch(pool, task.project_id, &payload.base_branch).await?
|
||||
{
|
||||
Task::update_parent_task_attempt(pool, task.id, Some(parent.id)).await?;
|
||||
}
|
||||
|
||||
let attempt_id = Uuid::new_v4();
|
||||
let git_branch_name = deployment
|
||||
.container()
|
||||
@@ -142,7 +151,7 @@ pub async fn create_task_attempt(
|
||||
.await;
|
||||
|
||||
let task_attempt = TaskAttempt::create(
|
||||
&deployment.db().pool,
|
||||
pool,
|
||||
&CreateTaskAttempt {
|
||||
executor: executor_profile_id.executor,
|
||||
base_branch: payload.base_branch.clone(),
|
||||
@@ -1018,17 +1027,13 @@ pub async fn change_target_branch(
|
||||
let project = Project::find_by_id(&deployment.db().pool, task.project_id)
|
||||
.await?
|
||||
.ok_or(ApiError::Project(ProjectError::ProjectNotFound))?;
|
||||
let pool = &deployment.db().pool;
|
||||
match deployment
|
||||
.git()
|
||||
.check_branch_exists(&project.git_repo_path, &new_target_branch)?
|
||||
{
|
||||
true => {
|
||||
TaskAttempt::update_target_branch(
|
||||
&deployment.db().pool,
|
||||
task_attempt.id,
|
||||
&new_target_branch,
|
||||
)
|
||||
.await?;
|
||||
TaskAttempt::update_target_branch(pool, task_attempt.id, &new_target_branch).await?;
|
||||
}
|
||||
false => {
|
||||
return Ok(ResponseJson(ApiResponse::error(
|
||||
@@ -1040,6 +1045,17 @@ pub async fn change_target_branch(
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Link task to parent attempt if new_target_branch matches an existing attempt's branch
|
||||
if let Some(parent_attempt) =
|
||||
TaskAttempt::find_by_branch(pool, project.id, &new_target_branch).await?
|
||||
{
|
||||
// Only update if different from current parent
|
||||
if task.parent_task_attempt != Some(parent_attempt.id) {
|
||||
Task::update_parent_task_attempt(pool, task.id, Some(parent_attempt.id)).await?;
|
||||
}
|
||||
}
|
||||
|
||||
let status = deployment.git().get_branch_status(
|
||||
&project.git_repo_path,
|
||||
&task_attempt.branch,
|
||||
@@ -1239,6 +1255,16 @@ pub async fn rebase_task_attempt(
|
||||
};
|
||||
}
|
||||
|
||||
// Link task to parent attempt if new_base_branch matches an existing attempt's branch
|
||||
if let Some(parent_attempt) =
|
||||
TaskAttempt::find_by_branch(pool, ctx.project.id, &new_base_branch).await?
|
||||
{
|
||||
// Only update if different from current parent
|
||||
if task.parent_task_attempt != Some(parent_attempt.id) {
|
||||
Task::update_parent_task_attempt(pool, task.id, Some(parent_attempt.id)).await?;
|
||||
}
|
||||
}
|
||||
|
||||
deployment
|
||||
.track_if_analytics_allowed(
|
||||
"task_attempt_rebased",
|
||||
|
||||
@@ -147,11 +147,20 @@ pub async fn create_task_and_start(
|
||||
State(deployment): State<DeploymentImpl>,
|
||||
Json(payload): Json<CreateAndStartTaskRequest>,
|
||||
) -> Result<ResponseJson<ApiResponse<TaskWithAttemptStatus>>, ApiError> {
|
||||
let task_id = Uuid::new_v4();
|
||||
let task = Task::create(&deployment.db().pool, &payload.task, task_id).await?;
|
||||
let pool = &deployment.db().pool;
|
||||
|
||||
if let Some(image_ids) = &payload.task.image_ids {
|
||||
TaskImage::associate_many_dedup(&deployment.db().pool, task.id, image_ids).await?;
|
||||
// Resolve parent from branch if applicable
|
||||
let task_payload = payload
|
||||
.task
|
||||
.clone()
|
||||
.with_parent_from_branch(pool, &payload.base_branch)
|
||||
.await?;
|
||||
|
||||
let task_id = Uuid::new_v4();
|
||||
let task = Task::create(pool, &task_payload, task_id).await?;
|
||||
|
||||
if let Some(image_ids) = &task_payload.image_ids {
|
||||
TaskImage::associate_many_dedup(pool, task.id, image_ids).await?;
|
||||
}
|
||||
|
||||
deployment
|
||||
@@ -161,7 +170,7 @@ pub async fn create_task_and_start(
|
||||
"task_id": task.id.to_string(),
|
||||
"project_id": task.project_id,
|
||||
"has_description": task.description.is_some(),
|
||||
"has_images": payload.task.image_ids.is_some(),
|
||||
"has_images": task_payload.image_ids.is_some(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
@@ -172,7 +181,7 @@ pub async fn create_task_and_start(
|
||||
.await;
|
||||
|
||||
let task_attempt = TaskAttempt::create(
|
||||
&deployment.db().pool,
|
||||
pool,
|
||||
&CreateTaskAttempt {
|
||||
executor: payload.executor_profile_id.executor,
|
||||
base_branch: payload.base_branch,
|
||||
@@ -200,7 +209,7 @@ pub async fn create_task_and_start(
|
||||
)
|
||||
.await;
|
||||
|
||||
let task = Task::find_by_id(&deployment.db().pool, task.id)
|
||||
let task = Task::find_by_id(pool, task.id)
|
||||
.await?
|
||||
.ok_or(ApiError::Database(SqlxError::RowNotFound))?;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user