Skip to content

Commit f4572a2

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

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
@@ -132,7 +132,7 @@ trait SupersededCloseoutRecoveryOperationsRunner {
132132
fn update_issue_state(&mut self) -> Result<()>;
133133
fn write_closeout_event(&mut self) -> Result<bool>;
134134
fn write_cleanup_event(&mut self) -> Result<bool>;
135-
fn record_lifecycle_authority(&mut self) -> Result<()>;
135+
fn record_lifecycle_authority(&mut self, cleanup_state: &'static str) -> Result<()>;
136136
fn update_run_status(&mut self) -> Result<()>;
137137
fn clear_worktree(&mut self) -> Result<()>;
138138
fn post_pull_request_comment(&mut self) -> Result<()>;
@@ -143,14 +143,15 @@ fn apply_superseded_closeout_recovery_sequence(
143143
operations: &mut impl SupersededCloseoutRecoveryOperationsRunner,
144144
) -> Result<(bool, bool, bool)> {
145145
operations.ensure_terminalizable()?;
146-
operations.update_issue_state()?;
147146
let closeout_recorded = operations.write_closeout_event()?;
147+
operations.record_lifecycle_authority("pending")?;
148+
operations.post_pull_request_comment()?;
149+
let pr_closed = operations.close_pull_request_if_open()?;
150+
operations.update_issue_state()?;
148151
let cleanup_recorded = operations.write_cleanup_event()?;
149-
operations.record_lifecycle_authority()?;
152+
operations.record_lifecycle_authority("completed")?;
150153
operations.update_run_status()?;
151154
operations.clear_worktree()?;
152-
operations.post_pull_request_comment()?;
153-
let pr_closed = operations.close_pull_request_if_open()?;
154155

155156
Ok((closeout_recorded, cleanup_recorded, pr_closed))
156157
}
@@ -197,8 +198,8 @@ impl SupersededCloseoutRecoveryOperationsRunner for SupersededCloseoutRecoveryOp
197198
)
198199
}
199200

200-
fn record_lifecycle_authority(&mut self) -> Result<()> {
201-
record_superseded_closeout_lifecycle_authority(self.context, self.validation)
201+
fn record_lifecycle_authority(&mut self, cleanup_state: &'static str) -> Result<()> {
202+
record_superseded_closeout_lifecycle_authority(self.context, self.validation, cleanup_state)
202203
}
203204

204205
fn update_run_status(&mut self) -> Result<()> {
@@ -305,8 +306,12 @@ mod tests {
305306
Ok(true)
306307
}
307308

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

312317
fn update_run_status(&mut self) -> Result<()> {
@@ -340,37 +345,58 @@ mod tests {
340345
operations.steps.into_inner(),
341346
vec![
342347
"ensure_terminalizable",
343-
"update_issue_state",
344348
"write_closeout_event",
349+
"record_lifecycle_authority_pending",
350+
"post_pull_request_comment",
351+
"close_pull_request_if_open",
352+
"update_issue_state",
345353
"write_cleanup_event",
346-
"record_lifecycle_authority",
354+
"record_lifecycle_authority_completed",
347355
"update_run_status",
348356
"clear_worktree",
349-
"post_pull_request_comment",
350-
"close_pull_request_if_open",
351357
]
352358
);
353359
}
354360

355361
#[test]
356-
fn superseded_closeout_does_not_touch_github_when_lifecycle_authority_fails() {
362+
fn superseded_closeout_does_not_terminalize_issue_when_lifecycle_authority_fails() {
357363
let mut operations = RecordingSupersededCloseoutOperations {
358-
fail_at: Some("record_lifecycle_authority"),
364+
fail_at: Some("record_lifecycle_authority_pending"),
359365
..RecordingSupersededCloseoutOperations::default()
360366
};
361367

362368
let error = apply_superseded_closeout_recovery_sequence(&mut operations)
363369
.expect_err("lifecycle authority failure should stop recovery");
364370

365-
assert!(error.to_string().contains("record_lifecycle_authority failed"));
371+
assert!(error.to_string().contains("record_lifecycle_authority_pending failed"));
366372
assert_eq!(
367373
operations.steps.into_inner(),
368374
vec![
369375
"ensure_terminalizable",
370-
"update_issue_state",
371376
"write_closeout_event",
372-
"write_cleanup_event",
373-
"record_lifecycle_authority",
377+
"record_lifecycle_authority_pending",
378+
]
379+
);
380+
}
381+
382+
#[test]
383+
fn superseded_closeout_keeps_cleanup_retryable_when_github_comment_fails() {
384+
let mut operations = RecordingSupersededCloseoutOperations {
385+
fail_at: Some("post_pull_request_comment"),
386+
..RecordingSupersededCloseoutOperations::default()
387+
};
388+
389+
let error = apply_superseded_closeout_recovery_sequence(&mut operations)
390+
.expect_err("GitHub comment failure should stop cleanup");
391+
392+
assert!(error.to_string().contains("post_pull_request_comment failed"));
393+
assert_eq!(
394+
operations.steps.into_inner(),
395+
vec![
396+
"ensure_terminalizable",
397+
"write_closeout_event",
398+
"record_lifecycle_authority_pending",
399+
"post_pull_request_comment",
374400
]
375401
);
376402
}
@@ -558,6 +584,7 @@ fn record_merged_closeout_lifecycle_decision(
558584
fn record_superseded_closeout_lifecycle_authority(
559585
context: &RecoveryContext,
560586
validation: &SupersededCloseoutValidation,
587+
cleanup_state: &'static str,
561588
) -> Result<()> {
562589
let review_level = context.config.codex().review_level();
563590
let checkpoint = orchestrator::runtime_review_checkpoint_status_for_head(
@@ -592,25 +619,31 @@ fn record_superseded_closeout_lifecycle_authority(
592619
next_state: record.next_state(),
593620
});
594621
let idempotency_key = format!(
595-
"{}:{}:{}:{}",
622+
"{}:{}:{}:{}:{}",
596623
context.config.service_id(),
597624
validation.issue.id,
598625
validation.successor_merge_commit,
599-
"superseded_closeout_recovery"
626+
"superseded_closeout_recovery",
627+
cleanup_state
600628
);
629+
let causation_id = match cleanup_state {
630+
"pending" => "superseded_closeout_recovery_pr_close_authorized",
631+
"completed" => "superseded_closeout_recovery_closeout_complete",
632+
_ => "superseded_closeout_recovery_closeout_state",
633+
};
601634
let decided_at = current_timestamp();
602635
let decision = self::decide_lifecycle_transition(LifecycleDecisionInput {
603636
facts: &facts,
604637
previous,
605638
evidence_kind: LifecycleEvidenceKind::CloseoutCompletion,
606639
outcome: LifecycleOutcome::Succeeded,
607640
merge_commit: Some(&validation.successor_merge_commit),
608-
cleanup_state: Some("completed"),
641+
cleanup_state: Some(cleanup_state),
609642
authority: "issue_authority",
610643
actor: "superseded_closeout_recovery",
611644
idempotency_key: &idempotency_key,
612645
correlation_id: &validation.run_id,
613-
causation_id: Some("superseded_closeout_recovery_closeout_complete"),
646+
causation_id: Some(causation_id),
614647
decided_at: &decided_at,
615648
});
616649

openwiki/specs/contracts-and-data.md

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

151151
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.
152152

153-
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.
153+
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.
154154

155155
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.
156156

openwiki/workflows/runtime-operator-workflows.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ decodex recover superseded-closeout ...
148148
```
149149

150150
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`).
151-
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.
151+
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.
152152

153153
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.
154154

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)