Skip to content

Commit 1c74ce6

Browse files
{"schema":"decodex/commit/2","change":"Repair superseded closeout cleanup ordering","authority":"XY-1248","impact":"compatible"}
1 parent 88451b2 commit 1c74ce6

4 files changed

Lines changed: 61 additions & 26 deletions

File tree

apps/decodex/src/recovery/closeout/apply.rs

Lines changed: 56 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ trait SupersededCloseoutRecoveryOperationsRunner {
129129
fn update_issue_state(&mut self) -> Result<()>;
130130
fn write_closeout_event(&mut self) -> Result<bool>;
131131
fn write_cleanup_event(&mut self) -> Result<bool>;
132-
fn record_lifecycle_authority(&mut self) -> Result<()>;
132+
fn record_lifecycle_authority(&mut self, cleanup_state: &'static str) -> Result<()>;
133133
fn update_run_status(&mut self) -> Result<()>;
134134
fn clear_worktree(&mut self) -> Result<()>;
135135
fn post_pull_request_comment(&mut self) -> Result<()>;
@@ -140,14 +140,15 @@ fn apply_superseded_closeout_recovery_sequence(
140140
operations: &mut impl SupersededCloseoutRecoveryOperationsRunner,
141141
) -> Result<(bool, bool, bool)> {
142142
operations.ensure_terminalizable()?;
143-
operations.update_issue_state()?;
144143
let closeout_recorded = operations.write_closeout_event()?;
144+
operations.record_lifecycle_authority("pending")?;
145+
operations.post_pull_request_comment()?;
146+
let pr_closed = operations.close_pull_request_if_open()?;
147+
operations.update_issue_state()?;
145148
let cleanup_recorded = operations.write_cleanup_event()?;
146-
operations.record_lifecycle_authority()?;
149+
operations.record_lifecycle_authority("completed")?;
147150
operations.update_run_status()?;
148151
operations.clear_worktree()?;
149-
operations.post_pull_request_comment()?;
150-
let pr_closed = operations.close_pull_request_if_open()?;
151152

152153
Ok((closeout_recorded, cleanup_recorded, pr_closed))
153154
}
@@ -194,8 +195,8 @@ impl SupersededCloseoutRecoveryOperationsRunner for SupersededCloseoutRecoveryOp
194195
)
195196
}
196197

