feat: expose selector based merge request threads

This commit is contained in:
2026-08-17 07:09:20 +09:00
parent aeb2727bea
commit 9e48cae759
10 changed files with 547 additions and 452 deletions
+21 -22
View File
@@ -28,7 +28,7 @@ use crate::internal_worker::{
use crate::prompt::catalog::PromptCatalog;
use crate::spawn::registry::SpawnedWorkerRegistry;
use crate::worker::{
ReviewerAttemptContext, ReviewerChildWorkspaceClient, Worker, WorkerFilesystemAuthority,
ReviewerChildWorkspaceClient, ReviewerContext, Worker, WorkerFilesystemAuthority,
WorkspaceRequest, WorkspaceRequestMethod,
};
use protocol::Method;
@@ -58,8 +58,8 @@ struct SubWorkerSpawnInput {
/// spawner's explicit delegation authority; direct tool scope alone is not
/// sufficient. Omit `recursive` for normal workspace/worktree delegation; it defaults to true.
scope: Vec<ScopeRuleInput>,
/// Binds an actual read-only builtin Reviewer child to an immutable Merge Request revision.
/// Review attempt identity and capability material are generated by the trusted spawn layer.
/// Binds an actual read-only builtin Reviewer child to the current Merge Request candidate.
/// Review capability material is generated by the trusted spawn layer.
#[serde(default)]
review: Option<ReviewerHandoffInput>,
}
@@ -67,7 +67,7 @@ struct SubWorkerSpawnInput {
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct ReviewerHandoffInput {
ticket_id: String,
revision_id: String,
expected_head_commit: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
@@ -337,9 +337,9 @@ fn validate_reviewer_handoff(input: &SubWorkerSpawnInput) -> Result<(), ToolErro
let Some(review) = &input.review else {
return Ok(());
};
if review.ticket_id.trim().is_empty() || review.revision_id.trim().is_empty() {
if review.ticket_id.trim().is_empty() || review.expected_head_commit.trim().is_empty() {
return Err(ToolError::InvalidArgument(
"reviewer handoff requires non-empty ticket_id and revision_id".to_string(),
"reviewer handoff requires non-empty ticket_id and expected_head_commit".to_string(),
));
}
if input.profile.as_deref() != Some("builtin:reviewer") {
@@ -418,11 +418,10 @@ impl Tool for SubWorkerSpawnTool {
.map_err(|error| {
ToolError::ExecutionFailed(format!("resolve child manifest: {error}"))
})?;
let reviewer_attempt = input.review.as_ref().map(|review| {
let reviewer_capability = input.review.as_ref().map(|review| {
(
review.ticket_id.clone(),
review.revision_id.clone(),
uuid::Uuid::now_v7().to_string(),
review.expected_head_commit.clone(),
format!(
"{}{}",
uuid::Uuid::now_v7().simple(),
@@ -431,7 +430,8 @@ impl Tool for SubWorkerSpawnTool {
)
});
let child_workspace_context =
if let Some((ticket_id, revision_id, _, capability_token)) = &reviewer_attempt {
if let Some((ticket_id, expected_head_commit, capability_token)) = &reviewer_capability
{
let workspace_id =
self.workspace_context
.workspace_id()
@@ -450,9 +450,9 @@ impl Tool for SubWorkerSpawnTool {
let child_client: Arc<dyn crate::worker::WorkspaceClient> =
Arc::new(ReviewerChildWorkspaceClient::new(
parent_client.clone(),
ReviewerAttemptContext {
ReviewerContext {
ticket_id: ticket_id.clone(),
revision_id: revision_id.clone(),
expected_head_commit: expected_head_commit.clone(),
},
capability_token.clone(),
));
@@ -547,9 +547,9 @@ impl Tool for SubWorkerSpawnTool {
}
};
if let Some((ticket_id, revision_id, attempt_id, capability_token)) = &reviewer_attempt {
if let Some((ticket_id, expected_head_commit, capability_token)) = &reviewer_capability {
let workspace_id = self.workspace_context.workspace_id().ok_or_else(|| {
ToolError::ExecutionFailed("reviewer attempt lost Workspace identity".to_string())
ToolError::ExecutionFailed("review capability lost Workspace identity".to_string())
})?;
let child_session_id = session.session_id_string();
let child_registration = WorkspaceRequest::json(
@@ -577,15 +577,14 @@ impl Tool for SubWorkerSpawnTool {
)));
}
let body = serde_json::json!({
"attempt_id": attempt_id,
"revision_id": revision_id,
"expected_head_commit": expected_head_commit,
"child_session_id": child_session_id,
"capability_token": capability_token,
});
let request = WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
format!(
"/api/w/{}/tickets/{}/merge-request/review-attempts",
"/api/w/{}/tickets/{}/merge-request/review-capabilities",
workspace_id.as_str(),
ticket_id
),
@@ -596,12 +595,12 @@ impl Tool for SubWorkerSpawnTool {
.client()
.execute(request)
.map_err(|error| {
ToolError::ExecutionFailed(format!("register reviewer attempt: {error}"))
ToolError::ExecutionFailed(format!("register review capability: {error}"))
})?;
if !response.is_success() {
let _ = session.stop().await;
return Err(ToolError::ExecutionFailed(format!(
"register reviewer attempt failed with status {}: {}",
"register review capability failed with status {}: {}",
response.status, response.body
)));
}
@@ -1044,21 +1043,21 @@ mod tests {
let valid: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({
"name":"reviewer","task":"review","profile":"builtin:reviewer",
"scope":[{"target":"/tmp/work","permission":"read"}],
"review":{"ticket_id":"T1","revision_id":"V1"}
"review":{"ticket_id":"T1","expected_head_commit":"V1"}
}))
.unwrap();
assert!(validate_reviewer_handoff(&valid).is_ok());
let wrong_profile: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({
"name":"reviewer","task":"review","profile":"builtin:coder",
"scope":[{"target":"/tmp/work","permission":"read"}],
"review":{"ticket_id":"T1","revision_id":"V1"}
"review":{"ticket_id":"T1","expected_head_commit":"V1"}
}))
.unwrap();
assert!(validate_reviewer_handoff(&wrong_profile).is_err());
let writable: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({
"name":"reviewer","task":"review","profile":"builtin:reviewer",
"scope":[{"target":"/tmp/work","permission":"write"}],
"review":{"ticket_id":"T1","revision_id":"V1"}
"review":{"ticket_id":"T1","expected_head_commit":"V1"}
}))
.unwrap();
assert!(validate_reviewer_handoff(&writable).is_err());