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
48 changes: 48 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,54 @@ unattended supervisor session. There are two supported transports:
authenticated for HTTPS pushes to github.com, run `gh auth setup-git` once
to install gh's credential helper into your global git config.

## Rebase anchor lifecycle

A patch agent's rebase upstream is chosen by `lib_core/rebase_decision.ml`'s
`plan` from the agent's `anchor_history` (newest-first list of
`Anchor.t = { base; sha; observed_at_remote }`, cap 8, in `lib_core/`).

Anchors get recorded at three moments by `lib/runner_fiber_impl.ml`,
mediated by `Worktree_plan` capture/record ops the executor interprets:

- **Start** — `Worktree_plan.for_start` runs after the worktree is created
and before the LLM session begins. It fetches origin, reads
`origin/<base_branch>`'s tip, and records that SHA as the agent's
initial anchor via `Orchestrator.apply_anchor_events`. Closes the
production-bug case where a patch branched off a dep and never rebased
before the dep squash-merged.
- **Rebase (Ok / Noop)** — `Worktree_plan.for_rebase` captures
`origin/<new_base>` post-fetch and a `Record_anchor_on_success` op
emits the anchor event after the rebase succeeds. `Noop` refreshes the
anchor too — a noop proves local HEAD already contains the remote tip.
- **Merge-conflict resolution (Ok / Noop)** — `for_merge_conflict` mirrors
the rebase plan; a successful conflict rebase also refreshes the
anchor.

`Conflict` and `Error` rebase results preserve the prior anchor unchanged
(via `Rebase_decision.anchor_after_result`); a failed attempt never
corrupts what was recorded.

At rebase time the executor calls `Rebase_decision.plan` with the agent's
`anchor_history` and an `is_ancestor` oracle (`git merge-base
--is-ancestor` via `Worktree.S.is_ancestor`). The plan picks the newest
anchor whose SHA is reachable from the patch's HEAD; if none is
reachable it falls back to history, then to `Plain { No_anchor }` which
runs the legacy 2-arg `git rebase <target>`. The cherry-pick / patch-id
detection inside `Worktree.rebase_onto` (`find_old_base`) remains as
defense-in-depth: it tries first; the planner's chosen `upstream` is
used only on its fallback path.

`Orchestrator.refresh_base_branch` deliberately does NOT invalidate the
anchor when the base retargets — the planner handles staleness via its
ancestor oracle at rebase time, and touching `branch_rebased_onto` here
would hide the drift the detector exists to surface (see PI-16 in
`test_interleaving_properties.ml`).

The pure decision is covered by `test/test_rebase_decision_properties.ml`
(RD-PLAN-1..8, RD-AAR-1..5) plus `test/test_anchor.ml`. Realistic event
sequences are covered by `test/test_rebase_state_machine.ml` (SM-1, SM-2
= production bug, SM-3a/b, SM-4, SM-7, SM-8, SM-no-anchor).

## Reference
- Reference implementation (Elixir): `../orchestrate-gameplan/`
- Reference specification: `../orchestrate-gameplan/spec/anton.pant`
Expand Down
31 changes: 31 additions & 0 deletions lib/orchestrator.ml
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,15 @@ let refresh_base_branch t patch_id =
Graph.initial_base t.graph patch_id ~has_merged ~branch_of
~main:t.main_branch
in
(* Intentionally do NOT touch branch_rebased_onto here, even when
[fresh] differs from the current base. branch_rebased_onto
tracks where the local branch was LAST REBASED — clearing it
would hide the drift the detector exists to surface. The
rebase planner reads [anchor_history] directly and uses an
[is_ancestor] oracle at rebase time to decide whether the
recorded anchor is still safe for the new base; orchestrator-
level invalidation would be redundant and would also break
drift detection (PI-16). *)
update_agent t patch_id ~f:(fun a ->
Patch_agent.set_base_branch a fresh)

Expand Down Expand Up @@ -482,6 +491,18 @@ let apply_rebase_result t patch_id rebase_result new_base =
let t = set_tried_fresh t patch_id in
(complete t patch_id, [])

let fold_anchor_events t patch_id events =
List.fold events ~init:t ~f:(fun t (ev : Worktree_plan.anchor_event) ->
match ev with
| Worktree_plan.Anchor_recorded a ->
update_agent t patch_id ~f:(fun ag -> Patch_agent.record_anchor ag a)
| Worktree_plan.Anchor_capture_failed -> t)

