A model works out and sets up what a project needs on its build VM; code makes sure that happens, bounds it and checks the result (D3). - Before each driver run on a project, one round trip checks the build VM: the harness's own baseline (missing: the item waits, a template problem), the workspace clone (missing: cloned again and said; the item resumes from what it pushed) and a per-project marker. - No marker (a new project, a new or wiped VM): the item's driver model runs a bounded preflight first, with its own prompt, the execution tools (screened as ever) and toolchain_ready. The harness runs each reported version command itself (version-shaped commands only, even from the marker, which the model's commands could edit) and writes the marker. Gaps and an unfinished preflight are facts and work goes on; the thread hears only of installs and gaps. A rebuilt VM starts from the last toolchain. - Later runs check the versions again; a change is a fact and a thread line. The PR body says what the work was tested with. - A changed host key is its own failure (HostKeyChanged): the item waits for the operator's `trust new host key`, which drops the pin; then the VM is set up again. - A prompt line: install what the work needs on the build VM, say why, and put lasting needs in the project's own pins. - tuning.preflight: max_turns, command_timeout_s. - The property walk gains a VM wipe and ready_before_work (checked against a mutant that skips preflight); parked_work_safe accepts that a wipe loses what wasn't pushed (D3). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
373 lines
16 KiB
Python
373 lines
16 KiB
Python
"""The workspace and milestones of a work item (D3, D4, D5).
|
|
|
|
The harness owns branches: a work item starts from a fresh default branch on
|
|
a branch of its own, a milestone is cut by start_milestone, and finishing it
|
|
commits only the work, pushes with the push credential, and opens the PR.
|
|
Publishing is idempotent under an intent (L8): an open PR for the branch is
|
|
adopted, and a PR the operator closed is an answer, never reopened.
|
|
"""
|
|
|
|
import contextlib
|
|
import time
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
from typing import Literal
|
|
|
|
from nisse_dev.git import GitError, GitRepo, PushCredential
|
|
from nisse_dev.gitea import GiteaClient, GiteaError, PullRequest
|
|
from nisse_dev.state import StateStore
|
|
|
|
OPEN_PR = "open_pr"
|
|
SET_ASIDE = "set-aside"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Published:
|
|
kind: Literal["opened", "adopted", "nothing", "closed", "failed"]
|
|
note: str
|
|
pr: PullRequest | None = None
|
|
left_out: tuple[str, ...] = ()
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Fixed:
|
|
kind: Literal["pushed", "nothing", "new-pr", "closed", "failed"]
|
|
note: str
|
|
pr: PullRequest | None = None
|
|
branch: str = "" # a new PR's branch, which later fixes go to
|
|
|
|
|
|
class Milestones:
|
|
def __init__(
|
|
self,
|
|
store: StateStore,
|
|
git: GitRepo,
|
|
gitea: GiteaClient,
|
|
*,
|
|
repo: str,
|
|
remote: str,
|
|
credential: PushCredential | None,
|
|
base: str = "main",
|
|
) -> None:
|
|
self._store = store
|
|
self._git = git
|
|
self._gitea = gitea
|
|
self._repo = repo
|
|
self._remote = remote
|
|
self._credential = credential
|
|
self._base = base
|
|
|
|
# -- a work item's workspace (D3) ------------------------------------------
|
|
|
|
def prepare(self, item_branch: str, *, keep: tuple[str, ...] = ()) -> list[str]:
|
|
"""A fresh workspace for a work item: fetch, set aside (never
|
|
discard) whatever was left, check out a clean default branch, and put
|
|
the item on a branch of its own. Branches starting with a ``keep``
|
|
prefix belong to open work items, which parking has pushed: they are
|
|
left alone. Returns what was set aside."""
|
|
git = self._git
|
|
self.park(keep=keep)
|
|
git.fetch(credential=self._credential)
|
|
stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
notes: list[str] = []
|
|
if git.has_changes():
|
|
aside = f"{SET_ASIDE}/{stamp}/worktree"
|
|
git.checkout_new_branch(aside)
|
|
git.commit_all("Set aside: uncommitted changes left in the workspace")
|
|
notes.append(f"uncommitted changes, as {aside}")
|
|
for branch in git.local_branches():
|
|
if branch == self._base or branch.startswith(f"{SET_ASIDE}/"):
|
|
continue
|
|
if keep and branch.startswith(keep):
|
|
continue
|
|
git.rename_branch(branch, f"{SET_ASIDE}/{stamp}/{branch}")
|
|
notes.append(f"branch {branch}, as {SET_ASIDE}/{stamp}/{branch}")
|
|
if self._base in git.local_branches() and self._ahead_of_origin():
|
|
aside = f"{SET_ASIDE}/{stamp}/{self._base}"
|
|
git.branch_at(aside, self._base)
|
|
notes.append(f"unpushed commits on {self._base}, as {aside}")
|
|
git.reset_to(self._base, f"origin/{self._base}")
|
|
git.checkout_new_branch(item_branch)
|
|
return notes
|
|
|
|
# -- parking and resuming (L1, D4) -----------------------------------------
|
|
|
|
def park(self, *, keep: tuple[str, ...]) -> str | None:
|
|
"""Before another item uses the workspace: nothing of the item on it
|
|
may live only on the build VM. Its uncommitted work is stashed and
|
|
pushed to ``<item>-parked``, never committed onto its branch, which
|
|
may be a PR's that has merged meanwhile; resuming puts it back
|
|
uncommitted, for wherever the work goes next. Commits the harness
|
|
made but couldn't push are pushed. Only an open item's harness branch
|
|
(one starting with a ``keep`` prefix, ``<item>-``) is parked; returns
|
|
its name."""
|
|
git = self._git
|
|
branch = git.current_branch()
|
|
prefix = next((k for k in keep if branch.startswith(k)), None)
|
|
if prefix is None:
|
|
return None
|
|
sha = git.stash_all(f"parked from {branch}")
|
|
if sha is not None:
|
|
git.push_commit(
|
|
self._remote, sha, f"{prefix}parked", credential=self._credential
|
|
)
|
|
remote = git.remote_sha(self._remote, branch, credential=self._credential)
|
|
if remote != git.head_sha() and (
|
|
remote is not None or git.commits_since(f"origin/{self._base}") > 0
|
|
):
|
|
git.push(self._remote, branch, credential=self._credential)
|
|
return branch
|
|
|
|
def resume(self, branch: str, *, keep: tuple[str, ...], mine: str) -> None:
|
|
"""Back to a parked item: park whatever is on the workspace now,
|
|
check out the item's branch, and put its parked work back. The local
|
|
branch wins when it exists (it can only be ahead: parking pushed it);
|
|
otherwise it comes from origin. A workspace already on one of the
|
|
item's own branches (``mine``, its ``<item>-`` prefix) stays."""
|
|
git = self._git
|
|
current = git.current_branch()
|
|
if current != branch and not current.startswith(mine):
|
|
self.park(keep=keep)
|
|
if branch in git.local_branches():
|
|
git.checkout_existing_branch(branch)
|
|
git.reset_to(branch, branch)
|
|
else:
|
|
git.fetch(credential=self._credential)
|
|
pushed = git.remote_sha(
|
|
self._remote, branch, credential=self._credential
|
|
)
|
|
# A branch never pushed held nothing beyond the default branch.
|
|
start = f"origin/{branch}" if pushed else f"origin/{self._base}"
|
|
git.reset_to(branch, start)
|
|
# Even when it stayed: a crash may have come between parking and the
|
|
# next item's start.
|
|
self.unpark(mine)
|
|
|
|
def unpark(self, mine: str) -> None:
|
|
parked = f"{mine}parked"
|
|
git = self._git
|
|
sha = git.remote_sha(self._remote, parked, credential=self._credential)
|
|
if sha is None:
|
|
return
|
|
if not git.has_commit(sha):
|
|
git.fetch(credential=self._credential)
|
|
git.apply_stash(sha)
|
|
git.delete_remote_branch(self._remote, parked, credential=self._credential)
|
|
|
|
def drop_parked(self, mine: str) -> None:
|
|
"""A closed item's parked work, if any, goes: closing set it aside."""
|
|
parked = f"{mine}parked"
|
|
git = self._git
|
|
if git.remote_sha(self._remote, parked, credential=self._credential):
|
|
git.delete_remote_branch(self._remote, parked, credential=self._credential)
|
|
|
|
@property
|
|
def git(self) -> GitRepo:
|
|
"""The clone, for a delegation's branch (L9)."""
|
|
return self._git
|
|
|
|
def changed(self) -> list[str]:
|
|
"""What the current milestone changes, from the default branch."""
|
|
return self._git.changed_paths(f"origin/{self._base}")
|
|
|
|
# -- a new project's checks (D1, D4) -----------------------------------------
|
|
|
|
def reclone(self, account: str) -> None:
|
|
"""The workspace's clone again, after the build VM was rebuilt or
|
|
wiped (D3, M4.1): as at the start, with no credential in it."""
|
|
if not self._git.exists():
|
|
self._git.clone(self._remote, credential=self._credential)
|
|
self._git.configure_identity(account, f"{account}@users.noreply.local")
|
|
self._git.install_excludes()
|
|
|
|
def verify_access(self, account: str) -> list[str]:
|
|
"""What the operator provided, checked rather than assumed: the
|
|
agent may push a branch with its push credential, may not push to
|
|
the default branch, and may not merge its own PR without approval.
|
|
A throwaway branch with an empty commit carries the checks; its PR
|
|
is closed and the branch deleted. Returns what failed."""
|
|
git, base, failures = self._git, self._base, []
|
|
try:
|
|
repo = self._gitea.repository(self._repo)
|
|
if not (repo.get("permissions") or {}).get("push"):
|
|
failures.append(f"{account} isn't a collaborator with write access")
|
|
branch = self._gitea.branch(self._repo, base)
|
|
if not branch.get("protected") or branch.get("user_can_push"):
|
|
failures.append(f"{base} isn't protected against my pushes")
|
|
except GiteaError as exc:
|
|
return [f"I can't see the repository {self._repo}: {exc}"]
|
|
check = f"nisse-check-{uuid.uuid4().hex[:6]}"
|
|
try:
|
|
git.fetch(credential=self._credential)
|
|
git.reset_to(check, f"origin/{base}")
|
|
git._git("commit", "-q", "--allow-empty", "-m", "nisse-dev: access check")
|
|
except GitError as exc:
|
|
return [*failures, f"the workspace isn't usable: {exc}"]
|
|
try:
|
|
git.push(self._remote, check, credential=self._credential)
|
|
except GitError as exc:
|
|
failures.append(f"my push credential can't push a branch: {exc}")
|
|
self._back_to_base(check)
|
|
return failures
|
|
try:
|
|
git.push(self._remote, base, credential=self._credential)
|
|
failures.append(
|
|
f"I could push to {base}, which protection should refuse; an "
|
|
f"empty commit, 'nisse-dev: access check', is on {base} now"
|
|
)
|
|
except GitError:
|
|
pass # refused, as it should be
|
|
pr = None
|
|
try:
|
|
pr = self._gitea.open_pull_request(
|
|
self._repo,
|
|
head=check,
|
|
base=base,
|
|
title="nisse-dev access check (never merged)",
|
|
body="Checks that merging needs an approval; closed straight away.",
|
|
)
|
|
try:
|
|
self._gitea.merge_pull_request(self._repo, pr.number)
|
|
failures.append(
|
|
f"I could merge my own PR #{pr.number} without an approval"
|
|
)
|
|
except GiteaError:
|
|
pass # refused, as it should be
|
|
except GiteaError as exc:
|
|
failures.append(f"I couldn't open a PR to check merging: {exc}")
|
|
finally:
|
|
if pr is not None:
|
|
with contextlib.suppress(GiteaError):
|
|
self._gitea.close_pull_request(self._repo, pr.number)
|
|
with contextlib.suppress(GitError):
|
|
git.delete_remote_branch(
|
|
self._remote, check, credential=self._credential
|
|
)
|
|
self._back_to_base(check)
|
|
return failures
|
|
|
|
def _back_to_base(self, branch: str) -> None:
|
|
with contextlib.suppress(GitError):
|
|
self._git.reset_to(self._base, f"origin/{self._base}")
|
|
self._git._git("branch", "-q", "-D", branch)
|
|
|
|
def _ahead_of_origin(self) -> bool:
|
|
current = self._git.current_branch()
|
|
try:
|
|
self._git.checkout_existing_branch(self._base)
|
|
return self._git.commits_since(f"origin/{self._base}") > 0
|
|
finally:
|
|
if current and current != self._base:
|
|
self._git.checkout_existing_branch(current)
|
|
|
|
# -- milestones (D4) -------------------------------------------------------
|
|
|
|
def start(self, branch: str) -> str:
|
|
"""Cut the milestone's branch from the latest default branch (D4):
|
|
a milestone after a merge builds on it, and one redone after a closed
|
|
PR doesn't carry the rejected work. Uncommitted work comes along.
|
|
Returns where it starts."""
|
|
self._git.fetch(credential=self._credential)
|
|
self._git.checkout_new_branch(branch, f"origin/{self._base}")
|
|
return self._git.head_sha()
|
|
|
|
def publish(
|
|
self,
|
|
*,
|
|
item_id: str,
|
|
branch: str,
|
|
title: str,
|
|
summary: str,
|
|
issue: int,
|
|
final: bool = True,
|
|
) -> Published:
|
|
git = self._git
|
|
try:
|
|
if git.current_branch() != branch:
|
|
git.checkout_new_branch(branch)
|
|
git.commit_all(title)
|
|
left_out = tuple(git.left_out())
|
|
if git.commits_since(f"origin/{self._base}") == 0:
|
|
return Published("nothing", "no changes to publish", left_out=left_out)
|
|
existing = self._gitea.find_pull_request(self._repo, branch)
|
|
if existing is not None and existing.state != "open":
|
|
# D4: the operator closed it; that is their answer.
|
|
return Published(
|
|
"closed",
|
|
f"PR #{existing.number} for this branch was closed",
|
|
existing,
|
|
left_out,
|
|
)
|
|
intent = self._store.begin_intent(
|
|
OPEN_PR,
|
|
{"branch": branch, "title": title, "issue": issue},
|
|
cause=f"publishing {branch}",
|
|
work_item=item_id,
|
|
)
|
|
git.push(self._remote, branch, credential=self._credential)
|
|
existing = self._gitea.find_pull_request(self._repo, branch)
|
|
if existing is not None and existing.state == "open":
|
|
self._store.commit_intent(
|
|
intent, cause=f"adopted PR #{existing.number}"
|
|
)
|
|
return Published("adopted", "pushed to the open PR", existing, left_out)
|
|
pr = self._gitea.open_pull_request(
|
|
self._repo,
|
|
head=branch,
|
|
base=self._base,
|
|
title=title,
|
|
body=_body(summary, issue, left_out, final),
|
|
)
|
|
self._store.commit_intent(intent, cause=f"opened PR #{pr.number}")
|
|
return Published("opened", f"opened PR #{pr.number}", pr, left_out)
|
|
except (GitError, GiteaError) as exc:
|
|
return Published("failed", f"publishing failed: {exc}")
|
|
|
|
def push_fix(
|
|
self, *, item_id: str, branch: str, pr: int, message: str, attempt: int
|
|
) -> Fixed:
|
|
"""Push a review fix to the PR's branch, then check the PR: one that
|
|
merged before the fix landed gets the fix as a new PR, and one that
|
|
was closed is reported (D5)."""
|
|
git = self._git
|
|
try:
|
|
if git.current_branch() != branch:
|
|
git.checkout_existing_branch(branch)
|
|
sha = git.commit_all(message) or git.head_sha()
|
|
if git.remote_sha(self._remote, branch, credential=self._credential) == sha:
|
|
return Fixed("nothing", "no change was needed")
|
|
git.push(self._remote, branch, credential=self._credential)
|
|
state = self._gitea.pull_request(self._repo, pr)
|
|
if state.merged:
|
|
git.fetch(credential=self._credential)
|
|
if git.is_ancestor(sha, f"origin/{self._base}"):
|
|
return Fixed("pushed", "pushed, and it was merged with the PR")
|
|
follow = f"{branch}-fix-{attempt}"
|
|
git.checkout_new_branch(follow)
|
|
new = self.publish(
|
|
item_id=item_id,
|
|
branch=follow,
|
|
title=f"Follow-up to #{pr}: {message}",
|
|
summary=f"PR #{pr} merged before this review fix landed.",
|
|
issue=0,
|
|
)
|
|
return Fixed("new-pr", new.note, new.pr, follow)
|
|
if state.state != "open":
|
|
return Fixed("closed", f"PR #{pr} was closed", state)
|
|
return Fixed("pushed", f"pushed {sha[:8]}", state)
|
|
except (GitError, GiteaError) as exc:
|
|
return Fixed("failed", f"pushing the fix failed: {exc}")
|
|
|
|
|
|
def _body(
|
|
summary: str, issue: int, left_out: tuple[str, ...], final: bool = True
|
|
) -> str:
|
|
parts = [summary]
|
|
if issue:
|
|
# D1: the last PR closes the issue on merge; earlier ones are part of it.
|
|
parts += ["", f"Closes #{issue}" if final else f"Part of #{issue}"]
|
|
if left_out:
|
|
parts += ["", "## Left out", "", "Build artifacts, never part of the work:"]
|
|
parts += [f"- `{path}`" for path in left_out]
|
|
return "\n".join(parts)
|