diff --git a/docs/workflows.md b/docs/workflows.md index c35a880..7e03e1d 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -300,6 +300,31 @@ From a toolkit checkout: `python3 scripts/run_cycle.py …` **Local-only rehearsal:** `--cwd /path/to/linked-worktree --workspace current --local-only` refuses the live repository. The explicit `current` policy is required so workspace selection cannot create another task worktree. It runs implementation only, adds a no-publish boundary to the prompt, marks each row `local_only`, and ends with `HUMAN_INTERVENTION` because there is no PR to review. It is a policy, not a network sandbox. Implementation workflows use a task-specific linked Git worktree by default. + +Setup records each resource it creates, including the task worktree, local +branch, fetched PR ref, and setup status or handoff files, with its identity, +provenance and compensating action. Before the first stage dispatch starts, a +failure rolls those resources back in reverse order and collects cleanup +errors in the error message (CLI setup errors still exit with code 2). A +worktree or branch that existed before the run, including one reused by +`--continue` or opened in Herdr, is never owned by this rollback. Herdr-created +Git worktrees are removed through Git's worktree registration. A created PR +ref is removed only while unchanged and unused by a local branch or detached +worktree; otherwise cleanup retains it and reports why. + +The first adapter invocation commits setup permanently. Routing or preflight +failure before that boundary still rolls back; a stage failure or interruption +after it leaves the task workspace available for continuation. With `--detach`, +successful process handoff commits setup and a failed handoff rolls it back. +Shared telemetry and status revision counters remain the record of the attempt. + +The native entry point remains `run_cycle.py`. Its CLI lives in `cycle_cli.py`, +selection in `cycle_workspace.py`, and the stage loop in `cycle_loop.py`, whose +control transition table includes resume entries, retries, CI, stops and +interruptions. Session lifetime, stage dispatch, publication history and review +gates have separate modules. Public names imported from `run_cycle` remain +available; this split preserves the existing stage policies and reports. + The implementation, resolution, and any later code-mutating stage for one change request share its worktree and branch. An active linked worktree is reused only when it belongs to the requested work item; other tasks get separate diff --git a/scripts/cycle.py b/scripts/cycle.py index 56033ec..dd0df35 100644 --- a/scripts/cycle.py +++ b/scripts/cycle.py @@ -393,6 +393,8 @@ def __init__( self.stage_started = stage_started self.on_progress = on_progress self.on_workspace: Callable[..., None] | None = None + # Called at the adapter invocation boundary, after routing/preflight. + self.on_dispatch_started: Callable[[], None] | None = None def stage(self, role: str, task: str, *, escalated: bool = False, effort_override: str | None = None, target_override: Target | None = None, @@ -690,6 +692,8 @@ def begin_dispatch_attempt() -> str: detail=f"{target.provider}/{target.model} {target.effort}", correlation={"parent_attempt_id": parent_attempt_id, "relationship": relationship}) + if self.on_dispatch_started is not None: + self.on_dispatch_started() return attempt_id tool_activity = False diff --git a/scripts/cycle_cli.py b/scripts/cycle_cli.py new file mode 100644 index 0000000..0376613 --- /dev/null +++ b/scripts/cycle_cli.py @@ -0,0 +1,277 @@ +"""CLI parsing and application wiring.""" +from __future__ import annotations +import argparse +from typing import Any + +def main(api: Any, argv: list[str] | None = None) -> int: + api.apply_gh_environment_defaults() + api._configure_stdout() + parser: argparse.ArgumentParser = api.argparse.ArgumentParser( + prog="run_cycle", + description="Run one work item through a recorded cycle.", + ) + parser.add_argument("--repo", default=None, + help=("repository identifier, owner/name; defaults to " + f"code_cycle.repository.selector in {api.CONFIG_NAME}")) + parser.add_argument("--task", required=True, help="work item identifier") + parser.add_argument( + "--work-item-repository", default=None, + help="provider repository or project when it differs from the configured default", + ) + # Labelled before routing, never after: choosing a model from a judgement + # and then measuring by model measures the routing rather than the models. + parser.add_argument("--difficulty", type=int, choices=(1, 2, 3), default=2) + parser.add_argument("--verifiability", choices=("auto", "partial", "human"), + default="auto") + parser.add_argument("--security-sensitive", action="store_true") + parser.add_argument("--verification", choices=("available", "unavailable"), + default=None, + help=("whether the change can be verified automatically; " + "recorded as a pre-routing signal, unknown when omitted")) + parser.add_argument("--mode", choices=("production", "calibration"), + default="production") + parser.add_argument("--from", dest="start_from", choices=api.CYCLE_STARTS, + default="implement", + help=("stage to start at; anything but implement resumes " + "the change request named by --pr (default: implement)")) + parser.add_argument("--pr", dest="change_request", default=None, + help="existing change request a resumed cycle works on") + parser.add_argument("--issue-review", choices=("auto", "off"), default=None, + help=("review the work item before implementing it: auto " + "reviews all but declared trivial, non-sensitive " + "work; off keeps the original flow (default: " + f"code_cycle.issue_review.mode in {api.CONFIG_NAME}, " + "else auto). A resumed cycle never runs it")) + parser.add_argument("--continue", dest="continue_work", action="store_true", + help=("continue an interrupted implementation from the partial " + "work in --cwd instead of starting it again; skips the " + "issue review")) + parser.add_argument("--max-iterations", type=int, default=3) + parser.add_argument("--ci-policy", choices=api.CI_POLICIES, default=None, + help=("how the cycle treats the code host's checks: strict waits " + "for them in each writing stage; parallel reviews while " + "they run and approves only after every required check " + "of the reviewed SHA passed (default: " + f"code_cycle.ci.policy in {api.CONFIG_NAME}, else strict)")) + parser.add_argument("--ci-timeout", type=int, default=None, + help=("seconds the parallel policy's CI gate waits at most for " + f"one SHA (default: code_cycle.ci.timeout, else " + f"{api.ci_gate.DEFAULT_TIMEOUT})")) + parser.add_argument("--cwd", default=None, + help="working directory the executor runs in") + parser.add_argument("--workspace", choices=api.WORKSPACE_MODES, default="task", + help="workspace policy: task worktree by default, or current checkout") + parser.add_argument("--local-only", action="store_true", + help=("run only in the explicit --cwd linked worktree " + "with --workspace current; " + "stop after implementation without publishing")) + parser.add_argument("--timeout", type=int, default=None, + help="seconds one dispatch may take") + parser.add_argument("--detach", action="store_true", + help=("start the cycle in its own session and return at once, " + "so a host's task time limit cannot stop it; follow it " + "with cycle_status.py and read its report in the printed log")) + parser.add_argument("--verbose", action="store_true", + help="show stage starts, live progress, and stage results") + parser.add_argument("--progress-interval", type=float, default=60, + help="initial heartbeat seconds (default: 60; later intervals adapt)") + parser.add_argument("--database", default=None, + help=f"telemetry database (default: {api.default_database_path()})") + parser.add_argument("--config", default=None, + help=f"path to {api.CONFIG_NAME} (default: alongside the work, else the main worktree's)") + parser.add_argument("--no-config", action="store_true", + help="run on the built-in defaults, ignoring any configuration") + parser.add_argument("--grant", action="append", default=[], metavar="ID=ANSWER", + help="answer a sensitive sandbox permission in advance: " + "docker_host=cycle|persistent|deny (repeatable); persistent " + "and deny are stored only for a scope an earlier run asked about") + parser.add_argument("--repair-approval", default=None, + help="approve exactly one proposed comment repair: COMMENT_ID:SHA256") + parser.add_argument("--execution-profile", choices=api.EXECUTION_PROFILES, default=None, + help=("how deep the cycle goes (default: code_cycle.execution.profile " + f"in {api.CONFIG_NAME}, else standard); separate from the model " + "routing profiles")) + parser.add_argument("--objective", choices=api.EXECUTION_OBJECTIVES, default=None, + help=("the outcome the cycle must reach (default: " + "code_cycle.execution.objective, else the profile's); " + "--local-only is local_change")) + parser.add_argument("--stage", action="append", default=[], metavar="STAGE=POLICY", + help=("set one stage's policy: disabled, auto, enabled or " + "required (repeatable); an override never removes an " + "obligation")) + parser.add_argument("--dry-run", action="store_true", + help=("print the execution plan and exit; runs no stage and " + "changes nothing")) + parser.add_argument("--json", action="store_true", + help="with --dry-run, print the plan as its JSON contract") + args = parser.parse_args(argv) + if args.progress_interval <= 0: + parser.error("--progress-interval must be greater than zero") + if args.json and not args.dry_run: + parser.error("--json needs --dry-run") + if args.dry_run and args.detach: + parser.error("--dry-run runs nothing to detach") + + try: + api.validate_start(args.start_from, args.change_request, local_only=args.local_only, + continue_work=args.continue_work) + if args.dry_run: + planned = api.dry_run(args) + print(api.json.dumps(planned.to_dict(), indent=2) if args.json + else api.render_plan(planned)) + return 0 + repo, profiles, routing_strategy = api.plan_with_strategy(args) + config = api.resolve_config(args, report=print) + execution = api.execution_of(args, config) + api.execution_plan_of(args, config, repo, provenance="run") + api.check_current_flow(execution) + if execution.objective.value == "local_change" and not args.local_only: + # The current flow reaches local_change only as a local-only run, + # with the same explicit worktree it requires. + args.local_only = True + api.validate_start(args.start_from, args.change_request, local_only=True, + continue_work=args.continue_work) + grant_answers = api.parse_grant_options(args.grant) + if args.local_only: + api.validate_local_only_cwd(args.cwd) + if args.workspace != "current": + raise api.CycleDriverError( + "--local-only requires --workspace current to use the " + "explicitly supplied linked worktree" + ) + if args.continue_work and not args.cwd: + raise api.CycleDriverError( + "--continue needs --cwd: the checkout the interrupted run used" + ) + ci_policy = api.ci_policy_of(config, args.ci_policy) + ci_timeout = api.ci_timeout_of(config, args.ci_timeout) + api.check_ci_policy(ci_policy, (config.get("code_cycle") or {}).get("code_host")) + if args.start_from in api.RESUMES: + host = (config.get("code_cycle") or {}).get("code_host") + if host not in (None, "github"): + raise api.CycleDriverError( + f"resuming reads the pull request through GitHub; code_host " + f"{host} is not supported") + selected_cwd, worktree_dir = api.select_workspace( + args.task, repo, config, workspace=args.workspace, cwd=args.cwd, + change_request_id=(args.change_request if args.start_from in api.RESUMES else None), + continue_work=args.continue_work, + ) + if (args.local_only or args.continue_work) and ( + not args.cwd or api.Path(args.cwd).resolve() != api.Path(selected_cwd).resolve()): + raise api.CycleDriverError( + "BLOCKED: the explicitly supplied --cwd does not identify the selected " + "task worktree. Pass its path with --cwd or use --workspace current." + ) + args.cwd = selected_cwd + if args.continue_work: + api.check_continuation(args.cwd, config) + if args.start_from in api.RESUMES: + database = api.Path(args.database) if args.database else api.default_database_path() + if database.is_file() and args.start_from == "resolve": + api.check_change_request(repo, args.change_request, args.cwd, + history_store=api.CycleHistoryStore(database)) + else: + api.check_change_request(repo, args.change_request, args.cwd) + except (api.CycleDriverError, api.GrantError) as error: + parser.error(str(error)) + + if args.detach: + # Everything that can be refused was refused above, where a person is + # still reading; the detached run repeats those checks on its own. + return api.detach(list(api.sys.argv[1:] if argv is None else argv), args.database) + + change_bases = api.change_bases_of(config) + # Already validated by `load_config`; read here so the run carries it. + jev = api.load_jev_config(config) + # `--issue-review`, `--stage issue_review=…` and the file's mode were layered + # into one policy; the current flow honours auto and disabled. + issue_review = (api.IssueReviewMode.OFF + if execution.stages["issue_review"].policy is api.PlanStagePolicy.DISABLED + else api.IssueReviewMode.AUTO) + work_item_provider, work_item_repository = api._work_item_scope( + config, repo, args.work_item_repository + ) + code_host = (config.get("code_cycle") or {}).get("code_host") + policy_code_host = api.code_host_of(config, args.cwd) + # Every publishing stage is audited on `origin` (#261). Only GitHub can be + # read for change-request state and protected branches until #238; any + # other code host still has its refs audited and the rest is `unverified`. + publication_auditor = (None if args.local_only or not args.cwd else api.PublicationAuditor( + args.cwd, repo, + api.GitHubHostReader(args.cwd) if (code_host or policy_code_host) == "github" else None, + # Only a configured default branch; otherwise `origin`'s live HEAD. + default_branch=api.base_branch_of(config, None))) + # Asked on a terminal only; a run nobody watches never widens a sandbox + # without an answer given in advance. + consent = api.ConsentBroker( + repo, store=api.ConsentStore(), answers=grant_answers, + preauthorized=frozenset({"docker_host"} if api.docker_host_socket_access(config) else ()), + prompt=(api.terminal_prompt() if api.sys.stdin.isatty() and api.sys.stderr.isatty() else None), + ) + telemetry = api.Telemetry(api.Path(args.database) if args.database else None) + try: + with api.interruptible(): + report = api.run_cycle( + repo, args.task, + api.TaskSignals(difficulty=args.difficulty, + verifiability=args.verifiability, + security_sensitive=args.security_sensitive), + telemetry, + profiles=profiles, + mode=api.RoutingMode[args.mode.upper()], + routing_strategy=routing_strategy, + max_iterations=args.max_iterations, + cwd=args.cwd, + timeout=args.timeout, + verbose=args.verbose, + progress_interval=args.progress_interval, + local_only=args.local_only, + change_bases=change_bases, + verification_available=( + None if args.verification is None + else args.verification == "available" + ), + jev=jev, + start_from=args.start_from, + change_request_id=args.change_request, + issue_review=issue_review, + continue_work=args.continue_work, + worktree_dir=worktree_dir, + workspace=args.workspace, + work_item_provider=work_item_provider, + work_item_repository=work_item_repository, + code_host=code_host, + policy_code_host=policy_code_host, + publication_auditor=publication_auditor, + trusted_authors=tuple(((config.get("code_cycle") or {}).get("review") or {}).get( + "trusted_authors", [])), + resolve_effort=((config.get("code_cycle") or {}).get("resolve") or {}).get("effort"), + allowed_test_command=api.allowed_test_command(config), + allowed_verify_commands=api.allowed_verify_commands(config), + # None, not empty tuples, when no file was read: the permission + # view infers a withdrawal only from a source it could read (#314). + user_tool_choices=(api.user_choices.load(config) if api.configuration_read(args) + else None), + base_branch=api.base_branch_of(config, args.cwd), + ci_policy=ci_policy, + ci_timeout=ci_timeout, + repair_approval=args.repair_approval, + consent=consent, + ) + except (api.CycleDriverError, api.GrantError) as error: + parser.error(str(error)) + except api.CycleInterrupted as interruption: + # Before the recorder existed, or after it closed: nothing is running + # and there is no stage to record. + print(f"interrupted by {interruption.signal_name} before any stage ran", + file=api.sys.stderr) + return 1 + + if consent.notes: + report.notes[:0] = [(0, f" permission {api.readable(line, 300)}") + for line in consent.notes] + api.save_decisions(report, api.Path(telemetry.path).parent / "decisions") + print(report.explain()) + print(f"recorded in {telemetry.path}") + return 0 if report.status == api.APPROVED_END else 1 diff --git a/scripts/cycle_dispatch.py b/scripts/cycle_dispatch.py new file mode 100644 index 0000000..40b9055 --- /dev/null +++ b/scripts/cycle_dispatch.py @@ -0,0 +1,565 @@ +"""One stage dispatch, evidence checks and bounded recovery.""" +from __future__ import annotations +from typing import TYPE_CHECKING, Any +if TYPE_CHECKING: + from .run_cycle import CycleReport, Reported, StageOutcome, StagePolicy + +class StageDispatch: + def stage_policy(self: Any, role: str, request: str | None) -> StagePolicy: + """The closed command table this stage receives (#142).""" + base_ref = head_ref = None + if self.policy_host == "github" and request: + number = self.api.StagePolicy(role=role, change_request_id=request).pr_number + if number: + try: + pull = self.checks_provider.pull(self.repo_id, number) + except self.api.ci_gate.CheckReadError: + # Failing closed here denies ref-sensitive Git commands; + # it does not prevent the stage from doing other work. + pass + else: + if pull.state == "OPEN": + base_ref, head_ref = pull.base_ref, pull.head_ref + return self.api.StagePolicy( + role=role, repo=self.repo_id, change_request_id=request, + work_item_id=self.task_id, base_branch=self.base_branch, + base_ref=base_ref, head_ref=head_ref, + test_command=self.allowed_test_command, + verify_prefixes=tuple(self.allowed_verify_commands), + code_host=self.policy_host, issue_provider=self.policy_provider, + # The hook records its denials in the journal of this stage, the + # next one the recorder numbers (#306). + journal_path=str(self.telemetry.path), cycle_id=self.recorder.cycle_id, + stage_seq=self.recorder.stage_seq + 1, checkout_branch=self.checkout_branch, + **(self.user_tool_choices or {}), + ) + + def run(self: Any, role: str, instruction: str = "", *, + change_request_id: str | None = None, escalated: bool = False, + resolve_target=None, resolve_escalated: bool = False, + resolve_target_used_fallback: bool = False, + escalation_finding_ids: tuple[str, ...] = (), + publishes: bool = True, + ) -> tuple[StageOutcome, Reported, str | None, dict[str, Any]]: + """Dispatch one stage; also say whether the evidence it was given changed. + + `publishes` is false for a dispatch that only re-reads a publication + already recovered, so the outbox expects nothing from it. + """ + self.control.move(role) + resolve_start_head = ( + self.api._git_line(self.cwd, ["rev-parse", "HEAD"], 30) + if role == "resolve" and self.cwd else None + ) + stage_start_branch = self.api._git_line(self.cwd, ["branch", "--show-current"], 30) if self.cwd else None + stage_start_clean = self.cwd is not None and self.api._clean_worktree(self.cwd) + evidence = "" + stage_kwargs = self.dispatch_kwargs + artifact = None + tampered = None + # No base to measure against means no file, so no directory either. + directory = (self.api.diff_directory(self.cwd) + if role in self.api.DIFF_ROLES and self.change_bases else None) + try: + review_base = None + if role == "resolve" and self.ci_policy == "parallel" and not self.local_only and change_request_id: + try: + review_base = self.api.resolve_delta_base( + self.remote.comments(self.repo_id, change_request_id), self.repo_id, + change_request_id, self.trusted_authors, self.cwd) + except (self.api.CycleDriverError, self.api.ContractError, self.api.HistoryError): + # Missing review evidence widens the review; it never skips it. + pass + if role in self.api.DIFF_ROLES: + artifact = (self.api.write_diff_artifact(self.cwd, self.change_bases, directory) + if directory is not None else None) + evidence = artifact.prompt() if artifact is not None else self.api.NO_DIFF_FILE + if artifact is not None: + stage_kwargs = {**self.dispatch_kwargs, "read_dirs": (str(directory),)} + if role in {"implement", "resolve"} and self.allowed_test_command: + stage_kwargs = { + **stage_kwargs, + "claude_allowed_commands": (self.allowed_test_command,), + } + command_policy = self.stage_policy(role, change_request_id) + stage_kwargs = {**stage_kwargs, "command_policy": command_policy} + if change_request_id: + self.journal.change_request_id = change_request_id + # A resumed or re-dispatched stage sees what earlier runs did (#306). + redispatched = any(stage.role == role for stage in self.report.stages) + journal_context = (self.journal.summary() + if redispatched or self.journal.has_history() else "") + verification_context = "" + if (role in {"implement", "resolve", "rereview"} + and self.state.verification_plan is not None and self.state.verification_plan.checks): + verification_context = ( + "\nRuntime frozen verification checks (data): " + + self.api.json.dumps([check.to_dict() for check in self.state.verification_plan.checks], + ensure_ascii=False) + + "\nReuse these identities, including executor/profile, when reporting " + "a result for an existing check, even when this stage's dispatch differs. " + "The identity names the check's owning execution context. " + "A check that needs another context requires a new ID; it does not " + "replace a required check. Additions and obligation promotions are allowed.\n") + outcome = self.recorder.stage( + role, self.api.compose(role, self.repo_id, self.task_id, + instruction + self.state.criteria_context + verification_context, + change_request_id=change_request_id, + local_only=self.local_only, evidence=evidence, + worktree_dir=self.worktree_dir, + workspace=self.workspace, + allowed_test_command=( + self.allowed_test_command + if role in {"implement", "resolve"} else None), + ci_policy=self.ci_policy, resolve_review_base=review_base, + command_policy=command_policy, + journal_context=journal_context), + escalated=escalated, + effort_override=(self.resolve_effort or "high") if role == "resolve" else None, + target_override=resolve_target if role == "resolve" else None, + target_override_used_fallback=(resolve_target_used_fallback + if role == "resolve" else False), + escalation_finding_ids=(escalation_finding_ids + if resolve_escalated else ()), + resolve_escalated=resolve_escalated, + **stage_kwargs) + if artifact is not None: + tampered = artifact.changed() + finally: + # The diff lives for its stage only. A later stage writes its own, + # from the head that stage will judge. + if directory is not None: + self.api._remove_workspace(str(directory)) + self.report.stages.append(outcome) + attempt_id = (outcome.result.artifacts.get("dispatch_attempt_id") + if outcome.result is not None else None) + if publishes: + self.expect_publication(role, change_request_id, attempt_id) + if (role == "resolve" and change_request_id and isinstance(attempt_id, str) + and resolve_start_head and self.state.history_store is not None): + try: + self.state.history_store.record_attempt_start( + attempt_id, self.repo_id, change_request_id, role, resolve_start_head) + except (self.api.sqlite3.Error, self.api.HistoryError): + # Assisted repair requires this proof; absent storage stays manual. + pass + reported = self.api.read_structured_result(outcome.result) + sandbox_startup_failures = (outcome.result.artifacts.get("sandbox_startup_failures", []) + if outcome.result is not None else []) + reported = self.api._with_sandbox_startup_failures(reported, sandbox_startup_failures) + if (role in {"implement", "resolve"} and self.code_host == "github" and not self.local_only + and self.cwd and reported.completes(role, self.ci_policy) + and outcome.result is not None + and self.registry.get(outcome.result.executor).requires_publication_preflight): + request = self.api.Reported(payload={ + "change_request_id": reported.change_request_id or change_request_id + }).telemetry_pull_request_id("github") + expected = (reported.payload or {}).get("head_sha") + try: + if not request or not isinstance(expected, str) or not self.api.FULL_SHA.fullmatch(expected): + raise self.api.CycleDriverError("publication completion requires a change request and full head SHA") + remote_head = self.api.probe_change_request(self.repo_id, request, self.cwd).head_sha + local_head = self.api._git_line(self.cwd, ["rev-parse", "HEAD"], 30) + if remote_head != expected or local_head != expected: + raise self.api.CycleDriverError( + f"upload unconfirmed: claimed {expected}, local {local_head}, PR {remote_head}; " + "retain the local commit and resume the publishing stage") + transport = (outcome.result.artifacts if outcome.result else {}).get("publication_transport") + if transport is not None and not any( + item.get("action") == "preflight" and item.get("status") == "ready" + for item in transport if isinstance(item, dict)): + raise self.api.CycleDriverError("publication preflight inside the stage sandbox was not confirmed") + except self.api.CycleDriverError as error: + reported = self.api.Reported(present=True, status="BLOCKED", payload={ + **(reported.payload or {}), "status": "BLOCKED", "reason": str(error), + "publication_pending": True}) + if (role == "resolve" and change_request_id and self.cwd and resolve_start_head + and stage_start_clean and stage_start_branch and self.state.history_store is not None + and isinstance(attempt_id, str) and outcome.result is not None + and outcome.result.process_exited and not outcome.result.asynchronous): + end_head = self.api._git_line(self.cwd, ["rev-parse", "HEAD"], 30) + if (end_head and end_head != resolve_start_head + and stage_start_branch == self.api._git_line(self.cwd, ["branch", "--show-current"], 30) + and self.api._clean_worktree(self.cwd)): + try: + self.state.history_store.record_unpublished_head( + attempt_id, self.repo_id, change_request_id, self.cwd, stage_start_branch, + resolve_start_head, end_head) + except (self.api.sqlite3.Error, self.api.HistoryError): + pass # Missing attribution keeps resume conservative. + if (outcome.succeeded and outcome.result is not None + and not outcome.result.asynchronous and reported.status is None): + result = outcome.result + has_output = bool(result.agent_output.strip()) + code = "unreadable_result" if has_output else "no_error_report" + detail = "no parseable structured stage result" + pending = result.artifacts.get("background_command") + if has_output and pending in {"confirmed", "suspected"}: + detail = ("background_command_pending: verification command did not " + "complete before stage exit" + + (" (suspected)" if pending == "suspected" else "")) + if has_output: + detail += ": " + self.api.failure_excerpt(result.agent_output) + outcome.result = self.api.replace(result, error_code=code, + detail=self.api.failure_excerpt(detail)) + if outcome.rows: + attempt_id = result.artifacts.get("dispatch_attempt_id") + if attempt_id: + self.telemetry.update_dispatch_attempt( + attempt_id, {"error_code": code, "outcome": "failed", "lifecycle_state": "failed"}, + update_id=f"update-{self.api.uuid.uuid4().hex}", + correction_reason="parser_correction", + ) + self.journal_result(role, outcome, reported, attempt_id) + findings = self.api._findings(reported.payload, role) + warnings = self.api._stage_warnings(outcome) + blocked = self.api._blocked_commands(outcome) + warnings += [f"blocked command: `{command}`" for command in blocked] + notice = self.api._preapproved_notice(outcome) + if notice and notice not in self.noticed: + self.noticed.add(notice) + warnings.append(notice) + reported_status = reported.status if outcome.succeeded else None + duration = self.status_writer.stage_finished( + outcome.result, reported_status, + findings=self.api._findings_line(role, findings), warnings=warnings, + reviewed_head=self.api._reviewed_head(reported.payload, role).get("reviewed_head_sha"), + ) + self.report.stage_details.append(self.api.StageReportDetails( + round=self.recorder.iteration, + status=reported_status, + findings=findings, + warnings=warnings, + duration_seconds=duration if outcome.result is not None else None, + blocked_commands=blocked, + )) + return outcome, reported, tampered, findings + + def unfinished(self: Any, outcome: StageOutcome, reported: Reported, + tampered: str | None) -> CycleReport | None: + """Stop a stage whose dispatch did not finish here, before its report is read.""" + # Before anything the stage reported is read: its verdict rests on + # evidence the runtime no longer vouches for. + if tampered is not None: + return self.stop(tampered, reported=reported) + if self.consent is not None and self.consent.pending: + # The sandbox stayed as it was; a person answers before any rerun. + self.report.permission_requests = list(self.consent.pending) + asked = self.consent.pending[0] + return self.stop(f"the {asked.role} stage needs permission: {asked.grant.id} " + f"({asked.grant.resource})", reported=reported, + stop_reason="permission_required") + if not outcome.succeeded: + blocked_effort = ( + (outcome.result is not None + and outcome.result.failure_code == "unsupported_effort") + or (outcome.decision is not None and outcome.decision.blocked + and any("does not support effort" in reason + for reason in outcome.decision.reasons)) + ) + return self.stop(self.api._why(outcome), reported=reported, + status="BLOCKED" if blocked_effort else self.api.UNRESOLVED_END, + stop_reason="dispatch_failed") + if self.api._started_elsewhere(outcome): + return self.stop(self.api._elsewhere(outcome), reported=reported) + return None + + def audit_publication(self: Any, role: str, before, reported: Reported, + request: str | None) -> CycleReport | None: + """Stop when a publication invariant failed during `role` (#261). + + The change request the stage was given, or for `implement` the one it + reports, is read as well as those whose head is the cycle branch. A + breach names what moved, never who moved it. + """ + number = self.api._pull_number(request) + if number is None and role == "implement": + number = reported.telemetry_pull_request_id("github") + try: + result = self.publication_auditor.verify(before, change_request_id=number) + except self.api.AuditUnreadable as error: + self.report.publication_audit = {"status": "unreadable", "role": role} + return self.stop(f"the publication audit could not read the code host after the " + f"{role} stage: {error}", "BLOCKED", reported=reported, + stop_reason="publication_unverified") + evidence = {"role": role, **result.evidence()} + self.report.publication_audit = evidence + self.journal.record("stage_result", role=role, stage_seq=self.recorder.stage_seq, + component="publication_audit", status=result.status, + correlation={"cycle_branch": result.before.cycle_branch, + "default_branch": result.before.default_branch, + "breaches": len(result.breaches)}) + for breach in result.breaches: + self.journal.record("failure", role=role, stage_seq=self.recorder.stage_seq, + component="publication_audit", status=breach.kind, + reason=breach.explain(), + correlation={"ref": breach.ref, "before": breach.before, + "after": breach.after}) + if result.unverified: + self.report.note(f"publication audit after {role}: unverified " + f"({', '.join(result.unverified)}); the rest was checked") + if result.status != self.api.AUDIT_VIOLATED: + return None + for breach in result.breaches: + self.report.note(f" publication breach {self.api.json.dumps(breach.evidence(), sort_keys=True)}") + return self.stop(result.explain(role), reported=reported, + stop_reason="publication_violation") + + def review_issue(self: Any) -> CycleReport | None: + """The readiness gate: only a confirmed READY lets `implement` run. + + A READY the runtime cannot confirm — low confidence, or a material + uncertainty left open — is reviewed once more on the stronger profile. + When that is not possible, or the second pass cannot confirm it either, + the cycle stops for a person rather than implementing on a guess. + """ + escalated = False + while True: + outcome, reported, tampered, _ = self.run( + self.api.ISSUE_REVIEW_ROLE, self.api.ESCALATED_ISSUE_REVIEW if escalated else "", + escalated=escalated) + stopped = self.unfinished(outcome, reported, tampered) + if stopped is not None: + return stopped + readiness = self.api.assess_readiness(reported.payload, self.task_id) + self.report.readiness = readiness + if reported.status: + self.recorder.record_verdict(self.api.ISSUE_REVIEW_ROLE, reported.status, + **readiness.telemetry_fields()) + if readiness.gate == self.api.READINESS_CONTINUE: + return None + if readiness.gate == self.api.READINESS_STOP: + if not reported.readable: + return self.stop(reported.explain(self.api.ISSUE_REVIEW_ROLE), reported=reported) + if readiness.valid and readiness.status == "NEEDS_REFINEMENT": + return self.stop(readiness.explain(), reported=reported, + stop_reason="needs_refinement") + return self.stop(readiness.explain(), reported=reported) + stronger = self.api.ESCALATION_PROFILES[self.api.ISSUE_REVIEW_ROLE] + if escalated or outcome.decision.profile == stronger: + return self.stop(readiness.explain(), reported=reported, + stop_reason="readiness_unconfirmed") + escalated = True + + def advance(self: Any, role: str, instruction: str = "", *, + change_request_id: str | None = None, + resolve_target=None, resolve_escalated: bool = False, + resolve_target_used_fallback: bool = False, + escalation_finding_ids: tuple[str, ...] = (), + recover: bool = True) -> tuple[Reported, CycleReport | None]: + """Run one stage and decide whether the cycle may continue past it. + + Every reason to stop is here rather than repeated per stage: a stop + condition that has to be remembered four times is one that will be + missing from the fourth. + """ + if self.state.pending_recovery is not None: + ids = [item["id"] for item in self.state.pending_recovery.get("finding_outcomes", []) + if item["status"] in {"resolved", "not_applicable"}] + instruction += ( + f" Verify recovered attempt {self.state.pending_recovery['attempt_id']} at head " + f"{self.state.pending_recovery['head_sha']}. Execute verification for every claimed " + f"finding ID: {', '.join(ids) or '(none)'}. Report head_sha and explicit " + "verified_findings outcomes (resolved, still_open, not_applicable). " + "A reopened finding follows the normal resolution loop." + ) + if recover: + instruction += self.retry_directive(role, change_request_id) + if (role in {"resolve", "rereview"} and self.code_host == "github" + and change_request_id is not None and self.cwd is not None): + try: + target_head = self.api.probe_change_request(self.repo_id, change_request_id, self.cwd).head_sha + unpublished = (self.state.history_store.unpublished_heads( + self.repo_id, change_request_id, self.cwd, + self.api._git_line(self.cwd, ["branch", "--show-current"], 30) or "", target_head or "") + if self.state.history_store is not None and role == "resolve" else None) + if unpublished is None: + synchronized = self.api.synchronize_worktree(change_request_id, self.cwd, target_head=target_head) + else: + synchronized = self.api.synchronize_worktree( + change_request_id, self.cwd, target_head=target_head, unpublished_heads=unpublished) + except self.api.WorktreeSyncError as error: + return self.api.Reported(), self.stop(str(error), "BLOCKED", + stop_reason=error.stop_reason or "stage_not_completed") + except self.api.CycleDriverError as error: + return self.api.Reported(), self.stop(str(error), "BLOCKED") + if synchronized is not None: + old_head, new_head = synchronized + self.report.note(f"worktree fast-forwarded: {old_head} -> {new_head}") + self.journal.record( + "stage_result", role=role, stage_seq=self.recorder.stage_seq + 1, + component="runtime", status="worktree_synchronized", + reason="clean worktree fast-forwarded to the open pull request head", + correlation={"old_head_sha": old_head, "new_head_sha": new_head}) + audited = None + if self.publication_auditor is not None and role in self.api.PUBLISHING_ROLES: + try: + audited = self.publication_auditor.capture( + checkout_branch=self.api._git_line(self.cwd, ["branch", "--show-current"], 30), + change_request_id=self.api._pull_number(change_request_id)) + except self.api.AuditUnreadable as error: + return self.api.Reported(), self.stop( + f"the publication audit could not read the code host before the {role} " + f"stage, so nothing was dispatched: {error}", "BLOCKED", + stop_reason="publication_unverified") + outcome, reported, tampered, findings = self.run( + role, instruction, change_request_id=change_request_id, + resolve_target=resolve_target, + resolve_target_used_fallback=resolve_target_used_fallback, + resolve_escalated=resolve_escalated, + escalation_finding_ids=escalation_finding_ids, + publishes=recover) + if audited is not None: + # Before anything the stage reported is read, whatever it reported: + # a breach is never a `stage_not_completed` (#261, #364). + stopped = self.audit_publication(role, audited, reported, change_request_id) + if stopped is not None: + return reported, stopped + if role == "resolve" and outcome.decision is not None: + self.state.previous_resolve_target = outcome.decision.target + self.state.previous_resolve_used_fallback = outcome.decision.used_fallback + result = outcome.result + if role in {"review", "rereview"}: + dispatched = (result.artifacts.get("dispatch_attempt_id") + if result is not None else None) + # A re-dispatch for a recovered verdict keeps the recovered attempt. + self.state.review_attempts = (set(self.state.review_attempts) if not recover else set()) | ( + {dispatched} if isinstance(dispatched, str) else set()) + if (recover and self.state.pending_recovery is None and tampered is None and reported.status is None + and result is not None and not result.asynchronous + and result.error_code in {"unreadable_result", "no_error_report"} + and self.code_host == "github" and not self.local_only): + attempt_id = result.artifacts.get("dispatch_attempt_id") + try: + if not attempt_id: + raise self.api.CycleDriverError("recovery requires a recorded dispatch attempt") + if (role == "review" and self.code_host == "github" + and change_request_id is not None and self.cwd is not None): + target_head = self.api.probe_change_request(self.repo_id, change_request_id, self.cwd).head_sha + synchronized = self.api.synchronize_worktree( + change_request_id, self.cwd, target_head=target_head) + if synchronized is not None: + old_head, new_head = synchronized + self.report.note(f"worktree fast-forwarded for recovery: {old_head} -> {new_head}") + self.journal.record( + "stage_result", role=role, stage_seq=self.recorder.stage_seq + 1, + component="runtime", status="worktree_synchronized", + reason="clean worktree fast-forwarded before review recovery", + correlation={"old_head_sha": old_head, "new_head_sha": new_head}) + receipt = self.api.recovery_evidence( + self.repo_id, role, attempt_id, change_request_id=change_request_id, + cwd=self.cwd, trusted_authors=self.trusted_authors, awaiting_ci=self.parallel) + self.telemetry.record_recovery( + attempt_id, change_request_id=receipt["change_request_id"], + head_sha=receipt["head_sha"], comment_id=receipt["comment_id"]) + except (self.api.CycleDriverError, self.api.TelemetryError) as error: + if isinstance(error, self.api.WorktreeSyncError): + return reported, self.stop(f"{role} recovery rejected: {error}", "BLOCKED", + reported=reported, + stop_reason=error.stop_reason or "stage_not_completed") + return reported, self.stop(f"{role} recovery rejected: {error}", reported=reported) + self.state.pending_recovery = receipt + self.report.stage_details[-1].status = "recovered_pending_verification" + if role in {"review", "rereview"}: + self.state.review_attempts = {receipt["attempt_id"]} + return self.advance(role, "Obtain a structured verdict for the recovered review.", + change_request_id=receipt["change_request_id"], recover=False) + if role == "resolve": + self.history.append(self.api.RunStatuses(self.api.RESOLUTION_RUN, { + item["id"]: item["status"] for item in receipt.get("finding_outcomes", [])})) + return self.api.Reported(present=True, status="recovered_pending_verification", + payload={"change_request_id": receipt["change_request_id"], + "head_sha": receipt["head_sha"]}), None + stopped = self.unfinished(outcome, reported, tampered) + if stopped is not None: + return reported, stopped + if self.state.pending_recovery is not None: + try: + current_receipt = self.api.recovery_evidence( + self.repo_id, self.state.pending_recovery["stage"], self.state.pending_recovery["attempt_id"], + change_request_id=self.state.pending_recovery["change_request_id"], + cwd=self.cwd, trusted_authors=self.trusted_authors, awaiting_ci=self.parallel) + except self.api.CycleDriverError as error: + return reported, self.stop(f"recovered stage not completed: {error}", reported=reported) + claims = {item["id"] for item in self.state.pending_recovery.get("finding_outcomes", []) + if item["status"] in {"resolved", "not_applicable"}} + payload = reported.payload or {} + verified = payload.get("verified_findings", []) + verified_statuses = self.api._finding_statuses({"verified_findings": verified}, "rereview") + verified_ids = {item["id"] for item in verified + if isinstance(item, dict) and isinstance(item.get("id"), str)} if isinstance(verified, list) else set() + if (not reported.completes(role, self.ci_policy) + or current_receipt != self.state.pending_recovery + or payload.get("head_sha") != self.state.pending_recovery["head_sha"] + or payload.get("reviewed_head_sha", payload.get("head_sha")) != self.state.pending_recovery["head_sha"] + or self.api._git_line(self.cwd, ["rev-parse", "HEAD"], 30) != self.state.pending_recovery["head_sha"] + or (claims and (verified_statuses is None or not claims.issubset(verified_ids))) + or (reported.status in self.api.CLEAN_REVIEW_STATUSES and verified_statuses + and any(verified_statuses.get(finding_id) == "open" for finding_id in claims))): + return reported, self.stop("recovered stage not completed: verifier did not confirm " + "the recovered head and every claimed finding", reported=reported) + self.state.pending_recovery = None + # The dispatch and the work are different facts. This records what the + # agent said it did; the row already says the call returned. The prose + # beside it is not recorded: the store holds references and counts. + if reported.status: + test_payload = reported.payload + if (self.state.verification_plan is None and self.allowed_test_command + and role in {"implement", "resolve", "rereview"}): + # Resumes cannot recover criteria revisions until #329. Keep + # them explicitly open and retain the project's obligation. + self.state.verification_plan = self.api.verification_contract.VerificationPlan( + self.api.verification_contract.Criteria( + self.policy_provider or "unknown", "", "not_evaluable", + reason="runtime criteria revision unavailable")) + if role in {"implement", "resolve", "rereview"} and self.state.verification_plan is not None: + # Mandatory commands come from project policy, never the report. + if self.allowed_test_command and not any( + check.id == "configured-test" for check in self.state.verification_plan.checks): + self.state.verification_plan = self.state.verification_plan.extend(( + self.api.verification_contract.configured_check( + self.allowed_test_command, + outcome.result.executor if outcome.result else "unknown", + outcome.decision.profile if outcome.decision else "unknown"),)) + test_payload, self.state.verification_plan = self.api._normalize_test_payload( + test_payload, self.state.verification_plan) + pull_request_id = ( + reported.telemetry_pull_request_id(self.code_host) + if role == "implement" else None + ) + self.recorder.record_verdict(role, reported.status, + **findings, + **(self.api._forecast(reported.payload) if role == "implement" else {}), + **(self.api._legacy_tests(test_payload, boundary_rule=( + self.api.boundary_rule_fired(self.recorder.latest_change))) + if self.state.verification_plan is not None else + self.api._tests(test_payload, boundary_rule=( + self.api.boundary_rule_fired(self.recorder.latest_change)))), + **self.api._checks(reported.payload), + **self.api._reviewed_head(reported.payload, role), + **({"pull_request_id": pull_request_id} + if pull_request_id else {})) + if not reported.completes(role, self.ci_policy): + because = reported.explain(role, self.ci_policy) + if (role == "rereview" and not self.local_only + and (self.code_host or self.policy_code_host) == "github"): + because += self.manual_hint(reported) + return reported, self.stop(because, + "BLOCKED" if (reported.payload or {}).get("publication_pending") is True + else self.api.UNRESOLVED_END, reported=reported) + if recover and role in self.api.COMMENT_STAGES and change_request_id: + stopped = self.after_publication( + role, change_request_id, + result.artifacts.get("dispatch_attempt_id") if result is not None else None, + reported) + if stopped is not None: + return reported, stopped + # Only a completed stage enters the history the ladder reads. The + # cycle already stops on anything else, so an unreadable result can + # neither reset nor advance a count. + run_statuses = self.api._run_statuses(role, reported.payload) + if run_statuses is not None: + self.history.append(run_statuses) + if role in {"review", "rereview"}: + self.progress.append(self.api._progress_key(role, reported.payload)) + return reported, None diff --git a/scripts/cycle_loop.py b/scripts/cycle_loop.py new file mode 100644 index 0000000..38b0f4c --- /dev/null +++ b/scripts/cycle_loop.py @@ -0,0 +1,398 @@ +"""The existing stage loop and its explicit control transitions.""" +from __future__ import annotations +from typing import TYPE_CHECKING, Any +if TYPE_CHECKING: + from .run_cycle import CycleReport, ci_gate +from dataclasses import dataclass, field +from enum import Enum + + +class ControlState(str, Enum): + START = "start" + ISSUE_REVIEW = "issue_review" + IMPLEMENT = "implement" + REVIEW = "review" + RESOLVE = "resolve" + REREVIEW = "rereview" + CI = "ci" + STOPPED = "stopped" + INTERRUPTED = "interrupted" + + +# Control flow only. Completion policies remain in advance() and gate(). +# START accepts every resume entry; self edges describe bounded retries and +# recovered verdict verification. CI can request resolution or rereview when +# checks fail or the head moves. All live states can stop or be interrupted. +_SUCCESSORS = { + ControlState.START: {"issue_review", "implement", "review", "resolve", "rereview", "ci"}, + ControlState.ISSUE_REVIEW: {"issue_review", "implement"}, + ControlState.IMPLEMENT: {"implement", "review"}, + ControlState.REVIEW: {"review", "resolve", "ci"}, + ControlState.RESOLVE: {"resolve", "rereview"}, + ControlState.REREVIEW: {"rereview", "resolve", "ci"}, + ControlState.CI: {"ci", "resolve", "rereview"}, +} +TRANSITIONS = { + state: {**{event: ControlState(event) for event in events}, + "stop": ControlState.STOPPED, "interrupt": ControlState.INTERRUPTED} + for state, events in _SUCCESSORS.items() +} +TRANSITIONS[ControlState.INTERRUPTED] = {"stop": ControlState.STOPPED} +TRANSITIONS[ControlState.STOPPED] = {} + + +@dataclass +class CycleControl: + state: ControlState = ControlState.START + transitions: list[tuple[ControlState, str, ControlState]] = field(default_factory=list) + + def move(self, event: str): + destination = TRANSITIONS[self.state][event] + self.transitions.append((self.state, event, destination)) + self.state = destination + +class StageLoop: + def drive(self: Any) -> CycleReport: + reported = self.api.Reported() + verdict = None + if self.repair_approval is not None: + self.journal.record("decision_answered", component="--repair-approval", + decision_key=f"repair:{self.repair_approval}", status="submitted") + if not self.state.change_request_id or self.code_host != "github": + return self.stop("repair approval requires a GitHub change request", reported=reported, + stop_reason="triage_repair_blocked") + candidates = self.repair_proposals(self.state.change_request_id) + if len(candidates) != 1: + return self.stop("approved repair proposal is no longer uniquely available; use manual repair", + reported=reported, stop_reason="triage_repair_blocked") + record, proposal = candidates[0] + + if self.repair_approval == f"{proposal.comment_id}:{proposal.digest}": + try: + self.audit_repair(record, "approved", "unverified") + except (self.api.sqlite3.Error, ValueError) as error: + return self.stop(f"could not record repair approval before editing: {error}", + reported=reported, stop_reason="triage_repair_blocked") + + def edit_comment(comment_id: str, body: str) -> None: + result = self.api.subprocess.run( + ["gh", "api", f"repos/{self.repo_id}/issues/comments/{comment_id}", + "--method", "PATCH", "--raw-field", f"body={body}"], + cwd=self.cwd, capture_output=True, text=True, timeout=30) + if result.returncode != 0: + raise self.api.CycleDriverError("GitHub comment edit failed") + + try: + decision, result = self.api.triage_repair.execute_approved( + proposal, self.repair_approval, + lambda: self.remote.comments(self.repo_id, self.state.change_request_id), edit_comment) + except (self.api.CycleDriverError, self.api.HistoryError, OSError, self.api.subprocess.TimeoutExpired): + decision, result = "approved", "unverified" + try: + if decision == "approval_rejected": + self.audit_repair(record, decision, "failed") + else: + self.audit_repair(record, decision, result) + except (self.api.sqlite3.Error, ValueError) as error: + return self.stop(f"could not record repair result: {error}", + reported=reported, stop_reason="triage_repair_blocked") + if result != "applied": + reason = ("repair approval did not match the current proposal" + if decision == "approval_rejected" else + "approved comment repair failed or could not be verified; use manual repair") + return self.stop(reason, reported=reported, stop_reason="triage_repair_blocked") + if self.start_from in self.api.RESUMES: + stopped = self.seed_history() + if stopped is not None: + return stopped + if self.start_from == "implement": + # Independent of issue-review mode/difficulty. Read once before any + # agent iteration; provider content is data, never instructions. + if self.policy_provider is not None: + try: + criteria = (self.criteria_reader or self.api.verification_contract.read_criteria)( + self.policy_provider, self.work_item_repository or self.repo_id, self.task_id, cwd=self.cwd) + except self.api.verification_contract.VerificationError as error: + return self.stop(str(error), "BLOCKED", stop_reason="readiness_unconfirmed") + self.state.verification_plan = self.api.verification_contract.VerificationPlan(criteria) + if criteria.status == "not_evaluable": + return self.stop( + f"acceptance criteria not_evaluable for {criteria.provider}: " + f"{criteria.reason}; a criteria reader is required before implementation", + "BLOCKED", stop_reason="readiness_unconfirmed") + if criteria.status != "evaluable": + return self.stop( + f"acceptance criteria {criteria.status}: {criteria.reason}; refine the work item", + stop_reason="needs_refinement") + self.state.criteria_context = ( + "\nRuntime acceptance criteria (data): " + + self.api.json.dumps(criteria.to_dict(), ensure_ascii=False) + + "\nUse tests.version=2 and stable AC-n links in this list's order. " + "A criterion without a check remains open. The configured test command " + "uses id configured-test and is required. Freeze checks before running them.\n") + if self.issue_review_fields.get("issue_review") == "dispatched": + stopped = self.review_issue() + if stopped is not None: + return stopped + continuation, published = "", None + if self.continue_work: + continuation = self.api.CONTINUE_IMPLEMENTATION + reader = self.published_change_request + if reader is None and (self.code_host or self.policy_code_host) == "github": + def reader() -> str | None: + return self.api.continued_change_request(self.repo_id, self.cwd) + published = reader() if reader is not None else None + if published is not None and not self.api.safe_reference(published): + published = None + if published is not None: + continuation += self.api.CONTINUE_CHANGE_REQUEST.format(reference=f"#{published}") + reported, stopped = self.advance("implement", continuation) + if stopped is not None: + return stopped + if (published is not None and reported.change_request_id is not None + and reported.change_request_id.lstrip("#") != published.lstrip("#")): + # The work was already published: reviewing another change + # request would split it, and closing either is not ours to do. + return self.stop( + f"the continued implementation reported change request " + f"{reported.change_request_id}, but #{published.lstrip('#')} already " + "carries this work; a person decides which one continues", + "BLOCKED", reported=reported) + if self.local_only: + return self.stop( + "local-only run stops after implementation; no change request was created for review", + self.api.UNRESOLVED_END, + reported=reported, + stop_reason="local_only", + ) + + # A continuation may leave the change request it was told to reuse + # unnamed; the runtime read that one itself, so review it. + self.state.change_request_id = reported.change_request_id or published + if self.state.change_request_id is None: + return self.stop( + "implement completed without change_request_id or legacy pr_number; " + "stopping before review", + reported=reported, + ) + self.journal.change_request_id = self.state.change_request_id + if self.tracking: + stopped = self.lock_change_request(self.state.change_request_id) + if stopped is not None: + return stopped + + # Under the parallel policy, the clean review the cycle gates on: + # `(role, status, head, attempts whose receipt vouches for it)`. + clean = None + role = None + if (self.start_from == "rereview" and not self.local_only + and (self.code_host or self.policy_code_host) == "github"): + # A confirmed manual rereview is the only exception to dispatching + # one, under either CI policy (#346). + manual, stopped = self.manual_review() + if stopped is not None: + return stopped + if manual is not None: + return self.close_manual(manual, reported) + if self.parallel and self.start_from == "rereview": + clean, stopped = self.reusable_review() + if stopped is not None: + return stopped + if clean is not None: + role, verdict = clean[0], clean[1] + self.report.verdict = verdict + elif self.start_from in {"implement", "review"}: + role = "review" + reported, stopped = self.advance("review", change_request_id=self.state.change_request_id) + if stopped is not None: + return stopped + verdict = reported.status + self.report.verdict = verdict + elif self.start_from == "rereview": + role = "rereview" + reported, stopped = self.advance("rereview", "Re-review the change after the fixes.", + change_request_id=self.state.change_request_id) + if stopped is not None: + return stopped + verdict = reported.status + self.report.verdict = verdict + else: + # Resuming at the resolution: the change request's own comments carry + # the review it resolves, and the stage recovers those findings. The + # verdict is what this cycle acts on, not one it observed, so the + # report's verdict stays unset until a re-review gives one. + verdict = "CHANGES_REQUESTED" + + moved = False + ci_failure: ci_gate.GateResult | None = None + conflicting_round_done = False + while True: + if self.parallel and verdict in self.api.CLEAN_REVIEW_STATUSES: + # A clean review is no approval yet: the gate waits for its checks. + if clean is None: + head, stopped = self.clean_head(role, reported) + if stopped is not None: + return stopped + clean = (role, verdict, head, set(self.state.review_attempts)) + head = clean[2] + outcome = self.gate(clean[0], head, clean[3], reported) + clean = None + if isinstance(outcome, self.api.CycleReport): + return outcome + kind, result = outcome + if kind == "approved": + return self.stop("", self.api.APPROVED_END, reported=reported, stop_reason="approved") + if kind == "moved": + new = result.pr_head_sha + if moved or not isinstance(new, str) or self.api.FULL_SHA.fullmatch(new) is None: + return self.stop( + f"the change request's head moved {'again ' if moved else ''}" + f"after the clean review of {head[:12]}; a person decides " + "what to review", self.api.UNRESOLVED_END, reported=reported, + stop_reason="head_moved") + moved = True + if self.recorder.iteration >= self.max_iterations: + return self.stop( + f"the change request's head moved after the clean review of " + f"{head[:12]} and the resolution budget is spent", + reported=reported, stop_reason="iteration_limit") + # The review of the old head is not reused: the new head + # gets its own, once, as one round. + self.recorder.next_iteration() + role = "rereview" + reported, stopped = self.advance( + "rereview", self.api.HEAD_MOVED_REREVIEW.format(old=head, new=new), + change_request_id=self.state.change_request_id) + if stopped is not None: + return stopped + verdict = reported.status + self.report.verdict = verdict + continue + # The checks failed: their diagnosis is the next resolution's. + ci_failure = result + elif verdict != "CHANGES_REQUESTED": + if verdict not in self.api.CLEAN_REVIEW_STATUSES: + break + reviewed_head = self.api._reviewed_head( + reported.payload, role).get("reviewed_head_sha") + if reviewed_head is None and reported.payload: + candidate = reported.payload.get("head_sha") + reviewed_head = candidate if isinstance(candidate, str) else "" + mergeability = self.check_strict_mergeability(reviewed_head or "") + if isinstance(mergeability, self.api.CycleReport): + return mergeability + if mergeability is None: + break + if mergeability.status == self.api.ci_gate.CI_MERGEABILITY_UNKNOWN: + return self.stop( + f"GitHub could not determine whether the reviewed head can merge " + f"with its base after bounded retries: {self.api._ci_detail(mergeability)}", + "BLOCKED", reported=reported, + stop_reason="ci_unreadable") + ci_failure = mergeability + + # One resolution round: for the review's blocking findings, for the + # failed checks of a clean review's head, or for both. + survivals = self.api.claimed_fix_survivals(self.history) + self.recorder.repeated_findings = len(survivals) + repeated = sorted(finding for finding, count in survivals.items() + if count >= self.api.SURVIVALS_BEFORE_STOP) + if repeated: + return self.stop( + f"{', '.join(repeated)} survived {self.api.SURVIVALS_BEFORE_STOP} claimed " + "fixes; stopping before another resolution", + reported=reported, stop_reason="repeated_findings") + if (ci_failure is not None and ci_failure.status == self.api.ci_gate.CI_CONFLICTING + and conflicting_round_done): + return self.stop( + "the change request still conflicts with its base after the " + "resolution round", reported=reported, stop_reason="no_progress") + if len(self.progress) >= 2 and self.progress[-1] is not None and self.progress[-1] == self.progress[-2]: + return self.stop( + "the re-review found the same head and the same open findings " + "as the previous review", + reported=reported, stop_reason="no_progress") + if self.recorder.iteration >= self.max_iterations: + break + self.recorder.next_iteration() + + instruction = ( + self.api.CONFLICT_DIRECTIVE if ci_failure is not None + and ci_failure.status == self.api.ci_gate.CI_CONFLICTING else + "Resolve the failed checks of the reviewed head." + if ci_failure is not None else "Resolve the findings from the review.") + contested = sorted(finding for finding, count in survivals.items() + if count >= self.api.SURVIVALS_BEFORE_DIRECTIVE) + if contested: + instruction = (f"{instruction} " + f"{self.api.CONTESTED_DIRECTIVE.format(ids=', '.join(contested))}") + failed_checks = ci_failure + if self.parallel and failed_checks is None and role is not None: + # Changes requested while the checks run: one read, never a + # wait, so a failure already known reaches this resolution too. + reviewed = self.api._reviewed_head(reported.payload, role).get("reviewed_head_sha") + if reviewed: + snapshot = self.read_ci(reviewed, role, once=True) + if snapshot.status == self.api.ci_gate.CI_FAILED: + failed_checks = snapshot + if failed_checks is not None and failed_checks.status == self.api.ci_gate.CI_FAILED: + instruction += " " + self.api.CI_FAILURE_DIRECTIVE.format( + head=failed_checks.head_sha, count=len(failed_checks.failed)) + items = self.api.failed_check_items(failed_checks) + if items: + instruction += self.api.CI_FAILURE_OUTPUT.format(items="; ".join(items), + repo=self.repo_id) + open_findings = (self.history[-1].statuses if self.history + and self.history[-1].kind == self.api.REVIEW_RUN else {}) + escalation_ids = tuple(sorted( + finding for finding, count in survivals.items() + if (self.resolve_effort is None and count >= 1 + and open_findings.get(finding) == "open") + )[:16]) + resolved, stopped = self.advance( + "resolve", instruction, change_request_id=self.state.change_request_id, + resolve_target=self.state.previous_resolve_target if escalation_ids else None, + resolve_target_used_fallback=(self.state.previous_resolve_used_fallback + if escalation_ids else False), + resolve_escalated=bool(escalation_ids), + escalation_finding_ids=escalation_ids) + if stopped is not None: + return stopped + if ci_failure is not None and ci_failure.status == self.api.ci_gate.CI_CONFLICTING: + conflicting_round_done = True + if ci_failure is not None: + after = (resolved.payload or {}).get("head_sha") + if after == ci_failure.head_sha: + # Re-reviewing an unchanged head would repeat a review the + # CI state did not change; the failed checks still stand. + return self.stop( + f"the resolution of the failed checks of {after[:12]} left the " + "head unchanged, so the failures stand", + reported=resolved, stop_reason="no_progress") + ci_failure = None + + role = "rereview" + reported, stopped = self.advance("rereview", "Re-review the change after the fixes.", + change_request_id=self.state.change_request_id) + if stopped is not None: + return stopped + verdict = reported.status + self.report.verdict = verdict + + # The last stage's own account travels with every ending, not only the bad + # ones: a run that finished still said something about how. Under the + # parallel policy only the CI gate above approves. + if ci_failure is not None and ci_failure.status == self.api.ci_gate.CI_CONFLICTING: + return self.stop( + f"the change request still conflicts with its base after the " + f"resolution budget of {self.max_iterations} round(s) is spent", + reported=reported, stop_reason="iteration_limit") + if not self.parallel and verdict == "APPROVED": + return self.stop("", self.api.APPROVED_END, reported=reported, stop_reason="approved") + if ci_failure is not None: + return self.stop( + f"the checks of {ci_failure.head_sha[:12]} failed after its clean review " + f"and the budget of {self.max_iterations} round(s) is spent", + reported=reported, stop_reason="iteration_limit") + return self.stop(f"still {verdict} after {self.recorder.iteration} round(s)", + reported=reported, stop_reason="iteration_limit") diff --git a/scripts/cycle_publication.py b/scripts/cycle_publication.py new file mode 100644 index 0000000..998708f --- /dev/null +++ b/scripts/cycle_publication.py @@ -0,0 +1,218 @@ +"""Change-request history, publication receipts and repair proposals.""" +from __future__ import annotations +from typing import TYPE_CHECKING, Any +if TYPE_CHECKING: + from .run_cycle import CycleReport, Reported + +class PublicationHistory: + def lock_change_request(self: Any, request: str) -> CycleReport | None: + """Hold the per-machine writer lock on `request` until the cycle ends.""" + if self.state.history_store is None or self.state.history_lock is not None: + return None + try: + self.state.history_lock = (self.state.history_store, request, self.state.history_store.acquire(self.repo_id, request)) + except self.api.LockHeld as error: + return self.stop(f"{error}; another cycle is driving it", "BLOCKED") + except (self.api.sqlite3.Error, self.api.HistoryError): + # An unusable store is skipped; the comments remain the record. + self.state.history_store = None + return None + + def release_lock(self: Any) -> None: + # Released through the store that took it, even if the history was + # given up on since. + if self.state.history_lock is not None: + store, request, owner = self.state.history_lock + with self.api.contextlib.suppress(self.api.sqlite3.Error): + store.release(self.repo_id, request, owner) + + def expect_publication(self: Any, role: str, request: str | None, attempt_id) -> None: + if (not self.tracking or self.state.history_store is None or role not in self.api.COMMENT_STAGES + or not request or not isinstance(attempt_id, str)): + return + try: + self.state.history_store.expect_publication(attempt_id, self.repo_id, request, role) + except (self.api.sqlite3.Error, self.api.HistoryError): + self.state.history_store = None + + def retry_directive(self: Any, role: str, request: str | None) -> str: + """Name earlier attempts of `role` whose publication was never observed.""" + if self.state.history_store is None or role not in self.api.COMMENT_STAGES or not request: + return "" + try: + pending = self.state.history_store.pending_publications(self.repo_id, request, role) + except self.api.sqlite3.Error: + self.state.history_store = None + return "" + if not pending: + return "" + return " " + self.api.RETRY_PUBLICATION.format( + role=role, ids=", ".join(item["attempt_id"] for item in pending)) + + def repair_proposals(self: Any, request: str): + """Find uniquely provable repairs in trusted GitHub comments.""" + if not self.trusted_authors or self.state.history_store is None or self.code_host != "github": + return [] + try: + comments = self.remote.comments(self.repo_id, request) + except (self.api.CycleDriverError, self.api.HistoryError): + return [] + trusted = {author.casefold() for author in self.trusted_authors} + candidates = [] + for comment in comments: + if (not comment.author or comment.author.casefold() not in trusted + or not comment.id): + continue + try: + receipts = self.api.stage_evidence(comment.body) + except self.api.ContractError: + continue + if len(receipts) != 1: + continue + receipt = receipts[0] + if receipt.get("repo") != self.repo_id or receipt.get("stage") != "resolve": + continue + start_sha = self.api._safe_repair_start_head( + self.state.history_store, receipt["attempt_id"], self.repo_id, request) + proposal = self.api.triage_repair.propose_with_start( + self.repo_id, comment.id, comment.body, start_sha) + if proposal is None: + continue + url = comment.url or ( + f"https://github.com/{self.repo_id}/pull/{request}#issuecomment-{comment.id}") + diff = "\n".join(self.api.difflib.unified_diff( + proposal.original.splitlines(), proposal.repaired.splitlines(), + fromfile="original", tofile="proposed", lineterm="")) + record = { + "repo": self.repo_id, "comment_id": comment.id, "comment_url": url, + "original_content": proposal.original, "repaired_content": proposal.repaired, + "original_line": proposal.run_line, "repaired_line": proposal.repaired_line, + "sha": proposal.sha, "evidence": proposal.evidence, "diff": diff, + "digest": proposal.digest, + } + candidates.append((record, proposal)) + return candidates + + def audit_repair(self: Any, record: dict[str, Any], decision: str, result: str) -> None: + self.telemetry.record_triage_repair( + repo=self.repo_id, pull_number=str(self.state.change_request_id), + comment_id=record["comment_id"], + original_sha256=self.api.hashlib.sha256(record["original_content"].encode("utf-8")).hexdigest(), + repaired_sha256=self.api.hashlib.sha256(record["repaired_content"].encode("utf-8")).hexdigest(), + proposal_sha256=record["digest"], triaged_sha=record["sha"], + repair_type=record["evidence"], decision=decision, result=result) + + def attach_repair_proposal(self: Any, request: str) -> None: + """Attach one bounded, attempt-proven GitHub comment repair proposal.""" + candidates = self.repair_proposals(request) + if len(candidates) != 1: + return + record, _ = candidates[0] + if len(self.api.json.dumps(record, ensure_ascii=False)) <= 12000: + self.report.repair_proposal = record + self.audit_repair(record, "proposed", "unverified") + self.journal.record("decision_asked", component="repair", + decision_key=f"repair:{record['comment_id']}:{record['digest']}", + status="unanswered", reason="Approve the content-bound comment repair", + detail=record["diff"]) + else: + self.report.note("assisted repair proposal exceeds report bounds; use manual repair") + + def after_publication(self: Any, role: str, request: str, attempt_id, + reported: Reported) -> CycleReport | None: + """Reconcile the history after a publishing stage and check its outbox. + + A receipt from an attempt nobody here dispatched is another writer: + the history is already reconciled, and the cycle stops for whoever + owns the change request. Failed history reconciliation blocks after + publication; when reconciliation succeeds but this attempt's + publication is absent, the next dispatch of the same stage retries it. + """ + if not self.tracking: + return None + details = self.report.stage_details[-1] + if self.state.history_store is None: + try: + comments = self.remote.comments(self.repo_id, request) + for comment in comments: + if comment.author and comment.author.casefold() in { + author.casefold() for author in self.trusted_authors}: + self.api.validate_triage_comment(comment.body) + except self.api.ContractError as error: + self.attach_repair_proposal(request) + return self.stop( + f"published {role} comment cannot be read: contract validation failed: {error}", + "BLOCKED", reported=reported) + except self.api.CycleDriverError as error: + return self.stop( + f"published {role} comment validation could not complete: {error}", + "BLOCKED", reported=reported) + return None + try: + synced = self.state.history_store.sync(self.repo_id, request, trusted_authors=self.trusted_authors, + remote=self.remote) + published = (self.state.history_store.publication(attempt_id) + if isinstance(attempt_id, str) else None) + except self.api.ContractError as error: + self.attach_repair_proposal(request) + return self.stop( + f"published {role} comment cannot be read: contract validation failed: {error}", + "BLOCKED", reported=reported) + except (self.api.CycleDriverError, self.api.HistoryError) as error: + return self.stop( + f"published {role} comment validation could not complete: {error}", + "BLOCKED", reported=reported) + except self.api.sqlite3.Error: + self.state.history_store = None + return None + if synced.foreign: + return self.stop( + f"change request {request} received a publication from attempt(s) " + f"{', '.join(synced.foreign)}, which this machine did not dispatch, " + f"while {role} ran; the local history was reconciled, resume once " + "the other writer has finished", "BLOCKED", reported=reported) + if isinstance(attempt_id, str) and (published is None + or published["state"] != "published"): + details.warnings.append( + f"the {role} publication of attempt {attempt_id} was not observed on " + f"change request {request}; the next {role} retries it") + return None + + def seed_history(self: Any) -> CycleReport | None: + """Rebuild the claim and survival history of a resumed change request. + + An unreadable history is never an empty one. Without trusted authors + nobody can say whose comments count, so a person decides; a failed + fetch, a failed freshness check, a comment without an author, or a + trusted contract comment that cannot be parsed or placed blocks. No + contract comment at all is an empty history. A local history is used + only when verified; an unusable database is skipped and the history + rebuilt from the comments. Nothing is dispatched on a stop. + """ + if not any(author.strip() for author in self.trusted_authors): + return self.stop( + "the claimed-fix history of the resumed change request cannot be " + "rebuilt: review.trusted_authors is empty", + self.api.UNRESOLVED_END) + stopped = self.lock_change_request(self.state.change_request_id) + if stopped is not None: + return stopped + try: + # A remote that cannot be checked could never verify the store. + if self.state.history_store is not None and not isinstance(self.remote, self.api.ReaderRemote): + try: + self.history.extend(self.state.history_store.sync( + self.repo_id, self.state.change_request_id, trusted_authors=self.trusted_authors, + remote=self.remote).runs) + return None + except (self.api.sqlite3.Error, self.api.HistoryError): + # Unusable or unstorable: the comments remain the record. + self.state.history_store = None + self.history.extend(self.api.comment_run_history( + self.remote.comments(self.repo_id, self.state.change_request_id), + trusted_authors=self.trusted_authors)) + except (self.api.CycleDriverError, self.api.ContractError, self.api.HistoryError) as error: + return self.stop( + f"the claimed-fix history of the resumed change request is " + f"unreadable: {error}", "BLOCKED") + return None diff --git a/scripts/cycle_review_gate.py b/scripts/cycle_review_gate.py new file mode 100644 index 0000000..1b74fed --- /dev/null +++ b/scripts/cycle_review_gate.py @@ -0,0 +1,397 @@ +"""Review reuse and CI/manual completion gates.""" +from __future__ import annotations +from typing import TYPE_CHECKING, Any +if TYPE_CHECKING: + from .run_cycle import CycleReport, Reported, ci_gate +from review_contract import RUNTIME_PROVENANCE + +class ReviewGate: + def pull_number(self: Any) -> str | None: + return self.api._pull_number(self.state.change_request_id) + + def read_ci(self: Any, head: str, role: str | None, *, once: bool) -> ci_gate.GateResult: + """One read, or the one bounded wait, of the CI gate: recorded and shown. + + No stage runs while it waits and nothing else polls: under the parallel + policy this is the cycle's only wait on the code host's checks. + """ + self.control.move("ci") + number = self.pull_number() + if number is None: + result = self.api.ci_gate.GateResult( + status=self.api.ci_gate.CI_UNREADABLE, head_sha=head, + reason="the change request names no GitHub pull request number") + else: + timing = {} + if self.ci_clock is not None: + timing["clock"] = self.ci_clock + if self.ci_sleep is not None: + timing["sleep"] = self.ci_sleep + result = self.api.ci_gate.wait_for_checks( + self.checks_provider, self.repo_id, number, head, timeout=self.ci_timeout, + poll_interval=self.ci_poll_interval, once=once, + on_poll=lambda observed: self.status_writer.ci_observed( + observed, waiting=not once and observed.status == self.api.ci_gate.CI_PENDING), + **timing) + self.recorder.record_ci(result, mode="once" if once else "wait", role=role) + self.status_writer.ci_observed(result, waiting=False) + self.report.note(self.api._ci_line(result, once=once)) + return result + + def receipt_problem(self: Any, role: str, head: str, attempts: set[str], + provenance: str = RUNTIME_PROVENANCE) -> str | None: + """Why no trusted receipt on the change request vouches for a clean review. + + The PR comment is the record of a clean review awaiting its checks, so + approval never rests on the structured result alone. A trusted receipt + of one of `attempts` must name this stage, this head, a clean status + and `provenance`; none of their receipts may say otherwise; and nothing + may have been published after them. + """ + number = self.pull_number() + trusted = self.api.trusted_logins(self.trusted_authors) + if not trusted: + return "review.trusted_authors is empty, so no receipt can be authenticated" + if number is None or not attempts: + return "the review has no pull request number or recorded attempt to look for" + try: + comments = self.remote.comments(self.repo_id, number) + except self.api.CycleDriverError as error: + return f"the comments of change request {number} could not be read: {error}" + receipts = [] + for comment in comments: + author = (comment.author.strip().casefold() + if isinstance(comment.author, str) else None) + if author not in trusted: + continue + try: + found = self.api.stage_evidence(comment.body) + except self.api.ContractError: + if any(attempt in comment.body for attempt in attempts): + return "its receipt cannot be read" + continue + receipts.extend(receipt for receipt in found + if receipt["repo"] == self.repo_id + and receipt["change_request_id"] == number) + mine = [receipt for receipt in receipts if receipt["attempt_id"] in attempts] + if not mine: + return "no trusted comment carries its receipt" + if any(receipt["stage"] != role or receipt["head_sha"] != head + or receipt["status"] not in self.api.CLEAN_REVIEW_STATUSES for receipt in mine): + return "its receipt names another stage, head or status" + if any(self.api.receipt_provenance(receipt) != provenance for receipt in mine): + return f"its receipt is not a {provenance} receipt" + if receipts[-1]["attempt_id"] not in attempts: + return "another stage published after it" + return None + + def clean_head(self: Any, role: str, reported: Reported) -> tuple[str | None, CycleReport | None]: + """The head a clean review completed on, once its result and receipt hold.""" + problem = self.api.clean_review_problem(reported.payload, role) + if problem is not None: + return None, self.stop( + f"the {role} reported {reported.status} without a valid completed " + f"clean review: {problem}", reported=reported) + head = reported.payload["reviewed_head_sha"] + problem = self.receipt_problem(role, head, self.state.review_attempts) + if problem is not None: + return None, self.stop( + f"the {role} reported {reported.status} for {head[:12]}, but {problem}; " + "the receipt on the change request is the record of a clean review", + "BLOCKED", reported=reported) + return head, None + + def gate(self: Any, role: str, head: str, attempts: set[str], reported: Reported, + provenance: str = RUNTIME_PROVENANCE): + """Wait for the checks of a clean review's head; approve only on a pass. + + Returns `("approved" | "moved" | "failed", result)`, or the report of a + stop. The final barrier is all of: every required check of `head` + passed while it was still the change request's head, and the clean + review's receipt still holds with nothing published after it. + """ + result = self.read_ci(head, role, once=False) + number = self.pull_number() or self.state.change_request_id + if result.status == self.api.ci_gate.CI_PASSED: + problem = self.receipt_problem(role, head, attempts, provenance) + if problem is not None: + return self.stop( + f"the checks of {head[:12]} passed, but the clean {role}'s receipt " + f"no longer holds: {problem}", "BLOCKED", reported=reported) + return "approved", result + if result.status == self.api.ci_gate.CI_HEAD_MOVED: + return "moved", result + if result.status == self.api.ci_gate.CI_CONFLICTING: + return "conflicting", result + if result.status == self.api.ci_gate.CI_MERGEABILITY_UNKNOWN: + return self.stop( + f"GitHub could not determine whether the change request at {head[:12]} " + f"can merge with its base after bounded retries: {self.api._ci_detail(result)}", + "BLOCKED", reported=reported, stop_reason="ci_unreadable") + if result.status == self.api.ci_gate.CI_FAILED: + return "failed", result + detail = self.api._ci_detail(result) + if result.status == self.api.ci_gate.CI_TIMEOUT: + return self.stop( + f"the {role} of {head[:12]} is clean and stays valid for that head, but " + f"its checks had not all passed after {self.api._duration(self.ci_timeout)}: {detail}; " + f"resume with --from rereview --pr {number} --ci-policy parallel to keep " + "waiting without another review", + "BLOCKED", reported=reported, stop_reason="ci_timeout") + if result.status in {self.api.ci_gate.CI_SKIPPED, self.api.ci_gate.NO_CI_CHECKS_FOUND}: + return self.stop( + f"the {role} of {head[:12]} is clean, but its checks cannot pass " + f"({result.status}): {detail}; approval needs every required check " + "to succeed on that head", + self.api.UNRESOLVED_END, reported=reported, stop_reason="ci_not_passing") + return self.stop(f"the checks of {head[:12]} could not be read: {detail}", + "BLOCKED", reported=reported, stop_reason="ci_unreadable") + + def check_strict_mergeability(self: Any, head: str) -> ci_gate.GateResult | CycleReport | None: + """Check GitHub mergeability before a sequential-policy approval.""" + if self.code_host != "github": + return None + number = self.pull_number() + if number is None: + return self.stop("the reviewed change request has no GitHub pull request number", + "BLOCKED", stop_reason="ci_unreadable") + for attempt in range(3): + try: + pull = self.checks_provider.pull(self.repo_id, number) + except self.api.ci_gate.CheckReadError as error: + return self.stop(f"the change request mergeability could not be read: {error}", + "BLOCKED", stop_reason="ci_unreadable") + if pull.state != "OPEN": + return self.stop(f"the change request is {pull.state.lower()}, not open", + "BLOCKED", stop_reason="ci_unreadable") + if pull.head_sha != head: + return self.stop( + f"the change request head moved from {head[:12]} to " + f"{pull.head_sha[:12]} before mergeability was checked", + "BLOCKED", stop_reason="head_moved") + if pull.mergeable is True: + return None + if pull.mergeable is False and pull.mergeable_state == "dirty": + return self.api.ci_gate.GateResult( + status=self.api.ci_gate.CI_CONFLICTING, head_sha=head, pr_head_sha=head, + reason="the change request conflicts with its base branch") + if attempt < 2: + (self.ci_sleep or time.sleep)(min(self.ci_poll_interval, 5)) + return self.api.ci_gate.GateResult( + status=self.api.ci_gate.CI_MERGEABILITY_UNKNOWN, head_sha=head, pr_head_sha=head, + reason="GitHub could not determine whether the change request is mergeable") + + def reusable_review(self: Any): + """The clean review a resumed change request already has, when it holds. + + The receipt on the change request is the record: when the latest + trusted receipt is a clean review or re-review of the current head and + the published findings block nothing, the review is not run again and + the cycle only waits for its checks. Anything else reviews the head. + Returns `((role, status, head, attempts) | None, stop | None)`. + """ + number = self.pull_number() + if number is None: + return None, self.stop( + f"the parallel CI policy needs a GitHub pull request number, not " + f"{self.state.change_request_id}", "BLOCKED", stop_reason="ci_unreadable") + try: + comments = self.remote.comments(self.repo_id, number) + pull = self.checks_provider.pull(self.repo_id, number) + except (self.api.CycleDriverError, self.api.ci_gate.CheckReadError) as error: + return None, self.stop( + f"whether change request {number} already has a clean review could " + f"not be read: {error}", "BLOCKED", stop_reason="ci_unreadable") + if pull.state != "OPEN": + return None, self.stop( + f"change request {number} is {pull.state.lower()}, not open", "BLOCKED") + self.recorder.review_reused = False + try: + receipts = [receipt for receipt in self.api.trusted_receipts( + comments, trusted_authors=self.trusted_authors) + if receipt["repo"] == self.repo_id + and receipt["change_request_id"] == number] + published = self.api.recover_comment_history(comments, trusted_authors=self.trusted_authors) + except self.api.ContractError as error: + self.report.note(f" resume no clean review reused: {self.api.readable(str(error), 200)}") + return None, None + latest = receipts[-1] if receipts else None + # A manual rereview is never reused for being clean and trusted; only + # `manual_review` accepts one, on its own conditions. + if (latest is None or latest["stage"] not in {"review", "rereview"} + or latest["status"] not in self.api.CLEAN_REVIEW_STATUSES + or self.api.receipt_provenance(latest) != self.api.RUNTIME_PROVENANCE): + return None, None + if latest["head_sha"] != pull.head_sha: + self.report.note( + f" resume the clean {latest['stage']} of {latest['head_sha'][:12]} " + f"does not hold for the current head {self.api.readable(pull.head_sha, 12)}; " + "reviewing the current head") + return None, None + if any(finding.status == "open" and finding.blocks_approval + for finding in published.findings): + self.report.note(" resume the published findings still block approval; " + "reviewing again") + return None, None + self.recorder.review_reused = True + self.report.note( + f" resume reused the clean {latest['stage']} of {latest['head_sha'][:12]} " + "from its receipt; no review dispatched") + return (latest["stage"], latest["status"], latest["head_sha"], + {latest["attempt_id"]}), None + + def manual_step(self: Any, number: str, head: str) -> str: + """The command that records a manual rereview of `head` (#346).""" + return (f"python3 manual_rereview.py record --repo {self.repo_id} --pr {number} " + f"--status APPROVED --confirm-head {head} " + "--check RESULT:NAME:EVIDENCE") + + def manual_hint(self: Any, reported: Reported) -> str: + """What closes a cycle whose rereview could not complete here (#346).""" + number = self.pull_number() + if number is None: + return "" + head = (reported.payload or {}).get("head_sha") + if not isinstance(head, str) or self.api.FULL_SHA.fullmatch(head) is None: + head = "" + return ("; a rereview done outside the runtime has no receipt until it is " + f"recorded with `{self.manual_step(number, head)}`, then resume with " + f"--from rereview --pr {number}") + + def manual_review(self: Any): + """The confirmed manual rereview a resume closes on, when it holds (#346). + + Considered only when the latest trusted receipt of the change request + is a manual one; anything else returns `(None, None)` and the resume + goes on as before. It is accepted only when the account that published + it is the operator it names, it is a clean rereview confirmed for the + change request's current head, nothing readable was published after + it, and every published finding that still blocks approval is one it + verified. Otherwise the cycle stops for a person with the missing + step: a manual rereview is never replaced by a runtime one behind the + operator's back, nor upgraded to one. + Returns `((role, status, head, attempts) | None, stop | None)`. + """ + number = self.pull_number() + trusted = self.api.trusted_logins(self.trusted_authors) + if number is None or not trusted: + return None, None + try: + comments = self.remote.comments(self.repo_id, number) + except (self.api.CycleDriverError, self.api.HistoryError): + return None, None + latest, publisher = None, None + for comment in comments: + author = (comment.author.strip().casefold() + if isinstance(comment.author, str) else None) + if author not in trusted: + continue + try: + found = self.api.stage_evidence(comment.body) + except self.api.ContractError: + # Unreadable, so it cannot be ruled out as the latest. + latest, publisher = None, None + continue + for receipt in found: + if receipt["repo"] == self.repo_id and receipt["change_request_id"] == number: + latest, publisher = receipt, author + if latest is None or self.api.receipt_provenance(latest) != self.api.MANUAL_PROVENANCE: + return None, None + try: + pull = self.checks_provider.pull(self.repo_id, number) + except self.api.ci_gate.CheckReadError as error: + return None, self.stop( + f"the manual rereview of change request {number} could not be " + f"checked against its head: {error}", "BLOCKED", + stop_reason="ci_unreadable") + if pull.state != "OPEN": + return None, self.stop( + f"change request {number} is {pull.state.lower()}, not open", "BLOCKED") + reviewed = latest["head_sha"] + step = self.manual_step(number, pull.head_sha) + + def rejected(problem: str) -> tuple[None, CycleReport]: + return None, self.stop( + f"the latest receipt of change request {number} is a manual rereview " + f"of {reviewed[:12]}, but {problem}; a manual rereview is never " + "upgraded to a runtime one", self.api.UNRESOLVED_END, + stop_reason="manual_rereview_rejected") + + if latest["operator"].strip().casefold() != publisher: + return rejected( + f"it names operator {self.api.readable(latest['operator'], 40)!r} while " + f"{self.api.readable(publisher or '', 40)!r} published it") + if reviewed != pull.head_sha: + return rejected( + f"the current head is {self.api.readable(pull.head_sha, 12)}: a new head " + "invalidates the confirmation. Review the current head and record " + f"it with `{step}`, or resume with --from review to have the " + "runtime review it") + if latest["status"] not in self.api.CLEAN_REVIEW_STATUSES: + return rejected( + f"it requested changes ({latest['status']}). Resolve them with " + f"--from resolve --pr {number}, then rereview the new head") + try: + published = self.api.recover_comment_history(comments, trusted_authors=self.trusted_authors) + except self.api.ContractError as error: + return rejected(f"the published review history cannot be read: " + f"{self.api.readable(str(error), 200)}") + verified = {item["id"] for item in latest.get("finding_outcomes", []) + if item["status"] in {"resolved", "not_applicable"}} + blocking = sorted(finding.id for finding in published.findings + if finding.status == "open" and finding.blocks_approval + and finding.id not in verified) + if blocking: + return rejected( + f"published findings still block approval and it verified none of " + f"them: {', '.join(blocking)}. Record their outcomes with " + "--finding REV-NNN=resolved in a new manual record") + self.report.review_provenance = self.api.MANUAL_PROVENANCE + self.recorder.review_provenance = self.api.MANUAL_PROVENANCE + declared = "; ".join( + f"{self.api.readable(item['name'], 60)} {item['result']}" for item in latest["checks"]) + self.report.note( + f" resume accepted the manual rereview of {reviewed[:12]} by " + f"{self.api.readable(latest['operator'], 40)} ({latest['attempt_id']}); checks " + f"declared by the operator, not run by the runtime: {declared}") + self.journal.record( + "stage_result", role="rereview", stage_seq=self.recorder.stage_seq, + component="runtime", status="manual_rereview_accepted", + reason="the confirmed manual rereview of the current head is the review " + "the cycle closes on, after its required checks pass", + correlation={"head_sha": reviewed, "attempt_id": latest["attempt_id"], + "review_provenance": self.api.MANUAL_PROVENANCE}) + return ("rereview", latest["status"], reviewed, {latest["attempt_id"]}), None + + def close_manual(self: Any, clean, reported: Reported) -> CycleReport: + """Approve a confirmed manual rereview only once its head's checks pass. + + The same CI gate as a runtime review, under either CI policy: a manual + rereview never bypasses a required check, a conflict, or a later + publication. What it cannot approve goes to a person, never to a + runtime resolution it did not ask for. + """ + role, verdict, head, attempts = clean + self.report.verdict = verdict + number = self.pull_number() or self.state.change_request_id + outcome = self.gate(role, head, attempts, reported, provenance=self.api.MANUAL_PROVENANCE) + if isinstance(outcome, self.api.CycleReport): + return outcome + kind, result = outcome + if kind == "approved": + return self.stop("", self.api.APPROVED_END, reported=reported, stop_reason="approved") + if kind == "moved": + new = self.api.readable(result.pr_head_sha or "", 40) + return self.stop( + f"the change request's head moved after the manual rereview of " + f"{head[:12]}, which invalidates its confirmation; review the new " + f"head and record it with `{self.manual_step(number, new)}`", + self.api.UNRESOLVED_END, reported=reported, stop_reason="head_moved") + detail = self.api._ci_detail(result) + if kind == "conflicting": + detail = "the change request conflicts with its base branch" + return self.stop( + f"the manual rereview of {head[:12]} is clean, but {detail}; a manual " + f"rereview never bypasses the required checks: resolve with --from " + f"resolve --pr {number}", self.api.UNRESOLVED_END, reported=reported, + stop_reason="ci_not_passing") diff --git a/scripts/cycle_session.py b/scripts/cycle_session.py new file mode 100644 index 0000000..47e2c4d --- /dev/null +++ b/scripts/cycle_session.py @@ -0,0 +1,312 @@ +"""Cycle lifetime, shared state and reporting.""" +from __future__ import annotations +from typing import TYPE_CHECKING, Any +if TYPE_CHECKING: + from .run_cycle import CycleHistoryStore, CycleInterrupted, CycleReport, Reported, RunStatuses + from . import verification_plan as verification_contract + + +if TYPE_CHECKING: + from .cycle_state import SessionState + from .cycle_dispatch import StageDispatch + from .cycle_publication import PublicationHistory + from .cycle_review_gate import ReviewGate + from .cycle_loop import CycleControl, StageLoop + from .workspace_setup import commit_setup, own_file +else: + from cycle_state import SessionState + from cycle_dispatch import StageDispatch + from cycle_publication import PublicationHistory + from cycle_review_gate import ReviewGate + from cycle_loop import CycleControl, StageLoop + from workspace_setup import commit_setup, own_file + +class CycleSession(StageDispatch, PublicationHistory, ReviewGate, StageLoop): + def __init__(self, api: Any, options: dict[str, Any]): + # The facade supplies dependencies so existing integrations keep + # the same public names and injection points. + self.api = api + self.state = SessionState() + self.control = CycleControl() + self.repo_id = options["repo_id"] + self.task_id = options["task_id"] + self.signals = options["signals"] + self.telemetry = options["telemetry"] + self.profiles = options["profiles"] + self.registry = options["registry"] + self.availability = options["availability"] + self.mode = options["mode"] + self.routing_strategy = options["routing_strategy"] + self.policy = options["policy"] + self.max_iterations = options["max_iterations"] + self.cwd = options["cwd"] + self.timeout = options["timeout"] + self.local_only = options["local_only"] + self.change_bases = options["change_bases"] + self.verification_available = options["verification_available"] + self.jev = options["jev"] + self.shadow = options["shadow"] + self.verbose = options["verbose"] + self.progress_interval = options["progress_interval"] + self.start_from = options["start_from"] + self.state.change_request_id = options["change_request_id"] + self.issue_review = options["issue_review"] + self.continue_work = options["continue_work"] + self.worktree_dir = options["worktree_dir"] + self.workspace = options["workspace"] + self.work_item_provider = options["work_item_provider"] + self.work_item_repository = options["work_item_repository"] + self.code_host = options["code_host"] + self.trusted_authors = options["trusted_authors"] + self.resolve_effort = options["resolve_effort"] + self.comment_reader = options["comment_reader"] + self.allowed_test_command = options["allowed_test_command"] + self.allowed_verify_commands = options["allowed_verify_commands"] + self.user_tool_choices = options["user_tool_choices"] + self.base_branch = options["base_branch"] + self.policy_code_host = options["policy_code_host"] + self.history_remote = options["history_remote"] + self.ci_policy = options["ci_policy"] + self.ci_timeout = options["ci_timeout"] + self.consent = options["consent"] + self.ci_checks = options["ci_checks"] + self.ci_poll_interval = options["ci_poll_interval"] + self.ci_clock = options["ci_clock"] + self.ci_sleep = options["ci_sleep"] + self.repair_approval = options["repair_approval"] + self.published_change_request = options["published_change_request"] + self.publication_auditor = options["publication_auditor"] + self.criteria_reader = options["criteria_reader"] + self.api.validate_start(self.start_from, self.state.change_request_id, local_only=self.local_only, + continue_work=self.continue_work) + self.api.check_ci_policy(self.ci_policy, self.code_host) + self.issue_review = self.api.IssueReviewMode(self.issue_review) + if self.shadow is None and self.jev is not None and self.jev.enabled: + self.shadow = self.api.JevShadow(self.jev) + if self.local_only: + self.api.validate_local_only_cwd(self.cwd) + self.registry = self.registry or self.api.Registry() + self.policy = self.policy or self.api.ReadinessPolicy.for_mode(self.mode) + self.probes = self.registry.probe_all() + if self.availability is None: + self.availability = self.registry.availability(self.policy, self.probes) + self.recorder = self.api.CycleRecorder( + self.telemetry, self.repo_id, self.task_id, self.signals, + availability=self.availability, registry=self.registry, mode=self.mode, policy=self.policy, + probes=self.probes, profiles=self.profiles, local_only=self.local_only, + continue_work=self.continue_work, + started_from=self.start_from, + routing_strategy=self.routing_strategy, + change_observer=( + (lambda: self.api.collect_change_signals(self.cwd, self.change_bases)) + if self.change_bases else None + ), + verification_available=self.verification_available, + work_item_provider=self.work_item_provider, + work_item_repository=self.work_item_repository, + shadow=self.shadow, + skills_by_role=self.api.SKILL_FOR_ROLE, + change_request_id=self.state.change_request_id, + resolve_effort=self.resolve_effort, + ci_policy=self.ci_policy, + consent=self.consent, + ) + self.journal = self.api.Journal(self.telemetry.path, self.repo_id, self.task_id, self.recorder.cycle_id, + change_request_id=self.state.change_request_id) + self.recorder.journal = self.journal + self.previous_consent_observer = self.consent.observer if self.consent is not None else None + if self.consent is not None: + self.consent.observer = self.journal.consent_event + # Read before this run asks anything: a `--grant` answer is stored only + # for a scope an earlier run of the work item showed the person. + self.consent.asked = self.consent.asked | self.api.asked_scopes(self.telemetry.path, self.repo_id, self.task_id) + for self.grant_id, self.answer in self.consent.answers.items(): + self.journal.record("decision_answered", component="--grant", + decision_key=f"grant:{self.grant_id}", status=self.answer) + self.checkout_branch = self.api._git_line(self.cwd, ["branch", "--show-current"], 30) + self.consent_scopes = None + if self.consent is not None and self.consent.store is not None: + try: + self.consent_scopes = self.consent.store.allowed_scopes(self.repo_id) + except self.api.GrantError: + pass + self.journal.reconcile_permissions(consents=self.consent_scopes, user_choices=self.user_tool_choices, + branch=self.checkout_branch) + self.status_writer = self.api.CycleStatusWriter( + self.telemetry.path, self.recorder.cycle_id, self.repo_id, self.task_id, + progress_interval=self.progress_interval, verbose=self.verbose, + workspace=self.cwd or self.api.Path.cwd(), + ) + self.recorder.stage_started = self.progress_stage_started + self.recorder.on_dispatch_started = commit_setup + self.recorder.on_progress = self.status_writer.activity + self.recorder.on_workspace = self.status_writer.workspace_observed + own_file(self.status_writer.path, "cycle status before first dispatch") + self.status_writer.start() + self.report = self.api.CycleReport(repo_id=self.repo_id, task_id=self.task_id) + self.noticed: set[str] = set() + self.dispatch_kwargs = {} + if self.cwd: + self.dispatch_kwargs["cwd"] = self.cwd + self.common_directory = self.api._git_common_directory(self.cwd) + if self.common_directory: + self.worktree_directory = self.api._git_worktree_directory(self.cwd) + self.dispatch_kwargs["writable_dirs"] = tuple(dict.fromkeys( + directory for directory in (self.common_directory, self.worktree_directory) + if directory + )) + if self.timeout is not None: + self.dispatch_kwargs["timeout"] = self.timeout + self.state.previous_resolve_target = None + self.state.previous_resolve_used_fallback = False + self.policy_host = self.code_host or self.policy_code_host + self.policy_provider = self.work_item_provider or ( + "github" if self.policy_host == "github" and str(self.task_id).isdecimal() else None) + self.state.verification_plan = None + self.state.criteria_context = "" + if self.history_remote is not None: + self.remote, self.tracking = self.history_remote, True + elif self.comment_reader is not None: + self.remote, self.tracking = self.api.ReaderRemote(self.comment_reader, self.cwd), False + elif self.code_host == "github": + self.remote, self.tracking = self.api.GitHubHistoryRemote(self.cwd), True + else: + self.remote, self.tracking = self.api.ReaderRemote(self.api.read_change_request_comments, self.cwd), False + self.tracking = self.tracking and not self.local_only + self.state.history_store = self.api.CycleHistoryStore(self.telemetry.path) + self.state.history_lock = None + self.state.pending_recovery = None + self.state.review_attempts = set() + self.parallel = self.ci_policy == "parallel" and not self.local_only + self.history: list[RunStatuses] = [] + self.progress: list[tuple[str, frozenset[str]] | None] = [] + self.issue_review_fields = {} + if self.continue_work: + # The interrupted run already passed, or skipped, the readiness gate. + self.issue_review_fields["continued"] = True + elif self.start_from == "implement": + self.issue_review_fields["issue_review"] = self.api.issue_review_decision( + self.issue_review, self.signals) + self.checks_provider = self.ci_checks if self.ci_checks is not None else self.api.ci_gate.GitHubChecks(self.cwd) + + def progress_stage_started(self, role, decision) -> None: + iteration = self.recorder.iteration if role in {"review", "resolve", "rereview"} else None + self.status_writer.stage_started(role, decision, iteration=iteration) + + def journal_result(self, role, outcome, reported, attempt_id) -> None: + payload = reported.payload or {} + # A stage's report cannot redirect attribution or retention. The driver + # owns this reference and accepts a new implementation's validated ID + # only after its existing continuation and publication checks below. + self.journal.change_request_id = self.state.change_request_id + self.journal.record("stage_result", role=role, stage_seq=self.recorder.stage_seq, + attempt_id=attempt_id, component="runtime", status=reported.status, + detail=reported.reason, correlation={"head_sha": payload.get("head_sha")}) + transport = (outcome.result.artifacts if outcome.result else {}).get("publication_transport", []) + for event in transport if isinstance(transport, list) else []: + if not isinstance(event, dict): + continue + attempts = event.get("attempts", []) + for attempt in attempts if isinstance(attempts, list) else []: + if not isinstance(attempt, dict): + continue + command = attempt.get("command") + if not isinstance(command, list) or not all(isinstance(word, str) for word in command): + continue + self.journal.record( + "stage_result", role=role, stage_seq=self.recorder.stage_seq, + attempt_id=attempt_id, component="publication_transport", + status=event.get("status"), command=self.api.shlex.join(command), + detail=attempt.get("error"), + correlation={"head_sha": event.get("head_sha"), + "remote_head_sha": event.get("remote_head_sha"), + "exit_code": attempt.get("exit_code")}) + questions = payload.get("questions") + if isinstance(questions, list): + for question in questions: + if isinstance(question, dict): + self.journal.record("decision_asked", role=role, stage_seq=self.recorder.stage_seq, + attempt_id=attempt_id, component="stage_result", + decision_key=f"{attempt_id}:{question.get('id')}", + status="unanswered", reason=question.get("prompt"), + detail=self.api.json.dumps(question.get("options"))) + + def stop(self, because: str, status: str = "HUMAN_INTERVENTION", + reported: Reported | None = None, *, + stop_reason: str = "stage_not_completed") -> CycleReport: + self.control.move("stop") + if self.report.stages and self.report.stage_details: + # The exact command a permission stopped, in the stop line, so a + # person can authorize it once and resume (#142). + denied = self.report.stage_details[-1].blocked_commands + if denied: + more = f" (and {len(denied) - 1} more)" if len(denied) > 1 else "" + because += (f"; blocked command in the {self.report.stages[-1].role} stage: " + f"`{denied[0]}`{more}") + self.report.stopped_because = because + self.report.status = status + self.report.iterations = self.recorder.iteration + self.report.stop_reason = stop_reason + if reported is not None: + self.report.reason = reported.reason + refusals = self.journal.refusals(stage_seq=self.recorder.stage_seq) + for refusal in refusals: + citation = (f"refused by {refusal['component']}: {refusal['command'] or '(no command)'}" + f" — {refusal['reason']}") + if citation not in because: + because += f"; {citation}" + self.report.stopped_because = because + self.journal.record("stop", stage_seq=self.recorder.stage_seq, + role=self.report.stages[-1].role if self.report.stages else None, + component="runtime", status=status, reason=because, + detail=self.report.reason, correlation={"stop_reason": stop_reason}) + try: + from cycle_journal import explain as explain_journal + self.report.journal_explanation = explain_journal( + self.journal.path, self.repo_id, self.task_id, self.recorder.cycle_id) + except Exception: + pass + for error in self.journal.errors: + self.report.note(f" journal {error}") + self.recorder.close(status, stop_reason=stop_reason, **self.issue_review_fields) + self.status_writer.finish( + status, + stop_reason=stop_reason, + reason=because, + readiness=self.report.readiness, + review_provenance=self.report.review_provenance, + ) + return self.report + + def interrupted(self, interruption: CycleInterrupted) -> CycleReport: + """Close an interrupted cycle so its record says what happened.""" + self.control.move("interrupt") + outcome = interruption.outcome + where = "between stages" + if outcome is not None: + where = f"during {outcome.role}" + self.report.stages.append(outcome) + duration = self.status_writer.stage_finished(outcome.result, None) + self.report.stage_details.append(self.api.StageReportDetails( + round=self.recorder.iteration, status=None, duration_seconds=duration)) + return self.stop( + f"interrupted by {interruption.signal_name} {where}; the checkout " + "was left as it was", + stop_reason="interrupted") + + def execute(self): + try: + try: + self.api.journal_cleanup(self.telemetry.path, self.repo_id, + (lambda request: self.checks_provider.pull(self.repo_id, request).state) + if self.policy_host == "github" else None) + except Exception as error: + self.report.note(f" journal cleanup failed ({type(error).__name__}); detail retained") + return self.drive() + except self.api.CycleInterrupted as interruption: + return self.interrupted(interruption) + finally: + self.journal.end_cycle() + if self.consent is not None: + self.consent.observer = self.previous_consent_observer + self.release_lock() diff --git a/scripts/cycle_state.py b/scripts/cycle_state.py new file mode 100644 index 0000000..0fc7a43 --- /dev/null +++ b/scripts/cycle_state.py @@ -0,0 +1,23 @@ +"""Mutable state shared by dispatch, history, and the stage loop.""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from .cycle_history import CycleHistoryStore + from .router import Target + from .verification_plan import VerificationPlan + + +@dataclass +class SessionState: + change_request_id: str | None = None + previous_resolve_target: Target | None = None + previous_resolve_used_fallback: bool = False + review_attempts: set[str] = field(default_factory=set) + pending_recovery: dict[str, Any] | None = None + verification_plan: VerificationPlan | None = None + criteria_context: str = "" + history_store: CycleHistoryStore | None = None + history_lock: tuple[CycleHistoryStore, str, str] | None = None diff --git a/scripts/cycle_workspace.py b/scripts/cycle_workspace.py new file mode 100644 index 0000000..09c3470 --- /dev/null +++ b/scripts/cycle_workspace.py @@ -0,0 +1,203 @@ +"""Task-worktree selection and setup ownership.""" +from __future__ import annotations +from typing import TYPE_CHECKING, Any +from pathlib import Path + + +if TYPE_CHECKING: + from .workspace_setup import current_setup +else: + from workspace_setup import current_setup + +def _ref(api, root, name, run): + result = api._selection_result( + ["git", "rev-parse", "--verify", "--quiet", name], root, run) + return result.stdout.strip() if result.returncode == 0 else None + + +def _own_ref(api, root, name, run, *, fetched=False): + """Delete only an unchanged ref created by this setup and now unused.""" + setup = current_setup() + sha = _ref(api, root, name, run) + if setup is None or sha is None: + return + + def compensate(): + if _ref(api, root, name, run) != sha: + raise RuntimeError("ref changed since setup; retained") + if fetched: + users = api._selection_text( + ["git", "for-each-ref", f"--contains={sha}", "--format=%(refname)", "refs/heads/"], + root, "check fetched-ref users", run) + worktrees = api._worktree_records(root, run) + detached_users = [item for item in worktrees if not item["branch"] and + api._selection_result( + ["git", "merge-base", "--is-ancestor", sha, "HEAD"], + Path(item["path"]), run).returncode == 0] + if users or detached_users: + raise RuntimeError("fetched ref is in use; retained") + else: + branch = name.removeprefix("refs/heads/") + if any(item["branch"] == branch for item in api._worktree_records(root, run)): + raise RuntimeError("branch is in use by a worktree; retained") + api._selection_text(["git", "update-ref", "-d", name, sha], root, + "roll back created ref", run) + + setup.record("fetched ref" if fetched else "branch", name, + "GitHub PR fetch" if fetched else "task worktree setup", compensate) + + +def _own_worktree(api, root, target, run, herdr_active): + setup = current_setup() + registered = any(Path(item["path"]) == target for item in api._worktree_records(root, run)) + if setup is not None and registered: + setup.record("worktree", target, "Herdr" if herdr_active else "git worktree add", + lambda: api._selection_text( + ["git", "worktree", "remove", str(target)], root, + "roll back created worktree", run)) + +def select_workspace(api: Any, work_item_id: str, repo_id: str, config: dict[str, Any], *, + workspace: str = "task", cwd: str | None = None, + change_request_id: str | None = None, + continue_work: bool = False, run=None) -> tuple[str, Path | None]: + """Select one task worktree before any cycle stage can mutate the checkout.""" + run = run or api.subprocess.run + current = api.Path(cwd or api.Path.cwd()).expanduser().resolve() + if not current.is_dir(): + raise api.CycleDriverError(f"BLOCKED: workspace does not exist: {current}") + if workspace not in api.WORKSPACE_MODES: + raise api.CycleDriverError(f"unknown workspace mode: {workspace}") + if workspace == "current": + return str(current), None + root_text = api._selection_text( + ["git", "rev-parse", "--show-toplevel"], current, + "locate the Git repository", run, + ) + repository_root = api.Path(root_text).resolve() + main_root = api._main_repository_root(repository_root, run) + herdr_active = api.os.environ.get("HERDR_ENV") == "1" + requested_branch = ( + api._pull_request_branch(repo_id, change_request_id, work_item_id, repository_root, run) + if change_request_id else None + ) + records = api._worktree_records(main_root, run) + matches = [ + api.Path(item["path"]) for item in records + if item["branch"] and api._is_linked_worktree(api.Path(item["path"])) + and ((item["branch"] == requested_branch) if requested_branch + else api.branch_identifies_work_item(item["branch"], work_item_id)) + ] + if len(matches) > 1: + names = ", ".join(str(path) for path in matches) + raise api.CycleDriverError( + f"BLOCKED: more than one linked worktree matches work item {work_item_id}: " + f"{names}. Pass --cwd for the intended worktree or --workspace current." + ) + if matches: + selected = matches[0] + if herdr_active and selected != repository_root: + api._selection_text( + ["herdr", "worktree", "open", "--cwd", str(main_root), + "--path", str(selected), "--no-focus"], + main_root, f"open linked worktree {selected} in Herdr", run, + ) + # Resolve the configured base from the main root even when reusing a + # linked checkout, so worker worktrees are not nested inside it. + return str(selected), api.resolve_worktree_dir(config, main_root) + if continue_work: + raise api.CycleDriverError( + "BLOCKED: no active linked worktree matches the interrupted task. " + "Provide its path with --cwd, or explicitly opt into the invoking " + "checkout with --workspace current." + ) + + worktree_dir = api.resolve_worktree_dir(config, main_root) + slug = api.re.sub(r"[^A-Za-z0-9._-]+", "-", str(work_item_id).strip()).strip(".-") + if not slug: + raise api.CycleDriverError(f"BLOCKED: work item {work_item_id!r} has no safe worktree name") + branch = f"task-{slug}" + target = (worktree_dir / branch).resolve() + try: + target.relative_to(worktree_dir.resolve()) + except ValueError as error: + raise api.CycleDriverError("BLOCKED: task worktree path escapes its configured base") from error + if target.exists(): + raise api.CycleDriverError( + f"BLOCKED: task worktree path already exists but is not an active matching " + f"worktree: {target}. Inspect it, or opt into the current checkout with " + "--workspace current." + ) + try: + missing = [] + parent = worktree_dir + while not parent.exists(): + missing.append(parent) + parent = parent.parent + worktree_dir.mkdir(parents=True, exist_ok=True) + setup = current_setup() + if setup is not None: + for directory in reversed(missing): + setup.record("directory", directory, "worktree base", directory.rmdir) + except OSError as error: + raise api.CycleDriverError( + f"BLOCKED: cannot create worktree base {worktree_dir}: {error}. " + "Use --workspace current to opt into the invoking checkout." + ) from error + branches = set(api._selection_text( + ["git", "branch", "--format=%(refname:short)"], main_root, + "inspect local branches", run, + ).splitlines()) + base_ref = None + create_branch = False + if requested_branch: + branch = requested_branch + if branch not in branches: + number = (change_request_id or "").lstrip("#") + if not api.re.fullmatch(r"[1-9][0-9]*", number): + raise api.CycleDriverError( + f"BLOCKED: cannot fetch pull request branch {branch} without a numeric " + "GitHub change-request ID. Provide its linked worktree with --cwd." + ) + fetched_ref = f"refs/code-cycle/pulls/{number}/head" + existed = _ref(api, main_root, fetched_ref, run) is not None + try: + api._selection_text( + ["git", "fetch", "origin", + f"+refs/pull/{number}/head:{fetched_ref}"], + main_root, f"fetch pull request {number}'s head", run, + ) + finally: + if not existed: + _own_ref(api, main_root, fetched_ref, run, fetched=True) + base_ref = fetched_ref + create_branch = True + elif branch not in branches: + base_ref = api._default_base_ref(main_root, config, run) + create_branch = True + + if herdr_active: + command = [ + "herdr", "worktree", "create", "--cwd", str(main_root), + "--branch", branch, "--path", str(target), "--no-focus", + ] + if base_ref: + command.extend(["--base", base_ref]) + else: + command = ["git", "worktree", "add"] + if create_branch: + command.extend(["-b", branch]) + command.extend([str(target), base_ref or branch]) + try: + api._selection_text(command, main_root, f"create linked worktree {target}", run) + finally: + # A manager may create Git resources and then fail. Record only the + # identities absent in our pre-setup snapshot, including on that path. + if branch not in branches: + _own_ref(api, main_root, f"refs/heads/{branch}", run) + _own_worktree(api, main_root, target, run, herdr_active) + if not api._is_linked_worktree(target): + raise api.CycleDriverError( + f"BLOCKED: worktree manager did not create a linked worktree at {target}. " + "Use --workspace current only if you explicitly accept editing the invoking checkout." + ) + return str(target), worktree_dir diff --git a/scripts/run_cycle.py b/scripts/run_cycle.py index 6d3b25c..64f72ca 100644 --- a/scripts/run_cycle.py +++ b/scripts/run_cycle.py @@ -1703,125 +1703,27 @@ def select_workspace(work_item_id: str, repo_id: str, config: dict, *, workspace: str = "task", cwd: str | None = None, change_request_id: str | None = None, continue_work: bool = False, run=subprocess.run) -> tuple[str, Path | None]: - """Select one task worktree before any cycle stage can mutate the checkout.""" - current = Path(cwd or Path.cwd()).expanduser().resolve() - if not current.is_dir(): - raise CycleDriverError(f"BLOCKED: workspace does not exist: {current}") - if workspace not in WORKSPACE_MODES: - raise CycleDriverError(f"unknown workspace mode: {workspace}") - if workspace == "current": - return str(current), None - root_text = _selection_text( - ["git", "rev-parse", "--show-toplevel"], current, - "locate the Git repository", run, - ) - repository_root = Path(root_text).resolve() - main_root = _main_repository_root(repository_root, run) - herdr_active = os.environ.get("HERDR_ENV") == "1" - requested_branch = ( - _pull_request_branch(repo_id, change_request_id, work_item_id, repository_root, run) - if change_request_id else None - ) - records = _worktree_records(main_root, run) - matches = [ - Path(item["path"]) for item in records - if item["branch"] and _is_linked_worktree(Path(item["path"])) - and ((item["branch"] == requested_branch) if requested_branch - else branch_identifies_work_item(item["branch"], work_item_id)) - ] - if len(matches) > 1: - names = ", ".join(str(path) for path in matches) - raise CycleDriverError( - f"BLOCKED: more than one linked worktree matches work item {work_item_id}: " - f"{names}. Pass --cwd for the intended worktree or --workspace current." - ) - if matches: - selected = matches[0] - if herdr_active and selected != repository_root: - _selection_text( - ["herdr", "worktree", "open", "--cwd", str(main_root), - "--path", str(selected), "--no-focus"], - main_root, f"open linked worktree {selected} in Herdr", run, - ) - # Resolve the configured base from the main root even when reusing a - # linked checkout, so worker worktrees are not nested inside it. - return str(selected), resolve_worktree_dir(config, main_root) - if continue_work: - raise CycleDriverError( - "BLOCKED: no active linked worktree matches the interrupted task. " - "Provide its path with --cwd, or explicitly opt into the invoking " - "checkout with --workspace current." - ) - - worktree_dir = resolve_worktree_dir(config, main_root) - slug = re.sub(r"[^A-Za-z0-9._-]+", "-", str(work_item_id).strip()).strip(".-") - if not slug: - raise CycleDriverError(f"BLOCKED: work item {work_item_id!r} has no safe worktree name") - branch = f"task-{slug}" - target = (worktree_dir / branch).resolve() - try: - target.relative_to(worktree_dir.resolve()) - except ValueError as error: - raise CycleDriverError("BLOCKED: task worktree path escapes its configured base") from error - if target.exists(): - raise CycleDriverError( - f"BLOCKED: task worktree path already exists but is not an active matching " - f"worktree: {target}. Inspect it, or opt into the current checkout with " - "--workspace current." - ) - try: - worktree_dir.mkdir(parents=True, exist_ok=True) - except OSError as error: - raise CycleDriverError( - f"BLOCKED: cannot create worktree base {worktree_dir}: {error}. " - "Use --workspace current to opt into the invoking checkout." - ) from error - branches = set(_selection_text( - ["git", "branch", "--format=%(refname:short)"], main_root, - "inspect local branches", run, - ).splitlines()) - base_ref = None - create_branch = False - if requested_branch: - branch = requested_branch - if branch not in branches: - number = (change_request_id or "").lstrip("#") - if not re.fullmatch(r"[1-9][0-9]*", number): - raise CycleDriverError( - f"BLOCKED: cannot fetch pull request branch {branch} without a numeric " - "GitHub change-request ID. Provide its linked worktree with --cwd." - ) - fetched_ref = f"refs/code-cycle/pulls/{number}/head" - _selection_text( - ["git", "fetch", "origin", - f"+refs/pull/{number}/head:{fetched_ref}"], - main_root, f"fetch pull request {number}'s head", run, - ) - base_ref = fetched_ref - create_branch = True - elif branch not in branches: - base_ref = _default_base_ref(main_root, config, run) - create_branch = True - - if herdr_active: - command = [ - "herdr", "worktree", "create", "--cwd", str(main_root), - "--branch", branch, "--path", str(target), "--no-focus", - ] - if base_ref: - command.extend(["--base", base_ref]) - else: - command = ["git", "worktree", "add"] - if create_branch: - command.extend(["-b", branch]) - command.extend([str(target), base_ref or branch]) - _selection_text(command, main_root, f"create linked worktree {target}", run) - if not _is_linked_worktree(target): - raise CycleDriverError( - f"BLOCKED: worktree manager did not create a linked worktree at {target}. " - "Use --workspace current only if you explicitly accept editing the invoking checkout." - ) - return str(target), worktree_dir + from cycle_workspace import select_workspace as implementation + from workspace_setup import WorkspaceSetup, current_setup + def select(): + return implementation( + sys.modules[__name__], work_item_id, repo_id, config, + workspace=workspace, cwd=cwd, change_request_id=change_request_id, + continue_work=continue_work, run=run) + if current_setup() is not None: + return select() + # Standalone callers retain ownership after a successful selection; the + # CLI keeps its surrounding transaction open until the dispatch boundary. + with WorkspaceSetup() as setup: + try: + selected = select() + setup.commit() + return selected + except Exception as error: + errors = setup.rollback() + if errors: + raise CycleDriverError(str(error) + "; setup rollback errors: " + "; ".join(errors)) from error + raise def evidence_root() -> Path: @@ -2511,1782 +2413,8 @@ def run_cycle( `publication_violation` when an invariant failed, whatever the stage reported, or `publication_unverified` when that state cannot be read. """ - validate_start(start_from, change_request_id, local_only=local_only, - continue_work=continue_work) - check_ci_policy(ci_policy, code_host) - issue_review = IssueReviewMode(issue_review) - if shadow is None and jev is not None and jev.enabled: - shadow = JevShadow(jev) - if local_only: - validate_local_only_cwd(cwd) - registry = registry or Registry() - policy = policy or ReadinessPolicy.for_mode(mode) - # Once, for the whole cycle. Re-probing between stages would let an - # availability change with nothing recording that it had. - probes = registry.probe_all() - if availability is None: - availability = registry.availability(policy, probes) - - recorder = CycleRecorder( - telemetry, repo_id, task_id, signals, - availability=availability, registry=registry, mode=mode, policy=policy, - probes=probes, profiles=profiles, local_only=local_only, - continue_work=continue_work, - started_from=start_from, - routing_strategy=routing_strategy, - change_observer=( - (lambda: collect_change_signals(cwd, change_bases)) - if change_bases else None - ), - verification_available=verification_available, - work_item_provider=work_item_provider, - work_item_repository=work_item_repository, - shadow=shadow, - skills_by_role=SKILL_FOR_ROLE, - change_request_id=change_request_id, - resolve_effort=resolve_effort, - ci_policy=ci_policy, - consent=consent, - ) - # The cycle journal (#306): what this run does, fails at and decides, in - # the telemetry file's own journal tables, so a stop is explained by a query. - journal = Journal(telemetry.path, repo_id, task_id, recorder.cycle_id, - change_request_id=change_request_id) - recorder.journal = journal - previous_consent_observer = consent.observer if consent is not None else None - if consent is not None: - consent.observer = journal.consent_event - # Read before this run asks anything: a `--grant` answer is stored only - # for a scope an earlier run of the work item showed the person. - consent.asked = consent.asked | asked_scopes(telemetry.path, repo_id, task_id) - for grant_id, answer in consent.answers.items(): - journal.record("decision_answered", component="--grant", - decision_key=f"grant:{grant_id}", status=answer) - # The permission view (#311): what the persistent sources hold now, against - # what the journal last saw. An unreadable `consents.json` withdraws nothing. - checkout_branch = _git_line(cwd, ["branch", "--show-current"], 30) - consent_scopes = None - if consent is not None and consent.store is not None: - try: - consent_scopes = consent.store.allowed_scopes(repo_id) - except GrantError: - pass - journal.reconcile_permissions(consents=consent_scopes, user_choices=user_tool_choices, - branch=checkout_branch) - status_writer = CycleStatusWriter( - telemetry.path, recorder.cycle_id, repo_id, task_id, - progress_interval=progress_interval, verbose=verbose, - workspace=cwd or Path.cwd(), - ) - def progress_stage_started(role, decision) -> None: - iteration = recorder.iteration if role in {"review", "resolve", "rereview"} else None - status_writer.stage_started(role, decision, iteration=iteration) - - recorder.stage_started = progress_stage_started - recorder.on_progress = status_writer.activity - recorder.on_workspace = status_writer.workspace_observed - status_writer.start() - report = CycleReport(repo_id=repo_id, task_id=task_id) - # Each notice of tools outside the stage table, reported once per run. - noticed: set[str] = set() - dispatch_kwargs = {} - if cwd: - dispatch_kwargs["cwd"] = cwd - common_directory = _git_common_directory(cwd) - if common_directory: - worktree_directory = _git_worktree_directory(cwd) - dispatch_kwargs["writable_dirs"] = tuple(dict.fromkeys( - directory for directory in (common_directory, worktree_directory) - if directory - )) - if timeout is not None: - dispatch_kwargs["timeout"] = timeout - - previous_resolve_target = None - previous_resolve_used_fallback = False - - # A GitHub issue number may be inferred only when the code host is GitHub. - policy_host = code_host or policy_code_host - policy_provider = work_item_provider or ( - "github" if policy_host == "github" and str(task_id).isdecimal() else None) - verification_plan: verification_contract.VerificationPlan | None = None - criteria_context = "" - - def stage_policy(role: str, request: str | None) -> StagePolicy: - """The closed command table this stage receives (#142).""" - base_ref = head_ref = None - if policy_host == "github" and request: - number = StagePolicy(role=role, change_request_id=request).pr_number - if number: - try: - pull = checks_provider.pull(repo_id, number) - except ci_gate.CheckReadError: - # Failing closed here denies ref-sensitive Git commands; - # it does not prevent the stage from doing other work. - pass - else: - if pull.state == "OPEN": - base_ref, head_ref = pull.base_ref, pull.head_ref - return StagePolicy( - role=role, repo=repo_id, change_request_id=request, - work_item_id=task_id, base_branch=base_branch, - base_ref=base_ref, head_ref=head_ref, - test_command=allowed_test_command, - verify_prefixes=tuple(allowed_verify_commands), - code_host=policy_host, issue_provider=policy_provider, - # The hook records its denials in the journal of this stage, the - # next one the recorder numbers (#306). - journal_path=str(telemetry.path), cycle_id=recorder.cycle_id, - stage_seq=recorder.stage_seq + 1, checkout_branch=checkout_branch, - **(user_tool_choices or {}), - ) - - # The local history of the change request (#223). With a remote that can - # be checked, every comment-publishing stage is expected to publish, and - # the history is reconciled right after it. Without one, a resume rebuilds - # from the comments every time, which is what a missing store does too. - if history_remote is not None: - remote, tracking = history_remote, True - elif comment_reader is not None: - remote, tracking = ReaderRemote(comment_reader, cwd), False - elif code_host == "github": - remote, tracking = GitHubHistoryRemote(cwd), True - else: - remote, tracking = ReaderRemote(read_change_request_comments, cwd), False - tracking = tracking and not local_only - history_store: CycleHistoryStore | None = CycleHistoryStore(telemetry.path) - history_lock: tuple[CycleHistoryStore, str, str] | None = None - - def lock_change_request(request: str) -> CycleReport | None: - """Hold the per-machine writer lock on `request` until the cycle ends.""" - nonlocal history_store, history_lock - if history_store is None or history_lock is not None: - return None - try: - history_lock = (history_store, request, history_store.acquire(repo_id, request)) - except LockHeld as error: - return stop(f"{error}; another cycle is driving it", "BLOCKED") - except (sqlite3.Error, HistoryError): - # An unusable store is skipped; the comments remain the record. - history_store = None - return None - - def release_lock() -> None: - # Released through the store that took it, even if the history was - # given up on since. - if history_lock is not None: - store, request, owner = history_lock - with contextlib.suppress(sqlite3.Error): - store.release(repo_id, request, owner) - - def expect_publication(role: str, request: str | None, attempt_id) -> None: - nonlocal history_store - if (not tracking or history_store is None or role not in COMMENT_STAGES - or not request or not isinstance(attempt_id, str)): - return - try: - history_store.expect_publication(attempt_id, repo_id, request, role) - except (sqlite3.Error, HistoryError): - history_store = None - - def retry_directive(role: str, request: str | None) -> str: - """Name earlier attempts of `role` whose publication was never observed.""" - nonlocal history_store - if history_store is None or role not in COMMENT_STAGES or not request: - return "" - try: - pending = history_store.pending_publications(repo_id, request, role) - except sqlite3.Error: - history_store = None - return "" - if not pending: - return "" - return " " + RETRY_PUBLICATION.format( - role=role, ids=", ".join(item["attempt_id"] for item in pending)) - - def repair_proposals(request: str): - """Find uniquely provable repairs in trusted GitHub comments.""" - if not trusted_authors or history_store is None or code_host != "github": - return [] - try: - comments = remote.comments(repo_id, request) - except (CycleDriverError, HistoryError): - return [] - trusted = {author.casefold() for author in trusted_authors} - candidates = [] - for comment in comments: - if (not comment.author or comment.author.casefold() not in trusted - or not comment.id): - continue - try: - receipts = stage_evidence(comment.body) - except ContractError: - continue - if len(receipts) != 1: - continue - receipt = receipts[0] - if receipt.get("repo") != repo_id or receipt.get("stage") != "resolve": - continue - start_sha = _safe_repair_start_head( - history_store, receipt["attempt_id"], repo_id, request) - proposal = triage_repair.propose_with_start( - repo_id, comment.id, comment.body, start_sha) - if proposal is None: - continue - url = comment.url or ( - f"https://github.com/{repo_id}/pull/{request}#issuecomment-{comment.id}") - diff = "\n".join(difflib.unified_diff( - proposal.original.splitlines(), proposal.repaired.splitlines(), - fromfile="original", tofile="proposed", lineterm="")) - record = { - "repo": repo_id, "comment_id": comment.id, "comment_url": url, - "original_content": proposal.original, "repaired_content": proposal.repaired, - "original_line": proposal.run_line, "repaired_line": proposal.repaired_line, - "sha": proposal.sha, "evidence": proposal.evidence, "diff": diff, - "digest": proposal.digest, - } - candidates.append((record, proposal)) - return candidates - - def audit_repair(record: dict, decision: str, result: str) -> None: - telemetry.record_triage_repair( - repo=repo_id, pull_number=str(change_request_id), - comment_id=record["comment_id"], - original_sha256=hashlib.sha256(record["original_content"].encode("utf-8")).hexdigest(), - repaired_sha256=hashlib.sha256(record["repaired_content"].encode("utf-8")).hexdigest(), - proposal_sha256=record["digest"], triaged_sha=record["sha"], - repair_type=record["evidence"], decision=decision, result=result) - - def attach_repair_proposal(request: str) -> None: - """Attach one bounded, attempt-proven GitHub comment repair proposal.""" - candidates = repair_proposals(request) - if len(candidates) != 1: - return - record, _ = candidates[0] - if len(json.dumps(record, ensure_ascii=False)) <= 12000: - report.repair_proposal = record - audit_repair(record, "proposed", "unverified") - journal.record("decision_asked", component="repair", - decision_key=f"repair:{record['comment_id']}:{record['digest']}", - status="unanswered", reason="Approve the content-bound comment repair", - detail=record["diff"]) - else: - report.note("assisted repair proposal exceeds report bounds; use manual repair") - - def after_publication(role: str, request: str, attempt_id, - reported: Reported) -> CycleReport | None: - """Reconcile the history after a publishing stage and check its outbox. - - A receipt from an attempt nobody here dispatched is another writer: - the history is already reconciled, and the cycle stops for whoever - owns the change request. Failed history reconciliation blocks after - publication; when reconciliation succeeds but this attempt's - publication is absent, the next dispatch of the same stage retries it. - """ - nonlocal history_store - if not tracking: - return None - details = report.stage_details[-1] - if history_store is None: - try: - comments = remote.comments(repo_id, request) - for comment in comments: - if comment.author and comment.author.casefold() in { - author.casefold() for author in trusted_authors}: - validate_triage_comment(comment.body) - except ContractError as error: - attach_repair_proposal(request) - return stop( - f"published {role} comment cannot be read: contract validation failed: {error}", - "BLOCKED", reported=reported) - except CycleDriverError as error: - return stop( - f"published {role} comment validation could not complete: {error}", - "BLOCKED", reported=reported) - return None - try: - synced = history_store.sync(repo_id, request, trusted_authors=trusted_authors, - remote=remote) - published = (history_store.publication(attempt_id) - if isinstance(attempt_id, str) else None) - except ContractError as error: - attach_repair_proposal(request) - return stop( - f"published {role} comment cannot be read: contract validation failed: {error}", - "BLOCKED", reported=reported) - except (CycleDriverError, HistoryError) as error: - return stop( - f"published {role} comment validation could not complete: {error}", - "BLOCKED", reported=reported) - except sqlite3.Error: - history_store = None - return None - if synced.foreign: - return stop( - f"change request {request} received a publication from attempt(s) " - f"{', '.join(synced.foreign)}, which this machine did not dispatch, " - f"while {role} ran; the local history was reconciled, resume once " - "the other writer has finished", "BLOCKED", reported=reported) - if isinstance(attempt_id, str) and (published is None - or published["state"] != "published"): - details.warnings.append( - f"the {role} publication of attempt {attempt_id} was not observed on " - f"change request {request}; the next {role} retries it") - return None - - def run(role: str, instruction: str = "", *, - change_request_id: str | None = None, escalated: bool = False, - resolve_target=None, resolve_escalated: bool = False, - resolve_target_used_fallback: bool = False, - escalation_finding_ids: tuple[str, ...] = (), - publishes: bool = True, - ) -> tuple[StageOutcome, Reported, str | None, dict]: - """Dispatch one stage; also say whether the evidence it was given changed. - - `publishes` is false for a dispatch that only re-reads a publication - already recovered, so the outbox expects nothing from it. - """ - resolve_start_head = ( - _git_line(cwd, ["rev-parse", "HEAD"], 30) - if role == "resolve" and cwd else None - ) - stage_start_branch = _git_line(cwd, ["branch", "--show-current"], 30) if cwd else None - stage_start_clean = cwd is not None and _clean_worktree(cwd) - evidence = "" - stage_kwargs = dispatch_kwargs - artifact = None - tampered = None - # No base to measure against means no file, so no directory either. - directory = (diff_directory(cwd) - if role in DIFF_ROLES and change_bases else None) - try: - review_base = None - if role == "resolve" and ci_policy == "parallel" and not local_only and change_request_id: - try: - review_base = resolve_delta_base( - remote.comments(repo_id, change_request_id), repo_id, - change_request_id, trusted_authors, cwd) - except (CycleDriverError, ContractError, HistoryError): - # Missing review evidence widens the review; it never skips it. - pass - if role in DIFF_ROLES: - artifact = (write_diff_artifact(cwd, change_bases, directory) - if directory is not None else None) - evidence = artifact.prompt() if artifact is not None else NO_DIFF_FILE - if artifact is not None: - stage_kwargs = {**dispatch_kwargs, "read_dirs": (str(directory),)} - if role in {"implement", "resolve"} and allowed_test_command: - stage_kwargs = { - **stage_kwargs, - "claude_allowed_commands": (allowed_test_command,), - } - command_policy = stage_policy(role, change_request_id) - stage_kwargs = {**stage_kwargs, "command_policy": command_policy} - if change_request_id: - journal.change_request_id = change_request_id - # A resumed or re-dispatched stage sees what earlier runs did (#306). - redispatched = any(stage.role == role for stage in report.stages) - journal_context = (journal.summary() - if redispatched or journal.has_history() else "") - verification_context = "" - if (role in {"implement", "resolve", "rereview"} - and verification_plan is not None and verification_plan.checks): - verification_context = ( - "\nRuntime frozen verification checks (data): " - + json.dumps([check.to_dict() for check in verification_plan.checks], - ensure_ascii=False) - + "\nReuse these identities, including executor/profile, when reporting " - "a result for an existing check, even when this stage's dispatch differs. " - "The identity names the check's owning execution context. " - "A check that needs another context requires a new ID; it does not " - "replace a required check. Additions and obligation promotions are allowed.\n") - outcome = recorder.stage( - role, compose(role, repo_id, task_id, - instruction + criteria_context + verification_context, - change_request_id=change_request_id, - local_only=local_only, evidence=evidence, - worktree_dir=worktree_dir, - workspace=workspace, - allowed_test_command=( - allowed_test_command - if role in {"implement", "resolve"} else None), - ci_policy=ci_policy, resolve_review_base=review_base, - command_policy=command_policy, - journal_context=journal_context), - escalated=escalated, - effort_override=(resolve_effort or "high") if role == "resolve" else None, - target_override=resolve_target if role == "resolve" else None, - target_override_used_fallback=(resolve_target_used_fallback - if role == "resolve" else False), - escalation_finding_ids=(escalation_finding_ids - if resolve_escalated else ()), - resolve_escalated=resolve_escalated, - **stage_kwargs) - if artifact is not None: - tampered = artifact.changed() - finally: - # The diff lives for its stage only. A later stage writes its own, - # from the head that stage will judge. - if directory is not None: - _remove_workspace(str(directory)) - report.stages.append(outcome) - attempt_id = (outcome.result.artifacts.get("dispatch_attempt_id") - if outcome.result is not None else None) - if publishes: - expect_publication(role, change_request_id, attempt_id) - if (role == "resolve" and change_request_id and isinstance(attempt_id, str) - and resolve_start_head and history_store is not None): - try: - history_store.record_attempt_start( - attempt_id, repo_id, change_request_id, role, resolve_start_head) - except (sqlite3.Error, HistoryError): - # Assisted repair requires this proof; absent storage stays manual. - pass - reported = read_structured_result(outcome.result) - sandbox_startup_failures = (outcome.result.artifacts.get("sandbox_startup_failures", []) - if outcome.result is not None else []) - reported = _with_sandbox_startup_failures(reported, sandbox_startup_failures) - if (role in {"implement", "resolve"} and code_host == "github" and not local_only - and cwd and reported.completes(role, ci_policy) - and outcome.result is not None - and registry.get(outcome.result.executor).requires_publication_preflight): - request = Reported(payload={ - "change_request_id": reported.change_request_id or change_request_id - }).telemetry_pull_request_id("github") - expected = (reported.payload or {}).get("head_sha") - try: - if not request or not isinstance(expected, str) or not FULL_SHA.fullmatch(expected): - raise CycleDriverError("publication completion requires a change request and full head SHA") - remote_head = probe_change_request(repo_id, request, cwd).head_sha - local_head = _git_line(cwd, ["rev-parse", "HEAD"], 30) - if remote_head != expected or local_head != expected: - raise CycleDriverError( - f"upload unconfirmed: claimed {expected}, local {local_head}, PR {remote_head}; " - "retain the local commit and resume the publishing stage") - transport = (outcome.result.artifacts if outcome.result else {}).get("publication_transport") - if transport is not None and not any( - item.get("action") == "preflight" and item.get("status") == "ready" - for item in transport if isinstance(item, dict)): - raise CycleDriverError("publication preflight inside the stage sandbox was not confirmed") - except CycleDriverError as error: - reported = Reported(present=True, status="BLOCKED", payload={ - **(reported.payload or {}), "status": "BLOCKED", "reason": str(error), - "publication_pending": True}) - if (role == "resolve" and change_request_id and cwd and resolve_start_head - and stage_start_clean and stage_start_branch and history_store is not None - and isinstance(attempt_id, str) and outcome.result is not None - and outcome.result.process_exited and not outcome.result.asynchronous): - end_head = _git_line(cwd, ["rev-parse", "HEAD"], 30) - if (end_head and end_head != resolve_start_head - and stage_start_branch == _git_line(cwd, ["branch", "--show-current"], 30) - and _clean_worktree(cwd)): - try: - history_store.record_unpublished_head( - attempt_id, repo_id, change_request_id, cwd, stage_start_branch, - resolve_start_head, end_head) - except (sqlite3.Error, HistoryError): - pass # Missing attribution keeps resume conservative. - if (outcome.succeeded and outcome.result is not None - and not outcome.result.asynchronous and reported.status is None): - result = outcome.result - has_output = bool(result.agent_output.strip()) - code = "unreadable_result" if has_output else "no_error_report" - detail = "no parseable structured stage result" - pending = result.artifacts.get("background_command") - if has_output and pending in {"confirmed", "suspected"}: - detail = ("background_command_pending: verification command did not " - "complete before stage exit" - + (" (suspected)" if pending == "suspected" else "")) - if has_output: - detail += ": " + failure_excerpt(result.agent_output) - outcome.result = replace(result, error_code=code, - detail=failure_excerpt(detail)) - if outcome.rows: - attempt_id = result.artifacts.get("dispatch_attempt_id") - if attempt_id: - telemetry.update_dispatch_attempt( - attempt_id, {"error_code": code, "outcome": "failed", "lifecycle_state": "failed"}, - update_id=f"update-{uuid.uuid4().hex}", - correction_reason="parser_correction", - ) - journal_result(role, outcome, reported, attempt_id) - findings = _findings(reported.payload, role) - warnings = _stage_warnings(outcome) - blocked = _blocked_commands(outcome) - warnings += [f"blocked command: `{command}`" for command in blocked] - notice = _preapproved_notice(outcome) - if notice and notice not in noticed: - noticed.add(notice) - warnings.append(notice) - reported_status = reported.status if outcome.succeeded else None - duration = status_writer.stage_finished( - outcome.result, reported_status, - findings=_findings_line(role, findings), warnings=warnings, - reviewed_head=_reviewed_head(reported.payload, role).get("reviewed_head_sha"), - ) - report.stage_details.append(StageReportDetails( - round=recorder.iteration, - status=reported_status, - findings=findings, - warnings=warnings, - duration_seconds=duration if outcome.result is not None else None, - blocked_commands=blocked, - )) - return outcome, reported, tampered, findings - - def journal_result(role, outcome, reported, attempt_id) -> None: - payload = reported.payload or {} - # A stage's report cannot redirect attribution or retention. The driver - # owns this reference and accepts a new implementation's validated ID - # only after its existing continuation and publication checks below. - journal.change_request_id = change_request_id - journal.record("stage_result", role=role, stage_seq=recorder.stage_seq, - attempt_id=attempt_id, component="runtime", status=reported.status, - detail=reported.reason, correlation={"head_sha": payload.get("head_sha")}) - transport = (outcome.result.artifacts if outcome.result else {}).get("publication_transport", []) - for event in transport if isinstance(transport, list) else []: - if not isinstance(event, dict): - continue - attempts = event.get("attempts", []) - for attempt in attempts if isinstance(attempts, list) else []: - if not isinstance(attempt, dict): - continue - command = attempt.get("command") - if not isinstance(command, list) or not all(isinstance(word, str) for word in command): - continue - journal.record( - "stage_result", role=role, stage_seq=recorder.stage_seq, - attempt_id=attempt_id, component="publication_transport", - status=event.get("status"), command=shlex.join(command), - detail=attempt.get("error"), - correlation={"head_sha": event.get("head_sha"), - "remote_head_sha": event.get("remote_head_sha"), - "exit_code": attempt.get("exit_code")}) - questions = payload.get("questions") - if isinstance(questions, list): - for question in questions: - if isinstance(question, dict): - journal.record("decision_asked", role=role, stage_seq=recorder.stage_seq, - attempt_id=attempt_id, component="stage_result", - decision_key=f"{attempt_id}:{question.get('id')}", - status="unanswered", reason=question.get("prompt"), - detail=json.dumps(question.get("options"))) - - def stop(because: str, status: str = UNRESOLVED_END, - reported: Reported | None = None, *, - stop_reason: str = "stage_not_completed") -> CycleReport: - if report.stages and report.stage_details: - # The exact command a permission stopped, in the stop line, so a - # person can authorize it once and resume (#142). - denied = report.stage_details[-1].blocked_commands - if denied: - more = f" (and {len(denied) - 1} more)" if len(denied) > 1 else "" - because += (f"; blocked command in the {report.stages[-1].role} stage: " - f"`{denied[0]}`{more}") - report.stopped_because = because - report.status = status - report.iterations = recorder.iteration - report.stop_reason = stop_reason - if reported is not None: - report.reason = reported.reason - refusals = journal.refusals(stage_seq=recorder.stage_seq) - for refusal in refusals: - citation = (f"refused by {refusal['component']}: {refusal['command'] or '(no command)'}" - f" — {refusal['reason']}") - if citation not in because: - because += f"; {citation}" - report.stopped_because = because - journal.record("stop", stage_seq=recorder.stage_seq, - role=report.stages[-1].role if report.stages else None, - component="runtime", status=status, reason=because, - detail=report.reason, correlation={"stop_reason": stop_reason}) - try: - from cycle_journal import explain as explain_journal - report.journal_explanation = explain_journal( - journal.path, repo_id, task_id, recorder.cycle_id) - except Exception: - pass - for error in journal.errors: - report.note(f" journal {error}") - recorder.close(status, stop_reason=stop_reason, **issue_review_fields) - status_writer.finish( - status, - stop_reason=stop_reason, - reason=because, - readiness=report.readiness, - review_provenance=report.review_provenance, - ) - return report - - def unfinished(outcome: StageOutcome, reported: Reported, - tampered: str | None) -> CycleReport | None: - """Stop a stage whose dispatch did not finish here, before its report is read.""" - # Before anything the stage reported is read: its verdict rests on - # evidence the runtime no longer vouches for. - if tampered is not None: - return stop(tampered, reported=reported) - if consent is not None and consent.pending: - # The sandbox stayed as it was; a person answers before any rerun. - report.permission_requests = list(consent.pending) - asked = consent.pending[0] - return stop(f"the {asked.role} stage needs permission: {asked.grant.id} " - f"({asked.grant.resource})", reported=reported, - stop_reason="permission_required") - if not outcome.succeeded: - blocked_effort = ( - (outcome.result is not None - and outcome.result.failure_code == "unsupported_effort") - or (outcome.decision is not None and outcome.decision.blocked - and any("does not support effort" in reason - for reason in outcome.decision.reasons)) - ) - return stop(_why(outcome), reported=reported, - status="BLOCKED" if blocked_effort else UNRESOLVED_END, - stop_reason="dispatch_failed") - if _started_elsewhere(outcome): - return stop(_elsewhere(outcome), reported=reported) - return None - - def audit_publication(role: str, before, reported: Reported, - request: str | None) -> CycleReport | None: - """Stop when a publication invariant failed during `role` (#261). - - The change request the stage was given, or for `implement` the one it - reports, is read as well as those whose head is the cycle branch. A - breach names what moved, never who moved it. - """ - number = _pull_number(request) - if number is None and role == "implement": - number = reported.telemetry_pull_request_id("github") - try: - result = publication_auditor.verify(before, change_request_id=number) - except AuditUnreadable as error: - report.publication_audit = {"status": "unreadable", "role": role} - return stop(f"the publication audit could not read the code host after the " - f"{role} stage: {error}", "BLOCKED", reported=reported, - stop_reason="publication_unverified") - evidence = {"role": role, **result.evidence()} - report.publication_audit = evidence - journal.record("stage_result", role=role, stage_seq=recorder.stage_seq, - component="publication_audit", status=result.status, - correlation={"cycle_branch": result.before.cycle_branch, - "default_branch": result.before.default_branch, - "breaches": len(result.breaches)}) - for breach in result.breaches: - journal.record("failure", role=role, stage_seq=recorder.stage_seq, - component="publication_audit", status=breach.kind, - reason=breach.explain(), - correlation={"ref": breach.ref, "before": breach.before, - "after": breach.after}) - if result.unverified: - report.note(f"publication audit after {role}: unverified " - f"({', '.join(result.unverified)}); the rest was checked") - if result.status != AUDIT_VIOLATED: - return None - for breach in result.breaches: - report.note(f" publication breach {json.dumps(breach.evidence(), sort_keys=True)}") - return stop(result.explain(role), reported=reported, - stop_reason="publication_violation") - - def review_issue() -> CycleReport | None: - """The readiness gate: only a confirmed READY lets `implement` run. - - A READY the runtime cannot confirm — low confidence, or a material - uncertainty left open — is reviewed once more on the stronger profile. - When that is not possible, or the second pass cannot confirm it either, - the cycle stops for a person rather than implementing on a guess. - """ - escalated = False - while True: - outcome, reported, tampered, _ = run( - ISSUE_REVIEW_ROLE, ESCALATED_ISSUE_REVIEW if escalated else "", - escalated=escalated) - stopped = unfinished(outcome, reported, tampered) - if stopped is not None: - return stopped - readiness = assess_readiness(reported.payload, task_id) - report.readiness = readiness - if reported.status: - recorder.record_verdict(ISSUE_REVIEW_ROLE, reported.status, - **readiness.telemetry_fields()) - if readiness.gate == READINESS_CONTINUE: - return None - if readiness.gate == READINESS_STOP: - if not reported.readable: - return stop(reported.explain(ISSUE_REVIEW_ROLE), reported=reported) - if readiness.valid and readiness.status == "NEEDS_REFINEMENT": - return stop(readiness.explain(), reported=reported, - stop_reason="needs_refinement") - return stop(readiness.explain(), reported=reported) - stronger = ESCALATION_PROFILES[ISSUE_REVIEW_ROLE] - if escalated or outcome.decision.profile == stronger: - return stop(readiness.explain(), reported=reported, - stop_reason="readiness_unconfirmed") - escalated = True - - pending_recovery: dict | None = None - # The attempts whose receipt may carry the latest review's verdict: its - # dispatch, and the recovered attempt when its result had to be recovered. - review_attempts: set[str] = set() - parallel = ci_policy == "parallel" and not local_only - - def advance(role: str, instruction: str = "", *, - change_request_id: str | None = None, - resolve_target=None, resolve_escalated: bool = False, - resolve_target_used_fallback: bool = False, - escalation_finding_ids: tuple[str, ...] = (), - recover: bool = True) -> tuple[Reported, CycleReport | None]: - """Run one stage and decide whether the cycle may continue past it. - - Every reason to stop is here rather than repeated per stage: a stop - condition that has to be remembered four times is one that will be - missing from the fourth. - """ - nonlocal pending_recovery, previous_resolve_target, previous_resolve_used_fallback - nonlocal review_attempts, verification_plan - if pending_recovery is not None: - ids = [item["id"] for item in pending_recovery.get("finding_outcomes", []) - if item["status"] in {"resolved", "not_applicable"}] - instruction += ( - f" Verify recovered attempt {pending_recovery['attempt_id']} at head " - f"{pending_recovery['head_sha']}. Execute verification for every claimed " - f"finding ID: {', '.join(ids) or '(none)'}. Report head_sha and explicit " - "verified_findings outcomes (resolved, still_open, not_applicable). " - "A reopened finding follows the normal resolution loop." - ) - if recover: - instruction += retry_directive(role, change_request_id) - if (role in {"resolve", "rereview"} and code_host == "github" - and change_request_id is not None and cwd is not None): - try: - target_head = probe_change_request(repo_id, change_request_id, cwd).head_sha - unpublished = (history_store.unpublished_heads( - repo_id, change_request_id, cwd, - _git_line(cwd, ["branch", "--show-current"], 30) or "", target_head or "") - if history_store is not None and role == "resolve" else None) - if unpublished is None: - synchronized = synchronize_worktree(change_request_id, cwd, target_head=target_head) - else: - synchronized = synchronize_worktree( - change_request_id, cwd, target_head=target_head, unpublished_heads=unpublished) - except WorktreeSyncError as error: - return Reported(), stop(str(error), "BLOCKED", - stop_reason=error.stop_reason or "stage_not_completed") - except CycleDriverError as error: - return Reported(), stop(str(error), "BLOCKED") - if synchronized is not None: - old_head, new_head = synchronized - report.note(f"worktree fast-forwarded: {old_head} -> {new_head}") - journal.record( - "stage_result", role=role, stage_seq=recorder.stage_seq + 1, - component="runtime", status="worktree_synchronized", - reason="clean worktree fast-forwarded to the open pull request head", - correlation={"old_head_sha": old_head, "new_head_sha": new_head}) - audited = None - if publication_auditor is not None and role in PUBLISHING_ROLES: - try: - audited = publication_auditor.capture( - checkout_branch=_git_line(cwd, ["branch", "--show-current"], 30), - change_request_id=_pull_number(change_request_id)) - except AuditUnreadable as error: - return Reported(), stop( - f"the publication audit could not read the code host before the {role} " - f"stage, so nothing was dispatched: {error}", "BLOCKED", - stop_reason="publication_unverified") - outcome, reported, tampered, findings = run( - role, instruction, change_request_id=change_request_id, - resolve_target=resolve_target, - resolve_target_used_fallback=resolve_target_used_fallback, - resolve_escalated=resolve_escalated, - escalation_finding_ids=escalation_finding_ids, - publishes=recover) - if audited is not None: - # Before anything the stage reported is read, whatever it reported: - # a breach is never a `stage_not_completed` (#261, #364). - stopped = audit_publication(role, audited, reported, change_request_id) - if stopped is not None: - return reported, stopped - if role == "resolve" and outcome.decision is not None: - previous_resolve_target = outcome.decision.target - previous_resolve_used_fallback = outcome.decision.used_fallback - result = outcome.result - if role in {"review", "rereview"}: - dispatched = (result.artifacts.get("dispatch_attempt_id") - if result is not None else None) - # A re-dispatch for a recovered verdict keeps the recovered attempt. - review_attempts = (set(review_attempts) if not recover else set()) | ( - {dispatched} if isinstance(dispatched, str) else set()) - if (recover and pending_recovery is None and tampered is None and reported.status is None - and result is not None and not result.asynchronous - and result.error_code in {"unreadable_result", "no_error_report"} - and code_host == "github" and not local_only): - attempt_id = result.artifacts.get("dispatch_attempt_id") - try: - if not attempt_id: - raise CycleDriverError("recovery requires a recorded dispatch attempt") - if (role == "review" and code_host == "github" - and change_request_id is not None and cwd is not None): - target_head = probe_change_request(repo_id, change_request_id, cwd).head_sha - synchronized = synchronize_worktree( - change_request_id, cwd, target_head=target_head) - if synchronized is not None: - old_head, new_head = synchronized - report.note(f"worktree fast-forwarded for recovery: {old_head} -> {new_head}") - journal.record( - "stage_result", role=role, stage_seq=recorder.stage_seq + 1, - component="runtime", status="worktree_synchronized", - reason="clean worktree fast-forwarded before review recovery", - correlation={"old_head_sha": old_head, "new_head_sha": new_head}) - receipt = recovery_evidence( - repo_id, role, attempt_id, change_request_id=change_request_id, - cwd=cwd, trusted_authors=trusted_authors, awaiting_ci=parallel) - telemetry.record_recovery( - attempt_id, change_request_id=receipt["change_request_id"], - head_sha=receipt["head_sha"], comment_id=receipt["comment_id"]) - except (CycleDriverError, TelemetryError) as error: - if isinstance(error, WorktreeSyncError): - return reported, stop(f"{role} recovery rejected: {error}", "BLOCKED", - reported=reported, - stop_reason=error.stop_reason or "stage_not_completed") - return reported, stop(f"{role} recovery rejected: {error}", reported=reported) - pending_recovery = receipt - report.stage_details[-1].status = "recovered_pending_verification" - if role in {"review", "rereview"}: - review_attempts = {receipt["attempt_id"]} - return advance(role, "Obtain a structured verdict for the recovered review.", - change_request_id=receipt["change_request_id"], recover=False) - if role == "resolve": - history.append(RunStatuses(RESOLUTION_RUN, { - item["id"]: item["status"] for item in receipt.get("finding_outcomes", [])})) - return Reported(present=True, status="recovered_pending_verification", - payload={"change_request_id": receipt["change_request_id"], - "head_sha": receipt["head_sha"]}), None - stopped = unfinished(outcome, reported, tampered) - if stopped is not None: - return reported, stopped - if pending_recovery is not None: - try: - current_receipt = recovery_evidence( - repo_id, pending_recovery["stage"], pending_recovery["attempt_id"], - change_request_id=pending_recovery["change_request_id"], - cwd=cwd, trusted_authors=trusted_authors, awaiting_ci=parallel) - except CycleDriverError as error: - return reported, stop(f"recovered stage not completed: {error}", reported=reported) - claims = {item["id"] for item in pending_recovery.get("finding_outcomes", []) - if item["status"] in {"resolved", "not_applicable"}} - payload = reported.payload or {} - verified = payload.get("verified_findings", []) - verified_statuses = _finding_statuses({"verified_findings": verified}, "rereview") - verified_ids = {item["id"] for item in verified - if isinstance(item, dict) and isinstance(item.get("id"), str)} if isinstance(verified, list) else set() - if (not reported.completes(role, ci_policy) - or current_receipt != pending_recovery - or payload.get("head_sha") != pending_recovery["head_sha"] - or payload.get("reviewed_head_sha", payload.get("head_sha")) != pending_recovery["head_sha"] - or _git_line(cwd, ["rev-parse", "HEAD"], 30) != pending_recovery["head_sha"] - or (claims and (verified_statuses is None or not claims.issubset(verified_ids))) - or (reported.status in CLEAN_REVIEW_STATUSES and verified_statuses - and any(verified_statuses.get(finding_id) == "open" for finding_id in claims))): - return reported, stop("recovered stage not completed: verifier did not confirm " - "the recovered head and every claimed finding", reported=reported) - pending_recovery = None - # The dispatch and the work are different facts. This records what the - # agent said it did; the row already says the call returned. The prose - # beside it is not recorded: the store holds references and counts. - if reported.status: - test_payload = reported.payload - if (verification_plan is None and allowed_test_command - and role in {"implement", "resolve", "rereview"}): - # Resumes cannot recover criteria revisions until #329. Keep - # them explicitly open and retain the project's obligation. - verification_plan = verification_contract.VerificationPlan( - verification_contract.Criteria( - policy_provider or "unknown", "", "not_evaluable", - reason="runtime criteria revision unavailable")) - if role in {"implement", "resolve", "rereview"} and verification_plan is not None: - # Mandatory commands come from project policy, never the report. - if allowed_test_command and not any( - check.id == "configured-test" for check in verification_plan.checks): - verification_plan = verification_plan.extend(( - verification_contract.configured_check( - allowed_test_command, - outcome.result.executor if outcome.result else "unknown", - outcome.decision.profile if outcome.decision else "unknown"),)) - test_payload, verification_plan = _normalize_test_payload( - test_payload, verification_plan) - pull_request_id = ( - reported.telemetry_pull_request_id(code_host) - if role == "implement" else None - ) - recorder.record_verdict(role, reported.status, - **findings, - **(_forecast(reported.payload) if role == "implement" else {}), - **(_legacy_tests(test_payload, boundary_rule=( - boundary_rule_fired(recorder.latest_change))) - if verification_plan is not None else - _tests(test_payload, boundary_rule=( - boundary_rule_fired(recorder.latest_change)))), - **_checks(reported.payload), - **_reviewed_head(reported.payload, role), - **({"pull_request_id": pull_request_id} - if pull_request_id else {})) - if not reported.completes(role, ci_policy): - because = reported.explain(role, ci_policy) - if (role == "rereview" and not local_only - and (code_host or policy_code_host) == "github"): - because += manual_hint(reported) - return reported, stop(because, - "BLOCKED" if (reported.payload or {}).get("publication_pending") is True - else UNRESOLVED_END, reported=reported) - if recover and role in COMMENT_STAGES and change_request_id: - stopped = after_publication( - role, change_request_id, - result.artifacts.get("dispatch_attempt_id") if result is not None else None, - reported) - if stopped is not None: - return reported, stopped - # Only a completed stage enters the history the ladder reads. The - # cycle already stops on anything else, so an unreadable result can - # neither reset nor advance a count. - run_statuses = _run_statuses(role, reported.payload) - if run_statuses is not None: - history.append(run_statuses) - if role in {"review", "rereview"}: - progress.append(_progress_key(role, reported.payload)) - return reported, None - - # What the ladder and the no-progress guard read: every completed run of - # the change request, and each review's head and open set. A resumed cycle - # seeds the runs from the published comments of the trusted authors before - # its first stage (`seed_history`), through the local history when one - # remote check verifies it; the no-progress guard still starts empty, so it - # can stop later than a whole cycle would, never earlier. - # `recorder.repeated_findings` stays unknown until the ladder is first - # evaluated: a cycle that never reached it was not measured at zero. - history: list[RunStatuses] = [] - progress: list[tuple[str, frozenset[str]] | None] = [] - - def seed_history() -> CycleReport | None: - """Rebuild the claim and survival history of a resumed change request. - - An unreadable history is never an empty one. Without trusted authors - nobody can say whose comments count, so a person decides; a failed - fetch, a failed freshness check, a comment without an author, or a - trusted contract comment that cannot be parsed or placed blocks. No - contract comment at all is an empty history. A local history is used - only when verified; an unusable database is skipped and the history - rebuilt from the comments. Nothing is dispatched on a stop. - """ - nonlocal history_store - if not any(author.strip() for author in trusted_authors): - return stop( - "the claimed-fix history of the resumed change request cannot be " - "rebuilt: review.trusted_authors is empty", - UNRESOLVED_END) - stopped = lock_change_request(change_request_id) - if stopped is not None: - return stopped - try: - # A remote that cannot be checked could never verify the store. - if history_store is not None and not isinstance(remote, ReaderRemote): - try: - history.extend(history_store.sync( - repo_id, change_request_id, trusted_authors=trusted_authors, - remote=remote).runs) - return None - except (sqlite3.Error, HistoryError): - # Unusable or unstorable: the comments remain the record. - history_store = None - history.extend(comment_run_history( - remote.comments(repo_id, change_request_id), - trusted_authors=trusted_authors)) - except (CycleDriverError, ContractError, HistoryError) as error: - return stop( - f"the claimed-fix history of the resumed change request is " - f"unreadable: {error}", "BLOCKED") - return None - - # Recorded on the closing row of every cycle that could have reviewed its - # work item; a resumed cycle could not, and says nothing about it. - issue_review_fields = {} - if continue_work: - # The interrupted run already passed, or skipped, the readiness gate. - issue_review_fields["continued"] = True - elif start_from == "implement": - issue_review_fields["issue_review"] = issue_review_decision( - issue_review, signals) - - # The parallel CI policy's gate (#228): the code host it reads, and the - # cycle's only wait on checks. Nothing here writes to the code host. - checks_provider = ci_checks if ci_checks is not None else ci_gate.GitHubChecks(cwd) - - def pull_number() -> str | None: - return _pull_number(change_request_id) - - def read_ci(head: str, role: str | None, *, once: bool) -> ci_gate.GateResult: - """One read, or the one bounded wait, of the CI gate: recorded and shown. - - No stage runs while it waits and nothing else polls: under the parallel - policy this is the cycle's only wait on the code host's checks. - """ - number = pull_number() - if number is None: - result = ci_gate.GateResult( - status=ci_gate.CI_UNREADABLE, head_sha=head, - reason="the change request names no GitHub pull request number") - else: - timing = {} - if ci_clock is not None: - timing["clock"] = ci_clock - if ci_sleep is not None: - timing["sleep"] = ci_sleep - result = ci_gate.wait_for_checks( - checks_provider, repo_id, number, head, timeout=ci_timeout, - poll_interval=ci_poll_interval, once=once, - on_poll=lambda observed: status_writer.ci_observed( - observed, waiting=not once and observed.status == ci_gate.CI_PENDING), - **timing) - recorder.record_ci(result, mode="once" if once else "wait", role=role) - status_writer.ci_observed(result, waiting=False) - report.note(_ci_line(result, once=once)) - return result - - def receipt_problem(role: str, head: str, attempts: set[str], - provenance: str = RUNTIME_PROVENANCE) -> str | None: - """Why no trusted receipt on the change request vouches for a clean review. - - The PR comment is the record of a clean review awaiting its checks, so - approval never rests on the structured result alone. A trusted receipt - of one of `attempts` must name this stage, this head, a clean status - and `provenance`; none of their receipts may say otherwise; and nothing - may have been published after them. - """ - number = pull_number() - trusted = trusted_logins(trusted_authors) - if not trusted: - return "review.trusted_authors is empty, so no receipt can be authenticated" - if number is None or not attempts: - return "the review has no pull request number or recorded attempt to look for" - try: - comments = remote.comments(repo_id, number) - except CycleDriverError as error: - return f"the comments of change request {number} could not be read: {error}" - receipts = [] - for comment in comments: - author = (comment.author.strip().casefold() - if isinstance(comment.author, str) else None) - if author not in trusted: - continue - try: - found = stage_evidence(comment.body) - except ContractError: - if any(attempt in comment.body for attempt in attempts): - return "its receipt cannot be read" - continue - receipts.extend(receipt for receipt in found - if receipt["repo"] == repo_id - and receipt["change_request_id"] == number) - mine = [receipt for receipt in receipts if receipt["attempt_id"] in attempts] - if not mine: - return "no trusted comment carries its receipt" - if any(receipt["stage"] != role or receipt["head_sha"] != head - or receipt["status"] not in CLEAN_REVIEW_STATUSES for receipt in mine): - return "its receipt names another stage, head or status" - if any(receipt_provenance(receipt) != provenance for receipt in mine): - return f"its receipt is not a {provenance} receipt" - if receipts[-1]["attempt_id"] not in attempts: - return "another stage published after it" - return None - - def clean_head(role: str, reported: Reported) -> tuple[str | None, CycleReport | None]: - """The head a clean review completed on, once its result and receipt hold.""" - problem = clean_review_problem(reported.payload, role) - if problem is not None: - return None, stop( - f"the {role} reported {reported.status} without a valid completed " - f"clean review: {problem}", reported=reported) - head = reported.payload["reviewed_head_sha"] - problem = receipt_problem(role, head, review_attempts) - if problem is not None: - return None, stop( - f"the {role} reported {reported.status} for {head[:12]}, but {problem}; " - "the receipt on the change request is the record of a clean review", - "BLOCKED", reported=reported) - return head, None - - def gate(role: str, head: str, attempts: set[str], reported: Reported, - provenance: str = RUNTIME_PROVENANCE): - """Wait for the checks of a clean review's head; approve only on a pass. - - Returns `("approved" | "moved" | "failed", result)`, or the report of a - stop. The final barrier is all of: every required check of `head` - passed while it was still the change request's head, and the clean - review's receipt still holds with nothing published after it. - """ - result = read_ci(head, role, once=False) - number = pull_number() or change_request_id - if result.status == ci_gate.CI_PASSED: - problem = receipt_problem(role, head, attempts, provenance) - if problem is not None: - return stop( - f"the checks of {head[:12]} passed, but the clean {role}'s receipt " - f"no longer holds: {problem}", "BLOCKED", reported=reported) - return "approved", result - if result.status == ci_gate.CI_HEAD_MOVED: - return "moved", result - if result.status == ci_gate.CI_CONFLICTING: - return "conflicting", result - if result.status == ci_gate.CI_MERGEABILITY_UNKNOWN: - return stop( - f"GitHub could not determine whether the change request at {head[:12]} " - f"can merge with its base after bounded retries: {_ci_detail(result)}", - "BLOCKED", reported=reported, stop_reason="ci_unreadable") - if result.status == ci_gate.CI_FAILED: - return "failed", result - detail = _ci_detail(result) - if result.status == ci_gate.CI_TIMEOUT: - return stop( - f"the {role} of {head[:12]} is clean and stays valid for that head, but " - f"its checks had not all passed after {_duration(ci_timeout)}: {detail}; " - f"resume with --from rereview --pr {number} --ci-policy parallel to keep " - "waiting without another review", - "BLOCKED", reported=reported, stop_reason="ci_timeout") - if result.status in {ci_gate.CI_SKIPPED, ci_gate.NO_CI_CHECKS_FOUND}: - return stop( - f"the {role} of {head[:12]} is clean, but its checks cannot pass " - f"({result.status}): {detail}; approval needs every required check " - "to succeed on that head", - UNRESOLVED_END, reported=reported, stop_reason="ci_not_passing") - return stop(f"the checks of {head[:12]} could not be read: {detail}", - "BLOCKED", reported=reported, stop_reason="ci_unreadable") - - def check_strict_mergeability(head: str) -> ci_gate.GateResult | CycleReport | None: - """Check GitHub mergeability before a sequential-policy approval.""" - if code_host != "github": - return None - number = pull_number() - if number is None: - return stop("the reviewed change request has no GitHub pull request number", - "BLOCKED", stop_reason="ci_unreadable") - for attempt in range(3): - try: - pull = checks_provider.pull(repo_id, number) - except ci_gate.CheckReadError as error: - return stop(f"the change request mergeability could not be read: {error}", - "BLOCKED", stop_reason="ci_unreadable") - if pull.state != "OPEN": - return stop(f"the change request is {pull.state.lower()}, not open", - "BLOCKED", stop_reason="ci_unreadable") - if pull.head_sha != head: - return stop( - f"the change request head moved from {head[:12]} to " - f"{pull.head_sha[:12]} before mergeability was checked", - "BLOCKED", stop_reason="head_moved") - if pull.mergeable is True: - return None - if pull.mergeable is False and pull.mergeable_state == "dirty": - return ci_gate.GateResult( - status=ci_gate.CI_CONFLICTING, head_sha=head, pr_head_sha=head, - reason="the change request conflicts with its base branch") - if attempt < 2: - (ci_sleep or time.sleep)(min(ci_poll_interval, 5)) - return ci_gate.GateResult( - status=ci_gate.CI_MERGEABILITY_UNKNOWN, head_sha=head, pr_head_sha=head, - reason="GitHub could not determine whether the change request is mergeable") - - def reusable_review(): - """The clean review a resumed change request already has, when it holds. - - The receipt on the change request is the record: when the latest - trusted receipt is a clean review or re-review of the current head and - the published findings block nothing, the review is not run again and - the cycle only waits for its checks. Anything else reviews the head. - Returns `((role, status, head, attempts) | None, stop | None)`. - """ - number = pull_number() - if number is None: - return None, stop( - f"the parallel CI policy needs a GitHub pull request number, not " - f"{change_request_id}", "BLOCKED", stop_reason="ci_unreadable") - try: - comments = remote.comments(repo_id, number) - pull = checks_provider.pull(repo_id, number) - except (CycleDriverError, ci_gate.CheckReadError) as error: - return None, stop( - f"whether change request {number} already has a clean review could " - f"not be read: {error}", "BLOCKED", stop_reason="ci_unreadable") - if pull.state != "OPEN": - return None, stop( - f"change request {number} is {pull.state.lower()}, not open", "BLOCKED") - recorder.review_reused = False - try: - receipts = [receipt for receipt in trusted_receipts( - comments, trusted_authors=trusted_authors) - if receipt["repo"] == repo_id - and receipt["change_request_id"] == number] - published = recover_comment_history(comments, trusted_authors=trusted_authors) - except ContractError as error: - report.note(f" resume no clean review reused: {readable(str(error), 200)}") - return None, None - latest = receipts[-1] if receipts else None - # A manual rereview is never reused for being clean and trusted; only - # `manual_review` accepts one, on its own conditions. - if (latest is None or latest["stage"] not in {"review", "rereview"} - or latest["status"] not in CLEAN_REVIEW_STATUSES - or receipt_provenance(latest) != RUNTIME_PROVENANCE): - return None, None - if latest["head_sha"] != pull.head_sha: - report.note( - f" resume the clean {latest['stage']} of {latest['head_sha'][:12]} " - f"does not hold for the current head {readable(pull.head_sha, 12)}; " - "reviewing the current head") - return None, None - if any(finding.status == "open" and finding.blocks_approval - for finding in published.findings): - report.note(" resume the published findings still block approval; " - "reviewing again") - return None, None - recorder.review_reused = True - report.note( - f" resume reused the clean {latest['stage']} of {latest['head_sha'][:12]} " - "from its receipt; no review dispatched") - return (latest["stage"], latest["status"], latest["head_sha"], - {latest["attempt_id"]}), None - - def manual_step(number: str, head: str) -> str: - """The command that records a manual rereview of `head` (#346).""" - return (f"python3 manual_rereview.py record --repo {repo_id} --pr {number} " - f"--status APPROVED --confirm-head {head} " - "--check RESULT:NAME:EVIDENCE") - - def manual_hint(reported: Reported) -> str: - """What closes a cycle whose rereview could not complete here (#346).""" - number = pull_number() - if number is None: - return "" - head = (reported.payload or {}).get("head_sha") - if not isinstance(head, str) or FULL_SHA.fullmatch(head) is None: - head = "" - return ("; a rereview done outside the runtime has no receipt until it is " - f"recorded with `{manual_step(number, head)}`, then resume with " - f"--from rereview --pr {number}") - - def manual_review(): - """The confirmed manual rereview a resume closes on, when it holds (#346). - - Considered only when the latest trusted receipt of the change request - is a manual one; anything else returns `(None, None)` and the resume - goes on as before. It is accepted only when the account that published - it is the operator it names, it is a clean rereview confirmed for the - change request's current head, nothing readable was published after - it, and every published finding that still blocks approval is one it - verified. Otherwise the cycle stops for a person with the missing - step: a manual rereview is never replaced by a runtime one behind the - operator's back, nor upgraded to one. - Returns `((role, status, head, attempts) | None, stop | None)`. - """ - number = pull_number() - trusted = trusted_logins(trusted_authors) - if number is None or not trusted: - return None, None - try: - comments = remote.comments(repo_id, number) - except (CycleDriverError, HistoryError): - return None, None - latest, publisher = None, None - for comment in comments: - author = (comment.author.strip().casefold() - if isinstance(comment.author, str) else None) - if author not in trusted: - continue - try: - found = stage_evidence(comment.body) - except ContractError: - # Unreadable, so it cannot be ruled out as the latest. - latest, publisher = None, None - continue - for receipt in found: - if receipt["repo"] == repo_id and receipt["change_request_id"] == number: - latest, publisher = receipt, author - if latest is None or receipt_provenance(latest) != MANUAL_PROVENANCE: - return None, None - try: - pull = checks_provider.pull(repo_id, number) - except ci_gate.CheckReadError as error: - return None, stop( - f"the manual rereview of change request {number} could not be " - f"checked against its head: {error}", "BLOCKED", - stop_reason="ci_unreadable") - if pull.state != "OPEN": - return None, stop( - f"change request {number} is {pull.state.lower()}, not open", "BLOCKED") - reviewed = latest["head_sha"] - step = manual_step(number, pull.head_sha) - - def rejected(problem: str) -> tuple[None, CycleReport]: - return None, stop( - f"the latest receipt of change request {number} is a manual rereview " - f"of {reviewed[:12]}, but {problem}; a manual rereview is never " - "upgraded to a runtime one", UNRESOLVED_END, - stop_reason="manual_rereview_rejected") - - if latest["operator"].strip().casefold() != publisher: - return rejected( - f"it names operator {readable(latest['operator'], 40)!r} while " - f"{readable(publisher or '', 40)!r} published it") - if reviewed != pull.head_sha: - return rejected( - f"the current head is {readable(pull.head_sha, 12)}: a new head " - "invalidates the confirmation. Review the current head and record " - f"it with `{step}`, or resume with --from review to have the " - "runtime review it") - if latest["status"] not in CLEAN_REVIEW_STATUSES: - return rejected( - f"it requested changes ({latest['status']}). Resolve them with " - f"--from resolve --pr {number}, then rereview the new head") - try: - published = recover_comment_history(comments, trusted_authors=trusted_authors) - except ContractError as error: - return rejected(f"the published review history cannot be read: " - f"{readable(str(error), 200)}") - verified = {item["id"] for item in latest.get("finding_outcomes", []) - if item["status"] in {"resolved", "not_applicable"}} - blocking = sorted(finding.id for finding in published.findings - if finding.status == "open" and finding.blocks_approval - and finding.id not in verified) - if blocking: - return rejected( - f"published findings still block approval and it verified none of " - f"them: {', '.join(blocking)}. Record their outcomes with " - "--finding REV-NNN=resolved in a new manual record") - report.review_provenance = MANUAL_PROVENANCE - recorder.review_provenance = MANUAL_PROVENANCE - declared = "; ".join( - f"{readable(item['name'], 60)} {item['result']}" for item in latest["checks"]) - report.note( - f" resume accepted the manual rereview of {reviewed[:12]} by " - f"{readable(latest['operator'], 40)} ({latest['attempt_id']}); checks " - f"declared by the operator, not run by the runtime: {declared}") - journal.record( - "stage_result", role="rereview", stage_seq=recorder.stage_seq, - component="runtime", status="manual_rereview_accepted", - reason="the confirmed manual rereview of the current head is the review " - "the cycle closes on, after its required checks pass", - correlation={"head_sha": reviewed, "attempt_id": latest["attempt_id"], - "review_provenance": MANUAL_PROVENANCE}) - return ("rereview", latest["status"], reviewed, {latest["attempt_id"]}), None - - def close_manual(clean, reported: Reported) -> CycleReport: - """Approve a confirmed manual rereview only once its head's checks pass. - - The same CI gate as a runtime review, under either CI policy: a manual - rereview never bypasses a required check, a conflict, or a later - publication. What it cannot approve goes to a person, never to a - runtime resolution it did not ask for. - """ - role, verdict, head, attempts = clean - report.verdict = verdict - number = pull_number() or change_request_id - outcome = gate(role, head, attempts, reported, provenance=MANUAL_PROVENANCE) - if isinstance(outcome, CycleReport): - return outcome - kind, result = outcome - if kind == "approved": - return stop("", APPROVED_END, reported=reported, stop_reason="approved") - if kind == "moved": - new = readable(result.pr_head_sha or "", 40) - return stop( - f"the change request's head moved after the manual rereview of " - f"{head[:12]}, which invalidates its confirmation; review the new " - f"head and record it with `{manual_step(number, new)}`", - UNRESOLVED_END, reported=reported, stop_reason="head_moved") - detail = _ci_detail(result) - if kind == "conflicting": - detail = "the change request conflicts with its base branch" - return stop( - f"the manual rereview of {head[:12]} is clean, but {detail}; a manual " - f"rereview never bypasses the required checks: resolve with --from " - f"resolve --pr {number}", UNRESOLVED_END, reported=reported, - stop_reason="ci_not_passing") - - def drive() -> CycleReport: - nonlocal verification_plan, criteria_context - nonlocal change_request_id - reported = Reported() - verdict = None - if repair_approval is not None: - journal.record("decision_answered", component="--repair-approval", - decision_key=f"repair:{repair_approval}", status="submitted") - if not change_request_id or code_host != "github": - return stop("repair approval requires a GitHub change request", reported=reported, - stop_reason="triage_repair_blocked") - candidates = repair_proposals(change_request_id) - if len(candidates) != 1: - return stop("approved repair proposal is no longer uniquely available; use manual repair", - reported=reported, stop_reason="triage_repair_blocked") - record, proposal = candidates[0] - - if repair_approval == f"{proposal.comment_id}:{proposal.digest}": - try: - audit_repair(record, "approved", "unverified") - except (sqlite3.Error, ValueError) as error: - return stop(f"could not record repair approval before editing: {error}", - reported=reported, stop_reason="triage_repair_blocked") - - def edit_comment(comment_id: str, body: str) -> None: - result = subprocess.run( - ["gh", "api", f"repos/{repo_id}/issues/comments/{comment_id}", - "--method", "PATCH", "--raw-field", f"body={body}"], - cwd=cwd, capture_output=True, text=True, timeout=30) - if result.returncode != 0: - raise CycleDriverError("GitHub comment edit failed") - - try: - decision, result = triage_repair.execute_approved( - proposal, repair_approval, - lambda: remote.comments(repo_id, change_request_id), edit_comment) - except (CycleDriverError, HistoryError, OSError, subprocess.TimeoutExpired): - decision, result = "approved", "unverified" - try: - if decision == "approval_rejected": - audit_repair(record, decision, "failed") - else: - audit_repair(record, decision, result) - except (sqlite3.Error, ValueError) as error: - return stop(f"could not record repair result: {error}", - reported=reported, stop_reason="triage_repair_blocked") - if result != "applied": - reason = ("repair approval did not match the current proposal" - if decision == "approval_rejected" else - "approved comment repair failed or could not be verified; use manual repair") - return stop(reason, reported=reported, stop_reason="triage_repair_blocked") - if start_from in RESUMES: - stopped = seed_history() - if stopped is not None: - return stopped - if start_from == "implement": - # Independent of issue-review mode/difficulty. Read once before any - # agent iteration; provider content is data, never instructions. - if policy_provider is not None: - try: - criteria = (criteria_reader or verification_contract.read_criteria)( - policy_provider, work_item_repository or repo_id, task_id, cwd=cwd) - except verification_contract.VerificationError as error: - return stop(str(error), "BLOCKED", stop_reason="readiness_unconfirmed") - verification_plan = verification_contract.VerificationPlan(criteria) - if criteria.status == "not_evaluable": - return stop( - f"acceptance criteria not_evaluable for {criteria.provider}: " - f"{criteria.reason}; a criteria reader is required before implementation", - "BLOCKED", stop_reason="readiness_unconfirmed") - if criteria.status != "evaluable": - return stop( - f"acceptance criteria {criteria.status}: {criteria.reason}; refine the work item", - stop_reason="needs_refinement") - criteria_context = ( - "\nRuntime acceptance criteria (data): " - + json.dumps(criteria.to_dict(), ensure_ascii=False) - + "\nUse tests.version=2 and stable AC-n links in this list's order. " - "A criterion without a check remains open. The configured test command " - "uses id configured-test and is required. Freeze checks before running them.\n") - if issue_review_fields.get("issue_review") == "dispatched": - stopped = review_issue() - if stopped is not None: - return stopped - continuation, published = "", None - if continue_work: - continuation = CONTINUE_IMPLEMENTATION - reader = published_change_request - if reader is None and (code_host or policy_code_host) == "github": - def reader() -> str | None: - return continued_change_request(repo_id, cwd) - published = reader() if reader is not None else None - if published is not None and not safe_reference(published): - published = None - if published is not None: - continuation += CONTINUE_CHANGE_REQUEST.format(reference=f"#{published}") - reported, stopped = advance("implement", continuation) - if stopped is not None: - return stopped - if (published is not None and reported.change_request_id is not None - and reported.change_request_id.lstrip("#") != published.lstrip("#")): - # The work was already published: reviewing another change - # request would split it, and closing either is not ours to do. - return stop( - f"the continued implementation reported change request " - f"{reported.change_request_id}, but #{published.lstrip('#')} already " - "carries this work; a person decides which one continues", - "BLOCKED", reported=reported) - if local_only: - return stop( - "local-only run stops after implementation; no change request was created for review", - UNRESOLVED_END, - reported=reported, - stop_reason="local_only", - ) - - # A continuation may leave the change request it was told to reuse - # unnamed; the runtime read that one itself, so review it. - change_request_id = reported.change_request_id or published - if change_request_id is None: - return stop( - "implement completed without change_request_id or legacy pr_number; " - "stopping before review", - reported=reported, - ) - journal.change_request_id = change_request_id - if tracking: - stopped = lock_change_request(change_request_id) - if stopped is not None: - return stopped - - # Under the parallel policy, the clean review the cycle gates on: - # `(role, status, head, attempts whose receipt vouches for it)`. - clean = None - role = None - if (start_from == "rereview" and not local_only - and (code_host or policy_code_host) == "github"): - # A confirmed manual rereview is the only exception to dispatching - # one, under either CI policy (#346). - manual, stopped = manual_review() - if stopped is not None: - return stopped - if manual is not None: - return close_manual(manual, reported) - if parallel and start_from == "rereview": - clean, stopped = reusable_review() - if stopped is not None: - return stopped - if clean is not None: - role, verdict = clean[0], clean[1] - report.verdict = verdict - elif start_from in {"implement", "review"}: - role = "review" - reported, stopped = advance("review", change_request_id=change_request_id) - if stopped is not None: - return stopped - verdict = reported.status - report.verdict = verdict - elif start_from == "rereview": - role = "rereview" - reported, stopped = advance("rereview", "Re-review the change after the fixes.", - change_request_id=change_request_id) - if stopped is not None: - return stopped - verdict = reported.status - report.verdict = verdict - else: - # Resuming at the resolution: the change request's own comments carry - # the review it resolves, and the stage recovers those findings. The - # verdict is what this cycle acts on, not one it observed, so the - # report's verdict stays unset until a re-review gives one. - verdict = "CHANGES_REQUESTED" - - moved = False - ci_failure: ci_gate.GateResult | None = None - conflicting_round_done = False - while True: - if parallel and verdict in CLEAN_REVIEW_STATUSES: - # A clean review is no approval yet: the gate waits for its checks. - if clean is None: - head, stopped = clean_head(role, reported) - if stopped is not None: - return stopped - clean = (role, verdict, head, set(review_attempts)) - head = clean[2] - outcome = gate(clean[0], head, clean[3], reported) - clean = None - if isinstance(outcome, CycleReport): - return outcome - kind, result = outcome - if kind == "approved": - return stop("", APPROVED_END, reported=reported, stop_reason="approved") - if kind == "moved": - new = result.pr_head_sha - if moved or not isinstance(new, str) or FULL_SHA.fullmatch(new) is None: - return stop( - f"the change request's head moved {'again ' if moved else ''}" - f"after the clean review of {head[:12]}; a person decides " - "what to review", UNRESOLVED_END, reported=reported, - stop_reason="head_moved") - moved = True - if recorder.iteration >= max_iterations: - return stop( - f"the change request's head moved after the clean review of " - f"{head[:12]} and the resolution budget is spent", - reported=reported, stop_reason="iteration_limit") - # The review of the old head is not reused: the new head - # gets its own, once, as one round. - recorder.next_iteration() - role = "rereview" - reported, stopped = advance( - "rereview", HEAD_MOVED_REREVIEW.format(old=head, new=new), - change_request_id=change_request_id) - if stopped is not None: - return stopped - verdict = reported.status - report.verdict = verdict - continue - # The checks failed: their diagnosis is the next resolution's. - ci_failure = result - elif verdict != "CHANGES_REQUESTED": - if verdict not in CLEAN_REVIEW_STATUSES: - break - reviewed_head = _reviewed_head( - reported.payload, role).get("reviewed_head_sha") - if reviewed_head is None and reported.payload: - candidate = reported.payload.get("head_sha") - reviewed_head = candidate if isinstance(candidate, str) else "" - mergeability = check_strict_mergeability(reviewed_head or "") - if isinstance(mergeability, CycleReport): - return mergeability - if mergeability is None: - break - if mergeability.status == ci_gate.CI_MERGEABILITY_UNKNOWN: - return stop( - f"GitHub could not determine whether the reviewed head can merge " - f"with its base after bounded retries: {_ci_detail(mergeability)}", - "BLOCKED", reported=reported, - stop_reason="ci_unreadable") - ci_failure = mergeability - - # One resolution round: for the review's blocking findings, for the - # failed checks of a clean review's head, or for both. - survivals = claimed_fix_survivals(history) - recorder.repeated_findings = len(survivals) - repeated = sorted(finding for finding, count in survivals.items() - if count >= SURVIVALS_BEFORE_STOP) - if repeated: - return stop( - f"{', '.join(repeated)} survived {SURVIVALS_BEFORE_STOP} claimed " - "fixes; stopping before another resolution", - reported=reported, stop_reason="repeated_findings") - if (ci_failure is not None and ci_failure.status == ci_gate.CI_CONFLICTING - and conflicting_round_done): - return stop( - "the change request still conflicts with its base after the " - "resolution round", reported=reported, stop_reason="no_progress") - if len(progress) >= 2 and progress[-1] is not None and progress[-1] == progress[-2]: - return stop( - "the re-review found the same head and the same open findings " - "as the previous review", - reported=reported, stop_reason="no_progress") - if recorder.iteration >= max_iterations: - break - recorder.next_iteration() - - instruction = ( - CONFLICT_DIRECTIVE if ci_failure is not None - and ci_failure.status == ci_gate.CI_CONFLICTING else - "Resolve the failed checks of the reviewed head." - if ci_failure is not None else "Resolve the findings from the review.") - contested = sorted(finding for finding, count in survivals.items() - if count >= SURVIVALS_BEFORE_DIRECTIVE) - if contested: - instruction = (f"{instruction} " - f"{CONTESTED_DIRECTIVE.format(ids=', '.join(contested))}") - failed_checks = ci_failure - if parallel and failed_checks is None and role is not None: - # Changes requested while the checks run: one read, never a - # wait, so a failure already known reaches this resolution too. - reviewed = _reviewed_head(reported.payload, role).get("reviewed_head_sha") - if reviewed: - snapshot = read_ci(reviewed, role, once=True) - if snapshot.status == ci_gate.CI_FAILED: - failed_checks = snapshot - if failed_checks is not None and failed_checks.status == ci_gate.CI_FAILED: - instruction += " " + CI_FAILURE_DIRECTIVE.format( - head=failed_checks.head_sha, count=len(failed_checks.failed)) - items = failed_check_items(failed_checks) - if items: - instruction += CI_FAILURE_OUTPUT.format(items="; ".join(items), - repo=repo_id) - open_findings = (history[-1].statuses if history - and history[-1].kind == REVIEW_RUN else {}) - escalation_ids = tuple(sorted( - finding for finding, count in survivals.items() - if (resolve_effort is None and count >= 1 - and open_findings.get(finding) == "open") - )[:16]) - resolved, stopped = advance( - "resolve", instruction, change_request_id=change_request_id, - resolve_target=previous_resolve_target if escalation_ids else None, - resolve_target_used_fallback=(previous_resolve_used_fallback - if escalation_ids else False), - resolve_escalated=bool(escalation_ids), - escalation_finding_ids=escalation_ids) - if stopped is not None: - return stopped - if ci_failure is not None and ci_failure.status == ci_gate.CI_CONFLICTING: - conflicting_round_done = True - if ci_failure is not None: - after = (resolved.payload or {}).get("head_sha") - if after == ci_failure.head_sha: - # Re-reviewing an unchanged head would repeat a review the - # CI state did not change; the failed checks still stand. - return stop( - f"the resolution of the failed checks of {after[:12]} left the " - "head unchanged, so the failures stand", - reported=resolved, stop_reason="no_progress") - ci_failure = None - - role = "rereview" - reported, stopped = advance("rereview", "Re-review the change after the fixes.", - change_request_id=change_request_id) - if stopped is not None: - return stopped - verdict = reported.status - report.verdict = verdict - - # The last stage's own account travels with every ending, not only the bad - # ones: a run that finished still said something about how. Under the - # parallel policy only the CI gate above approves. - if ci_failure is not None and ci_failure.status == ci_gate.CI_CONFLICTING: - return stop( - f"the change request still conflicts with its base after the " - f"resolution budget of {max_iterations} round(s) is spent", - reported=reported, stop_reason="iteration_limit") - if not parallel and verdict == "APPROVED": - return stop("", APPROVED_END, reported=reported, stop_reason="approved") - if ci_failure is not None: - return stop( - f"the checks of {ci_failure.head_sha[:12]} failed after its clean review " - f"and the budget of {max_iterations} round(s) is spent", - reported=reported, stop_reason="iteration_limit") - return stop(f"still {verdict} after {recorder.iteration} round(s)", - reported=reported, stop_reason="iteration_limit") - - - def interrupted(interruption: CycleInterrupted) -> CycleReport: - """Close an interrupted cycle so its record says what happened.""" - outcome = interruption.outcome - where = "between stages" - if outcome is not None: - where = f"during {outcome.role}" - report.stages.append(outcome) - duration = status_writer.stage_finished(outcome.result, None) - report.stage_details.append(StageReportDetails( - round=recorder.iteration, status=None, duration_seconds=duration)) - return stop( - f"interrupted by {interruption.signal_name} {where}; the checkout " - "was left as it was", - stop_reason="interrupted") - - try: - try: - journal_cleanup(telemetry.path, repo_id, - (lambda request: checks_provider.pull(repo_id, request).state) - if policy_host == "github" else None) - except Exception as error: - report.note(f" journal cleanup failed ({type(error).__name__}); detail retained") - return drive() - except CycleInterrupted as interruption: - return interrupted(interruption) - finally: - journal.end_cycle() - if consent is not None: - consent.observer = previous_consent_observer - release_lock() + from cycle_session import CycleSession + return CycleSession(sys.modules[__name__], locals()).execute() def _started_elsewhere(outcome: StageOutcome) -> bool: """Whether this stage launched work that finishes outside this process.""" @@ -5113,6 +3241,9 @@ def detach(arguments: list[str], database: str | None) -> int: run_id = uuid.uuid4().hex[:8] log = directory / f"detached-{stamp}-{os.getpid()}-{run_id}.log" cursor_file = directory / f"detached-{stamp}-{os.getpid()}-{run_id}.cursor" + from workspace_setup import commit_setup, own_file + own_file(log, "detached handoff log") + own_file(cursor_file, "detached handoff cursor") # Seed before Popen: the new cycle's first snapshot must be newer than it. _write_cursor(cursor_file, _visible_revision(directory)) options: dict = {} @@ -5128,6 +3259,7 @@ def detach(arguments: list[str], database: str | None) -> int: stdin=subprocess.DEVNULL, stdout=handle, stderr=subprocess.STDOUT, close_fds=True, **options, ) + commit_setup() progress = [sys.executable, str(Path(__file__).resolve().with_name("cycle_status.py")), "--line", "--cursor-file", str(cursor_file)] if database: @@ -5139,277 +3271,18 @@ def detach(arguments: list[str], database: str | None) -> int: def main(argv: list[str] | None = None) -> int: - apply_gh_environment_defaults() - _configure_stdout() - parser = argparse.ArgumentParser( - prog="run_cycle", - description="Run one work item through a recorded cycle.", - ) - parser.add_argument("--repo", default=None, - help=("repository identifier, owner/name; defaults to " - f"code_cycle.repository.selector in {CONFIG_NAME}")) - parser.add_argument("--task", required=True, help="work item identifier") - parser.add_argument( - "--work-item-repository", default=None, - help="provider repository or project when it differs from the configured default", - ) - # Labelled before routing, never after: choosing a model from a judgement - # and then measuring by model measures the routing rather than the models. - parser.add_argument("--difficulty", type=int, choices=(1, 2, 3), default=2) - parser.add_argument("--verifiability", choices=("auto", "partial", "human"), - default="auto") - parser.add_argument("--security-sensitive", action="store_true") - parser.add_argument("--verification", choices=("available", "unavailable"), - default=None, - help=("whether the change can be verified automatically; " - "recorded as a pre-routing signal, unknown when omitted")) - parser.add_argument("--mode", choices=("production", "calibration"), - default="production") - parser.add_argument("--from", dest="start_from", choices=CYCLE_STARTS, - default="implement", - help=("stage to start at; anything but implement resumes " - "the change request named by --pr (default: implement)")) - parser.add_argument("--pr", dest="change_request", default=None, - help="existing change request a resumed cycle works on") - parser.add_argument("--issue-review", choices=("auto", "off"), default=None, - help=("review the work item before implementing it: auto " - "reviews all but declared trivial, non-sensitive " - "work; off keeps the original flow (default: " - f"code_cycle.issue_review.mode in {CONFIG_NAME}, " - "else auto). A resumed cycle never runs it")) - parser.add_argument("--continue", dest="continue_work", action="store_true", - help=("continue an interrupted implementation from the partial " - "work in --cwd instead of starting it again; skips the " - "issue review")) - parser.add_argument("--max-iterations", type=int, default=3) - parser.add_argument("--ci-policy", choices=CI_POLICIES, default=None, - help=("how the cycle treats the code host's checks: strict waits " - "for them in each writing stage; parallel reviews while " - "they run and approves only after every required check " - "of the reviewed SHA passed (default: " - f"code_cycle.ci.policy in {CONFIG_NAME}, else strict)")) - parser.add_argument("--ci-timeout", type=int, default=None, - help=("seconds the parallel policy's CI gate waits at most for " - f"one SHA (default: code_cycle.ci.timeout, else " - f"{ci_gate.DEFAULT_TIMEOUT})")) - parser.add_argument("--cwd", default=None, - help="working directory the executor runs in") - parser.add_argument("--workspace", choices=WORKSPACE_MODES, default="task", - help="workspace policy: task worktree by default, or current checkout") - parser.add_argument("--local-only", action="store_true", - help=("run only in the explicit --cwd linked worktree " - "with --workspace current; " - "stop after implementation without publishing")) - parser.add_argument("--timeout", type=int, default=None, - help="seconds one dispatch may take") - parser.add_argument("--detach", action="store_true", - help=("start the cycle in its own session and return at once, " - "so a host's task time limit cannot stop it; follow it " - "with cycle_status.py and read its report in the printed log")) - parser.add_argument("--verbose", action="store_true", - help="show stage starts, live progress, and stage results") - parser.add_argument("--progress-interval", type=float, default=60, - help="initial heartbeat seconds (default: 60; later intervals adapt)") - parser.add_argument("--database", default=None, - help=f"telemetry database (default: {default_database_path()})") - parser.add_argument("--config", default=None, - help=f"path to {CONFIG_NAME} (default: alongside the work, else the main worktree's)") - parser.add_argument("--no-config", action="store_true", - help="run on the built-in defaults, ignoring any configuration") - parser.add_argument("--grant", action="append", default=[], metavar="ID=ANSWER", - help="answer a sensitive sandbox permission in advance: " - "docker_host=cycle|persistent|deny (repeatable); persistent " - "and deny are stored only for a scope an earlier run asked about") - parser.add_argument("--repair-approval", default=None, - help="approve exactly one proposed comment repair: COMMENT_ID:SHA256") - parser.add_argument("--execution-profile", choices=EXECUTION_PROFILES, default=None, - help=("how deep the cycle goes (default: code_cycle.execution.profile " - f"in {CONFIG_NAME}, else standard); separate from the model " - "routing profiles")) - parser.add_argument("--objective", choices=EXECUTION_OBJECTIVES, default=None, - help=("the outcome the cycle must reach (default: " - "code_cycle.execution.objective, else the profile's); " - "--local-only is local_change")) - parser.add_argument("--stage", action="append", default=[], metavar="STAGE=POLICY", - help=("set one stage's policy: disabled, auto, enabled or " - "required (repeatable); an override never removes an " - "obligation")) - parser.add_argument("--dry-run", action="store_true", - help=("print the execution plan and exit; runs no stage and " - "changes nothing")) - parser.add_argument("--json", action="store_true", - help="with --dry-run, print the plan as its JSON contract") - args = parser.parse_args(argv) - if args.progress_interval <= 0: - parser.error("--progress-interval must be greater than zero") - if args.json and not args.dry_run: - parser.error("--json needs --dry-run") - if args.dry_run and args.detach: - parser.error("--dry-run runs nothing to detach") - - try: - validate_start(args.start_from, args.change_request, local_only=args.local_only, - continue_work=args.continue_work) - if args.dry_run: - planned = dry_run(args) - print(json.dumps(planned.to_dict(), indent=2) if args.json - else render_plan(planned)) - return 0 - repo, profiles, routing_strategy = plan_with_strategy(args) - config = resolve_config(args, report=print) - execution = execution_of(args, config) - execution_plan_of(args, config, repo, provenance="run") - check_current_flow(execution) - if execution.objective.value == "local_change" and not args.local_only: - # The current flow reaches local_change only as a local-only run, - # with the same explicit worktree it requires. - args.local_only = True - validate_start(args.start_from, args.change_request, local_only=True, - continue_work=args.continue_work) - grant_answers = parse_grant_options(args.grant) - if args.local_only: - validate_local_only_cwd(args.cwd) - if args.workspace != "current": - raise CycleDriverError( - "--local-only requires --workspace current to use the " - "explicitly supplied linked worktree" - ) - if args.continue_work and not args.cwd: - raise CycleDriverError( - "--continue needs --cwd: the checkout the interrupted run used" - ) - ci_policy = ci_policy_of(config, args.ci_policy) - ci_timeout = ci_timeout_of(config, args.ci_timeout) - check_ci_policy(ci_policy, (config.get("code_cycle") or {}).get("code_host")) - if args.start_from in RESUMES: - host = (config.get("code_cycle") or {}).get("code_host") - if host not in (None, "github"): - raise CycleDriverError( - f"resuming reads the pull request through GitHub; code_host " - f"{host} is not supported") - selected_cwd, worktree_dir = select_workspace( - args.task, repo, config, workspace=args.workspace, cwd=args.cwd, - change_request_id=(args.change_request if args.start_from in RESUMES else None), - continue_work=args.continue_work, - ) - if (args.local_only or args.continue_work) and ( - not args.cwd or Path(args.cwd).resolve() != Path(selected_cwd).resolve()): - raise CycleDriverError( - "BLOCKED: the explicitly supplied --cwd does not identify the selected " - "task worktree. Pass its path with --cwd or use --workspace current." - ) - args.cwd = selected_cwd - if args.continue_work: - check_continuation(args.cwd, config) - if args.start_from in RESUMES: - database = Path(args.database) if args.database else default_database_path() - if database.is_file() and args.start_from == "resolve": - check_change_request(repo, args.change_request, args.cwd, - history_store=CycleHistoryStore(database)) - else: - check_change_request(repo, args.change_request, args.cwd) - except (CycleDriverError, GrantError) as error: - parser.error(str(error)) - - if args.detach: - # Everything that can be refused was refused above, where a person is - # still reading; the detached run repeats those checks on its own. - return detach(list(sys.argv[1:] if argv is None else argv), args.database) - - change_bases = change_bases_of(config) - # Already validated by `load_config`; read here so the run carries it. - jev = load_jev_config(config) - # `--issue-review`, `--stage issue_review=…` and the file's mode were layered - # into one policy; the current flow honours auto and disabled. - issue_review = (IssueReviewMode.OFF - if execution.stages["issue_review"].policy is PlanStagePolicy.DISABLED - else IssueReviewMode.AUTO) - work_item_provider, work_item_repository = _work_item_scope( - config, repo, args.work_item_repository - ) - code_host = (config.get("code_cycle") or {}).get("code_host") - policy_code_host = code_host_of(config, args.cwd) - # Every publishing stage is audited on `origin` (#261). Only GitHub can be - # read for change-request state and protected branches until #238; any - # other code host still has its refs audited and the rest is `unverified`. - publication_auditor = (None if args.local_only or not args.cwd else PublicationAuditor( - args.cwd, repo, - GitHubHostReader(args.cwd) if (code_host or policy_code_host) == "github" else None, - # Only a configured default branch; otherwise `origin`'s live HEAD. - default_branch=base_branch_of(config, None))) - # Asked on a terminal only; a run nobody watches never widens a sandbox - # without an answer given in advance. - consent = ConsentBroker( - repo, store=ConsentStore(), answers=grant_answers, - preauthorized=frozenset({"docker_host"} if docker_host_socket_access(config) else ()), - prompt=(terminal_prompt() if sys.stdin.isatty() and sys.stderr.isatty() else None), - ) - telemetry = Telemetry(Path(args.database) if args.database else None) - try: - with interruptible(): - report = run_cycle( - repo, args.task, - TaskSignals(difficulty=args.difficulty, - verifiability=args.verifiability, - security_sensitive=args.security_sensitive), - telemetry, - profiles=profiles, - mode=RoutingMode[args.mode.upper()], - routing_strategy=routing_strategy, - max_iterations=args.max_iterations, - cwd=args.cwd, - timeout=args.timeout, - verbose=args.verbose, - progress_interval=args.progress_interval, - local_only=args.local_only, - change_bases=change_bases, - verification_available=( - None if args.verification is None - else args.verification == "available" - ), - jev=jev, - start_from=args.start_from, - change_request_id=args.change_request, - issue_review=issue_review, - continue_work=args.continue_work, - worktree_dir=worktree_dir, - workspace=args.workspace, - work_item_provider=work_item_provider, - work_item_repository=work_item_repository, - code_host=code_host, - policy_code_host=policy_code_host, - publication_auditor=publication_auditor, - trusted_authors=tuple(((config.get("code_cycle") or {}).get("review") or {}).get( - "trusted_authors", [])), - resolve_effort=((config.get("code_cycle") or {}).get("resolve") or {}).get("effort"), - allowed_test_command=allowed_test_command(config), - allowed_verify_commands=allowed_verify_commands(config), - # None, not empty tuples, when no file was read: the permission - # view infers a withdrawal only from a source it could read (#314). - user_tool_choices=(user_choices.load(config) if configuration_read(args) - else None), - base_branch=base_branch_of(config, args.cwd), - ci_policy=ci_policy, - ci_timeout=ci_timeout, - repair_approval=args.repair_approval, - consent=consent, - ) - except (CycleDriverError, GrantError) as error: - parser.error(str(error)) - except CycleInterrupted as interruption: - # Before the recorder existed, or after it closed: nothing is running - # and there is no stage to record. - print(f"interrupted by {interruption.signal_name} before any stage ran", - file=sys.stderr) - return 1 - - if consent.notes: - report.notes[:0] = [(0, f" permission {readable(line, 300)}") - for line in consent.notes] - save_decisions(report, Path(telemetry.path).parent / "decisions") - print(report.explain()) - print(f"recorded in {telemetry.path}") - return 0 if report.status == APPROVED_END else 1 + from cycle_cli import main as implementation + from workspace_setup import WorkspaceSetup + with WorkspaceSetup() as setup: + try: + return implementation(sys.modules[__name__], argv) + except (CycleDriverError, GrantError) as error: + print(f"run_cycle: error: {error}", file=sys.stderr) + raise SystemExit(2) from error + finally: + errors = setup.rollback() + if errors: + print("setup rollback errors: " + "; ".join(errors), file=sys.stderr) if __name__ == "__main__": diff --git a/scripts/runtime.manifest b/scripts/runtime.manifest index 6998ce4..f71b860 100644 --- a/scripts/runtime.manifest +++ b/scripts/runtime.manifest @@ -49,3 +49,13 @@ jev_shadow.py issue_review.py stop_questions.py behavioral.py + +cycle_publication.py +cycle_dispatch.py +cycle_review_gate.py +cycle_loop.py +cycle_session.py +cycle_cli.py +cycle_workspace.py +workspace_setup.py +cycle_state.py diff --git a/scripts/windows-test-shards.json b/scripts/windows-test-shards.json index 901e17b..536dcbf 100644 --- a/scripts/windows-test-shards.json +++ b/scripts/windows-test-shards.json @@ -47,6 +47,7 @@ "test_ci_failure_access", "test_cycle_journal", "test_dependency_setup", + "test_driver_characterization", "test_execution_profiles", "test_host_permissions", "test_manual_rereview", @@ -58,7 +59,8 @@ "test_stage_dispatch", "test_stage_permissions", "test_support", - "test_validate_workflow" + "test_validate_workflow", + "test_workspace_setup" ], "estimated_shard_seconds": { "windows-1": 416.4, @@ -75,13 +77,15 @@ "windows-2": [ "test_behavioral", "test_dependency_setup", + "test_driver_characterization", "test_environment_recovery", "test_issue_review", "test_jev_shadow", "test_reroute", "test_stage_signals", "test_telemetry", - "test_validate_workflow" + "test_validate_workflow", + "test_workspace_setup" ], "windows-3": [ "test_ci_failure_access", diff --git a/scripts/workspace_setup.py b/scripts/workspace_setup.py new file mode 100644 index 0000000..ff161a2 --- /dev/null +++ b/scripts/workspace_setup.py @@ -0,0 +1,79 @@ +"""Ownership ledger for setup, committed at the first dispatch or handoff.""" +from __future__ import annotations + +from collections.abc import Callable +from contextvars import ContextVar, Token +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class CreatedResource: + kind: str + identity: str + provenance: str + compensate: Callable[[], None] + + +_active: ContextVar[WorkspaceSetup | None] = ContextVar("workspace_setup", default=None) + + +class WorkspaceSetup: + """Compensate owned resources in reverse order until dispatch commits. + + The ledger is run-local, never inferred from an existing checkout. Commit + is permanent: failures in a stage cannot reactivate setup rollback. + """ + + def __init__(self): + self.resources: list[CreatedResource] = [] + self.committed = False + self.errors: list[str] = [] + self._token: Token[WorkspaceSetup | None] | None = None + + def record(self, kind: str, identity: str | Path, provenance: str, + compensate: Callable[[], None]) -> None: + if not self.committed: + self.resources.append(CreatedResource(kind, str(identity), provenance, compensate)) + + def commit(self) -> None: + self.committed = True + self.resources.clear() + + def rollback(self) -> list[str]: + if not self.committed: + while self.resources: + resource = self.resources.pop() + try: + resource.compensate() + except Exception as error: + self.errors.append(f"{resource.kind} {resource.identity}: {error}") + return self.errors + + def __enter__(self): + self._token = _active.set(self) + return self + + def __exit__(self, *exc): + try: + self.rollback() + finally: + assert self._token is not None + _active.reset(self._token) + + +def current_setup() -> WorkspaceSetup | None: + return _active.get() + + +def commit_setup() -> None: + setup = current_setup() + if setup is not None: + setup.commit() + + +def own_file(path: Path, provenance: str): + """Reserve an absent setup file before its writer can fail halfway.""" + setup = current_setup() + if setup is not None and not path.exists(): + setup.record("file", path, provenance, lambda: path.unlink(missing_ok=True)) diff --git a/tests/test_driver_characterization.py b/tests/test_driver_characterization.py new file mode 100644 index 0000000..defec6a --- /dev/null +++ b/tests/test_driver_characterization.py @@ -0,0 +1,75 @@ +"""Observable driver contracts pinned before extracting its implementation.""" +import contextlib +import io +import unittest + +from test_run_cycle import ( + RunCycleTestCase, Talker, Sequence, Starter, Interrupter, block, rc, +) +from cycle_loop import CycleControl, ControlState + + +class DriverCharacterizationTests(RunCycleTestCase): + def test_control_table_covers_retry_resume_gate_and_interruption(self): + paths = [ + ["issue_review", "issue_review", "implement", "review", "resolve", "rereview", "ci", "stop"], + ["review", "review", "ci", "rereview", "ci", "resolve", "rereview", "stop"], + ["resolve", "resolve", "rereview", "interrupt", "stop"], + ["rereview", "ci", "stop"], + ] + for path in paths: + with self.subTest(path=path): + control = CycleControl() + for event in path: + control.move(event) + self.assertEqual(ControlState.STOPPED, control.state) + self.assertEqual(path, [event for _, event, _ in control.transitions]) + + def test_stage_attempt_sequences_and_endings(self): + cases = [ + ({}, Talker("codex"), Talker("claude"), + ["implement", "review"], rc.APPROVED_END), + ({}, Talker("codex"), Sequence("claude", [block("CHANGES_REQUESTED"), block("APPROVED")]), + ["implement", "review", "resolve", "rereview"], rc.APPROVED_END), + ({"start_from": "resolve", "change_request_id": "74"}, + Talker("codex"), Talker("claude"), ["resolve", "rereview"], rc.APPROVED_END), + ({}, Starter("codex"), Talker("claude"), ["implement"], rc.UNRESOLVED_END), + ({}, Interrupter("codex"), Talker("claude"), ["implement"], rc.UNRESOLVED_END), + ({}, Talker("codex", block("BLOCKED")), Talker("claude"), + ["implement"], rc.UNRESOLVED_END), + ] + for index, (options, coder, reviewer, roles, ending) in enumerate(cases): + with self.subTest(index=index): + before = len(self.rows()) + report = self.run_cycle(coder, reviewer, **options) + dispatches = [r for r in self.rows()[before:] + if r["payload"].get("record_kind") == "dispatch"] + self.assertEqual(roles, [s.role for s in report.stages]) + self.assertEqual(roles, [r["role"] for r in dispatches]) + self.assertEqual(ending, report.status) + + def test_cli_parse_errors_keep_exit_two(self): + for flags, message in [ + (["--progress-interval", "0"], "--progress-interval must be greater than zero"), + (["--json"], "--json needs --dry-run"), + (["--dry-run", "--detach"], "--dry-run runs nothing to detach"), + (["--from", "resolve"], "--from resolve requires --pr"), + ]: + with self.subTest(flags=flags): + stderr = io.StringIO() + with contextlib.redirect_stderr(stderr), self.assertRaises(SystemExit) as error: + rc.main(["--repo", "owner/api", "--task", "API-7", "--no-config", *flags]) + self.assertEqual(2, error.exception.code) + self.assertIn(message, stderr.getvalue()) + + def test_report_explain_text(self): + report = rc.CycleReport("owner/api", "API-7", stopped_because="fixture stop", + reason="agent account") + report.note(" fixture note") + self.assertEqual( + "owner/api API-7: HUMAN_INTERVENTION\n fixture note\n" + " stopped: fixture stop\n reason: agent account", report.explain()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_workspace_setup.py b/tests/test_workspace_setup.py new file mode 100644 index 0000000..5b9b6cc --- /dev/null +++ b/tests/test_workspace_setup.py @@ -0,0 +1,222 @@ +"""Rollback at the real Git boundary, limited to this run's resources.""" +import contextlib +import io +import os +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest import mock + +import test_run_cycle as fixtures +from test_run_cycle import RunCycleTestCase, Talker, rc +from workspace_setup import WorkspaceSetup, own_file + + +def setUpModule(): + global restore_environment + restore_environment = fixtures.isolate_host_environment() + + +def tearDownModule(): + restore_environment() + + +class WorkspaceTransactionTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) / "repo" + self.fixture = fixtures.WorkspaceSelectionTests() + self.config = self.fixture.repository(self.root) + self.target = self.root / ".worktree" / "task-189" + + def git(self, *args): + return self.fixture.git(self.root, *args) + + def select(self, **kw): + return rc.select_workspace("189", "owner/api", self.config, cwd=str(self.root), **kw) + + def assert_removed(self): + self.assertFalse(self.target.exists()) + self.assertNotIn("task-189", self.git("branch", "--format=%(refname:short)").splitlines()) + self.assertNotIn(str(self.target), self.git("worktree", "list", "--porcelain")) + + def test_failure_after_selection_removes_owned_worktree_and_branch(self): + with WorkspaceSetup() as setup: + self.select() + self.assertEqual(["directory", "branch", "worktree"], [r.kind for r in setup.resources]) + self.assertEqual([], setup.errors) + self.assert_removed() + + def test_failure_after_manager_created_resources_is_rolled_back(self): + def run(command, **kw): + result = subprocess.run(command, **kw) + if command[:3] == ["git", "worktree", "add"] and result.returncode == 0: + return subprocess.CompletedProcess(command, 1, "", "injected manager failure") + return result + with self.assertRaisesRegex(rc.CycleDriverError, "injected manager failure"): + self.select(run=run) + self.assert_removed() + + def test_preexisting_branch_is_retained(self): + self.git("branch", "task-189", "main") + with WorkspaceSetup(): + self.select() + self.assertFalse(self.target.exists()) + self.assertIn("task-189", self.git("branch", "--format=%(refname:short)").splitlines()) + + def test_continue_reuses_worktree_without_owning_it(self): + selected, _ = self.select() + with WorkspaceSetup() as setup: + self.select(continue_work=True) + self.assertEqual([], setup.resources) + self.assertTrue(Path(selected).is_dir()) + + def test_herdr_created_worktree_has_provenance_and_is_rolled_back(self): + def run(command, **kw): + if command[:3] == ["herdr", "worktree", "create"]: + command = ["git", "worktree", "add", "-b", + command[command.index("--branch") + 1], + command[command.index("--path") + 1], + command[command.index("--base") + 1]] + return subprocess.run(command, **kw) + with mock.patch.dict(os.environ, {"HERDR_ENV": "1"}), WorkspaceSetup() as setup: + self.select(run=run) + self.assertEqual("Herdr", setup.resources[-1].provenance) + self.assert_removed() + + def test_preexisting_herdr_worktree_is_not_owned(self): + self.select() + def run(command, **kw): + if command[:3] == ["herdr", "worktree", "open"]: + return subprocess.CompletedProcess(command, 0, "", "") + return subprocess.run(command, **kw) + with mock.patch.dict(os.environ, {"HERDR_ENV": "1"}), WorkspaceSetup() as setup: + self.select(continue_work=True, run=run) + self.assertEqual([], setup.resources) + self.assertTrue(self.target.exists()) + + def pr_setup(self, *, existing=False): + # A local origin exports a pull ref at a commit not on main. + self.git("checkout", "-b", "pr-source") + (self.root / "feature").write_text("PR\n") + self.git("add", "feature") + self.git("commit", "-m", "PR head") + sha = self.git("rev-parse", "HEAD") + self.git("update-ref", "refs/pull/74/head", sha) + self.git("checkout", "main") + self.git("branch", "-D", "pr-source") + self.git("remote", "add", "origin", str(self.root)) + if existing: + self.git("update-ref", "refs/code-cycle/pulls/74/head", sha) + return sha + + def test_created_pr_ref_is_removed_after_worktree_and_branch(self): + self.pr_setup() + with mock.patch.object(rc, "_pull_request_branch", return_value="feature-189"), WorkspaceSetup() as setup: + self.select(change_request_id="74") + self.assertEqual([], setup.errors) + self.assertFalse(self.target.exists()) + self.assertNotIn("feature-189", self.git("branch", "--format=%(refname:short)").splitlines()) + self.assertEqual("", self.git("for-each-ref", "--format=%(refname)", "refs/code-cycle/pulls/74")) + + def test_existing_pr_ref_survives_rollback(self): + sha = self.pr_setup(existing=True) + with mock.patch.object(rc, "_pull_request_branch", return_value="feature-189"), WorkspaceSetup(): + self.select(change_request_id="74") + self.assertEqual(sha, self.git("rev-parse", "refs/code-cycle/pulls/74/head")) + + def test_created_pr_ref_in_use_is_retained_with_error(self): + sha = self.pr_setup() + with mock.patch.object(rc, "_pull_request_branch", return_value="feature-189"), WorkspaceSetup() as setup: + self.select(change_request_id="74") + self.git("branch", "another-user", sha) + self.assertEqual(sha, self.git("rev-parse", "refs/code-cycle/pulls/74/head")) + self.assertTrue(any("fetched ref is in use" in error for error in setup.errors)) + + def test_main_failure_before_run_cycle_rolls_back_and_exits_two(self): + config = self.root / ".code-cycle.yml" + config.write_text("code_cycle:\n repository:\n default_branch: main\n") + stderr = io.StringIO() + with mock.patch.object(rc, "code_host_of", side_effect=rc.CycleDriverError("injected setup failure")): + with contextlib.redirect_stderr(stderr), contextlib.redirect_stdout(io.StringIO()): + with self.assertRaises(SystemExit) as error: + rc.main(["--repo", "owner/api", "--task", "189", "--cwd", str(self.root), + "--config", str(config)]) + self.assertEqual(2, error.exception.code) + self.assertIn("injected setup failure", stderr.getvalue()) + self.assert_removed() + + def test_rollback_collects_errors_and_continues_in_reverse_order(self): + seen = [] + with WorkspaceSetup() as setup: + setup.record("fixture", "first", "test", lambda: seen.append("first")) + def fail(): + seen.append("second") + raise RuntimeError("injected") + setup.record("fixture", "second", "test", fail) + setup.record("fixture", "third", "test", lambda: seen.append("third")) + self.assertEqual(["third", "second", "first"], seen) + self.assertEqual(["fixture second: injected"], setup.errors) + setup.rollback() + self.assertEqual(3, len(seen)) + + def test_successful_detach_commits_and_failed_handoff_rolls_back(self): + for fails in (False, True): + with self.subTest(fails=fails): + with tempfile.TemporaryDirectory() as temporary: + database = str(Path(temporary) / "telemetry.sqlite") + owned = Path(temporary) / "owned" + owned.write_text("owned") + with WorkspaceSetup() as setup: + setup.record("fixture", owned, "test", owned.unlink) + with mock.patch.object(rc.subprocess, "Popen", side_effect=OSError("handoff failed") if fails else None) as popen: + popen.return_value.pid = 42 + with contextlib.redirect_stdout(io.StringIO()): + if fails: + with self.assertRaises(OSError): + rc.detach([], database) + else: + self.assertEqual(0, rc.detach([], database)) + self.assertEqual(not fails, owned.exists()) + if fails: + self.assertEqual([], list((Path(temporary) / "status").glob("detached-*"))) + + +class DispatchCommitTests(RunCycleTestCase): + def test_routing_stop_keeps_rollback_active_and_removes_owned_status(self): + with WorkspaceSetup() as setup: + report = rc.run_cycle( + "owner/api", "API-7", fixtures.router.TaskSignals(), self.store, + registry=fixtures.ex.Registry([Talker("codex"), Talker("claude")]), + availability={"codex": fixtures.ex.Availability.UNKNOWN, + "claude": fixtures.ex.Availability.UNKNOWN}, + issue_review="off") + self.assertFalse(setup.committed) + status_files = list((Path(self.store.path).parent / "status").glob("*.json")) + self.assertTrue(status_files) + self.assertEqual(rc.UNRESOLVED_END, report.status) + self.assertTrue(all(not path.exists() for path in status_files)) + + def test_stage_failure_after_dispatch_never_rolls_back_setup(self): + with WorkspaceSetup() as setup: + marker = self.home / "owned" + marker.parent.mkdir(parents=True, exist_ok=True) + own_file(marker, "test") + marker.write_text("owned") + self.run_cycle(Talker("codex", "no structured result"), Talker("claude")) + self.assertTrue(setup.committed) + self.assertTrue(marker.exists()) + + def test_pre_dispatch_failure_rolls_back_setup(self): + with WorkspaceSetup() as setup: + marker = self.home / "owned" + marker.parent.mkdir(parents=True, exist_ok=True) + own_file(marker, "test") + marker.write_text("owned") + with mock.patch.object(rc, "CycleRecorder", side_effect=rc.CycleDriverError("before dispatch")): + with self.assertRaises(rc.CycleDriverError): + self.run_cycle(Talker("codex"), Talker("claude")) + self.assertFalse(setup.committed) + self.assertFalse(marker.exists())