From c62c6db1c40f932327eede13efbb725f81c1d311 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Wed, 10 Jun 2026 20:19:21 +0800 Subject: [PATCH] {"schema":"decodex/commit/1","summary":"Surface loop status in operator readback","authority":"XY-874"} --- .../src/orchestrator/agent_evidence.rs | 294 +++++++- .../src/orchestrator/operator_dashboard.html | 66 ++ .../decodex/src/orchestrator/operator_http.rs | 5 +- apps/decodex/src/orchestrator/status.rs | 654 +++++++++++++++++- .../tests/operator/status/agent_evidence.rs | 86 ++- .../tests/operator/status/dashboard.rs | 12 + .../tests/operator/status/history.rs | 13 + .../tests/operator/status/text.rs | 198 ++++++ .../tests/operator/status_support.rs | 2 + apps/decodex/src/orchestrator/types.rs | 59 ++ docs/reference/operator-control-plane.md | 22 +- docs/spec/agent-evidence.md | 5 + docs/spec/runtime.md | 6 + 13 files changed, 1348 insertions(+), 74 deletions(-) diff --git a/apps/decodex/src/orchestrator/agent_evidence.rs b/apps/decodex/src/orchestrator/agent_evidence.rs index 151e74ce4..dfdc61850 100644 --- a/apps/decodex/src/orchestrator/agent_evidence.rs +++ b/apps/decodex/src/orchestrator/agent_evidence.rs @@ -6,6 +6,7 @@ const AGENT_RUN_CAPSULE_SCHEMA: &str = "decodex.run_capsule/1"; const AGENT_EVIDENCE_EVENT_SCHEMA: &str = "decodex.agent_evidence_event/1"; const PRIVATE_EVIDENCE_READBACK_SCHEMA: &str = "decodex.private_execution_evidence_readback/1"; const PRIVATE_EVIDENCE_PAYLOAD_PREVIEW_LIMIT: usize = 160; +const REVIEW_CHECKPOINT_EVENT_TYPE: &str = "review_checkpoint"; const HANDOFF_INDEX_FILE_NAME: &str = "handoff-index.json"; const BLOCKERS_DIR_NAME: &str = "blockers"; const RUNS_DIR_NAME: &str = "runs"; @@ -259,6 +260,8 @@ struct PrivateEvidenceReadback { event_count: usize, latest_event_type: Option, latest_event_at: Option, + review_checkpoints: Vec, + boundary_checks: Vec, decision_requests: Vec, architecture_recoveries: Vec, improvement_candidates: Vec, @@ -277,6 +280,28 @@ struct PrivateEvidenceDecisionRequestSummary { resume_condition: Option, } +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +struct PrivateEvidenceReviewCheckpointSummary { + phase: String, + status: String, + head_sha: Option, + round: Option, + accepted_finding_count: usize, + rejected_finding_count: usize, + next_action: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +struct PrivateEvidenceBoundaryCheckSummary { + disposition: String, + reason: Option, + attempted_recovery_reason: Option, + decision_contract_count: usize, + changed_surface_count: usize, + improvement_signal_count: usize, + next_action: String, +} + #[derive(Clone, Debug, Eq, PartialEq, Serialize)] struct PrivateEvidenceArchitectureRecoverySummary { reason_code: String, @@ -670,6 +695,8 @@ fn build_private_evidence_readback( event_count: events.len(), latest_event_type: latest_event.map(|event| event.event_type().to_owned()), latest_event_at: latest_event.map(|event| event.recorded_at().to_owned()), + review_checkpoints: review_checkpoints_from_private_events(&events), + boundary_checks: boundary_checks_from_private_events(&events), decision_requests: authority_decision_requests_from_private_events(&events), architecture_recoveries: architecture_recoveries_from_private_events(&events), improvement_candidates: harness_improvement_candidates_from_private_events(&events), @@ -767,6 +794,129 @@ fn private_evidence_direct_lookup_issue_id( ) } +fn review_checkpoints_from_private_events( + events: &[state::PrivateExecutionEvent], +) -> Vec { + events + .iter() + .filter(|event| event.event_type() == REVIEW_CHECKPOINT_EVENT_TYPE) + .filter_map(review_checkpoint_from_private_event) + .collect() +} + +fn review_checkpoint_from_private_event( + event: &state::PrivateExecutionEvent, +) -> Option { + let payload = event.payload(); + let phase = payload.get("phase")?.as_str()?.to_owned(); + let status = payload.get("status")?.as_str()?.to_owned(); + let head_sha = payload + .get("head_sha") + .and_then(Value::as_str) + .map(str::to_owned); + let round = payload + .get("nonclean_rounds") + .or_else(|| payload.get("round")) + .and_then(Value::as_u64); + let accepted_finding_count = payload + .get("review") + .and_then(|review| review.get("accepted_findings")) + .or_else(|| payload.get("accepted_findings")) + .and_then(Value::as_array) + .map_or(0, Vec::len); + let rejected_finding_count = payload + .get("review") + .and_then(|review| review.get("rejected_findings")) + .or_else(|| payload.get("rejected_findings")) + .and_then(Value::as_array) + .map_or(0, Vec::len); + let next_action = review_checkpoint_next_action(&status); + + Some(PrivateEvidenceReviewCheckpointSummary { + phase, + status, + head_sha, + round, + accepted_finding_count, + rejected_finding_count, + next_action, + }) +} + +fn review_checkpoint_next_action(status: &str) -> String { + match status { + "clean" => String::from("Proceed with review handoff when repo gate evidence is current."), + "findings" => String::from("Repair accepted findings, rerun validation, and checkpoint the repaired head."), + "blocked" => String::from("Resolve the blocking review condition before continuing."), + "needs_architecture_review" => { + String::from("Escalate for an architecture decision before further repair churn.") + }, + _ => String::from("Inspect the Decodex Review checkpoint summary before continuing."), + } +} + +fn boundary_checks_from_private_events( + events: &[state::PrivateExecutionEvent], +) -> Vec { + events + .iter() + .filter(|event| event.event_type() == AUTHORITY_BOUNDARY_CHECK_EVENT_TYPE) + .filter_map(boundary_check_from_private_event) + .collect() +} + +fn boundary_check_from_private_event( + event: &state::PrivateExecutionEvent, +) -> Option { + let payload = event.payload(); + let disposition = payload.get("disposition")?.as_str()?.to_owned(); + let reason = payload + .get("final_disposition") + .and_then(|final_disposition| final_disposition.get("reason")) + .and_then(Value::as_str) + .map(str::to_owned); + let attempted_recovery_reason = payload + .get("attempted_recovery_reason") + .and_then(Value::as_str) + .map(str::to_owned); + let decision_contract_count = payload + .get("decision_contract_ids") + .and_then(Value::as_array) + .map_or(0, Vec::len); + let changed_surface_count = payload + .get("changed_surfaces") + .and_then(Value::as_array) + .map_or(0, Vec::len); + let improvement_signal_count = payload + .get("improvement_signals") + .and_then(Value::as_array) + .map_or(0, Vec::len); + let next_action = boundary_check_next_action(&disposition); + + Some(PrivateEvidenceBoundaryCheckSummary { + disposition, + reason, + attempted_recovery_reason, + decision_contract_count, + changed_surface_count, + improvement_signal_count, + next_action, + }) +} + +fn boundary_check_next_action(disposition: &str) -> String { + match disposition { + "within_authority" => { + String::from("Continue autonomous architecture recovery inside the accepted boundary.") + }, + "requires_human" => String::from("Stop for a human boundary decision before continuing."), + "insufficient_evidence" => { + String::from("Provide boundary evidence or a Decision Contract before retrying.") + }, + _ => String::from("Inspect the authority boundary summary before continuing."), + } +} + fn authority_decision_requests_from_private_events( events: &[state::PrivateExecutionEvent], ) -> Vec { @@ -1032,6 +1182,27 @@ fn truncate_private_evidence_payload_preview(value: &str) -> String { fn render_private_evidence_readback(readback: &PrivateEvidenceReadback) -> String { let mut output = String::new(); + append_private_evidence_readback_header(&mut output, readback); + append_private_evidence_decision_requests(&mut output, &readback.decision_requests); + append_private_evidence_review_checkpoints(&mut output, &readback.review_checkpoints); + append_private_evidence_architecture_recoveries( + &mut output, + &readback.architecture_recoveries, + ); + append_private_evidence_boundary_checks(&mut output, &readback.boundary_checks); + append_private_evidence_improvement_candidates( + &mut output, + &readback.improvement_candidates, + ); + append_private_evidence_events(&mut output, &readback.events); + + output +} + +fn append_private_evidence_readback_header( + output: &mut String, + readback: &PrivateEvidenceReadback, +) { output.push_str(&format!("Project: {}\n", readback.project_id)); output.push_str("Private Execution Evidence\n"); output.push_str(&format!("issue_selector: {}\n", readback.issue_selector)); @@ -1054,6 +1225,18 @@ fn render_private_evidence_readback(readback: &PrivateEvidenceReadback) -> Strin "decision_request_count: {}\n", readback.decision_requests.len() )); + output.push_str(&format!( + "review_checkpoint_count: {}\n", + readback.review_checkpoints.len() + )); + output.push_str(&format!( + "architecture_recovery_count: {}\n", + readback.architecture_recoveries.len() + )); + output.push_str(&format!( + "boundary_check_count: {}\n", + readback.boundary_checks.len() + )); output.push_str(&format!( "latest_event_type: {}\n", readback.latest_event_type.as_deref().unwrap_or("none") @@ -1066,13 +1249,18 @@ fn render_private_evidence_readback(readback: &PrivateEvidenceReadback) -> Strin if !readback.warnings.is_empty() { output.push_str(&format!("warnings: {}\n", readback.warnings.join(", "))); } +} +fn append_private_evidence_decision_requests( + output: &mut String, + decision_requests: &[PrivateEvidenceDecisionRequestSummary], +) { output.push_str("\nDecision Requests\n"); - if readback.decision_requests.is_empty() { + if decision_requests.is_empty() { output.push_str("- none\n"); } else { - for request in &readback.decision_requests { + for request in decision_requests { output.push_str(&format!( "- id: {}\n phase: {}\n reason: {}\n boundary: {}\n next_action: {}\n", request.decision_request_id, @@ -1083,13 +1271,98 @@ fn render_private_evidence_readback(readback: &PrivateEvidenceReadback) -> Strin )); } } +} + +fn append_private_evidence_review_checkpoints( + output: &mut String, + review_checkpoints: &[PrivateEvidenceReviewCheckpointSummary], +) { + output.push_str("\nReview Checkpoints\n"); + + if review_checkpoints.is_empty() { + output.push_str("- none\n"); + } else { + for checkpoint in review_checkpoints { + output.push_str(&format!( + "- phase: {}\n status: {}\n head_sha: {}\n round: {}\n accepted_findings: {}\n rejected_findings: {}\n next_action: {}\n", + checkpoint.phase, + checkpoint.status, + checkpoint.head_sha.as_deref().unwrap_or("none"), + checkpoint + .round + .map_or_else(|| String::from("none"), |round| round.to_string()), + checkpoint.accepted_finding_count, + checkpoint.rejected_finding_count, + checkpoint.next_action + )); + } + } +} +fn append_private_evidence_architecture_recoveries( + output: &mut String, + architecture_recoveries: &[PrivateEvidenceArchitectureRecoverySummary], +) { + output.push_str("\nArchitecture Recoveries\n"); + + if architecture_recoveries.is_empty() { + output.push_str("- none\n"); + } else { + for recovery in architecture_recoveries { + output.push_str(&format!( + "- reason_code: {}\n guardrail_reason: {}\n boundary_disposition: {}\n budget: {}/{}\n next_action: {}\n", + recovery.reason_code, + recovery.guardrail_reason.as_deref().unwrap_or("none"), + recovery.boundary_disposition.as_deref().unwrap_or("none"), + recovery + .recovery_budget_attempt + .map_or_else(|| String::from("none"), |attempt| attempt.to_string()), + recovery + .recovery_budget_max_attempts + .map_or_else(|| String::from("none"), |max_attempts| max_attempts.to_string()), + recovery.next_action + )); + } + } +} + +fn append_private_evidence_boundary_checks( + output: &mut String, + boundary_checks: &[PrivateEvidenceBoundaryCheckSummary], +) { + output.push_str("\nBoundary Checks\n"); + + if boundary_checks.is_empty() { + output.push_str("- none\n"); + } else { + for boundary in boundary_checks { + output.push_str(&format!( + "- disposition: {}\n reason: {}\n attempted_recovery: {}\n decision_contracts: {}\n changed_surfaces: {}\n improvement_signals: {}\n next_action: {}\n", + boundary.disposition, + boundary.reason.as_deref().unwrap_or("none"), + boundary + .attempted_recovery_reason + .as_deref() + .unwrap_or("none"), + boundary.decision_contract_count, + boundary.changed_surface_count, + boundary.improvement_signal_count, + boundary.next_action + )); + } + } +} + +fn append_private_evidence_improvement_candidates( + output: &mut String, + improvement_candidates: &[HarnessImprovementCandidateSummary], +) { output.push_str("\nImprovement Candidates\n"); - if readback.improvement_candidates.is_empty() { + if improvement_candidates.is_empty() { output.push_str("- none\n"); } else { - for candidate in &readback.improvement_candidates { + for candidate in improvement_candidates { output.push_str(&format!( "- kind: {}\n reason_code: {}\n target: {}\n source_event_count: {}\n recommendation: {}\n", candidate.kind, @@ -1100,16 +1373,21 @@ fn render_private_evidence_readback(readback: &PrivateEvidenceReadback) -> Strin )); } } +} +fn append_private_evidence_events( + output: &mut String, + events: &[PrivateEvidenceReadbackEvent], +) { output.push_str("\nEvents\n"); - if readback.events.is_empty() { + if events.is_empty() { output.push_str("- none\n"); - return output; + return; } - for event in &readback.events { + for event in events { output.push_str(&format!( "- record_id: {}\n event_type: {}\n recorded_at: {}\n payload: {}\n", event.record_id, @@ -1122,8 +1400,6 @@ fn render_private_evidence_readback(readback: &PrivateEvidenceReadback) -> Strin output.push_str(&format!(" full_payload: {}\n", payload)); } } - - output } fn render_private_evidence_payload_summary( diff --git a/apps/decodex/src/orchestrator/operator_dashboard.html b/apps/decodex/src/orchestrator/operator_dashboard.html index f6d465d10..a8908f956 100644 --- a/apps/decodex/src/orchestrator/operator_dashboard.html +++ b/apps/decodex/src/orchestrator/operator_dashboard.html @@ -5059,6 +5059,61 @@

Run History

return facts; } + function loopStatusFacts(loopStatus) { + if (!loopStatus) { + return []; + } + + const facts = []; + if (loopStatus.review?.status) { + const checkpoint = loopStatus.review.checkpoint; + const round = + checkpoint && checkpoint.round != null ? ` r${checkpoint.round}` : ""; + facts.push([ + "Review", + `${displayToken(loopStatus.review.phase)} ${displayToken(loopStatus.review.status)}${round}`, + ]); + } + if (loopStatus.architecture_recovery?.status) { + const recovery = loopStatus.architecture_recovery; + const budget = recovery.budget + ? ` ${recovery.budget.attempt}/${recovery.budget.max_attempts}` + : ""; + facts.push([ + "Recovery", + `${displayToken(recovery.status)} ${displayToken(recovery.reason_code)}${budget}`, + ]); + } + if (loopStatus.boundary?.disposition) { + facts.push(["Boundary", displayToken(loopStatus.boundary.disposition)]); + } + if (loopStatus.decision_request?.decision_request_id) { + facts.push(["Decision", loopStatus.decision_request.decision_request_id]); + } + if (loopStatus.autonomy) { + facts.push(["Autonomy", displayToken(loopStatus.autonomy)]); + } + + return facts; + } + + function loopStatusInline(loopStatus) { + if (!loopStatus) { + return ""; + } + if (loopStatus.decision_request?.reason) { + return displayToken(loopStatus.decision_request.reason); + } + if (loopStatus.architecture_recovery?.status) { + return displayToken(loopStatus.architecture_recovery.status); + } + if (loopStatus.review?.status) { + return displayToken(loopStatus.review.status); + } + + return displayToken(loopStatus.autonomy || ""); + } + function renderAttentionFacts(candidate) { const attention = candidate.attention; if (!attention) { @@ -5109,6 +5164,7 @@

Run History

if (attention.last_activity_at) { facts.push(["Last", formatTimestampCompact(attention.last_activity_at)]); } + facts.push(...loopStatusFacts(attention.loop_status)); if (!facts.length) { return ""; @@ -8737,6 +8793,7 @@

Run History

["Threads", reviewThreadToken(lane.unresolved_review_threads)], ["PR", optionalCardToken(lane.pr_url)], ["Branch", optionalCardToken(lane.branch_name)], + ...loopStatusFacts(lane.loop_status), ...postReviewReadbackFacts(lane), ], }); @@ -8760,6 +8817,7 @@

Run History

["Threads", reviewThreadToken(lane.unresolved_review_threads)], ["PR", optionalCardToken(lane.pr_url)], ["Branch", optionalCardToken(lane.branch_name)], + ...loopStatusFacts(lane.loop_status), ...postReviewReadbackFacts(lane), ], }); @@ -8779,6 +8837,7 @@

