pub(super) async fn publish_bug_monitor_draft(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Response {
let existing_draft = state.get_bug_monitor_draft(&id).await;
match bug_monitor_github::publish_draft(
&state,
&id,
None,
bug_monitor_github::PublishMode::ManualPublish,
)
.await
{
Ok(outcome) => {
let triage_summary =
outcome
.draft
.triage_run_id
.as_deref()
.map(|triage_run_id| async {
load_bug_monitor_triage_summary_artifact(&state, triage_run_id).await
});
let issue_draft = if outcome.draft.triage_run_id.is_some() {
ensure_bug_monitor_issue_draft(state.clone(), &outcome.draft.draft_id, false)
.await
.ok()
} else {
None
};
let (duplicate_summary, duplicate_matches) =
bug_monitor_duplicate_match_context(&state, outcome.draft.triage_run_id.as_deref())
.await;
let (triage_summary_artifact, issue_draft_artifact, duplicate_matches_artifact) =
bug_monitor_triage_artifacts(&state, outcome.draft.triage_run_id.as_deref());
let triage_summary = match triage_summary {
Some(loader) => loader.await,
None => None,
};
let external_action = match outcome.post.as_ref() {
Some(post) => state.get_external_action(&post.post_id).await,
None => None,
};
let post_id = outcome.post.as_ref().map(|post| post.post_id.clone());
let issue_number = outcome
.post
.as_ref()
.and_then(|post| post.issue_number)
.or(outcome.draft.issue_number)
.or(outcome.draft.matched_issue_number);
let issue_url = outcome
.post
.as_ref()
.and_then(|post| post.issue_url.clone())
.or_else(|| outcome.draft.github_issue_url.clone());
let comment_id = outcome
.post
.as_ref()
.and_then(|post| post.comment_id.clone());
let comment_url = outcome
.post
.as_ref()
.and_then(|post| post.comment_url.clone());
Json(json!({
"ok": true,
"draft": outcome.draft,
"action": outcome.action,
"triage_summary": triage_summary,
"issue_draft": issue_draft,
"duplicate_summary": duplicate_summary,
"duplicate_matches": duplicate_matches,
"triage_summary_artifact": triage_summary_artifact,
"issue_draft_artifact": issue_draft_artifact,
"duplicate_matches_artifact": duplicate_matches_artifact,
"post_id": post_id,
"issue_number": issue_number,
"issue_url": issue_url,
"comment_id": comment_id,
"comment_url": comment_url,
"post": outcome.post,
"external_action": external_action,
}))
.into_response()
}
Err(error) => {
let draft = state.get_bug_monitor_draft(&id).await.or(existing_draft);
let triage_summary = if let Some(triage_run_id) =
draft.as_ref().and_then(|row| row.triage_run_id.as_deref())
{
load_bug_monitor_triage_summary_artifact(&state, triage_run_id).await
} else {
None
};
let issue_draft = if draft
.as_ref()
.and_then(|row| row.triage_run_id.as_ref())
.is_some()
{
ensure_bug_monitor_issue_draft(state.clone(), &id, false)
.await
.ok()
} else {
None
};
let (duplicate_summary, duplicate_matches) = bug_monitor_duplicate_match_context(
&state,
draft.as_ref().and_then(|row| row.triage_run_id.as_deref()),
)
.await;
let (triage_summary_artifact, issue_draft_artifact, duplicate_matches_artifact) =
bug_monitor_triage_artifacts(
&state,
draft.as_ref().and_then(|row| row.triage_run_id.as_deref()),
);
(
StatusCode::BAD_REQUEST,
Json(json!({
"error": "Failed to publish Bug Monitor draft to GitHub",
"code": "BUG_MONITOR_DRAFT_PUBLISH_FAILED",
"draft_id": id,
"draft": draft,
"triage_summary": triage_summary,
"issue_draft": issue_draft,
"duplicate_summary": duplicate_summary,
"duplicate_matches": duplicate_matches,
"triage_summary_artifact": triage_summary_artifact,
"issue_draft_artifact": issue_draft_artifact,
"duplicate_matches_artifact": duplicate_matches_artifact,
"detail": error.to_string(),
})),
)
.into_response()
}
}
}
pub(super) async fn recheck_bug_monitor_draft_match(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Response {
let existing_draft = state.get_bug_monitor_draft(&id).await;
match bug_monitor_github::publish_draft(
&state,
&id,
None,
bug_monitor_github::PublishMode::RecheckOnly,
)
.await
{
Ok(outcome) => {
let triage_summary =
outcome
.draft
.triage_run_id
.as_deref()
.map(|triage_run_id| async {
load_bug_monitor_triage_summary_artifact(&state, triage_run_id).await
});
let issue_draft = if outcome.draft.triage_run_id.is_some() {
ensure_bug_monitor_issue_draft(state.clone(), &outcome.draft.draft_id, false)
.await
.ok()
} else {
None
};
let (duplicate_summary, duplicate_matches) =
bug_monitor_duplicate_match_context(&state, outcome.draft.triage_run_id.as_deref())
.await;
let (triage_summary_artifact, issue_draft_artifact, duplicate_matches_artifact) =
bug_monitor_triage_artifacts(&state, outcome.draft.triage_run_id.as_deref());
let triage_summary = match triage_summary {
Some(loader) => loader.await,
None => None,
};
let post_id = outcome.post.as_ref().map(|post| post.post_id.clone());
let issue_number = outcome
.post
.as_ref()
.and_then(|post| post.issue_number)
.or(outcome.draft.issue_number)
.or(outcome.draft.matched_issue_number);
let issue_url = outcome
.post
.as_ref()
.and_then(|post| post.issue_url.clone())
.or_else(|| outcome.draft.github_issue_url.clone());
let comment_id = outcome
.post
.as_ref()
.and_then(|post| post.comment_id.clone());
let comment_url = outcome
.post
.as_ref()
.and_then(|post| post.comment_url.clone());
Json(json!({
"ok": true,
"draft": outcome.draft,
"action": outcome.action,
"triage_summary": triage_summary,
"issue_draft": issue_draft,
"duplicate_summary": duplicate_summary,
"duplicate_matches": duplicate_matches,
"triage_summary_artifact": triage_summary_artifact,
"issue_draft_artifact": issue_draft_artifact,
"duplicate_matches_artifact": duplicate_matches_artifact,
"post_id": post_id,
"issue_number": issue_number,
"issue_url": issue_url,
"comment_id": comment_id,
"comment_url": comment_url,
"post": outcome.post,
}))
.into_response()
}
Err(error) => {
let draft = state.get_bug_monitor_draft(&id).await.or(existing_draft);
let triage_summary = if let Some(triage_run_id) =
draft.as_ref().and_then(|row| row.triage_run_id.as_deref())
{
load_bug_monitor_triage_summary_artifact(&state, triage_run_id).await
} else {
None
};
let issue_draft = if draft
.as_ref()
.and_then(|row| row.triage_run_id.as_ref())
.is_some()
{
ensure_bug_monitor_issue_draft(state.clone(), &id, false)
.await
.ok()
} else {
None
};
let (duplicate_summary, duplicate_matches) = bug_monitor_duplicate_match_context(
&state,
draft.as_ref().and_then(|row| row.triage_run_id.as_deref()),
)
.await;
let (triage_summary_artifact, issue_draft_artifact, duplicate_matches_artifact) =
bug_monitor_triage_artifacts(
&state,
draft.as_ref().and_then(|row| row.triage_run_id.as_deref()),
);
(
StatusCode::BAD_REQUEST,
Json(json!({
"error": "Failed to recheck Bug Monitor draft against GitHub",
"code": "BUG_MONITOR_DRAFT_RECHECK_FAILED",
"draft_id": id,
"draft": draft,
"triage_summary": triage_summary,
"issue_draft": issue_draft,
"duplicate_summary": duplicate_summary,
"duplicate_matches": duplicate_matches,
"triage_summary_artifact": triage_summary_artifact,
"issue_draft_artifact": issue_draft_artifact,
"duplicate_matches_artifact": duplicate_matches_artifact,
"detail": error.to_string(),
})),
)
.into_response()
}
}
}
pub(crate) async fn ensure_bug_monitor_triage_run(
state: AppState,
id: &str,
bypass_approval_gate: bool,
) -> anyhow::Result<(BugMonitorDraftRecord, String, bool)> {
let config = state.bug_monitor_config().await;
let draft = state
.get_bug_monitor_draft(id)
.await
.ok_or_else(|| anyhow::anyhow!("Bug Monitor draft not found"))?;
if draft.status.eq_ignore_ascii_case("denied") {
anyhow::bail!("Denied Bug Monitor drafts cannot create triage runs");
}
if !bypass_approval_gate
&& config.require_approval_for_new_issues
&& draft.status.eq_ignore_ascii_case("approval_required")
{
anyhow::bail!("Bug Monitor draft must be approved before triage run creation");
}
if let Some(existing_run_id) = draft.triage_run_id.clone() {
if let Some(automation_run_id) =
bug_monitor_automation_run_id_from_triage_run_id(&existing_run_id)
{
if let Some(run) = state.get_automation_v2_run(&automation_run_id).await {
let stale_contract = if let Some(automation) = run.automation_snapshot.as_ref() {
bug_monitor_triage_flow_has_stale_output_contracts(&automation.flow)
} else if let Some(automation) = state.get_automation_v2(&run.automation_id).await {
bug_monitor_triage_flow_has_stale_output_contracts(&automation.flow)
} else {
false
};
if stale_contract {
tracing::warn!(
draft_id = %draft.draft_id,
triage_run_id = %existing_run_id,
run_id = %automation_run_id,
"Bug Monitor triage run has stale output contracts; recreating run",
);
} else if bug_monitor_triage_run_is_reusable(&state, &existing_run_id).await {
return Ok((draft, existing_run_id, true));
}
}
} else if bug_monitor_triage_run_is_reusable(&state, &existing_run_id).await {
return Ok((draft, existing_run_id, true));
}
}
let model_policy = config.model_policy.clone();
let mcp_servers = config
.mcp_server
.as_ref()
.map(|row| vec![row.clone()])
.filter(|row| !row.is_empty());
let duplicate_matches = super::coder::query_failure_pattern_matches(
&state,
&draft.repo,
&draft.fingerprint,
draft.title.as_deref(),
draft.detail.as_deref(),
&[],
3,
)
.await
.map_err(|status| {
anyhow::anyhow!("Failed to query duplicate failure patterns: HTTP {status}")
})?;
let incident = latest_bug_monitor_incident_for_draft(&state, &draft.draft_id).await;
let incident_payload = incident
.as_ref()
.and_then(|row| row.event_payload.clone())
.unwrap_or(Value::Null);
let workflow_run_task_ids = json!({
"workflow_id": incident_payload.get("workflow_id").or_else(|| incident_payload.get("workflowID")).cloned().unwrap_or(Value::Null),
"workflow_name": incident_payload.get("workflow_name").or_else(|| incident_payload.get("workflowName")).cloned().unwrap_or(Value::Null),
"run_id": incident
.as_ref()
.and_then(|row| row.run_id.clone())
.map(Value::String)
.or_else(|| incident_payload.get("run_id").or_else(|| incident_payload.get("runID")).cloned())
.unwrap_or(Value::Null),
"session_id": incident
.as_ref()
.and_then(|row| row.session_id.clone())
.map(Value::String)
.or_else(|| incident_payload.get("session_id").or_else(|| incident_payload.get("sessionID")).cloned())
.unwrap_or(Value::Null),
"task_id": incident_payload.get("task_id").or_else(|| incident_payload.get("taskID")).cloned().unwrap_or(Value::Null),
"stage_id": incident_payload.get("stage_id").or_else(|| incident_payload.get("stageID")).cloned().unwrap_or(Value::Null),
"node_id": incident_payload.get("node_id").or_else(|| incident_payload.get("nodeID")).cloned().unwrap_or(Value::Null),
"automation_id": incident_payload.get("automation_id").or_else(|| incident_payload.get("automationID")).cloned().unwrap_or(Value::Null),
"routine_id": incident_payload.get("routine_id").or_else(|| incident_payload.get("routineID")).cloned().unwrap_or(Value::Null),
});
let artifact_refs = incident_payload
.get("artifact_refs")
.or_else(|| incident_payload.get("artifactRefs"))
.cloned()
.unwrap_or(Value::Array(Vec::new()));
let files_touched = incident_payload
.get("files_touched")
.or_else(|| incident_payload.get("filesTouched"))
.cloned()
.unwrap_or(Value::Array(Vec::new()));
let error_provenance_payload = compute_error_provenance_payload(
config.workspace_root.as_deref(),
&draft,
incident.as_ref(),
)
.await;
let inspection_payload = json!({
"task_kind": "inspection",
"title": "Inspect failure report and affected area",
"draft_id": draft.draft_id,
"repo": draft.repo,
"summary": draft.title,
"detail": draft.detail,
"duplicate_matches": duplicate_matches,
"incident": incident,
"incident_payload": incident_payload,
"artifact_refs": artifact_refs,
"files_touched": files_touched,
"workflow_run_task_ids": workflow_run_task_ids,
"error_provenance": error_provenance_payload,
"expected_artifact": "bug_monitor_inspection",
});
let research_payload = json!({
"task_kind": "research",
"title": "Research likely root cause and related failures",
"draft_id": draft.draft_id,
"repo": draft.repo,
"depends_on": "inspect_failure_report",
"research_requirements": {
"search_repo": true,
"search_failure_memory": true,
"search_github_issues": true,
"inspect_artifacts": true,
"web_research_when_external_error": true,
"first_step": "Before any other research, treat the literal error string in `error_provenance.error_message` as the primary anchor. If `error_provenance.hints` is non-empty, read those files at the indicated lines first; they are the deterministic emission sites for this exact failure message. Only after reading those files should you grep more broadly for related code paths. Do not list files in `Files likely involved` unless you have read them and confirmed they reference the failure path."
},
"duplicate_matches": duplicate_matches,
"artifact_refs": artifact_refs,
"files_touched": files_touched,
"error_provenance": error_provenance_payload,
"expected_artifact": "bug_monitor_research_report",
});
let validation_payload = json!({
"task_kind": "validation",
"title": "Validate or reproduce failure scope",
"draft_id": draft.draft_id,
"repo": draft.repo,
"depends_on": "research_likely_root_cause",
"validation_requirements": {
"confirm_failure_scope": true,
"classify_failure_type": true,
"avoid_destructive_actions": true,
"produce_evidence": true
},
"expected_artifact": "bug_monitor_validation",
});
let fix_payload = json!({
"task_kind": "fix_proposal",
"title": "Propose fix and verification plan",
"draft_id": draft.draft_id,
"repo": draft.repo,
"depends_on": "validate_failure_scope",
"proposal_requirements": {
"suspected_root_cause": true,
"likely_files_to_edit": true,
"recommended_fix": true,
"acceptance_criteria": true,
"smoke_test_steps": true,
"coder_ready_assessment": true,
"suggested_labels": true,
"risk_level": true
},
"expected_artifact": "bug_monitor_fix_proposal",
});
let triage_spec = bug_monitor_triage_spec(
&draft,
config.workspace_root.clone(),
model_policy,
mcp_servers.unwrap_or_default(),
inspection_payload.clone(),
research_payload.clone(),
validation_payload.clone(),
fix_payload.clone(),
);
let mut triage_spec = triage_spec;
normalize_bug_monitor_triage_output_contracts(&mut triage_spec);
let stored_spec = state.put_automation_v2(triage_spec).await?;
let automation_run = state
.create_automation_v2_run(&stored_spec, "bug_monitor_triage")
.await?;
let run_id = bug_monitor_triage_context_run_id(&automation_run.run_id);
if !duplicate_matches.is_empty() {
write_bug_monitor_artifact(
&state,
&run_id,
"failure-duplicate-matches",
"failure_duplicate_matches",
"artifacts/failure_duplicate_matches.json",
&json!({
"draft_id": draft.draft_id,
"repo": draft.repo,
"fingerprint": draft.fingerprint,
"matches": duplicate_matches,
"created_at_ms": crate::now_ms(),
}),
)
.await
.map_err(|status| {
anyhow::anyhow!("Failed to write duplicate matches artifact: HTTP {status}")
})?;
}
for (artifact_id, artifact_type, path, payload) in [
(
"bug-monitor-inspection-brief",
"bug_monitor_inspection_task_spec",
"artifacts/bug_monitor.inspection.task_spec.json",
inspection_payload,
),
(
"bug-monitor-research-brief",
"bug_monitor_research_task_spec",
"artifacts/bug_monitor.research.task_spec.json",
research_payload,
),
(
"bug-monitor-validation-brief",
"bug_monitor_validation_task_spec",
"artifacts/bug_monitor.validation.task_spec.json",
validation_payload,
),
(
"bug-monitor-fix-proposal-brief",
"bug_monitor_fix_proposal_task_spec",
"artifacts/bug_monitor.fix_proposal.task_spec.json",
fix_payload,
),
] {
write_bug_monitor_artifact(&state, &run_id, artifact_id, artifact_type, path, &payload)
.await
.map_err(|status| anyhow::anyhow!("Failed to write triage artifact: HTTP {status}"))?;
}
let mut updated_draft = draft.clone();
updated_draft.triage_run_id = Some(run_id.clone());
updated_draft.status = "triage_queued".to_string();
{
let mut drafts = state.bug_monitor_drafts.write().await;
drafts.insert(updated_draft.draft_id.clone(), updated_draft.clone());
}
state.persist_bug_monitor_drafts().await?;
ensure_context_run_dir(&state, &run_id)
.await
.map_err(|status| {
anyhow::anyhow!("Failed to finalize triage run workspace: HTTP {status}")
})?;
if let Ok(mut context_run) = load_context_run_state(&state, &run_id).await {
context_run.run_type = "bug_monitor_triage".to_string();
context_run.source_client = Some("bug_monitor_triage".to_string());
save_context_run_state(&state, &context_run)
.await
.map_err(|status| {
anyhow::anyhow!("Failed to persist triage run context metadata: HTTP {status}")
})?;
}
state.event_bus.publish(tandem_types::EngineEvent::new(
"bug_monitor.triage_run.created",
json!({
"draft_id": updated_draft.draft_id,
"run_id": run_id,
"automation_run_id": automation_run.run_id,
"repo": updated_draft.repo,
}),
));
Ok((updated_draft, run_id, false))
}
async fn compute_error_provenance_payload(
config_workspace_root: Option<&str>,
draft: &BugMonitorDraftRecord,
incident: Option<&crate::BugMonitorIncidentRecord>,
) -> serde_json::Value {
let Some(error_message) = pick_error_message_for_triage(draft, incident) else {
return serde_json::Value::Null;
};
let Some(workspace_root) = pick_workspace_root_for_triage(config_workspace_root, incident)
else {
return serde_json::json!({
"error_message": error_message,
"hints": [],
"note": "workspace_root not configured; LLM should grep the workspace itself for `error_message`."
});
};
let path = std::path::Path::new(&workspace_root);
if !path.is_absolute() || !path.exists() {
return serde_json::json!({
"error_message": error_message,
"hints": [],
"note": "workspace_root not accessible; LLM should grep the workspace itself for `error_message`."
});
}
let hits =
crate::bug_monitor::error_provenance::locate_error_provenance(path, &error_message).await;
let hints = hits
.into_iter()
.map(|hit| {
json!({
"path": hit.path,
"line": hit.line,
"snippet": hit.snippet,
})
})
.collect::<Vec<_>>();
let note = if hints.is_empty() {
"No exact match for `error_message` in tracked source files. The LLM should grep more broadly (e.g. for fragments of the message) before listing any files in `Files likely involved`.".to_string()
} else {
"`hints` are deterministic, server-side grep results for the literal `error_message`. Read these files at the indicated lines first; they are the emission sites for this exact failure. Use them as the anchor for `Files likely involved` rather than fuzzy-matching on the workflow name.".to_string()
};
json!({
"error_message": error_message,
"hints": hints,
"note": note,
})
}
fn pick_workspace_root_for_triage(
config_workspace_root: Option<&str>,
incident: Option<&crate::BugMonitorIncidentRecord>,
) -> Option<String> {
let candidates = [
config_workspace_root.map(str::to_string),
incident.map(|row| row.workspace_root.clone()),
];
candidates
.into_iter()
.flatten()
.map(|value| value.trim().to_string())
.find(|value| !value.is_empty())
}
fn pick_error_message_for_triage(
draft: &BugMonitorDraftRecord,
incident: Option<&crate::BugMonitorIncidentRecord>,
) -> Option<String> {
let candidates = [
incident.and_then(|row| {
row.excerpt
.iter()
.find(|line| !line.trim().is_empty())
.cloned()
}),
draft.detail.clone(),
incident.and_then(|row| row.detail.clone()),
incident.and_then(|row| {
row.title
.split_once(':')
.map(|(_, suffix)| suffix.trim().to_string())
.filter(|s| !s.is_empty() && s.split_whitespace().count() >= 3)
}),
incident.map(|row| row.title.clone()),
draft.title.clone(),
];
candidates
.into_iter()
.flatten()
.map(|value| value.trim().to_string())
.find(|value| !value.is_empty())
}