Skip to content

Ask whether a rebase hand-over would be taken before replaying, so a refused one leaves no conflict standing - #247

Merged
vjovanov merged 3 commits into
mainfrom
fix/issue-245
Oct 22, 2026
Merged

vjovanov merged 3 commits into
mainfrom
fix/issue-245

Conversation

@vjovanov

@vjovanov vjovanov commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #245

ephor rebase --dispatch (and the rebase action that runs it) now asks whether the hand-over would be taken before it replays the branch. When the ticket would be refused, the replay puts the tree back on the commit it started from, the conflict is still reported, and the refusal is the exit and says what to change. Before this change, the replay left the conflict standing first, and only then found that no ticket could be written. That left a mid-rebase tree that nobody was coming to resolve.

Before, with a configured rebase recipe whose state: rebase is not declared by the machine already in the checkout's panta/states.yaml (from the issue's job log, paths shortened):

rebase onto main in <checkout> — 1 repository.
  Repository root — HEAD: **The rebase stopped in a conflict.** The repository is left mid-rebase, with these paths unmerged:
      shared.txt
      Resolve them in `<checkout>/.`, `git add` each one, then `git rebase --continue`.
ERROR: recipe 'rebase' starts in state 'rebase', which the machine 'ephor-work' in <checkout>/panta does not declare (it has: collect, fix, review, gate-green, gave-up, done).

The checkout was left mid-rebase and no ticket was written.

After, on 64da471, the same setup:

  • the tree is back on its starting commit and is not mid-rebase;
  • the conflict is still reported, and written to REPORT when one was asked for, and the report says the tree was restored;
  • no ticket and no work root are written, and the root's states.yaml is untouched;
  • the command exits non-zero with a refusal that names the next step:
recipe 'rebase' starts in state 'rebase', which the machine 'ephor-work' in <checkout>/panta does not declare (it has: collect, fix, review, gate-green, gave-up, done). Give the recipe a 'state' the machine declares in the work configuration, or declare 'rebase' in <checkout>/panta/states.yaml.

When the hand-over would be taken, nothing changes: the conflict is left standing for the ticket, exactly as before.

What was actually wrong

The issue's title misread the cause. The machine was not stale. The shipped rebase recipe starts in fix, which every machine involved declares, and a fresh root accepts it. The state 'rebase' in the report came from a recipe configured on that site. The real defect was ordering. ephor rebase --dispatch replayed the branch with the conflict left standing (Stopped::Leave), and only afterwards did the hand-over vet the recipe's state against the root's machine and refuse. So the conflict was left for a ticket that was never written. §FS-005-dispatch.12 already said that a replay nobody is waiting on puts the tree back on the commit it started from.

The issue offered three remedies. Two of them, reconciling the root's machine and isolating the work in another root, were rejected: both cross §FS-005-dispatch.6, under which an existing root keeps its machine unchanged. The third, refusing with actionable instructions, moves no contract. It was taken, together with vetting the hand-over before the replay so that the replay's disposition is chosen correctly.

What changed

The hand-over is asked before the replay. src/rebase.rs first runs a dry-run dispatch of the same rebase recipe on a dispatcher of its own, which is then discarded so its notes never reach the real hand-over. The answer chooses the disposition of the one replay: Leave when the ticket would be taken, Restore when it would be refused. On refusal, the conflict is still printed and REPORT is still written, and the refusal becomes the command's exit. A refusal asked about before a replay that turns out clean is dropped, because a clean replay needs no hand-over (§FS-005-dispatch.12).

A recipe's opening move waits for the machine. In Dispatcher::dispatch, the machine vet (the open root's machine, or the proposed shipped one) now runs before opening(). A recipe with opens_with: rebase whose state the machine refuses therefore makes no opening move, and the tree is untouched (§FS-005-dispatch.12).