Run History

["Threads", reviewThreadToken(lane.unresolved_review_threads)], ["PR", optionalCardToken(lane.pr_url)], ["Branch", optionalCardToken(lane.branch_name)], + ...loopStatusFacts(lane.loop_status), ...postReviewReadbackFacts(lane), ], }); @@ -8799,6 +8858,7 @@

Run History

["Threads", reviewThreadToken(lane.unresolved_review_threads)], ["PR", optionalCardToken(lane.pr_url)], ["Branch", optionalCardToken(lane.branch_name)], + ...loopStatusFacts(lane.loop_status), ...postReviewReadbackFacts(lane), ], }); @@ -9610,6 +9670,10 @@

${escapeHtml(item.title)}

statusBits.push(inlineStatusFact("Continuation", "Pending")); } } + const loopInline = loopStatusInline(run.loop_status); + if (loopInline && !displayTextRepeats(summary, loopInline)) { + statusBits.push(inlineStatusFact("Loop", loopInline)); + } const attemptNumber = attemptNumberFromRun(run); return `
@@ -9644,6 +9708,8 @@

${escapeHtml(issueTitle)}

${field("Worktree", run.worktree_path || "none")} ${field("Queue lease", runQueueLeaseSummary(run))} ${field("Execution liveness", runExecutionLivenessSummary(run))} + ${field("Loop", run.loop_status?.summary || "none")} + ${field("Review loop", loopStatusFacts(run.loop_status).map(([label, value]) => `${label}: ${value}`).join("; ") || "none")} ${field("Model", runModelSummary(run))} ${field("Child agent", childAgentDebugSummary(childAgentActivity(run)))} ${field("Context pressure", childAgentContextSummary(childAgentActivity(run)))} diff --git a/apps/decodex/src/orchestrator/operator_http.rs b/apps/decodex/src/orchestrator/operator_http.rs index 9b66025ac..4ac24df49 100644 --- a/apps/decodex/src/orchestrator/operator_http.rs +++ b/apps/decodex/src/orchestrator/operator_http.rs @@ -518,10 +518,13 @@ fn build_operator_run_activity_event( let project_display_name = operator_project_display_name(&project); let recent_runs = recent_runs .into_iter() - .map(|run| operator_run_status(&project, &project_display_name, run, now_unix_epoch)) + .map(|run| { + operator_run_status(&project, state_store, &project_display_name, run, now_unix_epoch) + }) .collect::>>()?; let project_active_runs = operator_active_run_statuses( &project, + state_store, &project_display_name, leased_runs, &recent_runs, diff --git a/apps/decodex/src/orchestrator/status.rs b/apps/decodex/src/orchestrator/status.rs index d3403dcc5..670ddba0c 100644 --- a/apps/decodex/src/orchestrator/status.rs +++ b/apps/decodex/src/orchestrator/status.rs @@ -284,10 +284,13 @@ fn build_operator_status_snapshot_with_account_mode( let project_display_name = operator_project_display_name(project); let recent_runs = recent_runs .into_iter() - .map(|run| operator_run_status(project, &project_display_name, run, now_unix_epoch)) + .map(|run| { + operator_run_status(project, state_store, &project_display_name, run, now_unix_epoch) + }) .collect::>>()?; let active_runs = operator_active_run_statuses( project, + state_store, &project_display_name, leased_runs, &recent_runs, @@ -341,6 +344,7 @@ fn build_operator_status_snapshot_with_account_mode( fn operator_active_run_statuses( project: &ServiceConfig, + state_store: &StateStore, project_display_name: &str, leased_runs: Vec, recent_runs: &[OperatorRunStatus], @@ -348,7 +352,7 @@ fn operator_active_run_statuses( ) -> crate::prelude::Result> { let mut active_runs = leased_runs .into_iter() - .map(|run| operator_run_status(project, project_display_name, run, now_unix_epoch)) + .map(|run| operator_run_status(project, state_store, project_display_name, run, now_unix_epoch)) .collect::>>()? .into_iter() .filter(operator_run_counts_as_active) @@ -898,6 +902,18 @@ fn apply_terminal_history_ledger_outcome_to_latest_run(lane: &mut OperatorHistor lane.latest_run.retry_kind = None; lane.latest_run.next_retry_at = None; + if let Some(loop_status) = lane.latest_run.loop_status.as_mut() { + loop_status.summary = format!("terminal lifecycle: {}", lane.latest_run.status); + loop_status.next_action = None; + + if loop_status + .review + .as_ref() + .is_some_and(|review| review.status == "pending" && review.checkpoint.is_none()) + { + loop_status.review = None; + } + } if let Some(final_event_at) = final_event_at { lane.latest_run.updated_at = final_event_at.clone(); lane.latest_run.last_run_activity_at = Some(final_event_at); @@ -2362,6 +2378,13 @@ where attention_record.as_ref(), marker.as_ref(), )?; + let loop_status = operator_queued_issue_loop_status( + project, + state_store, + issue, + attention_record.as_ref(), + marker.as_ref(), + )?; let attempt_status = marker .as_ref() .and_then(|marker| state_store.run_attempt(marker.run_id()).transpose()) @@ -2392,6 +2415,7 @@ where .and_then(RunActivityMarker::thread_status) .map(str::to_owned), attempt_status, + loop_status, auto_retry_blocked_reason, attention_error_class, attention_next_action, @@ -2419,6 +2443,35 @@ where })) } +fn operator_queued_issue_loop_status( + project: &ServiceConfig, + state_store: &StateStore, + issue: &TrackerIssue, + attention_record: Option<&LinearExecutionEventRecord>, + marker: Option<&RunActivityMarker>, +) -> crate::prelude::Result> { + let run_id = attention_record + .map(|record| record.run_id.as_str()) + .or_else(|| marker.map(RunActivityMarker::run_id)); + let attempt_number = attention_record + .map(|record| record.attempt_number) + .or_else(|| marker.map(RunActivityMarker::attempt_number)); + + match (run_id, attempt_number) { + (Some(run_id), Some(attempt_number)) => operator_loop_status_for_run( + project, + state_store, + &issue.id, + run_id, + attempt_number, + Some("handoff"), + None, + ) + .map(Some), + _ => Ok(None), + } +} + fn operator_queued_issue_decision_request_status( project: &ServiceConfig, state_store: &StateStore, @@ -2826,10 +2879,12 @@ where lanes.push(degraded_post_review_lane_status_from_classification( project, + state_store, &worktree, + &review_handoff, issue_identifier, classification, - )); + )?); } lanes.sort_by(|left, right| left.issue_identifier.cmp(&right.issue_identifier)); @@ -2839,11 +2894,23 @@ where fn degraded_post_review_lane_status_from_classification( project: &ServiceConfig, + state_store: &StateStore, worktree: &WorktreeMapping, + review_handoff: &ReviewHandoffMarker, issue_identifier: String, classification: PostReviewLaneClassification, -) -> OperatorPostReviewLaneStatus { - OperatorPostReviewLaneStatus { +) -> crate::prelude::Result { + let loop_status = operator_loop_status_for_run( + project, + state_store, + worktree.issue_id(), + review_handoff.run_id(), + review_handoff.attempt_number(), + Some("repair"), + None, + )?; + + Ok(OperatorPostReviewLaneStatus { issue_id: worktree.issue_id().to_owned(), issue_identifier, issue_state: String::from("tracker_readback_degraded"), @@ -2860,7 +2927,8 @@ fn degraded_post_review_lane_status_from_classification( unresolved_review_threads: classification.unresolved_review_threads, readback_warning: classification.readback_warning, readback_root_cause: classification.readback_root_cause, - } + loop_status: Some(loop_status), + }) } fn retained_issue_identifier_from_worktree(worktree: &WorktreeMapping) -> String { @@ -2991,13 +3059,13 @@ where local_branch_name, local_head_oid, }; - let mut classification = classify_post_review_lane_with_project( - &snapshot, - context.project, - context.workflow, - context.state_store, - context.review_state_inspector, - )?; + let mut classification = classify_post_review_lane_with_project( + &snapshot, + context.project, + context.workflow, + context.state_store, + context.review_state_inspector, + )?; if retry_budget_exhausted { classification = retry_budget_exhausted_post_review_lane_classification( @@ -3018,9 +3086,10 @@ where Ok(Some(post_review_lane_status_from_classification( context.project, + context.state_store, &snapshot, classification, - ))) + )?)) } fn apply_active_ownership_warning_to_post_review_lane( @@ -3043,10 +3112,13 @@ fn apply_active_ownership_warning_to_post_review_lane( fn post_review_lane_status_from_classification( project: &ServiceConfig, + state_store: &StateStore, snapshot: &PostReviewLaneSnapshot, classification: PostReviewLaneClassification, -) -> OperatorPostReviewLaneStatus { - OperatorPostReviewLaneStatus { +) -> crate::prelude::Result { + let loop_status = operator_post_review_loop_status(project, state_store, snapshot)?; + + Ok(OperatorPostReviewLaneStatus { issue_id: snapshot.issue.id.clone(), issue_identifier: snapshot.issue.identifier.clone(), issue_state: snapshot.issue.state.name.clone(), @@ -3066,7 +3138,29 @@ fn post_review_lane_status_from_classification( unresolved_review_threads: classification.unresolved_review_threads, readback_warning: classification.readback_warning, readback_root_cause: classification.readback_root_cause, - } + loop_status, + }) +} + +fn operator_post_review_loop_status( + project: &ServiceConfig, + state_store: &StateStore, + snapshot: &PostReviewLaneSnapshot, +) -> crate::prelude::Result> { + let Some(review_handoff) = snapshot.review_handoff.as_ref() else { + return Ok(None); + }; + + operator_loop_status_for_run( + project, + state_store, + &snapshot.issue.id, + review_handoff.run_id(), + review_handoff.attempt_number(), + Some("repair"), + None, + ) + .map(Some) } fn post_review_lane_static_block_reason( @@ -4032,6 +4126,7 @@ fn blocked_post_review_lane_status( readback_warning: None, readback_root_cause: post_review_readback_root_cause_for_reason(reason) .map(|root_cause| root_cause.as_str().to_owned()), + loop_status: None, } } @@ -4634,6 +4729,7 @@ fn append_primary_account_if_missing( fn operator_run_status( project: &ServiceConfig, + state_store: &StateStore, project_display_name: &str, run: ProjectRunStatus, now_unix_epoch: i64, @@ -4656,7 +4752,7 @@ fn operator_run_status( marker.as_ref().and_then(RunActivityMarker::retry_ready_at_unix_epoch), now_unix_epoch, ); - let (phase, mut wait_reason) = classify_operator_run_phase( + let (phase, wait_reason) = classify_operator_run_phase( &status, retry_kind.as_deref(), retry_ready_at_unix_epoch, @@ -4680,14 +4776,7 @@ fn operator_run_status( timing.protocol_idle_for_seconds, matches!(status.as_str(), "starting" | "running"), ); - - if wait_reason.is_none() && phase == "executing" { - wait_reason = protocol_activity - .as_ref() - .and_then(|summary| summary.waiting_reason.clone()) - .filter(|reason| reason != "turn_completed"); - } - + let wait_reason = operator_run_wait_reason(&phase, wait_reason, protocol_activity.as_ref()); let (account, accounts) = operator_run_accounts(marker.as_ref()); let branch_name = run.branch_name().map(str::to_owned); let worktree_path = operator_run_relative_worktree_path(project, &run); @@ -4697,6 +4786,8 @@ fn operator_run_status( worktree_path.as_deref(), ); let private_evidence = operator_run_private_evidence(project, &run, issue_identifier.as_deref()); + let loop_status = + operator_run_loop_status(project, state_store, &run, &status, &phase, ¤t_operation)?; let control_capability = operator_run_control_capability(&run, &app_server_state); let execution_liveness = operator_run_execution_liveness(&status, &timing, &app_server_state, &protocol_summary); @@ -4737,6 +4828,7 @@ fn operator_run_status( last_event_at: protocol_summary.last_event_at, event_count: protocol_summary.event_count, private_evidence, + loop_status: Some(loop_status), control_capability, process_id: timing.process_id, process_alive: timing.process_alive, @@ -4758,6 +4850,20 @@ fn operator_run_status( }) } +fn operator_run_wait_reason( + phase: &str, + wait_reason: Option, + protocol_activity: Option<&ProtocolActivitySummary>, +) -> Option { + if wait_reason.is_some() || phase != "executing" { + return wait_reason; + } + + protocol_activity + .and_then(|summary| summary.waiting_reason.clone()) + .filter(|reason| reason != "turn_completed") +} + fn operator_run_accounts( marker: Option<&RunActivityMarker>, ) -> (Option, Vec) { @@ -4791,6 +4897,401 @@ fn operator_run_private_evidence( ) } +fn operator_run_loop_status( + project: &ServiceConfig, + state_store: &StateStore, + run: &ProjectRunStatus, + status: &str, + phase: &str, + current_operation: &str, +) -> crate::prelude::Result { + operator_loop_status_for_run( + project, + state_store, + run.issue_id(), + run.run_id(), + run.attempt_number(), + operator_run_default_review_phase(status, phase, current_operation), + operator_run_lifecycle_loop_summary(status, phase, current_operation), + ) +} + +fn operator_run_default_review_phase( + status: &str, + phase: &str, + current_operation: &str, +) -> Option<&'static str> { + if operator_run_has_terminal_lifecycle(status, phase, current_operation) { + return None; + } + + Some("handoff") +} + +fn operator_run_lifecycle_loop_summary( + status: &str, + phase: &str, + current_operation: &str, +) -> Option { + operator_run_has_terminal_lifecycle(status, phase, current_operation) + .then(|| format!("terminal lifecycle: {status}")) +} + +fn operator_run_has_terminal_lifecycle( + status: &str, + phase: &str, + current_operation: &str, +) -> bool { + phase == "completed" + || current_operation == "ledger_outcome" + || matches!( + status, + "succeeded" + | "failed" + | "interrupted" + | "cleanup_complete" + | "closeout" + | "landed" + | "manual_attention" + | TERMINAL_GUARDED_RUN_STATUS + ) +} + +fn operator_loop_status_for_run( + project: &ServiceConfig, + state_store: &StateStore, + issue_id: &str, + run_id: &str, + attempt_number: i64, + default_review_phase: Option<&str>, + lifecycle_summary: Option, +) -> crate::prelude::Result { + let review_level = project.codex().review_level(); + let review = operator_review_loop_status( + review_level, + state_store, + project.service_id(), + issue_id, + run_id, + attempt_number, + default_review_phase, + )?; + let events = state_store.list_private_execution_events( + project.service_id(), + issue_id, + run_id, + attempt_number, + )?; + let architecture_recovery = events + .iter() + .rev() + .find_map(operator_architecture_recovery_status_from_event); + let boundary = events.iter().rev().find_map(operator_boundary_status_from_event); + let decision_request = events + .iter() + .rev() + .find(|event| event.event_type() == AUTHORITY_DECISION_REQUEST_EVENT_TYPE) + .and_then(operator_authority_decision_request_status_from_event); + let autonomy = operator_loop_autonomy( + boundary.as_ref(), + architecture_recovery.as_ref(), + decision_request.as_ref(), + ); + let summary = operator_loop_status_summary( + review.as_ref(), + architecture_recovery.as_ref(), + boundary.as_ref(), + decision_request.as_ref(), + autonomy, + lifecycle_summary.as_deref(), + ); + let next_action = operator_loop_status_next_action( + review.as_ref(), + architecture_recovery.as_ref(), + boundary.as_ref(), + decision_request.as_ref(), + ); + + Ok(OperatorLoopStatus { + review_level: review_level.as_str().to_owned(), + autonomy: autonomy.to_owned(), + summary, + next_action, + review, + architecture_recovery, + boundary, + decision_request, + }) +} + +fn operator_review_loop_status( + review_level: ReviewLevel, + state_store: &StateStore, + project_id: &str, + issue_id: &str, + run_id: &str, + attempt_number: i64, + default_review_phase: Option<&str>, +) -> crate::prelude::Result> { + let latest_checkpoint = ["handoff", "repair"] + .into_iter() + .filter_map(|phase| { + state_store + .review_policy_checkpoint(project_id, issue_id, run_id, attempt_number, phase) + .transpose() + }) + .collect::>>()? + .into_iter() + .max_by(|left, right| { + left.updated_at_unix() + .cmp(&right.updated_at_unix()) + .then_with(|| left.phase().cmp(right.phase())) + }); + + if let Some(checkpoint) = latest_checkpoint { + let nonclean_rounds = checkpoint.nonclean_rounds(); + + return Ok(Some(OperatorReviewLoopStatus { + phase: checkpoint.phase().to_owned(), + status: checkpoint.status().to_owned(), + checkpoint: Some(OperatorReviewCheckpointStatus { + head_sha: checkpoint.head_sha().to_owned(), + round: nonclean_rounds, + nonclean_rounds, + updated_at: checkpoint.updated_at().to_owned(), + }), + })); + } + + if review_level.requires_review_checkpoint() + && let Some(default_review_phase) = default_review_phase + { + return Ok(Some(OperatorReviewLoopStatus { + phase: default_review_phase.to_owned(), + status: String::from("pending"), + checkpoint: None, + })); + } + + Ok(None) +} + +fn operator_architecture_recovery_status_from_event( + event: &PrivateExecutionEvent, +) -> Option { + if !matches!( + event.event_type(), + ARCHITECTURE_RECOVERY_PACKET_EVENT_TYPE + | ARCHITECTURE_RECOVERY_STARTED_EVENT_TYPE + | ARCHITECTURE_RECOVERY_TERMINAL_EVENT_TYPE + ) { + return None; + } + + let payload = event.payload(); + let reason_code = payload.get("reason_code")?.as_str()?.to_owned(); + let guardrail_reason = payload + .get("guardrail_reason") + .and_then(Value::as_str) + .or_else(|| { + payload + .get("loop_guardrail") + .and_then(|guardrail| guardrail.get("reason")) + .and_then(Value::as_str) + }) + .map(str::to_owned); + let boundary_disposition = payload + .get("boundary_disposition") + .and_then(Value::as_str) + .or_else(|| { + payload + .get("authority_boundary_check") + .and_then(|boundary| boundary.get("disposition")) + .and_then(Value::as_str) + }) + .map(str::to_owned); + let recovery_budget_attempt = payload + .get("recovery_budget") + .and_then(|budget| budget.get("attempt")) + .and_then(Value::as_u64); + let recovery_budget_max_attempts = payload + .get("recovery_budget") + .and_then(|budget| budget.get("max_attempts")) + .and_then(Value::as_u64); + let budget = recovery_budget_attempt + .zip(recovery_budget_max_attempts) + .map(|(attempt, max_attempts)| OperatorRecoveryBudgetStatus { attempt, max_attempts }); + let next_action = operator_architecture_recovery_next_action(&reason_code); + + Some(OperatorArchitectureRecoveryStatus { + status: operator_architecture_recovery_status_for_reason(&reason_code).to_owned(), + reason_code, + guardrail_reason, + boundary_disposition, + round: recovery_budget_attempt, + budget, + next_action, + }) +} + +fn operator_architecture_recovery_status_for_reason(reason_code: &str) -> &'static str { + match reason_code { + "architecture_recovery_started" => "active", + "architecture_recovery_exhausted" => "exhausted", + "contract_boundary_required" | "external_dependency_required" => "human_required", + _ => "terminal", + } +} + +fn operator_architecture_recovery_next_action(reason_code: &str) -> String { + match reason_code { + "architecture_recovery_started" => { + String::from("Retry with a materially different implementation strategy inside authority.") + }, + "architecture_recovery_exhausted" => { + String::from("Require a new accepted recovery strategy or architecture decision before retrying.") + }, + "external_dependency_required" => { + String::from("Resolve the dependency or Execution Program readiness blocker before retrying.") + }, + "contract_boundary_required" => { + String::from("Resolve the Decision Contract or Authority Envelope boundary before retrying.") + }, + _ => String::from("Inspect the Architecture Recovery Packet before retrying."), + } +} + +fn operator_boundary_status_from_event( + event: &PrivateExecutionEvent, +) -> Option { + if event.event_type() != AUTHORITY_BOUNDARY_CHECK_EVENT_TYPE { + return None; + } + + let payload = event.payload(); + let disposition = payload + .get("final_disposition") + .and_then(|final_disposition| final_disposition.get("disposition")) + .and_then(Value::as_str) + .or_else(|| payload.get("disposition").and_then(Value::as_str))? + .to_owned(); + let reason = payload + .get("final_disposition") + .and_then(|final_disposition| final_disposition.get("reason")) + .and_then(Value::as_str) + .map(str::to_owned); + let attempted_recovery_reason = payload + .get("attempted_recovery_reason") + .and_then(Value::as_str) + .map(str::to_owned); + let changed_surface_count = payload + .get("changed_surfaces") + .and_then(Value::as_array) + .map_or(0, Vec::len); + let improvement_signal_count = payload + .get("improvement_signals") + .and_then(Value::as_array) + .map_or(0, Vec::len); + + Some(OperatorBoundaryStatus { + disposition, + reason, + attempted_recovery_reason, + changed_surface_count, + improvement_signal_count, + }) +} + +fn operator_loop_autonomy( + boundary: Option<&OperatorBoundaryStatus>, + architecture_recovery: Option<&OperatorArchitectureRecoveryStatus>, + decision_request: Option<&OperatorAuthorityDecisionRequestStatus>, +) -> &'static str { + if decision_request.is_some() { + return "human_required"; + } + if boundary.is_some_and(|boundary| { + matches!(boundary.disposition.as_str(), "requires_human" | "insufficient_evidence") + }) { + return "human_required"; + } + if architecture_recovery.is_some_and(|recovery| recovery.status != "active") { + return "human_required"; + } + + "autonomous" +} + +fn operator_loop_status_summary( + review: Option<&OperatorReviewLoopStatus>, + architecture_recovery: Option<&OperatorArchitectureRecoveryStatus>, + boundary: Option<&OperatorBoundaryStatus>, + decision_request: Option<&OperatorAuthorityDecisionRequestStatus>, + autonomy: &str, + lifecycle_summary: Option<&str>, +) -> String { + if let Some(request) = decision_request { + return format!( + "human-required boundary stop: {} on {}", + request.reason, request.boundary + ); + } + if let Some(recovery) = architecture_recovery { + return format!( + "architecture recovery {}: {}", + recovery.status, recovery.reason_code + ); + } + if let Some(review) = review { + return format!("review {}: {}", review.phase, review.status); + } + if let Some(boundary) = boundary { + return format!("boundary check: {}", boundary.disposition); + } + if let Some(lifecycle_summary) = lifecycle_summary { + return lifecycle_summary.to_owned(); + } + + format!("loop autonomy: {autonomy}") +} + +fn operator_loop_status_next_action( + review: Option<&OperatorReviewLoopStatus>, + architecture_recovery: Option<&OperatorArchitectureRecoveryStatus>, + boundary: Option<&OperatorBoundaryStatus>, + decision_request: Option<&OperatorAuthorityDecisionRequestStatus>, +) -> Option { + if let Some(request) = decision_request { + return Some(request.next_action.clone()); + } + if let Some(recovery) = architecture_recovery { + return Some(recovery.next_action.clone()); + } + if let Some(boundary) = boundary + && matches!(boundary.disposition.as_str(), "requires_human" | "insufficient_evidence") + { + return Some(String::from( + "Resolve the Authority Boundary Check before retrying the lane.", + )); + } + + review.and_then(|review| match review.status.as_str() { + "pending" => Some(String::from( + "Record the independent Decodex Review checkpoint for the current lane head.", + )), + "findings" => Some(String::from( + "Repair validated review findings and record a fresh checkpoint.", + )), + "blocked" => Some(String::from( + "Resolve the blocked Decodex Review before continuing.", + )), + "needs_architecture_review" => Some(String::from( + "Get architecture direction before continuing review repair.", + )), + _ => None, + }) +} + fn operator_run_control_capability( run: &ProjectRunStatus, app_server_state: &OperatorRunAppServerState, @@ -5459,8 +5960,14 @@ fn append_rendered_post_review_lanes(output: &mut String, snapshot: &OperatorSta } for lane in &snapshot.post_review_lanes { + let loop_status = render_loop_status_summary(lane.loop_status.as_ref()); + let loop_review = render_loop_review_summary(lane.loop_status.as_ref()); + let loop_architecture_recovery = + render_loop_architecture_recovery_summary(lane.loop_status.as_ref()); + let loop_boundary = render_loop_boundary_summary(lane.loop_status.as_ref()); + output.push_str(&format!( - "- issue_id: {}\n issue: {}\n state: {}\n classification: {}\n reason: {}\n branch: {}\n worktree_path: {}\n pr_url: {}\n pr_head_sha: {}\n pr_state: {}\n review_decision: {}\n mergeable: {}\n check_state: {}\n unresolved_review_threads: {}\n readback_warning: {}\n readback_root_cause: {}\n", + "- issue_id: {}\n issue: {}\n state: {}\n classification: {}\n reason: {}\n branch: {}\n worktree_path: {}\n pr_url: {}\n pr_head_sha: {}\n pr_state: {}\n review_decision: {}\n mergeable: {}\n check_state: {}\n unresolved_review_threads: {}\n readback_warning: {}\n readback_root_cause: {}\n loop_status: {}\n loop_review: {}\n loop_architecture_recovery: {}\n loop_boundary: {}\n", lane.issue_id, lane.issue_identifier, lane.issue_state, @@ -5478,7 +5985,11 @@ fn append_rendered_post_review_lanes(output: &mut String, snapshot: &OperatorSta .unresolved_review_threads .map_or_else(|| String::from("none"), |value| value.to_string()), lane.readback_warning.as_deref().unwrap_or("none"), - lane.readback_root_cause.as_deref().unwrap_or("none") + lane.readback_root_cause.as_deref().unwrap_or("none"), + loop_status, + loop_review, + loop_architecture_recovery, + loop_boundary )); } } @@ -5907,8 +6418,14 @@ fn append_rendered_queued_issue( )); if let Some(attention) = &queued_issue.attention { + let loop_status = render_loop_status_summary(attention.loop_status.as_ref()); + let loop_review = render_loop_review_summary(attention.loop_status.as_ref()); + let loop_architecture_recovery = + render_loop_architecture_recovery_summary(attention.loop_status.as_ref()); + let loop_boundary = render_loop_boundary_summary(attention.loop_status.as_ref()); + output.push_str(&format!( - " attention: {}\n attention_run: {}\n attention_attempt: {}\n attention_operation: {}\n attention_thread: {}\n attention_cause: {}\n attention_next_action: {}\n attention_auto_retry: {}\n attention_retry_budget_attempts: {}\n attention_worktree: {}\n attention_last_activity: {}\n", + " attention: {}\n attention_run: {}\n attention_attempt: {}\n attention_operation: {}\n attention_thread: {}\n attention_cause: {}\n attention_next_action: {}\n attention_auto_retry: {}\n attention_retry_budget_attempts: {}\n attention_worktree: {}\n attention_last_activity: {}\n loop_status: {}\n loop_review: {}\n loop_architecture_recovery: {}\n loop_boundary: {}\n", attention.summary, attention.run_id.as_deref().unwrap_or("none"), attention @@ -5924,6 +6441,10 @@ fn append_rendered_queued_issue( .map_or_else(|| String::from("none"), |value| value.to_string()), attention.worktree_path.as_deref().unwrap_or("none"), attention.last_activity_at.as_deref().unwrap_or("none"), + loop_status, + loop_review, + loop_architecture_recovery, + loop_boundary )); if let Some(decision_request) = attention.decision_request.as_ref() { @@ -6039,6 +6560,70 @@ fn render_protocol_activity_summary(summary: Option<&ProtocolActivitySummary>) - format!("turn={turn}; waiting={wait}; rate_limit={rate_limit}; recent={recent}") } +fn render_loop_status_summary(status: Option<&OperatorLoopStatus>) -> String { + let Some(status) = status else { + return String::from("none"); + }; + let next_action = status.next_action.as_deref().unwrap_or("none"); + + format!( + "{}; review_level={}; autonomy={}; next_action={next_action}", + status.summary, status.review_level, status.autonomy + ) +} + +fn render_loop_review_summary(status: Option<&OperatorLoopStatus>) -> String { + let Some(review) = status.and_then(|status| status.review.as_ref()) else { + return String::from("none"); + }; + let checkpoint = review.checkpoint.as_ref().map_or_else( + || String::from("checkpoint=none"), + |checkpoint| { + format!( + "checkpoint=head:{} round:{} updated:{}", + checkpoint.head_sha, checkpoint.round, checkpoint.updated_at + ) + }, + ); + + format!("phase={} status={} {checkpoint}", review.phase, review.status) +} + +fn render_loop_architecture_recovery_summary(status: Option<&OperatorLoopStatus>) -> String { + let Some(recovery) = status.and_then(|status| status.architecture_recovery.as_ref()) else { + return String::from("none"); + }; + let budget = recovery.budget.as_ref().map_or_else( + || String::from("none"), + |budget| format!("{}/{}", budget.attempt, budget.max_attempts), + ); + + format!( + "status={} reason={} guardrail={} boundary={} budget={} next_action={}", + recovery.status, + recovery.reason_code, + recovery.guardrail_reason.as_deref().unwrap_or("none"), + recovery.boundary_disposition.as_deref().unwrap_or("none"), + budget, + recovery.next_action + ) +} + +fn render_loop_boundary_summary(status: Option<&OperatorLoopStatus>) -> String { + let Some(boundary) = status.and_then(|status| status.boundary.as_ref()) else { + return String::from("none"); + }; + + format!( + "disposition={} reason={} attempted_recovery={} changed_surfaces={} improvement_signals={}", + boundary.disposition, + boundary.reason.as_deref().unwrap_or("none"), + boundary.attempted_recovery_reason.as_deref().unwrap_or("none"), + boundary.changed_surface_count, + boundary.improvement_signal_count + ) +} + fn render_control_capability_summary( capability: Option<&OperatorRunControlCapability>, ) -> String { @@ -6343,10 +6928,15 @@ fn append_rendered_run(output: &mut String, run: &OperatorRunStatus) { let account = render_account_summary(run.account.as_ref()); let accounts = render_accounts_summary(&run.accounts); let private_evidence = render_private_evidence_reference(run); + let loop_status = render_loop_status_summary(run.loop_status.as_ref()); + let loop_review = render_loop_review_summary(run.loop_status.as_ref()); + let loop_architecture_recovery = + render_loop_architecture_recovery_summary(run.loop_status.as_ref()); + let loop_boundary = render_loop_boundary_summary(run.loop_status.as_ref()); let control_capability = render_control_capability_summary(run.control_capability.as_ref()); output.push_str(&format!( - "- run_id: {}\n project_id: {}\n issue_id: {}\n issue_identifier: {}\n title: {}\n attempt: {}\n status: {}\n attempt_status: {}\n phase: {}\n wait_reason: {}\n current_operation: {}\n active_lease: {}\n queue_lease_state: {}\n queue_lease: {}\n execution_liveness: {}\n freshness_at: {}\n freshness_source: {}\n timing: run_idle={} protocol_idle={} last_progress={} protocol_event={} events={}\n account: {}\n accounts: {}\n child_agent_activity: {}\n protocol_activity: {}\n context_pressure: {}\n private_evidence: {}\n control_capability: {}\n thread_id: {}\n turn_id: {}\n thread_status: {}\n thread_active_flags: {}\n interactive_requested: {}\n continuation_pending: {}\n branch: {}\n worktree_path: {}\n updated_at: {}\n last_run_activity_at: {}\n last_protocol_activity_at: {}\n last_progress_at: {}\n idle_for_seconds: {}\n protocol_idle_for_seconds: {}\n suspected_stall: {}\n process_id: {}\n process_alive: {}\n process_liveness_reason: {}\n retry_kind: {}\n next_retry_at: {}\n effective_model: {}\n effective_model_provider: {}\n effective_cwd: {}\n effective_approval_policy: {}\n effective_approvals_reviewer: {}\n effective_sandbox_mode: {}\n protocol_event: {}\n event_count: {}\n", + "- run_id: {}\n project_id: {}\n issue_id: {}\n issue_identifier: {}\n title: {}\n attempt: {}\n status: {}\n attempt_status: {}\n phase: {}\n wait_reason: {}\n current_operation: {}\n active_lease: {}\n queue_lease_state: {}\n queue_lease: {}\n execution_liveness: {}\n freshness_at: {}\n freshness_source: {}\n timing: run_idle={} protocol_idle={} last_progress={} protocol_event={} events={}\n account: {}\n accounts: {}\n child_agent_activity: {}\n protocol_activity: {}\n context_pressure: {}\n private_evidence: {}\n loop_status: {}\n loop_review: {}\n loop_architecture_recovery: {}\n loop_boundary: {}\n control_capability: {}\n thread_id: {}\n turn_id: {}\n thread_status: {}\n thread_active_flags: {}\n interactive_requested: {}\n continuation_pending: {}\n branch: {}\n worktree_path: {}\n updated_at: {}\n last_run_activity_at: {}\n last_protocol_activity_at: {}\n last_progress_at: {}\n idle_for_seconds: {}\n protocol_idle_for_seconds: {}\n suspected_stall: {}\n process_id: {}\n process_alive: {}\n process_liveness_reason: {}\n retry_kind: {}\n next_retry_at: {}\n effective_model: {}\n effective_model_provider: {}\n effective_cwd: {}\n effective_approval_policy: {}\n effective_approvals_reviewer: {}\n effective_sandbox_mode: {}\n protocol_event: {}\n event_count: {}\n", run.run_id, run.project_id, run.issue_id, @@ -6375,6 +6965,10 @@ fn append_rendered_run(output: &mut String, run: &OperatorRunStatus) { protocol_activity, context_pressure, private_evidence, + loop_status, + loop_review, + loop_architecture_recovery, + loop_boundary, control_capability, thread_id, turn_id, diff --git a/apps/decodex/src/orchestrator/tests/operator/status/agent_evidence.rs b/apps/decodex/src/orchestrator/tests/operator/status/agent_evidence.rs index 47265f96c..d8f1cecf4 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status/agent_evidence.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status/agent_evidence.rs @@ -1,5 +1,6 @@ use orchestrator::HarnessOutcomeKind; use orchestrator::HarnessOutcomeRecordInput; +use orchestrator::PrivateEvidenceReadback; use crate::loop_contract::DecisionContract; @@ -516,7 +517,7 @@ fn private_evidence_readback_direct_lookup_uses_stored_issue_id() { } #[test] -fn harness_outcome_records_validation_review_and_repair_signals() { +fn agent_evidence_harness_outcome_records_validation_review_and_repair_signals() { let (_temp_dir, config, _workflow) = temp_project_layout(); let state_store = StateStore::open_in_memory().expect("state store should open"); @@ -544,6 +545,28 @@ fn harness_outcome_records_validation_review_and_repair_signals() { let payload = recorded.payload(); assert_eq!(recorded.event_type(), "harness_outcome"); + + assert_harness_outcome_payload(payload); + + let request = EvidenceRequest { + config_path: None, + issue: "issue-harness", + run_id: Some("run-harness"), + attempt_number: Some(2), + json: true, + include_payload: false, + }; + let readback = orchestrator::build_private_evidence_readback( + &state_store, + &config, + &request, + ) + .expect("private evidence should read"); + + assert_harness_private_readback(&readback); +} + +fn assert_harness_outcome_payload(payload: &Value) { assert_eq!(payload["schema"], "decodex.harness_outcome/1"); assert_eq!(payload["validation"]["result"], "failed"); assert_eq!(payload["validation"]["failure_count"], 2); @@ -555,43 +578,23 @@ fn harness_outcome_records_validation_review_and_repair_signals() { assert_eq!(payload["review"]["accepted_finding_count"], 1); assert_eq!(payload["authority_boundary"]["failed_check_count"], 1); assert_eq!(payload["authority_boundary"]["improvement_signal_count"], 1); + + assert_harness_payload_candidate(payload, "accepted_review_findings"); + assert_harness_payload_candidate(payload, "authority_underspecified"); + assert_harness_payload_candidate(payload, "architecture_recovery_exhausted"); +} + +fn assert_harness_payload_candidate(payload: &Value, reason_code: &str) { assert!( payload["improvement_candidates"] .as_array() .expect("candidates should be an array") .iter() - .any(|candidate| candidate["reason_code"] == "accepted_review_findings") - ); - assert!( - payload["improvement_candidates"] - .as_array() - .expect("candidates should be an array") - .iter() - .any(|candidate| candidate["reason_code"] == "authority_underspecified") - ); - assert!( - payload["improvement_candidates"] - .as_array() - .expect("candidates should be an array") - .iter() - .any(|candidate| candidate["reason_code"] == "architecture_recovery_exhausted") + .any(|candidate| candidate["reason_code"] == reason_code) ); +} - let request = EvidenceRequest { - config_path: None, - issue: "issue-harness", - run_id: Some("run-harness"), - attempt_number: Some(2), - json: true, - include_payload: false, - }; - let readback = orchestrator::build_private_evidence_readback( - &state_store, - &config, - &request, - ) - .expect("private evidence should read"); - +fn assert_harness_private_readback(readback: &PrivateEvidenceReadback) { assert!( readback .improvement_candidates @@ -619,6 +622,27 @@ fn harness_outcome_records_validation_review_and_repair_signals() { && recovery.recovery_budget_max_attempts == Some(1) }) ); + assert!(readback.review_checkpoints.iter().any(|checkpoint| { + checkpoint.phase == "handoff" + && checkpoint.status == "findings" + && checkpoint.head_sha.as_deref() == Some("abc123") + && checkpoint.round == Some(1) + && checkpoint.accepted_finding_count == 1 + })); + assert!(readback.boundary_checks.iter().any(|boundary| { + boundary.disposition == "requires_human" + && boundary.attempted_recovery_reason.as_deref() == Some("uncovered_direction") + && boundary.changed_surface_count == 1 + && boundary.improvement_signal_count == 1 + })); + + let rendered = orchestrator::render_private_evidence_readback(readback); + + assert!(rendered.contains("Review Checkpoints")); + assert!(rendered.contains("Architecture Recoveries")); + assert!(rendered.contains("Boundary Checks")); + assert!(rendered.contains("status: findings")); + assert!(rendered.contains("disposition: requires_human")); assert!(readback.events.iter().all(|event| event.payload.is_none())); } diff --git a/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs b/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs index 85591c2a0..e9b2ca229 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs @@ -31,6 +31,18 @@ fn operator_app_snapshot_endpoint_returns_json() { assert!(response.ends_with("\r\n\r\n{}")); } +#[test] +fn operator_dashboard_surfaces_loop_status_fields() { + let response = dashboard_response(); + + assert!(response.contains("function loopStatusFacts(loopStatus)")); + assert!(response.contains("function loopStatusInline(loopStatus)")); + assert!(response.contains("loopStatusInline(run.loop_status)")); + assert!(response.contains("loopStatusFacts(run.loop_status)")); + assert!(response.contains("loopStatusFacts(lane.loop_status)")); + assert!(response.contains("loopStatusFacts(attention.loop_status)")); +} + #[test] fn operator_dashboard_background_wash_stays_viewport_fixed() { let response = dashboard_response(); diff --git a/apps/decodex/src/orchestrator/tests/operator/status/history.rs b/apps/decodex/src/orchestrator/tests/operator/status/history.rs index 174685037..e77caf70c 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status/history.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status/history.rs @@ -377,6 +377,15 @@ fn local_operator_history_lanes_prefer_terminal_ledger_outcome() { assert_eq!(lane.latest_run.status, "closeout"); assert_eq!(lane.latest_run.attempt_status, "closeout"); assert_eq!(lane.latest_run.phase, "completed"); + assert_eq!( + lane + .latest_run + .loop_status + .as_ref() + .expect("terminal history should keep loop readback") + .summary, + "terminal lifecycle: closeout" + ); assert_eq!(lane.ledger_outcome.final_outcome, "closeout"); assert_eq!(lane.attempts.len(), 1); assert_eq!(lane.attempts[0].status, "failed"); @@ -384,6 +393,10 @@ fn local_operator_history_lanes_prefer_terminal_ledger_outcome() { snapshot_json["history_lanes"][0]["latest_run"]["status"], "closeout" ); + assert_eq!( + snapshot_json["history_lanes"][0]["latest_run"]["loop_status"]["summary"], + "terminal lifecycle: closeout" + ); assert_eq!( snapshot_json["history_lanes"][0]["attempts"][0]["status"], "failed" diff --git a/apps/decodex/src/orchestrator/tests/operator/status/text.rs b/apps/decodex/src/orchestrator/tests/operator/status/text.rs index 7101cfaef..08a9e3841 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status/text.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status/text.rs @@ -1,3 +1,5 @@ +use state::ReviewPolicyCheckpointInput; + #[test] fn operator_status_text_surfaces_github_cli_authority() { let snapshot = OperatorStatusSnapshot { @@ -197,6 +199,201 @@ fn operator_status_text_surfaces_execution_program_summary() { )); } +#[test] +fn operator_status_json_and_text_surface_loop_review_and_recovery_state() { + let (_temp_dir, config, _workflow) = temp_project_layout(); + let state_store = StateStore::open_in_memory().expect("state store should open"); + + seed_loop_status_runs(&state_store, &config); + seed_loop_status_review_checkpoints(&state_store, &config); + seed_loop_status_private_events(&state_store, &config); + + let snapshot = orchestrator::build_operator_status_snapshot(&config, &state_store, 10) + .expect("snapshot should build"); + let snapshot_json = serde_json::to_value(&snapshot).expect("snapshot should serialize"); + let pending = active_run_json(&snapshot_json, "run-pending"); + let clean = active_run_json(&snapshot_json, "run-clean"); + let findings = active_run_json(&snapshot_json, "run-findings"); + let blocked = active_run_json(&snapshot_json, "run-blocked"); + + assert_eq!(pending["loop_status"]["review_level"], "strict"); + assert_eq!(pending["loop_status"]["review"]["status"], "pending"); + assert_eq!(clean["loop_status"]["review"]["status"], "clean"); + assert_eq!( + clean["loop_status"]["review"]["checkpoint"]["head_sha"], + "1111111111111111111111111111111111111111" + ); + assert_eq!(findings["loop_status"]["review"]["status"], "findings"); + assert_eq!(findings["loop_status"]["review"]["checkpoint"]["round"], 2); + assert_eq!( + findings["loop_status"]["architecture_recovery"]["status"], + "active" + ); + assert_eq!(findings["loop_status"]["autonomy"], "autonomous"); + assert_eq!(blocked["loop_status"]["review"]["status"], "blocked"); + assert_eq!( + blocked["loop_status"]["architecture_recovery"]["status"], + "exhausted" + ); + assert_eq!( + blocked["loop_status"]["boundary"]["disposition"], + "requires_human" + ); + assert_eq!(blocked["loop_status"]["autonomy"], "human_required"); + assert_eq!( + blocked["loop_status"]["decision_request"]["decision_request_id"], + "dr-pub-874-1" + ); + + let rendered = orchestrator::render_operator_status(&snapshot); + + assert!(rendered.contains("loop_review: phase=handoff status=pending checkpoint=none")); + assert!(rendered.contains("loop_review: phase=handoff status=findings checkpoint=head:2222222222222222222222222222222222222222 round:2")); + assert!(rendered.contains( + "loop_architecture_recovery: status=active reason=architecture_recovery_started" + )); + assert!(rendered.contains( + "loop_status: human-required boundary stop: contract_boundary_required on accepted_behavior; review_level=strict; autonomy=human_required" + )); + assert!(rendered.contains( + "loop_boundary: disposition=requires_human reason=accepted behavior would change attempted_recovery=review_churn" + )); +} + +fn seed_loop_status_runs(state_store: &StateStore, config: &ServiceConfig) { + for (issue_id, run_id) in [ + ("issue-pending", "run-pending"), + ("issue-clean", "run-clean"), + ("issue-findings", "run-findings"), + ("issue-blocked", "run-blocked"), + ] { + state_store + .record_run_attempt(run_id, issue_id, 1, "running") + .expect("run attempt should record"); + state_store + .upsert_lease(config.service_id(), issue_id, run_id, "In Progress") + .expect("lease should record"); + } +} + +fn seed_loop_status_review_checkpoints(state_store: &StateStore, config: &ServiceConfig) { + state_store + .upsert_review_policy_checkpoint(ReviewPolicyCheckpointInput { + project_id: config.service_id(), + issue_id: "issue-clean", + run_id: "run-clean", + attempt_number: 1, + phase: "handoff", + status: "clean", + head_sha: "1111111111111111111111111111111111111111", + nonclean_rounds: 0, + details_json: "{}", + }) + .expect("clean checkpoint should record"); + state_store + .upsert_review_policy_checkpoint(ReviewPolicyCheckpointInput { + project_id: config.service_id(), + issue_id: "issue-findings", + run_id: "run-findings", + attempt_number: 1, + phase: "handoff", + status: "findings", + head_sha: "2222222222222222222222222222222222222222", + nonclean_rounds: 2, + details_json: "{}", + }) + .expect("findings checkpoint should record"); + state_store + .upsert_review_policy_checkpoint(ReviewPolicyCheckpointInput { + project_id: config.service_id(), + issue_id: "issue-blocked", + run_id: "run-blocked", + attempt_number: 1, + phase: "repair", + status: "blocked", + head_sha: "3333333333333333333333333333333333333333", + nonclean_rounds: 0, + details_json: "{}", + }) + .expect("blocked checkpoint should record"); +} + +fn seed_loop_status_private_events(state_store: &StateStore, config: &ServiceConfig) { + state_store + .append_private_execution_event( + config.service_id(), + "issue-findings", + "run-findings", + 1, + "architecture_recovery_started", + serde_json::json!({ + "reason_code": "architecture_recovery_started", + "guardrail_reason": "validation_repeat", + "recovery_budget": { "attempt": 1, "max_attempts": 2 }, + }), + ) + .expect("active recovery should record"); + state_store + .append_private_execution_event( + config.service_id(), + "issue-blocked", + "run-blocked", + 1, + "authority_boundary_check", + serde_json::json!({ + "attempted_recovery_reason": "review_churn", + "disposition": "requires_human", + "final_disposition": { + "disposition": "requires_human", + "reason": "accepted behavior would change", + }, + "changed_surfaces": [{ "surface": "runtime", "change_summary": "change behavior" }], + "improvement_signals": [{ "kind": "missing_validator" }], + }), + ) + .expect("boundary check should record"); + state_store + .append_private_execution_event( + config.service_id(), + "issue-blocked", + "run-blocked", + 1, + "architecture_recovery_terminal", + serde_json::json!({ + "reason_code": "architecture_recovery_exhausted", + "guardrail_reason": "review_churn", + "boundary_disposition": "requires_human", + "recovery_budget": { "attempt": 2, "max_attempts": 2 }, + }), + ) + .expect("terminal recovery should record"); + state_store + .append_private_execution_event( + config.service_id(), + "issue-blocked", + "run-blocked", + 1, + "authority_decision_request", + serde_json::json!({ + "decision_request_id": "dr-pub-874-1", + "phase": "human_required", + "reason": "contract_boundary_required", + "boundary": "accepted_behavior", + "next_action": "accept or reject the recovery direction", + }), + ) + .expect("decision request should record"); +} + +fn active_run_json<'a>(snapshot_json: &'a Value, run_id: &str) -> &'a Value { + snapshot_json["active_runs"] + .as_array() + .expect("active runs should be an array") + .iter() + .find(|run| run["run_id"] == run_id) + .unwrap_or_else(|| panic!("active run `{run_id}` should exist")) +} + #[test] fn queue_explain_renders_candidate_reasons_without_running_dispatch() { let (_temp_dir, config, _workflow) = temp_project_layout(); @@ -322,6 +519,7 @@ fn operator_status_text_surfaces_cleanup_blocker_pr_url() { unresolved_review_threads: Some(0), readback_warning: None, readback_root_cause: Some(String::from("lineage_validation_failed")), + loop_status: None, }], }; let rendered = orchestrator::render_operator_status(&snapshot); diff --git a/apps/decodex/src/orchestrator/tests/operator/status_support.rs b/apps/decodex/src/orchestrator/tests/operator/status_support.rs index ef4858f98..fd59f44ce 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status_support.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status_support.rs @@ -264,6 +264,7 @@ fn operator_status_text_active_run() -> orchestrator::OperatorRunStatus { "decodex evidence PUB-101 --run-id run-1 --attempt 1 --json", ), }, + loop_status: None, control_capability: Some(operator_status_text_control_capability()), process_id: Some(1_234), process_alive: Some(true), @@ -439,6 +440,7 @@ fn operator_status_text_post_review_lanes() -> Vec unresolved_review_threads: Some(0), readback_warning: None, readback_root_cause: None, + loop_status: None, }] } diff --git a/apps/decodex/src/orchestrator/types.rs b/apps/decodex/src/orchestrator/types.rs index 007d0a3ea..d153bac2b 100644 --- a/apps/decodex/src/orchestrator/types.rs +++ b/apps/decodex/src/orchestrator/types.rs @@ -1423,6 +1423,8 @@ struct OperatorRunStatus { last_event_at: Option, event_count: i64, private_evidence: AgentPrivateEvidenceRef, + #[serde(skip_serializing_if = "Option::is_none")] + loop_status: Option, control_capability: Option, process_id: Option, process_alive: Option, @@ -1483,6 +1485,8 @@ struct OperatorQueuedIssueAttentionStatus { current_operation: Option, thread_status: Option, attempt_status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + loop_status: Option, auto_retry_blocked_reason: Option, attention_error_class: Option, attention_next_action: Option, @@ -1509,6 +1513,59 @@ struct OperatorAuthorityDecisionRequestStatus { resume_condition: Option, } +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +struct OperatorLoopStatus { + review_level: String, + autonomy: String, + summary: String, + next_action: Option, + review: Option, + architecture_recovery: Option, + boundary: Option, + decision_request: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +struct OperatorReviewLoopStatus { + phase: String, + status: String, + checkpoint: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +struct OperatorReviewCheckpointStatus { + head_sha: String, + round: i64, + nonclean_rounds: i64, + updated_at: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +struct OperatorArchitectureRecoveryStatus { + status: String, + reason_code: String, + guardrail_reason: Option, + boundary_disposition: Option, + round: Option, + budget: Option, + next_action: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +struct OperatorRecoveryBudgetStatus { + attempt: u64, + max_attempts: u64, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +struct OperatorBoundaryStatus { + disposition: String, + reason: Option, + attempted_recovery_reason: Option, + changed_surface_count: usize, + improvement_signal_count: usize, +} + #[derive(Clone, Debug, Eq, PartialEq, Serialize)] struct OperatorWorktreeStatus { issue_id: String, @@ -1547,6 +1604,8 @@ struct OperatorPostReviewLaneStatus { unresolved_review_threads: Option, readback_warning: Option, readback_root_cause: Option, + #[serde(skip_serializing_if = "Option::is_none")] + loop_status: Option, } #[derive(Clone, Debug, Eq, PartialEq)] diff --git a/docs/reference/operator-control-plane.md b/docs/reference/operator-control-plane.md index dc89268cb..35325d5bc 100644 --- a/docs/reference/operator-control-plane.md +++ b/docs/reference/operator-control-plane.md @@ -152,6 +152,13 @@ candidate improvements by kind, reason code, target, source-event count, and recommendation. These summaries are local operator guidance; they are not Linear ledger records and do not automatically edit prompts, skills, validators, issue templates, or loop policy. +The same private-evidence readback exposes compact review checkpoint, architecture +recovery, and authority-boundary summaries for the selected run/attempt: review phase, +status, head, round, finding counts; recovery reason, boundary disposition, budget; +and boundary disposition, reason, attempted recovery, changed-surface count, and +improvement-signal count. These summaries are safe operator readback; raw reviewer +finding bodies, changed-surface payloads, retained diffs, logs, and transcripts remain +hidden unless `--include-payload` is explicitly requested for local repair. ## State Ownership @@ -246,8 +253,8 @@ protocol activity durable outside the local operator surface. | `Accounts` | Shared Codex account pool and usage table from `~/.codex/decodex/accounts.jsonl` when `[codex.accounts]` is enabled for a project. Account identity can be obscured from the `Account` column header eye without changing the underlying snapshot. The row weight column shows the capacity multiplier used for pool usage estimates: `pro` accounts count as `20x`, and all other plans count as `1x`. Usage probes read Codex `/wham/usage` for window capacity and `/wham/profiles/me` for profile token stats such as lifetime tokens, peak daily tokens, longest task, streaks, and daily token activity. Selecting an account writes the global `[codex.accounts].fixed_account` selector in `~/.codex/decodex/config.toml`; clearing it returns all new account-pool runs to balanced account selection. Account display-name rerolls write `[codex.account_names.offsets]` in the same global config so Decodex App and the dashboard share the privacy-preserving names. Theme, sort, and identity-visibility preferences are client-local presentation state. The selector is global and does not pin a project to an account. | | `Projects` | Fleet-level project table. The section-level filter toggles between active project work and the full registry. Location is its own compact path column and can be obscured from the location header eye. `Activity` shows a relative timestamp or `-`; `Work` is `running/waiting/attention`. It should not duplicate per-lane details already shown below. | | `Running Lanes` | Active leased or live-executing issue lanes. A lane here is currently owned by this local control plane, or a live process/thread/protocol marker still explains active execution even when the queue lease is not held. It shows issue identity, phase, operation, attempt, queue lease state, execution liveness, thread/protocol status, child-agent activity when captured, phase-goal status when app-server reports it, timing, branch, and worktree. | -| `Intake Queue` | Queued tracker issues before execution. Candidates are classified as `ready`, capacity-waiting, claimed without a matching local lane, blocked, or closed/stale. Repeated identical open dependency blockers surface as `dependency_program_stale` after the guardrail threshold so operators can distinguish a stale Execution Program/dependency plan from a newly blocked queue item. A blocked queued candidate can still show an attached `.worktrees/XY-*` path when the queue owns the attention state; if that worktree has tracked changes after stalled reconciliation, failure writeback, or retries, the candidate is partial retained progress and not just a generic stalled or retry-budget hold. Human-required authority stops expose their compact decision request fields here: `phase = human_required`, reason, boundary, `decision_request_id`, and `next_action`. Running lanes are not repeated as normal intake work. | -| `Review & Landing` | Retained PR lanes after review handoff. This section owns post-review repair, wait-for-review, ready-to-land, closeout, cleanup, and blocked retained-lane visibility. | +| `Intake Queue` | Queued tracker issues before execution. Candidates are classified as `ready`, capacity-waiting, claimed without a matching local lane, blocked, or closed/stale. Repeated identical open dependency blockers surface as `dependency_program_stale` after the guardrail threshold so operators can distinguish a stale Execution Program/dependency plan from a newly blocked queue item. A blocked queued candidate can still show an attached `.worktrees/XY-*` path when the queue owns the attention state; if that worktree has tracked changes after stalled reconciliation, failure writeback, or retries, the candidate is partial retained progress and not just a generic stalled or retry-budget hold. Human-required authority stops expose their compact decision request fields here: `phase = human_required`, reason, boundary, `decision_request_id`, and `next_action`. When queued attention still maps to a run/attempt, it also carries the same compact loop status used by running lanes. Running lanes are not repeated as normal intake work. | +| `Review & Landing` | Retained PR lanes after review handoff. This section owns post-review repair, wait-for-review, ready-to-land, closeout, cleanup, and blocked retained-lane visibility. Retained lanes expose compact loop status for their bound handoff run/attempt so operators can see review repair checkpoint state, architecture recovery stops, and boundary/human-required disposition without direct SQLite inspection. | | `Recovery Worktrees` | Retained local worktrees that are not currently owned by `Running Lanes`, `Review & Landing`, or queued attention in `Intake Queue`. This is the cleanup or recovery inbox for recovered paths, retained PR leftovers, and cleanup-only local worktrees. Empty is the normal healthy state. | | `Run Ledger` | Completed or non-running issue history, grouped by issue/lane. Decodex Linear execution ledger comments provide the durable completed outcome when available. If no `decodex.linear_execution_event` record exists, the row reports `missing` / `execution_ledger_missing`; the control plane does not derive a completed or landed outcome from tracker state, local attempts, or non-ledger comments. Raw local attempts and heartbeat details stay in debug expansion. | @@ -271,7 +278,10 @@ Recommended readback sequence: 4. For authority-boundary stops, inspect `decision_requests` for the public-safe decision request id, boundary, recommendation, resume condition, and next action, then use the linked Authority Boundary Check summary to audit the stop. -5. Use `--include-payload` only when compact payload summaries are insufficient for +5. For Decodex Review or architecture recovery stops, inspect `review_checkpoints`, + `architecture_recoveries`, and `boundary_checks` before deciding whether the lane + is still autonomous, exhausted, or human-required. +6. Use `--include-payload` only when compact payload summaries are insufficient for local repair. Do not paste full payloads into Linear or GitHub. The command does not require live Linear or GitHub observer access. It resolves known @@ -341,6 +351,12 @@ Worktree visibility follows the owning dashboard section: and snapshot health, not repeated in each lane debug row. These high-frequency details remain local/operator-only and are not written to Linear except through existing lifecycle summaries. +- Status JSON and the dashboard share a `loop_status` object when a row can be tied + to a runtime run/attempt. It carries `review_level`, `autonomy`, concise `summary` + and `next_action`, plus optional `review`, `architecture_recovery`, `boundary`, and + `decision_request` subobjects. Text status renders the same state as `loop_status`, + `loop_review`, `loop_architecture_recovery`, and `loop_boundary` lines so operators + can answer what the lane is doing and why without knowing runtime event names. - Dynamic tool failures appear in local protocol activity as `item/tool/call/failure` with a normalized failure class and next action. Invalid or undeclared app-server tool requests are protocol failures; declared Decodex diff --git a/docs/spec/agent-evidence.md b/docs/spec/agent-evidence.md index 071c0a83f..beb4fd2da 100644 --- a/docs/spec/agent-evidence.md +++ b/docs/spec/agent-evidence.md @@ -165,6 +165,11 @@ The readback includes: boundary, recommendation, resume condition, and next action, but not retained worktree evidence, raw diff payloads, recovery context, transcripts, logs, or credentials unless `--include-payload` is explicitly requested for local repair. +- Decodex Review checkpoint summaries when `review_checkpoint` events are present. + Default readback may expose review phase, normalized status, head SHA, non-clean + round, accepted/rejected finding counts, and compact next action, but not raw + reviewer finding bodies or checklist payloads unless `--include-payload` is + explicitly requested for local repair. - Architecture Recovery Packet summaries when `architecture_recovery_packet`, `architecture_recovery_started`, or `architecture_recovery_terminal` events are present. Default readback may expose the recovery reason code, guardrail reason, diff --git a/docs/spec/runtime.md b/docs/spec/runtime.md index 503f50794..740f8b9a7 100644 --- a/docs/spec/runtime.md +++ b/docs/spec/runtime.md @@ -483,6 +483,12 @@ linked to the Authority Boundary Check record. Status JSON and dashboard snapsho must expose the compact request fields (`phase = human_required`, reason, boundary, `decision_request_id`, and `next_action`) so the lane is operable without inspecting SQLite directly. +Operator status JSON and dashboard snapshots also expose compact loop readback for +the same owned run/attempt when available: review level, review phase/status/round, +architecture recovery reason and budget, boundary-check disposition, and whether the +lane is still autonomous or has crossed into human-required handling. These fields are +readback only and do not replace the runtime review-policy, recovery, or boundary +decisions. Runtime-owned review-policy stops use either bounded architecture recovery or the same human-required failure path, with dedicated `error_class` values: