126 """Shared inputs for the round-close executors."""
134 plan_specs: list[PlanSpec]
135 actor_type: str =
"orchestrator_agent"
136 main_branch: str =
"main"
137 base_ref: str =
"main"
138 conflict_policy: ConflictPolicy = field(default_factory=ConflictPolicy)
139 test_runner: TestRunner |
None =
None
140 lint_runner: LintRunner |
None =
None
141 roadmap_bumper: RoadmapBumper |
None =
None
142 roadmap_dir: str |
None =
None
143 allow_untracked_roadmap_edits: bool =
False
144 app_version: str |
None =
None
145 telemetry: dict[str, object] |
None =
None
157 """Open the round: one ``round.open`` audit row per plan (SF-001)."""
160 round_obj = ctx.registry.backend.get_round(ctx.round_no)
161 if round_obj
is not None and str(round_obj.get(
"status"))
in {
"closed",
"aborted"}:
164 EscalationCode.ROUND_STATE,
165 round_no=ctx.round_no,
167 message=f
"round {ctx.round_no} is {round_obj.get('status')}",
170 for spec
in sorted(ctx.plan_specs, key=
lambda s: s.merge_order):
171 existing = ctx.registry.backend.get_round_plan(ctx.round_no, spec.plan_id)
172 if existing
is not None:
173 _dbg(
"OC_CHAT.MAIN", func, f
"plan {spec.plan_id} already open", 3)
175 ctx.registry.round_open(
176 round_no=ctx.round_no,
177 orchestrator_slot=ctx.actor_slot,
178 plan_id=spec.plan_id,
179 merge_order=spec.merge_order,
180 actor_slot=ctx.actor_slot,
181 actor_type=ctx.actor_type,
183 if not ctx.plan_specs
and round_obj
is None:
184 ctx.registry.round_open(
185 round_no=ctx.round_no,
186 orchestrator_slot=ctx.actor_slot,
187 actor_slot=ctx.actor_slot,
188 actor_type=ctx.actor_type,
190 _dbg(
"OC_CHAT.MAIN", func, f
"round {ctx.round_no} open", 2)
194 result_summary=f
"opened round {ctx.round_no} with {len(ctx.plan_specs)} plans",
198def run_merge(ctx: RoundContext, spec: PlanSpec) -> StepOutcome:
199 """Merge one plan branch into staging (idempotent on merge_commit_sha)."""
202 plan_row = ctx.registry.backend.get_round_plan(ctx.round_no, spec.plan_id)
203 if plan_row
is not None and plan_row.get(
"merge_commit_sha"):
204 _dbg(
"OC_CHAT.MAIN", func, f
"plan {spec.plan_id} merged already", 2)
206 step_name=f
"merge:{spec.plan_id}",
209 result_summary=f
"{spec.plan_id} already merged",
211 ctx.git.checkout(ctx.staging_branch)
212 base_sha = ctx.git.rev_parse(ctx.staging_branch)
213 result = ctx.git.merge(
216 message=f
"[round-{ctx.round_no}] merge {spec.plan_id}",
220 if not self_resolved:
221 ctx.git.merge_abort()
224 EscalationCode.MERGE_CONFLICT,
225 round_no=ctx.round_no,
228 f
"merge of {spec.plan_id} conflicted on "
229 f
"{', '.join(result.conflicted_paths) or 'unknown'}"
231 resume_command=resume_command_for(ctx.round_no),
233 "plan_id": spec.plan_id,
234 "conflicted_paths": list(result.conflicted_paths),
240 head_sha = result.head_sha
241 ctx.registry.round_merge(
242 round_no=ctx.round_no,
243 plan_id=spec.plan_id,
244 merge_order=spec.merge_order,
245 merge_commit_sha=head_sha,
247 staging_branch=ctx.staging_branch,
248 merge_base_sha=base_sha,
249 actor_slot=ctx.actor_slot,
250 actor_type=ctx.actor_type,
252 _dbg(
"OC_CHAT.MAIN", func, f
"merged {spec.plan_id} -> {head_sha[:8]}", 2)
254 step_name=f
"merge:{spec.plan_id}",
256 post_step_head_sha=head_sha,
257 result_summary=f
"merged {spec.plan_id}",
264 conflicted_paths: Sequence[str],
266 func =
"_try_resolve_conflict"
267 decision = ctx.conflict_policy.classify(conflicted_paths)
268 if decision.strategy
is ConflictStrategy.ABORT:
269 if decision.blocking_paths:
270 _dbg(
"OC_CHAT.MAIN", func,
"blocking conflict", 2)
272 if not decision.resolvable_paths:
274 for path
in decision.resolvable_paths:
275 ctx.git.checkout_path(
"current", path)
277 ctx.git.run(
"commit",
"--no-edit")
278 except Exception
as exc:
280 round_no=ctx.round_no,
281 plan_id=spec.plan_id,
282 conflicted_paths=tuple(conflicted_paths),
284 _dbg(
"OC_CHAT.MAIN", func, f
"auto-resolved {len(decision.resolvable_paths)}", 2)
288def run_test(ctx: RoundContext, prior_input_hash: str |
None =
None) -> StepOutcome:
289 """Run the full suite; idempotent on the staging+args input hash."""
293 plan_ids = sorted(spec.plan_id
for spec
in ctx.plan_specs)
294 input_hash =
_hash_inputs(
"test", staging_sha, plan_ids)
295 if prior_input_hash
is not None and prior_input_hash == input_hash:
296 _dbg(
"OC_CHAT.MAIN", func,
"test input unchanged; skip", 2)
300 post_step_head_sha=staging_sha,
301 input_hash=input_hash,
302 result_summary=
"tests already green for this staging head",
304 if ctx.test_runner
is None:
306 round_no=ctx.round_no,
307 message=
"no test_runner configured for round.test",
309 result = ctx.test_runner(ctx)
310 if not result.passed:
313 EscalationCode.TEST_FAILED,
314 round_no=ctx.round_no,
316 message=
"full test suite failed on staging",
317 resume_command=resume_command_for(ctx.round_no),
318 details={
"artifact_paths": list(result.artifact_paths)},
321 ctx.registry.round_test(
322 round_no=ctx.round_no,
323 test_count_before=result.count_before,
324 test_count_after=result.count_after,
325 input_hash=input_hash,
327 duration_seconds=result.duration_seconds,
328 plan_results=list(result.plan_results),
329 artifact_paths=list(result.artifact_paths),
330 actor_slot=ctx.actor_slot,
331 actor_type=ctx.actor_type,
333 _dbg(
"OC_CHAT.MAIN", func,
"tests passed", 2)
337 post_step_head_sha=staging_sha,
338 input_hash=input_hash,
339 result_summary=f
"tests passed ({result.count_after})",
340 artifact_paths=tuple(result.artifact_paths),
344def run_lint(ctx: RoundContext, prior_input_hash: str |
None =
None) -> StepOutcome:
345 """Run strict lint over staging; idempotent on the staging+args hash."""
350 if prior_input_hash
is not None and prior_input_hash == input_hash:
351 _dbg(
"OC_CHAT.MAIN", func,
"lint input unchanged; skip", 2)
355 post_step_head_sha=staging_sha,
356 input_hash=input_hash,
357 result_summary=
"lint already clean for this staging head",
359 if ctx.lint_runner
is None:
361 round_no=ctx.round_no,
362 message=
"no lint_runner configured for round.lint",
364 result = ctx.lint_runner(ctx)
365 if not result.passed:
368 EscalationCode.LINT_FAILED,
369 round_no=ctx.round_no,
371 message=f
"strict lint failed: {result.errors} errors",
372 resume_command=resume_command_for(ctx.round_no),
374 "errors": result.errors,
375 "warnings": result.warnings,
376 "artifact_paths": list(result.artifact_paths),
380 ctx.registry.round_lint(
381 round_no=ctx.round_no,
382 staging_sha=staging_sha,
383 errors=result.errors,
384 warnings=result.warnings,
385 input_hash=input_hash,
387 artifact_paths=list(result.artifact_paths),
388 actor_slot=ctx.actor_slot,
389 actor_type=ctx.actor_type,
391 _dbg(
"OC_CHAT.MAIN", func,
"lint passed", 2)
395 post_step_head_sha=staging_sha,
396 input_hash=input_hash,
397 result_summary=f
"lint clean ({result.warnings} warnings)",
398 artifact_paths=tuple(result.artifact_paths),
403 """Record the staging HEAD as final_commit_sha; flip to committed."""
405 func =
"run_consolidate"
406 round_obj = ctx.registry.backend.get_round(ctx.round_no)
408 if round_obj
is not None and str(round_obj.get(
"status")) ==
"committed":
409 _dbg(
"OC_CHAT.MAIN", func,
"already committed; skip", 2)
411 step_name=
"consolidate",
413 post_step_head_sha=final_sha,
414 result_summary=
"round already committed",
416 ctx.registry.round_consolidate(
417 round_no=ctx.round_no,
418 final_commit_sha=final_sha,
419 actor_slot=ctx.actor_slot,
420 actor_type=ctx.actor_type,
422 _dbg(
"OC_CHAT.MAIN", func, f
"committed {final_sha[:8]}", 2)
424 step_name=
"consolidate",
426 post_step_head_sha=final_sha,
427 result_summary=f
"consolidated at {final_sha[:8]}",
432 """Fast-forward main to the round's final commit (rejects non-FF)."""
435 round_obj = ctx.registry.backend.get_round(ctx.round_no)
436 if round_obj
is None or not round_obj.get(
"final_commit_sha"):
438 round_no=ctx.round_no,
439 message=
"ff_main requires a recorded final_commit_sha",
441 target = str(round_obj[
"final_commit_sha"])
442 current_main = ctx.git.rev_parse(ctx.main_branch)
443 if current_main == target:
444 _dbg(
"OC_CHAT.MAIN", func,
"main already at target; skip", 2)
448 post_step_head_sha=target,
449 result_summary=
"main already fast-forwarded",
451 if not ctx.git.is_ancestor(current_main, target):
453 round_no=ctx.round_no,
454 message=f
"main {current_main[:8]} is not an ancestor of {target[:8]}",
456 new_head = ctx.git.fast_forward(ctx.main_branch, target)
457 ctx.registry.round_ff_main(
458 round_no=ctx.round_no,
459 from_sha=current_main,
461 actor_slot=ctx.actor_slot,
462 actor_type=ctx.actor_type,
464 ctx.git.checkout(ctx.staging_branch)
465 _dbg(
"OC_CHAT.MAIN", func, f
"main -> {new_head[:8]}", 2)
470 result_summary=f
"main fast-forwarded to {new_head[:8]}",
475 """Delete plan branches/worktrees, bump roadmap, close round when clean."""
478 round_obj = ctx.registry.backend.get_round(ctx.round_no)
479 if round_obj
is not None and str(round_obj.get(
"status")) ==
"closed":
480 _dbg(
"OC_CHAT.MAIN", func,
"round already closed; skip", 2)
485 result_summary=
"round already closed",
488 branches_deleted: list[str] = []
489 worktrees_removed: list[str] = []
490 failures: list[str] = []
491 for spec
in ctx.plan_specs:
493 if ctx.git.worktree_remove(spec.worktree, force=
True):
494 worktrees_removed.append(spec.worktree)
496 failures.append(f
"worktree {spec.worktree}")
497 if spec.branch
and ctx.git.branch_exists(spec.branch):
498 if ctx.git.branch_delete(spec.branch, force=
True):
499 branches_deleted.append(spec.branch)
501 failures.append(f
"branch {spec.branch}")
502 roadmap_version: str |
None =
None
503 roadmap_failed =
False
504 if not failures
and ctx.roadmap_bumper
is not None:
506 roadmap_version = ctx.roadmap_bumper(ctx)
507 except Exception
as exc:
508 roadmap_failed =
True
509 failures.append(f
"roadmap bump: {exc}")
511 ctx.registry.round_cleanup(
512 round_no=ctx.round_no,
514 branches_deleted=branches_deleted,
515 worktrees_removed=worktrees_removed,
516 error_summary=
"; ".join(failures),
517 actor_slot=ctx.actor_slot,
518 actor_type=ctx.actor_type,
521 EscalationCode.ROADMAP_BUMP_FAILED
523 else EscalationCode.CLEANUP_FAILED
528 round_no=ctx.round_no,
530 message=
"; ".join(failures),
531 resume_command=f
"oct agent round cleanup {ctx.round_no}",
533 "branches_deleted": branches_deleted,
534 "worktrees_removed": worktrees_removed,
538 ctx.registry.round_cleanup(
539 round_no=ctx.round_no,
541 branches_deleted=branches_deleted,
542 worktrees_removed=worktrees_removed,
543 roadmap_version=roadmap_version,
544 app_version=ctx.app_version,
545 actor_slot=ctx.actor_slot,
546 actor_type=ctx.actor_type,
548 _dbg(
"OC_CHAT.MAIN", func, f
"closed round {ctx.round_no}", 2)
554 f
"deleted {len(branches_deleted)} branches, "
555 f
"{len(worktrees_removed)} worktrees"
561 if ctx.roadmap_dir
is None:
563 result = verify_roadmap_files_unchanged(ctx.registry, ctx.roadmap_dir)
566 drift = [item.to_dict()
for item
in result.drift]
567 if ctx.allow_untracked_roadmap_edits:
568 ctx.registry.execute_domain_command(
573 "kind":
"roadmap_untracked_edit",
574 "round_no": ctx.round_no,
575 "roadmap_dir": ctx.roadmap_dir,
578 actor_type=ctx.actor_type,
579 related_round=ctx.round_no,
582 first = result.drift[0]
585 EscalationCode.ROADMAP_UNTRACKED_EDIT,
586 round_no=ctx.round_no,
589 f
"untracked ROADMAP edit in {first.source_file}: "
590 f
"{first.kind} {first.item_id}"
592 resume_command=f
"oct agent round cleanup {ctx.round_no}",
594 "roadmap_dir": ctx.roadmap_dir,
595 "files": result.files,
607def run_abort(ctx: RoundContext, *, reason: str, drop_staging: bool =
True) -> StepOutcome:
608 """Abort the round: return plans to ready_for_merge and drop staging."""
611 ctx.registry.round_abort(
612 round_no=ctx.round_no,
614 actor_slot=ctx.actor_slot,
615 actor_type=ctx.actor_type,
618 if drop_staging
and ctx.git.branch_exists(ctx.staging_branch):
619 ctx.git.checkout(ctx.base_ref)
620 ctx.git.branch_delete(ctx.staging_branch, force=
True)
621 _dbg(
"OC_CHAT.MAIN", func, f
"aborted round {ctx.round_no}", 2)
625 result_summary=f
"aborted: {reason}",
630 """Roll back a diverged round: drop staging without touching plans."""
632 func =
"run_rollback"
633 ctx.registry.round_abort(
634 round_no=ctx.round_no,
636 actor_slot=ctx.actor_slot,
637 actor_type=ctx.actor_type,
639 if ctx.git.branch_exists(ctx.staging_branch):
640 ctx.git.checkout(ctx.base_ref)
641 ctx.git.branch_delete(ctx.staging_branch, force=
True)
642 _dbg(
"OC_CHAT.MAIN", func, f
"rolled back round {ctx.round_no}", 2)
644 step_name=
"rollback",
646 result_summary=f
"rolled back: {reason}",
651 func =
"_reset_plans_to_ready"
652 connect_func = getattr(ctx.registry.backend,
"connect")
653 with connect_func()
as conn:
655 "SELECT p.plan_id AS plan_id, p.status AS status "
656 "FROM round_plans rp JOIN plans p ON p.plan_id = rp.plan_id "
657 "WHERE rp.round_no = ?",
661 current = str(row[
"status"])
662 if current ==
"ready_for_merge":
664 ctx.registry.plan_set_status(
665 plan_id=str(row[
"plan_id"]),
667 to_status=
"ready_for_merge",
668 source=
"round_lifecycle",
669 note=f
"restored from aborted round {ctx.round_no}",
670 actor_slot=ctx.actor_slot,
671 actor_type=ctx.actor_type,
673 _dbg(
"OC_CHAT.MAIN", func, f
"reset {len(rows)} plans", 3)