A dry run into a fresh root vets the machine it would install. For a root that does not exist yet, the dry run now vets WorkRoot::proposed, as the real dispatch already did. Without this, a configured recipe whose state the shipped machine lacks still replayed under Leave and refused afterwards: the same defect, reached on a second path. Visible side effect: work dispatch --dry-run on a fresh root now refuses where the real dispatch would, which is what §FS-005-dispatch.6 already promised of a dry run.

One refusal, with a next step. The undeclared-state refusal now lives in one place, WorkRoot::undeclared (src/work/runtime/plan.rs). Dispatch and the sweep (src/sweep.rs) both use it; the sweep used to carry its own copy. The refusal still names the recipe, the state and the machine, and now ends with what to change: give the recipe a state the machine declares, or declare the state in <root>/states.yaml. For a proposed root, where no states.yaml exists yet, it names the work configuration's states instead (§FS-005-dispatch.6).

Spec. §FS-005-dispatch.12 gains the paragraph "Whether anybody is coming is asked before the replay, not learned after it". §FS-005-dispatch.6 gains the clause saying the refusal names what to change, and that the root's machine is never changed for the reader. Both code sites cite these points.

Known and left as is. A refusal that appears only in the real dispatch after a passing ask, such as the read-back of the machine after it is minted, would still follow a Leave replay. Review judged it a nit and could not reproduce it, and the comment in src/rebase.rs names the case.

No changelog entry, per the repository's convention.

Verification

New and updated tests in tests/integration/work_test.rs:

  • a_fresh_root_takes_the_shipped_rebase_recipe_and_the_conflict_stands_for_it: the control. A fresh root takes the shipped recipe, exits 3, the tree is left mid-rebase, and the ticket is written.
  • a_rebase_whose_hand_over_would_be_refused_puts_the_tree_back: the issue's case. The command exits non-zero with the refusal, the tree is not mid-rebase, HEAD is on its starting commit, no ticket is written, and REPORT is written.
  • a_fresh_root_whose_machine_would_refuse_puts_the_tree_back: the second path from review. The proposed machine is vetted, the tree is put back, no work root is made, and the refusal names the configuration's states.
  • a_refusal_for_an_undeclared_state_names_what_to_change: both next-step clauses, and the root's machine is left byte-for-byte unchanged.
  • a_recipe_dispatch_the_machine_refuses_makes_no_opening_move: work dispatch with opens_with: rebase and an undeclared state leaves the tree untouched.

