oc_chat
Loading...
Searching...
No Matches
state_machine.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/registry/round/state_machine.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by oc_chat/tests/registry/test_round_close_e2e.py"
6"""
7Seven-phase round-close state machine and step driver.
8
9Purpose
10-------
11Sequence the round-close phases (open -> merge xN -> test -> lint ->
12consolidate -> ff_main -> cleanup), record durable per-step state in
13round_jobs / round_job_steps, and make every step resumable.
14
15Responsibilities
16----------------
17- Build the ordered step plan for a round from its plan set.
18- Drive steps idempotently; skip steps already recorded ``done``.
19- Persist step status, post-step HEAD, and input hashes for resume.
20- Surface RoundEscalation as a paused/failed job, not a crash.
21
22Diagnostics
23-----------
24Domain: OC_CHAT.MAIN
25Levels:
26 L2 - phase transitions
27 L3 - step bookkeeping
28 L4 - deep tracing
29
30Contracts
31---------
32- The driver writes round_job_steps; executors never do.
33- A step recorded ``done`` is never re-executed on resume.
34- Interactive abort runs round.abort and stops cleanly.
35
36Dependencies
37------------
38- oc_diagnostics (structured debug logging via _dbg)
39- oc_chat.registry.round.executors (the per-phase run_* executors and the PlanSpec, RoundContext and StepOutcome types)
40- oc_chat.registry.round.escalation (RoundEscalation caught and recorded as a failed step and job, and the Escalation record on the result)
41"""
42from __future__ import annotations
43
44from dataclasses import dataclass, field
45import json
46import uuid
47from typing import Callable
48
49from oc_diagnostics import _dbg
50
51from oc_chat.registry.round import executors as ex
52from oc_chat.registry.round.escalation import Escalation, RoundEscalation
53from oc_chat.registry.round.executors import PlanSpec, RoundContext, StepOutcome
54
55
56_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
57
58PHASE_ORDER = ("open", "merge", "test", "lint", "consolidate", "ff_main", "cleanup")
59TERMINAL_PHASE = "cleanup"
60
61
62@dataclass(frozen=True)
63class StepDef:
64 """A single position in the round-close step plan."""
65
66 step_no: int
67 step_name: str
68 phase: str
69 plan_spec: PlanSpec | None = None
70
71
72@dataclass
74 """The outcome of driving a round-close job to a stopping point."""
75
76 job_id: str
77 status: str
78 completed_steps: list[str] = field(default_factory=list)
79 escalation: Escalation | None = None
80
81
82Prompter = Callable[[StepDef, RoundContext], str]
83
84
85def build_step_plan(ctx: RoundContext) -> list[StepDef]:
86 """Build the ordered step plan for ``ctx``'s round."""
87
88 func = "build_step_plan"
89 steps: list[StepDef] = []
90 step_no = 1
91 steps.append(StepDef(step_no, "open", "open"))
92 step_no += 1
93 for spec in sorted(ctx.plan_specs, key=lambda s: s.merge_order):
94 steps.append(StepDef(step_no, f"merge:{spec.plan_id}", "merge", spec))
95 step_no += 1
96 for phase in ("test", "lint", "consolidate", "ff_main", "cleanup"):
97 steps.append(StepDef(step_no, phase, phase))
98 step_no += 1
99 _dbg("OC_CHAT.MAIN", func, f"{len(steps)} steps", 4)
100 return steps
101
102
103def create_close_job(ctx: RoundContext, *, create_staging: bool = True) -> str:
104 """Create the round-job row and staging branch; return the job id."""
105
106 func = "create_close_job"
107 # The round_jobs row carries an FK to rounds(round_no); ensure the round
108 # row exists first. round.open is idempotent (ON CONFLICT DO NOTHING).
109 ctx.registry.round_open(
110 round_no=ctx.round_no,
111 orchestrator_slot=ctx.actor_slot,
112 actor_slot=ctx.actor_slot,
113 actor_type=ctx.actor_type,
114 )
115 if create_staging and not ctx.git.branch_exists(ctx.staging_branch):
116 ctx.git.create_branch(ctx.staging_branch, ctx.base_ref)
117 head_sha = (
118 ctx.git.rev_parse(ctx.staging_branch)
119 if ctx.git.branch_exists(ctx.staging_branch)
120 else None
121 )
122 ctx.registry.backend.create_round_job(
123 job_id=ctx.job_id,
124 round_no=ctx.round_no,
125 created_by=ctx.actor_slot,
126 staging_branch_head_sha=head_sha,
127 )
128 _dbg("OC_CHAT.MAIN", func, f"job {ctx.job_id} head={head_sha}", 2)
129 return ctx.job_id
130
131
132def new_job_id() -> str:
133 """Return a fresh round-close job id (UUIDv4)."""
134
135 return str(uuid.uuid4())
136
137
139 ctx: RoundContext,
140 *,
141 interactive: bool = False,
142 prompter: Prompter | None = None,
143 create_staging: bool = True,
144) -> RoundCloseResult:
145 """Drive a fresh round-close from the first step to a stopping point."""
146
147 func = "run_round_close"
148 create_close_job(ctx, create_staging=create_staging)
149 _dbg("OC_CHAT.MAIN", func, f"begin round {ctx.round_no}", 2)
150 return drive_steps(
151 ctx,
152 start_step=1,
153 interactive=interactive,
154 prompter=prompter,
155 )
156
157
159 ctx: RoundContext,
160 *,
161 start_step: int,
162 interactive: bool = False,
163 prompter: Prompter | None = None,
164) -> RoundCloseResult:
165 """Execute the step plan from ``start_step`` onward, persisting state."""
166
167 func = "drive_steps"
168 backend = ctx.registry.backend
169 steps = build_step_plan(ctx)
170 existing = {int(row["step_no"]): row for row in backend.list_round_job_steps(ctx.job_id)}
171 completed: list[str] = []
172 for step in steps:
173 prior = existing.get(step.step_no)
174 if step.step_no < start_step or (prior and str(prior["status"]) == "done"):
175 if prior and str(prior["status"]) == "done":
176 completed.append(step.step_name)
177 continue
178 if interactive and prompter is not None:
179 decision = prompter(step, ctx)
180 if decision == "abort":
181 ex.run_abort(ctx, reason="operator abort at phase prompt")
182 backend.set_round_job_status(
183 ctx.job_id, status="rolled_back", current_step=step.step_name
184 )
185 _dbg("OC_CHAT.MAIN", func, "operator aborted", 2)
186 return RoundCloseResult(ctx.job_id, "aborted", completed)
187 result = _run_one_step(ctx, step, prior)
188 if result is not None:
189 return result
190 completed.append(step.step_name)
191 backend.set_round_job_status(
192 ctx.job_id, status="done", current_step=TERMINAL_PHASE
193 )
194 _dbg("OC_CHAT.MAIN", func, f"round {ctx.round_no} closed", 2)
195 return RoundCloseResult(ctx.job_id, "done", completed)
196
197
199 ctx: RoundContext,
200 step: StepDef,
201 prior: dict[str, object] | None,
202) -> RoundCloseResult | None:
203 func = "_run_one_step"
204 backend = ctx.registry.backend
205 now = ctx.registry.clock.now_wall()
206 backend.upsert_round_job_step(
207 job_id=ctx.job_id,
208 step_no=step.step_no,
209 step_name=step.step_name,
210 status="running",
211 started_at=now,
212 )
213 backend.set_round_job_status(
214 ctx.job_id, status="running", current_step=step.step_name
215 )
216 prior_hash = None if prior is None else _optional_text(prior.get("input_hash"))
217 try:
218 outcome = _execute_step(ctx, step, prior_hash)
219 except RoundEscalation as esc:
220 backend.upsert_round_job_step(
221 job_id=ctx.job_id,
222 step_no=step.step_no,
223 step_name=step.step_name,
224 status="failed",
225 error_summary=esc.escalation.message,
226 finished_at=ctx.registry.clock.now_wall(),
227 )
228 backend.set_round_job_status(
229 ctx.job_id, status="failed", current_step=step.step_name
230 )
231 _dbg("OC_CHAT.MAIN", func, f"escalation {esc.escalation.code.value}", 2)
232 return RoundCloseResult(
233 ctx.job_id,
234 "failed",
235 [],
236 escalation=esc.escalation,
237 )
238 artifacts_json = (
239 json.dumps(list(outcome.artifact_paths)) if outcome.artifact_paths else None
240 )
241 backend.upsert_round_job_step(
242 job_id=ctx.job_id,
243 step_no=step.step_no,
244 step_name=step.step_name,
245 status="done",
246 input_hash=outcome.input_hash,
247 post_step_head_sha=outcome.post_step_head_sha,
248 result_summary=outcome.result_summary,
249 artifact_paths_json=artifacts_json,
250 finished_at=ctx.registry.clock.now_wall(),
251 )
252 if outcome.post_step_head_sha:
253 backend.set_round_job_head_sha(ctx.job_id, outcome.post_step_head_sha)
254 _dbg("OC_CHAT.MAIN", func, f"step {step.step_name} {outcome.status}", 3)
255 return None
256
257
259 ctx: RoundContext,
260 step: StepDef,
261 prior_input_hash: str | None,
262) -> StepOutcome:
263 func = "_execute_step"
264 _dbg("OC_CHAT.MAIN", func, f"exec {step.phase}", 4)
265 if step.phase == "open":
266 return ex.run_open(ctx)
267 if step.phase == "merge":
268 assert step.plan_spec is not None
269 return ex.run_merge(ctx, step.plan_spec)
270 if step.phase == "test":
271 return ex.run_test(ctx, prior_input_hash)
272 if step.phase == "lint":
273 return ex.run_lint(ctx, prior_input_hash)
274 if step.phase == "consolidate":
275 return ex.run_consolidate(ctx)
276 if step.phase == "ff_main":
277 return ex.run_ff_main(ctx)
278 if step.phase == "cleanup":
279 return ex.run_cleanup(ctx)
280 raise ValueError(f"unknown phase: {step.phase}")
281
282
283def _optional_text(value: object) -> str | None:
284 if value is None:
285 return None
286 return str(value)
RoundCloseResult run_round_close(RoundContext ctx, *, bool interactive=False, Prompter|None prompter=None, bool create_staging=True)
StepOutcome _execute_step(RoundContext ctx, StepDef step, str|None prior_input_hash)
RoundCloseResult drive_steps(RoundContext ctx, *, int start_step, bool interactive=False, Prompter|None prompter=None)
list[StepDef] build_step_plan(RoundContext ctx)
RoundCloseResult|None _run_one_step(RoundContext ctx, StepDef step, dict[str, object]|None prior)
str create_close_job(RoundContext ctx, *, bool create_staging=True)
str|None _optional_text(object value)