Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,27 @@ Useful launch controls:
- `--extra_instructions` accepts a Markdown file or literal operator guidance.
- `human_issues: false` disables GitHub Issue polling for isolated launches.

Operators can use the same guarded assignment transitions as the advisor
without constructing branches or pull requests through raw `git` or `gh`:

```bash
export GH_REPO=owner/repo
# GITHUB_TOKEN or GH_TOKEN must already be set in the environment.
uv run python -m senpai_agent.github.operator \
--workspace target --advisor-branch research --student-names fern,frieren \
adopt-assignment action.json
```

Use `create-assignment` for a new typed branch and draft PR. Each command reads
its corresponding `CreateAssignmentAction` or `AdoptAssignmentAction` JSON from
the named file, or from standard input when the path is `-`. Repository and
GitHub credentials are accepted only through `GH_REPO` and
`GITHUB_TOKEN`/`GH_TOKEN`; the command uses the same validated executors as the
advisor tool.

Do not edit an adopted PR body concurrently with this command; GitHub does not
offer this client a body-write lease.

Advisor and student images are built from the same source revision. The advisor image excludes CUDA and PyTorch; the student image contains the CUDA/PyTorch runtime; the cutoff image contains only the minimal job runtime and pinned `kubectl`. Advisor and student builds install Chromium and execute an OpenHands browser smoke test.

For multi-day fleets, [`arm_senpai_cluster_cutoff.sh`](scripts/arm_senpai_cluster_cutoff.sh) creates a cluster-side hard cutoff that does not depend on an operator laptop remaining online. It can also hold a shared start gate until the expected fleet is ready or its readiness deadline expires.
Expand Down
12 changes: 12 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,7 @@ Advisor operations that act on an assignment share this object:
| Tool | Role | Input beyond the shared `assignment` object |
|---|---|---|
| `create_assignment` | advisor | `assignment_id`, `revision_id`, `student`, `expected_base_sha`, `head_branch`, `title`, `body`; the base is the configured advisor branch |
| `adopt_assignment` | advisor | `pr_number`, `assignment_id`, `revision_id`, `student`, `expected_base_sha`, `head_branch`, `expected_pr_head_sha`; the base is the configured advisor branch |
| `publish_advisor_branch` | advisor | `remote_branch_sha_before_push`, `local_commit_sha` |
| `repair_assignment_routing` | advisor | `working_state` (`wip` or `review`) and a `blockers` list containing only `blocked`, `hold`, or `needs-rebase` |
| `send_assignment_feedback` | advisor | `feedback_id`, `comment` |
Expand All @@ -331,6 +332,17 @@ assignment commit with `git commit-tree`, publishes with force-with-lease,
refuses a second active assignment for the student, creates or reconciles one
draft PR, embeds a typed assignment marker, and verifies routing state.

Assignment adoption is a separate, explicit recovery transition for an
existing markerless draft PR. It verifies the configured student and base,
exact remote head and Git ancestry, authenticated PR author, WIP routing, and
absence of conflicting protocol state before adding one marker; it never
infers assignment identity from PR prose. `repair_assignment_routing` remains
limited to labels and draft state after a valid marker already exists.

The transition re-reads the body immediately before its PATCH and verifies the
marker afterward, but this client has no PR-body write lease; operators must
not edit that body concurrently with adoption.

Advisor feedback carries exact assignment, revision, and PR-head preconditions.
It creates one immutable feedback ID without changing the assignment marker,
draft state, or routing labels, so a nudge reaches the current conversation
Expand Down
98 changes: 98 additions & 0 deletions senpai_agent/git_assignment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"""Read-only verification for adopting an existing remote assignment branch."""

from pathlib import Path

from pydantic import SecretStr

from senpai_agent.git_workflow import (
GitWorkflowPreconditionError,
_git,
_remote_head,
_validate_token,
)


def require_remote_assignment_history(
workspace: Path,
*,
branch: str,
expected_head_sha: str,
base_branch: str,
expected_base_sha: str,
remote: str = "origin",
token: SecretStr | None = None,
) -> None:
"""Verify that an existing remote branch contains its declared research base."""

workspace = Path(workspace).resolve()
_validate_token(token)
_git(workspace, "rev-parse", "--is-inside-work-tree")
_git(workspace, "check-ref-format", "--branch", branch)
_git(workspace, "check-ref-format", "--branch", base_branch)
if branch == base_branch:
raise GitWorkflowPreconditionError(
"assignment branch must differ from the base branch"
)
if not expected_head_sha.strip() or not expected_base_sha.strip():
raise ValueError("expected head and base SHAs must not be empty")