let apply_rebase_with_anchor t patch_id rebase_result new_base anchor_events =
let t, effects = apply_rebase_result t patch_id rebase_result new_base in
let t = fold_anchor_events t patch_id anchor_events in
(t, effects)

type rebase_push_resolution =
| Rebase_push_ok
| Rebase_push_failed
Expand Down Expand Up @@ -550,6 +571,16 @@ let apply_conflict_rebase_result t patch_id rebase_result new_base =
let t = complete t patch_id in
(t, Conflict_failed, [])

let apply_conflict_rebase_with_anchor t patch_id rebase_result new_base
anchor_events =
let t, decision, effects =
apply_conflict_rebase_result t patch_id rebase_result new_base
in
let t = fold_anchor_events t patch_id anchor_events in
(t, decision, effects)

let apply_anchor_events t patch_id events = fold_anchor_events t patch_id events

type conflict_resolution =
| Conflict_done
| Conflict_retry_push
Expand Down
34 changes: 34 additions & 0 deletions lib/orchestrator.mli
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,21 @@ val apply_rebase_result :
-> set_base_branch + set_has_conflict + enqueue Merge_conflict + complete.
[Error _] -> set_session_failed + set_tried_fresh + complete. *)

val apply_rebase_with_anchor :
t ->
Patch_id.t ->
Worktree.rebase_result ->
Branch.t ->
Worktree_plan.anchor_event list ->
t * rebase_effect list
(** Atomic variant of {!apply_rebase_result} that also folds the executor's
{!Worktree_plan.anchor_event} observations into the agent. The two
transitions are combined so the orchestrator can never observe a half-
applied state where (say) the rebase succeeded but the anchor was not
recorded. Each [Anchor_recorded] event calls {!Patch_agent.record_anchor};
[Anchor_capture_failed] events are ignored (the prior anchor is preserved).
*)

type rebase_push_resolution =
| Rebase_push_ok
| Rebase_push_failed
Expand Down Expand Up @@ -337,6 +352,25 @@ val apply_conflict_rebase_result :
[Conflict] -> set_has_conflict + [Deliver_to_agent] + [[]]. [Error _] ->
set_session_failed + complete + [Conflict_failed]. *)

val apply_conflict_rebase_with_anchor :
t ->
Patch_id.t ->
Worktree.rebase_result ->
Branch.t ->
Worktree_plan.anchor_event list ->
t * conflict_rebase_decision * rebase_effect list
(** Atomic variant of {!apply_conflict_rebase_result} that also folds
{!Worktree_plan.anchor_event} observations into the agent. Same policy as
{!apply_rebase_with_anchor}: [Anchor_recorded] calls
{!Patch_agent.record_anchor}; [Anchor_capture_failed] is ignored. *)

val apply_anchor_events :
t -> Patch_id.t -> Worktree_plan.anchor_event list -> t
(** Fold {!Worktree_plan.anchor_event} observations into the agent without any
other transition. Called by the runner Start path after executing
{!Worktree_plan.for_start} to record the initial anchor for a
freshly-branched-off-dep patch, before the LLM session begins. *)