The gate on 64da471, all exit 0:

  • the triage reproducer, which exited 1 before the fix and now reports "verdict: fixed";
  • pre-commit run --all-files (cargo test, grund check and grund fmt under CI's pinned grund 0.18.0, fissile, private words, attribution);
  • python -m unittest discover -s tests/integration -p 'test_*.py' (113 tests);
  • python scripts/check_boundary.py and python scripts/check_parity.py;
  • cargo test with TMPDIR pointed at a symlinked directory, as on macOS.
AI workflow: `rhei`, 15 agent invocations across 1 model; 8 tasks completed, 8 in progress.
  1. github-issues-agent-grounds-ephor-245-implement-e95896a2.ticket supervising (visit 1) — cld, anthropic/claude-opus-5-5 — 1m00s — 807.0k in / 5.6k out
  2. github-issues-agent-grounds-ephor-245-implement-e95896a2.ticket.triage assessing (visit 1) — cld, anthropic/claude-opus-5-5 — 2m56s — 1.8M in / 14.3k out
  3. github-issues-agent-grounds-ephor-245-implement-e95896a2.ticket.triage reproducing (visit 1) — cld, anthropic/claude-opus-5-5 — 1m19s — 537.9k in / 7.4k out
  4. github-issues-agent-grounds-ephor-245-implement-e95896a2.ticket supervising (visit 2) — cld, anthropic/claude-opus-5-5 — 1m25s — 684.2k in / 7.0k out
  5. github-issues-agent-grounds-ephor-245-implement-e95896a2.ticket.specify specify — cld, anthropic/claude-opus-5-5 — 8m30s — 1.7M in / 15.3k out
  6. github-issues-agent-grounds-ephor-245-implement-e95896a2.ticket supervising (visit 3) — cld, anthropic/claude-opus-5-5 — 26.0s — 250.2k in / 2.8k out
  7. github-issues-agent-grounds-ephor-245-implement-e95896a2.ticket.implement implement — cld, anthropic/claude-opus-5-5 — 19m12s — 2.2M in / 14.8k out
  8. github-issues-agent-grounds-ephor-245-implement-e95896a2.ticket supervising (visit 4) — cld, anthropic/claude-opus-5-5 — 52.0s — 262.9k in / 3.0k out
  9. github-issues-agent-grounds-ephor-245-implement-e95896a2.ticket.review-1 review — cld, anthropic/claude-opus-5-5 — 4m47s — 1.4M in / 11.3k out
  10. github-issues-agent-grounds-ephor-245-implement-e95896a2.ticket supervising (visit 5) — cld, anthropic/claude-opus-5-5 — 24.0s — 264.3k in / 2.3k out
  11. github-issues-agent-grounds-ephor-245-implement-e95896a2.ticket.fix-1 fix — cld, anthropic/claude-opus-5-5 — 13m12s — 826.9k in / 7.0k out
  12. github-issues-agent-grounds-ephor-245-implement-e95896a2.ticket supervising (visit 6) — cld, anthropic/claude-opus-5-5 — 28.0s — 267.2k in / 2.3k out
  13. github-issues-agent-grounds-ephor-245-implement-e95896a2.ticket.review-2 review — cld, anthropic/claude-opus-5-5 — 2m03s — 1.2M in / 6.2k out
  14. github-issues-agent-grounds-ephor-245-implement-e95896a2.ticket supervising (visit 7) — cld, anthropic/claude-opus-5-5 — 25.0s — 272.3k in / 2.4k out
  15. github-issues-agent-grounds-ephor-245-implement-e95896a2.ticket supervising (visit 8) — cld, anthropic/claude-opus-5-5 — 27.0s — 275.6k in / 2.4k out
Accounting Value
cost $10.19
total tokens 12.8M
input tokens (incl. cache) 12.7M
input cache read 11.6M
input cache write 1.2M
output tokens (incl. cache) 104.1k
output cache read -
output cache write -
coverage Complete

…lows

A rebase handed over to a ticket the machine refuses has no successor, so
the replay puts the tree back and reports the conflict instead of leaving
it standing (FS-005-dispatch.12); the refusal names what to change
(FS-005-dispatch.6). The tests pinning it are red until #245 lands.
`ephor rebase --dispatch` replayed under the leaving disposition and only
then learned that the hand-over was refused, so a conflict stood in the
tree with no ticket and nobody coming. It now asks the hand-over as a dry
run first: taken, the conflict is left for the ticket; refused, the replay
puts the tree back, still reports the conflict, and the refusal is the
exit. A recipe's own opening move is likewise made only after the machine
has answered.

The undeclared-state refusal, shared now by dispatch and the sweep, names
what to change: the recipe's state or the root's states.yaml.

Closes #245
…root asks first

The ask `ephor rebase --dispatch` makes before its replay is a dry run, and a
dry run vetted only a work root already on disk. Into a fresh root a recipe
whose state the shipped machine lacks still replayed with the conflict left
standing and only then refused (§FS-005-dispatch.12). The dry run now vets the
machine the real dispatch would install, and the refusal for a proposed root
names the work configuration's states rather than a states.yaml that is not
there (§FS-005-dispatch.6).
@vjovanov vjovanov changed the title spec: ask whether a hand-over would be taken before the replay it follows Ask whether a rebase hand-over would be taken before replaying, so a refused one leaves no conflict standing Oct 22, 2026
@vjovanov
vjovanov marked this pull request as ready for review October 22, 2026 15:36
@vjovanov
vjovanov requested a review from kimeta October 22, 2026 15:36
@vjovanov
vjovanov merged commit bddf438 into main Oct 22, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The rebase action dispatches a rebase recipe into a stale ephor-work machine and fails

1 participant