From 041680e156f5894386a0efeabaea6987b25d9200 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Thu, 9 Jul 2026 08:09:51 -0400 Subject: [PATCH 01/16] {"schema":"decodex/commit/2","change":"Add superseded retained PR closeout recovery","authority":"XY-1248","impact":"compatible"} --- README.md | 1 + apps/decodex/src/cli/recovery_commands.rs | 23 +- .../src/cli/recovery_commands/closeout.rs | 22 ++ .../src/cli/tests/recovery/closeout.rs | 40 ++- apps/decodex/src/github.rs | 2 + apps/decodex/src/github/pull_requests.rs | 73 +++++ .../plugin_surface_tests/decodex_skills.rs | 1 + apps/decodex/src/recovery.rs | 5 +- apps/decodex/src/recovery/closeout.rs | 72 ++++- apps/decodex/src/recovery/closeout/apply.rs | 256 +++++++++++++++++- apps/decodex/src/recovery/closeout/events.rs | 115 +++++++- .../src/recovery/closeout/validation.rs | 2 + .../recovery/closeout/validation/merged.rs | 4 +- .../validation/merged/pull_request.rs | 39 +++ .../closeout/validation/superseded.rs | 211 +++++++++++++++ apps/decodex/src/recovery/requests.rs | 17 ++ .../src/recovery/tests/event_records.rs | 55 ++++ openwiki/specs/contracts-and-data.md | 2 +- .../workflows/runtime-operator-workflows.md | 2 + plugins/decodex/skills/decodex-ops/SKILL.md | 4 + 20 files changed, 935 insertions(+), 11 deletions(-) create mode 100644 apps/decodex/src/github/pull_requests.rs create mode 100644 apps/decodex/src/recovery/closeout/validation/superseded.rs 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/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..14ab3f8dd 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,14 +17,14 @@ use crate::{ prelude::Result, recovery::{ closeout::{ - LegacyCloseoutValidation, MergedCloseoutValidation, + LegacyCloseoutValidation, MergedCloseoutValidation, SupersededCloseoutValidation, apply::lifecycle::decide_lifecycle_transition, events, }, context::RecoveryContext, pull_request_inspection, }, tracker::{ - self, + self, IssueTracker, privacy_classifier::ConfiguredPublicProjectionPrivacyClassifier, records::{self, LinearExecutionEventRecord}, }, @@ -117,6 +118,79 @@ pub(super) fn apply_merged_closeout_recovery( Ok((closeout_recorded, cleanup_recorded)) } +pub(super) fn apply_superseded_closeout_recovery( + context: &RecoveryContext, + validation: &SupersededCloseoutValidation, +) -> Result<(bool, bool, bool)> { + 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 github_token = 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 being terminalized as superseded and should not be landed from this PR.", + validation.successor_issue.identifier, validation.issue.identifier + ); + + github::post_pull_request_issue_comment( + context.config.repo_root(), + obsolete_pr_url, + &pr_comment, + &github_token, + context.config.github().command_path(), + )?; + + let pr_closed = if validation.obsolete_landing_state.state == "OPEN" { + github::close_pull_request( + context.config.repo_root(), + obsolete_pr_url, + &github_token, + context.config.github().command_path(), + )?; + true + } else { + false + }; + + let closeout_event = events::superseded_closeout_event(context, validation); + let cleanup_event = events::superseded_closeout_cleanup_event(context, validation); + let closeout_recorded = write_superseded_closeout_event( + context, + validation, + &closeout_event, + "Decodex superseded closeout recovery: verified a successor PR landed the retained repair lineage and closed the obsolete PR.", + )?; + let cleanup_recorded = match write_superseded_closeout_event( + context, + validation, + &cleanup_event, + "Decodex superseded closeout recovery: verified the obsolete retained lane has no remaining unique unlanded work 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)?; + } + + return Err(error); + }, + }; + + context.tracker.update_issue_state(&validation.issue.id, &validation.completed_state_id)?; + clear_superseded_lane_labels(context, validation)?; + context.state_store.clear_worktree(&validation.issue.id)?; + + if validation.issue.identifier != validation.issue.id { + context.state_store.clear_worktree(&validation.issue.identifier)?; + } + + record_superseded_closeout_lifecycle_authority(context, validation)?; + context.state_store.update_run_status(&validation.run_id, "succeeded")?; + + Ok((closeout_recorded, cleanup_recorded, pr_closed)) +} + fn write_merged_closeout_event( context: &RecoveryContext, validation: &MergedCloseoutValidation, @@ -155,6 +229,83 @@ fn write_merged_closeout_event( Ok(true) } +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); + 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); + } + + 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)?; + + return Err(error); + } + + Ok(true) +} + +fn clear_superseded_lane_labels( + context: &RecoveryContext, + validation: &SupersededCloseoutValidation, +) -> Result<()> { + let tracker_policy = context.workflow.frontmatter().tracker(); + let candidate_labels = [ + tracker::automation_queue_label(context.config.service_id()), + tracker::automation_active_label(context.config.service_id()), + tracker_policy.needs_attention_label().to_owned(), + ]; + let mut label_ids = Vec::new(); + + for label in candidate_labels { + if !tracker::issue_has_label_with_server_confirmation( + &context.tracker, + &validation.issue, + &label, + )? { + continue; + } + if let Some(label_id) = validation.issue.label_id_for_name(&label) { + label_ids.push(label_id.to_owned()); + + continue; + } + if let Some(label_id) = + context.tracker.find_team_label_id(&validation.issue.team.id, &label)? + { + label_ids.push(label_id); + } + } + + if label_ids.is_empty() { + return Ok(()); + } + + context.tracker.remove_issue_labels(&validation.issue.id, &label_ids) +} + fn record_merged_closeout_lifecycle_authority( context: &RecoveryContext, validation: &MergedCloseoutValidation, @@ -258,6 +409,107 @@ fn record_merged_closeout_lifecycle_decision( Ok(()) } +fn record_superseded_closeout_lifecycle_authority( + context: &RecoveryContext, + validation: &SupersededCloseoutValidation, +) -> 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("completed"), + 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" + ); + let decided_at = current_timestamp(); + let decision = self::decide_lifecycle_transition(LifecycleDecisionInput { + facts: &facts, + previous, + evidence_kind: LifecycleEvidenceKind::CloseoutCompletion, + outcome: LifecycleOutcome::Succeeded, + merge_commit: Some(&validation.successor_merge_commit), + cleanup_state: Some("completed"), + authority: "issue_authority", + actor: "superseded_closeout_recovery", + idempotency_key: &idempotency_key, + correlation_id: &validation.run_id, + causation_id: Some("superseded_closeout_recovery_closeout_complete"), + decided_at: &decided_at, + }); + + context.state_store.record_lifecycle_decision( + &validation.run_id, + validation.attempt_number, + &decision, + )?; + + Ok(()) +} + +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..fb2239ecc 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, @@ -161,3 +164,113 @@ pub(super) fn merged_closeout_cleanup_event( 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, mark the superseded issue complete, 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_closed_or_already_closed=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..80c131ee9 100644 --- a/apps/decodex/src/recovery/closeout/validation.rs +++ b/apps/decodex/src/recovery/closeout/validation.rs @@ -1,6 +1,8 @@ mod legacy; mod merged; +mod superseded; 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..b2debd171 --- /dev/null +++ b/apps/decodex/src/recovery/closeout/validation/superseded.rs @@ -0,0 +1,211 @@ +use crate::{ + prelude::{Result, eyre}, + recovery::{ + closeout::{ + SupersededCloseoutValidation, + validation::merged::{issue, pull_request}, + }, + context::RecoveryContext, + pull_request_inspection, + requests::SupersededCloseoutRecoveryRequest, + review_handoff, + }, + tracker::TrackerIssue, +}; + +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)?; + + 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, + )?; + 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) + }; + + Ok(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, + }) +} + +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_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() + ) + }) +} + +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(()) +} 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/openwiki/specs/contracts-and-data.md b/openwiki/specs/contracts-and-data.md index 75d5f3dbe..d956c65e8 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 explicit predecessor/successor issue and PR lineage, merged successor readback, default-branch reachability, clean retained worktree evidence, no patch-unique obsolete PR commits relative to the default branch, public-safe closeout ledger records, and an issue-authority lifecycle closeout record. 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..7a0edaf86 100644 --- a/openwiki/workflows/runtime-operator-workflows.md +++ b/openwiki/workflows/runtime-operator-workflows.md @@ -144,9 +144,11 @@ 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 the obsolete and successor issues, same configured repository/default branch, merged successor PR reachability from `origin/`, clean retained worktree, and absence of patch-unique obsolete PR commits relative to the default branch before it closes the obsolete PR, marks the original issue complete, records public-safe lineage, and clears retained lane cleanup state. 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..5f9cfbb89 100644 --- a/plugins/decodex/skills/decodex-ops/SKILL.md +++ b/plugins/decodex/skills/decodex-ops/SKILL.md @@ -41,6 +41,10 @@ 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. - Do not infer PR lineage from branch names, PR titles, Linear comments, status summaries, or stale snapshots. From 21fb2a7dfdc46dd9a87bfc573ba60db4a31af955 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Thu, 9 Jul 2026 08:42:47 -0400 Subject: [PATCH 02/16] {"schema":"decodex/commit/2","change":"Repair superseded closeout review blockers","authority":"XY-1248","impact":"compatible"} --- apps/decodex/src/recovery/closeout/apply.rs | 95 +-- apps/decodex/src/recovery/closeout/events.rs | 8 +- .../src/recovery/closeout/validation.rs | 1 + .../closeout/validation/superseded.rs | 615 +++++++++++++++++- openwiki/specs/contracts-and-data.md | 2 +- .../workflows/runtime-operator-workflows.md | 2 +- plugins/decodex/skills/decodex-ops/SKILL.md | 5 +- 7 files changed, 650 insertions(+), 78 deletions(-) diff --git a/apps/decodex/src/recovery/closeout/apply.rs b/apps/decodex/src/recovery/closeout/apply.rs index 14ab3f8dd..650ba721f 100644 --- a/apps/decodex/src/recovery/closeout/apply.rs +++ b/apps/decodex/src/recovery/closeout/apply.rs @@ -125,9 +125,30 @@ pub(super) fn apply_superseded_closeout_recovery( 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); + super::validation::ensure_superseded_issue_terminalizable(context, &validation.issue)?; + context.tracker.update_issue_state(&validation.issue.id, &validation.completed_state_id)?; + + let closeout_event = events::superseded_closeout_event(context, validation); + let cleanup_event = events::superseded_closeout_cleanup_event(context, validation); + let closeout_recorded = write_superseded_closeout_event( + context, + validation, + &closeout_event, + "Decodex superseded closeout recovery: verified a successor PR landed the retained repair lineage and authorized closure of the obsolete PR.", + )?; + let cleanup_recorded = match write_superseded_closeout_event( + context, + validation, + &cleanup_event, + "Decodex superseded closeout recovery: verified the obsolete retained lane has no remaining unique unlanded work and recorded cleanup_complete.", + ) { + Ok(cleanup_recorded) => cleanup_recorded, + Err(error) => return Err(error), + }; + let github_token = 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 being terminalized as superseded and should not be landed from this PR.", + "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.", validation.successor_issue.identifier, validation.issue.identifier ); @@ -151,43 +172,14 @@ pub(super) fn apply_superseded_closeout_recovery( false }; - let closeout_event = events::superseded_closeout_event(context, validation); - let cleanup_event = events::superseded_closeout_cleanup_event(context, validation); - let closeout_recorded = write_superseded_closeout_event( - context, - validation, - &closeout_event, - "Decodex superseded closeout recovery: verified a successor PR landed the retained repair lineage and closed the obsolete PR.", - )?; - let cleanup_recorded = match write_superseded_closeout_event( - context, - validation, - &cleanup_event, - "Decodex superseded closeout recovery: verified the obsolete retained lane has no remaining unique unlanded work 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)?; - } - - return Err(error); - }, - }; - - context.tracker.update_issue_state(&validation.issue.id, &validation.completed_state_id)?; - clear_superseded_lane_labels(context, validation)?; + record_superseded_closeout_lifecycle_authority(context, validation)?; + context.state_store.update_run_status(&validation.run_id, "succeeded")?; context.state_store.clear_worktree(&validation.issue.id)?; if validation.issue.identifier != validation.issue.id { context.state_store.clear_worktree(&validation.issue.identifier)?; } - record_superseded_closeout_lifecycle_authority(context, validation)?; - context.state_store.update_run_status(&validation.run_id, "succeeded")?; - Ok((closeout_recorded, cleanup_recorded, pr_closed)) } @@ -267,45 +259,6 @@ fn write_superseded_closeout_event( Ok(true) } -fn clear_superseded_lane_labels( - context: &RecoveryContext, - validation: &SupersededCloseoutValidation, -) -> Result<()> { - let tracker_policy = context.workflow.frontmatter().tracker(); - let candidate_labels = [ - tracker::automation_queue_label(context.config.service_id()), - tracker::automation_active_label(context.config.service_id()), - tracker_policy.needs_attention_label().to_owned(), - ]; - let mut label_ids = Vec::new(); - - for label in candidate_labels { - if !tracker::issue_has_label_with_server_confirmation( - &context.tracker, - &validation.issue, - &label, - )? { - continue; - } - if let Some(label_id) = validation.issue.label_id_for_name(&label) { - label_ids.push(label_id.to_owned()); - - continue; - } - if let Some(label_id) = - context.tracker.find_team_label_id(&validation.issue.team.id, &label)? - { - label_ids.push(label_id); - } - } - - if label_ids.is_empty() { - return Ok(()); - } - - context.tracker.remove_issue_labels(&validation.issue.id, &label_ids) -} - fn record_merged_closeout_lifecycle_authority( context: &RecoveryContext, validation: &MergedCloseoutValidation, diff --git a/apps/decodex/src/recovery/closeout/events.rs b/apps/decodex/src/recovery/closeout/events.rs index fb2239ecc..ceada1f69 100644 --- a/apps/decodex/src/recovery/closeout/events.rs +++ b/apps/decodex/src/recovery/closeout/events.rs @@ -160,7 +160,9 @@ 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("No Decodex runtime action remains for this lane.")); + event.next_action = Some(String::from( + "Decodex will close the obsolete PR, record lifecycle authority, and clear retained lane cleanup state.", + )); event } @@ -217,7 +219,7 @@ pub(super) fn superseded_closeout_event( String::from("retained_worktree_has_no_uncommitted_changes=true"), ]); event.next_action = Some(String::from( - "Decodex will close the obsolete PR, mark the superseded issue complete, and clear retained lane cleanup state.", + "Decodex will close the obsolete PR and clear retained lane cleanup state.", )); event @@ -267,7 +269,7 @@ pub(super) fn superseded_closeout_cleanup_event( format!("branch={}", validation.branch_name), format!("worktree_path={}", validation.worktree_path_for_event), format!("successor_issue={}", validation.successor_issue.identifier), - String::from("obsolete_pr_closed_or_already_closed=true"), + 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.")); diff --git a/apps/decodex/src/recovery/closeout/validation.rs b/apps/decodex/src/recovery/closeout/validation.rs index 80c131ee9..393794ff4 100644 --- a/apps/decodex/src/recovery/closeout/validation.rs +++ b/apps/decodex/src/recovery/closeout/validation.rs @@ -2,6 +2,7 @@ mod legacy; mod merged; mod superseded; +pub(in crate::recovery::closeout) use self::superseded::ensure_superseded_issue_terminalizable; 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/superseded.rs b/apps/decodex/src/recovery/closeout/validation/superseded.rs index b2debd171..59b06e52b 100644 --- a/apps/decodex/src/recovery/closeout/validation/superseded.rs +++ b/apps/decodex/src/recovery/closeout/validation/superseded.rs @@ -10,7 +10,10 @@ use crate::{ requests::SupersededCloseoutRecoveryRequest, review_handoff, }, - tracker::TrackerIssue, + tracker::{ + self, IssueTracker, TrackerIssue, + records::{self, LinearExecutionEventRecord}, + }, }; pub(in crate::recovery) fn validate_superseded_closeout_request( @@ -53,6 +56,13 @@ pub(in crate::recovery) fn validate_superseded_closeout_request( 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, @@ -138,6 +148,7 @@ fn validate_superseded_issue_context( tracker_policy.opt_out_label() ); } + ensure_superseded_issue_terminalizable(context, issue)?; issue .team @@ -154,6 +165,173 @@ fn validate_superseded_issue_context( }) } +pub(in crate::recovery::closeout) fn ensure_superseded_issue_terminalizable( + context: &RecoveryContext, + issue: &TrackerIssue, +) -> Result<()> { + ensure_superseded_issue_terminalizable_with_tracker(context, &context.tracker, issue) +} + +fn ensure_superseded_issue_terminalizable_with_tracker( + context: &RecoveryContext, + tracker: &T, + issue: &TrackerIssue, +) -> 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) +} + +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, +) -> Result<()> { + 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 + ); + } + if let Some(attempt) = context.state_store.latest_run_attempt_for_issue(&issue_key)? + && matches!(attempt.status(), "starting" | "running") + { + eyre::bail!( + "Issue `{}` still has {} run `{}` for `{issue_key}`; superseded closeout recovery requires live ownership to be absent.", + issue.identifier, + attempt.status(), + attempt.run_id() + ); + } + } + + 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(); @@ -209,3 +387,438 @@ fn validate_obsolete_pull_request( 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::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 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 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"); + } + + 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 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, + state_updates: RefCell>, + label_removals: RefCell)>>, + } + + impl TestTracker { + fn with_issues(issues: Vec) -> Self { + Self { + issues, + comments: 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 + } + } + + 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 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/openwiki/specs/contracts-and-data.md b/openwiki/specs/contracts-and-data.md index d956c65e8..148f08378 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. 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 explicit predecessor/successor issue and PR lineage, merged successor readback, default-branch reachability, clean retained worktree evidence, no patch-unique obsolete PR commits relative to the default branch, public-safe closeout ledger records, and an issue-authority lifecycle closeout record. +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 explicit predecessor/successor issue and 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, clean retained worktree evidence, no patch-unique obsolete PR commits relative to the default branch, public-safe closeout ledger records, and an issue-authority lifecycle closeout record. 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 7a0edaf86..056be69a7 100644 --- a/openwiki/workflows/runtime-operator-workflows.md +++ b/openwiki/workflows/runtime-operator-workflows.md @@ -148,7 +148,7 @@ 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 the obsolete and successor issues, same configured repository/default branch, merged successor PR reachability from `origin/`, clean retained worktree, and absence of patch-unique obsolete PR commits relative to the default branch before it closes the obsolete PR, marks the original issue complete, records public-safe lineage, and clears retained lane cleanup state. +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 the obsolete and successor issues, 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 before it closes the obsolete PR, marks the original issue complete, records public-safe lineage, and clears retained lane cleanup state. 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 5f9cfbb89..a22144a76 100644 --- a/plugins/decodex/skills/decodex-ops/SKILL.md +++ b/plugins/decodex/skills/decodex-ops/SKILL.md @@ -44,7 +44,10 @@ service labels, recovery, or lane-control details matter. - 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. + before live superseded closeout. The obsolete issue must have no queue, active, + or needs-attention labels or live runtime ownership, and the successor issue + must expose a Decodex ledger record for the exact successor PR head and merge + commit. - Do not infer PR lineage from branch names, PR titles, Linear comments, status summaries, or stale snapshots. From b1eee8b96cb9a5e1e66f7e6da5e93adadfdcd65c Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Thu, 9 Jul 2026 11:04:39 -0400 Subject: [PATCH 03/16] {"schema":"decodex/commit/2","change":"Repair superseded closeout side-effect order","authority":"XY-1248","impact":"compatible"} --- apps/decodex/src/recovery/closeout/apply.rs | 287 ++++++++++++++++--- apps/decodex/src/recovery/closeout/events.rs | 2 +- 2 files changed, 241 insertions(+), 48 deletions(-) diff --git a/apps/decodex/src/recovery/closeout/apply.rs b/apps/decodex/src/recovery/closeout/apply.rs index 650ba721f..79b46b912 100644 --- a/apps/decodex/src/recovery/closeout/apply.rs +++ b/apps/decodex/src/recovery/closeout/apply.rs @@ -122,65 +122,258 @@ pub(super) fn apply_superseded_closeout_recovery( context: &RecoveryContext, validation: &SupersededCloseoutValidation, ) -> Result<(bool, bool, bool)> { - 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); - super::validation::ensure_superseded_issue_terminalizable(context, &validation.issue)?; - context.tracker.update_issue_state(&validation.issue.id, &validation.completed_state_id)?; - - let closeout_event = events::superseded_closeout_event(context, validation); - let cleanup_event = events::superseded_closeout_cleanup_event(context, validation); - let closeout_recorded = write_superseded_closeout_event( - context, - validation, - &closeout_event, - "Decodex superseded closeout recovery: verified a successor PR landed the retained repair lineage and authorized closure of the obsolete PR.", - )?; - let cleanup_recorded = match write_superseded_closeout_event( - context, - validation, - &cleanup_event, - "Decodex superseded closeout recovery: verified the obsolete retained lane has no remaining unique unlanded work and recorded cleanup_complete.", - ) { - Ok(cleanup_recorded) => cleanup_recorded, - Err(error) => return Err(error), - }; + let mut operations = SupersededCloseoutRecoveryOperations { context, validation }; - let github_token = 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.", - validation.successor_issue.identifier, validation.issue.identifier - ); + apply_superseded_closeout_recovery_sequence(&mut operations) +} - github::post_pull_request_issue_comment( - context.config.repo_root(), - obsolete_pr_url, - &pr_comment, - &github_token, - context.config.github().command_path(), - )?; +trait SupersededCloseoutRecoveryOperationsRunner { + fn ensure_terminalizable(&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) -> 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.update_issue_state()?; + let closeout_recorded = operations.write_closeout_event()?; + let cleanup_recorded = operations.write_cleanup_event()?; + operations.record_lifecycle_authority()?; + operations.update_run_status()?; + operations.clear_worktree()?; + operations.post_pull_request_comment()?; + let pr_closed = operations.close_pull_request_if_open()?; + + 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.issue, + ) + } + + 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) -> Result<()> { + record_superseded_closeout_lifecycle_authority(self.context, self.validation) + } + + 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()?; - let pr_closed = if validation.obsolete_landing_state.state == "OPEN" { github::close_pull_request( - context.config.repo_root(), + self.context.config.repo_root(), obsolete_pr_url, &github_token, - context.config.github().command_path(), + self.context.config.github().command_path(), )?; - true - } else { - false + + Ok(true) + } +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + + use crate::prelude::{Result, eyre}; + + use super::{ + SupersededCloseoutRecoveryOperationsRunner, apply_superseded_closeout_recovery_sequence, }; - record_superseded_closeout_lifecycle_authority(context, validation)?; - context.state_store.update_run_status(&validation.run_id, "succeeded")?; - context.state_store.clear_worktree(&validation.issue.id)?; + #[derive(Default)] + struct RecordingSupersededCloseoutOperations { + steps: RefCell>, + fail_at: Option<&'static str>, + } + impl RecordingSupersededCloseoutOperations { + fn record(&self, step: &'static str) -> Result<()> { + self.steps.borrow_mut().push(step); - if validation.issue.identifier != validation.issue.id { - context.state_store.clear_worktree(&validation.issue.identifier)?; + if self.fail_at == Some(step) { + eyre::bail!("{step} failed"); + } + + Ok(()) + } } - Ok((closeout_recorded, cleanup_recorded, pr_closed)) + impl SupersededCloseoutRecoveryOperationsRunner for RecordingSupersededCloseoutOperations { + fn ensure_terminalizable(&mut self) -> Result<()> { + self.record("ensure_terminalizable") + } + + 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) -> Result<()> { + self.record("record_lifecycle_authority") + } + + 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) + } + } + + #[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", + "update_issue_state", + "write_closeout_event", + "write_cleanup_event", + "record_lifecycle_authority", + "update_run_status", + "clear_worktree", + "post_pull_request_comment", + "close_pull_request_if_open", + ] + ); + } + + #[test] + fn superseded_closeout_does_not_touch_github_when_lifecycle_authority_fails() { + let mut operations = RecordingSupersededCloseoutOperations { + fail_at: Some("record_lifecycle_authority"), + ..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 failed")); + assert_eq!( + operations.steps.into_inner(), + vec![ + "ensure_terminalizable", + "update_issue_state", + "write_closeout_event", + "write_cleanup_event", + "record_lifecycle_authority", + ] + ); + } } fn write_merged_closeout_event( diff --git a/apps/decodex/src/recovery/closeout/events.rs b/apps/decodex/src/recovery/closeout/events.rs index ceada1f69..13294aaac 100644 --- a/apps/decodex/src/recovery/closeout/events.rs +++ b/apps/decodex/src/recovery/closeout/events.rs @@ -161,7 +161,7 @@ pub(super) fn merged_closeout_cleanup_event( String::from("retained_worktree_has_no_uncommitted_changes=true"), ]); event.next_action = Some(String::from( - "Decodex will close the obsolete PR, record lifecycle authority, and clear retained lane cleanup state.", + "Decodex will record lifecycle authority and clear retained lane cleanup state.", )); event From 43181ae3e17be2c0fd43f21ea081aeb008a2c4f4 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Thu, 9 Jul 2026 11:07:06 -0400 Subject: [PATCH 04/16] {"schema":"decodex/commit/2","change":"Document superseded closeout apply ordering","authority":"XY-1248","impact":"compatible"} --- openwiki/workflows/runtime-operator-workflows.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openwiki/workflows/runtime-operator-workflows.md b/openwiki/workflows/runtime-operator-workflows.md index 056be69a7..2fb696c5e 100644 --- a/openwiki/workflows/runtime-operator-workflows.md +++ b/openwiki/workflows/runtime-operator-workflows.md @@ -148,7 +148,7 @@ 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 the obsolete and successor issues, 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 before it closes the obsolete PR, marks the original issue complete, records public-safe lineage, and clears retained lane cleanup state. +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 the obsolete and successor issues, 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. Apply records the public-safe closeout ledger, lifecycle authority, run success, and retained cleanup state before commenting on or closing the obsolete GitHub PR, so the PR mutation is never the only durable side effect. 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. From f4572a28fec1f9592ee566ab336b791c2c3cae4c Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Thu, 9 Jul 2026 11:29:02 -0400 Subject: [PATCH 05/16] {"schema":"decodex/commit/2","change":"Repair superseded closeout cleanup ordering","authority":"XY-1248","impact":"compatible"} --- apps/decodex/src/recovery/closeout/apply.rs | 79 +++++++++++++------ openwiki/specs/contracts-and-data.md | 2 +- .../workflows/runtime-operator-workflows.md | 2 +- plugins/decodex/skills/decodex-ops/SKILL.md | 4 +- 4 files changed, 61 insertions(+), 26 deletions(-) diff --git a/apps/decodex/src/recovery/closeout/apply.rs b/apps/decodex/src/recovery/closeout/apply.rs index 79b46b912..e5fa3a47f 100644 --- a/apps/decodex/src/recovery/closeout/apply.rs +++ b/apps/decodex/src/recovery/closeout/apply.rs @@ -132,7 +132,7 @@ trait SupersededCloseoutRecoveryOperationsRunner { 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) -> 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<()>; @@ -143,14 +143,15 @@ fn apply_superseded_closeout_recovery_sequence( operations: &mut impl SupersededCloseoutRecoveryOperationsRunner, ) -> Result<(bool, bool, bool)> { operations.ensure_terminalizable()?; - operations.update_issue_state()?; let closeout_recorded = operations.write_closeout_event()?; + operations.record_lifecycle_authority("pending")?; + operations.post_pull_request_comment()?; + let pr_closed = operations.close_pull_request_if_open()?; + operations.update_issue_state()?; let cleanup_recorded = operations.write_cleanup_event()?; - operations.record_lifecycle_authority()?; + operations.record_lifecycle_authority("completed")?; operations.update_run_status()?; operations.clear_worktree()?; - operations.post_pull_request_comment()?; - let pr_closed = operations.close_pull_request_if_open()?; Ok((closeout_recorded, cleanup_recorded, pr_closed)) } @@ -197,8 +198,8 @@ impl SupersededCloseoutRecoveryOperationsRunner for SupersededCloseoutRecoveryOp ) } - fn record_lifecycle_authority(&mut self) -> Result<()> { - record_superseded_closeout_lifecycle_authority(self.context, self.validation) + 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<()> { @@ -305,8 +306,12 @@ mod tests { Ok(true) } - fn record_lifecycle_authority(&mut self) -> Result<()> { - self.record("record_lifecycle_authority") + 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<()> { @@ -340,37 +345,58 @@ mod tests { operations.steps.into_inner(), vec![ "ensure_terminalizable", - "update_issue_state", "write_closeout_event", + "record_lifecycle_authority_pending", + "post_pull_request_comment", + "close_pull_request_if_open", + "update_issue_state", "write_cleanup_event", - "record_lifecycle_authority", + "record_lifecycle_authority_completed", "update_run_status", "clear_worktree", - "post_pull_request_comment", - "close_pull_request_if_open", ] ); } #[test] - fn superseded_closeout_does_not_touch_github_when_lifecycle_authority_fails() { + fn superseded_closeout_does_not_terminalize_issue_when_lifecycle_authority_fails() { let mut operations = RecordingSupersededCloseoutOperations { - fail_at: Some("record_lifecycle_authority"), + 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 failed")); + assert!(error.to_string().contains("record_lifecycle_authority_pending failed")); assert_eq!( operations.steps.into_inner(), vec![ "ensure_terminalizable", - "update_issue_state", "write_closeout_event", - "write_cleanup_event", - "record_lifecycle_authority", + "record_lifecycle_authority_pending", + ] + ); + } + + #[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", + "write_closeout_event", + "record_lifecycle_authority_pending", + "post_pull_request_comment", ] ); } @@ -558,6 +584,7 @@ fn record_merged_closeout_lifecycle_decision( 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( @@ -592,12 +619,18 @@ fn record_superseded_closeout_lifecycle_authority( next_state: record.next_state(), }); let idempotency_key = format!( - "{}:{}:{}:{}", + "{}:{}:{}:{}:{}", context.config.service_id(), validation.issue.id, validation.successor_merge_commit, - "superseded_closeout_recovery" + "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 decided_at = current_timestamp(); let decision = self::decide_lifecycle_transition(LifecycleDecisionInput { facts: &facts, @@ -605,12 +638,12 @@ fn record_superseded_closeout_lifecycle_authority( evidence_kind: LifecycleEvidenceKind::CloseoutCompletion, outcome: LifecycleOutcome::Succeeded, merge_commit: Some(&validation.successor_merge_commit), - cleanup_state: Some("completed"), + cleanup_state: Some(cleanup_state), authority: "issue_authority", actor: "superseded_closeout_recovery", idempotency_key: &idempotency_key, correlation_id: &validation.run_id, - causation_id: Some("superseded_closeout_recovery_closeout_complete"), + causation_id: Some(causation_id), decided_at: &decided_at, }); diff --git a/openwiki/specs/contracts-and-data.md b/openwiki/specs/contracts-and-data.md index 148f08378..a35c36049 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. 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 explicit predecessor/successor issue and 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, clean retained worktree evidence, no patch-unique obsolete PR commits relative to the default branch, public-safe closeout ledger records, and an issue-authority lifecycle closeout record. +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 explicit predecessor/successor issue and 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, clean retained worktree evidence, no patch-unique obsolete PR commits relative to the default branch, public-safe closeout ledger records, and issue-authority lifecycle closeout records that move from pending cleanup before PR mutation to completed cleanup after the obsolete PR is commented/closed. 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 2fb696c5e..0f6bf3721 100644 --- a/openwiki/workflows/runtime-operator-workflows.md +++ b/openwiki/workflows/runtime-operator-workflows.md @@ -148,7 +148,7 @@ 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 the obsolete and successor issues, 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. Apply records the public-safe closeout ledger, lifecycle authority, run success, and retained cleanup state before commenting on or closing the obsolete GitHub PR, so the PR mutation is never the only durable side effect. +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 the obsolete and successor issues, 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. Apply records the public-safe close authorization ledger and pending-cleanup lifecycle authority before terminalizing the issue or mutating the obsolete GitHub PR, then records cleanup completion and clears retained worktree state only after the obsolete PR comment/close path succeeds. 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 a22144a76..ddacb5f54 100644 --- a/plugins/decodex/skills/decodex-ops/SKILL.md +++ b/plugins/decodex/skills/decodex-ops/SKILL.md @@ -47,7 +47,9 @@ service labels, recovery, or lane-control details matter. before live superseded closeout. The obsolete issue must have no queue, active, or needs-attention labels or live runtime ownership, and the successor issue must expose a Decodex ledger record for the exact successor PR head and merge - commit. + commit. Live superseded closeout records close authorization before the obsolete + issue/PR terminal mutation and records cleanup completion only after the PR + comment/close path succeeds. - Do not infer PR lineage from branch names, PR titles, Linear comments, status summaries, or stale snapshots. From 6239cf42084c47bf76538108d0bf2484c136bd90 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Thu, 9 Jul 2026 11:59:58 -0400 Subject: [PATCH 06/16] {"schema":"decodex/commit/2","change":"Repair superseded closeout pending authority","authority":"XY-1248","impact":"compatible"} --- .../src/orchestrator/kernel/lifecycle.rs | 69 +++++++++++++++++++ apps/decodex/src/recovery/closeout/apply.rs | 23 ++++++- .../closeout/validation/superseded.rs | 24 ++++++- openwiki/specs/contracts-and-data.md | 2 +- .../workflows/runtime-operator-workflows.md | 2 +- plugins/decodex/skills/decodex-ops/SKILL.md | 11 +-- 6 files changed, 119 insertions(+), 12 deletions(-) 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/recovery/closeout/apply.rs b/apps/decodex/src/recovery/closeout/apply.rs index e5fa3a47f..e33002e88 100644 --- a/apps/decodex/src/recovery/closeout/apply.rs +++ b/apps/decodex/src/recovery/closeout/apply.rs @@ -604,7 +604,7 @@ fn record_superseded_closeout_lifecycle_authority( review_level, phase: "superseded_closeout_recovery", landing_state: Some("superseded"), - closeout_state: Some("completed"), + 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()), @@ -631,12 +631,13 @@ fn record_superseded_closeout_lifecycle_authority( "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: LifecycleEvidenceKind::CloseoutCompletion, - outcome: LifecycleOutcome::Succeeded, + evidence_kind, + outcome, merge_commit: Some(&validation.successor_merge_commit), cleanup_state: Some(cleanup_state), authority: "issue_authority", @@ -656,6 +657,22 @@ fn record_superseded_closeout_lifecycle_authority( 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 { diff --git a/apps/decodex/src/recovery/closeout/validation/superseded.rs b/apps/decodex/src/recovery/closeout/validation/superseded.rs index 59b06e52b..020ace67e 100644 --- a/apps/decodex/src/recovery/closeout/validation/superseded.rs +++ b/apps/decodex/src/recovery/closeout/validation/superseded.rs @@ -1,4 +1,5 @@ use crate::{ + orchestrator, prelude::{Result, eyre}, recovery::{ closeout::{ @@ -225,10 +226,10 @@ fn ensure_issue_has_no_live_runtime_ownership( ); } if let Some(attempt) = context.state_store.latest_run_attempt_for_issue(&issue_key)? - && matches!(attempt.status(), "starting" | "running") + && !orchestrator::local_run_attempt_status_is_terminal(attempt.status()) { eyre::bail!( - "Issue `{}` still has {} run `{}` for `{issue_key}`; superseded closeout recovery requires live ownership to be absent.", + "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() @@ -470,6 +471,25 @@ mod tests { 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 successor_lineage_rejects_unrelated_completed_issue_and_pr() { let temp_dir = TempDir::new().expect("tempdir should create"); diff --git a/openwiki/specs/contracts-and-data.md b/openwiki/specs/contracts-and-data.md index a35c36049..8bcf07b5d 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. 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 explicit predecessor/successor issue and 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, clean retained worktree evidence, no patch-unique obsolete PR commits relative to the default branch, public-safe closeout ledger records, and issue-authority lifecycle closeout records that move from pending cleanup before PR mutation to completed cleanup after the obsolete PR is commented/closed. +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 explicit predecessor/successor issue and 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, 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 PR mutation to completed closeout after the obsolete PR is commented/closed. 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 0f6bf3721..a9fb309d3 100644 --- a/openwiki/workflows/runtime-operator-workflows.md +++ b/openwiki/workflows/runtime-operator-workflows.md @@ -148,7 +148,7 @@ 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 the obsolete and successor issues, 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. Apply records the public-safe close authorization ledger and pending-cleanup lifecycle authority before terminalizing the issue or mutating the obsolete GitHub PR, then records cleanup completion and clears retained worktree state only after the obsolete PR comment/close path succeeds. +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 the obsolete and successor issues, 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 non-terminal run attempts such as `starting`, `running`, and `continuation_pending`. Apply records the public-safe close authorization ledger and a retryable, non-terminal pending closeout authority before terminalizing the issue or mutating the obsolete GitHub PR, then records cleanup completion and clears retained worktree state only after the obsolete PR comment/close path succeeds. 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 ddacb5f54..575a244e2 100644 --- a/plugins/decodex/skills/decodex-ops/SKILL.md +++ b/plugins/decodex/skills/decodex-ops/SKILL.md @@ -45,11 +45,12 @@ service labels, recovery, or lane-control details matter. 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, and the successor issue - must expose a Decodex ledger record for the exact successor PR head and merge - commit. Live superseded closeout records close authorization before the obsolete - issue/PR terminal mutation and records cleanup completion only after the PR - comment/close path succeeds. + or needs-attention labels or live runtime ownership, including non-terminal + attempts such as `continuation_pending`, and the successor issue must expose a + Decodex ledger record for the exact successor PR head and merge commit. Live + superseded closeout records close authorization plus retryable pending closeout + authority before the obsolete issue/PR terminal mutation and records cleanup + completion only after the PR comment/close path succeeds. - Do not infer PR lineage from branch names, PR titles, Linear comments, status summaries, or stale snapshots. From 17612e52708533a3cd79bb24bc5f4006b496fc0d Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Thu, 9 Jul 2026 12:19:19 -0400 Subject: [PATCH 07/16] {"schema":"decodex/commit/2","change":"Reject superseded closeout retry ownership","authority":"XY-1248","impact":"compatible"} --- .../closeout/validation/superseded.rs | 94 +++++++++++++++++-- openwiki/specs/contracts-and-data.md | 2 +- .../workflows/runtime-operator-workflows.md | 2 +- plugins/decodex/skills/decodex-ops/SKILL.md | 11 ++- 4 files changed, 93 insertions(+), 16 deletions(-) diff --git a/apps/decodex/src/recovery/closeout/validation/superseded.rs b/apps/decodex/src/recovery/closeout/validation/superseded.rs index 020ace67e..58b0ef62f 100644 --- a/apps/decodex/src/recovery/closeout/validation/superseded.rs +++ b/apps/decodex/src/recovery/closeout/validation/superseded.rs @@ -11,6 +11,7 @@ use crate::{ requests::SupersededCloseoutRecoveryRequest, review_handoff, }, + state, tracker::{ self, IssueTracker, TrackerIssue, records::{self, LinearExecutionEventRecord}, @@ -215,6 +216,8 @@ fn ensure_issue_has_no_live_runtime_ownership( context: &RecoveryContext, issue: &TrackerIssue, ) -> Result<()> { + let retained_worktree_mapping = issue::retained_worktree_mapping_for_issue(context, issue)?; + for issue_key in issue_keys(issue) { if context .state_store @@ -225,21 +228,57 @@ fn ensure_issue_has_no_live_runtime_ownership( issue.identifier ); } - if let Some(attempt) = context.state_store.latest_run_attempt_for_issue(&issue_key)? - && !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() - ); + + if let Some(attempt) = context.state_store.latest_run_attempt_for_issue(&issue_key)? { + 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_latest_attempt_has_no_retry_schedule( + issue, + &issue_key, + attempt.run_id(), + attempt.attempt_number(), + retained_worktree_mapping.as_ref(), + )?; } } Ok(()) } +fn ensure_latest_attempt_has_no_retry_schedule( + issue: &TrackerIssue, + issue_key: &str, + run_id: &str, + attempt_number: i64, + 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 marker.run_id() == run_id + && marker.attempt_number() == attempt_number + && let Some(retry_kind) = marker.retry_kind() + { + eyre::bail!( + "Issue `{}` still has retry-scheduled runtime ownership for `{issue_key}` run `{run_id}` attempt {attempt_number} ({retry_kind}); superseded closeout recovery requires live ownership to be absent.", + issue.identifier + ); + } + + Ok(()) +} + fn validate_successor_issue_pr_lineage( context: &RecoveryContext, tracker: &T, @@ -490,6 +529,43 @@ mod tests { assert!(error.to_string().contains("continuation_pending")); } + #[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 successor_lineage_rejects_unrelated_completed_issue_and_pr() { let temp_dir = TempDir::new().expect("tempdir should create"); diff --git a/openwiki/specs/contracts-and-data.md b/openwiki/specs/contracts-and-data.md index 8bcf07b5d..53cb24ef1 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. 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 explicit predecessor/successor issue and 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, 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 PR mutation to completed closeout after the obsolete PR is commented/closed. +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 explicit predecessor/successor issue and 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 retained retry schedule for the latest terminal run 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 PR mutation to completed closeout after the obsolete PR is commented/closed. 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 a9fb309d3..454f1d689 100644 --- a/openwiki/workflows/runtime-operator-workflows.md +++ b/openwiki/workflows/runtime-operator-workflows.md @@ -148,7 +148,7 @@ 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 the obsolete and successor issues, 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 non-terminal run attempts such as `starting`, `running`, and `continuation_pending`. Apply records the public-safe close authorization ledger and a retryable, non-terminal pending closeout authority before terminalizing the issue or mutating the obsolete GitHub PR, then records cleanup completion and clears retained worktree state only after the obsolete PR comment/close path succeeds. +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 the obsolete and successor issues, 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 non-terminal run attempts such as `starting`, `running`, and `continuation_pending`, plus retained retry schedules for the latest terminal run attempt. Apply records the public-safe close authorization ledger and a retryable, non-terminal pending closeout authority before terminalizing the issue or mutating the obsolete GitHub PR, then records cleanup completion and clears retained worktree state only after the obsolete PR comment/close path succeeds. 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 575a244e2..575de5095 100644 --- a/plugins/decodex/skills/decodex-ops/SKILL.md +++ b/plugins/decodex/skills/decodex-ops/SKILL.md @@ -46,11 +46,12 @@ service labels, recovery, or lane-control details matter. `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`, and the successor issue must expose a - Decodex ledger record for the exact successor PR head and merge commit. Live - superseded closeout records close authorization plus retryable pending closeout - authority before the obsolete issue/PR terminal mutation and records cleanup - completion only after the PR comment/close path succeeds. + attempts such as `continuation_pending` and retained retry schedules for a + terminal latest attempt. The successor issue must expose a Decodex ledger + record for the exact successor PR head and merge commit. Live superseded + closeout records close authorization plus retryable pending closeout authority + before the obsolete issue/PR terminal mutation and records cleanup completion + only after the PR comment/close path succeeds. - Do not infer PR lineage from branch names, PR titles, Linear comments, status summaries, or stale snapshots. From 14490a833b2a4486222e7cb89177e0f32deeae8d Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Thu, 9 Jul 2026 12:44:03 -0400 Subject: [PATCH 08/16] {"schema":"decodex/commit/2","change":"Reject live retained markers in superseded closeout","authority":"XY-1248","impact":"compatible"} --- .../closeout/validation/superseded.rs | 114 +++++++++++++++++- openwiki/specs/contracts-and-data.md | 2 +- .../workflows/runtime-operator-workflows.md | 2 +- plugins/decodex/skills/decodex-ops/SKILL.md | 6 +- 4 files changed, 117 insertions(+), 7 deletions(-) diff --git a/apps/decodex/src/recovery/closeout/validation/superseded.rs b/apps/decodex/src/recovery/closeout/validation/superseded.rs index 58b0ef62f..c221eaac7 100644 --- a/apps/decodex/src/recovery/closeout/validation/superseded.rs +++ b/apps/decodex/src/recovery/closeout/validation/superseded.rs @@ -7,6 +7,7 @@ use crate::{ validation::merged::{issue, pull_request}, }, context::RecoveryContext, + process_liveness::{self, StaleActiveProcessLiveness}, pull_request_inspection, requests::SupersededCloseoutRecoveryRequest, review_handoff, @@ -238,7 +239,7 @@ fn ensure_issue_has_no_live_runtime_ownership( attempt.run_id() ); } - ensure_latest_attempt_has_no_retry_schedule( + ensure_latest_attempt_has_no_live_retained_marker_ownership( issue, &issue_key, attempt.run_id(), @@ -251,7 +252,7 @@ fn ensure_issue_has_no_live_runtime_ownership( Ok(()) } -fn ensure_latest_attempt_has_no_retry_schedule( +fn ensure_latest_attempt_has_no_live_retained_marker_ownership( issue: &TrackerIssue, issue_key: &str, run_id: &str, @@ -276,6 +277,63 @@ fn ensure_latest_attempt_has_no_retry_schedule( ); } + ensure_matching_marker_has_no_live_runtime_evidence( + issue, + issue_key, + run_id, + attempt_number, + &marker, + )?; + + Ok(()) +} + +fn ensure_matching_marker_has_no_live_runtime_evidence( + issue: &TrackerIssue, + issue_key: &str, + run_id: &str, + attempt_number: i64, + marker: &state::RunActivityMarker, +) -> Result<()> { + if marker.run_id() != run_id || marker.attempt_number() != attempt_number { + return Ok(()); + } + + 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 `{issue_key}` 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 `{issue_key}` 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 `{issue_key}` run `{run_id}` attempt {attempt_number}; superseded closeout recovery requires live ownership to be absent.", + issue.identifier + ); + } + + if marker_liveness != StaleActiveProcessLiveness::NotAlive + && (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 live retained activity marker ownership for `{issue_key}` run `{run_id}` attempt {attempt_number}; superseded closeout recovery requires live ownership to be absent.", + issue.identifier + ); + } + Ok(()) } @@ -439,7 +497,7 @@ mod tests { use crate::{ config::ServiceConfig, recovery::{LEGACY_MANUAL_CLOSEOUT_EVENT, RecoveryRuntimeMutationPolicy}, - state::StateStore, + state::{ProtocolActivityMarker, ProtocolActivitySummary, StateStore}, tracker::{ TrackerComment, TrackerIssue, TrackerIssueBriefUpdate, TrackerIssueCreate, TrackerLabel, TrackerState, TrackerTeam, @@ -566,6 +624,56 @@ mod tests { 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 successor_lineage_rejects_unrelated_completed_issue_and_pr() { let temp_dir = TempDir::new().expect("tempdir should create"); diff --git a/openwiki/specs/contracts-and-data.md b/openwiki/specs/contracts-and-data.md index 53cb24ef1..2af70d8c6 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. 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 explicit predecessor/successor issue and 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 retained retry schedule for the latest terminal run 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 PR mutation to completed closeout after the obsolete PR is commented/closed. +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 explicit predecessor/successor issue and 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 retained retry schedule for the latest terminal run attempt, no matching retained worktree marker with live process/thread/protocol activity, 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 PR mutation to completed closeout after the obsolete PR is commented/closed. 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 454f1d689..7a45c7607 100644 --- a/openwiki/workflows/runtime-operator-workflows.md +++ b/openwiki/workflows/runtime-operator-workflows.md @@ -148,7 +148,7 @@ 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 the obsolete and successor issues, 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 non-terminal run attempts such as `starting`, `running`, and `continuation_pending`, plus retained retry schedules for the latest terminal run attempt. Apply records the public-safe close authorization ledger and a retryable, non-terminal pending closeout authority before terminalizing the issue or mutating the obsolete GitHub PR, then records cleanup completion and clears retained worktree state only after the obsolete PR comment/close path succeeds. +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 the obsolete and successor issues, 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 non-terminal run attempts such as `starting`, `running`, and `continuation_pending`, retained retry schedules for the latest terminal run attempt, and matching retained worktree activity markers that still show a live process, active thread, or live protocol/activity evidence. Apply records the public-safe close authorization ledger and a retryable, non-terminal pending closeout authority before terminalizing the issue or mutating the obsolete GitHub PR, then records cleanup completion and clears retained worktree state only after the obsolete PR comment/close path succeeds. 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 575de5095..1bb8a4fd0 100644 --- a/plugins/decodex/skills/decodex-ops/SKILL.md +++ b/plugins/decodex/skills/decodex-ops/SKILL.md @@ -47,8 +47,10 @@ service labels, recovery, or lane-control details matter. 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` and retained retry schedules for a - terminal latest attempt. The successor issue must expose a Decodex ledger - record for the exact successor PR head and merge commit. Live superseded + terminal latest attempt, plus matching retained worktree markers that still + show live process, active thread, or live protocol/activity evidence. The + successor issue must expose a Decodex ledger record for the exact successor + PR head and merge commit. Live superseded closeout records close authorization plus retryable pending closeout authority before the obsolete issue/PR terminal mutation and records cleanup completion only after the PR comment/close path succeeds. From 07a2c3f1fbf2299cc9719ddf3d5515e27f786fcc Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Thu, 9 Jul 2026 13:06:34 -0400 Subject: [PATCH 09/16] {"schema":"decodex/commit/2","change":"Reject stale superseded closeout ownership","authority":"XY-1248","impact":"compatible"} --- apps/decodex/src/recovery/closeout/apply.rs | 44 ++- .../closeout/validation/superseded.rs | 256 +++++++++++++++--- openwiki/specs/contracts-and-data.md | 2 +- .../workflows/runtime-operator-workflows.md | 2 +- plugins/decodex/skills/decodex-ops/SKILL.md | 13 +- 5 files changed, 260 insertions(+), 57 deletions(-) diff --git a/apps/decodex/src/recovery/closeout/apply.rs b/apps/decodex/src/recovery/closeout/apply.rs index e33002e88..bb027ea35 100644 --- a/apps/decodex/src/recovery/closeout/apply.rs +++ b/apps/decodex/src/recovery/closeout/apply.rs @@ -143,13 +143,13 @@ fn apply_superseded_closeout_recovery_sequence( operations: &mut impl SupersededCloseoutRecoveryOperationsRunner, ) -> Result<(bool, bool, bool)> { operations.ensure_terminalizable()?; - let closeout_recorded = operations.write_closeout_event()?; operations.record_lifecycle_authority("pending")?; + let closeout_recorded = operations.write_closeout_event()?; operations.post_pull_request_comment()?; let pr_closed = operations.close_pull_request_if_open()?; operations.update_issue_state()?; - let cleanup_recorded = operations.write_cleanup_event()?; operations.record_lifecycle_authority("completed")?; + let cleanup_recorded = operations.write_cleanup_event()?; operations.update_run_status()?; operations.clear_worktree()?; @@ -345,13 +345,13 @@ mod tests { operations.steps.into_inner(), vec![ "ensure_terminalizable", - "write_closeout_event", "record_lifecycle_authority_pending", + "write_closeout_event", "post_pull_request_comment", "close_pull_request_if_open", "update_issue_state", - "write_cleanup_event", "record_lifecycle_authority_completed", + "write_cleanup_event", "update_run_status", "clear_worktree", ] @@ -371,14 +371,38 @@ mod tests { assert!(error.to_string().contains("record_lifecycle_authority_pending failed")); assert_eq!( operations.steps.into_inner(), - vec![ - "ensure_terminalizable", - "write_closeout_event", - "record_lifecycle_authority_pending", - ] + vec!["ensure_terminalizable", "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 { @@ -394,8 +418,8 @@ mod tests { operations.steps.into_inner(), vec![ "ensure_terminalizable", - "write_closeout_event", "record_lifecycle_authority_pending", + "write_closeout_event", "post_pull_request_comment", ] ); diff --git a/apps/decodex/src/recovery/closeout/validation/superseded.rs b/apps/decodex/src/recovery/closeout/validation/superseded.rs index c221eaac7..204bd24a8 100644 --- a/apps/decodex/src/recovery/closeout/validation/superseded.rs +++ b/apps/decodex/src/recovery/closeout/validation/superseded.rs @@ -218,6 +218,10 @@ fn ensure_issue_has_no_live_runtime_ownership( issue: &TrackerIssue, ) -> 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 @@ -230,7 +234,7 @@ fn ensure_issue_has_no_live_runtime_ownership( ); } - if let Some(attempt) = context.state_store.latest_run_attempt_for_issue(&issue_key)? { + for attempt in context.state_store.list_run_attempts_for_issue(&issue_key)? { 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.", @@ -239,24 +243,14 @@ fn ensure_issue_has_no_live_runtime_ownership( attempt.run_id() ); } - ensure_latest_attempt_has_no_live_retained_marker_ownership( - issue, - &issue_key, - attempt.run_id(), - attempt.attempt_number(), - retained_worktree_mapping.as_ref(), - )?; } } Ok(()) } -fn ensure_latest_attempt_has_no_live_retained_marker_ownership( +fn ensure_retained_worktree_marker_has_no_live_runtime_ownership( issue: &TrackerIssue, - issue_key: &str, - run_id: &str, - attempt_number: i64, retained_worktree_mapping: Option<&state::WorktreeMapping>, ) -> Result<()> { let Some(worktree_mapping) = retained_worktree_mapping else { @@ -267,47 +261,33 @@ fn ensure_latest_attempt_has_no_live_retained_marker_ownership( return Ok(()); }; - if marker.run_id() == run_id - && marker.attempt_number() == attempt_number - && let Some(retry_kind) = marker.retry_kind() - { + if let Some(retry_kind) = marker.retry_kind() { eyre::bail!( - "Issue `{}` still has retry-scheduled runtime ownership for `{issue_key}` run `{run_id}` attempt {attempt_number} ({retry_kind}); superseded closeout recovery requires live ownership to be absent.", - issue.identifier + "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_matching_marker_has_no_live_runtime_evidence( - issue, - issue_key, - run_id, - attempt_number, - &marker, - )?; - - Ok(()) + ensure_marker_has_no_live_runtime_evidence(issue, &marker) } -fn ensure_matching_marker_has_no_live_runtime_evidence( +fn ensure_marker_has_no_live_runtime_evidence( issue: &TrackerIssue, - issue_key: &str, - run_id: &str, - attempt_number: i64, marker: &state::RunActivityMarker, ) -> Result<()> { - if marker.run_id() != run_id || marker.attempt_number() != attempt_number { - return Ok(()); - } - + 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 `{issue_key}` run `{run_id}` attempt {attempt_number}; superseded closeout recovery requires live ownership to be absent.", + "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 `{issue_key}` run `{run_id}` attempt {attempt_number}; superseded closeout recovery requires live ownership to be absent.", + "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 => {}, @@ -315,7 +295,7 @@ fn ensure_matching_marker_has_no_live_runtime_evidence( if process_liveness::stale_active_marker_thread_active(marker) { eyre::bail!( - "Issue `{}` still has live retained thread ownership for `{issue_key}` run `{run_id}` attempt {attempt_number}; superseded closeout recovery requires live ownership to be absent.", + "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 ); } @@ -329,7 +309,7 @@ fn ensure_matching_marker_has_no_live_runtime_evidence( || marker.protocol_activity().is_some()) { eyre::bail!( - "Issue `{}` still has live retained activity marker ownership for `{issue_key}` run `{run_id}` attempt {attempt_number}; superseded closeout recovery requires live ownership to be absent.", + "Issue `{}` still has live retained activity marker ownership for retained worktree run `{run_id}` attempt {attempt_number}; superseded closeout recovery requires live ownership to be absent.", issue.identifier ); } @@ -624,6 +604,76 @@ mod tests { 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"); @@ -674,6 +724,106 @@ mod tests { 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_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"); @@ -740,6 +890,34 @@ mod tests { issue } + 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, diff --git a/openwiki/specs/contracts-and-data.md b/openwiki/specs/contracts-and-data.md index 2af70d8c6..e7c91c533 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. 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 explicit predecessor/successor issue and 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 retained retry schedule for the latest terminal run attempt, no matching retained worktree marker with live process/thread/protocol activity, 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 PR mutation to completed closeout after the obsolete PR is commented/closed. +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 explicit predecessor/successor issue and 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 non-terminal retained attempt for either issue id or identifier, no retained retry schedule or live process/thread/protocol activity marker for the retained worktree, 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 public cleanup projection after the obsolete PR is commented/closed. 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 7a45c7607..f6ba34686 100644 --- a/openwiki/workflows/runtime-operator-workflows.md +++ b/openwiki/workflows/runtime-operator-workflows.md @@ -148,7 +148,7 @@ 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 the obsolete and successor issues, 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 non-terminal run attempts such as `starting`, `running`, and `continuation_pending`, retained retry schedules for the latest terminal run attempt, and matching retained worktree activity markers that still show a live process, active thread, or live protocol/activity evidence. Apply records the public-safe close authorization ledger and a retryable, non-terminal pending closeout authority before terminalizing the issue or mutating the obsolete GitHub PR, then records cleanup completion and clears retained worktree state only after the obsolete PR comment/close path succeeds. +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 the obsolete and successor issues, 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`, retained retry schedules on the worktree marker, and retained worktree activity markers that still show a live process, active thread, or live protocol/activity evidence even when the marker has no matching latest attempt row. Apply records retryable, non-terminal pending closeout authority before public Linear ledger or GitHub PR projections, then records completed closeout authority before the public cleanup projection and clears retained worktree state only after the obsolete PR comment/close path succeeds. 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 1bb8a4fd0..5affa11b5 100644 --- a/plugins/decodex/skills/decodex-ops/SKILL.md +++ b/plugins/decodex/skills/decodex-ops/SKILL.md @@ -46,14 +46,15 @@ service labels, recovery, or lane-control details matter. `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` and retained retry schedules for a - terminal latest attempt, plus matching retained worktree markers that still - show live process, active thread, or live protocol/activity evidence. The + attempts such as `continuation_pending`, retained retry schedules on the + worktree marker, and retained worktree markers that still show live process, + active thread, or live protocol/activity evidence even without a matching + latest attempt row. The successor issue must expose a Decodex ledger record for the exact successor PR head and merge commit. Live superseded - closeout records close authorization plus retryable pending closeout authority - before the obsolete issue/PR terminal mutation and records cleanup completion - only after the PR comment/close path succeeds. + closeout records retryable pending closeout authority before public Linear or + GitHub projections, then records completed closeout authority before public + cleanup projection and retained worktree cleanup. - Do not infer PR lineage from branch names, PR titles, Linear comments, status summaries, or stale snapshots. From 9d8cd03cb4295bd396a73fdeecbde083da0ef6ff Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Thu, 9 Jul 2026 13:26:52 -0400 Subject: [PATCH 10/16] {"schema":"decodex/commit/2","change":"Repair closeout projection ordering","authority":"XY-1248","impact":"compatible"} --- apps/decodex/src/recovery/closeout/apply.rs | 428 +++++++++++++++--- openwiki/specs/contracts-and-data.md | 2 +- .../workflows/runtime-operator-workflows.md | 3 +- plugins/decodex/skills/decodex-ops/SKILL.md | 6 +- 4 files changed, 366 insertions(+), 73 deletions(-) diff --git a/apps/decodex/src/recovery/closeout/apply.rs b/apps/decodex/src/recovery/closeout/apply.rs index bb027ea35..0e2ef8ad4 100644 --- a/apps/decodex/src/recovery/closeout/apply.rs +++ b/apps/decodex/src/recovery/closeout/apply.rs @@ -23,9 +23,12 @@ use crate::{ context::RecoveryContext, pull_request_inspection, }, + state::StateStore, tracker::{ self, IssueTracker, - privacy_classifier::ConfiguredPublicProjectionPrivacyClassifier, + privacy_classifier::{ + ConfiguredPublicProjectionPrivacyClassifier, PublicProjectionPrivacyClassifier, + }, records::{self, LinearExecutionEventRecord}, }, }; @@ -77,45 +80,78 @@ 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 }; - return Err(error); - }, - }; + 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()?; - if validation.worktree_mapping.is_some() { - context.state_store.clear_worktree(&validation.issue.id)?; + Ok((closeout_recorded, cleanup_recorded)) +} - if validation.issue.identifier != validation.issue.id { - context.state_store.clear_worktree(&validation.issue.identifier)?; - } +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) } - record_merged_closeout_lifecycle_authority(context, validation)?; + fn write_closeout_event(&mut self) -> Result { + let closeout_event = events::merged_closeout_event(self.context, self.validation); - context.state_store.update_run_status(&validation.run_id, "succeeded")?; + 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.", + ) + } - Ok((closeout_recorded, cleanup_recorded)) + 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)?; + } + } + + 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( @@ -262,12 +298,65 @@ impl SupersededCloseoutRecoveryOperationsRunner for SupersededCloseoutRecoveryOp mod tests { use std::cell::RefCell; - use crate::prelude::{Result, eyre}; + use crate::{ + prelude::{Result, eyre}, + state::StateStore, + tracker::{ + IssueTracker, TrackerComment, TrackerIssue, + privacy_classifier::DISABLED_PUBLIC_PROJECTION_PRIVACY_CLASSIFIER, + records::{self, LinearExecutionEventIdentity, LinearExecutionEventRecord}, + }, + }; use super::{ - SupersededCloseoutRecoveryOperationsRunner, apply_superseded_closeout_recovery_sequence, + MergedCloseoutRecoveryOperationsRunner, SupersededCloseoutRecoveryOperationsRunner, + apply_merged_closeout_recovery_sequence, apply_superseded_closeout_recovery_sequence, + write_recovery_closeout_event, }; + #[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(()) + } + } + + impl MergedCloseoutRecoveryOperationsRunner for RecordingMergedCloseoutOperations { + fn record_lifecycle_authority(&mut self) -> Result<()> { + self.record("record_lifecycle_authority") + } + + 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 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>, @@ -333,6 +422,200 @@ mod tests { } } + #[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(); @@ -439,29 +722,16 @@ 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); - 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); - } - - if let Err(error) = tracker::create_linear_execution_event_comment_direct( + write_recovery_closeout_event( &context.tracker, + &context.state_store, &validation.issue.id, - &projection, - ) { - context.state_store.forget_linear_execution_event(&projection.record.idempotency_key)?; - - return Err(error); - } - - Ok(true) + event, + body, + retry_budget_attempt_count, + &privacy_classifier, + ) } fn write_superseded_closeout_event( @@ -477,29 +747,47 @@ fn write_superseded_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_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)?; + tracker::prepare_linear_execution_event_comment(&body, event, privacy_classifier)?; + let recorded = state_store.record_linear_execution_event(&projection.record)?; - if !recorded { - return Ok(false); - } - - 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( diff --git a/openwiki/specs/contracts-and-data.md b/openwiki/specs/contracts-and-data.md index e7c91c533..5783a83d4 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. 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 explicit predecessor/successor issue and 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 non-terminal retained attempt for either issue id or identifier, no retained retry schedule or live process/thread/protocol activity marker for the retained worktree, 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 public cleanup projection after the obsolete PR is commented/closed. +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 explicit predecessor/successor issue and 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 non-terminal retained attempt for either issue id or identifier, no retained retry schedule or live process/thread/protocol activity marker for the retained worktree, 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 public cleanup projection after the obsolete PR is commented/closed. 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 f6ba34686..3bf54d7fc 100644 --- a/openwiki/workflows/runtime-operator-workflows.md +++ b/openwiki/workflows/runtime-operator-workflows.md @@ -148,7 +148,8 @@ 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 the obsolete and successor issues, 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`, retained retry schedules on the worktree marker, and retained worktree activity markers that still show a live process, active thread, or live protocol/activity evidence even when the marker has no matching latest attempt row. Apply records retryable, non-terminal pending closeout authority before public Linear ledger or GitHub PR projections, then records completed closeout authority before the public cleanup projection and clears retained worktree state only after the obsolete PR comment/close path succeeds. +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 the obsolete and successor issues, 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`, retained retry schedules on the worktree marker, and retained worktree activity markers that still show a live process, active thread, or live protocol/activity evidence even when the marker has no matching latest attempt row. Apply records retryable, non-terminal pending closeout authority before public Linear ledger or GitHub PR projections, then records completed closeout authority before the public cleanup projection and clears retained worktree state only after the obsolete PR comment/close path succeeds. 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 5affa11b5..a29e319fd 100644 --- a/plugins/decodex/skills/decodex-ops/SKILL.md +++ b/plugins/decodex/skills/decodex-ops/SKILL.md @@ -54,7 +54,11 @@ service labels, recovery, or lane-control details matter. PR head and merge commit. Live superseded closeout records retryable pending closeout authority before public Linear or GitHub projections, then records completed closeout authority before public - cleanup projection and retained worktree cleanup. + cleanup projection and retained worktree cleanup. 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. From 46b6562f3c3357e765fdcb99b240c5f13b48b2a1 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Thu, 9 Jul 2026 15:16:48 -0400 Subject: [PATCH 11/16] {"schema":"decodex/commit/2","change":"Reject stale retained activity closeout","authority":"XY-1248","impact":"compatible"} --- .../closeout/validation/superseded.rs | 61 ++++++++++++++++--- openwiki/specs/contracts-and-data.md | 2 +- .../workflows/runtime-operator-workflows.md | 2 +- plugins/decodex/skills/decodex-ops/SKILL.md | 8 ++- 4 files changed, 60 insertions(+), 13 deletions(-) diff --git a/apps/decodex/src/recovery/closeout/validation/superseded.rs b/apps/decodex/src/recovery/closeout/validation/superseded.rs index 204bd24a8..a9c5bdef4 100644 --- a/apps/decodex/src/recovery/closeout/validation/superseded.rs +++ b/apps/decodex/src/recovery/closeout/validation/superseded.rs @@ -300,16 +300,15 @@ fn ensure_marker_has_no_live_runtime_evidence( ); } - if marker_liveness != StaleActiveProcessLiveness::NotAlive - && (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()) + if 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 live retained activity marker ownership for retained worktree run `{run_id}` attempt {attempt_number}; superseded closeout recovery requires live ownership to be absent.", + "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 ); } @@ -782,6 +781,52 @@ mod tests { 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_any_non_terminal_attempt_for_issue() { let temp_dir = TempDir::new().expect("tempdir should create"); diff --git a/openwiki/specs/contracts-and-data.md b/openwiki/specs/contracts-and-data.md index 5783a83d4..4bf772b7e 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. 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 explicit predecessor/successor issue and 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 non-terminal retained attempt for either issue id or identifier, no retained retry schedule or live process/thread/protocol activity marker for the retained worktree, 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 public cleanup projection after the obsolete PR is commented/closed. 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. +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 explicit predecessor/successor issue and 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 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 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 public cleanup projection after the obsolete PR is commented/closed. 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 3bf54d7fc..a934f6801 100644 --- a/openwiki/workflows/runtime-operator-workflows.md +++ b/openwiki/workflows/runtime-operator-workflows.md @@ -148,7 +148,7 @@ 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 the obsolete and successor issues, 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`, retained retry schedules on the worktree marker, and retained worktree activity markers that still show a live process, active thread, or live protocol/activity evidence even when the marker has no matching latest attempt row. Apply records retryable, non-terminal pending closeout authority before public Linear ledger or GitHub PR projections, then records completed closeout authority before the public cleanup projection and clears retained worktree state only after the obsolete PR comment/close path succeeds. 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. +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 the obsolete and successor issues, 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`, 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 or the marker has no matching latest attempt row; explicit stale-lane recovery must clear or classify that evidence first. Apply records retryable, non-terminal pending closeout authority before public Linear ledger or GitHub PR projections, then records completed closeout authority before the public cleanup projection and clears retained worktree state only after the obsolete PR comment/close path succeeds. 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 a29e319fd..b90928b04 100644 --- a/plugins/decodex/skills/decodex-ops/SKILL.md +++ b/plugins/decodex/skills/decodex-ops/SKILL.md @@ -47,9 +47,11 @@ service labels, recovery, or lane-control details matter. 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`, retained retry schedules on the - worktree marker, and retained worktree markers that still show live process, - active thread, or live protocol/activity evidence even without a matching - latest attempt row. 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 or no matching latest + attempt row exists; explicit stale-lane recovery must clear or classify that + evidence first. 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 From 31aa56c684d92258474fd73f38683f1d80ddb308 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Thu, 9 Jul 2026 15:35:40 -0400 Subject: [PATCH 12/16] {"schema":"decodex/commit/2","change":"Reject stale retained activity-only markers","authority":"XY-1248","impact":"compatible"} --- .../closeout/validation/superseded.rs | 99 ++++++++++++++++++- 1 file changed, 98 insertions(+), 1 deletion(-) diff --git a/apps/decodex/src/recovery/closeout/validation/superseded.rs b/apps/decodex/src/recovery/closeout/validation/superseded.rs index a9c5bdef4..a262562f3 100644 --- a/apps/decodex/src/recovery/closeout/validation/superseded.rs +++ b/apps/decodex/src/recovery/closeout/validation/superseded.rs @@ -300,7 +300,9 @@ fn ensure_marker_has_no_live_runtime_evidence( ); } - if marker.last_progress_unix_epoch().is_some() + 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() @@ -827,6 +829,101 @@ mod tests { 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"); From ae560d8a6e912d5dc1d868c474a67925294d088c Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Thu, 9 Jul 2026 15:57:37 -0400 Subject: [PATCH 13/16] {"schema":"decodex/commit/2","change":"Reject superseded closeout durable evidence","authority":"XY-1248","impact":"compatible"} --- .../closeout/validation/superseded.rs | 149 ++++++++++++++++++ .../store/execution_evidence/activity.rs | 9 ++ 2 files changed, 158 insertions(+) diff --git a/apps/decodex/src/recovery/closeout/validation/superseded.rs b/apps/decodex/src/recovery/closeout/validation/superseded.rs index a262562f3..2ba05d560 100644 --- a/apps/decodex/src/recovery/closeout/validation/superseded.rs +++ b/apps/decodex/src/recovery/closeout/validation/superseded.rs @@ -7,6 +7,7 @@ use crate::{ validation::merged::{issue, pull_request}, }, context::RecoveryContext, + evidence, process_liveness::{self, StaleActiveProcessLiveness}, pull_request_inspection, requests::SupersededCloseoutRecoveryRequest, @@ -243,12 +244,59 @@ fn ensure_issue_has_no_live_runtime_ownership( attempt.run_id() ); } + + ensure_run_attempt_has_no_durable_runtime_evidence(context, issue, &attempt)?; } } Ok(()) } +fn ensure_run_attempt_has_no_durable_runtime_evidence( + context: &RecoveryContext, + issue: &TrackerIssue, + attempt: &state::RunAttempt, +) -> Result<()> { + let run_id = attempt.run_id(); + let attempt_number = attempt.attempt_number(); + let private_events = context.state_store.list_private_execution_events_for_run_attempt( + context.config.service_id(), + run_id, + attempt_number, + )?; + + if let Some(event) = private_events.iter().find(|event| { + event.event_type() == "progress_checkpoint" + || !evidence::stale_active_private_event_allows_release( + event, + StaleActiveProcessLiveness::NotAlive, + false, + ) + }) { + eyre::bail!( + "Issue `{}` still has retained private progress evidence `{}` for run `{run_id}` attempt {attempt_number}; superseded closeout recovery requires stale progress 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 ensure_retained_worktree_marker_has_no_live_runtime_ownership( issue: &TrackerIssue, retained_worktree_mapping: Option<&state::WorktreeMapping>, @@ -568,6 +616,107 @@ mod tests { assert!(error.to_string().contains("continuation_pending")); } + #[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 progress 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"); 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()?; From efad45a4a9a076ddbc83fad2346bb2041cea678c Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Thu, 9 Jul 2026 16:26:18 -0400 Subject: [PATCH 14/16] {"schema":"decodex/commit/2","change":"Reject unresolved superseded closeout ownership","authority":"XY-1248","impact":"compatible"} --- .../closeout/validation/superseded.rs | 274 +++++++++++++++++- .../src/state/store_run_control/channel.rs | 24 ++ openwiki/specs/contracts-and-data.md | 2 +- .../workflows/runtime-operator-workflows.md | 2 +- plugins/decodex/skills/decodex-ops/SKILL.md | 15 +- 5 files changed, 295 insertions(+), 22 deletions(-) diff --git a/apps/decodex/src/recovery/closeout/validation/superseded.rs b/apps/decodex/src/recovery/closeout/validation/superseded.rs index 2ba05d560..b8c7c8531 100644 --- a/apps/decodex/src/recovery/closeout/validation/superseded.rs +++ b/apps/decodex/src/recovery/closeout/validation/superseded.rs @@ -235,7 +235,29 @@ fn ensure_issue_has_no_live_runtime_ownership( ); } - for attempt in context.state_store.list_run_attempts_for_issue(&issue_key)? { + 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() + }) + }) { + 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.", @@ -245,36 +267,57 @@ fn ensure_issue_has_no_live_runtime_ownership( ); } - ensure_run_attempt_has_no_durable_runtime_evidence(context, issue, &attempt)?; + ensure_run_attempt_has_no_durable_runtime_evidence( + context, + issue, + &attempt, + &private_events, + )?; } } 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], ) -> Result<()> { let run_id = attempt.run_id(); let attempt_number = attempt.attempt_number(); - let private_events = context.state_store.list_private_execution_events_for_run_attempt( - context.config.service_id(), - run_id, - 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" - || !evidence::stale_active_private_event_allows_release( - event, - StaleActiveProcessLiveness::NotAlive, - false, - ) + || !superseded_private_event_allows_terminalization(event) }) { eyre::bail!( - "Issue `{}` still has retained private progress evidence `{}` for run `{run_id}` attempt {attempt_number}; superseded closeout recovery requires stale progress evidence to be cleared or classified by explicit recovery before terminalization.", + "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() ); @@ -297,6 +340,37 @@ fn ensure_run_attempt_has_no_durable_runtime_evidence( Ok(()) } +fn superseded_private_event_allows_terminalization(event: &state::PrivateExecutionEvent) -> 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() { + "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 ensure_retained_worktree_marker_has_no_live_runtime_ownership( issue: &TrackerIssue, retained_worktree_mapping: Option<&state::WorktreeMapping>, @@ -616,6 +690,178 @@ mod tests { 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_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"); @@ -652,7 +898,7 @@ mod tests { .expect_err("superseded closeout should reject durable private progress"); assert!(error.to_string().contains("run-private-progress")); - assert!(error.to_string().contains("private progress evidence")); + assert!(error.to_string().contains("private execution evidence")); } #[test] 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/openwiki/specs/contracts-and-data.md b/openwiki/specs/contracts-and-data.md index 4bf772b7e..800c7da90 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. 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 explicit predecessor/successor issue and 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 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 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 public cleanup projection after the obsolete PR is commented/closed. 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. +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 explicit predecessor/successor issue and 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 public cleanup projection after the obsolete PR is commented/closed. 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 a934f6801..f179d46aa 100644 --- a/openwiki/workflows/runtime-operator-workflows.md +++ b/openwiki/workflows/runtime-operator-workflows.md @@ -148,7 +148,7 @@ 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 the obsolete and successor issues, 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`, 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 or the marker has no matching latest attempt row; explicit stale-lane recovery must clear or classify that evidence first. Apply records retryable, non-terminal pending closeout authority before public Linear ledger or GitHub PR projections, then records completed closeout authority before the public cleanup projection and clears retained worktree state only after the obsolete PR comment/close path succeeds. 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. +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 the obsolete and successor issues, 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, then records completed closeout authority before the public cleanup projection and clears retained worktree state only after the obsolete PR comment/close path succeeds. 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 b90928b04..39f0d2784 100644 --- a/plugins/decodex/skills/decodex-ops/SKILL.md +++ b/plugins/decodex/skills/decodex-ops/SKILL.md @@ -46,12 +46,15 @@ service labels, recovery, or lane-control details matter. `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`, 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 or no matching latest - attempt row exists; explicit stale-lane recovery must clear or classify that - evidence first. The + 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 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 From 937a8b23a852f88d6ae55633012b5f7dba09ec15 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Thu, 9 Jul 2026 16:58:21 -0400 Subject: [PATCH 15/16] {"schema":"decodex/commit/2","change":"Make superseded closeout retries authority-safe","authority":"XY-1248","impact":"compatible"} --- apps/decodex/src/recovery/closeout/apply.rs | 104 ++++- .../src/recovery/closeout/validation.rs | 4 +- .../closeout/validation/superseded.rs | 428 +++++++++++++++++- openwiki/specs/contracts-and-data.md | 2 +- .../workflows/runtime-operator-workflows.md | 2 +- plugins/decodex/skills/decodex-ops/SKILL.md | 14 +- 6 files changed, 529 insertions(+), 25 deletions(-) diff --git a/apps/decodex/src/recovery/closeout/apply.rs b/apps/decodex/src/recovery/closeout/apply.rs index 0e2ef8ad4..b39f54a7b 100644 --- a/apps/decodex/src/recovery/closeout/apply.rs +++ b/apps/decodex/src/recovery/closeout/apply.rs @@ -165,6 +165,7 @@ pub(super) fn apply_superseded_closeout_recovery( trait SupersededCloseoutRecoveryOperationsRunner { fn ensure_terminalizable(&mut self) -> Result<()>; + fn ensure_run_attempt_recorded(&mut self) -> Result<()>; fn update_issue_state(&mut self) -> Result<()>; fn write_closeout_event(&mut self) -> Result; fn write_cleanup_event(&mut self) -> Result; @@ -179,12 +180,13 @@ 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")?; let closeout_recorded = operations.write_closeout_event()?; operations.post_pull_request_comment()?; let pr_closed = operations.close_pull_request_if_open()?; - operations.update_issue_state()?; operations.record_lifecycle_authority("completed")?; + operations.update_issue_state()?; let cleanup_recorded = operations.write_cleanup_event()?; operations.update_run_status()?; operations.clear_worktree()?; @@ -199,9 +201,15 @@ struct SupersededCloseoutRecoveryOperations<'a> { impl SupersededCloseoutRecoveryOperationsRunner for SupersededCloseoutRecoveryOperations<'_> { fn ensure_terminalizable(&mut self) -> Result<()> { - super::validation::ensure_superseded_issue_terminalizable( - self.context, - &self.validation.issue, + 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, ) } @@ -294,6 +302,24 @@ impl SupersededCloseoutRecoveryOperationsRunner for SupersededCloseoutRecoveryOp } } +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; @@ -311,7 +337,7 @@ mod tests { use super::{ MergedCloseoutRecoveryOperationsRunner, SupersededCloseoutRecoveryOperationsRunner, apply_merged_closeout_recovery_sequence, apply_superseded_closeout_recovery_sequence, - write_recovery_closeout_event, + ensure_superseded_closeout_run_attempt, write_recovery_closeout_event, }; #[derive(Default)] @@ -379,6 +405,10 @@ mod tests { self.record("ensure_terminalizable") } + fn ensure_run_attempt_recorded(&mut self) -> Result<()> { + self.record("ensure_run_attempt_recorded") + } + fn update_issue_state(&mut self) -> Result<()> { self.record("update_issue_state") } @@ -628,12 +658,13 @@ mod tests { operations.steps.into_inner(), vec![ "ensure_terminalizable", + "ensure_run_attempt_recorded", "record_lifecycle_authority_pending", "write_closeout_event", "post_pull_request_comment", "close_pull_request_if_open", - "update_issue_state", "record_lifecycle_authority_completed", + "update_issue_state", "write_cleanup_event", "update_run_status", "clear_worktree", @@ -654,7 +685,11 @@ mod tests { assert!(error.to_string().contains("record_lifecycle_authority_pending failed")); assert_eq!( operations.steps.into_inner(), - vec!["ensure_terminalizable", "record_lifecycle_authority_pending",] + vec![ + "ensure_terminalizable", + "ensure_run_attempt_recorded", + "record_lifecycle_authority_pending", + ] ); } @@ -701,12 +736,67 @@ mod tests { operations.steps.into_inner(), vec![ "ensure_terminalizable", + "ensure_run_attempt_recorded", "record_lifecycle_authority_pending", "write_closeout_event", "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( diff --git a/apps/decodex/src/recovery/closeout/validation.rs b/apps/decodex/src/recovery/closeout/validation.rs index 393794ff4..5ae676747 100644 --- a/apps/decodex/src/recovery/closeout/validation.rs +++ b/apps/decodex/src/recovery/closeout/validation.rs @@ -2,7 +2,9 @@ mod legacy; mod merged; mod superseded; -pub(in crate::recovery::closeout) use self::superseded::ensure_superseded_issue_terminalizable; +pub(in crate::recovery::closeout) use self::superseded::{ + ensure_superseded_closeout_run_attempt_compatible, ensure_superseded_issue_terminalizable, +}; 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/superseded.rs b/apps/decodex/src/recovery/closeout/validation/superseded.rs index b8c7c8531..a25ef06d2 100644 --- a/apps/decodex/src/recovery/closeout/validation/superseded.rs +++ b/apps/decodex/src/recovery/closeout/validation/superseded.rs @@ -110,8 +110,14 @@ pub(in crate::recovery) fn validate_superseded_closeout_request( } 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, + )?; - Ok(SupersededCloseoutValidation { + let validation = SupersededCloseoutValidation { issue, successor_issue, branch_name: worktree_mapping.branch_name().to_owned(), @@ -122,7 +128,35 @@ pub(in crate::recovery) fn validate_superseded_closeout_request( 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<()> { @@ -152,8 +186,6 @@ fn validate_superseded_issue_context( tracker_policy.opt_out_label() ); } - ensure_superseded_issue_terminalizable(context, issue)?; - issue .team .states @@ -171,21 +203,73 @@ fn validate_superseded_issue_context( pub(in crate::recovery::closeout) fn ensure_superseded_issue_terminalizable( context: &RecoveryContext, - issue: &TrackerIssue, + validation: &SupersededCloseoutValidation, ) -> Result<()> { - ensure_superseded_issue_terminalizable_with_tracker(context, &context.tracker, issue) + 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) + 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( @@ -217,6 +301,7 @@ where 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( @@ -246,7 +331,7 @@ fn ensure_issue_has_no_live_runtime_ownership( !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.", @@ -272,6 +357,7 @@ fn ensure_issue_has_no_live_runtime_ownership( issue, &attempt, &private_events, + retry_lineage, )?; } } @@ -304,6 +390,7 @@ fn ensure_run_attempt_has_no_durable_runtime_evidence( 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(); @@ -314,7 +401,7 @@ fn ensure_run_attempt_has_no_durable_runtime_evidence( if let Some(event) = private_events.iter().find(|event| { event.event_type() == "progress_checkpoint" - || !superseded_private_event_allows_terminalization(event) + || !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.", @@ -340,13 +427,18 @@ fn ensure_run_attempt_has_no_durable_runtime_evidence( Ok(()) } -fn superseded_private_event_allows_terminalization(event: &state::PrivateExecutionEvent) -> bool { +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") @@ -371,6 +463,79 @@ fn superseded_private_event_allows_terminalization(event: &state::PrivateExecuti ) } +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>, @@ -653,6 +818,25 @@ mod tests { 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"); @@ -783,6 +967,156 @@ mod tests { } } + #[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"); @@ -1427,6 +1761,80 @@ mod tests { 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, diff --git a/openwiki/specs/contracts-and-data.md b/openwiki/specs/contracts-and-data.md index 800c7da90..2c0e69a2a 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. 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 explicit predecessor/successor issue and 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 public cleanup projection after the obsolete PR is commented/closed. 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. +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 explicit predecessor/successor issue and 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 after the obsolete PR is commented/closed. 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. 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 f179d46aa..e918297e0 100644 --- a/openwiki/workflows/runtime-operator-workflows.md +++ b/openwiki/workflows/runtime-operator-workflows.md @@ -148,7 +148,7 @@ 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 the obsolete and successor issues, 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, then records completed closeout authority before the public cleanup projection and clears retained worktree state only after the obsolete PR comment/close path succeeds. 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. +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 the obsolete and successor issues, 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. After the obsolete PR comment/close path succeeds, apply records completed closeout authority before moving the obsolete Linear issue to its terminal state or publishing cleanup, and clears retained worktree state only after those projections succeed. 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 39f0d2784..8958e6561 100644 --- a/plugins/decodex/skills/decodex-ops/SKILL.md +++ b/plugins/decodex/skills/decodex-ops/SKILL.md @@ -56,11 +56,15 @@ service labels, recovery, or lane-control details matter. after the independent channel, attempt, marker, protocol, activity, and progress guards pass. 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, then records completed closeout authority before public - cleanup projection and retained worktree cleanup. Closeout recovery retries - any local ledger duplicate whose matching public Linear comment is absent. + 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. After the obsolete PR comment and 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. From 2377fb44a3c89b7ef8aa46714472d972191bd522 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Thu, 9 Jul 2026 21:39:26 -0400 Subject: [PATCH 16/16] {"schema":"decodex/commit/2","change":"Harden superseded closeout lineage","authority":"XY-1248","impact":"compatible"} --- apps/decodex/src/recovery/closeout/apply.rs | 140 +++++++++++++++- .../src/recovery/closeout/validation.rs | 1 + .../closeout/validation/superseded.rs | 155 ++++++++++++++++++ apps/decodex/src/tracker/linear/client.rs | 1 + .../src/tracker/linear/client/relations.rs | 109 ++++++++++++ .../src/tracker/linear/issue_tracker.rs | 8 + apps/decodex/src/tracker/linear/queries.rs | 3 +- .../src/tracker/linear/queries/issue.rs | 2 + .../tracker/linear/queries/issue/relations.rs | 41 +++++ apps/decodex/src/tracker/linear/schema.rs | 9 +- .../src/tracker/linear/schema/issue.rs | 48 ++++++ apps/decodex/src/tracker/types.rs | 9 + openwiki/specs/contracts-and-data.md | 2 +- .../workflows/runtime-operator-workflows.md | 2 +- plugins/decodex/skills/decodex-ops/SKILL.md | 12 +- 15 files changed, 529 insertions(+), 13 deletions(-) create mode 100644 apps/decodex/src/tracker/linear/client/relations.rs create mode 100644 apps/decodex/src/tracker/linear/queries/issue/relations.rs diff --git a/apps/decodex/src/recovery/closeout/apply.rs b/apps/decodex/src/recovery/closeout/apply.rs index b39f54a7b..7ef8bb99f 100644 --- a/apps/decodex/src/recovery/closeout/apply.rs +++ b/apps/decodex/src/recovery/closeout/apply.rs @@ -166,6 +166,8 @@ pub(super) fn apply_superseded_closeout_recovery( 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; @@ -182,13 +184,20 @@ fn apply_superseded_closeout_recovery_sequence( 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)) @@ -213,6 +222,32 @@ impl SupersededCloseoutRecoveryOperationsRunner for SupersededCloseoutRecoveryOp ) } + 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 @@ -387,12 +422,17 @@ mod tests { 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<()> { - self.steps.borrow_mut().push(step); + 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) { + if self.fail_at == Some(step) || self.fail_on_occurrence == Some((step, occurrence)) { eyre::bail!("{step} failed"); } @@ -409,6 +449,14 @@ mod tests { 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") } @@ -660,18 +708,104 @@ mod tests { "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 { @@ -738,7 +872,9 @@ mod tests { "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", ] ); diff --git a/apps/decodex/src/recovery/closeout/validation.rs b/apps/decodex/src/recovery/closeout/validation.rs index 5ae676747..6fecbd139 100644 --- a/apps/decodex/src/recovery/closeout/validation.rs +++ b/apps/decodex/src/recovery/closeout/validation.rs @@ -4,6 +4,7 @@ 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, diff --git a/apps/decodex/src/recovery/closeout/validation/superseded.rs b/apps/decodex/src/recovery/closeout/validation/superseded.rs index a25ef06d2..ca34225af 100644 --- a/apps/decodex/src/recovery/closeout/validation/superseded.rs +++ b/apps/decodex/src/recovery/closeout/validation/superseded.rs @@ -37,6 +37,7 @@ pub(in crate::recovery) fn validate_superseded_closeout_request( 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)?; @@ -173,6 +174,81 @@ fn validate_same_tracker_team(issue: &TrackerIssue, successor_issue: &TrackerIss ) } +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, @@ -1752,6 +1828,66 @@ mod tests { .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"); @@ -2057,6 +2193,7 @@ Test workflow. struct TestTracker { issues: Vec, comments: Vec, + related_issue_pairs: Vec<(String, String)>, state_updates: RefCell>, label_removals: RefCell)>>, } @@ -2066,6 +2203,7 @@ Test workflow. Self { issues, comments: Vec::new(), + related_issue_pairs: Vec::new(), state_updates: RefCell::new(Vec::new()), label_removals: RefCell::new(Vec::new()), } @@ -2076,6 +2214,12 @@ Test workflow. 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 { @@ -2112,6 +2256,17 @@ Test workflow. 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())); 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 2c0e69a2a..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. 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 explicit predecessor/successor issue and 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 after the obsolete PR is commented/closed. 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. 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. +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 e918297e0..e9b951fcd 100644 --- a/openwiki/workflows/runtime-operator-workflows.md +++ b/openwiki/workflows/runtime-operator-workflows.md @@ -148,7 +148,7 @@ 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 the obsolete and successor issues, 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. After the obsolete PR comment/close path succeeds, apply records completed closeout authority before moving the obsolete Linear issue to its terminal state or publishing cleanup, and clears retained worktree state only after those projections succeed. 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. +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 8958e6561..0faf1f642 100644 --- a/plugins/decodex/skills/decodex-ops/SKILL.md +++ b/plugins/decodex/skills/decodex-ops/SKILL.md @@ -55,12 +55,16 @@ service labels, recovery, or lane-control details matter. classify that evidence first. Shaped historical telemetry is non-owning only after the independent channel, attempt, marker, protocol, activity, and progress guards pass. 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 + 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. After the obsolete PR comment and close path + 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