type conflict_resolution =
| Conflict_done
| Conflict_retry_push
Expand Down
20 changes: 14 additions & 6 deletions lib/persistence.ml
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ let patch_agent_to_yojson (a : Patch_agent.t) =
match a.branch_rebased_onto_sha with
| None -> `Null
| Some s -> `String s );
("anchor_history", Anchor_history.yojson_of_t a.anchor_history);
("checks_passing", `Bool a.checks_passing);
( "current_op",
match a.current_op with
Expand Down Expand Up @@ -298,6 +299,13 @@ let patch_agent_of_yojson ~gameplan json =
(Option.value (int_member_opt "push_failure_count" json) ~default:0)
~branch_rebased_onto_sha:
(string_member_opt "branch_rebased_onto_sha" json)
~anchor_history:
(match Yojson.Safe.Util.member "anchor_history" json with
| `Null -> Anchor_history.empty
| v -> (
match Anchor_history.of_yojson_opt v with
| Some h -> h
| None -> Anchor_history.empty))
~branch_rebased_onto:
(match string_member_opt "branch_rebased_onto" json with
| Some s -> Some (Branch.of_string s)
Expand Down Expand Up @@ -764,12 +772,12 @@ let%test_module "session_id_sidecars" =
~start_attempts_without_pr:0 ~conflict_noop_count:0
~no_commits_push_count:0 ~push_failure_count:0
~branch_rebased_onto:None ~branch_rebased_onto_sha:None
~checks_passing:false ~current_op:None
~current_op_state:Patch_agent.Queued ~current_message_id:None
~generation:0 ~worktree_path:None ~branch_blocked:false
~llm_session_id ~automerge_enabled:false ~automerge_deadline:None
~automerge_inflight:false ~automerge_failure_count:0
~delivered_ci_run_ids:[]
~anchor_history:Anchor_history.empty ~checks_passing:false
~current_op:None ~current_op_state:Patch_agent.Queued
~current_message_id:None ~generation:0 ~worktree_path:None
~branch_blocked:false ~llm_session_id ~automerge_enabled:false
~automerge_deadline:None ~automerge_inflight:false
~automerge_failure_count:0 ~delivered_ci_run_ids:[]
in
let orch =
Orchestrator.restore
Expand Down
68 changes: 42 additions & 26 deletions lib/runner_fiber_impl.ml
Original file line number Diff line number Diff line change
Expand Up @@ -673,6 +673,40 @@ struct
.Session_worktree_missing);
`Failed
| Some _wt_path ->
(* Capture the initial anchor for this
Start: resolve origin/<base_branch>'s
current tip so the first rebase has
a usable [<upstream>] for [git rebase
--onto]. Closes the production-bug
blind spot where a Start-then-dep-
squash-merge sequence left the agent
with no anchor at first-rebase time
and forced the legacy 2-arg fallback
into a "both added" conflict. *)
let ancestor_ids =
Runtime.read runtime (fun snap ->
Graph.transitive_ancestors
(Orchestrator.graph
snap.Runtime.orchestrator)
patch_id)
in
let _, _, start_anchor_events =
Worktree_plan_executor.execute
~patch_id ~agent
~fetch_lock:fetch_mutex
~fail_label:"start anchor capture"
~ancestor_ids
(Worktree_plan.for_start
~base:base_branch)
in
(match start_anchor_events with
| [] -> ()
| _ ->
Runtime.update_orchestrator runtime
(fun orch ->
Orchestrator.apply_anchor_events
orch patch_id
start_anchor_events));
let agents_md =
read_optional_file
(Stdlib.Filename.concat _wt_path
Expand Down Expand Up @@ -895,26 +929,12 @@ struct
(Orchestrator.graph snap.Runtime.orchestrator)
patch_id)
in
let rebase_result, wt_path =
let rebase_result, wt_path, anchor_events =
Worktree_plan_executor.execute ~patch_id ~agent
~fetch_lock:fetch_mutex ~fail_label:"rebase"
~ancestor_ids
(Worktree_plan.for_rebase ~new_base)
in
(* On a successful rebase, capture the SHA the new
base resolved to so the next rebase can pass it
as [prev_base_sha] and trim commits absorbed into
a squash-merge on origin. Records via
[Orchestrator.set_branch_rebased_onto_sha]. *)
let post_rebase_sha =
match rebase_result with
| Worktree.Ok ->
W.read_branch_sha ~path:wt_path
~ref_name:("origin/" ^ Branch.to_string new_base)
| Worktree.Noop | Worktree.Conflict _
| Worktree.Error _ ->
None
in
(match rebase_result with
| Worktree.Ok ->
log_event runtime ~patch_id
Expand All @@ -936,15 +956,8 @@ struct
Orchestrator.agent orch patch_id
in
let orch, effects =
Orchestrator.apply_rebase_result orch patch_id
rebase_result new_base
in
let orch =
match post_rebase_sha with
| Some _ ->
Orchestrator.set_branch_rebased_onto_sha
orch patch_id post_rebase_sha
| None -> orch
Orchestrator.apply_rebase_with_anchor orch
patch_id rebase_result new_base anchor_events
in
let agent_after =
Orchestrator.agent orch patch_id
Expand Down Expand Up @@ -1321,7 +1334,9 @@ struct
target origin/<base> so we rebase
against fresh refs, not the stale
local tracking ref. *)
let rebase_result, _wt_path =
let ( rebase_result,
_wt_path,
anchor_events ) =
Worktree_plan_executor.execute
~patch_id ~agent
~fetch_lock:fetch_mutex
Expand Down Expand Up @@ -1369,9 +1384,10 @@ struct
in
let orch, decision, effects =
Orchestrator
.apply_conflict_rebase_result
.apply_conflict_rebase_with_anchor
orch patch_id rebase_result
(Types.Branch.of_string base)
anchor_events
in
let agent_after =
Orchestrator.agent orch patch_id
Expand Down
46 changes: 25 additions & 21 deletions lib/worktree.ml
Original file line number Diff line number Diff line change
Expand Up @@ -425,8 +425,15 @@ let read_branch_sha ~process_mgr ~path ~ref_name =
if String.is_empty s then None else Some s
else None

let rebase_onto ?(prev_base_sha = None) ~process_mgr ~path ~target ~project_name
~ancestor_ids () =
let is_ancestor ~process_mgr ~path ~ancestor ~descendant =
let code, _, _ =
run_git_exit_code ~process_mgr
[ "git"; "-C"; path; "merge-base"; "--is-ancestor"; ancestor; descendant ]
in
code = 0

let rebase_onto ~upstream ~process_mgr ~path ~target ~project_name ~ancestor_ids
() =
let target = Types.Branch.to_string target in
let ancestor_code, _, ancestor_stderr =
run_git_exit_code ~process_mgr
Expand Down Expand Up @@ -455,19 +462,13 @@ let rebase_onto ?(prev_base_sha = None) ~process_mgr ~path ~target ~project_name
find_old_base ~process_mgr ~path ~target ~project_name ~ancestor_ids
with
| Result.Error msg ->
(* If we can't find unique commits, decide what upstream to pass to
[git rebase]:
- [prev_base_sha = Some sha]: do [git rebase --onto target sha]. This
is the patch-6 case — the orchestrator recorded the SHA the
previous base resolved to, and the patch's own commits live in
[sha..HEAD]. Drops squash-merged-equivalent dep commits.
- [None]: legacy plain [git rebase target] — replays everything in
[target..HEAD] including possibly-stale dep commits, preserved
for back-compat with agents whose [branch_rebased_onto_sha] was
never recorded. *)
let upstream =
Rebase_decision.upstream ~prev_base_sha ~fallback:target
in
(* The cherry-pick / patch-id detection in [find_old_base] failed,
so use the caller-supplied [upstream] (computed by the executor
via [Rebase_decision.plan] from the agent's anchor history). If
[upstream] equals [target], no usable anchor exists and we fall
back to the 2-arg form; otherwise [git rebase --onto target
upstream HEAD] replays exactly the patch's own commits past
[upstream]. *)
let rebase_args =
if String.equal upstream target then
[ "git"; "-C"; path; "rebase"; target ]
Expand Down Expand Up @@ -727,18 +728,19 @@ module type S = sig
val conflict_diff : path:string -> string

val rebase_onto :
?prev_base_sha:string option ->
path:string ->
target:Types.Branch.t ->
upstream:string ->
project_name:string ->
ancestor_ids:Types.Patch_id.t list ->
unit ->
rebase_result

val read_branch_sha : path:string -> ref_name:string -> string option
(** Resolve [ref_name] to a SHA in the worktree at [path]. [None] on any error
(missing ref, git failure). Used by the runner fiber to capture
[branch_rebased_onto_sha] after a successful rebase. *)
(missing ref, git failure). *)

val is_ancestor : path:string -> ancestor:string -> descendant:string -> bool

val read_in_progress_conflict_info :
path:string ->
Expand Down Expand Up @@ -783,14 +785,16 @@ let make ~process_mgr ~repo_root =
let git_status ~path = git_status ~process_mgr ~path
let conflict_diff ~path = conflict_diff ~process_mgr ~path

let rebase_onto ?(prev_base_sha = None) ~path ~target ~project_name
~ancestor_ids () =
rebase_onto ~prev_base_sha ~process_mgr ~path ~target ~project_name
let rebase_onto ~path ~target ~upstream ~project_name ~ancestor_ids () =
rebase_onto ~upstream ~process_mgr ~path ~target ~project_name
~ancestor_ids ()

let read_branch_sha ~path ~ref_name =
read_branch_sha ~process_mgr ~path ~ref_name

let is_ancestor ~path ~ancestor ~descendant =
is_ancestor ~process_mgr ~path ~ancestor ~descendant

let read_in_progress_conflict_info ~path ~target ~project_name ~ancestor_ids
=
read_in_progress_conflict_info ~process_mgr ~path ~target ~project_name
Expand Down
Loading
Loading