remote_head = _remote_head(workspace, remote, branch, token=token)
if not remote_head:
raise GitWorkflowPreconditionError(
f"remote assignment branch {branch!r} does not exist"
)
remote_base = _remote_head(workspace, remote, base_branch, token=token)
if not remote_base:
raise GitWorkflowPreconditionError(
f"remote base branch {base_branch!r} does not exist"
)

for ref, expected in ((base_branch, remote_base), (branch, remote_head)):
_git(
workspace,
"fetch",
"--no-tags",
remote,
f"refs/heads/{ref}",
token=token,
)
if _git(workspace, "rev-parse", "FETCH_HEAD") != expected:
raise GitWorkflowPreconditionError(
f"remote branch {ref!r} moved while verifying assignment history"
)

try:
base = _git(workspace, "rev-parse", f"{expected_base_sha}^{{commit}}")
head = _git(workspace, "rev-parse", f"{expected_head_sha}^{{commit}}")
except GitWorkflowPreconditionError as error:
raise GitWorkflowPreconditionError(
"expected assignment head and base must be fetched Git commits"
) from error
if base != expected_base_sha or head != expected_head_sha:
raise GitWorkflowPreconditionError(
"assignment SHAs must be full Git commit object IDs"
)
try:
_git(workspace, "merge-base", "--is-ancestor", head, remote_head)
except GitWorkflowPreconditionError as error:
raise GitWorkflowPreconditionError(
f"remote head {remote_head} does not contain expected head {head}"
) from error
try:
branch_point = _git(workspace, "merge-base", remote_base, remote_head)
except GitWorkflowPreconditionError as error:
raise GitWorkflowPreconditionError(
f"assignment head {remote_head} does not share research-base history"
) from error
if branch_point != base:
raise GitWorkflowPreconditionError(
f"assignment branch diverges at {branch_point}, expected base {base}"
)

if (
_remote_head(workspace, remote, branch, token=token) != remote_head
or _remote_head(workspace, remote, base_branch, token=token) != remote_base
):
raise GitWorkflowPreconditionError(
"remote assignment history moved while it was being verified"
)
54 changes: 27 additions & 27 deletions senpai_agent/github/mailbox/advisor.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,15 @@
ResultMarkerError,
authoritative_marker_line,
experiment_result_digest,
parse_assignment_markers,
parse_research_base_acceptance_markers,
parse_result_markers,
)

