oc_chat
Loading...
Searching...
No Matches
resume.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/registry/round/resume.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by oc_chat/tests/registry/test_round_resume_protocol.py"
6"""
7Resume protocol for crashed or paused round-close jobs.
8
9Purpose
10-------
11Given a round number, find the most recent resumable round-job, verify
12the on-disk staging branch matches recorded state, and dispatch the next
13pending step through the same executors used on the first run (OI-315).
14
15Responsibilities
16----------------
17- Resolve the resumable job and compute the highest completed step.
18- Verify the staging branch HEAD against known-good recorded heads.
19- Emit STAGING_BRANCH_DIVERGED (empty resume_command) on real divergence.
20- Re-enter the state-machine driver at the next pending step.
21
22Diagnostics
23-----------
24Domain: OC_CHAT.MAIN
25Levels:
26 L2 - resume decisions
27 L3 - head comparison details
28 L4 - deep tracing
29
30Contracts
31---------
32- Resume is itself idempotent; re-running after success is a no-op.
33- A diverged staging branch is never auto-merged; rollback is the only path.
34- Crash-after-merge (git done, DB pending) re-attempts the idempotent step.
35
36Dependencies
37------------
38- oc_diagnostics (structured debug logging via _dbg)
39- oc_chat.registry.round.state_machine (build_step_plan and drive_steps - re-entering the driver at the next pending step)
40- oc_chat.registry.round.executors (RoundContext - the shared executor inputs)
41- oc_chat.registry.round.escalation (make_escalation - the STAGING_BRANCH_DIVERGED escalation)
42- oc_chat.registry.round.preflight (preflight_resume - the resume preflight check)
43- oc_chat.registry.errors (PreflightFailedError - raised when the resume preflight fails)
44"""
45from __future__ import annotations
46
47from oc_diagnostics import _dbg
48
49from oc_chat.registry.errors import PreflightFailedError
51 Escalation,
52 EscalationCode,
53 make_escalation,
54)
55from oc_chat.registry.round.executors import RoundContext
56from oc_chat.registry.round.preflight import preflight_resume
58 RoundCloseResult,
59 Prompter,
60 build_step_plan,
61 drive_steps,
62)
63
64
65_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
66
67RESUMABLE_STATUSES = ("running", "paused", "failed")
68
69
71 ctx: RoundContext,
72 *,
73 interactive: bool = False,
74 prompter: Prompter | None = None,
75) -> RoundCloseResult:
76 """Resume the most recent resumable round-job for ``ctx.round_no``."""
77
78 func = "resume_round"
79 backend = ctx.registry.backend
80 job = backend.latest_round_job(ctx.round_no, statuses=RESUMABLE_STATUSES)
81 job_id = None if job is None else str(job["job_id"])
82 preflight = preflight_resume(ctx.registry, round_no=ctx.round_no, job_id=job_id)
83 if not preflight.ok:
84 raise PreflightFailedError(round_no=ctx.round_no, reasons=preflight.reasons)
85 assert job is not None
86 ctx.job_id = str(job["job_id"])
87 steps = build_step_plan(ctx)
88 step_rows = backend.list_round_job_steps(ctx.job_id)
89 done = [row for row in step_rows if str(row["status"]) == "done"]
90 highest_done = max((int(row["step_no"]) for row in done), default=0)
91 start_step = highest_done + 1
92 if start_step > len(steps):
93 backend.set_round_job_status(
94 ctx.job_id, status="done", current_step="cleanup"
95 )
96 _dbg("OC_CHAT.MAIN", func, "nothing left to resume", 2)
97 return RoundCloseResult(ctx.job_id, "done", [s.step_name for s in steps])
98
99 divergence = _check_divergence(
100 ctx,
101 job=job,
102 done_rows=done,
103 next_phase=steps[start_step - 1].phase,
104 )
105 if divergence is not None:
106 backend.set_round_job_status(
107 ctx.job_id, status="failed", current_step=steps[start_step - 1].step_name
108 )
109 _dbg("OC_CHAT.MAIN", func, "staging branch diverged", 2)
110 return RoundCloseResult(ctx.job_id, "failed", [], escalation=divergence)
111
112 _dbg("OC_CHAT.MAIN", func, f"resume from step {start_step}", 2)
113 return drive_steps(
114 ctx,
115 start_step=start_step,
116 interactive=interactive,
117 prompter=prompter,
118 )
119
120
122 ctx: RoundContext,
123 *,
124 job: dict[str, object],
125 done_rows: list[dict[str, object]],
126 next_phase: str,
127) -> Escalation | None:
128 func = "_check_divergence"
129 if not ctx.git.branch_exists(ctx.staging_branch):
130 return _diverged(ctx, expected=_text(job.get("staging_branch_head_sha")), actual=None)
131 actual_head = ctx.git.rev_parse(ctx.staging_branch)
132 expected_head = _text(job.get("staging_branch_head_sha"))
133 known_heads: set[str] = set()
134 if expected_head:
135 known_heads.add(expected_head)
136 for row in done_rows:
137 head = _text(row.get("post_step_head_sha"))
138 if head:
139 known_heads.add(head)
140 if actual_head in known_heads:
141 _dbg("OC_CHAT.MAIN", func, "head matches known state", 3)
142 return None
143 # Crash-after-merge: git advanced HEAD before the DB row was written.
144 if (
145 next_phase == "merge"
146 and expected_head
147 and ctx.git.is_ancestor(expected_head, actual_head)
148 ):
149 _dbg("OC_CHAT.MAIN", func, "crash-after-merge; re-attempt", 2)
150 return None
151 return _diverged(ctx, expected=expected_head, actual=actual_head)
152
153
155 ctx: RoundContext,
156 *,
157 expected: str | None,
158 actual: str | None,
159) -> Escalation:
160 return make_escalation(
161 EscalationCode.STAGING_BRANCH_DIVERGED,
162 round_no=ctx.round_no,
163 phase="resume",
164 message=(
165 f"staging branch {ctx.staging_branch!r} HEAD {actual!r} does not match "
166 f"recorded state {expected!r}; rollback required"
167 ),
168 details={"expected_sha": expected, "actual_sha": actual},
169 )
170
171
172def _text(value: object) -> str | None:
173 if value is None:
174 return None
175 text = str(value)
176 return text or None
RoundCloseResult resume_round(RoundContext ctx, *, bool interactive=False, Prompter|None prompter=None)
Definition resume.py:75
Escalation|None _check_divergence(RoundContext ctx, *, dict[str, object] job, list[dict[str, object]] done_rows, str next_phase)
Definition resume.py:127
str|None _text(object value)
Definition resume.py:172
Escalation _diverged(RoundContext ctx, *, str|None expected, str|None actual)
Definition resume.py:159