Files
nisse/nisse-dev/src/nisse_dev/loop/milestones.py
T
dongarandClaude Opus 5.5 59fd00ce8c
CI / checks (push) Skipped
CI / checks (pull_request) Failing after 5m23s
CI / e2e (pull_request) Canceled after 21m42s
Roles, capability profiles and help on the new loop (M2.6)
- roles.driver (unset: the planner's model) and roles.classifier (unset:
  the expert's); the mapping is pinned when an item opens, and the driver
  and the go-ahead reader run on the item's own (L9).
- profiles: in configuration (tools, strengths, weaknesses), used in
  briefings: the driver's instructions say what its helper and expert are
  good at, the helper's brief who reads its handback; never a model name.
- delegate(task) and ask_expert(question) for the driver. A helper works
  on a branch off the milestone's; on a handback its work comes back as
  uncommitted changes, otherwise it is discarded, so harness branches
  never hold commits only the clone has. ask_expert keeps its budget.
- Triggers: failure_limit failed commands in a row (Driver's new "stuck"
  outcome) or the turn cap, mid-milestone, call a helper in once per
  milestone; if it fails too, the item waits on the operator. A
  delegation cut off by a crash is discarded at boot.
- Evaluation capture: delegations and ask_expert as before, and the
  reader's verdicts as classifier pairs.

Tests: help tests (merge-back, discard, needs a milestone, the stuck
trigger and its once-per-milestone limit, the expert and its budget,
pinning, briefings by profile, boot discarding a cut-off delegation),
config tests for roles and profiles, and the walk delegating with a
scripted helper that sometimes fails, with a delegations_settle property.
The walk found helper commits stranded on a merged PR's branch, fixed by
returning a helper's work uncommitted.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
2026-10-07 16:33:26 +00:00

365 lines
15 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 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)