Skip to content
Merged
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
14 changes: 14 additions & 0 deletions simple_coding_agent/attempt_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,20 @@ def handoff_note_commit(
"""
...

def handoff_note_commits(
self, prepared: PreparedAttempt, issue_number: int, *, attempt_id: str
) -> tuple[AttemptCommit, ...]:
"""Every commit of this attempt touching the issue's handoff note path.

Newest first, under the same rules as :meth:`handoff_note_commit`:
position-independent, and limited to this attempt (earlier rounds'
note commits are excluded). The newest entry is what
:meth:`handoff_note_commit` returns. Used by handoff-note validation
to skip the unbroken run of this attempt's note commits beneath the
newest one when deriving the expected ``last_work_commit``.
"""
...

def cleanup(
self, *, base_branch: str, prepared: PreparedAttempt, retain_branch: bool
) -> None:
Expand Down
43 changes: 31 additions & 12 deletions simple_coding_agent/git_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -396,17 +396,16 @@ def commit_handoff_note(self, issue_number: int, content: str) -> AttemptCommit:
revision = self._git("rev-parse", "HEAD")
return AttemptCommit(revision=revision, subject=subject)

def handoff_note_commit(
def handoff_note_commits(
self, prepared: PreparedAttempt, issue_number: int, *, attempt_id: str
) -> AttemptCommit | None:
"""Return the newest commit of this attempt touching the handoff note path.

Position-independent: a seal commit landing on top of a
model-committed note does not hide the note. Notes carried over from
earlier rounds of a continued branch are ignored: only commits
authored at or after the attempt start (``attempt_id`` is the
checkpoint's ``started_at``) count. Author dates survive the rebase
onto the base branch, unlike revisions. An unparseable
) -> tuple[AttemptCommit, ...]:
"""Return every commit of this attempt touching the handoff note path.

Newest first, under the same rules as :meth:`handoff_note_commit`:
position-independent, and limited to this attempt (notes carried over
from earlier rounds of a continued branch are ignored: only commits
authored at or after the attempt start count). Author dates survive
the rebase onto the base branch, unlike revisions. An unparseable
``attempt_id`` applies no time bound.
"""

Expand All @@ -418,14 +417,34 @@ def handoff_note_commit(
"--",
handoff_note_relpath(issue_number),
)
found: list[AttemptCommit] = []
for line in output.splitlines():
revision, _, rest = line.partition("\x00")
authored, separator, subject = rest.partition("\x00")
if not separator or not revision or not authored.isdigit():
raise GitWorkspaceError("Git returned an invalid commit listing")
if started is None or int(authored) >= started:
return AttemptCommit(revision=revision, subject=subject)
return None
found.append(AttemptCommit(revision=revision, subject=subject))
return tuple(found)

def handoff_note_commit(
self, prepared: PreparedAttempt, issue_number: int, *, attempt_id: str
) -> AttemptCommit | None:
"""Return the newest commit of this attempt touching the handoff note path.

Position-independent: a seal commit landing on top of a
model-committed note does not hide the note. Notes carried over from
earlier rounds of a continued branch are ignored: only commits
authored at or after the attempt start (``attempt_id`` is the
checkpoint's ``started_at``) count. Author dates survive the rebase
onto the base branch, unlike revisions. An unparseable
``attempt_id`` applies no time bound.
"""

commits = self.handoff_note_commits(
prepared, issue_number, attempt_id=attempt_id
)
return commits[0] if commits else None

def cleanup(
self, *, base_branch: str, prepared: PreparedAttempt, retain_branch: bool = True
Expand Down
76 changes: 71 additions & 5 deletions simple_coding_agent/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from __future__ import annotations

import asyncio
from collections.abc import Callable, Mapping, Sequence
from collections.abc import Callable, Collection, Mapping, Sequence
from dataclasses import dataclass, replace
from datetime import UTC, datetime
from enum import StrEnum
Expand Down Expand Up @@ -661,6 +661,9 @@ def __call__(self, claim: Claim, profile: RepositoryProfile, prepared: PreparedA
claim.issue.number,
self._workspace.working_directory,
prepared.base_revision,
note_commit_revisions=_this_attempt_note_commit_revisions(
self._workspace, prepared, claim.issue.number, attempt_id
),
)
_emit_handoff_note_events(
self._event_log, claim.issue.number, validation
Expand Down Expand Up @@ -1959,6 +1962,12 @@ def _infer_retry_decision(
claim.issue.number,
self._workspace.working_directory,
prepared.base_revision,
note_commit_revisions=_this_attempt_note_commit_revisions(
self._workspace,
prepared,
claim.issue.number,
checkpoint.started_at,
),
)
_emit_handoff_note_events(
self._event_log, claim.issue.number, validation
Expand Down Expand Up @@ -2022,6 +2031,12 @@ def _infer_release_outcome(
claim.issue.number,
self._workspace.working_directory,
prepared.base_revision,
note_commit_revisions=_this_attempt_note_commit_revisions(
self._workspace,
prepared,
claim.issue.number,
checkpoint.started_at,
),
)
except Exception:
validation = None
Expand Down Expand Up @@ -3321,6 +3336,9 @@ def _check_operator_note(
issue_number,
working_directory,
prepared.base_revision,
note_commit_revisions=_this_attempt_note_commit_revisions(
self._workspace, prepared, issue_number, attempt_id
),
)
except Exception as error:
return False, f"the handoff note could not be validated ({_exception_detail(error)})"
Expand Down Expand Up @@ -3617,6 +3635,12 @@ def _reconcile_startup(self) -> AttemptOutcome | None:
claim.issue.number,
self._workspace.working_directory,
prepared.base_revision,
note_commit_revisions=_this_attempt_note_commit_revisions(
self._workspace,
prepared,
claim.issue.number,
checkpoint.started_at,
),
)
_emit_handoff_note_events(
self._event_log, claim.issue.number, validation
Expand Down Expand Up @@ -4864,12 +4888,36 @@ class _HandoffNoteValidation:
started_at_warning: str | None = None


def _this_attempt_note_commit_revisions(
workspace: AttemptWorkspace,
prepared: PreparedAttempt,
issue_number: int,
attempt_id: str,
) -> frozenset[str]:
"""Revisions of this attempt's note-touching commits for note validation.

An unreadable note history fails closed to just the newest note commit,
which is exactly the pre-fix strictness: validation then expects the
commit directly beneath the note.
"""

try:
commits = workspace.handoff_note_commits(
prepared, issue_number, attempt_id=attempt_id
)
except Exception:
return frozenset()
return frozenset(commit.revision for commit in commits)


def _validate_handoff_note(
note_commit: AttemptCommit | None,
commits: Sequence[AttemptCommit],
issue_number: int,
working_directory: Path,
base_revision: str,
*,
note_commit_revisions: Collection[str] | None = None,
) -> _HandoffNoteValidation:
"""Confirm a handoff has a trustworthy, committed note before it may publish.

Expand Down Expand Up @@ -4937,15 +4985,33 @@ def _validate_handoff_note(
last_work_commit = fields.get("last_work_commit", "")
# The note describes the work preserved beneath it: the attempt commit
# directly under the note in newest-first order, or the base revision
# when the note is the oldest attempt commit.
# when the note is the oldest attempt commit. When the model committed
# the note more than once, the commits beneath the newest note commit
# are earlier note commits of this same attempt, not work: skip the
# unbroken run of this attempt's note-touching commits below the note
# (whatever other files they also carry) and expect the first commit
# beneath that run. Note commits from earlier rounds of a continued
# branch are never in ``note_commit_revisions``, so a new round that
# names the previous round's note commit (HEAD at injection) still
# validates.
if note_commit_revisions is None:
note_revisions = {note_commit.revision}
else:
note_revisions = set(note_commit_revisions) | {note_commit.revision}
note_index = next(
(index for index, commit in enumerate(commits) if commit.revision == note_commit.revision),
None,
)
if note_index is not None and note_index + 1 < len(commits):
expected_last_work_commit = commits[note_index + 1].revision
else:
if note_index is None:
expected_last_work_commit = base_revision
else:
cursor = note_index + 1
while cursor < len(commits) and commits[cursor].revision in note_revisions:
cursor += 1
if cursor < len(commits):
expected_last_work_commit = commits[cursor].revision
else:
expected_last_work_commit = base_revision
if (
len(last_work_commit) < 7
or not expected_last_work_commit.startswith(last_work_commit)
Expand Down
19 changes: 14 additions & 5 deletions tests/fakes.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,14 +267,23 @@ def prior_round_commit(self, subject: str, files: tuple[str, ...]) -> AttemptCom

return self._record_commit(subject, "", frozenset(files), clear_dirty=False, prior_round=True)

def handoff_note_commits(
self, prepared: PreparedAttempt, issue_number: int, *, attempt_id: str
) -> tuple[AttemptCommit, ...]:
relpath = handoff_note_relpath(issue_number)
return tuple(
AttemptCommit(revision=record.revision, subject=record.subject)
for record in reversed(self._commits)
if relpath in record.files and not record.prior_round
)

def handoff_note_commit(
self, prepared: PreparedAttempt, issue_number: int, *, attempt_id: str
) -> AttemptCommit | None:
relpath = handoff_note_relpath(issue_number)
for record in reversed(self._commits):
if relpath in record.files and not record.prior_round:
return AttemptCommit(revision=record.revision, subject=record.subject)
return None
commits = self.handoff_note_commits(
prepared, issue_number, attempt_id=attempt_id
)
return commits[0] if commits else None

def cleanup(
self, *, base_branch: str, prepared: PreparedAttempt, retain_branch: bool
Expand Down
98 changes: 98 additions & 0 deletions tests/test_attempt_workspace_conformance.py
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,104 @@ def test_handoff_note_commit_ignores_a_note_from_an_earlier_round(
assert found.revision == note.revision


def test_handoff_note_commits_returns_every_note_commit_newest_first(
attempt: SimpleNamespace,
) -> None:
first = attempt.workspace.commit_handoff_note(24, "# Handoff note: issue #24\n\none\n")
model_commit(attempt, "work.txt", "work", "Model work")
second = attempt.workspace.commit_handoff_note(24, "# Handoff note: issue #24\n\ntwo\n")

commits = attempt.workspace.handoff_note_commits(
attempt.prepared, 24, attempt_id=attempt.attempt_id
)

assert [entry.revision for entry in commits] == [second.revision, first.revision]
assert (
attempt.workspace.handoff_note_commit(
attempt.prepared, 24, attempt_id=attempt.attempt_id
)
== commits[0]
)


def test_handoff_note_commits_includes_a_note_commit_with_other_files(
attempt: SimpleNamespace,
) -> None:
"""A note commit that also carries work still touches the note path (#34)."""

if attempt.kind == "git":
note_path = attempt.root / ".agent" / "handoff" / "24.md"
note_path.parent.mkdir(parents=True, exist_ok=True)
note_path.write_text("# Handoff note: issue #24\n")
(attempt.root / "work.txt").write_text("work")
git(attempt.root, "add", ".agent/handoff/24.md", "work.txt")
git(attempt.root, "commit", "-m", "Handoff note: issue #24")
note = attempt.workspace.handoff_note_commit(
attempt.prepared, 24, attempt_id=attempt.attempt_id
)
assert note is not None
else:
attempt.workspace.make_dirty(".agent/handoff/24.md", "# Handoff note: issue #24\n")
attempt.workspace.make_dirty("work.txt", "work")
note = attempt.workspace.model_commit(
"Handoff note: issue #24",
files=(".agent/handoff/24.md", "work.txt"),
)

commits = attempt.workspace.handoff_note_commits(
attempt.prepared, 24, attempt_id=attempt.attempt_id
)

assert [entry.revision for entry in commits] == [note.revision]


def test_handoff_note_commits_excludes_notes_from_an_earlier_round(
attempt: SimpleNamespace,
) -> None:
prior_round_commit(
attempt, ".agent/handoff/24.md", "# Handoff note: issue #24\n", "Handoff note: issue #24"
)
note = attempt.workspace.commit_handoff_note(24, "# Handoff note: issue #24\n\nnew\n")

commits = attempt.workspace.handoff_note_commits(
attempt.prepared, 24, attempt_id=attempt.attempt_id
)

assert [entry.revision for entry in commits] == [note.revision]


def test_handoff_note_commits_survives_a_seal_commit_on_top(
attempt: SimpleNamespace,
) -> None:
note = attempt.workspace.commit_handoff_note(24, "# Handoff note: issue #24\n")
dirty_file(attempt, "work.txt", "uncommitted edits")
seal_commit = attempt.workspace.seal(
issue_number=24,
attempt_id="2026-09-29T00:00:00Z",
status=ModelExecutionStatus.HANDOFF_REQUESTED,
)
assert seal_commit is not None

commits = attempt.workspace.handoff_note_commits(
attempt.prepared, 24, attempt_id=attempt.attempt_id
)

assert [entry.revision for entry in commits] == [note.revision]


def test_handoff_note_commits_is_empty_without_a_note(
attempt: SimpleNamespace,
) -> None:
model_commit(attempt, "work.txt", "work", "Model work")

assert (
attempt.workspace.handoff_note_commits(
attempt.prepared, 24, attempt_id=attempt.attempt_id
)
== ()
)


def test_cleanup_on_a_dirty_tree_stays_on_the_attempt_branch(
attempt: SimpleNamespace,
) -> None:
Expand Down
Loading
Loading