Skip to content

Commit 1196972

Browse files
[codex] fix mission ownership convergence and routing (#88)
* fix(mission): reconcile redirected work ownership * fix(mission): harden work-item routing * fix(mission): address bugbot regressions * fix(mission): prefer reviewer lanes for review work
1 parent e3ee603 commit 1196972

5 files changed

Lines changed: 1117 additions & 29 deletions

File tree

src/mission/compiler.rs

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -219,16 +219,16 @@ impl WorkGraphCompiler {
219219

220220
/// Infer owner role from issue labels or content.
221221
#[must_use]
222-
pub fn infer_owner_role(&self, title: &str, body: &str) -> Option<String> {
222+
pub fn infer_owner_role(&self, title: &str, body: &str, kind: WorkKind) -> Option<String> {
223223
let text = format!("{} {}", title, body).to_lowercase();
224224

225225
if text.contains("backend") || text.contains("api") || text.contains("server") {
226226
Some("backend".to_string())
227227
} else if text.contains("frontend") || text.contains("ui") || text.contains("web") {
228228
Some("frontend".to_string())
229-
} else if text.contains("test") || text.contains("qa") {
229+
} else if matches!(kind, WorkKind::Test) || text.contains("qa owner") {
230230
Some("tester".to_string())
231-
} else if text.contains("review") {
231+
} else if matches!(kind, WorkKind::Review) {
232232
Some("reviewer".to_string())
233233
} else if text.contains("devops")
234234
|| text.contains("infrastructure")
@@ -252,7 +252,7 @@ impl WorkGraphCompiler {
252252
) -> ParsedIssue {
253253
let depends_on = self.parse_dependencies(&body);
254254
let kind = self.infer_work_kind(&title, &body);
255-
let owner_role = self.infer_owner_role(&title, &body);
255+
let owner_role = self.infer_owner_role(&title, &body, kind);
256256

257257
ParsedIssue {
258258
number,
@@ -516,6 +516,36 @@ mod tests {
516516
);
517517
}
518518

519+
#[test]
520+
fn test_infer_owner_role_is_kind_aware_for_implement_work() {
521+
let compiler = WorkGraphCompiler::new();
522+
523+
assert_eq!(
524+
compiler.infer_owner_role(
525+
"Implement auth flow",
526+
"Also add integration tests for login and signup",
527+
WorkKind::Implement
528+
),
529+
None
530+
);
531+
assert_eq!(
532+
compiler
533+
.infer_owner_role(
534+
"Implement auth API",
535+
"Add integration tests for login and signup",
536+
WorkKind::Implement
537+
)
538+
.as_deref(),
539+
Some("backend")
540+
);
541+
assert_eq!(
542+
compiler
543+
.infer_owner_role("Add integration tests", "", WorkKind::Test)
544+
.as_deref(),
545+
Some("tester")
546+
);
547+
}
548+
519549
#[test]
520550
fn test_compile_simple() {
521551
let compiler = WorkGraphCompiler::new();

src/mission/dispatcher.rs

Lines changed: 170 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ use crate::error::Result;
2020
use crate::message::{Message, MessageType};
2121
use crate::mission::scheduler::{MissionScheduler, SchedulerTickResult};
2222
use crate::mission::storage::MissionStorage;
23-
use crate::mission::types::{MissionId, MissionState, WatchStatus};
23+
use crate::mission::types::{MissionId, MissionState, WatchStatus, WorkItemId};
2424
use crate::mission::watch::{GitHubClient, WatchEngine, WatchEngineTickResult};
2525

2626
/// Dispatcher configuration.
@@ -154,6 +154,7 @@ impl<G: GitHubClient> MissionDispatcher<G> {
154154
.find(|result| result.mission_id == *mission_id)
155155
.is_some_and(|result| {
156156
result.state_changed
157+
|| !result.reconciled.is_empty()
157158
|| !result.promoted.is_empty()
158159
|| !result.assigned.is_empty()
159160
|| !result.completed.is_empty()
@@ -278,6 +279,80 @@ impl<G: GitHubClient> MissionDispatcher<G> {
278279
)
279280
.await?;
280281
}
282+
} else if let Some(directive) = parse_redirect_directive(body) {
283+
let work_item_id = match self
284+
.resolve_work_item_ref(mission_id, directive.work_item_ref.as_deref())
285+
.await?
286+
{
287+
Some(id) => id,
288+
None => {
289+
self.storage
290+
.log_event(
291+
mission_id,
292+
&format!(
293+
"Dispatcher ignored redirect directive from {}: could not resolve work item in '{}'",
294+
message.sender, body
295+
),
296+
)
297+
.await?;
298+
message.mark_processed();
299+
self.storage.save_control_message(&message).await?;
300+
continue;
301+
}
302+
};
303+
let agent_id = match self.resolve_agent_ref(&directive.agent_ref).await? {
304+
Some(id) => id,
305+
None => {
306+
self.storage
307+
.log_event(
308+
mission_id,
309+
&format!(
310+
"Dispatcher ignored redirect directive from {}: unknown agent '{}' in '{}'",
311+
message.sender, directive.agent_ref, body
312+
),
313+
)
314+
.await?;
315+
message.mark_processed();
316+
self.storage.save_control_message(&message).await?;
317+
continue;
318+
}
319+
};
320+
321+
if self
322+
.scheduler
323+
.redirect_work_item(
324+
mission_id,
325+
work_item_id,
326+
agent_id,
327+
directive.reason.as_deref(),
328+
)
329+
.await?
330+
{
331+
if mission.state.can_resume() {
332+
mission.start();
333+
mission.set_next_wake_at(None);
334+
}
335+
progressed = true;
336+
self.storage
337+
.log_event(
338+
mission_id,
339+
&format!(
340+
"Dispatcher redirected work item {} to {} from {}",
341+
work_item_id, directive.agent_ref, message.sender
342+
),
343+
)
344+
.await?;
345+
} else {
346+
self.storage
347+
.log_event(
348+
mission_id,
349+
&format!(
350+
"Dispatcher ignored redirect directive from {}: failed to redirect '{}'",
351+
message.sender, body
352+
),
353+
)
354+
.await?;
355+
}
281356
} else if lower.starts_with("pause") || lower.starts_with("hold") {
282357
if mission.state.can_pause() {
283358
mission.block(format!("Paused by {}: {}", message.sender, body));
@@ -436,4 +511,98 @@ impl<G: GitHubClient> MissionDispatcher<G> {
436511
.await?;
437512
Ok(())
438513
}
514+
515+
async fn resolve_agent_ref(&self, raw: &str) -> Result<Option<AgentId>> {
516+
if let Ok(id) = raw.parse::<AgentId>() {
517+
return Ok(Some(id));
518+
}
519+
520+
let raw_lower = raw.to_ascii_lowercase();
521+
let agents = self.channel.list_agents().await?;
522+
let mut matches = agents.into_iter().filter(|agent| {
523+
agent.name.eq_ignore_ascii_case(raw)
524+
|| agent.display_label().eq_ignore_ascii_case(raw)
525+
|| agent.id.short_id().eq_ignore_ascii_case(&raw_lower)
526+
});
527+
528+
let first = matches.next().map(|agent| agent.id);
529+
if matches.next().is_some() {
530+
Ok(None)
531+
} else {
532+
Ok(first)
533+
}
534+
}
535+
536+
async fn resolve_work_item_ref(
537+
&self,
538+
mission_id: MissionId,
539+
raw: Option<&str>,
540+
) -> Result<Option<WorkItemId>> {
541+
let work_items = self.storage.list_work_items(mission_id).await?;
542+
let active_items: Vec<_> = work_items
543+
.iter()
544+
.filter(|item| item.status != crate::mission::WorkStatus::Done)
545+
.collect();
546+
547+
let Some(raw) = raw else {
548+
return Ok((active_items.len() == 1).then(|| active_items[0].id));
549+
};
550+
551+
if let Ok(id) = raw.parse::<WorkItemId>() {
552+
return Ok(Some(id));
553+
}
554+
555+
let matches: Vec<_> = active_items
556+
.iter()
557+
.filter(|item| item.id.short_id().eq_ignore_ascii_case(raw))
558+
.map(|item| item.id)
559+
.collect();
560+
561+
if matches.len() == 1 {
562+
Ok(Some(matches[0]))
563+
} else {
564+
Ok(None)
565+
}
566+
}
567+
}
568+
569+
#[derive(Debug, Clone)]
570+
struct RedirectDirective {
571+
work_item_ref: Option<String>,
572+
agent_ref: String,
573+
reason: Option<String>,
574+
}
575+
576+
fn parse_redirect_directive(body: &str) -> Option<RedirectDirective> {
577+
let parts: Vec<_> = body.split_whitespace().collect();
578+
let verb = parts.first()?.to_ascii_lowercase();
579+
if !matches!(
580+
verb.as_str(),
581+
"redirect" | "reassign" | "reroute" | "rebind"
582+
) {
583+
return None;
584+
}
585+
586+
let (work_item_ref, to_idx) = if parts.get(1)?.eq_ignore_ascii_case("to") {
587+
(None, 1usize)
588+
} else {
589+
(Some(parts.get(1)?.to_string()), 2usize)
590+
};
591+
592+
if !parts.get(to_idx)?.eq_ignore_ascii_case("to") {
593+
return None;
594+
}
595+
596+
let agent_ref = parts.get(to_idx + 1)?.to_string();
597+
let reason = if parts.len() > to_idx + 2 {
598+
Some(parts[to_idx + 2..].join(" "))
599+
} else {
600+
None
601+
};
602+
603+
Some(RedirectDirective {
604+
work_item_ref,
605+
agent_ref,
606+
reason,
607+
})
439608
}

0 commit comments

Comments
 (0)