Repository navigation
Valid handoff note rejected when the note is committed more than once #213
Description
Problem
A valid handoff note is rejected when the model commits it in more than one commit. _validate_handoff_note (lifecycle.py, around line 4940) takes the commit directly beneath the newest note commit as the expected last_work_commit. If the model commits the note, then edits and commits it again, the commit beneath the newest note commit is the previous note commit, not the work commit. The note correctly names the work commit, but it is rejected with:
handoff_note_rejected: Handoff note's `last_work_commit` does not match the last preserved work commit.
The attempt is then downgraded from handoff to incomplete, so no continuation is scheduled, even though the implementation is committed and the full gate is green.
Observed on
simple-rpg-engine issue #33, attempt 2026-10-11T16:28:27.446796Z, agent 0.4.3, model muse-spark-1.3-contributor behind LiteLLM.
Branch agent/issue-33 after the attempt (newest first):
f70753a Handoff note: issue #33 <- newest commit touching .agent/handoff/33.md (note_commit)
2fca1c4 Handoff note: issue #33 <- commits[note_index + 1], used as expected_last_work_commit
67b744f Implement content compatibility by reference check <- the note says last_work_commit: 67b744f…
Timeline from agent_output.json:
| UTC | event |
|---|---|
| 16:36:22 | token_soft_threshold_crossed (budget 6 303 047 / soft 6 300 000), during a code-review with two subagents |
| 16:36:23–29 | skill_call handoff, but only from the two subagents (these don't count as a main-thread handoff) |
| 16:38:20 | token_soft_threshold_handoff_context_injected. The pre-filled header has last_work_commit: 67b744f… (HEAD at injection). The model writes .agent/handoff/33.md by hand and commits it as 2fca1c4 |
| 16:38:49 | token_soft_threshold_handoff_followup. The model rewrites the note (3 lines changed, same header) and commits again as f70753a |
| 16:39:04 | model_execution_finished: status=model_limit_reached |
| 16:39:04 | handoff_note_rejected, then completion_decided: outcome=incomplete |
Because the model committed a note, the MODEL_LIMIT_REACHED path in lifecycle.py (around line 537) uses that note instead of writing an emergency one, and turns the status into HANDOFF_REQUESTED. Only the validator stands between this attempt and a proper handoff.
Expected
The note names the last work commit beneath the note. That holds here: 67b744f is the newest attempt commit that isn't a note commit. The validator should accept it.
Proposed fix
In _validate_handoff_note, when deriving expected_last_work_commit, skip consecutive commits beneath note_commit that touch only handoff_note_relpath(issue_number). Use the first commit below them that changes anything else, or base_revision if there is none. A seal commit landing on top of the note is already handled by handoff_note_commit being position-independent. This change makes the lookup below the note equally tolerant.
Add a regression test next to test_handoff_note_with_stale_last_work_commit_publishes_as_ordinary_incomplete in tests/test_handoff_end_to_end.py: a work commit, then two note-only commits with last_work_commit set to the work commit. The test should assert outcome=handoff. Keep the existing stale-commit test failing as it does now.
Related
- Handoff silently downgraded to incomplete when the note's started_at is not ISO 8601 #174: same class of problem (a handoff silently downgraded to
incompleteby a strict note-field check). - Separately, the model ignored the injected instruction to invoke the
handoffskill via theSkilltool, twice. That isn't what this issue is about: a model-written note is already accepted on this path, as long as it validates.
Activity
lbacik commentedon Oct 11, 2026
Second occurrence: the proposed fix doesn't cover it
Same rejection on simple-rpg-engine issue #34, attempt
2026-10-11T17:03:20.578527Z, agent0.4.3, same model behind LiteLLM. This time, though, the "skip note-only commits" rule proposed above would not fix it, because the first note commit also carries a work change.Branch
agent/issue-34after the attempt (newest first):Timeline from
agent_output.json:token_soft_threshold_crossed(6 308 118 / soft 6 300 000)token_soft_threshold_handoff_context_injected. At this point HEAD isd40b4a0(= base) and all the work is uncommittedgit add .agent/handoff/34.md && git commit. Agit mvit had staged earlier gets swept into the same commit,755aa72(2 files changed)code_review_nudge, thenskill_call code-reviewtoken_soft_threshold_handoff_followup. The model rewrites the note (same header) and commits it again as832d60a(note only)model_execution_finished: status=model_limit_reached, then the seal commit3cd85bbwith the uncommitted workhandoff_note_rejected, thencompletion_decided: outcome=incompleteThe validator's expected value is
commits[note_index + 1]=755aa72. The proposed fix would keep755aa72, because that commit touches more than the note, so the note'sd40b4a0would still be rejected.Suggested amendment
When deriving
expected_last_work_commit, skip the consecutive commits beneathnote_committhat touchhandoff_note_relpath(issue_number), whatever other files they also change. Use the first commit below that run, orbase_revisionif there is none. Checked against both cases:f70753a,2fca1c4; expected67b744f, accepted.832d60a,755aa72; expectedd40b4a0(base), accepted.Trade-off: a change swept into a note commit (here the rename) doesn't count as "last work". It is still preserved on the branch, and the continuation reads the branch, so nothing is lost.
For the regression tests, add a second case next to the one proposed above: a work commit, then a note commit that also changes another file, then a note-only commit, with
last_work_commitset to the work commit (or tobase_revisionwhen there is no work commit). Assertoutcome=handoff.lbacik commentedon Oct 12, 2026
Agent Brief
Category: bug
Summary: Accept a valid handoff note when this attempt committed the note more than once
Current behavior:
_validate_handoff_notefinds the newest commit that touches the issue's handoff note (handoff_note_relpath(issue_number)). It then takes the attempt commit directly beneath it (newest-first) as the expectedlast_work_commit, orbase_revisionwhen there is none. If the model commits the note more than once in the same attempt, the commit beneath the newest note commit is the previous note commit. The note then fails withhandoff_note_rejected: Handoff note's last_work_commit does not match the last preserved work commit., and the attempt is downgraded fromhandofftoincomplete. Triage reproduced both shapes from this issue onmain(0.4.3):base_revisionand is rejected.Desired behavior:
When deriving the expected
last_work_commit, skip the unbroken run of commits beneath the newest note commit that both:handoff_note_commitalready applies: author date at or after the attempt start (attempt_id/ checkpointstarted_at), with no time bound whenattempt_idcan't be parsed.The expected value is the first commit below that run, or
base_revisionif the run reaches the bottom of the attempt commits.The attempt-only limit matters. On a continued branch, an earlier round's note commit can sit directly under this round's notes. When this round has made no commit before the note, the pre-filled note header and the runner's emergency note both set
last_work_committo HEAD. HEAD is then that earlier-round note commit, and it must stay accepted, as it is today. Do not skip earlier rounds' note commits.Trade-off, accepted on purpose: a non-note change swept into one of this attempt's note commits (the #34 shape) doesn't count as "last work". It is still preserved on the branch, which is what a continuation reads.
All other checks in the validator stay as they are: title,
issue,reason,started_atas a warning only, and the minimum prefix length oflast_work_commit. A seal commit on top of the note stays handled, as it is today.Key interfaces:
AttemptCommitonly carriesrevisionandsubject, so the validator can't currently tell which commits beneath the note touched the note path. TheAttemptWorkspaceprotocol, and its git-backed implementationGitWorkspace, need a way to report this attempt's note-touching commits, not just the newest one. One option is a method returning all of this attempt's commits that touch the note path, newest first; the newest is whathandoff_note_commitreturns today. The agent decides the exact shape. Keephandoff_note_commit's contract unchanged, or derive it from the new method. Update every in-memory/fake workspace used in tests, plus the workspace conformance tests, so they implement the new capability._validate_handoff_note(...): receives the extra information it needs (for example, the set of this attempt's note-commit revisions) and applies the skip rule above. Every call site in the lifecycle passes it consistently. There are several: the normal completion path, theMODEL_LIMIT_REACHEDpath, and the recovery/resume paths.Acceptance criteria:
test_handoff_note_with_stale_last_work_commit_publishes_as_ordinary_incomplete): a work commit W, then two note-only commits, both withlast_work_commit= W. The attempt ends withoutcome=handoff.last_work_commit=base_revision. The attempt ends withoutcome=handoff. A variant with a work commit W before them, naming W, also ends inhandoff.last_work_commitis the earlier round's note commit (HEAD at injection). The attempt ends withoutcome=handoff, notincomplete.last_work_commit="0" * 40) still endsincompletewith thelast_work_commitrejection in the comment.Out of scope:
handoffskill instead of writing the note by hand. That is a separate behavior; a model-written note is already accepted on this path when it validates._ALLOWED_HANDOFF_REASONS.last_work_commitcheck into a warning.lbacik commentedon Oct 12, 2026
Agent Attempt Result: complete
Branch: agent/issue-213
PR: #214
Evidence
UV_PROJECT_ENVIRONMENT=/home/agent/venv-py311 uv run --python 3.11 --extra dev ruff check && UV_PROJECT_ENVIRONMENT=/home/agent/venv-py311 uv run --python 3.11 --extra dev pytest && UV_PROJECT_ENVIRONMENT=/home/agent/venv-py313 uv run --python 3.13 --extra dev pytest→ exit code 0Details
Claude SDK completed the implementation workflow.