Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
041680e
{"schema":"decodex/commit/2","change":"Add superseded retained PR clo…
yvette-carlisle Jul 9, 2026
21fb2a7
{"schema":"decodex/commit/2","change":"Repair superseded closeout rev…
yvette-carlisle Jul 9, 2026
b1eee8b
{"schema":"decodex/commit/2","change":"Repair superseded closeout sid…
yvette-carlisle Jul 9, 2026
43181ae
{"schema":"decodex/commit/2","change":"Document superseded closeout a…
yvette-carlisle Jul 9, 2026
f4572a2
{"schema":"decodex/commit/2","change":"Repair superseded closeout cle…
yvette-carlisle Jul 9, 2026
6239cf4
{"schema":"decodex/commit/2","change":"Repair superseded closeout pen…
yvette-carlisle Jul 9, 2026
17612e5
{"schema":"decodex/commit/2","change":"Reject superseded closeout ret…
yvette-carlisle Jul 9, 2026
14490a8
{"schema":"decodex/commit/2","change":"Reject live retained markers i…
yvette-carlisle Jul 9, 2026
07a2c3f
{"schema":"decodex/commit/2","change":"Reject stale superseded closeo…
yvette-carlisle Jul 9, 2026
9d8cd03
{"schema":"decodex/commit/2","change":"Repair closeout projection ord…
yvette-carlisle Jul 9, 2026
46b6562
{"schema":"decodex/commit/2","change":"Reject stale retained activity…
yvette-carlisle Jul 9, 2026
31aa56c
{"schema":"decodex/commit/2","change":"Reject stale retained activity…
yvette-carlisle Jul 9, 2026
ae560d8
{"schema":"decodex/commit/2","change":"Reject superseded closeout dur…
yvette-carlisle Jul 9, 2026
efad45a
{"schema":"decodex/commit/2","change":"Reject unresolved superseded c…
yvette-carlisle Jul 9, 2026
937a8b2
{"schema":"decodex/commit/2","change":"Make superseded closeout retri…
yvette-carlisle Jul 9, 2026
2377fb4
{"schema":"decodex/commit/2","change":"Harden superseded closeout lin…
yvette-carlisle Jul 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ decodex intake goal --project decodex <CONTRACT_ID> --dry-run
decodex intake goal --project decodex <CONTRACT_ID> --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 <ISSUE> --pr <OLD_PR> --successor-issue <ISSUE> --successor-pr <MERGED_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
Expand Down
23 changes: 21 additions & 2 deletions apps/decodex/src/cli/recovery_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,21 @@ 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,
};
use crate::{
cli::ProjectConfigArgs,
prelude::Result,
recovery::{self, LegacyCloseoutRecoveryRequest, MergedCloseoutRecoveryRequest},
recovery::{
self, LegacyCloseoutRecoveryRequest, MergedCloseoutRecoveryRequest,
SupersededCloseoutRecoveryRequest,
},
};

#[derive(Debug, Args)]
Expand Down Expand Up @@ -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,
},
),
}
}
}
Expand All @@ -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),
}
22 changes: 22 additions & 0 deletions apps/decodex/src/cli/recovery_commands/closeout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
40 changes: 39 additions & 1 deletion apps/decodex/src/cli/tests/recovery/closeout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ use crate::cli::{
Cli, Command,
recovery_commands::{
RecoverCommand, RecoverSubcommand,
closeout::{LegacyCloseoutRecoveryCommand, MergedCloseoutRecoveryCommand},
closeout::{
LegacyCloseoutRecoveryCommand, MergedCloseoutRecoveryCommand,
SupersededCloseoutRecoveryCommand,
},
},
};

Expand Down Expand Up @@ -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"
));
}
2 changes: 2 additions & 0 deletions apps/decodex/src/github.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ mod comments;
mod landing_state;
mod locator;
mod merge_readback;
mod pull_requests;
mod repository;
mod status;

Expand Down Expand Up @@ -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,
};
Expand Down
73 changes: 73 additions & 0 deletions apps/decodex/src/github/pull_requests.rs
Original file line number Diff line number Diff line change
@@ -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"));
}
}
69 changes: 69 additions & 0 deletions apps/decodex/src/orchestrator/kernel/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ pub(crate) enum LifecycleEvidenceKind {
ReviewRepair,
LandingIntent,
LandingReadback,
CloseoutIntent,
CloseoutCompletion,
}
impl LifecycleEvidenceKind {
Expand All @@ -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",
}
}
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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");
}
}
1 change: 1 addition & 0 deletions apps/decodex/src/plugin_surface_tests/decodex_skills.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
5 changes: 4 additions & 1 deletion apps/decodex/src/recovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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";
Expand Down
Loading
Loading