197-
fn record_lifecycle_authority(&mut self) -> Result<()> {
198-
record_superseded_closeout_lifecycle_authority(self.context, self.validation)
198+
fn record_lifecycle_authority(&mut self, cleanup_state: &'static str) -> Result<()> {
199+
record_superseded_closeout_lifecycle_authority(self.context, self.validation, cleanup_state)
199200
}
200201

201202
fn update_run_status(&mut self) -> Result<()> {
@@ -302,8 +303,12 @@ mod tests {
302303
Ok(true)
303304
}
304305

305-
fn record_lifecycle_authority(&mut self) -> Result<()> {
306-
self.record("record_lifecycle_authority")
306+
fn record_lifecycle_authority(&mut self, cleanup_state: &'static str) -> Result<()> {
307+
match cleanup_state {
308+
"pending" => self.record("record_lifecycle_authority_pending"),
309+
"completed" => self.record("record_lifecycle_authority_completed"),
310+
_ => unreachable!("unsupported test cleanup state"),
311+
}
307312
}
308313

309314
fn update_run_status(&mut self) -> Result<()> {
@@ -337,37 +342,58 @@ mod tests {
337342
operations.steps.into_inner(),
338343
vec![
339344
"ensure_terminalizable",
340-
"update_issue_state",
341345
"write_closeout_event",
346+
"record_lifecycle_authority_pending",
347+
"post_pull_request_comment",
348+
"close_pull_request_if_open",
349+
"update_issue_state",
342350
"write_cleanup_event",
343-
"record_lifecycle_authority",
351+
"record_lifecycle_authority_completed",
344352
"update_run_status",
345353
"clear_worktree",
346-
"post_pull_request_comment",
347-
"close_pull_request_if_open",
348354
]
349355
);
350356
}
351357

352358
#[test]
353-
fn superseded_closeout_does_not_touch_github_when_lifecycle_authority_fails() {
359+
fn superseded_closeout_does_not_terminalize_issue_when_lifecycle_authority_fails() {
354360
let mut operations = RecordingSupersededCloseoutOperations {
355-
fail_at: Some("record_lifecycle_authority"),
361+
fail_at: Some("record_lifecycle_authority_pending"),
356362
..RecordingSupersededCloseoutOperations::default()
357363
};
358364

359365
let error = apply_superseded_closeout_recovery_sequence(&mut operations)
360366
.expect_err("lifecycle authority failure should stop recovery");
361367

362-
assert!(error.to_string().contains("record_lifecycle_authority failed"));
368+
assert!(error.to_string().contains("record_lifecycle_authority_pending failed"));
363369
assert_eq!(
364370
operations.steps.into_inner(),
365371
vec![
366372
"ensure_terminalizable",
367-
"update_issue_state",
368373
"write_closeout_event",
369-
"write_cleanup_event",
370-
"record_lifecycle_authority",
374+
"record_lifecycle_authority_pending",
375+
]
376+
);
377+
}
378+
379+
#[test]
380+
fn superseded_closeout_keeps_cleanup_retryable_when_github_comment_fails() {
381+
let mut operations = RecordingSupersededCloseoutOperations {
382+
fail_at: Some("post_pull_request_comment"),
383+
..RecordingSupersededCloseoutOperations::default()
384+
};
385+
386+
let error = apply_superseded_closeout_recovery_sequence(&mut operations)
387+
.expect_err("GitHub comment failure should stop cleanup");
388+
389+
assert!(error.to_string().contains("post_pull_request_comment failed"));
390+
assert_eq!(
391+
operations.steps.into_inner(),
392+
vec![
393+
"ensure_terminalizable",
394+
"write_closeout_event",
395+
"record_lifecycle_authority_pending",
396+
"post_pull_request_comment",
371397
]
372398
);
373399
}
@@ -554,6 +580,7 @@ fn record_merged_closeout_lifecycle_decision(
554580
fn record_superseded_closeout_lifecycle_authority(
555581
context: &RecoveryContext,
556582
validation: &SupersededCloseoutValidation,
583+
cleanup_state: &'static str,
557584
) -> Result<()> {
558585
let review_level = context.config.codex().review_level();
559586
let checkpoint = runtime_review_checkpoint_status_for_head(
@@ -588,25 +615,31 @@ fn record_superseded_closeout_lifecycle_authority(
588615
next_state: record.next_state(),
589616
});
590617
let idempotency_key = format!(
591-
"{}:{}:{}:{}",
618+
"{}:{}:{}:{}:{}",
592619
context.config.service_id(),
593620
validation.issue.id,
594621
validation.successor_merge_commit,
595-
"superseded_closeout_recovery"
622+
"superseded_closeout_recovery",
623+
cleanup_state
596624
);
625+
let causation_id = match cleanup_state {
626+
"pending" => "superseded_closeout_recovery_pr_close_authorized",
627+
"completed" => "superseded_closeout_recovery_closeout_complete",
628+
_ => "superseded_closeout_recovery_closeout_state",
629+
};
597630
let decided_at = current_timestamp();
598631
let decision = decide_lifecycle_transition(LifecycleDecisionInput {
599632
facts: &facts,
600633
previous,
601634
evidence_kind: LifecycleEvidenceKind::CloseoutCompletion,
602635
outcome: LifecycleOutcome::Succeeded,
603636
merge_commit: Some(&validation.successor_merge_commit),
604-
cleanup_state: Some("completed"),
637+
cleanup_state: Some(cleanup_state),
605638
authority: "issue_authority",
606639
actor: "superseded_closeout_recovery",
607640
idempotency_key: &idempotency_key,
608641
correlation_id: &validation.run_id,
609-
causation_id: Some("superseded_closeout_recovery_closeout_complete"),
642+
causation_id: Some(causation_id),
610643
decided_at: &decided_at,
611644
});
612645

openwiki/specs/contracts-and-data.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ After PR-backed handoff, retained review/landing/closeout lifecycle authority is
139139

140140
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.
141141

142-
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.
142+
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.
143143

144144
## Commit message contract
145145

openwiki/workflows/runtime-operator-workflows.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ decodex recover superseded-closeout ...
135135
```
136136

137137
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`).
138-
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/<default>`, 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.
138+
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/<default>`, 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.
139139

140140
Future agents changing recovery must preserve explicit evidence requirements and add tests under the matching `apps/decodex/src/orchestrator/tests/recovery_*` or `apps/decodex/src/recovery/tests/` area.
141141

plugins/decodex/skills/decodex-ops/SKILL.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,9 @@ service labels, recovery, or lane-control details matter.
4747
before live superseded closeout. The obsolete issue must have no queue, active,
4848
or needs-attention labels or live runtime ownership, and the successor issue
4949
must expose a Decodex ledger record for the exact successor PR head and merge
50-
commit.
50+
commit. Live superseded closeout records close authorization before the obsolete
51+
issue/PR terminal mutation and records cleanup completion only after the PR
52+
comment/close path succeeds.
5153
- Do not infer PR lineage from branch names, PR titles, Linear comments, status
5254
summaries, or stale snapshots.
5355

0 commit comments

Comments
 (0)