diff --git a/README.md b/README.md index 794698357..24917ddda 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,7 @@ decodex intake goal --project decodex --dry-run decodex intake goal --project decodex --apply decodex intake issues --project decodex XY-1 XY-2 --dry-run decodex intake issues --project decodex XY-1 XY-2 --apply +decodex recover superseded-closeout --pr --successor-issue --successor-pr --dry-run decodex mcp serve --transport stdio decodex mcp serve --transport streamable-http --listen-address 127.0.0.1:8193 decodex run --dry-run diff --git a/apps/decodex/src/cli/recovery_commands.rs b/apps/decodex/src/cli/recovery_commands.rs index e3ce6151e..50d1f407c 100644 --- a/apps/decodex/src/cli/recovery_commands.rs +++ b/apps/decodex/src/cli/recovery_commands.rs @@ -8,7 +8,10 @@ pub(in crate::cli) mod stale_active; use clap::{Args, Subcommand}; use self::{ - closeout::{LegacyCloseoutRecoveryCommand, MergedCloseoutRecoveryCommand}, + closeout::{ + LegacyCloseoutRecoveryCommand, MergedCloseoutRecoveryCommand, + SupersededCloseoutRecoveryCommand, + }, ghost_lane::GhostLaneRecoveryCommand, review_handoff::ReviewHandoffRecoveryCommand, stale_active::StaleActiveRecoveryCommand, @@ -16,7 +19,10 @@ use self::{ use crate::{ cli::ProjectConfigArgs, prelude::Result, - recovery::{self, LegacyCloseoutRecoveryRequest, MergedCloseoutRecoveryRequest}, + recovery::{ + self, LegacyCloseoutRecoveryRequest, MergedCloseoutRecoveryRequest, + SupersededCloseoutRecoveryRequest, + }, }; #[derive(Debug, Args)] @@ -50,6 +56,17 @@ impl RecoverCommand { manual_authority: args.manual_authority, }, ), + RecoverSubcommand::SupersededCloseout(args) => recovery::run_superseded_closeout( + self.project_config.as_path(), + &SupersededCloseoutRecoveryRequest { + issue: args.issue.clone(), + pr_url: args.pr.clone(), + successor_issue: args.successor_issue.clone(), + successor_pr_url: args.successor_pr.clone(), + dry_run: args.dry_run, + manual_authority: args.manual_authority, + }, + ), } } } @@ -66,4 +83,6 @@ pub(super) enum RecoverSubcommand { LegacyCloseout(LegacyCloseoutRecoveryCommand), /// Reconcile stale retained attention after a PR is already merged and cleaned up. MergedCloseout(MergedCloseoutRecoveryCommand), + /// Close an obsolete retained PR after a successor PR landed the repair lineage. + SupersededCloseout(SupersededCloseoutRecoveryCommand), } diff --git a/apps/decodex/src/cli/recovery_commands/closeout.rs b/apps/decodex/src/cli/recovery_commands/closeout.rs index 1a22dc2de..9004fda9a 100644 --- a/apps/decodex/src/cli/recovery_commands/closeout.rs +++ b/apps/decodex/src/cli/recovery_commands/closeout.rs @@ -31,3 +31,25 @@ pub(in crate::cli) struct MergedCloseoutRecoveryCommand { #[arg(long)] pub(in crate::cli) manual_authority: bool, } + +#[derive(Debug, Args)] +pub(in crate::cli) struct SupersededCloseoutRecoveryCommand { + /// Issue identifier for the obsolete retained lane. + #[arg(value_name = "ISSUE")] + pub(in crate::cli) issue: String, + /// Obsolete retained pull request URL to close. + #[arg(long, value_name = "PR_URL")] + pub(in crate::cli) pr: String, + /// Successor issue identifier that landed the accepted repair. + #[arg(long, value_name = "ISSUE")] + pub(in crate::cli) successor_issue: String, + /// Merged successor pull request URL that proves terminal code lineage. + #[arg(long, value_name = "PR_URL")] + pub(in crate::cli) successor_pr: String, + /// Validate without writing runtime, tracker, or GitHub state. + #[arg(long)] + pub(in crate::cli) dry_run: bool, + /// Required for non-dry-run superseded closeout reconciliation. + #[arg(long)] + pub(in crate::cli) manual_authority: bool, +} diff --git a/apps/decodex/src/cli/tests/recovery/closeout.rs b/apps/decodex/src/cli/tests/recovery/closeout.rs index 5bd2fd858..f4ba3d1d7 100644 --- a/apps/decodex/src/cli/tests/recovery/closeout.rs +++ b/apps/decodex/src/cli/tests/recovery/closeout.rs @@ -4,7 +4,10 @@ use crate::cli::{ Cli, Command, recovery_commands::{ RecoverCommand, RecoverSubcommand, - closeout::{LegacyCloseoutRecoveryCommand, MergedCloseoutRecoveryCommand}, + closeout::{ + LegacyCloseoutRecoveryCommand, MergedCloseoutRecoveryCommand, + SupersededCloseoutRecoveryCommand, + }, }, }; @@ -61,3 +64,38 @@ fn parses_merged_closeout_manual_authority() { && pr == "https://github.com/helixbox/pubfi-mono/pull/309" )); } + +#[test] +fn parses_superseded_closeout_manual_authority() { + let cli = Cli::parse_from([ + "decodex", + "recover", + "superseded-closeout", + "PUB-1704", + "--pr", + "https://github.com/helixbox/pubfi-mono/pull/826", + "--successor-issue", + "PUB-1705", + "--successor-pr", + "https://github.com/helixbox/pubfi-mono/pull/827", + "--manual-authority", + ]); + + assert!(matches!( + cli.command, + Command::Recover(RecoverCommand { + command: RecoverSubcommand::SupersededCloseout(SupersededCloseoutRecoveryCommand { + issue, + pr, + successor_issue, + successor_pr, + dry_run: false, + manual_authority: true, + }), + .. + }) if issue == "PUB-1704" + && pr == "https://github.com/helixbox/pubfi-mono/pull/826" + && successor_issue == "PUB-1705" + && successor_pr == "https://github.com/helixbox/pubfi-mono/pull/827" + )); +} diff --git a/apps/decodex/src/github.rs b/apps/decodex/src/github.rs index b2b9922d0..e40b414d4 100644 --- a/apps/decodex/src/github.rs +++ b/apps/decodex/src/github.rs @@ -4,6 +4,7 @@ mod comments; mod landing_state; mod locator; mod merge_readback; +mod pull_requests; mod repository; mod status; @@ -35,6 +36,7 @@ pub(crate) use merge_readback::{ commit_subject_wait_error_is_retryable, configure_admin_merge_command, merge_commit_wait_error_is_retryable, }; +pub(crate) use pull_requests::close_pull_request; pub(crate) use repository::{ RepositoryContext, inspect_repository_context, pull_request_matches_repository, }; diff --git a/apps/decodex/src/github/pull_requests.rs b/apps/decodex/src/github/pull_requests.rs new file mode 100644 index 000000000..def2b4bd1 --- /dev/null +++ b/apps/decodex/src/github/pull_requests.rs @@ -0,0 +1,73 @@ +use std::path::Path; + +use crate::{ + github::{self}, + prelude::{Result, eyre}, +}; + +pub(crate) fn close_pull_request( + cwd: &Path, + pr_url: &str, + github_token: &str, + gh_command_path: Option<&Path>, +) -> Result<()> { + let locator = github::parse_pull_request_url(pr_url)?; + let endpoint = format!("repos/{}/{}/pulls/{}", locator.owner, locator.repo, locator.number); + let mut command = github::gh_command_with_config(gh_command_path); + + command.args(["api", "--method", "PATCH", endpoint.as_str(), "-f", "state=closed"]); + command.current_dir(cwd); + + github::configure_gh_command(&mut command, github_token); + + let output = command.output()?; + + if output.status.success() { + return Ok(()); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + + eyre::bail!("Failed to close pull request `{pr_url}`: {}", stderr.trim()) +} + +#[cfg(test)] +mod tests { + use std::{fs, os::unix::fs::PermissionsExt}; + + use tempfile::TempDir; + + use super::*; + + #[test] + fn close_pull_request_patches_pull_request_state() { + let temp_dir = TempDir::new().expect("temp dir should create"); + let gh_path = temp_dir.path().join("gh"); + let log_path = temp_dir.path().join("gh.log"); + let script = format!( + r#"#!/bin/sh +printf '%s\n' "$*" > '{}' +printf '{{"state":"closed"}}' +"#, + log_path.display() + ); + + fs::write(&gh_path, script).expect("fake gh should write"); + let mut permissions = fs::metadata(&gh_path).expect("fake gh metadata").permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&gh_path, permissions).expect("fake gh should be executable"); + + close_pull_request( + temp_dir.path(), + "https://github.com/helixbox/pubfi-mono/pull/826", + "ghp_test", + Some(&gh_path), + ) + .expect("close should succeed"); + + let log = fs::read_to_string(log_path).expect("fake gh should log args"); + + assert!(log.contains("api --method PATCH repos/helixbox/pubfi-mono/pulls/826")); + assert!(log.contains("-f state=closed")); + } +} diff --git a/apps/decodex/src/orchestrator/kernel/lifecycle.rs b/apps/decodex/src/orchestrator/kernel/lifecycle.rs index be392eaa6..4471d47d1 100644 --- a/apps/decodex/src/orchestrator/kernel/lifecycle.rs +++ b/apps/decodex/src/orchestrator/kernel/lifecycle.rs @@ -13,6 +13,7 @@ pub(crate) enum LifecycleEvidenceKind { ReviewRepair, LandingIntent, LandingReadback, + CloseoutIntent, CloseoutCompletion, } impl LifecycleEvidenceKind { @@ -23,6 +24,7 @@ impl LifecycleEvidenceKind { Self::ReviewRepair => "review_repair", Self::LandingIntent => "landing_intent", Self::LandingReadback => "landing_readback", + Self::CloseoutIntent => "closeout_intent", Self::CloseoutCompletion => "closeout_completion", } } @@ -193,6 +195,8 @@ fn lifecycle_transition(kind: LifecycleEvidenceKind, outcome: LifecycleOutcome) (LifecycleEvidenceKind::LandingReadback, LifecycleOutcome::Failed) => "landing_failed", (LifecycleEvidenceKind::LandingReadback, LifecycleOutcome::NeedsManualAttention) => "manual_attention_required", + (LifecycleEvidenceKind::CloseoutIntent, LifecycleOutcome::Intent) => + "closeout_intent_recorded", (LifecycleEvidenceKind::CloseoutCompletion, LifecycleOutcome::Succeeded) => "closeout_completed", (LifecycleEvidenceKind::CloseoutCompletion, LifecycleOutcome::Failed) => "closeout_failed", @@ -214,6 +218,7 @@ fn lifecycle_next_state(kind: LifecycleEvidenceKind, outcome: LifecycleOutcome) (LifecycleEvidenceKind::LandingReadback, LifecycleOutcome::Failed) => "landing_failed", (LifecycleEvidenceKind::LandingReadback, LifecycleOutcome::NeedsManualAttention) => "manual_attention_required", + (LifecycleEvidenceKind::CloseoutIntent, LifecycleOutcome::Intent) => "closeout_pending", (LifecycleEvidenceKind::CloseoutCompletion, LifecycleOutcome::Succeeded) => "closed", (LifecycleEvidenceKind::CloseoutCompletion, LifecycleOutcome::Failed) => "closeout_failed", (_, LifecycleOutcome::NeedsManualAttention) => "manual_attention_required", @@ -238,6 +243,7 @@ fn lifecycle_phase( (LifecycleEvidenceKind::LandingReadback, LifecycleOutcome::Failed) => "landing_failed", (LifecycleEvidenceKind::LandingReadback, LifecycleOutcome::NeedsManualAttention) => "manual_attention_required", + (LifecycleEvidenceKind::CloseoutIntent, LifecycleOutcome::Intent) => "closeout_pending", (LifecycleEvidenceKind::CloseoutCompletion, LifecycleOutcome::Succeeded) => "closed", (LifecycleEvidenceKind::CloseoutCompletion, LifecycleOutcome::Failed) => "closeout_failed", (_, LifecycleOutcome::NeedsManualAttention) => "manual_attention_required", @@ -272,6 +278,8 @@ fn lifecycle_next_action( "run_retained_closeout_adapter", (LifecycleEvidenceKind::LandingReadback, LifecycleOutcome::Failed, _) => "repair_landing_failure_or_request_manual_attention", + (LifecycleEvidenceKind::CloseoutIntent, LifecycleOutcome::Intent, _) => + "run_retained_closeout_adapter", (LifecycleEvidenceKind::CloseoutCompletion, LifecycleOutcome::Succeeded, _) => "no_action", (_, LifecycleOutcome::NeedsManualAttention, _) => "request_manual_attention", _ => "continue_lifecycle", @@ -369,4 +377,65 @@ mod tests { ); assert_eq!(decision.authority_record_envelope.authority_record, decision.authority_record); } + + #[test] + fn closeout_intent_is_retryable_non_terminal_authority() { + let facts = + orchestrator::build_post_review_lifecycle_facts(PostReviewLifecycleFactsInput { + project_id: "pubfi", + issue_id: "PUB-1704", + review_lifecycle: None, + review_state: &PullRequestReviewState { + url: String::from("https://github.com/helixbox/pubfi-mono/pull/826"), + state: String::from("OPEN"), + is_draft: false, + review_decision: Some(String::from("APPROVED")), + merge_commit_allowed: false, + pending_review_requests: 0, + mergeable: String::from("CONFLICTING"), + merge_state_status: String::from("DIRTY"), + base_ref_oid: Some(String::from("base-sha")), + head_ref_name: String::from("y/pubfi-pub-1704"), + head_ref_oid: String::from("obsolete-head"), + merge_commit_oid: Some(String::from("successor-merge")), + head_repository_name: None, + head_repository_owner: None, + status_check_rollup_state: Some(String::from("SUCCESS")), + required_status_contexts: Vec::new(), + unresolved_review_threads: 0, + issue_description_external_review_thumbs_up_count: 0, + issue_comments: Vec::new(), + reviews: Vec::new(), + }, + worktree_path: Path::new(".worktrees/PUB-1704"), + review_level: ReviewLevel::Strict, + phase: "superseded_closeout_recovery", + landing_state: Some("superseded"), + closeout_state: Some("not_started"), + validated_head_sha: Some("obsolete-head"), + review_checkpoint_phase: None, + review_checkpoint_status: None, + }); + let decision = super::decide_lifecycle_transition(LifecycleDecisionInput { + facts: &facts, + previous: None, + evidence_kind: LifecycleEvidenceKind::CloseoutIntent, + outcome: LifecycleOutcome::Intent, + merge_commit: Some("successor-merge"), + cleanup_state: Some("pending"), + authority: "issue_authority", + actor: "superseded_closeout_recovery", + idempotency_key: "pubfi:PUB-1704:successor-merge:superseded_closeout_recovery:pending", + correlation_id: "run-1", + causation_id: Some("superseded_closeout_recovery_pr_close_authorized"), + decided_at: "2026-07-09T00:00:00Z", + }); + + assert_eq!(decision.authority_record.next_state, "closeout_pending"); + assert_eq!(decision.authority_record.phase, "closeout_pending"); + assert_eq!(decision.authority_record.cleanup_state, "pending"); + assert_eq!(decision.authority_record.next_action, "run_retained_closeout_adapter"); + assert_ne!(decision.authority_record.next_state, "closed"); + assert_ne!(decision.authority_record.next_action, "no_action"); + } } diff --git a/apps/decodex/src/plugin_surface_tests/decodex_skills.rs b/apps/decodex/src/plugin_surface_tests/decodex_skills.rs index 8c4287d76..94d8d12da 100644 --- a/apps/decodex/src/plugin_surface_tests/decodex_skills.rs +++ b/apps/decodex/src/plugin_surface_tests/decodex_skills.rs @@ -16,6 +16,7 @@ fn packaged_decodex_skills_are_runtime_operator_only() { plugin_surface_tests::assert_contains(&skill_surface, "review-handoff diagnose"); plugin_surface_tests::assert_contains(&skill_surface, "review-handoff rebind"); plugin_surface_tests::assert_contains(&skill_surface, "review-handoff adopt"); + plugin_surface_tests::assert_contains(&skill_surface, "superseded-closeout"); plugin_surface_tests::assert_contains(&skill_surface, "Only `decodex land` lands"); plugin_surface_tests::assert_contains(&skill_surface, "Do not bypass Decodex authority"); plugin_surface_tests::assert_contains(&skill_surface, "raw Git"); diff --git a/apps/decodex/src/recovery.rs b/apps/decodex/src/recovery.rs index 83f22ee4a..2fcaa252a 100644 --- a/apps/decodex/src/recovery.rs +++ b/apps/decodex/src/recovery.rs @@ -28,12 +28,13 @@ mod stale_active_runtime; mod stale_active_worktree; pub(crate) use self::{ - closeout::{run_legacy_closeout, run_merged_closeout}, + closeout::{run_legacy_closeout, run_merged_closeout, run_superseded_closeout}, ghost_lane::{run_ghost_lane_cleanup, run_ghost_lane_diagnose}, requests::{ GhostLaneCleanupRequest, GhostLaneDiagnoseRequest, LegacyCloseoutRecoveryRequest, MergedCloseoutRecoveryRequest, ReviewHandoffAdoptRequest, ReviewHandoffDiagnoseRequest, ReviewHandoffRebindRequest, StaleActiveDiagnoseRequest, StaleActiveReleaseRequest, + SupersededCloseoutRecoveryRequest, }, review_handoff::commands::{ run_review_handoff_adopt, run_review_handoff_diagnose, run_review_handoff_rebind, @@ -119,6 +120,8 @@ const LEGACY_MANUAL_CLOSEOUT_EVENT: &str = "closeout"; const LEGACY_MANUAL_CLOSEOUT_ANCHOR: &str = "legacy_manual_closeout"; const MERGED_CLOSEOUT_CLOSEOUT_ANCHOR: &str = "merged_closeout"; const MERGED_CLOSEOUT_CLEANUP_ANCHOR: &str = "merged_closeout_cleanup"; +const SUPERSEDED_CLOSEOUT_ANCHOR: &str = "superseded_closeout"; +const SUPERSEDED_CLOSEOUT_CLEANUP_ANCHOR: &str = "superseded_closeout_cleanup"; const GHOST_LANE_CLASSIFICATION: &str = "missing_issue_ghost_lane"; const MCP_TEST_FIXTURE_GHOST_LANE_CLASSIFICATION: &str = "mcp_test_fixture_ghost_lane"; const GHOST_LANE_BLOCKED_CLASSIFICATION: &str = "ghost_lane_recovery_blocked"; diff --git a/apps/decodex/src/recovery/closeout.rs b/apps/decodex/src/recovery/closeout.rs index 1a373a7c2..67009feed 100644 --- a/apps/decodex/src/recovery/closeout.rs +++ b/apps/decodex/src/recovery/closeout.rs @@ -11,7 +11,10 @@ use crate::{ pull_request::PullRequestLandingState, recovery::{ pull_request_inspection, - requests::{LegacyCloseoutRecoveryRequest, MergedCloseoutRecoveryRequest}, + requests::{ + LegacyCloseoutRecoveryRequest, MergedCloseoutRecoveryRequest, + SupersededCloseoutRecoveryRequest, + }, }, state::WorktreeMapping, tracker::TrackerIssue, @@ -44,6 +47,19 @@ struct MergedCloseoutRetainedContext { attempt_number: i64, } +struct SupersededCloseoutValidation { + issue: TrackerIssue, + successor_issue: TrackerIssue, + branch_name: String, + worktree_path_for_event: String, + run_id: String, + attempt_number: i64, + obsolete_landing_state: PullRequestLandingState, + successor_landing_state: PullRequestLandingState, + successor_merge_commit: String, + completed_state_id: String, +} + /// Run an explicit audited legacy closeout fallback. pub(crate) fn run_legacy_closeout( config_path: Option<&Path>, @@ -136,3 +152,57 @@ pub(crate) fn run_merged_closeout( Ok(()) } + +/// Run an explicit superseded PR closeout after a successor PR has landed. +pub(crate) fn run_superseded_closeout( + config_path: Option<&Path>, + request: &SupersededCloseoutRecoveryRequest, +) -> Result<()> { + let context = super::load_recovery_context_for_dry_run(config_path, request.dry_run)?; + let validation = validation::validate_superseded_closeout_request(&context, request)?; + + if request.dry_run { + println!( + "dry run: superseded closeout validated for project={} issue={} branch={} worktree_path={} pr={} head={} successor_issue={} successor_pr={} successor_head={} successor_merge_commit={} run_id={} attempt={}", + context.config.service_id(), + validation.issue.identifier, + validation.branch_name, + validation.worktree_path_for_event, + pull_request_inspection::landing_url(&validation.obsolete_landing_state), + validation.obsolete_landing_state.head_ref_oid, + validation.successor_issue.identifier, + pull_request_inspection::landing_url(&validation.successor_landing_state), + validation.successor_landing_state.head_ref_oid, + validation.successor_merge_commit, + validation.run_id, + validation.attempt_number + ); + + return Ok(()); + } + if !request.manual_authority { + eyre::bail!( + "`recover superseded-closeout` closes an obsolete PR and writes closeout ledger records, so it requires --manual-authority outside dry-run mode." + ); + } + + let (closeout_recorded, cleanup_recorded, pr_closed) = + apply::apply_superseded_closeout_recovery(&context, &validation)?; + + println!( + "superseded closeout recovery ok: project={} issue={} branch={} worktree_path={} pr={} successor_issue={} successor_pr={} successor_merge_commit={} closeout_recorded={} cleanup_recorded={} pr_closed={}", + context.config.service_id(), + validation.issue.identifier, + validation.branch_name, + validation.worktree_path_for_event, + pull_request_inspection::landing_url(&validation.obsolete_landing_state), + validation.successor_issue.identifier, + pull_request_inspection::landing_url(&validation.successor_landing_state), + validation.successor_merge_commit, + closeout_recorded, + cleanup_recorded, + pr_closed + ); + + Ok(()) +} diff --git a/apps/decodex/src/recovery/closeout/apply.rs b/apps/decodex/src/recovery/closeout/apply.rs index b99fd55cc..7ef8bb99f 100644 --- a/apps/decodex/src/recovery/closeout/apply.rs +++ b/apps/decodex/src/recovery/closeout/apply.rs @@ -3,6 +3,7 @@ use std::path::Path; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use crate::{ + github, orchestrator::{ self, PostReviewLifecycleFactsInput, PullRequestReviewState, kernel::{ @@ -16,15 +17,18 @@ use crate::{ prelude::Result, recovery::{ closeout::{ - LegacyCloseoutValidation, MergedCloseoutValidation, + LegacyCloseoutValidation, MergedCloseoutValidation, SupersededCloseoutValidation, apply::lifecycle::decide_lifecycle_transition, events, }, context::RecoveryContext, pull_request_inspection, }, + state::StateStore, tracker::{ - self, - privacy_classifier::ConfiguredPublicProjectionPrivacyClassifier, + self, IssueTracker, + privacy_classifier::{ + ConfiguredPublicProjectionPrivacyClassifier, PublicProjectionPrivacyClassifier, + }, records::{self, LinearExecutionEventRecord}, }, }; @@ -76,45 +80,859 @@ pub(super) fn apply_merged_closeout_recovery( context: &RecoveryContext, validation: &MergedCloseoutValidation, ) -> Result<(bool, bool)> { - let closeout_event = events::merged_closeout_event(context, validation); - let cleanup_event = events::merged_closeout_cleanup_event(context, validation); - let closeout_recorded = write_merged_closeout_event( - context, - validation, - &closeout_event, - "Decodex merged closeout recovery: verified the PR was merged into the current default branch and reconciled the stale retained attention closeout ledger.", - )?; - let cleanup_recorded = match write_merged_closeout_event( - context, - validation, - &cleanup_event, - "Decodex merged closeout recovery: verified retained lane cleanup is already complete and recorded cleanup_complete.", - ) { - Ok(cleanup_recorded) => cleanup_recorded, - Err(error) => { - if closeout_recorded { - context - .state_store - .forget_linear_execution_event(&closeout_event.idempotency_key)?; + let mut operations = MergedCloseoutRecoveryOperations { context, validation }; + + apply_merged_closeout_recovery_sequence(&mut operations) +} + +trait MergedCloseoutRecoveryOperationsRunner { + fn record_lifecycle_authority(&mut self) -> Result<()>; + fn write_closeout_event(&mut self) -> Result; + fn write_cleanup_event(&mut self) -> Result; + fn clear_worktree_if_present(&mut self) -> Result<()>; + fn update_run_status(&mut self) -> Result<()>; +} + +fn apply_merged_closeout_recovery_sequence( + operations: &mut impl MergedCloseoutRecoveryOperationsRunner, +) -> Result<(bool, bool)> { + operations.record_lifecycle_authority()?; + let closeout_recorded = operations.write_closeout_event()?; + let cleanup_recorded = operations.write_cleanup_event()?; + operations.clear_worktree_if_present()?; + operations.update_run_status()?; + + Ok((closeout_recorded, cleanup_recorded)) +} + +struct MergedCloseoutRecoveryOperations<'a> { + context: &'a RecoveryContext, + validation: &'a MergedCloseoutValidation, +} + +impl MergedCloseoutRecoveryOperationsRunner for MergedCloseoutRecoveryOperations<'_> { + fn record_lifecycle_authority(&mut self) -> Result<()> { + record_merged_closeout_lifecycle_authority(self.context, self.validation) + } + + fn write_closeout_event(&mut self) -> Result { + let closeout_event = events::merged_closeout_event(self.context, self.validation); + + write_merged_closeout_event( + self.context, + self.validation, + &closeout_event, + "Decodex merged closeout recovery: verified the PR was merged into the current default branch and reconciled the stale retained attention closeout ledger.", + ) + } + + fn write_cleanup_event(&mut self) -> Result { + let cleanup_event = events::merged_closeout_cleanup_event(self.context, self.validation); + + write_merged_closeout_event( + self.context, + self.validation, + &cleanup_event, + "Decodex merged closeout recovery: verified retained lane cleanup is already complete and recorded cleanup_complete.", + ) + } + + fn clear_worktree_if_present(&mut self) -> Result<()> { + if self.validation.worktree_mapping.is_some() { + self.context.state_store.clear_worktree(&self.validation.issue.id)?; + + if self.validation.issue.identifier != self.validation.issue.id { + self.context.state_store.clear_worktree(&self.validation.issue.identifier)?; } + } - return Err(error); + Ok(()) + } + + fn update_run_status(&mut self) -> Result<()> { + self.context.state_store.update_run_status(&self.validation.run_id, "succeeded") + } +} + +pub(super) fn apply_superseded_closeout_recovery( + context: &RecoveryContext, + validation: &SupersededCloseoutValidation, +) -> Result<(bool, bool, bool)> { + let mut operations = SupersededCloseoutRecoveryOperations { context, validation }; + + apply_superseded_closeout_recovery_sequence(&mut operations) +} + +trait SupersededCloseoutRecoveryOperationsRunner { + fn ensure_terminalizable(&mut self) -> Result<()>; + fn ensure_run_attempt_recorded(&mut self) -> Result<()>; + fn revalidate_obsolete_pull_request(&mut self) -> Result<()>; + fn confirm_obsolete_pull_request_closed(&mut self) -> Result<()>; + fn update_issue_state(&mut self) -> Result<()>; + fn write_closeout_event(&mut self) -> Result; + fn write_cleanup_event(&mut self) -> Result; + fn record_lifecycle_authority(&mut self, cleanup_state: &'static str) -> Result<()>; + fn update_run_status(&mut self) -> Result<()>; + fn clear_worktree(&mut self) -> Result<()>; + fn post_pull_request_comment(&mut self) -> Result<()>; + fn close_pull_request_if_open(&mut self) -> Result; +} + +fn apply_superseded_closeout_recovery_sequence( + operations: &mut impl SupersededCloseoutRecoveryOperationsRunner, +) -> Result<(bool, bool, bool)> { + operations.ensure_terminalizable()?; + operations.ensure_run_attempt_recorded()?; + operations.record_lifecycle_authority("pending")?; + operations.revalidate_obsolete_pull_request()?; + let closeout_recorded = operations.write_closeout_event()?; + operations.revalidate_obsolete_pull_request()?; + operations.post_pull_request_comment()?; + operations.revalidate_obsolete_pull_request()?; + let pr_closed = operations.close_pull_request_if_open()?; + operations.confirm_obsolete_pull_request_closed()?; + operations.record_lifecycle_authority("completed")?; + operations.confirm_obsolete_pull_request_closed()?; + operations.update_issue_state()?; + operations.confirm_obsolete_pull_request_closed()?; + let cleanup_recorded = operations.write_cleanup_event()?; + operations.update_run_status()?; + operations.confirm_obsolete_pull_request_closed()?; + operations.clear_worktree()?; + + Ok((closeout_recorded, cleanup_recorded, pr_closed)) +} + +struct SupersededCloseoutRecoveryOperations<'a> { + context: &'a RecoveryContext, + validation: &'a SupersededCloseoutValidation, +} + +impl SupersededCloseoutRecoveryOperationsRunner for SupersededCloseoutRecoveryOperations<'_> { + fn ensure_terminalizable(&mut self) -> Result<()> { + super::validation::ensure_superseded_issue_terminalizable(self.context, self.validation) + } + + fn ensure_run_attempt_recorded(&mut self) -> Result<()> { + ensure_superseded_closeout_run_attempt( + &self.context.state_store, + &self.validation.issue.id, + &self.validation.run_id, + self.validation.attempt_number, + ) + } + + fn revalidate_obsolete_pull_request(&mut self) -> Result<()> { + let obsolete_pr_url = + pull_request_inspection::landing_url(&self.validation.obsolete_landing_state); + let (current, default_branch) = + pull_request_inspection::inspect_project_pull_request(self.context, obsolete_pr_url)?; + + super::validation::validate_obsolete_pull_request_unchanged( + &self.validation.obsolete_landing_state, + ¤t, + &default_branch, + ) + } + + fn confirm_obsolete_pull_request_closed(&mut self) -> Result<()> { + let obsolete_pr_url = + pull_request_inspection::landing_url(&self.validation.obsolete_landing_state); + let (current, default_branch) = + pull_request_inspection::inspect_project_pull_request(self.context, obsolete_pr_url)?; + + super::validation::validate_obsolete_pull_request_closed( + &self.validation.obsolete_landing_state, + ¤t, + &default_branch, + ) + } + + fn update_issue_state(&mut self) -> Result<()> { + self.context + .tracker + .update_issue_state(&self.validation.issue.id, &self.validation.completed_state_id) + } + + fn write_closeout_event(&mut self) -> Result { + let closeout_event = events::superseded_closeout_event(self.context, self.validation); + + write_superseded_closeout_event( + self.context, + self.validation, + &closeout_event, + "Decodex superseded closeout recovery: verified a successor PR landed the retained repair lineage and authorized closure of the obsolete PR.", + ) + } + + fn write_cleanup_event(&mut self) -> Result { + let cleanup_event = + events::superseded_closeout_cleanup_event(self.context, self.validation); + + write_superseded_closeout_event( + self.context, + self.validation, + &cleanup_event, + "Decodex superseded closeout recovery: verified the obsolete retained lane has no remaining unique unlanded work and recorded cleanup_complete.", + ) + } + + fn record_lifecycle_authority(&mut self, cleanup_state: &'static str) -> Result<()> { + record_superseded_closeout_lifecycle_authority(self.context, self.validation, cleanup_state) + } + + fn update_run_status(&mut self) -> Result<()> { + self.context.state_store.update_run_status(&self.validation.run_id, "succeeded") + } + + fn clear_worktree(&mut self) -> Result<()> { + self.context.state_store.clear_worktree(&self.validation.issue.id)?; + + if self.validation.issue.identifier != self.validation.issue.id { + self.context.state_store.clear_worktree(&self.validation.issue.identifier)?; + } + + Ok(()) + } + + fn post_pull_request_comment(&mut self) -> Result<()> { + let obsolete_pr_url = + pull_request_inspection::landing_url(&self.validation.obsolete_landing_state); + let successor_pr_url = + pull_request_inspection::landing_url(&self.validation.successor_landing_state); + let github_token = self.context.config.github().resolve_token()?; + let pr_comment = format!( + "Decodex superseded closeout: closing this retained PR because successor PR {successor_pr_url} for issue {} landed the accepted repair. Original issue {} is terminalized as superseded and should not be landed from this PR.", + self.validation.successor_issue.identifier, self.validation.issue.identifier + ); + + github::post_pull_request_issue_comment( + self.context.config.repo_root(), + obsolete_pr_url, + &pr_comment, + &github_token, + self.context.config.github().command_path(), + )?; + + Ok(()) + } + + fn close_pull_request_if_open(&mut self) -> Result { + if self.validation.obsolete_landing_state.state != "OPEN" { + return Ok(false); + } + + let obsolete_pr_url = + pull_request_inspection::landing_url(&self.validation.obsolete_landing_state); + let github_token = self.context.config.github().resolve_token()?; + + github::close_pull_request( + self.context.config.repo_root(), + obsolete_pr_url, + &github_token, + self.context.config.github().command_path(), + )?; + + Ok(true) + } +} + +fn ensure_superseded_closeout_run_attempt( + state_store: &StateStore, + issue_id: &str, + run_id: &str, + attempt_number: i64, +) -> Result<()> { + if !super::validation::ensure_superseded_closeout_run_attempt_compatible( + state_store, + issue_id, + run_id, + attempt_number, + )? { + state_store.record_run_attempt(run_id, issue_id, attempt_number, "terminated")?; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + + use crate::{ + prelude::{Result, eyre}, + state::StateStore, + tracker::{ + IssueTracker, TrackerComment, TrackerIssue, + privacy_classifier::DISABLED_PUBLIC_PROJECTION_PRIVACY_CLASSIFIER, + records::{self, LinearExecutionEventIdentity, LinearExecutionEventRecord}, }, }; - if validation.worktree_mapping.is_some() { - context.state_store.clear_worktree(&validation.issue.id)?; + use super::{ + MergedCloseoutRecoveryOperationsRunner, SupersededCloseoutRecoveryOperationsRunner, + apply_merged_closeout_recovery_sequence, apply_superseded_closeout_recovery_sequence, + ensure_superseded_closeout_run_attempt, write_recovery_closeout_event, + }; - if validation.issue.identifier != validation.issue.id { - context.state_store.clear_worktree(&validation.issue.identifier)?; + #[derive(Default)] + struct RecordingMergedCloseoutOperations { + steps: RefCell>, + fail_at: Option<&'static str>, + } + impl RecordingMergedCloseoutOperations { + fn record(&self, step: &'static str) -> Result<()> { + self.steps.borrow_mut().push(step); + + if self.fail_at == Some(step) { + eyre::bail!("{step} failed"); + } + + Ok(()) } } - record_merged_closeout_lifecycle_authority(context, validation)?; + impl MergedCloseoutRecoveryOperationsRunner for RecordingMergedCloseoutOperations { + fn record_lifecycle_authority(&mut self) -> Result<()> { + self.record("record_lifecycle_authority") + } - context.state_store.update_run_status(&validation.run_id, "succeeded")?; + fn write_closeout_event(&mut self) -> Result { + self.record("write_closeout_event")?; - Ok((closeout_recorded, cleanup_recorded)) + Ok(true) + } + + fn write_cleanup_event(&mut self) -> Result { + self.record("write_cleanup_event")?; + + Ok(true) + } + + fn clear_worktree_if_present(&mut self) -> Result<()> { + self.record("clear_worktree_if_present") + } + + fn update_run_status(&mut self) -> Result<()> { + self.record("update_run_status") + } + } + + #[derive(Default)] + struct RecordingSupersededCloseoutOperations { + steps: RefCell>, + fail_at: Option<&'static str>, + fail_on_occurrence: Option<(&'static str, usize)>, + } + impl RecordingSupersededCloseoutOperations { + fn record(&self, step: &'static str) -> Result<()> { + let occurrence = { + let mut steps = self.steps.borrow_mut(); + steps.push(step); + steps.iter().filter(|recorded| **recorded == step).count() + }; + + if self.fail_at == Some(step) || self.fail_on_occurrence == Some((step, occurrence)) { + eyre::bail!("{step} failed"); + } + + Ok(()) + } + } + + impl SupersededCloseoutRecoveryOperationsRunner for RecordingSupersededCloseoutOperations { + fn ensure_terminalizable(&mut self) -> Result<()> { + self.record("ensure_terminalizable") + } + + fn ensure_run_attempt_recorded(&mut self) -> Result<()> { + self.record("ensure_run_attempt_recorded") + } + + fn revalidate_obsolete_pull_request(&mut self) -> Result<()> { + self.record("revalidate_obsolete_pull_request") + } + + fn confirm_obsolete_pull_request_closed(&mut self) -> Result<()> { + self.record("confirm_obsolete_pull_request_closed") + } + + fn update_issue_state(&mut self) -> Result<()> { + self.record("update_issue_state") + } + + fn write_closeout_event(&mut self) -> Result { + self.record("write_closeout_event")?; + + Ok(true) + } + + fn write_cleanup_event(&mut self) -> Result { + self.record("write_cleanup_event")?; + + Ok(true) + } + + fn record_lifecycle_authority(&mut self, cleanup_state: &'static str) -> Result<()> { + match cleanup_state { + "pending" => self.record("record_lifecycle_authority_pending"), + "completed" => self.record("record_lifecycle_authority_completed"), + _ => unreachable!("unsupported test cleanup state"), + } + } + + fn update_run_status(&mut self) -> Result<()> { + self.record("update_run_status") + } + + fn clear_worktree(&mut self) -> Result<()> { + self.record("clear_worktree") + } + + fn post_pull_request_comment(&mut self) -> Result<()> { + self.record("post_pull_request_comment") + } + + fn close_pull_request_if_open(&mut self) -> Result { + self.record("close_pull_request_if_open")?; + + Ok(true) + } + } + + #[derive(Default)] + struct ProjectionTracker { + comments: RefCell>, + created_comments: RefCell>, + list_error: Option<&'static str>, + create_error: Option<&'static str>, + } + + impl ProjectionTracker { + fn with_comments(comments: Vec) -> Self { + Self { + comments: RefCell::new(comments), + created_comments: RefCell::new(Vec::new()), + list_error: None, + create_error: None, + } + } + } + + impl IssueTracker for ProjectionTracker { + fn list_issues_with_label(&self, _label_name: &str) -> Result> { + Ok(Vec::new()) + } + + fn find_team_label_id(&self, _team_id: &str, _label_name: &str) -> Result> { + Ok(None) + } + + fn get_issue_by_identifier(&self, _issue_identifier: &str) -> Result> { + Ok(None) + } + + fn refresh_issues(&self, _issue_ids: &[String]) -> Result> { + Ok(Vec::new()) + } + + fn list_comments(&self, _issue_id: &str) -> Result> { + if let Some(error) = self.list_error { + eyre::bail!(error); + } + + Ok(self.comments.borrow().clone()) + } + + fn update_issue_state(&self, _issue_id: &str, _state_id: &str) -> Result<()> { + Ok(()) + } + + fn add_issue_labels(&self, _issue_id: &str, _label_ids: &[String]) -> Result<()> { + Ok(()) + } + + fn remove_issue_labels(&self, _issue_id: &str, _label_ids: &[String]) -> Result<()> { + Ok(()) + } + + fn create_comment(&self, _issue_id: &str, body: &str) -> Result<()> { + if let Some(error) = self.create_error { + eyre::bail!(error); + } + + self.created_comments.borrow_mut().push(body.to_owned()); + self.comments.borrow_mut().push(TrackerComment { + body: body.to_owned(), + created_at: String::from("2026-07-09T00:00:00Z"), + }); + + Ok(()) + } + } + + fn closeout_record(anchor: &str) -> LinearExecutionEventRecord { + let mut record = LinearExecutionEventRecord::new( + LinearExecutionEventIdentity { + service_id: "decodex", + issue_id: "issue-id", + issue_identifier: "XY-1248", + run_id: "run-id", + attempt_number: 1, + }, + "closeout", + String::from("2026-07-09T00:00:00Z"), + anchor, + ); + record.pr_url = Some(String::from("https://github.com/hack-ink/decodex/pull/1073")); + record.commit_sha = Some(String::from("0123456789abcdef0123456789abcdef01234567")); + record.summary = Some(String::from("Closeout recovery projection test record.")); + + record + } + + fn tracker_comment_for_record(record: &LinearExecutionEventRecord) -> TrackerComment { + let body = records::render_linear_execution_event_comment_body(record, None); + let body = records::append_structured_comment_record(&body, record) + .expect("structured comment should render"); + + TrackerComment { body, created_at: String::from("2026-07-09T00:00:00Z") } + } + + #[test] + fn merged_closeout_records_lifecycle_authority_before_public_projection() { + let mut operations = RecordingMergedCloseoutOperations::default(); + + let result = apply_merged_closeout_recovery_sequence(&mut operations) + .expect("merged closeout sequence should succeed"); + + assert_eq!(result, (true, true)); + assert_eq!( + operations.steps.into_inner(), + vec![ + "record_lifecycle_authority", + "write_closeout_event", + "write_cleanup_event", + "clear_worktree_if_present", + "update_run_status", + ] + ); + } + + #[test] + fn merged_closeout_does_not_write_public_projection_when_lifecycle_authority_fails() { + let mut operations = RecordingMergedCloseoutOperations { + fail_at: Some("record_lifecycle_authority"), + ..RecordingMergedCloseoutOperations::default() + }; + + let error = apply_merged_closeout_recovery_sequence(&mut operations) + .expect_err("lifecycle authority failure should stop recovery"); + + assert!(error.to_string().contains("record_lifecycle_authority failed")); + assert_eq!(operations.steps.into_inner(), vec!["record_lifecycle_authority"]); + } + + #[test] + fn merged_closeout_does_not_clear_or_succeed_run_when_projection_fails() { + let mut operations = RecordingMergedCloseoutOperations { + fail_at: Some("write_closeout_event"), + ..RecordingMergedCloseoutOperations::default() + }; + + let error = apply_merged_closeout_recovery_sequence(&mut operations) + .expect_err("public projection failure should stop recovery"); + + assert!(error.to_string().contains("write_closeout_event failed")); + assert_eq!( + operations.steps.into_inner(), + vec!["record_lifecycle_authority", "write_closeout_event",] + ); + } + + #[test] + fn closeout_projection_retries_remote_comment_when_local_record_is_duplicate() { + let state_store = StateStore::open_in_memory().expect("state store should open"); + let tracker = ProjectionTracker::default(); + let event = closeout_record("duplicate-local-missing-remote"); + state_store.record_linear_execution_event(&event).expect("local event should record"); + + let written = write_recovery_closeout_event( + &tracker, + &state_store, + "issue-id", + &event, + "Recovery closeout projection.", + None, + &DISABLED_PUBLIC_PROJECTION_PRIVACY_CLASSIFIER, + ) + .expect("duplicate local record should still post missing remote projection"); + + assert!(written); + assert_eq!(tracker.created_comments.borrow().len(), 1); + } + + #[test] + fn closeout_projection_skips_duplicate_only_when_remote_comment_exists() { + let state_store = StateStore::open_in_memory().expect("state store should open"); + let event = closeout_record("duplicate-local-and-remote"); + state_store.record_linear_execution_event(&event).expect("local event should record"); + let tracker = ProjectionTracker::with_comments(vec![tracker_comment_for_record(&event)]); + + let written = write_recovery_closeout_event( + &tracker, + &state_store, + "issue-id", + &event, + "Recovery closeout projection.", + None, + &DISABLED_PUBLIC_PROJECTION_PRIVACY_CLASSIFIER, + ) + .expect("remote duplicate should be accepted"); + + assert!(!written); + assert!(tracker.created_comments.borrow().is_empty()); + } + + #[test] + fn superseded_closeout_records_durable_state_before_github_side_effects() { + let mut operations = RecordingSupersededCloseoutOperations::default(); + + let result = apply_superseded_closeout_recovery_sequence(&mut operations) + .expect("superseded closeout sequence should succeed"); + + assert_eq!(result, (true, true, true)); + assert_eq!( + operations.steps.into_inner(), + vec![ + "ensure_terminalizable", + "ensure_run_attempt_recorded", + "record_lifecycle_authority_pending", + "revalidate_obsolete_pull_request", + "write_closeout_event", + "revalidate_obsolete_pull_request", + "post_pull_request_comment", + "revalidate_obsolete_pull_request", + "close_pull_request_if_open", + "confirm_obsolete_pull_request_closed", + "record_lifecycle_authority_completed", + "confirm_obsolete_pull_request_closed", + "update_issue_state", + "confirm_obsolete_pull_request_closed", + "write_cleanup_event", + "update_run_status", + "confirm_obsolete_pull_request_closed", + "clear_worktree", + ] + ); + } + + #[test] + fn superseded_closeout_confirms_closed_pr_before_issue_terminalization() { + let mut operations = RecordingSupersededCloseoutOperations { + fail_on_occurrence: Some(("confirm_obsolete_pull_request_closed", 2)), + ..RecordingSupersededCloseoutOperations::default() + }; + + let error = apply_superseded_closeout_recovery_sequence(&mut operations) + .expect_err("reopened PR should stop issue terminalization"); + + assert!(error.to_string().contains("confirm_obsolete_pull_request_closed failed")); + assert!(!operations.steps.borrow().contains(&"update_issue_state")); + assert!(!operations.steps.borrow().contains(&"write_cleanup_event")); + } + + #[test] + fn superseded_closeout_confirms_closed_pr_before_cleanup_projection() { + let mut operations = RecordingSupersededCloseoutOperations { + fail_on_occurrence: Some(("confirm_obsolete_pull_request_closed", 3)), + ..RecordingSupersededCloseoutOperations::default() + }; + + let error = apply_superseded_closeout_recovery_sequence(&mut operations) + .expect_err("reopened PR should stop cleanup projection"); + + assert!(error.to_string().contains("confirm_obsolete_pull_request_closed failed")); + assert!(operations.steps.borrow().contains(&"update_issue_state")); + assert!(!operations.steps.borrow().contains(&"write_cleanup_event")); + assert!(!operations.steps.borrow().contains(&"update_run_status")); + assert!(!operations.steps.borrow().contains(&"clear_worktree")); + } + + #[test] + fn superseded_closeout_confirms_closed_pr_before_worktree_clear() { + let mut operations = RecordingSupersededCloseoutOperations { + fail_on_occurrence: Some(("confirm_obsolete_pull_request_closed", 4)), + ..RecordingSupersededCloseoutOperations::default() + }; + + let error = apply_superseded_closeout_recovery_sequence(&mut operations) + .expect_err("reopened PR should preserve retained worktree"); + + assert!(error.to_string().contains("confirm_obsolete_pull_request_closed failed")); + assert!(operations.steps.borrow().contains(&"write_cleanup_event")); + assert!(operations.steps.borrow().contains(&"update_run_status")); + assert!(!operations.steps.borrow().contains(&"clear_worktree")); + } + + #[test] + fn superseded_closeout_revalidates_before_public_projection() { + let mut operations = RecordingSupersededCloseoutOperations { + fail_on_occurrence: Some(("revalidate_obsolete_pull_request", 1)), + ..RecordingSupersededCloseoutOperations::default() + }; + + let error = apply_superseded_closeout_recovery_sequence(&mut operations) + .expect_err("changed PR should stop before public projection"); + + assert!(error.to_string().contains("revalidate_obsolete_pull_request failed")); + assert!(!operations.steps.borrow().contains(&"write_closeout_event")); + assert!(!operations.steps.borrow().contains(&"post_pull_request_comment")); + } + + #[test] + fn superseded_closeout_revalidates_again_before_pr_close() { + let mut operations = RecordingSupersededCloseoutOperations { + fail_on_occurrence: Some(("revalidate_obsolete_pull_request", 3)), + ..RecordingSupersededCloseoutOperations::default() + }; + + let error = apply_superseded_closeout_recovery_sequence(&mut operations) + .expect_err("changed PR after comment should stop before close"); + + assert!(error.to_string().contains("revalidate_obsolete_pull_request failed")); + assert!(operations.steps.borrow().contains(&"post_pull_request_comment")); + assert!(!operations.steps.borrow().contains(&"close_pull_request_if_open")); + assert!(!operations.steps.borrow().contains(&"record_lifecycle_authority_completed")); + } + + #[test] + fn superseded_closeout_does_not_terminalize_issue_when_lifecycle_authority_fails() { + let mut operations = RecordingSupersededCloseoutOperations { + fail_at: Some("record_lifecycle_authority_pending"), + ..RecordingSupersededCloseoutOperations::default() + }; + + let error = apply_superseded_closeout_recovery_sequence(&mut operations) + .expect_err("lifecycle authority failure should stop recovery"); + + assert!(error.to_string().contains("record_lifecycle_authority_pending failed")); + assert_eq!( + operations.steps.into_inner(), + vec![ + "ensure_terminalizable", + "ensure_run_attempt_recorded", + "record_lifecycle_authority_pending", + ] + ); + } + + #[test] + fn superseded_closeout_does_not_write_public_closeout_when_lifecycle_authority_fails() { + let mut operations = RecordingSupersededCloseoutOperations { + fail_at: Some("record_lifecycle_authority_pending"), + ..RecordingSupersededCloseoutOperations::default() + }; + + let error = apply_superseded_closeout_recovery_sequence(&mut operations) + .expect_err("lifecycle authority failure should stop recovery"); + + assert!(error.to_string().contains("record_lifecycle_authority_pending failed")); + assert!(!operations.steps.borrow().contains(&"write_closeout_event")); + } + + #[test] + fn superseded_closeout_does_not_write_public_cleanup_when_lifecycle_authority_fails() { + let mut operations = RecordingSupersededCloseoutOperations { + fail_at: Some("record_lifecycle_authority_completed"), + ..RecordingSupersededCloseoutOperations::default() + }; + + let error = apply_superseded_closeout_recovery_sequence(&mut operations) + .expect_err("completed lifecycle authority failure should stop recovery"); + + assert!(error.to_string().contains("record_lifecycle_authority_completed failed")); + assert!(!operations.steps.borrow().contains(&"write_cleanup_event")); + } + + #[test] + fn superseded_closeout_keeps_cleanup_retryable_when_github_comment_fails() { + let mut operations = RecordingSupersededCloseoutOperations { + fail_at: Some("post_pull_request_comment"), + ..RecordingSupersededCloseoutOperations::default() + }; + + let error = apply_superseded_closeout_recovery_sequence(&mut operations) + .expect_err("GitHub comment failure should stop cleanup"); + + assert!(error.to_string().contains("post_pull_request_comment failed")); + assert_eq!( + operations.steps.into_inner(), + vec![ + "ensure_terminalizable", + "ensure_run_attempt_recorded", + "record_lifecycle_authority_pending", + "revalidate_obsolete_pull_request", + "write_closeout_event", + "revalidate_obsolete_pull_request", + "post_pull_request_comment", + ] + ); + } + + #[test] + fn superseded_closeout_records_completed_authority_before_terminal_issue_state() { + let mut operations = RecordingSupersededCloseoutOperations { + fail_at: Some("record_lifecycle_authority_completed"), + ..RecordingSupersededCloseoutOperations::default() + }; + + let error = apply_superseded_closeout_recovery_sequence(&mut operations) + .expect_err("completed lifecycle authority failure should stop terminalization"); + + assert!(error.to_string().contains("record_lifecycle_authority_completed failed")); + assert!(!operations.steps.borrow().contains(&"update_issue_state")); + } + + #[test] + fn superseded_closeout_records_missing_recovery_run_attempt() { + let state_store = StateStore::open_in_memory().expect("state store should open"); + + ensure_superseded_closeout_run_attempt( + &state_store, + "issue-id", + "superseded-closeout-xy-1248", + 1, + ) + .expect("missing recovery run attempt should be recorded"); + + let attempt = state_store + .run_attempt("superseded-closeout-xy-1248") + .expect("run attempt read should succeed") + .expect("run attempt should exist"); + assert_eq!(attempt.issue_id(), "issue-id"); + assert_eq!(attempt.attempt_number(), 1); + assert_eq!(attempt.status(), "terminated"); + } + + #[test] + fn superseded_closeout_rejects_conflicting_recovery_run_attempt() { + let state_store = StateStore::open_in_memory().expect("state store should open"); + state_store + .record_run_attempt("superseded-closeout-xy-1248", "other-issue", 2, "running") + .expect("conflicting run attempt should record"); + + let error = ensure_superseded_closeout_run_attempt( + &state_store, + "issue-id", + "superseded-closeout-xy-1248", + 1, + ) + .expect_err("conflicting run attempt should fail closed"); + + assert!(error.to_string().contains("conflicts with issue")); + assert!(error.to_string().contains("other-issue")); + } } fn write_merged_closeout_event( @@ -130,29 +948,72 @@ fn write_merged_closeout_event( context.state_store.retry_budget_attempt_count(&validation.issue.id)?; let retry_budget_attempt_count = (retry_budget_attempt_count > 0).then_some(retry_budget_attempt_count); + + write_recovery_closeout_event( + &context.tracker, + &context.state_store, + &validation.issue.id, + event, + body, + retry_budget_attempt_count, + &privacy_classifier, + ) +} + +fn write_superseded_closeout_event( + context: &RecoveryContext, + validation: &SupersededCloseoutValidation, + event: &LinearExecutionEventRecord, + body: &str, +) -> Result { + let privacy_classifier = ConfiguredPublicProjectionPrivacyClassifier::from_config( + context.config.privacy_classifier(), + )?; + let retry_budget_attempt_count = + context.state_store.retry_budget_attempt_count(&validation.issue.id)?; + let retry_budget_attempt_count = + (retry_budget_attempt_count > 0).then_some(retry_budget_attempt_count); + write_recovery_closeout_event( + &context.tracker, + &context.state_store, + &validation.issue.id, + event, + body, + retry_budget_attempt_count, + &privacy_classifier, + ) +} + +fn write_recovery_closeout_event( + tracker: &T, + state_store: &StateStore, + issue_id: &str, + event: &LinearExecutionEventRecord, + body: &str, + retry_budget_attempt_count: Option, + privacy_classifier: &dyn PublicProjectionPrivacyClassifier, +) -> Result +where + T: IssueTracker + ?Sized, +{ let body = format!( "{body}\n\n{}", records::render_linear_execution_event_comment_body(event, retry_budget_attempt_count) ); let projection = - tracker::prepare_linear_execution_event_comment(&body, event, &privacy_classifier)?; - let recorded = context.state_store.record_linear_execution_event(&projection.record)?; - - if !recorded { - return Ok(false); - } + tracker::prepare_linear_execution_event_comment(&body, event, privacy_classifier)?; + let recorded = state_store.record_linear_execution_event(&projection.record)?; - if let Err(error) = tracker::create_linear_execution_event_comment_direct( - &context.tracker, - &validation.issue.id, - &projection, - ) { - context.state_store.forget_linear_execution_event(&projection.record.idempotency_key)?; + match tracker::create_prepared_linear_execution_event_comment(tracker, issue_id, &projection) { + Ok(comment_created) => Ok(recorded || comment_created), + Err(error) => { + if recorded { + state_store.forget_linear_execution_event(&projection.record.idempotency_key)?; + } - return Err(error); + Err(error) + }, } - - Ok(true) } fn record_merged_closeout_lifecycle_authority( @@ -258,6 +1119,131 @@ fn record_merged_closeout_lifecycle_decision( Ok(()) } +fn record_superseded_closeout_lifecycle_authority( + context: &RecoveryContext, + validation: &SupersededCloseoutValidation, + cleanup_state: &'static str, +) -> Result<()> { + let review_level = context.config.codex().review_level(); + let checkpoint = orchestrator::runtime_review_checkpoint_status_for_head( + &context.state_store, + context.config.service_id(), + &validation.issue.id, + review_level, + &validation.obsolete_landing_state.head_ref_oid, + )?; + let review_state = superseded_closeout_review_state(validation); + let facts = orchestrator::build_post_review_lifecycle_facts(PostReviewLifecycleFactsInput { + project_id: context.config.service_id(), + issue_id: &validation.issue.id, + review_lifecycle: None, + review_state: &review_state, + worktree_path: Path::new(&validation.worktree_path_for_event), + review_level, + phase: "superseded_closeout_recovery", + landing_state: Some("superseded"), + closeout_state: Some(superseded_closeout_fact_closeout_state(cleanup_state)), + validated_head_sha: Some(&validation.obsolete_landing_state.head_ref_oid), + review_checkpoint_phase: checkpoint.as_ref().map(|checkpoint| checkpoint.phase), + review_checkpoint_status: checkpoint.as_ref().map(|checkpoint| checkpoint.status.as_str()), + }); + let previous_record = context.state_store.review_lifecycle_record( + context.config.service_id(), + &validation.issue.id, + &validation.branch_name, + )?; + let previous = previous_record.as_ref().map(|record| PreviousLifecycleAuthority { + sequence: record.sequence(), + next_state: record.next_state(), + }); + let idempotency_key = format!( + "{}:{}:{}:{}:{}", + context.config.service_id(), + validation.issue.id, + validation.successor_merge_commit, + "superseded_closeout_recovery", + cleanup_state + ); + let causation_id = match cleanup_state { + "pending" => "superseded_closeout_recovery_pr_close_authorized", + "completed" => "superseded_closeout_recovery_closeout_complete", + _ => "superseded_closeout_recovery_closeout_state", + }; + let (evidence_kind, outcome) = superseded_closeout_lifecycle_evidence(cleanup_state); + let decided_at = current_timestamp(); + let decision = self::decide_lifecycle_transition(LifecycleDecisionInput { + facts: &facts, + previous, + evidence_kind, + outcome, + merge_commit: Some(&validation.successor_merge_commit), + cleanup_state: Some(cleanup_state), + authority: "issue_authority", + actor: "superseded_closeout_recovery", + idempotency_key: &idempotency_key, + correlation_id: &validation.run_id, + causation_id: Some(causation_id), + decided_at: &decided_at, + }); + + context.state_store.record_lifecycle_decision( + &validation.run_id, + validation.attempt_number, + &decision, + )?; + + Ok(()) +} + +fn superseded_closeout_fact_closeout_state(cleanup_state: &str) -> &'static str { + match cleanup_state { + "completed" => "completed", + _ => "not_started", + } +} + +fn superseded_closeout_lifecycle_evidence( + cleanup_state: &str, +) -> (LifecycleEvidenceKind, LifecycleOutcome) { + match cleanup_state { + "completed" => (LifecycleEvidenceKind::CloseoutCompletion, LifecycleOutcome::Succeeded), + _ => (LifecycleEvidenceKind::CloseoutIntent, LifecycleOutcome::Intent), + } +} + +fn superseded_closeout_review_state( + validation: &SupersededCloseoutValidation, +) -> PullRequestReviewState { + PullRequestReviewState { + url: pull_request_inspection::landing_url(&validation.obsolete_landing_state).to_owned(), + state: validation.obsolete_landing_state.state.clone(), + is_draft: validation.obsolete_landing_state.is_draft, + review_decision: validation.obsolete_landing_state.review_decision.clone(), + merge_commit_allowed: false, + pending_review_requests: validation.obsolete_landing_state.pending_review_requests, + mergeable: validation.obsolete_landing_state.mergeable.clone(), + merge_state_status: validation.obsolete_landing_state.merge_state_status.clone(), + base_ref_oid: validation.obsolete_landing_state.base_ref_oid.clone(), + head_ref_name: validation.obsolete_landing_state.head_ref_name.clone(), + head_ref_oid: validation.obsolete_landing_state.head_ref_oid.clone(), + merge_commit_oid: Some(validation.successor_merge_commit.clone()), + head_repository_name: None, + head_repository_owner: None, + status_check_rollup_state: validation + .obsolete_landing_state + .status_check_rollup_state + .clone(), + required_status_contexts: validation + .obsolete_landing_state + .required_status_contexts + .clone(), + unresolved_review_threads: validation.obsolete_landing_state.unresolved_review_threads, + issue_description_external_review_thumbs_up_count: 0, + issue_comments: Vec::new(), + reviews: Vec::new(), + } +} + fn merged_closeout_review_state(validation: &MergedCloseoutValidation) -> PullRequestReviewState { PullRequestReviewState { url: pull_request_inspection::landing_url(&validation.landing_state).to_owned(), diff --git a/apps/decodex/src/recovery/closeout/events.rs b/apps/decodex/src/recovery/closeout/events.rs index f4e566736..13294aaac 100644 --- a/apps/decodex/src/recovery/closeout/events.rs +++ b/apps/decodex/src/recovery/closeout/events.rs @@ -2,7 +2,10 @@ use crate::{ recovery::{ LEGACY_MANUAL_CLOSEOUT_ANCHOR, LEGACY_MANUAL_CLOSEOUT_EVENT, MERGED_CLOSEOUT_CLEANUP_ANCHOR, MERGED_CLOSEOUT_CLOSEOUT_ANCHOR, - closeout::{LegacyCloseoutValidation, MergedCloseoutValidation}, + SUPERSEDED_CLOSEOUT_ANCHOR, SUPERSEDED_CLOSEOUT_CLEANUP_ANCHOR, + closeout::{ + LegacyCloseoutValidation, MergedCloseoutValidation, SupersededCloseoutValidation, + }, context::RecoveryContext, events::{self}, pull_request_inspection, @@ -157,6 +160,118 @@ pub(super) fn merged_closeout_cleanup_event( String::from("linear_queue_active_attention_labels_absent=true"), String::from("retained_worktree_has_no_uncommitted_changes=true"), ]); + event.next_action = Some(String::from( + "Decodex will record lifecycle authority and clear retained lane cleanup state.", + )); + + event +} + +pub(super) fn superseded_closeout_event( + context: &RecoveryContext, + validation: &SupersededCloseoutValidation, +) -> LinearExecutionEventRecord { + let obsolete_pr_url = pull_request_inspection::landing_url(&validation.obsolete_landing_state); + let successor_pr_url = + pull_request_inspection::landing_url(&validation.successor_landing_state); + let stable_anchor = records::stable_event_anchor(&[ + obsolete_pr_url, + successor_pr_url, + &validation.successor_merge_commit, + SUPERSEDED_CLOSEOUT_ANCHOR, + ]); + let mut event = LinearExecutionEventRecord::new( + LinearExecutionEventIdentity { + service_id: context.config.service_id(), + issue_id: &validation.issue.id, + issue_identifier: &validation.issue.identifier, + run_id: &validation.run_id, + attempt_number: validation.attempt_number, + }, + LEGACY_MANUAL_CLOSEOUT_EVENT, + events::current_timestamp(), + &stable_anchor, + ); + + event.branch = Some(validation.branch_name.clone()); + event.worktree_path = Some(validation.worktree_path_for_event.clone()); + event.pr_url = Some(obsolete_pr_url.to_owned()); + event.pr_head_sha = Some(validation.obsolete_landing_state.head_ref_oid.clone()); + event.pr_base_ref = Some(validation.obsolete_landing_state.base_ref_name.clone()); + event.commit_sha = Some(validation.successor_merge_commit.clone()); + event.validation_result = Some(String::from("passed")); + event.target_state = + Some(context.workflow.frontmatter().tracker().resolved_completed_state().to_owned()); + event.summary = Some(format!( + "Superseded closeout recorded for {} after successor issue {} landed PR {}.", + validation.issue.identifier, validation.successor_issue.identifier, successor_pr_url + )); + event.evidence = Some(vec![ + format!("issue_state={}", validation.issue.state.name), + format!("successor_issue={}", validation.successor_issue.identifier), + format!("successor_issue_state={}", validation.successor_issue.state.name), + format!("obsolete_pr_url={obsolete_pr_url}"), + format!("obsolete_pr_head_sha={}", validation.obsolete_landing_state.head_ref_oid), + format!("successor_pr_url={successor_pr_url}"), + format!("successor_pr_head_sha={}", validation.successor_landing_state.head_ref_oid), + format!("successor_merge_commit={}", validation.successor_merge_commit), + String::from("obsolete_pr_has_no_unique_unlanded_patch=true"), + String::from("retained_worktree_has_no_uncommitted_changes=true"), + ]); + event.next_action = Some(String::from( + "Decodex will close the obsolete PR and clear retained lane cleanup state.", + )); + + event +} + +pub(super) fn superseded_closeout_cleanup_event( + context: &RecoveryContext, + validation: &SupersededCloseoutValidation, +) -> LinearExecutionEventRecord { + let obsolete_pr_url = pull_request_inspection::landing_url(&validation.obsolete_landing_state); + let successor_pr_url = + pull_request_inspection::landing_url(&validation.successor_landing_state); + let stable_anchor = records::stable_event_anchor(&[ + &validation.branch_name, + &validation.worktree_path_for_event, + &validation.successor_merge_commit, + SUPERSEDED_CLOSEOUT_CLEANUP_ANCHOR, + ]); + let mut event = LinearExecutionEventRecord::new( + LinearExecutionEventIdentity { + service_id: context.config.service_id(), + issue_id: &validation.issue.id, + issue_identifier: &validation.issue.identifier, + run_id: &validation.run_id, + attempt_number: validation.attempt_number, + }, + "cleanup_complete", + events::timestamp_after_seconds(1), + &stable_anchor, + ); + + event.branch = Some(validation.branch_name.clone()); + event.worktree_path = Some(validation.worktree_path_for_event.clone()); + event.pr_url = Some(obsolete_pr_url.to_owned()); + event.pr_head_sha = Some(validation.obsolete_landing_state.head_ref_oid.clone()); + event.pr_base_ref = Some(validation.obsolete_landing_state.base_ref_name.clone()); + event.commit_sha = Some(validation.successor_merge_commit.clone()); + event.cleanup_status = Some(String::from("superseded_closeout_reconciled")); + event.target_state = + Some(context.workflow.frontmatter().tracker().resolved_completed_state().to_owned()); + event.summary = Some(format!( + "Superseded closeout marked retained lane {} cleanup complete after successor PR {}.", + validation.issue.identifier, successor_pr_url + )); + event.evidence = Some(vec![ + format!("issue_state={}", validation.issue.state.name), + format!("branch={}", validation.branch_name), + format!("worktree_path={}", validation.worktree_path_for_event), + format!("successor_issue={}", validation.successor_issue.identifier), + String::from("obsolete_pr_closure_authorized=true"), + String::from("retained_worktree_has_no_uncommitted_changes=true"), + ]); event.next_action = Some(String::from("No Decodex runtime action remains for this lane.")); event diff --git a/apps/decodex/src/recovery/closeout/validation.rs b/apps/decodex/src/recovery/closeout/validation.rs index 36496e44a..6fecbd139 100644 --- a/apps/decodex/src/recovery/closeout/validation.rs +++ b/apps/decodex/src/recovery/closeout/validation.rs @@ -1,6 +1,12 @@ mod legacy; mod merged; +mod superseded; +pub(in crate::recovery::closeout) use self::superseded::{ + ensure_superseded_closeout_run_attempt_compatible, ensure_superseded_issue_terminalizable, + validate_obsolete_pull_request_closed, validate_obsolete_pull_request_unchanged, +}; pub(super) use self::{ legacy::validate_legacy_closeout_request, merged::validate_merged_closeout_request, + superseded::validate_superseded_closeout_request, }; diff --git a/apps/decodex/src/recovery/closeout/validation/merged.rs b/apps/decodex/src/recovery/closeout/validation/merged.rs index 86c7079b4..7c01eea96 100644 --- a/apps/decodex/src/recovery/closeout/validation/merged.rs +++ b/apps/decodex/src/recovery/closeout/validation/merged.rs @@ -1,5 +1,5 @@ -mod issue; -mod pull_request; +pub(in crate::recovery::closeout::validation) mod issue; +pub(in crate::recovery::closeout::validation) mod pull_request; mod worktree; use crate::{ diff --git a/apps/decodex/src/recovery/closeout/validation/merged/pull_request.rs b/apps/decodex/src/recovery/closeout/validation/merged/pull_request.rs index 5091d79da..31c2da41f 100644 --- a/apps/decodex/src/recovery/closeout/validation/merged/pull_request.rs +++ b/apps/decodex/src/recovery/closeout/validation/merged/pull_request.rs @@ -86,3 +86,42 @@ pub(in crate::recovery) fn ensure_merge_commit_reachable_from_remote_default_bra repo_root.display() ) } + +pub(in crate::recovery) fn ensure_head_has_no_unique_patch_from_remote_default_branch( + repo_root: &Path, + head_commit: &str, + default_branch: &str, + context: &str, +) -> Result<()> { + let remote_ref = format!("refs/remotes/origin/{default_branch}"); + let output = Command::new("git") + .arg("-C") + .arg(repo_root) + .args(["cherry", remote_ref.as_str(), head_commit]) + .output()?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + + eyre::bail!( + "`git cherry {remote_ref} {head_commit}` failed in `{}` while checking `{context}`: {}", + repo_root.display(), + stderr.trim() + ); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let unique_commits = + stdout.lines().filter_map(|line| line.strip_prefix("+ ")).collect::>(); + + if unique_commits.is_empty() { + return Ok(()); + } + + eyre::bail!( + "Configured repo root `{}` found {} patch-unique obsolete PR commit(s) not present on `{remote_ref}` while checking `{context}`: {}", + repo_root.display(), + unique_commits.len(), + unique_commits.join(", ") + ) +} diff --git a/apps/decodex/src/recovery/closeout/validation/superseded.rs b/apps/decodex/src/recovery/closeout/validation/superseded.rs new file mode 100644 index 000000000..ca34225af --- /dev/null +++ b/apps/decodex/src/recovery/closeout/validation/superseded.rs @@ -0,0 +1,2306 @@ +use crate::{ + orchestrator, + prelude::{Result, eyre}, + recovery::{ + closeout::{ + SupersededCloseoutValidation, + validation::merged::{issue, pull_request}, + }, + context::RecoveryContext, + evidence, + process_liveness::{self, StaleActiveProcessLiveness}, + pull_request_inspection, + requests::SupersededCloseoutRecoveryRequest, + review_handoff, + }, + state, + tracker::{ + self, IssueTracker, TrackerIssue, + records::{self, LinearExecutionEventRecord}, + }, +}; + +pub(in crate::recovery) fn validate_superseded_closeout_request( + context: &RecoveryContext, + request: &SupersededCloseoutRecoveryRequest, +) -> Result { + if request.issue.eq_ignore_ascii_case(&request.successor_issue) { + eyre::bail!("Superseded issue and successor issue must be distinct."); + } + if request.pr_url.trim() == request.successor_pr_url.trim() { + eyre::bail!("Superseded PR and successor PR must be distinct."); + } + + let issue = review_handoff::load_issue_by_identifier(&context.tracker, &request.issue)?; + let successor_issue = + review_handoff::load_issue_by_identifier(&context.tracker, &request.successor_issue)?; + let completed_state_id = validate_superseded_issue_context(context, &issue)?; + validate_successor_issue_context(context, &successor_issue)?; + validate_same_tracker_team(&issue, &successor_issue)?; + validate_predecessor_successor_issue_relation(&context.tracker, &issue, &successor_issue)?; + + let (obsolete_landing_state, default_branch) = + pull_request_inspection::inspect_project_pull_request(context, &request.pr_url)?; + validate_obsolete_pull_request(&obsolete_landing_state, &default_branch)?; + + let (successor_landing_state, successor_default_branch) = + pull_request_inspection::inspect_project_pull_request(context, &request.successor_pr_url)?; + if successor_default_branch != default_branch { + eyre::bail!( + "Successor PR default branch `{successor_default_branch}` does not match obsolete PR default branch `{default_branch}`." + ); + } + pull_request::validate_merged_closeout_pull_request( + context, + &successor_landing_state, + &default_branch, + )?; + + let successor_merge_commit = + pull_request_inspection::inspect_project_pull_request_merge_commit( + context, + &request.successor_pr_url, + )?; + validate_successor_issue_pr_lineage( + context, + &context.tracker, + &successor_issue, + &successor_landing_state, + &successor_merge_commit, + )?; + pull_request::ensure_merge_commit_reachable_from_remote_default_branch( + context.config.repo_root(), + &request.successor_pr_url, + &successor_merge_commit, + &default_branch, + )?; + pull_request::ensure_head_has_no_unique_patch_from_remote_default_branch( + context.config.repo_root(), + &obsolete_landing_state.head_ref_oid, + &default_branch, + "obsolete PR has no unique unlanded patch after the successor PR landed", + )?; + + let worktree_mapping = issue::retained_worktree_mapping_for_issue(context, &issue)? + .ok_or_else(|| { + eyre::eyre!( + "Issue `{}` has no retained worktree mapping; superseded closeout requires the obsolete retained lane mapping.", + issue.identifier + ) + })?; + let local_head = review_handoff::validate_retained_pr_worktree( + &worktree_mapping, + &obsolete_landing_state, + "superseded closeout", + )?; + if local_head != obsolete_landing_state.head_ref_oid { + eyre::bail!( + "Retained worktree HEAD `{local_head}` does not match obsolete PR head `{}`.", + obsolete_landing_state.head_ref_oid + ); + } + + let worktree_path_for_event = review_handoff::relative_worktree_path_for_recovery( + context, + worktree_mapping.worktree_path(), + ) + .unwrap_or_else(|| worktree_mapping.worktree_path().display().to_string()); + let (run_id, attempt_number) = + if let Some(attempt) = context.state_store.latest_run_attempt_for_issue(&issue.id)? { + (attempt.run_id().to_owned(), attempt.attempt_number()) + } else { + (format!("superseded-closeout-{}", issue.identifier.to_ascii_lowercase()), 1) + }; + ensure_superseded_closeout_run_attempt_compatible( + &context.state_store, + &issue.id, + &run_id, + attempt_number, + )?; + + let validation = SupersededCloseoutValidation { + issue, + successor_issue, + branch_name: worktree_mapping.branch_name().to_owned(), + worktree_path_for_event, + run_id, + attempt_number, + obsolete_landing_state, + successor_landing_state, + successor_merge_commit, + completed_state_id, + }; + ensure_superseded_issue_terminalizable(context, &validation)?; + + Ok(validation) +} + +pub(in crate::recovery::closeout) fn ensure_superseded_closeout_run_attempt_compatible( + state_store: &state::StateStore, + issue_id: &str, + run_id: &str, + attempt_number: i64, +) -> Result { + let Some(attempt) = state_store.run_attempt(run_id)? else { + return Ok(false); + }; + + if attempt.issue_id() != issue_id + || attempt.attempt_number() != attempt_number + || !orchestrator::local_run_attempt_status_is_terminal(attempt.status()) + { + eyre::bail!( + "Superseded closeout recovery run `{run_id}` conflicts with issue `{}` attempt {} status `{}`; expected issue `{issue_id}` attempt {attempt_number} with terminal status.", + attempt.issue_id(), + attempt.attempt_number(), + attempt.status() + ); + } + + Ok(true) +} + +fn validate_same_tracker_team(issue: &TrackerIssue, successor_issue: &TrackerIssue) -> Result<()> { + if issue.team.id == successor_issue.team.id { + return Ok(()); + } + + eyre::bail!( + "Superseded issue `{}` belongs to team `{}`, but successor issue `{}` belongs to team `{}`.", + issue.identifier, + issue.team.name, + successor_issue.identifier, + successor_issue.team.name + ) +} + +fn validate_predecessor_successor_issue_relation( + tracker: &T, + issue: &TrackerIssue, + successor_issue: &TrackerIssue, +) -> Result<()> +where + T: IssueTracker + ?Sized, +{ + if tracker.issues_have_explicit_relation(&issue.id, &successor_issue.id)? { + return Ok(()); + } + + eyre::bail!( + "Superseded issue `{}` and successor issue `{}` have no explicit tracker relation; superseded closeout requires public predecessor/successor lineage before terminalization.", + issue.identifier, + successor_issue.identifier + ) +} + +pub(in crate::recovery::closeout) fn validate_obsolete_pull_request_unchanged( + validated: &crate::pull_request::PullRequestLandingState, + current: &crate::pull_request::PullRequestLandingState, + current_default_branch: &str, +) -> Result<()> { + validate_obsolete_pull_request_identity( + validated, + current, + current_default_branch, + &validated.state, + ) +} + +pub(in crate::recovery::closeout) fn validate_obsolete_pull_request_closed( + validated: &crate::pull_request::PullRequestLandingState, + current: &crate::pull_request::PullRequestLandingState, + current_default_branch: &str, +) -> Result<()> { + validate_obsolete_pull_request_identity(validated, current, current_default_branch, "CLOSED") +} + +fn validate_obsolete_pull_request_identity( + validated: &crate::pull_request::PullRequestLandingState, + current: &crate::pull_request::PullRequestLandingState, + current_default_branch: &str, + expected_state: &str, +) -> Result<()> { + let validated_url = pull_request_inspection::landing_url(validated); + let current_url = pull_request_inspection::landing_url(current); + + if current_url != validated_url + || current_default_branch != validated.base_ref_name + || current.base_ref_name != validated.base_ref_name + || current.base_ref_oid != validated.base_ref_oid + || current.state != expected_state + || current.head_ref_name != validated.head_ref_name + || current.head_ref_oid != validated.head_ref_oid + { + eyre::bail!( + "Obsolete pull request `{validated_url}` changed after superseded closeout validation; expected state `{}`, base `{}` at `{:?}`, and head `{}` at `{}`, but found URL `{current_url}`, state `{}`, default/base `{current_default_branch}`/`{}` at `{:?}`, and head `{}` at `{}`.", + expected_state, + validated.base_ref_name, + validated.base_ref_oid, + validated.head_ref_name, + validated.head_ref_oid, + current.state, + current.base_ref_name, + current.base_ref_oid, + current.head_ref_name, + current.head_ref_oid + ); + } + + Ok(()) +} + +fn validate_superseded_issue_context( + context: &RecoveryContext, + issue: &TrackerIssue, +) -> Result { + let tracker_policy = context.workflow.frontmatter().tracker(); + + if issue.has_label(tracker_policy.opt_out_label()) { + eyre::bail!( + "Issue `{}` has opt-out label `{}`.", + issue.identifier, + tracker_policy.opt_out_label() + ); + } + issue + .team + .states + .iter() + .find(|state| state.name == tracker_policy.resolved_completed_state()) + .map(|state| state.id.clone()) + .ok_or_else(|| { + eyre::eyre!( + "Issue `{}` team has no completed state `{}`.", + issue.identifier, + tracker_policy.resolved_completed_state() + ) + }) +} + +pub(in crate::recovery::closeout) fn ensure_superseded_issue_terminalizable( + context: &RecoveryContext, + validation: &SupersededCloseoutValidation, +) -> Result<()> { + let lineage = SupersededCloseoutRetryLineage::from_validation(context, validation); + + ensure_superseded_issue_terminalizable_with_tracker_and_lineage( + context, + &context.tracker, + &validation.issue, + Some(&lineage), + ) +} + +#[cfg(test)] +fn ensure_superseded_issue_terminalizable_with_tracker( + context: &RecoveryContext, + tracker: &T, + issue: &TrackerIssue, +) -> Result<()> +where + T: IssueTracker + ?Sized, +{ + ensure_superseded_issue_terminalizable_with_tracker_and_lineage(context, tracker, issue, None) +} + +fn ensure_superseded_issue_terminalizable_with_tracker_and_lineage( + context: &RecoveryContext, + tracker: &T, + issue: &TrackerIssue, + retry_lineage: Option<&SupersededCloseoutRetryLineage<'_>>, +) -> Result<()> +where + T: IssueTracker + ?Sized, +{ + ensure_superseded_issue_recovery_labels_absent_with_tracker(context, tracker, issue)?; + ensure_issue_has_no_live_runtime_ownership(context, issue, retry_lineage) +} + +struct SupersededCloseoutRetryLineage<'a> { + project_id: &'a str, + issue_id: &'a str, + run_id: &'a str, + attempt_number: i64, + branch_name: &'a str, + base_branch: &'a str, + obsolete_pr_url: &'a str, + obsolete_head_sha: &'a str, + successor_merge_commit: &'a str, +} +impl<'a> SupersededCloseoutRetryLineage<'a> { + fn from_validation( + context: &'a RecoveryContext, + validation: &'a SupersededCloseoutValidation, + ) -> Self { + Self { + project_id: context.config.service_id(), + issue_id: &validation.issue.id, + run_id: &validation.run_id, + attempt_number: validation.attempt_number, + branch_name: &validation.branch_name, + base_branch: &validation.obsolete_landing_state.base_ref_name, + obsolete_pr_url: pull_request_inspection::landing_url( + &validation.obsolete_landing_state, + ), + obsolete_head_sha: &validation.obsolete_landing_state.head_ref_oid, + successor_merge_commit: &validation.successor_merge_commit, + } + } +} + +fn ensure_superseded_issue_recovery_labels_absent_with_tracker( + context: &RecoveryContext, + tracker: &T, + issue: &TrackerIssue, +) -> Result<()> +where + T: IssueTracker + ?Sized, +{ + let tracker_policy = context.workflow.frontmatter().tracker(); + + for label in [ + tracker::automation_queue_label(context.config.service_id()), + tracker::automation_active_label(context.config.service_id()), + tracker_policy.needs_attention_label().to_owned(), + ] { + if tracker::issue_has_label_with_server_confirmation(tracker, issue, &label)? { + eyre::bail!( + "Issue `{}` still has Linear label `{label}`; superseded closeout recovery requires queue, active, and needs-attention labels to be absent.", + issue.identifier + ); + } + } + + Ok(()) +} + +fn ensure_issue_has_no_live_runtime_ownership( + context: &RecoveryContext, + issue: &TrackerIssue, + retry_lineage: Option<&SupersededCloseoutRetryLineage<'_>>, +) -> Result<()> { + let retained_worktree_mapping = issue::retained_worktree_mapping_for_issue(context, issue)?; + ensure_retained_worktree_marker_has_no_live_runtime_ownership( + issue, + retained_worktree_mapping.as_ref(), + )?; + + for issue_key in issue_keys(issue) { + if context + .state_store + .issue_has_active_shared_claim_read_only(context.config.service_id(), &issue_key)? + { + eyre::bail!( + "Issue `{}` still has active runtime ownership for `{issue_key}`; superseded closeout recovery requires live ownership to be absent.", + issue.identifier + ); + } + + ensure_issue_has_no_active_run_control_channel(context, issue, &issue_key)?; + + let attempts = context.state_store.list_run_attempts_for_issue(&issue_key)?; + let private_events = context + .state_store + .list_private_execution_events_for_issue(context.config.service_id(), &issue_key)?; + + if let Some(event) = private_events.iter().find(|event| { + !attempts.iter().any(|attempt| { + attempt.run_id() == event.run_id() + && attempt.attempt_number() == event.attempt_number() + }) && !superseded_closeout_lifecycle_event_allows_retry(event, retry_lineage) + }) { + eyre::bail!( + "Issue `{}` still has retained private execution evidence `{}` for run `{}` attempt {} without a matching run attempt; superseded closeout recovery requires stale private evidence to be cleared or classified by explicit recovery before terminalization.", + issue.identifier, + event.event_type(), + event.run_id(), + event.attempt_number() + ); + } + + for attempt in attempts { + if !orchestrator::local_run_attempt_status_is_terminal(attempt.status()) { + eyre::bail!( + "Issue `{}` still has non-terminal {} run `{}` for `{issue_key}`; superseded closeout recovery requires live ownership to be absent.", + issue.identifier, + attempt.status(), + attempt.run_id() + ); + } + + ensure_run_attempt_has_no_durable_runtime_evidence( + context, + issue, + &attempt, + &private_events, + retry_lineage, + )?; + } + } + + Ok(()) +} + +fn ensure_issue_has_no_active_run_control_channel( + context: &RecoveryContext, + issue: &TrackerIssue, + issue_key: &str, +) -> Result<()> { + if let Some(channel) = context + .state_store + .active_run_control_channel_for_issue(context.config.service_id(), issue_key)? + { + eyre::bail!( + "Issue `{}` still has active run-control channel ownership for run `{}` attempt {}; superseded closeout recovery requires explicit stale-lane recovery before terminalization.", + issue.identifier, + channel.run_id(), + channel.attempt_number() + ); + } + + Ok(()) +} + +fn ensure_run_attempt_has_no_durable_runtime_evidence( + context: &RecoveryContext, + issue: &TrackerIssue, + attempt: &state::RunAttempt, + issue_private_events: &[state::PrivateExecutionEvent], + retry_lineage: Option<&SupersededCloseoutRetryLineage<'_>>, +) -> Result<()> { + let run_id = attempt.run_id(); + let attempt_number = attempt.attempt_number(); + let private_events = issue_private_events + .iter() + .filter(|event| event.run_id() == run_id && event.attempt_number() == attempt_number) + .collect::>(); + + if let Some(event) = private_events.iter().find(|event| { + event.event_type() == "progress_checkpoint" + || !superseded_private_event_allows_terminalization(event, retry_lineage) + }) { + eyre::bail!( + "Issue `{}` still has retained private execution evidence `{}` for run `{run_id}` attempt {attempt_number}; superseded closeout recovery requires stale private evidence to be cleared or classified by explicit recovery before terminalization.", + issue.identifier, + event.event_type() + ); + } + + if context.state_store.event_count(run_id)? > 0 { + eyre::bail!( + "Issue `{}` still has retained protocol event evidence for run `{run_id}` attempt {attempt_number}; superseded closeout recovery requires stale protocol evidence to be cleared or classified by explicit recovery before terminalization.", + issue.identifier + ); + } + + if context.state_store.run_has_activity_summary_evidence(run_id)? { + eyre::bail!( + "Issue `{}` still has retained run activity summary evidence for run `{run_id}` attempt {attempt_number}; superseded closeout recovery requires stale activity evidence to be cleared or classified by explicit recovery before terminalization.", + issue.identifier + ); + } + + Ok(()) +} + +fn superseded_private_event_allows_terminalization( + event: &state::PrivateExecutionEvent, + retry_lineage: Option<&SupersededCloseoutRetryLineage<'_>>, +) -> bool { + // Superseded closeout independently rejects active control rows, non-terminal attempts, and + // progress/protocol/activity evidence. The stale-active release allowlist is therefore limited + // here to shaped historical telemetry that does not itself retain execution ownership. + let payload = event.payload(); + + match event.event_type() { + "lifecycle_event" => + return superseded_closeout_lifecycle_event_allows_retry(event, retry_lineage), + "control_channel_published" => { + return payload.get("schema").and_then(serde_json::Value::as_str) + == Some("decodex.run_control_channel/v1") + && payload.get("transport").and_then(serde_json::Value::as_str).is_some() + && payload.get("channel_path").and_then(serde_json::Value::as_str).is_some() + && payload.get("status").and_then(serde_json::Value::as_str) == Some("active") + && payload.get("published_at").and_then(serde_json::Value::as_str).is_some(); + }, + "phase_goal_set" => { + return payload.get("schema").and_then(serde_json::Value::as_str) + == Some("decodex.phase_goal_signal/1") + && payload.get("phase").and_then(serde_json::Value::as_str).is_some() + && payload.get("payload").is_some_and(serde_json::Value::is_object); + }, + _ => {}, + } + + evidence::stale_active_private_event_allows_release( + event, + StaleActiveProcessLiveness::NotAlive, + false, + ) +} + +fn superseded_closeout_lifecycle_event_allows_retry( + event: &state::PrivateExecutionEvent, + retry_lineage: Option<&SupersededCloseoutRetryLineage<'_>>, +) -> bool { + let Some(lineage) = retry_lineage else { + return false; + }; + let envelope = event.payload(); + let Some(authority) = envelope.get("authority_record") else { + return false; + }; + let envelope_string = |field| envelope.get(field).and_then(serde_json::Value::as_str); + let authority_string = |field| authority.get(field).and_then(serde_json::Value::as_str); + let expected_subject_id = + format!("{}:{}:{}", lineage.project_id, lineage.issue_id, lineage.branch_name); + let expected_pending_idempotency_key = format!( + "{}:{}:{}:superseded_closeout_recovery:pending", + lineage.project_id, lineage.issue_id, lineage.successor_merge_commit + ); + let expected_completed_idempotency_key = format!( + "{}:{}:{}:superseded_closeout_recovery:completed", + lineage.project_id, lineage.issue_id, lineage.successor_merge_commit + ); + + if envelope_string("schema_version") != Some("decodex/lifecycle-event/1") + || envelope_string("event_type") != Some("lifecycle_authority_recorded") + || envelope_string("subject_id") != Some(expected_subject_id.as_str()) + || envelope_string("subject_id") != authority_string("subject_id") + || envelope_string("idempotency_key") != authority_string("idempotency_key") + || envelope_string("correlation_id") != Some(event.run_id()) + || authority_string("schema_version") != Some("decodex/lifecycle-authority-record/1") + || authority_string("issue_id") != Some(event.issue_id()) + || authority_string("project_id") != Some(lineage.project_id) + || authority_string("service_id") != Some(lineage.project_id) + || authority_string("issue_id") != Some(lineage.issue_id) + || authority_string("correlation_id") != Some(event.run_id()) + || event.run_id() != lineage.run_id + || event.attempt_number() != lineage.attempt_number + || authority_string("head_branch") != Some(lineage.branch_name) + || authority_string("base_branch") != Some(lineage.base_branch) + || authority_string("pr_url") != Some(lineage.obsolete_pr_url) + || authority_string("validated_head_sha") != Some(lineage.obsolete_head_sha) + || authority_string("merge_commit") != Some(lineage.successor_merge_commit) + || authority_string("actor") != Some("superseded_closeout_recovery") + || authority_string("authority") != Some("issue_authority") + || envelope_string("causation_id") != authority_string("causation_id") + { + return false; + } + + match authority_string("cleanup_state") { + Some("pending") => + authority_string("phase") == Some("closeout_pending") + && authority_string("transition") == Some("closeout_intent_recorded") + && authority_string("next_state") == Some("closeout_pending") + && authority_string("next_action") == Some("run_retained_closeout_adapter") + && authority_string("causation_id") + == Some("superseded_closeout_recovery_pr_close_authorized") + && authority_string("idempotency_key") + == Some(expected_pending_idempotency_key.as_str()), + Some("completed") => + authority_string("phase") == Some("closed") + && authority_string("transition") == Some("closeout_completed") + && authority_string("next_state") == Some("closed") + && authority_string("next_action") == Some("no_action") + && authority_string("causation_id") + == Some("superseded_closeout_recovery_closeout_complete") + && authority_string("idempotency_key") + == Some(expected_completed_idempotency_key.as_str()), + _ => false, + } +} + +fn ensure_retained_worktree_marker_has_no_live_runtime_ownership( + issue: &TrackerIssue, + retained_worktree_mapping: Option<&state::WorktreeMapping>, +) -> Result<()> { + let Some(worktree_mapping) = retained_worktree_mapping else { + return Ok(()); + }; + let Some(marker) = state::read_run_activity_marker_snapshot(worktree_mapping.worktree_path())? + else { + return Ok(()); + }; + + if let Some(retry_kind) = marker.retry_kind() { + eyre::bail!( + "Issue `{}` still has retry-scheduled runtime ownership for retained worktree run `{}` attempt {} ({retry_kind}); superseded closeout recovery requires live ownership to be absent.", + issue.identifier, + marker.run_id(), + marker.attempt_number() + ); + } + + ensure_marker_has_no_live_runtime_evidence(issue, &marker) +} + +fn ensure_marker_has_no_live_runtime_evidence( + issue: &TrackerIssue, + marker: &state::RunActivityMarker, +) -> Result<()> { + let run_id = marker.run_id(); + let attempt_number = marker.attempt_number(); + let marker_liveness = + process_liveness::stale_active_optional_marker_process_liveness(Some(marker)); + match marker_liveness { + StaleActiveProcessLiveness::Alive => eyre::bail!( + "Issue `{}` still has live retained process ownership for retained worktree run `{run_id}` attempt {attempt_number}; superseded closeout recovery requires live ownership to be absent.", + issue.identifier + ), + StaleActiveProcessLiveness::Unknown if marker.process_id().is_some() => eyre::bail!( + "Issue `{}` still has unknown retained process liveness for retained worktree run `{run_id}` attempt {attempt_number}; superseded closeout recovery requires live ownership to be absent.", + issue.identifier + ), + StaleActiveProcessLiveness::Unknown | StaleActiveProcessLiveness::NotAlive => {}, + } + + if process_liveness::stale_active_marker_thread_active(marker) { + eyre::bail!( + "Issue `{}` still has live retained thread ownership for retained worktree run `{run_id}` attempt {attempt_number}; superseded closeout recovery requires live ownership to be absent.", + issue.identifier + ); + } + + if marker.last_activity_unix_epoch().is_some() + || marker.current_operation().is_some() + || marker.last_progress_unix_epoch().is_some() + || marker.last_protocol_activity_unix_epoch().is_some() + || marker.event_count() > 0 + || marker.last_event_type().is_some() + || marker.child_agent_activity().is_some() + || marker.protocol_activity().is_some() + { + eyre::bail!( + "Issue `{}` still has retained activity marker ownership for retained worktree run `{run_id}` attempt {attempt_number}; superseded closeout recovery requires stale activity evidence to be cleared by explicit recovery before terminalization.", + issue.identifier + ); + } + + Ok(()) +} + +fn validate_successor_issue_pr_lineage( + context: &RecoveryContext, + tracker: &T, + successor_issue: &TrackerIssue, + successor_landing_state: &crate::pull_request::PullRequestLandingState, + successor_merge_commit: &str, +) -> Result<()> +where + T: IssueTracker + ?Sized, +{ + let records = successor_issue_lifecycle_records(context, tracker, successor_issue)?; + + if records.iter().any(|record| { + successor_issue_record_matches_pr_lineage( + context, + successor_issue, + record, + successor_landing_state, + successor_merge_commit, + ) + }) { + return Ok(()); + } + + eyre::bail!( + "Successor issue `{}` has no Decodex execution ledger record tying it to successor PR `{}` head `{}` and merge commit `{successor_merge_commit}`.", + successor_issue.identifier, + pull_request_inspection::landing_url(successor_landing_state), + successor_landing_state.head_ref_oid + ) +} + +fn successor_issue_lifecycle_records( + context: &RecoveryContext, + tracker: &T, + successor_issue: &TrackerIssue, +) -> Result> +where + T: IssueTracker + ?Sized, +{ + let mut lineage_records = Vec::new(); + + for issue_key in issue_keys(successor_issue) { + lineage_records.extend( + context + .state_store + .list_linear_execution_events(context.config.service_id(), &issue_key)?, + ); + } + + lineage_records.extend( + tracker + .list_comments(&successor_issue.id)? + .iter() + .filter_map(|comment| records::parse_linear_execution_event_record(&comment.body)), + ); + + Ok(lineage_records + .into_iter() + .filter(|record| { + record.service_id == context.config.service_id() + && (record.issue_id == successor_issue.id + || record.issue_identifier == successor_issue.identifier) + }) + .collect()) +} + +fn successor_issue_record_matches_pr_lineage( + context: &RecoveryContext, + successor_issue: &TrackerIssue, + record: &LinearExecutionEventRecord, + successor_landing_state: &crate::pull_request::PullRequestLandingState, + successor_merge_commit: &str, +) -> bool { + record.service_id == context.config.service_id() + && (record.issue_id == successor_issue.id + || record.issue_identifier == successor_issue.identifier) + && record.pr_url.as_deref().map(str::trim) + == Some(pull_request_inspection::landing_url(successor_landing_state)) + && record.pr_head_sha.as_deref() == Some(successor_landing_state.head_ref_oid.as_str()) + && record.commit_sha.as_deref() == Some(successor_merge_commit) +} + +fn issue_keys(issue: &TrackerIssue) -> Vec { + let mut keys = vec![issue.id.clone()]; + + if issue.identifier != issue.id { + keys.push(issue.identifier.clone()); + } + + keys +} + +fn validate_successor_issue_context(context: &RecoveryContext, issue: &TrackerIssue) -> Result<()> { + let tracker_policy = context.workflow.frontmatter().tracker(); + let completed_state = tracker_policy.resolved_completed_state(); + + if issue.state.name != completed_state { + eyre::bail!( + "Successor issue `{}` is in `{}`, but superseded closeout requires `{completed_state}`.", + issue.identifier, + issue.state.name + ); + } + if issue.has_label(tracker_policy.opt_out_label()) { + eyre::bail!( + "Successor issue `{}` has opt-out label `{}`.", + issue.identifier, + tracker_policy.opt_out_label() + ); + } + + Ok(()) +} + +fn validate_obsolete_pull_request( + landing_state: &crate::pull_request::PullRequestLandingState, + default_branch: &str, +) -> Result<()> { + if landing_state.base_ref_name != default_branch { + eyre::bail!( + "Obsolete pull request `{}` targets `{}`, but configured default branch is `{default_branch}`.", + pull_request_inspection::landing_url(landing_state), + landing_state.base_ref_name + ); + } + if landing_state.state == "MERGED" { + eyre::bail!( + "Obsolete pull request `{}` is already merged; use merged closeout recovery for same-PR lineage.", + pull_request_inspection::landing_url(landing_state) + ); + } + if !matches!(landing_state.state.as_str(), "OPEN" | "CLOSED") { + eyre::bail!( + "Obsolete pull request `{}` is `{}`; superseded closeout requires `OPEN` or already `CLOSED`.", + pull_request_inspection::landing_url(landing_state), + landing_state.state + ); + } + if landing_state.head_ref_name.trim().is_empty() { + eyre::bail!( + "Obsolete pull request `{}` does not expose the retained head branch required for superseded closeout.", + pull_request_inspection::landing_url(landing_state) + ); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::{cell::RefCell, fs}; + + use tempfile::TempDir; + use time::{OffsetDateTime, format_description::well_known::Rfc3339}; + + use super::*; + use crate::{ + config::ServiceConfig, + recovery::{LEGACY_MANUAL_CLOSEOUT_EVENT, RecoveryRuntimeMutationPolicy}, + state::{ProtocolActivityMarker, ProtocolActivitySummary, StateStore}, + tracker::{ + TrackerComment, TrackerIssue, TrackerIssueBriefUpdate, TrackerIssueCreate, + TrackerLabel, TrackerState, TrackerTeam, + linear::LinearClient, + records::{self, LinearExecutionEventIdentity, LinearExecutionEventRecord}, + }, + workflow::WorkflowDocument, + }; + + #[test] + fn terminalizable_guard_rejects_queue_active_and_attention_labels() { + let labels = [ + tracker::automation_queue_label("pubfi"), + tracker::automation_active_label("pubfi"), + String::from("decodex:needs-attention"), + ]; + + for label in labels { + let temp_dir = TempDir::new().expect("tempdir should create"); + let context = + sample_recovery_context(&temp_dir, RecoveryRuntimeMutationPolicy::ReadOnly); + let issue = sample_issue_with_labels("Todo", &[label.clone()]); + let tracker = TestTracker::with_issues(vec![issue.clone()]); + let error = + ensure_superseded_issue_terminalizable_with_tracker(&context, &tracker, &issue) + .expect_err("superseded closeout should reject ownership labels"); + + assert!( + error.to_string().contains(label.as_str()), + "error should name rejected label `{label}`: {error}" + ); + } + } + + #[test] + fn terminalizable_guard_rejects_live_runtime_ownership() { + let temp_dir = TempDir::new().expect("tempdir should create"); + let context = sample_recovery_context(&temp_dir, RecoveryRuntimeMutationPolicy::ReadOnly); + let issue = sample_issue("Todo"); + let tracker = TestTracker::with_issues(vec![issue.clone()]); + + context + .state_store + .upsert_lease(context.config.service_id(), &issue.id, "run-lease", "Todo") + .expect("lease should persist"); + + let error = ensure_superseded_issue_terminalizable_with_tracker(&context, &tracker, &issue) + .expect_err("superseded closeout should reject active runtime lease"); + + assert!(error.to_string().contains("active runtime ownership")); + } + + #[test] + fn superseded_recovery_validation_rejects_deterministic_run_id_collision() { + let state_store = StateStore::open_in_memory().expect("state store should open"); + state_store + .record_run_attempt("superseded-closeout-pub-1704", "other-issue", 2, "running") + .expect("conflicting run attempt should record"); + + let error = ensure_superseded_closeout_run_attempt_compatible( + &state_store, + "issue-id", + "superseded-closeout-pub-1704", + 1, + ) + .expect_err("dry-run validation should reject a deterministic run id collision"); + + assert!(error.to_string().contains("conflicts with issue")); + assert!(error.to_string().contains("other-issue")); + } + + #[test] + fn terminalizable_guard_rejects_running_attempt_without_lease() { + let temp_dir = TempDir::new().expect("tempdir should create"); + let context = sample_recovery_context(&temp_dir, RecoveryRuntimeMutationPolicy::ReadOnly); + let issue = sample_issue("Todo"); + let tracker = TestTracker::with_issues(vec![issue.clone()]); + + context + .state_store + .record_run_attempt("run-active", &issue.id, 1, "running") + .expect("run should persist"); + + let error = ensure_superseded_issue_terminalizable_with_tracker(&context, &tracker, &issue) + .expect_err("superseded closeout should reject running attempts"); + + assert!(error.to_string().contains("run-active")); + } + + #[test] + fn terminalizable_guard_rejects_continuation_pending_attempt_without_lease() { + let temp_dir = TempDir::new().expect("tempdir should create"); + let context = sample_recovery_context(&temp_dir, RecoveryRuntimeMutationPolicy::ReadOnly); + let issue = sample_issue("Todo"); + let tracker = TestTracker::with_issues(vec![issue.clone()]); + + context + .state_store + .record_run_attempt("run-continuation", &issue.id, 1, "continuation_pending") + .expect("run should persist"); + + let error = ensure_superseded_issue_terminalizable_with_tracker(&context, &tracker, &issue) + .expect_err("superseded closeout should reject continuation-owned attempts"); + + assert!(error.to_string().contains("run-continuation")); + assert!(error.to_string().contains("continuation_pending")); + } + + #[test] + fn terminalizable_guard_rejects_active_control_channel_after_terminal_attempt() { + let temp_dir = TempDir::new().expect("tempdir should create"); + let issue = sample_issue("Todo"); + let tracker = TestTracker::with_issues(vec![issue.clone()]); + + for (case, issue_key) in + [issue.id.as_str(), issue.identifier.as_str()].into_iter().enumerate() + { + let state_path = temp_dir.path().join(format!("active-control-channel-{case}.sqlite3")); + let channel_path = temp_dir.path().join(format!("run-control-{case}.channel")); + let run_id = format!("run-control-{case}"); + let mut context = + sample_recovery_context(&temp_dir, RecoveryRuntimeMutationPolicy::ReadOnly); + + context.state_store = StateStore::open(&state_path).expect("state store should open"); + context + .state_store + .upsert_lease(context.config.service_id(), issue_key, &run_id, "Todo") + .expect("lease should persist"); + context + .state_store + .record_run_attempt(&run_id, issue_key, 1, "running") + .expect("run should persist"); + context + .state_store + .publish_run_control_channel_for_active_attempt( + &run_id, + 1, + &channel_path, + "local_file", + ) + .expect("control channel should publish") + .expect("control channel should be active"); + context + .state_store + .update_run_status(&run_id, "failed") + .expect("run should become terminal"); + context + .state_store + .clear_lease(issue_key) + .expect("lease should clear without retiring the channel"); + context.state_store = StateStore::open(&state_path).expect("state store should reopen"); + + let error = + ensure_superseded_issue_terminalizable_with_tracker(&context, &tracker, &issue) + .expect_err("superseded closeout should reject an active control channel"); + + assert!(error.to_string().contains("active run-control channel ownership")); + assert!(error.to_string().contains(&run_id)); + } + } + + #[test] + fn terminalizable_guard_rejects_private_evidence_without_attempt_row() { + let temp_dir = TempDir::new().expect("tempdir should create"); + let issue = sample_issue("Todo"); + let tracker = TestTracker::with_issues(vec![issue.clone()]); + + for (case, issue_key) in + [issue.id.as_str(), issue.identifier.as_str()].into_iter().enumerate() + { + let state_path = + temp_dir.path().join(format!("orphan-private-evidence-{case}.sqlite3")); + let run_id = format!("run-without-attempt-{case}"); + let mut context = + sample_recovery_context(&temp_dir, RecoveryRuntimeMutationPolicy::ReadOnly); + + context.state_store = StateStore::open(&state_path).expect("state store should open"); + context + .state_store + .append_private_execution_event( + context.config.service_id(), + issue_key, + &run_id, + 1, + "phase_goal_set", + serde_json::json!({"phase": "implement_to_validation_ready"}), + ) + .expect("orphan private evidence should persist"); + context.state_store = StateStore::open(&state_path).expect("state store should reopen"); + + let error = + ensure_superseded_issue_terminalizable_with_tracker(&context, &tracker, &issue) + .expect_err( + "superseded closeout should reject private evidence without an attempt", + ); + + assert!(error.to_string().contains(&run_id)); + assert!(error.to_string().contains("without a matching run attempt")); + } + } + + #[test] + fn terminalizable_guard_allows_superseded_lifecycle_retry_without_attempt_row() { + let temp_dir = TempDir::new().expect("tempdir should create"); + let issue = sample_issue("Todo"); + let tracker = TestTracker::with_issues(vec![issue.clone()]); + + for cleanup_state in ["pending", "completed"] { + let context = + sample_recovery_context(&temp_dir, RecoveryRuntimeMutationPolicy::ReadOnly); + let run_id = format!("superseded-closeout-{cleanup_state}"); + let lineage = sample_superseded_retry_lineage(&issue, &run_id); + + context + .state_store + .append_private_execution_event( + context.config.service_id(), + &issue.id, + &run_id, + 1, + "lifecycle_event", + superseded_lifecycle_event_payload(&issue.id, &run_id, cleanup_state), + ) + .expect("superseded lifecycle evidence should persist"); + + ensure_superseded_issue_terminalizable_with_tracker_and_lineage( + &context, + &tracker, + &issue, + Some(&lineage), + ) + .expect("matching superseded lifecycle authority should remain retryable"); + } + } + + #[test] + fn terminalizable_guard_allows_superseded_lifecycle_retry_with_terminal_attempt() { + let temp_dir = TempDir::new().expect("tempdir should create"); + let context = sample_recovery_context(&temp_dir, RecoveryRuntimeMutationPolicy::ReadOnly); + let issue = sample_issue("Todo"); + let tracker = TestTracker::with_issues(vec![issue.clone()]); + let run_id = "run-existing-terminal"; + let lineage = sample_superseded_retry_lineage(&issue, run_id); + + context + .state_store + .record_run_attempt(run_id, &issue.id, 1, "terminated") + .expect("terminal run attempt should persist"); + context + .state_store + .append_private_execution_event( + context.config.service_id(), + &issue.id, + run_id, + 1, + "lifecycle_event", + superseded_lifecycle_event_payload(&issue.id, run_id, "pending"), + ) + .expect("pending lifecycle evidence should persist"); + + ensure_superseded_issue_terminalizable_with_tracker_and_lineage( + &context, + &tracker, + &issue, + Some(&lineage), + ) + .expect("matching pending authority should permit reentry"); + } + + #[test] + fn terminalizable_guard_rejects_unshaped_lifecycle_event_without_attempt_row() { + let temp_dir = TempDir::new().expect("tempdir should create"); + let context = sample_recovery_context(&temp_dir, RecoveryRuntimeMutationPolicy::ReadOnly); + let issue = sample_issue("Todo"); + let tracker = TestTracker::with_issues(vec![issue.clone()]); + + context + .state_store + .append_private_execution_event( + context.config.service_id(), + &issue.id, + "superseded-closeout-malformed", + 1, + "lifecycle_event", + serde_json::json!({"actor": "superseded_closeout_recovery"}), + ) + .expect("malformed lifecycle evidence should persist"); + + let error = ensure_superseded_issue_terminalizable_with_tracker(&context, &tracker, &issue) + .expect_err("unshaped lifecycle evidence should fail closed"); + + assert!(error.to_string().contains("without a matching run attempt")); + } + + #[test] + fn terminalizable_guard_rejects_superseded_lifecycle_lineage_mismatch() { + let temp_dir = TempDir::new().expect("tempdir should create"); + let issue = sample_issue("Todo"); + let tracker = TestTracker::with_issues(vec![issue.clone()]); + let cases = [ + ("root", "subject_id"), + ("authority_record", "project_id"), + ("authority_record", "issue_id"), + ("authority_record", "correlation_id"), + ("authority_record", "pr_url"), + ("authority_record", "base_branch"), + ("authority_record", "validated_head_sha"), + ("authority_record", "merge_commit"), + ("authority_record", "actor"), + ("authority_record", "idempotency_key"), + ]; + + for (case, (scope, field)) in cases.into_iter().enumerate() { + let context = + sample_recovery_context(&temp_dir, RecoveryRuntimeMutationPolicy::ReadOnly); + let run_id = format!("superseded-closeout-mismatch-{case}"); + let lineage = sample_superseded_retry_lineage(&issue, &run_id); + let mut payload = superseded_lifecycle_event_payload(&issue.id, &run_id, "pending"); + if scope == "root" { + payload[field] = serde_json::Value::String(String::from("mismatch")); + } else { + payload[scope][field] = serde_json::Value::String(String::from("mismatch")); + } + + context + .state_store + .append_private_execution_event( + context.config.service_id(), + &issue.id, + &run_id, + 1, + "lifecycle_event", + payload, + ) + .expect("mismatched lifecycle evidence should persist"); + + let error = ensure_superseded_issue_terminalizable_with_tracker_and_lineage( + &context, + &tracker, + &issue, + Some(&lineage), + ) + .expect_err("mismatched lifecycle lineage should fail closed"); + + assert!( + error.to_string().contains("without a matching run attempt"), + "{scope}.{field} mismatch should reject reentry: {error}" + ); + } + } + + #[test] + fn terminalizable_guard_allows_shaped_historical_private_telemetry() { + let temp_dir = TempDir::new().expect("tempdir should create"); + let context = sample_recovery_context(&temp_dir, RecoveryRuntimeMutationPolicy::ReadOnly); + let issue = sample_issue("Todo"); + let tracker = TestTracker::with_issues(vec![issue.clone()]); + + context + .state_store + .record_run_attempt("run-classification", &issue.id, 1, "failed") + .expect("terminal run should persist"); + context + .state_store + .append_private_execution_event( + context.config.service_id(), + &issue.id, + "run-classification", + 1, + "phase_goal_set", + serde_json::json!({ + "schema": "decodex.phase_goal_signal/1", + "phase": "implement_to_validation_ready", + "payload": {"status": "active"}, + }), + ) + .expect("private evidence should persist"); + + context + .state_store + .append_private_execution_event( + context.config.service_id(), + &issue.id, + "run-classification", + 1, + "control_channel_published", + serde_json::json!({ + "schema": "decodex.run_control_channel/v1", + "transport": "local_file", + "channel_path": ".decodex-run-control/run-classification-1.channel", + "status": "active", + "published_at": "2026-07-09T00:00:00Z", + }), + ) + .expect("historical control telemetry should persist"); + + ensure_superseded_issue_terminalizable_with_tracker(&context, &tracker, &issue) + .expect("shaped historical telemetry should not retain runtime ownership"); + } + + #[test] + fn terminalizable_guard_rejects_malformed_historical_private_telemetry() { + let temp_dir = TempDir::new().expect("tempdir should create"); + let context = sample_recovery_context(&temp_dir, RecoveryRuntimeMutationPolicy::ReadOnly); + let issue = sample_issue("Todo"); + let tracker = TestTracker::with_issues(vec![issue.clone()]); + + context + .state_store + .record_run_attempt("run-malformed-telemetry", &issue.id, 1, "failed") + .expect("terminal run should persist"); + context + .state_store + .append_private_execution_event( + context.config.service_id(), + &issue.id, + "run-malformed-telemetry", + 1, + "control_channel_published", + serde_json::json!({"status": "active"}), + ) + .expect("malformed telemetry should persist"); + + let error = ensure_superseded_issue_terminalizable_with_tracker(&context, &tracker, &issue) + .expect_err("unshaped historical telemetry should fail closed"); + + assert!(error.to_string().contains("control_channel_published")); + assert!(error.to_string().contains("private execution evidence")); + } + + #[test] + fn terminalizable_guard_rejects_durable_private_progress_without_marker() { + let temp_dir = TempDir::new().expect("tempdir should create"); + let state_path = temp_dir.path().join("private-progress.sqlite3"); + let mut context = + sample_recovery_context(&temp_dir, RecoveryRuntimeMutationPolicy::ReadOnly); + let issue = sample_issue("Todo"); + let tracker = TestTracker::with_issues(vec![issue.clone()]); + + context.state_store = StateStore::open(&state_path).expect("state store should open"); + + context + .state_store + .record_run_attempt("run-private-progress", &issue.id, 1, "failed") + .expect("terminal run should persist"); + context + .state_store + .append_private_execution_event( + context.config.service_id(), + &issue.id, + "run-private-progress", + 1, + "progress_checkpoint", + serde_json::json!({ + "phase": "probing", + "pr_url": null, + "verification": [], + }), + ) + .expect("private progress should persist"); + context.state_store = StateStore::open(&state_path).expect("state store should reopen"); + + let error = ensure_superseded_issue_terminalizable_with_tracker(&context, &tracker, &issue) + .expect_err("superseded closeout should reject durable private progress"); + + assert!(error.to_string().contains("run-private-progress")); + assert!(error.to_string().contains("private execution evidence")); + } + + #[test] + fn terminalizable_guard_rejects_durable_protocol_events_without_marker() { + let temp_dir = TempDir::new().expect("tempdir should create"); + let state_path = temp_dir.path().join("protocol-events.sqlite3"); + let mut context = + sample_recovery_context(&temp_dir, RecoveryRuntimeMutationPolicy::ReadOnly); + let issue = sample_issue("Todo"); + let tracker = TestTracker::with_issues(vec![issue.clone()]); + + context.state_store = StateStore::open(&state_path).expect("state store should open"); + + context + .state_store + .record_run_attempt("run-protocol-evidence", &issue.id, 1, "failed") + .expect("terminal run should persist"); + context + .state_store + .append_event("run-protocol-evidence", 1, "turn/item", r#"{"kind":"progress"}"#) + .expect("protocol event should persist"); + context.state_store = StateStore::open(&state_path).expect("state store should reopen"); + + let error = ensure_superseded_issue_terminalizable_with_tracker(&context, &tracker, &issue) + .expect_err("superseded closeout should reject durable protocol evidence"); + + assert!(error.to_string().contains("run-protocol-evidence")); + assert!(error.to_string().contains("protocol event evidence")); + } + + #[test] + fn terminalizable_guard_rejects_durable_run_activity_summary_without_marker() { + let temp_dir = TempDir::new().expect("tempdir should create"); + let state_path = temp_dir.path().join("run-activity.sqlite3"); + let mut context = + sample_recovery_context(&temp_dir, RecoveryRuntimeMutationPolicy::ReadOnly); + let issue = sample_issue("Todo"); + let tracker = TestTracker::with_issues(vec![issue.clone()]); + let protocol_activity = ProtocolActivitySummary { + turn_status: Some(String::from("completed")), + waiting_reason: None, + rate_limit_status: None, + recent_events: Vec::new(), + }; + + context.state_store = StateStore::open(&state_path).expect("state store should open"); + + context + .state_store + .record_run_attempt("run-activity-summary", &issue.id, 1, "failed") + .expect("terminal run should persist"); + context + .state_store + .record_run_activity_summary("run-activity-summary", 1, None, Some(&protocol_activity)) + .expect("run activity summary should persist"); + context.state_store = StateStore::open(&state_path).expect("state store should reopen"); + + let error = ensure_superseded_issue_terminalizable_with_tracker(&context, &tracker, &issue) + .expect_err("superseded closeout should reject durable run activity evidence"); + + assert!(error.to_string().contains("run-activity-summary")); + assert!(error.to_string().contains("run activity summary evidence")); + } + + #[test] + fn terminalizable_guard_rejects_retry_scheduled_terminal_attempt() { + let temp_dir = TempDir::new().expect("tempdir should create"); + let context = sample_recovery_context(&temp_dir, RecoveryRuntimeMutationPolicy::ReadOnly); + let issue = sample_issue("Todo"); + let tracker = TestTracker::with_issues(vec![issue.clone()]); + let worktree_path = temp_dir.path().join("PUB-1704"); + + context + .state_store + .upsert_worktree( + context.config.service_id(), + &issue.id, + "y/pubfi-pub-1704", + &worktree_path.display().to_string(), + ) + .expect("worktree mapping should persist"); + context + .state_store + .record_run_attempt("run-retry", &issue.id, 1, "failed") + .expect("terminal run should persist"); + state::write_run_retry_schedule( + &worktree_path, + "run-retry", + 1, + "failure", + OffsetDateTime::now_utc().unix_timestamp() + 300, + ) + .expect("retry schedule should persist"); + + let error = ensure_superseded_issue_terminalizable_with_tracker(&context, &tracker, &issue) + .expect_err("superseded closeout should reject retry-scheduled attempts"); + + assert!(error.to_string().contains("run-retry")); + assert!(error.to_string().contains("retry-scheduled runtime ownership")); + } + + #[test] + fn terminalizable_guard_rejects_retry_marker_without_attempt_row() { + let temp_dir = TempDir::new().expect("tempdir should create"); + let context = sample_recovery_context(&temp_dir, RecoveryRuntimeMutationPolicy::ReadOnly); + let issue = sample_issue("Todo"); + let tracker = TestTracker::with_issues(vec![issue.clone()]); + let worktree_path = temp_dir.path().join("PUB-1704"); + + context + .state_store + .upsert_worktree( + context.config.service_id(), + &issue.id, + "y/pubfi-pub-1704", + &worktree_path.display().to_string(), + ) + .expect("worktree mapping should persist"); + state::write_run_retry_schedule( + &worktree_path, + "run-marker-retry", + 1, + "failure", + OffsetDateTime::now_utc().unix_timestamp() + 300, + ) + .expect("retry schedule should persist"); + + let error = ensure_superseded_issue_terminalizable_with_tracker(&context, &tracker, &issue) + .expect_err("superseded closeout should reject marker-only retry ownership"); + + assert!(error.to_string().contains("run-marker-retry")); + assert!(error.to_string().contains("retry-scheduled runtime ownership")); + } + + #[test] + fn terminalizable_guard_rejects_mismatched_retry_marker() { + let temp_dir = TempDir::new().expect("tempdir should create"); + let context = sample_recovery_context(&temp_dir, RecoveryRuntimeMutationPolicy::ReadOnly); + let issue = sample_issue("Todo"); + let tracker = TestTracker::with_issues(vec![issue.clone()]); + let worktree_path = temp_dir.path().join("PUB-1704"); + + context + .state_store + .upsert_worktree( + context.config.service_id(), + &issue.id, + "y/pubfi-pub-1704", + &worktree_path.display().to_string(), + ) + .expect("worktree mapping should persist"); + context + .state_store + .record_run_attempt("run-latest", &issue.id, 2, "succeeded") + .expect("latest terminal run should persist"); + state::write_run_retry_schedule( + &worktree_path, + "run-older-retry", + 1, + "failure", + OffsetDateTime::now_utc().unix_timestamp() + 300, + ) + .expect("retry schedule should persist"); + + let error = ensure_superseded_issue_terminalizable_with_tracker(&context, &tracker, &issue) + .expect_err("superseded closeout should reject mismatched retry ownership"); + + assert!(error.to_string().contains("run-older-retry")); + assert!(error.to_string().contains("retry-scheduled runtime ownership")); + } + + #[test] + fn terminalizable_guard_rejects_live_retained_marker_after_terminal_attempt() { + let temp_dir = TempDir::new().expect("tempdir should create"); + let context = sample_recovery_context(&temp_dir, RecoveryRuntimeMutationPolicy::ReadOnly); + let issue = sample_issue("Todo"); + let tracker = TestTracker::with_issues(vec![issue.clone()]); + let worktree_path = temp_dir.path().join("PUB-1704"); + + context + .state_store + .upsert_worktree( + context.config.service_id(), + &issue.id, + "y/pubfi-pub-1704", + &worktree_path.display().to_string(), + ) + .expect("worktree mapping should persist"); + context + .state_store + .record_run_attempt("run-live-marker", &issue.id, 1, "failed") + .expect("terminal run should persist"); + + let protocol_activity = ProtocolActivitySummary { + turn_status: Some(String::from("running")), + waiting_reason: Some(String::from("model_execution")), + rate_limit_status: None, + recent_events: Vec::new(), + }; + state::write_run_protocol_activity_marker( + &worktree_path, + &ProtocolActivityMarker { + run_id: "run-live-marker", + attempt_number: 1, + thread_id: Some("thread-live"), + turn_id: Some("turn-live"), + event_count: 1, + last_event_type: "thread/turn/started", + child_agent_activity: None, + protocol_activity: Some(&protocol_activity), + }, + ) + .expect("live protocol marker should persist"); + + let error = ensure_superseded_issue_terminalizable_with_tracker(&context, &tracker, &issue) + .expect_err("superseded closeout should reject live retained marker ownership"); + + assert!(error.to_string().contains("run-live-marker")); + assert!(error.to_string().contains("live retained")); + } + + #[test] + fn terminalizable_guard_rejects_live_retained_marker_without_attempt_row() { + let temp_dir = TempDir::new().expect("tempdir should create"); + let context = sample_recovery_context(&temp_dir, RecoveryRuntimeMutationPolicy::ReadOnly); + let issue = sample_issue("Todo"); + let tracker = TestTracker::with_issues(vec![issue.clone()]); + let worktree_path = temp_dir.path().join("PUB-1704"); + + context + .state_store + .upsert_worktree( + context.config.service_id(), + &issue.id, + "y/pubfi-pub-1704", + &worktree_path.display().to_string(), + ) + .expect("worktree mapping should persist"); + write_live_protocol_marker(&worktree_path, "run-marker-only", 1); + + let error = ensure_superseded_issue_terminalizable_with_tracker(&context, &tracker, &issue) + .expect_err("superseded closeout should reject marker-only live ownership"); + + assert!(error.to_string().contains("run-marker-only")); + assert!(error.to_string().contains("live retained")); + } + + #[test] + fn terminalizable_guard_rejects_mismatched_live_retained_marker() { + let temp_dir = TempDir::new().expect("tempdir should create"); + let context = sample_recovery_context(&temp_dir, RecoveryRuntimeMutationPolicy::ReadOnly); + let issue = sample_issue("Todo"); + let tracker = TestTracker::with_issues(vec![issue.clone()]); + let worktree_path = temp_dir.path().join("PUB-1704"); + + context + .state_store + .upsert_worktree( + context.config.service_id(), + &issue.id, + "y/pubfi-pub-1704", + &worktree_path.display().to_string(), + ) + .expect("worktree mapping should persist"); + context + .state_store + .record_run_attempt("run-latest", &issue.id, 2, "succeeded") + .expect("latest terminal run should persist"); + write_live_protocol_marker(&worktree_path, "run-older-live-marker", 1); + + let error = ensure_superseded_issue_terminalizable_with_tracker(&context, &tracker, &issue) + .expect_err( + "superseded closeout should reject mismatched live retained marker ownership", + ); + + assert!(error.to_string().contains("run-older-live-marker")); + assert!(error.to_string().contains("live retained")); + } + + #[test] + fn terminalizable_guard_rejects_mismatched_dead_retained_activity_marker() { + let temp_dir = TempDir::new().expect("tempdir should create"); + let context = sample_recovery_context(&temp_dir, RecoveryRuntimeMutationPolicy::ReadOnly); + let issue = sample_issue("Todo"); + let tracker = TestTracker::with_issues(vec![issue.clone()]); + let worktree_path = temp_dir.path().join("PUB-1704"); + + context + .state_store + .upsert_worktree( + context.config.service_id(), + &issue.id, + "y/pubfi-pub-1704", + &worktree_path.display().to_string(), + ) + .expect("worktree mapping should persist"); + context + .state_store + .record_run_attempt("run-latest", &issue.id, 2, "succeeded") + .expect("latest terminal run should persist"); + fs::create_dir_all(&worktree_path).expect("worktree should exist"); + state::write_run_activity_marker_for_process( + &worktree_path, + "run-older-dead-marker", + 1, + u32::MAX, + ) + .expect("dead process marker should persist"); + write_live_protocol_marker(&worktree_path, "run-older-dead-marker", 1); + + let marker = state::read_run_activity_marker_snapshot(&worktree_path) + .expect("marker should read") + .expect("marker should exist"); + assert_eq!( + process_liveness::stale_active_optional_marker_process_liveness(Some(&marker)), + StaleActiveProcessLiveness::NotAlive + ); + + let error = ensure_superseded_issue_terminalizable_with_tracker(&context, &tracker, &issue) + .expect_err("superseded closeout should reject stale retained activity evidence"); + + assert!(error.to_string().contains("run-older-dead-marker")); + assert!(error.to_string().contains("retained activity marker ownership")); + } + + #[test] + fn terminalizable_guard_rejects_mismatched_dead_activity_only_marker() { + let temp_dir = TempDir::new().expect("tempdir should create"); + let context = sample_recovery_context(&temp_dir, RecoveryRuntimeMutationPolicy::ReadOnly); + let issue = sample_issue("Todo"); + let tracker = TestTracker::with_issues(vec![issue.clone()]); + let worktree_path = temp_dir.path().join("PUB-1704"); + + context + .state_store + .upsert_worktree( + context.config.service_id(), + &issue.id, + "y/pubfi-pub-1704", + &worktree_path.display().to_string(), + ) + .expect("worktree mapping should persist"); + context + .state_store + .record_run_attempt("run-latest", &issue.id, 2, "succeeded") + .expect("latest terminal run should persist"); + fs::create_dir_all(&worktree_path).expect("worktree should exist"); + state::write_run_activity_marker_for_process( + &worktree_path, + "run-older-dead-activity-marker", + 1, + u32::MAX, + ) + .expect("dead activity-only marker should persist"); + + let marker = state::read_run_activity_marker_snapshot(&worktree_path) + .expect("marker should read") + .expect("marker should exist"); + assert_eq!( + process_liveness::stale_active_optional_marker_process_liveness(Some(&marker)), + StaleActiveProcessLiveness::NotAlive + ); + assert!(marker.last_activity_unix_epoch().is_some()); + assert!(marker.current_operation().is_none()); + assert!(marker.last_progress_unix_epoch().is_none()); + assert!(marker.last_protocol_activity_unix_epoch().is_none()); + assert_eq!(marker.event_count(), 0); + assert!(marker.last_event_type().is_none()); + assert!(marker.child_agent_activity().is_none()); + assert!(marker.protocol_activity().is_none()); + + let error = ensure_superseded_issue_terminalizable_with_tracker(&context, &tracker, &issue) + .expect_err("superseded closeout should reject stale activity-only evidence"); + + assert!(error.to_string().contains("run-older-dead-activity-marker")); + assert!(error.to_string().contains("retained activity marker ownership")); + } + + #[test] + fn terminalizable_guard_rejects_mismatched_operation_only_marker() { + let temp_dir = TempDir::new().expect("tempdir should create"); + let context = sample_recovery_context(&temp_dir, RecoveryRuntimeMutationPolicy::ReadOnly); + let issue = sample_issue("Todo"); + let tracker = TestTracker::with_issues(vec![issue.clone()]); + let worktree_path = temp_dir.path().join("PUB-1704"); + + context + .state_store + .upsert_worktree( + context.config.service_id(), + &issue.id, + "y/pubfi-pub-1704", + &worktree_path.display().to_string(), + ) + .expect("worktree mapping should persist"); + context + .state_store + .record_run_attempt("run-latest", &issue.id, 2, "succeeded") + .expect("latest terminal run should persist"); + state::write_run_operation_marker_preserving_activity( + &worktree_path, + "run-older-operation-marker", + 1, + state::RUN_OPERATION_AGENT_RUN, + ) + .expect("operation-only marker should persist"); + + let marker = state::read_run_activity_marker_snapshot(&worktree_path) + .expect("marker should read") + .expect("marker should exist"); + assert!(marker.last_activity_unix_epoch().is_none()); + assert_eq!(marker.current_operation(), Some(state::RUN_OPERATION_AGENT_RUN)); + + let error = ensure_superseded_issue_terminalizable_with_tracker(&context, &tracker, &issue) + .expect_err("superseded closeout should reject stale operation-only evidence"); + + assert!(error.to_string().contains("run-older-operation-marker")); + assert!(error.to_string().contains("retained activity marker ownership")); + } + + #[test] + fn terminalizable_guard_rejects_any_non_terminal_attempt_for_issue() { + let temp_dir = TempDir::new().expect("tempdir should create"); + let context = sample_recovery_context(&temp_dir, RecoveryRuntimeMutationPolicy::ReadOnly); + let issue = sample_issue("Todo"); + let tracker = TestTracker::with_issues(vec![issue.clone()]); + + context + .state_store + .record_run_attempt("run-older-active", &issue.id, 1, "running") + .expect("older active run should persist"); + context + .state_store + .record_run_attempt("run-latest-terminal", &issue.id, 2, "succeeded") + .expect("latest terminal run should persist"); + + let error = ensure_superseded_issue_terminalizable_with_tracker(&context, &tracker, &issue) + .expect_err("superseded closeout should reject any non-terminal attempt"); + + assert!(error.to_string().contains("run-older-active")); + assert!(error.to_string().contains("running")); + } + + #[test] + fn terminalizable_guard_rejects_non_terminal_identifier_attempt() { + let temp_dir = TempDir::new().expect("tempdir should create"); + let context = sample_recovery_context(&temp_dir, RecoveryRuntimeMutationPolicy::ReadOnly); + let issue = sample_issue("Todo"); + let tracker = TestTracker::with_issues(vec![issue.clone()]); + + context + .state_store + .record_run_attempt("run-identifier-active", &issue.identifier, 1, "running") + .expect("identifier-keyed active run should persist"); + + let error = ensure_superseded_issue_terminalizable_with_tracker(&context, &tracker, &issue) + .expect_err("superseded closeout should reject identifier-keyed non-terminal attempt"); + + assert!(error.to_string().contains("run-identifier-active")); + assert!(error.to_string().contains(&issue.identifier)); + } + + #[test] + fn successor_lineage_rejects_unrelated_completed_issue_and_pr() { + let temp_dir = TempDir::new().expect("tempdir should create"); + let context = sample_recovery_context(&temp_dir, RecoveryRuntimeMutationPolicy::ReadOnly); + let successor_issue = successor_issue(); + let mut successor_landing = sample_landing_state( + "https://github.com/helixbox/pubfi-mono/pull/827", + "y/pubfi-pub-1705", + "0123456789abcdef0123456789abcdef01234567", + ); + successor_landing.state = String::from("MERGED"); + let tracker = TestTracker::with_issues(vec![successor_issue.clone()]); + + let error = validate_successor_issue_pr_lineage( + &context, + &tracker, + &successor_issue, + &successor_landing, + "1123456789abcdef0123456789abcdef01234567", + ) + .expect_err("unrelated successor issue and PR should be rejected"); + + assert!(error.to_string().contains("has no Decodex execution ledger record")); + } + + #[test] + fn successor_lineage_accepts_matching_successor_closeout_ledger() { + let temp_dir = TempDir::new().expect("tempdir should create"); + let context = sample_recovery_context(&temp_dir, RecoveryRuntimeMutationPolicy::ReadOnly); + let successor_issue = successor_issue(); + let mut successor_landing = sample_landing_state( + "https://github.com/helixbox/pubfi-mono/pull/827", + "y/pubfi-pub-1705", + "0123456789abcdef0123456789abcdef01234567", + ); + successor_landing.state = String::from("MERGED"); + let merge_commit = "1123456789abcdef0123456789abcdef01234567"; + let comment_body = successor_closeout_comment( + &context, + &successor_issue, + &successor_landing, + merge_commit, + ); + let tracker = TestTracker::with_issues(vec![successor_issue.clone()]).with_comments(vec![ + TrackerComment { body: comment_body, created_at: current_timestamp() }, + ]); + + validate_successor_issue_pr_lineage( + &context, + &tracker, + &successor_issue, + &successor_landing, + merge_commit, + ) + .expect("matching successor closeout ledger should prove lineage"); + } + + #[test] + fn predecessor_successor_lineage_rejects_unrelated_issues() { + let issue = sample_issue("Todo"); + let successor_issue = successor_issue(); + let tracker = TestTracker::with_issues(vec![issue.clone(), successor_issue.clone()]); + + let error = + validate_predecessor_successor_issue_relation(&tracker, &issue, &successor_issue) + .expect_err("unrelated issues should not prove superseded lineage"); + + assert!(error.to_string().contains("no explicit tracker relation")); + } + + #[test] + fn predecessor_successor_lineage_accepts_explicit_issue_relation() { + let issue = sample_issue("Todo"); + let successor_issue = successor_issue(); + let tracker = TestTracker::with_issues(vec![issue.clone(), successor_issue.clone()]) + .with_related_issues(&issue.id, &successor_issue.id); + + validate_predecessor_successor_issue_relation(&tracker, &issue, &successor_issue) + .expect("explicit issue relation should prove superseded lineage"); + } + + #[test] + fn obsolete_pull_request_revalidation_rejects_moved_head() { + let validated = sample_landing_state( + "https://github.com/helixbox/pubfi-mono/pull/826", + "y/pubfi-pub-1704", + "validated-head", + ); + let mut current = validated.clone(); + current.head_ref_oid = String::from("moved-head"); + + let error = validate_obsolete_pull_request_unchanged(&validated, ¤t, "main") + .expect_err("moved PR head should invalidate superseded closeout"); + + assert!(error.to_string().contains("changed after superseded closeout validation")); + assert!(error.to_string().contains("moved-head")); + } + + #[test] + fn obsolete_pull_request_close_readback_requires_same_lineage_and_closed_state() { + let validated = sample_landing_state( + "https://github.com/helixbox/pubfi-mono/pull/826", + "y/pubfi-pub-1704", + "validated-head", + ); + let mut closed = validated.clone(); + closed.state = String::from("CLOSED"); + + validate_obsolete_pull_request_closed(&validated, &closed, "main") + .expect("same-head closed readback should confirm PR closure"); + + closed.base_ref_oid = Some(String::from("moved-base")); + let error = validate_obsolete_pull_request_closed(&validated, &closed, "main") + .expect_err("moved base should invalidate close readback"); + assert!(error.to_string().contains("changed after superseded closeout validation")); + } + + fn successor_issue() -> TrackerIssue { + let mut issue = sample_issue("Done"); + + issue.id = String::from("successor-issue-id"); + issue.identifier = String::from("PUB-1705"); + + issue + } + + fn superseded_lifecycle_event_payload( + issue_id: &str, + run_id: &str, + cleanup_state: &str, + ) -> serde_json::Value { + let (phase, transition, next_state, next_action, causation_id) = match cleanup_state { + "pending" => ( + "closeout_pending", + "closeout_intent_recorded", + "closeout_pending", + "run_retained_closeout_adapter", + "superseded_closeout_recovery_pr_close_authorized", + ), + "completed" => ( + "closed", + "closeout_completed", + "closed", + "no_action", + "superseded_closeout_recovery_closeout_complete", + ), + _ => panic!("unsupported lifecycle cleanup state"), + }; + let idempotency_key = + format!("pubfi:{issue_id}:merge:superseded_closeout_recovery:{cleanup_state}"); + + serde_json::json!({ + "schema_version": "decodex/lifecycle-event/1", + "event_type": "lifecycle_authority_recorded", + "subject_id": format!("pubfi:{issue_id}:y/pubfi-pub-1704"), + "idempotency_key": idempotency_key, + "correlation_id": run_id, + "causation_id": causation_id, + "authority_record": { + "schema_version": "decodex/lifecycle-authority-record/1", + "project_id": "pubfi", + "service_id": "pubfi", + "issue_id": issue_id, + "subject_id": format!("pubfi:{issue_id}:y/pubfi-pub-1704"), + "phase": phase, + "transition": transition, + "next_state": next_state, + "next_action": next_action, + "pr_url": "https://github.com/helixbox/pubfi-mono/pull/826", + "base_branch": "main", + "head_branch": "y/pubfi-pub-1704", + "validated_head_sha": "obsolete-head", + "merge_commit": "merge", + "cleanup_state": cleanup_state, + "authority": "issue_authority", + "actor": "superseded_closeout_recovery", + "idempotency_key": idempotency_key, + "correlation_id": run_id, + "causation_id": causation_id, + } + }) + } + + fn sample_superseded_retry_lineage<'a>( + issue: &'a TrackerIssue, + run_id: &'a str, + ) -> SupersededCloseoutRetryLineage<'a> { + SupersededCloseoutRetryLineage { + project_id: "pubfi", + issue_id: &issue.id, + run_id, + attempt_number: 1, + branch_name: "y/pubfi-pub-1704", + base_branch: "main", + obsolete_pr_url: "https://github.com/helixbox/pubfi-mono/pull/826", + obsolete_head_sha: "obsolete-head", + successor_merge_commit: "merge", + } + } + + fn write_live_protocol_marker( + worktree_path: &std::path::Path, + run_id: &str, + attempt_number: i64, + ) { + let protocol_activity = ProtocolActivitySummary { + turn_status: Some(String::from("running")), + waiting_reason: Some(String::from("model_execution")), + rate_limit_status: None, + recent_events: Vec::new(), + }; + + state::write_run_protocol_activity_marker( + worktree_path, + &ProtocolActivityMarker { + run_id, + attempt_number, + thread_id: Some("thread-live"), + turn_id: Some("turn-live"), + event_count: 1, + last_event_type: "thread/turn/started", + child_agent_activity: None, + protocol_activity: Some(&protocol_activity), + }, + ) + .expect("live protocol marker should persist"); + } + + fn successor_closeout_comment( + context: &RecoveryContext, + successor_issue: &TrackerIssue, + successor_landing: &crate::pull_request::PullRequestLandingState, + merge_commit: &str, + ) -> String { + let mut record = LinearExecutionEventRecord::new( + LinearExecutionEventIdentity { + service_id: context.config.service_id(), + issue_id: &successor_issue.id, + issue_identifier: &successor_issue.identifier, + run_id: "pub-1705-attempt-1", + attempt_number: 1, + }, + LEGACY_MANUAL_CLOSEOUT_EVENT, + current_timestamp(), + "successor-closeout", + ); + + record.branch = Some(successor_landing.head_ref_name.clone()); + record.worktree_path = Some(String::from(".worktrees/PUB-1705")); + record.pr_url = Some(successor_landing.url.clone()); + record.pr_head_sha = Some(successor_landing.head_ref_oid.clone()); + record.pr_base_ref = Some(successor_landing.base_ref_name.clone()); + record.commit_sha = Some(merge_commit.to_owned()); + record.validation_result = Some(String::from("passed")); + record.target_state = Some(String::from("Done")); + record.summary = Some(String::from("Successor closeout recorded.")); + + records::append_structured_comment_record("successor closeout", &record) + .expect("comment record should render") + } + + fn sample_recovery_context( + temp_dir: &TempDir, + runtime_mutation_policy: RecoveryRuntimeMutationPolicy, + ) -> RecoveryContext { + let repo_root = temp_dir.path().join("repo"); + let config_path = temp_dir.path().join("project.toml"); + + fs::create_dir_all(&repo_root).expect("repo root should exist"); + fs::write( + &config_path, + r#" +service_id = "pubfi" + +[paths] +repo_root = "repo" + +[tracker] +api_key_env_var = "HOME" + +[github] +token_env_var = "HOME" +"#, + ) + .expect("config should write"); + + RecoveryContext { + config: ServiceConfig::from_path(&config_path).expect("config should load"), + workflow: sample_workflow(), + state_store: StateStore::open_in_memory().expect("state store should open"), + tracker: LinearClient::new(String::from("test-token")) + .expect("linear client should build"), + runtime_mutation_policy, + } + } + + fn sample_workflow() -> WorkflowDocument { + WorkflowDocument::parse_markdown( + r#" ++++ +version = 1 + +[tracker] +provider = "linear" +startable_states = ["Todo"] +terminal_states = ["Done", "Canceled", "Duplicate"] +in_progress_state = "In Progress" +success_state = "In Review" +completed_state = "Done" +failure_state = "Todo" +opt_out_label = "decodex:manual-only" +needs_attention_label = "decodex:needs-attention" + +[agent] +transport = "stdio://" + +[execution] +max_attempts = 3 +max_turns = 8 +max_retry_backoff_ms = 300000 +gate_profiles = {} +canonicalize_commands = [] +verify_commands = [] + +[execution.workspace_hooks] +after_create_commands = [] +before_remove_commands = [] +timeout_seconds = 60 + +[context] +read_first = [] ++++ + +Test workflow. +"#, + ) + .expect("sample workflow should parse") + } + + fn sample_landing_state( + pr_url: &str, + branch_name: &str, + head_oid: &str, + ) -> crate::pull_request::PullRequestLandingState { + crate::pull_request::PullRequestLandingState { + url: pr_url.to_owned(), + state: String::from("OPEN"), + is_draft: false, + review_decision: Some(String::from("APPROVED")), + base_ref_name: String::from("main"), + base_ref_oid: Some(String::from("base-sha")), + pending_review_requests: 0, + mergeable: String::from("MERGEABLE"), + merge_state_status: String::from("CLEAN"), + head_ref_name: branch_name.to_owned(), + head_ref_oid: head_oid.to_owned(), + status_check_rollup_state: Some(String::from("SUCCESS")), + required_status_contexts: Vec::new(), + unresolved_review_threads: 0, + } + } + + fn sample_issue(state_name: &str) -> TrackerIssue { + let states = vec![ + TrackerState { id: String::from("state-todo"), name: String::from("Todo") }, + TrackerState { id: String::from("state-progress"), name: String::from("In Progress") }, + TrackerState { id: String::from("state-review"), name: String::from("In Review") }, + TrackerState { id: String::from("state-done"), name: String::from("Done") }, + ]; + let state = states + .iter() + .find(|state| state.name == state_name) + .expect("sample state should exist") + .clone(); + + TrackerIssue { + id: String::from("issue-id"), + identifier: String::from("PUB-1704"), + #[cfg(test)] + project_slug: None, + title: String::from("Sample issue"), + author: None, + description: String::new(), + priority: None, + created_at: String::from("2026-06-09T00:00:00Z"), + updated_at: String::from("2026-06-09T00:00:00Z"), + state, + team: TrackerTeam { + id: String::from("team-id"), + name: String::from("XY"), + states, + labels: Vec::new(), + }, + labels_complete: true, + labels: Vec::new(), + blockers: Vec::new(), + } + } + + fn sample_issue_with_labels(state_name: &str, labels: &[String]) -> TrackerIssue { + let mut issue = sample_issue(state_name); + + for label in labels { + let tracker_label = TrackerLabel { + id: format!("label-{}", label.replace(':', "-")), + name: label.clone(), + }; + + issue.team.labels.push(tracker_label.clone()); + issue.labels.push(tracker_label); + } + + issue + } + + fn current_timestamp() -> String { + OffsetDateTime::now_utc().format(&Rfc3339).expect("timestamp formatting should succeed") + } + + struct TestTracker { + issues: Vec, + comments: Vec, + related_issue_pairs: Vec<(String, String)>, + state_updates: RefCell>, + label_removals: RefCell)>>, + } + + impl TestTracker { + fn with_issues(issues: Vec) -> Self { + Self { + issues, + comments: Vec::new(), + related_issue_pairs: Vec::new(), + state_updates: RefCell::new(Vec::new()), + label_removals: RefCell::new(Vec::new()), + } + } + + fn with_comments(mut self, comments: Vec) -> Self { + self.comments = comments; + + self + } + + fn with_related_issues(mut self, issue_id: &str, related_issue_id: &str) -> Self { + self.related_issue_pairs.push((issue_id.to_owned(), related_issue_id.to_owned())); + + self + } + } + + impl IssueTracker for TestTracker { + fn list_issues_with_label(&self, label_name: &str) -> Result> { + Ok(self.issues.iter().filter(|issue| issue.has_label(label_name)).cloned().collect()) + } + + fn find_team_label_id(&self, team_id: &str, label_name: &str) -> Result> { + Ok(self + .issues + .iter() + .find(|issue| issue.team.id == team_id) + .and_then(|issue| issue.label_id_for_name(label_name).map(ToOwned::to_owned))) + } + + fn get_issue_by_identifier(&self, issue_identifier: &str) -> Result> { + Ok(self + .issues + .iter() + .find(|issue| issue.identifier.eq_ignore_ascii_case(issue_identifier)) + .cloned()) + } + + fn refresh_issues(&self, issue_ids: &[String]) -> Result> { + Ok(self + .issues + .iter() + .filter(|issue| issue_ids.iter().any(|issue_id| issue_id == &issue.id)) + .cloned() + .collect()) + } + + fn list_comments(&self, _issue_id: &str) -> Result> { + Ok(self.comments.clone()) + } + + fn issues_have_explicit_relation( + &self, + issue_id: &str, + related_issue_id: &str, + ) -> Result { + Ok(self.related_issue_pairs.iter().any(|(left, right)| { + (left == issue_id && right == related_issue_id) + || (left == related_issue_id && right == issue_id) + })) + } + + fn update_issue_state(&self, issue_id: &str, state_id: &str) -> Result<()> { + self.state_updates.borrow_mut().push((issue_id.to_owned(), state_id.to_owned())); + + Ok(()) + } + + fn add_issue_labels(&self, _issue_id: &str, _label_ids: &[String]) -> Result<()> { + Ok(()) + } + + fn remove_issue_labels(&self, issue_id: &str, label_ids: &[String]) -> Result<()> { + self.label_removals.borrow_mut().push((issue_id.to_owned(), label_ids.to_vec())); + + Ok(()) + } + + fn create_comment(&self, _issue_id: &str, _body: &str) -> Result<()> { + Ok(()) + } + + fn create_issue(&self, request: &TrackerIssueCreate) -> Result { + let _ = request; + + eyre::bail!("test tracker does not create issues") + } + + fn update_issue_brief( + &self, + issue_id: &str, + request: &TrackerIssueBriefUpdate, + ) -> Result { + let _ = (issue_id, request); + + eyre::bail!("test tracker does not update issue briefs") + } + } +} diff --git a/apps/decodex/src/recovery/requests.rs b/apps/decodex/src/recovery/requests.rs index 0b6dc0f85..491ec137e 100644 --- a/apps/decodex/src/recovery/requests.rs +++ b/apps/decodex/src/recovery/requests.rs @@ -92,3 +92,20 @@ pub(crate) struct MergedCloseoutRecoveryRequest { /// Required for non-dry-run mutation. pub(crate) manual_authority: bool, } + +/// Explicit superseded retained PR closeout after a successor PR landed the repair. +#[derive(Debug)] +pub(crate) struct SupersededCloseoutRecoveryRequest { + /// Superseded issue identifier to terminalize. + pub(crate) issue: String, + /// Obsolete retained pull request URL to close. + pub(crate) pr_url: String, + /// Successor issue identifier that carried the landed repair. + pub(crate) successor_issue: String, + /// Merged successor pull request URL that proves terminal code lineage. + pub(crate) successor_pr_url: String, + /// Validate without writing runtime, tracker, or GitHub state. + pub(crate) dry_run: bool, + /// Required for non-dry-run mutation. + pub(crate) manual_authority: bool, +} diff --git a/apps/decodex/src/recovery/tests/event_records.rs b/apps/decodex/src/recovery/tests/event_records.rs index b0ac9d2a6..c42362488 100644 --- a/apps/decodex/src/recovery/tests/event_records.rs +++ b/apps/decodex/src/recovery/tests/event_records.rs @@ -117,6 +117,61 @@ fn merged_closeout_recovery_events_validate() { .expect("merged closeout cleanup event should validate"); } +#[test] +fn superseded_closeout_recovery_events_validate() { + let mut closeout = LinearExecutionEventRecord::new( + LinearExecutionEventIdentity { + service_id: "pubfi-mono", + issue_id: "issue-id", + issue_identifier: "PUB-1704", + run_id: "pub-1704-attempt-1", + attempt_number: 1, + }, + LEGACY_MANUAL_CLOSEOUT_EVENT, + super::current_timestamp(), + "anchor-closeout", + ); + + closeout.branch = Some(String::from("y/pubfi-pub-1704")); + closeout.worktree_path = Some(String::from(".worktrees/PUB-1704")); + closeout.pr_url = Some(String::from("https://github.com/helixbox/pubfi-mono/pull/826")); + closeout.pr_head_sha = Some(String::from("0123456789abcdef0123456789abcdef01234567")); + closeout.pr_base_ref = Some(String::from("main")); + closeout.commit_sha = Some(String::from("1123456789abcdef0123456789abcdef01234567")); + closeout.validation_result = Some(String::from("passed")); + closeout.target_state = Some(String::from("Done")); + closeout.summary = Some(String::from("Superseded closeout recovery recorded.")); + + records::validate_linear_execution_event_record(&closeout) + .expect("superseded closeout event should validate"); + + let mut cleanup = LinearExecutionEventRecord::new( + LinearExecutionEventIdentity { + service_id: "pubfi-mono", + issue_id: "issue-id", + issue_identifier: "PUB-1704", + run_id: "pub-1704-attempt-1", + attempt_number: 1, + }, + "cleanup_complete", + super::timestamp_after_seconds(1), + "anchor-cleanup", + ); + + cleanup.branch = Some(String::from("y/pubfi-pub-1704")); + cleanup.worktree_path = Some(String::from(".worktrees/PUB-1704")); + cleanup.pr_url = Some(String::from("https://github.com/helixbox/pubfi-mono/pull/826")); + cleanup.pr_head_sha = Some(String::from("0123456789abcdef0123456789abcdef01234567")); + cleanup.pr_base_ref = Some(String::from("main")); + cleanup.commit_sha = Some(String::from("1123456789abcdef0123456789abcdef01234567")); + cleanup.cleanup_status = Some(String::from("superseded_closeout_reconciled")); + cleanup.target_state = Some(String::from("Done")); + cleanup.summary = Some(String::from("Superseded closeout recovery marked cleanup complete.")); + + records::validate_linear_execution_event_record(&cleanup) + .expect("superseded closeout cleanup event should validate"); +} + #[test] fn review_handoff_rebind_event_requires_evidence() { let mut record = LinearExecutionEventRecord::new( diff --git a/apps/decodex/src/state/store/execution_evidence/activity.rs b/apps/decodex/src/state/store/execution_evidence/activity.rs index a3250132c..11bddf638 100644 --- a/apps/decodex/src/state/store/execution_evidence/activity.rs +++ b/apps/decodex/src/state/store/execution_evidence/activity.rs @@ -32,6 +32,15 @@ impl StateStore { self.upsert_run_activity_summary_locked(&summary) } + /// Return whether one run retains durable child-agent or protocol activity evidence. + pub(crate) fn run_has_activity_summary_evidence(&self, run_id: &str) -> Result { + let state = self.lock()?; + + Ok(state.run_activity_summaries.get(run_id).is_some_and(|summary| { + summary.child_agent_activity.is_some() || summary.protocol_activity.is_some() + })) + } + /// Read the latest recorded activity timestamp for one run as a Unix epoch. pub fn last_run_activity_unix_epoch(&self, run_id: &str) -> Result> { let state = self.lock()?; diff --git a/apps/decodex/src/state/store_run_control/channel.rs b/apps/decodex/src/state/store_run_control/channel.rs index 20ef09fc7..6bc70a5e0 100644 --- a/apps/decodex/src/state/store_run_control/channel.rs +++ b/apps/decodex/src/state/store_run_control/channel.rs @@ -9,6 +9,30 @@ use crate::{ }; impl StateStore { + /// Read one active run-control channel for an issue, when retained runtime control exists. + pub(crate) fn active_run_control_channel_for_issue( + &self, + project_id: &str, + issue_id: &str, + ) -> Result> { + let state = self.lock()?; + + Ok(state + .control_channels + .values() + .filter(|channel| { + channel.project_id == project_id + && channel.issue_id == issue_id + && channel.status == RUN_CONTROL_CHANNEL_STATUS_ACTIVE + }) + .max_by(|left, right| { + left.attempt_number + .cmp(&right.attempt_number) + .then_with(|| left.run_id.cmp(&right.run_id)) + }) + .map(RunControlChannelRecord::as_public)) + } + /// Publish the local control channel for an active attempt when the runtime owns it. pub(crate) fn publish_run_control_channel_for_active_attempt( &self, diff --git a/apps/decodex/src/tracker/linear/client.rs b/apps/decodex/src/tracker/linear/client.rs index c84a5d7b1..c477f297a 100644 --- a/apps/decodex/src/tracker/linear/client.rs +++ b/apps/decodex/src/tracker/linear/client.rs @@ -3,6 +3,7 @@ mod blockers; mod comments; mod pagination; mod post; +mod relations; use std::time::Duration; diff --git a/apps/decodex/src/tracker/linear/client/relations.rs b/apps/decodex/src/tracker/linear/client/relations.rs new file mode 100644 index 000000000..800bc42ba --- /dev/null +++ b/apps/decodex/src/tracker/linear/client/relations.rs @@ -0,0 +1,109 @@ +use crate::{ + prelude::{Result, eyre}, + tracker::linear::{ + LinearClient, + queries::{ISSUE_INVERSE_RELATIONS_QUERY, ISSUE_RELATIONS_QUERY}, + schema::{ + ExplicitIssueRelationConnection, IssueInverseRelationsData, IssueRelationsData, + IssueRelationsVariables, + }, + }, +}; + +impl LinearClient { + pub(in crate::tracker::linear) fn inspect_explicit_issue_relation( + &self, + issue_id: &str, + related_issue_id: &str, + ) -> Result { + if self.direct_issue_relations_contain(issue_id, related_issue_id)? { + return Ok(true); + } + + self.inverse_issue_relations_contain(issue_id, related_issue_id) + } + + fn direct_issue_relations_contain( + &self, + issue_id: &str, + related_issue_id: &str, + ) -> Result { + let mut after = None; + + loop { + let data = self.post::<_, IssueRelationsData>( + ISSUE_RELATIONS_QUERY, + &IssueRelationsVariables { issue_id: issue_id.to_owned(), after }, + )?; + let issue = data.issue.ok_or_else(|| { + eyre::eyre!( + "Linear did not return issue `{issue_id}` while checking issue lineage." + ) + })?; + + if relation_connection_contains(&issue.relations, issue_id, related_issue_id) { + return Ok(true); + } + + after = next_relation_cursor(issue.relations, issue_id)?; + if after.is_none() { + return Ok(false); + } + } + } + + fn inverse_issue_relations_contain( + &self, + issue_id: &str, + related_issue_id: &str, + ) -> Result { + let mut after = None; + + loop { + let data = self.post::<_, IssueInverseRelationsData>( + ISSUE_INVERSE_RELATIONS_QUERY, + &IssueRelationsVariables { issue_id: issue_id.to_owned(), after }, + )?; + let issue = data.issue.ok_or_else(|| { + eyre::eyre!( + "Linear did not return issue `{issue_id}` while checking issue lineage." + ) + })?; + + if relation_connection_contains(&issue.inverse_relations, issue_id, related_issue_id) { + return Ok(true); + } + + after = next_relation_cursor(issue.inverse_relations, issue_id)?; + if after.is_none() { + return Ok(false); + } + } + } +} + +fn relation_connection_contains( + connection: &ExplicitIssueRelationConnection, + issue_id: &str, + related_issue_id: &str, +) -> bool { + connection.nodes.iter().any(|relation| { + (relation.issue.id == issue_id && relation.related_issue.id == related_issue_id) + || (relation.issue.id == related_issue_id && relation.related_issue.id == issue_id) + }) +} + +fn next_relation_cursor( + connection: ExplicitIssueRelationConnection, + issue_id: &str, +) -> Result> { + if !connection.page_info.has_next_page { + return Ok(None); + } + + connection.page_info.end_cursor.map(Some).ok_or_else(|| { + eyre::eyre!( + "Linear issue `{issue_id}` relation pagination reported `hasNextPage = true` without an `endCursor`." + ) + }) +} diff --git a/apps/decodex/src/tracker/linear/issue_tracker.rs b/apps/decodex/src/tracker/linear/issue_tracker.rs index dde9f8f88..7e1b91b2b 100644 --- a/apps/decodex/src/tracker/linear/issue_tracker.rs +++ b/apps/decodex/src/tracker/linear/issue_tracker.rs @@ -30,6 +30,14 @@ impl IssueTracker for LinearClient { self.collect_issue_comments(issue_id) } + fn issues_have_explicit_relation( + &self, + issue_id: &str, + related_issue_id: &str, + ) -> Result { + self.inspect_explicit_issue_relation(issue_id, related_issue_id) + } + fn update_issue_state(&self, issue_id: &str, state_id: &str) -> Result<()> { mutations::update_issue_state(self, issue_id, state_id) } diff --git a/apps/decodex/src/tracker/linear/queries.rs b/apps/decodex/src/tracker/linear/queries.rs index 29f034bf4..f1fc55fd1 100644 --- a/apps/decodex/src/tracker/linear/queries.rs +++ b/apps/decodex/src/tracker/linear/queries.rs @@ -4,7 +4,8 @@ mod team; pub(super) use self::{ issue::{ - ISSUE_BLOCKERS_QUERY, ISSUE_BY_IDENTIFIER_QUERY, ISSUE_COMMENTS_QUERY, ISSUES_BY_IDS_QUERY, + ISSUE_BLOCKERS_QUERY, ISSUE_BY_IDENTIFIER_QUERY, ISSUE_COMMENTS_QUERY, + ISSUE_INVERSE_RELATIONS_QUERY, ISSUE_RELATIONS_QUERY, ISSUES_BY_IDS_QUERY, ISSUES_WITH_LABEL_QUERY, }, mutation::{ diff --git a/apps/decodex/src/tracker/linear/queries/issue.rs b/apps/decodex/src/tracker/linear/queries/issue.rs index 669294b7e..7f444aae2 100644 --- a/apps/decodex/src/tracker/linear/queries/issue.rs +++ b/apps/decodex/src/tracker/linear/queries/issue.rs @@ -1,11 +1,13 @@ mod blockers; mod comments; mod lookup; +mod relations; mod search; pub(in crate::tracker::linear) use self::{ blockers::ISSUE_BLOCKERS_QUERY, comments::ISSUE_COMMENTS_QUERY, lookup::{ISSUE_BY_IDENTIFIER_QUERY, ISSUES_BY_IDS_QUERY}, + relations::{ISSUE_INVERSE_RELATIONS_QUERY, ISSUE_RELATIONS_QUERY}, search::ISSUES_WITH_LABEL_QUERY, }; diff --git a/apps/decodex/src/tracker/linear/queries/issue/relations.rs b/apps/decodex/src/tracker/linear/queries/issue/relations.rs new file mode 100644 index 000000000..25a93e5d1 --- /dev/null +++ b/apps/decodex/src/tracker/linear/queries/issue/relations.rs @@ -0,0 +1,41 @@ +pub(in crate::tracker::linear) const ISSUE_RELATIONS_QUERY: &str = r#" +query IssueRelations($issueId: String!, $after: String) { + issue(id: $issueId) { + relations(first: 50, after: $after) { + nodes { + issue { + id + } + relatedIssue { + id + } + } + pageInfo { + hasNextPage + endCursor + } + } + } +} +"#; + +pub(in crate::tracker::linear) const ISSUE_INVERSE_RELATIONS_QUERY: &str = r#" +query IssueInverseRelations($issueId: String!, $after: String) { + issue(id: $issueId) { + inverseRelations(first: 50, after: $after) { + nodes { + issue { + id + } + relatedIssue { + id + } + } + pageInfo { + hasNextPage + endCursor + } + } + } +} +"#; diff --git a/apps/decodex/src/tracker/linear/schema.rs b/apps/decodex/src/tracker/linear/schema.rs index e6d882685..6f42e1cb2 100644 --- a/apps/decodex/src/tracker/linear/schema.rs +++ b/apps/decodex/src/tracker/linear/schema.rs @@ -11,10 +11,11 @@ pub(super) use self::issue::{ pub(super) use self::{ graphql::{GraphqlError, GraphqlRequest, GraphqlResponse}, issue::{ - IssueBlockersData, IssueBlockersVariables, IssueByIdentifierData, - IssueByIdentifierVariables, IssueCommentsData, IssueCommentsVariables, IssueConnectionData, - IssuesByIdsVariables, IssuesWithLabelVariables, LinearIssue, LinearIssueRelation, - LinearUser, + ExplicitIssueRelationConnection, IssueBlockersData, IssueBlockersVariables, + IssueByIdentifierData, IssueByIdentifierVariables, IssueCommentsData, + IssueCommentsVariables, IssueConnectionData, IssueInverseRelationsData, IssueRelationsData, + IssueRelationsVariables, IssuesByIdsVariables, IssuesWithLabelVariables, LinearIssue, + LinearIssueRelation, LinearUser, }, mutation::{ CommentCreateData, CommentCreateInput, CommentCreateVariables, IssueArchiveData, diff --git a/apps/decodex/src/tracker/linear/schema/issue.rs b/apps/decodex/src/tracker/linear/schema/issue.rs index 545f48c0f..c114155af 100644 --- a/apps/decodex/src/tracker/linear/schema/issue.rs +++ b/apps/decodex/src/tracker/linear/schema/issue.rs @@ -40,6 +40,14 @@ pub(in crate::tracker::linear) struct IssueCommentsVariables { pub(in crate::tracker::linear) after: Option, } +#[derive(Serialize)] +pub(in crate::tracker::linear) struct IssueRelationsVariables { + #[serde(rename = "issueId")] + pub(in crate::tracker::linear) issue_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(in crate::tracker::linear) after: Option, +} + #[derive(Deserialize)] pub(in crate::tracker::linear) struct IssueConnectionData { pub(in crate::tracker::linear) issues: IssueConnection, @@ -60,6 +68,16 @@ pub(in crate::tracker::linear) struct IssueCommentsData { pub(in crate::tracker::linear) issue: Option, } +#[derive(Deserialize)] +pub(in crate::tracker::linear) struct IssueRelationsData { + pub(in crate::tracker::linear) issue: Option, +} + +#[derive(Deserialize)] +pub(in crate::tracker::linear) struct IssueInverseRelationsData { + pub(in crate::tracker::linear) issue: Option, +} + #[derive(Deserialize)] pub(in crate::tracker::linear) struct IssueConnection { pub(in crate::tracker::linear) nodes: Vec, @@ -83,6 +101,17 @@ pub(in crate::tracker::linear) struct LinearIssueComments { pub(in crate::tracker::linear) comments: CommentConnection, } +#[derive(Deserialize)] +pub(in crate::tracker::linear) struct LinearIssueRelations { + pub(in crate::tracker::linear) relations: ExplicitIssueRelationConnection, +} + +#[derive(Deserialize)] +pub(in crate::tracker::linear) struct LinearIssueInverseRelations { + #[serde(rename = "inverseRelations")] + pub(in crate::tracker::linear) inverse_relations: ExplicitIssueRelationConnection, +} + #[derive(Deserialize)] pub(in crate::tracker::linear) struct CommentConnection { pub(in crate::tracker::linear) nodes: Vec, @@ -151,6 +180,25 @@ pub(in crate::tracker::linear) struct IssueRelationConnection { pub(in crate::tracker::linear) page_info: PageInfo, } +#[derive(Deserialize)] +pub(in crate::tracker::linear) struct ExplicitIssueRelationConnection { + pub(in crate::tracker::linear) nodes: Vec, + #[serde(rename = "pageInfo")] + pub(in crate::tracker::linear) page_info: PageInfo, +} + +#[derive(Deserialize)] +pub(in crate::tracker::linear) struct ExplicitIssueRelation { + pub(in crate::tracker::linear) issue: ExplicitRelatedIssue, + #[serde(rename = "relatedIssue")] + pub(in crate::tracker::linear) related_issue: ExplicitRelatedIssue, +} + +#[derive(Deserialize)] +pub(in crate::tracker::linear) struct ExplicitRelatedIssue { + pub(in crate::tracker::linear) id: String, +} + #[derive(Deserialize)] pub(in crate::tracker::linear) struct LinearIssueRelation { #[serde(rename = "type")] diff --git a/apps/decodex/src/tracker/types.rs b/apps/decodex/src/tracker/types.rs index 7def2901e..ab5d07062 100644 --- a/apps/decodex/src/tracker/types.rs +++ b/apps/decodex/src/tracker/types.rs @@ -6,6 +6,15 @@ pub(crate) trait IssueTracker { fn get_issue_by_identifier(&self, issue_identifier: &str) -> Result>; fn refresh_issues(&self, issue_ids: &[String]) -> Result>; fn list_comments(&self, issue_id: &str) -> Result>; + fn issues_have_explicit_relation( + &self, + issue_id: &str, + related_issue_id: &str, + ) -> Result { + let _ = (issue_id, related_issue_id); + + Ok(false) + } fn update_issue_state(&self, issue_id: &str, state_id: &str) -> Result<()>; fn add_issue_labels(&self, issue_id: &str, label_ids: &[String]) -> Result<()>; fn remove_issue_labels(&self, issue_id: &str, label_ids: &[String]) -> Result<()>; diff --git a/openwiki/specs/contracts-and-data.md b/openwiki/specs/contracts-and-data.md index 75d5f3dbe..1816e4f2f 100644 --- a/openwiki/specs/contracts-and-data.md +++ b/openwiki/specs/contracts-and-data.md @@ -150,7 +150,7 @@ After PR-backed handoff, retained review/landing/closeout lifecycle authority is The pure lifecycle kernel decides from normalized facts; adapters perform side effects and persist projections. Linear comments, manual receipts, local branch names, PR titles, and current head heuristics are not final lifecycle authority. The persisted lifecycle authority projection records issue/project identity, phase, transition, previous/next state, next action, review gate state, PR URL, base/head branches, validated head, worktree path, merge/cleanup state, source evidence refs, idempotency/correlation ids, actor, and decision time. Historical handoff/orchestration tables and public ledger comments are not a substitute for this projection. -Fail-closed rule: if a retained lane lacks an exact lifecycle authority projection, normal/program/retry dispatch must not guess a lineage. Use explicit `decodex recover review-handoff diagnose`, `adopt`, or `rebind` paths depending on evidence. +Fail-closed rule: if a retained lane lacks an exact lifecycle authority projection, normal/program/retry dispatch must not guess a lineage. Use explicit `decodex recover review-handoff diagnose`, `adopt`, or `rebind` paths depending on evidence. If a later issue and successor PR already landed the accepted repair, use `decodex recover superseded-closeout` instead of rebinding the obsolete PR; it requires an explicit Linear relation between the predecessor and successor issues, obsolete retained issue/PR lineage, a successor issue execution ledger record for the exact merged successor PR head and merge commit, merged successor readback, default-branch reachability, absent queue/active/needs-attention labels and live runtime ownership for the obsolete issue, no active issue-scoped run-control channel, no non-terminal retained attempt for either issue id or identifier, no retained retry schedule, no live retained process/thread marker, and no retained protocol/activity/progress evidence even when the marker process is dead, a private event has no matching attempt row, or its run does not match the latest attempt, clean retained worktree evidence, no patch-unique obsolete PR commits relative to the default branch, public-safe closeout ledger records, and issue-authority lifecycle records that move from retryable pending closeout intent before public ledger or PR mutation to completed closeout before the obsolete Linear issue transition and public cleanup projection. Exact superseded-closeout lifecycle envelopes are retry-safe reentry evidence rather than stale execution ownership; if no prior run attempt exists, live apply records a matching terminal synthetic attempt before the first lifecycle write so recovery never creates orphan private evidence. Apply must re-read and match the obsolete PR URL, default/base ref and OID, state, branch, and head OID before each public Linear/GitHub mutation, then confirm the same lineage is closed before completed authority, issue terminalization, cleanup, or worktree clearing. Shaped historical control, phase-goal, no-diff, and no-progress telemetry is non-owning only after the independent channel, attempt, marker, protocol, activity, and progress guards pass. Closeout recovery must verify a matching public Linear comment before treating a local duplicate ledger record as projected, and merged closeout recovery records lifecycle authority before public closeout or cleanup projections. Recovery boundaries are explicit. Startup/current-lane recovery may rebuild retained worktree mappings only from deterministic paths plus tracker/runtime/lifecycle evidence; missing lifecycle records, mismatched stored handoff heads, stale PID markers, and unscoped logs are diagnostic inputs, not ownership. Retained tracked changes after crash or stall flow through retry, phase-goal recovery, repo-gate recovery, or human-attention classification according to runtime evidence; they must not be rebound from branch names or PR titles. diff --git a/openwiki/workflows/runtime-operator-workflows.md b/openwiki/workflows/runtime-operator-workflows.md index 725672856..e9b951fcd 100644 --- a/openwiki/workflows/runtime-operator-workflows.md +++ b/openwiki/workflows/runtime-operator-workflows.md @@ -144,9 +144,12 @@ decodex recover ghost-lane ... decodex recover stale-active ... decodex recover legacy-closeout ... decodex recover merged-closeout ... +decodex recover superseded-closeout ... ``` Use recovery when normal runtime status reports retained lane drift, missing lifecycle authority, ghost lanes, stale active ownership, or already-merged closeout gaps. The post-review lifecycle spec is strict: missing lifecycle records are fail-closed and must not be reconstructed from branch names, PR titles, Linear comments, or current head alone (`openwiki/specs/contracts-and-data.md`). +Use `superseded-closeout` when an obsolete retained PR should not be rebound because a distinct successor issue and merged successor PR already landed the accepted repair. The command validates an explicit Linear relation between the predecessor and successor issues, the same configured repository/default branch, successor issue ledger lineage for the exact merged successor PR head and merge commit, merged successor PR reachability from `origin/`, absent queue/active/needs-attention labels and live runtime ownership for the obsolete issue, clean retained worktree, and absence of patch-unique obsolete PR commits relative to the default branch. Live ownership includes any non-terminal retained run attempt such as `starting`, `running`, and `continuation_pending`, any active issue-scoped run-control channel row, retained retry schedules on the worktree marker, and retained worktree activity markers that show a live process or active thread. Any retained protocol, activity, or progress evidence also blocks terminalization even when its process is dead, its private event has no matching run-attempt row, or the marker has no matching latest attempt row; explicit stale-lane recovery must clear or classify that evidence first. Shaped historical control, phase-goal, no-diff, and no-progress telemetry may remain only when the separate active-channel, attempt, marker, protocol, activity, and progress guards prove that it retains no execution ownership. Apply records retryable, non-terminal pending closeout authority before public Linear ledger or GitHub PR projections. Exact superseded-closeout lifecycle events remain valid reentry evidence after a partial failure, and a recovery with no prior attempt records a terminal synthetic attempt before writing lifecycle evidence so it cannot create orphan private state. Before each public Linear/GitHub mutation, apply re-reads the obsolete PR URL, default/base ref and OID, state, branch, and head OID against the validated lineage; after the close mutation it requires the same lineage to read back as closed before completed closeout authority, issue terminalization, cleanup projection, or retained worktree clearing. Closeout recovery does not treat a local ledger duplicate as public projection success unless the matching Linear comment is already present; retries repost missing comments before terminal cleanup. +Merged closeout recovery records durable lifecycle authority before publishing public Linear closeout or cleanup projections, and only clears retained worktree/run state after those projections succeed or are confirmed already present. Recovery is dry-run-first unless the command is read-only. `review-handoff diagnose` is the read-only entrypoint; `rebind` repairs retained Decodex PR lanes only when the retained worktree and PR prove exact issue/branch/head authority, while `adopt` is limited to human-created PRs from a managed clean Decodex worktree and must not take over lanes that already have lifecycle records. `stale-active diagnose` applies to tracker-present active-label ownership with no safe live/progress owner; `ghost-lane diagnose` applies to missing-issue local runtime state. Stop instead of mutating when the report names live process state, run leases/shared claims, needs-attention labels, non-runtime worktree changes, unmerged commits, unavailable default-branch proof, private progress evidence, review-policy checkpoints, PR/review lineage, mixed private evidence, or unreadable worktrees. diff --git a/plugins/decodex/skills/decodex-ops/SKILL.md b/plugins/decodex/skills/decodex-ops/SKILL.md index 044153a0f..0faf1f642 100644 --- a/plugins/decodex/skills/decodex-ops/SKILL.md +++ b/plugins/decodex/skills/decodex-ops/SKILL.md @@ -41,6 +41,37 @@ service labels, recovery, or lane-control details matter. - If diagnosis shows a human-owned PR takeover from the current managed worktree, dry-run `decodex recover review-handoff adopt --pr --dry-run` before any live adopt. +- If a later issue and merged successor PR already landed the accepted repair, do + not rebind the obsolete PR. Dry-run + `decodex recover superseded-closeout --pr --successor-issue --successor-pr --dry-run` + before live superseded closeout. The obsolete issue must have no queue, active, + or needs-attention labels or live runtime ownership, including non-terminal + attempts such as `continuation_pending`, active issue-scoped run-control + channels, retained retry schedules on the worktree marker, and retained + worktree markers that still show live process or active thread. Any retained + protocol, activity, or progress evidence also blocks terminalization when the + marker process is dead, a private event has no matching attempt row, or no + matching latest attempt row exists; explicit stale-lane recovery must clear or + classify that evidence first. Shaped historical telemetry is non-owning only + after the independent channel, attempt, marker, protocol, activity, and + progress guards pass. The + predecessor and successor issues must have an explicit Linear relation, and + the successor issue must expose a Decodex ledger record for the exact + successor PR head and merge commit. Live superseded closeout records + retryable pending closeout authority before public Linear or GitHub + projections. Exact + superseded-closeout lifecycle envelopes remain retryable after partial + failure, and a no-attempt lane records a matching terminal synthetic attempt + before lifecycle evidence. The apply path re-reads the obsolete PR URL, + base, state, and head before each public Linear/GitHub mutation, then confirms + the same lineage is closed before completed authority. After that close path + succeeds, the command records completed authority before the terminal Linear + transition or public cleanup, then clears retained worktree state only after + those projections succeed. Closeout recovery retries any local ledger + duplicate whose matching public Linear comment is absent. +- Merged closeout recovery records lifecycle authority before public Linear + closeout or cleanup projections and only clears retained run/worktree state + after required projections succeed or are confirmed already present. - Do not infer PR lineage from branch names, PR titles, Linear comments, status summaries, or stale snapshots.