from .values import (
assignment_from_pull,
github_datetime,
label_names,
malformed_assignment_event,
object_value,
pull_reference,
result_matches_assignment,
Expand All @@ -42,7 +43,9 @@ def advisor_events(
) -> tuple[ControllerEvent, ...]:
events: list[ControllerEvent] = []
active_assignments: list[tuple[dict[str, object], AssignmentRecord]] = []
active_by_student: dict[str, list[int]] = {student: [] for student in mailbox.students}
active_by_student: dict[str, list[int]] = {
student: [] for student in mailbox.students
}
now = datetime.now(UTC)
for pull in pulls:
labels = label_names(pull)
Expand All @@ -53,36 +56,33 @@ def advisor_events(
for label in labels
if label.startswith("student:")
)
if "status:wip" in labels:
for student in students:
active_by_student.setdefault(student, []).append(number)
reference = pull_reference(pull)
assignment = None
if {"status:wip", "status:review"} & labels:
if {"status:wip", "status:review"} & labels and len(students) == 1:
try:
assignments = parse_assignment_markers(str(pull.get("body") or ""))
except ValueError:
assignments = []
if len(assignments) == 1:
assignment = assignments[0]
assignment = assignment_from_pull(pull, repo=mailbox.repo)
except ValueError as error:
events.append(malformed_assignment_event(pull, error))
else:
active_assignments.append((pull, assignment))
if "status:review" in labels:
review_payload = reference
review_identity: tuple[object, ...] = (number, head_sha)
if assignment is not None:
review_identity = (
number,
assignment.assignment_id,
assignment.revision_id,
head_sha,
)
review_payload = {
**reference,
"assignment_id": assignment.assignment_id,
"revision_id": assignment.revision_id,
}
if assignment is not None and "status:wip" in labels:
active_by_student.setdefault(assignment.student, []).append(number)
if assignment is not None and "status:review" in labels:
review_identity: tuple[object, ...] = (
number,
assignment.assignment_id,
assignment.revision_id,
head_sha,
)
review_payload = {
**reference,
"assignment_id": assignment.assignment_id,
"revision_id": assignment.revision_id,
}
events.append(
versioned_event("review_ready", *review_identity, payload=review_payload)
versioned_event(
"review_ready", *review_identity, payload=review_payload
)
)
reasons: list[str] = []
if "status:blocked" in labels:
Expand Down
114 changes: 30 additions & 84 deletions senpai_agent/github/mailbox/student.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,57 +2,27 @@

from __future__ import annotations

import re
from collections.abc import Mapping, Sequence
from collections.abc import Sequence
from typing import TYPE_CHECKING

from senpai_agent.mailbox import ControllerEvent
from senpai_agent.models import AssignmentRecord, parse_assignment_markers
from senpai_agent.models import AssignmentRecord

from .feedback import student_pr_feedback_events
from .issues import human_issue_events
from .values import label_names, object_value, pull_reference, versioned_event
from .values import (
assignment_from_pull,
label_names,
malformed_assignment_event,
object_value,
pull_reference,
versioned_event,
)

if TYPE_CHECKING:
from .core import GitHubMailbox


_GIT_OBJECT_ID = re.compile(r"(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})\Z")


def _validate_assignment_route(
pull: Mapping[str, object],
assignment: AssignmentRecord,
*,
repo: str,
student: str,
) -> None:
head = pull.get("head")
base = pull.get("base")
if not isinstance(head, dict) or not isinstance(base, dict):
raise ValueError("assigned PR has invalid head or base metadata")
expected = {
"repo": (assignment.repo, repo),
"student": (assignment.student, student),
"head_ref": (assignment.head_ref, str(head.get("ref") or "")),
"base_ref": (assignment.base_ref, str(base.get("ref") or "")),
}
mismatches = [
name for name, (recorded, live) in expected.items() if recorded != live
]
if mismatches:
raise ValueError(
"assignment marker does not match PR routing: " + ", ".join(mismatches)
)
for name, value in (
("assignment head SHA", assignment.head_sha),
("assignment base SHA", assignment.base_sha),
("live head SHA", str(head.get("sha") or "")),
):
if _GIT_OBJECT_ID.fullmatch(value) is None:
raise ValueError(f"{name} is not a full Git object ID")


def student_events(
mailbox: GitHubMailbox,
pulls: Sequence[dict[str, object]],
Expand All @@ -66,12 +36,28 @@ def student_events(
if assignment_label in label_names(pull)
and {"status:wip", "status:review"} & label_names(pull)
]
wip = [pull for pull in relevant if "status:wip" in label_names(pull)]

events: list[ControllerEvent] = []
assignments: list[tuple[dict[str, object], AssignmentRecord]] = []
for pull in relevant:
try:
assignment = assignment_from_pull(
pull,
repo=mailbox.repo,
expected_student=mailbox.student_name,
)
except ValueError as error:
events.append(malformed_assignment_event(pull, error))
continue
assignments.append((pull, assignment))

wip = [
(pull, assignment)
for pull, assignment in assignments
if "status:wip" in label_names(pull)
]
duplicate_wip = len(wip) > 1
if duplicate_wip:
numbers = sorted(int(pull["number"]) for pull in wip)
numbers = sorted(int(pull["number"]) for pull, _assignment in wip)
events.append(
ControllerEvent(
kind="duplicate_assignment",
Expand All @@ -86,47 +72,7 @@ def student_events(
)
)

for pull in relevant:
try:
student_labels = {
label
for label in label_names(pull)
if label.startswith("student:")
}
if student_labels != {assignment_label}:
raise ValueError(
"assigned PR must contain exactly one student label"
)
markers = parse_assignment_markers(str(pull.get("body") or ""))
if len(markers) != 1:
raise ValueError(
"assigned PR must contain exactly one Senpai assignment marker"
)
assignment = markers[0]
if assignment.student != mailbox.student_name:
raise ValueError(
"assignment marker student does not match the student label"
)
_validate_assignment_route(
pull,
assignment,
repo=mailbox.repo,
student=mailbox.student_name,
)
except ValueError as error:
number = int(pull["number"])
head_sha = str(object_value(pull["head"])["sha"])
payload = {
**pull_reference(pull),
"error": f"Assigned PR #{number}: {error}",
}
events.append(
versioned_event(
"malformed_assignment", number, head_sha, payload=payload
)
)
continue

for pull, assignment in assignments:
feedback = student_pr_feedback_events(mailbox, pull, assignment)
prior_revision_pending = any(
event.payload["assignment_id"] != assignment.assignment_id
Expand Down
Loading
Loading