Reconcile compatible remote advancement before publishing accepted states
Hop-State: A_06FN8BW3A4C9EV7XMN6Q0FR Hop-Proposal: R_06FN8BSNQNQNKB9D7951MQR Hop-Task: T_06FN8AWMR0HY1RJY8AN2A3R Hop-Attempt: AT_06FN8AWMR0WHBQ7ZMDZ0NWG
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"id": "P_06FN83DN7R0B9X5TEV3YDC0",
|
||||
"task_id": "T_06FN83DN7SMZB9WJRZNZMTR",
|
||||
"attempt_id": "AT_06FN83DN7VHVF5BVW6DEJ8R",
|
||||
"state_id": "P_06FN83DN7R0B9X5TEV3YDC0",
|
||||
"prompt": "# Files mentioned by the user:\n\n## Screenshot 2026-07-11 at 6.58.29 PM.png: /var/folders/58/b6yn15tx3jb7_6lw9m67f89m0000gn/T/TemporaryItems/NSIRD_screencaptureui_vyA0oe/Screenshot 2026-07-11 at 6.58.29 PM.png\n\n## My request for Codex:\nonce again, there are prompts here that arent mine!",
|
||||
"agent_name": "codex",
|
||||
"status": "proposed",
|
||||
"response_summary": "Purge internal reconciliation prompts from portable records",
|
||||
"created_at": "2026-07-12T02:00:12.350322Z",
|
||||
"metadata": {
|
||||
"source_tree": "27e94b6d19eb7e4107b127e3cec0ca41718053dc",
|
||||
"git_commit": "74ba06de917236754f6146aca0e2d6e10438e455",
|
||||
"attempt_head": "C_06FN83MVSDWQH772HR663SR",
|
||||
"attempt_head_kind": "checkpoint"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"id": "P_06FN892CVDG36AN981MGF00",
|
||||
"task_id": "T_06FN892CVE210TZR2ZQQ9JG",
|
||||
"attempt_id": "AT_06FN892CVDGHFG3F889Q3YR",
|
||||
"state_id": "P_06FN892CVDG36AN981MGF00",
|
||||
"prompt": "implement that",
|
||||
"agent_name": "codex",
|
||||
"status": "proposed",
|
||||
"response_summary": "Attribute accepted commits to the prompting user's configured Git identity",
|
||||
"created_at": "2026-07-12T02:24:52.955741Z",
|
||||
"metadata": {
|
||||
"source_tree": "5895f21f33fd3d6d04b764c84dfeb6e0c4738c1f",
|
||||
"git_commit": "787acb2330d8662320477d022da37ee4029697ac",
|
||||
"attempt_head": "C_06FN89NDDXYFZK3XRRBQPVR",
|
||||
"attempt_head_kind": "checkpoint"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"id": "P_06FN8AWMR1EMWPB5787YS60",
|
||||
"task_id": "T_06FN8AWMR0HY1RJY8AN2A3R",
|
||||
"attempt_id": "AT_06FN8AWMR0WHBQ7ZMDZ0NWG",
|
||||
"state_id": "P_06FN8AWMR1EMWPB5787YS60",
|
||||
"prompt": "HOP IS SUPPOSED TO INTELLIGENTLY RECONCILE THIS!",
|
||||
"agent_name": "codex",
|
||||
"status": "proposed",
|
||||
"response_summary": "Reconcile compatible remote advancement before publishing accepted states",
|
||||
"created_at": "2026-07-12T02:32:50.112254Z",
|
||||
"metadata": {
|
||||
"source_tree": "b24e0fc4185ee2df0b827cbcddf7712e8f14ebff",
|
||||
"git_commit": "59b8ca7c05edbdd50535a61ae9ca732516ce7f31",
|
||||
"attempt_head": "C_06FN8BEYCESTJ4ZDR0XRNK0",
|
||||
"attempt_head_kind": "checkpoint"
|
||||
}
|
||||
}
|
||||
+112
-7
@@ -67,7 +67,7 @@ func (e *GitError) Error() string {
|
||||
func (e *GitError) Unwrap() error { return e.Err }
|
||||
|
||||
// CommitOptions controls a synthetic commit. Empty identities and timestamps
|
||||
// use the GitStore defaults; user Git identity configuration is never used.
|
||||
// use the GitStore defaults.
|
||||
type CommitOptions struct {
|
||||
Message string
|
||||
Parents []string
|
||||
@@ -309,6 +309,25 @@ func (r *Repository) PushAccepted(ctx context.Context, commit string) (result Re
|
||||
if err := validObjectName(commit); err != nil {
|
||||
return result, false, fmt.Errorf("invalid accepted commit: %w", err)
|
||||
}
|
||||
destination, configured, err := r.pushDestination(ctx)
|
||||
if err != nil || !configured {
|
||||
return result, configured, err
|
||||
}
|
||||
if _, err := r.run(ctx, []string{"GIT_TERMINAL_PROMPT=0"}, nil,
|
||||
"push", "--porcelain", destination.remote, commit+":"+destination.ref); err != nil {
|
||||
return result, true, fmt.Errorf("push accepted commit to %s/%s: %w", destination.remote, strings.TrimPrefix(destination.ref, "refs/heads/"), err)
|
||||
}
|
||||
return RemotePushResult{Remote: destination.safeRemote, Ref: destination.ref, Commit: commit}, true, nil
|
||||
}
|
||||
|
||||
type pushDestination struct {
|
||||
remote string
|
||||
safeRemote string
|
||||
ref string
|
||||
}
|
||||
|
||||
func (r *Repository) pushDestination(ctx context.Context) (pushDestination, bool, error) {
|
||||
var result pushDestination
|
||||
branch, exists, err := r.optionalGitOutput(ctx, "symbolic-ref", "--quiet", "--short", "HEAD")
|
||||
if err != nil {
|
||||
return result, false, fmt.Errorf("discover automatic push branch: %w", err)
|
||||
@@ -375,13 +394,60 @@ func (r *Repository) PushAccepted(ctx context.Context, commit string) (result Re
|
||||
if err := r.validateRef(ctx, ref); err != nil {
|
||||
return result, false, fmt.Errorf("validate automatic push destination: %w", err)
|
||||
}
|
||||
|
||||
if _, err := r.run(ctx, []string{"GIT_TERMINAL_PROMPT=0"}, nil,
|
||||
"push", "--porcelain", remote, commit+":"+ref); err != nil {
|
||||
return result, true, fmt.Errorf("push accepted commit to %s/%s: %w", remote, strings.TrimPrefix(ref, "refs/heads/"), err)
|
||||
}
|
||||
safeRemote, _ := RedactPromptSecrets(remote)
|
||||
return RemotePushResult{Remote: safeRemote, Ref: ref, Commit: commit}, true, nil
|
||||
return pushDestination{remote: remote, safeRemote: safeRemote, ref: ref}, true, nil
|
||||
}
|
||||
|
||||
// FetchPushTip fetches the configured destination branch without changing the
|
||||
// user's branch, index, or working tree. exists is false for a new remote branch.
|
||||
func (r *Repository) FetchPushTip(ctx context.Context) (tip string, configured, exists bool, err error) {
|
||||
destination, configured, err := r.pushDestination(ctx)
|
||||
if err != nil || !configured {
|
||||
return "", configured, false, err
|
||||
}
|
||||
output, err := r.run(ctx, []string{"GIT_TERMINAL_PROMPT=0"}, nil,
|
||||
"ls-remote", "--refs", destination.remote, destination.ref)
|
||||
if err != nil {
|
||||
return "", true, false, fmt.Errorf("inspect remote branch %s/%s: %w", destination.remote, strings.TrimPrefix(destination.ref, "refs/heads/"), err)
|
||||
}
|
||||
fields := strings.Fields(output)
|
||||
if len(fields) == 0 {
|
||||
return "", true, false, nil
|
||||
}
|
||||
if len(fields) != 2 || fields[1] != destination.ref {
|
||||
return "", true, false, fmt.Errorf("inspect remote branch %s/%s: unexpected ls-remote response", destination.remote, strings.TrimPrefix(destination.ref, "refs/heads/"))
|
||||
}
|
||||
if err := validObjectName(fields[0]); err != nil {
|
||||
return "", true, false, fmt.Errorf("invalid remote branch tip: %w", err)
|
||||
}
|
||||
if _, err := r.run(ctx, []string{"GIT_TERMINAL_PROMPT=0"}, nil,
|
||||
"fetch", "--no-tags", destination.remote, destination.ref); err != nil {
|
||||
return "", true, false, fmt.Errorf("fetch remote branch %s/%s: %w", destination.remote, strings.TrimPrefix(destination.ref, "refs/heads/"), err)
|
||||
}
|
||||
fetched, err := r.run(ctx, nil, nil, "rev-parse", "--verify", "FETCH_HEAD^{commit}")
|
||||
if err != nil {
|
||||
return "", true, false, fmt.Errorf("resolve fetched remote branch: %w", err)
|
||||
}
|
||||
fetched = trimLine(fetched)
|
||||
if fetched != fields[0] {
|
||||
return "", true, false, errors.New("fetched remote branch changed while reconciling; retry")
|
||||
}
|
||||
return fetched, true, true, nil
|
||||
}
|
||||
|
||||
// MergeBase returns the best common ancestor of two commits.
|
||||
func (r *Repository) MergeBase(ctx context.Context, left, right string) (string, error) {
|
||||
if err := validObjectName(left); err != nil {
|
||||
return "", fmt.Errorf("invalid left commit: %w", err)
|
||||
}
|
||||
if err := validObjectName(right); err != nil {
|
||||
return "", fmt.Errorf("invalid right commit: %w", err)
|
||||
}
|
||||
output, err := r.run(ctx, nil, nil, "merge-base", left, right)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("find merge base: %w", err)
|
||||
}
|
||||
return trimLine(output), nil
|
||||
}
|
||||
|
||||
func (r *Repository) optionalGitOutput(ctx context.Context, args ...string) (string, bool, error) {
|
||||
@@ -478,6 +544,32 @@ func (r *Repository) CommitTree(ctx context.Context, tree string, parents []stri
|
||||
})
|
||||
}
|
||||
|
||||
// ConfiguredUserIdentity returns the Git identity configured for the user in
|
||||
// this repository. Both user.name and user.email must be present.
|
||||
func (r *Repository) ConfiguredUserIdentity(ctx context.Context) (GitIdentity, bool, error) {
|
||||
name, nameSet, err := r.configValue(ctx, "user.name")
|
||||
if err != nil {
|
||||
return GitIdentity{}, false, err
|
||||
}
|
||||
email, emailSet, err := r.configValue(ctx, "user.email")
|
||||
if err != nil {
|
||||
return GitIdentity{}, false, err
|
||||
}
|
||||
if !nameSet || !emailSet {
|
||||
return GitIdentity{}, false, nil
|
||||
}
|
||||
identity := GitIdentity{Name: name, Email: email}
|
||||
if err := validateIdentity(identity); err != nil {
|
||||
return GitIdentity{}, false, fmt.Errorf("invalid configured user identity: %w", err)
|
||||
}
|
||||
return identity, true, nil
|
||||
}
|
||||
|
||||
// SyntheticIdentity is Hop's controlled committer identity.
|
||||
func (r *Repository) SyntheticIdentity() GitIdentity {
|
||||
return r.store.identity(GitIdentity{})
|
||||
}
|
||||
|
||||
// CommitTreeWithOptions creates a synthetic commit without invoking hooks or
|
||||
// consulting the user's Git identity. It does not update any ref.
|
||||
func (r *Repository) CommitTreeWithOptions(ctx context.Context, tree string, options CommitOptions) (string, error) {
|
||||
@@ -1303,6 +1395,19 @@ func (s *GitStore) identity(override GitIdentity) GitIdentity {
|
||||
return identity
|
||||
}
|
||||
|
||||
func (r *Repository) configValue(ctx context.Context, key string) (string, bool, error) {
|
||||
output, err := r.run(ctx, nil, nil, "config", "--get", key)
|
||||
if err != nil {
|
||||
var gitErr *GitError
|
||||
if errors.As(err, &gitErr) && gitErr.ExitCode == 1 {
|
||||
return "", false, nil
|
||||
}
|
||||
return "", false, fmt.Errorf("read git config %s: %w", key, err)
|
||||
}
|
||||
value := trimLine(output)
|
||||
return value, value != "", nil
|
||||
}
|
||||
|
||||
func (s *GitStore) now() time.Time {
|
||||
if s.Now != nil {
|
||||
return s.Now()
|
||||
|
||||
@@ -81,6 +81,7 @@ func (s *Service) exportPromptLedger(ctx context.Context, destinationRoot string
|
||||
wantedAttempts[attemptID] = struct{}{}
|
||||
}
|
||||
lastPromptByAttempt := make(map[string]string)
|
||||
excludedPromptIDs := make(map[string]struct{})
|
||||
for _, row := range graph {
|
||||
if row.State.Kind == StatePrompt && row.State.Prompt != "" && row.State.AttemptID != "" {
|
||||
lastPromptByAttempt[row.State.AttemptID] = row.State.ID
|
||||
@@ -92,9 +93,11 @@ func (s *Service) exportPromptLedger(ctx context.Context, destinationRoot string
|
||||
continue
|
||||
}
|
||||
if _, hidden := suppressed[state.ID]; hidden {
|
||||
excludedPromptIDs[state.ID] = struct{}{}
|
||||
continue
|
||||
}
|
||||
if _, reconciliation := decodeReconciliationConflicts(state.Summary); reconciliation || strings.HasPrefix(state.Prompt, "Resolve proposal ") {
|
||||
excludedPromptIDs[state.ID] = struct{}{}
|
||||
continue
|
||||
}
|
||||
if len(wantedAttempts) > 0 {
|
||||
@@ -156,6 +159,12 @@ func (s *Service) exportPromptLedger(ctx context.Context, destinationRoot string
|
||||
if err := os.MkdirAll(outputDir, 0o755); err != nil {
|
||||
return PortablePromptLedger{}, fmt.Errorf("create prompt ledger directory: %w", err)
|
||||
}
|
||||
for promptID := range excludedPromptIDs {
|
||||
outputPath := filepath.Join(outputDir, promptID+".json")
|
||||
if err := os.Remove(outputPath); err != nil && !os.IsNotExist(err) {
|
||||
return PortablePromptLedger{}, fmt.Errorf("remove excluded prompt record %s: %w", promptID, err)
|
||||
}
|
||||
}
|
||||
for _, record := range ledger.Prompts {
|
||||
outputPath := filepath.Join(outputDir, record.ID+".json")
|
||||
_, statErr := os.Stat(outputPath)
|
||||
|
||||
+39
-1
@@ -750,6 +750,32 @@ func (s *Service) accept(ctx context.Context, proposalID string, checkCommand []
|
||||
return AcceptResult{}, &ConflictError{Paths: mergeConflicts}
|
||||
}
|
||||
}
|
||||
commitParents := []string{current.GitCommit}
|
||||
remoteCtx, cancelRemote := context.WithTimeout(ctx, 30*time.Second)
|
||||
remoteTip, _, remoteExists, err := s.Repo.FetchPushTip(remoteCtx)
|
||||
cancelRemote()
|
||||
if err != nil {
|
||||
return AcceptResult{}, err
|
||||
}
|
||||
if remoteExists && remoteTip != current.GitCommit {
|
||||
candidate, candidateErr := s.Repo.CommitTree(ctx, finalTree, []string{current.GitCommit}, "Hop remote reconciliation candidate\n")
|
||||
if candidateErr != nil {
|
||||
return AcceptResult{}, candidateErr
|
||||
}
|
||||
mergeBase, mergeErr := s.Repo.MergeBase(ctx, candidate, remoteTip)
|
||||
if mergeErr != nil {
|
||||
return AcceptResult{}, mergeErr
|
||||
}
|
||||
var remoteConflicts []string
|
||||
finalTree, remoteConflicts, err = s.Repo.ComposeTrees(ctx, mergeBase, remoteTip, candidate)
|
||||
if err != nil {
|
||||
return AcceptResult{}, err
|
||||
}
|
||||
if len(remoteConflicts) > 0 {
|
||||
return AcceptResult{}, fmt.Errorf("remote accepted-state reconciliation requires agent resolution: %s", strings.Join(remoteConflicts, ", "))
|
||||
}
|
||||
commitParents = []string{remoteTip, current.GitCommit}
|
||||
}
|
||||
|
||||
acceptedID := newID("a")
|
||||
message := proposal.Summary
|
||||
@@ -757,7 +783,19 @@ func (s *Service) accept(ctx context.Context, proposalID string, checkCommand []
|
||||
message = "Accept " + proposal.ID
|
||||
}
|
||||
message += fmt.Sprintf("\n\nHop-State: %s\nHop-Proposal: %s\nHop-Task: %s\nHop-Attempt: %s\n", acceptedID, proposal.ID, proposal.TaskID, proposal.AttemptID)
|
||||
commit, err := s.Repo.CommitTree(ctx, finalTree, []string{current.GitCommit}, message)
|
||||
author, configured, err := s.Repo.ConfiguredUserIdentity(ctx)
|
||||
if err != nil {
|
||||
return AcceptResult{}, err
|
||||
}
|
||||
if !configured {
|
||||
author = s.Repo.SyntheticIdentity()
|
||||
}
|
||||
commit, err := s.Repo.CommitTreeWithOptions(ctx, finalTree, CommitOptions{
|
||||
Message: message,
|
||||
Parents: commitParents,
|
||||
Author: author,
|
||||
Committer: s.Repo.SyntheticIdentity(),
|
||||
})
|
||||
if err != nil {
|
||||
return AcceptResult{}, err
|
||||
}
|
||||
|
||||
@@ -279,6 +279,29 @@ func TestExportOmitsActiveInternalAttempt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportRemovesPreviouslyPublishedReconciliationPrompt(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"})
|
||||
result, err := service.CreatePrompt(ctx, "Resolve proposal R_TEST against accepted state A_TEST", "", "codex")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recordDir := filepath.Join(service.Root, ".hop", "records", "prompts")
|
||||
if err := os.MkdirAll(recordDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recordPath := filepath.Join(recordDir, result.Prompt.ID+".json")
|
||||
if err := os.WriteFile(recordPath, []byte("{}\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.ExportPromptLedger(ctx, service.Root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(recordPath); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("internal reconciliation record still exists: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposeRespectsSuppressedPromptManifest(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"})
|
||||
@@ -1036,6 +1059,33 @@ func TestDisjointProposalsLandAndUndoMovesForward(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptedCommitAttributesPromptingUserAndRetainsHopCommitter(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"})
|
||||
runGitTest(t, service.Root, "config", "user.name", "Prompting User")
|
||||
runGitTest(t, service.Root, "config", "user.email", "prompter@example.com")
|
||||
|
||||
attempt, err := service.CreatePrompt(ctx, "Add authored change", "", "codex")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeTestFile(t, filepath.Join(attempt.Workspace, "authored.txt"), "authored\n")
|
||||
proposal, err := service.Propose(ctx, attempt.Prompt.ID, "Add authored change")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
accepted, err := service.Accept(ctx, proposal.Proposal.ID, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
metadata := runGitTest(t, service.Root, "show", "-s", "--format=%an <%ae>%n%cn <%ce>", accepted.State.GitCommit)
|
||||
want := "Prompting User <prompter@example.com>\nHop <hop@localhost>"
|
||||
if metadata != want {
|
||||
t.Fatalf("accepted commit identity = %q, want %q", metadata, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentDisjointAcceptancesSerialize(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"})
|
||||
@@ -1601,7 +1651,7 @@ func TestLandAutomaticallyPushesAcceptedCommit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomaticPushNeverForcesDivergedRemote(t *testing.T) {
|
||||
func TestAutomaticPushReconcilesCompatibleDivergedRemote(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"})
|
||||
remote := filepath.Join(t.TempDir(), "remote.git")
|
||||
@@ -1647,14 +1697,21 @@ func TestAutomaticPushNeverForcesDivergedRemote(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if secondLanded.RemotePush != nil {
|
||||
t.Fatalf("diverged remote unexpectedly reported a successful push: %#v", secondLanded.RemotePush)
|
||||
if secondLanded.RemotePush == nil {
|
||||
t.Fatal("compatible remote advancement was not reconciled and pushed")
|
||||
}
|
||||
if len(secondLanded.Warnings) == 0 || !strings.Contains(strings.Join(secondLanded.Warnings, "\n"), "automatic push failed") {
|
||||
t.Fatalf("diverged remote warnings = %#v", secondLanded.Warnings)
|
||||
if got := runGitTest(t, service.Root, "--git-dir", remote, "rev-parse", "refs/heads/"+branch); got != secondLanded.State.GitCommit {
|
||||
t.Fatalf("remote branch = %s, want reconciled accepted commit %s", got, secondLanded.State.GitCommit)
|
||||
}
|
||||
if got := runGitTest(t, service.Root, "--git-dir", remote, "rev-parse", "refs/heads/"+branch); got != remoteTip {
|
||||
t.Fatalf("automatic push rewrote diverged remote from %s to %s", remoteTip, got)
|
||||
assertTreeFiles(t, service, secondLanded.State.GitCommit, map[string]string{
|
||||
"base.txt": "base\n",
|
||||
"first.txt": "first\n",
|
||||
"local.txt": "local\n",
|
||||
"remote.txt": "remote\n",
|
||||
})
|
||||
parents := strings.Fields(runGitTest(t, service.Root, "show", "-s", "--format=%P", secondLanded.State.GitCommit))
|
||||
if len(parents) != 2 || parents[0] != remoteTip || parents[1] != firstLanded.State.GitCommit {
|
||||
t.Fatalf("reconciled parents = %v, want [%s %s]", parents, remoteTip, firstLanded.State.GitCommit)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user